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,71 @@
{
"id": "asset-system",
"name": "@esengine/asset-system",
"globalKey": "assetSystem",
"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",
"AudioLoader",
"PrefabLoader"
],
"other": [
"AssetManager",
"AssetDatabase",
"AssetCache"
]
},
"assetExtensions": {
".png": "texture",
".jpg": "texture",
".jpeg": "texture",
".gif": "texture",
".webp": "texture",
".bmp": "texture",
".svg": "texture",
".mp3": "audio",
".ogg": "audio",
".wav": "audio",
".m4a": "audio",
".aac": "audio",
".flac": "audio",
".json": "data",
".xml": "data",
".yaml": "data",
".yml": "data",
".txt": "text",
".ttf": "font",
".woff": "font",
".woff2": "font",
".otf": "font",
".fnt": "font",
".atlas": "atlas",
".prefab": "prefab"
},
"requiresWasm": false,
"outputPath": "dist/index.js"
}

View File

@@ -0,0 +1,52 @@
{
"name": "@esengine/asset-system",
"version": "1.0.0",
"description": "Asset management system for ES Engine",
"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",
"resource",
"bundle"
],
"author": "yhh",
"license": "MIT",
"devDependencies": {
"@esengine/build-config": "workspace:*",
"@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"rimraf": "^5.0.0",
"tsup": "^8.0.0",
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/esengine/esengine.git",
"directory": "packages/asset-system"
},
"dependencies": {
"@types/pako": "^2.0.4",
"pako": "^2.1.0"
}
}

View File

@@ -0,0 +1,278 @@
/**
* 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;
/**
* 需要预加载的包名列表 | List of bundle names to preload
* 如果未指定,默认预加载 'core' 和 'main' 包
* If not specified, defaults to preloading 'core' and 'main' bundles
*/
preloadBundles?: string[];
}
/**
* 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';
}

View File

@@ -0,0 +1,130 @@
/**
* Asset cache implementation
* 资产缓存实现
*/
import { AssetGUID } from '../types/AssetTypes';
/**
* Cache entry
* 缓存条目
*/
interface CacheEntry {
guid: AssetGUID;
asset: unknown;
lastAccessTime: number;
accessCount: number;
}
/**
* Asset cache implementation
* 资产缓存实现
*/
export class AssetCache {
private readonly _cache = new Map<AssetGUID, CacheEntry>();
constructor() {
// 无配置,无限制缓存 / No config, unlimited cache
}
/**
* Get cached asset
* 获取缓存的资产
*/
get<T = unknown>(guid: AssetGUID): T | null {
const entry = this._cache.get(guid);
if (!entry) return null;
// 更新访问信息 / Update access info
entry.lastAccessTime = Date.now();
entry.accessCount++;
return entry.asset as T;
}
/**
* Set cached asset
* 设置缓存的资产
*/
set<T = unknown>(guid: AssetGUID, asset: T): void {
const now = Date.now();
const entry: CacheEntry = {
guid,
asset,
lastAccessTime: now,
accessCount: 1
};
// 如果已存在,更新 / Update if exists
const oldEntry = this._cache.get(guid);
if (oldEntry) {
entry.accessCount = oldEntry.accessCount + 1;
}
this._cache.set(guid, entry);
}
/**
* Check if asset is cached
* 检查资产是否缓存
*/
has(guid: AssetGUID): boolean {
return this._cache.has(guid);
}
/**
* Remove asset from cache
* 从缓存移除资产
*/
remove(guid: AssetGUID): void {
this._cache.delete(guid);
}
/**
* Clear all cache
* 清空所有缓存
*/
clear(): void {
this._cache.clear();
}
/**
* Get cache size
* 获取缓存大小
*/
getSize(): number {
return this._cache.size;
}
/**
* Get all cached GUIDs
* 获取所有缓存的GUID
*/
getAllGuids(): AssetGUID[] {
return Array.from(this._cache.keys());
}
/**
* Get cache statistics
* 获取缓存统计
*/
getStatistics(): {
count: number;
entries: Array<{
guid: AssetGUID;
accessCount: number;
lastAccessTime: number;
}>;
} {
const entries = Array.from(this._cache.values()).map((entry) => ({
guid: entry.guid,
accessCount: entry.accessCount,
lastAccessTime: entry.lastAccessTime
}));
return {
count: this._cache.size,
entries
};
}
}

View File

@@ -0,0 +1,577 @@
/**
* Asset database for managing asset metadata
* 用于管理资产元数据的资产数据库
*/
import {
AssetGUID,
AssetType,
IAssetMetadata,
IAssetCatalogEntry
} from '../types/AssetTypes';
/**
* 纹理 Sprite 信息(从 meta 文件的 importSettings 读取)
* Texture sprite info (read from meta file's importSettings)
*/
export interface ITextureSpriteInfo {
/**
* 九宫格切片边距 [top, right, bottom, left]
* Nine-patch slice border
*/
sliceBorder?: [number, number, number, number];
/**
* Sprite 锚点 [x, y]0-1 归一化)
* Sprite pivot point (0-1 normalized)
*/
pivot?: [number, number];
/**
* 纹理宽度(可选,需要纹理已加载)
* Texture width (optional, requires texture to be loaded)
*/
width?: number;
/**
* 纹理高度(可选,需要纹理已加载)
* Texture height (optional, requires texture to be loaded)
*/
height?: number;
}
/**
* Sprite settings in import settings
* 导入设置中的 Sprite 设置
*/
interface ISpriteSettings {
sliceBorder?: [number, number, number, number];
pivot?: [number, number];
pixelsPerUnit?: number;
/** Texture width (from import settings) | 纹理宽度(来自导入设置) */
width?: number;
/** Texture height (from import settings) | 纹理高度(来自导入设置) */
height?: number;
}
/**
* Asset database implementation
* 资产数据库实现
*/
export class AssetDatabase {
private readonly _metadata = new Map<AssetGUID, IAssetMetadata>();
private readonly _pathToGuid = new Map<string, AssetGUID>();
private readonly _typeToGuids = new Map<AssetType, Set<AssetGUID>>();
private readonly _labelToGuids = new Map<string, Set<AssetGUID>>();
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
* 添加资产到数据库
*/
addAsset(metadata: IAssetMetadata): void {
const { guid, path, type, labels, dependencies } = metadata;
// 存储元数据 / Store metadata
this._metadata.set(guid, metadata);
this._pathToGuid.set(path, guid);
// 按类型索引 / Index by type
if (!this._typeToGuids.has(type)) {
this._typeToGuids.set(type, new Set());
}
this._typeToGuids.get(type)!.add(guid);
// 按标签索引 / Index by labels
labels.forEach((label) => {
if (!this._labelToGuids.has(label)) {
this._labelToGuids.set(label, new Set());
}
this._labelToGuids.get(label)!.add(guid);
});
// 建立依赖关系 / Establish dependencies
this.updateDependencies(guid, dependencies);
}
/**
* Remove asset from database
* 从数据库移除资产
*/
removeAsset(guid: AssetGUID): void {
const metadata = this._metadata.get(guid);
if (!metadata) return;
// 清理元数据 / Clean up metadata
this._metadata.delete(guid);
this._pathToGuid.delete(metadata.path);
// 清理类型索引 / Clean up type index
const typeSet = this._typeToGuids.get(metadata.type);
if (typeSet) {
typeSet.delete(guid);
if (typeSet.size === 0) {
this._typeToGuids.delete(metadata.type);
}
}
// 清理标签索引 / Clean up label indices
metadata.labels.forEach((label) => {
const labelSet = this._labelToGuids.get(label);
if (labelSet) {
labelSet.delete(guid);
if (labelSet.size === 0) {
this._labelToGuids.delete(label);
}
}
});
// 清理依赖关系 / Clean up dependencies
this.clearDependencies(guid);
}
/**
* Update asset metadata
* 更新资产元数据
*/
updateAsset(guid: AssetGUID, updates: Partial<IAssetMetadata>): void {
const metadata = this._metadata.get(guid);
if (!metadata) return;
// 如果路径改变,更新索引 / Update index if path changed
if (updates.path && updates.path !== metadata.path) {
this._pathToGuid.delete(metadata.path);
this._pathToGuid.set(updates.path, guid);
}
// 如果类型改变,更新索引 / Update index if type changed
if (updates.type && updates.type !== metadata.type) {
const oldTypeSet = this._typeToGuids.get(metadata.type);
if (oldTypeSet) {
oldTypeSet.delete(guid);
}
if (!this._typeToGuids.has(updates.type)) {
this._typeToGuids.set(updates.type, new Set());
}
this._typeToGuids.get(updates.type)!.add(guid);
}
// 如果依赖改变,更新关系 / Update relations if dependencies changed
if (updates.dependencies) {
this.updateDependencies(guid, updates.dependencies);
}
// 合并更新 / Merge updates
Object.assign(metadata, updates);
metadata.lastModified = Date.now();
metadata.version++;
}
/**
* Get asset metadata
* 获取资产元数据
*/
getMetadata(guid: AssetGUID): IAssetMetadata | undefined {
return this._metadata.get(guid);
}
/**
* Get metadata by path
* 通过路径获取元数据
*/
getMetadataByPath(path: string): IAssetMetadata | undefined {
const guid = this._pathToGuid.get(path);
return guid ? this._metadata.get(guid) : undefined;
}
/**
* Get texture sprite info from metadata
* 从元数据获取纹理 Sprite 信息
*
* Extracts spriteSettings from importSettings if available.
* 如果可用,从 importSettings 提取 spriteSettings。
*
* @param guid - Texture asset GUID | 纹理资产 GUID
* @returns Sprite info or undefined if not found/not a texture | Sprite 信息或未找到/非纹理则为 undefined
*/
getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
const metadata = this._metadata.get(guid);
if (!metadata) return undefined;
// Check if it's a texture asset
// 检查是否是纹理资产
if (metadata.type !== AssetType.Texture) return undefined;
// Extract spriteSettings from importSettings
// 从 importSettings 提取 spriteSettings
const importSettings = metadata.importSettings as Record<string, unknown> | undefined;
const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined;
if (!spriteSettings) return undefined;
return {
sliceBorder: spriteSettings.sliceBorder,
pivot: spriteSettings.pivot,
// Include dimensions from import settings if available
// 如果可用,包含来自导入设置的尺寸
width: spriteSettings.width,
height: spriteSettings.height
};
}
/**
* Find assets by type
* 按类型查找资产
*/
findAssetsByType(type: AssetType): AssetGUID[] {
const guids = this._typeToGuids.get(type);
return guids ? Array.from(guids) : [];
}
/**
* Find assets by label
* 按标签查找资产
*/
findAssetsByLabel(label: string): AssetGUID[] {
const guids = this._labelToGuids.get(label);
return guids ? Array.from(guids) : [];
}
/**
* Find assets by multiple labels (AND operation)
* 按多个标签查找资产AND操作
*/
findAssetsByLabels(labels: string[]): AssetGUID[] {
if (labels.length === 0) return [];
let result: Set<AssetGUID> | null = null;
for (const label of labels) {
const labelGuids = this._labelToGuids.get(label);
if (!labelGuids || labelGuids.size === 0) return [];
if (!result) {
result = new Set(labelGuids);
} else {
// 交集 / Intersection
const intersection = new Set<AssetGUID>();
labelGuids.forEach((guid) => {
if (result!.has(guid)) {
intersection.add(guid);
}
});
result = intersection;
}
}
return result ? Array.from(result) : [];
}
/**
* Search assets by query
* 通过查询搜索资产
*/
searchAssets(query: {
name?: string;
type?: AssetType;
labels?: string[];
path?: string;
}): AssetGUID[] {
let results = Array.from(this._metadata.keys());
// 按名称过滤 / Filter by name
if (query.name) {
const nameLower = query.name.toLowerCase();
results = results.filter((guid) => {
const metadata = this._metadata.get(guid)!;
return metadata.name.toLowerCase().includes(nameLower);
});
}
// 按类型过滤 / Filter by type
if (query.type) {
const typeGuids = this._typeToGuids.get(query.type);
if (!typeGuids) return [];
results = results.filter((guid) => typeGuids.has(guid));
}
// 按标签过滤 / Filter by labels
if (query.labels && query.labels.length > 0) {
const labelResults = this.findAssetsByLabels(query.labels);
const labelSet = new Set(labelResults);
results = results.filter((guid) => labelSet.has(guid));
}
// 按路径过滤 / Filter by path
if (query.path) {
const pathLower = query.path.toLowerCase();
results = results.filter((guid) => {
const metadata = this._metadata.get(guid)!;
return metadata.path.toLowerCase().includes(pathLower);
});
}
return results;
}
/**
* Get asset dependencies
* 获取资产依赖
*/
getDependencies(guid: AssetGUID): AssetGUID[] {
const deps = this._dependencies.get(guid);
return deps ? Array.from(deps) : [];
}
/**
* Get asset dependents (assets that depend on this one)
* 获取资产的依赖者(依赖此资产的其他资产)
*/
getDependents(guid: AssetGUID): AssetGUID[] {
const deps = this._dependents.get(guid);
return deps ? Array.from(deps) : [];
}
/**
* Get all dependencies recursively
* 递归获取所有依赖
*/
getAllDependencies(guid: AssetGUID, visited = new Set<AssetGUID>()): AssetGUID[] {
if (visited.has(guid)) return [];
visited.add(guid);
const result: AssetGUID[] = [];
const directDeps = this.getDependencies(guid);
for (const dep of directDeps) {
result.push(dep);
const transitiveDeps = this.getAllDependencies(dep, visited);
result.push(...transitiveDeps);
}
return result;
}
/**
* Check for circular dependencies
* 检查循环依赖
*/
hasCircularDependency(guid: AssetGUID): boolean {
const visited = new Set<AssetGUID>();
const recursionStack = new Set<AssetGUID>();
const checkCycle = (current: AssetGUID): boolean => {
visited.add(current);
recursionStack.add(current);
const deps = this.getDependencies(current);
for (const dep of deps) {
if (!visited.has(dep)) {
if (checkCycle(dep)) return true;
} else if (recursionStack.has(dep)) {
return true;
}
}
recursionStack.delete(current);
return false;
};
return checkCycle(guid);
}
/**
* Update dependencies
* 更新依赖关系
*/
private updateDependencies(guid: AssetGUID, newDependencies: AssetGUID[]): void {
// 清除旧的依赖关系 / Clear old dependencies
this.clearDependencies(guid);
// 建立新的依赖关系 / Establish new dependencies
if (newDependencies.length > 0) {
this._dependencies.set(guid, new Set(newDependencies));
// 更新被依赖关系 / Update dependent relations
newDependencies.forEach((dep) => {
if (!this._dependents.has(dep)) {
this._dependents.set(dep, new Set());
}
this._dependents.get(dep)!.add(guid);
});
}
}
/**
* Clear dependencies
* 清除依赖关系
*/
private clearDependencies(guid: AssetGUID): void {
// 清除依赖 / Clear dependencies
const deps = this._dependencies.get(guid);
if (deps) {
deps.forEach((dep) => {
const dependents = this._dependents.get(dep);
if (dependents) {
dependents.delete(guid);
if (dependents.size === 0) {
this._dependents.delete(dep);
}
}
});
this._dependencies.delete(guid);
}
// 清除被依赖 / Clear dependents
const dependents = this._dependents.get(guid);
if (dependents) {
dependents.forEach((dependent) => {
const dependencies = this._dependencies.get(dependent);
if (dependencies) {
dependencies.delete(guid);
if (dependencies.size === 0) {
this._dependencies.delete(dependent);
}
}
});
this._dependents.delete(guid);
}
}
/**
* Get database statistics
* 获取数据库统计
*/
getStatistics(): {
totalAssets: number;
assetsByType: Map<AssetType, number>;
totalDependencies: number;
assetsWithDependencies: number;
circularDependencies: number;
} {
const assetsByType = new Map<AssetType, number>();
this._typeToGuids.forEach((guids, type) => {
assetsByType.set(type, guids.size);
});
let circularDependencies = 0;
this._metadata.forEach((_, guid) => {
if (this.hasCircularDependency(guid)) {
circularDependencies++;
}
});
return {
totalAssets: this._metadata.size,
assetsByType,
totalDependencies: Array.from(this._dependencies.values()).reduce(
(sum, deps) => sum + deps.size,
0
),
assetsWithDependencies: this._dependencies.size,
circularDependencies
};
}
/**
* Export to catalog entries
* 导出为目录条目
*/
exportToCatalog(): IAssetCatalogEntry[] {
const entries: IAssetCatalogEntry[] = [];
this._metadata.forEach((metadata) => {
entries.push({
guid: metadata.guid,
path: metadata.path,
type: metadata.type,
size: metadata.size,
hash: metadata.hash
});
});
return entries;
}
/**
* Clear database
* 清空数据库
*/
clear(): void {
this._metadata.clear();
this._pathToGuid.clear();
this._typeToGuids.clear();
this._labelToGuids.clear();
this._dependencies.clear();
this._dependents.clear();
}
}

View File

