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:
50
packages/asset-system-editor/package.json
Normal file
50
packages/asset-system-editor/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@esengine/asset-system-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor-side asset management: meta files, packing, and bundling",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"clean": "rimraf dist",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"asset",
|
||||
"editor",
|
||||
"bundle",
|
||||
"packing"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@esengine/asset-system": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"rimraf": "^5.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/ecs-framework.git",
|
||||
"directory": "packages/asset-system-editor"
|
||||
}
|
||||
}
|
||||
39
packages/asset-system-editor/src/index.ts
Normal file
39
packages/asset-system-editor/src/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Asset System Editor
|
||||
* 资产系统编辑器模块
|
||||
*
|
||||
* Editor-side asset management:
|
||||
* - Meta files (.meta) management
|
||||
* - Asset packing and bundling
|
||||
* - Import settings
|
||||
*
|
||||
* 编辑器端资产管理:
|
||||
* - 元数据文件 (.meta) 管理
|
||||
* - 资产打包和捆绑
|
||||
* - 导入设置
|
||||
*/
|
||||
|
||||
// Meta file management
|
||||
export {
|
||||
AssetMetaManager,
|
||||
type IAssetMeta,
|
||||
type IImportSettings,
|
||||
type IMetaFileSystem,
|
||||
generateGUID,
|
||||
getMetaFilePath,
|
||||
inferAssetType,
|
||||
getDefaultImportSettings,
|
||||
createAssetMeta,
|
||||
serializeAssetMeta,
|
||||
parseAssetMeta,
|
||||
isValidGUID
|
||||
} from './meta/AssetMetaFile';
|
||||
|
||||
// Asset packing
|
||||
export {
|
||||
AssetPacker,
|
||||
collectSceneAssets,
|
||||
type IPackingResult,
|
||||
type IPackedBundle,
|
||||
type IAssetFileReader
|
||||
} from './packing/AssetPacker';
|
||||
424
packages/asset-system-editor/src/meta/AssetMetaFile.ts
Normal file
424
packages/asset-system-editor/src/meta/AssetMetaFile.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Asset Meta File (.meta) Management
|
||||
* 资产元数据文件 (.meta) 管理
|
||||
*
|
||||
* Each asset file has a companion .meta file that stores:
|
||||
* - GUID: Persistent unique identifier
|
||||
* - Import settings: How to process the asset
|
||||
* - Labels: User-defined tags
|
||||
*
|
||||
* 每个资产文件都有一个配套的 .meta 文件,存储:
|
||||
* - GUID:持久化唯一标识符
|
||||
* - 导入设置:如何处理资产
|
||||
* - 标签:用户定义的标签
|
||||
*/
|
||||
|
||||
import { AssetGUID, AssetType } from '@esengine/asset-system';
|
||||
|
||||
/**
|
||||
* Meta file content structure
|
||||
* 元数据文件内容结构
|
||||
*/
|
||||
export interface IAssetMeta {
|
||||
/** Persistent unique identifier | 持久化唯一标识符 */
|
||||
guid: AssetGUID;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Import settings | 导入设置 */
|
||||
importSettings?: IImportSettings;
|
||||
/** User-defined labels | 用户定义的标签 */
|
||||
labels?: string[];
|
||||
/** Meta file version | 元数据文件版本 */
|
||||
version: number;
|
||||
/** Last modified timestamp | 最后修改时间戳 */
|
||||
lastModified?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings for different asset types
|
||||
* 不同资产类型的导入设置
|
||||
*/
|
||||
export interface IImportSettings {
|
||||
// Texture settings | 纹理设置
|
||||
maxSize?: number;
|
||||
compression?: 'none' | 'dxt' | 'etc2' | 'astc' | 'webp';
|
||||
generateMipmaps?: boolean;
|
||||
filterMode?: 'point' | 'bilinear' | 'trilinear';
|
||||
wrapMode?: 'clamp' | 'repeat' | 'mirror';
|
||||
premultiplyAlpha?: boolean;
|
||||
|
||||
// Audio settings | 音频设置
|
||||
audioFormat?: 'mp3' | 'ogg' | 'wav';
|
||||
sampleRate?: number;
|
||||
channels?: 1 | 2;
|
||||
normalize?: boolean;
|
||||
|
||||
// General settings | 通用设置
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new UUID v4
|
||||
* 生成新的 UUID v4
|
||||
*/
|
||||
export function generateGUID(): AssetGUID {
|
||||
// Use crypto.randomUUID if available (modern browsers/Node 19+)
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback implementation
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meta file path for an asset
|
||||
* 获取资产的元数据文件路径
|
||||
*/
|
||||
export function getMetaFilePath(assetPath: string): string {
|
||||
return `${assetPath}.meta`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer asset type from file extension
|
||||
* 根据文件扩展名推断资产类型
|
||||
*/
|
||||
export function inferAssetType(path: string): AssetType {
|
||||
const ext = path.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const typeMap: Record<string, AssetType> = {
|
||||
// Textures
|
||||
png: 'texture',
|
||||
jpg: 'texture',
|
||||
jpeg: 'texture',
|
||||
gif: 'texture',
|
||||
webp: 'texture',
|
||||
bmp: 'texture',
|
||||
svg: 'texture',
|
||||
|
||||
// Audio
|
||||
mp3: 'audio',
|
||||
wav: 'audio',
|
||||
ogg: 'audio',
|
||||
m4a: 'audio',
|
||||
flac: 'audio',
|
||||
|
||||
// Data
|
||||
json: 'json',
|
||||
txt: 'text',
|
||||
xml: 'text',
|
||||
csv: 'text',
|
||||
|
||||
// Scenes and prefabs
|
||||
ecs: 'scene',
|
||||
prefab: 'prefab',
|
||||
|
||||
// Fonts
|
||||
ttf: 'font',
|
||||
otf: 'font',
|
||||
woff: 'font',
|
||||
woff2: 'font',
|
||||
|
||||
// Shaders
|
||||
glsl: 'shader',
|
||||
vert: 'shader',
|
||||
frag: 'shader',
|
||||
|
||||
// Custom types (plugins)
|
||||
tilemap: 'tilemap',
|
||||
tileset: 'tileset',
|
||||
btree: 'behavior-tree',
|
||||
bp: 'blueprint',
|
||||
mat: 'material'
|
||||
};
|
||||
|
||||
return typeMap[ext] || 'binary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default import settings for asset type
|
||||
* 获取资产类型的默认导入设置
|
||||
*/
|
||||
export function getDefaultImportSettings(type: AssetType): IImportSettings {
|
||||
switch (type) {
|
||||
case 'texture':
|
||||
return {
|
||||
maxSize: 2048,
|
||||
compression: 'none',
|
||||
generateMipmaps: false,
|
||||
filterMode: 'bilinear',
|
||||
wrapMode: 'clamp',
|
||||
premultiplyAlpha: false
|
||||
};
|
||||
|
||||
case 'audio':
|
||||
return {
|
||||
audioFormat: 'mp3',
|
||||
sampleRate: 44100,
|
||||
channels: 2,
|
||||
normalize: false
|
||||
};
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new meta file content
|
||||
* 创建新的元数据文件内容
|
||||
*/
|
||||
export function createAssetMeta(assetPath: string, overrides?: Partial<IAssetMeta>): IAssetMeta {
|
||||
const type = overrides?.type || inferAssetType(assetPath);
|
||||
|
||||
return {
|
||||
guid: overrides?.guid || generateGUID(),
|
||||
type,
|
||||
importSettings: overrides?.importSettings || getDefaultImportSettings(type),
|
||||
labels: overrides?.labels || [],
|
||||
version: 1,
|
||||
lastModified: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize meta to JSON string
|
||||
* 将元数据序列化为 JSON 字符串
|
||||
*/
|
||||
export function serializeAssetMeta(meta: IAssetMeta): string {
|
||||
return JSON.stringify(meta, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse meta from JSON string
|
||||
* 从 JSON 字符串解析元数据
|
||||
*/
|
||||
export function parseAssetMeta(json: string): IAssetMeta {
|
||||
const meta = JSON.parse(json) as IAssetMeta;
|
||||
|
||||
// Validate required fields
|
||||
if (!meta.guid || typeof meta.guid !== 'string') {
|
||||
throw new Error('Invalid meta file: missing or invalid guid');
|
||||
}
|
||||
if (!meta.type || typeof meta.type !== 'string') {
|
||||
throw new Error('Invalid meta file: missing or invalid type');
|
||||
}
|
||||
|
||||
// Set defaults for optional fields
|
||||
meta.version = meta.version || 1;
|
||||
meta.labels = meta.labels || [];
|
||||
meta.importSettings = meta.importSettings || {};
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate GUID format (UUID v4)
|
||||
* 验证 GUID 格式 (UUID v4)
|
||||
*/
|
||||
export function isValidGUID(guid: string): boolean {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset Meta File Manager
|
||||
* 资产元数据文件管理器
|
||||
*
|
||||
* Handles reading/writing .meta files through a file system interface.
|
||||
*/
|
||||
export class AssetMetaManager {
|
||||
private _cache = new Map<string, IAssetMeta>();
|
||||
private _guidToPath = new Map<AssetGUID, string>();
|
||||
|
||||
/**
|
||||
* File system interface for reading/writing files
|
||||
* 用于读写文件的文件系统接口
|
||||
*/
|
||||
private _fs: IMetaFileSystem | null = null;
|
||||
|
||||
/**
|
||||
* Set file system interface
|
||||
* 设置文件系统接口
|
||||
*/
|
||||
setFileSystem(fs: IMetaFileSystem): void {
|
||||
this._fs = fs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create meta for an asset
|
||||
* 获取或创建资产的元数据
|
||||
*/
|
||||
async getOrCreateMeta(assetPath: string): Promise<IAssetMeta> {
|
||||
// Check cache first
|
||||
const cached = this._cache.get(assetPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const metaPath = getMetaFilePath(assetPath);
|
||||
|
||||
// Try to read existing meta file
|
||||
if (this._fs) {
|
||||
try {
|
||||
if (await this._fs.exists(metaPath)) {
|
||||
const content = await this._fs.readText(metaPath);
|
||||
const meta = parseAssetMeta(content);
|
||||
this._cache.set(assetPath, meta);
|
||||
this._guidToPath.set(meta.guid, assetPath);
|
||||
return meta;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to read meta file: ${metaPath}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new meta
|
||||
const meta = createAssetMeta(assetPath);
|
||||
this._cache.set(assetPath, meta);
|
||||
this._guidToPath.set(meta.guid, assetPath);
|
||||
|
||||
// Save to file system
|
||||
if (this._fs) {
|
||||
try {
|
||||
await this._fs.writeText(metaPath, serializeAssetMeta(meta));
|
||||
} catch (e) {
|
||||
console.warn(`Failed to write meta file: ${metaPath}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meta by GUID
|
||||
* 根据 GUID 获取元数据
|
||||
*/
|
||||
getMetaByGUID(guid: AssetGUID): IAssetMeta | undefined {
|
||||
const path = this._guidToPath.get(guid);
|
||||
return path ? this._cache.get(path) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset path by GUID
|
||||
* 根据 GUID 获取资产路径
|
||||
*/
|
||||
getPathByGUID(guid: AssetGUID): string | undefined {
|
||||
return this._guidToPath.get(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GUID by asset path
|
||||
* 根据资产路径获取 GUID
|
||||
*/
|
||||
async getGUIDByPath(assetPath: string): Promise<AssetGUID> {
|
||||
const meta = await this.getOrCreateMeta(assetPath);
|
||||
return meta.guid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update meta and save
|
||||
* 更新元数据并保存
|
||||
*/
|
||||
async updateMeta(assetPath: string, updates: Partial<IAssetMeta>): Promise<void> {
|
||||
const meta = await this.getOrCreateMeta(assetPath);
|
||||
|
||||
// Apply updates
|
||||
Object.assign(meta, updates);
|
||||
meta.lastModified = Date.now();
|
||||
meta.version++;
|
||||
|
||||
// Update cache
|
||||
this._cache.set(assetPath, meta);
|
||||
|
||||
// Handle GUID change (rare, but possible)
|
||||
if (updates.guid && updates.guid !== meta.guid) {
|
||||
this._guidToPath.delete(meta.guid);
|
||||
this._guidToPath.set(updates.guid, assetPath);
|
||||
}
|
||||
|
||||
// Save to file system
|
||||
if (this._fs) {
|
||||
const metaPath = getMetaFilePath(assetPath);
|
||||
await this._fs.writeText(metaPath, serializeAssetMeta(meta));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle asset rename
|
||||
* 处理资产重命名
|
||||
*/
|
||||
async handleAssetRename(oldPath: string, newPath: string): Promise<void> {
|
||||
const meta = this._cache.get(oldPath);
|
||||
if (meta) {
|
||||
// Update cache with new path
|
||||
this._cache.delete(oldPath);
|
||||
this._cache.set(newPath, meta);
|
||||
this._guidToPath.set(meta.guid, newPath);
|
||||
|
||||
// Move meta file
|
||||
if (this._fs) {
|
||||
const oldMetaPath = getMetaFilePath(oldPath);
|
||||
const newMetaPath = getMetaFilePath(newPath);
|
||||
|
||||
if (await this._fs.exists(oldMetaPath)) {
|
||||
const content = await this._fs.readText(oldMetaPath);
|
||||
await this._fs.writeText(newMetaPath, content);
|
||||
await this._fs.delete(oldMetaPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle asset delete
|
||||
* 处理资产删除
|
||||
*/
|
||||
async handleAssetDelete(assetPath: string): Promise<void> {
|
||||
const meta = this._cache.get(assetPath);
|
||||
if (meta) {
|
||||
this._cache.delete(assetPath);
|
||||
this._guidToPath.delete(meta.guid);
|
||||
|
||||
// Delete meta file
|
||||
if (this._fs) {
|
||||
const metaPath = getMetaFilePath(assetPath);
|
||||
if (await this._fs.exists(metaPath)) {
|
||||
await this._fs.delete(metaPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
* 清除缓存
|
||||
*/
|
||||
clear(): void {
|
||||
this._cache.clear();
|
||||
this._guidToPath.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached metas
|
||||
* 获取所有缓存的元数据
|
||||
*/
|
||||
getAllMetas(): Map<string, IAssetMeta> {
|
||||
return new Map(this._cache);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File system interface for meta file operations
|
||||
* 元数据文件操作的文件系统接口
|
||||
*/
|
||||
export interface IMetaFileSystem {
|
||||
exists(path: string): Promise<boolean>;
|
||||
readText(path: string): Promise<string>;
|
||||
writeText(path: string, content: string): Promise<void>;
|
||||
delete(path: string): Promise<void>;
|
||||
}
|
||||
408
packages/asset-system-editor/src/packing/AssetPacker.ts
Normal file
408
packages/asset-system-editor/src/packing/AssetPacker.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Asset Packer
|
||||
* 资产打包器
|
||||
*
|
||||
* Collects and packs assets into bundles for runtime loading.
|
||||
* 收集并将资产打包成运行时加载的包。
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetGUID,
|
||||
AssetType,
|
||||
IBundleManifest,
|
||||
IBundleAssetInfo,
|
||||
IRuntimeCatalog,
|
||||
IRuntimeBundleInfo,
|
||||
IRuntimeAssetLocation,
|
||||
IAssetToPack,
|
||||
IBundlePackOptions
|
||||
} from '@esengine/asset-system';
|
||||
import { IAssetMeta } from '../meta/AssetMetaFile';
|
||||
|
||||
/**
|
||||
* Packing result
|
||||
* 打包结果
|
||||
*/
|
||||
export interface IPackingResult {
|
||||
/** Generated bundles | 生成的包 */
|
||||
bundles: IPackedBundle[];
|
||||
/** Runtime catalog | 运行时目录 */
|
||||
catalog: IRuntimeCatalog;
|
||||
/** Total size in bytes | 总大小 */
|
||||
totalSize: number;
|
||||
/** Number of assets packed | 打包的资产数量 */
|
||||
assetCount: number;
|
||||
/** Packing duration in ms | 打包耗时 */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Packed bundle
|
||||
* 已打包的包
|
||||
*/
|
||||
export interface IPackedBundle {
|
||||
/** Bundle name | 包名称 */
|
||||
name: string;
|
||||
/** Bundle data | 包数据 */
|
||||
data: ArrayBuffer;
|
||||
/** Bundle manifest | 包清单 */
|
||||
manifest: IBundleManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset file reader interface
|
||||
* 资产文件读取器接口
|
||||
*/
|
||||
export interface IAssetFileReader {
|
||||
readBinary(path: string): Promise<ArrayBuffer>;
|
||||
readText(path: string): Promise<string>;
|
||||
exists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset Packer
|
||||
* 资产打包器
|
||||
*/
|
||||
export class AssetPacker {
|
||||
private _fileReader: IAssetFileReader | null = null;
|
||||
private _assets: IAssetToPack[] = [];
|
||||
private _metas = new Map<AssetGUID, IAssetMeta>();
|
||||
|
||||
/**
|
||||
* Set file reader for loading asset data
|
||||
* 设置用于加载资产数据的文件读取器
|
||||
*/
|
||||
setFileReader(reader: IAssetFileReader): void {
|
||||
this._fileReader = reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add asset to pack
|
||||
* 添加要打包的资产
|
||||
*/
|
||||
addAsset(asset: IAssetToPack, meta?: IAssetMeta): void {
|
||||
this._assets.push(asset);
|
||||
if (meta) {
|
||||
this._metas.set(asset.guid, meta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple assets
|
||||
* 添加多个资产
|
||||
*/
|
||||
addAssets(assets: IAssetToPack[]): void {
|
||||
for (const asset of assets) {
|
||||
this.addAsset(asset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all added assets
|
||||
* 清除所有已添加的资产
|
||||
*/
|
||||
clear(): void {
|
||||
this._assets = [];
|
||||
this._metas.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack assets into bundles
|
||||
* 将资产打包成包
|
||||
*/
|
||||
async pack(options: IBundlePackOptions = { name: 'main' }): Promise<IPackingResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Group assets for bundling
|
||||
const groups = this._groupAssets(options);
|
||||
|
||||
// Pack each group into a bundle
|
||||
const bundles: IPackedBundle[] = [];
|
||||
const catalogAssets: Record<AssetGUID, IRuntimeAssetLocation> = {};
|
||||
const catalogBundles: Record<string, IRuntimeBundleInfo> = {};
|
||||
|
||||
for (const [bundleName, assets] of groups) {
|
||||
const packed = await this._packBundle(bundleName, assets, options);
|
||||
bundles.push(packed);
|
||||
|
||||
// Add to catalog
|
||||
catalogBundles[bundleName] = {
|
||||
url: `assets/${bundleName}.bundle`,
|
||||
size: packed.data.byteLength,
|
||||
hash: await this._hashBuffer(packed.data),
|
||||
preload: bundleName === 'core' || bundleName === 'main'
|
||||
};
|
||||
|
||||
// Add asset locations
|
||||
for (const assetInfo of packed.manifest.assets) {
|
||||
catalogAssets[assetInfo.guid] = {
|
||||
bundle: bundleName,
|
||||
offset: assetInfo.offset,
|
||||
size: assetInfo.size,
|
||||
type: assetInfo.type,
|
||||
name: assetInfo.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create catalog
|
||||
const catalog: IRuntimeCatalog = {
|
||||
version: '1.0',
|
||||
createdAt: Date.now(),
|
||||
bundles: catalogBundles,
|
||||
assets: catalogAssets
|
||||
};
|
||||
|
||||
const totalSize = bundles.reduce((sum, b) => sum + b.data.byteLength, 0);
|
||||
|
||||
return {
|
||||
bundles,
|
||||
catalog,
|
||||
totalSize,
|
||||
assetCount: this._assets.length,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack assets by type (textures.bundle, audio.bundle, etc.)
|
||||
* 按类型打包资产
|
||||
*/
|
||||
async packByType(): Promise<IPackingResult> {
|
||||
return this.pack({
|
||||
name: 'main',
|
||||
groupByType: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group assets for bundling
|
||||
* 分组资产以便打包
|
||||
*/
|
||||
private _groupAssets(options: IBundlePackOptions): Map<string, IAssetToPack[]> {
|
||||
const groups = new Map<string, IAssetToPack[]>();
|
||||
|
||||
if (options.groupByType) {
|
||||
// Group by asset type
|
||||
for (const asset of this._assets) {
|
||||
const bundleName = this._getBundleNameForType(asset.type);
|
||||
const group = groups.get(bundleName) || [];
|
||||
group.push(asset);
|
||||
groups.set(bundleName, group);
|
||||
}
|
||||
} else {
|
||||
// Single bundle
|
||||
groups.set(options.name, [...this._assets]);
|
||||
}
|
||||
|
||||
// Handle max size splitting
|
||||
if (options.maxSize) {
|
||||
const splitGroups = new Map<string, IAssetToPack[]>();
|
||||
|
||||
for (const [name, assets] of groups) {
|
||||
let currentSize = 0;
|
||||
let partIndex = 0;
|
||||
let currentGroup: IAssetToPack[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
const assetSize = asset.data?.byteLength || 0;
|
||||
|
||||
if (currentSize + assetSize > options.maxSize && currentGroup.length > 0) {
|
||||
splitGroups.set(`${name}_${partIndex}`, currentGroup);
|
||||
partIndex++;
|
||||
currentGroup = [];
|
||||
currentSize = 0;
|
||||
}
|
||||
|
||||
currentGroup.push(asset);
|
||||
currentSize += assetSize;
|
||||
}
|
||||
|
||||
if (currentGroup.length > 0) {
|
||||
const finalName = partIndex > 0 ? `${name}_${partIndex}` : name;
|
||||
splitGroups.set(finalName, currentGroup);
|
||||
}
|
||||
}
|
||||
|
||||
return splitGroups;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bundle name for asset type
|
||||
* 获取资产类型的包名称
|
||||
*/
|
||||
private _getBundleNameForType(type: AssetType): string {
|
||||
const typeGroups: Record<string, string[]> = {
|
||||
textures: ['texture'],
|
||||
audio: ['audio'],
|
||||
data: ['json', 'text', 'binary', 'scene', 'prefab'],
|
||||
fonts: ['font'],
|
||||
shaders: ['shader', 'material'],
|
||||
tilemaps: ['tilemap', 'tileset'],
|
||||
scripts: ['behavior-tree', 'blueprint']
|
||||
};
|
||||
|
||||
for (const [bundleName, types] of Object.entries(typeGroups)) {
|
||||
if (types.includes(type)) {
|
||||
return bundleName;
|
||||
}
|
||||
}
|
||||
|
||||
return 'misc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack a single bundle
|
||||
* 打包单个包
|
||||
*/
|
||||
private async _packBundle(
|
||||
name: string,
|
||||
assets: IAssetToPack[],
|
||||
_options: IBundlePackOptions
|
||||
): Promise<IPackedBundle> {
|
||||
const assetInfos: IBundleAssetInfo[] = [];
|
||||
const dataChunks: ArrayBuffer[] = [];
|
||||
let currentOffset = 0;
|
||||
|
||||
// Load and pack each asset
|
||||
for (const asset of assets) {
|
||||
let data = asset.data;
|
||||
|
||||
// Load data if not provided
|
||||
if (!data && this._fileReader) {
|
||||
try {
|
||||
data = await this._fileReader.readBinary(asset.path);
|
||||
} catch (e) {
|
||||
console.warn(`[AssetPacker] Failed to load asset: ${asset.path}`, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
console.warn(`[AssetPacker] No data for asset: ${asset.guid}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Align to 4 bytes
|
||||
const padding = (4 - (data.byteLength % 4)) % 4;
|
||||
const paddedSize = data.byteLength + padding;
|
||||
|
||||
assetInfos.push({
|
||||
guid: asset.guid,
|
||||
name: asset.name,
|
||||
type: asset.type,
|
||||
offset: currentOffset,
|
||||
size: data.byteLength
|
||||
});
|
||||
|
||||
// Add data with padding
|
||||
dataChunks.push(data);
|
||||
if (padding > 0) {
|
||||
dataChunks.push(new ArrayBuffer(padding));
|
||||
}
|
||||
|
||||
currentOffset += paddedSize;
|
||||
}
|
||||
|
||||
// Combine all data
|
||||
const totalSize = dataChunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||
const bundleData = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of dataChunks) {
|
||||
bundleData.set(new Uint8Array(chunk), offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
// Create manifest
|
||||
const manifest: IBundleManifest = {
|
||||
name,
|
||||
version: '1.0',
|
||||
hash: await this._hashBuffer(bundleData.buffer),
|
||||
compression: 'none',
|
||||
size: bundleData.byteLength,
|
||||
assets: assetInfos,
|
||||
dependencies: [],
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
data: bundleData.buffer,
|
||||
manifest
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a buffer using SHA-256
|
||||
* 使用 SHA-256 哈希缓冲区
|
||||
*/
|
||||
private async _hashBuffer(buffer: ArrayBuffer): Promise<string> {
|
||||
// Use Web Crypto API if available
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
||||
}
|
||||
|
||||
// Fallback: simple hash
|
||||
const view = new Uint8Array(buffer);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
hash = ((hash << 5) - hash) + view[i];
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(16, '0');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect assets referenced by a scene
|
||||
* 收集场景引用的资产
|
||||
*/
|
||||
export async function collectSceneAssets(
|
||||
sceneData: unknown,
|
||||
_metaManager: { getPathByGUID: (guid: AssetGUID) => string | undefined }
|
||||
): Promise<AssetGUID[]> {
|
||||
const guids = new Set<AssetGUID>();
|
||||
|
||||
// Recursively find all GUID references
|
||||
function findGUIDs(obj: unknown): void {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
findGUIDs(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
// Check for GUID fields
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key.endsWith('Guid') || key.endsWith('GUID') || key === 'guid') {
|
||||
if (typeof value === 'string' && isValidGUID(value)) {
|
||||
guids.add(value);
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
findGUIDs(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findGUIDs(sceneData);
|
||||
return Array.from(guids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate GUID format
|
||||
* 验证 GUID 格式
|
||||
*/
|
||||
function isValidGUID(guid: string): boolean {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(guid);
|
||||
}
|
||||
36
packages/asset-system-editor/tsconfig.json
Normal file
36
packages/asset-system-editor/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
10
packages/asset-system-editor/tsup.config.ts
Normal file
10
packages/asset-system-editor/tsup.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
external: ['@esengine/asset-system']
|
||||
});
|
||||
41
packages/asset-system/module.json
Normal file
41
packages/asset-system/module.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"id": "asset-system",
|
||||
"name": "@esengine/asset-system",
|
||||
"displayName": "Asset System",
|
||||
"description": "Asset loading, caching and management | 资源加载、缓存和管理",
|
||||
"version": "1.0.0",
|
||||
"category": "Core",
|
||||
"icon": "FolderOpen",
|
||||
"tags": [
|
||||
"asset",
|
||||
"resource",
|
||||
"loader"
|
||||
],
|
||||
"isCore": true,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core"
|
||||
],
|
||||
"exports": {
|
||||
"loaders": [
|
||||
"TextureLoader",
|
||||
"JsonLoader",
|
||||
"TextLoader",
|
||||
"BinaryLoader"
|
||||
],
|
||||
"other": [
|
||||
"AssetManager",
|
||||
"AssetDatabase",
|
||||
"AssetCache"
|
||||
]
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js"
|
||||
}
|
||||
272
packages/asset-system/src/bundle/BundleFormat.ts
Normal file
272
packages/asset-system/src/bundle/BundleFormat.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Asset Bundle Format Definitions
|
||||
* 资产包格式定义
|
||||
*
|
||||
* Binary format for efficient asset storage and loading.
|
||||
* 用于高效资产存储和加载的二进制格式。
|
||||
*/
|
||||
|
||||
import { AssetGUID, AssetType } from '../types/AssetTypes';
|
||||
|
||||
/**
|
||||
* Bundle file magic number
|
||||
* 包文件魔数
|
||||
*/
|
||||
export const BUNDLE_MAGIC = 'ESBNDL';
|
||||
|
||||
/**
|
||||
* Bundle format version
|
||||
* 包格式版本
|
||||
*/
|
||||
export const BUNDLE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Bundle compression types
|
||||
* 包压缩类型
|
||||
*/
|
||||
export enum BundleCompression {
|
||||
None = 0,
|
||||
Gzip = 1,
|
||||
Brotli = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle flags
|
||||
* 包标志
|
||||
*/
|
||||
export enum BundleFlags {
|
||||
None = 0,
|
||||
Compressed = 1 << 0,
|
||||
Encrypted = 1 << 1,
|
||||
Streaming = 1 << 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset type codes for binary serialization
|
||||
* 用于二进制序列化的资产类型代码
|
||||
*/
|
||||
export const AssetTypeCode: Record<string, number> = {
|
||||
texture: 1,
|
||||
audio: 2,
|
||||
json: 3,
|
||||
text: 4,
|
||||
binary: 5,
|
||||
scene: 6,
|
||||
prefab: 7,
|
||||
font: 8,
|
||||
shader: 9,
|
||||
material: 10,
|
||||
mesh: 11,
|
||||
animation: 12,
|
||||
tilemap: 20,
|
||||
tileset: 21,
|
||||
'behavior-tree': 22,
|
||||
blueprint: 23
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundle header structure (32 bytes)
|
||||
* 包头结构 (32 字节)
|
||||
*/
|
||||
export interface IBundleHeader {
|
||||
/** Magic number "ESBNDL" | 魔数 */
|
||||
magic: string;
|
||||
/** Format version | 格式版本 */
|
||||
version: number;
|
||||
/** Bundle flags | 包标志 */
|
||||
flags: BundleFlags;
|
||||
/** Compression type | 压缩类型 */
|
||||
compression: BundleCompression;
|
||||
/** Number of assets | 资产数量 */
|
||||
assetCount: number;
|
||||
/** TOC offset from start | TOC 偏移量 */
|
||||
tocOffset: number;
|
||||
/** Data offset from start | 数据偏移量 */
|
||||
dataOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table of Contents entry (40 bytes per entry)
|
||||
* 目录条目 (每条 40 字节)
|
||||
*/
|
||||
export interface IBundleTocEntry {
|
||||
/** Asset GUID (16 bytes as UUID binary) | 资产 GUID */
|
||||
guid: AssetGUID;
|
||||
/** Asset type code | 资产类型代码 */
|
||||
typeCode: number;
|
||||
/** Offset from data section start | 相对于数据段起始的偏移 */
|
||||
offset: number;
|
||||
/** Compressed size in bytes | 压缩后大小 */
|
||||
compressedSize: number;
|
||||
/** Uncompressed size in bytes | 未压缩大小 */
|
||||
uncompressedSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle manifest (JSON sidecar file)
|
||||
* 包清单 (JSON 附属文件)
|
||||
*/
|
||||
export interface IBundleManifest {
|
||||
/** Bundle name | 包名称 */
|
||||
name: string;
|
||||
/** Bundle version | 包版本 */
|
||||
version: string;
|
||||
/** Content hash for integrity | 内容哈希 */
|
||||
hash: string;
|
||||
/** Compression type | 压缩类型 */
|
||||
compression: 'none' | 'gzip' | 'brotli';
|
||||
/** Total bundle size | 包总大小 */
|
||||
size: number;
|
||||
/** Assets in this bundle | 包含的资产 */
|
||||
assets: IBundleAssetInfo[];
|
||||
/** Dependencies on other bundles | 依赖的其他包 */
|
||||
dependencies: string[];
|
||||
/** Creation timestamp | 创建时间戳 */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset info in bundle manifest
|
||||
* 包清单中的资产信息
|
||||
*/
|
||||
export interface IBundleAssetInfo {
|
||||
/** Asset GUID | 资产 GUID */
|
||||
guid: AssetGUID;
|
||||
/** Asset name (for debugging) | 资产名称 (用于调试) */
|
||||
name: string;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Offset in bundle | 包内偏移 */
|
||||
offset: number;
|
||||
/** Size in bytes | 大小 */
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime catalog format (loaded in browser)
|
||||
* 运行时目录格式 (在浏览器中加载)
|
||||
*/
|
||||
export interface IRuntimeCatalog {
|
||||
/** Catalog version | 目录版本 */
|
||||
version: string;
|
||||
/** Creation timestamp | 创建时间戳 */
|
||||
createdAt: number;
|
||||
/** Available bundles | 可用的包 */
|
||||
bundles: Record<string, IRuntimeBundleInfo>;
|
||||
/** Asset GUID to location mapping | 资产 GUID 到位置的映射 */
|
||||
assets: Record<AssetGUID, IRuntimeAssetLocation>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle info in runtime catalog
|
||||
* 运行时目录中的包信息
|
||||
*/
|
||||
export interface IRuntimeBundleInfo {
|
||||
/** Bundle URL (relative to catalog) | 包 URL */
|
||||
url: string;
|
||||
/** Bundle size in bytes | 包大小 */
|
||||
size: number;
|
||||
/** Content hash | 内容哈希 */
|
||||
hash: string;
|
||||
/** Whether bundle is preloaded | 是否预加载 */
|
||||
preload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset location in runtime catalog
|
||||
* 运行时目录中的资产位置
|
||||
*/
|
||||
export interface IRuntimeAssetLocation {
|
||||
/** Bundle name containing this asset | 包含此资产的包名 */
|
||||
bundle: string;
|
||||
/** Offset within bundle | 包内偏移 */
|
||||
offset: number;
|
||||
/** Size in bytes | 大小 */
|
||||
size: number;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Asset name (for debugging) | 资产名称 */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle packing options
|
||||
* 包打包选项
|
||||
*/
|
||||
export interface IBundlePackOptions {
|
||||
/** Bundle name | 包名称 */
|
||||
name: string;
|
||||
/** Compression type | 压缩类型 */
|
||||
compression?: BundleCompression;
|
||||
/** Maximum bundle size (split if exceeded) | 最大包大小 */
|
||||
maxSize?: number;
|
||||
/** Group assets by type | 按类型分组资产 */
|
||||
groupByType?: boolean;
|
||||
/** Include asset names in bundle | 在包中包含资产名称 */
|
||||
includeNames?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset to pack
|
||||
* 要打包的资产
|
||||
*/
|
||||
export interface IAssetToPack {
|
||||
/** Asset GUID | 资产 GUID */
|
||||
guid: AssetGUID;
|
||||
/** Asset path (for reading) | 资产路径 */
|
||||
path: string;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Asset name | 资产名称 */
|
||||
name: string;
|
||||
/** Raw data (or null to read from path) | 原始数据 */
|
||||
data?: ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GUID from 16-byte binary
|
||||
* 从 16 字节二进制解析 GUID
|
||||
*/
|
||||
export function parseGUIDFromBinary(bytes: Uint8Array): AssetGUID {
|
||||
const hex = Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize GUID to 16-byte binary
|
||||
* 将 GUID 序列化为 16 字节二进制
|
||||
*/
|
||||
export function serializeGUIDToBinary(guid: AssetGUID): Uint8Array {
|
||||
const hex = guid.replace(/-/g, '');
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type code from asset type string
|
||||
* 从资产类型字符串获取类型代码
|
||||
*/
|
||||
export function getAssetTypeCode(type: AssetType): number {
|
||||
return AssetTypeCode[type] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset type string from type code
|
||||
* 从类型代码获取资产类型字符串
|
||||
*/
|
||||
export function getAssetTypeFromCode(code: number): AssetType {
|
||||
for (const [type, typeCode] of Object.entries(AssetTypeCode)) {
|
||||
if (typeCode === code) {
|
||||
return type as AssetType;
|
||||
}
|
||||
}
|
||||
return 'binary';
|
||||
}
|
||||
@@ -22,6 +22,76 @@ export class AssetDatabase {
|
||||
private readonly _dependencies = new Map<AssetGUID, Set<AssetGUID>>();
|
||||
private readonly _dependents = new Map<AssetGUID, Set<AssetGUID>>();
|
||||
|
||||
/** Project root path for resolving relative paths. | 项目根路径,用于解析相对路径。 */
|
||||
private _projectRoot: string | null = null;
|
||||
|
||||
/**
|
||||
* Set project root path.
|
||||
* 设置项目根路径。
|
||||
*
|
||||
* @param path - Absolute path to project root. | 项目根目录的绝对路径。
|
||||
*/
|
||||
setProjectRoot(path: string): void {
|
||||
this._projectRoot = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project root path.
|
||||
* 获取项目根路径。
|
||||
*/
|
||||
getProjectRoot(): string | null {
|
||||
return this._projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relative path to absolute path.
|
||||
* 将相对路径解析为绝对路径。
|
||||
*
|
||||
* @param relativePath - Relative asset path (e.g., "assets/texture.png"). | 相对资产路径。
|
||||
* @returns Absolute file system path. | 绝对文件系统路径。
|
||||
*/
|
||||
resolveAbsolutePath(relativePath: string): string {
|
||||
// Already absolute path (Windows or Unix).
|
||||
// 已经是绝对路径。
|
||||
if (relativePath.match(/^[a-zA-Z]:/) || relativePath.startsWith('/')) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// No project root set, return as-is.
|
||||
// 未设置项目根路径,原样返回。
|
||||
if (!this._projectRoot) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// Join with project root.
|
||||
// 与项目根路径拼接。
|
||||
const separator = this._projectRoot.includes('\\') ? '\\' : '/';
|
||||
const normalizedPath = relativePath.replace(/[/\\]/g, separator);
|
||||
return `${this._projectRoot}${separator}${normalizedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert absolute path to relative path.
|
||||
* 将绝对路径转换为相对路径。
|
||||
*
|
||||
* @param absolutePath - Absolute file system path. | 绝对文件系统路径。
|
||||
* @returns Relative asset path, or null if not under project root. | 相对资产路径。
|
||||
*/
|
||||
toRelativePath(absolutePath: string): string | null {
|
||||
if (!this._projectRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedAbs = absolutePath.replace(/\\/g, '/');
|
||||
const normalizedRoot = this._projectRoot.replace(/\\/g, '/');
|
||||
|
||||
if (normalizedAbs.startsWith(normalizedRoot)) {
|
||||
return normalizedAbs.substring(normalizedRoot.length + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add asset to database
|
||||
* 添加资产到数据库
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
IAssetManager,
|
||||
IAssetLoadQueue
|
||||
} from '../interfaces/IAssetManager';
|
||||
import { IAssetLoader, IAssetLoaderFactory } from '../interfaces/IAssetLoader';
|
||||
import { IAssetLoader, IAssetLoaderFactory, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetReader, IAssetContent } from '../interfaces/IAssetReader';
|
||||
import { AssetCache } from './AssetCache';
|
||||
import { AssetLoadQueue } from './AssetLoadQueue';
|
||||
import { AssetLoaderFactory } from '../loaders/AssetLoaderFactory';
|
||||
@@ -55,6 +56,9 @@ export class AssetManager implements IAssetManager {
|
||||
private readonly _loaderFactory: IAssetLoaderFactory;
|
||||
private readonly _database: AssetDatabase;
|
||||
|
||||
/** Asset reader for file operations. | 用于文件操作的资产读取器。 */
|
||||
private _reader: IAssetReader | null = null;
|
||||
|
||||
private _nextHandle: AssetHandle = 1;
|
||||
|
||||
private _statistics = {
|
||||
@@ -71,12 +75,35 @@ export class AssetManager implements IAssetManager {
|
||||
this._loaderFactory = new AssetLoaderFactory();
|
||||
this._database = new AssetDatabase();
|
||||
|
||||
// 如果提供了目录,初始化数据库 / Initialize database if catalog provided
|
||||
if (catalog) {
|
||||
this.initializeFromCatalog(catalog);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set asset reader.
|
||||
* 设置资产读取器。
|
||||
*/
|
||||
setReader(reader: IAssetReader): void {
|
||||
this._reader = reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set project root path for resolving relative paths.
|
||||
* 设置项目根路径用于解析相对路径。
|
||||
*/
|
||||
setProjectRoot(path: string): void {
|
||||
this._database.setProjectRoot(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the asset database.
|
||||
* 获取资产数据库。
|
||||
*/
|
||||
getDatabase(): AssetDatabase {
|
||||
return this._database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from catalog
|
||||
* 从目录初始化
|
||||
@@ -196,32 +223,89 @@ export class AssetManager implements IAssetManager {
|
||||
startTime: number,
|
||||
entry: AssetEntry
|
||||
): Promise<IAssetLoadResult<T>> {
|
||||
// 加载依赖 / Load dependencies
|
||||
if (!this._reader) {
|
||||
throw new Error('Asset reader not set. Call setReader() first.');
|
||||
}
|
||||
|
||||
// Load dependencies first.
|
||||
// 先加载依赖。
|
||||
if (metadata.dependencies.length > 0) {
|
||||
await this.loadDependencies(metadata.dependencies, options);
|
||||
}
|
||||
|
||||
// 执行加载 / Execute loading
|
||||
const result = await loader.load(metadata.path, metadata, options);
|
||||
// Resolve absolute path.
|
||||
// 解析绝对路径。
|
||||
const absolutePath = this._database.resolveAbsolutePath(metadata.path);
|
||||
|
||||
// 更新条目 / Update entry
|
||||
entry.asset = result.asset;
|
||||
// Read content based on loader's content type.
|
||||
// 根据加载器的内容类型读取内容。
|
||||
const content = await this.readContent(loader.contentType, absolutePath);
|
||||
|
||||
// Create parse context.
|
||||
// 创建解析上下文。
|
||||
const context: IAssetParseContext = {
|
||||
metadata,
|
||||
options,
|
||||
loadDependency: async <D>(relativePath: string) => {
|
||||
const result = await this.loadAssetByPath<D>(relativePath, options);
|
||||
return result.asset;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse asset.
|
||||
// 解析资产。
|
||||
const asset = await loader.parse(content, context);
|
||||
|
||||
// Update entry.
|
||||
// 更新条目。
|
||||
entry.asset = asset;
|
||||
entry.state = AssetState.Loaded;
|
||||
|
||||
// 缓存资产 / Cache asset
|
||||
this._cache.set(metadata.guid, result.asset);
|
||||
// Cache asset.
|
||||
// 缓存资产。
|
||||
this._cache.set(metadata.guid, asset);
|
||||
|
||||
// 更新统计 / Update statistics
|
||||
// Update statistics.
|
||||
// 更新统计。
|
||||
this._statistics.loadedCount++;
|
||||
|
||||
const loadResult: IAssetLoadResult<T> = {
|
||||
asset: result.asset as T,
|
||||
return {
|
||||
asset: asset as T,
|
||||
handle: entry.handle,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
return loadResult;
|
||||
/**
|
||||
* Read content based on content type.
|
||||
* 根据内容类型读取内容。
|
||||
*/
|
||||
private async readContent(contentType: string, absolutePath: string): Promise<IAssetContent> {
|
||||
if (!this._reader) {
|
||||
throw new Error('Asset reader not set');
|
||||
}
|
||||
|
||||
switch (contentType) {
|
||||
case 'text': {
|
||||
const text = await this._reader.readText(absolutePath);
|
||||
return { type: 'text', text };
|
||||
}
|
||||
case 'binary': {
|
||||
const binary = await this._reader.readBinary(absolutePath);
|
||||
return { type: 'binary', binary };
|
||||
}
|
||||
case 'image': {
|
||||
const image = await this._reader.loadImage(absolutePath);
|
||||
return { type: 'image', image };
|
||||
}
|
||||
case 'audio': {
|
||||
const audioBuffer = await this._reader.loadAudio(absolutePath);
|
||||
return { type: 'audio', audioBuffer };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown content type: ${contentType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,6 +513,19 @@ export class AssetManager implements IAssetManager {
|
||||
return this.getAsset<T>(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loaded asset by path (synchronous)
|
||||
* 通过路径获取已加载的资产(同步)
|
||||
*
|
||||
* Returns the asset if it's already loaded, null otherwise.
|
||||
* 如果资产已加载则返回资产,否则返回 null。
|
||||
*/
|
||||
getAssetByPath<T = unknown>(path: string): T | null {
|
||||
const guid = this._pathToGuid.get(path);
|
||||
if (!guid) return null;
|
||||
return this.getAsset<T>(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
/**
|
||||
* Asset System for ECS Framework
|
||||
* ECS框架的资产系统
|
||||
*
|
||||
* Runtime-focused asset management:
|
||||
* - Asset loading and caching
|
||||
* - GUID-based asset resolution
|
||||
* - Bundle loading
|
||||
*
|
||||
* For editor-side functionality (meta files, packing), use @esengine/asset-system-editor
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types/AssetTypes';
|
||||
|
||||
// Bundle format (shared types for runtime and editor)
|
||||
export * from './bundle/BundleFormat';
|
||||
|
||||
// Runtime catalog
|
||||
export { RuntimeCatalog, runtimeCatalog } from './runtime/RuntimeCatalog';
|
||||
|
||||
// Interfaces
|
||||
export * from './interfaces/IAssetLoader';
|
||||
export * from './interfaces/IAssetManager';
|
||||
export * from './interfaces/IAssetReader';
|
||||
export * from './interfaces/IResourceComponent';
|
||||
|
||||
// Core
|
||||
@@ -51,9 +65,12 @@ export const assetManager = new AssetManager();
|
||||
* Initialize asset system with catalog
|
||||
* 使用目录初始化资产系统
|
||||
*/
|
||||
export function initializeAssetSystem(catalog?: any): AssetManager {
|
||||
export function initializeAssetSystem(catalog?: IAssetCatalog): AssetManager {
|
||||
if (catalog) {
|
||||
return new AssetManager(catalog);
|
||||
}
|
||||
return assetManager;
|
||||
}
|
||||
|
||||
// Re-export IAssetCatalog for initializeAssetSystem signature
|
||||
import type { IAssetCatalog } from './types/AssetTypes';
|
||||
|
||||
@@ -65,8 +65,8 @@ export class EngineIntegration {
|
||||
* Load texture for component
|
||||
* 为组件加载纹理
|
||||
*
|
||||
* 统一的路径解析入口:相对路径会被转换为 Tauri 可用的 asset:// URL
|
||||
* Unified path resolution entry: relative paths will be converted to Tauri-compatible asset:// URLs
|
||||
* AssetManager 内部会处理路径解析,这里只需传入原始路径。
|
||||
* AssetManager handles path resolution internally, just pass the original path here.
|
||||
*/
|
||||
async loadTextureForComponent(texturePath: string): Promise<number> {
|
||||
// 检查缓存(使用原始路径作为键)
|
||||
@@ -76,19 +76,18 @@ export class EngineIntegration {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
// 使用 globalPathResolver 转换路径
|
||||
// Use globalPathResolver to transform the path
|
||||
const resolvedPath = globalPathResolver.resolve(texturePath);
|
||||
|
||||
// 通过资产系统加载(使用解析后的路径)
|
||||
// Load through asset system (using resolved path)
|
||||
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(resolvedPath);
|
||||
// 通过资产系统加载(AssetManager 内部会解析路径)
|
||||
// Load through asset system (AssetManager resolves path internally)
|
||||
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath);
|
||||
const textureAsset = result.asset;
|
||||
|
||||
// 如果有引擎桥接,上传到GPU(使用解析后的路径)
|
||||
// Upload to GPU if bridge exists (using resolved path)
|
||||
// 如果有引擎桥接,上传到GPU
|
||||
// Upload to GPU if bridge exists
|
||||
// 使用 globalPathResolver 将路径转换为引擎可用的 URL
|
||||
// Use globalPathResolver to convert path to engine-compatible URL
|
||||
if (this._engineBridge && textureAsset.data) {
|
||||
await this._engineBridge.loadTexture(textureAsset.textureId, resolvedPath);
|
||||
const engineUrl = globalPathResolver.resolve(texturePath);
|
||||
await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl);
|
||||
}
|
||||
|
||||
// 缓存映射(使用原始路径作为键,避免重复解析)
|
||||
|
||||
@@ -7,40 +7,64 @@ import {
|
||||
AssetType,
|
||||
AssetGUID,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult
|
||||
IAssetMetadata
|
||||
} from '../types/AssetTypes';
|
||||
import type { IAssetContent, AssetContentType } from './IAssetReader';
|
||||
|
||||
/**
|
||||
* Base asset loader interface
|
||||
* 基础资产加载器接口
|
||||
* Parse context provided to loaders.
|
||||
* 提供给加载器的解析上下文。
|
||||
*/
|
||||
export interface IAssetParseContext {
|
||||
/** Asset metadata. | 资产元数据。 */
|
||||
metadata: IAssetMetadata;
|
||||
/** Load options. | 加载选项。 */
|
||||
options?: IAssetLoadOptions;
|
||||
/**
|
||||
* Load a dependency asset by relative path.
|
||||
* 通过相对路径加载依赖资产。
|
||||
*/
|
||||
loadDependency<D = unknown>(relativePath: string): Promise<D>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loader interface.
|
||||
* 资产加载器接口。
|
||||
*
|
||||
* Loaders only parse content, file reading is handled by AssetManager.
|
||||
* 加载器只负责解析内容,文件读取由 AssetManager 处理。
|
||||
*/
|
||||
export interface IAssetLoader<T = unknown> {
|
||||
/** 支持的资产类型 / Supported asset type */
|
||||
/** Supported asset type. | 支持的资产类型。 */
|
||||
readonly supportedType: AssetType;
|
||||
|
||||
/** 支持的文件扩展名 / Supported file extensions */
|
||||
/** Supported file extensions. | 支持的文件扩展名。 */
|
||||
readonly supportedExtensions: string[];
|
||||
|
||||
/**
|
||||
* Load an asset from the given path
|
||||
* 从指定路径加载资产
|
||||
* Required content type for this loader.
|
||||
* 此加载器需要的内容类型。
|
||||
*
|
||||
* - 'text': For JSON, shader, material files
|
||||
* - 'binary': For binary formats
|
||||
* - 'image': For textures
|
||||
* - 'audio': For audio files
|
||||
*/
|
||||
load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<T>>;
|
||||
readonly contentType: AssetContentType;
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
* Parse asset from content.
|
||||
* 从内容解析资产。
|
||||
*
|
||||
* @param content - File content. | 文件内容。
|
||||
* @param context - Parse context. | 解析上下文。
|
||||
* @returns Parsed asset. | 解析后的资产。
|
||||
*/
|
||||
canLoad(path: string, metadata: IAssetMetadata): boolean;
|
||||
parse(content: IAssetContent, context: IAssetParseContext): Promise<T>;
|
||||
|
||||
/**
|
||||
* Dispose loaded asset and free resources
|
||||
* 释放已加载的资产并释放资源
|
||||
* Dispose loaded asset and free resources.
|
||||
* 释放已加载的资产。
|
||||
*/
|
||||
dispose(asset: T): void;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ export interface IAssetManager {
|
||||
*/
|
||||
getAssetByHandle<T = unknown>(handle: AssetHandle): T | null;
|
||||
|
||||
/**
|
||||
* Get loaded asset by path (synchronous)
|
||||
* 通过路径获取已加载的资产(同步)
|
||||
*/
|
||||
getAssetByPath<T = unknown>(path: string): T | null;
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
|
||||
90
packages/asset-system/src/interfaces/IAssetReader.ts
Normal file
90
packages/asset-system/src/interfaces/IAssetReader.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Asset Reader Interface
|
||||
* 资产读取器接口
|
||||
*
|
||||
* Provides unified file reading abstraction across different platforms.
|
||||
* 提供跨平台的统一文件读取抽象。
|
||||
*/
|
||||
|
||||
/**
|
||||
* Asset content types.
|
||||
* 资产内容类型。
|
||||
*/
|
||||
export type AssetContentType = 'text' | 'binary' | 'image' | 'audio';
|
||||
|
||||
/**
|
||||
* Asset content result.
|
||||
* 资产内容结果。
|
||||
*/
|
||||
export interface IAssetContent {
|
||||
/** Content type. | 内容类型。 */
|
||||
type: AssetContentType;
|
||||
/** Text content (for text/json files). | 文本内容。 */
|
||||
text?: string;
|
||||
/** Binary content. | 二进制内容。 */
|
||||
binary?: ArrayBuffer;
|
||||
/** Image element (for textures). | 图片元素。 */
|
||||
image?: HTMLImageElement;
|
||||
/** Audio buffer (for audio files). | 音频缓冲区。 */
|
||||
audioBuffer?: AudioBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset reader interface.
|
||||
* 资产读取器接口。
|
||||
*
|
||||
* Abstracts platform-specific file reading operations.
|
||||
* 抽象平台特定的文件读取操作。
|
||||
*/
|
||||
export interface IAssetReader {
|
||||
/**
|
||||
* Read file as text.
|
||||
* 读取文件为文本。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Text content. | 文本内容。
|
||||
*/
|
||||
readText(absolutePath: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Read file as binary.
|
||||
* 读取文件为二进制。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Binary content. | 二进制内容。
|
||||
*/
|
||||
readBinary(absolutePath: string): Promise<ArrayBuffer>;
|
||||
|
||||
/**
|
||||
* Load image from file.
|
||||
* 从文件加载图片。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Image element. | 图片元素。
|
||||
*/
|
||||
loadImage(absolutePath: string): Promise<HTMLImageElement>;
|
||||
|
||||
/**
|
||||
* Load audio from file.
|
||||
* 从文件加载音频。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Audio buffer. | 音频缓冲区。
|
||||
*/
|
||||
loadAudio(absolutePath: string): Promise<AudioBuffer>;
|
||||
|
||||
/**
|
||||
* Check if file exists.
|
||||
* 检查文件是否存在。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns True if exists. | 是否存在。
|
||||
*/
|
||||
exists(absolutePath: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service identifier for IAssetReader.
|
||||
* IAssetReader 的服务标识符。
|
||||
*/
|
||||
export const IAssetReaderService = Symbol.for('IAssetReaderService');
|
||||
@@ -3,14 +3,9 @@
|
||||
* 二进制资产加载器
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError
|
||||
} from '../types/AssetTypes';
|
||||
import { IAssetLoader, IBinaryAsset } from '../interfaces/IAssetLoader';
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import { IAssetLoader, IBinaryAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
|
||||
/**
|
||||
* Binary loader implementation
|
||||
@@ -22,144 +17,27 @@ export class BinaryLoader implements IAssetLoader<IBinaryAsset> {
|
||||
'.bin', '.dat', '.raw', '.bytes',
|
||||
'.wasm', '.so', '.dll', '.dylib'
|
||||
];
|
||||
readonly contentType: AssetContentType = 'binary';
|
||||
|
||||
/**
|
||||
* Load binary asset
|
||||
* 加载二进制资产
|
||||
* Parse binary from content.
|
||||
* 从内容解析二进制。
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<IBinaryAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(path, options?.timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取MIME类型 / Get MIME type
|
||||
const mimeType = response.headers.get('content-type') || undefined;
|
||||
|
||||
// 获取总大小用于进度回调 / Get total size for progress callback
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
|
||||
// 读取响应 / Read response
|
||||
let data: ArrayBuffer;
|
||||
if (options?.onProgress && total > 0) {
|
||||
data = await this.readResponseWithProgress(response, total, options.onProgress);
|
||||
} else {
|
||||
data = await response.arrayBuffer();
|
||||
}
|
||||
|
||||
const asset: IBinaryAsset = {
|
||||
data,
|
||||
mimeType
|
||||
};
|
||||
|
||||
return {
|
||||
asset,
|
||||
handle: 0,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load binary: ${error.message}`,
|
||||
metadata.guid,
|
||||
AssetType.Binary,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IBinaryAsset> {
|
||||
if (!content.binary) {
|
||||
throw new Error('Binary content is empty');
|
||||
}
|
||||
|
||||
return {
|
||||
data: content.binary
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with timeout
|
||||
* 带超时的fetch
|
||||
*/
|
||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read response with progress
|
||||
* 带进度读取响应
|
||||
*/
|
||||
private async readResponseWithProgress(
|
||||
response: Response,
|
||||
total: number,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<ArrayBuffer> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
|
||||
// 报告进度 / Report progress
|
||||
onProgress(receivedLength / total);
|
||||
}
|
||||
|
||||
// 合并chunks到ArrayBuffer / Merge chunks into ArrayBuffer
|
||||
const result = new Uint8Array(receivedLength);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return this.supportedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory usage for the asset
|
||||
* 估算资产的内存使用量
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dispose loaded asset
|
||||
* 释放已加载的资产
|
||||
*/
|
||||
dispose(asset: IBinaryAsset): void {
|
||||
// ArrayBuffer无法直接释放,但可以清空引用 / Can't directly release ArrayBuffer, but clear reference
|
||||
(asset as any).data = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
* JSON资产加载器
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError
|
||||
} from '../types/AssetTypes';
|
||||
import { IAssetLoader, IJsonAsset } from '../interfaces/IAssetLoader';
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import { IAssetLoader, IJsonAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
|
||||
/**
|
||||
* JSON loader implementation
|
||||
@@ -19,144 +14,27 @@ import { IAssetLoader, IJsonAsset } from '../interfaces/IAssetLoader';
|
||||
export class JsonLoader implements IAssetLoader<IJsonAsset> {
|
||||
readonly supportedType = AssetType.Json;
|
||||
readonly supportedExtensions = ['.json', '.jsonc'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* Load JSON asset
|
||||
* 加载JSON资产
|
||||
* Parse JSON from text content.
|
||||
* 从文本内容解析JSON。
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<IJsonAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(path, options?.timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取总大小用于进度回调 / Get total size for progress callback
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
|
||||
// 读取响应 / Read response
|
||||
let jsonData: unknown;
|
||||
if (options?.onProgress && total > 0) {
|
||||
jsonData = await this.readResponseWithProgress(response, total, options.onProgress);
|
||||
} else {
|
||||
jsonData = await response.json();
|
||||
}
|
||||
|
||||
const asset: IJsonAsset = {
|
||||
data: jsonData
|
||||
};
|
||||
|
||||
return {
|
||||
asset,
|
||||
handle: 0,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load JSON: ${error.message}`,
|
||||
metadata.guid,
|
||||
AssetType.Json,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IJsonAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('JSON content is empty');
|
||||
}
|
||||
|
||||
return {
|
||||
data: JSON.parse(content.text)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with timeout
|
||||
* 带超时的fetch
|
||||
*/
|
||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read response with progress
|
||||
* 带进度读取响应
|
||||
*/
|
||||
private async readResponseWithProgress(
|
||||
response: Response,
|
||||
total: number,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<unknown> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
|
||||
// 报告进度 / Report progress
|
||||
onProgress(receivedLength / total);
|
||||
}
|
||||
|
||||
// 合并chunks / Merge chunks
|
||||
const allChunks = new Uint8Array(receivedLength);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
allChunks.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
|
||||
// 解码为字符串并解析JSON / Decode to string and parse JSON
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const jsonString = decoder.decode(allChunks);
|
||||
return JSON.parse(jsonString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return this.supportedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory usage for the asset
|
||||
* 估算资产的内存使用量
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dispose loaded asset
|
||||
* 释放已加载的资产
|
||||
*/
|
||||
dispose(asset: IJsonAsset): void {
|
||||
// JSON资产通常不需要特殊清理 / JSON assets usually don't need special cleanup
|
||||
// 但可以清空引用以帮助GC / But can clear references to help GC
|
||||
(asset as any).data = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
* 文本资产加载器
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError
|
||||
} from '../types/AssetTypes';
|
||||
import { IAssetLoader, ITextAsset } from '../interfaces/IAssetLoader';
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import { IAssetLoader, ITextAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
|
||||
/**
|
||||
* Text loader implementation
|
||||
@@ -19,115 +14,21 @@ import { IAssetLoader, ITextAsset } from '../interfaces/IAssetLoader';
|
||||
export class TextLoader implements IAssetLoader<ITextAsset> {
|
||||
readonly supportedType = AssetType.Text;
|
||||
readonly supportedExtensions = ['.txt', '.text', '.md', '.csv', '.xml', '.html', '.css', '.js', '.ts'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* Load text asset
|
||||
* 加载文本资产
|
||||
* Parse text from content.
|
||||
* 从内容解析文本。
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<ITextAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(path, options?.timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取总大小用于进度回调 / Get total size for progress callback
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
|
||||
// 读取响应 / Read response
|
||||
let content: string;
|
||||
if (options?.onProgress && total > 0) {
|
||||
content = await this.readResponseWithProgress(response, total, options.onProgress);
|
||||
} else {
|
||||
content = await response.text();
|
||||
}
|
||||
|
||||
// 检测编码 / Detect encoding
|
||||
const encoding = this.detectEncoding(content);
|
||||
|
||||
const asset: ITextAsset = {
|
||||
content,
|
||||
encoding
|
||||
};
|
||||
|
||||
return {
|
||||
asset,
|
||||
handle: 0,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load text: ${error.message}`,
|
||||
metadata.guid,
|
||||
AssetType.Text,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with timeout
|
||||
* 带超时的fetch
|
||||
*/
|
||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read response with progress
|
||||
* 带进度读取响应
|
||||
*/
|
||||
private async readResponseWithProgress(
|
||||
response: Response,
|
||||
total: number,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return response.text();
|
||||
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITextAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('Text content is empty');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let result = '';
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
receivedLength += value.length;
|
||||
result += decoder.decode(value, { stream: true });
|
||||
|
||||
// 报告进度 / Report progress
|
||||
onProgress(receivedLength / total);
|
||||
}
|
||||
|
||||
return result;
|
||||
return {
|
||||
content: content.text,
|
||||
encoding: this.detectEncoding(content.text)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,38 +36,20 @@ export class TextLoader implements IAssetLoader<ITextAsset> {
|
||||
* 检测文本编码
|
||||
*/
|
||||
private detectEncoding(content: string): 'utf8' | 'utf16' | 'ascii' {
|
||||
// 简单的编码检测 / Simple encoding detection
|
||||
// 检查是否包含非ASCII字符 / Check for non-ASCII characters
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const charCode = content.charCodeAt(i);
|
||||
if (charCode > 127) {
|
||||
// 包含非ASCII字符,可能是UTF-8或UTF-16 / Contains non-ASCII, likely UTF-8 or UTF-16
|
||||
return charCode > 255 ? 'utf16' : 'utf8';
|
||||
}
|
||||
}
|
||||
return 'ascii';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return this.supportedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory usage for the asset
|
||||
* 估算资产的内存使用量
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dispose loaded asset
|
||||
* 释放已加载的资产
|
||||
*/
|
||||
dispose(asset: ITextAsset): void {
|
||||
// 清空内容以帮助GC / Clear content to help GC
|
||||
(asset as any).content = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
* 纹理资产加载器
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError
|
||||
} from '../types/AssetTypes';
|
||||
import { IAssetLoader, ITextureAsset } from '../interfaces/IAssetLoader';
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import { IAssetLoader, ITextureAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
|
||||
/**
|
||||
* Texture loader implementation
|
||||
@@ -19,147 +14,36 @@ import { IAssetLoader, ITextureAsset } from '../interfaces/IAssetLoader';
|
||||
export class TextureLoader implements IAssetLoader<ITextureAsset> {
|
||||
readonly supportedType = AssetType.Texture;
|
||||
readonly supportedExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'];
|
||||
readonly contentType: AssetContentType = 'image';
|
||||
|
||||
private static _nextTextureId = 1;
|
||||
private readonly _loadedTextures = new Map<string, ITextureAsset>();
|
||||
|
||||
/**
|
||||
* Load texture asset
|
||||
* 加载纹理资产
|
||||
* Parse texture from image content.
|
||||
* 从图片内容解析纹理。
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<ITextureAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// 检查缓存 / Check cache
|
||||
if (!options?.forceReload && this._loadedTextures.has(path)) {
|
||||
const cached = this._loadedTextures.get(path)!;
|
||||
return {
|
||||
asset: cached,
|
||||
handle: cached.textureId,
|
||||
metadata,
|
||||
loadTime: 0
|
||||
};
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<ITextureAsset> {
|
||||
if (!content.image) {
|
||||
throw new Error('Texture content is empty');
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建图像元素 / Create image element
|
||||
const image = await this.loadImage(path, options);
|
||||
const image = content.image;
|
||||
|
||||
// 创建纹理资产 / Create texture asset
|
||||
const textureAsset: ITextureAsset = {
|
||||
textureId: TextureLoader._nextTextureId++,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
format: 'rgba', // 默认格式 / Default format
|
||||
hasMipmaps: false,
|
||||
data: image
|
||||
};
|
||||
const textureAsset: ITextureAsset = {
|
||||
textureId: TextureLoader._nextTextureId++,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
format: 'rgba',
|
||||
hasMipmaps: false,
|
||||
data: image
|
||||
};
|
||||
|
||||
// 缓存纹理 / Cache texture
|
||||
this._loadedTextures.set(path, textureAsset);
|
||||
|
||||
// 触发引擎纹理加载(如果有引擎桥接) / Trigger engine texture loading if bridge exists
|
||||
if (typeof window !== 'undefined' && (window as any).engineBridge) {
|
||||
await this.uploadToGPU(textureAsset, path);
|
||||
}
|
||||
|
||||
return {
|
||||
asset: textureAsset,
|
||||
handle: textureAsset.textureId,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from URL
|
||||
* 从URL加载图像
|
||||
*/
|
||||
private async loadImage(url: string, options?: IAssetLoadOptions): Promise<HTMLImageElement> {
|
||||
// For Tauri asset URLs, use fetch to load the image
|
||||
// 对于Tauri资产URL,使用fetch加载图像
|
||||
if (url.startsWith('http://asset.localhost/') || url.startsWith('asset://')) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.statusText}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
// Clean up blob URL after loading
|
||||
// 加载后清理blob URL
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
reject(new Error(`Failed to load image from blob: ${url}`));
|
||||
};
|
||||
image.src = blobUrl;
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load Tauri asset: ${url} - ${error}`);
|
||||
}
|
||||
// Upload to GPU if bridge exists.
|
||||
if (typeof window !== 'undefined' && (window as any).engineBridge) {
|
||||
await this.uploadToGPU(textureAsset, context.metadata.path);
|
||||
}
|
||||
|
||||
// For regular URLs, use standard Image loading
|
||||
// 对于常规URL,使用标准Image加载
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
|
||||
// 超时处理 / Timeout handling
|
||||
const timeout = options?.timeout || 30000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Image load timeout: ${url}`));
|
||||
}, timeout);
|
||||
|
||||
// 进度回调 / Progress callback
|
||||
if (options?.onProgress) {
|
||||
// 图像加载没有真正的进度事件,模拟进度 / Images don't have real progress events, simulate
|
||||
let progress = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
progress = Math.min(progress + 0.1, 0.9);
|
||||
options.onProgress!(progress);
|
||||
}, 100);
|
||||
|
||||
image.onload = () => {
|
||||
clearInterval(progressInterval);
|
||||
clearTimeout(timeoutId);
|
||||
options.onProgress!(1);
|
||||
resolve(image);
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
clearInterval(progressInterval);
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to load image: ${url}`));
|
||||
};
|
||||
} else {
|
||||
image.onload = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(image);
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to load image: ${url}`));
|
||||
};
|
||||
}
|
||||
|
||||
image.src = url;
|
||||
});
|
||||
return textureAsset;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,34 +57,12 @@ export class TextureLoader implements IAssetLoader<ITextureAsset> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return this.supportedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory usage for the asset
|
||||
* 估算资产的内存使用量
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dispose loaded asset
|
||||
* 释放已加载的资产
|
||||
*/
|
||||
dispose(asset: ITextureAsset): void {
|
||||
// 从缓存中移除 / Remove from cache
|
||||
for (const [path, cached] of this._loadedTextures.entries()) {
|
||||
if (cached === asset) {
|
||||
this._loadedTextures.delete(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 释放GPU资源 / Release GPU resources
|
||||
// Release GPU resources.
|
||||
if (typeof window !== 'undefined' && (window as any).engineBridge) {
|
||||
const bridge = (window as any).engineBridge;
|
||||
if (bridge.unloadTexture) {
|
||||
@@ -208,7 +70,7 @@ export class TextureLoader implements IAssetLoader<ITextureAsset> {
|
||||
}
|
||||
}
|
||||
|
||||
// 清理图像数据 / Clean up image data
|
||||
// Clean up image data.
|
||||
if (asset.data instanceof HTMLImageElement) {
|
||||
asset.data.src = '';
|
||||
}
|
||||
|
||||
275
packages/asset-system/src/runtime/RuntimeCatalog.ts
Normal file
275
packages/asset-system/src/runtime/RuntimeCatalog.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Runtime Catalog for Asset Resolution
|
||||
* 资产解析的运行时目录
|
||||
*
|
||||
* Provides GUID-based asset lookup at runtime.
|
||||
* 提供运行时基于 GUID 的资产查找。
|
||||
*/
|
||||
|
||||
import { AssetGUID, AssetType } from '../types/AssetTypes';
|
||||
import {
|
||||
IRuntimeCatalog,
|
||||
IRuntimeAssetLocation,
|
||||
IRuntimeBundleInfo
|
||||
} from '../bundle/BundleFormat';
|
||||
|
||||
/**
|
||||
* Runtime Catalog Manager
|
||||
* 运行时目录管理器
|
||||
*
|
||||
* Loads and manages the asset catalog for runtime GUID resolution.
|
||||
*/
|
||||
export class RuntimeCatalog {
|
||||
private _catalog: IRuntimeCatalog | null = null;
|
||||
private _loadedBundles = new Map<string, ArrayBuffer>();
|
||||
private _loadingBundles = new Map<string, Promise<ArrayBuffer>>();
|
||||
private _baseUrl: string = './';
|
||||
|
||||
/**
|
||||
* Set base URL for loading catalog and bundles
|
||||
* 设置加载目录和包的基础 URL
|
||||
*/
|
||||
setBaseUrl(url: string): void {
|
||||
this._baseUrl = url.endsWith('/') ? url : `${url}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load catalog from URL
|
||||
* 从 URL 加载目录
|
||||
*/
|
||||
async loadCatalog(catalogUrl?: string): Promise<void> {
|
||||
const url = catalogUrl || `${this._baseUrl}asset-catalog.json`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load catalog: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this._catalog = this._parseCatalog(data);
|
||||
|
||||
console.log(`[RuntimeCatalog] Loaded catalog with ${Object.keys(this._catalog.assets).length} assets`);
|
||||
} catch (error) {
|
||||
console.error('[RuntimeCatalog] Failed to load catalog:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with pre-loaded catalog data
|
||||
* 使用预加载的目录数据初始化
|
||||
*/
|
||||
initWithData(catalogData: IRuntimeCatalog): void {
|
||||
this._catalog = catalogData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if catalog is loaded
|
||||
* 检查目录是否已加载
|
||||
*/
|
||||
isLoaded(): boolean {
|
||||
return this._catalog !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset location by GUID
|
||||
* 根据 GUID 获取资产位置
|
||||
*/
|
||||
getAssetLocation(guid: AssetGUID): IRuntimeAssetLocation | null {
|
||||
if (!this._catalog) {
|
||||
console.warn('[RuntimeCatalog] Catalog not loaded');
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._catalog.assets[guid] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset exists in catalog
|
||||
* 检查资产是否存在于目录中
|
||||
*/
|
||||
hasAsset(guid: AssetGUID): boolean {
|
||||
return this._catalog?.assets[guid] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assets of a specific type
|
||||
* 获取特定类型的所有资产
|
||||
*/
|
||||
getAssetsByType(type: AssetType): AssetGUID[] {
|
||||
if (!this._catalog) return [];
|
||||
|
||||
return Object.entries(this._catalog.assets)
|
||||
.filter(([_, loc]) => loc.type === type)
|
||||
.map(([guid]) => guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bundle info
|
||||
* 获取包信息
|
||||
*/
|
||||
getBundleInfo(bundleName: string): IRuntimeBundleInfo | null {
|
||||
return this._catalog?.bundles[bundleName] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a bundle
|
||||
* 加载包
|
||||
*/
|
||||
async loadBundle(bundleName: string): Promise<ArrayBuffer> {
|
||||
// Return cached bundle
|
||||
const cached = this._loadedBundles.get(bundleName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Return pending load
|
||||
const pending = this._loadingBundles.get(bundleName);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Start new load
|
||||
const bundleInfo = this.getBundleInfo(bundleName);
|
||||
if (!bundleInfo) {
|
||||
throw new Error(`Bundle not found in catalog: ${bundleName}`);
|
||||
}
|
||||
|
||||
const loadPromise = this._fetchBundle(bundleInfo);
|
||||
this._loadingBundles.set(bundleName, loadPromise);
|
||||
|
||||
try {
|
||||
const data = await loadPromise;
|
||||
this._loadedBundles.set(bundleName, data);
|
||||
return data;
|
||||
} finally {
|
||||
this._loadingBundles.delete(bundleName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset data by GUID
|
||||
* 根据 GUID 加载资产数据
|
||||
*/
|
||||
async loadAssetData(guid: AssetGUID): Promise<ArrayBuffer> {
|
||||
const location = this.getAssetLocation(guid);
|
||||
if (!location) {
|
||||
throw new Error(`Asset not found in catalog: ${guid}`);
|
||||
}
|
||||
|
||||
// Load the bundle containing this asset
|
||||
const bundleData = await this.loadBundle(location.bundle);
|
||||
|
||||
// Extract asset data from bundle
|
||||
return bundleData.slice(location.offset, location.offset + location.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload bundles marked for preloading
|
||||
* 预加载标记为预加载的包
|
||||
*/
|
||||
async preloadBundles(): Promise<void> {
|
||||
if (!this._catalog) return;
|
||||
|
||||
const preloadPromises: Promise<void>[] = [];
|
||||
|
||||
for (const [name, info] of Object.entries(this._catalog.bundles)) {
|
||||
if (info.preload) {
|
||||
preloadPromises.push(
|
||||
this.loadBundle(name).then(() => {
|
||||
console.log(`[RuntimeCatalog] Preloaded bundle: ${name}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(preloadPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a bundle from memory
|
||||
* 从内存卸载包
|
||||
*/
|
||||
unloadBundle(bundleName: string): void {
|
||||
this._loadedBundles.delete(bundleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all loaded bundles
|
||||
* 清除所有已加载的包
|
||||
*/
|
||||
clearBundles(): void {
|
||||
this._loadedBundles.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get catalog statistics
|
||||
* 获取目录统计信息
|
||||
*/
|
||||
getStatistics(): {
|
||||
totalAssets: number;
|
||||
totalBundles: number;
|
||||
loadedBundles: number;
|
||||
assetsByType: Record<string, number>;
|
||||
} {
|
||||
if (!this._catalog) {
|
||||
return {
|
||||
totalAssets: 0,
|
||||
totalBundles: 0,
|
||||
loadedBundles: 0,
|
||||
assetsByType: {}
|
||||
};
|
||||
}
|
||||
|
||||
const assetsByType: Record<string, number> = {};
|
||||
for (const loc of Object.values(this._catalog.assets)) {
|
||||
assetsByType[loc.type] = (assetsByType[loc.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalAssets: Object.keys(this._catalog.assets).length,
|
||||
totalBundles: Object.keys(this._catalog.bundles).length,
|
||||
loadedBundles: this._loadedBundles.size,
|
||||
assetsByType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse catalog JSON to typed structure
|
||||
* 将目录 JSON 解析为类型化结构
|
||||
*/
|
||||
private _parseCatalog(data: unknown): IRuntimeCatalog {
|
||||
const raw = data as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
version: (raw.version as string) || '1.0',
|
||||
createdAt: (raw.createdAt as number) || Date.now(),
|
||||
bundles: (raw.bundles as Record<string, IRuntimeBundleInfo>) || {},
|
||||
assets: (raw.assets as Record<AssetGUID, IRuntimeAssetLocation>) || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch bundle data
|
||||
* 获取包数据
|
||||
*/
|
||||
private async _fetchBundle(info: IRuntimeBundleInfo): Promise<ArrayBuffer> {
|
||||
const url = info.url.startsWith('http')
|
||||
? info.url
|
||||
: `${this._baseUrl}${info.url}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load bundle: ${url} (${response.status})`);
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global runtime catalog instance
|
||||
* 全局运行时目录实例
|
||||
*/
|
||||
export const runtimeCatalog = new RuntimeCatalog();
|
||||
@@ -6,29 +6,77 @@
|
||||
* 验证并清理资产路径以确保安全
|
||||
*/
|
||||
|
||||
/**
|
||||
* Path validation options.
|
||||
* 路径验证选项。
|
||||
*/
|
||||
export interface PathValidationOptions {
|
||||
/** Allow absolute paths (for editor environment). | 允许绝对路径(用于编辑器环境)。 */
|
||||
allowAbsolutePaths?: boolean;
|
||||
/** Allow URLs (http://, https://, asset://). | 允许 URL。 */
|
||||
allowUrls?: boolean;
|
||||
}
|
||||
|
||||
export class PathValidator {
|
||||
// Dangerous path patterns
|
||||
private static readonly DANGEROUS_PATTERNS = [
|
||||
// Dangerous path patterns (without absolute path checks)
|
||||
private static readonly DANGEROUS_PATTERNS_STRICT = [
|
||||
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||||
/^[/\\]/, // Absolute paths on Unix
|
||||
/^[a-zA-Z]:[/\\]/, // Absolute paths on Windows
|
||||
/[<>:"|?*]/, // Invalid characters for Windows paths
|
||||
/\0/, // Null bytes
|
||||
/%00/, // URL encoded null bytes
|
||||
/\.\.%2[fF]/ // URL encoded path traversal
|
||||
];
|
||||
|
||||
// Valid path characters (alphanumeric, dash, underscore, dot, slash)
|
||||
// Dangerous path patterns (allowing absolute paths)
|
||||
private static readonly DANGEROUS_PATTERNS_RELAXED = [
|
||||
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||||
/\0/, // Null bytes
|
||||
/%00/, // URL encoded null bytes
|
||||
/\.\.%2[fF]/ // URL encoded path traversal
|
||||
];
|
||||
|
||||
// Valid path characters for relative paths (alphanumeric, dash, underscore, dot, slash)
|
||||
private static readonly VALID_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@]+$/;
|
||||
|
||||
// Valid path characters for absolute paths (includes colon for Windows drives)
|
||||
private static readonly VALID_ABSOLUTE_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@:]+$/;
|
||||
|
||||
// URL pattern
|
||||
private static readonly URL_REGEX = /^(https?|asset|blob|data):\/\//;
|
||||
|
||||
// Maximum path length
|
||||
private static readonly MAX_PATH_LENGTH = 260;
|
||||
private static readonly MAX_PATH_LENGTH = 1024;
|
||||
|
||||
/** Global options for path validation. | 路径验证的全局选项。 */
|
||||
private static _globalOptions: PathValidationOptions = {
|
||||
allowAbsolutePaths: false,
|
||||
allowUrls: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Set global validation options.
|
||||
* 设置全局验证选项。
|
||||
*/
|
||||
static setGlobalOptions(options: PathValidationOptions): void {
|
||||
this._globalOptions = { ...this._globalOptions, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current global options.
|
||||
* 获取当前全局选项。
|
||||
*/
|
||||
static getGlobalOptions(): PathValidationOptions {
|
||||
return { ...this._globalOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a path is safe
|
||||
* 验证路径是否安全
|
||||
*/
|
||||
static validate(path: string): { valid: boolean; reason?: string } {
|
||||
static validate(path: string, options?: PathValidationOptions): { valid: boolean; reason?: string } {
|
||||
const opts = { ...this._globalOptions, ...options };
|
||||
|
||||
// Check for null/undefined/empty
|
||||
if (!path || typeof path !== 'string') {
|
||||
return { valid: false, reason: 'Path is empty or invalid' };
|
||||
@@ -39,15 +87,29 @@ export class PathValidator {
|
||||
return { valid: false, reason: `Path exceeds maximum length of ${this.MAX_PATH_LENGTH} characters` };
|
||||
}
|
||||
|
||||
// Allow URLs if enabled
|
||||
if (opts.allowUrls && this.URL_REGEX.test(path)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Choose patterns based on options
|
||||
const patterns = opts.allowAbsolutePaths
|
||||
? this.DANGEROUS_PATTERNS_RELAXED
|
||||
: this.DANGEROUS_PATTERNS_STRICT;
|
||||
|
||||
// Check for dangerous patterns
|
||||
for (const pattern of this.DANGEROUS_PATTERNS) {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(path)) {
|
||||
return { valid: false, reason: 'Path contains dangerous pattern' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for valid characters
|
||||
if (!this.VALID_PATH_REGEX.test(path)) {
|
||||
const validCharsRegex = opts.allowAbsolutePaths
|
||||
? this.VALID_ABSOLUTE_PATH_REGEX
|
||||
: this.VALID_PATH_REGEX;
|
||||
|
||||
if (!validCharsRegex.test(path)) {
|
||||
return { valid: false, reason: 'Path contains invalid characters' };
|
||||
}
|
||||
|
||||
|
||||
43
packages/audio/module.json
Normal file
43
packages/audio/module.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "audio",
|
||||
"name": "@esengine/audio",
|
||||
"displayName": "Audio",
|
||||
"description": "Audio playback and sound effects | 音频播放和音效",
|
||||
"version": "1.0.0",
|
||||
"category": "Audio",
|
||||
"icon": "Volume2",
|
||||
"tags": [
|
||||
"audio",
|
||||
"sound",
|
||||
"music"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core",
|
||||
"asset-system"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"AudioSourceComponent",
|
||||
"AudioListenerComponent"
|
||||
],
|
||||
"systems": [
|
||||
"AudioSystem"
|
||||
],
|
||||
"other": [
|
||||
"AudioClip",
|
||||
"AudioMixer"
|
||||
]
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "AudioPlugin"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import { AudioSourceComponent } from './AudioSourceComponent';
|
||||
|
||||
class AudioRuntimeModule implements IRuntimeModule {
|
||||
@@ -8,17 +8,21 @@ class AudioRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/audio',
|
||||
name: 'Audio',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'audio',
|
||||
name: '@esengine/audio',
|
||||
displayName: 'Audio',
|
||||
version: '1.0.0',
|
||||
description: '音频组件',
|
||||
category: 'audio',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: true
|
||||
category: 'Audio',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
dependencies: ['core', 'asset-system'],
|
||||
exports: { components: ['AudioSourceComponent'] }
|
||||
};
|
||||
|
||||
export const AudioPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new AudioRuntimeModule()
|
||||
};
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
/**
|
||||
* Behavior Tree Plugin Descriptor
|
||||
* 行为树插件描述符
|
||||
* Behavior Tree Plugin Manifest
|
||||
* 行为树插件清单
|
||||
*/
|
||||
|
||||
import type { PluginDescriptor } from '@esengine/editor-runtime';
|
||||
import type { ModuleManifest } from '@esengine/editor-runtime';
|
||||
|
||||
/**
|
||||
* 插件描述符
|
||||
* 插件清单
|
||||
*/
|
||||
export const descriptor: PluginDescriptor = {
|
||||
export const manifest: ModuleManifest = {
|
||||
id: '@esengine/behavior-tree',
|
||||
name: 'Behavior Tree System',
|
||||
name: '@esengine/behavior-tree',
|
||||
displayName: 'Behavior Tree System',
|
||||
version: '1.0.0',
|
||||
description: 'AI 行为树系统,支持可视化编辑和运行时执行',
|
||||
category: 'ai',
|
||||
enabledByDefault: true,
|
||||
category: 'AI',
|
||||
icon: 'GitBranch',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: false,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: false,
|
||||
modules: [
|
||||
{
|
||||
name: 'BehaviorTreeRuntime',
|
||||
type: 'runtime',
|
||||
loadingPhase: 'default'
|
||||
},
|
||||
{
|
||||
name: 'BehaviorTreeEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'default'
|
||||
}
|
||||
],
|
||||
dependencies: [
|
||||
{ id: '@esengine/engine-core', version: '>=1.0.0', optional: true }
|
||||
],
|
||||
icon: 'GitBranch'
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['BehaviorTreeRuntimeComponent'],
|
||||
systems: ['BehaviorTreeExecutionSystem'],
|
||||
loaders: ['BehaviorTreeLoader']
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,8 +42,8 @@ import { useBehaviorTreeDataStore } from './stores';
|
||||
import { createRootNode } from './domain/constants/RootNode';
|
||||
import { PluginContext } from './PluginContext';
|
||||
|
||||
// Import descriptor from local file
|
||||
import { descriptor } from './BehaviorTreePlugin';
|
||||
// Import manifest from local file
|
||||
import { manifest } from './BehaviorTreePlugin';
|
||||
|
||||
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM)
|
||||
// Import editor CSS styles (automatically handled and injected by vite)
|
||||
@@ -340,7 +340,7 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
|
||||
|
||||
// Create the complete plugin with editor module
|
||||
export const BehaviorTreePlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new BehaviorTreeRuntimeModule(),
|
||||
editorModule: new BehaviorTreeEditorModule(),
|
||||
};
|
||||
|
||||
45
packages/behavior-tree/module.json
Normal file
45
packages/behavior-tree/module.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": "behavior-tree",
|
||||
"name": "@esengine/behavior-tree",
|
||||
"displayName": "Behavior Tree",
|
||||
"description": "AI behavior tree system | AI 行为树系统",
|
||||
"version": "1.0.0",
|
||||
"category": "AI",
|
||||
"icon": "GitBranch",
|
||||
"tags": [
|
||||
"ai",
|
||||
"behavior",
|
||||
"tree"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop"
|
||||
],
|
||||
"dependencies": [
|
||||
"core"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"BehaviorTreeComponent"
|
||||
],
|
||||
"systems": [
|
||||
"BehaviorTreeSystem"
|
||||
],
|
||||
"other": [
|
||||
"BehaviorTree",
|
||||
"BTNode",
|
||||
"Selector",
|
||||
"Sequence",
|
||||
"Condition",
|
||||
"Action"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/behavior-tree-editor",
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "BehaviorTreePlugin"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry, Core } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor, SystemContext } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import type { AssetManager } from '@esengine/asset-system';
|
||||
|
||||
import { BehaviorTreeRuntimeComponent } from './execution/BehaviorTreeRuntimeComponent';
|
||||
@@ -39,7 +39,10 @@ class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
||||
this._loaderRegistered = true;
|
||||
}
|
||||
|
||||
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(Core);
|
||||
// 使用 context 中的 services,确保与调用方使用同一个 ServiceContainer 实例
|
||||
// Use services from context to ensure same ServiceContainer instance as caller
|
||||
const services = (btContext as any).services || Core.services;
|
||||
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(services);
|
||||
|
||||
if (btContext.assetManager) {
|
||||
behaviorTreeSystem.setAssetManager(btContext.assetManager);
|
||||
@@ -54,18 +57,25 @@ class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/behavior-tree',
|
||||
name: 'Behavior Tree',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'behavior-tree',
|
||||
name: '@esengine/behavior-tree',
|
||||
displayName: 'Behavior Tree',
|
||||
version: '1.0.0',
|
||||
description: 'AI behavior tree system',
|
||||
category: 'ai',
|
||||
enabledByDefault: false,
|
||||
isEnginePlugin: true
|
||||
category: 'AI',
|
||||
icon: 'GitBranch',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
dependencies: ['core'],
|
||||
exports: { components: ['BehaviorTreeComponent'] },
|
||||
editorPackage: '@esengine/behavior-tree-editor'
|
||||
};
|
||||
|
||||
export const BehaviorTreePlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new BehaviorTreeRuntimeModule()
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EntitySystem, Matcher, Entity, Time, Core, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { EntitySystem, Matcher, Entity, Time, Core, ECSSystem, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { AssetManager } from '@esengine/asset-system';
|
||||
import { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent';
|
||||
import { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager';
|
||||
@@ -6,6 +6,7 @@ import { NodeExecutorRegistry, NodeExecutionContext } from './NodeExecutor';
|
||||
import { BehaviorTreeData, BehaviorNodeData } from './BehaviorTreeData';
|
||||
import { TaskStatus } from '../Types/TaskStatus';
|
||||
import { NodeMetadataRegistry } from './NodeMetadata';
|
||||
import type { IBehaviorTreeAsset } from '../loaders/BehaviorTreeLoader';
|
||||
import './Executors';
|
||||
|
||||
/**
|
||||
@@ -17,14 +18,17 @@ import './Executors';
|
||||
export class BehaviorTreeExecutionSystem extends EntitySystem {
|
||||
private btAssetManager: BehaviorTreeAssetManager | null = null;
|
||||
private executorRegistry: NodeExecutorRegistry;
|
||||
private coreInstance: typeof Core | null = null;
|
||||
private _services: ServiceContainer | null = null;
|
||||
|
||||
/** 引用 asset-system 的 AssetManager(由 BehaviorTreeRuntimeModule 设置) */
|
||||
private _assetManager: AssetManager | null = null;
|
||||
|
||||
constructor(coreInstance?: typeof Core) {
|
||||
/** 已警告过的缺失资产,避免重复警告 */
|
||||
private _warnedMissingAssets: Set<string> = new Set();
|
||||
|
||||
constructor(services?: ServiceContainer) {
|
||||
super(Matcher.empty().all(BehaviorTreeRuntimeComponent));
|
||||
this.coreInstance = coreInstance || null;
|
||||
this._services = services || null;
|
||||
this.executorRegistry = new NodeExecutorRegistry();
|
||||
this.registerBuiltInExecutors();
|
||||
}
|
||||
@@ -121,12 +125,38 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
|
||||
|
||||
private getBTAssetManager(): BehaviorTreeAssetManager {
|
||||
if (!this.btAssetManager) {
|
||||
const core = this.coreInstance || Core;
|
||||
this.btAssetManager = core.services.resolve(BehaviorTreeAssetManager);
|
||||
// 优先使用传入的 services,否则回退到全局 Core.services
|
||||
// Prefer passed services, fallback to global Core.services
|
||||
const services = this._services || Core.services;
|
||||
if (!services) {
|
||||
throw new Error('ServiceContainer is not available. Ensure Core.create() was called.');
|
||||
}
|
||||
this.btAssetManager = services.resolve(BehaviorTreeAssetManager);
|
||||
}
|
||||
return this.btAssetManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取行为树数据
|
||||
* Get behavior tree data from AssetManager or BehaviorTreeAssetManager
|
||||
*
|
||||
* 优先从 AssetManager 获取(新方式),如果没有再从 BehaviorTreeAssetManager 获取(兼容旧方式)
|
||||
*/
|
||||
private getTreeData(assetIdOrPath: string): BehaviorTreeData | undefined {
|
||||
// 1. 优先从 AssetManager 获取(如果已加载)
|
||||
// First try AssetManager (preferred way)
|
||||
if (this._assetManager) {
|
||||
const cachedAsset = this._assetManager.getAssetByPath<IBehaviorTreeAsset>(assetIdOrPath);
|
||||
if (cachedAsset?.data) {
|
||||
return cachedAsset.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 回退到 BehaviorTreeAssetManager(兼容旧方式)
|
||||
// Fallback to BehaviorTreeAssetManager (legacy support)
|
||||
return this.getBTAssetManager().getAsset(assetIdOrPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册所有执行器(包括内置和插件提供的)
|
||||
*/
|
||||
@@ -158,9 +188,14 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
|
||||
continue;
|
||||
}
|
||||
|
||||
const treeData = this.getBTAssetManager().getAsset(runtime.treeAssetId);
|
||||
const treeData = this.getTreeData(runtime.treeAssetId);
|
||||
if (!treeData) {
|
||||
this.logger.warn(`未找到行为树资产: ${runtime.treeAssetId}`);
|
||||
// 只警告一次,避免每帧重复输出
|
||||
// Only warn once to avoid repeated output every frame
|
||||
if (!this._warnedMissingAssets.has(runtime.treeAssetId)) {
|
||||
this._warnedMissingAssets.add(runtime.treeAssetId);
|
||||
this.logger.warn(`未找到行为树资产: ${runtime.treeAssetId}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
import type {
|
||||
IAssetLoader,
|
||||
IAssetMetadata,
|
||||
IAssetLoadOptions,
|
||||
IAssetLoadResult
|
||||
IAssetParseContext,
|
||||
IAssetContent,
|
||||
AssetContentType
|
||||
} from '@esengine/asset-system';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { BehaviorTreeData } from '../execution/BehaviorTreeData';
|
||||
@@ -34,60 +34,38 @@ export interface IBehaviorTreeAsset {
|
||||
export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
|
||||
readonly supportedType = BehaviorTreeAssetType;
|
||||
readonly supportedExtensions = ['.btree'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* 加载行为树资产
|
||||
* Load behavior tree asset
|
||||
* 从内容解析行为树资产
|
||||
* Parse behavior tree asset from content
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
_options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<IBehaviorTreeAsset>> {
|
||||
// 获取文件系统服务
|
||||
const IFileSystemServiceKey = Symbol.for('IFileSystemService');
|
||||
const fileSystem = Core.services.tryResolve(IFileSystemServiceKey) as IFileSystem | null;
|
||||
|
||||
if (!fileSystem) {
|
||||
throw new Error('FileSystem service not available');
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IBehaviorTreeAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('Behavior tree content is empty');
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
const content = await fileSystem.readFile(path);
|
||||
|
||||
// 转换为运行时数据
|
||||
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content);
|
||||
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content.text);
|
||||
|
||||
// 使用文件路径作为 ID
|
||||
treeData.id = path;
|
||||
const assetPath = context.metadata.path;
|
||||
treeData.id = assetPath;
|
||||
|
||||
// 注册到 BehaviorTreeAssetManager(保持兼容性)
|
||||
// 同时注册到 BehaviorTreeAssetManager
|
||||
// Also register to BehaviorTreeAssetManager for legacy code that uses it directly
|
||||
// (e.g., loadFromEditorJSON, or code that doesn't use AssetManager)
|
||||
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
|
||||
if (btAssetManager) {
|
||||
btAssetManager.loadAsset(treeData);
|
||||
}
|
||||
|
||||
const asset: IBehaviorTreeAsset = {
|
||||
data: treeData,
|
||||
path
|
||||
};
|
||||
|
||||
return {
|
||||
asset,
|
||||
handle: 0, // 由 AssetManager 分配
|
||||
metadata,
|
||||
loadTime: 0
|
||||
data: treeData,
|
||||
path: assetPath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以加载
|
||||
* Check if can load this asset
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
return path.endsWith('.btree');
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资产
|
||||
* Dispose asset
|
||||
@@ -100,11 +78,3 @@ export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件系统接口(简化版,仅用于类型)
|
||||
*/
|
||||
interface IFileSystem {
|
||||
readFile(path: string): Promise<string>;
|
||||
exists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Core, type ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import type { IPlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import type { IEditorModuleLoader, PanelDescriptor, FileActionHandler, FileCreationTemplate } from '@esengine/editor-core';
|
||||
import { MessageHub, PanelPosition } from '@esengine/editor-core';
|
||||
|
||||
@@ -95,19 +95,23 @@ class BlueprintEditorModuleImpl implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/blueprint',
|
||||
name: 'Blueprint',
|
||||
name: '@esengine/blueprint',
|
||||
displayName: 'Blueprint',
|
||||
version: '1.0.0',
|
||||
description: 'Visual scripting system for ECS Framework',
|
||||
category: 'scripting',
|
||||
enabledByDefault: false,
|
||||
isEnginePlugin: true,
|
||||
category: 'Other',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
modules: [
|
||||
{ name: 'Runtime', type: 'runtime', loadingPhase: 'default' },
|
||||
{ name: 'Editor', type: 'editor', loadingPhase: 'postDefault' }
|
||||
]
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['BlueprintComponent'],
|
||||
systems: ['BlueprintSystem'],
|
||||
other: ['NodeRegistry', 'BlueprintVM']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -115,7 +119,7 @@ const descriptor: PluginDescriptor = {
|
||||
* 完整的蓝图插件,包含运行时和编辑器模块
|
||||
*/
|
||||
export const BlueprintPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new BlueprintEditorModuleImpl()
|
||||
};
|
||||
|
||||
|
||||
43
packages/blueprint/module.json
Normal file
43
packages/blueprint/module.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "blueprint",
|
||||
"name": "@esengine/blueprint",
|
||||
"displayName": "Blueprint",
|
||||
"description": "Visual scripting system | 可视化脚本系统",
|
||||
"version": "1.0.0",
|
||||
"category": "AI",
|
||||
"icon": "Workflow",
|
||||
"tags": [
|
||||
"visual",
|
||||
"scripting",
|
||||
"blueprint",
|
||||
"nodes"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop"
|
||||
],
|
||||
"dependencies": [
|
||||
"core"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"BlueprintComponent"
|
||||
],
|
||||
"systems": [
|
||||
"BlueprintSystem"
|
||||
],
|
||||
"other": [
|
||||
"Blueprint",
|
||||
"BlueprintNode",
|
||||
"BlueprintGraph"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/blueprint-editor",
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "BlueprintPlugin"
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"@types/node": "^20.19.17",
|
||||
"rimraf": "^5.0.0",
|
||||
|
||||
60
packages/blueprint/src/BlueprintPlugin.ts
Normal file
60
packages/blueprint/src/BlueprintPlugin.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Blueprint Plugin for ES Engine.
|
||||
* ES引擎的蓝图插件。
|
||||
*
|
||||
* Provides visual scripting runtime support.
|
||||
* 提供可视化脚本运行时支持。
|
||||
*/
|
||||
|
||||
import type { IPlugin, ModuleManifest, IRuntimeModule } from '@esengine/engine-core';
|
||||
|
||||
/**
|
||||
* Blueprint Runtime Module.
|
||||
* 蓝图运行时模块。
|
||||
*
|
||||
* Note: Blueprint uses a custom system (IBlueprintSystem) instead of EntitySystem,
|
||||
* so createSystems is not implemented here. Blueprint systems should be created
|
||||
* manually using createBlueprintSystem(scene).
|
||||
*/
|
||||
class BlueprintRuntimeModule implements IRuntimeModule {
|
||||
async onInitialize(): Promise<void> {
|
||||
// Blueprint system initialization
|
||||
// Blueprint uses IBlueprintSystem which is different from EntitySystem
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
// Cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin manifest for Blueprint.
|
||||
* 蓝图的插件清单。
|
||||
*/
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'blueprint',
|
||||
name: '@esengine/blueprint',
|
||||
displayName: 'Blueprint',
|
||||
version: '1.0.0',
|
||||
description: '可视化脚本系统',
|
||||
category: 'AI',
|
||||
icon: 'Workflow',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
dependencies: ['core'],
|
||||
exports: {
|
||||
components: ['BlueprintComponent'],
|
||||
systems: ['BlueprintSystem']
|
||||
},
|
||||
requiresWasm: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Blueprint Plugin.
|
||||
* 蓝图插件。
|
||||
*/
|
||||
export const BlueprintPlugin: IPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new BlueprintRuntimeModule()
|
||||
};
|
||||
@@ -29,3 +29,6 @@ export {
|
||||
triggerCustomBlueprintEvent
|
||||
} from './runtime/BlueprintSystem';
|
||||
export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint';
|
||||
|
||||
// Plugin
|
||||
export { BlueprintPlugin } from './BlueprintPlugin';
|
||||
|
||||
@@ -4,31 +4,33 @@
|
||||
* 插件定义 - 注册编辑器模块(Inspector、工具等)
|
||||
*/
|
||||
|
||||
import type { IPluginLoader, PluginDescriptor, IEditorModuleLoader } from '@esengine/ecs-components';
|
||||
import type { IPlugin, ModuleManifest, IEditorModuleLoader } from '@esengine/editor-core';
|
||||
import { {{name}}RuntimeModule } from '../{{name}}RuntimeModule';
|
||||
|
||||
class {{name}}EditorModule implements IEditorModuleLoader {
|
||||
registerInspectors(registry: any): void {
|
||||
async install(): Promise<void> {
|
||||
// 注册组件 Inspector
|
||||
// registry.register('MyComponent', MyComponentInspector);
|
||||
}
|
||||
async uninstall(): Promise<void> {}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/{{name}}',
|
||||
name: '{{displayName}}',
|
||||
name: '@esengine/{{name}}',
|
||||
displayName: '{{displayName}}',
|
||||
version: '1.0.0',
|
||||
description: '{{displayName}} plugin',
|
||||
category: '{{category}}',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: false,
|
||||
modules: [
|
||||
{ name: '{{name}}Runtime', type: 'runtime', entry: './src/runtime.ts' },
|
||||
{ name: '{{name}}Editor', type: 'editor', entry: './src/editor/index.ts' }
|
||||
]
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: false,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const {{name}}Plugin: IPluginLoader = {
|
||||
descriptor,
|
||||
export const {{name}}Plugin: IPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new {{name}}RuntimeModule(),
|
||||
editorModule: new {{name}}EditorModule()
|
||||
};
|
||||
|
||||
38
packages/camera/module.json
Normal file
38
packages/camera/module.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "camera",
|
||||
"name": "@esengine/camera",
|
||||
"displayName": "Camera",
|
||||
"description": "Camera and viewport management | 相机和视口管理",
|
||||
"version": "1.0.0",
|
||||
"category": "Rendering",
|
||||
"icon": "Video",
|
||||
"tags": [
|
||||
"camera",
|
||||
"viewport",
|
||||
"rendering"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core",
|
||||
"math"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"CameraComponent"
|
||||
],
|
||||
"systems": [
|
||||
"CameraSystem"
|
||||
]
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "CameraPlugin"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import { CameraComponent } from './CameraComponent';
|
||||
|
||||
class CameraRuntimeModule implements IRuntimeModule {
|
||||
@@ -8,17 +8,21 @@ class CameraRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/camera',
|
||||
name: 'Camera',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'camera',
|
||||
name: '@esengine/camera',
|
||||
displayName: 'Camera',
|
||||
version: '1.0.0',
|
||||
description: '2D/3D 相机组件',
|
||||
category: 'core',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: true
|
||||
category: 'Rendering',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
dependencies: ['core', 'math'],
|
||||
exports: { components: ['CameraComponent'] }
|
||||
};
|
||||
|
||||
export const CameraPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new CameraRuntimeModule()
|
||||
};
|
||||
|
||||
23
packages/core/module.json
Normal file
23
packages/core/module.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "core",
|
||||
"name": "@esengine/ecs-framework",
|
||||
"displayName": "Core ECS",
|
||||
"outputPath": "dist/index.mjs",
|
||||
"description": "Core Entity-Component-System framework | 核心 ECS 框架",
|
||||
"version": "1.0.0",
|
||||
"category": "Core",
|
||||
"icon": "Box",
|
||||
"tags": ["ecs", "entity", "component", "system"],
|
||||
"isCore": true,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": ["web", "desktop", "mobile"],
|
||||
"dependencies": [],
|
||||
"exports": {
|
||||
"components": ["Component", "Transform"],
|
||||
"systems": ["System"],
|
||||
"other": ["World", "Entity", "EntityManager", "SystemManager"]
|
||||
},
|
||||
"requiresWasm": false
|
||||
}
|
||||
@@ -30,10 +30,10 @@
|
||||
"clean": "rimraf bin dist tsconfig.tsbuildinfo",
|
||||
"build:ts": "tsc",
|
||||
"prebuild": "npm run clean",
|
||||
"build": "npm run build:ts",
|
||||
"build": "npm run build:ts && node build-rollup.cjs",
|
||||
"build:watch": "tsc --watch",
|
||||
"rebuild": "npm run clean && npm run build",
|
||||
"build:npm": "npm run build && node build-rollup.cjs",
|
||||
"build:npm": "npm run build",
|
||||
"test": "jest --config jest.config.cjs",
|
||||
"test:watch": "jest --watch --config jest.config.cjs",
|
||||
"test:performance": "jest --config jest.performance.config.cjs",
|
||||
|
||||
@@ -70,7 +70,7 @@ module.exports = [
|
||||
warn(warning);
|
||||
},
|
||||
treeshake: {
|
||||
moduleSideEffects: false,
|
||||
moduleSideEffects: (id) => id.includes('reflect-metadata'),
|
||||
propertyReadSideEffects: false,
|
||||
unknownGlobalSideEffects: false
|
||||
}
|
||||
@@ -102,7 +102,7 @@ module.exports = [
|
||||
warn(warning);
|
||||
},
|
||||
treeshake: {
|
||||
moduleSideEffects: false
|
||||
moduleSideEffects: (id) => id.includes('reflect-metadata')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -133,7 +133,7 @@ module.exports = [
|
||||
warn(warning);
|
||||
},
|
||||
treeshake: {
|
||||
moduleSideEffects: false
|
||||
moduleSideEffects: (id) => id.includes('reflect-metadata')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -193,10 +193,10 @@ module.exports = [
|
||||
warn(warning);
|
||||
},
|
||||
treeshake: {
|
||||
moduleSideEffects: false
|
||||
moduleSideEffects: (id) => id.includes('reflect-metadata')
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 类型定义构建
|
||||
{
|
||||
input: 'bin/index.d.ts',
|
||||
|
||||
21
packages/ecs-engine-bindgen/module.json
Normal file
21
packages/ecs-engine-bindgen/module.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "ecs-engine-bindgen",
|
||||
"name": "@esengine/ecs-engine-bindgen",
|
||||
"displayName": "Engine Bindgen",
|
||||
"description": "Bridge between ECS and Rust Engine | ECS 与 Rust 引擎之间的桥接层",
|
||||
"version": "0.1.0",
|
||||
"category": "Core",
|
||||
"icon": "Link",
|
||||
"tags": ["engine", "bindgen", "wasm", "rendering"],
|
||||
"isCore": true,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": ["web", "desktop"],
|
||||
"dependencies": ["core", "math"],
|
||||
"exports": {
|
||||
"other": ["EngineBridge", "EngineRenderSystem", "CameraSystem"]
|
||||
},
|
||||
"requiresWasm": true,
|
||||
"outputPath": "dist/index.js"
|
||||
}
|
||||
@@ -39,10 +39,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/ecs-framework-math": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/camera": "workspace:*",
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.8.0",
|
||||
"rimraf": "^5.0.0"
|
||||
|
||||
@@ -58,6 +58,7 @@ export class EngineBridge implements IEngineBridge {
|
||||
private textureIdBuffer: Uint32Array;
|
||||
private uvBuffer: Float32Array;
|
||||
private colorBuffer: Uint32Array;
|
||||
private materialIdBuffer: Uint32Array;
|
||||
|
||||
// Statistics | 统计信息
|
||||
private stats: EngineStats = {
|
||||
@@ -92,6 +93,7 @@ export class EngineBridge implements IEngineBridge {
|
||||
this.textureIdBuffer = new Uint32Array(maxSprites);
|
||||
this.uvBuffer = new Float32Array(maxSprites * 4); // u0, v0, u1, v1
|
||||
this.colorBuffer = new Uint32Array(maxSprites);
|
||||
this.materialIdBuffer = new Uint32Array(maxSprites);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,6 +237,9 @@ export class EngineBridge implements IEngineBridge {
|
||||
|
||||
// Color | 颜色
|
||||
this.colorBuffer[i] = sprite.color;
|
||||
|
||||
// Material ID (0 = default) | 材质ID(0 = 默认)
|
||||
this.materialIdBuffer[i] = sprite.materialId ?? 0;
|
||||
}
|
||||
|
||||
// Submit to engine (single WASM call) | 提交到引擎(单次WASM调用)
|
||||
@@ -242,7 +247,8 @@ export class EngineBridge implements IEngineBridge {
|
||||
this.transformBuffer.subarray(0, count * 7),
|
||||
this.textureIdBuffer.subarray(0, count),
|
||||
this.uvBuffer.subarray(0, count * 4),
|
||||
this.colorBuffer.subarray(0, count)
|
||||
this.colorBuffer.subarray(0, count),
|
||||
this.materialIdBuffer.subarray(0, count)
|
||||
);
|
||||
|
||||
this.stats.spriteCount = count;
|
||||
|
||||
@@ -74,10 +74,14 @@ export class RenderBatcher {
|
||||
* @returns Sorted array of sprites | 排序后的精灵数组
|
||||
*/
|
||||
getSprites(): SpriteRenderData[] {
|
||||
// Sort by texture ID for better batching (fewer texture switches)
|
||||
// 按纹理ID排序以获得更好的批处理效果(减少纹理切换)
|
||||
// Sort by material ID first, then texture ID for better batching
|
||||
// 先按材质ID排序,再按纹理ID排序以获得更好的批处理效果
|
||||
if (!this.sortByZ) {
|
||||
this.sprites.sort((a, b) => a.textureId - b.textureId);
|
||||
this.sprites.sort((a, b) => {
|
||||
const materialDiff = (a.materialId || 0) - (b.materialId || 0);
|
||||
if (materialDiff !== 0) return materialDiff;
|
||||
return a.textureId - b.textureId;
|
||||
});
|
||||
}
|
||||
return this.sprites;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import type { EngineBridge } from './EngineBridge';
|
||||
import { RenderBatcher } from './RenderBatcher';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { getMaterialManager } from '@esengine/material-system';
|
||||
import type { SpriteRenderData } from '../types';
|
||||
|
||||
/**
|
||||
@@ -108,6 +109,14 @@ export class SpriteRenderHelper {
|
||||
// Convert hex color string to packed RGBA
|
||||
const color = this.hexToPackedColor(sprite.color, sprite.alpha);
|
||||
|
||||
// Get material ID from path (0 = default if not found or no path specified)
|
||||
const materialId = sprite.material
|
||||
? getMaterialManager().getMaterialIdByPath(sprite.material)
|
||||
: 0;
|
||||
|
||||
// Collect material overrides if any
|
||||
const hasOverrides = sprite.hasOverrides();
|
||||
|
||||
const renderData: SpriteRenderData = {
|
||||
x: transform.position.x,
|
||||
y: transform.position.y,
|
||||
@@ -118,7 +127,10 @@ export class SpriteRenderHelper {
|
||||
originY: sprite.originY,
|
||||
textureId: sprite.textureId,
|
||||
uv,
|
||||
color
|
||||
color,
|
||||
materialId,
|
||||
// Only include overrides if there are any
|
||||
...(hasOverrides ? { materialOverrides: sprite.materialOverrides } : {})
|
||||
};
|
||||
|
||||
this.batcher.addSprite(renderData);
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ComponentType, ECSSystem, Component, Core } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { Color } from '@esengine/ecs-framework-math';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { getMaterialManager } from '@esengine/material-system';
|
||||
import type { EngineBridge } from '../core/EngineBridge';
|
||||
import { RenderBatcher } from '../core/RenderBatcher';
|
||||
import type { SpriteRenderData } from '../types';
|
||||
@@ -279,7 +281,7 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
: (typeof transform.rotation === 'number' ? transform.rotation : transform.rotation.z);
|
||||
|
||||
// Convert hex color string to packed RGBA | 将十六进制颜色字符串转换为打包的RGBA
|
||||
const color = this.hexToPackedColor(sprite.color, sprite.alpha);
|
||||
const color = Color.packHexAlpha(sprite.color, sprite.alpha);
|
||||
|
||||
// Get texture ID from sprite component
|
||||
// 从精灵组件获取纹理ID
|
||||
@@ -290,6 +292,16 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
textureId = this.bridge.getOrLoadTextureByPath(sprite.texture);
|
||||
}
|
||||
|
||||
// Get material ID from path (0 = default if not found or no path specified)
|
||||
// 从路径获取材质 ID(0 = 默认,如果未找到或未指定路径)
|
||||
const materialId = sprite.material
|
||||
? getMaterialManager().getMaterialIdByPath(sprite.material)
|
||||
: 0;
|
||||
|
||||
// Collect material overrides if any
|
||||
// 收集材质覆盖(如果有)
|
||||
const hasOverrides = sprite.hasOverrides();
|
||||
|
||||
// Pass actual display dimensions (sprite size * world transform scale)
|
||||
// 传递实际显示尺寸(sprite尺寸 * 世界变换缩放)
|
||||
const renderData: SpriteRenderData = {
|
||||
@@ -302,7 +314,11 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
originY: sprite.anchorY,
|
||||
textureId,
|
||||
uv,
|
||||
color
|
||||
color,
|
||||
materialId,
|
||||
// Only include overrides if there are any
|
||||
// 仅在有覆盖时包含
|
||||
...(hasOverrides ? { materialOverrides: sprite.materialOverrides } : {})
|
||||
};
|
||||
|
||||
renderItems.push({ sortingOrder: sprite.sortingOrder, sprites: [renderData] });
|
||||
@@ -1054,32 +1070,6 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
return this.transformMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color string to packed RGBA.
|
||||
* 将十六进制颜色字符串转换为打包的RGBA。
|
||||
*/
|
||||
private hexToPackedColor(hex: string, alpha: number): number {
|
||||
let r = 255, g = 255, b = 255;
|
||||
|
||||
if (typeof hex === 'string' && hex.startsWith('#')) {
|
||||
const hexValue = hex.slice(1);
|
||||
if (hexValue.length === 3) {
|
||||
r = parseInt(hexValue[0] + hexValue[0], 16);
|
||||
g = parseInt(hexValue[1] + hexValue[1], 16);
|
||||
b = parseInt(hexValue[2] + hexValue[2], 16);
|
||||
} else if (hexValue.length === 6) {
|
||||
r = parseInt(hexValue.slice(0, 2), 16);
|
||||
g = parseInt(hexValue.slice(2, 4), 16);
|
||||
b = parseInt(hexValue.slice(4, 6), 16);
|
||||
}
|
||||
}
|
||||
|
||||
const a = Math.round(alpha * 255);
|
||||
// Pack as 0xAABBGGRR for WebGL
|
||||
return ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register a render data provider.
|
||||
* 注册渲染数据提供者。
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
* 引擎桥接层的类型定义。
|
||||
*/
|
||||
|
||||
/**
|
||||
* Material property override for rendering.
|
||||
* 用于渲染的材质属性覆盖。
|
||||
*/
|
||||
export interface MaterialPropertyOverride {
|
||||
/** Uniform type. | Uniform 类型。 */
|
||||
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
|
||||
/** Uniform value. | Uniform 值。 */
|
||||
value: number | number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Material overrides map.
|
||||
* 材质覆盖映射。
|
||||
*/
|
||||
export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
|
||||
|
||||
/**
|
||||
* Sprite render data for batch submission.
|
||||
* 用于批量提交的精灵渲染数据。
|
||||
@@ -28,6 +45,13 @@ export interface SpriteRenderData {
|
||||
uv: [number, number, number, number];
|
||||
/** Packed RGBA color. | 打包的RGBA颜色。 */
|
||||
color: number;
|
||||
/** Material ID (0 = default material). | 材质ID(0 = 默认材质)。 */
|
||||
materialId?: number;
|
||||
/**
|
||||
* Material property overrides (instance level).
|
||||
* 材质属性覆盖(实例级别)。
|
||||
*/
|
||||
materialOverrides?: MaterialOverrides;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
107
packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts
vendored
107
packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts
vendored
@@ -23,6 +23,11 @@ export class GameEngine {
|
||||
* Array of [x, y, zoom, rotation] | 数组 [x, y, zoom, rotation]
|
||||
*/
|
||||
getCamera(): Float32Array;
|
||||
/**
|
||||
* Check if a shader exists.
|
||||
* 检查着色器是否存在。
|
||||
*/
|
||||
hasShader(shader_id: number): boolean;
|
||||
/**
|
||||
* Set camera position, zoom, and rotation.
|
||||
* 设置相机位置、缩放和旋转。
|
||||
@@ -42,6 +47,11 @@ export class GameEngine {
|
||||
* * `key_code` - The key code to check | 要检查的键码
|
||||
*/
|
||||
isKeyDown(key_code: string): boolean;
|
||||
/**
|
||||
* Check if a material exists.
|
||||
* 检查材质是否存在。
|
||||
*/
|
||||
hasMaterial(material_id: number): boolean;
|
||||
/**
|
||||
* Load a texture from URL.
|
||||
* 从URL加载纹理。
|
||||
@@ -64,6 +74,11 @@ export class GameEngine {
|
||||
* 适用于微信小游戏等环境。
|
||||
*/
|
||||
static fromExternal(gl_context: any, width: number, height: number): GameEngine;
|
||||
/**
|
||||
* Remove a shader.
|
||||
* 移除着色器。
|
||||
*/
|
||||
removeShader(shader_id: number): boolean;
|
||||
/**
|
||||
* Set grid visibility.
|
||||
* 设置网格可见性。
|
||||
@@ -90,6 +105,18 @@ export class GameEngine {
|
||||
* * `show_handles` - Whether to show transform handles | 是否显示变换手柄
|
||||
*/
|
||||
addGizmoRect(x: number, y: number, width: number, height: number, rotation: number, origin_x: number, origin_y: number, r: number, g: number, b: number, a: number, show_handles: boolean): void;
|
||||
/**
|
||||
* Compile and register a custom shader.
|
||||
* 编译并注册自定义着色器。
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `vertex_source` - Vertex shader GLSL source | 顶点着色器GLSL源代码
|
||||
* * `fragment_source` - Fragment shader GLSL source | 片段着色器GLSL源代码
|
||||
*
|
||||
* # Returns | 返回
|
||||
* The shader ID for referencing this shader | 用于引用此着色器的ID
|
||||
*/
|
||||
compileShader(vertex_source: string, fragment_source: string): number;
|
||||
/**
|
||||
* Render sprites as overlay (without clearing screen).
|
||||
* 渲染精灵作为叠加层(不清除屏幕)。
|
||||
@@ -98,6 +125,24 @@ export class GameEngine {
|
||||
* 用于在世界内容上渲染 UI。
|
||||
*/
|
||||
renderOverlay(): void;
|
||||
/**
|
||||
* Create and register a new material.
|
||||
* 创建并注册新材质。
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Material name for debugging | 材质名称(用于调试)
|
||||
* * `shader_id` - Shader ID to use | 使用的着色器ID
|
||||
* * `blend_mode` - Blend mode: 0=None, 1=Alpha, 2=Additive, 3=Multiply, 4=Screen, 5=PremultipliedAlpha
|
||||
*
|
||||
* # Returns | 返回
|
||||
* The material ID for referencing this material | 用于引用此材质的ID
|
||||
*/
|
||||
createMaterial(name: string, shader_id: number, blend_mode: number): number;
|
||||
/**
|
||||
* Remove a material.
|
||||
* 移除材质。
|
||||
*/
|
||||
removeMaterial(material_id: number): boolean;
|
||||
/**
|
||||
* Resize a specific viewport.
|
||||
* 调整特定视口大小。
|
||||
@@ -140,11 +185,36 @@ export class GameEngine {
|
||||
* * `canvas_id` - HTML canvas element ID | HTML canvas元素ID
|
||||
*/
|
||||
registerViewport(id: string, canvas_id: string): void;
|
||||
/**
|
||||
* Set a material's vec2 uniform.
|
||||
* 设置材质的vec2 uniform。
|
||||
*/
|
||||
setMaterialVec2(material_id: number, name: string, x: number, y: number): boolean;
|
||||
/**
|
||||
* Set a material's vec3 uniform.
|
||||
* 设置材质的vec3 uniform。
|
||||
*/
|
||||
setMaterialVec3(material_id: number, name: string, x: number, y: number, z: number): boolean;
|
||||
/**
|
||||
* Set a material's vec4 uniform (also used for colors).
|
||||
* 设置材质的vec4 uniform(也用于颜色)。
|
||||
*/
|
||||
setMaterialVec4(material_id: number, name: string, x: number, y: number, z: number, w: number): boolean;
|
||||
/**
|
||||
* Render to a specific viewport.
|
||||
* 渲染到特定视口。
|
||||
*/
|
||||
renderToViewport(viewport_id: string): void;
|
||||
/**
|
||||
* Set a material's color uniform (RGBA, 0.0-1.0).
|
||||
* 设置材质的颜色uniform(RGBA,0.0-1.0)。
|
||||
*/
|
||||
setMaterialColor(material_id: number, name: string, r: number, g: number, b: number, a: number): boolean;
|
||||
/**
|
||||
* Set a material's float uniform.
|
||||
* 设置材质的浮点uniform。
|
||||
*/
|
||||
setMaterialFloat(material_id: number, name: string, value: number): boolean;
|
||||
/**
|
||||
* Set transform tool mode.
|
||||
* 设置变换工具模式。
|
||||
@@ -191,8 +261,9 @@ export class GameEngine {
|
||||
* * `texture_ids` - Uint32Array of texture IDs | 纹理ID数组
|
||||
* * `uvs` - Float32Array [u0, v0, u1, v1] per sprite | 每个精灵的UV坐标
|
||||
* * `colors` - Uint32Array of packed RGBA colors | 打包的RGBA颜色数组
|
||||
* * `material_ids` - Uint32Array of material IDs (0 = default) | 材质ID数组(0 = 默认)
|
||||
*/
|
||||
submitSpriteBatch(transforms: Float32Array, texture_ids: Uint32Array, uvs: Float32Array, colors: Uint32Array): void;
|
||||
submitSpriteBatch(transforms: Float32Array, texture_ids: Uint32Array, uvs: Float32Array, colors: Uint32Array, material_ids: Uint32Array): void;
|
||||
/**
|
||||
* Unregister a viewport.
|
||||
* 注销视口。
|
||||
@@ -206,6 +277,11 @@ export class GameEngine {
|
||||
* * `path` - Image path/URL to load | 要加载的图片路径/URL
|
||||
*/
|
||||
loadTextureByPath(path: string): number;
|
||||
/**
|
||||
* Compile a shader with a specific ID.
|
||||
* 使用特定ID编译着色器。
|
||||
*/
|
||||
compileShaderWithId(shader_id: number, vertex_source: string, fragment_source: string): void;
|
||||
/**
|
||||
* Get texture ID by path.
|
||||
* 按路径获取纹理ID。
|
||||
@@ -214,6 +290,19 @@ export class GameEngine {
|
||||
* * `path` - Image path to lookup | 要查找的图片路径
|
||||
*/
|
||||
getTextureIdByPath(path: string): number | undefined;
|
||||
/**
|
||||
* Create a material with a specific ID.
|
||||
* 使用特定ID创建材质。
|
||||
*/
|
||||
createMaterialWithId(material_id: number, name: string, shader_id: number, blend_mode: number): void;
|
||||
/**
|
||||
* Set a material's blend mode.
|
||||
* 设置材质的混合模式。
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `blend_mode` - 0=None, 1=Alpha, 2=Additive, 3=Multiply, 4=Screen, 5=PremultipliedAlpha
|
||||
*/
|
||||
setMaterialBlendMode(material_id: number, blend_mode: number): boolean;
|
||||
/**
|
||||
* Create a new game engine instance.
|
||||
* 创建新的游戏引擎实例。
|
||||
@@ -272,18 +361,26 @@ export interface InitOutput {
|
||||
readonly gameengine_addGizmoLine: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void;
|
||||
readonly gameengine_addGizmoRect: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number) => void;
|
||||
readonly gameengine_clear: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly gameengine_compileShader: (a: number, b: number, c: number, d: number, e: number) => [number, number, number];
|
||||
readonly gameengine_compileShaderWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
|
||||
readonly gameengine_createMaterial: (a: number, b: number, c: number, d: number, e: number) => number;
|
||||
readonly gameengine_createMaterialWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number];
|
||||
readonly gameengine_getCamera: (a: number) => [number, number];
|
||||
readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
|
||||
readonly gameengine_getTextureIdByPath: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_getViewportCamera: (a: number, b: number, c: number) => [number, number];
|
||||
readonly gameengine_getViewportIds: (a: number) => [number, number];
|
||||
readonly gameengine_hasMaterial: (a: number, b: number) => number;
|
||||
readonly gameengine_hasShader: (a: number, b: number) => number;
|
||||
readonly gameengine_height: (a: number) => number;
|
||||
readonly gameengine_isKeyDown: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_loadTexture: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
readonly gameengine_loadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
|
||||
readonly gameengine_new: (a: number, b: number) => [number, number, number];
|
||||
readonly gameengine_registerViewport: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
||||
readonly gameengine_removeMaterial: (a: number, b: number) => number;
|
||||
readonly gameengine_removeShader: (a: number, b: number) => number;
|
||||
readonly gameengine_render: (a: number) => [number, number];
|
||||
readonly gameengine_renderOverlay: (a: number) => [number, number];
|
||||
readonly gameengine_renderToViewport: (a: number, b: number, c: number) => [number, number];
|
||||
@@ -292,12 +389,18 @@ export interface InitOutput {
|
||||
readonly gameengine_setActiveViewport: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_setCamera: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly gameengine_setClearColor: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly gameengine_setMaterialBlendMode: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_setMaterialColor: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number;
|
||||
readonly gameengine_setMaterialFloat: (a: number, b: number, c: number, d: number, e: number) => number;
|
||||
readonly gameengine_setMaterialVec2: (a: number, b: number, c: number, d: number, e: number, f: number) => number;
|
||||
readonly gameengine_setMaterialVec3: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number;
|
||||
readonly gameengine_setMaterialVec4: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number;
|
||||
readonly gameengine_setShowGizmos: (a: number, b: number) => void;
|
||||
readonly gameengine_setShowGrid: (a: number, b: number) => void;
|
||||
readonly gameengine_setTransformMode: (a: number, b: number) => void;
|
||||
readonly gameengine_setViewportCamera: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
|
||||
readonly gameengine_setViewportConfig: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number];
|
||||
readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => [number, number];
|
||||
readonly gameengine_unregisterViewport: (a: number, b: number, c: number) => void;
|
||||
readonly gameengine_updateInput: (a: number) => void;
|
||||
readonly gameengine_width: (a: number) => number;
|
||||
|
||||
@@ -9,20 +9,26 @@
|
||||
"build": "npm run build:sdk && tsc && vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "npm run build:sdk && tauri dev",
|
||||
"copy-modules": "node ../../scripts/copy-engine-modules.mjs",
|
||||
"tauri:dev": "npm run build:sdk && npm run copy-modules && tauri dev",
|
||||
"bundle:runtime": "node scripts/bundle-runtime.mjs",
|
||||
"tauri:build": "npm run build:sdk && npm run bundle:runtime && tauri build",
|
||||
"tauri:build": "npm run build:sdk && npm run copy-modules && npm run bundle:runtime && tauri build",
|
||||
"version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/asset-system-editor": "workspace:*",
|
||||
"@esengine/behavior-tree": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"@esengine/material-editor": "workspace:*",
|
||||
"@esengine/behavior-tree-editor": "workspace:*",
|
||||
"@esengine/blueprint": "workspace:*",
|
||||
"@esengine/blueprint-editor": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/sprite-editor": "workspace:*",
|
||||
"@esengine/shader-editor": "workspace:*",
|
||||
"@esengine/camera": "workspace:*",
|
||||
"@esengine/audio": "workspace:*",
|
||||
"@esengine/physics-rapier2d": "workspace:*",
|
||||
|
||||
101
packages/editor-app/src-tauri/Cargo.lock
generated
101
packages/editor-app/src-tauri/Cargo.lock
generated
@@ -1100,6 +1100,8 @@ dependencies = [
|
||||
"futures-util",
|
||||
"glob",
|
||||
"image",
|
||||
"notify",
|
||||
"notify-debouncer-mini",
|
||||
"once_cell",
|
||||
"qrcode",
|
||||
"serde",
|
||||
@@ -1379,6 +1381,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -2205,6 +2216,26 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -2214,6 +2245,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
@@ -2379,6 +2419,26 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -2603,6 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -2708,6 +2769,46 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-mini"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
|
||||
dependencies = [
|
||||
"instant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
|
||||
@@ -34,6 +34,8 @@ once_cell = "1.19"
|
||||
urlencoding = "2.1"
|
||||
qrcode = "0.14"
|
||||
image = "0.25"
|
||||
notify = "7.0"
|
||||
notify-debouncer-mini = "0.5"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
|
||||
455
packages/editor-app/src-tauri/src/commands/build.rs
Normal file
455
packages/editor-app/src-tauri/src/commands/build.rs
Normal file
@@ -0,0 +1,455 @@
|
||||
//! Build related commands.
|
||||
//! 构建相关命令。
|
||||
//!
|
||||
//! Provides file operations and compilation for build pipelines.
|
||||
//! 为构建管线提供文件操作和编译功能。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Build progress event.
|
||||
/// 构建进度事件。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildProgressEvent {
|
||||
/// Progress percentage (0-100) | 进度百分比
|
||||
pub progress: u32,
|
||||
/// Current step message | 当前步骤消息
|
||||
pub message: String,
|
||||
/// Current step index | 当前步骤索引
|
||||
pub current_step: u32,
|
||||
/// Total steps | 总步骤数
|
||||
pub total_steps: u32,
|
||||
}
|
||||
|
||||
/// Clean and recreate output directory.
|
||||
/// 清理并重建输出目录。
|
||||
#[tauri::command]
|
||||
pub async fn prepare_build_directory(output_path: String) -> Result<(), String> {
|
||||
let path = Path::new(&output_path);
|
||||
|
||||
// Remove existing directory if exists | 如果存在则删除现有目录
|
||||
if path.exists() {
|
||||
fs::remove_dir_all(path)
|
||||
.map_err(|e| format!("Failed to clean output directory | 清理输出目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// Create fresh directory | 创建新目录
|
||||
fs::create_dir_all(path)
|
||||
.map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy directory recursively.
|
||||
/// 递归复制目录。
|
||||
#[tauri::command]
|
||||
pub async fn copy_directory(
|
||||
src: String,
|
||||
dst: String,
|
||||
patterns: Option<Vec<String>>,
|
||||
) -> Result<u32, String> {
|
||||
let src_path = Path::new(&src);
|
||||
let dst_path = Path::new(&dst);
|
||||
|
||||
if !src_path.exists() {
|
||||
return Err(format!("Source directory does not exist | 源目录不存在: {}", src));
|
||||
}
|
||||
|
||||
// Create destination directory | 创建目标目录
|
||||
fs::create_dir_all(dst_path)
|
||||
.map_err(|e| format!("Failed to create destination directory | 创建目标目录失败: {}", e))?;
|
||||
|
||||
let mut copied_count = 0u32;
|
||||
|
||||
// Recursively copy | 递归复制
|
||||
copy_dir_recursive(src_path, dst_path, &patterns, &mut copied_count)?;
|
||||
|
||||
Ok(copied_count)
|
||||
}
|
||||
|
||||
/// Helper function to copy directory recursively.
|
||||
/// 递归复制目录的辅助函数。
|
||||
fn copy_dir_recursive(
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
patterns: &Option<Vec<String>>,
|
||||
count: &mut u32,
|
||||
) -> Result<(), String> {
|
||||
for entry in fs::read_dir(src)
|
||||
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
|
||||
let src_path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let dst_path = dst.join(&file_name);
|
||||
|
||||
if src_path.is_dir() {
|
||||
// Skip hidden directories | 跳过隐藏目录
|
||||
if file_name.to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dst_path)
|
||||
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
|
||||
copy_dir_recursive(&src_path, &dst_path, patterns, count)?;
|
||||
} else {
|
||||
// Check if file matches patterns | 检查文件是否匹配模式
|
||||
if let Some(ref pats) = patterns {
|
||||
let file_name_str = file_name.to_string_lossy();
|
||||
let matches = pats.iter().any(|p| {
|
||||
if p.starts_with("*.") {
|
||||
let ext = &p[2..];
|
||||
file_name_str.ends_with(&format!(".{}", ext))
|
||||
} else {
|
||||
file_name_str.contains(p)
|
||||
}
|
||||
});
|
||||
|
||||
if !matches {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
fs::copy(&src_path, &dst_path)
|
||||
.map_err(|e| format!("Failed to copy file | 复制文件失败: {} -> {}: {}",
|
||||
src_path.display(), dst_path.display(), e))?;
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bundle options for esbuild.
|
||||
/// esbuild 打包选项。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BundleOptions {
|
||||
/// Entry files | 入口文件
|
||||
pub entry_points: Vec<String>,
|
||||
/// Output directory | 输出目录
|
||||
pub output_dir: String,
|
||||
/// Output format (esm or iife) | 输出格式
|
||||
pub format: String,
|
||||
/// Bundle name | 打包名称
|
||||
pub bundle_name: String,
|
||||
/// Whether to minify | 是否压缩
|
||||
pub minify: bool,
|
||||
/// Whether to generate source map | 是否生成 source map
|
||||
pub source_map: bool,
|
||||
/// External dependencies | 外部依赖
|
||||
pub external: Vec<String>,
|
||||
/// Project root for resolving imports | 项目根目录
|
||||
pub project_root: String,
|
||||
/// Define replacements | 宏定义替换
|
||||
pub define: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Bundle result.
|
||||
/// 打包结果。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BundleResult {
|
||||
/// Whether bundling succeeded | 是否打包成功
|
||||
pub success: bool,
|
||||
/// Output file path | 输出文件路径
|
||||
pub output_file: Option<String>,
|
||||
/// Output file size in bytes | 输出文件大小(字节)
|
||||
pub output_size: Option<u64>,
|
||||
/// Error message if failed | 失败时的错误信息
|
||||
pub error: Option<String>,
|
||||
/// Warnings | 警告
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Bundle JavaScript/TypeScript files using esbuild.
|
||||
/// 使用 esbuild 打包 JavaScript/TypeScript 文件。
|
||||
#[tauri::command]
|
||||
pub async fn bundle_scripts(options: BundleOptions) -> Result<BundleResult, String> {
|
||||
let esbuild_path = find_esbuild(&options.project_root)?;
|
||||
|
||||
// Build output file path | 构建输出文件路径
|
||||
let output_file = Path::new(&options.output_dir)
|
||||
.join(&options.bundle_name)
|
||||
.with_extension("js");
|
||||
|
||||
// Ensure output directory exists | 确保输出目录存在
|
||||
if let Some(parent) = output_file.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// Build esbuild arguments | 构建 esbuild 参数
|
||||
let mut args: Vec<String> = options.entry_points.clone();
|
||||
|
||||
args.push("--bundle".to_string());
|
||||
args.push(format!("--outfile={}", output_file.display()));
|
||||
args.push(format!("--format={}", options.format));
|
||||
args.push("--platform=browser".to_string());
|
||||
args.push("--target=es2020".to_string());
|
||||
|
||||
if options.source_map {
|
||||
args.push("--sourcemap".to_string());
|
||||
}
|
||||
|
||||
if options.minify {
|
||||
args.push("--minify".to_string());
|
||||
}
|
||||
|
||||
for external in &options.external {
|
||||
args.push(format!("--external:{}", external));
|
||||
}
|
||||
|
||||
// Add define replacements | 添加宏定义替换
|
||||
if let Some(ref defines) = options.define {
|
||||
for (key, value) in defines {
|
||||
args.push(format!("--define:{}={}", key, value));
|
||||
}
|
||||
}
|
||||
|
||||
// Run esbuild | 运行 esbuild
|
||||
let output = Command::new(&esbuild_path)
|
||||
.args(&args)
|
||||
.current_dir(&options.project_root)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
// Get output file size | 获取输出文件大小
|
||||
let output_size = fs::metadata(&output_file)
|
||||
.map(|m| m.len())
|
||||
.ok();
|
||||
|
||||
// Parse warnings from stderr | 从 stderr 解析警告
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let warnings: Vec<String> = stderr
|
||||
.lines()
|
||||
.filter(|l| l.contains("warning"))
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
|
||||
Ok(BundleResult {
|
||||
success: true,
|
||||
output_file: Some(output_file.to_string_lossy().to_string()),
|
||||
output_size,
|
||||
error: None,
|
||||
warnings,
|
||||
})
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
Ok(BundleResult {
|
||||
success: false,
|
||||
output_file: None,
|
||||
output_size: None,
|
||||
error: Some(stderr.to_string()),
|
||||
warnings: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate HTML file from template.
|
||||
/// 从模板生成 HTML 文件。
|
||||
#[tauri::command]
|
||||
pub async fn generate_html(
|
||||
output_path: String,
|
||||
title: String,
|
||||
scripts: Vec<String>,
|
||||
body_content: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let scripts_html: String = scripts
|
||||
.iter()
|
||||
.map(|s| format!(r#" <script src="{}"></script>"#, s))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let body = body_content.unwrap_or_else(|| {
|
||||
r#" <canvas id="game-canvas" style="width: 100%; height: 100%;"></canvas>"#.to_string()
|
||||
});
|
||||
|
||||
let html = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{}</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
html, body {{ width: 100%; height: 100%; overflow: hidden; background: #000; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
title, body, scripts_html
|
||||
);
|
||||
|
||||
// Ensure parent directory exists | 确保父目录存在
|
||||
let path = Path::new(&output_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&output_path, html)
|
||||
.map_err(|e| format!("Failed to write HTML file | 写入 HTML 文件失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get file size.
|
||||
/// 获取文件大小。
|
||||
#[tauri::command]
|
||||
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
|
||||
fs::metadata(&file_path)
|
||||
.map(|m| m.len())
|
||||
.map_err(|e| format!("Failed to get file size | 获取文件大小失败: {}", e))
|
||||
}
|
||||
|
||||
/// Get directory size recursively.
|
||||
/// 递归获取目录大小。
|
||||
#[tauri::command]
|
||||
pub async fn get_directory_size(dir_path: String) -> Result<u64, String> {
|
||||
let path = Path::new(&dir_path);
|
||||
if !path.exists() {
|
||||
return Err(format!("Directory does not exist | 目录不存在: {}", dir_path));
|
||||
}
|
||||
|
||||
calculate_dir_size(path)
|
||||
}
|
||||
|
||||
/// Helper to calculate directory size.
|
||||
/// 计算目录大小的辅助函数。
|
||||
fn calculate_dir_size(path: &Path) -> Result<u64, String> {
|
||||
let mut total_size = 0u64;
|
||||
|
||||
for entry in fs::read_dir(path)
|
||||
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
|
||||
let entry_path = entry.path();
|
||||
|
||||
if entry_path.is_dir() {
|
||||
total_size += calculate_dir_size(&entry_path)?;
|
||||
} else {
|
||||
total_size += fs::metadata(&entry_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total_size)
|
||||
}
|
||||
|
||||
/// Find esbuild executable.
|
||||
/// 查找 esbuild 可执行文件。
|
||||
fn find_esbuild(project_root: &str) -> Result<String, String> {
|
||||
let project_path = Path::new(project_root);
|
||||
|
||||
// Try local node_modules first | 首先尝试本地 node_modules
|
||||
let local_esbuild = if cfg!(windows) {
|
||||
project_path.join("node_modules/.bin/esbuild.cmd")
|
||||
} else {
|
||||
project_path.join("node_modules/.bin/esbuild")
|
||||
};
|
||||
|
||||
if local_esbuild.exists() {
|
||||
return Ok(local_esbuild.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Try global esbuild | 尝试全局 esbuild
|
||||
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
|
||||
|
||||
let check = Command::new(global_esbuild)
|
||||
.arg("--version")
|
||||
.output();
|
||||
|
||||
match check {
|
||||
Ok(output) if output.status.success() => Ok(global_esbuild.to_string()),
|
||||
_ => Err("esbuild not found | 未找到 esbuild".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write JSON file.
|
||||
/// 写入 JSON 文件。
|
||||
#[tauri::command]
|
||||
pub async fn write_json_file(file_path: String, content: String) -> Result<(), String> {
|
||||
let path = Path::new(&file_path);
|
||||
|
||||
// Ensure parent directory exists | 确保父目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write JSON file | 写入 JSON 文件失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List files in directory with extension filter.
|
||||
/// 列出目录中指定扩展名的文件。
|
||||
#[tauri::command]
|
||||
pub async fn list_files_by_extension(
|
||||
dir_path: String,
|
||||
extensions: Vec<String>,
|
||||
recursive: bool,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let path = Path::new(&dir_path);
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
list_files_recursive(path, &extensions, recursive, &mut files)?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Helper to list files recursively.
|
||||
/// 递归列出文件的辅助函数。
|
||||
fn list_files_recursive(
|
||||
path: &Path,
|
||||
extensions: &[String],
|
||||
recursive: bool,
|
||||
files: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
for entry in fs::read_dir(path)
|
||||
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
|
||||
let entry_path = entry.path();
|
||||
|
||||
if entry_path.is_dir() {
|
||||
if recursive {
|
||||
list_files_recursive(&entry_path, extensions, recursive, files)?;
|
||||
}
|
||||
} else if let Some(ext) = entry_path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
if extensions.iter().any(|e| e.to_lowercase() == ext_str) {
|
||||
files.push(entry_path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read binary file and return as base64.
|
||||
/// 读取二进制文件并返回 base64 编码。
|
||||
#[tauri::command]
|
||||
pub async fn read_binary_file_as_base64(path: String) -> Result<String, String> {
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))?;
|
||||
|
||||
Ok(STANDARD.encode(&bytes))
|
||||
}
|
||||
389
packages/editor-app/src-tauri/src/commands/compiler.rs
Normal file
389
packages/editor-app/src-tauri/src/commands/compiler.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
//! User code compilation commands.
|
||||
//! 用户代码编译命令。
|
||||
//!
|
||||
//! Provides TypeScript compilation using esbuild for user scripts.
|
||||
//! 使用 esbuild 为用户脚本提供 TypeScript 编译。
|
||||
|
||||
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::channel;
|
||||
use std::time::Duration;
|
||||
use tauri::{command, AppHandle, Emitter, State};
|
||||
use crate::state::ScriptWatcherState;
|
||||
|
||||
/// Compilation options.
|
||||
/// 编译选项。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompileOptions {
|
||||
/// Entry file path | 入口文件路径
|
||||
pub entry_path: String,
|
||||
/// Output file path | 输出文件路径
|
||||
pub output_path: String,
|
||||
/// Output format (esm or iife) | 输出格式
|
||||
pub format: String,
|
||||
/// Whether to generate source map | 是否生成 source map
|
||||
pub source_map: bool,
|
||||
/// Whether to minify | 是否压缩
|
||||
pub minify: bool,
|
||||
/// External dependencies | 外部依赖
|
||||
pub external: Vec<String>,
|
||||
/// Project root for resolving imports | 项目根目录用于解析导入
|
||||
pub project_root: String,
|
||||
}
|
||||
|
||||
/// Compilation error.
|
||||
/// 编译错误。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompileError {
|
||||
/// Error message | 错误信息
|
||||
pub message: String,
|
||||
/// File path | 文件路径
|
||||
pub file: Option<String>,
|
||||
/// Line number | 行号
|
||||
pub line: Option<u32>,
|
||||
/// Column number | 列号
|
||||
pub column: Option<u32>,
|
||||
}
|
||||
|
||||
/// Compilation result.
|
||||
/// 编译结果。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompileResult {
|
||||
/// Whether compilation succeeded | 是否编译成功
|
||||
pub success: bool,
|
||||
/// Compilation errors | 编译错误
|
||||
pub errors: Vec<CompileError>,
|
||||
/// Output file path (if successful) | 输出文件路径(如果成功)
|
||||
pub output_path: Option<String>,
|
||||
}
|
||||
|
||||
/// File change event sent to frontend.
|
||||
/// 发送到前端的文件变更事件。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileChangeEvent {
|
||||
/// Type of change: "create", "modify", "remove" | 变更类型
|
||||
pub change_type: String,
|
||||
/// File paths that changed | 发生变更的文件路径
|
||||
pub paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// Compile TypeScript using esbuild.
|
||||
/// 使用 esbuild 编译 TypeScript。
|
||||
///
|
||||
/// # Arguments | 参数
|
||||
/// * `options` - Compilation options | 编译选项
|
||||
///
|
||||
/// # Returns | 返回
|
||||
/// Compilation result | 编译结果
|
||||
#[command]
|
||||
pub async fn compile_typescript(options: CompileOptions) -> Result<CompileResult, String> {
|
||||
// Check if esbuild is available | 检查 esbuild 是否可用
|
||||
let esbuild_path = find_esbuild(&options.project_root)?;
|
||||
|
||||
// Build esbuild arguments | 构建 esbuild 参数
|
||||
let mut args = vec![
|
||||
options.entry_path.clone(),
|
||||
"--bundle".to_string(),
|
||||
format!("--outfile={}", options.output_path),
|
||||
format!("--format={}", options.format),
|
||||
"--platform=browser".to_string(),
|
||||
"--target=es2020".to_string(),
|
||||
];
|
||||
|
||||
// Add source map option | 添加 source map 选项
|
||||
if options.source_map {
|
||||
args.push("--sourcemap".to_string());
|
||||
}
|
||||
|
||||
// Add minify option | 添加压缩选项
|
||||
if options.minify {
|
||||
args.push("--minify".to_string());
|
||||
}
|
||||
|
||||
// Add external dependencies | 添加外部依赖
|
||||
for external in &options.external {
|
||||
args.push(format!("--external:{}", external));
|
||||
}
|
||||
|
||||
// Run esbuild | 运行 esbuild
|
||||
let output = Command::new(&esbuild_path)
|
||||
.args(&args)
|
||||
.current_dir(&options.project_root)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(CompileResult {
|
||||
success: true,
|
||||
errors: vec![],
|
||||
output_path: Some(options.output_path),
|
||||
})
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let errors = parse_esbuild_errors(&stderr);
|
||||
|
||||
Ok(CompileResult {
|
||||
success: false,
|
||||
errors,
|
||||
output_path: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch for file changes in scripts directory.
|
||||
/// 监视脚本目录中的文件变更。
|
||||
///
|
||||
/// Emits "user-code:file-changed" events when files change.
|
||||
/// 当文件发生变更时触发 "user-code:file-changed" 事件。
|
||||
#[command]
|
||||
pub async fn watch_scripts(
|
||||
app: AppHandle,
|
||||
watcher_state: State<'_, ScriptWatcherState>,
|
||||
project_path: String,
|
||||
scripts_dir: String,
|
||||
) -> Result<(), String> {
|
||||
let watch_path = Path::new(&project_path).join(&scripts_dir);
|
||||
|
||||
if !watch_path.exists() {
|
||||
return Err(format!(
|
||||
"Scripts directory does not exist | 脚本目录不存在: {}",
|
||||
watch_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Check if already watching this project | 检查是否已在监视此项目
|
||||
{
|
||||
let watchers = watcher_state.watchers.lock().await;
|
||||
if watchers.contains_key(&project_path) {
|
||||
println!("[UserCode] Already watching: {}", project_path);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Create a channel for shutdown signal | 创建关闭信号通道
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
// Clone values for the spawned task | 克隆值以供任务使用
|
||||
let project_path_clone = project_path.clone();
|
||||
let watch_path_clone = watch_path.clone();
|
||||
let app_clone = app.clone();
|
||||
|
||||
// Spawn file watcher task | 启动文件监视任务
|
||||
tokio::spawn(async move {
|
||||
// Create notify watcher | 创建 notify 监视器
|
||||
let (tx, rx) = channel();
|
||||
|
||||
let mut watcher = match RecommendedWatcher::new(
|
||||
move |res: Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
},
|
||||
Config::default().with_poll_interval(Duration::from_millis(500)),
|
||||
) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("[UserCode] Failed to create watcher: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Start watching | 开始监视
|
||||
if let Err(e) = watcher.watch(&watch_path_clone, RecursiveMode::Recursive) {
|
||||
eprintln!("[UserCode] Failed to watch path: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("[UserCode] Started watching: {}", watch_path_clone.display());
|
||||
|
||||
// Event loop | 事件循环
|
||||
loop {
|
||||
// Check for shutdown | 检查关闭信号
|
||||
if shutdown_rx.try_recv().is_ok() {
|
||||
println!("[UserCode] Stopping watcher for: {}", project_path_clone);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for file events with timeout | 带超时检查文件事件
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(event) => {
|
||||
// Filter for TypeScript/JavaScript files | 过滤 TypeScript/JavaScript 文件
|
||||
let ts_paths: Vec<String> = event
|
||||
.paths
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
matches!(ext, "ts" | "tsx" | "js" | "jsx")
|
||||
})
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
if !ts_paths.is_empty() {
|
||||
let change_type = match event.kind {
|
||||
EventKind::Create(_) => "create",
|
||||
EventKind::Modify(_) => "modify",
|
||||
EventKind::Remove(_) => "remove",
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let file_event = FileChangeEvent {
|
||||
change_type: change_type.to_string(),
|
||||
paths: ts_paths,
|
||||
};
|
||||
|
||||
println!("[UserCode] File change detected: {:?}", file_event);
|
||||
|
||||
// Emit event to frontend | 向前端发送事件
|
||||
if let Err(e) = app_clone.emit("user-code:file-changed", file_event) {
|
||||
eprintln!("[UserCode] Failed to emit event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// No events, continue | 无事件,继续
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
println!("[UserCode] Watcher channel disconnected");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store watcher handle | 存储监视器句柄
|
||||
{
|
||||
let mut watchers = watcher_state.watchers.lock().await;
|
||||
watchers.insert(
|
||||
project_path.clone(),
|
||||
crate::state::WatcherHandle { shutdown_tx },
|
||||
);
|
||||
}
|
||||
|
||||
println!("[UserCode] Watch scripts started for: {}/{}", project_path, scripts_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop watching for file changes.
|
||||
/// 停止监视文件变更。
|
||||
#[command]
|
||||
pub async fn stop_watch_scripts(
|
||||
watcher_state: State<'_, ScriptWatcherState>,
|
||||
project_path: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut watchers = watcher_state.watchers.lock().await;
|
||||
|
||||
match project_path {
|
||||
Some(path) => {
|
||||
// Stop specific watcher | 停止特定监视器
|
||||
if let Some(handle) = watchers.remove(&path) {
|
||||
let _ = handle.shutdown_tx.send(());
|
||||
println!("[UserCode] Stopped watching: {}", path);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Stop all watchers | 停止所有监视器
|
||||
for (path, handle) in watchers.drain() {
|
||||
let _ = handle.shutdown_tx.send(());
|
||||
println!("[UserCode] Stopped watching: {}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find esbuild executable path.
|
||||
/// 查找 esbuild 可执行文件路径。
|
||||
fn find_esbuild(project_root: &str) -> Result<String, String> {
|
||||
let project_path = Path::new(project_root);
|
||||
|
||||
// Try local node_modules first | 首先尝试本地 node_modules
|
||||
let local_esbuild = if cfg!(windows) {
|
||||
project_path.join("node_modules/.bin/esbuild.cmd")
|
||||
} else {
|
||||
project_path.join("node_modules/.bin/esbuild")
|
||||
};
|
||||
|
||||
if local_esbuild.exists() {
|
||||
return Ok(local_esbuild.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Try global esbuild | 尝试全局 esbuild
|
||||
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
|
||||
|
||||
// Check if global esbuild exists | 检查全局 esbuild 是否存在
|
||||
let check = Command::new(global_esbuild)
|
||||
.arg("--version")
|
||||
.output();
|
||||
|
||||
match check {
|
||||
Ok(output) if output.status.success() => Ok(global_esbuild.to_string()),
|
||||
_ => Err("esbuild not found. Please install esbuild: npm install -g esbuild | 未找到 esbuild,请安装: npm install -g esbuild".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse esbuild error output.
|
||||
/// 解析 esbuild 错误输出。
|
||||
fn parse_esbuild_errors(stderr: &str) -> Vec<CompileError> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Simple error parsing - esbuild outputs errors in a specific format
|
||||
// 简单的错误解析 - esbuild 以特定格式输出错误
|
||||
for line in stderr.lines() {
|
||||
if line.contains("error:") || line.contains("Error:") {
|
||||
// Try to parse file:line:column format | 尝试解析 file:line:column 格式
|
||||
let parts: Vec<&str> = line.splitn(2, ": ").collect();
|
||||
|
||||
if parts.len() == 2 {
|
||||
let location = parts[0];
|
||||
let message = parts[1].to_string();
|
||||
|
||||
// Parse location (file:line:column) | 解析位置
|
||||
let loc_parts: Vec<&str> = location.split(':').collect();
|
||||
|
||||
let (file, line_num, column) = if loc_parts.len() >= 3 {
|
||||
(
|
||||
Some(loc_parts[0].to_string()),
|
||||
loc_parts[1].parse().ok(),
|
||||
loc_parts[2].parse().ok(),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
errors.push(CompileError {
|
||||
message,
|
||||
file,
|
||||
line: line_num,
|
||||
column,
|
||||
});
|
||||
} else {
|
||||
errors.push(CompileError {
|
||||
message: line.to_string(),
|
||||
file: None,
|
||||
line: None,
|
||||
column: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific errors found, add the whole stderr as one error
|
||||
// 如果没有找到特定错误,将整个 stderr 作为一个错误
|
||||
if errors.is_empty() && !stderr.trim().is_empty() {
|
||||
errors.push(CompileError {
|
||||
message: stderr.to_string(),
|
||||
file: None,
|
||||
line: None,
|
||||
column: None,
|
||||
});
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
//! Command modules
|
||||
//! Command modules.
|
||||
//! 命令模块。
|
||||
//!
|
||||
//! All Tauri commands organized by domain.
|
||||
//! 所有按领域组织的 Tauri 命令。
|
||||
|
||||
pub mod build;
|
||||
pub mod compiler;
|
||||
pub mod dialog;
|
||||
pub mod file_system;
|
||||
pub mod modules;
|
||||
pub mod plugin;
|
||||
pub mod profiler;
|
||||
pub mod project;
|
||||
pub mod system;
|
||||
|
||||
// Re-export all commands for convenience
|
||||
// Re-export all commands for convenience | 重新导出所有命令以方便使用
|
||||
pub use build::*;
|
||||
pub use compiler::*;
|
||||
pub use dialog::*;
|
||||
pub use file_system::*;
|
||||
pub use modules::*;
|
||||
pub use plugin::*;
|
||||
pub use profiler::*;
|
||||
pub use project::*;
|
||||
|
||||
175
packages/editor-app/src-tauri/src/commands/modules.rs
Normal file
175
packages/editor-app/src-tauri/src/commands/modules.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! Engine Module Commands
|
||||
//! 引擎模块命令
|
||||
//!
|
||||
//! Commands for reading engine module configurations.
|
||||
//! 用于读取引擎模块配置的命令。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{command, AppHandle};
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
use tauri::Manager;
|
||||
|
||||
/// Module index structure.
|
||||
/// 模块索引结构。
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModuleIndex {
|
||||
pub version: String,
|
||||
#[serde(rename = "generatedAt")]
|
||||
pub generated_at: String,
|
||||
pub modules: Vec<ModuleIndexEntry>,
|
||||
}
|
||||
|
||||
/// Module index entry.
|
||||
/// 模块索引条目。
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModuleIndexEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "displayName")]
|
||||
pub display_name: String,
|
||||
#[serde(rename = "hasRuntime")]
|
||||
pub has_runtime: bool,
|
||||
#[serde(rename = "editorPackage")]
|
||||
pub editor_package: Option<String>,
|
||||
#[serde(rename = "isCore")]
|
||||
pub is_core: bool,
|
||||
pub category: String,
|
||||
/// JS bundle size in bytes | JS 包大小(字节)
|
||||
#[serde(rename = "jsSize")]
|
||||
pub js_size: Option<u64>,
|
||||
/// Whether this module requires WASM | 是否需要 WASM
|
||||
#[serde(rename = "requiresWasm")]
|
||||
pub requires_wasm: Option<bool>,
|
||||
/// WASM file size in bytes | WASM 文件大小(字节)
|
||||
#[serde(rename = "wasmSize")]
|
||||
pub wasm_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Get the engine modules directory path.
|
||||
/// 获取引擎模块目录路径。
|
||||
///
|
||||
/// Uses compile-time CARGO_MANIFEST_DIR in dev mode to locate dist/engine.
|
||||
/// 在开发模式下使用编译时的 CARGO_MANIFEST_DIR 来定位 dist/engine。
|
||||
#[allow(unused_variables)]
|
||||
fn get_engine_modules_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
// In development mode, use compile-time path
|
||||
// 在开发模式下,使用编译时路径
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
// CARGO_MANIFEST_DIR is set at compile time, pointing to src-tauri
|
||||
// CARGO_MANIFEST_DIR 在编译时设置,指向 src-tauri
|
||||
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.map(|p| p.join("dist/engine"))
|
||||
.unwrap_or_else(|| PathBuf::from("dist/engine"));
|
||||
|
||||
if dev_path.exists() {
|
||||
println!("[modules] Using dev path: {:?}", dev_path);
|
||||
return Ok(dev_path);
|
||||
}
|
||||
|
||||
// Fallback: try current working directory
|
||||
// 回退:尝试当前工作目录
|
||||
let cwd_path = std::env::current_dir()
|
||||
.map(|p| p.join("dist/engine"))
|
||||
.unwrap_or_else(|_| PathBuf::from("dist/engine"));
|
||||
|
||||
if cwd_path.exists() {
|
||||
println!("[modules] Using cwd path: {:?}", cwd_path);
|
||||
return Ok(cwd_path);
|
||||
}
|
||||
|
||||
return Err(format!(
|
||||
"Engine modules directory not found in dev mode. Tried: {:?}, {:?}. Run 'pnpm copy-modules' first.",
|
||||
dev_path, cwd_path
|
||||
));
|
||||
}
|
||||
|
||||
// Production: use resource directory
|
||||
// 生产环境:使用资源目录
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let resource_path = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|e| format!("Failed to get resource dir: {}", e))?;
|
||||
|
||||
let prod_path = resource_path.join("engine");
|
||||
|
||||
if prod_path.exists() {
|
||||
return Ok(prod_path);
|
||||
}
|
||||
|
||||
// Fallback: try exe directory
|
||||
// 回退:尝试可执行文件目录
|
||||
let exe_path = std::env::current_exe()
|
||||
.map_err(|e| format!("Failed to get exe path: {}", e))?;
|
||||
let exe_dir = exe_path.parent()
|
||||
.ok_or("Failed to get exe directory")?;
|
||||
|
||||
let exe_engine_path = exe_dir.join("engine");
|
||||
if exe_engine_path.exists() {
|
||||
return Ok(exe_engine_path);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Engine modules directory not found. Tried: {:?}, {:?}",
|
||||
prod_path, exe_engine_path
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the engine modules index.
|
||||
/// 读取引擎模块索引。
|
||||
#[command]
|
||||
pub async fn read_engine_modules_index(app: AppHandle) -> Result<ModuleIndex, String> {
|
||||
println!("[modules] read_engine_modules_index called");
|
||||
let engine_path = get_engine_modules_path(&app)?;
|
||||
println!("[modules] engine_path: {:?}", engine_path);
|
||||
let index_path = engine_path.join("index.json");
|
||||
|
||||
if !index_path.exists() {
|
||||
return Err(format!(
|
||||
"Module index not found at {:?}. Run 'pnpm copy-modules' first.",
|
||||
index_path
|
||||
));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&index_path)
|
||||
.map_err(|e| format!("Failed to read index.json: {}", e))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse index.json: {}", e))
|
||||
}
|
||||
|
||||
/// Read a specific module's manifest.
|
||||
/// 读取特定模块的清单。
|
||||
#[command]
|
||||
pub async fn read_module_manifest(app: AppHandle, module_id: String) -> Result<serde_json::Value, String> {
|
||||
let engine_path = get_engine_modules_path(&app)?;
|
||||
let manifest_path = engine_path.join(&module_id).join("module.json");
|
||||
|
||||
if !manifest_path.exists() {
|
||||
return Err(format!(
|
||||
"Module manifest not found for '{}' at {:?}",
|
||||
module_id, manifest_path
|
||||
));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&manifest_path)
|
||||
.map_err(|e| format!("Failed to read module.json for {}: {}", module_id, e))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse module.json for {}: {}", module_id, e))
|
||||
}
|
||||
|
||||
/// Get the base path to engine modules directory.
|
||||
/// 获取引擎模块目录的基础路径。
|
||||
#[command]
|
||||
pub async fn get_engine_modules_base_path(app: AppHandle) -> Result<String, String> {
|
||||
let path = get_engine_modules_path(&app)?;
|
||||
path.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Failed to convert path to string".to_string())
|
||||
}
|
||||
@@ -12,14 +12,15 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::Manager;
|
||||
|
||||
use state::{ProfilerState, ProjectPaths};
|
||||
use state::{ProfilerState, ProjectPaths, ScriptWatcherState};
|
||||
|
||||
fn main() {
|
||||
// Initialize shared state
|
||||
// Initialize shared state | 初始化共享状态
|
||||
let project_paths: ProjectPaths = Arc::new(Mutex::new(HashMap::new()));
|
||||
let project_paths_for_protocol = Arc::clone(&project_paths);
|
||||
|
||||
let profiler_state = ProfilerState::new();
|
||||
let script_watcher_state = ScriptWatcherState::new();
|
||||
|
||||
// Build and run the Tauri application
|
||||
tauri::Builder::default()
|
||||
@@ -34,10 +35,11 @@ fn main() {
|
||||
.register_uri_scheme_protocol("project", move |_app, request| {
|
||||
handle_project_protocol(request, &project_paths_for_protocol)
|
||||
})
|
||||
// Setup application state
|
||||
// Setup application state | 设置应用状态
|
||||
.setup(move |app| {
|
||||
app.manage(project_paths);
|
||||
app.manage(profiler_state);
|
||||
app.manage(script_watcher_state);
|
||||
Ok(())
|
||||
})
|
||||
// Register all commands
|
||||
@@ -85,6 +87,24 @@ fn main() {
|
||||
commands::stop_local_server,
|
||||
commands::get_local_ip,
|
||||
commands::generate_qrcode,
|
||||
// User code compilation | 用户代码编译
|
||||
commands::compile_typescript,
|
||||
commands::watch_scripts,
|
||||
commands::stop_watch_scripts,
|
||||
// Build commands | 构建命令
|
||||
commands::prepare_build_directory,
|
||||
commands::copy_directory,
|
||||
commands::bundle_scripts,
|
||||
commands::generate_html,
|
||||
commands::get_file_size,
|
||||
commands::get_directory_size,
|
||||
commands::write_json_file,
|
||||
commands::list_files_by_extension,
|
||||
commands::read_binary_file_as_base64,
|
||||
// Engine modules | 引擎模块
|
||||
commands::read_engine_modules_index,
|
||||
commands::read_module_manifest,
|
||||
commands::get_engine_modules_base_path,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
//! Application state definitions
|
||||
//! Application state definitions.
|
||||
//! 应用状态定义。
|
||||
//!
|
||||
//! Centralized state management for the Tauri application.
|
||||
//! Tauri 应用的集中状态管理。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use crate::profiler_ws::ProfilerServer;
|
||||
|
||||
/// Project paths state
|
||||
/// Project paths state.
|
||||
/// 项目路径状态。
|
||||
///
|
||||
/// Stores the current project path and other path-related information.
|
||||
/// 存储当前项目路径和其他路径相关信息。
|
||||
pub type ProjectPaths = Arc<Mutex<HashMap<String, String>>>;
|
||||
|
||||
/// Script watcher state.
|
||||
/// 脚本监视器状态。
|
||||
///
|
||||
/// Manages file watchers for hot reload functionality.
|
||||
/// 管理用于热重载功能的文件监视器。
|
||||
pub struct ScriptWatcherState {
|
||||
/// Active watchers keyed by project path | 按项目路径索引的活动监视器
|
||||
pub watchers: Arc<TokioMutex<HashMap<String, WatcherHandle>>>,
|
||||
}
|
||||
|
||||
/// Handle to a running file watcher.
|
||||
/// 正在运行的文件监视器句柄。
|
||||
pub struct WatcherHandle {
|
||||
/// Shutdown signal sender | 关闭信号发送器
|
||||
pub shutdown_tx: tokio::sync::oneshot::Sender<()>,
|
||||
}
|
||||
|
||||
impl ScriptWatcherState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
watchers: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScriptWatcherState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Profiler server state
|
||||
///
|
||||
/// Manages the lifecycle of the WebSocket profiler server.
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
ICompilerRegistry,
|
||||
InspectorRegistry,
|
||||
INotification,
|
||||
CommandManager
|
||||
CommandManager,
|
||||
BuildService
|
||||
} from '@esengine/editor-core';
|
||||
import type { IDialogExtended } from './services/TauriDialogService';
|
||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||
@@ -42,6 +43,7 @@ import { AboutDialog } from './components/AboutDialog';
|
||||
import { ErrorDialog } from './components/ErrorDialog';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
|
||||
import { ToastProvider, useToast } from './components/Toast';
|
||||
import { TitleBar } from './components/TitleBar';
|
||||
import { MainToolbar } from './components/MainToolbar';
|
||||
@@ -95,6 +97,7 @@ function App() {
|
||||
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
||||
const [notification, setNotification] = useState<INotification | null>(null);
|
||||
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
|
||||
const [buildService, setBuildService] = useState<BuildService | null>(null);
|
||||
const [commandManager] = useState(() => new CommandManager());
|
||||
const { t, locale, changeLocale } = useLocale();
|
||||
|
||||
@@ -117,6 +120,7 @@ function App() {
|
||||
showSettings, setShowSettings,
|
||||
showAbout, setShowAbout,
|
||||
showPluginGenerator, setShowPluginGenerator,
|
||||
showBuildSettings, setShowBuildSettings,
|
||||
errorDialog, setErrorDialog,
|
||||
confirmDialog, setConfirmDialog
|
||||
} = useDialogStore();
|
||||
@@ -285,6 +289,7 @@ function App() {
|
||||
setSceneManager(services.sceneManager);
|
||||
setNotification(services.notification);
|
||||
setDialog(services.dialog as IDialogExtended);
|
||||
setBuildService(services.buildService);
|
||||
setStatus(t('header.status.ready'));
|
||||
|
||||
// Check for updates on startup (after 3 seconds)
|
||||
@@ -768,7 +773,7 @@ function App() {
|
||||
let content: React.ReactNode;
|
||||
if (panelDesc.component) {
|
||||
const Component = panelDesc.component;
|
||||
content = <Component projectPath={currentProjectPath} />;
|
||||
content = <Component projectPath={currentProjectPath} locale={locale} />;
|
||||
} else if (panelDesc.render) {
|
||||
content = panelDesc.render();
|
||||
}
|
||||
@@ -883,6 +888,7 @@ function App() {
|
||||
onOpenAbout={handleOpenAbout}
|
||||
onCreatePlugin={handleCreatePlugin}
|
||||
onReloadPlugins={handleReloadPlugins}
|
||||
onOpenBuildSettings={() => setShowBuildSettings(true)}
|
||||
/>
|
||||
<MainToolbar
|
||||
locale={locale}
|
||||
@@ -971,6 +977,16 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBuildSettings && (
|
||||
<BuildSettingsWindow
|
||||
onClose={() => setShowBuildSettings(false)}
|
||||
projectPath={currentProjectPath || undefined}
|
||||
locale={locale}
|
||||
buildService={buildService || undefined}
|
||||
sceneManager={sceneManager || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorDialog && (
|
||||
<ErrorDialog
|
||||
title={errorDialog.title}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface DialogState {
|
||||
showSettings: boolean;
|
||||
showAbout: boolean;
|
||||
showPluginGenerator: boolean;
|
||||
showBuildSettings: boolean;
|
||||
errorDialog: ErrorDialogData | null;
|
||||
confirmDialog: ConfirmDialogData | null;
|
||||
|
||||
@@ -22,6 +23,7 @@ interface DialogState {
|
||||
setShowSettings: (show: boolean) => void;
|
||||
setShowAbout: (show: boolean) => void;
|
||||
setShowPluginGenerator: (show: boolean) => void;
|
||||
setShowBuildSettings: (show: boolean) => void;
|
||||
setErrorDialog: (data: ErrorDialogData | null) => void;
|
||||
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
||||
closeAllDialogs: () => void;
|
||||
@@ -34,6 +36,7 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
|
||||
@@ -43,6 +46,7 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
setShowSettings: (show) => set({ showSettings: show }),
|
||||
setShowAbout: (show) => set({ showAbout: show }),
|
||||
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
||||
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
|
||||
setErrorDialog: (data) => set({ errorDialog: data }),
|
||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||
|
||||
@@ -53,6 +57,7 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null
|
||||
})
|
||||
|
||||
@@ -13,8 +13,8 @@ import { GizmoPlugin } from '../../plugins/builtin/GizmoPlugin';
|
||||
import { SceneInspectorPlugin } from '../../plugins/builtin/SceneInspectorPlugin';
|
||||
import { ProfilerPlugin } from '../../plugins/builtin/ProfilerPlugin';
|
||||
import { EditorAppearancePlugin } from '../../plugins/builtin/EditorAppearancePlugin';
|
||||
import { PluginConfigPlugin } from '../../plugins/builtin/PluginConfigPlugin';
|
||||
import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin';
|
||||
// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin
|
||||
|
||||
// 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor)
|
||||
import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor';
|
||||
@@ -22,6 +22,9 @@ import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor';
|
||||
import { TilemapPlugin } from '@esengine/tilemap-editor';
|
||||
import { UIPlugin } from '@esengine/ui-editor';
|
||||
import { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
import { MaterialPlugin } from '@esengine/material-editor';
|
||||
import { SpritePlugin } from '@esengine/sprite-editor';
|
||||
import { ShaderEditorPlugin } from '@esengine/shader-editor';
|
||||
|
||||
export class PluginInstaller {
|
||||
/**
|
||||
@@ -34,13 +37,12 @@ export class PluginInstaller {
|
||||
{ name: 'SceneInspectorPlugin', plugin: SceneInspectorPlugin },
|
||||
{ name: 'ProfilerPlugin', plugin: ProfilerPlugin },
|
||||
{ name: 'EditorAppearancePlugin', plugin: EditorAppearancePlugin },
|
||||
{ name: 'PluginConfigPlugin', plugin: PluginConfigPlugin },
|
||||
{ name: 'ProjectSettingsPlugin', plugin: ProjectSettingsPlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of builtinPlugins) {
|
||||
if (!plugin || !plugin.descriptor) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin);
|
||||
if (!plugin || !plugin.manifest) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -52,20 +54,23 @@ export class PluginInstaller {
|
||||
|
||||
// 统一模块插件(runtime + editor)
|
||||
const modulePlugins = [
|
||||
{ name: 'SpritePlugin', plugin: SpritePlugin },
|
||||
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
||||
{ name: 'UIPlugin', plugin: UIPlugin },
|
||||
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
|
||||
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
|
||||
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
|
||||
{ name: 'MaterialPlugin', plugin: MaterialPlugin },
|
||||
{ name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of modulePlugins) {
|
||||
if (!plugin || !plugin.descriptor) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin);
|
||||
if (!plugin || !plugin.manifest) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||
continue;
|
||||
}
|
||||
// 详细日志,检查 editorModule 是否存在
|
||||
console.log(`[PluginInstaller] ${name}: descriptor.id=${plugin.descriptor.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`);
|
||||
console.log(`[PluginInstaller] ${name}: manifest.id=${plugin.manifest.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`);
|
||||
try {
|
||||
pluginManager.register(plugin);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
UIRegistry,
|
||||
MessageHub,
|
||||
@@ -27,8 +28,18 @@ import {
|
||||
IDialogService,
|
||||
IFileSystemService,
|
||||
CompilerRegistry,
|
||||
ICompilerRegistry
|
||||
ICompilerRegistry,
|
||||
IViewportService_ID,
|
||||
IPreviewSceneService,
|
||||
IEditorViewportServiceIdentifier,
|
||||
PreviewSceneService,
|
||||
EditorViewportService,
|
||||
BuildService,
|
||||
WebBuildPipeline,
|
||||
WeChatBuildPipeline,
|
||||
moduleRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import { ViewportService } from '../../services/ViewportService';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
@@ -65,6 +76,8 @@ import {
|
||||
AnimationClipsFieldEditor
|
||||
} from '../../infrastructure/field-editors';
|
||||
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
|
||||
import { buildFileSystem } from '../../services/BuildFileSystemService';
|
||||
import { TauriModuleFileSystem } from '../../services/TauriModuleFileSystem';
|
||||
|
||||
export interface EditorServices {
|
||||
uiRegistry: UIRegistry;
|
||||
@@ -90,6 +103,7 @@ export interface EditorServices {
|
||||
inspectorRegistry: InspectorRegistry;
|
||||
propertyRendererRegistry: PropertyRendererRegistry;
|
||||
fieldEditorRegistry: FieldEditorRegistry;
|
||||
buildService: BuildService;
|
||||
}
|
||||
|
||||
export class ServiceRegistry {
|
||||
@@ -172,6 +186,22 @@ export class ServiceRegistry {
|
||||
Core.services.registerInstance(IDialogService, dialog);
|
||||
Core.services.registerInstance(IFileSystemService, fileSystem);
|
||||
|
||||
// Register viewport service for editor panels
|
||||
// 注册视口服务供编辑器面板使用
|
||||
const viewportService = ViewportService.getInstance();
|
||||
Core.services.registerInstance(IViewportService_ID, viewportService);
|
||||
|
||||
// Register preview scene service for isolated preview scenes
|
||||
// 注册预览场景服务,用于隔离的预览场景
|
||||
const previewSceneService = PreviewSceneService.getInstance();
|
||||
Core.services.registerInstance(IPreviewSceneService, previewSceneService);
|
||||
|
||||
// Register editor viewport service for coordinating viewports with overlays
|
||||
// 注册编辑器视口服务,协调带有覆盖层的视口
|
||||
const editorViewportService = EditorViewportService.getInstance();
|
||||
editorViewportService.setViewportService(viewportService);
|
||||
Core.services.registerInstance(IEditorViewportServiceIdentifier, editorViewportService);
|
||||
|
||||
const inspectorRegistry = new InspectorRegistry();
|
||||
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
||||
Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问
|
||||
@@ -204,6 +234,43 @@ export class ServiceRegistry {
|
||||
// Register component inspectors
|
||||
componentInspectorRegistry.register(new TransformComponentInspector());
|
||||
|
||||
// 注册构建服务
|
||||
// Register build service
|
||||
const buildService = new BuildService();
|
||||
|
||||
// Register Web build pipeline with file system service
|
||||
// 注册 Web 构建管线并注入文件系统服务
|
||||
const webPipeline = new WebBuildPipeline();
|
||||
webPipeline.setFileSystem(buildFileSystem);
|
||||
|
||||
// Get engine modules path from Tauri backend
|
||||
// 从 Tauri 后端获取引擎模块路径
|
||||
invoke<string>('get_engine_modules_base_path').then(enginePath => {
|
||||
console.log('[ServiceRegistry] Engine modules path:', enginePath);
|
||||
webPipeline.setEngineModulesPath(enginePath);
|
||||
}).catch(err => {
|
||||
console.warn('[ServiceRegistry] Failed to get engine modules path:', err);
|
||||
});
|
||||
|
||||
buildService.register(webPipeline);
|
||||
|
||||
// Register WeChat build pipeline
|
||||
// 注册微信构建管线
|
||||
const wechatPipeline = new WeChatBuildPipeline();
|
||||
wechatPipeline.setFileSystem(buildFileSystem);
|
||||
buildService.register(wechatPipeline);
|
||||
|
||||
Core.services.registerInstance(BuildService, buildService);
|
||||
|
||||
// Initialize ModuleRegistry with Tauri file system
|
||||
// 使用 Tauri 文件系统初始化 ModuleRegistry
|
||||
// Engine modules are read via Tauri commands from local file system
|
||||
// 引擎模块通过 Tauri 命令从本地文件系统读取
|
||||
const tauriModuleFs = new TauriModuleFileSystem();
|
||||
moduleRegistry.initialize(tauriModuleFs, '/engine').catch(err => {
|
||||
console.warn('[ServiceRegistry] Failed to initialize ModuleRegistry:', err);
|
||||
});
|
||||
|
||||
// 注册默认场景模板 - 创建默认相机
|
||||
// Register default scene template - creates default camera
|
||||
this.registerDefaultSceneTemplate();
|
||||
@@ -231,7 +298,8 @@ export class ServiceRegistry {
|
||||
notification,
|
||||
inspectorRegistry,
|
||||
propertyRendererRegistry,
|
||||
fieldEditorRegistry
|
||||
fieldEditorRegistry,
|
||||
buildService
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
897
packages/editor-app/src/components/BuildSettingsPanel.tsx
Normal file
897
packages/editor-app/src/components/BuildSettingsPanel.tsx
Normal file
@@ -0,0 +1,897 @@
|
||||
/**
|
||||
* Build Settings Panel.
|
||||
* 构建设置面板。
|
||||
*
|
||||
* Provides build settings interface for managing platform builds,
|
||||
* scenes, and player settings.
|
||||
* 提供构建设置界面,用于管理平台构建、场景和玩家设置。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Monitor, Apple, Smartphone, Globe, Server, Gamepad2,
|
||||
Plus, Minus, ChevronDown, ChevronRight, Settings,
|
||||
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X
|
||||
} from 'lucide-react';
|
||||
import type { BuildService, BuildProgress, BuildConfig, WebBuildConfig, WeChatBuildConfig, SceneManagerService } from '@esengine/editor-core';
|
||||
import { BuildPlatform, BuildStatus } from '@esengine/editor-core';
|
||||
import '../styles/BuildSettingsPanel.css';
|
||||
|
||||
// ==================== Types | 类型定义 ====================
|
||||
|
||||
/** Platform type | 平台类型 */
|
||||
type PlatformType =
|
||||
| 'windows'
|
||||
| 'macos'
|
||||
| 'linux'
|
||||
| 'android'
|
||||
| 'ios'
|
||||
| 'web'
|
||||
| 'wechat-minigame';
|
||||
|
||||
/** Build profile | 构建配置 */
|
||||
interface BuildProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: PlatformType;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** Scene entry | 场景条目 */
|
||||
interface SceneEntry {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Platform configuration | 平台配置 */
|
||||
interface PlatformConfig {
|
||||
platform: PlatformType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/** Build settings | 构建设置 */
|
||||
interface BuildSettings {
|
||||
scenes: SceneEntry[];
|
||||
scriptingDefines: string[];
|
||||
companyName: string;
|
||||
productName: string;
|
||||
version: string;
|
||||
// Platform-specific | 平台特定
|
||||
developmentBuild: boolean;
|
||||
sourceMap: boolean;
|
||||
compressionMethod: 'Default' | 'LZ4' | 'LZ4HC';
|
||||
bundleModules: boolean;
|
||||
}
|
||||
|
||||
// ==================== Constants | 常量 ====================
|
||||
|
||||
const PLATFORMS: PlatformConfig[] = [
|
||||
{ platform: 'windows', label: 'Windows', icon: <Monitor size={16} />, available: true },
|
||||
{ platform: 'macos', label: 'macOS', icon: <Apple size={16} />, available: true },
|
||||
{ platform: 'linux', label: 'Linux', icon: <Server size={16} />, available: true },
|
||||
{ platform: 'android', label: 'Android', icon: <Smartphone size={16} />, available: true },
|
||||
{ platform: 'ios', label: 'iOS', icon: <Smartphone size={16} />, available: true },
|
||||
{ platform: 'web', label: 'Web', icon: <Globe size={16} />, available: true },
|
||||
{ platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: <Gamepad2 size={16} />, available: true },
|
||||
];
|
||||
|
||||
const DEFAULT_SETTINGS: BuildSettings = {
|
||||
scenes: [],
|
||||
scriptingDefines: [],
|
||||
companyName: 'DefaultCompany',
|
||||
productName: 'MyGame',
|
||||
version: '0.1.0',
|
||||
developmentBuild: false,
|
||||
sourceMap: false,
|
||||
compressionMethod: 'Default',
|
||||
bundleModules: false,
|
||||
};
|
||||
|
||||
// ==================== i18n | 国际化 ====================
|
||||
|
||||
const i18n = {
|
||||
en: {
|
||||
buildProfiles: 'Build Profiles',
|
||||
addBuildProfile: 'Add Build Profile',
|
||||
playerSettings: 'Player Settings',
|
||||
assetImportOverrides: 'Asset Import Overrides',
|
||||
platforms: 'Platforms',
|
||||
sceneList: 'Scene List',
|
||||
active: 'Active',
|
||||
switchProfile: 'Switch Profile',
|
||||
build: 'Build',
|
||||
buildAndRun: 'Build And Run',
|
||||
buildData: 'Build Data',
|
||||
scriptingDefines: 'Scripting Defines',
|
||||
listIsEmpty: 'List is empty',
|
||||
addOpenScenes: 'Add Open Scenes',
|
||||
platformSettings: 'Platform Settings',
|
||||
architecture: 'Architecture',
|
||||
developmentBuild: 'Development Build',
|
||||
sourceMap: 'Source Map',
|
||||
compressionMethod: 'Compression Method',
|
||||
bundleModules: 'Bundle Modules',
|
||||
bundleModulesHint: 'Merge all modules into single file',
|
||||
separateModulesHint: 'Keep modules as separate files',
|
||||
playerSettingsOverrides: 'Player Settings Overrides',
|
||||
companyName: 'Company Name',
|
||||
productName: 'Product Name',
|
||||
version: 'Version',
|
||||
defaultIcon: 'Default Icon',
|
||||
none: 'None',
|
||||
// Build progress | 构建进度
|
||||
buildInProgress: 'Build in Progress',
|
||||
preparing: 'Preparing...',
|
||||
compiling: 'Compiling...',
|
||||
packaging: 'Packaging assets...',
|
||||
copying: 'Copying files...',
|
||||
postProcessing: 'Post-processing...',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
buildSucceeded: 'Build succeeded!',
|
||||
buildFailed: 'Build failed',
|
||||
warnings: 'Warnings',
|
||||
outputPath: 'Output Path',
|
||||
duration: 'Duration',
|
||||
},
|
||||
zh: {
|
||||
buildProfiles: '构建配置',
|
||||
addBuildProfile: '添加构建配置',
|
||||
playerSettings: '玩家设置',
|
||||
assetImportOverrides: '资源导入覆盖',
|
||||
platforms: '平台',
|
||||
sceneList: '场景列表',
|
||||
active: '激活',
|
||||
switchProfile: '切换配置',
|
||||
build: '构建',
|
||||
buildAndRun: '构建并运行',
|
||||
buildData: '构建数据',
|
||||
scriptingDefines: '脚本定义',
|
||||
listIsEmpty: '列表为空',
|
||||
addOpenScenes: '添加已打开的场景',
|
||||
platformSettings: '平台设置',
|
||||
architecture: '架构',
|
||||
developmentBuild: '开发版本',
|
||||
sourceMap: 'Source Map',
|
||||
compressionMethod: '压缩方式',
|
||||
bundleModules: '打包模块',
|
||||
bundleModulesHint: '合并所有模块为单文件',
|
||||
separateModulesHint: '保持模块为独立文件',
|
||||
playerSettingsOverrides: '玩家设置覆盖',
|
||||
companyName: '公司名称',
|
||||
productName: '产品名称',
|
||||
version: '版本',
|
||||
defaultIcon: '默认图标',
|
||||
none: '无',
|
||||
// Build progress | 构建进度
|
||||
buildInProgress: '正在构建',
|
||||
preparing: '准备中...',
|
||||
compiling: '编译中...',
|
||||
packaging: '打包资源...',
|
||||
copying: '复制文件...',
|
||||
postProcessing: '后处理...',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
buildSucceeded: '构建成功!',
|
||||
buildFailed: '构建失败',
|
||||
warnings: '警告',
|
||||
outputPath: '输出路径',
|
||||
duration: '耗时',
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Props | 属性 ====================
|
||||
|
||||
interface BuildSettingsPanelProps {
|
||||
projectPath?: string;
|
||||
locale?: string;
|
||||
buildService?: BuildService;
|
||||
sceneManager?: SceneManagerService;
|
||||
onBuild?: (profile: BuildProfile, settings: BuildSettings) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// ==================== Component | 组件 ====================
|
||||
|
||||
export function BuildSettingsPanel({
|
||||
projectPath,
|
||||
locale = 'en',
|
||||
buildService,
|
||||
sceneManager,
|
||||
onBuild,
|
||||
onClose
|
||||
}: BuildSettingsPanelProps) {
|
||||
const t = i18n[locale as keyof typeof i18n] || i18n.en;
|
||||
|
||||
// State | 状态
|
||||
const [profiles, setProfiles] = useState<BuildProfile[]>([
|
||||
{ id: 'web-dev', name: 'Web - Development', platform: 'web', isActive: true },
|
||||
{ id: 'web-prod', name: 'Web - Production', platform: 'web' },
|
||||
{ id: 'wechat', name: 'WeChat Mini Game', platform: 'wechat-minigame' },
|
||||
]);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<PlatformType>('web');
|
||||
const [selectedProfile, setSelectedProfile] = useState<BuildProfile | null>(profiles[0] || null);
|
||||
const [settings, setSettings] = useState<BuildSettings>(DEFAULT_SETTINGS);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
sceneList: true,
|
||||
scriptingDefines: true,
|
||||
platformSettings: true,
|
||||
playerSettings: true,
|
||||
});
|
||||
|
||||
// Build state | 构建状态
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
|
||||
const [buildResult, setBuildResult] = useState<{
|
||||
success: boolean;
|
||||
outputPath: string;
|
||||
duration: number;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
const [showBuildProgress, setShowBuildProgress] = useState(false);
|
||||
const buildAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Handlers | 处理函数
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handlePlatformSelect = useCallback((platform: PlatformType) => {
|
||||
setSelectedPlatform(platform);
|
||||
// Find first profile for this platform | 查找此平台的第一个配置
|
||||
const profile = profiles.find(p => p.platform === platform);
|
||||
setSelectedProfile(profile || null);
|
||||
}, [profiles]);
|
||||
|
||||
const handleProfileSelect = useCallback((profile: BuildProfile) => {
|
||||
setSelectedProfile(profile);
|
||||
setSelectedPlatform(profile.platform);
|
||||
}, []);
|
||||
|
||||
const handleAddProfile = useCallback(() => {
|
||||
const newProfile: BuildProfile = {
|
||||
id: `profile-${Date.now()}`,
|
||||
name: `${selectedPlatform} - New Profile`,
|
||||
platform: selectedPlatform,
|
||||
};
|
||||
setProfiles(prev => [...prev, newProfile]);
|
||||
setSelectedProfile(newProfile);
|
||||
}, [selectedPlatform]);
|
||||
|
||||
// Map platform type to BuildPlatform enum | 将平台类型映射到 BuildPlatform 枚举
|
||||
const getPlatformEnum = useCallback((platformType: PlatformType): BuildPlatform => {
|
||||
const platformMap: Record<PlatformType, BuildPlatform> = {
|
||||
'web': BuildPlatform.Web,
|
||||
'wechat-minigame': BuildPlatform.WeChatMiniGame,
|
||||
'windows': BuildPlatform.Desktop,
|
||||
'macos': BuildPlatform.Desktop,
|
||||
'linux': BuildPlatform.Desktop,
|
||||
'android': BuildPlatform.Android,
|
||||
'ios': BuildPlatform.iOS
|
||||
};
|
||||
return platformMap[platformType];
|
||||
}, []);
|
||||
|
||||
const handleBuild = useCallback(async () => {
|
||||
if (!selectedProfile || !projectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call external handler if provided | 如果提供了外部处理程序则调用
|
||||
if (onBuild) {
|
||||
onBuild(selectedProfile, settings);
|
||||
}
|
||||
|
||||
// Use BuildService if available | 如果可用则使用 BuildService
|
||||
if (buildService) {
|
||||
setIsBuilding(true);
|
||||
setBuildProgress(null);
|
||||
setBuildResult(null);
|
||||
setShowBuildProgress(true);
|
||||
|
||||
try {
|
||||
const platform = getPlatformEnum(selectedProfile.platform);
|
||||
const baseConfig = {
|
||||
platform,
|
||||
outputPath: `${projectPath}/build/${selectedProfile.platform}`,
|
||||
isRelease: !settings.developmentBuild,
|
||||
sourceMap: settings.sourceMap,
|
||||
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path)
|
||||
};
|
||||
|
||||
// Build platform-specific config | 构建平台特定配置
|
||||
let buildConfig: BuildConfig;
|
||||
if (platform === BuildPlatform.Web) {
|
||||
const webConfig: WebBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.Web,
|
||||
format: 'iife',
|
||||
bundleModules: settings.bundleModules,
|
||||
generateHtml: true
|
||||
};
|
||||
buildConfig = webConfig;
|
||||
} else if (platform === BuildPlatform.WeChatMiniGame) {
|
||||
const wechatConfig: WeChatBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.WeChatMiniGame,
|
||||
appId: '',
|
||||
useSubpackages: false,
|
||||
mainPackageLimit: 4096,
|
||||
usePlugins: false
|
||||
};
|
||||
buildConfig = wechatConfig;
|
||||
} else {
|
||||
buildConfig = baseConfig;
|
||||
}
|
||||
|
||||
// Execute build with progress callback | 执行构建并传入进度回调
|
||||
const result = await buildService.build(buildConfig, (progress) => {
|
||||
setBuildProgress(progress);
|
||||
});
|
||||
|
||||
// Set result | 设置结果
|
||||
setBuildResult({
|
||||
success: result.success,
|
||||
outputPath: result.outputPath,
|
||||
duration: result.duration,
|
||||
warnings: result.warnings,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
setBuildResult({
|
||||
success: false,
|
||||
outputPath: '',
|
||||
duration: 0,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
setIsBuilding(false);
|
||||
}
|
||||
}
|
||||
}, [selectedProfile, settings, projectPath, buildService, onBuild, getPlatformEnum]);
|
||||
|
||||
// Monitor build progress from service | 从服务监控构建进度
|
||||
useEffect(() => {
|
||||
if (!buildService || !isBuilding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const task = buildService.getCurrentTask();
|
||||
if (task) {
|
||||
setBuildProgress(task.progress);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [buildService, isBuilding]);
|
||||
|
||||
const handleCancelBuild = useCallback(() => {
|
||||
if (buildService) {
|
||||
buildService.cancelBuild();
|
||||
}
|
||||
}, [buildService]);
|
||||
|
||||
const handleCloseBuildProgress = useCallback(() => {
|
||||
if (!isBuilding) {
|
||||
setShowBuildProgress(false);
|
||||
setBuildProgress(null);
|
||||
setBuildResult(null);
|
||||
}
|
||||
}, [isBuilding]);
|
||||
|
||||
// Get status message | 获取状态消息
|
||||
const getStatusMessage = useCallback((status: BuildStatus): string => {
|
||||
const statusMessages: Record<BuildStatus, keyof typeof i18n.en> = {
|
||||
[BuildStatus.Idle]: 'preparing',
|
||||
[BuildStatus.Preparing]: 'preparing',
|
||||
[BuildStatus.Compiling]: 'compiling',
|
||||
[BuildStatus.Packaging]: 'packaging',
|
||||
[BuildStatus.Copying]: 'copying',
|
||||
[BuildStatus.PostProcessing]: 'postProcessing',
|
||||
[BuildStatus.Completed]: 'completed',
|
||||
[BuildStatus.Failed]: 'failed',
|
||||
[BuildStatus.Cancelled]: 'cancelled'
|
||||
};
|
||||
return t[statusMessages[status]] || status;
|
||||
}, [t]);
|
||||
|
||||
const handleAddScene = useCallback(() => {
|
||||
if (!sceneManager) {
|
||||
console.warn('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
const currentScenePath = sceneState.currentScenePath;
|
||||
|
||||
if (!currentScenePath) {
|
||||
console.warn('No scene is currently open');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if scene is already in the list | 检查场景是否已在列表中
|
||||
const exists = settings.scenes.some(s => s.path === currentScenePath);
|
||||
if (exists) {
|
||||
console.log('Scene already in list:', currentScenePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add current scene to the list | 将当前场景添加到列表中
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scenes: [...prev.scenes, { path: currentScenePath, enabled: true }]
|
||||
}));
|
||||
}, [sceneManager, settings.scenes]);
|
||||
|
||||
const handleAddDefine = useCallback(() => {
|
||||
const define = prompt('Enter scripting define:');
|
||||
if (define) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scriptingDefines: [...prev.scriptingDefines, define]
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveDefine = useCallback((index: number) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scriptingDefines: prev.scriptingDefines.filter((_, i) => i !== index)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Get platform config | 获取平台配置
|
||||
const currentPlatformConfig = PLATFORMS.find(p => p.platform === selectedPlatform);
|
||||
|
||||
return (
|
||||
<div className="build-settings-panel">
|
||||
{/* Header Tabs | 头部标签 */}
|
||||
<div className="build-settings-header">
|
||||
<div className="build-settings-tabs">
|
||||
<div className="build-settings-tab active">
|
||||
<Package size={14} />
|
||||
{t.buildProfiles}
|
||||
</div>
|
||||
</div>
|
||||
<div className="build-settings-header-actions">
|
||||
<button className="build-settings-header-btn">{t.playerSettings}</button>
|
||||
<button className="build-settings-header-btn">{t.assetImportOverrides}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Profile Bar | 添加配置栏 */}
|
||||
<div className="build-settings-add-bar">
|
||||
<button className="build-settings-add-btn" onClick={handleAddProfile}>
|
||||
<Plus size={14} />
|
||||
{t.addBuildProfile}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content | 主要内容 */}
|
||||
<div className="build-settings-content">
|
||||
{/* Left Sidebar | 左侧边栏 */}
|
||||
<div className="build-settings-sidebar">
|
||||
{/* Platforms Section | 平台部分 */}
|
||||
<div className="build-settings-section">
|
||||
<div className="build-settings-section-header">{t.platforms}</div>
|
||||
<div className="build-settings-platform-list">
|
||||
{PLATFORMS.map(platform => {
|
||||
const isActive = profiles.some(p => p.platform === platform.platform && p.isActive);
|
||||
return (
|
||||
<div
|
||||
key={platform.platform}
|
||||
className={`build-settings-platform-item ${selectedPlatform === platform.platform ? 'selected' : ''}`}
|
||||
onClick={() => handlePlatformSelect(platform.platform)}
|
||||
>
|
||||
<span className="build-settings-platform-icon">{platform.icon}</span>
|
||||
<span className="build-settings-platform-label">{platform.label}</span>
|
||||
{isActive && <span className="build-settings-active-badge">{t.active}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Profiles Section | 构建配置部分 */}
|
||||
<div className="build-settings-section">
|
||||
<div className="build-settings-section-header">{t.buildProfiles}</div>
|
||||
<div className="build-settings-profile-list">
|
||||
{profiles
|
||||
.filter(p => p.platform === selectedPlatform)
|
||||
.map(profile => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={`build-settings-profile-item ${selectedProfile?.id === profile.id ? 'selected' : ''}`}
|
||||
onClick={() => handleProfileSelect(profile)}
|
||||
>
|
||||
<span className="build-settings-profile-icon">
|
||||
{currentPlatformConfig?.icon}
|
||||
</span>
|
||||
<span className="build-settings-profile-name">{profile.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel | 右侧面板 */}
|
||||
<div className="build-settings-details">
|
||||
{selectedProfile ? (
|
||||
<>
|
||||
{/* Profile Header | 配置头部 */}
|
||||
<div className="build-settings-details-header">
|
||||
<div className="build-settings-details-title">
|
||||
<span className="build-settings-details-icon">
|
||||
{currentPlatformConfig?.icon}
|
||||
</span>
|
||||
<div className="build-settings-details-info">
|
||||
<h3>{selectedProfile.name}</h3>
|
||||
<span>{currentPlatformConfig?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="build-settings-details-actions">
|
||||
<button className="build-settings-btn secondary">{t.switchProfile}</button>
|
||||
<button className="build-settings-btn primary" onClick={handleBuild}>
|
||||
{t.build}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Data Section | 构建数据部分 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">{t.buildData}</div>
|
||||
|
||||
{/* Scene List | 场景列表 */}
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('sceneList')}
|
||||
>
|
||||
{expandedSections.sceneList ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t.sceneList}</span>
|
||||
</div>
|
||||
{expandedSections.sceneList && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-scene-list">
|
||||
{settings.scenes.length === 0 ? (
|
||||
<div className="build-settings-empty-list"></div>
|
||||
) : (
|
||||
settings.scenes.map((scene, index) => (
|
||||
<div key={index} className="build-settings-scene-item">
|
||||
<input type="checkbox" checked={scene.enabled} readOnly />
|
||||
<span>{scene.path}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="build-settings-field-actions">
|
||||
<button className="build-settings-btn text" onClick={handleAddScene}>
|
||||
{t.addOpenScenes}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scripting Defines | 脚本定义 */}
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('scriptingDefines')}
|
||||
>
|
||||
{expandedSections.scriptingDefines ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t.scriptingDefines}</span>
|
||||
</div>
|
||||
{expandedSections.scriptingDefines && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-defines-list">
|
||||
{settings.scriptingDefines.length === 0 ? (
|
||||
<div className="build-settings-empty-text">{t.listIsEmpty}</div>
|
||||
) : (
|
||||
settings.scriptingDefines.map((define, index) => (
|
||||
<div key={index} className="build-settings-define-item">
|
||||
<span>{define}</span>
|
||||
<button onClick={() => handleRemoveDefine(index)}>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="build-settings-list-actions">
|
||||
<button onClick={handleAddDefine}><Plus size={14} /></button>
|
||||
<button disabled={settings.scriptingDefines.length === 0}>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Settings Section | 平台设置部分 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">{t.platformSettings}</div>
|
||||
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('platformSettings')}
|
||||
>
|
||||
{expandedSections.platformSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{currentPlatformConfig?.label} Settings</span>
|
||||
</div>
|
||||
{expandedSections.platformSettings && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-form">
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.developmentBuild}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.developmentBuild}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
developmentBuild: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.sourceMap}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.sourceMap}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
sourceMap: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.compressionMethod}</label>
|
||||
<select
|
||||
value={settings.compressionMethod}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
compressionMethod: e.target.value as any
|
||||
}))}
|
||||
>
|
||||
<option value="Default">Default</option>
|
||||
<option value="LZ4">LZ4</option>
|
||||
<option value="LZ4HC">LZ4HC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.bundleModules}</label>
|
||||
<div className="build-settings-toggle-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.bundleModules}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
bundleModules: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
<span className="build-settings-hint">
|
||||
{settings.bundleModules ? t.bundleModulesHint : t.separateModulesHint}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Settings Overrides | 玩家设置覆盖 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">
|
||||
{t.playerSettingsOverrides}
|
||||
<button className="build-settings-more-btn">
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('playerSettings')}
|
||||
>
|
||||
{expandedSections.playerSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>Player Settings</span>
|
||||
</div>
|
||||
{expandedSections.playerSettings && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-form">
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.companyName}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.companyName}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
companyName: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.productName}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.productName}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
productName: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.version}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.version}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
version: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.defaultIcon}</label>
|
||||
<div className="build-settings-icon-picker">
|
||||
<span>{t.none}</span>
|
||||
<span className="build-settings-icon-hint">(Texture 2D)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="build-settings-no-selection">
|
||||
<p>Select a platform or build profile</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Progress Dialog | 构建进度对话框 */}
|
||||
{showBuildProgress && (
|
||||
<div className="build-progress-overlay">
|
||||
<div className="build-progress-dialog">
|
||||
<div className="build-progress-header">
|
||||
<h3>{t.buildInProgress}</h3>
|
||||
{!isBuilding && (
|
||||
<button
|
||||
className="build-progress-close"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="build-progress-content">
|
||||
{/* Status Icon | 状态图标 */}
|
||||
<div className="build-progress-status-icon">
|
||||
{isBuilding ? (
|
||||
<Loader2 size={48} className="build-progress-spinner" />
|
||||
) : buildResult?.success ? (
|
||||
<CheckCircle size={48} className="build-progress-success" />
|
||||
) : (
|
||||
<XCircle size={48} className="build-progress-error" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Message | 状态消息 */}
|
||||
<div className="build-progress-message">
|
||||
{isBuilding ? (
|
||||
buildProgress?.message || getStatusMessage(buildProgress?.status || BuildStatus.Preparing)
|
||||
) : buildResult?.success ? (
|
||||
t.buildSucceeded
|
||||
) : (
|
||||
t.buildFailed
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar | 进度条 */}
|
||||
{isBuilding && buildProgress && (
|
||||
<div className="build-progress-bar-container">
|
||||
<div
|
||||
className="build-progress-bar"
|
||||
style={{ width: `${buildProgress.progress}%` }}
|
||||
/>
|
||||
<span className="build-progress-percent">
|
||||
{Math.round(buildProgress.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Build Result Details | 构建结果详情 */}
|
||||
{!isBuilding && buildResult && (
|
||||
<div className="build-result-details">
|
||||
{buildResult.success && (
|
||||
<>
|
||||
<div className="build-result-row">
|
||||
<span className="build-result-label">{t.outputPath}:</span>
|
||||
<span className="build-result-value">{buildResult.outputPath}</span>
|
||||
</div>
|
||||
<div className="build-result-row">
|
||||
<span className="build-result-label">{t.duration}:</span>
|
||||
<span className="build-result-value">
|
||||
{(buildResult.duration / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error Message | 错误消息 */}
|
||||
{buildResult.error && (
|
||||
<div className="build-result-error">
|
||||
<AlertTriangle size={16} />
|
||||
<span>{buildResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings | 警告 */}
|
||||
{buildResult.warnings.length > 0 && (
|
||||
<div className="build-result-warnings">
|
||||
<div className="build-result-warnings-header">
|
||||
<AlertTriangle size={14} />
|
||||
<span>{t.warnings} ({buildResult.warnings.length})</span>
|
||||
</div>
|
||||
<ul className="build-result-warnings-list">
|
||||
{buildResult.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions | 操作按钮 */}
|
||||
<div className="build-progress-actions">
|
||||
{isBuilding ? (
|
||||
<button
|
||||
className="build-settings-btn secondary"
|
||||
onClick={handleCancelBuild}
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="build-settings-btn primary"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
{t.close}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BuildSettingsPanel;
|
||||
62
packages/editor-app/src/components/BuildSettingsWindow.tsx
Normal file
62
packages/editor-app/src/components/BuildSettingsWindow.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Build Settings Window.
|
||||
* 构建设置窗口。
|
||||
*
|
||||
* A modal window that displays the build settings panel.
|
||||
* 显示构建设置面板的模态窗口。
|
||||
*/
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import type { BuildService, SceneManagerService } from '@esengine/editor-core';
|
||||
import { BuildSettingsPanel } from './BuildSettingsPanel';
|
||||
import '../styles/BuildSettingsWindow.css';
|
||||
|
||||
interface BuildSettingsWindowProps {
|
||||
projectPath?: string;
|
||||
locale?: string;
|
||||
buildService?: BuildService;
|
||||
sceneManager?: SceneManagerService;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BuildSettingsWindow({
|
||||
projectPath,
|
||||
locale = 'en',
|
||||
buildService,
|
||||
sceneManager,
|
||||
onClose
|
||||
}: BuildSettingsWindowProps) {
|
||||
const t = locale === 'zh' ? {
|
||||
title: '构建设置'
|
||||
} : {
|
||||
title: 'Build Settings'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="build-settings-window-overlay">
|
||||
<div className="build-settings-window">
|
||||
<div className="build-settings-window-header">
|
||||
<h2>{t.title}</h2>
|
||||
<button
|
||||
className="build-settings-window-close"
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="build-settings-window-content">
|
||||
<BuildSettingsPanel
|
||||
projectPath={projectPath}
|
||||
locale={locale}
|
||||
buildService={buildService}
|
||||
sceneManager={sceneManager}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BuildSettingsWindow;
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Plus,
|
||||
Download,
|
||||
@@ -68,6 +69,21 @@ interface ContentBrowserProps {
|
||||
revealPath?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据图标名获取 Lucide 图标组件
|
||||
*/
|
||||
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
|
||||
if (!iconName) return <File size={size} />;
|
||||
|
||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||
const IconComponent = icons[iconName];
|
||||
if (IconComponent) {
|
||||
return <IconComponent size={size} />;
|
||||
}
|
||||
|
||||
return <File size={size} />;
|
||||
}
|
||||
|
||||
// 获取资产类型显示名称
|
||||
function getAssetTypeName(asset: AssetItem): string {
|
||||
if (asset.type === 'folder') return 'Folder';
|
||||
@@ -156,7 +172,8 @@ export function ContentBrowser({
|
||||
dockInLayout: 'Dock in Layout',
|
||||
noProject: 'No project loaded',
|
||||
empty: 'This folder is empty',
|
||||
newFolder: 'New Folder'
|
||||
newFolder: 'New Folder',
|
||||
newPrefix: 'New'
|
||||
},
|
||||
zh: {
|
||||
favorites: '收藏夹',
|
||||
@@ -169,7 +186,8 @@ export function ContentBrowser({
|
||||
dockInLayout: '停靠到布局',
|
||||
noProject: '未加载项目',
|
||||
empty: '文件夹为空',
|
||||
newFolder: '新建文件夹'
|
||||
newFolder: '新建文件夹',
|
||||
newPrefix: '新建'
|
||||
}
|
||||
}[locale] || {
|
||||
favorites: 'Favorites',
|
||||
@@ -182,7 +200,24 @@ export function ContentBrowser({
|
||||
dockInLayout: 'Dock in Layout',
|
||||
noProject: 'No project loaded',
|
||||
empty: 'This folder is empty',
|
||||
newFolder: 'New Folder'
|
||||
newFolder: 'New Folder',
|
||||
newPrefix: 'New'
|
||||
};
|
||||
|
||||
// 文件创建模板的 label 本地化映射
|
||||
const templateLabels: Record<string, { en: string; zh: string }> = {
|
||||
'Material': { en: 'Material', zh: '材质' },
|
||||
'Shader': { en: 'Shader', zh: '着色器' },
|
||||
'Tilemap': { en: 'Tilemap', zh: '瓦片地图' },
|
||||
'Tileset': { en: 'Tileset', zh: '瓦片集' },
|
||||
};
|
||||
|
||||
const getTemplateLabel = (label: string): string => {
|
||||
const mapping = templateLabels[label];
|
||||
if (mapping) {
|
||||
return locale === 'zh' ? mapping.zh : mapping.en;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
// Build folder tree - use ref to avoid dependency cycle
|
||||
@@ -546,8 +581,10 @@ export function ContentBrowser({
|
||||
if (templates.length > 0) {
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
for (const template of templates) {
|
||||
const localizedLabel = getTemplateLabel(template.label);
|
||||
items.push({
|
||||
label: `New ${template.label}`,
|
||||
label: `${t.newPrefix} ${localizedLabel}`,
|
||||
icon: getIconComponent(template.icon, 16),
|
||||
onClick: () => {
|
||||
setContextMenu(null);
|
||||
if (currentPath) {
|
||||
|
||||
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* EditorViewport Component
|
||||
* 编辑器视口组件
|
||||
*
|
||||
* A reusable viewport component for editor panels that need engine rendering.
|
||||
* Supports camera controls, overlays, and preview scenes.
|
||||
*
|
||||
* 用于需要引擎渲染的编辑器面板的可重用视口组件。
|
||||
* 支持相机控制、覆盖层和预览场景。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { ViewportCameraConfig, IViewportOverlay } from '@esengine/editor-core';
|
||||
import { ViewportService } from '../services/ViewportService';
|
||||
import '../styles/EditorViewport.css';
|
||||
|
||||
/**
|
||||
* EditorViewport configuration
|
||||
* 编辑器视口配置
|
||||
*/
|
||||
export interface EditorViewportConfig {
|
||||
/** Unique viewport identifier | 唯一视口标识符 */
|
||||
viewportId: string;
|
||||
/** Initial camera config | 初始相机配置 */
|
||||
initialCamera?: ViewportCameraConfig;
|
||||
/** Whether to show grid | 是否显示网格 */
|
||||
showGrid?: boolean;
|
||||
/** Whether to show gizmos | 是否显示辅助线 */
|
||||
showGizmos?: boolean;
|
||||
/** Background clear color | 背景清除颜色 */
|
||||
clearColor?: { r: number; g: number; b: number; a: number };
|
||||
/** Min zoom level | 最小缩放级别 */
|
||||
minZoom?: number;
|
||||
/** Max zoom level | 最大缩放级别 */
|
||||
maxZoom?: number;
|
||||
/** Enable camera pan | 启用相机平移 */
|
||||
enablePan?: boolean;
|
||||
/** Enable camera zoom | 启用相机缩放 */
|
||||
enableZoom?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport props
|
||||
* 编辑器视口属性
|
||||
*/
|
||||
export interface EditorViewportProps extends EditorViewportConfig {
|
||||
/** Class name for styling | 样式类名 */
|
||||
className?: string;
|
||||
/** Called when camera changes | 相机变化时的回调 */
|
||||
onCameraChange?: (camera: ViewportCameraConfig) => void;
|
||||
/** Called when viewport is ready | 视口准备就绪时的回调 */
|
||||
onReady?: () => void;
|
||||
/** Called on mouse down | 鼠标按下时的回调 */
|
||||
onMouseDown?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse move | 鼠标移动时的回调 */
|
||||
onMouseMove?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse up | 鼠标抬起时的回调 */
|
||||
onMouseUp?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse wheel | 鼠标滚轮时的回调 */
|
||||
onWheel?: (e: React.WheelEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Render custom overlays | 渲染自定义覆盖层 */
|
||||
renderOverlays?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport handle for imperative access
|
||||
* 编辑器视口句柄,用于命令式访问
|
||||
*/
|
||||
export interface EditorViewportHandle {
|
||||
/** Get current camera | 获取当前相机 */
|
||||
getCamera(): ViewportCameraConfig;
|
||||
/** Set camera | 设置相机 */
|
||||
setCamera(camera: ViewportCameraConfig): void;
|
||||
/** Reset camera to initial state | 重置相机到初始状态 */
|
||||
resetCamera(): void;
|
||||
/** Convert screen coordinates to world coordinates | 将屏幕坐标转换为世界坐标 */
|
||||
screenToWorld(screenX: number, screenY: number): { x: number; y: number };
|
||||
/** Convert world coordinates to screen coordinates | 将世界坐标转换为屏幕坐标 */
|
||||
worldToScreen(worldX: number, worldY: number): { x: number; y: number };
|
||||
/** Get canvas element | 获取画布元素 */
|
||||
getCanvas(): HTMLCanvasElement | null;
|
||||
/** Request render | 请求渲染 */
|
||||
requestRender(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport Component
|
||||
* 编辑器视口组件
|
||||
*/
|
||||
export const EditorViewport = forwardRef<EditorViewportHandle, EditorViewportProps>(function EditorViewport(
|
||||
{
|
||||
viewportId,
|
||||
initialCamera = { x: 0, y: 0, zoom: 1 },
|
||||
showGrid = true,
|
||||
showGizmos = false,
|
||||
clearColor,
|
||||
minZoom = 0.1,
|
||||
maxZoom = 10,
|
||||
enablePan = true,
|
||||
enableZoom = true,
|
||||
className,
|
||||
onCameraChange,
|
||||
onReady,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onWheel,
|
||||
renderOverlays
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Camera state
|
||||
const [camera, setCamera] = useState<ViewportCameraConfig>(initialCamera);
|
||||
const cameraRef = useRef(camera);
|
||||
|
||||
// Drag state
|
||||
const isDraggingRef = useRef(false);
|
||||
const lastMousePosRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Keep camera ref in sync
|
||||
useEffect(() => {
|
||||
cameraRef.current = camera;
|
||||
}, [camera]);
|
||||
|
||||
// Screen to world conversion
|
||||
const screenToWorld = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Convert to canvas pixel coordinates
|
||||
const canvasX = (screenX - rect.left) * dpr;
|
||||
const canvasY = (screenY - rect.top) * dpr;
|
||||
|
||||
// Convert to centered coordinates (Y-up)
|
||||
const centeredX = canvasX - canvas.width / 2;
|
||||
const centeredY = canvas.height / 2 - canvasY;
|
||||
|
||||
// Apply inverse zoom and add camera position
|
||||
const cam = cameraRef.current;
|
||||
const worldX = centeredX / cam.zoom + cam.x;
|
||||
const worldY = centeredY / cam.zoom + cam.y;
|
||||
|
||||
return { x: worldX, y: worldY };
|
||||
}, []);
|
||||
|
||||
// World to screen conversion
|
||||
const worldToScreen = useCallback((worldX: number, worldY: number): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cam = cameraRef.current;
|
||||
|
||||
// Apply camera transform
|
||||
const centeredX = (worldX - cam.x) * cam.zoom;
|
||||
const centeredY = (worldY - cam.y) * cam.zoom;
|
||||
|
||||
// Convert from centered coordinates
|
||||
const canvasX = centeredX + canvas.width / 2;
|
||||
const canvasY = canvas.height / 2 - centeredY;
|
||||
|
||||
// Convert to screen coordinates
|
||||
const screenX = canvasX / dpr + rect.left;
|
||||
const screenY = canvasY / dpr + rect.top;
|
||||
|
||||
return { x: screenX, y: screenY };
|
||||
}, []);
|
||||
|
||||
// Request render
|
||||
const requestRender = useCallback(() => {
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.renderToViewport(viewportId);
|
||||
}
|
||||
}, [viewportId]);
|
||||
|
||||
// Expose imperative handle
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCamera: () => cameraRef.current,
|
||||
setCamera: (newCamera: ViewportCameraConfig) => {
|
||||
setCamera(newCamera);
|
||||
onCameraChange?.(newCamera);
|
||||
},
|
||||
resetCamera: () => {
|
||||
setCamera(initialCamera);
|
||||
onCameraChange?.(initialCamera);
|
||||
},
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
getCanvas: () => canvasRef.current,
|
||||
requestRender
|
||||
}), [initialCamera, screenToWorld, worldToScreen, onCameraChange, requestRender]);
|
||||
|
||||
// Initialize viewport
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const canvasId = `editor-viewport-canvas-${viewportId}`;
|
||||
canvas.id = canvasId;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
|
||||
// Wait for service to be initialized
|
||||
const checkInit = () => {
|
||||
if (viewportService.isInitialized()) {
|
||||
// Register viewport
|
||||
viewportService.registerViewport(viewportId, canvasId);
|
||||
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
viewportService.setViewportCamera(viewportId, camera);
|
||||
|
||||
setIsReady(true);
|
||||
onReady?.();
|
||||
} else {
|
||||
// Retry after a short delay
|
||||
setTimeout(checkInit, 100);
|
||||
}
|
||||
};
|
||||
|
||||
checkInit();
|
||||
|
||||
return () => {
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.unregisterViewport(viewportId);
|
||||
}
|
||||
};
|
||||
}, [viewportId]);
|
||||
|
||||
// Update viewport config when props change
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
}
|
||||
}, [viewportId, showGrid, showGizmos, isReady]);
|
||||
|
||||
// Sync camera to viewport service
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.setViewportCamera(viewportId, camera);
|
||||
}
|
||||
}, [viewportId, camera, isReady]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
|
||||
if (isReady) {
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.resizeViewport(viewportId, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
|
||||
let rafId: number | null = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
resizeCanvas();
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [viewportId, isReady]);
|
||||
|
||||
// Mouse handlers
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
// Middle or right button for camera pan
|
||||
if (enablePan && (e.button === 1 || e.button === 2)) {
|
||||
isDraggingRef.current = true;
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
onMouseDown?.(e, worldPos);
|
||||
}, [enablePan, screenToWorld, onMouseDown]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
if (isDraggingRef.current && enablePan) {
|
||||
const deltaX = e.clientX - lastMousePosRef.current.x;
|
||||
const deltaY = e.clientY - lastMousePosRef.current.y;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
setCamera(prev => {
|
||||
const newCamera = {
|
||||
...prev,
|
||||
x: prev.x - (deltaX * dpr) / prev.zoom,
|
||||
y: prev.y + (deltaY * dpr) / prev.zoom
|
||||
};
|
||||
onCameraChange?.(newCamera);
|
||||
return newCamera;
|
||||
});
|
||||
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
onMouseMove?.(e, worldPos);
|
||||
}, [enablePan, screenToWorld, onMouseMove, onCameraChange]);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
isDraggingRef.current = false;
|
||||
onMouseUp?.(e, worldPos);
|
||||
}, [screenToWorld, onMouseUp]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
if (enableZoom) {
|
||||
e.preventDefault();
|
||||
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
|
||||
setCamera(prev => {
|
||||
const newZoom = Math.max(minZoom, Math.min(maxZoom, prev.zoom * zoomFactor));
|
||||
const newCamera = { ...prev, zoom: newZoom };
|
||||
onCameraChange?.(newCamera);
|
||||
return newCamera;
|
||||
});
|
||||
}
|
||||
|
||||
onWheel?.(e, worldPos);
|
||||
}, [enableZoom, minZoom, maxZoom, screenToWorld, onWheel, onCameraChange]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`editor-viewport ${className || ''}`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="editor-viewport-canvas"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
{renderOverlays?.()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditorViewport;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ interface MenuBarProps {
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
}
|
||||
|
||||
export function MenuBar({
|
||||
@@ -55,7 +56,8 @@ export function MenuBar({
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings
|
||||
}: MenuBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
@@ -129,7 +131,8 @@ export function MenuBar({
|
||||
documentation: 'Documentation',
|
||||
checkForUpdates: 'Check for Updates',
|
||||
about: 'About',
|
||||
devtools: 'Developer Tools'
|
||||
devtools: 'Developer Tools',
|
||||
buildSettings: 'Build Settings'
|
||||
},
|
||||
zh: {
|
||||
file: '文件',
|
||||
@@ -164,7 +167,8 @@ export function MenuBar({
|
||||
documentation: '文档',
|
||||
checkForUpdates: '检查更新',
|
||||
about: '关于',
|
||||
devtools: '开发者工具'
|
||||
devtools: '开发者工具',
|
||||
buildSettings: '构建设置'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
@@ -178,6 +182,8 @@ export function MenuBar({
|
||||
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||
{ separator: true },
|
||||
{ label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||
{ separator: true },
|
||||
{ label: t('openProject'), onClick: onOpenProject },
|
||||
{ label: t('closeProject'), onClick: onCloseProject },
|
||||
{ separator: true },
|
||||
|
||||
305
packages/editor-app/src/components/ModuleListSetting.tsx
Normal file
305
packages/editor-app/src/components/ModuleListSetting.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Module List Setting Component.
|
||||
* 模块列表设置组件。
|
||||
*
|
||||
* Renders a list of engine modules with checkboxes to enable/disable.
|
||||
* 渲染引擎模块列表,带复选框以启用/禁用。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, Package, AlertCircle } from 'lucide-react';
|
||||
import type { ModuleManifest, ModuleCategory } from '@esengine/editor-core';
|
||||
import './styles/ModuleListSetting.css';
|
||||
|
||||
/**
|
||||
* Module entry with enabled state.
|
||||
* 带启用状态的模块条目。
|
||||
*/
|
||||
interface ModuleEntry extends ModuleManifest {
|
||||
enabled: boolean;
|
||||
canDisable: boolean;
|
||||
disableReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for ModuleListSetting.
|
||||
*/
|
||||
interface ModuleListSettingProps {
|
||||
/** Module manifests (static) | 模块清单列表(静态) */
|
||||
modules?: ModuleManifest[];
|
||||
/** Function to get modules dynamically (sizes from module.json) | 动态获取模块的函数(大小来自 module.json) */
|
||||
getModules?: () => ModuleManifest[];
|
||||
/**
|
||||
* Module IDs list. Meaning depends on useBlacklist.
|
||||
* 模块 ID 列表。含义取决于 useBlacklist。
|
||||
* - useBlacklist=false: enabled modules (whitelist)
|
||||
* - useBlacklist=true: disabled modules (blacklist)
|
||||
*/
|
||||
value: string[];
|
||||
/** Callback when modules change | 模块变更回调 */
|
||||
onModulesChange: (moduleIds: string[]) => void;
|
||||
/**
|
||||
* Use blacklist mode: value contains disabled modules instead of enabled.
|
||||
* 使用黑名单模式:value 包含禁用的模块而不是启用的。
|
||||
* Default: false (whitelist mode)
|
||||
*/
|
||||
useBlacklist?: boolean;
|
||||
/** Validate if module can be disabled | 验证模块是否可禁用 */
|
||||
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string.
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module List Setting Component.
|
||||
* 模块列表设置组件。
|
||||
*/
|
||||
export const ModuleListSetting: React.FC<ModuleListSettingProps> = ({
|
||||
modules: staticModules,
|
||||
getModules,
|
||||
value,
|
||||
onModulesChange,
|
||||
useBlacklist = false,
|
||||
validateDisable
|
||||
}) => {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Core', 'Rendering']));
|
||||
const [validationError, setValidationError] = useState<{ moduleId: string; message: string } | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
// Get modules from function or static prop
|
||||
// 从函数或静态 prop 获取模块
|
||||
const modules = useMemo(() => {
|
||||
if (getModules) {
|
||||
return getModules();
|
||||
}
|
||||
return staticModules || [];
|
||||
}, [getModules, staticModules]);
|
||||
|
||||
// Build module entries with enabled state | 构建带启用状态的模块条目
|
||||
// In blacklist mode: enabled = NOT in value list
|
||||
// In whitelist mode: enabled = IN value list
|
||||
const moduleEntries: ModuleEntry[] = useMemo(() => {
|
||||
return modules.map(mod => {
|
||||
let enabled: boolean;
|
||||
if (mod.isCore) {
|
||||
enabled = true; // Core modules always enabled
|
||||
} else if (useBlacklist) {
|
||||
enabled = !value.includes(mod.id); // Blacklist: NOT in list = enabled
|
||||
} else {
|
||||
enabled = value.includes(mod.id); // Whitelist: IN list = enabled
|
||||
}
|
||||
return {
|
||||
...mod,
|
||||
enabled,
|
||||
canDisable: !mod.isCore,
|
||||
disableReason: mod.isCore ? 'Core module cannot be disabled' : undefined
|
||||
};
|
||||
});
|
||||
}, [modules, value, useBlacklist]);
|
||||
|
||||
// Group by category | 按分类分组
|
||||
const groupedModules = useMemo(() => {
|
||||
const groups = new Map<string, ModuleEntry[]>();
|
||||
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
|
||||
|
||||
// Initialize groups | 初始化分组
|
||||
for (const cat of categoryOrder) {
|
||||
groups.set(cat, []);
|
||||
}
|
||||
|
||||
// Group modules | 分组模块
|
||||
for (const mod of moduleEntries) {
|
||||
const cat = mod.category || 'Other';
|
||||
if (!groups.has(cat)) {
|
||||
groups.set(cat, []);
|
||||
}
|
||||
groups.get(cat)!.push(mod);
|
||||
}
|
||||
|
||||
// Filter empty groups | 过滤空分组
|
||||
const result = new Map<string, ModuleEntry[]>();
|
||||
for (const [cat, mods] of groups) {
|
||||
if (mods.length > 0) {
|
||||
result.set(cat, mods);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [moduleEntries]);
|
||||
|
||||
// Calculate total size (JS + WASM) | 计算总大小(JS + WASM)
|
||||
const { totalJsSize, totalWasmSize, totalSize } = useMemo(() => {
|
||||
let js = 0;
|
||||
let wasm = 0;
|
||||
for (const m of moduleEntries) {
|
||||
if (m.enabled) {
|
||||
js += m.jsSize || 0;
|
||||
wasm += m.wasmSize || 0;
|
||||
}
|
||||
}
|
||||
return { totalJsSize: js, totalWasmSize: wasm, totalSize: js + wasm };
|
||||
}, [moduleEntries]);
|
||||
|
||||
// Toggle category expansion | 切换分类展开
|
||||
const toggleCategory = useCallback((category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle module toggle | 处理模块切换
|
||||
const handleModuleToggle = useCallback(async (module: ModuleEntry, enabled: boolean) => {
|
||||
if (module.isCore) return;
|
||||
|
||||
// If disabling, validate first | 如果禁用,先验证
|
||||
if (!enabled && validateDisable) {
|
||||
setLoading(module.id);
|
||||
try {
|
||||
const result = await validateDisable(module.id);
|
||||
if (!result.canDisable) {
|
||||
setValidationError({
|
||||
moduleId: module.id,
|
||||
message: result.reason || `Cannot disable ${module.displayName}`
|
||||
});
|
||||
setLoading(null);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Update module list based on mode
|
||||
let newValue: string[];
|
||||
|
||||
if (useBlacklist) {
|
||||
// Blacklist mode: value contains disabled modules
|
||||
if (enabled) {
|
||||
// Remove from blacklist (and also remove dependencies)
|
||||
const toRemove = new Set([module.id]);
|
||||
// Also enable dependencies if they were disabled
|
||||
for (const depId of module.dependencies) {
|
||||
toRemove.add(depId);
|
||||
}
|
||||
newValue = value.filter(id => !toRemove.has(id));
|
||||
} else {
|
||||
// Add to blacklist
|
||||
newValue = [...value, module.id];
|
||||
}
|
||||
} else {
|
||||
// Whitelist mode: value contains enabled modules
|
||||
if (enabled) {
|
||||
// Add to whitelist (and dependencies)
|
||||
newValue = [...value];
|
||||
const toEnable = [module.id, ...module.dependencies];
|
||||
for (const id of toEnable) {
|
||||
if (!newValue.includes(id)) {
|
||||
newValue.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove from whitelist
|
||||
newValue = value.filter(id => id !== module.id);
|
||||
}
|
||||
}
|
||||
|
||||
onModulesChange(newValue);
|
||||
}, [value, useBlacklist, onModulesChange, validateDisable]);
|
||||
|
||||
return (
|
||||
<div className="module-list-setting">
|
||||
{/* Module categories | 模块分类 */}
|
||||
<div className="module-list-categories">
|
||||
{Array.from(groupedModules.entries()).map(([category, mods]) => (
|
||||
<div key={category} className="module-category-group">
|
||||
<div
|
||||
className="module-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{expandedCategories.has(category) ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
<span className="module-category-name">{category}</span>
|
||||
<span className="module-category-count">
|
||||
{mods.filter(m => m.enabled).length}/{mods.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedCategories.has(category) && (
|
||||
<div className="module-category-items">
|
||||
{mods.map(mod => (
|
||||
<div
|
||||
key={mod.id}
|
||||
className={`module-item ${mod.enabled ? 'enabled' : ''} ${loading === mod.id ? 'loading' : ''}`}
|
||||
>
|
||||
<label className="module-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mod.enabled}
|
||||
disabled={mod.isCore || loading === mod.id}
|
||||
onChange={(e) => handleModuleToggle(mod, e.target.checked)}
|
||||
/>
|
||||
<Package size={14} className="module-icon" />
|
||||
<span className="module-name">{mod.displayName}</span>
|
||||
{mod.isCore && (
|
||||
<span className="module-badge core">Core</span>
|
||||
)}
|
||||
</label>
|
||||
{(mod.jsSize || mod.wasmSize) ? (
|
||||
<span className="module-size">
|
||||
{mod.isCore ? '' : '+'}
|
||||
{formatBytes((mod.jsSize || 0) + (mod.wasmSize || 0))}
|
||||
{(mod.wasmSize ?? 0) > 0 && (
|
||||
<span className="module-wasm-indicator" title="Includes WASM">⚡</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Size footer | 大小页脚 */}
|
||||
<div className="module-list-footer">
|
||||
<span className="module-list-size-label">Runtime size:</span>
|
||||
<span className="module-list-size-value">
|
||||
{formatBytes(totalSize)}
|
||||
{totalWasmSize > 0 && (
|
||||
<span className="module-size-breakdown">
|
||||
(JS: {formatBytes(totalJsSize)} + WASM: {formatBytes(totalWasmSize)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Validation error toast | 验证错误提示 */}
|
||||
{validationError && (
|
||||
<div className="module-validation-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{validationError.message}</span>
|
||||
<button onClick={() => setValidationError(null)}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleListSetting;
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { PluginManager, type RegisteredPlugin, type PluginCategory, ProjectService } from '@esengine/editor-core';
|
||||
import { PluginManager, type RegisteredPlugin, type ModuleCategory, ProjectService } from '@esengine/editor-core';
|
||||
import { Check, Lock, Package } from 'lucide-react';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import '../styles/PluginListSetting.css';
|
||||
@@ -20,21 +20,17 @@ interface PluginListSettingProps {
|
||||
pluginManager: PluginManager;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
|
||||
core: { zh: '核心', en: 'Core' },
|
||||
rendering: { zh: '渲染', en: 'Rendering' },
|
||||
ui: { zh: 'UI', en: 'UI' },
|
||||
ai: { zh: 'AI', en: 'AI' },
|
||||
physics: { zh: '物理', en: 'Physics' },
|
||||
audio: { zh: '音频', en: 'Audio' },
|
||||
networking: { zh: '网络', en: 'Networking' },
|
||||
tools: { zh: '工具', en: 'Tools' },
|
||||
scripting: { zh: '脚本', en: 'Scripting' },
|
||||
content: { zh: '内容', en: 'Content' },
|
||||
tilemap: { zh: '瓦片地图', en: 'Tilemap' }
|
||||
const categoryLabels: Record<ModuleCategory, { zh: string; en: string }> = {
|
||||
Core: { zh: '核心', en: 'Core' },
|
||||
Rendering: { zh: '渲染', en: 'Rendering' },
|
||||
Physics: { zh: '物理', en: 'Physics' },
|
||||
AI: { zh: 'AI', en: 'AI' },
|
||||
Audio: { zh: '音频', en: 'Audio' },
|
||||
Networking: { zh: '网络', en: 'Networking' },
|
||||
Other: { zh: '其他', en: 'Other' }
|
||||
};
|
||||
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tilemap', 'tools', 'content'];
|
||||
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
|
||||
|
||||
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
@@ -56,13 +52,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
};
|
||||
|
||||
const handleToggle = async (pluginId: string) => {
|
||||
const plugin = plugins.find(p => p.plugin.descriptor.id === pluginId);
|
||||
const plugin = plugins.find(p => p.plugin.manifest.id === pluginId);
|
||||
if (!plugin) return;
|
||||
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
const manifest = plugin.plugin.manifest;
|
||||
|
||||
// 核心插件不可禁用
|
||||
if (descriptor.isCore) {
|
||||
if (manifest.isCore) {
|
||||
showWarning('核心插件不可禁用');
|
||||
return;
|
||||
}
|
||||
@@ -71,14 +67,14 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 检查依赖(启用时)
|
||||
if (newEnabled) {
|
||||
const deps = descriptor.dependencies || [];
|
||||
const missingDeps = deps.filter(dep => {
|
||||
const depPlugin = plugins.find(p => p.plugin.descriptor.id === dep.id);
|
||||
const deps = manifest.dependencies || [];
|
||||
const missingDeps = deps.filter((depId: string) => {
|
||||
const depPlugin = plugins.find(p => p.plugin.manifest.id === depId);
|
||||
return depPlugin && !depPlugin.enabled;
|
||||
});
|
||||
|
||||
if (missingDeps.length > 0) {
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`);
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -100,7 +96,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 更新本地状态
|
||||
setPlugins(plugins.map(p => {
|
||||
if (p.plugin.descriptor.id === pluginId) {
|
||||
if (p.plugin.manifest.id === pluginId) {
|
||||
return { ...p, enabled: newEnabled };
|
||||
}
|
||||
return p;
|
||||
@@ -115,7 +111,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.show(
|
||||
newEnabled ? `已启用插件: ${descriptor.name}` : `已禁用插件: ${descriptor.name}`,
|
||||
newEnabled ? `已启用插件: ${manifest.displayName}` : `已禁用插件: ${manifest.displayName}`,
|
||||
'success',
|
||||
2000
|
||||
);
|
||||
@@ -135,8 +131,8 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 获取当前启用的插件列表(排除核心插件)
|
||||
const enabledPlugins = pluginManager.getEnabledPlugins()
|
||||
.filter(p => !p.plugin.descriptor.isCore)
|
||||
.map(p => p.plugin.descriptor.id);
|
||||
.filter(p => !p.plugin.manifest.isCore)
|
||||
.map(p => p.plugin.manifest.id);
|
||||
|
||||
console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins);
|
||||
|
||||
@@ -150,13 +146,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 按类别分组并排序
|
||||
const groupedPlugins = plugins.reduce((acc, plugin) => {
|
||||
const category = plugin.plugin.descriptor.category;
|
||||
const category = plugin.plugin.manifest.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(plugin);
|
||||
return acc;
|
||||
}, {} as Record<PluginCategory, RegisteredPlugin[]>);
|
||||
}, {} as Record<ModuleCategory, RegisteredPlugin[]>);
|
||||
|
||||
// 按照 categoryOrder 排序
|
||||
const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0);
|
||||
@@ -169,19 +165,19 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
{categoryLabels[category]?.zh || category}
|
||||
</div>
|
||||
<div className="plugin-list">
|
||||
{groupedPlugins[category].map(plugin => {
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
{groupedPlugins[category]?.map(plugin => {
|
||||
const manifest = plugin.plugin.manifest;
|
||||
const hasRuntime = !!plugin.plugin.runtimeModule;
|
||||
const hasEditor = !!plugin.plugin.editorModule;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={descriptor.id}
|
||||
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${descriptor.isCore ? 'core' : ''}`}
|
||||
onClick={() => handleToggle(descriptor.id)}
|
||||
key={manifest.id}
|
||||
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${manifest.isCore ? 'core' : ''}`}
|
||||
onClick={() => handleToggle(manifest.id)}
|
||||
>
|
||||
<div className="plugin-checkbox">
|
||||
{descriptor.isCore ? (
|
||||
{manifest.isCore ? (
|
||||
<Lock size={10} />
|
||||
) : (
|
||||
plugin.enabled && <Check size={10} />
|
||||
@@ -189,12 +185,12 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
</div>
|
||||
<div className="plugin-info">
|
||||
<div className="plugin-header">
|
||||
<span className="plugin-name">{descriptor.name}</span>
|
||||
<span className="plugin-version">v{descriptor.version}</span>
|
||||
<span className="plugin-name">{manifest.displayName}</span>
|
||||
<span className="plugin-version">v{manifest.version}</span>
|
||||
</div>
|
||||
{descriptor.description && (
|
||||
{manifest.description && (
|
||||
<div className="plugin-description">
|
||||
{descriptor.description}
|
||||
{manifest.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="plugin-modules">
|
||||
|
||||
@@ -928,11 +928,27 @@ function ContextMenuWithSubmenu({
|
||||
'other': { zh: '其他', en: 'Other' },
|
||||
};
|
||||
|
||||
// 实体创建模板的 label 本地化映射
|
||||
const entityTemplateLabels: Record<string, { zh: string; en: string }> = {
|
||||
'Sprite': { zh: '精灵', en: 'Sprite' },
|
||||
'Animated Sprite': { zh: '动画精灵', en: 'Animated Sprite' },
|
||||
'创建 Tilemap': { zh: '瓦片地图', en: 'Tilemap' },
|
||||
'Camera 2D': { zh: '2D 相机', en: 'Camera 2D' },
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels = categoryLabels[category];
|
||||
return labels ? (locale === 'zh' ? labels.zh : labels.en) : category;
|
||||
};
|
||||
|
||||
const getEntityTemplateLabel = (label: string) => {
|
||||
const mapping = entityTemplateLabels[label];
|
||||
if (mapping) {
|
||||
return locale === 'zh' ? mapping.zh : mapping.en;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const templatesByCategory = pluginTemplates.reduce((acc, template) => {
|
||||
const cat = template.category || 'other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
@@ -996,7 +1012,7 @@ function ContextMenuWithSubmenu({
|
||||
{templates.map((template) => (
|
||||
<button key={template.id} onClick={() => onCreateFromTemplate(template)}>
|
||||
{getIconComponent(template.icon as string, 12)}
|
||||
<span>{template.label}</span>
|
||||
<span>{getEntityTemplateLabel(template.label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager, ModuleManifest } from '@esengine/editor-core';
|
||||
import { PluginListSetting } from './PluginListSetting';
|
||||
import { ModuleListSetting } from './ModuleListSetting';
|
||||
import '../styles/SettingsWindow.css';
|
||||
|
||||
interface SettingsWindowProps {
|
||||
@@ -142,6 +143,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
} else if (key === 'project.uiDesignResolution.preset') {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
||||
} else if (key === 'project.disabledModules') {
|
||||
// Load disabled modules from ProjectService
|
||||
initialValues.set(key, projectService.getDisabledModules());
|
||||
} else {
|
||||
initialValues.set(key, descriptor.defaultValue);
|
||||
}
|
||||
@@ -199,6 +203,8 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
let uiResolutionChanged = false;
|
||||
let newWidth = 1920;
|
||||
let newHeight = 1080;
|
||||
let disabledModulesChanged = false;
|
||||
let newDisabledModules: string[] = [];
|
||||
|
||||
for (const [key, value] of values.entries()) {
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
@@ -215,6 +221,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
newHeight = h;
|
||||
uiResolutionChanged = true;
|
||||
}
|
||||
} else if (key === 'project.disabledModules') {
|
||||
newDisabledModules = value as string[];
|
||||
disabledModulesChanged = true;
|
||||
}
|
||||
changedSettings[key] = value;
|
||||
} else {
|
||||
@@ -227,6 +236,10 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
}
|
||||
|
||||
if (disabledModulesChanged && projectService) {
|
||||
await projectService.setDisabledModules(newDisabledModules);
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: changedSettings
|
||||
@@ -487,6 +500,31 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
);
|
||||
}
|
||||
|
||||
case 'moduleList': {
|
||||
// Get module data from setting's custom props
|
||||
// 从设置的自定义属性获取模块数据
|
||||
const moduleData = setting as SettingDescriptor & {
|
||||
modules?: ModuleManifest[];
|
||||
getModules?: () => ModuleManifest[];
|
||||
useBlacklist?: boolean;
|
||||
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
|
||||
};
|
||||
const moduleValue = value as string[] || [];
|
||||
|
||||
return (
|
||||
<div className="settings-module-list">
|
||||
<ModuleListSetting
|
||||
modules={moduleData.modules}
|
||||
getModules={moduleData.getModules}
|
||||
value={moduleValue}
|
||||
onModulesChange={(newValue) => handleValueChange(setting.key, newValue, setting)}
|
||||
useBlacklist={moduleData.useBlacklist}
|
||||
validateDisable={moduleData.validateDisable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ interface TitleBarProps {
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
}
|
||||
|
||||
export function TitleBar({
|
||||
@@ -58,7 +59,8 @@ export function TitleBar({
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings
|
||||
}: TitleBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
@@ -152,7 +154,8 @@ export function TitleBar({
|
||||
documentation: 'Documentation',
|
||||
checkForUpdates: 'Check for Updates',
|
||||
about: 'About',
|
||||
devtools: 'Developer Tools'
|
||||
devtools: 'Developer Tools',
|
||||
buildSettings: 'Build Settings'
|
||||
},
|
||||
zh: {
|
||||
file: '文件',
|
||||
@@ -187,7 +190,8 @@ export function TitleBar({
|
||||
documentation: '文档',
|
||||
checkForUpdates: '检查更新',
|
||||
about: '关于',
|
||||
devtools: '开发者工具'
|
||||
devtools: '开发者工具',
|
||||
buildSettings: '构建设置'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
@@ -201,6 +205,8 @@ export function TitleBar({
|
||||
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||
{ separator: true },
|
||||
{ label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||
{ separator: true },
|
||||
{ label: t('openProject'), onClick: onOpenProject },
|
||||
{ label: t('closeProject'), onClick: onCloseProject },
|
||||
{ separator: true },
|
||||
|
||||
@@ -17,69 +17,139 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||
import { QRCodeDialog } from './QRCodeDialog';
|
||||
|
||||
// Generate runtime HTML for browser preview
|
||||
function generateRuntimeHtml(): string {
|
||||
import type { ModuleManifest } from '../services/RuntimeResolver';
|
||||
|
||||
/**
|
||||
* Generate runtime HTML for browser preview using ES Modules with import maps
|
||||
* 使用 ES 模块和 import maps 生成浏览器预览的运行时 HTML
|
||||
*
|
||||
* This matches the structure of published builds for consistency
|
||||
* 这与发布构建的结构一致
|
||||
*/
|
||||
function generateRuntimeHtml(importMap: Record<string, string>, modules: ModuleManifest[]): string {
|
||||
const importMapScript = `<script type="importmap">
|
||||
${JSON.stringify({ imports: importMap }, null, 2).split('\n').join('\n ')}
|
||||
</script>`;
|
||||
|
||||
// Generate plugin import code for modules with pluginExport
|
||||
// Only modules with pluginExport are considered runtime plugins
|
||||
// Core/infrastructure modules don't need to be registered as plugins
|
||||
const pluginModules = modules.filter(m => m.pluginExport);
|
||||
|
||||
const pluginImportCode = pluginModules.map(m =>
|
||||
` try {
|
||||
const { ${m.pluginExport} } = await import('@esengine/${m.id}');
|
||||
runtime.registerPlugin(${m.pluginExport});
|
||||
} catch (e) {
|
||||
console.warn('[Preview] Failed to load plugin ${m.id}:', e.message);
|
||||
}`
|
||||
).join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>ECS Runtime Preview</title>
|
||||
${importMapScript}
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
background: #1e1e1e;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||
#game-canvas { width: 100%; height: 100%; display: block; }
|
||||
#loading {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
background: #1a1a2e; color: #eee; font-family: sans-serif;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
#loading .spinner {
|
||||
width: 40px; height: 40px; border: 3px solid #333;
|
||||
border-top-color: #4a9eff; border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
#loading .message { margin-top: 16px; font-size: 14px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: none; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
background: #1a1a2e; color: #ff6b6b; font-family: sans-serif;
|
||||
padding: 20px; text-align: center;
|
||||
}
|
||||
#error.show { display: flex; }
|
||||
#error h2 { margin-bottom: 16px; }
|
||||
#error pre {
|
||||
background: rgba(0,0,0,0.3); padding: 16px; border-radius: 8px;
|
||||
max-width: 600px; white-space: pre-wrap; word-break: break-word;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="runtime-canvas"></canvas>
|
||||
<script src="/runtime.browser.js"></script>
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div class="message" id="loading-message">Loading...</div>
|
||||
</div>
|
||||
<div id="error">
|
||||
<h2 id="error-title">Failed to start</h2>
|
||||
<pre id="error-message"></pre>
|
||||
</div>
|
||||
<canvas id="game-canvas"></canvas>
|
||||
|
||||
<script type="module">
|
||||
import * as esEngine from '/es_engine.js';
|
||||
(async function() {
|
||||
try {
|
||||
// Set canvas size before creating runtime
|
||||
const canvas = document.getElementById('runtime-canvas');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const loading = document.getElementById('loading');
|
||||
const loadingMessage = document.getElementById('loading-message');
|
||||
const errorDiv = document.getElementById('error');
|
||||
const errorTitle = document.getElementById('error-title');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
|
||||
const runtime = ECSRuntime.create({
|
||||
canvasId: 'runtime-canvas',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
projectConfigUrl: '/ecs-editor.config.json'
|
||||
});
|
||||
function showError(title, msg) {
|
||||
loading.style.display = 'none';
|
||||
errorTitle.textContent = title || 'Failed to start';
|
||||
errorMessage.textContent = msg;
|
||||
errorDiv.classList.add('show');
|
||||
console.error('[Preview]', msg);
|
||||
}
|
||||
|
||||
await runtime.initialize(esEngine);
|
||||
await runtime.loadScene('/scene.json?_=' + Date.now());
|
||||
runtime.start();
|
||||
function updateLoading(msg) {
|
||||
loadingMessage.textContent = msg;
|
||||
console.log('[Preview]', msg);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const canvas = document.getElementById('runtime-canvas');
|
||||
const newWidth = window.innerWidth;
|
||||
const newHeight = window.innerHeight;
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
runtime.handleResize(newWidth, newHeight);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Runtime error:', e);
|
||||
}
|
||||
})();
|
||||
try {
|
||||
updateLoading('Loading runtime...');
|
||||
const ECSRuntime = (await import('@esengine/platform-web')).default;
|
||||
|
||||
updateLoading('Loading WASM module...');
|
||||
const wasmModule = await import('./libs/es-engine/es_engine.js');
|
||||
|
||||
updateLoading('Initializing runtime...');
|
||||
const runtime = ECSRuntime.create({
|
||||
canvasId: 'game-canvas',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
assetBaseUrl: './assets',
|
||||
projectConfigUrl: './ecs-editor.config.json'
|
||||
});
|
||||
|
||||
updateLoading('Loading plugins...');
|
||||
${pluginImportCode}
|
||||
|
||||
await runtime.initialize(wasmModule);
|
||||
|
||||
updateLoading('Loading scene...');
|
||||
await runtime.loadScene('./scene.json?_=' + Date.now());
|
||||
|
||||
loading.style.display = 'none';
|
||||
runtime.start();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
runtime.handleResize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
console.log('[Preview] Started successfully');
|
||||
} catch (error) {
|
||||
showError(null, error.message || String(error));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -697,13 +767,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.createDirectory(runtimeDir);
|
||||
}
|
||||
|
||||
// Use RuntimeResolver to copy runtime files
|
||||
// 使用 RuntimeResolver 复制运行时文件
|
||||
// Use RuntimeResolver to copy runtime files with ES Modules structure
|
||||
// 使用 RuntimeResolver 复制运行时文件(ES 模块结构)
|
||||
const runtimeResolver = RuntimeResolver.getInstance();
|
||||
await runtimeResolver.initialize();
|
||||
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
const { modules, importMap } = await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
|
||||
// Write scene data and HTML (always update)
|
||||
// Write scene data
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneData);
|
||||
|
||||
// Copy project config file (for plugin settings)
|
||||
@@ -818,7 +888,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2));
|
||||
console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`);
|
||||
|
||||
const runtimeHtml = generateRuntimeHtml();
|
||||
// Generate HTML with import maps (matching published build structure)
|
||||
const runtimeHtml = generateRuntimeHtml(importMap, modules);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, runtimeHtml);
|
||||
|
||||
// Start local server and open browser
|
||||
@@ -865,10 +936,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.createDirectory(runtimeDir);
|
||||
}
|
||||
|
||||
// Use RuntimeResolver to copy runtime files
|
||||
// Use RuntimeResolver to copy runtime files with ES Modules structure
|
||||
const runtimeResolver = RuntimeResolver.getInstance();
|
||||
await runtimeResolver.initialize();
|
||||
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
const { modules, importMap } = await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
|
||||
// Copy project config file (for plugin settings)
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
@@ -883,10 +954,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Write scene data and HTML
|
||||
// Write scene data and HTML with import maps
|
||||
const sceneDataStr = typeof sceneData === 'string' ? sceneData : new TextDecoder().decode(sceneData);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneDataStr);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, generateRuntimeHtml());
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, generateRuntimeHtml(importMap, modules));
|
||||
|
||||
// Copy textures referenced in scene
|
||||
const assetsDir = `${runtimeDir}\\assets`;
|
||||
|
||||
@@ -99,7 +99,11 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
'conf',
|
||||
'log',
|
||||
'btree',
|
||||
'ecs'
|
||||
'ecs',
|
||||
'mat',
|
||||
'shader',
|
||||
'tilemap',
|
||||
'tileset'
|
||||
];
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'];
|
||||
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
|
||||
@@ -188,6 +192,12 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
}
|
||||
|
||||
if (target.type === 'asset-file') {
|
||||
// Check if a plugin provides a custom inspector for this asset type
|
||||
const customInspector = inspectorRegistry.render(target, { target, projectPath });
|
||||
if (customInspector) {
|
||||
return customInspector;
|
||||
}
|
||||
// Fall back to default asset file inspector
|
||||
return <AssetFileInspector fileInfo={target.data} content={target.content} isImage={target.isImage} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,16 +59,26 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||
|
||||
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
|
||||
// 注意:不要依赖 componentVersion,否则每次属性变化都会重置展开状态
|
||||
useEffect(() => {
|
||||
setExpandedComponents((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
// 添加所有当前组件的索引(保留已有的展开状态)
|
||||
// 只添加新增组件的索引(保留已有的展开/收缩状态)
|
||||
entity.components.forEach((_, index) => {
|
||||
newSet.add(index);
|
||||
// 只有当索引不在集合中时才添加(即新组件)
|
||||
if (!prev.has(index) && index >= prev.size) {
|
||||
newSet.add(index);
|
||||
}
|
||||
});
|
||||
// 移除不存在的索引(组件被删除的情况)
|
||||
for (const idx of prev) {
|
||||
if (idx >= entity.components.length) {
|
||||
newSet.delete(idx);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, [entity, entity.components.length, componentVersion]);
|
||||
}, [entity, entity.components.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showComponentMenu && addButtonRef.current) {
|
||||
@@ -439,6 +449,15 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
onAction={handlePropertyAction}
|
||||
/>
|
||||
}
|
||||
{/* Append-mode inspectors (shown after default inspector) */}
|
||||
{componentInspectorRegistry?.renderAppendInspectors({
|
||||
component,
|
||||
entity,
|
||||
version: componentVersion + localVersion,
|
||||
onChange: (propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value),
|
||||
onAction: handlePropertyAction
|
||||
})}
|
||||
{/* Dynamic component actions from plugins */}
|
||||
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => {
|
||||
// 解析图标:支持字符串(Lucide 图标名)或 React 元素
|
||||
|
||||
187
packages/editor-app/src/components/styles/ModuleListSetting.css
Normal file
187
packages/editor-app/src/components/styles/ModuleListSetting.css
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Module List Setting Styles.
|
||||
* 模块列表设置样式。
|
||||
*/
|
||||
|
||||
.module-list-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.module-list-categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Category Group */
|
||||
.module-category-group {
|
||||
border: 1px solid var(--border-color, #3a3a3a);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.module-category-header:hover {
|
||||
background: var(--bg-hover, #333);
|
||||
}
|
||||
|
||||
.module-category-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #eee);
|
||||
}
|
||||
|
||||
.module-category-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
/* Category Items */
|
||||
.module-category-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.module-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px 6px 28px;
|
||||
border-top: 1px solid var(--border-color, #3a3a3a);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.module-item:hover {
|
||||
background: var(--bg-hover, #333);
|
||||
}
|
||||
|
||||
.module-item.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Checkbox Label */
|
||||
.module-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module-checkbox-label input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.module-checkbox-label input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.module-icon {
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.module-item.enabled .module-icon {
|
||||
color: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.module-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #eee);
|
||||
}
|
||||
|
||||
.module-badge {
|
||||
padding: 1px 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.module-badge.core {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
color: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.module-size {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.module-size-inlined {
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.module-wasm-indicator {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.module-size-breakdown {
|
||||
margin-left: 4px;
|
||||
opacity: 0.7;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.module-list-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.module-list-size-label {
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.module-list-size-value {
|
||||
color: var(--text-primary, #eee);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Validation Error */
|
||||
.module-validation-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ff6b6b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.module-validation-error button {
|
||||
margin-left: auto;
|
||||
padding: 2px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.module-validation-error button:hover {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
}
|
||||
@@ -50,7 +50,12 @@
|
||||
"core": "Core",
|
||||
"rendering": "Rendering",
|
||||
"physics": "Physics",
|
||||
"audio": "Audio"
|
||||
"audio": "Audio",
|
||||
"tilemap": "Tilemap"
|
||||
},
|
||||
"material": {
|
||||
"name": "Material",
|
||||
"description": "Custom material and shader component"
|
||||
},
|
||||
"transform": {
|
||||
"description": "Transform - Position, Rotation, Scale"
|
||||
@@ -76,5 +81,16 @@
|
||||
"audioSource": {
|
||||
"description": "Audio Source"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"create": {
|
||||
"material": "Material",
|
||||
"shader": "Shader"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"create": {
|
||||
"materialEntity": "Material Entity"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,12 @@
|
||||
"core": "基础",
|
||||
"rendering": "渲染",
|
||||
"physics": "物理",
|
||||
"audio": "音频"
|
||||
"audio": "音频",
|
||||
"tilemap": "瓦片地图"
|
||||
},
|
||||
"material": {
|
||||
"name": "材质",
|
||||
"description": "自定义材质和着色器组件"
|
||||
},
|
||||
"transform": {
|
||||
"description": "变换组件 - 位置、旋转、缩放"
|
||||
@@ -76,5 +81,16 @@
|
||||
"audioSource": {
|
||||
"description": "音频源组件"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"create": {
|
||||
"material": "材质",
|
||||
"shader": "着色器"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"create": {
|
||||
"materialEntity": "材质实体"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
|
||||
import { SettingsRegistry } from '@esengine/editor-core';
|
||||
import { SettingsService } from '../../services/SettingsService';
|
||||
|
||||
@@ -102,27 +102,23 @@ class EditorAppearanceEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/editor-appearance',
|
||||
name: 'Editor Appearance',
|
||||
name: '@esengine/editor-appearance',
|
||||
displayName: 'Editor Appearance',
|
||||
version: '1.0.0',
|
||||
description: 'Configure editor appearance settings',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Palette',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'EditorAppearanceEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'earliest'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: [],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const EditorAppearancePlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new EditorAppearanceEditorModule()
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor, GizmoProviderRegistration } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, ModuleManifest, GizmoProviderRegistration } from '@esengine/editor-core';
|
||||
import { registerSpriteGizmo } from '../../gizmos';
|
||||
|
||||
/**
|
||||
@@ -24,27 +24,25 @@ class GizmoEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/gizmo',
|
||||
name: 'Gizmo System',
|
||||
name: '@esengine/gizmo',
|
||||
displayName: 'Gizmo System',
|
||||
version: '1.0.0',
|
||||
description: 'Provides gizmo support for editor components',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Move',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'GizmoEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'preDefault'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
other: ['GizmoRegistry']
|
||||
}
|
||||
};
|
||||
|
||||
export const GizmoPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new GizmoEditorModule()
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
|
||||
import { SettingsRegistry } from '@esengine/editor-core';
|
||||
|
||||
const logger = createLogger('PluginConfigPlugin');
|
||||
@@ -51,27 +51,23 @@ class PluginConfigEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/plugin-config',
|
||||
name: 'Plugin Config',
|
||||
name: '@esengine/plugin-config',
|
||||
displayName: 'Plugin Config',
|
||||
version: '1.0.0',
|
||||
description: 'Configure engine plugins',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Package',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'PluginConfigEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: [],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const PluginConfigPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new PluginConfigEditorModule()
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPlugin,
|
||||
IEditorModuleLoader,
|
||||
PluginDescriptor,
|
||||
ModuleManifest,
|
||||
MenuItemDescriptor
|
||||
} from '@esengine/editor-core';
|
||||
import { MessageHub, SettingsRegistry } from '@esengine/editor-core';
|
||||
@@ -114,26 +114,23 @@ class ProfilerEditorModule implements IEditorModuleLoader {
|
||||
async onEditorReady(): Promise<void> {}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/profiler',
|
||||
name: 'Performance Profiler',
|
||||
name: '@esengine/profiler',
|
||||
displayName: 'Performance Profiler',
|
||||
version: '1.0.0',
|
||||
description: 'Real-time performance monitoring for ECS systems',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'BarChart3',
|
||||
enabledByDefault: true,
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'ProfilerEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault'
|
||||
}
|
||||
]
|
||||
dependencies: [],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const ProfilerPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new ProfilerEditorModule()
|
||||
};
|
||||
|
||||
@@ -8,10 +8,25 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger, Core } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, ProjectService } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, ProjectService, moduleRegistry } from '@esengine/editor-core';
|
||||
import EngineService from '../../services/EngineService';
|
||||
|
||||
/**
|
||||
* Get engine modules from ModuleRegistry.
|
||||
* 从 ModuleRegistry 获取引擎模块。
|
||||
*
|
||||
* Returns all registered modules from the module registry.
|
||||
* 返回模块注册表中的所有已注册模块。
|
||||
*/
|
||||
function getModuleManifests(): ModuleManifest[] {
|
||||
// Get modules from moduleRegistry singleton
|
||||
// 从 moduleRegistry 单例获取模块
|
||||
const modules = moduleRegistry.getAllModules();
|
||||
console.log('[ProjectSettingsPlugin] getModuleManifests: got', modules.length, 'modules from registry');
|
||||
return modules;
|
||||
}
|
||||
|
||||
const logger = createLogger('ProjectSettingsPlugin');
|
||||
|
||||
/**
|
||||
@@ -85,6 +100,38 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
}))
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'modules',
|
||||
title: '引擎模块',
|
||||
description: '管理项目使用的引擎模块。每个模块包含运行时组件和编辑器工具。禁用不需要的模块可以减小构建体积。',
|
||||
settings: [
|
||||
{
|
||||
key: 'project.disabledModules',
|
||||
label: '模块列表',
|
||||
type: 'moduleList',
|
||||
// Default: no modules disabled (all enabled)
|
||||
// 默认:没有禁用的模块(全部启用)
|
||||
defaultValue: [],
|
||||
description: '取消勾选不需要的模块。核心模块不能禁用。新增的模块会自动启用。',
|
||||
// Custom props for moduleList type
|
||||
// Modules are loaded dynamically from ModuleRegistry (sizes from module.json)
|
||||
// 模块从 ModuleRegistry 动态加载(大小来自 module.json)
|
||||
getModules: getModuleManifests,
|
||||
// Use blacklist mode: store disabled modules instead of enabled
|
||||
// 使用黑名单模式:存储禁用的模块而不是启用的
|
||||
useBlacklist: true,
|
||||
validateDisable: async (moduleId: string) => {
|
||||
// Use moduleRegistry singleton for validation
|
||||
// 使用 moduleRegistry 单例进行验证
|
||||
const validation = await moduleRegistry.validateDisable(moduleId);
|
||||
if (!validation.canDisable) {
|
||||
return { canDisable: false, reason: validation.message };
|
||||
}
|
||||
return { canDisable: true };
|
||||
}
|
||||
} as any // Cast to any to allow custom props
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -147,27 +194,23 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/project-settings',
|
||||
name: 'Project Settings',
|
||||
name: '@esengine/project-settings',
|
||||
displayName: 'Project Settings',
|
||||
version: '1.0.0',
|
||||
description: 'Configure project-level settings',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Settings',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'ProjectSettingsEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: [],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const ProjectSettingsPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new ProjectSettingsEditorModule()
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPlugin,
|
||||
IEditorModuleLoader,
|
||||
PluginDescriptor,
|
||||
ModuleManifest,
|
||||
PanelDescriptor,
|
||||
MenuItemDescriptor,
|
||||
ToolbarItemDescriptor,
|
||||
@@ -173,27 +173,25 @@ class SceneInspectorEditorModule implements IEditorModuleLoader {
|
||||
async onProjectClose(): Promise<void> {}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/scene-inspector',
|
||||
name: 'Scene Inspector',
|
||||
name: '@esengine/scene-inspector',
|
||||
displayName: 'Scene Inspector',
|
||||
version: '1.0.0',
|
||||
description: 'Scene hierarchy and entity inspector',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Search',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'SceneInspectorEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'default'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
other: ['SceneHierarchy', 'EntityInspector']
|
||||
}
|
||||
};
|
||||
|
||||
export const SceneInspectorPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new SceneInspectorEditorModule()
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export { GizmoPlugin } from './GizmoPlugin';
|
||||
export { SceneInspectorPlugin } from './SceneInspectorPlugin';
|
||||
export { ProfilerPlugin } from './ProfilerPlugin';
|
||||
export { EditorAppearancePlugin } from './EditorAppearancePlugin';
|
||||
export { PluginConfigPlugin } from './PluginConfigPlugin';
|
||||
export { ProjectSettingsPlugin } from './ProjectSettingsPlugin';
|
||||
// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin
|
||||
// TODO: Re-enable when blueprint-editor package is fixed
|
||||
// export { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
|
||||
243
packages/editor-app/src/services/BuildFileSystemService.ts
Normal file
243
packages/editor-app/src/services/BuildFileSystemService.ts
Normal 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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
80
packages/editor-app/src/services/TauriAssetReader.ts
Normal file
80
packages/editor-app/src/services/TauriAssetReader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
143
packages/editor-app/src/services/TauriModuleFileSystem.ts
Normal file
143
packages/editor-app/src/services/TauriModuleFileSystem.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
120
packages/editor-app/src/services/ViewportService.ts
Normal file
120
packages/editor-app/src/services/ViewportService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
909
packages/editor-app/src/styles/BuildSettingsPanel.css
Normal file
909
packages/editor-app/src/styles/BuildSettingsPanel.css
Normal file
@@ -0,0 +1,909 @@
|
||||
/**
|
||||
* Build Settings Panel Styles.
|
||||
* 构建设置面板样式。
|
||||
*
|
||||
* Designed to match the editor's existing panel style.
|
||||
* 设计与编辑器现有面板风格一致。
|
||||
*/
|
||||
|
||||
/* ==================== Main Container | 主容器 ==================== */
|
||||
|
||||
.build-settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ==================== Header Tabs | 头部标签 ==================== */
|
||||
|
||||
.build-settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.build-settings-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.build-settings-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.build-settings-tab:hover {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.build-settings-tab.active {
|
||||
background: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.build-settings-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.build-settings-header-btn {
|
||||
padding: 3px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.build-settings-header-btn:hover {
|
||||
background: #3a3a3a;
|
||||
border-color: #4a4a4a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* ==================== Add Profile Bar | 添加配置栏 ==================== */
|
||||
|
||||
.build-settings-add-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.build-settings-add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px dashed #3a3a3a;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.build-settings-add-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* ==================== Main Content | 主要内容 ==================== */
|
||||
|
||||
.build-settings-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== Sidebar | 侧边栏 ==================== */
|
||||
|
||||
.build-settings-sidebar {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #262626;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.build-settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.build-settings-section-header {
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
/* ==================== Platform List | 平台列表 ==================== */
|
||||
|
||||
.build-settings-platform-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.build-settings-platform-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.build-settings-platform-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.build-settings-platform-item.selected {
|
||||
background: #3d5a80;
|
||||
}
|
||||
|
||||
.build-settings-platform-item.selected:hover {
|
||||
background: #4a6a90;
|
||||
}
|
||||
|
||||
.build-settings-platform-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.build-settings-platform-item.selected .build-settings-platform-icon {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.build-settings-platform-label {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.build-settings-platform-item.selected .build-settings-platform-label {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.build-settings-active-badge {
|
||||
padding: 1px 6px;
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* ==================== Profile List | 配置列表 ==================== */
|
||||
|
||||
.build-settings-profile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.build-settings-profile-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.build-settings-profile-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.build-settings-profile-item.selected {
|
||||
background: #3d5a80;
|
||||
}
|
||||
|
||||
.build-settings-profile-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.build-settings-profile-item.selected .build-settings-profile-icon {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.build-settings-profile-name {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.build-settings-profile-item.selected .build-settings-profile-name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== Details Panel | 详情面板 ==================== */
|
||||
|
||||
.build-settings-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.build-settings-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.build-settings-details-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.build-settings-details-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #3a3a3a;
|
||||
border-radius: 3px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.build-settings-details-info h3 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.build-settings-details-info span {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.build-settings-details-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ==================== Buttons | 按钮 ==================== */
|
||||
|
||||
.build-settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.build-settings-btn.primary {
|
||||
background: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.build-settings-btn.primary:hover {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.build-settings-btn.secondary {
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.build-settings-btn.secondary:hover {
|
||||
background: #444;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.build-settings-btn.text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.build-settings-btn.text:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.build-settings-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==================== Card | 卡片 ==================== */
|
||||
|
||||
.build-settings-card {
|
||||
margin: 0;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.build-settings-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.build-settings-more-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.build-settings-more-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* ==================== Field Group | 字段组 ==================== */
|
||||
|
||||
.build-settings-field-group {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.build-settings-field-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.build-settings-field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: #ccc;
|
||||
font-size: 11px;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.build-settings-field-header:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.build-settings-field-header svg {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.build-settings-field-content {
|
||||
padding: 8px 12px;
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
/* ==================== Scene List | 场景列表 ==================== */
|
||||
|
||||
.build-settings-scene-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 40px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.build-settings-scene-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.build-settings-scene-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.build-settings-scene-item input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
.build-settings-empty-list {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.build-settings-empty-text {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.build-settings-field-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ==================== Defines List | 定义列表 ==================== */
|
||||
|
||||
.build-settings-defines-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 30px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.build-settings-define-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.build-settings-define-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.build-settings-define-item button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.build-settings-define-item button:hover {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.build-settings-list-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.build-settings-list-actions button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.build-settings-list-actions button:hover:not(:disabled) {
|
||||
background: #3a3a3a;
|
||||
border-color: #4a4a4a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.build-settings-list-actions button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==================== Form | 表单 ==================== */
|
||||
|
||||
.build-settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.build-settings-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 3px 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.build-settings-form-row:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.build-settings-form-row label {
|
||||
flex: 0 0 35%;
|
||||
min-width: 80px;
|
||||
max-width: 140px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.build-settings-form-row input[type="text"],
|
||||
.build-settings-form-row select {
|
||||
flex: 1;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
color: #ddd;
|
||||
font-size: 11px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.build-settings-form-row input[type="text"]:hover,
|
||||
.build-settings-form-row select:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.build-settings-form-row input[type="text"]:focus,
|
||||
.build-settings-form-row select:focus {
|
||||
border-color: #3b82f6;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.build-settings-form-row input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Icon Picker | 图标选择器 */
|
||||
|
||||
.build-settings-icon-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.build-settings-icon-hint {
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* ==================== No Selection | 无选择 ==================== */
|
||||
|
||||
.build-settings-no-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ==================== Scrollbar | 滚动条 ==================== */
|
||||
|
||||
.build-settings-sidebar::-webkit-scrollbar,
|
||||
.build-settings-details::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.build-settings-sidebar::-webkit-scrollbar-track,
|
||||
.build-settings-details::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.build-settings-sidebar::-webkit-scrollbar-thumb,
|
||||
.build-settings-details::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.build-settings-sidebar::-webkit-scrollbar-thumb:hover,
|
||||
.build-settings-details::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
/* ==================== Build Progress Dialog | 构建进度对话框 ==================== */
|
||||
|
||||
.build-progress-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.build-progress-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 360px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.build-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.build-progress-header h3 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.build-progress-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.build-progress-close:hover {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.build-progress-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.build-progress-status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.build-progress-spinner {
|
||||
color: #3b82f6;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.build-progress-success {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.build-progress-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.build-progress-message {
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.build-progress-bar-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #3a3a3a;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.build-progress-bar {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
border-radius: 2px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.build-progress-percent {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Build Result Details | 构建结果详情 */
|
||||
|
||||
.build-result-details {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.build-result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.build-result-label {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.build-result-value {
|
||||
color: #ccc;
|
||||
word-break: break-all;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.build-result-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 2px;
|
||||
color: #ef4444;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.build-result-error svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.build-result-warnings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.build-result-warnings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #f59e0b;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.build-result-warnings-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #ccc;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.build-result-warnings-list li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.build-progress-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.build-progress-actions .build-settings-btn {
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ==================== Toggle Group | 开关组 ==================== */
|
||||
|
||||
.build-settings-toggle-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.build-settings-hint {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
73
packages/editor-app/src/styles/BuildSettingsWindow.css
Normal file
73
packages/editor-app/src/styles/BuildSettingsWindow.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Build Settings Window Styles.
|
||||
* 构建设置窗口样式。
|
||||
*/
|
||||
|
||||
.build-settings-window-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.build-settings-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
height: 80%;
|
||||
max-height: 600px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.build-settings-window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.build-settings-window-header h2 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.build-settings-window-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.build-settings-window-close:hover {
|
||||
background: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.build-settings-window-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
104
packages/editor-app/src/styles/EditorViewport.css
Normal file
104
packages/editor-app/src/styles/EditorViewport.css
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* EditorViewport Styles
|
||||
* 编辑器视口样式
|
||||
*/
|
||||
|
||||
.editor-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-viewport, #1a1a1a);
|
||||
}
|
||||
|
||||
.editor-viewport-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.editor-viewport-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Overlay container */
|
||||
.editor-viewport-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor-viewport-overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Toolbar overlay */
|
||||
.editor-viewport-toolbar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--bg-toolbar, rgba(30, 30, 30, 0.9));
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.editor-viewport-toolbar-right {
|
||||
left: auto;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
/* Stats overlay */
|
||||
.editor-viewport-stats {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
padding: 8px;
|
||||
background: var(--bg-toolbar, rgba(30, 30, 30, 0.9));
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.editor-viewport-stats-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-viewport-stats-label {
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
.editor-viewport-stats-value {
|
||||
color: var(--text-primary, #ccc);
|
||||
}
|
||||
|
||||
/* Zoom indicator */
|
||||
.editor-viewport-zoom {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-toolbar, rgba(30, 30, 30, 0.9));
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
/* Crosshair cursor mode */
|
||||
.editor-viewport.crosshair .editor-viewport-canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* Pointer cursor mode */
|
||||
.editor-viewport.pointer .editor-viewport-canvas {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -442,7 +442,8 @@
|
||||
|
||||
/* ==================== Special Types ==================== */
|
||||
.settings-plugin-list,
|
||||
.settings-custom-renderer {
|
||||
.settings-custom-renderer,
|
||||
.settings-module-list {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,343 @@ function copyPluginModulesPlugin(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to copy engine modules after each build.
|
||||
* 每次构建后复制引擎模块的插件。
|
||||
*/
|
||||
function copyEngineModulesPlugin(): Plugin {
|
||||
const packagesDir = path.resolve(__dirname, '..');
|
||||
|
||||
function getEngineModules() {
|
||||
const modules: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
packageDir: string;
|
||||
moduleJsonPath: string;
|
||||
distPath: string;
|
||||
editorPackage?: string;
|
||||
isCore: boolean;
|
||||
category: string;
|
||||
}> = [];
|
||||
|
||||
let packages: string[];
|
||||
try {
|
||||
packages = fs.readdirSync(packagesDir);
|
||||
} catch {
|
||||
return modules;
|
||||
}
|
||||
|
||||
for (const pkg of packages) {
|
||||
const pkgDir = path.join(packagesDir, pkg);
|
||||
const moduleJsonPath = path.join(pkgDir, 'module.json');
|
||||
|
||||
try {
|
||||
if (!fs.statSync(pkgDir).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(moduleJsonPath)) continue;
|
||||
|
||||
try {
|
||||
const moduleJson = JSON.parse(fs.readFileSync(moduleJsonPath, 'utf-8'));
|
||||
if (moduleJson.isEngineModule !== false) {
|
||||
// Use outputPath from module.json, default to "dist/index.js"
|
||||
const outputPath = moduleJson.outputPath || 'dist/index.js';
|
||||
const distPath = path.join(pkgDir, outputPath);
|
||||
|
||||
modules.push({
|
||||
id: moduleJson.id || pkg,
|
||||
name: moduleJson.name || `@esengine/${pkg}`,
|
||||
displayName: moduleJson.displayName || pkg,
|
||||
packageDir: pkgDir,
|
||||
moduleJsonPath,
|
||||
distPath,
|
||||
editorPackage: moduleJson.editorPackage,
|
||||
isCore: moduleJson.isCore || false,
|
||||
category: moduleJson.category || 'Other'
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'copy-engine-modules',
|
||||
writeBundle(options) {
|
||||
const outDir = options.dir || 'dist';
|
||||
const engineDir = path.join(outDir, 'engine');
|
||||
|
||||
// Clean and recreate engine directory
|
||||
if (fs.existsSync(engineDir)) {
|
||||
fs.rmSync(engineDir, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(engineDir, { recursive: true });
|
||||
|
||||
const modules = getEngineModules();
|
||||
const moduleInfos: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
hasRuntime: boolean;
|
||||
editorPackage?: string;
|
||||
isCore: boolean;
|
||||
category: string;
|
||||
jsSize?: number;
|
||||
requiresWasm?: boolean;
|
||||
wasmSize?: number;
|
||||
wasmFiles?: string[];
|
||||
}> = [];
|
||||
|
||||
const editorPackages = new Set<string>();
|
||||
|
||||
/**
|
||||
* Calculate total WASM file size in a directory.
|
||||
* 计算目录中 WASM 文件的总大小。
|
||||
*/
|
||||
function getWasmSize(pkgDir: string): number {
|
||||
let totalSize = 0;
|
||||
const checkDirs = [
|
||||
pkgDir,
|
||||
path.join(pkgDir, 'pkg'),
|
||||
path.join(pkgDir, 'dist')
|
||||
];
|
||||
|
||||
for (const dir of checkDirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.wasm')) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
totalSize += stat.size;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
console.log(`[copy-engine-modules] Copying ${modules.length} modules to dist/engine/`);
|
||||
|
||||
for (const module of modules) {
|
||||
const moduleOutputDir = path.join(engineDir, module.id);
|
||||
fs.mkdirSync(moduleOutputDir, { recursive: true });
|
||||
|
||||
// Read full module.json for additional fields
|
||||
// 读取完整 module.json 获取额外字段
|
||||
let moduleJson: Record<string, unknown> = {};
|
||||
try {
|
||||
moduleJson = JSON.parse(fs.readFileSync(module.moduleJsonPath, 'utf-8'));
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
// Copy module.json
|
||||
fs.copyFileSync(module.moduleJsonPath, path.join(moduleOutputDir, 'module.json'));
|
||||
|
||||
// Copy dist/index.js if exists
|
||||
let hasRuntime = false;
|
||||
let jsSize = 0;
|
||||
if (fs.existsSync(module.distPath)) {
|
||||
fs.copyFileSync(module.distPath, path.join(moduleOutputDir, 'index.js'));
|
||||
// Get JS file size
|
||||
jsSize = fs.statSync(module.distPath).size;
|
||||
// Copy source map if exists
|
||||
const sourceMapPath = module.distPath + '.map';
|
||||
if (fs.existsSync(sourceMapPath)) {
|
||||
fs.copyFileSync(sourceMapPath, path.join(moduleOutputDir, 'index.js.map'));
|
||||
}
|
||||
hasRuntime = true;
|
||||
|
||||
// Copy additional included files (e.g., chunks)
|
||||
// 复制额外包含的文件(如 chunk)
|
||||
const includes = moduleJson.includes as string[] | undefined;
|
||||
if (includes && includes.length > 0) {
|
||||
const distDir = path.dirname(module.distPath);
|
||||
for (const pattern of includes) {
|
||||
// Convert glob pattern to regex
|
||||
const regexPattern = pattern
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\./g, '\\.')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
|
||||
// Find matching files in dist directory
|
||||
if (fs.existsSync(distDir)) {
|
||||
const files = fs.readdirSync(distDir);
|
||||
for (const file of files) {
|
||||
if (regex.test(file)) {
|
||||
const srcFile = path.join(distDir, file);
|
||||
const destFile = path.join(moduleOutputDir, file);
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
jsSize += fs.statSync(srcFile).size;
|
||||
// Copy source map for included file if exists
|
||||
const mapFile = srcFile + '.map';
|
||||
if (fs.existsSync(mapFile)) {
|
||||
fs.copyFileSync(mapFile, destFile + '.map');
|
||||
}
|
||||
console.log(`[copy-engine-modules] Copied include to ${module.id}/: ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate WASM size and copy WASM files if module requires WASM
|
||||
// 如果模块需要 WASM,计算 WASM 大小并复制 WASM 文件
|
||||
const requiresWasm = moduleJson.requiresWasm === true;
|
||||
let wasmSize = 0;
|
||||
const copiedWasmFiles: string[] = [];
|
||||
if (requiresWasm) {
|
||||
wasmSize = getWasmSize(module.packageDir);
|
||||
if (wasmSize > 0) {
|
||||
console.log(`[copy-engine-modules] ${module.id}: WASM size = ${(wasmSize / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
// Copy WASM files from wasmPaths defined in module.json
|
||||
// wasmPaths 现在是相对于源包目录的路径,如 "rapier_wasm2d_bg.wasm"
|
||||
// 需要找到实际的 WASM 文件并复制到输出的模块目录
|
||||
const wasmPaths = moduleJson.wasmPaths as string[] | undefined;
|
||||
if (wasmPaths && wasmPaths.length > 0) {
|
||||
for (const wasmRelPath of wasmPaths) {
|
||||
const wasmFileName = path.basename(wasmRelPath);
|
||||
|
||||
// 查找源 WASM 文件的可能位置
|
||||
// wasmPaths 里配置的是相对路径,实际文件在源包里
|
||||
// 对于 @esengine/rapier2d,WASM 在 packages/rapier2d/pkg/ 下
|
||||
const possibleSrcPaths = [
|
||||
// 直接在包目录下(如果 wasmRelPath 就是文件名)
|
||||
path.join(module.packageDir, wasmRelPath),
|
||||
// 在包的 pkg 目录下(wasm-pack 输出)
|
||||
path.join(module.packageDir, 'pkg', wasmFileName),
|
||||
// 在包的 dist 目录下
|
||||
path.join(module.packageDir, 'dist', wasmFileName),
|
||||
];
|
||||
|
||||
// 对于依赖其他包 WASM 的情况,检查依赖包
|
||||
// 例如 physics-rapier2d 依赖 rapier2d 的 WASM
|
||||
const depMatch = moduleJson.name?.toString().match(/@esengine\/(.+)/);
|
||||
if (depMatch) {
|
||||
// 检查同名的依赖包(去掉 physics- 前缀)
|
||||
const baseName = depMatch[1].replace('physics-', '');
|
||||
possibleSrcPaths.push(
|
||||
path.join(packagesDir, baseName, 'pkg', wasmFileName),
|
||||
path.join(packagesDir, baseName, wasmFileName)
|
||||
);
|
||||
}
|
||||
|
||||
let copied = false;
|
||||
for (const srcPath of possibleSrcPaths) {
|
||||
if (fs.existsSync(srcPath)) {
|
||||
const destPath = path.join(moduleOutputDir, wasmFileName);
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
copiedWasmFiles.push(wasmFileName);
|
||||
console.log(`[copy-engine-modules] Copied WASM to ${module.id}/: ${wasmFileName}`);
|
||||
copied = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!copied) {
|
||||
console.warn(`[copy-engine-modules] WASM file not found: ${wasmRelPath} (tried ${possibleSrcPaths.length} paths)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy pkg directory if exists (for WASM JS bindings like rapier2d)
|
||||
// 如果存在 pkg 目录则复制(用于 WASM JS 绑定如 rapier2d)
|
||||
// The JS and WASM files must be in the same directory for import.meta.url to work
|
||||
// JS 和 WASM 文件必须在同一目录才能让 import.meta.url 正常工作
|
||||
const pkgDir = path.join(module.packageDir, 'pkg');
|
||||
if (fs.existsSync(pkgDir)) {
|
||||
const pkgOutputDir = path.join(moduleOutputDir, 'pkg');
|
||||
fs.mkdirSync(pkgOutputDir, { recursive: true });
|
||||
const pkgFiles = fs.readdirSync(pkgDir);
|
||||
for (const file of pkgFiles) {
|
||||
// Copy both JS and WASM files to pkg directory
|
||||
// 将 JS 和 WASM 文件都复制到 pkg 目录
|
||||
if (file.endsWith('.js') || file.endsWith('.wasm')) {
|
||||
const srcFile = path.join(pkgDir, file);
|
||||
const destFile = path.join(pkgOutputDir, file);
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
console.log(`[copy-engine-modules] Copied pkg to ${module.id}/pkg/: ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moduleInfos.push({
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
displayName: module.displayName,
|
||||
hasRuntime,
|
||||
editorPackage: module.editorPackage,
|
||||
isCore: module.isCore,
|
||||
category: module.category,
|
||||
// Only include jsSize if there's actual runtime code
|
||||
// 只有实际有运行时代码时才包含 jsSize
|
||||
jsSize: jsSize > 0 ? jsSize : undefined,
|
||||
requiresWasm: requiresWasm || undefined,
|
||||
wasmSize: wasmSize > 0 ? wasmSize : undefined,
|
||||
// WASM files that were copied to dist/wasm/
|
||||
// 复制到 dist/wasm/ 的 WASM 文件
|
||||
wasmFiles: copiedWasmFiles.length > 0 ? copiedWasmFiles : undefined
|
||||
});
|
||||
|
||||
if (module.editorPackage) {
|
||||
editorPackages.add(module.editorPackage);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy editor packages
|
||||
for (const editorPkg of editorPackages) {
|
||||
const match = editorPkg.match(/@esengine\/(.+)/);
|
||||
if (!match) continue;
|
||||
|
||||
const pkgName = match[1];
|
||||
const pkgDir = path.join(packagesDir, pkgName);
|
||||
const distPath = path.join(pkgDir, 'dist', 'index.js');
|
||||
|
||||
if (!fs.existsSync(distPath)) continue;
|
||||
|
||||
const editorOutputDir = path.join(engineDir, pkgName);
|
||||
fs.mkdirSync(editorOutputDir, { recursive: true });
|
||||
fs.copyFileSync(distPath, path.join(editorOutputDir, 'index.js'));
|
||||
|
||||
const sourceMapPath = distPath + '.map';
|
||||
if (fs.existsSync(sourceMapPath)) {
|
||||
fs.copyFileSync(sourceMapPath, path.join(editorOutputDir, 'index.js.map'));
|
||||
}
|
||||
}
|
||||
|
||||
// Create index.json
|
||||
const indexData = {
|
||||
version: '1.0.0',
|
||||
generatedAt: new Date().toISOString(),
|
||||
modules: moduleInfos
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(engineDir, 'index.json'),
|
||||
JSON.stringify(indexData, null, 2)
|
||||
);
|
||||
|
||||
console.log(`[copy-engine-modules] Done! Created dist/engine/index.json`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
const wasmPackages: string[] = [];
|
||||
|
||||
@@ -161,6 +498,7 @@ export default defineConfig({
|
||||
tsDecorators: true,
|
||||
}),
|
||||
copyPluginModulesPlugin(),
|
||||
copyEngineModulesPlugin(),
|
||||
],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/asset-system-editor": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.27.1",
|
||||
|
||||
@@ -20,7 +20,8 @@ const external = [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react/jsx-runtime',
|
||||
/^@types\//
|
||||
/^@types\//,
|
||||
/^@tauri-apps\//
|
||||
];
|
||||
|
||||
const commonPlugins = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user