refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
20
packages/engine/platform-web/module.json
Normal file
20
packages/engine/platform-web/module.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"id": "platform-web",
|
||||
"name": "@esengine/platform-web",
|
||||
"displayName": "Platform Web",
|
||||
"description": "Web platform runtime | Web 平台运行时",
|
||||
"version": "1.0.0",
|
||||
"category": "Platform",
|
||||
"icon": "Globe",
|
||||
"tags": ["platform", "web", "browser", "runtime"],
|
||||
"isCore": true,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": ["web"],
|
||||
"dependencies": ["core", "runtime-core"],
|
||||
"exports": {},
|
||||
"outputPath": "dist/index.mjs",
|
||||
"requiresWasm": false,
|
||||
"isRuntimeEntry": true
|
||||
}
|
||||
61
packages/engine/platform-web/package.json
Normal file
61
packages/engine/platform-web/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@esengine/platform-web",
|
||||
"version": "1.0.0",
|
||||
"description": "Web/H5 Platform Adapter - Browser runtime and asset loading",
|
||||
"type": "module",
|
||||
"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",
|
||||
"runtime",
|
||||
"browser"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@esengine/runtime-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/platform-common": "workspace:*",
|
||||
"@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/esengine.git",
|
||||
"directory": "packages/platform-web"
|
||||
}
|
||||
}
|
||||
78
packages/engine/platform-web/rollup.config.js
Normal file
78
packages/engine/platform-web/rollup.config.js
Normal file
@@ -0,0 +1,78 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Platform-web Rollup Configuration
|
||||
*
|
||||
* Builds:
|
||||
* 1. ESM + CJS bundles for editor usage
|
||||
* 2. TypeScript declarations
|
||||
*
|
||||
* All @esengine/* packages are external to avoid bundling.
|
||||
* Game builds use import maps to resolve modules at runtime.
|
||||
*/
|
||||
|
||||
const external = [
|
||||
'@esengine/ecs-framework',
|
||||
'@esengine/runtime-core',
|
||||
'@esengine/platform-common',
|
||||
'@esengine/asset-system',
|
||||
'@esengine/ecs-components',
|
||||
'@esengine/ecs-engine-bindgen',
|
||||
'@esengine/tilemap',
|
||||
'@esengine/fairygui',
|
||||
'@esengine/behavior-tree',
|
||||
// Editor packages (should never be in runtime)
|
||||
'@esengine/editor-core',
|
||||
'@esengine/fairygui-editor',
|
||||
'@esengine/tilemap-editor',
|
||||
'@esengine/behavior-tree-editor',
|
||||
'@esengine/blueprint-editor',
|
||||
'@esengine/physics-rapier2d-editor',
|
||||
// React (editor only)
|
||||
'react',
|
||||
'react-dom',
|
||||
];
|
||||
|
||||
export default [
|
||||
// Main bundle (ESM + CJS)
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: [
|
||||
{
|
||||
file: 'dist/index.mjs',
|
||||
format: 'esm',
|
||||
sourcemap: true
|
||||
},
|
||||
{
|
||||
file: 'dist/index.js',
|
||||
format: 'cjs',
|
||||
sourcemap: true
|
||||
}
|
||||
],
|
||||
external,
|
||||
plugins: [
|
||||
resolve({
|
||||
browser: true,
|
||||
preferBuiltins: false
|
||||
}),
|
||||
commonjs(),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.json',
|
||||
declaration: false
|
||||
})
|
||||
]
|
||||
},
|
||||
// TypeScript declarations
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: 'dist/index.d.ts',
|
||||
format: 'esm'
|
||||
},
|
||||
external,
|
||||
plugins: [dts()]
|
||||
}
|
||||
];
|
||||
129
packages/engine/platform-web/src/BrowserAssetReader.ts
Normal file
129
packages/engine/platform-web/src/BrowserAssetReader.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Browser Asset Reader
|
||||
* 浏览器资产读取器
|
||||
*
|
||||
* Implements IAssetReader interface for browser environment.
|
||||
* Uses fetch API to load assets from web server.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Browser Asset Reader for loading assets via fetch API
|
||||
* 通过 fetch API 加载资产的浏览器读取器
|
||||
*/
|
||||
export class BrowserAssetReader {
|
||||
private _baseUrl: string;
|
||||
private _audioContext: AudioContext | null = null;
|
||||
|
||||
constructor(baseUrl: string = '/assets') {
|
||||
this._baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve asset path to URL
|
||||
* 将资产路径解析为 URL
|
||||
*/
|
||||
private _resolveUrl(absolutePath: string): string {
|
||||
// Handle absolute Windows paths (e.g., F:\TowerECS\assets\...)
|
||||
if (/^[A-Za-z]:[\\/]/.test(absolutePath)) {
|
||||
const normalized = absolutePath.replace(/\\/g, '/');
|
||||
const assetsIndex = normalized.toLowerCase().indexOf('/assets/');
|
||||
if (assetsIndex >= 0) {
|
||||
return `${this._baseUrl}${normalized.substring(assetsIndex + 7)}`;
|
||||
}
|
||||
const filename = normalized.split('/').pop();
|
||||
return `${this._baseUrl}/${filename}`;
|
||||
}
|
||||
|
||||
// Handle relative paths
|
||||
if (absolutePath.startsWith('./') || absolutePath.startsWith('../')) {
|
||||
return absolutePath;
|
||||
}
|
||||
if (absolutePath.startsWith('/assets/')) {
|
||||
return absolutePath;
|
||||
}
|
||||
if (absolutePath.startsWith('assets/')) {
|
||||
return `/${absolutePath}`;
|
||||
}
|
||||
return `${this._baseUrl}/${absolutePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text file
|
||||
* 读取文本文件
|
||||
*/
|
||||
async readText(absolutePath: string): Promise<string> {
|
||||
const url = this._resolveUrl(absolutePath);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load text: ${url} (${response.status})`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read binary file
|
||||
* 读取二进制文件
|
||||
*/
|
||||
async readBinary(absolutePath: string): Promise<ArrayBuffer> {
|
||||
const url = this._resolveUrl(absolutePath);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load binary: ${url} (${response.status})`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image
|
||||
* 加载图片
|
||||
*/
|
||||
async loadImage(absolutePath: string): Promise<HTMLImageElement> {
|
||||
const url = this._resolveUrl(absolutePath);
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load audio
|
||||
* 加载音频
|
||||
*/
|
||||
async loadAudio(absolutePath: string): Promise<AudioBuffer> {
|
||||
if (!this._audioContext) {
|
||||
this._audioContext = new AudioContext();
|
||||
}
|
||||
const url = this._resolveUrl(absolutePath);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load audio: ${url} (${response.status})`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return this._audioContext.decodeAudioData(arrayBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
async exists(absolutePath: string): Promise<boolean> {
|
||||
const url = this._resolveUrl(absolutePath);
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL
|
||||
* 获取基础 URL
|
||||
*/
|
||||
get baseUrl(): string {
|
||||
return this._baseUrl;
|
||||
}
|
||||
}
|
||||
427
packages/engine/platform-web/src/BrowserRuntime.ts
Normal file
427
packages/engine/platform-web/src/BrowserRuntime.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Browser Runtime
|
||||
* 浏览器运行时
|
||||
*
|
||||
* Lightweight runtime for web game builds.
|
||||
* Uses dynamic plugin loading via import maps.
|
||||
*
|
||||
* 轻量级 Web 游戏运行时。
|
||||
* 通过 import maps 动态加载插件。
|
||||
*/
|
||||
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import {
|
||||
GameRuntime,
|
||||
createGameRuntime,
|
||||
BrowserPlatformAdapter,
|
||||
runtimePluginManager,
|
||||
BrowserFileSystemService,
|
||||
RuntimeSceneManager,
|
||||
RuntimeSceneManagerToken,
|
||||
type IRuntimePlugin,
|
||||
type IRuntimeSceneManager
|
||||
} from '@esengine/runtime-core';
|
||||
import { isValidGUID, setGlobalAssetFileLoader, type IAssetManager, type IAssetFileLoader } from '@esengine/asset-system';
|
||||
import { BrowserAssetReader } from './BrowserAssetReader';
|
||||
import { WebInputSubsystem } from './subsystems/WebInputSubsystem';
|
||||
|
||||
/**
|
||||
* Runtime configuration
|
||||
* 运行时配置
|
||||
*/
|
||||
export interface RuntimeConfig {
|
||||
/** Canvas element ID */
|
||||
canvasId: string;
|
||||
/** Canvas width (defaults to window.innerWidth) */
|
||||
width?: number;
|
||||
/** Canvas height (defaults to window.innerHeight) */
|
||||
height?: number;
|
||||
/** Asset catalog file URL (defaults to '/asset-catalog.json') */
|
||||
assetCatalogUrl?: string;
|
||||
/** Asset base URL (defaults to '/assets') */
|
||||
assetBaseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser Runtime
|
||||
* 浏览器运行时
|
||||
*
|
||||
* Main entry point for running games in browser.
|
||||
* Supports dynamic plugin registration.
|
||||
*/
|
||||
export class BrowserRuntime {
|
||||
private _runtime: GameRuntime | null = null;
|
||||
private _canvasId: string;
|
||||
private _width: number;
|
||||
private _height: number;
|
||||
private _assetCatalogUrl: string;
|
||||
private _assetBaseUrl: string;
|
||||
private _fileSystem: BrowserFileSystemService | null = null;
|
||||
private _assetReader: BrowserAssetReader | null = null;
|
||||
private _sceneManager: RuntimeSceneManager | null = null;
|
||||
private _initialized = false;
|
||||
|
||||
constructor(config: RuntimeConfig) {
|
||||
this._canvasId = config.canvasId;
|
||||
this._width = config.width ?? window.innerWidth;
|
||||
this._height = config.height ?? window.innerHeight;
|
||||
this._assetCatalogUrl = config.assetCatalogUrl ?? '/asset-catalog.json';
|
||||
this._assetBaseUrl = config.assetBaseUrl ?? '/assets';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a plugin dynamically
|
||||
* 动态注册插件
|
||||
*
|
||||
* Call this before initialize() to register plugins.
|
||||
*/
|
||||
registerPlugin(plugin: IRuntimePlugin): void {
|
||||
if (plugin) {
|
||||
runtimePluginManager.register(plugin);
|
||||
runtimePluginManager.enable(plugin.manifest.id);
|
||||
console.log(`[Runtime] Registered plugin: ${plugin.manifest.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register multiple plugins
|
||||
* 注册多个插件
|
||||
*/
|
||||
registerPlugins(plugins: IRuntimePlugin[]): void {
|
||||
for (const plugin of plugins) {
|
||||
this.registerPlugin(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the runtime
|
||||
* 初始化运行时
|
||||
*
|
||||
* @param wasmModule - Optional WASM module (from es_engine.js)
|
||||
*/
|
||||
async initialize(wasmModule?: unknown): Promise<void> {
|
||||
if (this._initialized) {
|
||||
console.warn('[Runtime] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize browser file system service
|
||||
this._fileSystem = new BrowserFileSystemService({
|
||||
baseUrl: this._assetBaseUrl,
|
||||
catalogUrl: this._assetCatalogUrl,
|
||||
enableCache: true
|
||||
});
|
||||
await this._fileSystem.initialize();
|
||||
|
||||
// Initialize asset reader
|
||||
this._assetReader = new BrowserAssetReader(this._assetBaseUrl);
|
||||
|
||||
// Create browser platform adapter
|
||||
// 创建输入子系统,用于处理键盘、鼠标、触摸事件
|
||||
// Create input subsystem for keyboard, mouse, touch events
|
||||
// 使用 'direct' 模式,因为独立 Web 构建不使用 Tauri 代理
|
||||
// Use 'direct' mode since standalone web builds don't use Tauri proxy
|
||||
const platform = new BrowserPlatformAdapter({
|
||||
wasmModule: wasmModule ?? undefined,
|
||||
inputSubsystemFactory: () => new WebInputSubsystem(),
|
||||
assetBaseUrl: this._assetBaseUrl,
|
||||
pathResolveMode: 'direct'
|
||||
});
|
||||
|
||||
// Create game runtime
|
||||
this._runtime = createGameRuntime({
|
||||
platform,
|
||||
canvasId: this._canvasId,
|
||||
width: this._width,
|
||||
height: this._height,
|
||||
autoStartRenderLoop: false
|
||||
});
|
||||
|
||||
await this._runtime.initialize();
|
||||
|
||||
// Register file system service
|
||||
const IFileSystemServiceKey = Symbol.for('IFileSystemService');
|
||||
if (!Core.services.isRegistered(IFileSystemServiceKey)) {
|
||||
Core.services.registerInstance(IFileSystemServiceKey, this._fileSystem);
|
||||
}
|
||||
|
||||
// Set asset reader for AssetManager
|
||||
// 设置资产读取器
|
||||
if (this._assetReader && this._runtime.assetManager) {
|
||||
this._runtime.assetManager.setReader(this._assetReader);
|
||||
|
||||
// Initialize AssetManager with catalog from BrowserFileSystemService
|
||||
// 使用 BrowserFileSystemService 的 catalog 初始化 AssetManager
|
||||
// Catalog format is now unified - no conversion needed
|
||||
// 目录格式已统一 - 无需转换
|
||||
if (this._fileSystem?.catalog) {
|
||||
const catalog = this._fileSystem.catalog;
|
||||
this._runtime.assetManager.initializeFromCatalog(catalog);
|
||||
}
|
||||
|
||||
// Set global asset file loader for UI atlas and other subsystems
|
||||
// 设置全局资产文件加载器供 UI 图集和其他子系统使用
|
||||
const assetFileLoader: IAssetFileLoader = {
|
||||
loadImage: (assetPath: string) => this._assetReader!.loadImage(assetPath),
|
||||
loadText: (assetPath: string) => this._assetReader!.readText(assetPath),
|
||||
loadBinary: (assetPath: string) => this._assetReader!.readBinary(assetPath),
|
||||
exists: (assetPath: string) => this._assetReader!.exists(assetPath)
|
||||
};
|
||||
setGlobalAssetFileLoader(assetFileLoader);
|
||||
}
|
||||
|
||||
// Disable editor mode (hides grid, gizmos, axis indicator)
|
||||
// 禁用编辑器模式(隐藏网格、gizmos、坐标轴指示器)
|
||||
this._runtime.setEditorMode(false);
|
||||
|
||||
// Set up asset path resolver for rendering system
|
||||
// 为渲染系统设置资产路径解析器
|
||||
this._setupAssetPathResolver();
|
||||
|
||||
// Initialize scene manager
|
||||
// 初始化场景管理器
|
||||
this._initializeSceneManager();
|
||||
|
||||
this._initialized = true;
|
||||
console.log('[Runtime] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the runtime scene manager
|
||||
* 初始化运行时场景管理器
|
||||
*/
|
||||
private _initializeSceneManager(): void {
|
||||
if (!this._runtime) return;
|
||||
|
||||
// Create scene manager with scene loader
|
||||
// 使用场景加载器创建场景管理器
|
||||
this._sceneManager = new RuntimeSceneManager(
|
||||
(url: string) => this._runtime!.loadSceneFromUrl(url),
|
||||
'./scenes'
|
||||
);
|
||||
|
||||
// Auto-discover scenes from catalog
|
||||
// 从目录自动发现场景
|
||||
// scenes 是运行时扩展字段,不在 IAssetCatalog 接口中
|
||||
// scenes is a runtime extension field, not in IAssetCatalog interface
|
||||
const catalog = this._fileSystem?.catalog as { scenes?: Array<{ name: string; path: string }> } | null;
|
||||
if (catalog?.scenes) {
|
||||
const scenes = catalog.scenes.map((scene) => ({
|
||||
name: scene.name,
|
||||
path: `./scenes/${scene.name}.ecs`
|
||||
}));
|
||||
this._sceneManager.registerScenes(scenes);
|
||||
}
|
||||
|
||||
// Register scene manager as a service
|
||||
// 注册场景管理器为服务
|
||||
const serviceRegistry = this._runtime.getServiceRegistry();
|
||||
if (serviceRegistry) {
|
||||
serviceRegistry.register(RuntimeSceneManagerToken, this._sceneManager);
|
||||
}
|
||||
|
||||
// Also register in Core.services for global access (systems can access it)
|
||||
// 同时注册到 Core.services 供全局访问(系统可以访问)
|
||||
// RuntimeSceneManager 实现了 IService 接口(有 dispose 方法)
|
||||
// RuntimeSceneManager implements IService interface (has dispose method)
|
||||
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
|
||||
if (!Core.services.isRegistered(GlobalSceneManagerKey)) {
|
||||
Core.services.registerInstance(GlobalSceneManagerKey, this._sceneManager);
|
||||
}
|
||||
|
||||
console.log('[Runtime] Scene manager initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up asset path resolver for the render system
|
||||
* 为渲染系统设置资产路径解析器
|
||||
*
|
||||
* This enables GUID-to-URL resolution for textures in Web runtime.
|
||||
* 这使得 Web 运行时能够将 GUID 解析为纹理 URL。
|
||||
*/
|
||||
private _setupAssetPathResolver(): void {
|
||||
const renderSystem = this._runtime?.renderSystem;
|
||||
const assetManager = this._runtime?.assetManager;
|
||||
if (!renderSystem || !assetManager) return;
|
||||
|
||||
const assetBaseUrl = this._assetBaseUrl;
|
||||
|
||||
renderSystem.setAssetPathResolver((guidOrPath: string): string => {
|
||||
// Skip if already a valid URL
|
||||
// 如果已经是有效的 URL 则跳过
|
||||
if (!guidOrPath || guidOrPath.startsWith('http') || guidOrPath.startsWith('data:') || guidOrPath.startsWith('/')) {
|
||||
return guidOrPath;
|
||||
}
|
||||
|
||||
// Check if this is a GUID using the unified validation function
|
||||
// 使用统一的验证函数检查是否为 GUID
|
||||
if (isValidGUID(guidOrPath)) {
|
||||
// Get metadata from runtime's asset manager
|
||||
// 从运行时资产管理器获取元数据
|
||||
const metadata = assetManager.getDatabase().getMetadata(guidOrPath);
|
||||
if (metadata?.path) {
|
||||
// Construct full URL: baseUrl + relative path
|
||||
// 构建完整 URL:baseUrl + 相对路径
|
||||
let relativePath = metadata.path.replace(/\\/g, '/');
|
||||
|
||||
// Remove 'assets/' prefix if present to avoid duplication
|
||||
// 移除 'assets/' 前缀以避免重复(assetBaseUrl 已包含 /assets)
|
||||
if (relativePath.startsWith('assets/')) {
|
||||
relativePath = relativePath.substring(7);
|
||||
}
|
||||
|
||||
return `${assetBaseUrl}/${relativePath}`;
|
||||
}
|
||||
|
||||
// GUID not found in catalog, return original
|
||||
// 目录中未找到 GUID,返回原值
|
||||
console.warn(`[BrowserRuntime] GUID not found in asset catalog: ${guidOrPath}`);
|
||||
return guidOrPath;
|
||||
}
|
||||
|
||||
// Not a GUID, treat as relative path
|
||||
// 不是 GUID,视为相对路径
|
||||
return `${assetBaseUrl}/${guidOrPath}`;
|
||||
});
|
||||
|
||||
console.log('[Runtime] Asset path resolver configured');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a scene from URL
|
||||
* 从 URL 加载场景
|
||||
*
|
||||
* @param sceneUrl 场景 URL 或名称 | Scene URL or name
|
||||
*/
|
||||
async loadScene(sceneUrl: string): Promise<void> {
|
||||
if (!this._runtime) {
|
||||
throw new Error('Runtime not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
// Use scene manager if available for proper tracking
|
||||
// 如果可用,使用场景管理器进行正确跟踪
|
||||
if (this._sceneManager) {
|
||||
await this._sceneManager.loadSceneByPath(sceneUrl);
|
||||
} else {
|
||||
await this._runtime.loadSceneFromUrl(sceneUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a scene from data object (for single-file mode)
|
||||
* 从数据对象加载场景(用于单文件模式)
|
||||
*/
|
||||
async loadSceneFromData(sceneData: unknown): Promise<void> {
|
||||
if (!this._runtime) {
|
||||
throw new Error('Runtime not initialized. Call initialize() first.');
|
||||
}
|
||||
await this._runtime.loadSceneFromData(sceneData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the game loop
|
||||
* 启动游戏循环
|
||||
*/
|
||||
start(): void {
|
||||
this._runtime?.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the game loop
|
||||
* 停止游戏循环
|
||||
*/
|
||||
stop(): void {
|
||||
this._runtime?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle window resize
|
||||
* 处理窗口大小变化
|
||||
*/
|
||||
handleResize(width: number, height: number): void {
|
||||
this._runtime?.resize(width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying GameRuntime
|
||||
* 获取底层 GameRuntime
|
||||
*/
|
||||
get gameRuntime(): GameRuntime | null {
|
||||
return this._runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the asset manager
|
||||
* 获取资产管理器
|
||||
*
|
||||
* @returns The asset manager instance, or null if not initialized
|
||||
*/
|
||||
get assetManager(): IAssetManager | null {
|
||||
return this._runtime?.assetManager ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scene manager
|
||||
* 获取场景管理器
|
||||
*
|
||||
* Use this to load scenes, check available scenes, and listen to scene events.
|
||||
* 使用它来加载场景、检查可用场景和监听场景事件。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Load a scene by name
|
||||
* await runtime.sceneManager?.loadScene('Level1');
|
||||
*
|
||||
* // Get list of available scenes
|
||||
* const scenes = runtime.sceneManager?.availableScenes;
|
||||
*
|
||||
* // Listen to scene load events
|
||||
* runtime.sceneManager?.onLoadComplete((sceneName) => {
|
||||
* console.log(`Scene loaded: ${sceneName}`);
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @returns The scene manager instance, or null if not initialized
|
||||
*/
|
||||
get sceneManager(): IRuntimeSceneManager | null {
|
||||
return this._sceneManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime is initialized
|
||||
* 检查运行时是否已初始化
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this._initialized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a browser runtime instance
|
||||
* 创建浏览器运行时实例
|
||||
*/
|
||||
export function create(config: RuntimeConfig): BrowserRuntime {
|
||||
return new BrowserRuntime(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default export for convenient usage
|
||||
* 默认导出,便于使用
|
||||
*
|
||||
* Usage in game HTML:
|
||||
* ```js
|
||||
* const ECSRuntime = (await import('@esengine/platform-web')).default;
|
||||
* const runtime = ECSRuntime.create({ canvasId: 'game-canvas' });
|
||||
* ```
|
||||
*/
|
||||
export default {
|
||||
create,
|
||||
BrowserRuntime,
|
||||
BrowserAssetReader,
|
||||
// Re-export useful types from dependencies
|
||||
Core,
|
||||
GameRuntime,
|
||||
createGameRuntime,
|
||||
BrowserPlatformAdapter,
|
||||
runtimePluginManager
|
||||
};
|
||||
254
packages/engine/platform-web/src/EngineBridge.ts
Normal file
254
packages/engine/platform-web/src/EngineBridge.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Rust 引擎桥接层
|
||||
* 负责在 Web 环境中初始化和管理 Rust WASM 引擎
|
||||
*/
|
||||
|
||||
import type { IPlatformCanvas, CanvasContextAttributes } from '@esengine/platform-common';
|
||||
import { WebCanvasSubsystem } from './subsystems/WebCanvasSubsystem';
|
||||
|
||||
/**
|
||||
* 引擎配置
|
||||
*/
|
||||
export interface EngineBridgeConfig {
|
||||
wasmPath: string;
|
||||
canvasId?: string;
|
||||
canvasWidth?: number;
|
||||
canvasHeight?: number;
|
||||
contextAttributes?: CanvasContextAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* GameEngine WASM 模块导出接口
|
||||
*/
|
||||
interface GameEngineExports {
|
||||
memory: WebAssembly.Memory;
|
||||
new: (canvasIdPtr: number, canvasIdLen: number) => number;
|
||||
fromExternal: (glContext: any, width: number, height: number) => any;
|
||||
clear: (engine: any, r: number, g: number, b: number, a: number) => void;
|
||||
render: (engine: any) => void;
|
||||
width: (engine: any) => number;
|
||||
height: (engine: any) => number;
|
||||
submitSpriteBatch: (
|
||||
engine: any,
|
||||
transforms: any,
|
||||
textureIds: any,
|
||||
uvs: any,
|
||||
colors: any
|
||||
) => void;
|
||||
loadTexture: (engine: any, id: number, urlPtr: number, urlLen: number) => void;
|
||||
isKeyDown: (engine: any, keyCodePtr: number, keyCodeLen: number) => boolean;
|
||||
updateInput: (engine: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎桥接层
|
||||
* 将 Web 平台能力桥接到 Rust WASM 引擎
|
||||
*/
|
||||
export class EngineBridge {
|
||||
private _canvasSubsystem: WebCanvasSubsystem;
|
||||
private _canvas: IPlatformCanvas;
|
||||
private _gl: WebGL2RenderingContext | null = null;
|
||||
private _wasmModule: WebAssembly.Module | null = null;
|
||||
private _wasmInstance: WebAssembly.Instance | null = null;
|
||||
private _gameEngine: any = null;
|
||||
private _config: EngineBridgeConfig;
|
||||
|
||||
constructor(config: EngineBridgeConfig) {
|
||||
this._config = config;
|
||||
this._canvasSubsystem = new WebCanvasSubsystem();
|
||||
|
||||
const width = config.canvasWidth ?? window.innerWidth;
|
||||
const height = config.canvasHeight ?? window.innerHeight;
|
||||
|
||||
if (config.canvasId) {
|
||||
const existingCanvas = document.getElementById(config.canvasId) as HTMLCanvasElement;
|
||||
if (existingCanvas) {
|
||||
existingCanvas.width = width;
|
||||
existingCanvas.height = height;
|
||||
this._canvas = this._wrapExistingCanvas(existingCanvas);
|
||||
} else {
|
||||
this._canvas = this._canvasSubsystem.createCanvas(width, height);
|
||||
}
|
||||
} else {
|
||||
this._canvas = this._canvasSubsystem.createCanvas(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
private _wrapExistingCanvas(canvas: HTMLCanvasElement): IPlatformCanvas {
|
||||
return {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
getContext: (type: string, attrs: any) => canvas.getContext(type, attrs as WebGLContextAttributes),
|
||||
toDataURL: () => canvas.toDataURL(),
|
||||
toTempFilePath: () => {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
} as IPlatformCanvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化引擎
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
this._gl = this._getWebGLContext();
|
||||
if (!this._gl) {
|
||||
throw new Error('无法获取 WebGL2 上下文');
|
||||
}
|
||||
|
||||
const imports = this._createWASMImports();
|
||||
const response = await fetch(this._config.wasmPath);
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
const result = await WebAssembly.instantiate(buffer, imports);
|
||||
this._wasmModule = result.module;
|
||||
this._wasmInstance = result.instance;
|
||||
|
||||
const exports = this._wasmInstance.exports as unknown as GameEngineExports;
|
||||
|
||||
if (typeof exports.fromExternal === 'function') {
|
||||
this._gameEngine = exports.fromExternal(
|
||||
this._gl,
|
||||
this._canvas.width,
|
||||
this._canvas.height
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebGL2 上下文
|
||||
*/
|
||||
private _getWebGLContext(): WebGL2RenderingContext | null {
|
||||
const attrs = this._config.contextAttributes ?? {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
premultipliedAlpha: true,
|
||||
preserveDrawingBuffer: false
|
||||
};
|
||||
|
||||
return this._canvas.getContext('webgl2', attrs) as WebGL2RenderingContext | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 WASM 导入对象
|
||||
*/
|
||||
private _createWASMImports(): WebAssembly.Imports {
|
||||
return {
|
||||
env: {
|
||||
memory: new WebAssembly.Memory({ initial: 256, maximum: 16384 }),
|
||||
|
||||
platform_log: (ptr: number, len: number) => {
|
||||
const message = this._readString(ptr, len);
|
||||
console.log('[Engine]', message);
|
||||
},
|
||||
|
||||
platform_error: (ptr: number, len: number) => {
|
||||
const message = this._readString(ptr, len);
|
||||
console.error('[Engine]', message);
|
||||
},
|
||||
|
||||
platform_now: () => {
|
||||
return performance.now();
|
||||
}
|
||||
},
|
||||
wbg: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 WASM 内存读取字符串
|
||||
*/
|
||||
private _readString(ptr: number, len: number): string {
|
||||
if (!this._wasmInstance) return '';
|
||||
|
||||
const memory = this._wasmInstance.exports.memory as WebAssembly.Memory;
|
||||
const bytes = new Uint8Array(memory.buffer, ptr, len);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Canvas
|
||||
*/
|
||||
get canvas(): IPlatformCanvas {
|
||||
return this._canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebGL 上下文
|
||||
*/
|
||||
get gl(): WebGL2RenderingContext | null {
|
||||
return this._gl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WASM 实例
|
||||
*/
|
||||
get wasmInstance(): WebAssembly.Instance | null {
|
||||
return this._wasmInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 GameEngine 实例
|
||||
*/
|
||||
get gameEngine(): any {
|
||||
return this._gameEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清屏
|
||||
*/
|
||||
clear(r: number, g: number, b: number, a: number): void {
|
||||
if (this._gl) {
|
||||
this._gl.clearColor(r, g, b, a);
|
||||
this._gl.clear(this._gl.COLOR_BUFFER_BIT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染一帧
|
||||
*/
|
||||
render(): void {
|
||||
if (this._wasmInstance && this._gameEngine) {
|
||||
const exports = this._wasmInstance.exports as unknown as GameEngineExports;
|
||||
if (exports.render) {
|
||||
exports.render(this._gameEngine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取画布宽度
|
||||
*/
|
||||
get width(): number {
|
||||
return this._canvas.width;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取画布高度
|
||||
*/
|
||||
get height(): number {
|
||||
return this._canvas.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整画布大小
|
||||
*/
|
||||
resize(width: number, height: number): void {
|
||||
this._canvas.width = width;
|
||||
this._canvas.height = height;
|
||||
if (this._gl) {
|
||||
this._gl.viewport(0, 0, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁引擎
|
||||
*/
|
||||
dispose(): void {
|
||||
this._gameEngine = null;
|
||||
this._wasmInstance = null;
|
||||
this._wasmModule = null;
|
||||
this._gl = null;
|
||||
}
|
||||
}
|
||||
82
packages/engine/platform-web/src/index.ts
Normal file
82
packages/engine/platform-web/src/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @esengine/platform-web
|
||||
*
|
||||
* Web/H5 Platform Adapter
|
||||
* Web/H5 平台适配器
|
||||
*
|
||||
* Provides browser-specific implementations:
|
||||
* - BrowserRuntime: Main entry point for game builds
|
||||
* - BrowserAssetReader: Asset loading via fetch API
|
||||
* - Web subsystems: Canvas, Input, Storage, WASM
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Runtime (main entry for game builds)
|
||||
// ============================================
|
||||
export {
|
||||
BrowserRuntime,
|
||||
create,
|
||||
type RuntimeConfig
|
||||
} from './BrowserRuntime';
|
||||
|
||||
// Default export for convenient usage in game builds
|
||||
export { default } from './BrowserRuntime';
|
||||
|
||||
// Asset reader
|
||||
export { BrowserAssetReader } from './BrowserAssetReader';
|
||||
|
||||
// Re-export scene manager for convenience
|
||||
// 重新导出场景管理器以方便使用
|
||||
export {
|
||||
RuntimeSceneManager,
|
||||
RuntimeSceneManagerToken,
|
||||
type IRuntimeSceneManager,
|
||||
type SceneInfo,
|
||||
type SceneLoadOptions
|
||||
} from '@esengine/runtime-core';
|
||||
|
||||
// ============================================
|
||||
// Web Platform Subsystems
|
||||
// ============================================
|
||||
export { WebCanvasSubsystem } from './subsystems/WebCanvasSubsystem';
|
||||
export { WebInputSubsystem } from './subsystems/WebInputSubsystem';
|
||||
export { WebStorageSubsystem } from './subsystems/WebStorageSubsystem';
|
||||
export { WebWASMSubsystem } from './subsystems/WebWASMSubsystem';
|
||||
|
||||
// ============================================
|
||||
// Web-specific Systems
|
||||
// ============================================
|
||||
export { Canvas2DRenderSystem } from './systems/Canvas2DRenderSystem';
|
||||
|
||||
// ============================================
|
||||
// Platform Detection Utilities
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if running in web browser
|
||||
* 检查是否在浏览器中运行
|
||||
*/
|
||||
export function isWebPlatform(): boolean {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get canvas element by ID
|
||||
* 根据 ID 获取 canvas 元素
|
||||
*/
|
||||
export function getWebCanvas(canvasId: string): HTMLCanvasElement | null {
|
||||
return document.getElementById(canvasId) as HTMLCanvasElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new canvas element
|
||||
* 创建新的 canvas 元素
|
||||
*/
|
||||
export function createWebCanvas(width: number, height: number): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
336
packages/engine/platform-web/src/subsystems/WebInputSubsystem.ts
Normal file
336
packages/engine/platform-web/src/subsystems/WebInputSubsystem.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Web 平台输入子系统
|
||||
* Web platform input subsystem
|
||||
*/
|
||||
|
||||
import type {
|
||||
IPlatformInputSubsystem,
|
||||
TouchHandler,
|
||||
TouchEvent,
|
||||
KeyboardHandler,
|
||||
KeyboardEventInfo,
|
||||
MouseHandler,
|
||||
MouseEventInfo,
|
||||
WheelHandler,
|
||||
WheelEventInfo
|
||||
} from '@esengine/platform-common';
|
||||
import { MouseButton } from '@esengine/platform-common';
|
||||
|
||||
/**
|
||||
* Web 平台输入子系统实现
|
||||
* Web platform input subsystem implementation
|
||||
*/
|
||||
export class WebInputSubsystem implements IPlatformInputSubsystem {
|
||||
// ========== Touch handlers ==========
|
||||
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();
|
||||
|
||||
// ========== Keyboard handlers ==========
|
||||
private _keyDownHandlers: Map<KeyboardHandler, (e: globalThis.KeyboardEvent) => void> = new Map();
|
||||
private _keyUpHandlers: Map<KeyboardHandler, (e: globalThis.KeyboardEvent) => void> = new Map();
|
||||
|
||||
// ========== Mouse handlers ==========
|
||||
private _mouseMoveHandlers: Map<MouseHandler, (e: globalThis.MouseEvent) => void> = new Map();
|
||||
private _mouseDownHandlers: Map<MouseHandler, (e: globalThis.MouseEvent) => void> = new Map();
|
||||
private _mouseUpHandlers: Map<MouseHandler, (e: globalThis.MouseEvent) => void> = new Map();
|
||||
private _wheelHandlers: Map<WheelHandler, (e: globalThis.WheelEvent) => void> = new Map();
|
||||
|
||||
// ========== Touch events ==========
|
||||
|
||||
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 typeof Touch !== 'undefined' && 'force' in Touch.prototype;
|
||||
}
|
||||
|
||||
// ========== Keyboard events ==========
|
||||
|
||||
onKeyDown(handler: KeyboardHandler): void {
|
||||
const nativeHandler = (e: globalThis.KeyboardEvent) => {
|
||||
handler(this.convertKeyboardEvent(e));
|
||||
};
|
||||
this._keyDownHandlers.set(handler, nativeHandler);
|
||||
window.addEventListener('keydown', nativeHandler);
|
||||
}
|
||||
|
||||
onKeyUp(handler: KeyboardHandler): void {
|
||||
const nativeHandler = (e: globalThis.KeyboardEvent) => {
|
||||
handler(this.convertKeyboardEvent(e));
|
||||
};
|
||||
this._keyUpHandlers.set(handler, nativeHandler);
|
||||
window.addEventListener('keyup', nativeHandler);
|
||||
}
|
||||
|
||||
offKeyDown(handler: KeyboardHandler): void {
|
||||
const nativeHandler = this._keyDownHandlers.get(handler);
|
||||
if (nativeHandler) {
|
||||
window.removeEventListener('keydown', nativeHandler);
|
||||
this._keyDownHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
offKeyUp(handler: KeyboardHandler): void {
|
||||
const nativeHandler = this._keyUpHandlers.get(handler);
|
||||
if (nativeHandler) {
|
||||
window.removeEventListener('keyup', nativeHandler);
|
||||
this._keyUpHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Mouse events ==========
|
||||
|
||||
onMouseMove(handler: MouseHandler): void {
|
||||
const nativeHandler = (e: globalThis.MouseEvent) => {
|
||||
handler(this.convertMouseEvent(e));
|
||||
};
|
||||
this._mouseMoveHandlers.set(handler, nativeHandler);
|
||||
window.addEventListener('mousemove', nativeHandler);
|
||||
}
|
||||
|
||||
onMouseDown(handler: MouseHandler): void {
|
||||
const nativeHandler = (e: globalThis.MouseEvent) => {
|
||||
handler(this.convertMouseEvent(e));
|
||||
};
|
||||
this._mouseDownHandlers.set(handler, nativeHandler);
|
||||
window.addEventListener('mousedown', nativeHandler);
|
||||
}
|
||||
|
||||
onMouseUp(handler: MouseHandler): void {
|
||||
const nativeHandler = (e: globalThis.MouseEvent) => {
|
||||
handler(this.convertMouseEvent(e));
|
||||
};
|
||||
this._mouseUpHandlers.set(handler, nativeHandler);
|
||||
window.addEventListener('mouseup', nativeHandler);
|
||||
}
|
||||
|
||||
onWheel(handler: WheelHandler): void {
|
||||
const nativeHandler = (e: globalThis.WheelEvent) => {
|
||||
handler(this.convertWheelEvent(e));
|
||||
};
|
||||
this._wheelHandlers.set(handler, nativeHandler);
|
||||
window.addEventListener('wheel', nativeHandler);
|
||||
}
|
||||
|
||||
offMouseMove(handler: MouseHandler): void {
|
||||
const nativeHandler = this._mouseMoveHandlers.get(handler);
|
||||
if (nativeHandler) {
|
||||
window.removeEventListener('mousemove', nativeHandler);
|
||||
this._mouseMoveHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
offMouseDown(handler: MouseHandler): void {
|
||||
const nativeHandler = this._mouseDownHandlers.get(handler);
|
||||
if (nativeHandler) {
|
||||
window.removeEventListener('mousedown', nativeHandler);
|
||||
this._mouseDownHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
offMouseUp(handler: MouseHandler): void {
|
||||
const nativeHandler = this._mouseUpHandlers.get(handler);
|
||||
if (nativeHandler) {
|
||||
window.removeEventListener('mouseup', nativeHandler);
|
||||
this._mouseUpHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
offWheel(handler: WheelHandler): void {
|
||||
const nativeHandler = this._wheelHandlers.get(handler);
|
||||
if (nativeHandler) {
|
||||
window.removeEventListener('wheel', nativeHandler);
|
||||
this._wheelHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Capability queries ==========
|
||||
|
||||
supportsKeyboard(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
supportsMouse(): boolean {
|
||||
// 检测是否有鼠标设备 | Check if mouse device exists
|
||||
return window.matchMedia('(pointer: fine)').matches;
|
||||
}
|
||||
|
||||
// ========== Event converters ==========
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
private convertKeyboardEvent(e: globalThis.KeyboardEvent): KeyboardEventInfo {
|
||||
return {
|
||||
code: e.code,
|
||||
key: e.key,
|
||||
altKey: e.altKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
shiftKey: e.shiftKey,
|
||||
metaKey: e.metaKey,
|
||||
repeat: e.repeat,
|
||||
timeStamp: e.timeStamp
|
||||
};
|
||||
}
|
||||
|
||||
private convertMouseEvent(e: globalThis.MouseEvent): MouseEventInfo {
|
||||
return {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
movementX: e.movementX,
|
||||
movementY: e.movementY,
|
||||
button: e.button as MouseButton,
|
||||
buttons: e.buttons,
|
||||
altKey: e.altKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
shiftKey: e.shiftKey,
|
||||
metaKey: e.metaKey,
|
||||
timeStamp: e.timeStamp
|
||||
};
|
||||
}
|
||||
|
||||
private convertWheelEvent(e: globalThis.WheelEvent): WheelEventInfo {
|
||||
return {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
deltaX: e.deltaX,
|
||||
deltaY: e.deltaY,
|
||||
deltaZ: e.deltaZ,
|
||||
timeStamp: e.timeStamp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁并移除所有事件监听器
|
||||
* Dispose and remove all event listeners
|
||||
*/
|
||||
dispose(): void {
|
||||
// 清理触摸事件 | Clean up touch events
|
||||
this._touchStartHandlers.forEach((handler) => {
|
||||
window.removeEventListener('touchstart', handler);
|
||||
});
|
||||
this._touchStartHandlers.clear();
|
||||
|
||||
this._touchMoveHandlers.forEach((handler) => {
|
||||
window.removeEventListener('touchmove', handler);
|
||||
});
|
||||
this._touchMoveHandlers.clear();
|
||||
|
||||
this._touchEndHandlers.forEach((handler) => {
|
||||
window.removeEventListener('touchend', handler);
|
||||
});
|
||||
this._touchEndHandlers.clear();
|
||||
|
||||
this._touchCancelHandlers.forEach((handler) => {
|
||||
window.removeEventListener('touchcancel', handler);
|
||||
});
|
||||
this._touchCancelHandlers.clear();
|
||||
|
||||
// 清理键盘事件 | Clean up keyboard events
|
||||
this._keyDownHandlers.forEach((handler) => {
|
||||
window.removeEventListener('keydown', handler);
|
||||
});
|
||||
this._keyDownHandlers.clear();
|
||||
|
||||
this._keyUpHandlers.forEach((handler) => {
|
||||
window.removeEventListener('keyup', handler);
|
||||
});
|
||||
this._keyUpHandlers.clear();
|
||||
|
||||
// 清理鼠标事件 | Clean up mouse events
|
||||
this._mouseMoveHandlers.forEach((handler) => {
|
||||
window.removeEventListener('mousemove', handler);
|
||||
});
|
||||
this._mouseMoveHandlers.clear();
|
||||
|
||||
this._mouseDownHandlers.forEach((handler) => {
|
||||
window.removeEventListener('mousedown', handler);
|
||||
});
|
||||
this._mouseDownHandlers.clear();
|
||||
|
||||
this._mouseUpHandlers.forEach((handler) => {
|
||||
window.removeEventListener('mouseup', handler);
|
||||
});
|
||||
this._mouseUpHandlers.clear();
|
||||
|
||||
this._wheelHandlers.forEach((handler) => {
|
||||
window.removeEventListener('wheel', handler);
|
||||
});
|
||||
this._wheelHandlers.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Web 平台存储子系统
|
||||
*/
|
||||
|
||||
import type {
|
||||
IPlatformStorageSubsystem,
|
||||
StorageInfo
|
||||
} from '@esengine/platform-common';
|
||||
|
||||
/**
|
||||
* Web 平台存储子系统实现
|
||||
*/
|
||||
export class WebStorageSubsystem implements IPlatformStorageSubsystem {
|
||||
getStorageSync<T = any>(key: string): T | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
setStorageSync<T = any>(key: string, value: T): void {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
removeStorageSync(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
clearStorageSync(): void {
|
||||
localStorage.clear();
|
||||
}
|
||||
|
||||
getStorageInfoSync(): StorageInfo {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
let currentSize = 0;
|
||||
for (const key of keys) {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value) {
|
||||
currentSize += key.length + value.length;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
keys,
|
||||
currentSize: Math.ceil(currentSize / 1024),
|
||||
limitSize: 5 * 1024
|
||||
};
|
||||
}
|
||||
|
||||
async getStorage<T = any>(key: string): Promise<T | undefined> {
|
||||
return this.getStorageSync<T>(key);
|
||||
}
|
||||
|
||||
async setStorage<T = any>(key: string, value: T): Promise<void> {
|
||||
this.setStorageSync(key, value);
|
||||
}
|
||||
|
||||
async removeStorage(key: string): Promise<void> {
|
||||
this.removeStorageSync(key);
|
||||
}
|
||||
|
||||
async clearStorage(): Promise<void> {
|
||||
this.clearStorageSync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Web 平台 WASM 子系统
|
||||
*/
|
||||
|
||||
import type {
|
||||
IPlatformWASMSubsystem,
|
||||
IWASMInstance,
|
||||
WASMImports,
|
||||
WASMExports
|
||||
} from '@esengine/platform-common';
|
||||
|
||||
/**
|
||||
* Web 平台 WASM 子系统实现
|
||||
*/
|
||||
export class WebWASMSubsystem implements IPlatformWASMSubsystem {
|
||||
async instantiate(path: string, imports?: WASMImports): Promise<IWASMInstance> {
|
||||
const response = await fetch(path);
|
||||
const buffer = await response.arrayBuffer();
|
||||
const result = await WebAssembly.instantiate(buffer, imports);
|
||||
|
||||
return {
|
||||
exports: result.instance.exports as WASMExports
|
||||
};
|
||||
}
|
||||
|
||||
isSupported(): boolean {
|
||||
return typeof WebAssembly !== 'undefined';
|
||||
}
|
||||
|
||||
createMemory(initial: number, maximum?: number): WebAssembly.Memory {
|
||||
return new WebAssembly.Memory({
|
||||
initial,
|
||||
maximum
|
||||
});
|
||||
}
|
||||
|
||||
createTable(initial: number, maximum?: number): WebAssembly.Table {
|
||||
return new WebAssembly.Table({
|
||||
element: 'anyfunc',
|
||||
initial,
|
||||
maximum
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Canvas 2D Render System
|
||||
* Canvas 2D 渲染系统
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, ECSSystem, Core } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
|
||||
@ECSSystem('Canvas2DRender', { updateOrder: 1000 })
|
||||
export class Canvas2DRenderSystem extends EntitySystem {
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private textureCache = new Map<string, HTMLImageElement>();
|
||||
|
||||
constructor(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) {
|
||||
super(Matcher.empty());
|
||||
this.ctx = ctx;
|
||||
this.canvas = canvas;
|
||||
}
|
||||
|
||||
async loadTexture(path: string): Promise<void> {
|
||||
if (this.textureCache.has(path)) return;
|
||||
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
const urlPath = `/asset?path=${encodeURIComponent(path)}`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error(`Failed to load: ${path}`));
|
||||
img.src = urlPath;
|
||||
});
|
||||
|
||||
this.textureCache.set(path, img);
|
||||
} catch (error) {
|
||||
console.warn('Texture load failed:', path, error);
|
||||
}
|
||||
}
|
||||
|
||||
update(): void {
|
||||
this.ctx.fillStyle = '#1a1a1a';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
if (!Core.scene) return;
|
||||
|
||||
for (const entity of Core.scene.entities.buffer) {
|
||||
if (!entity.enabled) continue;
|
||||
|
||||
const transform = entity.getComponent(TransformComponent) as TransformComponent | null;
|
||||
const sprite = entity.getComponent(SpriteComponent) as SpriteComponent | null;
|
||||
|
||||
if (!transform || !sprite) continue;
|
||||
|
||||
this.ctx.save();
|
||||
|
||||
// 使用世界变换(由 TransformSystem 计算,考虑父级变换)
|
||||
const x = (transform.worldPosition.x || 0) + this.canvas.width / 2;
|
||||
const y = this.canvas.height / 2 - (transform.worldPosition.y || 0);
|
||||
const width = (sprite.width || 64) * (transform.worldScale.x || 1);
|
||||
const height = (sprite.height || 64) * (transform.worldScale.y || 1);
|
||||
const rotation = -(transform.worldRotation.z || 0) * Math.PI / 180;
|
||||
|
||||
this.ctx.translate(x, y);
|
||||
this.ctx.rotate(rotation);
|
||||
|
||||
const texture = this.textureCache.get(sprite.textureGuid || '');
|
||||
if (texture) {
|
||||
this.ctx.drawImage(texture, -width / 2, -height / 2, width, height);
|
||||
} else {
|
||||
this.ctx.fillStyle = sprite.color || '#ffffff';
|
||||
this.ctx.fillRect(-width / 2, -height / 2, width, height);
|
||||
}
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
37
packages/engine/platform-web/tsconfig.json
Normal file
37
packages/engine/platform-web/tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../engine/platform-common"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user