@@ -0,0 +1,193 @@
/**
* Priority-based asset loading queue
* 基于优先级的资产加载队列
*/
import { AssetGUID, IAssetLoadOptions } from '../types/AssetTypes';
import { IAssetLoadQueue } from '../interfaces/IAssetManager';
/**
* Queue item
* 队列项
*/
interface QueueItem {
guid: AssetGUID;
priority: number;
options?: IAssetLoadOptions;
timestamp: number;
}
/**
* Asset load queue implementation
* 资产加载队列实现
*/
export class AssetLoadQueue implements IAssetLoadQueue {
private readonly _queue: QueueItem[] = [];
private readonly _guidToIndex = new Map<AssetGUID, number>();
/**
* Add to queue
* 添加到队列
*/
enqueue(guid: AssetGUID, priority: number, options?: IAssetLoadOptions): void {
// 检查是否已在队列中 / Check if already in queue
if (this._guidToIndex.has(guid)) {
this.reprioritize(guid, priority);
return;
}
const item: QueueItem = {
guid,
priority,
options,
timestamp: Date.now()
};
// 二分查找插入位置 / Binary search for insertion position
const index = this.findInsertIndex(priority);
this._queue.splice(index, 0, item);
// 更新索引映射 / Update index mapping
this.updateIndices(index);
}
/**
* Remove from queue
* 从队列移除
*/
dequeue(): { guid: AssetGUID; options?: IAssetLoadOptions } | null {
if (this._queue.length === 0) return null;
const item = this._queue.shift();
if (!item) return null;
// 更新索引映射 / Update index mapping
this._guidToIndex.delete(item.guid);
this.updateIndices(0);
return {
guid: item.guid,
options: item.options
};
}
/**
* Check if queue is empty
* 检查队列是否为空
*/
isEmpty(): boolean {
return this._queue.length === 0;
}
/**
* Get queue size
* 获取队列大小
*/
getSize(): number {
return this._queue.length;
}
/**
* Clear queue
* 清空队列
*/
clear(): void {
this._queue.length = 0;
this._guidToIndex.clear();
}
/**
* Reprioritize item
* 重新设置优先级
*/
reprioritize(guid: AssetGUID, newPriority: number): void {
const index = this._guidToIndex.get(guid);
if (index === undefined) return;
const item = this._queue[index];
if (!item || item.priority === newPriority) return;
// 移除旧项 / Remove old item
this._queue.splice(index, 1);
this._guidToIndex.delete(guid);
// 重新插入 / Reinsert with new priority
item.priority = newPriority;
const newIndex = this.findInsertIndex(newPriority);
this._queue.splice(newIndex, 0, item);
// 更新索引 / Update indices
this.updateIndices(Math.min(index, newIndex));
}
/**
* Find insertion index for priority
* 查找优先级的插入索引
*/
private findInsertIndex(priority: number): number {
let left = 0;
let right = this._queue.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
// 高优先级在前 / Higher priority first
if (this._queue[mid].priority >= priority) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
/**
* Update indices after modification
* 修改后更新索引
*/
private updateIndices(startIndex: number): void {
for (let i = startIndex; i < this._queue.length; i++) {
this._guidToIndex.set(this._queue[i].guid, i);
}
}
/**
* Get queue items (for debugging)
* 获取队列项(用于调试)
*/
getItems(): ReadonlyArray<{
guid: AssetGUID;
priority: number;
waitTime: number;
}> {
const now = Date.now();
return this._queue.map((item) => ({
guid: item.guid,
priority: item.priority,
waitTime: now - item.timestamp
}));
}
/**
* Remove specific item from queue
* 从队列中移除特定项
*/
remove(guid: AssetGUID): boolean {
const index = this._guidToIndex.get(guid);
if (index === undefined) return false;
this._queue.splice(index, 1);
this._guidToIndex.delete(guid);
this.updateIndices(index);
return true;
}
/**
* Check if guid is in queue
* 检查guid是否在队列中
*/
contains(guid: AssetGUID): boolean {
return this._guidToIndex.has(guid);
}
}

View File

@@ -0,0 +1,738 @@
/**
* Asset manager implementation
* 资产管理器实现
*/
import {
AssetGUID,
AssetHandle,
AssetType,
AssetState,
IAssetLoadOptions,
IAssetLoadResult,
IAssetReferenceInfo,
IAssetPreloadGroup,
IAssetLoadProgress,
IAssetMetadata,
AssetLoadError,
IAssetCatalog
} from '../types/AssetTypes';
import {
IAssetManager,
IAssetLoadQueue
} from '../interfaces/IAssetManager';
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';
import { AssetDatabase } from './AssetDatabase';
/**
* Asset entry in the manager
* 管理器中的资产条目
*/
interface AssetEntry {
guid: AssetGUID;
handle: AssetHandle;
asset: unknown;
metadata: IAssetMetadata;
state: AssetState;
referenceCount: number;
lastAccessTime: number;
loadPromise?: Promise<IAssetLoadResult>;
}
/**
* Asset manager implementation
* 资产管理器实现
*/
export class AssetManager implements IAssetManager {
private readonly _assets = new Map<AssetGUID, AssetEntry>();
private readonly _handleToGuid = new Map<AssetHandle, AssetGUID>();
private readonly _pathToGuid = new Map<string, AssetGUID>();
private readonly _cache: AssetCache;
private readonly _loadQueue: IAssetLoadQueue;
private readonly _loaderFactory: IAssetLoaderFactory;
private readonly _database: AssetDatabase;
/** Asset reader for file operations. | 用于文件操作的资产读取器。 */
private _reader: IAssetReader | null = null;
private _nextHandle: AssetHandle = 1;
private _statistics = {
loadedCount: 0,
failedCount: 0
};
private _isDisposed = false;
private _loadingCount = 0;
constructor(catalog?: IAssetCatalog) {
this._cache = new AssetCache();
this._loadQueue = new AssetLoadQueue();
this._loaderFactory = new AssetLoaderFactory();
this._database = new AssetDatabase();
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;
}
/**
* Get the loader factory.
* 获取加载器工厂。
*/
getLoaderFactory(): AssetLoaderFactory {
return this._loaderFactory as AssetLoaderFactory;
}
/**
* Initialize from catalog
* 从目录初始化
*
* Can be called after construction to load catalog entries.
* 可在构造后调用以加载目录条目。
*/
initializeFromCatalog(catalog: IAssetCatalog): void {
for (const [guid, entry] of Object.entries(catalog.entries)) {
const metadata: IAssetMetadata = {
guid,
path: entry.path,
type: entry.type,
name: entry.path.split('/').pop() || '',
size: entry.size,
hash: entry.hash,
dependencies: [],
labels: [],
tags: new Map(),
lastModified: Date.now(),
version: 1,
// Include importSettings for sprite slicing (nine-patch), etc.
// 包含 importSettings 以支持精灵切片(九宫格)等功能
importSettings: entry.importSettings
};
this._database.addAsset(metadata);
this._pathToGuid.set(entry.path, guid);
}
}
/**
* Load asset by GUID
* 通过GUID加载资产
*/
async loadAsset<T = unknown>(
guid: AssetGUID,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<T>> {
// 检查是否已加载 / Check if already loaded
const entry = this._assets.get(guid);
if (entry) {
if (entry.state === AssetState.Loaded && !options?.forceReload) {
entry.lastAccessTime = Date.now();
return {
asset: entry.asset as T,
handle: entry.handle,
metadata: entry.metadata,
loadTime: 0
};
}
if (entry.state === AssetState.Loading && entry.loadPromise) {
return entry.loadPromise as Promise<IAssetLoadResult<T>>;
}
}
// 获取元数据 / Get metadata
const metadata = this._database.getMetadata(guid);
if (!metadata) {
throw AssetLoadError.fileNotFound(guid, 'Unknown');
}
// 创建加载器 / Create loader
// 优先使用基于路径的加载器选择,支持多个加载器对应同一资产类型
// 例如 Model3D 类型支持 GLTF/FBX/OBJ根据扩展名选择正确的加载器
// Prefer path-based loader selection, supports multiple loaders for same asset type
// e.g., Model3D type supports GLTF/FBX/OBJ, selects correct loader by extension
let loader = this._loaderFactory.createLoaderForPath(metadata.path);
// 如果没有找到 loader 且类型是 Custom尝试重新解析类型
// If no loader found and type is Custom, try to re-resolve the type
if (!loader && metadata.type === AssetType.Custom) {
const newType = this.resolveAssetType(metadata.path);
if (newType !== AssetType.Custom) {
// 更新 metadata 类型 / Update metadata type
this._database.updateAsset(guid, { type: newType });
loader = this._loaderFactory.createLoader(newType);
}
}
if (!loader) {
throw AssetLoadError.unsupportedType(guid, metadata.type);
}
// 开始加载 / Start loading
const loadStartTime = performance.now();
const newEntry: AssetEntry = {
guid,
handle: this._nextHandle++,
asset: null,
metadata,
state: AssetState.Loading,
referenceCount: 0,
lastAccessTime: Date.now()
};
this._assets.set(guid, newEntry);
this._handleToGuid.set(newEntry.handle, guid);
this._loadingCount++;
// 创建加载Promise / Create loading promise
const loadPromise = this.performLoad<T>(loader, metadata, options, loadStartTime, newEntry);
newEntry.loadPromise = loadPromise;
try {
const result = await loadPromise;
return result;
} catch (error) {
this._statistics.failedCount++;
newEntry.state = AssetState.Failed;
throw error;
} finally {
this._loadingCount--;
delete newEntry.loadPromise;
}
}
/**
* Perform asset loading
* 执行资产加载
*/
private async performLoad<T>(
loader: IAssetLoader,
metadata: IAssetMetadata,
options: IAssetLoadOptions | undefined,
startTime: number,
entry: AssetEntry
): Promise<IAssetLoadResult<T>> {
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);
}
// Resolve absolute path.
// 解析绝对路径。
const absolutePath = this._database.resolveAbsolutePath(metadata.path);
// 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, asset);
// Update statistics.
// 更新统计。
this._statistics.loadedCount++;
return {
asset: asset as T,
handle: entry.handle,
metadata,
loadTime: performance.now() - startTime
};
}
/**
* 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}`);
}
}
/**
* Load dependencies
* 加载依赖
*/
private async loadDependencies(
dependencies: AssetGUID[],
options?: IAssetLoadOptions
): Promise<void> {
const promises = dependencies.map((dep) => this.loadAsset(dep, options));
await Promise.all(promises);
}
/**
* Load asset by path
* 通过路径加载资产
*/
async loadAssetByPath<T = unknown>(
path: string,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<T>> {
const guid = this._pathToGuid.get(path);
if (!guid) {
// 尝试从数据库查找 / Try to find from database
let metadata = this._database.getMetadataByPath(path);
if (!metadata) {
// 动态创建元数据 / Create metadata dynamically
const assetType = this.resolveAssetType(path);
// 生成唯一GUID / Generate unique GUID
const dynamicGuid = `dynamic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
metadata = {
guid: dynamicGuid,
path: path,
type: assetType,
name: path.split('/').pop() || path.split('\\').pop() || 'unnamed',
size: 0, // 动态加载时未知大小 / Unknown size for dynamic loading
hash: '',
dependencies: [],
labels: [],
tags: new Map(),
lastModified: Date.now(),
version: 1
};
// 注册到数据库 / Register to database
this._database.addAsset(metadata);
this._pathToGuid.set(path, metadata.guid);
} else {
// 如果之前缓存的类型是 Custom检查是否现在有注册的 loader 可以处理
// If previously cached as Custom, check if a registered loader can now handle it
if (metadata.type === AssetType.Custom) {
const newType = this.resolveAssetType(path);
if (newType !== AssetType.Custom) {
metadata.type = newType;
}
}
this._pathToGuid.set(path, metadata.guid);
}
return this.loadAsset<T>(metadata.guid, options);
}
// 同样检查已缓存的资产,如果类型是 Custom 但现在有 loader 可以处理
// Also check cached assets, if type is Custom but now a loader can handle it
const entry = this._assets.get(guid);
if (entry && entry.metadata.type === AssetType.Custom) {
const newType = this.resolveAssetType(path);
if (newType !== AssetType.Custom) {
entry.metadata.type = newType;
}
}
return this.loadAsset<T>(guid, options);
}
/**
* Resolve asset type from path
* 从路径解析资产类型
*/
private resolveAssetType(path: string): AssetType {
// 首先尝试从已注册的加载器获取资产类型 / First try to get asset type from registered loaders
const loaderType = (this._loaderFactory as AssetLoaderFactory).getAssetTypeByPath(path);
if (loaderType !== null) {
return loaderType;
}
// 如果没有找到匹配的加载器,使用默认的扩展名映射 / Fallback to default extension mapping
const fileExt = path.substring(path.lastIndexOf('.')).toLowerCase();
// 默认支持的基础类型 / Default supported basic types
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(fileExt)) {
return AssetType.Texture;
} else if (['.json'].includes(fileExt)) {
return AssetType.Json;
} else if (['.txt', '.md', '.xml', '.yaml'].includes(fileExt)) {
return AssetType.Text;
}
return AssetType.Custom;
}
/**
* Load multiple assets
* 批量加载资产
*/
async loadAssets(
guids: AssetGUID[],
options?: IAssetLoadOptions
): Promise<Map<AssetGUID, IAssetLoadResult>> {
const results = new Map<AssetGUID, IAssetLoadResult>();
// 并行加载所有资产 / Load all assets in parallel
const promises = guids.map(async (guid) => {
try {
const result = await this.loadAsset(guid, options);
results.set(guid, result);
} catch (error) {
console.error(`Failed to load asset ${guid}:`, error);
}
});
await Promise.all(promises);
return results;
}
/**
* Preload asset group
* 预加载资产组
*/
async preloadGroup(
group: IAssetPreloadGroup,
onProgress?: (progress: IAssetLoadProgress) => void
): Promise<void> {
const totalCount = group.assets.length;
let loadedCount = 0;
let loadedBytes = 0;
let totalBytes = 0;
// 计算总大小 / Calculate total size
for (const guid of group.assets) {
const metadata = this._database.getMetadata(guid);
if (metadata) {
totalBytes += metadata.size;
}
}
// 加载每个资产 / Load each asset
for (const guid of group.assets) {
const metadata = this._database.getMetadata(guid);
if (!metadata) continue;
if (onProgress) {
onProgress({
currentAsset: metadata.name,
loadedCount,
totalCount,
loadedBytes,
totalBytes,
progress: loadedCount / totalCount
});
}
await this.loadAsset(guid, { priority: group.priority });
loadedCount++;
loadedBytes += metadata.size;
}
// 最终进度 / Final progress
if (onProgress) {
onProgress({
currentAsset: '',
loadedCount: totalCount,
totalCount,
loadedBytes: totalBytes,
totalBytes,
progress: 1
});
}
}
/**
* Get loaded asset
* 获取已加载的资产
*/
getAsset<T = unknown>(guid: AssetGUID): T | null {
const entry = this._assets.get(guid);
if (entry && entry.state === AssetState.Loaded) {
entry.lastAccessTime = Date.now();
return entry.asset as T;
}
return null;
}
/**
* Get asset by handle
* 通过句柄获取资产
*/
getAssetByHandle<T = unknown>(handle: AssetHandle): T | null {
const guid = this._handleToGuid.get(handle);
if (!guid) return null;
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
* 检查资产是否已加载
*/
isLoaded(guid: AssetGUID): boolean {
const entry = this._assets.get(guid);
return entry?.state === AssetState.Loaded;
}
/**
* Get asset state
* 获取资产状态
*/
getAssetState(guid: AssetGUID): AssetState {
const entry = this._assets.get(guid);
return entry?.state || AssetState.Unloaded;
}
/**
* Unload asset
* 卸载资产
*/
unloadAsset(guid: AssetGUID): void {
const entry = this._assets.get(guid);
if (!entry) return;
// 检查引用计数 / Check reference count
if (entry.referenceCount > 0) {
return;
}
// 获取加载器以释放资源 / Get loader to dispose resources
const loader = this._loaderFactory.createLoader(entry.metadata.type);
if (loader) {
loader.dispose(entry.asset);
}
// 清理条目 / Clean up entry
this._handleToGuid.delete(entry.handle);
this._assets.delete(guid);
this._cache.remove(guid);
// 更新统计 / Update statistics
this._statistics.loadedCount--;
entry.state = AssetState.Unloaded;
}
/**
* Unload all assets
* 卸载所有资产
*/
unloadAllAssets(): void {
const guids = Array.from(this._assets.keys());
guids.forEach((guid) => this.unloadAsset(guid));
}
/**
* Unload unused assets
* 卸载未使用的资产
*/
unloadUnusedAssets(): void {
const guids = Array.from(this._assets.keys());
guids.forEach((guid) => {
const entry = this._assets.get(guid);
if (entry && entry.referenceCount === 0) {
this.unloadAsset(guid);
}
});
}
/**
* Unload assets by type
* 按类型卸载资产
*
* This is useful for clearing texture caches when restoring scene snapshots.
* 在恢复场景快照时清除纹理缓存时很有用。
*
* @param assetType 要卸载的资产类型 / Asset type to unload
* @param bForce 是否强制卸载(忽略引用计数)/ Whether to force unload (ignore reference count)
*/
unloadAssetsByType(assetType: AssetType, bForce: boolean = false): void {
const guids = Array.from(this._assets.keys());
guids.forEach((guid) => {
const entry = this._assets.get(guid);
if (entry && entry.metadata.type === assetType) {
if (bForce || entry.referenceCount === 0) {
// 获取加载器以释放资源 / Get loader to dispose resources
const loader = this._loaderFactory.createLoader(entry.metadata.type);
if (loader) {
loader.dispose(entry.asset);
}
// 清理条目 / Clean up entry
this._handleToGuid.delete(entry.handle);
this._assets.delete(guid);
this._cache.remove(guid);
// 更新统计 / Update statistics
this._statistics.loadedCount--;
entry.state = AssetState.Unloaded;
}
}
});
}
/**
* Add reference to asset
* 增加资产引用
*/
addReference(guid: AssetGUID): void {
const entry = this._assets.get(guid);
if (entry) {
entry.referenceCount++;
}
}
/**
* Remove reference from asset
* 移除资产引用
*/
removeReference(guid: AssetGUID): void {
const entry = this._assets.get(guid);
if (entry && entry.referenceCount > 0) {
entry.referenceCount--;
}
}
/**
* Get reference info
* 获取引用信息
*/
getReferenceInfo(guid: AssetGUID): IAssetReferenceInfo | null {
const entry = this._assets.get(guid);
if (!entry) return null;
return {
guid,
handle: entry.handle,
referenceCount: entry.referenceCount,
lastAccessTime: entry.lastAccessTime,
state: entry.state
};
}
/**
* Register custom loader
* 注册自定义加载器
*/
registerLoader(type: AssetType, loader: IAssetLoader): void {
this._loaderFactory.registerLoader(type, loader);
}
/**
* Get asset statistics
* 获取资产统计信息
*/
getStatistics(): { loadedCount: number; loadQueue: number; failedCount: number } {
return {
loadedCount: this._statistics.loadedCount,
loadQueue: this._loadQueue.getSize(),
failedCount: this._statistics.failedCount
};
}
/**
* Clear cache
* 清空缓存
*/
clearCache(): void {
this._cache.clear();
}
/**
* Dispose manager
* 释放管理器
*/
dispose(): void {
if (this._isDisposed) return;
this.unloadAllAssets();
this._cache.clear();
this._loadQueue.clear();
this._assets.clear();
this._handleToGuid.clear();
this._pathToGuid.clear();
this._isDisposed = true;
}
}

View File

@@ -0,0 +1,235 @@
/**
* Asset path resolver for different platforms and protocols
* 不同平台和协议的资产路径解析器
*/
import { AssetPlatform } from '../types/AssetTypes';
import { PathValidator } from '../utils/PathValidator';
/**
* Asset path resolver configuration
* 资产路径解析器配置
*/
export interface IAssetPathConfig {
/** Base URL for web assets | Web资产的基础URL */
baseUrl?: string;
/** Asset directory path | 资产目录路径 */
assetDir?: string;
/** Asset host for asset:// protocol | 资产协议的主机名 */
assetHost?: string;
/** Current platform | 当前平台 */
platform?: AssetPlatform;
/** Custom path transformer | 自定义路径转换器 */
pathTransformer?: (path: string) => string;
}
/**
* Asset path resolver
* 资产路径解析器
*/
export class AssetPathResolver {
private config: IAssetPathConfig;
constructor(config: IAssetPathConfig = {}) {
this.config = {
baseUrl: '',
assetDir: 'assets',
platform: AssetPlatform.H5,
...config
};
}
/**
* Update configuration
* 更新配置
*/
updateConfig(config: Partial<IAssetPathConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* Resolve asset path to full URL
* 解析资产路径为完整URL
*/
resolve(path: string): string {
// Validate input path
const validation = PathValidator.validate(path);
if (!validation.valid) {
console.warn(`Invalid asset path: ${path} - ${validation.reason}`);
// Sanitize the path instead of throwing
path = PathValidator.sanitize(path);
if (!path) {
throw new Error(`Cannot resolve invalid path: ${validation.reason}`);
}
}
// Already a full URL
// 已经是完整URL
if (this.isAbsoluteUrl(path)) {
return path;
}
// Data URL
// 数据URL
if (path.startsWith('data:')) {
return path;
}
// Normalize the path
path = PathValidator.normalize(path);
// Apply custom transformer if provided
// 应用自定义转换器(如果提供)
if (this.config.pathTransformer) {
path = this.config.pathTransformer(path);
// Transformer output is trusted (may be absolute path or asset:// URL)
// 转换器输出是可信的(可能是绝对路径或 asset:// URL
return path;
}
// Platform-specific resolution
// 平台特定解析
switch (this.config.platform) {
case AssetPlatform.H5:
return this.resolveH5Path(path);
case AssetPlatform.WeChat:
return this.resolveWeChatPath(path);
case AssetPlatform.Playable:
return this.resolvePlayablePath(path);
case AssetPlatform.Android:
case AssetPlatform.iOS:
return this.resolveMobilePath(path);
case AssetPlatform.Editor:
return this.resolveEditorPath(path);
default:
return this.resolveH5Path(path);
}
}
/**
* Resolve path for H5 platform
* 解析H5平台路径
*/
private resolveH5Path(path: string): string {
// Remove leading slash if present
// 移除开头的斜杠(如果存在)
path = path.replace(/^\//, '');
// Combine with base URL and asset directory
// 与基础URL和资产目录结合
const base = this.config.baseUrl || (typeof window !== 'undefined' ? window.location.origin : '');
const assetDir = this.config.assetDir || 'assets';
return `${base}/${assetDir}/${path}`.replace(/\/+/g, '/');
}
/**
* Resolve path for WeChat Mini Game
* 解析微信小游戏路径
*/
private resolveWeChatPath(path: string): string {
// WeChat mini games use relative paths
// 微信小游戏使用相对路径
return `${this.config.assetDir}/${path}`.replace(/\/+/g, '/');
}
/**
* Resolve path for Playable Ads platform
* 解析试玩广告平台路径
*/
private resolvePlayablePath(path: string): string {
// Playable ads typically use base64 embedded resources or relative paths
// 试玩广告通常使用base64内嵌资源或相对路径
// If custom transformer is provided (e.g., for base64 encoding)
// 如果提供了自定义转换器例如用于base64编码
if (this.config.pathTransformer) {
return this.config.pathTransformer(path);
}
// Default to relative path without directory prefix
// 默认使用不带目录前缀的相对路径
return path;
}
/**
* Resolve path for mobile platform (Android/iOS)
* 解析移动平台路径Android/iOS
*/
private resolveMobilePath(path: string): string {
// Mobile platforms use relative paths or file:// protocol
// 移动平台使用相对路径或file://协议
return `./${this.config.assetDir}/${path}`.replace(/\/+/g, '/');
}
/**
* Resolve path for Editor platform (Tauri)
* 解析编辑器平台路径Tauri
*/
private resolveEditorPath(path: string): string {
// For Tauri editor, use pathTransformer if provided
// 对于Tauri编辑器使用pathTransformer如果提供
if (this.config.pathTransformer) {
return this.config.pathTransformer(path);
}
// Use configurable asset host or default to 'localhost'
// 使用可配置的资产主机或默认为 'localhost'
const host = this.config.assetHost || 'localhost';
const sanitizedPath = PathValidator.sanitize(path);
return `asset://${host}/${sanitizedPath}`;
}
/**
* Check if path is absolute URL
* 检查路径是否为绝对URL
*/
private isAbsoluteUrl(path: string): boolean {
return /^(https?:\/\/|file:\/\/|asset:\/\/)/.test(path);
}
/**
* Get asset directory from path
* 从路径获取资产目录
*/
getAssetDirectory(path: string): string {
const resolved = this.resolve(path);
const lastSlash = resolved.lastIndexOf('/');
return lastSlash >= 0 ? resolved.substring(0, lastSlash) : '';
}
/**
* Get asset filename from path
* 从路径获取资产文件名
*/
getAssetFilename(path: string): string {
const resolved = this.resolve(path);
const lastSlash = resolved.lastIndexOf('/');
return lastSlash >= 0 ? resolved.substring(lastSlash + 1) : resolved;
}
/**
* Join paths
* 连接路径
*/
join(...paths: string[]): string {
return paths.join('/').replace(/\/+/g, '/');
}
/**
* Normalize path
* 规范化路径
*/
normalize(path: string): string {
return path.replace(/\\/g, '/').replace(/\/+/g, '/');
}
}

View File

@@ -0,0 +1,338 @@
/**
* Asset reference for lazy loading
* 用于懒加载的资产引用
*/
import { AssetGUID, IAssetLoadOptions, AssetState } from '../types/AssetTypes';
import { IAssetManager } from '../interfaces/IAssetManager';
/**
* Asset reference class for lazy loading
* 懒加载资产引用类
*/
export class AssetReference<T = unknown> {
private _guid: AssetGUID;
private _asset?: T;
private _loadPromise?: Promise<T>;
private _manager?: IAssetManager;
private _isReleased = false;
private _autoRelease = false;
/**
* Constructor
* 构造函数
*/
constructor(guid: AssetGUID, manager?: IAssetManager) {
this._guid = guid;
this._manager = manager;
}
/**
* Get asset GUID
* 获取资产GUID
*/
get guid(): AssetGUID {
return this._guid;
}
/**
* Check if asset is loaded
* 检查资产是否已加载
*/
get isLoaded(): boolean {
return this._asset !== undefined && !this._isReleased;
}
/**
* Get asset synchronously (returns null if not loaded)
* 同步获取资产如果未加载则返回null
*/
get asset(): T | null {
return this._asset ?? null;
}
/**
* Set asset manager
* 设置资产管理器
*/
setManager(manager: IAssetManager): void {
this._manager = manager;
}
/**
* Load asset asynchronously
* 异步加载资产
*/
async loadAsync(options?: IAssetLoadOptions): Promise<T> {
if (this._isReleased) {
throw new Error(`Asset reference ${this._guid} has been released`);
}
// 如果已经加载,直接返回 / Return if already loaded
if (this._asset !== undefined) {
return this._asset;
}
// 如果正在加载返回加载Promise / Return loading promise if loading
if (this._loadPromise) {
return this._loadPromise;
}
if (!this._manager) {
throw new Error('Asset manager not set for AssetReference');
}
// 开始加载 / Start loading
this._loadPromise = this.performLoad(options);
try {
const asset = await this._loadPromise;
return asset;
} finally {
this._loadPromise = undefined;
}
}
/**
* Perform asset loading
* 执行资产加载
*/
private async performLoad(options?: IAssetLoadOptions): Promise<T> {
if (!this._manager) {
throw new Error('Asset manager not set');
}
const result = await this._manager.loadAsset<T>(this._guid, options);
this._asset = result.asset;
// 增加引用计数 / Increase reference count
this._manager.addReference(this._guid);
return this._asset;
}
/**
* Release asset reference
* 释放资产引用
*/
release(): void {
if (this._isReleased) return;
if (this._manager && this._asset !== undefined) {
// 减少引用计数 / Decrease reference count
this._manager.removeReference(this._guid);
// 如果引用计数为0可以考虑卸载 / Consider unloading if reference count is 0
const refInfo = this._manager.getReferenceInfo(this._guid);
if (refInfo && refInfo.referenceCount === 0 && this._autoRelease) {
this._manager.unloadAsset(this._guid);
}
}
this._asset = undefined;
this._isReleased = true;
}
/**
* Set auto-release mode
* 设置自动释放模式
*/
setAutoRelease(autoRelease: boolean): void {
this._autoRelease = autoRelease;
}
/**
* Validate reference
* 验证引用
*/
validate(): boolean {
if (!this._manager) return false;
const state = this._manager.getAssetState(this._guid);
return state !== AssetState.Failed;
}
/**
* Get asset state
* 获取资产状态
*/
getState(): AssetState {
if (this._isReleased) return AssetState.Unloaded;
if (!this._manager) return AssetState.Unloaded;
return this._manager.getAssetState(this._guid);
}
/**
* Clone reference
* 克隆引用
*/
clone(): AssetReference<T> {
const newRef = new AssetReference<T>(this._guid, this._manager);
newRef.setAutoRelease(this._autoRelease);
return newRef;
}
/**
* Convert to JSON
* 转换为JSON
*/
toJSON(): { guid: AssetGUID } {
return { guid: this._guid };
}
/**
* Create from JSON
* 从JSON创建
*/
static fromJSON<T = unknown>(
json: { guid: AssetGUID },
manager?: IAssetManager
): AssetReference<T> {
return new AssetReference<T>(json.guid, manager);
}
}
/**
* Weak asset reference that doesn't prevent unloading
* 不阻止卸载的弱资产引用
*/
export class WeakAssetReference<T = unknown> {
private _guid: AssetGUID;
private _manager?: IAssetManager;
constructor(guid: AssetGUID, manager?: IAssetManager) {
this._guid = guid;
this._manager = manager;
}
/**
* Get asset GUID
* 获取资产GUID
*/
get guid(): AssetGUID {
return this._guid;
}
/**
* Try get asset without loading
* 尝试获取资产而不加载
*/
tryGet(): T | null {
if (!this._manager) return null;
return this._manager.getAsset<T>(this._guid);
}
/**
* Load asset if not loaded
* 如果未加载则加载资产
*/
async loadAsync(options?: IAssetLoadOptions): Promise<T> {
if (!this._manager) {
throw new Error('Asset manager not set');
}
const result = await this._manager.loadAsset<T>(this._guid, options);
// 不增加引用计数 / Don't increase reference count for weak reference
return result.asset;
}
/**
* Check if asset is loaded
* 检查资产是否已加载
*/
isLoaded(): boolean {
if (!this._manager) return false;
return this._manager.isLoaded(this._guid);
}
/**
* Set asset manager
* 设置资产管理器
*/
setManager(manager: IAssetManager): void {
this._manager = manager;
}
}
/**
* Asset reference array for managing multiple references
* 用于管理多个引用的资产引用数组
*/
export class AssetReferenceArray<T = unknown> {
private _references: AssetReference<T>[] = [];
private _manager?: IAssetManager;
constructor(guids: AssetGUID[] = [], manager?: IAssetManager) {
this._manager = manager;
this._references = guids.map((guid) => new AssetReference<T>(guid, manager));
}
/**
* Add reference
* 添加引用
*/
add(guid: AssetGUID): void {
this._references.push(new AssetReference<T>(guid, this._manager));
}
/**
* Remove reference
* 移除引用
*/
remove(guid: AssetGUID): boolean {
const index = this._references.findIndex((ref) => ref.guid === guid);
if (index >= 0) {
this._references[index].release();
this._references.splice(index, 1);
return true;
}
return false;
}
/**
* Load all assets
* 加载所有资产
*/
async loadAllAsync(options?: IAssetLoadOptions): Promise<T[]> {
const promises = this._references.map((ref) => ref.loadAsync(options));
return Promise.all(promises);
}
/**
* Release all references
* 释放所有引用
*/
releaseAll(): void {
this._references.forEach((ref) => ref.release());
this._references = [];
}
/**
* Get all loaded assets
* 获取所有已加载的资产
*/
getLoadedAssets(): T[] {
return this._references
.filter((ref) => ref.isLoaded)
.map((ref) => ref.asset!)
.filter((asset) => asset !== null);
}
/**
* Get reference count
* 获取引用数量
*/
get count(): number {
return this._references.length;
}
/**
* Set asset manager
* 设置资产管理器
*/
setManager(manager: IAssetManager): void {
this._manager = manager;
this._references.forEach((ref) => ref.setManager(manager));
}
}

View File

@@ -0,0 +1,116 @@
/**
* 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
*/
// Service tokens (谁定义接口,谁导出 Token)
export {
AssetManagerToken,
PrefabServiceToken,
PathResolutionServiceToken,
type IAssetManager,
type IPrefabService,
type IPrefabAsset,
type IPrefabData,
type IPrefabMetadata,
type IPathResolutionService
} from './tokens';
// 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/IAssetFileLoader';
export * from './interfaces/IResourceComponent';
// Core
export { AssetManager } from './core/AssetManager';
export { AssetCache } from './core/AssetCache';
export { AssetDatabase } from './core/AssetDatabase';
export { AssetLoadQueue } from './core/AssetLoadQueue';
export { AssetReference, WeakAssetReference, AssetReferenceArray } from './core/AssetReference';
export { AssetPathResolver } from './core/AssetPathResolver';
export type { IAssetPathConfig } from './core/AssetPathResolver';
// Loaders
export { AssetLoaderFactory } from './loaders/AssetLoaderFactory';
export { TextureLoader } from './loaders/TextureLoader';
export { JsonLoader } from './loaders/JsonLoader';
export { TextLoader } from './loaders/TextLoader';
export { BinaryLoader } from './loaders/BinaryLoader';
export { AudioLoader } from './loaders/AudioLoader';
export { PrefabLoader } from './loaders/PrefabLoader';
// 3D Model Loaders | 3D 模型加载器
export { GLTFLoader } from './loaders/GLTFLoader';
export { OBJLoader } from './loaders/OBJLoader';
export { FBXLoader } from './loaders/FBXLoader';
// Integration
export { EngineIntegration } from './integration/EngineIntegration';
export type { ITextureEngineBridge, TextureLoadCallback } from './integration/EngineIntegration';
// Services
export { SceneResourceManager } from './services/SceneResourceManager';
export type { IResourceLoader } from './services/SceneResourceManager';
export { PathResolutionService } from './services/PathResolutionService';
// Asset Metadata Service (primary API for sprite info)
// 资产元数据服务sprite 信息的主要 API
export {
setGlobalAssetDatabase,
getGlobalAssetDatabase,
setGlobalEngineBridge,
getGlobalEngineBridge,
getTextureSpriteInfo
} from './services/AssetMetadataService';
export type { ITextureSpriteInfo } from './core/AssetDatabase';
// Utils
export { UVHelper } from './utils/UVHelper';
export {
isValidGUID,
generateGUID,
hashBuffer,
hashString,
hashFileInfo
} from './utils/AssetUtils';
export {
collectAssetReferences,
extractUniqueGuids,
groupByComponentType,
DEFAULT_ASSET_PATTERNS,
type SceneAssetRef,
type AssetFieldPattern
} from './utils/AssetCollector';
// Re-export for initializeAssetSystem
import { AssetManager } from './core/AssetManager';
import type { IAssetCatalog } from './types/AssetTypes';
/**
* Initialize asset system with catalog
* 使用目录初始化资产系统
*
* @param catalog 资产目录 | Asset catalog
* @returns 新的 AssetManager 实例 | New AssetManager instance
*/
export function initializeAssetSystem(catalog?: IAssetCatalog): AssetManager {
return new AssetManager(catalog);
}

View File

@@ -0,0 +1,847 @@
/**
* Engine integration for asset system
* 资产系统的引擎集成
*/
import { AssetManager } from '../core/AssetManager';
import { AssetGUID, AssetType } from '../types/AssetTypes';
import { ITextureAsset, IAudioAsset, IJsonAsset } from '../interfaces/IAssetLoader';
import { PathResolutionService, type IPathResolutionService } from '../services/PathResolutionService';
/**
* Texture engine bridge interface (for asset system)
* 纹理引擎桥接接口(用于资产系统)
*/
export interface ITextureEngineBridge {
/**
* Load texture to GPU
* 加载纹理到GPU
*/
loadTexture(id: number, url: string): Promise<void>;
/**
* Load multiple textures
* 批量加载纹理
*/
loadTextures(requests: Array<{ id: number; url: string }>): Promise<void>;
/**
* Unload texture from GPU
* 从GPU卸载纹理
*/
unloadTexture(id: number): void;
/**
* Get or load texture by path.
* 按路径获取或加载纹理。
*
* This is the preferred method for getting texture IDs.
* The Rust engine is the single source of truth for texture ID allocation.
* 这是获取纹理 ID 的首选方法。
* Rust 引擎是纹理 ID 分配的唯一事实来源。
*
* @param path Image path/URL | 图片路径/URL
* @returns Texture ID allocated by Rust engine | Rust 引擎分配的纹理 ID
*/
getOrLoadTextureByPath?(path: string): number;
/**
* Clear the texture path cache (optional).
* 清除纹理路径缓存(可选)。
*
* This should be called when restoring scene snapshots to ensure
* textures are reloaded with correct IDs.
* 在恢复场景快照时应调用此方法以确保纹理使用正确的ID重新加载。
*/
clearTexturePathCache?(): void;
/**
* Clear all textures and reset state (optional).
* 清除所有纹理并重置状态(可选)。
*/
clearAllTextures?(): void;
// ===== Texture State API =====
// ===== 纹理状态 API =====
/**
* Get texture loading state.
* 获取纹理加载状态。
*
* @param id Texture ID | 纹理 ID
* @returns State string: 'loading', 'ready', or 'failed:reason' | 状态字符串
*/
getTextureState?(id: number): string;
/**
* Check if texture is ready for rendering.
* 检查纹理是否已就绪可渲染。
*
* @param id Texture ID | 纹理 ID
* @returns true if texture data is loaded | 纹理数据已加载则返回 true
*/
isTextureReady?(id: number): boolean;
/**
* Get count of textures currently loading.
* 获取当前正在加载的纹理数量。
*
* @returns Number of textures in 'loading' state | 处于加载状态的纹理数量
*/
getTextureLoadingCount?(): number;
/**
* Load texture asynchronously with Promise.
* 使用 Promise 异步加载纹理。
*
* Unlike loadTexture which returns immediately, this method
* waits until the texture is actually loaded and ready.
* 与 loadTexture 立即返回不同,此方法会等待纹理实际加载完成。
*
* @param id Texture ID | 纹理 ID
* @param url Image URL | 图片 URL
* @returns Promise that resolves when texture is ready | 纹理就绪时解析的 Promise
*/
loadTextureAsync?(id: number, url: string): Promise<void>;
/**
* Get texture info by path.
* 通过路径获取纹理信息。
*
* This is the primary API for getting texture dimensions.
* The Rust engine is the single source of truth for texture dimensions.
* 这是获取纹理尺寸的主要 API。
* Rust 引擎是纹理尺寸的唯一事实来源。
*
* @param path Image path/URL | 图片路径/URL
* @returns Texture info or null if not loaded | 纹理信息或未加载则为 null
*/
getTextureInfoByPath?(path: string): { width: number; height: number } | null;
}
/**
* Audio asset with runtime ID
* 带运行时 ID 的音频资产
*/
interface AudioAssetEntry {
id: number;
asset: IAudioAsset;
path: string;
}
/**
* Data asset with runtime ID
* 带运行时 ID 的数据资产
*/
interface DataAssetEntry {
id: number;
data: unknown;
path: string;
}
/**
* Texture load callback type
* 纹理加载回调类型
*/
export type TextureLoadCallback = (guid: string, path: string, textureId: number) => void;
/**
* Asset system engine integration
* 资产系统引擎集成
*/
/**
* Texture sprite info (nine-patch border, pivot, etc.)
* 纹理 Sprite 信息(九宫格边距、锚点等)
*/
export interface ITextureSpriteInfo {
/**
* 九宫格切片边距 [top, right, bottom, left]
* Nine-patch slice border
*/
sliceBorder?: [number, number, number, number];
/**
* Sprite 锚点 [x, y]0-1 归一化)
* Sprite pivot point (0-1 normalized)
*/
pivot?: [number, number];
/**
* 纹理宽度
* Texture width
*/
width: number;
/**
* 纹理高度
* Texture height
*/
height: number;
}
export class EngineIntegration {
private _assetManager: AssetManager;
private _engineBridge?: ITextureEngineBridge;
private _pathResolver: IPathResolutionService;
private _textureIdMap = new Map<AssetGUID, number>();
private _pathToTextureId = new Map<string, number>();
// 路径稳定 ID 缓存(跨 Play/Stop 循环保持稳定)
// Path-stable ID cache (persists across Play/Stop cycles)
private static _pathIdCache = new Map<string, number>();
// 纹理 Sprite 信息缓存(全局静态,可供渲染系统访问)
// Texture sprite info cache (global static, accessible by render systems)
private static _textureSpriteInfoCache = new Map<AssetGUID, ITextureSpriteInfo>();
// 纹理加载回调(用于动态图集集成等)
// Texture load callback (for dynamic atlas integration, etc.)
private static _textureLoadCallbacks: TextureLoadCallback[] = [];
/**
* Register a callback to be called when textures are loaded
* 注册纹理加载时调用的回调
*
* This can be used for dynamic atlas integration.
* 可用于动态图集集成。
*
* @param callback - Callback function | 回调函数
*/
static onTextureLoad(callback: TextureLoadCallback): void {
if (!EngineIntegration._textureLoadCallbacks.includes(callback)) {
EngineIntegration._textureLoadCallbacks.push(callback);
}
}
/**
* Remove a texture load callback
* 移除纹理加载回调
*/
static removeTextureLoadCallback(callback: TextureLoadCallback): void {
const index = EngineIntegration._textureLoadCallbacks.indexOf(callback);
if (index >= 0) {
EngineIntegration._textureLoadCallbacks.splice(index, 1);
}
}
/**
* Notify all callbacks of a texture load
* 通知所有回调纹理已加载
*/
private static notifyTextureLoad(guid: string, path: string, textureId: number): void {
for (const callback of EngineIntegration._textureLoadCallbacks) {
try {
callback(guid, path, textureId);
} catch (e) {
console.error('[EngineIntegration] Error in texture load callback:', e);
}
}
}
// Audio resource mappings | 音频资源映射
private _audioIdMap = new Map<AssetGUID, number>();
private _pathToAudioId = new Map<string, number>();
private _audioAssets = new Map<number, AudioAssetEntry>();
private static _nextAudioId = 1;
// Data resource mappings | 数据资源映射
private _dataIdMap = new Map<AssetGUID, number>();
private _pathToDataId = new Map<string, number>();
private _dataAssets = new Map<number, DataAssetEntry>();
private static _nextDataId = 1;
/**
* 根据路径生成稳定的 ID使用 FNV-1a hash
* Generate stable ID from path (using FNV-1a hash)
*
* 相同路径永远返回相同 ID即使在 clearTextureMappings 后
* Same path always returns same ID, even after clearTextureMappings
*
* @param path 资源路径 | Resource path
* @param type 资源类型 | Resource type
* @returns 稳定的运行时 ID | Stable runtime ID
*/
private static getStableIdForPath(path: string, type: 'texture' | 'audio'): number {
const cacheKey = `${type}:${path}`;
const cached = EngineIntegration._pathIdCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
// FNV-1a hash 算法 | FNV-1a hash algorithm
let hash = 2166136261; // FNV offset basis
for (let i = 0; i < path.length; i++) {
hash ^= path.charCodeAt(i);
hash = Math.imul(hash, 16777619); // FNV prime
hash = hash >>> 0; // Keep as uint32
}
// 确保 ID > 00 保留给默认纹理)
// Ensure ID > 0 (0 is reserved for default texture)
const id = (hash % 0x7FFFFFFF) + 1;
EngineIntegration._pathIdCache.set(cacheKey, id);
return id;
}
constructor(assetManager: AssetManager, engineBridge?: ITextureEngineBridge, pathResolver?: IPathResolutionService) {
this._assetManager = assetManager;
this._engineBridge = engineBridge;
this._pathResolver = pathResolver ?? new PathResolutionService();
}
/**
* Set path resolver
* 设置路径解析器
*/
setPathResolver(resolver: IPathResolutionService): void {
this._pathResolver = resolver;
}
/**
* Set engine bridge
* 设置引擎桥接
*/
setEngineBridge(bridge: ITextureEngineBridge): void {
this._engineBridge = bridge;
}
/**
* Load texture for component
* 为组件加载纹理
*
* 使用路径稳定 ID 确保相同路径在 Play/Stop 循环后返回相同 ID。
* 这样组件保存的 textureId 在恢复场景后仍然有效。
*
* Uses path-stable ID to ensure same path returns same ID across Play/Stop cycles.
* This ensures component's saved textureId remains valid after scene restore.
*
* AssetManager 内部会处理路径解析,这里只需传入原始路径。
* AssetManager handles path resolution internally, just pass the original path here.
*/
async loadTextureForComponent(texturePath: string): Promise<number> {
// 生成路径稳定 ID相同路径永远返回相同 ID
// Generate path-stable ID (same path always returns same ID)
const stableId = EngineIntegration.getStableIdForPath(texturePath, 'texture');
// 检查是否已加载到 GPU
// Check if already loaded to GPU
const existingId = this._pathToTextureId.get(texturePath);
if (existingId === stableId) {
return stableId; // 已加载,直接返回 | Already loaded, return directly
}
// 解析路径为引擎可用的 URL
// Resolve path to engine-compatible URL
const engineUrl = this._pathResolver.catalogToRuntime(texturePath);
// 使用稳定 ID 加载纹理到 GPU
// Load texture to GPU with stable ID
if (this._engineBridge) {
// 优先使用异步加载(支持加载状态追踪)
// Prefer async loading (supports loading state tracking)
if (this._engineBridge.loadTextureAsync) {
await this._engineBridge.loadTextureAsync(stableId, engineUrl);
} else {
await this._engineBridge.loadTexture(stableId, engineUrl);
}
}
// 缓存映射
// Cache mapping
this._pathToTextureId.set(texturePath, stableId);
return stableId;
}
/**
* Load texture by GUID
* 通过GUID加载纹理
*
* 使用路径稳定 ID 确保相同路径在 Play/Stop 循环后返回相同 ID。
* Uses path-stable ID to ensure same path returns same ID across Play/Stop cycles.
*/
async loadTextureByGuid(guid: AssetGUID): Promise<number> {
// 检查是否已有纹理ID / Check if texture ID exists
const existingId = this._textureIdMap.get(guid);
if (existingId) {
return existingId;
}
// 通过资产系统加载获取元数据和路径 / Load through asset system to get metadata and path
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
const metadata = result.metadata;
const assetPath = metadata.path;
const textureAsset = result.asset;
// 缓存 sprite 信息(九宫格边距等)到静态缓存
// Cache sprite info (slice border, etc.) to static cache
EngineIntegration._textureSpriteInfoCache.set(guid, {
sliceBorder: textureAsset.sliceBorder,
pivot: textureAsset.pivot,
width: textureAsset.width,
height: textureAsset.height
});
// 生成路径稳定 ID
// Generate path-stable ID
const stableId = EngineIntegration.getStableIdForPath(assetPath, 'texture');
// 检查是否已加载到 GPU
// Check if already loaded to GPU
if (this._pathToTextureId.get(assetPath) === stableId) {
this._textureIdMap.set(guid, stableId);
return stableId;
}
// 解析路径为引擎可用的 URL
// Resolve path to engine-compatible URL
const engineUrl = this._pathResolver.catalogToRuntime(assetPath);
// 使用稳定 ID 加载纹理到 GPU
// Load texture to GPU with stable ID
if (this._engineBridge) {
if (this._engineBridge.loadTextureAsync) {
await this._engineBridge.loadTextureAsync(stableId, engineUrl);
} else {
await this._engineBridge.loadTexture(stableId, engineUrl);
}
}
// 缓存映射 / Cache mapping
this._textureIdMap.set(guid, stableId);
this._pathToTextureId.set(assetPath, stableId);
// 通知回调(用于动态图集等)
// Notify callbacks (for dynamic atlas, etc.)
EngineIntegration.notifyTextureLoad(guid, engineUrl, stableId);
return stableId;
}
/**
* Get texture sprite info by GUID (static method for render system access)
* 通过 GUID 获取纹理 Sprite 信息(静态方法,供渲染系统访问)
*
* Returns cached sprite info including nine-patch slice border.
* Must call loadTextureByGuid first to populate the cache.
* 返回缓存的 sprite 信息,包括九宫格边距。
* 必须先调用 loadTextureByGuid 来填充缓存。
*
* @param guid - Texture asset GUID | 纹理资产 GUID
* @returns Sprite info or undefined if not loaded | Sprite 信息或未加载则为 undefined
*/
static getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
return EngineIntegration._textureSpriteInfoCache.get(guid);
}
/**
* Clear texture sprite info cache
* 清除纹理 Sprite 信息缓存
*/
static clearTextureSpriteInfoCache(): void {
EngineIntegration._textureSpriteInfoCache.clear();
}
/**
* Batch load textures
* 批量加载纹理
*/
async loadTexturesBatch(paths: string[]): Promise<Map<string, number>> {
const results = new Map<string, number>();
// 收集需要加载的纹理 / Collect textures to load
const toLoad: string[] = [];
for (const path of paths) {
const existingId = this._pathToTextureId.get(path);
if (existingId) {
results.set(path, existingId);
} else {
toLoad.push(path);
}
}
if (toLoad.length === 0) {
return results;
}
// 并行加载所有纹理 / Load all textures in parallel
const loadPromises = toLoad.map(async (path) => {
try {
const id = await this.loadTextureForComponent(path);
results.set(path, id);
} catch (error) {
console.error(`Failed to load texture: ${path}`, error);
results.set(path, 0); // 使用默认纹理ID / Use default texture ID
}
});
await Promise.all(loadPromises);
return results;
}
/**
* 批量加载资源(通用方法,支持 IResourceLoader 接口)
* Load resources in batch (generic method for IResourceLoader interface)
*
* @param paths 资源路径数组 / Array of resource paths
* @param type 资源类型 / Resource type
* @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs
*/
async loadResourcesBatch(paths: string[], type: 'texture' | 'audio' | 'font' | 'data'): Promise<Map<string, number>> {
switch (type) {
case 'texture':
return this.loadTexturesBatch(paths);
case 'audio':
return this.loadAudioBatch(paths);
case 'data':
return this.loadDataBatch(paths);
case 'font':
// 字体资源暂未实现 / Font resources not yet implemented
console.warn('[EngineIntegration] Font resource loading not yet implemented');
return new Map();
default:
console.warn(`[EngineIntegration] Unknown resource type '${type}'`);
return new Map();
}
}
// ============= Audio Resource Methods =============
// ============= 音频资源方法 =============
/**
* Load audio for component
* 为组件加载音频
*
* @param audioPath 音频文件路径 / Audio file path
* @returns 运行时音频 ID / Runtime audio ID
*/
async loadAudioForComponent(audioPath: string): Promise<number> {
// 检查缓存 / Check cache
const existingId = this._pathToAudioId.get(audioPath);
if (existingId) {
return existingId;
}
// 通过资产系统加载 / Load through asset system
const result = await this._assetManager.loadAssetByPath<IAudioAsset>(audioPath);
const audioAsset = result.asset;
// 分配运行时 ID / Assign runtime ID
const audioId = EngineIntegration._nextAudioId++;
// 缓存映射 / Cache mapping
this._pathToAudioId.set(audioPath, audioId);
this._audioAssets.set(audioId, {
id: audioId,
asset: audioAsset,
path: audioPath
});
return audioId;
}
/**
* Batch load audio files
* 批量加载音频文件
*/
async loadAudioBatch(paths: string[]): Promise<Map<string, number>> {
const results = new Map<string, number>();
// 收集需要加载的音频 / Collect audio to load
const toLoad: string[] = [];
for (const path of paths) {
const existingId = this._pathToAudioId.get(path);
if (existingId) {
results.set(path, existingId);
} else {
toLoad.push(path);
}
}
if (toLoad.length === 0) {
return results;
}
// 并行加载所有音频 / Load all audio in parallel
const loadPromises = toLoad.map(async (path) => {
try {
const id = await this.loadAudioForComponent(path);
results.set(path, id);
} catch (error) {
console.error(`Failed to load audio: ${path}`, error);
results.set(path, 0);
}
});
await Promise.all(loadPromises);
return results;
}
/**
* Get audio asset by ID
* 通过 ID 获取音频资产
*/
getAudioAsset(audioId: number): IAudioAsset | null {
const entry = this._audioAssets.get(audioId);
return entry?.asset || null;
}
/**
* Get audio ID for path
* 获取路径的音频 ID
*/
getAudioId(path: string): number | null {
return this._pathToAudioId.get(path) || null;
}
/**
* Unload audio
* 卸载音频
*/
unloadAudio(audioId: number): void {
const entry = this._audioAssets.get(audioId);
if (entry) {
this._pathToAudioId.delete(entry.path);
this._audioAssets.delete(audioId);
// 从 GUID 映射中清理 / Clean up GUID mapping
for (const [guid, id] of this._audioIdMap.entries()) {
if (id === audioId) {
this._audioIdMap.delete(guid);
this._assetManager.unloadAsset(guid);
break;
}
}
}
}
// ============= Data Resource Methods =============
// ============= 数据资源方法 =============
/**
* Load data (JSON) for component
* 为组件加载数据JSON
*
* @param dataPath 数据文件路径 / Data file path
* @returns 运行时数据 ID / Runtime data ID
*/
async loadDataForComponent(dataPath: string): Promise<number> {
// 检查缓存 / Check cache
const existingId = this._pathToDataId.get(dataPath);
if (existingId) {
return existingId;
}
// 通过资产系统加载 / Load through asset system
const result = await this._assetManager.loadAssetByPath<IJsonAsset>(dataPath);
const jsonAsset = result.asset;
// 分配运行时 ID / Assign runtime ID
const dataId = EngineIntegration._nextDataId++;
// 缓存映射 / Cache mapping
this._pathToDataId.set(dataPath, dataId);
this._dataAssets.set(dataId, {
id: dataId,
data: jsonAsset.data,
path: dataPath
});
return dataId;
}
/**
* Batch load data files
* 批量加载数据文件
*/
async loadDataBatch(paths: string[]): Promise<Map<string, number>> {
const results = new Map<string, number>();
// 收集需要加载的数据 / Collect data to load
const toLoad: string[] = [];
for (const path of paths) {
const existingId = this._pathToDataId.get(path);
if (existingId) {
results.set(path, existingId);
} else {
toLoad.push(path);
}
}
if (toLoad.length === 0) {
return results;
}
// 并行加载所有数据 / Load all data in parallel
const loadPromises = toLoad.map(async (path) => {
try {
const id = await this.loadDataForComponent(path);
results.set(path, id);
} catch (error) {
console.error(`Failed to load data: ${path}`, error);
results.set(path, 0);
}
});
await Promise.all(loadPromises);
return results;
}
/**
* Get data by ID
* 通过 ID 获取数据
*/
getData<T = unknown>(dataId: number): T | null {
const entry = this._dataAssets.get(dataId);
return (entry?.data as T) || null;
}
/**
* Get data ID for path
* 获取路径的数据 ID
*/
getDataId(path: string): number | null {
return this._pathToDataId.get(path) || null;
}
/**
* Unload data
* 卸载数据
*/
unloadData(dataId: number): void {
const entry = this._dataAssets.get(dataId);
if (entry) {
this._pathToDataId.delete(entry.path);
this._dataAssets.delete(dataId);
// 从 GUID 映射中清理 / Clean up GUID mapping
for (const [guid, id] of this._dataIdMap.entries()) {
if (id === dataId) {
this._dataIdMap.delete(guid);
this._assetManager.unloadAsset(guid);
break;
}
}
}
}
/**
* Unload texture
* 卸载纹理
*/
unloadTexture(textureId: number): void {
// 从引擎卸载 / Unload from engine
if (this._engineBridge) {
this._engineBridge.unloadTexture(textureId);
}
// 清理映射 / Clean up mappings
for (const [path, id] of this._pathToTextureId.entries()) {
if (id === textureId) {
this._pathToTextureId.delete(path);
break;
}
}
for (const [guid, id] of this._textureIdMap.entries()) {
if (id === textureId) {
this._textureIdMap.delete(guid);
// 也从资产管理器卸载 / Also unload from asset manager
this._assetManager.unloadAsset(guid);
break;
}
}
}
/**
* Get texture ID for path
* 获取路径的纹理ID
*/
getTextureId(path: string): number | null {
return this._pathToTextureId.get(path) || null;
}
/**
* Preload textures for scene
* 为场景预加载纹理
*/
async preloadSceneTextures(texturePaths: string[]): Promise<void> {
await this.loadTexturesBatch(texturePaths);
}
/**
* Clear all texture mappings (for scene switching)
* 清空所有纹理映射(用于场景切换)
*
* 注意:使用路径稳定 ID 后,不应在 Play/Stop 循环中调用此方法。
* 此方法仅用于场景切换时释放旧场景的纹理资源。
*
* NOTE: With path-stable IDs, this should NOT be called during Play/Stop cycle.
* This method is only for releasing old scene's texture resources during scene switching.
*
* _pathIdCache 不会被清除,确保相同路径始终返回相同 ID。
* _pathIdCache is NOT cleared, ensuring same path always returns same ID.
*/
clearTextureMappings(): void {
// 1. 清除加载状态映射(不清除 _pathIdCache
// Clear load state mappings (NOT clearing _pathIdCache)
this._textureIdMap.clear();
this._pathToTextureId.clear();
// 2. 清除 Rust 引擎的 GPU 纹理资源
// Clear Rust engine's GPU texture resources
if (this._engineBridge?.clearAllTextures) {
this._engineBridge.clearAllTextures();
}
// 3. 清除 AssetManager 中的纹理资产缓存
// Clear texture asset cache in AssetManager
this._assetManager.unloadAssetsByType(AssetType.Texture, true);
// 注意:不再重置 TextureLoader 的 ID 计数器,因为现在使用路径稳定 ID
// NOTE: No longer reset TextureLoader's ID counter as we now use path-stable IDs
}
/**
* Clear all audio mappings
* 清空所有音频映射
*/
clearAudioMappings(): void {
this._audioIdMap.clear();
this._pathToAudioId.clear();
this._audioAssets.clear();
}
/**
* Clear all data mappings
* 清空所有数据映射
*/
clearDataMappings(): void {
this._dataIdMap.clear();
this._pathToDataId.clear();
this._dataAssets.clear();
}
/**
* Clear all resource mappings
* 清空所有资源映射
*/
clearAllMappings(): void {
this.clearTextureMappings();
this.clearAudioMappings();
this.clearDataMappings();
}
/**
* Get statistics
* 获取统计信息
*/
getStatistics(): {
loadedTextures: number;
loadedAudio: number;
loadedData: number;
} {
return {
loadedTextures: this._pathToTextureId.size,
loadedAudio: this._audioAssets.size,
loadedData: this._dataAssets.size
};
}
}

View File

@@ -0,0 +1,103 @@
/**
* Asset File Loader Interface
* 资产文件加载器接口
*
* High-level file loading abstraction that combines path resolution
* with platform-specific file reading.
* 高级文件加载抽象,结合路径解析和平台特定的文件读取。
*
* This is the unified entry point for all file loading in the engine.
* Different from IAssetLoader (which parses content), this interface
* handles the actual file fetching from asset paths.
* 这是引擎中所有文件加载的统一入口。
* 与 IAssetLoader解析内容不同此接口处理从资产路径获取文件。
*/
/**
* Asset file loader interface.
* 资产文件加载器接口。
*
* Provides a unified API for loading files from asset paths (relative to project).
* Different platforms provide their own implementations.
* 提供从资产路径(相对于项目)加载文件的统一 API。
* 不同平台提供各自的实现。
*
* @example
* ```typescript
* // Get global loader
* const loader = getGlobalAssetFileLoader();
*
* // Load image from asset path (relative to project)
* const image = await loader.loadImage('assets/demo/button.png');
*
* // Load text content
* const json = await loader.loadText('assets/config.json');
* ```
*/
export interface IAssetFileLoader {
/**
* Load image from asset path.
* 从资产路径加载图片。
*
* @param assetPath - Asset path relative to project (e.g., "assets/demo/button.png").
* 相对于项目的资产路径。
* @returns Promise resolving to HTMLImageElement. | 返回 HTMLImageElement 的 Promise。
*/
loadImage(assetPath: string): Promise<HTMLImageElement>;
/**
* Load text content from asset path.
* 从资产路径加载文本内容。
*
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
* @returns Promise resolving to text content. | 返回文本内容的 Promise。
*/
loadText(assetPath: string): Promise<string>;
/**
* Load binary data from asset path.
* 从资产路径加载二进制数据。
*
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
* @returns Promise resolving to ArrayBuffer. | 返回 ArrayBuffer 的 Promise。
*/
loadBinary(assetPath: string): Promise<ArrayBuffer>;
/**
* Check if asset file exists.
* 检查资产文件是否存在。
*
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
* @returns Promise resolving to boolean. | 返回布尔值的 Promise。
*/
exists(assetPath: string): Promise<boolean>;
}
/**
* Global asset file loader instance.
* 全局资产文件加载器实例。
*/
let globalAssetFileLoader: IAssetFileLoader | null = null;
/**
* Set the global asset file loader.
* 设置全局资产文件加载器。
*
* Should be called during engine initialization with platform-specific implementation.
* 应在引擎初始化期间使用平台特定的实现调用。
*
* @param loader - Asset file loader instance or null. | 资产文件加载器实例或 null。
*/
export function setGlobalAssetFileLoader(loader: IAssetFileLoader | null): void {
globalAssetFileLoader = loader;
}
/**
* Get the global asset file loader.
* 获取全局资产文件加载器。
*
* @returns Asset file loader instance or null. | 资产文件加载器实例或 null。
*/
export function getGlobalAssetFileLoader(): IAssetFileLoader | null {
return globalAssetFileLoader;
}

View File

@@ -0,0 +1,616 @@
/**
* Asset loader interfaces
* 资产加载器接口
*/
import {
AssetType,
AssetGUID,
IAssetLoadOptions,
IAssetMetadata
} from '../types/AssetTypes';
import type { IAssetContent, AssetContentType } from './IAssetReader';
/**
* 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. | 支持的资产类型。 */
readonly supportedType: AssetType;
/** Supported file extensions. | 支持的文件扩展名。 */
readonly supportedExtensions: string[];
/**
* Required content type for this loader.
* 此加载器需要的内容类型。
*
* - 'text': For JSON, shader, material files
* - 'binary': For binary formats
* - 'image': For textures
* - 'audio': For audio files
*/
readonly contentType: AssetContentType;
/**
* Parse asset from content.
* 从内容解析资产。
*
* @param content - File content. | 文件内容。
* @param context - Parse context. | 解析上下文。
* @returns Parsed asset. | 解析后的资产。
*/
parse(content: IAssetContent, context: IAssetParseContext): Promise<T>;
/**
* Dispose loaded asset and free resources.
* 释放已加载的资产。
*/
dispose(asset: T): void;
}
/**
* Asset loader factory interface
* 资产加载器工厂接口
*/
export interface IAssetLoaderFactory {
/**
* Create loader for specific asset type
* 为特定资产类型创建加载器
*/
createLoader(type: AssetType): IAssetLoader | null;
/**
* Create loader for a specific file path (selects by extension)
* 为特定文件路径创建加载器(按扩展名选择)
*
* This method is preferred over createLoader() when multiple loaders
* support the same asset type (e.g., Model3D with GLTF/OBJ/FBX).
* 当多个加载器支持相同资产类型时(如 Model3D 的 GLTF/OBJ/FBX
* 优先使用此方法而非 createLoader()。
*/
createLoaderForPath(path: string): IAssetLoader | null;
/**
* Register custom loader
* 注册自定义加载器
*/
registerLoader(type: AssetType, loader: IAssetLoader): void;
/**
* Register a loader for a specific file extension
* 为特定文件扩展名注册加载器
*/
registerExtensionLoader(extension: string, loader: IAssetLoader): void;
/**
* Unregister loader
* 注销加载器
*/
unregisterLoader(type: AssetType): void;
/**
* Check if loader exists for type
* 检查类型是否有加载器
*/
hasLoader(type: AssetType): boolean;
/**
* Get asset type by file extension
* 根据文件扩展名获取资产类型
*/
getAssetTypeByExtension(extension: string): AssetType | null;
/**
* Get asset type by file path
* 根据文件路径获取资产类型
*/
getAssetTypeByPath(path: string): AssetType | null;
/**
* Get all supported file extensions from all registered loaders.
* 获取所有注册加载器支持的文件扩展名。
*
* @returns Array of extension patterns (e.g., ['*.png', '*.jpg', '*.particle'])
*/
getAllSupportedExtensions(): string[];
/**
* Get extension to type mapping for all registered loaders.
* 获取所有注册加载器的扩展名到类型的映射。
*
* @returns Map of extension (without dot) to asset type string
*/
getExtensionTypeMap(): Record<string, string>;
}
/**
* Texture asset interface
* 纹理资产接口
*/
export interface ITextureAsset {
/** WebGL纹理ID / WebGL texture ID */
textureId: number;
/** 宽度 / Width */
width: number;
/** 高度 / Height */
height: number;
/** 格式 / Format */
format: 'rgba' | 'rgb' | 'alpha';
/** 是否有Mipmap / Has mipmaps */
hasMipmaps: boolean;
/** 原始数据(如果可用) / Raw image data if available */
data?: ImageData | HTMLImageElement;
// ===== Sprite Settings =====
// ===== Sprite 设置 =====
/**
* 九宫格切片边距 [top, right, bottom, left]
* Nine-patch slice border
*
* Defines the non-stretchable borders for nine-patch rendering.
* 定义九宫格渲染时不可拉伸的边框区域。
*/
sliceBorder?: [number, number, number, number];
/**
* Sprite 锚点 [x, y]0-1 归一化)
* Sprite pivot point (0-1 normalized)
*/
pivot?: [number, number];
}
/**
* Mesh asset interface
* 网格资产接口
*/
export interface IMeshAsset {
/** 顶点数据 / Vertex data */
vertices: Float32Array;
/** 索引数据 / Index data */
indices: Uint16Array | Uint32Array;
/** 法线数据 / Normal data */
normals?: Float32Array;
/** UV坐标 / UV coordinates */
uvs?: Float32Array;
/** 切线数据 / Tangent data */
tangents?: Float32Array;
/** 边界盒 / Axis-aligned bounding box */
bounds: {
min: [number, number, number];
max: [number, number, number];
};
}
/**
* Audio asset interface
* 音频资产接口
*/
export interface IAudioAsset {
/** 音频缓冲区 / Audio buffer */
buffer: AudioBuffer;
/** 时长(秒) / Duration in seconds */
duration: number;
/** 采样率 / Sample rate */
sampleRate: number;
/** 声道数 / Number of channels */
channels: number;
}
/**
* Shader property type
* 着色器属性类型
*/
export type ShaderPropertyType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'int' | 'sampler2D' | 'mat3' | 'mat4';
/**
* Shader property definition
* 着色器属性定义
*/
export interface IShaderProperty {
/** 属性名称uniform 名) / Property name (uniform name) */
name: string;
/** 属性类型 / Property type */
type: ShaderPropertyType;
/** 默认值 / Default value */
default: number | number[];
/** 显示名称(编辑器用) / Display name for editor */
displayName?: string;
/** 值范围(用于 float/int / Value range for float/int */
range?: [number, number];
/** 是否隐藏(内部使用) / Hidden from inspector */
hidden?: boolean;
}
/**
* Shader asset interface
* 着色器资产接口
*
* Shader assets contain GLSL source code and property definitions.
* 着色器资产包含 GLSL 源代码和属性定义。
*/
export interface IShaderAsset {
/** 着色器名称 / Shader name (e.g., "UI/Shiny") */
name: string;
/** 顶点着色器源代码 / Vertex shader GLSL source */
vertex: string;
/** 片段着色器源代码 / Fragment shader GLSL source */
fragment: string;
/** 属性定义列表 / Property definitions */
properties: IShaderProperty[];
/** 编译后的着色器 ID运行时填充 / Compiled shader ID (runtime) */
shaderId?: number;
}
/**
* Material property value
* 材质属性值
*/
export type MaterialPropertyValue = number | number[] | string;
/**
* Material animator configuration
* 材质动画器配置
*/
export interface IMaterialAnimator {
/** 要动画的属性名 / Property to animate */
property: string;
/** 起始值 / Start value */
from: number;
/** 结束值 / End value */
to: number;
/** 持续时间(秒) / Duration in seconds */
duration: number;
/** 是否循环 / Loop animation */
loop?: boolean;
/** 循环间隔(秒) / Delay between loops */
loopDelay?: number;
/** 缓动函数 / Easing function */
easing?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
/** 是否自动播放 / Auto play on start */
autoPlay?: boolean;
}
/**
* Material asset interface
* 材质资产接口
*
* Material assets reference a shader and define property values.
* 材质资产引用着色器并定义属性值。
*/
export interface IMaterialAsset {
/** 材质名称 / Material name */
name: string;
/** 着色器 GUID 或内置路径 / Shader GUID or built-in path (e.g., "builtin://shaders/Shiny") */
shader: string;
/** 材质属性值 / Material property values */
properties: Record<string, MaterialPropertyValue>;
/** 纹理映射 / Texture slot mappings (property name -> texture GUID) */
textures?: Record<string, AssetGUID>;
/** 渲染状态 / Render states */
renderStates?: {
cullMode?: 'none' | 'front' | 'back';
blendMode?: 'none' | 'alpha' | 'additive' | 'multiply' | 'screen';
depthTest?: boolean;
depthWrite?: boolean;
};
/** 动画器配置(可选) / Animator configuration (optional) */
animator?: IMaterialAnimator;
/** 运行时:编译后的着色器 ID / Runtime: compiled shader ID */
_shaderId?: number;
/** 运行时:引擎材质 ID / Runtime: engine material ID */
_materialId?: number;
}
// 预制体资产接口从专用文件导出 | Prefab asset interface exported from dedicated file
export type { IPrefabAsset, IPrefabData, IPrefabMetadata, IPrefabService } from './IPrefabAsset';
/**
* Scene asset interface
* 场景资产接口
*/
export interface ISceneAsset {
/** 场景名称 / Scene name */
name: string;
/** 实体列表 / Serialized entity list */
entities: unknown[];
/** 场景设置 / Scene settings */
settings: {
/** 环境光 / Ambient light */
ambientLight?: [number, number, number];
/** 雾效 / Fog settings */
fog?: {
enabled: boolean;
color: [number, number, number];
density: number;
};
/** 天空盒 / Skybox asset */
skybox?: AssetGUID;
};
/** 引用的资产 / All referenced assets */
referencedAssets: AssetGUID[];
}
/**
* JSON asset interface
* JSON资产接口
*/
export interface IJsonAsset {
/** JSON数据 / JSON data */
data: unknown;
}
/**
* Text asset interface
* 文本资产接口
*/
export interface ITextAsset {
/** 文本内容 / Text content */
content: string;
/** 编码格式 / Encoding */
encoding: 'utf8' | 'utf16' | 'ascii';
}
/**
* Binary asset interface
* 二进制资产接口
*/
export interface IBinaryAsset {
/** 二进制数据 / Binary data */
data: ArrayBuffer;
/** MIME类型 / MIME type */
mimeType?: string;
}
// ===== GLTF/GLB 3D Model Types =====
// ===== GLTF/GLB 3D 模型类型 =====
/**
* Bounding box interface
* 边界盒接口
*/
export interface IBoundingBox {
/** 最小坐标 [x, y, z] | Minimum coordinates */
min: [number, number, number];
/** 最大坐标 [x, y, z] | Maximum coordinates */
max: [number, number, number];
}
/**
* Extended mesh data with name and material reference
* 扩展的网格数据,包含名称和材质引用
*/
export interface IMeshData extends IMeshAsset {
/** 网格名称 | Mesh name */
name: string;
/** 引用的材质索引 | Referenced material index */
materialIndex: number;
/** 顶点颜色(如果有)| Vertex colors if available */
colors?: Float32Array;
// ===== Skinning data for skeletal animation =====
// ===== 骨骼动画蒙皮数据 =====
/**
* Joint indices per vertex (4 influences, GLTF JOINTS_0)
* 每顶点的关节索引4 个影响GLTF JOINTS_0
* Format: [j0, j1, j2, j3] for each vertex
*/
joints?: Uint8Array | Uint16Array;
/**
* Joint weights per vertex (4 influences, GLTF WEIGHTS_0)
* 每顶点的关节权重4 个影响GLTF WEIGHTS_0
* Format: [w0, w1, w2, w3] for each vertex, should sum to 1.0
*/
weights?: Float32Array;
}
/**
* GLTF material definition
* GLTF 材质定义
*/
export interface IGLTFMaterial {
/** 材质名称 | Material name */
name: string;
/** 基础颜色 [r, g, b, a] | Base color factor */
baseColorFactor: [number, number, number, number];
/** 基础颜色纹理索引 | Base color texture index (-1 if none) */
baseColorTextureIndex: number;
/** 金属度 (0-1) | Metallic factor */
metallicFactor: number;
/** 粗糙度 (0-1) | Roughness factor */
roughnessFactor: number;
/** 金属粗糙度纹理索引 | Metallic-roughness texture index */
metallicRoughnessTextureIndex: number;
/** 法线纹理索引 | Normal texture index */
normalTextureIndex: number;
/** 法线缩放 | Normal scale */
normalScale: number;
/** 遮挡纹理索引 | Occlusion texture index */
occlusionTextureIndex: number;
/** 遮挡强度 | Occlusion strength */
occlusionStrength: number;
/** 自发光因子 [r, g, b] | Emissive factor */
emissiveFactor: [number, number, number];
/** 自发光纹理索引 | Emissive texture index */
emissiveTextureIndex: number;
/** Alpha 模式 | Alpha mode */
alphaMode: 'OPAQUE' | 'MASK' | 'BLEND';
/** Alpha 剔除阈值 | Alpha cutoff */
alphaCutoff: number;
/** 是否双面 | Double sided */
doubleSided: boolean;
}
/**
* GLTF texture info
* GLTF 纹理信息
*/
export interface IGLTFTextureInfo {
/** 纹理名称 | Texture name */
name?: string;
/** 图像数据(嵌入式)| Image data (embedded) */
imageData?: ArrayBuffer;
/** 图像 MIME 类型 | Image MIME type */
mimeType?: string;
/** 外部 URI非嵌入| External URI (non-embedded) */
uri?: string;
/** 加载后的纹理资产 GUID | Loaded texture asset GUID */
textureGuid?: AssetGUID;
}
/**
* GLTF node (scene hierarchy)
* GLTF 节点(场景层级)
*/
export interface IGLTFNode {
/** 节点名称 | Node name */
name: string;
/** 网格索引(可选)| Mesh index (optional) */
meshIndex?: number;
/** 子节点索引列表 | Child node indices */
children: number[];
/** 变换信息 | Transform info */
transform: {
/** 位置 [x, y, z] | Position */
position: [number, number, number];
/** 旋转四元数 [x, y, z, w] | Rotation quaternion */
rotation: [number, number, number, number];
/** 缩放 [x, y, z] | Scale */
scale: [number, number, number];
};
}
/**
* Animation channel target
* 动画通道目标
*/
export interface IAnimationChannelTarget {
/** 目标节点索引 | Target node index */
nodeIndex: number;
/** 目标属性 | Target property */
path: 'translation' | 'rotation' | 'scale' | 'weights';
}
/**
* Animation sampler
* 动画采样器
*/
export interface IAnimationSampler {
/** 输入时间数组 | Input time array */
input: Float32Array;
/** 输出值数组 | Output values array */
output: Float32Array;
/** 插值类型 | Interpolation type */
interpolation: 'LINEAR' | 'STEP' | 'CUBICSPLINE';
}
/**
* Animation channel
* 动画通道
*/
export interface IAnimationChannel {
/** 采样器索引 | Sampler index */
samplerIndex: number;
/** 目标 | Target */
target: IAnimationChannelTarget;
}
/**
* Animation clip from GLTF
* GLTF 动画片段
*/
export interface IGLTFAnimationClip {
/** 动画名称 | Animation name */
name: string;
/** 动画时长(秒)| Duration in seconds */
duration: number;
/** 采样器列表 | Sampler list */
samplers: IAnimationSampler[];
/** 通道列表 | Channel list */
channels: IAnimationChannel[];
}
/**
* Skeleton joint
* 骨骼关节
*/
export interface ISkeletonJoint {
/** 关节名称 | Joint name */
name: string;
/** 节点索引 | Node index */
nodeIndex: number;
/** 父关节索引(-1 表示根)| Parent joint index (-1 for root) */
parentIndex: number;
/** 逆绑定矩阵 (4x4) | Inverse bind matrix */
inverseBindMatrix: Float32Array;
}
/**
* Skeleton data
* 骨骼数据
*/
export interface ISkeletonData {
/** 关节列表 | Joint list */
joints: ISkeletonJoint[];
/** 根关节索引 | Root joint index */
rootJointIndex: number;
}
/**
* GLTF/GLB 3D model asset interface
* GLTF/GLB 3D 模型资产接口
*/
export interface IGLTFAsset {
/** 模型名称 | Model name */
name: string;
/** 网格数据列表 | Mesh data list */
meshes: IMeshData[];
/** 材质列表 | Material list */
materials: IGLTFMaterial[];
/** 纹理信息列表 | Texture info list */
textures: IGLTFTextureInfo[];
/** 场景层级节点 | Scene hierarchy nodes */
nodes: IGLTFNode[];
/** 根节点索引列表 | Root node indices */
rootNodes: number[];
/** 动画片段列表(可选)| Animation clips (optional) */
animations?: IGLTFAnimationClip[];
/** 骨骼数据(可选)| Skeleton data (optional) */
skeleton?: ISkeletonData;
/** 整体边界盒 | Overall bounding box */
bounds: IBoundingBox;
/** 源文件路径 | Source file path */
sourcePath?: string;
}

View File

@@ -0,0 +1,370 @@
/**
* Asset manager interfaces
* 资产管理器接口
*/
import {
AssetGUID,
AssetHandle,
AssetType,
AssetState,
IAssetLoadOptions,
IAssetLoadResult,
IAssetReferenceInfo,
IAssetPreloadGroup,
IAssetLoadProgress,
IAssetCatalog
} from '../types/AssetTypes';
import { IAssetLoader, IAssetLoaderFactory } from './IAssetLoader';
import { IAssetReader } from './IAssetReader';
import type { AssetDatabase } from '../core/AssetDatabase';
/**
* Asset manager interface
* 资产管理器接口
*/
export interface IAssetManager {
/**
* Load asset by GUID
* 通过GUID加载资产
*/
loadAsset<T = unknown>(
guid: AssetGUID,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<T>>;
/**
* Load asset by path
* 通过路径加载资产
*/
loadAssetByPath<T = unknown>(
path: string,
options?: IAssetLoadOptions
): Promise<IAssetLoadResult<T>>;
/**
* Load multiple assets
* 批量加载资产
*/
loadAssets(
guids: AssetGUID[],
options?: IAssetLoadOptions
): Promise<Map<AssetGUID, IAssetLoadResult>>;
/**
* Preload asset group
* 预加载资产组
*/
preloadGroup(
group: IAssetPreloadGroup,
onProgress?: (progress: IAssetLoadProgress) => void
): Promise<void>;
/**
* Get loaded asset
* 获取已加载的资产
*/
getAsset<T = unknown>(guid: AssetGUID): T | null;
/**
* Get asset by handle
* 通过句柄获取资产
*/
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
* 检查资产是否已加载
*/
isLoaded(guid: AssetGUID): boolean;
/**
* Get asset state
* 获取资产状态
*/
getAssetState(guid: AssetGUID): AssetState;
/**
* Unload asset
* 卸载资产
*/
unloadAsset(guid: AssetGUID): void;
/**
* Unload all assets
* 卸载所有资产
*/
unloadAllAssets(): void;
/**
* Unload unused assets
* 卸载未使用的资产
*/
unloadUnusedAssets(): void;
/**
* Add reference to asset
* 增加资产引用
*/
addReference(guid: AssetGUID): void;
/**
* Remove reference from asset
* 移除资产引用
*/
removeReference(guid: AssetGUID): void;
/**
* Get reference info
* 获取引用信息
*/
getReferenceInfo(guid: AssetGUID): IAssetReferenceInfo | null;
/**
* Register custom loader
* 注册自定义加载器
*/
registerLoader(type: AssetType, loader: IAssetLoader): void;
/**
* Get asset statistics
* 获取资产统计信息
*/
getStatistics(): {
loadedCount: number;
loadQueue: number;
failedCount: number;
};
/**
* Clear cache
* 清空缓存
*/
clearCache(): void;
/**
* Dispose manager
* 释放管理器
*/
dispose(): void;
/**
* Set asset reader
* 设置资产读取器
*/
setReader(reader: IAssetReader): void;
/**
* Initialize from catalog
* 从目录初始化
*
* Loads asset metadata from a catalog for runtime asset resolution.
* 从目录加载资产元数据,用于运行时资产解析。
*/
initializeFromCatalog(catalog: IAssetCatalog): void;
/**
* Get the asset database
* 获取资产数据库
*/
getDatabase(): AssetDatabase;
/**
* Get the loader factory
* 获取加载器工厂
*/
getLoaderFactory(): IAssetLoaderFactory;
/**
* Set project root path
* 设置项目根路径
*/
setProjectRoot(path: string): void;
}
/**
* Asset cache interface
* 资产缓存接口
*/
export interface IAssetCache {
/**
* Get cached asset
* 获取缓存的资产
*/
get<T = unknown>(guid: AssetGUID): T | null;
/**
* Set cached asset
* 设置缓存的资产
*/
set<T = unknown>(guid: AssetGUID, asset: T, size: number): void;
/**
* Check if asset is cached
* 检查资产是否已缓存
*/
has(guid: AssetGUID): boolean;
/**
* Remove from cache
* 从缓存中移除
*/
remove(guid: AssetGUID): void;
/**
* Clear all cache
* 清空所有缓存
*/
clear(): void;
/**
* Get cache size
* 获取缓存大小
*/
getSize(): number;
/**
* Get cached asset count
* 获取缓存资产数量
*/
getCount(): number;
/**
* Evict assets based on policy
* 根据策略驱逐资产
*/
evict(targetSize: number): void;
}
/**
* Asset loading queue interface
* 资产加载队列接口
*/
export interface IAssetLoadQueue {
/**
* Add to queue
* 添加到队列
*/
enqueue(
guid: AssetGUID,
priority: number,
options?: IAssetLoadOptions
): void;
/**
* Remove from queue
* 从队列移除
*/
dequeue(): {
guid: AssetGUID;
options?: IAssetLoadOptions;
} | null;
/**
* Check if queue is empty
* 检查队列是否为空
*/
isEmpty(): boolean;
/**
* Get queue size
* 获取队列大小
*/
getSize(): number;
/**
* Clear queue
* 清空队列
*/
clear(): void;
/**
* Reprioritize item
* 重新设置优先级
*/
reprioritize(guid: AssetGUID, newPriority: number): void;
}
/**
* Asset dependency resolver interface
* 资产依赖解析器接口
*/
export interface IAssetDependencyResolver {
/**
* Resolve dependencies for asset
* 解析资产的依赖
*/
resolveDependencies(guid: AssetGUID): Promise<AssetGUID[]>;
/**
* Get direct dependencies
* 获取直接依赖
*/
getDirectDependencies(guid: AssetGUID): AssetGUID[];
/**
* Get all dependencies recursively
* 递归获取所有依赖
*/
getAllDependencies(guid: AssetGUID): AssetGUID[];
/**
* Check for circular dependencies
* 检查循环依赖
*/
hasCircularDependency(guid: AssetGUID): boolean;
/**
* Build dependency graph
* 构建依赖图
*/
buildDependencyGraph(guids: AssetGUID[]): Map<AssetGUID, AssetGUID[]>;
}
/**
* Asset streaming interface
* 资产流式加载接口
*/
export interface IAssetStreaming {
/**
* Start streaming assets
* 开始流式加载资产
*/
startStreaming(guids: AssetGUID[]): void;
/**
* Stop streaming
* 停止流式加载
*/
stopStreaming(): void;
/**
* Pause streaming
* 暂停流式加载
*/
pauseStreaming(): void;
/**
* Resume streaming
* 恢复流式加载
*/
resumeStreaming(): void;
/**
* Set streaming budget per frame
* 设置每帧流式加载预算
*/
setFrameBudget(milliseconds: number): void;
/**
* Get streaming progress
* 获取流式加载进度
*/
getProgress(): IAssetLoadProgress;
}

View 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');

View File

@@ -0,0 +1,405 @@
/**
* 预制体资产接口定义
* Prefab asset interface definitions
*
* 定义预制体系统的核心类型,包括预制体数据格式、元数据、实例化选项等。
* Defines core types for the prefab system including data format, metadata, instantiation options, etc.
*/
import type { AssetGUID } from '../types/AssetTypes';
import type { SerializedEntity } from '@esengine/ecs-framework';
/**
* 预制体序列化实体(扩展自 SerializedEntity
* Serialized prefab entity (extends SerializedEntity)
*
* 在标准 SerializedEntity 基础上添加预制体特定属性。
* Adds prefab-specific properties on top of standard SerializedEntity.
*/
export interface SerializedPrefabEntity extends SerializedEntity {
/**
* 是否为预制体根节点
* Whether this is the prefab root entity
*/
isPrefabRoot?: boolean;
/**
* 嵌套预制体的 GUID如果此实体是另一个预制体的实例
* GUID of nested prefab (if this entity is an instance of another prefab)
*/
nestedPrefabGuid?: AssetGUID;
}
/**
* 预制体元数据
* Prefab metadata
*/
export interface IPrefabMetadata {
/**
* 预制体名称
* Prefab name
*/
name: string;
/**
* 资产 GUID在保存为资产后填充
* Asset GUID (populated after saving as asset)
*/
guid?: AssetGUID;
/**
* 创建时间戳
* Creation timestamp
*/
createdAt: number;
/**
* 最后修改时间戳
* Last modification timestamp
*/
modifiedAt: number;
/**
* 使用的组件类型列表
* List of component types used
*/
componentTypes: string[];
/**
* 引用的资产 GUID 列表
* List of referenced asset GUIDs
*/
referencedAssets: AssetGUID[];
/**
* 预制体描述
* Prefab description
*/
description?: string;
/**
* 预制体标签(用于分类和搜索)
* Prefab tags (for categorization and search)
*/
tags?: string[];
/**
* 缩略图数据Base64 编码)
* Thumbnail data (Base64 encoded)
*/
thumbnail?: string;
}
/**
* 组件类型注册条目
* Component type registry entry
*/
export interface IPrefabComponentTypeEntry {
/**
* 组件类型名称
* Component type name
*/
typeName: string;
/**
* 组件版本号
* Component version number
*/
version: number;
}
/**
* 预制体文件数据格式
* Prefab file data format
*
* 这是 .prefab 文件的完整结构。
* This is the complete structure of a .prefab file.
*/
export interface IPrefabData {
/**
* 预制体格式版本号
* Prefab format version number
*/
version: number;
/**
* 预制体元数据
* Prefab metadata
*/
metadata: IPrefabMetadata;
/**
* 根实体数据(包含完整的实体层级)
* Root entity data (contains full entity hierarchy)
*/
root: SerializedPrefabEntity;
/**
* 组件类型注册表(用于版本管理和兼容性检查)
* Component type registry (for versioning and compatibility checks)
*/
componentTypeRegistry: IPrefabComponentTypeEntry[];
}
/**
* 预制体资产(加载后的内存表示)
* Prefab asset (in-memory representation after loading)
*/
export interface IPrefabAsset {
/**
* 预制体数据
* Prefab data
*/
data: IPrefabData;
/**
* 资产 GUID
* Asset GUID
*/
guid: AssetGUID;
/**
* 资产路径
* Asset path
*/
path: string;
/**
* 根实体数据(快捷访问)
* Root entity data (quick access)
*/
readonly root: SerializedPrefabEntity;
/**
* 包含的组件类型列表(快捷访问)
* List of component types used (quick access)
*/
readonly componentTypes: string[];
/**
* 引用的资产列表(快捷访问)
* List of referenced assets (quick access)
*/
readonly referencedAssets: AssetGUID[];
}
/**
* 预制体实例化选项
* Prefab instantiation options
*/
export interface IPrefabInstantiateOptions {
/**
* 父实体 ID可选
* Parent entity ID (optional)
*/
parentId?: number;
/**
* 位置覆盖
* Position override
*/
position?: { x: number; y: number };
/**
* 旋转覆盖(角度)
* Rotation override (in degrees)
*/
rotation?: number;
/**
* 缩放覆盖
* Scale override
*/
scale?: { x: number; y: number };
/**
* 实体名称覆盖
* Entity name override
*/
name?: string;
/**
* 是否保留原始实体 ID默认 false生成新 ID
* Whether to preserve original entity IDs (default false, generate new IDs)
*/
preserveIds?: boolean;
/**
* 是否标记为预制体实例(默认 true
* Whether to mark as prefab instance (default true)
*/
trackInstance?: boolean;
/**
* 属性覆盖(组件属性覆盖)
* Property overrides (component property overrides)
*/
propertyOverrides?: IPrefabPropertyOverride[];
}
/**
* 预制体属性覆盖
* Prefab property override
*
* 用于记录预制体实例对原始预制体属性的修改。
* Used to record modifications to prefab properties in instances.
*/
export interface IPrefabPropertyOverride {
/**
* 目标实体路径(从根节点的相对路径,如 "Root/Child/GrandChild"
* Target entity path (relative path from root, e.g., "Root/Child/GrandChild")
*/
entityPath: string;
/**
* 组件类型名称
* Component type name
*/
componentType: string;
/**
* 属性路径(支持嵌套,如 "position.x"
* Property path (supports nesting, e.g., "position.x")
*/
propertyPath: string;
/**
* 覆盖值
* Override value
*/
value: unknown;
}
/**
* 预制体创建选项
* Prefab creation options
*/
export interface IPrefabCreateOptions {
/**
* 预制体名称
* Prefab name
*/
name: string;
/**
* 预制体描述
* Prefab description
*/
description?: string;
/**
* 预制体标签
* Prefab tags
*/
tags?: string[];
/**
* 是否包含子实体
* Whether to include child entities
*/
includeChildren?: boolean;
/**
* 保存路径(可选,用于指定保存位置)
* Save path (optional, for specifying save location)
*/
savePath?: string;
}
/**
* 预制体服务接口
* Prefab service interface
*
* 提供预制体的创建、实例化、管理等功能。
* Provides prefab creation, instantiation, management, etc.
*/
export interface IPrefabService {
/**
* 从实体创建预制体数据
* Create prefab data from entity
*
* @param entity - 源实体 | Source entity
* @param options - 创建选项 | Creation options
* @returns 预制体数据 | Prefab data
*/
createPrefab(entity: unknown, options: IPrefabCreateOptions): IPrefabData;
/**
* 实例化预制体
* Instantiate prefab
*
* @param prefab - 预制体资产 | Prefab asset
* @param scene - 目标场景 | Target scene
* @param options - 实例化选项 | Instantiation options
* @returns 创建的根实体 | Created root entity
*/
instantiate(prefab: IPrefabAsset, scene: unknown, options?: IPrefabInstantiateOptions): unknown;
/**
* 通过 GUID 实例化预制体
* Instantiate prefab by GUID
*
* @param guid - 预制体资产 GUID | Prefab asset GUID
* @param scene - 目标场景 | Target scene
* @param options - 实例化选项 | Instantiation options
* @returns 创建的根实体 | Created root entity
*/
instantiateByGuid(guid: AssetGUID, scene: unknown, options?: IPrefabInstantiateOptions): Promise<unknown>;
/**
* 检查实体是否为预制体实例
* Check if entity is a prefab instance
*
* @param entity - 要检查的实体 | Entity to check
* @returns 是否为预制体实例 | Whether it's a prefab instance
*/
isPrefabInstance(entity: unknown): boolean;
/**
* 获取预制体实例的源预制体 GUID
* Get source prefab GUID of a prefab instance
*
* @param entity - 预制体实例 | Prefab instance
* @returns 源预制体 GUID如果不是实例则返回 null | Source prefab GUID, null if not an instance
*/
getSourcePrefabGuid(entity: unknown): AssetGUID | null;
/**
* 将实例的修改应用到源预制体
* Apply instance modifications to source prefab
*
* @param instance - 预制体实例 | Prefab instance
* @returns 是否成功应用 | Whether application was successful
*/
applyToPrefab?(instance: unknown): Promise<boolean>;
/**
* 将实例还原为源预制体的状态
* Revert instance to source prefab state
*
* @param instance - 预制体实例 | Prefab instance
* @returns 是否成功还原 | Whether revert was successful
*/
revertToPrefab?(instance: unknown): Promise<boolean>;
/**
* 获取实例相对于源预制体的属性覆盖
* Get property overrides of instance relative to source prefab
*
* @param instance - 预制体实例 | Prefab instance
* @returns 属性覆盖列表 | List of property overrides
*/
getPropertyOverrides?(instance: unknown): IPrefabPropertyOverride[];
}
/**
* 预制体文件格式版本
* Prefab file format version
*/
export const PREFAB_FORMAT_VERSION = 1;
/**
* 预制体文件扩展名
* Prefab file extension
*/
export const PREFAB_FILE_EXTENSION = '.prefab';

View File

@@ -0,0 +1,62 @@
/**
* 资源组件接口 - 用于依赖运行时资源的组件(纹理、音频等)
* Interface for components that depend on runtime resources (textures, audio, etc.)
*
* 实现此接口的组件可以参与 SceneResourceManager 管理的集中式资源加载
* Components implementing this interface can participate in centralized resource loading managed by SceneResourceManager
*/
/**
* 资源引用 - 包含路径和运行时 ID
* Resource reference with path and runtime ID
*/
export interface ResourceReference {
/** 资源路径(例如 "assets/sprites/player.png"/ Asset path (e.g., "assets/sprites/player.png") */
path: string;
/** 引擎分配的运行时资源 ID例如 GPU 上的纹理 ID/ Runtime resource ID assigned by engine (e.g., texture ID on GPU) */
runtimeId?: number;
/** 资源类型标识符 / Resource type identifier */
type: 'texture' | 'audio' | 'font' | 'data';
}
/**
* 资源组件接口
* Resource component interface
*
* 实现此接口的组件可以在场景启动前由 SceneResourceManager 集中加载资源
* Components implementing this interface can have their resources loaded centrally by SceneResourceManager before the scene starts
*/
export interface IResourceComponent {
/**
* 获取此组件需要的所有资源引用
* Get all resource references needed by this component
*
* 在场景加载期间调用以收集资源路径
* Called during scene loading to collect resource paths
*/
getResourceReferences(): ResourceReference[];
/**
* 设置已加载资源的运行时 ID
* Set runtime IDs for loaded resources
*
* 在 SceneResourceManager 加载资源后调用
* Called after resources are loaded by SceneResourceManager
*
* @param pathToId 资源路径到运行时 ID 的映射 / Map of resource paths to runtime IDs
*/
setResourceIds(pathToId: Map<string, number>): void;
}
/**
* 类型守卫 - 检查组件是否实现了 IResourceComponent
* Type guard to check if a component implements IResourceComponent
*/
export function isResourceComponent(component: any): component is IResourceComponent {
return (
component !== null &&
typeof component === 'object' &&
typeof component.getResourceReferences === 'function' &&
typeof component.setResourceIds === 'function'
);
}

View File

@@ -0,0 +1,285 @@
/**
* Asset loader factory implementation
* 资产加载器工厂实现
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, IAssetLoaderFactory } from '../interfaces/IAssetLoader';
import { TextureLoader } from './TextureLoader';
import { JsonLoader } from './JsonLoader';
import { TextLoader } from './TextLoader';
import { BinaryLoader } from './BinaryLoader';
import { AudioLoader } from './AudioLoader';
import { PrefabLoader } from './PrefabLoader';
import { GLTFLoader } from './GLTFLoader';
import { OBJLoader } from './OBJLoader';
import { FBXLoader } from './FBXLoader';
/**
* Asset loader factory
* 资产加载器工厂
*
* Supports multiple loaders per asset type (selected by file extension).
* 支持每种资产类型的多个加载器(按文件扩展名选择)。
*/
export class AssetLoaderFactory implements IAssetLoaderFactory {
private readonly _loaders = new Map<AssetType, IAssetLoader>();
/** Extension -> Loader map for precise loader selection */
/** 扩展名 -> 加载器映射,用于精确选择加载器 */
private readonly _extensionLoaders = new Map<string, IAssetLoader>();
constructor() {
// 注册默认加载器 / Register default loaders
this.registerDefaultLoaders();
}
/**
* Register default loaders
* 注册默认加载器
*/
private registerDefaultLoaders(): void {
// 纹理加载器 / Texture loader
this._loaders.set(AssetType.Texture, new TextureLoader());
// JSON加载器 / JSON loader
this._loaders.set(AssetType.Json, new JsonLoader());
// 文本加载器 / Text loader
this._loaders.set(AssetType.Text, new TextLoader());
// 二进制加载器 / Binary loader
this._loaders.set(AssetType.Binary, new BinaryLoader());
// 音频加载器 / Audio loader
this._loaders.set(AssetType.Audio, new AudioLoader());
// 预制体加载器 / Prefab loader
this._loaders.set(AssetType.Prefab, new PrefabLoader());
// 3D模型加载器 / 3D Model loaders
// Default is GLTF, but OBJ and FBX are also supported
// 默认是 GLTF但也支持 OBJ 和 FBX
const gltfLoader = new GLTFLoader();
const objLoader = new OBJLoader();
const fbxLoader = new FBXLoader();
this._loaders.set(AssetType.Model3D, gltfLoader);
// Register extension-specific loaders
// 注册特定扩展名的加载器
this.registerExtensionLoader('.gltf', gltfLoader);
this.registerExtensionLoader('.glb', gltfLoader);
this.registerExtensionLoader('.obj', objLoader);
this.registerExtensionLoader('.fbx', fbxLoader);
// 注Shader 和 Material 加载器由 material-system 模块注册
// Note: Shader and Material loaders are registered by material-system module
}
/**
* Register a loader for a specific file extension
* 为特定文件扩展名注册加载器
*/
registerExtensionLoader(extension: string, loader: IAssetLoader): void {
const ext = extension.toLowerCase().startsWith('.') ? extension.toLowerCase() : `.${extension.toLowerCase()}`;
this._extensionLoaders.set(ext, loader);
}
/**
* Create loader for specific asset type
* 为特定资产类型创建加载器
*/
createLoader(type: AssetType): IAssetLoader | null {
return this._loaders.get(type) || null;
}
/**
* Create loader for a specific file path (selects by extension)
* 为特定文件路径创建加载器(按扩展名选择)
*
* This method is preferred over createLoader() when multiple loaders
* support the same asset type (e.g., Model3D with GLTF/OBJ/FBX).
* 当多个加载器支持相同资产类型时(如 Model3D 的 GLTF/OBJ/FBX
* 优先使用此方法而非 createLoader()。
*/
createLoaderForPath(path: string): IAssetLoader | null {
const lastDot = path.lastIndexOf('.');
if (lastDot !== -1) {
const ext = path.substring(lastDot).toLowerCase();
// First try extension-specific loader
// 首先尝试特定扩展名的加载器
const extLoader = this._extensionLoaders.get(ext);
if (extLoader) {
return extLoader;
}
}
// Fall back to type-based lookup
// 回退到基于类型的查找
const type = this.getAssetTypeByPath(path);
if (type) {
return this.createLoader(type);
}
return null;
}
/**
* Register custom loader
* 注册自定义加载器
*/
registerLoader(type: AssetType, loader: IAssetLoader): void {
this._loaders.set(type, loader);
}
/**
* Unregister loader
* 注销加载器
*/
unregisterLoader(type: AssetType): void {
this._loaders.delete(type);
}
/**
* Check if loader exists for type
* 检查类型是否有加载器
*/
hasLoader(type: AssetType): boolean {
return this._loaders.has(type);
}
/**
* Get asset type by file extension
* 根据文件扩展名获取资产类型
*
* @param extension - File extension including dot (e.g., '.btree', '.png')
* @returns Asset type if a loader supports this extension, null otherwise
*/
getAssetTypeByExtension(extension: string): AssetType | null {
const ext = extension.toLowerCase();
// Check extension-specific loaders first
// 首先检查特定扩展名的加载器
const extLoader = this._extensionLoaders.get(ext);
if (extLoader) {
return extLoader.supportedType;
}
// Fall back to type-based loaders
// 回退到基于类型的加载器
for (const [type, loader] of this._loaders) {
if (loader.supportedExtensions.some(e => e.toLowerCase() === ext)) {
return type;
}
}
return null;
}
/**
* Get asset type by file path
* 根据文件路径获取资产类型
*
* Checks for compound extensions (like .tilemap.json) first, then simple extensions
*
* @param path - File path
* @returns Asset type if a loader supports this file, null otherwise
*/
getAssetTypeByPath(path: string): AssetType | null {
const lowerPath = path.toLowerCase();
// First check compound extensions (e.g., .tilemap.json)
for (const [type, loader] of this._loaders) {
for (const ext of loader.supportedExtensions) {
if (ext.includes('.') && ext.split('.').length > 2) {
// This is a compound extension like .tilemap.json
if (lowerPath.endsWith(ext.toLowerCase())) {
return type;
}
}
}
}
// Then check simple extensions
const lastDot = path.lastIndexOf('.');
if (lastDot !== -1) {
const ext = path.substring(lastDot).toLowerCase();
return this.getAssetTypeByExtension(ext);
}
return null;
}
/**
* Get all registered loaders
* 获取所有注册的加载器
*/
getRegisteredTypes(): AssetType[] {
return Array.from(this._loaders.keys());
}
/**
* Clear all loaders
* 清空所有加载器
*/
clear(): void {
this._loaders.clear();
}
/**
* Get all supported file extensions from all registered loaders.
* 获取所有注册加载器支持的文件扩展名。
*
* @returns Array of extension patterns (e.g., ['*.png', '*.jpg', '*.particle'])
*/
getAllSupportedExtensions(): string[] {
const extensions = new Set<string>();
// From type-based loaders
// 从基于类型的加载器
for (const loader of this._loaders.values()) {
for (const ext of loader.supportedExtensions) {
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
extensions.add(`*.${cleanExt}`);
}
}
// From extension-specific loaders
// 从特定扩展名的加载器
for (const ext of this._extensionLoaders.keys()) {
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
extensions.add(`*.${cleanExt}`);
}
return Array.from(extensions);
}
/**
* Get extension to type mapping for all registered loaders.
* 获取所有注册加载器的扩展名到类型的映射。
*
* @returns Map of extension (without dot) to asset type string
*/
getExtensionTypeMap(): Record<string, string> {
const map: Record<string, string> = {};
// From type-based loaders
// 从基于类型的加载器
for (const [type, loader] of this._loaders) {
for (const ext of loader.supportedExtensions) {
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
map[cleanExt.toLowerCase()] = type;
}
}
// From extension-specific loaders
// 从特定扩展名的加载器
for (const [ext, loader] of this._extensionLoaders) {
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
map[cleanExt.toLowerCase()] = loader.supportedType;
}
return map;
}
}

View File

@@ -0,0 +1,109 @@
/**
* Audio asset loader
* 音频资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, IAudioAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* Audio loader implementation
* 音频加载器实现
*
* Uses Web Audio API to decode audio data into AudioBuffer.
* 使用 Web Audio API 将音频数据解码为 AudioBuffer。
*/
export class AudioLoader implements IAssetLoader<IAudioAsset> {
readonly supportedType = AssetType.Audio;
readonly supportedExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac'];
readonly contentType: AssetContentType = 'audio';
private static _audioContext: AudioContext | null = null;
/**
* Get or create shared AudioContext
* 获取或创建共享的 AudioContext
*/
private static getAudioContext(): AudioContext {
if (!AudioLoader._audioContext) {
// 兼容旧版 Safari 的 webkitAudioContext
// Support legacy Safari webkitAudioContext
const AudioContextClass = window.AudioContext ||
(window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) {
throw new Error('AudioContext is not supported in this browser');
}
AudioLoader._audioContext = new AudioContextClass();
}
return AudioLoader._audioContext;
}
/**
* Parse audio from content.
* 从内容解析音频。
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IAudioAsset> {
if (!content.audioBuffer) {
throw new Error('Audio content is empty');
}
const audioBuffer = content.audioBuffer;
const audioAsset: IAudioAsset = {
buffer: audioBuffer,
duration: audioBuffer.duration,
sampleRate: audioBuffer.sampleRate,
channels: audioBuffer.numberOfChannels
};
return audioAsset;
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(_asset: IAudioAsset): void {
// AudioBuffer doesn't need explicit cleanup in most browsers
// AudioBuffer 在大多数浏览器中不需要显式清理
// The garbage collector will handle it when no references remain
// 当没有引用时,垃圾回收器会处理它
}
/**
* Close the shared AudioContext
* 关闭共享的 AudioContext
*
* Call this when completely shutting down audio system.
* 在完全关闭音频系统时调用。
*/
static closeAudioContext(): void {
if (AudioLoader._audioContext) {
AudioLoader._audioContext.close();
AudioLoader._audioContext = null;
}
}
/**
* Resume AudioContext after user interaction
* 用户交互后恢复 AudioContext
*
* Browsers require user interaction before audio can play.
* 浏览器要求用户交互后才能播放音频。
*/
static async resumeAudioContext(): Promise<void> {
const ctx = AudioLoader.getAudioContext();
if (ctx.state === 'suspended') {
await ctx.resume();
}
}
/**
* Get the shared AudioContext instance
* 获取共享的 AudioContext 实例
*/
static get audioContext(): AudioContext {
return AudioLoader.getAudioContext();
}
}

View File

@@ -0,0 +1,45 @@
/**
* Binary asset loader
* 二进制资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, IBinaryAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* Binary loader implementation
* 二进制加载器实现
*/
export class BinaryLoader implements IAssetLoader<IBinaryAsset> {
readonly supportedType = AssetType.Binary;
readonly supportedExtensions = [
'.bin', '.dat', '.raw', '.bytes',
'.wasm', '.so', '.dll', '.dylib'
];
readonly contentType: AssetContentType = 'binary';
/**
* Parse binary from content.
* 从内容解析二进制。
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IBinaryAsset> {
if (!content.binary) {
throw new Error('Binary content is empty');
}
return {
data: content.binary
};
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: IBinaryAsset): void {
// 释放二进制数据引用以允许垃圾回收
// Release binary data reference to allow garbage collection
(asset as { data: ArrayBuffer | null }).data = null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,994 @@
/**
* GLTF/GLB model loader implementation
* GLTF/GLB 模型加载器实现
*
* Supports:
* - GLTF 2.0 (.gltf with external/embedded resources)
* - GLB (.glb binary format)
* - PBR materials
* - Scene hierarchy
* - Animations (basic)
* - Skinning (basic)
*/
import { AssetType } from '../types/AssetTypes';
import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
import type {
IAssetLoader,
IAssetParseContext,
IGLTFAsset,
IMeshData,
IGLTFMaterial,
IGLTFTextureInfo,
IGLTFNode,
IGLTFAnimationClip,
IAnimationSampler,
IAnimationChannel,
IBoundingBox,
ISkeletonData,
ISkeletonJoint
} from '../interfaces/IAssetLoader';
// ===== GLTF JSON Schema Types =====
interface GLTFJson {
asset: { version: string; generator?: string };
scene?: number;
scenes?: GLTFScene[];
nodes?: GLTFNodeDef[];
meshes?: GLTFMeshDef[];
accessors?: GLTFAccessor[];
bufferViews?: GLTFBufferView[];
buffers?: GLTFBuffer[];
materials?: GLTFMaterialDef[];
textures?: GLTFTextureDef[];
images?: GLTFImage[];
samplers?: GLTFSampler[];
animations?: GLTFAnimation[];
skins?: GLTFSkin[];
}
interface GLTFScene {
name?: string;
nodes?: number[];
}
interface GLTFNodeDef {
name?: string;
mesh?: number;
children?: number[];
translation?: [number, number, number];
rotation?: [number, number, number, number];
scale?: [number, number, number];
matrix?: number[];
skin?: number;
}
interface GLTFMeshDef {
name?: string;
primitives: GLTFPrimitive[];
}
interface GLTFPrimitive {
attributes: Record<string, number>;
indices?: number;
material?: number;
mode?: number;
}
interface GLTFAccessor {
bufferView?: number;
byteOffset?: number;
componentType: number;
count: number;
type: string;
min?: number[];
max?: number[];
normalized?: boolean;
}
interface GLTFBufferView {
buffer: number;
byteOffset?: number;
byteLength: number;
byteStride?: number;
target?: number;
}
interface GLTFBuffer {
uri?: string;
byteLength: number;
}
interface GLTFMaterialDef {
name?: string;
pbrMetallicRoughness?: {
baseColorFactor?: [number, number, number, number];
baseColorTexture?: { index: number };
metallicFactor?: number;
roughnessFactor?: number;
metallicRoughnessTexture?: { index: number };
};
normalTexture?: { index: number; scale?: number };
occlusionTexture?: { index: number; strength?: number };
emissiveFactor?: [number, number, number];
emissiveTexture?: { index: number };
alphaMode?: 'OPAQUE' | 'MASK' | 'BLEND';
alphaCutoff?: number;
doubleSided?: boolean;
}
interface GLTFTextureDef {
source?: number;
sampler?: number;
name?: string;
}
interface GLTFImage {
uri?: string;
mimeType?: string;
bufferView?: number;
name?: string;
}
interface GLTFSampler {
magFilter?: number;
minFilter?: number;
wrapS?: number;
wrapT?: number;
}
interface GLTFAnimation {
name?: string;
channels: GLTFAnimationChannel[];
samplers: GLTFAnimationSampler[];
}
interface GLTFAnimationChannel {
sampler: number;
target: {
node?: number;
path: 'translation' | 'rotation' | 'scale' | 'weights';
};
}
interface GLTFAnimationSampler {
input: number;
output: number;
interpolation?: 'LINEAR' | 'STEP' | 'CUBICSPLINE';
}
interface GLTFSkin {
name?: string;
inverseBindMatrices?: number;
skeleton?: number;
joints: number[];
}
// ===== Component Type Constants =====
const COMPONENT_TYPE_BYTE = 5120;
const COMPONENT_TYPE_UNSIGNED_BYTE = 5121;
const COMPONENT_TYPE_SHORT = 5122;
const COMPONENT_TYPE_UNSIGNED_SHORT = 5123;
const COMPONENT_TYPE_UNSIGNED_INT = 5125;
const COMPONENT_TYPE_FLOAT = 5126;
// ===== GLB Constants =====
const GLB_MAGIC = 0x46546C67; // 'glTF'
const GLB_VERSION = 2;
const GLB_CHUNK_TYPE_JSON = 0x4E4F534A; // 'JSON'
const GLB_CHUNK_TYPE_BIN = 0x004E4942; // 'BIN\0'
/**
* GLTF/GLB model loader
* GLTF/GLB 模型加载器
*/
export class GLTFLoader implements IAssetLoader<IGLTFAsset> {
readonly supportedType = AssetType.Model3D;
readonly supportedExtensions = ['.gltf', '.glb'];
readonly contentType: AssetContentType = 'binary';
/**
* Parse GLTF/GLB content
* 解析 GLTF/GLB 内容
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IGLTFAsset> {
const binary = content.binary;
if (!binary) {
throw new Error('GLTF loader requires binary content');
}
const isGLB = this.isGLB(binary);
let json: GLTFJson;
let binaryChunk: ArrayBuffer | null = null;
if (isGLB) {
const glbData = this.parseGLB(binary);
json = glbData.json;
binaryChunk = glbData.binary;
} else {
// GLTF is JSON text
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(binary);
json = JSON.parse(text) as GLTFJson;
}
// Validate GLTF version
if (!json.asset?.version?.startsWith('2.')) {
throw new Error(`Unsupported GLTF version: ${json.asset?.version}. Only GLTF 2.x is supported.`);
}
// Load external buffers if needed
const buffers = await this.loadBuffers(json, binaryChunk, context);
// Parse all components
const meshes = this.parseMeshes(json, buffers);
const materials = this.parseMaterials(json);
const textures = await this.parseTextures(json, buffers, context);
const nodes = this.parseNodes(json);
const rootNodes = this.getRootNodes(json);
const animations = this.parseAnimations(json, buffers);
const skeleton = this.parseSkeleton(json, buffers);
const bounds = this.calculateBounds(meshes);
// Get model name from file path
const pathParts = context.metadata.path.split(/[\\/]/);
const fileName = pathParts[pathParts.length - 1];
const name = fileName.replace(/\.(gltf|glb)$/i, '');
return {
name,
meshes,
materials,
textures,
nodes,
rootNodes,
animations: animations.length > 0 ? animations : undefined,
skeleton,
bounds,
sourcePath: context.metadata.path
};
}
/**
* Dispose GLTF asset
* 释放 GLTF 资产
*/
dispose(asset: IGLTFAsset): void {
// Clear mesh data
for (const mesh of asset.meshes) {
(mesh as { vertices: Float32Array | null }).vertices = null!;
(mesh as { indices: Uint16Array | Uint32Array | null }).indices = null!;
if (mesh.normals) (mesh as { normals: Float32Array | null }).normals = null;
if (mesh.uvs) (mesh as { uvs: Float32Array | null }).uvs = null;
if (mesh.colors) (mesh as { colors: Float32Array | null }).colors = null;
}
asset.meshes.length = 0;
asset.materials.length = 0;
asset.textures.length = 0;
asset.nodes.length = 0;
}
// ===== Private Methods =====
/**
* Check if content is GLB format
*/
private isGLB(data: ArrayBuffer): boolean {
if (data.byteLength < 12) return false;
const view = new DataView(data);
return view.getUint32(0, true) === GLB_MAGIC;
}
/**
* Parse GLB binary format
*/
private parseGLB(data: ArrayBuffer): { json: GLTFJson; binary: ArrayBuffer | null } {
const view = new DataView(data);
// Header
const magic = view.getUint32(0, true);
const version = view.getUint32(4, true);
const length = view.getUint32(8, true);
if (magic !== GLB_MAGIC) {
throw new Error('Invalid GLB magic number');
}
if (version !== GLB_VERSION) {
throw new Error(`Unsupported GLB version: ${version}`);
}
if (length !== data.byteLength) {
throw new Error('GLB length mismatch');
}
let json: GLTFJson | null = null;
let binary: ArrayBuffer | null = null;
let offset = 12;
// Parse chunks
while (offset < length) {
const chunkLength = view.getUint32(offset, true);
const chunkType = view.getUint32(offset + 4, true);
const chunkData = data.slice(offset + 8, offset + 8 + chunkLength);
if (chunkType === GLB_CHUNK_TYPE_JSON) {
const decoder = new TextDecoder('utf-8');
json = JSON.parse(decoder.decode(chunkData)) as GLTFJson;
} else if (chunkType === GLB_CHUNK_TYPE_BIN) {
binary = chunkData;
}
offset += 8 + chunkLength;
}
if (!json) {
throw new Error('GLB missing JSON chunk');
}
return { json, binary };
}
/**
* Load buffer data
*/
private async loadBuffers(
json: GLTFJson,
binaryChunk: ArrayBuffer | null,
_context: IAssetParseContext
): Promise<ArrayBuffer[]> {
const buffers: ArrayBuffer[] = [];
if (!json.buffers) return buffers;
for (let i = 0; i < json.buffers.length; i++) {
const bufferDef = json.buffers[i];
if (!bufferDef.uri) {
// GLB embedded binary chunk
if (binaryChunk && i === 0) {
buffers.push(binaryChunk);
} else {
throw new Error(`Buffer ${i} has no URI and no binary chunk available`);
}
} else if (bufferDef.uri.startsWith('data:')) {
// Data URI
buffers.push(this.decodeDataUri(bufferDef.uri));
} else {
// External file - not supported yet, would need asset loader context
throw new Error(`External buffer URIs not supported yet: ${bufferDef.uri}`);
}
}
return buffers;
}
/**
* Decode base64 data URI
*/
private decodeDataUri(uri: string): ArrayBuffer {
const match = uri.match(/^data:[^;]*;base64,(.*)$/);
if (!match) {
throw new Error('Invalid data URI format');
}
const base64 = match[1];
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Get accessor data as typed array
*/
private getAccessorData(
json: GLTFJson,
buffers: ArrayBuffer[],
accessorIndex: number
): { data: ArrayBufferView; count: number; componentCount: number } {
const accessor = json.accessors![accessorIndex];
const bufferView = json.bufferViews![accessor.bufferView!];
const buffer = buffers[bufferView.buffer];
const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0);
const componentCount = this.getComponentCount(accessor.type);
const elementCount = accessor.count * componentCount;
let data: ArrayBufferView;
switch (accessor.componentType) {
case COMPONENT_TYPE_BYTE:
data = new Int8Array(buffer, byteOffset, elementCount);
break;
case COMPONENT_TYPE_UNSIGNED_BYTE:
data = new Uint8Array(buffer, byteOffset, elementCount);
break;
case COMPONENT_TYPE_SHORT:
data = new Int16Array(buffer, byteOffset, elementCount);
break;
case COMPONENT_TYPE_UNSIGNED_SHORT:
data = new Uint16Array(buffer, byteOffset, elementCount);
break;
case COMPONENT_TYPE_UNSIGNED_INT:
data = new Uint32Array(buffer, byteOffset, elementCount);
break;
case COMPONENT_TYPE_FLOAT:
data = new Float32Array(buffer, byteOffset, elementCount);
break;
default:
throw new Error(`Unsupported component type: ${accessor.componentType}`);
}
return { data, count: accessor.count, componentCount };
}
/**
* Get component count from accessor type
*/
private getComponentCount(type: string): number {
switch (type) {
case 'SCALAR': return 1;
case 'VEC2': return 2;
case 'VEC3': return 3;
case 'VEC4': return 4;
case 'MAT2': return 4;
case 'MAT3': return 9;
case 'MAT4': return 16;
default:
throw new Error(`Unknown accessor type: ${type}`);
}
}
/**
* Parse all meshes
*/
private parseMeshes(json: GLTFJson, buffers: ArrayBuffer[]): IMeshData[] {
const meshes: IMeshData[] = [];
if (!json.meshes) return meshes;
for (const meshDef of json.meshes) {
for (const primitive of meshDef.primitives) {
// Only support triangles (mode 4 or undefined)
if (primitive.mode !== undefined && primitive.mode !== 4) {
console.warn('Skipping non-triangle primitive');
continue;
}
const mesh = this.parsePrimitive(json, buffers, primitive, meshDef.name || 'Mesh');
meshes.push(mesh);
}
}
return meshes;
}
/**
* Parse a single primitive
*/
private parsePrimitive(
json: GLTFJson,
buffers: ArrayBuffer[],
primitive: GLTFPrimitive,
name: string
): IMeshData {
// Position (required)
const positionAccessor = primitive.attributes['POSITION'];
if (positionAccessor === undefined) {
throw new Error('Mesh primitive missing POSITION attribute');
}
const positionData = this.getAccessorData(json, buffers, positionAccessor);
const vertices = new Float32Array(positionData.data.buffer, (positionData.data as Float32Array).byteOffset, positionData.count * 3);
// Indices (optional, generate sequential if missing)
let indices: Uint16Array | Uint32Array;
if (primitive.indices !== undefined) {
const indexData = this.getAccessorData(json, buffers, primitive.indices);
if (indexData.data instanceof Uint32Array) {
indices = indexData.data;
} else if (indexData.data instanceof Uint16Array) {
indices = indexData.data;
} else {
// Convert to Uint32Array
indices = new Uint32Array(indexData.count);
for (let i = 0; i < indexData.count; i++) {
indices[i] = (indexData.data as Uint8Array)[i];
}
}
} else {
// Generate sequential indices
indices = new Uint32Array(positionData.count);
for (let i = 0; i < positionData.count; i++) {
indices[i] = i;
}
}
// Normals (optional)
let normals: Float32Array | undefined;
const normalAccessor = primitive.attributes['NORMAL'];
if (normalAccessor !== undefined) {
const normalData = this.getAccessorData(json, buffers, normalAccessor);
normals = new Float32Array(normalData.data.buffer, (normalData.data as Float32Array).byteOffset, normalData.count * 3);
}
// UVs (optional, TEXCOORD_0)
let uvs: Float32Array | undefined;
const uvAccessor = primitive.attributes['TEXCOORD_0'];
if (uvAccessor !== undefined) {
const uvData = this.getAccessorData(json, buffers, uvAccessor);
uvs = new Float32Array(uvData.data.buffer, (uvData.data as Float32Array).byteOffset, uvData.count * 2);
}
// Vertex colors (optional, COLOR_0)
let colors: Float32Array | undefined;
const colorAccessor = primitive.attributes['COLOR_0'];
if (colorAccessor !== undefined) {
const colorData = this.getAccessorData(json, buffers, colorAccessor);
// Normalize if needed
if (colorData.data instanceof Float32Array) {
colors = colorData.data;
} else {
// Convert from normalized bytes
colors = new Float32Array(colorData.count * colorData.componentCount);
const source = colorData.data as Uint8Array;
for (let i = 0; i < source.length; i++) {
colors[i] = source[i] / 255;
}
}
}
// Tangents (optional)
let tangents: Float32Array | undefined;
const tangentAccessor = primitive.attributes['TANGENT'];
if (tangentAccessor !== undefined) {
const tangentData = this.getAccessorData(json, buffers, tangentAccessor);
tangents = new Float32Array(tangentData.data.buffer, (tangentData.data as Float32Array).byteOffset, tangentData.count * 4);
}
// Skinning: JOINTS_0 (bone indices per vertex)
// 蒙皮JOINTS_0每顶点的骨骼索引
let joints: Uint8Array | Uint16Array | undefined;
const jointsAccessor = primitive.attributes['JOINTS_0'];
if (jointsAccessor !== undefined) {
const jointsData = this.getAccessorData(json, buffers, jointsAccessor);
if (jointsData.data instanceof Uint8Array) {
joints = new Uint8Array(jointsData.data.buffer, jointsData.data.byteOffset, jointsData.count * 4);
} else if (jointsData.data instanceof Uint16Array) {
joints = new Uint16Array(jointsData.data.buffer, jointsData.data.byteOffset, jointsData.count * 4);
}
}
// Skinning: WEIGHTS_0 (bone weights per vertex)
// 蒙皮WEIGHTS_0每顶点的骨骼权重
let weights: Float32Array | undefined;
const weightsAccessor = primitive.attributes['WEIGHTS_0'];
if (weightsAccessor !== undefined) {
const weightsData = this.getAccessorData(json, buffers, weightsAccessor);
if (weightsData.data instanceof Float32Array) {
weights = new Float32Array(weightsData.data.buffer, weightsData.data.byteOffset, weightsData.count * 4);
} else if (weightsData.data instanceof Uint8Array) {
// Convert from normalized Uint8 to floats
weights = new Float32Array(weightsData.count * 4);
const source = weightsData.data;
for (let i = 0; i < source.length; i++) {
weights[i] = source[i] / 255;
}
} else if (weightsData.data instanceof Uint16Array) {
// Convert from normalized Uint16 to floats
weights = new Float32Array(weightsData.count * 4);
const source = weightsData.data;
for (let i = 0; i < source.length; i++) {
weights[i] = source[i] / 65535;
}
}
}
// Calculate bounds
const bounds = this.calculateMeshBounds(vertices);
return {
name,
vertices,
indices,
normals,
uvs,
tangents,
colors,
joints,
weights,
bounds,
materialIndex: primitive.material ?? -1
};
}
/**
* Calculate mesh bounding box
*/
private calculateMeshBounds(vertices: Float32Array): IBoundingBox {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < vertices.length; i += 3) {
const x = vertices[i];
const y = vertices[i + 1];
const z = vertices[i + 2];
minX = Math.min(minX, x);
minY = Math.min(minY, y);
minZ = Math.min(minZ, z);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
maxZ = Math.max(maxZ, z);
}
return {
min: [minX, minY, minZ],
max: [maxX, maxY, maxZ]
};
}
/**
* Parse all materials
*/
private parseMaterials(json: GLTFJson): IGLTFMaterial[] {
const materials: IGLTFMaterial[] = [];
if (!json.materials) {
// Add default material
materials.push(this.createDefaultMaterial());
return materials;
}
for (const matDef of json.materials) {
const pbr = matDef.pbrMetallicRoughness || {};
materials.push({
name: matDef.name || 'Material',
baseColorFactor: pbr.baseColorFactor || [1, 1, 1, 1],
baseColorTextureIndex: pbr.baseColorTexture?.index ?? -1,
metallicFactor: pbr.metallicFactor ?? 1,
roughnessFactor: pbr.roughnessFactor ?? 1,
metallicRoughnessTextureIndex: pbr.metallicRoughnessTexture?.index ?? -1,
normalTextureIndex: matDef.normalTexture?.index ?? -1,
normalScale: matDef.normalTexture?.scale ?? 1,
occlusionTextureIndex: matDef.occlusionTexture?.index ?? -1,
occlusionStrength: matDef.occlusionTexture?.strength ?? 1,
emissiveFactor: matDef.emissiveFactor || [0, 0, 0],
emissiveTextureIndex: matDef.emissiveTexture?.index ?? -1,
alphaMode: matDef.alphaMode || 'OPAQUE',
alphaCutoff: matDef.alphaCutoff ?? 0.5,
doubleSided: matDef.doubleSided ?? false
});
}
return materials;
}
/**
* Create default material
*/
private createDefaultMaterial(): IGLTFMaterial {
return {
name: 'Default',
baseColorFactor: [0.8, 0.8, 0.8, 1],
baseColorTextureIndex: -1,
metallicFactor: 0,
roughnessFactor: 0.5,
metallicRoughnessTextureIndex: -1,
normalTextureIndex: -1,
normalScale: 1,
occlusionTextureIndex: -1,
occlusionStrength: 1,
emissiveFactor: [0, 0, 0],
emissiveTextureIndex: -1,
alphaMode: 'OPAQUE',
alphaCutoff: 0.5,
doubleSided: false
};
}
/**
* Parse textures
*/
private async parseTextures(
json: GLTFJson,
buffers: ArrayBuffer[],
_context: IAssetParseContext
): Promise<IGLTFTextureInfo[]> {
const textures: IGLTFTextureInfo[] = [];
if (!json.textures || !json.images) return textures;
for (const texDef of json.textures) {
if (texDef.source === undefined) {
textures.push({});
continue;
}
const imageDef = json.images[texDef.source];
const textureInfo: IGLTFTextureInfo = {
name: imageDef.name || texDef.name
};
if (imageDef.bufferView !== undefined) {
// Embedded image
const bufferView = json.bufferViews![imageDef.bufferView];
const buffer = buffers[bufferView.buffer];
const byteOffset = bufferView.byteOffset || 0;
textureInfo.imageData = buffer.slice(byteOffset, byteOffset + bufferView.byteLength);
textureInfo.mimeType = imageDef.mimeType;
} else if (imageDef.uri) {
if (imageDef.uri.startsWith('data:')) {
// Data URI
textureInfo.imageData = this.decodeDataUri(imageDef.uri);
const mimeMatch = imageDef.uri.match(/^data:(.*?);/);
textureInfo.mimeType = mimeMatch?.[1];
} else {
// External URI
textureInfo.uri = imageDef.uri;
}
}
textures.push(textureInfo);
}
return textures;
}
/**
* Parse scene nodes
*/
private parseNodes(json: GLTFJson): IGLTFNode[] {
const nodes: IGLTFNode[] = [];
if (!json.nodes) return nodes;
for (const nodeDef of json.nodes) {
let position: [number, number, number] = [0, 0, 0];
let rotation: [number, number, number, number] = [0, 0, 0, 1];
let scale: [number, number, number] = [1, 1, 1];
if (nodeDef.matrix) {
// Decompose matrix
const m = nodeDef.matrix;
// Extract translation
position = [m[12], m[13], m[14]];
// Extract scale
scale = [
Math.sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]),
Math.sqrt(m[4] * m[4] + m[5] * m[5] + m[6] * m[6]),
Math.sqrt(m[8] * m[8] + m[9] * m[9] + m[10] * m[10])
];
// Extract rotation (simplified, assumes no shear)
rotation = this.matrixToQuaternion(m, scale);
} else {
if (nodeDef.translation) {
position = nodeDef.translation;
}
if (nodeDef.rotation) {
rotation = nodeDef.rotation;
}
if (nodeDef.scale) {
scale = nodeDef.scale;
}
}
nodes.push({
name: nodeDef.name || 'Node',
meshIndex: nodeDef.mesh,
children: nodeDef.children || [],
transform: { position, rotation, scale }
});
}
return nodes;
}
/**
* Extract quaternion from matrix
*/
private matrixToQuaternion(m: number[], scale: [number, number, number]): [number, number, number, number] {
// Normalize rotation matrix
const sx = scale[0], sy = scale[1], sz = scale[2];
const m00 = m[0] / sx, m01 = m[4] / sy, m02 = m[8] / sz;
const m10 = m[1] / sx, m11 = m[5] / sy, m12 = m[9] / sz;
const m20 = m[2] / sx, m21 = m[6] / sy, m22 = m[10] / sz;
const trace = m00 + m11 + m22;
let x: number, y: number, z: number, w: number;
if (trace > 0) {
const s = 0.5 / Math.sqrt(trace + 1.0);
w = 0.25 / s;
x = (m21 - m12) * s;
y = (m02 - m20) * s;
z = (m10 - m01) * s;
} else if (m00 > m11 && m00 > m22) {
const s = 2.0 * Math.sqrt(1.0 + m00 - m11 - m22);
w = (m21 - m12) / s;
x = 0.25 * s;
y = (m01 + m10) / s;
z = (m02 + m20) / s;
} else if (m11 > m22) {
const s = 2.0 * Math.sqrt(1.0 + m11 - m00 - m22);
w = (m02 - m20) / s;
x = (m01 + m10) / s;
y = 0.25 * s;
z = (m12 + m21) / s;
} else {
const s = 2.0 * Math.sqrt(1.0 + m22 - m00 - m11);
w = (m10 - m01) / s;
x = (m02 + m20) / s;
y = (m12 + m21) / s;
z = 0.25 * s;
}
return [x, y, z, w];
}
/**
* Get root node indices
*/
private getRootNodes(json: GLTFJson): number[] {
const sceneIndex = json.scene ?? 0;
const scene = json.scenes?.[sceneIndex];
return scene?.nodes || [];
}
/**
* Parse animations
*/
private parseAnimations(json: GLTFJson, buffers: ArrayBuffer[]): IGLTFAnimationClip[] {
const animations: IGLTFAnimationClip[] = [];
if (!json.animations) return animations;
for (const animDef of json.animations) {
const samplers: IAnimationSampler[] = [];
const channels: IAnimationChannel[] = [];
let duration = 0;
// Parse samplers
for (const samplerDef of animDef.samplers) {
const inputData = this.getAccessorData(json, buffers, samplerDef.input);
const outputData = this.getAccessorData(json, buffers, samplerDef.output);
const input = new Float32Array(inputData.data.buffer, (inputData.data as Float32Array).byteOffset, inputData.count);
const output = new Float32Array(outputData.data.buffer, (outputData.data as Float32Array).byteOffset, outputData.count * outputData.componentCount);
// Update duration
if (input.length > 0) {
duration = Math.max(duration, input[input.length - 1]);
}
samplers.push({
input,
output,
interpolation: samplerDef.interpolation || 'LINEAR'
});
}
// Parse channels
for (const channelDef of animDef.channels) {
if (channelDef.target.node === undefined) continue;
channels.push({
samplerIndex: channelDef.sampler,
target: {
nodeIndex: channelDef.target.node,
path: channelDef.target.path
}
});
}
animations.push({
name: animDef.name || 'Animation',
duration,
samplers,
channels
});
}
return animations;
}
/**
* Parse skeleton/skin data
*/
private parseSkeleton(json: GLTFJson, buffers: ArrayBuffer[]): ISkeletonData | undefined {
if (!json.skins || json.skins.length === 0) return undefined;
// Use first skin
const skin = json.skins[0];
const joints: ISkeletonJoint[] = [];
// Load inverse bind matrices
let inverseBindMatrices: Float32Array | null = null;
if (skin.inverseBindMatrices !== undefined) {
const ibmData = this.getAccessorData(json, buffers, skin.inverseBindMatrices);
inverseBindMatrices = new Float32Array(ibmData.data.buffer, (ibmData.data as Float32Array).byteOffset, ibmData.count * 16);
}
// Build joint hierarchy
const jointIndexMap = new Map<number, number>();
for (let i = 0; i < skin.joints.length; i++) {
jointIndexMap.set(skin.joints[i], i);
}
for (let i = 0; i < skin.joints.length; i++) {
const nodeIndex = skin.joints[i];
const node = json.nodes![nodeIndex];
// Find parent
let parentIndex = -1;
for (const [idx, jointIdx] of jointIndexMap) {
if (jointIdx !== i) {
const parentNode = json.nodes![idx];
if (parentNode.children?.includes(nodeIndex)) {
parentIndex = jointIdx;
break;
}
}
}
const ibm = new Float32Array(16);
if (inverseBindMatrices) {
for (let j = 0; j < 16; j++) {
ibm[j] = inverseBindMatrices[i * 16 + j];
}
} else {
// Identity matrix
ibm[0] = ibm[5] = ibm[10] = ibm[15] = 1;
}
joints.push({
name: node.name || `Joint_${i}`,
nodeIndex,
parentIndex,
inverseBindMatrix: ibm
});
}
// Find root joint
let rootJointIndex = 0;
for (let i = 0; i < joints.length; i++) {
if (joints[i].parentIndex === -1) {
rootJointIndex = i;
break;
}
}
return {
joints,
rootJointIndex
};
}
/**
* Calculate combined bounds for all meshes
*/
private calculateBounds(meshes: IMeshData[]): IBoundingBox {
if (meshes.length === 0) {
return { min: [0, 0, 0], max: [0, 0, 0] };
}
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const mesh of meshes) {
minX = Math.min(minX, mesh.bounds.min[0]);
minY = Math.min(minY, mesh.bounds.min[1]);
minZ = Math.min(minZ, mesh.bounds.min[2]);
maxX = Math.max(maxX, mesh.bounds.max[0]);
maxY = Math.max(maxY, mesh.bounds.max[1]);
maxZ = Math.max(maxZ, mesh.bounds.max[2]);
}
return {
min: [minX, minY, minZ],
max: [maxX, maxY, maxZ]
};
}
}

