feat: 添加跨平台运行时、资产系统和UI适配功能 (#256)

* feat(platform-common): 添加WASM加载器和环境检测API

* feat(rapier2d): 新增Rapier2D WASM绑定包

* feat(physics-rapier2d): 添加跨平台WASM加载器

* feat(asset-system): 添加运行时资产目录和bundle格式

* feat(asset-system-editor): 新增编辑器资产管理包

* feat(editor-core): 添加构建系统和模块管理

* feat(editor-app): 重构浏览器预览使用import maps

* feat(platform-web): 添加BrowserRuntime和资产读取

* feat(engine): 添加材质系统和着色器管理

* feat(material): 新增材质系统和着色器编辑器

* feat(tilemap): 增强tilemap编辑器和动画系统

* feat(modules): 添加module.json配置

* feat(core): 添加module.json和类型定义更新

* chore: 更新依赖和构建配置

* refactor(plugins): 更新插件模板使用ModuleManifest

* chore: 添加第三方依赖库

* chore: 移除BehaviourTree-ai和ecs-astar子模块

* docs: 更新README和文档主题样式

* fix: 修复Rust文档测试和添加rapier2d WASM绑定

* fix(tilemap-editor): 修复画布高DPI屏幕分辨率适配问题

* feat(ui): 添加UI屏幕适配系统(CanvasScaler/SafeArea)

* fix(ecs-engine-bindgen): 添加缺失的ecs-framework-math依赖

* fix: 添加缺失的包依赖修复CI构建

* fix: 修复CodeQL检测到的代码问题

* fix: 修复构建错误和缺失依赖

* fix: 修复类型检查错误

* fix(material-system): 修复tsconfig配置支持TypeScript项目引用

* fix(editor-core): 修复Rollup构建配置添加tauri external

* fix: 修复CodeQL检测到的代码问题

* fix: 修复CodeQL检测到的代码问题
This commit is contained in:
YHH
2025-12-03 22:15:22 +08:00
committed by GitHub
parent caf7622aa0
commit 63f006ab62
496 changed files with 77601 additions and 4067 deletions

View File

@@ -0,0 +1,243 @@
/**
* Build File System Service.
* 构建文件系统服务。
*
* Provides file operations for build pipelines via Tauri commands.
* 通过 Tauri 命令为构建管线提供文件操作。
*/
import { invoke } from '@tauri-apps/api/core';
/**
* Bundle options.
* 打包选项。
*/
export interface BundleOptions {
/** Entry files | 入口文件 */
entryPoints: string[];
/** Output directory | 输出目录 */
outputDir: string;
/** Output format (esm or iife) | 输出格式 */
format: 'esm' | 'iife';
/** Bundle name | 打包名称 */
bundleName: string;
/** Whether to minify | 是否压缩 */
minify: boolean;
/** Whether to generate source map | 是否生成 source map */
sourceMap: boolean;
/** External dependencies | 外部依赖 */
external: string[];
/** Project root for resolving imports | 项目根目录 */
projectRoot: string;
/** Define replacements | 宏定义替换 */
define?: Record<string, string>;
}
/**
* Bundle result.
* 打包结果。
*/
export interface BundleResult {
/** Whether bundling succeeded | 是否打包成功 */
success: boolean;
/** Output file path | 输出文件路径 */
outputFile?: string;
/** Output file size in bytes | 输出文件大小 */
outputSize?: number;
/** Error message if failed | 失败时的错误信息 */
error?: string;
/** Warnings | 警告 */
warnings: string[];
}
/**
* Build File System Service.
* 构建文件系统服务。
*/
export class BuildFileSystemService {
/**
* Prepare build directory (clean and recreate).
* 准备构建目录(清理并重建)。
*
* @param outputPath - Output directory path | 输出目录路径
*/
async prepareBuildDirectory(outputPath: string): Promise<void> {
await invoke('prepare_build_directory', { outputPath });
}
/**
* Copy directory recursively.
* 递归复制目录。
*
* @param src - Source directory | 源目录
* @param dst - Destination directory | 目标目录
* @param patterns - File patterns to include (e.g. ["*.png", "*.json"]) | 要包含的文件模式
* @returns Number of files copied | 复制的文件数量
*/
async copyDirectory(
src: string,
dst: string,
patterns?: string[]
): Promise<number> {
return await invoke('copy_directory', { src, dst, patterns });
}
/**
* Bundle scripts using esbuild.
* 使用 esbuild 打包脚本。
*
* @param options - Bundle options | 打包选项
* @returns Bundle result | 打包结果
*/
async bundleScripts(options: BundleOptions): Promise<BundleResult> {
return await invoke('bundle_scripts', { options });
}
/**
* Generate HTML file.
* 生成 HTML 文件。
*
* @param outputPath - Output file path | 输出文件路径
* @param title - Page title | 页面标题
* @param scripts - Script paths to include | 要包含的脚本路径
* @param bodyContent - Custom body content | 自定义 body 内容
*/
async generateHtml(
outputPath: string,
title: string,
scripts: string[],
bodyContent?: string
): Promise<void> {
await invoke('generate_html', { outputPath, title, scripts, bodyContent });
}
/**
* Get file size.
* 获取文件大小。
*
* @param filePath - File path | 文件路径
* @returns File size in bytes | 文件大小(字节)
*/
async getFileSize(filePath: string): Promise<number> {
return await invoke('get_file_size', { filePath });
}
/**
* Get directory size recursively.
* 递归获取目录大小。
*
* @param dirPath - Directory path | 目录路径
* @returns Total size in bytes | 总大小(字节)
*/
async getDirectorySize(dirPath: string): Promise<number> {
return await invoke('get_directory_size', { dirPath });
}
/**
* Write JSON file.
* 写入 JSON 文件。
*
* @param filePath - File path | 文件路径
* @param content - JSON content as string | JSON 内容字符串
*/
async writeJsonFile(filePath: string, content: string): Promise<void> {
await invoke('write_json_file', { filePath, content });
}
/**
* List files by extension.
* 按扩展名列出文件。
*
* @param dirPath - Directory path | 目录路径
* @param extensions - File extensions (without dot) | 文件扩展名(不含点)
* @param recursive - Whether to search recursively | 是否递归搜索
* @returns List of file paths | 文件路径列表
*/
async listFilesByExtension(
dirPath: string,
extensions: string[],
recursive: boolean = true
): Promise<string[]> {
return await invoke('list_files_by_extension', { dirPath, extensions, recursive });
}
/**
* Copy single file.
* 复制单个文件。
*
* @param src - Source file path | 源文件路径
* @param dst - Destination file path | 目标文件路径
*/
async copyFile(src: string, dst: string): Promise<void> {
await invoke('copy_file', { src, dst });
}
/**
* Check if path exists.
* 检查路径是否存在。
*
* @param path - Path to check | 要检查的路径
* @returns Whether path exists | 路径是否存在
*/
async pathExists(path: string): Promise<boolean> {
return await invoke('path_exists', { path });
}
/**
* Read file content.
* 读取文件内容。
*
* @param path - File path | 文件路径
* @returns File content | 文件内容
*/
async readFile(path: string): Promise<string> {
return await invoke('read_file_content', { path });
}
/**
* Write file content.
* 写入文件内容。
*
* @param path - File path | 文件路径
* @param content - Content to write | 要写入的内容
*/
async writeFile(path: string, content: string): Promise<void> {
await invoke('write_file_content', { path, content });
}
/**
* Read JSON file.
* 读取 JSON 文件。
*
* @param path - File path | 文件路径
* @returns Parsed JSON object | 解析后的 JSON 对象
*/
async readJson<T>(path: string): Promise<T> {
const content = await invoke<string>('read_file_content', { path });
return JSON.parse(content) as T;
}
/**
* Create directory.
* 创建目录。
*
* @param path - Directory path | 目录路径
*/
async createDirectory(path: string): Promise<void> {
await invoke('create_directory', { path });
}
/**
* Read binary file as base64.
* 读取二进制文件为 base64。
*
* @param path - File path | 文件路径
* @returns Base64 encoded content | Base64 编码的内容
*/
async readBinaryFileAsBase64(path: string): Promise<string> {
return await invoke('read_binary_file_as_base64', { path });
}
}
// Singleton instance | 单例实例
export const buildFileSystem = new BuildFileSystemService();

View File

@@ -27,8 +27,10 @@ import {
EditorPlatformAdapter,
type GameRuntimeConfig
} from '@esengine/runtime-core';
import { getMaterialManager } from '@esengine/material-system';
import { convertFileSrc } from '@tauri-apps/api/core';
import { IdGenerator } from '../utils/idGenerator';
import { TauriAssetReader } from './TauriAssetReader';
/**
* Engine service singleton for editor integration.
@@ -191,7 +193,7 @@ export class EngineService {
// 创建系统上下文
const context: SystemContext = {
core: Core,
services: Core.services,
engineBridge: this._runtime.bridge,
renderSystem: this._runtime.renderSystem,
assetManager: this._assetManager,
@@ -345,11 +347,25 @@ export class EngineService {
try {
this._assetManager = new AssetManager();
// Set up asset reader for Tauri environment.
// 为 Tauri 环境设置资产读取器。
const assetReader = new TauriAssetReader();
this._assetManager.setReader(assetReader);
// Set project root when project is open.
// 当项目打开时设置项目根路径。
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
if (projectService && projectService.isProjectOpen()) {
const projectInfo = projectService.getCurrentProject();
if (projectInfo) {
this._assetManager.setProjectRoot(projectInfo.path);
}
}
const pathTransformerFn = (path: string) => {
if (!path.startsWith('http://') && !path.startsWith('https://') &&
!path.startsWith('data:') && !path.startsWith('asset://')) {
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
if (projectService && projectService.isProjectOpen()) {
const projectInfo = projectService.getCurrentProject();
if (projectInfo) {
@@ -386,6 +402,13 @@ export class EngineService {
}
}
// Set asset manager for MaterialManager.
// 为 MaterialManager 设置 asset manager。
const materialManager = getMaterialManager();
if (materialManager) {
materialManager.setAssetManager(this._assetManager);
}
this._assetSystemInitialized = true;
this._initializationError = null;
} catch (error) {

View File

@@ -4,7 +4,7 @@
*/
import { PluginManager, LocaleService, MessageHub } from '@esengine/editor-core';
import type { IPluginLoader, PluginDescriptor } from '@esengine/editor-core';
import type { IPluginLoader, ModuleManifest } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { TauriAPI } from '../api/tauri';
import { PluginSDKRegistry } from './PluginSDKRegistry';
@@ -132,7 +132,7 @@ export class PluginLoader {
pluginManager.register(pluginLoader);
// 8. 初始化编辑器模块(注册面板、文件处理器等)
const pluginId = pluginLoader.descriptor.id;
const pluginId = pluginLoader.manifest.id;
await pluginManager.initializePluginEditor(pluginId, Core.services);
// 9. 记录已加载
@@ -285,7 +285,7 @@ export class PluginLoader {
}
// 新的 IPluginLoader 接口检查
if (obj.descriptor && this.isPluginDescriptor(obj.descriptor)) {
if (obj.manifest && this.isModuleManifest(obj.manifest)) {
return true;
}
@@ -293,9 +293,9 @@ export class PluginLoader {
}
/**
* 验证对象是否为有效的插件描述符
* 验证对象是否为有效的模块清单
*/
private isPluginDescriptor(obj: any): obj is PluginDescriptor {
private isModuleManifest(obj: any): obj is ModuleManifest {
return (
obj &&
typeof obj.id === 'string' &&

View File

@@ -1,56 +1,32 @@
/**
* Runtime Module Resolver
* 运行时模块解析器
*
* Resolves runtime module paths based on environment and configuration
* 根据环境和配置解析运行时模块路径
*
* 运行时文件打包在编辑器内,离线可用
* Runtime Module Resolver
*/
import { TauriAPI } from '../api/tauri';
// Sanitize path by removing path traversal sequences and normalizing
const sanitizePath = (path: string): string => {
// Split by path separators, filter out '..' and empty segments, rejoin
const segments = path.split(/[/\\]/).filter((segment) =>
segment !== '..' && segment !== '.' && segment !== ''
);
// Use Windows backslash for consistency
return segments.join('\\');
};
// Check if we're in development mode
const isDevelopment = (): boolean => {
try {
// Vite environment variable - this is the most reliable check
const viteDev = (import.meta as any).env?.DEV === true;
// Also check if MODE is 'development'
const viteMode = (import.meta as any).env?.MODE === 'development';
return viteDev || viteMode;
} catch {
return false;
}
};
export interface RuntimeModule {
type: 'javascript' | 'wasm' | 'binary';
files: string[];
sourcePath: string;
}
export interface RuntimeConfig {
runtime: {
version: string;
modules: Record<string, any>;
};
/**
* 运行时模块清单
* Module manifest for runtime modules
*/
export interface ModuleManifest {
id: string;
name: string;
version: string;
dependencies: string[];
hasRuntime: boolean;
pluginExport?: string;
requiresWasm?: boolean;
wasmPaths?: string[];
runtimeWasmPath?: string;
externalDependencies?: string[];
}
export class RuntimeResolver {
private static instance: RuntimeResolver;
private config: RuntimeConfig | null = null;
private baseDir: string = '';
private isDev: boolean = false; // Store dev mode state at initialization time
private engineModulesPath: string = '';
private initialized: boolean = false;
private constructor() {}
@@ -62,67 +38,40 @@ export class RuntimeResolver {
}
/**
* Initialize the runtime resolver
* 初始化运行时解析器
* Initialize the runtime resolver
*/
async initialize(): Promise<void> {
// Load runtime configuration
const response = await fetch('/runtime.config.json');
if (!response.ok) {
throw new Error(`Failed to load runtime configuration: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error(`Invalid runtime configuration response type: ${contentType}. Expected JSON but received ${await response.text().then(t => t.substring(0, 100))}`);
}
this.config = await response.json();
if (this.initialized) return;
// 查找 workspace 根目录
// 查找工作区根目录 | Find workspace root
const currentDir = await TauriAPI.getCurrentDir();
const workspaceRoot = await this.findWorkspaceRoot(currentDir);
this.baseDir = await this.findWorkspaceRoot(currentDir);
// 优先使用 workspace 中的开发文件(如果存在)
// Prefer workspace dev files if they exist
if (await this.hasRuntimeFilesInWorkspace(workspaceRoot)) {
this.baseDir = workspaceRoot;
this.isDev = true;
} else {
// 回退到打包的资源目录(生产模式)
this.baseDir = await TauriAPI.getAppResourceDir();
this.isDev = false;
}
// 查找引擎模块路径 | Find engine modules path
this.engineModulesPath = await this.findEngineModulesPath();
this.initialized = true;
}
/**
* Check if runtime files exist in workspace
* 检查 workspace 中是否存在运行时文件
*/
private async hasRuntimeFilesInWorkspace(workspaceRoot: string): Promise<boolean> {
const runtimePath = `${workspaceRoot}\\packages\\platform-web\\dist\\runtime.browser.js`;
return await TauriAPI.pathExists(runtimePath);
}
/**
* Find workspace root by looking for package.json or specific markers
* 通过查找 package.json 或特定标记来找到工作区根目录
* 查找工作区根目录
* Find workspace root by looking for workspace markers
*/
private async findWorkspaceRoot(startPath: string): Promise<string> {
let currentPath = startPath;
// Try to find the workspace root by looking for key files
// We'll check up to 3 levels up from current directory
for (let i = 0; i < 3; i++) {
// Check if we're in src-tauri
for (let i = 0; i < 5; i++) {
// 检查是否在 src-tauri 目录 | Check if we're in src-tauri
if (currentPath.endsWith('src-tauri')) {
// Go up two levels to get to workspace root
const parts = currentPath.split(/[/\\]/);
parts.pop(); // Remove src-tauri
parts.pop(); // Remove editor-app
parts.pop(); // Remove packages
parts.pop();
parts.pop();
parts.pop();
return parts.join('\\');
}
// Check for workspace markers
// 检查工作区标记 | Check for workspace markers
const workspaceMarkers = [
`${currentPath}\\pnpm-workspace.yaml`,
`${currentPath}\\packages\\editor-app`,
@@ -141,103 +90,336 @@ export class RuntimeResolver {
currentPath = parts.join('\\');
}
// Fallback to current directory
return startPath;
}
/**
* Get runtime module files
* 获取运行时模块文件
* Find engine modules path (where compiled modules with module.json are)
* 查找引擎模块路径(编译后的模块和 module.json 所在位置)
*/
async getModuleFiles(moduleName: string): Promise<RuntimeModule> {
if (!this.config) {
await this.initialize();
private async findEngineModulesPath(): Promise<string> {
// Try installed editor location first
const installedPath = 'C:/Program Files/ESEngine Editor/engine';
if (await TauriAPI.pathExists(`${installedPath}/index.json`)) {
return installedPath;
}
const moduleConfig = this.config!.runtime.modules[moduleName];
if (!moduleConfig) {
throw new Error(`Runtime module ${moduleName} not found in configuration`);
// Try workspace packages directory (dev mode)
const workspacePath = `${this.baseDir}\\packages`;
if (await TauriAPI.pathExists(`${workspacePath}\\core\\module.json`)) {
return workspacePath;
}
const files: string[] = [];
let sourcePath: string;
if (this.isDev) {
// Development mode - use relative paths from workspace root
const devPath = moduleConfig.development.path;
const sanitizedPath = sanitizePath(devPath);
sourcePath = `${this.baseDir}\\packages\\${sanitizedPath}`;
if (moduleConfig.main) {
files.push(`${sourcePath}\\${moduleConfig.main}`);
}
if (moduleConfig.files) {
for (const file of moduleConfig.files) {
files.push(`${sourcePath}\\${file}`);
}
}
} else {
// Production mode - files are bundled with the app
sourcePath = this.baseDir;
if (moduleConfig.main) {
files.push(`${sourcePath}\\${moduleConfig.main}`);
}
if (moduleConfig.files) {
for (const file of moduleConfig.files) {
files.push(`${sourcePath}\\${file}`);
}
}
}
return {
type: moduleConfig.type,
files,
sourcePath
};
return workspacePath;
}
/**
* Prepare runtime files for browser preview
* 为浏览器预览准备运行时文件
* Get list of available runtime modules
* 获取可用的运行时模块列表
*
* 开发模式:从本地 workspace 复制
* 生产模式:从编辑器内置资源复制
* Scans the packages directory for module.json files instead of hardcoding
* 扫描 packages 目录查找 module.json 文件,而不是硬编码
*/
async prepareRuntimeFiles(targetDir: string): Promise<void> {
async getAvailableModules(): Promise<ModuleManifest[]> {
if (!this.initialized) {
await this.initialize();
}
const modules: ModuleManifest[] = [];
// Try to read index.json if it exists (installed editor)
const indexPath = `${this.engineModulesPath}\\index.json`;
if (await TauriAPI.pathExists(indexPath)) {
try {
const indexContent = await TauriAPI.readFileContent(indexPath);
const indexData = JSON.parse(indexContent) as { modules: ModuleManifest[] };
return indexData.modules.filter(m => m.hasRuntime);
} catch (e) {
console.warn('[RuntimeResolver] Failed to read index.json:', e);
}
}
// Scan packages directory for module.json files
const packageEntries = await TauriAPI.listDirectory(this.engineModulesPath);
for (const entry of packageEntries) {
if (!entry.is_dir) continue;
const manifestPath = `${this.engineModulesPath}\\${entry.name}\\module.json`;
if (await TauriAPI.pathExists(manifestPath)) {
try {
const content = await TauriAPI.readFileContent(manifestPath);
const manifest = JSON.parse(content) as ModuleManifest;
if (manifest.hasRuntime !== false) {
modules.push(manifest);
}
} catch (e) {
console.warn(`[RuntimeResolver] Failed to read module.json for ${entry.name}:`, e);
}
}
}
// Sort by dependencies
return this.sortModulesByDependencies(modules);
}
/**
* Sort modules by dependencies (topological sort)
* 按依赖排序模块(拓扑排序)
*/
private sortModulesByDependencies(modules: ModuleManifest[]): ModuleManifest[] {
const sorted: ModuleManifest[] = [];
const visited = new Set<string>();
const moduleMap = new Map(modules.map(m => [m.id, m]));
const visit = (module: ModuleManifest) => {
if (visited.has(module.id)) return;
visited.add(module.id);
for (const depId of (module.dependencies || [])) {
const dep = moduleMap.get(depId);
if (dep) visit(dep);
}
sorted.push(module);
};
for (const module of modules) {
visit(module);
}
return sorted;
}
/**
* Prepare runtime files for browser preview using ES Modules
* 使用 ES 模块为浏览器预览准备运行时文件
*
* Creates libs/{moduleId}/{moduleId}.js structure matching published builds
* 创建与发布构建一致的 libs/{moduleId}/{moduleId}.js 结构
*/
async prepareRuntimeFiles(targetDir: string): Promise<{ modules: ModuleManifest[], importMap: Record<string, string> }> {
if (!this.initialized) {
await this.initialize();
}
// Ensure target directory exists
const dirExists = await TauriAPI.pathExists(targetDir);
if (!dirExists) {
if (!await TauriAPI.pathExists(targetDir)) {
await TauriAPI.createDirectory(targetDir);
}
// Copy platform-web runtime
const platformWeb = await this.getModuleFiles('platform-web');
for (const srcFile of platformWeb.files) {
const filename = srcFile.split(/[/\\]/).pop() || '';
const dstFile = `${targetDir}\\${filename}`;
const libsDir = `${targetDir}\\libs`;
if (!await TauriAPI.pathExists(libsDir)) {
await TauriAPI.createDirectory(libsDir);
}
const srcExists = await TauriAPI.pathExists(srcFile);
if (srcExists) {
const modules = await this.getAvailableModules();
const importMap: Record<string, string> = {};
const copiedModules: string[] = [];
// Copy each module's dist files
for (const module of modules) {
const moduleDistDir = `${this.engineModulesPath}\\${module.id}\\dist`;
const moduleSrcFile = `${moduleDistDir}\\index.mjs`;
// Check for index.mjs or index.js
let srcFile = moduleSrcFile;
if (!await TauriAPI.pathExists(srcFile)) {
srcFile = `${moduleDistDir}\\index.js`;
}
if (await TauriAPI.pathExists(srcFile)) {
const dstModuleDir = `${libsDir}\\${module.id}`;
if (!await TauriAPI.pathExists(dstModuleDir)) {
await TauriAPI.createDirectory(dstModuleDir);
}
const dstFile = `${dstModuleDir}\\${module.id}.js`;
await TauriAPI.copyFile(srcFile, dstFile);
} else {
throw new Error(`Runtime file not found: ${srcFile}`);
// Copy all chunk files (code splitting creates chunk-*.js files)
// 复制所有 chunk 文件(代码分割会创建 chunk-*.js 文件)
await this.copyChunkFiles(moduleDistDir, dstModuleDir);
// Add to import map
importMap[`@esengine/${module.id}`] = `./libs/${module.id}/${module.id}.js`;
// Also add common aliases
if (module.id === 'core') {
importMap['@esengine/ecs-framework'] = `./libs/${module.id}/${module.id}.js`;
}
if (module.id === 'math') {
importMap['@esengine/ecs-framework-math'] = `./libs/${module.id}/${module.id}.js`;
}
copiedModules.push(module.id);
}
}
// Copy engine WASM files
const engine = await this.getModuleFiles('engine');
for (const srcFile of engine.files) {
const filename = srcFile.split(/[/\\]/).pop() || '';
const dstFile = `${targetDir}\\${filename}`;
// Copy external dependencies (e.g., rapier2d)
await this.copyExternalDependencies(modules, libsDir, importMap);
const srcExists = await TauriAPI.pathExists(srcFile);
if (srcExists) {
await TauriAPI.copyFile(srcFile, dstFile);
} else {
throw new Error(`Engine file not found: ${srcFile}`);
// Copy engine WASM files to libs/es-engine/
await this.copyEngineWasm(libsDir);
// Copy module-specific WASM files
await this.copyModuleWasm(modules, targetDir);
console.log(`[RuntimeResolver] Prepared ${copiedModules.length} modules for browser preview`);
return { modules, importMap };
}
/**
* Copy chunk files from dist directory (for code-split modules)
* 复制 dist 目录中的 chunk 文件(用于代码分割的模块)
*/
private async copyChunkFiles(srcDir: string, dstDir: string): Promise<void> {
try {
const entries = await TauriAPI.listDirectory(srcDir);
for (const entry of entries) {
// Copy chunk-*.js files and any other .js files (except index.*)
if (!entry.is_dir && entry.name.endsWith('.js') && !entry.name.startsWith('index.')) {
const srcFile = `${srcDir}\\${entry.name}`;
const dstFile = `${dstDir}\\${entry.name}`;
await TauriAPI.copyFile(srcFile, dstFile);
}
}
} catch (e) {
// Ignore errors - some modules may not have chunk files
}
}
/**
* Copy external dependencies like rapier2d
* 复制外部依赖如 rapier2d
*/
private async copyExternalDependencies(
modules: ModuleManifest[],
libsDir: string,
importMap: Record<string, string>
): Promise<void> {
const externalDeps = new Set<string>();
for (const m of modules) {
if (m.externalDependencies) {
for (const dep of m.externalDependencies) {
externalDeps.add(dep);
}
}
}
for (const dep of externalDeps) {
const depId = dep.startsWith('@esengine/') ? dep.slice(10) : dep.replace(/^@[^/]+\//, '');
const srcDistDir = `${this.engineModulesPath}\\${depId}\\dist`;
let srcFile = `${srcDistDir}\\index.mjs`;
if (!await TauriAPI.pathExists(srcFile)) {
srcFile = `${srcDistDir}\\index.js`;
}
if (await TauriAPI.pathExists(srcFile)) {
const dstModuleDir = `${libsDir}\\${depId}`;
if (!await TauriAPI.pathExists(dstModuleDir)) {
await TauriAPI.createDirectory(dstModuleDir);
}
const dstFile = `${dstModuleDir}\\${depId}.js`;
await TauriAPI.copyFile(srcFile, dstFile);
// Copy chunk files for external dependencies too
await this.copyChunkFiles(srcDistDir, dstModuleDir);
importMap[dep] = `./libs/${depId}/${depId}.js`;
console.log(`[RuntimeResolver] Copied external dependency: ${depId}`);
}
}
}
/**
* Copy engine WASM files
* 复制引擎 WASM 文件
*/
private async copyEngineWasm(libsDir: string): Promise<void> {
const esEngineDir = `${libsDir}\\es-engine`;
if (!await TauriAPI.pathExists(esEngineDir)) {
await TauriAPI.createDirectory(esEngineDir);
}
// Try different locations for engine WASM
const wasmSearchPaths = [
`${this.baseDir}\\packages\\engine\\pkg`,
`${this.engineModulesPath}\\..\\..\\engine\\pkg`,
'C:/Program Files/ESEngine Editor/wasm'
];
const filesToCopy = ['es_engine_bg.wasm', 'es_engine.js', 'es_engine_bg.js'];
for (const searchPath of wasmSearchPaths) {
if (await TauriAPI.pathExists(searchPath)) {
for (const file of filesToCopy) {
const srcFile = `${searchPath}\\${file}`;
if (await TauriAPI.pathExists(srcFile)) {
const dstFile = `${esEngineDir}\\${file}`;
await TauriAPI.copyFile(srcFile, dstFile);
}
}
console.log('[RuntimeResolver] Copied engine WASM from:', searchPath);
return;
}
}
console.warn('[RuntimeResolver] Engine WASM files not found');
}
/**
* Copy module-specific WASM files (e.g., physics)
* 复制模块特定的 WASM 文件(如物理)
*/
private async copyModuleWasm(modules: ModuleManifest[], targetDir: string): Promise<void> {
for (const module of modules) {
if (!module.requiresWasm || !module.wasmPaths?.length) continue;
const runtimePath = module.runtimeWasmPath || `wasm/${module.wasmPaths[0]}`;
const dstPath = `${targetDir}\\${runtimePath.replace(/\//g, '\\')}`;
const dstDir = dstPath.substring(0, dstPath.lastIndexOf('\\'));
if (!await TauriAPI.pathExists(dstDir)) {
await TauriAPI.createDirectory(dstDir);
}
// Search for the WASM file
const wasmPath = module.wasmPaths[0];
if (!wasmPath) continue;
const wasmFileName = wasmPath.split(/[/\\]/).pop() || wasmPath;
// Build search paths - check module's own pkg, external deps, and common locations
const searchPaths: string[] = [
`${this.engineModulesPath}\\${module.id}\\pkg\\${wasmFileName}`,
`${this.baseDir}\\packages\\${module.id}\\pkg\\${wasmFileName}`,
];
// Check external dependencies for WASM (e.g., physics-rapier2d uses rapier2d's WASM)
if (module.externalDependencies) {
for (const dep of module.externalDependencies) {
const depId = dep.startsWith('@esengine/') ? dep.slice(10) : dep.replace(/^@[^/]+\//, '');
searchPaths.push(`${this.engineModulesPath}\\${depId}\\pkg\\${wasmFileName}`);
searchPaths.push(`${this.baseDir}\\packages\\${depId}\\pkg\\${wasmFileName}`);
}
}
for (const srcPath of searchPaths) {
if (await TauriAPI.pathExists(srcPath)) {
await TauriAPI.copyFile(srcPath, dstPath);
console.log(`[RuntimeResolver] Copied ${module.id} WASM to ${runtimePath}`);
break;
}
}
}
}
/**
* Generate import map for runtime HTML
* 生成运行时 HTML 的 import map
*/
generateImportMapHtml(importMap: Record<string, string>): string {
return `<script type="importmap">
${JSON.stringify({ imports: importMap }, null, 2).split('\n').join('\n ')}
</script>`;
}
/**
@@ -247,4 +429,12 @@ export class RuntimeResolver {
getBaseDir(): string {
return this.baseDir;
}
/**
* Get engine modules path
* 获取引擎模块路径
*/
getEngineModulesPath(): string {
return this.engineModulesPath;
}
}

View File

@@ -0,0 +1,80 @@
/**
* Tauri Asset Reader
* Tauri 资产读取器
*
* Implements IAssetReader for Tauri/editor environment.
* 为 Tauri/编辑器环境实现 IAssetReader。
*/
import { invoke } from '@tauri-apps/api/core';
import { convertFileSrc } from '@tauri-apps/api/core';
import type { IAssetReader } from '@esengine/asset-system';
/**
* Asset reader implementation for Tauri.
* Tauri 的资产读取器实现。
*/
export class TauriAssetReader implements IAssetReader {
/**
* Read file as text.
* 读取文件为文本。
*/
async readText(absolutePath: string): Promise<string> {
return await invoke<string>('read_file_content', { path: absolutePath });
}
/**
* Read file as binary.
* 读取文件为二进制。
*/
async readBinary(absolutePath: string): Promise<ArrayBuffer> {
const bytes = await invoke<number[]>('read_binary_file', { filePath: absolutePath });
return new Uint8Array(bytes).buffer;
}
/**
* Load image from file.
* 从文件加载图片。
*/
async loadImage(absolutePath: string): Promise<HTMLImageElement> {
// Only convert if not already a URL.
// 仅当不是 URL 时才转换。
let assetUrl = absolutePath;
if (!absolutePath.startsWith('http://') &&
!absolutePath.startsWith('https://') &&
!absolutePath.startsWith('data:') &&
!absolutePath.startsWith('asset://')) {
assetUrl = convertFileSrc(absolutePath);
}
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${absolutePath}`));
image.src = assetUrl;
});
}
/**
* Load audio from file.
* 从文件加载音频。
*/
async loadAudio(absolutePath: string): Promise<AudioBuffer> {
const binary = await this.readBinary(absolutePath);
const audioContext = new AudioContext();
return await audioContext.decodeAudioData(binary);
}
/**
* Check if file exists.
* 检查文件是否存在。
*/
async exists(absolutePath: string): Promise<boolean> {
try {
await invoke('read_file_content', { path: absolutePath });
return true;
} catch {
return false;
}
}
}

View File

@@ -51,7 +51,7 @@ export class TauriFileSystemService implements IFileSystem {
}
async scanFiles(basePath: string, pattern: string): Promise<string[]> {
return await invoke<string[]>('scan_files', { basePath, pattern });
return await invoke<string[]>('scan_directory', { path: basePath, pattern });
}
convertToAssetUrl(filePath: string): string {

View File

@@ -0,0 +1,143 @@
/**
* Tauri Module File System
* Tauri 模块文件系统
*
* Implements IModuleFileSystem interface for Tauri environment.
* 为 Tauri 环境实现 IModuleFileSystem 接口。
*
* This reads module files via Tauri commands from the local file system.
* 通过 Tauri 命令从本地文件系统读取模块文件。
*/
import { invoke } from '@tauri-apps/api/core';
import type { IModuleFileSystem } from '@esengine/editor-core';
/**
* Module index structure from Tauri backend.
* 来自 Tauri 后端的模块索引结构。
*/
interface ModuleIndex {
version: string;
generatedAt: string;
modules: Array<{
id: string;
name: string;
displayName: string;
hasRuntime: boolean;
editorPackage?: string;
isCore: boolean;
category: string;
}>;
}
/**
* Tauri-based module file system for reading module manifests.
* 基于 Tauri 的模块文件系统,用于读取模块清单。
*/
export class TauriModuleFileSystem implements IModuleFileSystem {
private _basePath: string = '';
private _indexCache: ModuleIndex | null = null;
/**
* Read JSON file via Tauri command.
* 通过 Tauri 命令读取 JSON 文件。
*/
async readJson<T>(path: string): Promise<T> {
// Check if reading index.json
// 检查是否读取 index.json
if (path.endsWith('/index.json') || path === 'index.json') {
const index = await invoke<ModuleIndex>('read_engine_modules_index');
this._indexCache = index;
return index as unknown as T;
}
// Extract module ID from path like "/engine/sprite/module.json"
// 从路径中提取模块 ID如 "/engine/sprite/module.json"
const match = path.match(/\/([^/]+)\/module\.json$/);
if (match) {
const moduleId = match[1];
return await invoke<T>('read_module_manifest', { moduleId });
}
throw new Error(`Unsupported path: ${path}`);
}
/**
* Write JSON file - not supported for engine modules.
* 写入 JSON 文件 - 引擎模块不支持。
*/
async writeJson(_path: string, _data: unknown): Promise<void> {
throw new Error('Write operation not supported for engine modules');
}
/**
* Check if path exists.
* 检查路径是否存在。
*/
async pathExists(path: string): Promise<boolean> {
try {
// For index.json, try to read it
// 对于 index.json尝试读取它
if (path.endsWith('/index.json') || path === 'index.json') {
console.log('[TauriModuleFileSystem] Checking index.json via Tauri command...');
await invoke('read_engine_modules_index');
console.log('[TauriModuleFileSystem] index.json exists');
return true;
}
// For module.json, check if module exists in index
// 对于 module.json检查模块是否存在于索引中
const match = path.match(/\/([^/]+)\/module\.json$/);
if (match) {
const moduleId = match[1];
// Use cached index if available
// 如果有缓存的索引则使用
if (this._indexCache) {
return this._indexCache.modules.some(m => m.id === moduleId);
}
// Otherwise try to read the manifest
// 否则尝试读取清单
try {
await invoke('read_module_manifest', { moduleId });
return true;
} catch {
return false;
}
}
return false;
} catch (err) {
console.error('[TauriModuleFileSystem] pathExists error:', err);
return false;
}
}
/**
* List files - not needed for module loading.
* 列出文件 - 模块加载不需要。
*/
async listFiles(_dir: string, _extensions: string[], _recursive?: boolean): Promise<string[]> {
return [];
}
/**
* Read file as text.
* 读取文件为文本。
*/
async readText(path: string): Promise<string> {
const json = await this.readJson(path);
return JSON.stringify(json);
}
/**
* Get the base path to engine modules.
* 获取引擎模块的基础路径。
*/
async getBasePath(): Promise<string> {
if (!this._basePath) {
this._basePath = await invoke<string>('get_engine_modules_base_path');
}
return this._basePath;
}
}

View File

@@ -0,0 +1,120 @@
/**
* Viewport Service Implementation
* 视口服务实现
*
* Implements IViewportService using EngineService.
* 使用 EngineService 实现 IViewportService。
*/
import type { IViewportService, ViewportCameraConfig } from '@esengine/editor-core';
import { EngineService } from './EngineService';
/**
* ViewportService - Wraps EngineService for IViewportService interface
* ViewportService - 为 IViewportService 接口包装 EngineService
*/
export class ViewportService implements IViewportService {
private static _instance: ViewportService | null = null;
private _engineService: EngineService;
private constructor() {
this._engineService = EngineService.getInstance();
}
/**
* Get singleton instance
* 获取单例实例
*/
static getInstance(): ViewportService {
if (!ViewportService._instance) {
ViewportService._instance = new ViewportService();
}
return ViewportService._instance;
}
/**
* Check if the service is initialized
* 检查服务是否已初始化
*/
isInitialized(): boolean {
return this._engineService.isInitialized();
}
/**
* Register a viewport with a canvas element
* 注册一个视口和画布元素
*/
registerViewport(viewportId: string, canvasId: string): void {
this._engineService.registerViewport(viewportId, canvasId);
}
/**
* Unregister a viewport
* 注销一个视口
*/
unregisterViewport(viewportId: string): void {
this._engineService.unregisterViewport(viewportId);
}
/**
* Set camera for a specific viewport
* 设置特定视口的相机
*/
setViewportCamera(viewportId: string, config: ViewportCameraConfig): void {
this._engineService.setViewportCamera(viewportId, {
x: config.x,
y: config.y,
zoom: config.zoom,
rotation: config.rotation ?? 0
});
}
/**
* Get camera for a specific viewport
* 获取特定视口的相机
*/
getViewportCamera(viewportId: string): ViewportCameraConfig | null {
return this._engineService.getViewportCamera(viewportId);
}
/**
* Set viewport configuration (grid, gizmos visibility)
* 设置视口配置(网格、辅助线可见性)
*/
setViewportConfig(viewportId: string, showGrid: boolean, showGizmos: boolean): void {
this._engineService.setViewportConfig(viewportId, showGrid, showGizmos);
}
/**
* Resize a specific viewport
* 调整特定视口的大小
*/
resizeViewport(viewportId: string, width: number, height: number): void {
this._engineService.resizeViewport(viewportId, width, height);
}
/**
* Render to a specific viewport
* 渲染到特定视口
*/
renderToViewport(viewportId: string): void {
this._engineService.renderToViewport(viewportId);
}
/**
* Load a texture and return its ID
* 加载纹理并返回其 ID
*/
async loadTexture(path: string): Promise<number> {
return await this._engineService.loadTextureAsset(path);
}
/**
* Dispose resources
* 释放资源
*/
dispose(): void {
// ViewportService is a lightweight wrapper, no resources to dispose
// The underlying EngineService manages its own lifecycle
}
}