Feature/editor optimization (#251)

* refactor: 编辑器/运行时架构拆分与构建系统升级

* feat(core): 层级系统重构与UI变换矩阵修复

* refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题

* fix(physics): 修复跨包组件类引用问题

* feat: 统一运行时架构与浏览器运行支持

* feat(asset): 实现浏览器运行时资产加载系统

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误

* test: 补齐核心模块测试用例,修复CI构建配置

* fix: 修复测试用例中的类型错误和断言问题

* fix: 修复 turbo build:npm 任务的依赖顺序问题

* fix: 修复 CI 构建错误并优化构建性能
This commit is contained in:
YHH
2025-12-01 22:28:51 +08:00
committed by GitHub
parent 189714c727
commit b42a7b4e43
468 changed files with 18301 additions and 9075 deletions

View File

@@ -0,0 +1,306 @@
/**
* 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.
*/
/**
* Asset catalog entry
*/
export interface AssetCatalogEntry {
guid: string;
path: string;
type: string;
size: number;
hash: string;
}
/**
* Asset catalog loaded from JSON
*/
export interface AssetCatalog {
version: string;
createdAt: number;
entries: Record<string, AssetCatalogEntry>;
}
/**
* 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.
*/
export class BrowserFileSystemService {
private _baseUrl: string;
private _catalogUrl: string;
private _catalog: AssetCatalog | null = null;
private _cache = new Map<string, string>();
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;
console.log('[BrowserFileSystem] Initialized with',
Object.keys(this._catalog?.entries ?? {}).length, 'assets');
} 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}`);
}
this._catalog = await response.json();
}
/**
* 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): AssetCatalogEntry | 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): AssetCatalogEntry[] {
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(): AssetCatalog | 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);
}