View File

@@ -0,0 +1,41 @@
/**
* JSON asset loader
* JSON资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, IJsonAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* JSON loader implementation
* JSON加载器实现
*/
export class JsonLoader implements IAssetLoader<IJsonAsset> {
readonly supportedType = AssetType.Json;
readonly supportedExtensions = ['.json', '.jsonc'];
readonly contentType: AssetContentType = 'text';
/**
* Parse JSON from text content.
* 从文本内容解析JSON。
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IJsonAsset> {
if (!content.text) {
throw new Error('JSON content is empty');
}
return {
data: JSON.parse(content.text)
};
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: IJsonAsset): void {
// 清空 JSON 数据 | Clear JSON data
asset.data = null;
}
}

View File

@@ -0,0 +1,553 @@
/**
* OBJ model loader implementation
* OBJ 模型加载器实现
*
* Supports:
* - Wavefront OBJ format (.obj)
* - Vertices, normals, texture coordinates
* - Triangular and quad faces (quads are triangulated)
* - Multiple objects/groups
* - MTL material references (materials loaded separately)
*/
import { AssetType } from '../types/AssetTypes';
import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
import type {
IAssetLoader,
IAssetParseContext,
IGLTFAsset,
IMeshData,
IGLTFMaterial,
IGLTFNode,
IBoundingBox
} from '../interfaces/IAssetLoader';
/**
* Parsed OBJ data structure
* 解析后的 OBJ 数据结构
*/
interface OBJParseResult {
positions: number[];
normals: number[];
uvs: number[];
objects: OBJObject[];
mtlLib?: string;
}
interface OBJObject {
name: string;
material?: string;
faces: OBJFace[];
}
interface OBJFace {
vertices: OBJVertex[];
}
interface OBJVertex {
positionIndex: number;
uvIndex?: number;
normalIndex?: number;
}
/**
* OBJ model loader
* OBJ 模型加载器
*/
export class OBJLoader implements IAssetLoader<IGLTFAsset> {
readonly supportedType = AssetType.Model3D;
readonly supportedExtensions = ['.obj'];
readonly contentType: AssetContentType = 'text';
/**
* Parse OBJ content
* 解析 OBJ 内容
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IGLTFAsset> {
const text = content.text;
if (!text) {
throw new Error('OBJ loader requires text content');
}
// Parse OBJ text
// 解析 OBJ 文本
const objData = this.parseOBJ(text);
// Convert to meshes
// 转换为网格
const meshes = this.buildMeshes(objData);
// Create default materials
// 创建默认材质
const materials = this.buildMaterials(objData);
// Build nodes (one per object)
// 构建节点(每个对象一个)
const nodes: IGLTFNode[] = meshes.map((mesh, index) => ({
name: mesh.name,
meshIndex: index,
children: [],
transform: {
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
scale: [1, 1, 1]
}
}));
// Calculate overall bounds
// 计算总边界
const bounds = this.calculateBounds(meshes);
// Get model name from file path
// 从文件路径获取模型名称
const pathParts = context.metadata.path.split(/[\\/]/);
const fileName = pathParts[pathParts.length - 1];
const name = fileName.replace(/\.obj$/i, '');
return {
name,
meshes,
materials,
textures: [],
nodes,
rootNodes: nodes.map((_, i) => i),
bounds,
sourcePath: context.metadata.path
};
}
/**
* Dispose OBJ asset
* 释放 OBJ 资产
*/
dispose(asset: IGLTFAsset): void {
for (const mesh of asset.meshes) {
(mesh as { vertices: Float32Array | null }).vertices = null!;
(mesh as { indices: Uint16Array | Uint32Array | null }).indices = null!;
}
asset.meshes.length = 0;
}
// ===== Private Methods =====
/**
* Parse OBJ text format
* 解析 OBJ 文本格式
*/
private parseOBJ(text: string): OBJParseResult {
const lines = text.split('\n');
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const objects: OBJObject[] = [];
let currentObject: OBJObject = { name: 'default', faces: [] };
let mtlLib: string | undefined;
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
const line = lines[lineNum].trim();
// Skip comments and empty lines
// 跳过注释和空行
if (line.length === 0 || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
const keyword = parts[0];
switch (keyword) {
case 'v': // Vertex position
positions.push(
parseFloat(parts[1]) || 0,
parseFloat(parts[2]) || 0,
parseFloat(parts[3]) || 0
);
break;
case 'vn': // Vertex normal
normals.push(
parseFloat(parts[1]) || 0,
parseFloat(parts[2]) || 0,
parseFloat(parts[3]) || 0
);
break;
case 'vt': // Texture coordinate
uvs.push(
parseFloat(parts[1]) || 0,
parseFloat(parts[2]) || 0
);
break;
case 'f': // Face
const face = this.parseFace(parts.slice(1));
if (face.vertices.length >= 3) {
// Triangulate if more than 3 vertices (fan triangulation)
// 如果超过 3 个顶点则三角化(扇形三角化)
for (let i = 1; i < face.vertices.length - 1; i++) {
currentObject.faces.push({
vertices: [
face.vertices[0],
face.vertices[i],
face.vertices[i + 1]
]
});
}
}
break;
case 'o': // Object name
case 'g': // Group name
if (currentObject.faces.length > 0) {
objects.push(currentObject);
}
currentObject = {
name: parts.slice(1).join(' ') || 'unnamed',
faces: []
};
break;
case 'usemtl': // Material reference
// If current object has faces with different material, split it
// 如果当前对象有不同材质的面,则拆分
if (currentObject.faces.length > 0 && currentObject.material) {
objects.push(currentObject);
currentObject = {
name: `${currentObject.name}_${parts[1]}`,
faces: [],
material: parts[1]
};
} else {
currentObject.material = parts[1];
}
break;
case 'mtllib': // MTL library reference
mtlLib = parts[1];
break;
case 's': // Smoothing group (ignored)
case 'l': // Line (ignored)
break;
}
}
// Push last object
// 推送最后一个对象
if (currentObject.faces.length > 0) {
objects.push(currentObject);
}
// If no objects were created, create one from default
// 如果没有创建对象,从默认创建一个
if (objects.length === 0 && currentObject.faces.length === 0) {
throw new Error('OBJ file contains no geometry');
}
return { positions, normals, uvs, objects, mtlLib };
}
/**
* Parse a face definition
* 解析面定义
*
* Format: v, v/vt, v/vt/vn, v//vn
*/
private parseFace(parts: string[]): OBJFace {
const vertices: OBJVertex[] = [];
for (const part of parts) {
const indices = part.split('/');
const vertex: OBJVertex = {
positionIndex: parseInt(indices[0], 10) - 1 // OBJ is 1-indexed
};
if (indices.length > 1 && indices[1]) {
vertex.uvIndex = parseInt(indices[1], 10) - 1;
}
if (indices.length > 2 && indices[2]) {
vertex.normalIndex = parseInt(indices[2], 10) - 1;
}
vertices.push(vertex);
}
return { vertices };
}
/**
* Build mesh data from parsed OBJ
* 从解析的 OBJ 构建网格数据
*/
private buildMeshes(objData: OBJParseResult): IMeshData[] {
const meshes: IMeshData[] = [];
for (const obj of objData.objects) {
const mesh = this.buildMesh(obj, objData);
meshes.push(mesh);
}
return meshes;
}
/**
* Build a single mesh from OBJ object
* 从 OBJ 对象构建单个网格
*/
private buildMesh(obj: OBJObject, objData: OBJParseResult): IMeshData {
// OBJ uses indexed vertices, but indices can reference different
// position/uv/normal combinations, so we need to expand
// OBJ 使用索引顶点,但索引可以引用不同的 position/uv/normal 组合,所以需要展开
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
// Map to track unique vertex combinations
// 用于跟踪唯一顶点组合的映射
const vertexMap = new Map<string, number>();
let vertexIndex = 0;
for (const face of obj.faces) {
const faceIndices: number[] = [];
for (const vertex of face.vertices) {
// Create unique key for this vertex combination
// 为此顶点组合创建唯一键
const key = `${vertex.positionIndex}/${vertex.uvIndex ?? ''}/${vertex.normalIndex ?? ''}`;
let index = vertexMap.get(key);
if (index === undefined) {
// New unique vertex - add to arrays
// 新的唯一顶点 - 添加到数组
index = vertexIndex++;
vertexMap.set(key, index);
// Position
const pi = vertex.positionIndex * 3;
positions.push(
objData.positions[pi] ?? 0,
objData.positions[pi + 1] ?? 0,
objData.positions[pi + 2] ?? 0
);
// UV
if (vertex.uvIndex !== undefined) {
const ui = vertex.uvIndex * 2;
uvs.push(
objData.uvs[ui] ?? 0,
1 - (objData.uvs[ui + 1] ?? 0) // Flip V coordinate
);
} else {
uvs.push(0, 0);
}
// Normal
if (vertex.normalIndex !== undefined) {
const ni = vertex.normalIndex * 3;
normals.push(
objData.normals[ni] ?? 0,
objData.normals[ni + 1] ?? 0,
objData.normals[ni + 2] ?? 0
);
} else {
normals.push(0, 1, 0); // Default up normal
}
}
faceIndices.push(index);
}
// Add triangle indices
// 添加三角形索引
if (faceIndices.length === 3) {
indices.push(faceIndices[0], faceIndices[1], faceIndices[2]);
}
}
// Calculate bounds
// 计算边界
const bounds = this.calculateMeshBounds(positions);
// Generate normals if not provided
// 如果未提供法线则生成
const hasValidNormals = objData.normals.length > 0;
const finalNormals = hasValidNormals
? new Float32Array(normals)
: this.generateNormals(positions, indices);
return {
name: obj.name,
vertices: new Float32Array(positions),
indices: new Uint32Array(indices),
normals: finalNormals,
uvs: new Float32Array(uvs),
bounds,
materialIndex: -1 // Material resolved by name
};
}
/**
* Generate flat normals for mesh
* 为网格生成平面法线
*/
private generateNormals(positions: number[], indices: number[]): Float32Array {
const normals = new Float32Array(positions.length);
for (let i = 0; i < indices.length; i += 3) {
const i0 = indices[i] * 3;
const i1 = indices[i + 1] * 3;
const i2 = indices[i + 2] * 3;
// Get triangle vertices
const v0x = positions[i0], v0y = positions[i0 + 1], v0z = positions[i0 + 2];
const v1x = positions[i1], v1y = positions[i1 + 1], v1z = positions[i1 + 2];
const v2x = positions[i2], v2y = positions[i2 + 1], v2z = positions[i2 + 2];
// Calculate edge vectors
const e1x = v1x - v0x, e1y = v1y - v0y, e1z = v1z - v0z;
const e2x = v2x - v0x, e2y = v2y - v0y, e2z = v2z - v0z;
// Cross product
const nx = e1y * e2z - e1z * e2y;
const ny = e1z * e2x - e1x * e2z;
const nz = e1x * e2y - e1y * e2x;
// Add to vertex normals (will be normalized later or kept as-is for flat shading)
normals[i0] += nx; normals[i0 + 1] += ny; normals[i0 + 2] += nz;
normals[i1] += nx; normals[i1 + 1] += ny; normals[i1 + 2] += nz;
normals[i2] += nx; normals[i2 + 1] += ny; normals[i2 + 2] += nz;
}
// Normalize
for (let i = 0; i < normals.length; i += 3) {
const len = Math.sqrt(normals[i] ** 2 + normals[i + 1] ** 2 + normals[i + 2] ** 2);
if (len > 0) {
normals[i] /= len;
normals[i + 1] /= len;
normals[i + 2] /= len;
}
}
return normals;
}
/**
* Build default materials
* 构建默认材质
*/
private buildMaterials(objData: OBJParseResult): IGLTFMaterial[] {
// Create one default material per unique material name
// 为每个唯一的材质名称创建一个默认材质
const materialNames = new Set<string>();
for (const obj of objData.objects) {
if (obj.material) {
materialNames.add(obj.material);
}
}
const materials: IGLTFMaterial[] = [];
// Default material
materials.push({
name: 'Default',
baseColorFactor: [0.8, 0.8, 0.8, 1],
baseColorTextureIndex: -1,
metallicFactor: 0,
roughnessFactor: 0.5,
metallicRoughnessTextureIndex: -1,
normalTextureIndex: -1,
normalScale: 1,
occlusionTextureIndex: -1,
occlusionStrength: 1,
emissiveFactor: [0, 0, 0],
emissiveTextureIndex: -1,
alphaMode: 'OPAQUE',
alphaCutoff: 0.5,
doubleSided: false
});
// Named materials (with placeholder values)
for (const name of materialNames) {
materials.push({
name,
baseColorFactor: [0.8, 0.8, 0.8, 1],
baseColorTextureIndex: -1,
metallicFactor: 0,
roughnessFactor: 0.5,
metallicRoughnessTextureIndex: -1,
normalTextureIndex: -1,
normalScale: 1,
occlusionTextureIndex: -1,
occlusionStrength: 1,
emissiveFactor: [0, 0, 0],
emissiveTextureIndex: -1,
alphaMode: 'OPAQUE',
alphaCutoff: 0.5,
doubleSided: false
});
}
return materials;
}
/**
* Calculate mesh bounding box
* 计算网格边界盒
*/
private calculateMeshBounds(positions: number[]): IBoundingBox {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i];
const y = positions[i + 1];
const z = positions[i + 2];
minX = Math.min(minX, x);
minY = Math.min(minY, y);
minZ = Math.min(minZ, z);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
maxZ = Math.max(maxZ, z);
}
if (!isFinite(minX)) {
return { min: [0, 0, 0], max: [0, 0, 0] };
}
return {
min: [minX, minY, minZ],
max: [maxX, maxY, maxZ]
};
}
/**
* Calculate combined bounds for all meshes
* 计算所有网格的组合边界
*/
private calculateBounds(meshes: IMeshData[]): IBoundingBox {
if (meshes.length === 0) {
return { min: [0, 0, 0], max: [0, 0, 0] };
}
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const mesh of meshes) {
minX = Math.min(minX, mesh.bounds.min[0]);
minY = Math.min(minY, mesh.bounds.min[1]);
minZ = Math.min(minZ, mesh.bounds.min[2]);
maxX = Math.max(maxX, mesh.bounds.max[0]);
maxY = Math.max(maxY, mesh.bounds.max[1]);
maxZ = Math.max(maxZ, mesh.bounds.max[2]);
}
return {
min: [minX, minY, minZ],
max: [maxX, maxY, maxZ]
};
}
}

