refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Browser File System Service
|
||||
* 浏览器文件系统服务
|
||||
*
|
||||
* 在浏览器运行时环境中,通过 HTTP fetch 加载资产文件。
|
||||
* 使用资产目录(asset-catalog.json)来解析 GUID 到实际 URL。
|
||||
*
|
||||
* In browser runtime environment, loads asset files via HTTP fetch.
|
||||
* Uses asset catalog to resolve GUIDs to actual URLs.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IAssetCatalog,
|
||||
IAssetCatalogEntry,
|
||||
IAssetBundleInfo,
|
||||
AssetLoadStrategy
|
||||
} from '@esengine/asset-system';
|
||||
|
||||
/**
|
||||
* Browser file system service options
|
||||
* 浏览器文件系统服务配置
|
||||
*/
|
||||
export interface BrowserFileSystemOptions {
|
||||
/** Base URL for assets (e.g., '/assets' or 'https://cdn.example.com/assets') */
|
||||
baseUrl?: string;
|
||||
/** Asset catalog URL */
|
||||
catalogUrl?: string;
|
||||
/** Enable caching */
|
||||
enableCache?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser File System Service
|
||||
* 浏览器文件系统服务
|
||||
*
|
||||
* Provides file system-like API for browser environments
|
||||
* by fetching files over HTTP. Supports both file-based and bundle-based loading.
|
||||
* 为浏览器环境提供类文件系统 API,通过 HTTP fetch 加载文件。
|
||||
* 支持基于文件和基于包的两种加载模式。
|
||||
*/
|
||||
export class BrowserFileSystemService {
|
||||
private _baseUrl: string;
|
||||
private _catalogUrl: string;
|
||||
private _catalog: IAssetCatalog | null = null;
|
||||
private _cache = new Map<string, string>();
|
||||
private _bundleCache = new Map<string, ArrayBuffer>();
|
||||
private _enableCache: boolean;
|
||||
private _initialized = false;
|
||||
|
||||
constructor(options: BrowserFileSystemOptions = {}) {
|
||||
this._baseUrl = options.baseUrl ?? '/assets';
|
||||
this._catalogUrl = options.catalogUrl ?? '/asset-catalog.json';
|
||||
this._enableCache = options.enableCache ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize service and load catalog
|
||||
* 初始化服务并加载目录
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this._initialized) return;
|
||||
|
||||
try {
|
||||
await this._loadCatalog();
|
||||
this._initialized = true;
|
||||
|
||||
const strategy = this._catalog?.loadStrategy ?? 'file';
|
||||
const assetCount = Object.keys(this._catalog?.entries ?? {}).length;
|
||||
console.log(`[BrowserFileSystem] Initialized: ${assetCount} assets, strategy=${strategy}`);
|
||||
} catch (error) {
|
||||
console.warn('[BrowserFileSystem] Failed to load catalog:', error);
|
||||
// Continue without catalog - will use path-based loading
|
||||
// 无目录时继续,使用基于路径的加载
|
||||
this._initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset catalog
|
||||
* 加载资产目录
|
||||
*/
|
||||
private async _loadCatalog(): Promise<void> {
|
||||
const response = await fetch(this._catalogUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch catalog: ${response.status}`);
|
||||
}
|
||||
|
||||
const rawCatalog = await response.json();
|
||||
|
||||
// Normalize catalog format (handle legacy format without loadStrategy)
|
||||
// 规范化目录格式(处理没有 loadStrategy 的旧格式)
|
||||
this._catalog = this._normalizeCatalog(rawCatalog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize catalog to ensure it has all required fields
|
||||
* 规范化目录,确保包含所有必需字段
|
||||
*
|
||||
* Supports both 'entries' (IAssetCatalog) and 'assets' (IRuntimeCatalog) field names.
|
||||
* 同时支持 'entries' (IAssetCatalog) 和 'assets' (IRuntimeCatalog) 字段名。
|
||||
*/
|
||||
private _normalizeCatalog(raw: Record<string, unknown>): IAssetCatalog {
|
||||
// Determine load strategy
|
||||
// 确定加载策略
|
||||
let loadStrategy: AssetLoadStrategy = 'file';
|
||||
// Only use bundle strategy if explicitly set or bundles is non-empty
|
||||
// 仅当明确设置或 bundles 非空时才使用 bundle 策略
|
||||
const hasBundles = raw.bundles && typeof raw.bundles === 'object' && Object.keys(raw.bundles as object).length > 0;
|
||||
if (raw.loadStrategy === 'bundle' || hasBundles) {
|
||||
loadStrategy = 'bundle';
|
||||
}
|
||||
|
||||
// Support both 'entries' and 'assets' field names for compatibility
|
||||
// 同时支持 'entries' 和 'assets' 字段名以保持兼容性
|
||||
const entries = (raw.entries ?? raw.assets) as Record<string, IAssetCatalogEntry> ?? {};
|
||||
|
||||
return {
|
||||
version: (raw.version as string) ?? '1.0.0',
|
||||
createdAt: (raw.createdAt as number) ?? Date.now(),
|
||||
loadStrategy,
|
||||
entries,
|
||||
bundles: (raw.bundles as Record<string, IAssetBundleInfo>) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current load strategy
|
||||
* 获取当前加载策略
|
||||
*/
|
||||
get loadStrategy(): AssetLoadStrategy {
|
||||
return this._catalog?.loadStrategy ?? 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content as string
|
||||
* @param path - Can be GUID, relative path, or absolute path
|
||||
*/
|
||||
async readFile(path: string): Promise<string> {
|
||||
const url = this._resolveUrl(path);
|
||||
|
||||
// Check cache
|
||||
if (this._enableCache && this._cache.has(url)) {
|
||||
return this._cache.get(url)!;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch file: ${url} (${response.status})`);
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
// Cache result
|
||||
if (this._enableCache) {
|
||||
this._cache.set(url, content);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file as binary (ArrayBuffer)
|
||||
*/
|
||||
async readBinary(path: string): Promise<ArrayBuffer> {
|
||||
const url = this._resolveUrl(path);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch binary: ${url} (${response.status})`);
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file as Blob
|
||||
*/
|
||||
async readBlob(path: string): Promise<Blob> {
|
||||
const url = this._resolveUrl(path);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blob: ${url} (${response.status})`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists (via HEAD request)
|
||||
*/
|
||||
async exists(path: string): Promise<boolean> {
|
||||
const url = this._resolveUrl(path);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path to URL
|
||||
* Handles GUID, relative path, and absolute path
|
||||
*/
|
||||
private _resolveUrl(path: string): string {
|
||||
// Check if it's a GUID and we have a catalog
|
||||
if (this._catalog && this._isGuid(path)) {
|
||||
const entry = this._catalog.entries[path];
|
||||
if (entry) {
|
||||
return this._pathToUrl(entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an absolute Windows path (e.g., F:\...)
|
||||
if (/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
// Try to extract relative path from absolute path
|
||||
const relativePath = this._extractRelativePath(path);
|
||||
if (relativePath) {
|
||||
return this._pathToUrl(relativePath);
|
||||
}
|
||||
// Fallback: use just the filename
|
||||
const filename = path.split(/[\\/]/).pop();
|
||||
return `${this._baseUrl}/${filename}`;
|
||||
}
|
||||
|
||||
// Check if it's already a URL
|
||||
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('/')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Treat as relative path
|
||||
return this._pathToUrl(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert relative path to URL
|
||||
*/
|
||||
private _pathToUrl(relativePath: string): string {
|
||||
// Normalize path separators
|
||||
const normalized = relativePath.replace(/\\/g, '/');
|
||||
|
||||
// Remove leading 'assets/' if baseUrl already includes it
|
||||
let cleanPath = normalized;
|
||||
if (cleanPath.startsWith('assets/') && this._baseUrl.endsWith('/assets')) {
|
||||
cleanPath = cleanPath.substring(7);
|
||||
}
|
||||
|
||||
// Ensure no double slashes
|
||||
const base = this._baseUrl.endsWith('/') ? this._baseUrl.slice(0, -1) : this._baseUrl;
|
||||
const path = cleanPath.startsWith('/') ? cleanPath.slice(1) : cleanPath;
|
||||
|
||||
return `${base}/${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract relative path from absolute path
|
||||
*/
|
||||
private _extractRelativePath(absolutePath: string): string | null {
|
||||
const normalized = absolutePath.replace(/\\/g, '/');
|
||||
|
||||
// Look for 'assets/' in the path
|
||||
const assetsIndex = normalized.toLowerCase().indexOf('/assets/');
|
||||
if (assetsIndex >= 0) {
|
||||
return normalized.substring(assetsIndex + 1); // Include 'assets/'
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string looks like a GUID
|
||||
*/
|
||||
private _isGuid(str: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset metadata from catalog
|
||||
* 从目录获取资产元数据
|
||||
*/
|
||||
getAssetMetadata(guidOrPath: string): IAssetCatalogEntry | null {
|
||||
if (!this._catalog) return null;
|
||||
|
||||
// Try as GUID
|
||||
if (this._catalog.entries[guidOrPath]) {
|
||||
return this._catalog.entries[guidOrPath];
|
||||
}
|
||||
|
||||
// Try as path
|
||||
for (const entry of Object.values(this._catalog.entries)) {
|
||||
if (entry.path === guidOrPath) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assets of a specific type
|
||||
* 获取指定类型的所有资产
|
||||
*/
|
||||
getAssetsByType(type: string): IAssetCatalogEntry[] {
|
||||
if (!this._catalog) return [];
|
||||
|
||||
return Object.values(this._catalog.entries)
|
||||
.filter(entry => entry.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
* 清除缓存
|
||||
*/
|
||||
clearCache(): void {
|
||||
this._cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get catalog
|
||||
* 获取目录
|
||||
*/
|
||||
get catalog(): IAssetCatalog | null {
|
||||
return this._catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if initialized
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this._initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose service and clear resources
|
||||
* Required by IService interface
|
||||
*/
|
||||
dispose(): void {
|
||||
this._cache.clear();
|
||||
this._catalog = null;
|
||||
this._initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and register browser file system service
|
||||
*/
|
||||
export function createBrowserFileSystem(options?: BrowserFileSystemOptions): BrowserFileSystemService {
|
||||
return new BrowserFileSystemService(options);
|
||||
}
|
||||
449
packages/engine/runtime-core/src/services/RuntimeSceneManager.ts
Normal file
449
packages/engine/runtime-core/src/services/RuntimeSceneManager.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 运行时场景管理器
|
||||
* Runtime Scene Manager
|
||||
*
|
||||
* 提供场景加载和切换 API,供用户脚本使用。
|
||||
* Provides scene loading and transition API for user scripts.
|
||||
*
|
||||
* 生命周期设计 | Lifecycle Design:
|
||||
* - reset(): 清理会话状态,保留核心功能(用于 Play/Stop 切换)
|
||||
* - dispose(): 完全销毁,释放所有资源(用于编辑器关闭)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 在用户脚本中获取场景管理器
|
||||
* // Get scene manager in user script
|
||||
* const sceneManager = services.get(RuntimeSceneManagerToken);
|
||||
*
|
||||
* // 加载场景(按名称)
|
||||
* // Load scene by name
|
||||
* await sceneManager.loadScene('GameScene');
|
||||
*
|
||||
* // 加载场景(按路径)
|
||||
* // Load scene by path
|
||||
* await sceneManager.loadSceneByPath('./scenes/Level1.ecs');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createServiceToken } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 场景信息
|
||||
* Scene info
|
||||
*/
|
||||
export interface SceneInfo {
|
||||
/** 场景名称 | Scene name */
|
||||
name: string;
|
||||
/** 场景路径(相对于构建输出目录)| Scene path (relative to build output) */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景加载选项
|
||||
* Scene load options
|
||||
*/
|
||||
export interface SceneLoadOptions {
|
||||
/**
|
||||
* 是否显示加载界面
|
||||
* Whether to show loading screen
|
||||
*/
|
||||
showLoading?: boolean;
|
||||
|
||||
/**
|
||||
* 过渡效果类型
|
||||
* Transition effect type
|
||||
*/
|
||||
transition?: 'none' | 'fade' | 'slide';
|
||||
|
||||
/**
|
||||
* 过渡持续时间(毫秒)
|
||||
* Transition duration in milliseconds
|
||||
*/
|
||||
transitionDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景加载器函数类型
|
||||
* Scene loader function type
|
||||
*/
|
||||
export type SceneLoader = (url: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 运行时场景管理器接口
|
||||
* Runtime Scene Manager Interface
|
||||
*
|
||||
* 生命周期方法 | Lifecycle Methods:
|
||||
* - reset(): 重置会话状态(Play/Stop 切换时调用)
|
||||
* - dispose(): 完全销毁(编辑器关闭时调用)
|
||||
*/
|
||||
export interface IRuntimeSceneManager {
|
||||
/**
|
||||
* 获取当前场景名称
|
||||
* Get current scene name
|
||||
*/
|
||||
readonly currentSceneName: string | null;
|
||||
|
||||
/**
|
||||
* 获取可用场景列表
|
||||
* Get available scene list
|
||||
*/
|
||||
readonly availableScenes: readonly SceneInfo[];
|
||||
|
||||
/**
|
||||
* 是否正在加载场景
|
||||
* Whether a scene is currently loading
|
||||
*/
|
||||
readonly isLoading: boolean;
|
||||
|
||||
/**
|
||||
* 注册可用场景
|
||||
* Register available scenes
|
||||
*/
|
||||
registerScenes(scenes: SceneInfo[]): void;
|
||||
|
||||
/**
|
||||
* 按名称加载场景
|
||||
* Load scene by name
|
||||
*/
|
||||
loadScene(sceneName: string, options?: SceneLoadOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* 按路径加载场景
|
||||
* Load scene by path
|
||||
*/
|
||||
loadSceneByPath(path: string, options?: SceneLoadOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* 重新加载当前场景
|
||||
* Reload current scene
|
||||
*/
|
||||
reloadCurrentScene(options?: SceneLoadOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* 添加场景加载开始监听器
|
||||
* Add scene load start listener
|
||||
*/
|
||||
onLoadStart(callback: (sceneName: string) => void): () => void;
|
||||
|
||||
/**
|
||||
* 添加场景加载完成监听器
|
||||
* Add scene load complete listener
|
||||
*/
|
||||
onLoadComplete(callback: (sceneName: string) => void): () => void;
|
||||
|
||||
/**
|
||||
* 添加场景加载错误监听器
|
||||
* Add scene load error listener
|
||||
*/
|
||||
onLoadError(callback: (error: Error, sceneName: string) => void): () => void;
|
||||
|
||||
/**
|
||||
* 设置场景加载器
|
||||
* Set scene loader
|
||||
*
|
||||
* 用于更新场景加载函数(如 Play 模式切换时)。
|
||||
* Used to update scene loader function (e.g., during Play mode transitions).
|
||||
*/
|
||||
setSceneLoader(loader: SceneLoader): void;
|
||||
|
||||
/**
|
||||
* 设置基础 URL
|
||||
* Set base URL
|
||||
*/
|
||||
setBaseUrl(baseUrl: string): void;
|
||||
|
||||
/**
|
||||
* 重置会话状态
|
||||
* Reset session state
|
||||
*
|
||||
* 清理监听器和当前场景状态,但保留 sceneLoader。
|
||||
* 用于 Play/Stop 切换时调用。
|
||||
*
|
||||
* Clears listeners and current scene state, but keeps sceneLoader.
|
||||
* Called during Play/Stop transitions.
|
||||
*/
|
||||
reset(): void;
|
||||
|
||||
/**
|
||||
* 完全释放资源
|
||||
* Dispose all resources
|
||||
*
|
||||
* 销毁实例,清理所有资源。
|
||||
* 仅在编辑器关闭时调用。
|
||||
*
|
||||
* Destroys the instance, cleans up all resources.
|
||||
* Only called when editor closes.
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时场景管理器服务令牌
|
||||
* Runtime Scene Manager Service Token
|
||||
*/
|
||||
export const RuntimeSceneManagerToken = createServiceToken<IRuntimeSceneManager>('runtimeSceneManager');
|
||||
|
||||
/**
|
||||
* 运行时场景管理器实现
|
||||
* Runtime Scene Manager Implementation
|
||||
*
|
||||
* 实现 IService 接口以兼容 ServiceContainer。
|
||||
* Implements IService for ServiceContainer compatibility.
|
||||
*/
|
||||
export class RuntimeSceneManager implements IRuntimeSceneManager {
|
||||
private _scenes = new Map<string, SceneInfo>();
|
||||
private _currentSceneName: string | null = null;
|
||||
private _currentScenePath: string | null = null;
|
||||
private _isLoading = false;
|
||||
private _sceneLoader: SceneLoader | null = null;
|
||||
private _baseUrl: string;
|
||||
private _disposed = false;
|
||||
|
||||
// 事件监听器 | Event listeners
|
||||
private _loadStartListeners = new Set<(sceneName: string) => void>();
|
||||
private _loadCompleteListeners = new Set<(sceneName: string) => void>();
|
||||
private _loadErrorListeners = new Set<(error: Error, sceneName: string) => void>();
|
||||
|
||||
/**
|
||||
* 创建运行时场景管理器
|
||||
* Create runtime scene manager
|
||||
*
|
||||
* @param sceneLoader 场景加载函数 | Scene loader function
|
||||
* @param baseUrl 场景文件基础 URL | Scene files base URL
|
||||
*/
|
||||
constructor(sceneLoader: SceneLoader, baseUrl: string = './scenes') {
|
||||
this._sceneLoader = sceneLoader;
|
||||
this._baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
get currentSceneName(): string | null {
|
||||
return this._currentSceneName;
|
||||
}
|
||||
|
||||
get availableScenes(): readonly SceneInfo[] {
|
||||
return Array.from(this._scenes.values());
|
||||
}
|
||||
|
||||
get isLoading(): boolean {
|
||||
return this._isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置场景加载器
|
||||
* Set scene loader
|
||||
*/
|
||||
setSceneLoader(loader: SceneLoader): void {
|
||||
this._sceneLoader = loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置基础 URL
|
||||
* Set base URL
|
||||
*/
|
||||
setBaseUrl(baseUrl: string): void {
|
||||
this._baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
registerScenes(scenes: SceneInfo[]): void {
|
||||
for (const scene of scenes) {
|
||||
this._scenes.set(scene.name, scene);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从目录或配置自动发现场景
|
||||
* Auto-discover scenes from catalog or config
|
||||
*/
|
||||
registerScenesFromCatalog(
|
||||
catalog: { scenes?: Array<{ name: string; path: string }> }
|
||||
): void {
|
||||
if (catalog.scenes) {
|
||||
this.registerScenes(catalog.scenes);
|
||||
}
|
||||
}
|
||||
|
||||
async loadScene(sceneName: string, options?: SceneLoadOptions): Promise<void> {
|
||||
const sceneInfo = this._scenes.get(sceneName);
|
||||
if (!sceneInfo) {
|
||||
// 尝试使用场景名作为路径
|
||||
// Try using scene name as path
|
||||
const guessedPath = `${this._baseUrl}/${sceneName}.ecs`;
|
||||
return this.loadSceneByPath(guessedPath, options);
|
||||
}
|
||||
|
||||
return this.loadSceneByPath(sceneInfo.path, options);
|
||||
}
|
||||
|
||||
async loadSceneByPath(path: string, options?: SceneLoadOptions): Promise<void> {
|
||||
if (!this._sceneLoader) {
|
||||
throw new Error('[RuntimeSceneManager] Scene loader not set');
|
||||
}
|
||||
|
||||
if (this._isLoading) {
|
||||
console.warn('[RuntimeSceneManager] Scene is already loading, ignoring request');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建完整 URL | Build full URL
|
||||
// Check if path is already absolute (http, relative ./, Unix /, or Windows drive letter)
|
||||
// 检查路径是否已经是绝对路径(http、相对 ./、Unix /、或 Windows 驱动器号)
|
||||
let fullPath = path;
|
||||
const isAbsolutePath = path.startsWith('http') ||
|
||||
path.startsWith('./') ||
|
||||
path.startsWith('/') ||
|
||||
(path.length > 1 && path[1] === ':'); // Windows absolute path like C:\ or F:\
|
||||
|
||||
if (!isAbsolutePath) {
|
||||
fullPath = `${this._baseUrl}/${path}`;
|
||||
}
|
||||
|
||||
// 提取场景名称 | Extract scene name
|
||||
const sceneName = this._extractSceneName(path);
|
||||
|
||||
this._isLoading = true;
|
||||
this._notifyLoadStart(sceneName);
|
||||
|
||||
try {
|
||||
// TODO: 实现过渡效果 | TODO: Implement transition effects
|
||||
// if (options?.transition && options.transition !== 'none') {
|
||||
// await this._startTransition(options.transition, options.transitionDuration);
|
||||
// }
|
||||
|
||||
await this._sceneLoader(fullPath);
|
||||
|
||||
this._currentSceneName = sceneName;
|
||||
this._currentScenePath = fullPath;
|
||||
this._isLoading = false;
|
||||
|
||||
this._notifyLoadComplete(sceneName);
|
||||
|
||||
console.log(`[RuntimeSceneManager] Scene loaded: ${sceneName}`);
|
||||
} catch (error) {
|
||||
this._isLoading = false;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this._notifyLoadError(err, sceneName);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadCurrentScene(options?: SceneLoadOptions): Promise<void> {
|
||||
if (!this._currentScenePath) {
|
||||
throw new Error('[RuntimeSceneManager] No current scene to reload');
|
||||
}
|
||||
|
||||
return this.loadSceneByPath(this._currentScenePath, options);
|
||||
}
|
||||
|
||||
onLoadStart(callback: (sceneName: string) => void): () => void {
|
||||
this._loadStartListeners.add(callback);
|
||||
return () => this._loadStartListeners.delete(callback);
|
||||
}
|
||||
|
||||
onLoadComplete(callback: (sceneName: string) => void): () => void {
|
||||
this._loadCompleteListeners.add(callback);
|
||||
return () => this._loadCompleteListeners.delete(callback);
|
||||
}
|
||||
|
||||
onLoadError(callback: (error: Error, sceneName: string) => void): () => void {
|
||||
this._loadErrorListeners.add(callback);
|
||||
return () => this._loadErrorListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查场景是否已注册
|
||||
* Check if scene is registered
|
||||
*/
|
||||
hasScene(sceneName: string): boolean {
|
||||
return this._scenes.has(sceneName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景路径
|
||||
* Get scene path
|
||||
*/
|
||||
getScenePath(sceneName: string): string | null {
|
||||
return this._scenes.get(sceneName)?.path ?? null;
|
||||
}
|
||||
|
||||
// ==================== 私有方法 | Private Methods ====================
|
||||
|
||||
private _extractSceneName(path: string): string {
|
||||
// 从路径中提取场景名称 | Extract scene name from path
|
||||
// ./scenes/Level1.ecs -> Level1
|
||||
// scenes/GameScene.ecs -> GameScene
|
||||
const fileName = path.split('/').pop() || path;
|
||||
return fileName.replace(/\.ecs$/, '');
|
||||
}
|
||||
|
||||
private _notifyLoadStart(sceneName: string): void {
|
||||
for (const listener of this._loadStartListeners) {
|
||||
try {
|
||||
listener(sceneName);
|
||||
} catch (e) {
|
||||
console.error('[RuntimeSceneManager] Error in load start listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _notifyLoadComplete(sceneName: string): void {
|
||||
for (const listener of this._loadCompleteListeners) {
|
||||
try {
|
||||
listener(sceneName);
|
||||
} catch (e) {
|
||||
console.error('[RuntimeSceneManager] Error in load complete listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _notifyLoadError(error: Error, sceneName: string): void {
|
||||
for (const listener of this._loadErrorListeners) {
|
||||
try {
|
||||
listener(error, sceneName);
|
||||
} catch (e) {
|
||||
console.error('[RuntimeSceneManager] Error in load error listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期方法 | Lifecycle Methods ====================
|
||||
|
||||
/**
|
||||
* 重置会话状态
|
||||
* Reset session state
|
||||
*
|
||||
* 清理监听器和当前场景状态,但保留 sceneLoader 和 baseUrl。
|
||||
* 用于 Play/Stop 切换时调用,允许实例复用。
|
||||
*
|
||||
* Clears listeners and current scene state, but keeps sceneLoader and baseUrl.
|
||||
* Called during Play/Stop transitions, allows instance reuse.
|
||||
*/
|
||||
reset(): void {
|
||||
this._loadStartListeners.clear();
|
||||
this._loadCompleteListeners.clear();
|
||||
this._loadErrorListeners.clear();
|
||||
this._scenes.clear();
|
||||
this._currentSceneName = null;
|
||||
this._currentScenePath = null;
|
||||
this._isLoading = false;
|
||||
// 注意:保留 _sceneLoader 和 _baseUrl
|
||||
// Note: Keep _sceneLoader and _baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 完全释放资源
|
||||
* Dispose all resources
|
||||
*
|
||||
* 销毁实例,清理所有资源包括 sceneLoader。
|
||||
* 仅在编辑器完全关闭时调用。
|
||||
*
|
||||
* Destroys the instance, cleans up all resources including sceneLoader.
|
||||
* Only called when editor completely closes.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
|
||||
this.reset();
|
||||
this._sceneLoader = null;
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user