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:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View File

@@ -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);
}

View 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;
}
}