View File

@@ -0,0 +1,156 @@
/**
* 预制体资产加载器
* Prefab asset loader
*/
import { AssetType } from '../types/AssetTypes';
import type { IAssetLoader, IAssetParseContext } from '../interfaces/IAssetLoader';
import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
import type {
IPrefabAsset,
IPrefabData,
SerializedPrefabEntity
} from '../interfaces/IPrefabAsset';
import { PREFAB_FORMAT_VERSION } from '../interfaces/IPrefabAsset';
/**
* 预制体加载器实现
* Prefab loader implementation
*/
export class PrefabLoader implements IAssetLoader<IPrefabAsset> {
readonly supportedType = AssetType.Prefab;
readonly supportedExtensions = ['.prefab'];
readonly contentType: AssetContentType = 'text';
/**
* 从文本内容解析预制体
* Parse prefab from text content
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IPrefabAsset> {
if (!content.text) {
throw new Error('Prefab content is empty');
}
let prefabData: IPrefabData;
try {
prefabData = JSON.parse(content.text) as IPrefabData;
} catch (error) {
throw new Error(`Failed to parse prefab JSON: ${(error as Error).message}`);
}
// 验证预制体格式 | Validate prefab format
this.validatePrefabData(prefabData);
// 版本兼容性检查 | Version compatibility check
if (prefabData.version > PREFAB_FORMAT_VERSION) {
console.warn(
`Prefab version ${prefabData.version} is newer than supported version ${PREFAB_FORMAT_VERSION}. ` +
`Some features may not work correctly.`
);
}
// 构建资产对象 | Build asset object
const prefabAsset: IPrefabAsset = {
data: prefabData,
guid: context.metadata.guid,
path: context.metadata.path,
// 快捷访问属性 | Quick access properties
get root(): SerializedPrefabEntity {
return prefabData.root;
},
get componentTypes(): string[] {
return prefabData.metadata.componentTypes;
},
get referencedAssets(): string[] {
return prefabData.metadata.referencedAssets;
}
};
return prefabAsset;
}
/**
* 释放已加载的资产
* Dispose loaded asset
*/
dispose(asset: IPrefabAsset): void {
// 清空预制体数据 | Clear prefab data
(asset as { data: IPrefabData | null }).data = null;
}
/**
* 验证预制体数据格式
* Validate prefab data format
*/
private validatePrefabData(data: unknown): asserts data is IPrefabData {
if (!data || typeof data !== 'object') {
throw new Error('Invalid prefab data: expected object');
}
const prefab = data as Partial<IPrefabData>;
// 验证版本号 | Validate version
if (typeof prefab.version !== 'number') {
throw new Error('Invalid prefab data: missing or invalid version');
}
// 验证元数据 | Validate metadata
if (!prefab.metadata || typeof prefab.metadata !== 'object') {
throw new Error('Invalid prefab data: missing or invalid metadata');
}
const metadata = prefab.metadata;
if (typeof metadata.name !== 'string') {
throw new Error('Invalid prefab data: missing or invalid metadata.name');
}
if (!Array.isArray(metadata.componentTypes)) {
throw new Error('Invalid prefab data: missing or invalid metadata.componentTypes');
}
if (!Array.isArray(metadata.referencedAssets)) {
throw new Error('Invalid prefab data: missing or invalid metadata.referencedAssets');
}
// 验证根实体 | Validate root entity
if (!prefab.root || typeof prefab.root !== 'object') {
throw new Error('Invalid prefab data: missing or invalid root entity');
}
this.validateSerializedEntity(prefab.root);
// 验证组件类型注册表 | Validate component type registry
if (!Array.isArray(prefab.componentTypeRegistry)) {
throw new Error('Invalid prefab data: missing or invalid componentTypeRegistry');
}
}
/**
* 验证序列化实体格式
* Validate serialized entity format
*/
private validateSerializedEntity(entity: unknown): void {
if (!entity || typeof entity !== 'object') {
throw new Error('Invalid entity data: expected object');
}
const e = entity as Partial<SerializedPrefabEntity>;
if (typeof e.id !== 'number') {
throw new Error('Invalid entity data: missing or invalid id');
}
if (typeof e.name !== 'string') {
throw new Error('Invalid entity data: missing or invalid name');
}
if (!Array.isArray(e.components)) {
throw new Error('Invalid entity data: missing or invalid components array');
}
if (!Array.isArray(e.children)) {
throw new Error('Invalid entity data: missing or invalid children array');
}
// 递归验证子实体 | Recursively validate child entities
for (const child of e.children) {
this.validateSerializedEntity(child);
}
}
}

