feat: 集成Rust WASM渲染引擎与TypeScript ECS框架 (#228)
* feat: 集成Rust WASM渲染引擎与TypeScript ECS框架 * feat: 增强编辑器UI功能与跨平台支持 * fix: 修复CI测试和类型检查问题 * fix: 修复CI问题并提高测试覆盖率 * fix: 修复CI问题并提高测试覆盖率
This commit is contained in:
@@ -37,15 +37,18 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
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
|
- name: Type check
|
||||||
run: npm run type-check
|
run: npm run type-check
|
||||||
|
|
||||||
- name: Lint check
|
- name: Lint check
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Build core package first
|
|
||||||
run: npm run build:core
|
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: npm run test:ci
|
run: npm run test:ci
|
||||||
|
|
||||||
|
|||||||
Generated
+187
-2
@@ -3256,6 +3256,10 @@
|
|||||||
"resolved": "packages/behavior-tree-editor",
|
"resolved": "packages/behavior-tree-editor",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@esengine/ecs-engine-bindgen": {
|
||||||
|
"resolved": "packages/ecs-engine-bindgen",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@esengine/ecs-framework": {
|
"node_modules/@esengine/ecs-framework": {
|
||||||
"resolved": "packages/core",
|
"resolved": "packages/core",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -3272,6 +3276,10 @@
|
|||||||
"resolved": "packages/editor-core",
|
"resolved": "packages/editor-core",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@esengine/engine": {
|
||||||
|
"resolved": "packages/engine",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@esengine/network-client": {
|
"node_modules/@esengine/network-client": {
|
||||||
"resolved": "packages/network-client",
|
"resolved": "packages/network-client",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -3284,6 +3292,18 @@
|
|||||||
"resolved": "packages/network-shared",
|
"resolved": "packages/network-shared",
|
||||||
"link": true
|
"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": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
"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": {
|
"node_modules/@rollup/pluginutils": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
||||||
@@ -8211,6 +8276,13 @@
|
|||||||
"@swc/counter": "^0.1.3"
|
"@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": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.8.0.tgz",
|
||||||
@@ -12541,6 +12613,10 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-engine": {
|
||||||
|
"resolved": "packages/engine/pkg",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/es-errors": {
|
"node_modules/es-errors": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
@@ -28384,6 +28460,32 @@
|
|||||||
"vite": "^7 || ^6 || ^5"
|
"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": {
|
"node_modules/vitepress": {
|
||||||
"version": "1.6.4",
|
"version": "1.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz",
|
||||||
@@ -29184,14 +29286,30 @@
|
|||||||
"typescript-eslint": "^8.46.1"
|
"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": {
|
"packages/editor-app": {
|
||||||
"name": "@esengine/editor-app",
|
"name": "@esengine/editor-app",
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@esengine/behavior-tree": "file:../behavior-tree",
|
"@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/ecs-framework": "file:../core",
|
||||||
"@esengine/editor-core": "file:../editor-core",
|
"@esengine/editor-core": "file:../editor-core",
|
||||||
|
"@esengine/engine": "file:../engine",
|
||||||
"@tauri-apps/api": "^2.2.0",
|
"@tauri-apps/api": "^2.2.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||||
@@ -29224,7 +29342,9 @@
|
|||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.0.7",
|
"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": {
|
"packages/editor-app/node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -29784,6 +29904,20 @@
|
|||||||
"@esengine/ecs-framework": "^2.2.8"
|
"@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": {
|
"packages/math": {
|
||||||
"name": "@esengine/ecs-framework-math",
|
"name": "@esengine/ecs-framework-math",
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
@@ -29895,6 +30029,57 @@
|
|||||||
"ts-jest": "^29.4.0",
|
"ts-jest": "^29.4.0",
|
||||||
"typescript": "^5.8.3"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"test:coverage": "jest --coverage --config jest.config.cjs",
|
"test:coverage": "jest --coverage --config jest.config.cjs",
|
||||||
"test:ci": "jest --ci --coverage --config jest.config.cjs",
|
"test:ci": "jest --ci --coverage --config jest.config.cjs",
|
||||||
"test:clear": "jest --clearCache",
|
"test:clear": "jest --clearCache",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "npx tsc --noEmit",
|
||||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||||
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix"
|
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { IComponent } from '../Types';
|
import type { IComponent } from '../Types';
|
||||||
|
import { Int32 } from './Core/SoAStorage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 游戏组件基类
|
* 游戏组件基类
|
||||||
@@ -50,6 +51,7 @@ export abstract class Component implements IComponent {
|
|||||||
*
|
*
|
||||||
* 存储实体ID而非引用,避免循环引用,符合ECS数据导向设计。
|
* 存储实体ID而非引用,避免循环引用,符合ECS数据导向设计。
|
||||||
*/
|
*/
|
||||||
|
@Int32
|
||||||
public entityId: number | null = null;
|
public entityId: number | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<T>(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<string, unknown>;
|
||||||
|
for (const key in obj) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
|
cloned[key] = this.deepClone((obj as Record<string, unknown>)[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import { Component } from '../Component';
|
import { Component } from '../Component';
|
||||||
import { ComponentType } from './ComponentStorage';
|
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优化装饰器
|
* 启用SoA优化装饰器
|
||||||
@@ -12,18 +22,6 @@ export function EnableSoA<T extends ComponentType>(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位浮点数装饰器
|
* 64位浮点数装饰器
|
||||||
* 标记字段使用Float64Array存储(更高精度但更多内存)
|
* 标记字段使用Float64Array存储(更高精度但更多内存)
|
||||||
@@ -181,164 +179,16 @@ export function DeepCopy(target: any, propertyKey: string | symbol): void {
|
|||||||
target.constructor.__deepCopyFields.add(key);
|
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存储器(需要装饰器启用)
|
* SoA存储器(需要装饰器启用)
|
||||||
* 使用Structure of Arrays存储模式,在大规模批量操作时提供优异性能
|
* 使用Structure of Arrays存储模式,在大规模批量操作时提供优异性能
|
||||||
*/
|
*/
|
||||||
export class SoAStorage<T extends Component> {
|
export class SoAStorage<T extends Component> {
|
||||||
private static readonly _logger = createLogger('SoAStorage');
|
|
||||||
private fields = new Map<string, SupportedTypedArray>();
|
private fields = new Map<string, SupportedTypedArray>();
|
||||||
private stringFields = new Map<string, string[]>(); // 专门存储字符串
|
private stringFields = new Map<string, string[]>();
|
||||||
private serializedFields = new Map<string, string[]>(); // 序列化存储Map/Set/Array
|
private serializedFields = new Map<string, string[]>();
|
||||||
private complexFields = new Map<number, Map<string, any>>(); // 存储复杂对象
|
private complexFields = new Map<number, Map<string, unknown>>();
|
||||||
private entityToIndex = new Map<number, number>();
|
private entityToIndex = new Map<number, number>();
|
||||||
private indexToEntity: number[] = [];
|
private indexToEntity: number[] = [];
|
||||||
private freeIndices: number[] = [];
|
private freeIndices: number[] = [];
|
||||||
@@ -346,6 +196,13 @@ export class SoAStorage<T extends Component> {
|
|||||||
private _capacity = 1000;
|
private _capacity = 1000;
|
||||||
public readonly type: ComponentType<T>;
|
public readonly type: ComponentType<T>;
|
||||||
|
|
||||||
|
// 缓存字段类型信息,避免重复创建实例
|
||||||
|
private fieldTypes = new Map<string, string>();
|
||||||
|
// 缓存装饰器元数据
|
||||||
|
private serializeMapFields: Set<string> = new Set();
|
||||||
|
private serializeSetFields: Set<string> = new Set();
|
||||||
|
private serializeArrayFields: Set<string> = new Set();
|
||||||
|
|
||||||
constructor(componentType: ComponentType<T>) {
|
constructor(componentType: ComponentType<T>) {
|
||||||
this.type = componentType;
|
this.type = componentType;
|
||||||
this.initializeFields(componentType);
|
this.initializeFields(componentType);
|
||||||
@@ -353,88 +210,85 @@ export class SoAStorage<T extends Component> {
|
|||||||
|
|
||||||
private initializeFields(componentType: ComponentType<T>): void {
|
private initializeFields(componentType: ComponentType<T>): void {
|
||||||
const instance = new componentType();
|
const instance = new componentType();
|
||||||
const highPrecisionFields = (componentType as any).__highPrecisionFields || new Set();
|
const typeWithMeta = componentType as ComponentType<T> & {
|
||||||
const float64Fields = (componentType as any).__float64Fields || new Set();
|
__float64Fields?: Set<string>;
|
||||||
const float32Fields = (componentType as any).__float32Fields || new Set();
|
__float32Fields?: Set<string>;
|
||||||
const int32Fields = (componentType as any).__int32Fields || new Set();
|
__int32Fields?: Set<string>;
|
||||||
const uint32Fields = (componentType as any).__uint32Fields || new Set();
|
__uint32Fields?: Set<string>;
|
||||||
const int16Fields = (componentType as any).__int16Fields || new Set();
|
__int16Fields?: Set<string>;
|
||||||
const uint16Fields = (componentType as any).__uint16Fields || new Set();
|
__uint16Fields?: Set<string>;
|
||||||
const int8Fields = (componentType as any).__int8Fields || new Set();
|
__int8Fields?: Set<string>;
|
||||||
const uint8Fields = (componentType as any).__uint8Fields || new Set();
|
__uint8Fields?: Set<string>;
|
||||||
const uint8ClampedFields = (componentType as any).__uint8ClampedFields || new Set();
|
__uint8ClampedFields?: Set<string>;
|
||||||
const autoTypedFields = (componentType as any).__autoTypedFields || new Map();
|
__serializeMapFields?: Set<string>;
|
||||||
const serializeMapFields = (componentType as any).__serializeMapFields || new Set();
|
__serializeSetFields?: Set<string>;
|
||||||
const serializeSetFields = (componentType as any).__serializeSetFields || new Set();
|
__serializeArrayFields?: Set<string>;
|
||||||
const serializeArrayFields = (componentType as any).__serializeArrayFields || new Set();
|
};
|
||||||
// const deepCopyFields = (componentType as any).__deepCopyFields || new Set(); // 未使用,但保留供future使用
|
|
||||||
|
|
||||||
for (const key in instance) {
|
const float64Fields = typeWithMeta.__float64Fields || new Set<string>();
|
||||||
if (instance.hasOwnProperty(key) && key !== 'id') {
|
const float32Fields = typeWithMeta.__float32Fields || new Set<string>();
|
||||||
const value = (instance as any)[key];
|
const int32Fields = typeWithMeta.__int32Fields || new Set<string>();
|
||||||
const type = typeof value;
|
const uint32Fields = typeWithMeta.__uint32Fields || new Set<string>();
|
||||||
|
const int16Fields = typeWithMeta.__int16Fields || new Set<string>();
|
||||||
|
const uint16Fields = typeWithMeta.__uint16Fields || new Set<string>();
|
||||||
|
const int8Fields = typeWithMeta.__int8Fields || new Set<string>();
|
||||||
|
const uint8Fields = typeWithMeta.__uint8Fields || new Set<string>();
|
||||||
|
const uint8ClampedFields = typeWithMeta.__uint8ClampedFields || new Set<string>();
|
||||||
|
|
||||||
if (type === 'number') {
|
// 缓存装饰器元数据
|
||||||
if (highPrecisionFields.has(key)) {
|
this.serializeMapFields = typeWithMeta.__serializeMapFields || new Set<string>();
|
||||||
// 标记为高精度,作为复杂对象处理
|
this.serializeSetFields = typeWithMeta.__serializeSetFields || new Set<string>();
|
||||||
// 不添加到fields,会在updateComponentAtIndex中自动添加到complexFields
|
this.serializeArrayFields = typeWithMeta.__serializeArrayFields || new Set<string>();
|
||||||
} else if (autoTypedFields.has(key)) {
|
|
||||||
// 使用自动类型推断
|
// 先收集所有有装饰器的字段,避免重复遍历
|
||||||
const options = autoTypedFields.get(key);
|
const decoratedFields = new Map<string, string>(); // fieldName -> arrayType
|
||||||
const inferredType = TypeInference.inferOptimalType(value, options);
|
|
||||||
const ArrayConstructor = TypeInference.getTypedArrayConstructor(inferredType);
|
// 处理各类型装饰器标记的字段
|
||||||
this.fields.set(key, new ArrayConstructor(this._capacity));
|
for (const key of float64Fields) decoratedFields.set(key, 'float64');
|
||||||
SoAStorage._logger.info(`字段 ${key} 自动推断为 ${inferredType} 类型,值: ${value}, 选项:`, options);
|
for (const key of float32Fields) decoratedFields.set(key, 'float32');
|
||||||
} else if (float64Fields.has(key)) {
|
for (const key of int32Fields) decoratedFields.set(key, 'int32');
|
||||||
// 使用Float64Array存储(高精度浮点数)
|
for (const key of uint32Fields) decoratedFields.set(key, 'uint32');
|
||||||
this.fields.set(key, new Float64Array(this._capacity));
|
for (const key of int16Fields) decoratedFields.set(key, 'int16');
|
||||||
} else if (int32Fields.has(key)) {
|
for (const key of uint16Fields) decoratedFields.set(key, 'uint16');
|
||||||
// 使用Int32Array存储(32位有符号整数)
|
for (const key of int8Fields) decoratedFields.set(key, 'int8');
|
||||||
this.fields.set(key, new Int32Array(this._capacity));
|
for (const key of uint8Fields) decoratedFields.set(key, 'uint8');
|
||||||
} else if (uint32Fields.has(key)) {
|
for (const key of uint8ClampedFields) decoratedFields.set(key, 'uint8clamped');
|
||||||
// 使用Uint32Array存储(32位无符号整数)
|
|
||||||
this.fields.set(key, new Uint32Array(this._capacity));
|
// 只遍历实例自身的属性(不包括原型链),跳过 id
|
||||||
} else if (int16Fields.has(key)) {
|
const instanceKeys = Object.keys(instance).filter(key => key !== 'id');
|
||||||
// 使用Int16Array存储(16位有符号整数)
|
|
||||||
this.fields.set(key, new Int16Array(this._capacity));
|
for (const key of instanceKeys) {
|
||||||
} else if (uint16Fields.has(key)) {
|
const value = (instance as Record<string, unknown>)[key];
|
||||||
// 使用Uint16Array存储(16位无符号整数)
|
const type = typeof value;
|
||||||
this.fields.set(key, new Uint16Array(this._capacity));
|
|
||||||
} else if (int8Fields.has(key)) {
|
// 跳过函数(通常不会出现在 Object.keys 中,但以防万一)
|
||||||
// 使用Int8Array存储(8位有符号整数)
|
if (type === 'function') continue;
|
||||||
this.fields.set(key, new Int8Array(this._capacity));
|
|
||||||
} else if (uint8Fields.has(key)) {
|
// 检查装饰器类型
|
||||||
// 使用Uint8Array存储(8位无符号整数)
|
const decoratorType = decoratedFields.get(key);
|
||||||
this.fields.set(key, new Uint8Array(this._capacity));
|
const effectiveType = decoratorType ? 'number' : type;
|
||||||
} else if (uint8ClampedFields.has(key)) {
|
this.fieldTypes.set(key, effectiveType);
|
||||||
// 使用Uint8ClampedArray存储(8位夹紧无符号整数)
|
|
||||||
this.fields.set(key, new Uint8ClampedArray(this._capacity));
|
if (decoratorType) {
|
||||||
} else if (float32Fields.has(key)) {
|
// 有装饰器标记的数字字段
|
||||||
// 使用Float32Array存储(32位浮点数)
|
const ArrayConstructor = SoATypeRegistry.getConstructor(decoratorType as TypedArrayTypeName);
|
||||||
this.fields.set(key, new Float32Array(this._capacity));
|
this.fields.set(key, new ArrayConstructor(this._capacity));
|
||||||
} else {
|
} else if (type === 'number') {
|
||||||
// 默认使用Float32Array
|
// 无装饰器的数字字段,默认使用 Float32Array
|
||||||
this.fields.set(key, new Float32Array(this._capacity));
|
this.fields.set(key, new Float32Array(this._capacity));
|
||||||
}
|
|
||||||
} else if (type === 'boolean') {
|
} else if (type === 'boolean') {
|
||||||
// 布尔值默认使用Uint8Array存储为0/1(更节省内存)
|
// 布尔值使用 Uint8Array 存储为 0/1
|
||||||
if (uint8Fields.has(key) || (!float32Fields.has(key) && !float64Fields.has(key))) {
|
this.fields.set(key, new Uint8Array(this._capacity));
|
||||||
this.fields.set(key, new Uint8Array(this._capacity));
|
|
||||||
} else {
|
|
||||||
// 兼容性:如果显式指定浮点类型则使用原有方式
|
|
||||||
this.fields.set(key, new Float32Array(this._capacity));
|
|
||||||
}
|
|
||||||
} else if (type === 'string') {
|
} else if (type === 'string') {
|
||||||
// 字符串专门处理
|
// 字符串专门处理
|
||||||
this.stringFields.set(key, new Array(this._capacity));
|
this.stringFields.set(key, new Array(this._capacity));
|
||||||
} else if (type === 'object' && value !== null) {
|
} 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));
|
this.serializedFields.set(key, new Array(this._capacity));
|
||||||
}
|
}
|
||||||
// 其他对象类型会在updateComponentAtIndex中作为复杂对象处理
|
// 其他对象类型会在updateComponentAtIndex中作为复杂对象处理
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,12 +351,16 @@ export class SoAStorage<T extends Component> {
|
|||||||
} else if (this.serializedFields.has(key)) {
|
} else if (this.serializedFields.has(key)) {
|
||||||
// 序列化字段处理
|
// 序列化字段处理
|
||||||
const serializedArray = this.serializedFields.get(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 {
|
} else {
|
||||||
// 复杂字段单独存储
|
// 复杂字段单独存储
|
||||||
if (deepCopyFields.has(key)) {
|
if (deepCopyFields.has(key)) {
|
||||||
// 深拷贝处理
|
// 深拷贝处理
|
||||||
complexFieldMap.set(key, this.deepClone(value));
|
complexFieldMap.set(key, SoASerializer.deepClone(value));
|
||||||
} else {
|
} else {
|
||||||
complexFieldMap.set(key, value);
|
complexFieldMap.set(key, value);
|
||||||
}
|
}
|
||||||
@@ -516,96 +374,6 @@ export class SoAStorage<T extends Component> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 序列化值为JSON字符串
|
|
||||||
*/
|
|
||||||
private serializeValue(value: any, key: string, mapFields: Set<string>, setFields: Set<string>, arrayFields: Set<string>): 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<string>, setFields: Set<string>, arrayFields: Set<string>): 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 {
|
public getComponent(entityId: number): T | null {
|
||||||
const index = this.entityToIndex.get(entityId);
|
const index = this.entityToIndex.get(entityId);
|
||||||
@@ -613,11 +381,162 @@ export class SoAStorage<T extends Component> {
|
|||||||
return null;
|
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<Record<string, unknown>> = {
|
||||||
|
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<string, unknown>, 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 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()) {
|
for (const [fieldName, array] of this.fields.entries()) {
|
||||||
@@ -640,7 +559,11 @@ export class SoAStorage<T extends Component> {
|
|||||||
for (const [fieldName, serializedArray] of this.serializedFields.entries()) {
|
for (const [fieldName, serializedArray] of this.serializedFields.entries()) {
|
||||||
const serialized = serializedArray[index];
|
const serialized = serializedArray[index];
|
||||||
if (serialized) {
|
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<T extends Component> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getFieldType(fieldName: string): string {
|
private getFieldType(fieldName: string): string {
|
||||||
// 通过创建临时实例检查字段类型
|
// 使用缓存的字段类型
|
||||||
const tempInstance = new this.type();
|
return this.fieldTypes.get(fieldName) || 'unknown';
|
||||||
const value = (tempInstance as any)[fieldName];
|
|
||||||
return typeof value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasComponent(entityId: number): boolean {
|
public hasComponent(entityId: number): boolean {
|
||||||
@@ -687,32 +608,7 @@ export class SoAStorage<T extends Component> {
|
|||||||
private resize(newCapacity: number): void {
|
private resize(newCapacity: number): void {
|
||||||
// 调整数值字段的TypedArray
|
// 调整数值字段的TypedArray
|
||||||
for (const [fieldName, oldArray] of this.fields.entries()) {
|
for (const [fieldName, oldArray] of this.fields.entries()) {
|
||||||
let newArray: SupportedTypedArray;
|
const newArray = SoATypeRegistry.createSameType(oldArray, newCapacity);
|
||||||
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
newArray.set(oldArray);
|
newArray.set(oldArray);
|
||||||
this.fields.set(fieldName, newArray);
|
this.fields.set(fieldName, newArray);
|
||||||
}
|
}
|
||||||
@@ -849,42 +745,8 @@ export class SoAStorage<T extends Component> {
|
|||||||
const fieldStats = new Map<string, any>();
|
const fieldStats = new Map<string, any>();
|
||||||
|
|
||||||
for (const [fieldName, array] of this.fields.entries()) {
|
for (const [fieldName, array] of this.fields.entries()) {
|
||||||
let bytesPerElement: number;
|
const typeName = SoATypeRegistry.getTypeName(array);
|
||||||
let typeName: string;
|
const bytesPerElement = SoATypeRegistry.getBytesPerElement(typeName);
|
||||||
|
|
||||||
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 memory = array.length * bytesPerElement;
|
const memory = array.length * bytesPerElement;
|
||||||
totalMemory += memory;
|
totalMemory += memory;
|
||||||
|
|
||||||
@@ -914,4 +776,5 @@ export class SoAStorage<T extends Component> {
|
|||||||
const activeIndices = this.getActiveIndices();
|
const activeIndices = this.getActiveIndices();
|
||||||
operation(this.fields, activeIndices);
|
operation(this.fields, activeIndices);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TypedArrayTypeName, TypedArrayConstructor> = {
|
||||||
|
float32: Float32Array,
|
||||||
|
float64: Float64Array,
|
||||||
|
int32: Int32Array,
|
||||||
|
uint32: Uint32Array,
|
||||||
|
int16: Int16Array,
|
||||||
|
uint16: Uint16Array,
|
||||||
|
int8: Int8Array,
|
||||||
|
uint8: Uint8Array,
|
||||||
|
uint8clamped: Uint8ClampedArray
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly TYPE_BYTES: Record<TypedArrayTypeName, number> = {
|
||||||
|
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<T>(
|
||||||
|
componentType: new () => T
|
||||||
|
): Map<string, FieldMetadata> {
|
||||||
|
const instance = new componentType();
|
||||||
|
const metadata = new Map<string, FieldMetadata>();
|
||||||
|
|
||||||
|
const typeWithMeta = componentType as typeof componentType & {
|
||||||
|
__float64Fields?: Set<string>;
|
||||||
|
__float32Fields?: Set<string>;
|
||||||
|
__int32Fields?: Set<string>;
|
||||||
|
__uint32Fields?: Set<string>;
|
||||||
|
__int16Fields?: Set<string>;
|
||||||
|
__uint16Fields?: Set<string>;
|
||||||
|
__int8Fields?: Set<string>;
|
||||||
|
__uint8Fields?: Set<string>;
|
||||||
|
__uint8ClampedFields?: Set<string>;
|
||||||
|
__serializeMapFields?: Set<string>;
|
||||||
|
__serializeSetFields?: Set<string>;
|
||||||
|
__serializeArrayFields?: Set<string>;
|
||||||
|
__deepCopyFields?: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 收集装饰器标记
|
||||||
|
const decoratorMap = new Map<string, TypedArrayTypeName>();
|
||||||
|
|
||||||
|
const addDecorators = (fields: Set<string> | 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<string, unknown>)[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export { ComponentPool, ComponentPoolManager } from '../ComponentPool';
|
export { ComponentPool, ComponentPoolManager } from '../ComponentPool';
|
||||||
export { ComponentStorage, ComponentRegistry } from '../ComponentStorage';
|
export { ComponentStorage, ComponentRegistry } from '../ComponentStorage';
|
||||||
export { EnableSoA, HighPrecision, Float64, Float32, Int32, SerializeMap, SoAStorage } from '../SoAStorage';
|
export { EnableSoA, Float64, Float32, Int32, SerializeMap, SoAStorage } from '../SoAStorage';
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export {
|
|||||||
EnableSoA,
|
EnableSoA,
|
||||||
|
|
||||||
// 数值类型装饰器
|
// 数值类型装饰器
|
||||||
HighPrecision,
|
|
||||||
Float64,
|
Float64,
|
||||||
Float32,
|
Float32,
|
||||||
Int32,
|
Int32,
|
||||||
@@ -34,10 +33,6 @@ export {
|
|||||||
Uint8,
|
Uint8,
|
||||||
Uint8Clamped,
|
Uint8Clamped,
|
||||||
|
|
||||||
// 自动类型推断
|
|
||||||
AutoTyped,
|
|
||||||
TypeInference,
|
|
||||||
|
|
||||||
// 序列化装饰器
|
// 序列化装饰器
|
||||||
SerializeMap,
|
SerializeMap,
|
||||||
SerializeSet,
|
SerializeSet,
|
||||||
|
|||||||
@@ -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<string, unknown> = {};
|
||||||
|
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<string, string>).get('key1')).toBe('value1');
|
||||||
|
expect((result as Map<string, string>).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<number>).has(1)).toBe(true);
|
||||||
|
expect((result as Set<number>).has(2)).toBe(true);
|
||||||
|
expect((result as Set<number>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,6 @@ import { Component } from '../../../src/ECS/Component';
|
|||||||
import { ComponentStorageManager } from '../../../src/ECS/Core/ComponentStorage';
|
import { ComponentStorageManager } from '../../../src/ECS/Core/ComponentStorage';
|
||||||
import {
|
import {
|
||||||
EnableSoA,
|
EnableSoA,
|
||||||
HighPrecision,
|
|
||||||
Float64,
|
Float64,
|
||||||
Int32,
|
Int32,
|
||||||
SerializeMap,
|
SerializeMap,
|
||||||
@@ -50,7 +49,7 @@ class BasicTypesComponent extends Component {
|
|||||||
class DecoratedNumberComponent extends Component {
|
class DecoratedNumberComponent extends Component {
|
||||||
public normalFloat: number;
|
public normalFloat: number;
|
||||||
|
|
||||||
@HighPrecision
|
@Float64
|
||||||
public highPrecisionNumber: number;
|
public highPrecisionNumber: number;
|
||||||
|
|
||||||
@Float64
|
@Float64
|
||||||
@@ -143,7 +142,7 @@ class ComplexObjectComponent extends Component {
|
|||||||
|
|
||||||
@EnableSoA
|
@EnableSoA
|
||||||
class MixedComponent extends Component {
|
class MixedComponent extends Component {
|
||||||
@HighPrecision
|
@Float64
|
||||||
public bigIntId: number;
|
public bigIntId: number;
|
||||||
|
|
||||||
@Float64
|
@Float64
|
||||||
@@ -288,7 +287,7 @@ describe('SoAStorage - SoA存储测试', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('数值类型装饰器', () => {
|
describe('数值类型装饰器', () => {
|
||||||
test('@HighPrecision应该保持高精度数值', () => {
|
test('@Float64应该保持高精度数值', () => {
|
||||||
const component = new DecoratedNumberComponent(
|
const component = new DecoratedNumberComponent(
|
||||||
0,
|
0,
|
||||||
Number.MAX_SAFE_INTEGER
|
Number.MAX_SAFE_INTEGER
|
||||||
@@ -336,7 +335,7 @@ describe('SoAStorage - SoA存储测试', () => {
|
|||||||
expect(storage.getFieldArray('normalFloat')).toBeInstanceOf(Float32Array);
|
expect(storage.getFieldArray('normalFloat')).toBeInstanceOf(Float32Array);
|
||||||
expect(storage.getFieldArray('preciseFloat')).toBeInstanceOf(Float64Array);
|
expect(storage.getFieldArray('preciseFloat')).toBeInstanceOf(Float64Array);
|
||||||
expect(storage.getFieldArray('integerValue')).toBeInstanceOf(Int32Array);
|
expect(storage.getFieldArray('integerValue')).toBeInstanceOf(Int32Array);
|
||||||
expect(storage.getFieldArray('highPrecisionNumber')).toBeNull();
|
expect(storage.getFieldArray('highPrecisionNumber')).toBeInstanceOf(Float64Array);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<EngineBridgeConfig>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T extends Component & ITransformComponent>(
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@esengine/behavior-tree": "file:../behavior-tree",
|
"@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/ecs-framework": "file:../core",
|
||||||
"@esengine/editor-core": "file:../editor-core",
|
"@esengine/editor-core": "file:../editor-core",
|
||||||
"@tauri-apps/api": "^2.2.0",
|
"@tauri-apps/api": "^2.2.0",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||||
"@tauri-apps/plugin-http": "^2.5.4",
|
"@tauri-apps/plugin-http": "^2.5.4",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
|
"@esengine/engine": "file:../engine",
|
||||||
"flexlayout-react": "^0.8.17",
|
"flexlayout-react": "^0.8.17",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
@@ -51,6 +52,8 @@
|
|||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.0.7",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,16 +14,18 @@ import {
|
|||||||
ProjectService,
|
ProjectService,
|
||||||
CompilerRegistry,
|
CompilerRegistry,
|
||||||
InspectorRegistry,
|
InspectorRegistry,
|
||||||
INotification
|
INotification,
|
||||||
|
CommandManager
|
||||||
} from '@esengine/editor-core';
|
} from '@esengine/editor-core';
|
||||||
import type { IDialogExtended } from './services/TauriDialogService';
|
import type { IDialogExtended } from './services/TauriDialogService';
|
||||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||||
import { ServiceRegistry, PluginInstaller, useDialogStore } from './app/managers';
|
import { ServiceRegistry, PluginInstaller, useDialogStore } from './app/managers';
|
||||||
import { StartupPage } from './components/StartupPage';
|
import { StartupPage } from './components/StartupPage';
|
||||||
import { SceneHierarchy } from './components/SceneHierarchy';
|
import { SceneHierarchy } from './components/SceneHierarchy';
|
||||||
import { Inspector } from './components/Inspector';
|
import { Inspector } from './components/inspectors/Inspector';
|
||||||
import { AssetBrowser } from './components/AssetBrowser';
|
import { AssetBrowser } from './components/AssetBrowser';
|
||||||
import { ConsolePanel } from './components/ConsolePanel';
|
import { ConsolePanel } from './components/ConsolePanel';
|
||||||
|
import { Viewport } from './components/Viewport';
|
||||||
import { PluginManagerWindow } from './components/PluginManagerWindow';
|
import { PluginManagerWindow } from './components/PluginManagerWindow';
|
||||||
import { ProfilerWindow } from './components/ProfilerWindow';
|
import { ProfilerWindow } from './components/ProfilerWindow';
|
||||||
import { PortManager } from './components/PortManager';
|
import { PortManager } from './components/PortManager';
|
||||||
@@ -82,6 +84,7 @@ function App() {
|
|||||||
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
||||||
const [notification, setNotification] = useState<INotification | null>(null);
|
const [notification, setNotification] = useState<INotification | null>(null);
|
||||||
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
|
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
|
||||||
|
const [commandManager] = useState(() => new CommandManager());
|
||||||
const { t, locale, changeLocale } = useLocale();
|
const { t, locale, changeLocale } = useLocale();
|
||||||
|
|
||||||
// 同步 locale 到 TauriDialogService
|
// 同步 locale 到 TauriDialogService
|
||||||
@@ -660,13 +663,13 @@ function App() {
|
|||||||
{
|
{
|
||||||
id: 'scene-hierarchy',
|
id: 'scene-hierarchy',
|
||||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
|
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} commandManager={commandManager} />,
|
||||||
closable: false
|
closable: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'inspector',
|
id: 'inspector',
|
||||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} />,
|
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
|
||||||
closable: false
|
closable: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -681,13 +684,19 @@ function App() {
|
|||||||
{
|
{
|
||||||
id: 'scene-hierarchy',
|
id: 'scene-hierarchy',
|
||||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
|
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} commandManager={commandManager} />,
|
||||||
|
closable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'viewport',
|
||||||
|
title: locale === 'zh' ? '视口' : 'Viewport',
|
||||||
|
content: <Viewport locale={locale} />,
|
||||||
closable: false
|
closable: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'inspector',
|
id: 'inspector',
|
||||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} />,
|
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
|
||||||
closable: false
|
closable: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<string, unknown>
|
||||||
|
) {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { AddComponentCommand } from './AddComponentCommand';
|
||||||
|
export { RemoveComponentCommand } from './RemoveComponentCommand';
|
||||||
|
export { UpdateComponentCommand } from './UpdateComponentCommand';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CreateEntityCommand } from './CreateEntityCommand';
|
||||||
|
export { DeleteEntityCommand } from './DeleteEntityCommand';
|
||||||
@@ -1,29 +1,37 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Entity, Core } from '@esengine/ecs-framework';
|
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 { 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 { ProfilerService, RemoteEntity } from '../services/ProfilerService';
|
||||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
|
||||||
import '../styles/SceneHierarchy.css';
|
import '../styles/SceneHierarchy.css';
|
||||||
|
|
||||||
|
type ViewMode = 'local' | 'remote';
|
||||||
|
|
||||||
interface SceneHierarchyProps {
|
interface SceneHierarchyProps {
|
||||||
entityStore: EntityStoreService;
|
entityStore: EntityStoreService;
|
||||||
messageHub: MessageHub;
|
messageHub: MessageHub;
|
||||||
|
commandManager: CommandManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) {
|
export function SceneHierarchy({ entityStore, messageHub, commandManager }: SceneHierarchyProps) {
|
||||||
const [entities, setEntities] = useState<Entity[]>([]);
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
const [remoteEntities, setRemoteEntities] = useState<RemoteEntity[]>([]);
|
const [remoteEntities, setRemoteEntities] = useState<RemoteEntity[]>([]);
|
||||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('local');
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sceneName, setSceneName] = useState<string>('Untitled');
|
const [sceneName, setSceneName] = useState<string>('Untitled');
|
||||||
const [remoteSceneName, setRemoteSceneName] = useState<string | null>(null);
|
const [remoteSceneName, setRemoteSceneName] = useState<string | null>(null);
|
||||||
const [sceneFilePath, setSceneFilePath] = useState<string | null>(null);
|
const [sceneFilePath, setSceneFilePath] = useState<string | null>(null);
|
||||||
const [isSceneModified, setIsSceneModified] = useState<boolean>(false);
|
const [isSceneModified, setIsSceneModified] = useState<boolean>(false);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entityId: number | null } | null>(null);
|
||||||
const { t, locale } = useLocale();
|
const { t, locale } = useLocale();
|
||||||
|
|
||||||
|
const isShowingRemote = viewMode === 'remote' && isRemoteConnected;
|
||||||
|
|
||||||
// Subscribe to scene changes
|
// Subscribe to scene changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sceneManager = Core.services.resolve(SceneManagerService);
|
const sceneManager = Core.services.resolve(SceneManagerService);
|
||||||
@@ -182,14 +190,15 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateEntity = () => {
|
const handleCreateEntity = () => {
|
||||||
const scene = Core.scene;
|
|
||||||
if (!scene) return;
|
|
||||||
|
|
||||||
const entityCount = entityStore.getAllEntities().length;
|
const entityCount = entityStore.getAllEntities().length;
|
||||||
const entityName = `Entity ${entityCount + 1}`;
|
const entityName = `Entity ${entityCount + 1}`;
|
||||||
const entity = scene.createEntity(entityName);
|
|
||||||
entityStore.addEntity(entity);
|
const command = new CreateEntityCommand(
|
||||||
entityStore.selectEntity(entity);
|
entityStore,
|
||||||
|
messageHub,
|
||||||
|
entityName
|
||||||
|
);
|
||||||
|
commandManager.execute(command);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEntity = async () => {
|
const handleDeleteEntity = async () => {
|
||||||
@@ -200,8 +209,8 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
|||||||
|
|
||||||
const confirmed = await confirm(
|
const confirmed = await confirm(
|
||||||
locale === 'zh'
|
locale === 'zh'
|
||||||
? `确定要删除实体 "${entity.name}" 吗?此操作无法撤销。`
|
? `确定要删除实体 "${entity.name}" 吗?`
|
||||||
: `Are you sure you want to delete entity "${entity.name}"? This action cannot be undone.`,
|
: `Are you sure you want to delete entity "${entity.name}"?`,
|
||||||
{
|
{
|
||||||
title: locale === 'zh' ? '删除实体' : 'Delete Entity',
|
title: locale === 'zh' ? '删除实体' : 'Delete Entity',
|
||||||
kind: 'warning'
|
kind: 'warning'
|
||||||
@@ -209,22 +218,44 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
entity.destroy();
|
const command = new DeleteEntityCommand(
|
||||||
entityStore.removeEntity(entity);
|
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
|
// Listen for Delete key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Delete' && selectedId && !isRemoteConnected) {
|
if (e.key === 'Delete' && selectedId && !isShowingRemote) {
|
||||||
handleDeleteEntity();
|
handleDeleteEntity();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [selectedId, isRemoteConnected]);
|
}, [selectedId, isShowingRemote]);
|
||||||
|
|
||||||
// Filter entities based on search query
|
// Filter entities based on search query
|
||||||
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
|
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
|
||||||
@@ -262,11 +293,11 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Determine which entities to display
|
// Determine which entities to display
|
||||||
const displayEntities = isRemoteConnected
|
const displayEntities = isShowingRemote
|
||||||
? filterRemoteEntities(remoteEntities)
|
? filterRemoteEntities(remoteEntities)
|
||||||
: filterLocalEntities(entities);
|
: filterLocalEntities(entities);
|
||||||
const showRemoteIndicator = isRemoteConnected && remoteEntities.length > 0;
|
const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0;
|
||||||
const displaySceneName = isRemoteConnected && remoteSceneName ? remoteSceneName : sceneName;
|
const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scene-hierarchy">
|
<div className="scene-hierarchy">
|
||||||
@@ -282,6 +313,24 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
|||||||
{displaySceneName}{!isRemoteConnected && isSceneModified ? '*' : ''}
|
{displaySceneName}{!isRemoteConnected && isSceneModified ? '*' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isRemoteConnected && (
|
||||||
|
<div className="view-mode-toggle">
|
||||||
|
<button
|
||||||
|
className={`mode-btn ${viewMode === 'local' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewMode('local')}
|
||||||
|
title={locale === 'zh' ? '本地场景' : 'Local Scene'}
|
||||||
|
>
|
||||||
|
<Monitor size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mode-btn ${viewMode === 'remote' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewMode('remote')}
|
||||||
|
title={locale === 'zh' ? '远程实体' : 'Remote Entities'}
|
||||||
|
>
|
||||||
|
<Globe size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showRemoteIndicator && (
|
{showRemoteIndicator && (
|
||||||
<div className="remote-indicator" title="Showing remote entities">
|
<div className="remote-indicator" title="Showing remote entities">
|
||||||
<Wifi size={12} />
|
<Wifi size={12} />
|
||||||
@@ -297,18 +346,17 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isRemoteConnected && (
|
{!isShowingRemote && (
|
||||||
<div className="hierarchy-toolbar">
|
<div className="hierarchy-toolbar">
|
||||||
<button
|
<button
|
||||||
className="toolbar-btn"
|
className="toolbar-btn icon-only"
|
||||||
onClick={handleCreateEntity}
|
onClick={handleCreateEntity}
|
||||||
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
|
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
<span>{locale === 'zh' ? '创建实体' : 'Create Entity'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="toolbar-btn"
|
className="toolbar-btn icon-only"
|
||||||
onClick={handleDeleteEntity}
|
onClick={handleDeleteEntity}
|
||||||
disabled={!selectedId}
|
disabled={!selectedId}
|
||||||
title={locale === 'zh' ? '删除实体' : 'Delete Entity'}
|
title={locale === 'zh' ? '删除实体' : 'Delete Entity'}
|
||||||
@@ -317,18 +365,18 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="hierarchy-content scrollable">
|
<div className="hierarchy-content scrollable" onContextMenu={(e) => !isShowingRemote && handleContextMenu(e, null)}>
|
||||||
{displayEntities.length === 0 ? (
|
{displayEntities.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<Box size={48} strokeWidth={1.5} className="empty-icon" />
|
<Box size={48} strokeWidth={1.5} className="empty-icon" />
|
||||||
<div className="empty-title">{t('hierarchy.empty')}</div>
|
<div className="empty-title">{t('hierarchy.empty')}</div>
|
||||||
<div className="empty-hint">
|
<div className="empty-hint">
|
||||||
{isRemoteConnected
|
{isShowingRemote
|
||||||
? 'No entities in remote game'
|
? 'No entities in remote game'
|
||||||
: 'Create an entity to get started'}
|
: 'Create an entity to get started'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isRemoteConnected ? (
|
) : isShowingRemote ? (
|
||||||
<ul className="entity-list">
|
<ul className="entity-list">
|
||||||
{(displayEntities as RemoteEntity[]).map((entity) => (
|
{(displayEntities as RemoteEntity[]).map((entity) => (
|
||||||
<li
|
<li
|
||||||
@@ -357,14 +405,45 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
|||||||
key={entity.id}
|
key={entity.id}
|
||||||
className={`entity-item ${selectedId === entity.id ? 'selected' : ''}`}
|
className={`entity-item ${selectedId === entity.id ? 'selected' : ''}`}
|
||||||
onClick={() => handleEntityClick(entity)}
|
onClick={() => handleEntityClick(entity)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEntityClick(entity);
|
||||||
|
handleContextMenu(e, entity.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Box size={14} className="entity-icon" />
|
<Box size={14} className="entity-icon" />
|
||||||
<span className="entity-name">Entity {entity.id}</span>
|
<span className="entity-name">{entity.name || `Entity ${entity.id}`}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contextMenu && !isShowingRemote && (
|
||||||
|
<div
|
||||||
|
className="context-menu"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: contextMenu.x,
|
||||||
|
top: contextMenu.y,
|
||||||
|
zIndex: 1000
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button onClick={() => { handleCreateEntity(); closeContextMenu(); }}>
|
||||||
|
<Plus size={12} />
|
||||||
|
<span>{locale === 'zh' ? '创建实体' : 'Create Entity'}</span>
|
||||||
|
</button>
|
||||||
|
{contextMenu.entityId && (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<button onClick={() => { handleDeleteEntity(); closeContextMenu(); }}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
<span>{locale === 'zh' ? '删除实体' : 'Delete Entity'}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
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 '../styles/Viewport.css';
|
||||||
|
import { useEngine } from '../hooks/useEngine';
|
||||||
|
|
||||||
interface ViewportProps {
|
interface ViewportProps {
|
||||||
locale?: string;
|
locale?: string;
|
||||||
@@ -14,6 +15,11 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
|
|||||||
const [showGizmos, setShowGizmos] = useState(true);
|
const [showGizmos, setShowGizmos] = useState(true);
|
||||||
const [showStats, setShowStats] = useState(false);
|
const [showStats, setShowStats] = useState(false);
|
||||||
const [is3D, setIs3D] = useState(true);
|
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<number>();
|
const animationFrameRef = useRef<number>();
|
||||||
const glRef = useRef<WebGLRenderingContext | null>(null);
|
const glRef = useRef<WebGLRenderingContext | null>(null);
|
||||||
const gridProgramRef = useRef<WebGLProgram | null>(null);
|
const gridProgramRef = useRef<WebGLProgram | null>(null);
|
||||||
@@ -573,7 +579,17 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePlayPause = () => {
|
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 = () => {
|
const handleReset = () => {
|
||||||
@@ -634,6 +650,15 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
|
|||||||
>
|
>
|
||||||
{is3D ? <Box size={16} /> : <Square size={16} />}
|
{is3D ? <Box size={16} /> : <Square size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
{!is3D && (
|
||||||
|
<button
|
||||||
|
className={`viewport-btn ${useRustEngine ? 'active' : ''}`}
|
||||||
|
onClick={() => setUseRustEngine(!useRustEngine)}
|
||||||
|
title={locale === 'zh' ? 'Rust引擎' : 'Rust Engine'}
|
||||||
|
>
|
||||||
|
<Zap size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="viewport-toolbar-right">
|
<div className="viewport-toolbar-right">
|
||||||
<button
|
<button
|
||||||
@@ -652,17 +677,33 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<canvas ref={canvasRef} className="viewport-canvas" />
|
<canvas ref={canvasRef} id="viewport-canvas" className="viewport-canvas" />
|
||||||
{showStats && (
|
{showStats && (
|
||||||
<div className="viewport-stats">
|
<div className="viewport-stats">
|
||||||
<div className="viewport-stat">
|
<div className="viewport-stat">
|
||||||
<span className="viewport-stat-label">FPS:</span>
|
<span className="viewport-stat-label">FPS:</span>
|
||||||
<span className="viewport-stat-value">{fps}</span>
|
<span className="viewport-stat-value">
|
||||||
|
{useRustEngine && !is3D ? engine.state.fps : fps}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="viewport-stat">
|
<div className="viewport-stat">
|
||||||
<span className="viewport-stat-label">Draw Calls:</span>
|
<span className="viewport-stat-label">Draw Calls:</span>
|
||||||
<span className="viewport-stat-value">{drawCalls}</span>
|
<span className="viewport-stat-value">
|
||||||
|
{useRustEngine && !is3D ? engine.state.drawCalls : drawCalls}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{useRustEngine && !is3D && (
|
||||||
|
<div className="viewport-stat">
|
||||||
|
<span className="viewport-stat-label">Sprites:</span>
|
||||||
|
<span className="viewport-stat-value">{engine.state.spriteCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{useRustEngine && !is3D && engine.state.error && (
|
||||||
|
<div className="viewport-stat viewport-stat-error">
|
||||||
|
<span className="viewport-stat-label">Error:</span>
|
||||||
|
<span className="viewport-stat-value">{engine.state.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
EntityInspector
|
EntityInspector
|
||||||
} from './views';
|
} 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<InspectorTarget>(null);
|
const [target, setTarget] = useState<InspectorTarget>(null);
|
||||||
const [componentVersion, setComponentVersion] = useState(0);
|
const [componentVersion, setComponentVersion] = useState(0);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
@@ -196,7 +196,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (target.type === 'entity') {
|
if (target.type === 'entity') {
|
||||||
return <EntityInspector entity={target.data} messageHub={messageHub} componentVersion={componentVersion} />;
|
return <EntityInspector entity={target.data} messageHub={messageHub} commandManager={commandManager} componentVersion={componentVersion} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Entity } from '@esengine/ecs-framework';
|
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 {
|
export interface InspectorProps {
|
||||||
entityStore: EntityStoreService;
|
entityStore: EntityStoreService;
|
||||||
messageHub: MessageHub;
|
messageHub: MessageHub;
|
||||||
inspectorRegistry: InspectorRegistry;
|
inspectorRegistry: InspectorRegistry;
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
|
commandManager: CommandManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetFileInfo {
|
export interface AssetFileInfo {
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Settings, ChevronDown, ChevronRight, X } from 'lucide-react';
|
import { Settings, ChevronDown, ChevronRight, X, Plus } from 'lucide-react';
|
||||||
import { Entity, Component } from '@esengine/ecs-framework';
|
import { Entity, Component, Core } from '@esengine/ecs-framework';
|
||||||
import { MessageHub } from '@esengine/editor-core';
|
import { MessageHub, CommandManager, ComponentRegistry } from '@esengine/editor-core';
|
||||||
import { PropertyInspector } from '../../PropertyInspector';
|
import { PropertyInspector } from '../../PropertyInspector';
|
||||||
|
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
|
||||||
import '../../../styles/EntityInspector.css';
|
import '../../../styles/EntityInspector.css';
|
||||||
|
|
||||||
interface EntityInspectorProps {
|
interface EntityInspectorProps {
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
messageHub: MessageHub;
|
messageHub: MessageHub;
|
||||||
|
commandManager: CommandManager;
|
||||||
componentVersion: number;
|
componentVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EntityInspector({ entity, messageHub, componentVersion }: EntityInspectorProps) {
|
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
|
||||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
|
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
|
||||||
|
const [showComponentMenu, setShowComponentMenu] = useState(false);
|
||||||
|
|
||||||
|
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||||
|
const availableComponents = componentRegistry?.getAllComponents() || [];
|
||||||
|
|
||||||
const toggleComponentExpanded = (index: number) => {
|
const toggleComponentExpanded = (index: number) => {
|
||||||
setExpandedComponents((prev) => {
|
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 handleRemoveComponent = (index: number) => {
|
||||||
const component = entity.components[index];
|
const component = entity.components[index];
|
||||||
if (component) {
|
if (component) {
|
||||||
entity.removeComponent(component);
|
const command = new RemoveComponentCommand(
|
||||||
messageHub.publish('component:removed', { entity, component });
|
messageHub,
|
||||||
|
entity,
|
||||||
|
component
|
||||||
|
);
|
||||||
|
commandManager.execute(command);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePropertyChange = (component: Component, propertyName: string, value: unknown) => {
|
const handlePropertyChange = (component: Component, propertyName: string, value: unknown) => {
|
||||||
messageHub.publish('component:property:changed', {
|
const command = new UpdateComponentCommand(
|
||||||
|
messageHub,
|
||||||
entity,
|
entity,
|
||||||
component,
|
component,
|
||||||
propertyName,
|
propertyName,
|
||||||
value
|
value
|
||||||
});
|
);
|
||||||
|
commandManager.execute(command);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,10 +81,77 @@ export function EntityInspector({ entity, messageHub, componentVersion }: Entity
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entity.components.length > 0 && (
|
<div className="inspector-section">
|
||||||
<div className="inspector-section">
|
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<div className="section-title">组件</div>
|
<span>组件</span>
|
||||||
{entity.components.map((component: Component, index: number) => {
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowComponentMenu(!showComponentMenu)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #4a4a4a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px 6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
fontSize: '11px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
添加
|
||||||
|
</button>
|
||||||
|
{showComponentMenu && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
right: 0,
|
||||||
|
marginTop: '4px',
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
border: '1px solid #4a4a4a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: '150px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{availableComponents.length === 0 ? (
|
||||||
|
<div style={{ padding: '8px 12px', color: '#888', fontSize: '11px' }}>
|
||||||
|
没有可用组件
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableComponents.map((info) => (
|
||||||
|
<button
|
||||||
|
key={info.name}
|
||||||
|
onClick={() => info.type && handleAddComponent(info.type)}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#3a3a3a')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
{info.name}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{entity.components.map((component: Component, index: number) => {
|
||||||
const isExpanded = expandedComponents.has(index);
|
const isExpanded = expandedComponents.has(index);
|
||||||
const componentName = component.constructor?.name || 'Component';
|
const componentName = component.constructor?.name || 'Component';
|
||||||
|
|
||||||
@@ -140,8 +225,7 @@ export function EntityInspector({ entity, messageHub, componentVersion }: Entity
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Entity, Component } from '@esengine/ecs-framework';
|
import type { Entity, Component } from '@esengine/ecs-framework';
|
||||||
import type { Node } from '@esengine/behavior-tree-editor';
|
|
||||||
|
|
||||||
export interface PluginEvent {
|
export interface PluginEvent {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -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>(EngineService.getInstance());
|
||||||
|
const statsIntervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const [state, setState] = useState<EngineState>({
|
||||||
|
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;
|
||||||
@@ -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<void> {
|
||||||
|
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;
|
||||||
@@ -67,6 +67,51 @@
|
|||||||
transition: color var(--transition-fast);
|
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 {
|
.remote-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -144,8 +189,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn:disabled {
|
.toolbar-btn:disabled {
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn.icon-only {
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
min-width: 28px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hierarchy-content {
|
.hierarchy-content {
|
||||||
@@ -340,3 +391,38 @@
|
|||||||
transform: scale(1.05);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
--color-text-primary: #cccccc;
|
--color-text-primary: #cccccc;
|
||||||
--color-text-secondary: #9d9d9d;
|
--color-text-secondary: #9d9d9d;
|
||||||
--color-text-tertiary: #6a6a6a;
|
--color-text-tertiary: #6a6a6a;
|
||||||
--color-text-disabled: #4d4d4d;
|
--color-text-disabled: #aaaaaa;
|
||||||
--color-text-inverse: #ffffff;
|
--color-text-inverse: #ffffff;
|
||||||
|
|
||||||
/* 颜色系统 - 边框 */
|
/* 颜色系统 - 边框 */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react-swc';
|
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 fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -8,6 +10,34 @@ const host = process.env.TAURI_DEV_HOST;
|
|||||||
const userProjectPathMap = new Map<string, string>();
|
const userProjectPathMap = new Map<string, string>();
|
||||||
const editorPackageMapping = new Map<string, string>();
|
const editorPackageMapping = new Map<string, string>();
|
||||||
const editorPackageVersions = new Map<string, string>();
|
const editorPackageVersions = new Map<string, string>();
|
||||||
|
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() {
|
function loadEditorPackages() {
|
||||||
const packagesDir = path.resolve(__dirname, '..');
|
const packagesDir = path.resolve(__dirname, '..');
|
||||||
@@ -25,23 +55,89 @@ function loadEditorPackages() {
|
|||||||
if (fs.existsSync(packageJsonPath)) {
|
if (fs.existsSync(packageJsonPath)) {
|
||||||
try {
|
try {
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
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;
|
const mainFile = packageJson.module || packageJson.main;
|
||||||
if (mainFile) {
|
if (mainFile) {
|
||||||
const entryPath = path.join(packagesDir, dir, mainFile);
|
const entryPath = path.join(packagesDir, dir, mainFile);
|
||||||
if (fs.existsSync(entryPath)) {
|
if (fs.existsSync(entryPath)) {
|
||||||
editorPackageMapping.set(packageJson.name, entryPath);
|
editorPackageMapping.set(packageName, entryPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (packageJson.version) {
|
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) {
|
} catch (e) {
|
||||||
console.error(`[Vite] Failed to read package.json for ${dir}:`, 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();
|
loadEditorPackages();
|
||||||
@@ -581,6 +677,8 @@ module.exports = [
|
|||||||
inlineDynamicImports: true
|
inlineDynamicImports: true
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
wasm(),
|
||||||
|
topLevelAwait(),
|
||||||
resolve({
|
resolve({
|
||||||
extensions: ['.js', '.jsx']
|
extensions: ['.js', '.jsx']
|
||||||
}),
|
}),
|
||||||
@@ -607,6 +705,8 @@ module.exports = [
|
|||||||
format: 'es'
|
format: 'es'
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
wasm(),
|
||||||
|
topLevelAwait(),
|
||||||
dts({
|
dts({
|
||||||
respectExternal: true
|
respectExternal: true
|
||||||
})
|
})
|
||||||
@@ -638,6 +738,8 @@ module.exports = [
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
wasm(),
|
||||||
|
topLevelAwait(),
|
||||||
...react({
|
...react({
|
||||||
tsDecorators: true,
|
tsDecorators: true,
|
||||||
}),
|
}),
|
||||||
@@ -665,4 +767,11 @@ export default defineConfig({
|
|||||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||||
sourcemap: !!process.env.TAURI_DEBUG,
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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<Self> {
|
||||||
|
// 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::<HtmlCanvasElement>()
|
||||||
|
.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::<WebGl2RenderingContext>()
|
||||||
|
.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<Self> {
|
||||||
|
// Convert JsValue to WebGl2RenderingContext
|
||||||
|
let gl = gl_context
|
||||||
|
.dyn_into::<WebGl2RenderingContext>()
|
||||||
|
.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::<HtmlCanvasElement>()
|
||||||
|
.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> = std::result::Result<T, EngineError>;
|
||||||
@@ -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};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
|
||||||
|
/// Keys pressed this frame.
|
||||||
|
/// 本帧按下的键。
|
||||||
|
just_pressed: HashSet<String>,
|
||||||
|
|
||||||
|
/// Keys released this frame.
|
||||||
|
/// 本帧释放的键。
|
||||||
|
just_released: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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<Self> {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<i32, TouchPoint>,
|
||||||
|
|
||||||
|
/// Touch IDs that started this frame.
|
||||||
|
/// 本帧开始的触摸ID。
|
||||||
|
just_started: Vec<i32>,
|
||||||
|
|
||||||
|
/// Touch IDs that ended this frame.
|
||||||
|
/// 本帧结束的触摸ID。
|
||||||
|
just_ended: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Item = &TouchPoint> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GameEngine, JsValue> {
|
||||||
|
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<GameEngine, JsValue> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Color> for [f32; 4] {
|
||||||
|
#[inline]
|
||||||
|
fn from(c: Color) -> Self {
|
||||||
|
[c.r, c.g, c.b, c.a]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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<Rect> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<f32> 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<f32> 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::<web_sys::HtmlCanvasElement>() {
|
||||||
|
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<dyn FnMut()>) -> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Sprite batch rendering system.
|
||||||
|
//! 精灵批处理渲染系统。
|
||||||
|
|
||||||
|
mod sprite_batch;
|
||||||
|
mod vertex;
|
||||||
|
|
||||||
|
pub use sprite_batch::SpriteBatch;
|
||||||
|
pub use vertex::{SpriteVertex, VERTEX_SIZE};
|
||||||
@@ -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<f32>,
|
||||||
|
|
||||||
|
/// Current number of sprites in batch.
|
||||||
|
/// 当前批次中的精灵数。
|
||||||
|
sprite_count: usize,
|
||||||
|
|
||||||
|
/// Current texture ID being batched.
|
||||||
|
/// 当前正在批处理的纹理ID。
|
||||||
|
current_texture: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Self> {
|
||||||
|
// 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<u16> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::<SpriteVertex>();
|
||||||
|
|
||||||
|
/// 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],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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<Self> {
|
||||||
|
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::<web_sys::HtmlCanvasElement>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
@@ -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};
|
||||||
@@ -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<Self> {
|
||||||
|
// 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<WebGlShader> {
|
||||||
|
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<WebGlUniformLocation> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Texture management system.
|
||||||
|
//! 纹理管理系统。
|
||||||
|
|
||||||
|
mod texture;
|
||||||
|
mod texture_manager;
|
||||||
|
|
||||||
|
pub use texture::Texture;
|
||||||
|
pub use texture_manager::TextureManager;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u32, Texture>,
|
||||||
|
|
||||||
|
/// Default white texture for untextured rendering.
|
||||||
|
/// 用于无纹理渲染的默认白色纹理。
|
||||||
|
default_texture: Option<WebGlTexture>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<dyn Fn()>);
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
/// Unique identifier.
|
||||||
|
/// 唯一标识符。
|
||||||
|
id: HandleId,
|
||||||
|
|
||||||
|
/// Phantom data for type safety.
|
||||||
|
/// 用于类型安全的幻象数据。
|
||||||
|
_marker: std::marker::PhantomData<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Handle<T> {
|
||||||
|
/// 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<T> From<HandleId> for Handle<T> {
|
||||||
|
#[inline]
|
||||||
|
fn from(id: HandleId) -> Self {
|
||||||
|
Self::new(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> From<Handle<T>> for HandleId {
|
||||||
|
#[inline]
|
||||||
|
fn from(handle: Handle<T>) -> Self {
|
||||||
|
handle.id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//! Resource management system.
|
||||||
|
//! 资源管理系统。
|
||||||
|
|
||||||
|
mod handle;
|
||||||
|
|
||||||
|
pub use handle::{Handle, HandleId};
|
||||||
|
pub use crate::renderer::texture::{Texture, TextureManager};
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()]
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 存储子系统
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储信息
|
||||||
|
*/
|
||||||
|
export interface StorageInfo {
|
||||||
|
keys: string[];
|
||||||
|
currentSize: number;
|
||||||
|
limitSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储子系统接口
|
||||||
|
*/
|
||||||
|
export interface IPlatformStorageSubsystem {
|
||||||
|
/**
|
||||||
|
* 同步获取存储
|
||||||
|
*/
|
||||||
|
getStorageSync<T = any>(key: string): T | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步设置存储
|
||||||
|
*/
|
||||||
|
setStorageSync<T = any>(key: string, value: T): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步移除存储
|
||||||
|
*/
|
||||||
|
removeStorageSync(key: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步清空存储
|
||||||
|
*/
|
||||||
|
clearStorageSync(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取存储信息
|
||||||
|
*/
|
||||||
|
getStorageInfoSync(): StorageInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步获取存储
|
||||||
|
*/
|
||||||
|
getStorage<T = any>(key: string): Promise<T | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步设置存储
|
||||||
|
*/
|
||||||
|
setStorage<T = any>(key: string, value: T): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步移除存储
|
||||||
|
*/
|
||||||
|
removeStorage(key: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步清空存储
|
||||||
|
*/
|
||||||
|
clearStorage(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 网络子系统
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求配置
|
||||||
|
*/
|
||||||
|
export interface RequestConfig {
|
||||||
|
url: string;
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE' | 'CONNECT';
|
||||||
|
data?: any;
|
||||||
|
header?: Record<string, string>;
|
||||||
|
timeout?: number;
|
||||||
|
dataType?: 'json' | 'text' | 'arraybuffer';
|
||||||
|
responseType?: 'text' | 'arraybuffer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求响应
|
||||||
|
*/
|
||||||
|
export interface RequestResponse<T = any> {
|
||||||
|
data: T;
|
||||||
|
statusCode: number;
|
||||||
|
header: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载任务
|
||||||
|
*/
|
||||||
|
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<string, string> }) => 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<T = any>(config: RequestConfig): Promise<RequestResponse<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
*/
|
||||||
|
downloadFile(options: {
|
||||||
|
url: string;
|
||||||
|
filePath?: string;
|
||||||
|
header?: Record<string, string>;
|
||||||
|
timeout?: number;
|
||||||
|
}): Promise<{ tempFilePath: string; filePath?: string; statusCode: number }> & IDownloadTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
*/
|
||||||
|
uploadFile(options: {
|
||||||
|
url: string;
|
||||||
|
filePath: string;
|
||||||
|
name: string;
|
||||||
|
header?: Record<string, string>;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
timeout?: number;
|
||||||
|
}): Promise<{ data: string; statusCode: number }> & IUploadTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 WebSocket 连接
|
||||||
|
*/
|
||||||
|
connectSocket(options: {
|
||||||
|
url: string;
|
||||||
|
header?: Record<string, string>;
|
||||||
|
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<string | ArrayBuffer>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步读取文件
|
||||||
|
*/
|
||||||
|
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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步写入文件
|
||||||
|
*/
|
||||||
|
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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件
|
||||||
|
*/
|
||||||
|
unlink(filePath: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建目录
|
||||||
|
*/
|
||||||
|
mkdir(options: {
|
||||||
|
dirPath: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除目录
|
||||||
|
*/
|
||||||
|
rmdir(options: {
|
||||||
|
dirPath: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取目录
|
||||||
|
*/
|
||||||
|
readdir(dirPath: string): Promise<string[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件信息
|
||||||
|
*/
|
||||||
|
stat(path: string): Promise<FileInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件/目录是否存在
|
||||||
|
*/
|
||||||
|
access(path: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件
|
||||||
|
*/
|
||||||
|
rename(oldPath: string, newPath: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文件
|
||||||
|
*/
|
||||||
|
copyFile(srcPath: string, destPath: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户数据目录路径
|
||||||
|
*/
|
||||||
|
getUserDataPath(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解压文件
|
||||||
|
*/
|
||||||
|
unzip?(options: {
|
||||||
|
zipFilePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WASM 子系统
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WASM 模块导出
|
||||||
|
*/
|
||||||
|
export type WASMExports = Record<string, WebAssembly.ExportValue>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WASM 导入值类型(兼容 Web 和小游戏平台)
|
||||||
|
*/
|
||||||
|
export type WASMImportValue = WebAssembly.ExportValue | number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WASM 模块导入
|
||||||
|
*/
|
||||||
|
export type WASMImports = Record<string, Record<string, WASMImportValue>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WASM 实例
|
||||||
|
*/
|
||||||
|
export interface IWASMInstance {
|
||||||
|
exports: WASMExports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WASM 子系统接口
|
||||||
|
*/
|
||||||
|
export interface IPlatformWASMSubsystem {
|
||||||
|
/**
|
||||||
|
* 实例化 WASM 模块
|
||||||
|
* @param path WASM 文件路径
|
||||||
|
* @param imports 导入对象
|
||||||
|
*/
|
||||||
|
instantiate(path: string, imports?: WASMImports): Promise<IWASMInstance>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否支持 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()]
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Web 平台输入子系统
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IPlatformInputSubsystem,
|
||||||
|
TouchHandler,
|
||||||
|
TouchEvent
|
||||||
|
} from '@esengine/platform-common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web 平台输入子系统实现
|
||||||
|
*/
|
||||||
|
export class WebInputSubsystem implements IPlatformInputSubsystem {
|
||||||
|
private _touchStartHandlers: Map<TouchHandler, (e: globalThis.TouchEvent) => void> = new Map();
|
||||||
|
private _touchMoveHandlers: Map<TouchHandler, (e: globalThis.TouchEvent) => void> = new Map();
|
||||||
|
private _touchEndHandlers: Map<TouchHandler, (e: globalThis.TouchEvent) => void> = new Map();
|
||||||
|
private _touchCancelHandlers: Map<TouchHandler, (e: globalThis.TouchEvent) => 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Web 平台存储子系统
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IPlatformStorageSubsystem,
|
||||||
|
StorageInfo
|
||||||
|
} from '@esengine/platform-common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web 平台存储子系统实现
|
||||||
|
*/
|
||||||
|
export class WebStorageSubsystem implements IPlatformStorageSubsystem {
|
||||||
|
getStorageSync<T = any>(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<T = any>(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<T = any>(key: string): Promise<T | undefined> {
|
||||||
|
return this.getStorageSync<T>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStorage<T = any>(key: string, value: T): Promise<void> {
|
||||||
|
this.setStorageSync(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeStorage(key: string): Promise<void> {
|
||||||
|
this.removeStorageSync(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearStorage(): Promise<void> {
|
||||||
|
this.clearStorageSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IWASMInstance> {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()]
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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<void> {
|
||||||
|
// 获取 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<string, Record<string, any>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PlatformConfig> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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<void> {
|
||||||
|
return promisify(getWx().setInnerAudioOption.bind(getWx()), options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string | ArrayBuffer> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._fs.unlink({
|
||||||
|
filePath,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(options: {
|
||||||
|
dirPath: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._fs.rmdir({
|
||||||
|
dirPath: options.dirPath,
|
||||||
|
recursive: options.recursive,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async readdir(dirPath: string): Promise<string[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._fs.readdir({
|
||||||
|
dirPath,
|
||||||
|
success: (res) => resolve(res.files),
|
||||||
|
fail: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(path: string): Promise<FileInfo> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._fs.access({
|
||||||
|
path,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rename(oldPath: string, newPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._fs.rename({
|
||||||
|
oldPath,
|
||||||
|
newPath,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyFile(srcPath: string, destPath: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._fs.unzip({
|
||||||
|
zipFilePath: options.zipFilePath,
|
||||||
|
targetPath: options.targetPath,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user