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

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