View File

@@ -0,0 +1,56 @@
/**
* Text asset loader
* 文本资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, ITextAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* Text loader implementation
* 文本加载器实现
*/
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';
/**
* Parse text from content.
* 从内容解析文本。
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITextAsset> {
if (!content.text) {
throw new Error('Text content is empty');
}
return {
content: content.text,
encoding: this.detectEncoding(content.text)
};
}
/**
* Detect text encoding
* 检测文本编码
*/
private detectEncoding(content: string): 'utf8' | 'utf16' | 'ascii' {
for (let i = 0; i < content.length; i++) {
const charCode = content.charCodeAt(i);
if (charCode > 127) {
return charCode > 255 ? 'utf16' : 'utf8';
}
}
return 'ascii';
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: ITextAsset): void {
// 清空文本内容 | Clear text content
asset.content = '';
}
}

View File

@@ -0,0 +1,117 @@
/**
* Texture asset loader
* 纹理资产加载器
*/
import { AssetType } from '../types/AssetTypes';
import { IAssetLoader, ITextureAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
/**
* 全局引擎桥接接口(运行时挂载到 window
* Global engine bridge interface (mounted to window at runtime)
*/
interface IEngineBridgeGlobal {
loadTexture?(textureId: number, path: string): Promise<void>;
unloadTexture?(textureId: number): void;
}
/**
* Sprite settings from texture meta
* 纹理 meta 中的 Sprite 设置
*/
interface ISpriteSettings {
sliceBorder?: [number, number, number, number];
pivot?: [number, number];
pixelsPerUnit?: number;
}
/**
* 获取全局引擎桥接
* Get global engine bridge
*/
function getEngineBridge(): IEngineBridgeGlobal | undefined {
if (typeof window !== 'undefined' && 'engineBridge' in window) {
return (window as Window & { engineBridge?: IEngineBridgeGlobal }).engineBridge;
}
return undefined;
}
/**
* Texture loader implementation
* 纹理加载器实现
*/
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;
/**
* Reset texture ID counter
* 重置纹理 ID 计数器
*
* This should be called when restoring scene snapshots to ensure
* textures start with fresh IDs.
* 在恢复场景快照时应调用此方法,以确保纹理从新 ID 开始。
*/
static resetTextureIdCounter(): void {
TextureLoader._nextTextureId = 1;
}
/**
* Parse texture from image content.
* 从图片内容解析纹理。
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<ITextureAsset> {
if (!content.image) {
throw new Error('Texture content is empty');
}
const image = content.image;
// Read sprite settings from import settings
// 从导入设置读取 sprite 设置
const importSettings = context.metadata.importSettings as Record<string, unknown> | undefined;
const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined;
const textureAsset: ITextureAsset = {
textureId: TextureLoader._nextTextureId++,
width: image.width,
height: image.height,
format: 'rgba',
hasMipmaps: false,
data: image,
// Include sprite settings if available
// 如果有则包含 sprite 设置
sliceBorder: spriteSettings?.sliceBorder,
pivot: spriteSettings?.pivot
};
// Upload to GPU if bridge exists.
const bridge = getEngineBridge();
if (bridge?.loadTexture) {
await bridge.loadTexture(textureAsset.textureId, context.metadata.path);
}
return textureAsset;
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: ITextureAsset): void {
// Release GPU resources.
const bridge = getEngineBridge();
if (bridge?.unloadTexture) {
bridge.unloadTexture(asset.textureId);
}
// Clean up image data.
if (asset.data instanceof HTMLImageElement) {
asset.data.src = '';
}
}
}

View 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();

View File

@@ -0,0 +1,139 @@
/**
* Asset Metadata Service
* 资产元数据服务
*
* Provides global access to asset metadata without requiring asset loading.
* This service is independent of the texture loading path, allowing
* render systems to query sprite info regardless of how textures are loaded.
*
* 提供对资产元数据的全局访问,无需加载资产。
* 此服务独立于纹理加载路径,允许渲染系统查询 sprite 信息,
* 无论纹理是如何加载的。
*/
import { AssetDatabase, ITextureSpriteInfo } from '../core/AssetDatabase';
import type { AssetGUID } from '../types/AssetTypes';
import type { ITextureEngineBridge } from '../integration/EngineIntegration';
/**
* Global asset database instance
* 全局资产数据库实例
*/
let globalAssetDatabase: AssetDatabase | null = null;
/**
* Global engine bridge instance
* 全局引擎桥实例
*
* Used to query texture dimensions from Rust engine (single source of truth).
* 用于从 Rust 引擎查询纹理尺寸(唯一事实来源)。
*/
let globalEngineBridge: ITextureEngineBridge | null = null;
/**
* Set the global asset database
* 设置全局资产数据库
*
* Should be called during engine initialization.
* 应在引擎初始化期间调用。
*
* @param database - AssetDatabase instance | AssetDatabase 实例
*/
export function setGlobalAssetDatabase(database: AssetDatabase | null): void {
globalAssetDatabase = database;
}
/**
* Get the global asset database
* 获取全局资产数据库
*
* @returns AssetDatabase instance or null | AssetDatabase 实例或 null
*/
export function getGlobalAssetDatabase(): AssetDatabase | null {
return globalAssetDatabase;
}
/**
* Set the global engine bridge
* 设置全局引擎桥
*
* The engine bridge is used to query texture dimensions directly from Rust engine.
* This is the single source of truth for texture dimensions.
* 引擎桥用于直接从 Rust 引擎查询纹理尺寸。
* 这是纹理尺寸的唯一事实来源。
*
* @param bridge - ITextureEngineBridge instance | ITextureEngineBridge 实例
*/
export function setGlobalEngineBridge(bridge: ITextureEngineBridge | null): void {
globalEngineBridge = bridge;
}
/**
* Get the global engine bridge
* 获取全局引擎桥
*
* @returns ITextureEngineBridge instance or null | ITextureEngineBridge 实例或 null
*/
export function getGlobalEngineBridge(): ITextureEngineBridge | null {
return globalEngineBridge;
}
/**
* Get texture sprite info by GUID
* 通过 GUID 获取纹理 Sprite 信息
*
* This is the primary API for render systems to query nine-patch/sprite info.
* It combines data from:
* - Asset metadata (sliceBorder, pivot) from AssetDatabase
* - Texture dimensions (width, height) from Rust engine (single source of truth)
*
* 这是渲染系统查询九宫格/sprite 信息的主要 API。
* 它合并来自:
* - AssetDatabase 的资产元数据sliceBorder, pivot
* - Rust 引擎的纹理尺寸width, height唯一事实来源
*
* @param guid - Texture asset GUID | 纹理资产 GUID
* @returns Sprite info or undefined | Sprite 信息或 undefined
*/
export function getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
// Get sprite settings from metadata
// 从元数据获取 sprite 设置
const metadataInfo = globalAssetDatabase?.getTextureSpriteInfo(guid);
// Get texture dimensions from Rust engine (single source of truth)
// 从 Rust 引擎获取纹理尺寸(唯一事实来源)
let dimensions: { width: number; height: number } | undefined;
if (globalEngineBridge?.getTextureInfoByPath && globalAssetDatabase) {
// Get asset path from database
// 从数据库获取资产路径
const metadata = globalAssetDatabase.getMetadata(guid);
if (metadata?.path) {
const engineInfo = globalEngineBridge.getTextureInfoByPath(metadata.path);
if (engineInfo) {
dimensions = engineInfo;
}
}
}
// If no metadata and no dimensions, return undefined
// 如果没有元数据也没有尺寸,返回 undefined
if (!metadataInfo && !dimensions) {
return undefined;
}
// Merge the two sources
// 合并两个数据源
// Prefer engine dimensions (runtime loaded), fallback to metadata dimensions (catalog stored)
// 优先使用引擎尺寸(运行时加载),后备使用元数据尺寸(目录存储)
return {
sliceBorder: metadataInfo?.sliceBorder,
pivot: metadataInfo?.pivot,
width: dimensions?.width ?? metadataInfo?.width,
height: dimensions?.height ?? metadataInfo?.height
};
}
// Re-export type for convenience
// 为方便起见重新导出类型
export type { ITextureSpriteInfo };

