feat: 集成Rust WASM渲染引擎与TypeScript ECS框架 (#228)

* feat: 集成Rust WASM渲染引擎与TypeScript ECS框架

* feat: 增强编辑器UI功能与跨平台支持

* fix: 修复CI测试和类型检查问题

* fix: 修复CI问题并提高测试覆盖率

* fix: 修复CI问题并提高测试覆盖率
This commit is contained in:
YHH
2025-11-21 10:03:18 +08:00
committed by GitHub
parent 8b9616837d
commit a768b890fd
107 changed files with 10221 additions and 477 deletions

View File

@@ -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
View File

@@ -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"
}
}
}
}

View File

@@ -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"
},

View File

@@ -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;
/**

View 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;
}
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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';

View File

@@ -23,7 +23,6 @@ export {
EnableSoA,
// 数值类型装饰器
HighPrecision,
Float64,
Float32,
Int32,
@@ -34,10 +33,6 @@ export {
Uint8,
Uint8Clamped,
// 自动类型推断
AutoTyped,
TypeInference,
// 序列化装饰器
SerializeMap,
SerializeSet,

View 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);
});
});
});

View File

@@ -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);
});
});

View 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');
});
});
});

View 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"
}
}

View 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).
* 原点X0-10.5为中心)。
*/
originX: number = 0.5;
/**
* Origin point Y (0-1, where 0.5 is center).
* 原点Y0-10.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;
}
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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';

View 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);
}
}

View 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). | 原点X0-1。 */
originX: number;
/** Origin Y (0-1). | 原点Y0-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;
}

View 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"]
}

View File

@@ -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"
}
}

View File

@@ -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
},
{

View File

@@ -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}`;
}
}

View File

@@ -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}`;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,3 @@
export { AddComponentCommand } from './AddComponentCommand';
export { RemoveComponentCommand } from './RemoveComponentCommand';
export { UpdateComponentCommand } from './UpdateComponentCommand';

View File

@@ -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;
}
}

View File

@@ -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}`;
}
}

View File

@@ -0,0 +1,2 @@
export { CreateEntityCommand } from './CreateEntityCommand';
export { DeleteEntityCommand } from './DeleteEntityCommand';

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>
);

View File

@@ -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;

View 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;

View 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;

View File

@@ -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;
}

View File

@@ -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;
/* 颜色系统 - 边框 */

View File

@@ -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
View 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

View 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

View 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": {}
}

View 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,
);
}
}

View 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();
}
}

View 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>;

View 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};

View 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();
}
}

View 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();
}
}

View 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};

View 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];
}
}

View 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
View 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();
}
}

View 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).
/// 转换为打包的u32WebGL的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]
}
}

View 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};

View 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,
)
}
}

View 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);
}
}

View 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 }
}
}

View 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,
}
}
}

View 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));
}
}

View 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};

View 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
}
}

View 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],
}
}
}

View 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)
}
}

View 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};

View 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);
}
}

View 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;
}
}
"#;

View 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};

View 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);
}
}
}

View File

@@ -0,0 +1,8 @@
//! Texture management system.
//! 纹理管理系统。
mod texture;
mod texture_manager;
pub use texture::Texture;
pub use texture_manager::TextureManager;

View 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
}
}

View 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));
}
}
}

View 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
}
}

View File

@@ -0,0 +1,7 @@
//! Resource management system.
//! 资源管理系统。
mod handle;
pub use handle::{Handle, HandleId};
pub use crate::renderer::texture::{Texture, TextureManager};

View 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

View 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"
}
}

View 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()]
}
];

View 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;
}

View 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';

View 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"]
}

View 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"
}
}

View 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()]
}
];

View 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;
}
}

View 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';
}

View 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;
}
}

View 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
};
}
}

View 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();
}
}

View 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
});
}
}

View 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"]
}

View 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

View 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"
}
}

View 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()]
}
];

View 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;
}
}

View 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);
}
}

View 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';

View File

@@ -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);
}
}

View 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;
}
}

View 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
});
});
}
}

View File

@@ -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