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:
306
packages/runtime-core/src/services/BrowserFileSystemService.ts
Normal file
306
packages/runtime-core/src/services/BrowserFileSystemService.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user