View File

@@ -0,0 +1,239 @@
/**
* 路径解析服务
* Path Resolution Service
*
* 提供统一的路径解析接口处理编辑器、Catalog、运行时三层路径转换。
* Provides unified path resolution interface for editor, catalog, and runtime path conversion.
*
* 路径格式约定 | Path Format Convention:
* - 编辑器路径 (Editor Path): 绝对路径,如 `C:\Project\assets\textures\bg.png`
* - Catalog 路径 (Catalog Path): 相对于 assets 目录,不含 `assets/` 前缀,如 `textures/bg.png`
* - 运行时 URL (Runtime URL): 完整 URL如 `./assets/textures/bg.png` 或 `https://cdn.example.com/assets/textures/bg.png`
*
* @example
* ```typescript
* import { PathResolutionServiceToken, type IPathResolutionService } from '@esengine/asset-system';
*
* // 获取服务
* const pathService = context.services.get(PathResolutionServiceToken);
*
* // Catalog 路径转运行时 URL
* const url = pathService.catalogToRuntime('textures/bg.png');
* // => './assets/textures/bg.png'
*
* // 编辑器路径转 Catalog 路径
* const catalogPath = pathService.editorToCatalog('C:\\Project\\assets\\textures\\bg.png', 'C:\\Project');
* // => 'textures/bg.png'
* ```
*/
import { createServiceToken } from '@esengine/ecs-framework';
// ============================================================================
// 接口定义 | Interface Definitions
// ============================================================================
/**
* 路径解析服务接口
* Path resolution service interface
*/
export interface IPathResolutionService {
/**
* 将 Catalog 路径转换为运行时 URL
* Convert catalog path to runtime URL
*
* @param catalogPath Catalog 路径(相对于 assets 目录,不含 assets/ 前缀)
* @returns 运行时 URL
*
* @example
* ```typescript
* // 输入: 'textures/bg.png'
* // 输出: './assets/textures/bg.png' (取决于 baseUrl 配置)
* pathService.catalogToRuntime('textures/bg.png');
* ```
*/
catalogToRuntime(catalogPath: string): string;
/**
* 将编辑器绝对路径转换为 Catalog 路径
* Convert editor absolute path to catalog path
*
* @param editorPath 编辑器绝对路径
* @param projectRoot 项目根目录
* @returns Catalog 路径(相对于 assets 目录,不含 assets/ 前缀)
*
* @example
* ```typescript
* // 输入: 'C:\\Project\\assets\\textures\\bg.png', 'C:\\Project'
* // 输出: 'textures/bg.png'
* pathService.editorToCatalog('C:\\Project\\assets\\textures\\bg.png', 'C:\\Project');
* ```
*/
editorToCatalog(editorPath: string, projectRoot: string): string;
/**
* 设置运行时基础 URL
* Set runtime base URL
*
* @param url 基础 URL通常为 './assets' 或 CDN URL
*/
setBaseUrl(url: string): void;
/**
* 获取当前基础 URL
* Get current base URL
*/
getBaseUrl(): string;
/**
* 规范化路径(统一斜杠方向,移除重复斜杠)
* Normalize path (unify slash direction, remove duplicate slashes)
*
* @param path 输入路径
* @returns 规范化后的路径
*/
normalize(path: string): string;
/**
* 检查路径是否为绝对 URL
* Check if path is absolute URL
*
* @param path 输入路径
* @returns 是否为绝对 URL
*/
isAbsoluteUrl(path: string): boolean;
}
// ============================================================================
// 服务令牌 | Service Token
// ============================================================================
/**
* 路径解析服务令牌
* Path resolution service token
*/
export const PathResolutionServiceToken = createServiceToken<IPathResolutionService>('pathResolutionService');
// ============================================================================
// 默认实现 | Default Implementation
// ============================================================================
/**
* 路径解析服务默认实现
* Default path resolution service implementation
*/
export class PathResolutionService implements IPathResolutionService {
private _baseUrl: string = './assets';
private _assetsDir: string = 'assets';
/**
* 创建路径解析服务
* Create path resolution service
*
* @param baseUrl 基础 URL默认 './assets'
*/
constructor(baseUrl?: string) {
if (baseUrl !== undefined) {
this._baseUrl = baseUrl;
}
}
/**
* 将 Catalog 路径转换为运行时 URL
* Convert catalog path to runtime URL
*/
catalogToRuntime(catalogPath: string): string {
// 空路径直接返回
if (!catalogPath) {
return catalogPath;
}
// 已经是绝对 URL 则直接返回
if (this.isAbsoluteUrl(catalogPath)) {
return catalogPath;
}
// Data URL 直接返回
if (catalogPath.startsWith('data:')) {
return catalogPath;
}
// 规范化路径
let normalized = this.normalize(catalogPath);
// 移除开头的斜杠
normalized = normalized.replace(/^\/+/, '');
// 如果路径以 'assets/' 开头,移除它(避免重复)
// Catalog 路径不应包含 assets/ 前缀
if (normalized.startsWith('assets/')) {
normalized = normalized.substring(7);
}
// 构建完整 URL
const base = this._baseUrl.replace(/\/+$/, ''); // 移除尾部斜杠
return `${base}/${normalized}`;
}
/**
* 将编辑器绝对路径转换为 Catalog 路径
* Convert editor absolute path to catalog path
*/
editorToCatalog(editorPath: string, projectRoot: string): string {
// 规范化路径
let normalizedPath = this.normalize(editorPath);
let normalizedRoot = this.normalize(projectRoot);
// 确保根路径以斜杠结尾
if (!normalizedRoot.endsWith('/')) {
normalizedRoot += '/';
}
// 移除项目根路径前缀
if (normalizedPath.startsWith(normalizedRoot)) {
normalizedPath = normalizedPath.substring(normalizedRoot.length);
}
// 移除 assets/ 前缀(如果存在)
const assetsPrefix = `${this._assetsDir}/`;
if (normalizedPath.startsWith(assetsPrefix)) {
normalizedPath = normalizedPath.substring(assetsPrefix.length);
}
return normalizedPath;
}
/**
* 设置运行时基础 URL
* Set runtime base URL
*/
setBaseUrl(url: string): void {
this._baseUrl = url;
}
/**
* 获取当前基础 URL
* Get current base URL
*/
getBaseUrl(): string {
return this._baseUrl;
}
/**
* 规范化路径
* Normalize path
*/
normalize(path: string): string {
return path
.replace(/\\/g, '/') // 反斜杠转正斜杠
.replace(/\/+/g, '/'); // 移除重复斜杠
}
/**
* 检查路径是否为绝对 URL
* Check if path is absolute URL
*/
isAbsoluteUrl(path: string): boolean {
return /^(https?:\/\/|file:\/\/|asset:\/\/|blob:)/.test(path);
}
}

