feat: 集成Rust WASM渲染引擎与TypeScript ECS框架 (#228)
* feat: 集成Rust WASM渲染引擎与TypeScript ECS框架 * feat: 增强编辑器UI功能与跨平台支持 * fix: 修复CI测试和类型检查问题 * fix: 修复CI问题并提高测试覆盖率 * fix: 修复CI问题并提高测试覆盖率
This commit is contained in:
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -37,15 +37,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build core package first
|
||||
run: npm run build:core
|
||||
|
||||
- name: Build platform-common for type declarations
|
||||
run: cd packages/platform-common && npm run build
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Lint check
|
||||
run: npm run lint
|
||||
|
||||
- name: Build core package first
|
||||
run: npm run build:core
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:ci
|
||||
|
||||
|
||||
189
package-lock.json
generated
189
package-lock.json
generated
@@ -3256,6 +3256,10 @@
|
||||
"resolved": "packages/behavior-tree-editor",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esengine/ecs-engine-bindgen": {
|
||||
"resolved": "packages/ecs-engine-bindgen",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esengine/ecs-framework": {
|
||||
"resolved": "packages/core",
|
||||
"link": true
|
||||
@@ -3272,6 +3276,10 @@
|
||||
"resolved": "packages/editor-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esengine/engine": {
|
||||
"resolved": "packages/engine",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esengine/network-client": {
|
||||
"resolved": "packages/network-client",
|
||||
"link": true
|
||||
@@ -3284,6 +3292,18 @@
|
||||
"resolved": "packages/network-shared",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esengine/platform-common": {
|
||||
"resolved": "packages/platform-common",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esengine/platform-web": {
|
||||
"resolved": "packages/platform-web",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esengine/platform-wechat": {
|
||||
"resolved": "packages/platform-wechat",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
@@ -6010,6 +6030,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-typescript": {
|
||||
"version": "11.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.6.tgz",
|
||||
"integrity": "sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.1.0",
|
||||
"resolve": "^1.22.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^2.14.0||^3.0.0||^4.0.0",
|
||||
"tslib": "*",
|
||||
"typescript": ">=3.7.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"tslib": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-virtual": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz",
|
||||
"integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
||||
@@ -8211,6 +8276,13 @@
|
||||
"@swc/counter": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/wasm": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.2.tgz",
|
||||
"integrity": "sha512-m9uPmG1M4uHCKN2hMKGWH+wy1S/ULoP8ojH967GIFPjSvxqm32rw7DGAIP0vBLc4UKBux9hJtTiwkgFqM79XhQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.8.0.tgz",
|
||||
@@ -12541,6 +12613,10 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-engine": {
|
||||
"resolved": "packages/engine/pkg",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
@@ -28384,6 +28460,32 @@
|
||||
"vite": "^7 || ^6 || ^5"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-top-level-await": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz",
|
||||
"integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/plugin-virtual": "^3.0.2",
|
||||
"@swc/core": "^1.12.14",
|
||||
"@swc/wasm": "^1.12.14",
|
||||
"uuid": "10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": ">=2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-wasm": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz",
|
||||
"integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/vitepress": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz",
|
||||
@@ -29184,14 +29286,30 @@
|
||||
"typescript-eslint": "^8.46.1"
|
||||
}
|
||||
},
|
||||
"packages/ecs-engine-bindgen": {
|
||||
"name": "@esengine/ecs-engine-bindgen",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@esengine/ecs-framework": "file:../core"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"es-engine": "file:../engine/pkg"
|
||||
}
|
||||
},
|
||||
"packages/editor-app": {
|
||||
"name": "@esengine/editor-app",
|
||||
"version": "1.0.5",
|
||||
"dependencies": {
|
||||
"@esengine/behavior-tree": "file:../behavior-tree",
|
||||
"@esengine/behavior-tree-editor": "file:../behavior-tree-editor",
|
||||
"@esengine/ecs-engine-bindgen": "file:../ecs-engine-bindgen",
|
||||
"@esengine/ecs-framework": "file:../core",
|
||||
"@esengine/editor-core": "file:../editor-core",
|
||||
"@esengine/engine": "file:../engine",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
@@ -29224,7 +29342,9 @@
|
||||
"sharp": "^0.34.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-swc-transform": "^1.1.1"
|
||||
"vite-plugin-swc-transform": "^1.1.1",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"packages/editor-app/node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -29784,6 +29904,20 @@
|
||||
"@esengine/ecs-framework": "^2.2.8"
|
||||
}
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@esengine/engine",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"rimraf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"packages/engine/pkg": {
|
||||
"name": "es-engine",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"packages/math": {
|
||||
"name": "@esengine/ecs-framework-math",
|
||||
"version": "1.0.5",
|
||||
@@ -29895,6 +30029,57 @@
|
||||
"ts-jest": "^29.4.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"packages/platform-common": {
|
||||
"name": "@esengine/platform-common",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"rimraf": "^5.0.0",
|
||||
"rollup": "^4.42.0",
|
||||
"rollup-plugin-dts": "^6.2.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"packages/platform-web": {
|
||||
"name": "@esengine/platform-web",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"rimraf": "^5.0.0",
|
||||
"rollup": "^4.42.0",
|
||||
"rollup-plugin-dts": "^6.2.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/platform-common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"packages/platform-wechat": {
|
||||
"name": "@esengine/platform-wechat",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"minigame-api-typings": "^3.8.12",
|
||||
"rimraf": "^5.0.0",
|
||||
"rollup": "^4.42.0",
|
||||
"rollup-plugin-dts": "^6.2.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/ecs-framework": "^2.0.0",
|
||||
"@esengine/platform-common": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"test:coverage": "jest --coverage --config jest.config.cjs",
|
||||
"test:ci": "jest --ci --coverage --config jest.config.cjs",
|
||||
"test:clear": "jest --clearCache",
|
||||
"type-check": "tsc --noEmit",
|
||||
"type-check": "npx tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IComponent } from '../Types';
|
||||
import { Int32 } from './Core/SoAStorage';
|
||||
|
||||
/**
|
||||
* 游戏组件基类
|
||||
@@ -50,6 +51,7 @@ export abstract class Component implements IComponent {
|
||||
*
|
||||
* 存储实体ID而非引用,避免循环引用,符合ECS数据导向设计。
|
||||
*/
|
||||
@Int32
|
||||
public entityId: number | null = null;
|
||||
|
||||
/**
|
||||
|
||||
108
packages/core/src/ECS/Core/SoASerializer.ts
Normal file
108
packages/core/src/ECS/Core/SoASerializer.ts
Normal file
@@ -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 { ComponentType } from './ComponentStorage';
|
||||
import { createLogger } from '../../Utils/Logger';
|
||||
import {
|
||||
SoATypeRegistry,
|
||||
SupportedTypedArray,
|
||||
TypedArrayTypeName
|
||||
} from './SoATypeRegistry';
|
||||
import { SoASerializer } from './SoASerializer';
|
||||
|
||||
// 重新导出类型,保持向后兼容
|
||||
export { SupportedTypedArray, TypedArrayTypeName } from './SoATypeRegistry';
|
||||
export { SoATypeRegistry } from './SoATypeRegistry';
|
||||
export { SoASerializer } from './SoASerializer';
|
||||
|
||||
/**
|
||||
* 启用SoA优化装饰器
|
||||
@@ -12,18 +22,6 @@ export function EnableSoA<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位浮点数装饰器
|
||||
* 标记字段使用Float64Array存储(更高精度但更多内存)
|
||||
@@ -181,164 +179,16 @@ export function DeepCopy(target: any, propertyKey: string | symbol): void {
|
||||
target.constructor.__deepCopyFields.add(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动类型推断装饰器
|
||||
* 根据字段的默认值和数值范围自动选择最优的TypedArray类型
|
||||
*
|
||||
* @param options 类型推断选项
|
||||
* @param options.minValue 数值的最小值(用于范围优化)
|
||||
* @param options.maxValue 数值的最大值(用于范围优化)
|
||||
* @param options.precision 是否需要浮点精度(true: 使用浮点数组, false: 使用整数数组)
|
||||
* @param options.signed 是否需要符号位(仅在整数模式下有效)
|
||||
*/
|
||||
export function AutoTyped(options?: {
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
precision?: boolean;
|
||||
signed?: boolean;
|
||||
}) {
|
||||
return function (target: any, propertyKey: string | symbol): void {
|
||||
const key = String(propertyKey);
|
||||
if (!target.constructor.__autoTypedFields) {
|
||||
target.constructor.__autoTypedFields = new Map();
|
||||
}
|
||||
target.constructor.__autoTypedFields.set(key, options || {});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动类型推断器
|
||||
* 根据数值类型和范围自动选择最优的TypedArray类型
|
||||
*/
|
||||
export class TypeInference {
|
||||
/**
|
||||
* 根据数值范围推断最优的TypedArray类型
|
||||
*/
|
||||
public static inferOptimalType(value: any, options: {
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
precision?: boolean;
|
||||
signed?: boolean;
|
||||
} = {}): string {
|
||||
const type = typeof value;
|
||||
|
||||
if (type === 'boolean') {
|
||||
return 'uint8'; // 布尔值使用最小的无符号整数
|
||||
}
|
||||
|
||||
if (type !== 'number') {
|
||||
return 'float32'; // 非数值类型默认使用Float32
|
||||
}
|
||||
|
||||
const { minValue, maxValue, precision, signed } = options;
|
||||
|
||||
// 如果显式要求精度,使用浮点数
|
||||
if (precision === true) {
|
||||
// 检查是否需要双精度
|
||||
if (Math.abs(value) > 3.4028235e+38 || (minValue !== undefined && Math.abs(minValue) > 3.4028235e+38) || (maxValue !== undefined && Math.abs(maxValue) > 3.4028235e+38)) {
|
||||
return 'float64';
|
||||
}
|
||||
return 'float32';
|
||||
}
|
||||
|
||||
// 如果显式禁用精度,或者是整数值,尝试使用整数数组
|
||||
if (precision === false || Number.isInteger(value)) {
|
||||
const actualMin = minValue !== undefined ? minValue : value;
|
||||
const actualMax = maxValue !== undefined ? maxValue : value;
|
||||
const needsSigned = signed !== false && (actualMin < 0 || value < 0);
|
||||
|
||||
// 根据范围选择最小的整数类型
|
||||
if (needsSigned) {
|
||||
// 有符号整数
|
||||
if (actualMin >= -128 && actualMax <= 127) {
|
||||
return 'int8';
|
||||
} else if (actualMin >= -32768 && actualMax <= 32767) {
|
||||
return 'int16';
|
||||
} else if (actualMin >= -2147483648 && actualMax <= 2147483647) {
|
||||
return 'int32';
|
||||
} else {
|
||||
return 'float64'; // 超出int32范围,使用双精度浮点
|
||||
}
|
||||
} else {
|
||||
// 无符号整数
|
||||
if (actualMax <= 255) {
|
||||
return 'uint8';
|
||||
} else if (actualMax <= 65535) {
|
||||
return 'uint16';
|
||||
} else if (actualMax <= 4294967295) {
|
||||
return 'uint32';
|
||||
} else {
|
||||
return 'float64'; // 超出uint32范围,使用双精度浮点
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认情况:检查是否为小数
|
||||
if (!Number.isInteger(value)) {
|
||||
return 'float32';
|
||||
}
|
||||
|
||||
// 整数值,但没有指定范围,根据值的大小选择
|
||||
if (value >= 0 && value <= 255) {
|
||||
return 'uint8';
|
||||
} else if (value >= -128 && value <= 127) {
|
||||
return 'int8';
|
||||
} else if (value >= 0 && value <= 65535) {
|
||||
return 'uint16';
|
||||
} else if (value >= -32768 && value <= 32767) {
|
||||
return 'int16';
|
||||
} else if (value >= 0 && value <= 4294967295) {
|
||||
return 'uint32';
|
||||
} else if (value >= -2147483648 && value <= 2147483647) {
|
||||
return 'int32';
|
||||
} else {
|
||||
return 'float64';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据推断的类型名创建对应的TypedArray构造函数
|
||||
*/
|
||||
public static getTypedArrayConstructor(typeName: string): typeof Float32Array | typeof Float64Array | typeof Int32Array | typeof Uint32Array | typeof Int16Array | typeof Uint16Array | typeof Int8Array | typeof Uint8Array | typeof Uint8ClampedArray {
|
||||
switch (typeName) {
|
||||
case 'float32': return Float32Array;
|
||||
case 'float64': return Float64Array;
|
||||
case 'int32': return Int32Array;
|
||||
case 'uint32': return Uint32Array;
|
||||
case 'int16': return Int16Array;
|
||||
case 'uint16': return Uint16Array;
|
||||
case 'int8': return Int8Array;
|
||||
case 'uint8': return Uint8Array;
|
||||
case 'uint8clamped': return Uint8ClampedArray;
|
||||
default: return Float32Array;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SoA存储器支持的TypedArray类型
|
||||
*/
|
||||
export type SupportedTypedArray =
|
||||
| Float32Array
|
||||
| Float64Array
|
||||
| Int32Array
|
||||
| Uint32Array
|
||||
| Int16Array
|
||||
| Uint16Array
|
||||
| Int8Array
|
||||
| Uint8Array
|
||||
| Uint8ClampedArray;
|
||||
|
||||
/**
|
||||
* SoA存储器(需要装饰器启用)
|
||||
* 使用Structure of Arrays存储模式,在大规模批量操作时提供优异性能
|
||||
*/
|
||||
export class SoAStorage<T extends Component> {
|
||||
private static readonly _logger = createLogger('SoAStorage');
|
||||
private fields = new Map<string, SupportedTypedArray>();
|
||||
private stringFields = new Map<string, string[]>(); // 专门存储字符串
|
||||
private serializedFields = new Map<string, string[]>(); // 序列化存储Map/Set/Array
|
||||
private complexFields = new Map<number, Map<string, any>>(); // 存储复杂对象
|
||||
private stringFields = new Map<string, string[]>();
|
||||
private serializedFields = new Map<string, string[]>();
|
||||
private complexFields = new Map<number, Map<string, unknown>>();
|
||||
private entityToIndex = new Map<number, number>();
|
||||
private indexToEntity: number[] = [];
|
||||
private freeIndices: number[] = [];
|
||||
@@ -346,6 +196,13 @@ export class SoAStorage<T extends Component> {
|
||||
private _capacity = 1000;
|
||||
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>) {
|
||||
this.type = componentType;
|
||||
this.initializeFields(componentType);
|
||||
@@ -353,88 +210,85 @@ export class SoAStorage<T extends Component> {
|
||||
|
||||
private initializeFields(componentType: ComponentType<T>): void {
|
||||
const instance = new componentType();
|
||||
const highPrecisionFields = (componentType as any).__highPrecisionFields || new Set();
|
||||
const float64Fields = (componentType as any).__float64Fields || new Set();
|
||||
const float32Fields = (componentType as any).__float32Fields || new Set();
|
||||
const int32Fields = (componentType as any).__int32Fields || new Set();
|
||||
const uint32Fields = (componentType as any).__uint32Fields || new Set();
|
||||
const int16Fields = (componentType as any).__int16Fields || new Set();
|
||||
const uint16Fields = (componentType as any).__uint16Fields || new Set();
|
||||
const int8Fields = (componentType as any).__int8Fields || new Set();
|
||||
const uint8Fields = (componentType as any).__uint8Fields || new Set();
|
||||
const uint8ClampedFields = (componentType as any).__uint8ClampedFields || new Set();
|
||||
const autoTypedFields = (componentType as any).__autoTypedFields || new Map();
|
||||
const serializeMapFields = (componentType as any).__serializeMapFields || new Set();
|
||||
const serializeSetFields = (componentType as any).__serializeSetFields || new Set();
|
||||
const serializeArrayFields = (componentType as any).__serializeArrayFields || new Set();
|
||||
// const deepCopyFields = (componentType as any).__deepCopyFields || new Set(); // 未使用,但保留供future使用
|
||||
const typeWithMeta = componentType as ComponentType<T> & {
|
||||
__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>;
|
||||
};
|
||||
|
||||
for (const key in instance) {
|
||||
if (instance.hasOwnProperty(key) && key !== 'id') {
|
||||
const value = (instance as any)[key];
|
||||
const type = typeof value;
|
||||
const float64Fields = typeWithMeta.__float64Fields || new Set<string>();
|
||||
const float32Fields = typeWithMeta.__float32Fields || new Set<string>();
|
||||
const int32Fields = typeWithMeta.__int32Fields || new Set<string>();
|
||||
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)) {
|
||||
// 标记为高精度,作为复杂对象处理
|
||||
// 不添加到fields,会在updateComponentAtIndex中自动添加到complexFields
|
||||
} else if (autoTypedFields.has(key)) {
|
||||
// 使用自动类型推断
|
||||
const options = autoTypedFields.get(key);
|
||||
const inferredType = TypeInference.inferOptimalType(value, options);
|
||||
const ArrayConstructor = TypeInference.getTypedArrayConstructor(inferredType);
|
||||
this.fields.set(key, new ArrayConstructor(this._capacity));
|
||||
SoAStorage._logger.info(`字段 ${key} 自动推断为 ${inferredType} 类型,值: ${value}, 选项:`, options);
|
||||
} else if (float64Fields.has(key)) {
|
||||
// 使用Float64Array存储(高精度浮点数)
|
||||
this.fields.set(key, new Float64Array(this._capacity));
|
||||
} else if (int32Fields.has(key)) {
|
||||
// 使用Int32Array存储(32位有符号整数)
|
||||
this.fields.set(key, new Int32Array(this._capacity));
|
||||
} else if (uint32Fields.has(key)) {
|
||||
// 使用Uint32Array存储(32位无符号整数)
|
||||
this.fields.set(key, new Uint32Array(this._capacity));
|
||||
} else if (int16Fields.has(key)) {
|
||||
// 使用Int16Array存储(16位有符号整数)
|
||||
this.fields.set(key, new Int16Array(this._capacity));
|
||||
} else if (uint16Fields.has(key)) {
|
||||
// 使用Uint16Array存储(16位无符号整数)
|
||||
this.fields.set(key, new Uint16Array(this._capacity));
|
||||
} else if (int8Fields.has(key)) {
|
||||
// 使用Int8Array存储(8位有符号整数)
|
||||
this.fields.set(key, new Int8Array(this._capacity));
|
||||
} else if (uint8Fields.has(key)) {
|
||||
// 使用Uint8Array存储(8位无符号整数)
|
||||
this.fields.set(key, new Uint8Array(this._capacity));
|
||||
} else if (uint8ClampedFields.has(key)) {
|
||||
// 使用Uint8ClampedArray存储(8位夹紧无符号整数)
|
||||
this.fields.set(key, new Uint8ClampedArray(this._capacity));
|
||||
} else if (float32Fields.has(key)) {
|
||||
// 使用Float32Array存储(32位浮点数)
|
||||
this.fields.set(key, new Float32Array(this._capacity));
|
||||
} else {
|
||||
// 默认使用Float32Array
|
||||
this.fields.set(key, new Float32Array(this._capacity));
|
||||
}
|
||||
// 缓存装饰器元数据
|
||||
this.serializeMapFields = typeWithMeta.__serializeMapFields || new Set<string>();
|
||||
this.serializeSetFields = typeWithMeta.__serializeSetFields || new Set<string>();
|
||||
this.serializeArrayFields = typeWithMeta.__serializeArrayFields || new Set<string>();
|
||||
|
||||
// 先收集所有有装饰器的字段,避免重复遍历
|
||||
const decoratedFields = new Map<string, string>(); // fieldName -> arrayType
|
||||
|
||||
// 处理各类型装饰器标记的字段
|
||||
for (const key of float64Fields) decoratedFields.set(key, 'float64');
|
||||
for (const key of float32Fields) decoratedFields.set(key, 'float32');
|
||||
for (const key of int32Fields) decoratedFields.set(key, 'int32');
|
||||
for (const key of uint32Fields) decoratedFields.set(key, 'uint32');
|
||||
for (const key of int16Fields) decoratedFields.set(key, 'int16');
|
||||
for (const key of uint16Fields) decoratedFields.set(key, 'uint16');
|
||||
for (const key of int8Fields) decoratedFields.set(key, 'int8');
|
||||
for (const key of uint8Fields) decoratedFields.set(key, 'uint8');
|
||||
for (const key of uint8ClampedFields) decoratedFields.set(key, 'uint8clamped');
|
||||
|
||||
// 只遍历实例自身的属性(不包括原型链),跳过 id
|
||||
const instanceKeys = Object.keys(instance).filter(key => key !== 'id');
|
||||
|
||||
for (const key of instanceKeys) {
|
||||
const value = (instance as Record<string, unknown>)[key];
|
||||
const type = typeof value;
|
||||
|
||||
// 跳过函数(通常不会出现在 Object.keys 中,但以防万一)
|
||||
if (type === 'function') continue;
|
||||
|
||||
// 检查装饰器类型
|
||||
const decoratorType = decoratedFields.get(key);
|
||||
const effectiveType = decoratorType ? 'number' : type;
|
||||
this.fieldTypes.set(key, effectiveType);
|
||||
|
||||
if (decoratorType) {
|
||||
// 有装饰器标记的数字字段
|
||||
const ArrayConstructor = SoATypeRegistry.getConstructor(decoratorType as TypedArrayTypeName);
|
||||
this.fields.set(key, new ArrayConstructor(this._capacity));
|
||||
} else if (type === 'number') {
|
||||
// 无装饰器的数字字段,默认使用 Float32Array
|
||||
this.fields.set(key, new Float32Array(this._capacity));
|
||||
} else if (type === 'boolean') {
|
||||
// 布尔值默认使用Uint8Array存储为0/1(更节省内存)
|
||||
if (uint8Fields.has(key) || (!float32Fields.has(key) && !float64Fields.has(key))) {
|
||||
this.fields.set(key, new Uint8Array(this._capacity));
|
||||
} else {
|
||||
// 兼容性:如果显式指定浮点类型则使用原有方式
|
||||
this.fields.set(key, new Float32Array(this._capacity));
|
||||
}
|
||||
// 布尔值使用 Uint8Array 存储为 0/1
|
||||
this.fields.set(key, new Uint8Array(this._capacity));
|
||||
} else if (type === 'string') {
|
||||
// 字符串专门处理
|
||||
this.stringFields.set(key, new Array(this._capacity));
|
||||
} else if (type === 'object' && value !== null) {
|
||||
// 处理集合类型
|
||||
if (serializeMapFields.has(key) || serializeSetFields.has(key) || serializeArrayFields.has(key)) {
|
||||
if (this.serializeMapFields.has(key) || this.serializeSetFields.has(key) || this.serializeArrayFields.has(key)) {
|
||||
// 序列化存储
|
||||
this.serializedFields.set(key, new Array(this._capacity));
|
||||
}
|
||||
// 其他对象类型会在updateComponentAtIndex中作为复杂对象处理
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,12 +351,16 @@ export class SoAStorage<T extends Component> {
|
||||
} else if (this.serializedFields.has(key)) {
|
||||
// 序列化字段处理
|
||||
const serializedArray = this.serializedFields.get(key)!;
|
||||
serializedArray[index] = this.serializeValue(value, key, serializeMapFields, serializeSetFields, serializeArrayFields);
|
||||
serializedArray[index] = SoASerializer.serialize(value, key, {
|
||||
isMap: serializeMapFields.has(key),
|
||||
isSet: serializeSetFields.has(key),
|
||||
isArray: serializeArrayFields.has(key)
|
||||
});
|
||||
} else {
|
||||
// 复杂字段单独存储
|
||||
if (deepCopyFields.has(key)) {
|
||||
// 深拷贝处理
|
||||
complexFieldMap.set(key, this.deepClone(value));
|
||||
complexFieldMap.set(key, SoASerializer.deepClone(value));
|
||||
} else {
|
||||
complexFieldMap.set(key, value);
|
||||
}
|
||||
@@ -516,96 +374,6 @@ export class SoAStorage<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 {
|
||||
const index = this.entityToIndex.get(entityId);
|
||||
@@ -613,11 +381,162 @@ export class SoAStorage<T extends Component> {
|
||||
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 serializeMapFields = (this.type as any).__serializeMapFields || new Set();
|
||||
const serializeSetFields = (this.type as any).__serializeSetFields || new Set();
|
||||
const serializeArrayFields = (this.type as any).__serializeArrayFields || new Set();
|
||||
|
||||
// 恢复数值字段
|
||||
for (const [fieldName, array] of this.fields.entries()) {
|
||||
@@ -640,7 +559,11 @@ export class SoAStorage<T extends Component> {
|
||||
for (const [fieldName, serializedArray] of this.serializedFields.entries()) {
|
||||
const serialized = serializedArray[index];
|
||||
if (serialized) {
|
||||
component[fieldName] = this.deserializeValue(serialized, fieldName, serializeMapFields, serializeSetFields, serializeArrayFields);
|
||||
component[fieldName] = SoASerializer.deserialize(serialized, fieldName, {
|
||||
isMap: this.serializeMapFields.has(fieldName),
|
||||
isSet: this.serializeSetFields.has(fieldName),
|
||||
isArray: this.serializeArrayFields.has(fieldName)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,10 +579,8 @@ export class SoAStorage<T extends Component> {
|
||||
}
|
||||
|
||||
private getFieldType(fieldName: string): string {
|
||||
// 通过创建临时实例检查字段类型
|
||||
const tempInstance = new this.type();
|
||||
const value = (tempInstance as any)[fieldName];
|
||||
return typeof value;
|
||||
// 使用缓存的字段类型
|
||||
return this.fieldTypes.get(fieldName) || 'unknown';
|
||||
}
|
||||
|
||||
public hasComponent(entityId: number): boolean {
|
||||
@@ -687,32 +608,7 @@ export class SoAStorage<T extends Component> {
|
||||
private resize(newCapacity: number): void {
|
||||
// 调整数值字段的TypedArray
|
||||
for (const [fieldName, oldArray] of this.fields.entries()) {
|
||||
let newArray: SupportedTypedArray;
|
||||
|
||||
if (oldArray instanceof Float32Array) {
|
||||
newArray = new Float32Array(newCapacity);
|
||||
} else if (oldArray instanceof Float64Array) {
|
||||
newArray = new Float64Array(newCapacity);
|
||||
} else if (oldArray instanceof Int32Array) {
|
||||
newArray = new Int32Array(newCapacity);
|
||||
} else if (oldArray instanceof Uint32Array) {
|
||||
newArray = new Uint32Array(newCapacity);
|
||||
} else if (oldArray instanceof Int16Array) {
|
||||
newArray = new Int16Array(newCapacity);
|
||||
} else if (oldArray instanceof Uint16Array) {
|
||||
newArray = new Uint16Array(newCapacity);
|
||||
} else if (oldArray instanceof Int8Array) {
|
||||
newArray = new Int8Array(newCapacity);
|
||||
} else if (oldArray instanceof Uint8Array) {
|
||||
newArray = new Uint8Array(newCapacity);
|
||||
} else if (oldArray instanceof Uint8ClampedArray) {
|
||||
newArray = new Uint8ClampedArray(newCapacity);
|
||||
} else {
|
||||
// 默认回退到Float32Array
|
||||
newArray = new Float32Array(newCapacity);
|
||||
SoAStorage._logger.warn(`未知的TypedArray类型用于字段 ${fieldName},回退到Float32Array`);
|
||||
}
|
||||
|
||||
const newArray = SoATypeRegistry.createSameType(oldArray, newCapacity);
|
||||
newArray.set(oldArray);
|
||||
this.fields.set(fieldName, newArray);
|
||||
}
|
||||
@@ -849,42 +745,8 @@ export class SoAStorage<T extends Component> {
|
||||
const fieldStats = new Map<string, any>();
|
||||
|
||||
for (const [fieldName, array] of this.fields.entries()) {
|
||||
let bytesPerElement: number;
|
||||
let typeName: string;
|
||||
|
||||
if (array instanceof Float32Array) {
|
||||
bytesPerElement = 4;
|
||||
typeName = 'float32';
|
||||
} else if (array instanceof Float64Array) {
|
||||
bytesPerElement = 8;
|
||||
typeName = 'float64';
|
||||
} else if (array instanceof Int32Array) {
|
||||
bytesPerElement = 4;
|
||||
typeName = 'int32';
|
||||
} else if (array instanceof Uint32Array) {
|
||||
bytesPerElement = 4;
|
||||
typeName = 'uint32';
|
||||
} else if (array instanceof Int16Array) {
|
||||
bytesPerElement = 2;
|
||||
typeName = 'int16';
|
||||
} else if (array instanceof Uint16Array) {
|
||||
bytesPerElement = 2;
|
||||
typeName = 'uint16';
|
||||
} else if (array instanceof Int8Array) {
|
||||
bytesPerElement = 1;
|
||||
typeName = 'int8';
|
||||
} else if (array instanceof Uint8Array) {
|
||||
bytesPerElement = 1;
|
||||
typeName = 'uint8';
|
||||
} else if (array instanceof Uint8ClampedArray) {
|
||||
bytesPerElement = 1;
|
||||
typeName = 'uint8clamped';
|
||||
} else {
|
||||
// 默认回退
|
||||
bytesPerElement = 4;
|
||||
typeName = 'unknown';
|
||||
}
|
||||
|
||||
const typeName = SoATypeRegistry.getTypeName(array);
|
||||
const bytesPerElement = SoATypeRegistry.getBytesPerElement(typeName);
|
||||
const memory = array.length * bytesPerElement;
|
||||
totalMemory += memory;
|
||||
|
||||
@@ -914,4 +776,5 @@ export class SoAStorage<T extends Component> {
|
||||
const activeIndices = this.getActiveIndices();
|
||||
operation(this.fields, activeIndices);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
208
packages/core/src/ECS/Core/SoATypeRegistry.ts
Normal file
208
packages/core/src/ECS/Core/SoATypeRegistry.ts
Normal file
@@ -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 { 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,
|
||||
|
||||
// 数值类型装饰器
|
||||
HighPrecision,
|
||||
Float64,
|
||||
Float32,
|
||||
Int32,
|
||||
@@ -34,10 +33,6 @@ export {
|
||||
Uint8,
|
||||
Uint8Clamped,
|
||||
|
||||
// 自动类型推断
|
||||
AutoTyped,
|
||||
TypeInference,
|
||||
|
||||
// 序列化装饰器
|
||||
SerializeMap,
|
||||
SerializeSet,
|
||||
|
||||
183
packages/core/tests/ECS/Core/SoASerializer.test.ts
Normal file
183
packages/core/tests/ECS/Core/SoASerializer.test.ts
Normal file
@@ -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 {
|
||||
EnableSoA,
|
||||
HighPrecision,
|
||||
Float64,
|
||||
Int32,
|
||||
SerializeMap,
|
||||
@@ -50,7 +49,7 @@ class BasicTypesComponent extends Component {
|
||||
class DecoratedNumberComponent extends Component {
|
||||
public normalFloat: number;
|
||||
|
||||
@HighPrecision
|
||||
@Float64
|
||||
public highPrecisionNumber: number;
|
||||
|
||||
@Float64
|
||||
@@ -143,7 +142,7 @@ class ComplexObjectComponent extends Component {
|
||||
|
||||
@EnableSoA
|
||||
class MixedComponent extends Component {
|
||||
@HighPrecision
|
||||
@Float64
|
||||
public bigIntId: number;
|
||||
|
||||
@Float64
|
||||
@@ -288,7 +287,7 @@ describe('SoAStorage - SoA存储测试', () => {
|
||||
});
|
||||
|
||||
describe('数值类型装饰器', () => {
|
||||
test('@HighPrecision应该保持高精度数值', () => {
|
||||
test('@Float64应该保持高精度数值', () => {
|
||||
const component = new DecoratedNumberComponent(
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER
|
||||
@@ -336,7 +335,7 @@ describe('SoAStorage - SoA存储测试', () => {
|
||||
expect(storage.getFieldArray('normalFloat')).toBeInstanceOf(Float32Array);
|
||||
expect(storage.getFieldArray('preciseFloat')).toBeInstanceOf(Float64Array);
|
||||
expect(storage.getFieldArray('integerValue')).toBeInstanceOf(Int32Array);
|
||||
expect(storage.getFieldArray('highPrecisionNumber')).toBeNull();
|
||||
expect(storage.getFieldArray('highPrecisionNumber')).toBeInstanceOf(Float64Array);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
233
packages/core/tests/ECS/Core/SoATypeRegistry.test.ts
Normal file
233
packages/core/tests/ECS/Core/SoATypeRegistry.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
49
packages/ecs-engine-bindgen/package.json
Normal file
49
packages/ecs-engine-bindgen/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
161
packages/ecs-engine-bindgen/src/components/SpriteComponent.ts
Normal file
161
packages/ecs-engine-bindgen/src/components/SpriteComponent.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
335
packages/ecs-engine-bindgen/src/core/EngineBridge.ts
Normal file
335
packages/ecs-engine-bindgen/src/core/EngineBridge.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
108
packages/ecs-engine-bindgen/src/core/RenderBatcher.ts
Normal file
108
packages/ecs-engine-bindgen/src/core/RenderBatcher.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
140
packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts
Normal file
140
packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
13
packages/ecs-engine-bindgen/src/index.ts
Normal file
13
packages/ecs-engine-bindgen/src/index.ts
Normal file
@@ -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';
|
||||
171
packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts
Normal file
171
packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
72
packages/ecs-engine-bindgen/src/types/index.ts
Normal file
72
packages/ecs-engine-bindgen/src/types/index.ts
Normal file
@@ -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;
|
||||
}
|
||||
22
packages/ecs-engine-bindgen/tsconfig.json
Normal file
22
packages/ecs-engine-bindgen/tsconfig.json
Normal file
@@ -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": {
|
||||
"@esengine/behavior-tree": "file:../behavior-tree",
|
||||
"@esengine/behavior-tree-editor": "file:../behavior-tree-editor",
|
||||
"@esengine/ecs-engine-bindgen": "file:../ecs-engine-bindgen",
|
||||
"@esengine/ecs-framework": "file:../core",
|
||||
"@esengine/editor-core": "file:../editor-core",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@esengine/engine": "file:../engine",
|
||||
"flexlayout-react": "^0.8.17",
|
||||
"i18next": "^25.6.0",
|
||||
"json5": "^2.2.3",
|
||||
@@ -51,6 +52,8 @@
|
||||
"sharp": "^0.34.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-swc-transform": "^1.1.1"
|
||||
"vite-plugin-swc-transform": "^1.1.1",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,18 @@ import {
|
||||
ProjectService,
|
||||
CompilerRegistry,
|
||||
InspectorRegistry,
|
||||
INotification
|
||||
INotification,
|
||||
CommandManager
|
||||
} from '@esengine/editor-core';
|
||||
import type { IDialogExtended } from './services/TauriDialogService';
|
||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||
import { ServiceRegistry, PluginInstaller, useDialogStore } from './app/managers';
|
||||
import { StartupPage } from './components/StartupPage';
|
||||
import { SceneHierarchy } from './components/SceneHierarchy';
|
||||
import { Inspector } from './components/Inspector';
|
||||
import { Inspector } from './components/inspectors/Inspector';
|
||||
import { AssetBrowser } from './components/AssetBrowser';
|
||||
import { ConsolePanel } from './components/ConsolePanel';
|
||||
import { Viewport } from './components/Viewport';
|
||||
import { PluginManagerWindow } from './components/PluginManagerWindow';
|
||||
import { ProfilerWindow } from './components/ProfilerWindow';
|
||||
import { PortManager } from './components/PortManager';
|
||||
@@ -82,6 +84,7 @@ function App() {
|
||||
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
||||
const [notification, setNotification] = useState<INotification | null>(null);
|
||||
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
|
||||
const [commandManager] = useState(() => new CommandManager());
|
||||
const { t, locale, changeLocale } = useLocale();
|
||||
|
||||
// 同步 locale 到 TauriDialogService
|
||||
@@ -660,13 +663,13 @@ function App() {
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} commandManager={commandManager} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: '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
|
||||
},
|
||||
{
|
||||
@@ -681,13 +684,19 @@ function App() {
|
||||
{
|
||||
id: '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
|
||||
},
|
||||
{
|
||||
id: '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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 { Entity, Core } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService } from '@esengine/editor-core';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { Box, Layers, Wifi, Search, Plus, Trash2 } from 'lucide-react';
|
||||
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe } from 'lucide-react';
|
||||
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
|
||||
import '../styles/SceneHierarchy.css';
|
||||
|
||||
type ViewMode = 'local' | 'remote';
|
||||
|
||||
interface SceneHierarchyProps {
|
||||
entityStore: EntityStoreService;
|
||||
messageHub: MessageHub;
|
||||
commandManager: CommandManager;
|
||||
}
|
||||
|
||||
export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) {
|
||||
export function SceneHierarchy({ entityStore, messageHub, commandManager }: SceneHierarchyProps) {
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [remoteEntities, setRemoteEntities] = useState<RemoteEntity[]>([]);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('local');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sceneName, setSceneName] = useState<string>('Untitled');
|
||||
const [remoteSceneName, setRemoteSceneName] = useState<string | null>(null);
|
||||
const [sceneFilePath, setSceneFilePath] = useState<string | null>(null);
|
||||
const [isSceneModified, setIsSceneModified] = useState<boolean>(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entityId: number | null } | null>(null);
|
||||
const { t, locale } = useLocale();
|
||||
|
||||
const isShowingRemote = viewMode === 'remote' && isRemoteConnected;
|
||||
|
||||
// Subscribe to scene changes
|
||||
useEffect(() => {
|
||||
const sceneManager = Core.services.resolve(SceneManagerService);
|
||||
@@ -182,14 +190,15 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
||||
};
|
||||
|
||||
const handleCreateEntity = () => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const entityCount = entityStore.getAllEntities().length;
|
||||
const entityName = `Entity ${entityCount + 1}`;
|
||||
const entity = scene.createEntity(entityName);
|
||||
entityStore.addEntity(entity);
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
const command = new CreateEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
entityName
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handleDeleteEntity = async () => {
|
||||
@@ -200,8 +209,8 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
||||
|
||||
const confirmed = await confirm(
|
||||
locale === 'zh'
|
||||
? `确定要删除实体 "${entity.name}" 吗?此操作无法撤销。`
|
||||
: `Are you sure you want to delete entity "${entity.name}"? This action cannot be undone.`,
|
||||
? `确定要删除实体 "${entity.name}" 吗?`
|
||||
: `Are you sure you want to delete entity "${entity.name}"?`,
|
||||
{
|
||||
title: locale === 'zh' ? '删除实体' : 'Delete Entity',
|
||||
kind: 'warning'
|
||||
@@ -209,22 +218,44 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
entity.destroy();
|
||||
entityStore.removeEntity(entity);
|
||||
const command = new DeleteEntityCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
entity
|
||||
);
|
||||
commandManager.execute(command);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, entityId: number | null) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, entityId });
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// Close context menu on click outside
|
||||
useEffect(() => {
|
||||
const handleClick = () => closeContextMenu();
|
||||
if (contextMenu) {
|
||||
window.addEventListener('click', handleClick);
|
||||
return () => window.removeEventListener('click', handleClick);
|
||||
}
|
||||
}, [contextMenu]);
|
||||
|
||||
// Listen for Delete key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Delete' && selectedId && !isRemoteConnected) {
|
||||
if (e.key === 'Delete' && selectedId && !isShowingRemote) {
|
||||
handleDeleteEntity();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, isRemoteConnected]);
|
||||
}, [selectedId, isShowingRemote]);
|
||||
|
||||
// Filter entities based on search query
|
||||
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
|
||||
@@ -262,11 +293,11 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
||||
};
|
||||
|
||||
// Determine which entities to display
|
||||
const displayEntities = isRemoteConnected
|
||||
const displayEntities = isShowingRemote
|
||||
? filterRemoteEntities(remoteEntities)
|
||||
: filterLocalEntities(entities);
|
||||
const showRemoteIndicator = isRemoteConnected && remoteEntities.length > 0;
|
||||
const displaySceneName = isRemoteConnected && remoteSceneName ? remoteSceneName : sceneName;
|
||||
const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0;
|
||||
const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName;
|
||||
|
||||
return (
|
||||
<div className="scene-hierarchy">
|
||||
@@ -282,6 +313,24 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
||||
{displaySceneName}{!isRemoteConnected && isSceneModified ? '*' : ''}
|
||||
</span>
|
||||
</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 && (
|
||||
<div className="remote-indicator" title="Showing remote entities">
|
||||
<Wifi size={12} />
|
||||
@@ -297,18 +346,17 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!isRemoteConnected && (
|
||||
{!isShowingRemote && (
|
||||
<div className="hierarchy-toolbar">
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
className="toolbar-btn icon-only"
|
||||
onClick={handleCreateEntity}
|
||||
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>{locale === 'zh' ? '创建实体' : 'Create Entity'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
className="toolbar-btn icon-only"
|
||||
onClick={handleDeleteEntity}
|
||||
disabled={!selectedId}
|
||||
title={locale === 'zh' ? '删除实体' : 'Delete Entity'}
|
||||
@@ -317,18 +365,18 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="hierarchy-content scrollable">
|
||||
<div className="hierarchy-content scrollable" onContextMenu={(e) => !isShowingRemote && handleContextMenu(e, null)}>
|
||||
{displayEntities.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Box size={48} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-title">{t('hierarchy.empty')}</div>
|
||||
<div className="empty-hint">
|
||||
{isRemoteConnected
|
||||
{isShowingRemote
|
||||
? 'No entities in remote game'
|
||||
: 'Create an entity to get started'}
|
||||
</div>
|
||||
</div>
|
||||
) : isRemoteConnected ? (
|
||||
) : isShowingRemote ? (
|
||||
<ul className="entity-list">
|
||||
{(displayEntities as RemoteEntity[]).map((entity) => (
|
||||
<li
|
||||
@@ -357,14 +405,45 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
|
||||
key={entity.id}
|
||||
className={`entity-item ${selectedId === entity.id ? 'selected' : ''}`}
|
||||
onClick={() => handleEntityClick(entity)}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Play, Pause, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity, Box, Square } from 'lucide-react';
|
||||
import { Play, Pause, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity, Box, Square, Zap } from 'lucide-react';
|
||||
import '../styles/Viewport.css';
|
||||
import { useEngine } from '../hooks/useEngine';
|
||||
|
||||
interface ViewportProps {
|
||||
locale?: string;
|
||||
@@ -14,6 +15,11 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
|
||||
const [showGizmos, setShowGizmos] = useState(true);
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const [is3D, setIs3D] = useState(true);
|
||||
const [useRustEngine, setUseRustEngine] = useState(false);
|
||||
|
||||
// Rust engine hook (only active in 2D mode with engine enabled)
|
||||
// Rust引擎钩子(仅在2D模式且启用引擎时激活)
|
||||
const engine = useEngine('viewport-canvas', useRustEngine && !is3D);
|
||||
const animationFrameRef = useRef<number>();
|
||||
const glRef = useRef<WebGLRenderingContext | null>(null);
|
||||
const gridProgramRef = useRef<WebGLProgram | null>(null);
|
||||
@@ -573,7 +579,17 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
setIsPlaying(!isPlaying);
|
||||
const newPlaying = !isPlaying;
|
||||
setIsPlaying(newPlaying);
|
||||
|
||||
// Control Rust engine if active | 控制Rust引擎(如果激活)
|
||||
if (useRustEngine && !is3D && engine.state.initialized) {
|
||||
if (newPlaying) {
|
||||
engine.start();
|
||||
} else {
|
||||
engine.stop();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
@@ -634,6 +650,15 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
|
||||
>
|
||||
{is3D ? <Box size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
{!is3D && (
|
||||
<button
|
||||
className={`viewport-btn ${useRustEngine ? 'active' : ''}`}
|
||||
onClick={() => setUseRustEngine(!useRustEngine)}
|
||||
title={locale === 'zh' ? 'Rust引擎' : 'Rust Engine'}
|
||||
>
|
||||
<Zap size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="viewport-toolbar-right">
|
||||
<button
|
||||
@@ -652,17 +677,33 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="viewport-canvas" />
|
||||
<canvas ref={canvasRef} id="viewport-canvas" className="viewport-canvas" />
|
||||
{showStats && (
|
||||
<div className="viewport-stats">
|
||||
<div className="viewport-stat">
|
||||
<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 className="viewport-stat">
|
||||
<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>
|
||||
{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>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
EntityInspector
|
||||
} from './views';
|
||||
|
||||
export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath }: InspectorProps) {
|
||||
export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath, commandManager }: InspectorProps) {
|
||||
const [target, setTarget] = useState<InspectorTarget>(null);
|
||||
const [componentVersion, setComponentVersion] = useState(0);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
@@ -196,7 +196,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
}
|
||||
|
||||
if (target.type === 'entity') {
|
||||
return <EntityInspector entity={target.data} messageHub={messageHub} componentVersion={componentVersion} />;
|
||||
return <EntityInspector entity={target.data} messageHub={messageHub} commandManager={commandManager} componentVersion={componentVersion} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, InspectorRegistry } from '@esengine/editor-core';
|
||||
import { EntityStoreService, MessageHub, InspectorRegistry, CommandManager } from '@esengine/editor-core';
|
||||
|
||||
export interface InspectorProps {
|
||||
entityStore: EntityStoreService;
|
||||
messageHub: MessageHub;
|
||||
inspectorRegistry: InspectorRegistry;
|
||||
projectPath?: string | null;
|
||||
commandManager: CommandManager;
|
||||
}
|
||||
|
||||
export interface AssetFileInfo {
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings, ChevronDown, ChevronRight, X } from 'lucide-react';
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { Settings, ChevronDown, ChevronRight, X, Plus } from 'lucide-react';
|
||||
import { Entity, Component, Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, CommandManager, ComponentRegistry } from '@esengine/editor-core';
|
||||
import { PropertyInspector } from '../../PropertyInspector';
|
||||
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
|
||||
interface EntityInspectorProps {
|
||||
entity: Entity;
|
||||
messageHub: MessageHub;
|
||||
commandManager: CommandManager;
|
||||
componentVersion: number;
|
||||
}
|
||||
|
||||
export function EntityInspector({ entity, messageHub, componentVersion }: EntityInspectorProps) {
|
||||
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
|
||||
const [showComponentMenu, setShowComponentMenu] = useState(false);
|
||||
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const availableComponents = componentRegistry?.getAllComponents() || [];
|
||||
|
||||
const toggleComponentExpanded = (index: number) => {
|
||||
setExpandedComponents((prev) => {
|
||||
@@ -26,21 +32,33 @@ export function EntityInspector({ entity, messageHub, componentVersion }: Entity
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddComponent = (ComponentClass: new () => Component) => {
|
||||
const command = new AddComponentCommand(messageHub, entity, ComponentClass);
|
||||
commandManager.execute(command);
|
||||
setShowComponentMenu(false);
|
||||
};
|
||||
|
||||
const handleRemoveComponent = (index: number) => {
|
||||
const component = entity.components[index];
|
||||
if (component) {
|
||||
entity.removeComponent(component);
|
||||
messageHub.publish('component:removed', { entity, component });
|
||||
const command = new RemoveComponentCommand(
|
||||
messageHub,
|
||||
entity,
|
||||
component
|
||||
);
|
||||
commandManager.execute(command);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePropertyChange = (component: Component, propertyName: string, value: unknown) => {
|
||||
messageHub.publish('component:property:changed', {
|
||||
const command = new UpdateComponentCommand(
|
||||
messageHub,
|
||||
entity,
|
||||
component,
|
||||
propertyName,
|
||||
value
|
||||
});
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -63,10 +81,77 @@ export function EntityInspector({ entity, messageHub, componentVersion }: Entity
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entity.components.length > 0 && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">组件</div>
|
||||
{entity.components.map((component: Component, index: number) => {
|
||||
<div className="inspector-section">
|
||||
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>组件</span>
|
||||
<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 componentName = component.constructor?.name || 'Component';
|
||||
|
||||
@@ -140,8 +225,7 @@ export function EntityInspector({ entity, messageHub, componentVersion }: Entity
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Entity, Component } from '@esengine/ecs-framework';
|
||||
import type { Node } from '@esengine/behavior-tree-editor';
|
||||
|
||||
export interface PluginEvent {
|
||||
name: string;
|
||||
|
||||
127
packages/editor-app/src/hooks/useEngine.ts
Normal file
127
packages/editor-app/src/hooks/useEngine.ts
Normal file
@@ -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;
|
||||
254
packages/editor-app/src/services/EngineService.ts
Normal file
254
packages/editor-app/src/services/EngineService.ts
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
.view-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background-color: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-xs);
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mode-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
min-width: 14px;
|
||||
min-height: 14px;
|
||||
color: inherit;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.remote-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -144,8 +189,14 @@
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.toolbar-btn.icon-only {
|
||||
padding: var(--spacing-xs);
|
||||
min-width: 28px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hierarchy-content {
|
||||
@@ -340,3 +391,38 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Context menu styles */
|
||||
.context-menu {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: var(--spacing-xs);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-menu button:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border-default);
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
--color-text-primary: #cccccc;
|
||||
--color-text-secondary: #9d9d9d;
|
||||
--color-text-tertiary: #6a6a6a;
|
||||
--color-text-disabled: #4d4d4d;
|
||||
--color-text-disabled: #aaaaaa;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
/* 颜色系统 - 边框 */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
import topLevelAwait from 'vite-plugin-top-level-await';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -8,6 +10,34 @@ const host = process.env.TAURI_DEV_HOST;
|
||||
const userProjectPathMap = new Map<string, string>();
|
||||
const editorPackageMapping = 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() {
|
||||
const packagesDir = path.resolve(__dirname, '..');
|
||||
@@ -25,23 +55,89 @@ function loadEditorPackages() {
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (packageJson.name && packageJson.name.startsWith('@esengine/')) {
|
||||
const packageName = packageJson.name;
|
||||
|
||||
if (packageName && packageName.startsWith('@esengine/')) {
|
||||
const mainFile = packageJson.module || packageJson.main;
|
||||
if (mainFile) {
|
||||
const entryPath = path.join(packagesDir, dir, mainFile);
|
||||
if (fs.existsSync(entryPath)) {
|
||||
editorPackageMapping.set(packageJson.name, entryPath);
|
||||
editorPackageMapping.set(packageName, entryPath);
|
||||
}
|
||||
}
|
||||
if (packageJson.version) {
|
||||
editorPackageVersions.set(packageJson.name, packageJson.version);
|
||||
editorPackageVersions.set(packageName, packageJson.version);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for WASM files and add to wasmPackages
|
||||
// 检查WASM文件并添加到wasmPackages
|
||||
const packageDir = path.join(packagesDir, dir);
|
||||
if (packageName && hasWasmFiles(packageDir)) {
|
||||
wasmPackages.push(packageName);
|
||||
console.log(`[Vite] Detected WASM package: ${packageName}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Vite] Failed to read package.json for ${dir}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan node_modules for WASM packages
|
||||
// 也扫描node_modules中的WASM包
|
||||
const nodeModulesDir = path.resolve(__dirname, 'node_modules');
|
||||
if (fs.existsSync(nodeModulesDir)) {
|
||||
scanNodeModulesForWasm(nodeModulesDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan node_modules for WASM packages.
|
||||
* 扫描node_modules中的WASM包。
|
||||
*/
|
||||
function scanNodeModulesForWasm(nodeModulesDir: string) {
|
||||
try {
|
||||
const entries = fs.readdirSync(nodeModulesDir);
|
||||
for (const entry of entries) {
|
||||
// Skip .pnpm and other hidden/internal directories
|
||||
if (entry.startsWith('.')) continue;
|
||||
|
||||
const entryPath = path.join(nodeModulesDir, entry);
|
||||
const stat = fs.statSync(entryPath);
|
||||
|
||||
if (!stat.isDirectory()) continue;
|
||||
|
||||
// Handle scoped packages (@scope/package)
|
||||
if (entry.startsWith('@')) {
|
||||
const scopedPackages = fs.readdirSync(entryPath);
|
||||
for (const scopedPkg of scopedPackages) {
|
||||
const scopedPath = path.join(entryPath, scopedPkg);
|
||||
const scopedStat = fs.statSync(scopedPath);
|
||||
if (scopedStat.isDirectory()) {
|
||||
checkAndAddWasmPackage(scopedPath, `${entry}/${scopedPkg}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
checkAndAddWasmPackage(entryPath, entry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a package has WASM files and add to wasmPackages.
|
||||
* 检查包是否有WASM文件并添加到wasmPackages。
|
||||
*/
|
||||
function checkAndAddWasmPackage(packagePath: string, packageName: string) {
|
||||
// Skip if already added
|
||||
if (wasmPackages.includes(packageName)) return;
|
||||
|
||||
if (hasWasmFiles(packagePath)) {
|
||||
wasmPackages.push(packageName);
|
||||
console.log(`[Vite] Detected WASM package in node_modules: ${packageName}`);
|
||||
}
|
||||
}
|
||||
|
||||
loadEditorPackages();
|
||||
@@ -581,6 +677,8 @@ module.exports = [
|
||||
inlineDynamicImports: true
|
||||
},
|
||||
plugins: [
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
resolve({
|
||||
extensions: ['.js', '.jsx']
|
||||
}),
|
||||
@@ -607,6 +705,8 @@ module.exports = [
|
||||
format: 'es'
|
||||
},
|
||||
plugins: [
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
dts({
|
||||
respectExternal: true
|
||||
})
|
||||
@@ -638,6 +738,8 @@ module.exports = [
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
...react({
|
||||
tsDecorators: true,
|
||||
}),
|
||||
@@ -665,4 +767,11 @@ export default defineConfig({
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Pre-bundle common dependencies to avoid runtime re-optimization | 预打包常用依赖以避免运行时重新优化
|
||||
include: ['tslib', 'react', 'react-dom', 'zustand', 'lucide-react'],
|
||||
// Exclude WASM packages from pre-bundling | 排除 WASM 包不进行预打包
|
||||
// Add user WASM plugins to wasmPackages array at top of file | 将用户 WASM 插件添加到文件顶部的 wasmPackages 数组
|
||||
exclude: wasmPackages,
|
||||
},
|
||||
});
|
||||
|
||||
16
packages/engine/.gitignore
vendored
Normal file
16
packages/engine/.gitignore
vendored
Normal file
@@ -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
|
||||
86
packages/engine/Cargo.toml
Normal file
86
packages/engine/Cargo.toml
Normal file
@@ -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
|
||||
37
packages/engine/package.json
Normal file
37
packages/engine/package.json
Normal file
@@ -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": {}
|
||||
}
|
||||
153
packages/engine/src/core/context.rs
Normal file
153
packages/engine/src/core/context.rs
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
187
packages/engine/src/core/engine.rs
Normal file
187
packages/engine/src/core/engine.rs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
58
packages/engine/src/core/error.rs
Normal file
58
packages/engine/src/core/error.rs
Normal file
@@ -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>;
|
||||
10
packages/engine/src/core/mod.rs
Normal file
10
packages/engine/src/core/mod.rs
Normal file
@@ -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};
|
||||
61
packages/engine/src/input/input_manager.rs
Normal file
61
packages/engine/src/input/input_manager.rs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
82
packages/engine/src/input/keyboard.rs
Normal file
82
packages/engine/src/input/keyboard.rs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
12
packages/engine/src/input/mod.rs
Normal file
12
packages/engine/src/input/mod.rs
Normal file
@@ -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};
|
||||
136
packages/engine/src/input/mouse.rs
Normal file
136
packages/engine/src/input/mouse.rs
Normal file
@@ -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];
|
||||
}
|
||||
}
|
||||
164
packages/engine/src/input/touch.rs
Normal file
164
packages/engine/src/input/touch.rs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
195
packages/engine/src/lib.rs
Normal file
195
packages/engine/src/lib.rs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
184
packages/engine/src/math/color.rs
Normal file
184
packages/engine/src/math/color.rs
Normal file
@@ -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]
|
||||
}
|
||||
}
|
||||
19
packages/engine/src/math/mod.rs
Normal file
19
packages/engine/src/math/mod.rs
Normal file
@@ -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};
|
||||
148
packages/engine/src/math/rect.rs
Normal file
148
packages/engine/src/math/rect.rs
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
164
packages/engine/src/math/transform.rs
Normal file
164
packages/engine/src/math/transform.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
214
packages/engine/src/math/vec2.rs
Normal file
214
packages/engine/src/math/vec2.rs
Normal file
@@ -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 }
|
||||
}
|
||||
}
|
||||
51
packages/engine/src/platform/mod.rs
Normal file
51
packages/engine/src/platform/mod.rs
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
146
packages/engine/src/platform/web.rs
Normal file
146
packages/engine/src/platform/web.rs
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
8
packages/engine/src/renderer/batch/mod.rs
Normal file
8
packages/engine/src/renderer/batch/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! Sprite batch rendering system.
|
||||
//! 精灵批处理渲染系统。
|
||||
|
||||
mod sprite_batch;
|
||||
mod vertex;
|
||||
|
||||
pub use sprite_batch::SpriteBatch;
|
||||
pub use vertex::{SpriteVertex, VERTEX_SIZE};
|
||||
401
packages/engine/src/renderer/batch/sprite_batch.rs
Normal file
401
packages/engine/src/renderer/batch/sprite_batch.rs
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
60
packages/engine/src/renderer/batch/vertex.rs
Normal file
60
packages/engine/src/renderer/batch/vertex.rs
Normal file
@@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
144
packages/engine/src/renderer/camera.rs
Normal file
144
packages/engine/src/renderer/camera.rs
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
14
packages/engine/src/renderer/mod.rs
Normal file
14
packages/engine/src/renderer/mod.rs
Normal file
@@ -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};
|
||||
134
packages/engine/src/renderer/renderer2d.rs
Normal file
134
packages/engine/src/renderer/renderer2d.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
63
packages/engine/src/renderer/shader/builtin.rs
Normal file
63
packages/engine/src/renderer/shader/builtin.rs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
8
packages/engine/src/renderer/shader/mod.rs
Normal file
8
packages/engine/src/renderer/shader/mod.rs
Normal file
@@ -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};
|
||||
154
packages/engine/src/renderer/shader/program.rs
Normal file
154
packages/engine/src/renderer/shader/program.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/engine/src/renderer/texture/mod.rs
Normal file
8
packages/engine/src/renderer/texture/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! Texture management system.
|
||||
//! 纹理管理系统。
|
||||
|
||||
mod texture;
|
||||
mod texture_manager;
|
||||
|
||||
pub use texture::Texture;
|
||||
pub use texture_manager::TextureManager;
|
||||
39
packages/engine/src/renderer/texture/texture.rs
Normal file
39
packages/engine/src/renderer/texture/texture.rs
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
217
packages/engine/src/renderer/texture/texture_manager.rs
Normal file
217
packages/engine/src/renderer/texture/texture_manager.rs
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
55
packages/engine/src/resource/handle.rs
Normal file
55
packages/engine/src/resource/handle.rs
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
7
packages/engine/src/resource/mod.rs
Normal file
7
packages/engine/src/resource/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Resource management system.
|
||||
//! 资源管理系统。
|
||||
|
||||
mod handle;
|
||||
|
||||
pub use handle::{Handle, HandleId};
|
||||
pub use crate::renderer::texture::{Texture, TextureManager};
|
||||
48
packages/platform-common/README.md
Normal file
48
packages/platform-common/README.md
Normal file
@@ -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
|
||||
50
packages/platform-common/package.json
Normal file
50
packages/platform-common/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
40
packages/platform-common/rollup.config.js
Normal file
40
packages/platform-common/rollup.config.js
Normal file
@@ -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()]
|
||||
}
|
||||
];
|
||||
632
packages/platform-common/src/IPlatformSubsystems.ts
Normal file
632
packages/platform-common/src/IPlatformSubsystems.ts
Normal file
@@ -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;
|
||||
}
|
||||
42
packages/platform-common/src/index.ts
Normal file
42
packages/platform-common/src/index.ts
Normal file
@@ -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';
|
||||
24
packages/platform-common/tsconfig.json
Normal file
24
packages/platform-common/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
54
packages/platform-web/package.json
Normal file
54
packages/platform-web/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
42
packages/platform-web/rollup.config.js
Normal file
42
packages/platform-web/rollup.config.js
Normal file
@@ -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()]
|
||||
}
|
||||
];
|
||||
254
packages/platform-web/src/EngineBridge.ts
Normal file
254
packages/platform-web/src/EngineBridge.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
19
packages/platform-web/src/index.ts
Normal file
19
packages/platform-web/src/index.ts
Normal file
@@ -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';
|
||||
}
|
||||
174
packages/platform-web/src/subsystems/WebCanvasSubsystem.ts
Normal file
174
packages/platform-web/src/subsystems/WebCanvasSubsystem.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
102
packages/platform-web/src/subsystems/WebInputSubsystem.ts
Normal file
102
packages/platform-web/src/subsystems/WebInputSubsystem.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
77
packages/platform-web/src/subsystems/WebStorageSubsystem.ts
Normal file
77
packages/platform-web/src/subsystems/WebStorageSubsystem.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
44
packages/platform-web/src/subsystems/WebWASMSubsystem.ts
Normal file
44
packages/platform-web/src/subsystems/WebWASMSubsystem.ts
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
24
packages/platform-web/tsconfig.json
Normal file
24
packages/platform-web/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
64
packages/platform-wechat/README.md
Normal file
64
packages/platform-wechat/README.md
Normal file
@@ -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
|
||||
56
packages/platform-wechat/package.json
Normal file
56
packages/platform-wechat/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
44
packages/platform-wechat/rollup.config.js
Normal file
44
packages/platform-wechat/rollup.config.js
Normal file
@@ -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()]
|
||||
}
|
||||
];
|
||||
235
packages/platform-wechat/src/EngineBridge.ts
Normal file
235
packages/platform-wechat/src/EngineBridge.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
289
packages/platform-wechat/src/WeChatAdapter.ts
Normal file
289
packages/platform-wechat/src/WeChatAdapter.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
23
packages/platform-wechat/src/index.ts
Normal file
23
packages/platform-wechat/src/index.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
209
packages/platform-wechat/src/subsystems/WeChatCanvasSubsystem.ts
Normal file
209
packages/platform-wechat/src/subsystems/WeChatCanvasSubsystem.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
204
packages/platform-wechat/src/subsystems/WeChatFileSubsystem.ts
Normal file
204
packages/platform-wechat/src/subsystems/WeChatFileSubsystem.ts
Normal file
@@ -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