View File

@@ -0,0 +1,358 @@
/**
* 场景资源管理器 - 集中式场景资源加载
* SceneResourceManager - Centralized resource loading for scenes
*
* 扫描场景中所有组件,收集资源引用,批量加载资源,并将运行时 ID 分配回组件
* Scans all components in a scene, collects resource references, batch-loads them, and assigns runtime IDs back to components
*/
import type { Scene } from '@esengine/ecs-framework';
import { isResourceComponent, type ResourceReference } from '../interfaces/IResourceComponent';
/**
* 资源加载器接口
* Resource loader interface
*/
export interface IResourceLoader {
/**
* 批量加载资源并返回路径到 ID 的映射
* Load a batch of resources and return path-to-ID mapping
* @param paths 资源路径数组 / Array of resource paths
* @param type 资源类型 / Resource type
* @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs
*/
loadResourcesBatch(paths: string[], type: ResourceReference['type']): Promise<Map<string, number>>;
/**
* 卸载纹理资源(可选)
* Unload texture resource (optional)
*/
unloadTexture?(textureId: number): void;
/**
* 卸载音频资源(可选)
* Unload audio resource (optional)
*/
unloadAudio?(audioId: number): void;
/**
* 卸载数据资源(可选)
* Unload data resource (optional)
*/
unloadData?(dataId: number): void;
}
/**
* 资源引用计数条目
* Resource reference count entry
*/
interface ResourceRefCountEntry {
/** 资源路径 / Resource path */
path: string;
/** 资源类型 / Resource type */
type: ResourceReference['type'];
/** 运行时 ID / Runtime ID */
runtimeId: number;
/** 使用此资源的场景名称集合 / Set of scene names using this resource */
sceneNames: Set<string>;
}
export class SceneResourceManager {
private resourceLoader: IResourceLoader | null = null;
/**
* 资源引用计数表
* Resource reference count table
*
* Key: resource path, Value: reference count entry
*/
private _resourceRefCounts = new Map<string, ResourceRefCountEntry>();
/**
* 场景到其使用的资源路径的映射
* Map of scene name to resource paths used by that scene
*/
private _sceneResources = new Map<string, Set<string>>();
/**
* 设置资源加载器实现
* Set the resource loader implementation
*
* 应由引擎集成层调用
* This should be called by the engine integration layer
*
* @param loader 资源加载器实例 / Resource loader instance
*/
setResourceLoader(loader: IResourceLoader): void {
this.resourceLoader = loader;
}
/**
* 加载场景所需的所有资源
* Load all resources required by a scene
*
* 流程 / Process:
* 1. 扫描所有实体并从 IResourceComponent 实现中收集资源引用
* Scan all entities and collect resource references from IResourceComponent implementations
* 2. 按类型分组资源(纹理、音频等)
* Group resources by type (texture, audio, etc.)
* 3. 批量加载每种资源类型
* Batch load each resource type
* 4. 将运行时 ID 分配回组件
* Assign runtime IDs back to components
* 5. 更新引用计数
* Update reference counts
*
* @param scene 要加载资源的场景 / The scene to load resources for
* @returns 当所有资源加载完成时解析的 Promise / Promise that resolves when all resources are loaded
*/
async loadSceneResources(scene: Scene): Promise<void> {
if (!this.resourceLoader) {
console.warn('[SceneResourceManager] No resource loader set, skipping resource loading');
return;
}
const sceneName = scene.name;
// 从组件收集所有资源引用 / Collect all resource references from components
const resourceRefs = this.collectResourceReferences(scene);
if (resourceRefs.length === 0) {
return;
}
// 按资源类型分组 / Group by resource type
const resourcesByType = new Map<ResourceReference['type'], Set<string>>();
for (const ref of resourceRefs) {
if (!resourcesByType.has(ref.type)) {
resourcesByType.set(ref.type, new Set());
}
resourcesByType.get(ref.type)!.add(ref.path);
}
// 批量加载每种资源类型 / Load each resource type in batch
const allResourceIds = new Map<string, number>();
for (const [type, paths] of resourcesByType) {
const pathsArray = Array.from(paths);
try {
const resourceIds = await this.resourceLoader.loadResourcesBatch(pathsArray, type);
// 合并到总映射表 / Merge into combined map
for (const [path, id] of resourceIds) {
allResourceIds.set(path, id);
// 更新引用计数 / Update reference count
this.addResourceReference(path, type, id, sceneName);
}
} catch (error) {
console.error(`[SceneResourceManager] Failed to load ${type} resources:`, error);
}
}
// 将资源 ID 分配回组件 / Assign resource IDs back to components
this.assignResourceIds(scene, allResourceIds);
// 记录场景使用的资源 / Record resources used by scene
const scenePaths = new Set<string>();
for (const ref of resourceRefs) {
scenePaths.add(ref.path);
}
this._sceneResources.set(sceneName, scenePaths);
}
/**
* 添加资源引用
* Add resource reference
*/
private addResourceReference(
path: string,
type: ResourceReference['type'],
runtimeId: number,
sceneName: string
): void {
let entry = this._resourceRefCounts.get(path);
if (!entry) {
entry = {
path,
type,
runtimeId,
sceneNames: new Set()
};
this._resourceRefCounts.set(path, entry);
}
entry.sceneNames.add(sceneName);
}
/**
* 移除资源引用
* Remove resource reference
*
* @returns true 如果资源引用计数归零 / true if resource reference count reaches zero
*/
private removeResourceReference(path: string, sceneName: string): boolean {
const entry = this._resourceRefCounts.get(path);
if (!entry) {
return false;
}
entry.sceneNames.delete(sceneName);
if (entry.sceneNames.size === 0) {
this._resourceRefCounts.delete(path);
return true;
}
return false;
}
/**
* 从场景实体收集所有资源引用
* Collect all resource references from scene entities
*/
private collectResourceReferences(scene: Scene): ResourceReference[] {
const refs: ResourceReference[] = [];
for (const entity of scene.entities.buffer) {
for (const component of entity.components) {
if (isResourceComponent(component)) {
const componentRefs = component.getResourceReferences();
refs.push(...componentRefs);
}
}
}
return refs;
}
/**
* 将已加载的资源 ID 分配回组件
* Assign loaded resource IDs back to components
*
* @param scene 场景 / Scene
* @param pathToId 路径到 ID 的映射 / Path to ID mapping
*/
private assignResourceIds(scene: Scene, pathToId: Map<string, number>): void {
for (const entity of scene.entities.buffer) {
for (const component of entity.components) {
if (isResourceComponent(component)) {
component.setResourceIds(pathToId);
}
}
}
}
/**
* 卸载场景使用的所有资源
* Unload all resources used by a scene
*
* 在场景销毁时调用,只会卸载不再被其他场景引用的资源
* Called when a scene is being destroyed, only unloads resources not referenced by other scenes
*
* @param scene 要卸载资源的场景 / The scene to unload resources for
*/
async unloadSceneResources(scene: Scene): Promise<void> {
const sceneName = scene.name;
// 获取场景使用的资源路径 / Get resource paths used by scene
const scenePaths = this._sceneResources.get(sceneName);
if (!scenePaths) {
return;
}
// 要卸载的资源 / Resources to unload
const toUnload: ResourceRefCountEntry[] = [];
// 移除引用并收集需要卸载的资源 / Remove references and collect resources to unload
for (const path of scenePaths) {
const entry = this._resourceRefCounts.get(path);
if (entry) {
const shouldUnload = this.removeResourceReference(path, sceneName);
if (shouldUnload) {
toUnload.push(entry);
}
}
}
// 清理场景资源记录 / Clean up scene resource record
this._sceneResources.delete(sceneName);
// 卸载不再使用的资源 / Unload resources no longer in use
if (this.resourceLoader && toUnload.length > 0) {
for (const entry of toUnload) {
this.unloadResource(entry);
}
}
}
/**
* 卸载单个资源
* Unload a single resource
*/
private unloadResource(entry: ResourceRefCountEntry): void {
if (!this.resourceLoader) return;
switch (entry.type) {
case 'texture':
if (this.resourceLoader.unloadTexture) {
this.resourceLoader.unloadTexture(entry.runtimeId);
}
break;
case 'audio':
if (this.resourceLoader.unloadAudio) {
this.resourceLoader.unloadAudio(entry.runtimeId);
}
break;
case 'data':
if (this.resourceLoader.unloadData) {
this.resourceLoader.unloadData(entry.runtimeId);
}
break;
case 'font':
// 字体卸载暂未实现 / Font unloading not yet implemented
break;
}
}
/**
* 获取资源统计信息
* Get resource statistics
*/
getStatistics(): {
totalResources: number;
trackedScenes: number;
resourcesByType: Map<ResourceReference['type'], number>;
} {
const resourcesByType = new Map<ResourceReference['type'], number>();
for (const entry of this._resourceRefCounts.values()) {
const count = resourcesByType.get(entry.type) || 0;
resourcesByType.set(entry.type, count + 1);
}
return {
totalResources: this._resourceRefCounts.size,
trackedScenes: this._sceneResources.size,
resourcesByType
};
}
/**
* 获取资源的引用计数
* Get reference count for a resource
*/
getResourceRefCount(path: string): number {
const entry = this._resourceRefCounts.get(path);
return entry ? entry.sceneNames.size : 0;
}
/**
* 清空所有跟踪数据
* Clear all tracking data
*/
clearAll(): void {
this._resourceRefCounts.clear();
this._sceneResources.clear();
}
}

View File

@@ -0,0 +1,54 @@
/**
* Asset System 服务令牌
* Asset System service tokens
*
* 定义 asset-system 模块导出的服务令牌和接口。
* Defines service tokens and interfaces exported by asset-system module.
*
* @example
* ```typescript
* // 消费方导入 Token | Consumer imports Token
* import { AssetManagerToken, type IAssetManager } from '@esengine/asset-system';
*
* // 获取服务 | Get service
* const assetManager = context.services.get(AssetManagerToken);
* ```
*/
import { createServiceToken } from '@esengine/ecs-framework';
import type { IAssetManager } from './interfaces/IAssetManager';
import type { IPrefabService } from './interfaces/IPrefabAsset';
import type { IPathResolutionService } from './services/PathResolutionService';
// 重新导出接口方便使用 | Re-export interface for convenience
export type { IAssetManager } from './interfaces/IAssetManager';
export type { IAssetLoadResult } from './types/AssetTypes';
export type { IPrefabService, IPrefabAsset, IPrefabData, IPrefabMetadata } from './interfaces/IPrefabAsset';
export type { IPathResolutionService } from './services/PathResolutionService';
/**
* 资产管理器服务令牌
* Asset manager service token
*
* 用于注册和获取资产管理器服务。
* For registering and getting asset manager service.
*/
export const AssetManagerToken = createServiceToken<IAssetManager>('assetManager');
/**
* 预制体服务令牌
* Prefab service token
*
* 用于注册和获取预制体服务。
* For registering and getting prefab service.
*/
export const PrefabServiceToken = createServiceToken<IPrefabService>('prefabService');
/**
* 路径解析服务令牌
* Path resolution service token
*
* 用于注册和获取路径解析服务。
* For registering and getting path resolution service.
*/
export const PathResolutionServiceToken = createServiceToken<IPathResolutionService>('pathResolutionService');

View File

@@ -0,0 +1,545 @@
/**
* Core asset system types and enums
* 核心资产系统类型和枚举
*/
/**
* Unique identifier for assets across the project
* 项目中资产的唯一标识符
*/
export type AssetGUID = string;
/**
* Runtime asset handle for efficient access
* 运行时资产句柄,用于高效访问
*/
export type AssetHandle = number;
/**
* Asset loading state
* 资产加载状态
*/
export enum AssetState {
/** 未加载 */
Unloaded = 'unloaded',
/** 加载中 */
Loading = 'loading',
/** 已加载 */
Loaded = 'loaded',
/** 加载失败 */
Failed = 'failed',
/** 释放中 */
Disposing = 'disposing'
}
/**
* Asset type - string based for extensibility
* 资产类型 - 使用字符串以支持插件扩展
*
* Plugins can define their own asset types by using custom strings.
* Built-in types are provided as constants below.
* 插件可以通过使用自定义字符串定义自己的资产类型。
* 内置类型作为常量提供如下。
*/
export type AssetType = string;
/**
* Built-in asset types provided by asset-system
* asset-system 提供的内置资产类型
*/
export const AssetType = {
/** 纹理 */
Texture: 'texture',
/** 网格 */
Mesh: 'mesh',
/** 3D模型 (GLTF/GLB) | 3D Model */
Model3D: 'model3d',
/** 材质 */
Material: 'material',
/** 着色器 */
Shader: 'shader',
/** 音频 */
Audio: 'audio',
/** 字体 */
Font: 'font',
/** 预制体 */
Prefab: 'prefab',
/** 场景 */
Scene: 'scene',
/** 脚本 */
Script: 'script',
/** 动画片段 */
AnimationClip: 'animation',
/** JSON数据 */
Json: 'json',
/** 文本 */
Text: 'text',
/** 二进制 */
Binary: 'binary',
/** 自定义 */
Custom: 'custom'
} as const;
/**
* Platform variants for assets
* 资产的平台变体
*/
export enum AssetPlatform {
/** H5平台浏览器 */
H5 = 'h5',
/** 微信小游戏 */
WeChat = 'wechat',
/** 试玩广告Playable Ads */
Playable = 'playable',
/** Android平台 */
Android = 'android',
/** iOS平台 */
iOS = 'ios',
/** 编辑器Tauri桌面 */
Editor = 'editor'
}
/**
* Quality levels for asset variants
* 资产变体的质量级别
*/
export enum AssetQuality {
/** 低质量 */
Low = 'low',
/** 中等质量 */
Medium = 'medium',
/** 高质量 */
High = 'high',
/** 超高质量 */
Ultra = 'ultra'
}
/**
* Asset metadata stored in the database
* 存储在数据库中的资产元数据
*/
export interface IAssetMetadata {
/** 全局唯一标识符 */
guid: AssetGUID;
/** 资产路径 */
path: string;
/** 资产类型 */
type: AssetType;
/** 资产名称 */
name: string;
/** 文件大小(字节) / File size in bytes */
size: number;
/** 内容哈希值 / Content hash for versioning */
hash: string;
/** 依赖的其他资产 / Dependencies on other assets */
dependencies: AssetGUID[];
/** 资产标签 / User-defined labels for categorization */
labels: string[];
/** 自定义标签 / Custom metadata tags */
tags: Map<string, string>;
/** 导入设置 / Import-time settings */
importSettings?: Record<string, unknown>;
/** 最后修改时间 / Unix timestamp of last modification */
lastModified: number;
/** 版本号 / Asset version number */
version: number;
}
/**
* Asset variant descriptor
* 资产变体描述符
*/
export interface IAssetVariant {
/** 目标平台 */
platform: AssetPlatform;
/** 质量级别 */
quality: AssetQuality;
/** 本地化语言 / Language code for localized assets */
locale?: string;
/** 主题变体 / Theme identifier (e.g., 'dark', 'light') */
theme?: string;
}
/**
* Asset load options
* 资产加载选项
*/
export interface IAssetLoadOptions {
/** 加载优先级0-100越高越优先 / Priority level 0-100, higher loads first */
priority?: number;
/** 是否异步加载 / Use async loading */
async?: boolean;
/** 指定加载的变体 / Specific variant to load */
variant?: IAssetVariant;
/** 强制重新加载 / Force reload even if cached */
forceReload?: boolean;
/** 超时时间(毫秒) / Timeout in milliseconds */
timeout?: number;
/** 进度回调 / Progress callback (0-1) */
onProgress?: (progress: number) => void;
}
/**
* Asset bundle manifest
* 资产包清单
*/
export interface IAssetBundleManifest {
/** 包名称 */
name: string;
/** 版本号 */
version: string;
/** 内容哈希 / Content hash for integrity check */
hash: string;
/** 压缩类型 */
compression?: 'none' | 'gzip' | 'brotli';
/** 包含的资产列表 / Assets contained in this bundle */
assets: AssetGUID[];
/** 依赖的其他包 / Other bundles this depends on */
dependencies: string[];
/** 包大小(字节) / Bundle size in bytes */
size: number;
/** 创建时间戳 / Creation timestamp */
createdAt: number;
}
/**
* Asset loading result
* 资产加载结果
*/
export interface IAssetLoadResult<T = unknown> {
/** 加载的资产实例 */
asset: T;
/** 资产句柄 */
handle: AssetHandle;
/** 资产元数据 */
metadata: IAssetMetadata;
/** 加载耗时(毫秒) / Load time in milliseconds */
loadTime: number;
}
/**
* Asset loading error
* 资产加载错误
*/
export class AssetLoadError extends Error {
constructor(
message: string,
public readonly guid: AssetGUID,
public readonly type: AssetType,
public readonly cause?: Error
) {
super(message);
this.name = 'AssetLoadError';
Object.setPrototypeOf(this, new.target.prototype);
}
/**
* Factory method for file not found error
* 文件未找到错误的工厂方法
*/
static fileNotFound(guid: AssetGUID, path: string): AssetLoadError {
return new AssetLoadError(`Asset file not found: ${path}`, guid, AssetType.Custom);
}
/**
* Factory method for unsupported type error
* 不支持的类型错误的工厂方法
*/
static unsupportedType(guid: AssetGUID, type: AssetType): AssetLoadError {
return new AssetLoadError(`Unsupported asset type: ${type}`, guid, type);
}
/**
* Factory method for load timeout error
* 加载超时错误的工厂方法
*/
static loadTimeout(guid: AssetGUID, type: AssetType, timeout: number): AssetLoadError {
return new AssetLoadError(`Asset load timeout after ${timeout}ms`, guid, type);
}
/**
* Factory method for dependency failed error
* 依赖加载失败错误的工厂方法
*/
static dependencyFailed(guid: AssetGUID, type: AssetType, depGuid: AssetGUID): AssetLoadError {
return new AssetLoadError(`Dependency failed to load: ${depGuid}`, guid, type);
}
}
/**
* Asset reference counting info
* 资产引用计数信息
*/
export interface IAssetReferenceInfo {
/** 资产GUID */
guid: AssetGUID;
/** 资产句柄 */
handle: AssetHandle;
/** 引用计数 */
referenceCount: number;
/** 最后访问时间 / Unix timestamp of last access */
lastAccessTime: number;
/** 当前状态 */
state: AssetState;
}
/**
* Asset import options
* 资产导入选项
*/
export interface IAssetImportOptions {
/** 资产类型 */
type: AssetType;
/** 生成Mipmap / Generate mipmaps for textures */
generateMipmaps?: boolean;
/** 纹理压缩格式 / Texture compression format */
compression?: 'none' | 'dxt' | 'etc2' | 'astc';
/** 最大纹理尺寸 / Maximum texture dimension */
maxTextureSize?: number;
/** 生成LOD / Generate LODs for meshes */
generateLODs?: boolean;
/** 优化网格 / Optimize mesh geometry */
optimizeMesh?: boolean;
/** 音频格式 / Audio encoding format */
audioFormat?: 'mp3' | 'ogg' | 'wav';
/** 自定义处理器 / Custom processor plugin name */
customProcessor?: string;
}
/**
* Asset usage statistics
* 资产使用统计
*/
export interface IAssetUsageStats {
/** 资产GUID */
guid: AssetGUID;
/** 加载次数 */
loadCount: number;
/** 总加载时间(毫秒) / Total time spent loading in ms */
totalLoadTime: number;
/** 平均加载时间(毫秒) / Average load time in ms */
averageLoadTime: number;
/** 最后使用时间 / Unix timestamp of last use */
lastUsedTime: number;
/** 被引用的资产列表 / Assets that reference this one */
referencedBy: AssetGUID[];
}
/**
* Asset preload group
* 资产预加载组
*/
export interface IAssetPreloadGroup {
/** 组名称 */
name: string;
/** 包含的资产 */
assets: AssetGUID[];
/** 加载优先级 / Load priority 0-100 */
priority: number;
/** 是否必需 / Must be loaded before scene start */
required: boolean;
}
/**
* Asset loading progress info
* 资产加载进度信息
*/
export interface IAssetLoadProgress {
/** 当前加载的资产 */
currentAsset: string;
/** 已加载数量 */
loadedCount: number;
/** 总数量 */
totalCount: number;
/** 已加载字节数 */
loadedBytes: number;
/** 总字节数 */
totalBytes: number;
/** 进度百分比0-1 / Progress value 0-1 */
progress: number;
}
/**
* Asset loading strategy
* 资产加载策略
*
* - 'file': Load assets directly via HTTP (development, simple builds)
* - 'bundle': Load assets from binary bundles (optimized production builds)
*
* - 'file': 通过 HTTP 直接加载资产(开发模式、简单构建)
* - 'bundle': 从二进制包加载资产(优化的生产构建)
*/
export type AssetLoadStrategy = 'file' | 'bundle';
/**
* Asset catalog entry for runtime lookups
* 运行时查找的资产目录条目
*
* This is a unified format supporting both file-based and bundle-based loading.
* 这是一个统一格式,同时支持基于文件和基于包的加载。
*/
export interface IAssetCatalogEntry {
/** 资产 GUID / Asset GUID */
guid: AssetGUID;
/** 资产相对路径 / Asset relative path (e.g., 'assets/textures/player.png') */
path: string;
/** 资产类型 / Asset type */
type: AssetType;
/** 文件大小(字节) / File size in bytes */
size: number;
/** 内容哈希(用于缓存校验) / Content hash for cache validation */
hash: string;
// ===== Bundle mode fields (optional) =====
// ===== Bundle 模式字段(可选)=====
/** 所在包名称(仅 bundle 模式) / Bundle name (bundle mode only) */
bundle?: string;
/** 包内偏移(仅 bundle 模式) / Offset within bundle (bundle mode only) */
offset?: number;
// ===== Optional metadata =====
// ===== 可选元数据 =====
/** 可用变体 / Available variants (platform/quality specific) */
variants?: IAssetVariant[];
/**
* Import settings (e.g., sprite slicing for nine-patch)
* 导入设置(如九宫格切片信息)
*/
importSettings?: Record<string, unknown>;
}
/**
* Asset bundle info for runtime loading
* 运行时加载的资产包信息
*/
export interface IAssetBundleInfo {
/** 包 URL相对于 catalog / Bundle URL relative to catalog */
url: string;
/** 包大小(字节) / Bundle size in bytes */
size: number;
/** 内容哈希 / Content hash for integrity check */
hash: string;
/** 是否预加载 / Whether to preload this bundle */
preload?: boolean;
/** 压缩类型 / Compression type */
compression?: 'none' | 'gzip' | 'brotli';
/** 依赖的其他包 / Dependencies on other bundles */
dependencies?: string[];
}
/**
* Runtime asset catalog
* 运行时资产目录
*
* This is the canonical format for asset catalogs in ESEngine.
* Both WebBuildPipeline and AssetPacker generate this format.
* 这是 ESEngine 中资产目录的标准格式。
* WebBuildPipeline 和 AssetPacker 都生成此格式。
*
* @example File mode (development/simple builds)
* ```json
* {
* "version": "1.0.0",
* "createdAt": 1702185600000,
* "loadStrategy": "file",
* "entries": {
* "550e8400-e29b-41d4-a716-446655440000": {
* "guid": "550e8400-e29b-41d4-a716-446655440000",
* "path": "assets/textures/player.png",
* "type": "texture",
* "size": 12345,
* "hash": "abc123"
* }
* }
* }
* ```
*
* @example Bundle mode (optimized production)
* ```json
* {
* "version": "1.0.0",
* "createdAt": 1702185600000,
* "loadStrategy": "bundle",
* "entries": {
* "550e8400-e29b-41d4-a716-446655440000": {
* "guid": "550e8400-e29b-41d4-a716-446655440000",
* "path": "assets/textures/player.png",
* "type": "texture",
* "size": 12345,
* "hash": "abc123",
* "bundle": "textures",
* "offset": 1024
* }
* },
* "bundles": {
* "textures": {
* "url": "bundles/textures.bundle",
* "size": 1048576,
* "hash": "def456",
* "preload": true
* }
* }
* }
* ```
*/
export interface IAssetCatalog {
/** 目录版本号 / Catalog version */
version: string;
/** 创建时间戳 / Creation timestamp (Unix ms) */
createdAt: number;
/**
* 加载策略 / Loading strategy
* - 'file': 直接 HTTP 加载
* - 'bundle': 从二进制包加载
*/
loadStrategy: AssetLoadStrategy;
/**
* 资产条目GUID 到条目的映射)
* Asset entries (GUID to entry mapping)
*
* Uses Record for JSON serialization compatibility.
* 使用 Record 以兼容 JSON 序列化。
*/
entries: Record<AssetGUID, IAssetCatalogEntry>;
/**
* 包信息(仅 bundle 模式)
* Bundle info (bundle mode only)
*/
bundles?: Record<string, IAssetBundleInfo>;
}
/**
* Asset hot-reload event
* 资产热重载事件
*/
export interface IAssetHotReloadEvent {
/** 资产GUID */
guid: AssetGUID;
/** 资产路径 */
path: string;
/** 资产类型 */
type: AssetType;
/** 旧版本哈希 / Previous version hash */
oldHash: string;
/** 新版本哈希 / New version hash */
newHash: string;
/** 时间戳 */
timestamp: number;
}

View File

@@ -0,0 +1,239 @@
/**
* 通用资产收集器
* Generic Asset Collector
*
* 从序列化的场景数据中自动收集资产引用。
* 支持基于字段名模式和 Property 元数据两种识别方式。
*
* Automatically collects asset references from serialized scene data.
* Supports both field name pattern matching and Property metadata recognition.
*/
/**
* 场景资产引用信息(用于构建时收集)
* Scene asset reference info (for build-time collection)
*/
export interface SceneAssetRef {
/** 资产 GUID | Asset GUID */
guid: string;
/** 来源组件类型 | Source component type */
componentType: string;
/** 来源字段名 | Source field name */
fieldName: string;
/** 实体名称(可选)| Entity name (optional) */
entityName?: string;
}
/**
* 资产字段模式配置
* Asset field pattern configuration
*/
export interface AssetFieldPattern {
/** 字段名模式(正则表达式)| Field name pattern (regex) */
pattern: RegExp;
/** 字段类型(用于分类)| Field type (for categorization) */
type?: string;
}
/**
* 默认资产字段模式
* Default asset field patterns
*
* 这些模式用于识别常见的资产引用字段
* These patterns are used to identify common asset reference fields
*/
export const DEFAULT_ASSET_PATTERNS: AssetFieldPattern[] = [
// GUID 类字段 | GUID-like fields
{ pattern: /^.*[Gg]uid$/, type: 'guid' },
{ pattern: /^.*[Aa]sset[Ii]d$/, type: 'guid' },
{ pattern: /^.*[Aa]ssetGuid$/, type: 'guid' },
// 纹理/贴图字段 | Texture fields
{ pattern: /^texture$/, type: 'texture' },
{ pattern: /^.*[Tt]exture[Pp]ath$/, type: 'texture' },
// 音频字段 | Audio fields
{ pattern: /^clip$/, type: 'audio' },
{ pattern: /^.*[Aa]udio[Pp]ath$/, type: 'audio' },
// 通用路径字段 | Generic path fields
{ pattern: /^.*[Pp]ath$/, type: 'path' },
];
/**
* 检查值是否像 GUID
* Check if value looks like a GUID
*/
function isGuidLike(value: unknown): value is string {
if (typeof value !== 'string') return false;
// GUID 格式: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// 或者简单的包含连字符的长字符串
return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value) ||
(value.includes('-') && value.length >= 30 && value.length <= 40);
}
/**
* 从组件数据中收集资产引用
* Collect asset references from component data
*/
function collectFromComponentData(
componentType: string,
data: Record<string, unknown>,
patterns: AssetFieldPattern[],
entityName?: string
): SceneAssetRef[] {
const references: SceneAssetRef[] = [];
for (const [fieldName, value] of Object.entries(data)) {
// 检查是否匹配任何资产字段模式
// Check if matches any asset field pattern
const matchesPattern = patterns.some(p => p.pattern.test(fieldName));
if (matchesPattern) {
// 处理单个值 | Handle single value
if (isGuidLike(value)) {
references.push({
guid: value,
componentType,
fieldName,
entityName
});
}
// 处理数组 | Handle array
else if (Array.isArray(value)) {
for (const item of value) {
if (isGuidLike(item)) {
references.push({
guid: item,
componentType,
fieldName,
entityName
});
}
}
}
}
// 特殊处理已知的数组字段(如 particleAssets
// Special handling for known array fields (like particleAssets)
if (fieldName === 'particleAssets' && Array.isArray(value)) {
for (const item of value) {
if (isGuidLike(item)) {
references.push({
guid: item,
componentType,
fieldName,
entityName
});
}
}
}
}
return references;
}
/**
* 实体类型定义(支持嵌套 children
* Entity type definition (supports nested children)
*/
interface EntityData {
name?: string;
components?: Array<{ type: string; data?: Record<string, unknown> }>;
children?: EntityData[];
}
/**
* 递归处理实体及其子实体
* Recursively process entity and its children
*/
function collectFromEntity(
entity: EntityData,
patterns: AssetFieldPattern[],
references: SceneAssetRef[]
): void {
const entityName = entity.name;
// 处理当前实体的组件 | Process current entity's components
if (entity.components) {
for (const component of entity.components) {
if (!component.data) continue;
const componentRefs = collectFromComponentData(
component.type,
component.data,
patterns,
entityName
);
references.push(...componentRefs);
}
}
// 递归处理子实体 | Recursively process children
if (entity.children && Array.isArray(entity.children)) {
for (const child of entity.children) {
collectFromEntity(child, patterns, references);
}
}
}
/**
* 从序列化的场景数据中收集所有资产引用
* Collect all asset references from serialized scene data
*
* @param sceneData 序列化的场景数据JSON 对象)| Serialized scene data (JSON object)
* @param patterns 资产字段模式(可选,默认使用内置模式)| Asset field patterns (optional, defaults to built-in patterns)
* @returns 资产引用列表 | List of asset references
*
* @example
* ```typescript
* const sceneData = JSON.parse(sceneJson);
* const references = collectAssetReferences(sceneData);
* for (const ref of references) {
* console.log(`Found asset ${ref.guid} in ${ref.componentType}.${ref.fieldName}`);
* }
* ```
*/
export function collectAssetReferences(
sceneData: { entities?: EntityData[] },
patterns: AssetFieldPattern[] = DEFAULT_ASSET_PATTERNS
): SceneAssetRef[] {
const references: SceneAssetRef[] = [];
if (!sceneData.entities) {
return references;
}
// 遍历顶层实体,递归处理嵌套的子实体
// Iterate top-level entities, recursively process nested children
for (const entity of sceneData.entities) {
collectFromEntity(entity, patterns, references);
}
return references;
}
/**
* 从资产引用列表中提取唯一的 GUID 集合
* Extract unique GUID set from asset references
*/
export function extractUniqueGuids(references: SceneAssetRef[]): Set<string> {
return new Set(references.map(ref => ref.guid));
}
/**
* 按组件类型分组资产引用
* Group asset references by component type
*/
export function groupByComponentType(references: SceneAssetRef[]): Map<string, SceneAssetRef[]> {
const groups = new Map<string, SceneAssetRef[]>();
for (const ref of references) {
const existing = groups.get(ref.componentType) || [];
existing.push(ref);
groups.set(ref.componentType, existing);
}
return groups;
}

View File

@@ -0,0 +1,79 @@
/**
* Asset Utilities
* 资产工具函数
*
* Provides common utilities for asset management:
* - GUID validation and generation (re-exported from core)
* - Content hashing
* 提供资产管理的通用工具:
* - GUID 验证和生成(从 core 重导出)
* - 内容哈希
*/
// Re-export GUID utilities from core (single source of truth)
// 从 core 重导出 GUID 工具(单一来源)
export { generateGUID, isValidGUID } from '@esengine/ecs-framework';
// ============================================================================
// Hash Utilities
// 哈希工具
// ============================================================================
/**
* Compute SHA-256 hash of an ArrayBuffer
* 计算 ArrayBuffer 的 SHA-256 哈希
*
* Returns first 16 hex characters of the hash.
* 返回哈希的前 16 个十六进制字符。
*
* @param buffer - The buffer to hash
* @returns Hash string (16 hex characters)
*/
export async function hashBuffer(buffer: ArrayBuffer): Promise<string> {
// Use Web Crypto API if available
// 如果可用则使用 Web Crypto API
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 DJB2 hash
// 回退:简单的 DJB2 哈希
const view = new Uint8Array(buffer);
let hash = 5381;
for (let i = 0; i < view.length; i++) {
hash = ((hash << 5) + hash) ^ view[i];
}
return Math.abs(hash).toString(16).padStart(16, '0');
}
/**
* Compute hash of a string
* 计算字符串的哈希
*
* @param str - The string to hash
* @returns Hash string (8 hex characters)
*/
export function hashString(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
}
return Math.abs(hash).toString(16).padStart(8, '0');
}
/**
* Compute content hash from file path and size
* 从文件路径和大小计算内容哈希
*
* This is a lightweight hash for quick comparison, not cryptographically secure.
* 这是一个用于快速比较的轻量级哈希,不具有加密安全性。
*
* @param path - File path
* @param size - File size in bytes
* @returns Hash string (8 hex characters)
*/
export function hashFileInfo(path: string, size: number): string {
return hashString(`${path}:${size}`);
}

View File

@@ -0,0 +1,227 @@
/**
* Path Validator
* 路径验证器
*
* Validates and sanitizes asset paths for security
* 验证并清理资产路径以确保安全
*/
/**
* 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 (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
/\0/, // Null bytes
/%00/, // URL encoded null bytes
/\.\.%2[fF]/ // URL encoded path traversal
];
// 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 = 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, 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' };
}
// Check length
if (path.length > this.MAX_PATH_LENGTH) {
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 patterns) {
if (pattern.test(path)) {
return { valid: false, reason: 'Path contains dangerous pattern' };
}
}
// Check for valid characters
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' };
}
return { valid: true };
}
/**
* Sanitize a path
* 清理路径
*/
static sanitize(path: string): string {
if (!path || typeof path !== 'string') {
return '';
}
// Remove dangerous patterns
let sanitized = path;
// Remove path traversal (apply repeatedly until fully removed)
let prev;
do {
prev = sanitized;
sanitized = sanitized.replace(/\.\.[/\\]/g, '');
} while (sanitized !== prev);
// Remove leading slashes
sanitized = sanitized.replace(/^[/\\]+/, '');
// Remove null bytes
sanitized = sanitized.replace(/\0/g, '');
sanitized = sanitized.replace(/%00/g, '');
// Remove invalid Windows characters
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
// Normalize slashes
sanitized = sanitized.replace(/\\/g, '/');
// Remove double slashes
sanitized = sanitized.replace(/\/+/g, '/');
// Trim whitespace
sanitized = sanitized.trim();
// Truncate if too long
if (sanitized.length > this.MAX_PATH_LENGTH) {
sanitized = sanitized.substring(0, this.MAX_PATH_LENGTH);
}
return sanitized;
}
/**
* Check if path is trying to escape the base directory
* 检查路径是否试图逃离基础目录
*/
static isPathTraversal(path: string): boolean {
const normalized = path.replace(/\\/g, '/');
return normalized.includes('../') || normalized.includes('..\\');
}
/**
* Normalize a path for consistent handling
* 规范化路径以便一致处理
*/
static normalize(path: string): string {
if (!path) return '';
// Sanitize first
let normalized = this.sanitize(path);
// Convert backslashes to forward slashes
normalized = normalized.replace(/\\/g, '/');
// Remove trailing slash (except for root)
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
/**
* Join path segments safely
* 安全地连接路径段
*/
static join(...segments: string[]): string {
const validSegments = segments
.filter((s) => s && typeof s === 'string')
.map((s) => this.sanitize(s))
.filter((s) => s.length > 0);
if (validSegments.length === 0) {
return '';
}
return this.normalize(validSegments.join('/'));
}
/**
* Get file extension safely
* 安全地获取文件扩展名
*/
static getExtension(path: string): string {
const sanitized = this.sanitize(path);
const lastDot = sanitized.lastIndexOf('.');
const lastSlash = sanitized.lastIndexOf('/');
if (lastDot > lastSlash && lastDot > 0) {
return sanitized.substring(lastDot + 1).toLowerCase();
}
return '';
}
}

View File

@@ -0,0 +1,81 @@
/**
* UV Coordinate Helper
* UV 坐标辅助工具
*
* 引擎使用图像坐标系:
* Engine uses image coordinate system:
* - 原点 (0, 0) 在左上角 | Origin at top-left
* - V 轴向下增长 | V-axis increases downward
* - UV 格式:[u0, v0, u1, v1] 其中 v0 < v1
*/
export class UVHelper {
/**
* Calculate UV coordinates for a texture region
* 计算纹理区域的 UV 坐标
*/
static calculateUV(
imageRect: { x: number; y: number; width: number; height: number },
textureSize: { width: number; height: number }
): [number, number, number, number] {
const { x, y, width, height } = imageRect;
const { width: tw, height: th } = textureSize;
return [
x / tw, // u0
y / th, // v0
(x + width) / tw, // u1
(y + height) / th // v1
];
}
/**
* Calculate UV coordinates for a tile in a tileset
* 计算 tileset 中某个 tile 的 UV 坐标
*/
static calculateTileUV(
tileIndex: number,
tilesetInfo: {
columns: number;
tileWidth: number;
tileHeight: number;
imageWidth: number;
imageHeight: number;
margin?: number;
spacing?: number;
}
): [number, number, number, number] | null {
if (tileIndex < 0) return null;
const {
columns,
tileWidth,
tileHeight,
imageWidth,
imageHeight,
margin = 0,
spacing = 0
} = tilesetInfo;
const col = tileIndex % columns;
const row = Math.floor(tileIndex / columns);
const x = margin + col * (tileWidth + spacing);
const y = margin + row * (tileHeight + spacing);
return this.calculateUV(
{ x, y, width: tileWidth, height: tileHeight },
{ width: imageWidth, height: imageHeight }
);
}
static validateUV(uv: [number, number, number, number]): boolean {
const [u0, v0, u1, v1] = uv;
return u0 >= 0 && u0 <= 1 && u1 >= 0 && u1 <= 1 &&
v0 >= 0 && v0 <= 1 && v1 >= 0 && v1 <= 1 &&
u0 < u1 && v0 < v1;
}
static debugPrint(uv: [number, number, number, number], label?: string): void {
const prefix = label ? `[${label}] ` : '';
console.log(`${prefix}UV: [${uv.map(n => n.toFixed(4)).join(', ')}]`);
}
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"composite": true,
"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"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsup';
import { runtimeOnlyPreset } from '../../tools/build-config/src/presets/plugin-tsup';
export default defineConfig({
...runtimeOnlyPreset(),
tsconfig: 'tsconfig.build.json'
});