Compare commits
24 Commits
@esengine/
...
feat/textu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8662449dcf | ||
|
|
1834bc2068 | ||
|
|
c23c6c21db | ||
|
|
b494283e9c | ||
|
|
9b334f36e1 | ||
|
|
7f8d2eb142 | ||
|
|
9d3eeb1980 | ||
|
|
0bcb675c3b | ||
|
|
574b4d08a3 | ||
|
|
d64e463a71 | ||
|
|
792fd05c85 | ||
|
|
7814b97ace | ||
|
|
75be905f14 | ||
|
|
01293590e8 | ||
|
|
b236b729b4 | ||
|
|
0170dc6e9c | ||
|
|
7834328ae0 | ||
|
|
39fa797299 | ||
|
|
03229ffb59 | ||
|
|
844a770335 | ||
|
|
c8dc9869a3 | ||
|
|
38755c9014 | ||
|
|
5d5537e4c7 | ||
|
|
9da9f5f068 |
48
.github/workflows/release-editor.yml
vendored
48
.github/workflows/release-editor.yml
vendored
@@ -122,33 +122,62 @@ jobs:
|
||||
|
||||
# SignPath 代码签名(Windows)
|
||||
# SignPath OSS code signing for Windows
|
||||
# 注意:需要先在 https://signpath.io 申请 OSS 证书
|
||||
# Note: Apply for OSS certificate at https://signpath.io first
|
||||
# 并配置 GitHub Secrets: SIGNPATH_API_TOKEN, SIGNPATH_ORGANIZATION_ID
|
||||
# Configure GitHub Secrets: SIGNPATH_API_TOKEN, SIGNPATH_ORGANIZATION_ID
|
||||
#
|
||||
# 配置步骤 | Setup Steps:
|
||||
# 1. 在 SignPath 门户创建项目 | Create project in SignPath portal
|
||||
# 2. 导入 .signpath/artifact-configuration.xml | Import artifact configuration
|
||||
# 3. 使用 'test-signing' 策略测试 | Use 'test-signing' policy for testing
|
||||
# 生产环境改为 'release-signing' | Change to 'release-signing' for production
|
||||
# 4. 配置 GitHub Secrets | Configure GitHub Secrets:
|
||||
# - SIGNPATH_API_TOKEN: API token from SignPath
|
||||
# - SIGNPATH_ORGANIZATION_ID: Your organization ID
|
||||
#
|
||||
# 文档 | Documentation: https://about.signpath.io/documentation/trusted-build-systems/github
|
||||
sign-windows:
|
||||
needs: build-tauri
|
||||
runs-on: ubuntu-latest
|
||||
if: success() && secrets.SIGNPATH_API_TOKEN != ''
|
||||
# 只有在构建成功时才运行 | Only run on successful build
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- name: Check SignPath configuration
|
||||
id: check-signpath
|
||||
run: |
|
||||
if [ -n "${{ secrets.SIGNPATH_API_TOKEN }}" ] && [ -n "${{ secrets.SIGNPATH_ORGANIZATION_ID }}" ]; then
|
||||
echo "enabled=true" >> $GITHUB_OUTPUT
|
||||
echo "SignPath is configured, proceeding with code signing"
|
||||
else
|
||||
echo "enabled=false" >> $GITHUB_OUTPUT
|
||||
echo "SignPath secrets not configured, skipping code signing"
|
||||
echo "To enable: add SIGNPATH_API_TOKEN and SIGNPATH_ORGANIZATION_ID secrets"
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
if: steps.check-signpath.outputs.enabled == 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Windows artifact
|
||||
if: steps.check-signpath.outputs.enabled == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-unsigned
|
||||
path: ./artifacts
|
||||
|
||||
- name: List artifacts for signing
|
||||
if: steps.check-signpath.outputs.enabled == 'true'
|
||||
run: |
|
||||
echo "Files to be signed:"
|
||||
find ./artifacts -type f \( -name "*.exe" -o -name "*.msi" \) | head -20
|
||||
|
||||
- name: Submit to SignPath for code signing
|
||||
if: steps.check-signpath.outputs.enabled == 'true'
|
||||
id: signpath
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: 'ecs-framework'
|
||||
signing-policy-slug: 'release-signing'
|
||||
signing-policy-slug: 'test-signing'
|
||||
artifact-configuration-slug: 'default'
|
||||
github-artifact-name: 'windows-unsigned'
|
||||
wait-for-completion: true
|
||||
@@ -156,6 +185,7 @@ jobs:
|
||||
output-artifact-directory: './signed'
|
||||
|
||||
- name: Upload signed artifacts to release
|
||||
if: steps.check-signpath.outputs.enabled == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: ./signed/*
|
||||
@@ -165,9 +195,11 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 构建成功后,创建 PR 更新版本号
|
||||
# Create PR to update version after successful build
|
||||
update-version-pr:
|
||||
needs: sign-windows
|
||||
if: github.event_name == 'workflow_dispatch' && success()
|
||||
needs: [build-tauri, sign-windows]
|
||||
# 即使签名跳过也要运行 | Run even if signing is skipped
|
||||
if: github.event_name == 'workflow_dispatch' && !failure()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
AssetMetaManager,
|
||||
type IAssetMeta,
|
||||
type IImportSettings,
|
||||
type ISpriteSettings,
|
||||
type IMetaFileSystem,
|
||||
getMetaFilePath,
|
||||
inferAssetType,
|
||||
|
||||
@@ -49,6 +49,36 @@ export interface IAssetMeta {
|
||||
lastModified?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite settings for textures
|
||||
* 纹理的 Sprite 设置
|
||||
*/
|
||||
export interface ISpriteSettings {
|
||||
/**
|
||||
* Nine-patch slice border [top, right, bottom, left]
|
||||
* 九宫格切片边距
|
||||
*
|
||||
* Defines the non-stretchable borders for nine-patch rendering.
|
||||
* 定义九宫格渲染时不可拉伸的边框区域。
|
||||
*/
|
||||
sliceBorder?: [number, number, number, number];
|
||||
|
||||
/**
|
||||
* Sprite pivot point (0-1 normalized)
|
||||
* Sprite 锚点(0-1 归一化)
|
||||
*
|
||||
* Default is [0.5, 0.5] (center)
|
||||
* 默认为 [0.5, 0.5](中心)
|
||||
*/
|
||||
pivot?: [number, number];
|
||||
|
||||
/**
|
||||
* Pixels per unit for world-space rendering
|
||||
* 世界空间渲染的像素单位比
|
||||
*/
|
||||
pixelsPerUnit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings for different asset types
|
||||
* 不同资产类型的导入设置
|
||||
@@ -62,6 +92,9 @@ export interface IImportSettings {
|
||||
wrapMode?: 'clamp' | 'repeat' | 'mirror';
|
||||
premultiplyAlpha?: boolean;
|
||||
|
||||
// Sprite settings | Sprite 设置
|
||||
spriteSettings?: ISpriteSettings;
|
||||
|
||||
// Audio settings | 音频设置
|
||||
audioFormat?: 'mp3' | 'ogg' | 'wav';
|
||||
sampleRate?: number;
|
||||
@@ -385,6 +418,21 @@ export class AssetMetaManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific asset path
|
||||
* 使特定资产路径的缓存失效
|
||||
*
|
||||
* Call this when a .meta file is modified externally.
|
||||
* 当 .meta 文件被外部修改时调用此方法。
|
||||
*/
|
||||
invalidateCache(assetPath: string): void {
|
||||
const meta = this._cache.get(assetPath);
|
||||
if (meta) {
|
||||
this._guidToPath.delete(meta.guid);
|
||||
this._cache.delete(assetPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
* 清除缓存
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
import { TextureLoader } from '../loaders/TextureLoader';
|
||||
|
||||
/**
|
||||
* Texture engine bridge interface (for asset system)
|
||||
@@ -67,6 +66,49 @@ export interface ITextureEngineBridge {
|
||||
* 清除所有纹理并重置状态(可选)。
|
||||
*/
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,6 +142,10 @@ export class EngineIntegration {
|
||||
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>();
|
||||
|
||||
// Audio resource mappings | 音频资源映射
|
||||
private _audioIdMap = new Map<AssetGUID, number>();
|
||||
private _pathToAudioId = new Map<string, number>();
|
||||
@@ -112,6 +158,39 @@ export class EngineIntegration {
|
||||
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 > 0(0 保留给默认纹理)
|
||||
// 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;
|
||||
@@ -138,63 +217,56 @@ export class EngineIntegration {
|
||||
* Load texture for component
|
||||
* 为组件加载纹理
|
||||
*
|
||||
* 使用 Rust 引擎作为纹理 ID 的唯一分配源。
|
||||
* Uses Rust engine as the single source of truth for texture ID allocation.
|
||||
* 使用路径稳定 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> {
|
||||
// 检查缓存(使用原始路径作为键)
|
||||
// Check cache (using original path as key)
|
||||
// 生成路径稳定 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) {
|
||||
return existingId;
|
||||
if (existingId === stableId) {
|
||||
return stableId; // 已加载,直接返回 | Already loaded, return directly
|
||||
}
|
||||
|
||||
// 解析路径为引擎可用的 URL
|
||||
// Resolve path to engine-compatible URL
|
||||
const engineUrl = this._pathResolver.catalogToRuntime(texturePath);
|
||||
|
||||
// 优先使用 getOrLoadTextureByPath(Rust 分配 ID)
|
||||
// Prefer getOrLoadTextureByPath (Rust allocates ID)
|
||||
// 这确保纹理 ID 由 Rust 引擎统一分配,避免 JS/Rust 层 ID 不同步问题
|
||||
// This ensures texture IDs are allocated by Rust engine uniformly,
|
||||
// avoiding JS/Rust layer ID desync issues
|
||||
if (this._engineBridge?.getOrLoadTextureByPath) {
|
||||
const rustTextureId = this._engineBridge.getOrLoadTextureByPath(engineUrl);
|
||||
if (rustTextureId > 0) {
|
||||
// 缓存映射
|
||||
// Cache mapping
|
||||
this._pathToTextureId.set(texturePath, rustTextureId);
|
||||
return rustTextureId;
|
||||
// 使用稳定 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:通过资产系统加载(兼容旧流程)
|
||||
// Fallback: Load through asset system (for backward compatibility)
|
||||
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath);
|
||||
const textureAsset = result.asset;
|
||||
// 缓存映射
|
||||
// Cache mapping
|
||||
this._pathToTextureId.set(texturePath, stableId);
|
||||
|
||||
// 如果有引擎桥接,上传到GPU
|
||||
// Upload to GPU if bridge exists
|
||||
if (this._engineBridge && textureAsset.data) {
|
||||
await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl);
|
||||
}
|
||||
|
||||
// 缓存映射(使用原始路径作为键,避免重复解析)
|
||||
// Cache mapping (using original path as key to avoid re-resolving)
|
||||
this._pathToTextureId.set(texturePath, textureAsset.textureId);
|
||||
|
||||
return textureAsset.textureId;
|
||||
return stableId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture by GUID
|
||||
* 通过GUID加载纹理
|
||||
*
|
||||
* 使用 Rust 引擎作为纹理 ID 的唯一分配源。
|
||||
* Uses Rust engine as the single source of truth for texture ID allocation.
|
||||
* 使用路径稳定 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
|
||||
@@ -206,31 +278,38 @@ export class EngineIntegration {
|
||||
// 通过资产系统加载获取元数据和路径 / Load through asset system to get metadata and path
|
||||
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
|
||||
const metadata = result.metadata;
|
||||
const engineUrl = this._pathResolver.catalogToRuntime(metadata.path);
|
||||
const assetPath = metadata.path;
|
||||
|
||||
// 优先使用 getOrLoadTextureByPath(Rust 分配 ID)
|
||||
// Prefer getOrLoadTextureByPath (Rust allocates ID)
|
||||
if (this._engineBridge?.getOrLoadTextureByPath) {
|
||||
const rustTextureId = this._engineBridge.getOrLoadTextureByPath(engineUrl);
|
||||
if (rustTextureId > 0) {
|
||||
// 缓存映射
|
||||
// Cache mapping
|
||||
this._textureIdMap.set(guid, rustTextureId);
|
||||
return rustTextureId;
|
||||
// 生成路径稳定 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:使用 TextureLoader 分配的 ID(兼容旧流程)
|
||||
// Fallback: Use TextureLoader allocated ID (for backward compatibility)
|
||||
const textureAsset = result.asset;
|
||||
if (this._engineBridge && textureAsset.data) {
|
||||
await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl);
|
||||
}
|
||||
|
||||
// 缓存映射 / Cache mapping
|
||||
this._textureIdMap.set(guid, textureAsset.textureId);
|
||||
this._textureIdMap.set(guid, stableId);
|
||||
this._pathToTextureId.set(assetPath, stableId);
|
||||
|
||||
return textureAsset.textureId;
|
||||
return stableId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,40 +640,36 @@ export class EngineIntegration {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all texture mappings
|
||||
* 清空所有纹理映射
|
||||
* Clear all texture mappings (for scene switching)
|
||||
* 清空所有纹理映射(用于场景切换)
|
||||
*
|
||||
* This clears both local texture ID mappings and the AssetManager's
|
||||
* texture cache to ensure textures are fully reloaded.
|
||||
* 这会清除本地纹理 ID 映射和 AssetManager 的纹理缓存,确保纹理完全重新加载。
|
||||
* 注意:使用路径稳定 ID 后,不应在 Play/Stop 循环中调用此方法。
|
||||
* 此方法仅用于场景切换时释放旧场景的纹理资源。
|
||||
*
|
||||
* IMPORTANT: This also clears the Rust engine's texture cache to ensure
|
||||
* both JS and Rust layers are in sync.
|
||||
* 重要:这也会清除 Rust 引擎的纹理缓存,确保 JS 和 Rust 层同步。
|
||||
* 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. 清除本地映射
|
||||
// Clear local mappings
|
||||
// 1. 清除加载状态映射(不清除 _pathIdCache)
|
||||
// Clear load state mappings (NOT clearing _pathIdCache)
|
||||
this._textureIdMap.clear();
|
||||
this._pathToTextureId.clear();
|
||||
|
||||
// 2. 清除 Rust 引擎的纹理缓存(如果可用)
|
||||
// Clear Rust engine's texture cache (if available)
|
||||
// 这确保下次加载时 Rust 会重新分配 ID
|
||||
// This ensures Rust will reallocate IDs on next load
|
||||
// 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
|
||||
// 强制清除以确保纹理使用新的 ID 重新加载
|
||||
// Force clear to ensure textures are reloaded with new IDs
|
||||
this._assetManager.unloadAssetsByType(AssetType.Texture, true);
|
||||
|
||||
// 4. 重置 TextureLoader 的 ID 计数器(保持向后兼容)
|
||||
// Reset TextureLoader's ID counter (for backward compatibility)
|
||||
TextureLoader.resetTextureIdCounter();
|
||||
// 注意:不再重置 TextureLoader 的 ID 计数器,因为现在使用路径稳定 ID
|
||||
// NOTE: No longer reset TextureLoader's ID counter as we now use path-stable IDs
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
|
||||
import type { IComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import { AudioSourceComponent } from './AudioSourceComponent';
|
||||
|
||||
class AudioRuntimeModule implements IRuntimeModule {
|
||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(AudioSourceComponent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import { AssetManagerToken } from '@esengine/asset-system';
|
||||
|
||||
@@ -17,7 +16,7 @@ export { BehaviorTreeSystemToken } from './tokens';
|
||||
class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
||||
private _loaderRegistered = false;
|
||||
|
||||
registerComponents(registry: typeof ComponentRegistry): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(BehaviorTreeRuntimeComponent);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
|
||||
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import { EngineBridgeToken } from '@esengine/engine-core';
|
||||
import { CameraComponent } from './CameraComponent';
|
||||
import { CameraSystem } from './CameraSystem';
|
||||
|
||||
class CameraRuntimeModule implements IRuntimeModule {
|
||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(CameraComponent);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Entity } from '../Entity';
|
||||
import { ComponentType, ComponentRegistry } from './ComponentStorage';
|
||||
import { ComponentType, GlobalComponentRegistry } from './ComponentStorage';
|
||||
import { BitMask64Data, BitMask64Utils } from '../Utils';
|
||||
import { BitMaskHashMap } from '../Utils/BitMaskHashMap';
|
||||
|
||||
@@ -271,7 +271,7 @@ export class ArchetypeSystem {
|
||||
private generateArchetypeId(componentTypes: ComponentType[]): ArchetypeId {
|
||||
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||
for (const type of componentTypes) {
|
||||
const bitMask = ComponentRegistry.getBitMask(type);
|
||||
const bitMask = GlobalComponentRegistry.getBitMask(type);
|
||||
BitMask64Utils.orInPlace(mask, bitMask);
|
||||
}
|
||||
return mask;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Entity } from '../Entity';
|
||||
import { Component } from '../Component';
|
||||
import { ComponentType, ComponentRegistry } from './ComponentStorage';
|
||||
import { ComponentType, GlobalComponentRegistry } from './ComponentStorage';
|
||||
import { IScene } from '../IScene';
|
||||
import { createLogger } from '../../Utils/Logger';
|
||||
|
||||
@@ -198,10 +198,10 @@ export class CommandBuffer {
|
||||
private getTypeId(componentOrType: Component | ComponentType): number {
|
||||
if (typeof componentOrType === 'function') {
|
||||
// ComponentType
|
||||
return ComponentRegistry.getBitIndex(componentOrType);
|
||||
return GlobalComponentRegistry.getBitIndex(componentOrType);
|
||||
} else {
|
||||
// Component instance
|
||||
return ComponentRegistry.getBitIndex(componentOrType.constructor as ComponentType);
|
||||
return GlobalComponentRegistry.getBitIndex(componentOrType.constructor as ComponentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +413,7 @@ export class CommandBuffer {
|
||||
if (ops.removes && ops.removes.size > 0) {
|
||||
for (const typeId of ops.removes) {
|
||||
try {
|
||||
const componentType = ComponentRegistry.getTypeByBitIndex(typeId);
|
||||
const componentType = GlobalComponentRegistry.getTypeByBitIndex(typeId);
|
||||
if (componentType) {
|
||||
entity.removeComponentByType(componentType);
|
||||
commandCount++;
|
||||
|
||||
@@ -3,10 +3,13 @@ import { BitMask64Utils, BitMask64Data } from '../Utils/BigIntCompatibility';
|
||||
import { SoAStorage, SupportedTypedArray } from './SoAStorage';
|
||||
import { createLogger } from '../../Utils/Logger';
|
||||
import { getComponentTypeName, ComponentType } from '../Decorators';
|
||||
import { ComponentRegistry } from './ComponentStorage/ComponentRegistry';
|
||||
import { ComponentRegistry, GlobalComponentRegistry } from './ComponentStorage/ComponentRegistry';
|
||||
import type { IComponentRegistry } from './ComponentStorage/IComponentRegistry';
|
||||
|
||||
// 导出核心类型
|
||||
export { ComponentRegistry };
|
||||
// Export core types
|
||||
export { ComponentRegistry, GlobalComponentRegistry };
|
||||
export type { IComponentRegistry };
|
||||
export type { ComponentType };
|
||||
|
||||
|
||||
@@ -333,15 +336,18 @@ export class ComponentStorageManager {
|
||||
|
||||
/**
|
||||
* 获取实体的组件位掩码
|
||||
* @param entityId 实体ID
|
||||
* @returns 组件位掩码
|
||||
* Get component bitmask for entity
|
||||
*
|
||||
* @param entityId 实体ID | Entity ID
|
||||
* @param registry 组件注册表(可选,默认使用全局注册表)| Component registry (optional, defaults to global)
|
||||
* @returns 组件位掩码 | Component bitmask
|
||||
*/
|
||||
public getComponentMask(entityId: number): BitMask64Data {
|
||||
public getComponentMask(entityId: number, registry: IComponentRegistry = GlobalComponentRegistry): BitMask64Data {
|
||||
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||
|
||||
for (const [componentType, storage] of this.storages.entries()) {
|
||||
if (storage.hasComponent(entityId)) {
|
||||
const componentMask = ComponentRegistry.getBitMask(componentType as ComponentType);
|
||||
const componentMask = registry.getBitMask(componentType as ComponentType);
|
||||
BitMask64Utils.orInPlace(mask, componentMask);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* Component Registry Implementation.
|
||||
* 组件注册表实现。
|
||||
*
|
||||
* Manages component type bitmask allocation.
|
||||
* Each Scene has its own registry instance for isolation.
|
||||
* 管理组件类型的位掩码分配。
|
||||
* 每个 Scene 都有自己的注册表实例以实现隔离。
|
||||
*/
|
||||
|
||||
import { Component } from '../../Component';
|
||||
import { BitMask64Utils, BitMask64Data } from '../../Utils/BigIntCompatibility';
|
||||
import { createLogger } from '../../../Utils/Logger';
|
||||
@@ -6,48 +16,43 @@ import {
|
||||
getComponentTypeName,
|
||||
hasECSComponentDecorator
|
||||
} from './ComponentTypeUtils';
|
||||
import type { IComponentRegistry } from './IComponentRegistry';
|
||||
|
||||
const logger = createLogger('ComponentRegistry');
|
||||
|
||||
/**
|
||||
* 组件注册表
|
||||
* 管理组件类型的位掩码分配
|
||||
* Component Registry.
|
||||
* 组件注册表。
|
||||
*
|
||||
* Instance-based registry for component type management.
|
||||
* Each Scene should have its own registry.
|
||||
* 基于实例的组件类型管理注册表。
|
||||
* 每个 Scene 应有自己的注册表。
|
||||
*/
|
||||
export class ComponentRegistry {
|
||||
protected static readonly _logger = createLogger('ComponentStorage');
|
||||
private static componentTypes = new Map<Function, number>();
|
||||
private static bitIndexToType = new Map<number, Function>();
|
||||
private static componentNameToType = new Map<string, Function>();
|
||||
private static componentNameToId = new Map<string, number>();
|
||||
private static maskCache = new Map<string, BitMask64Data>();
|
||||
private static nextBitIndex = 0;
|
||||
export class ComponentRegistry implements IComponentRegistry {
|
||||
private _componentTypes = new Map<Function, number>();
|
||||
private _bitIndexToType = new Map<number, Function>();
|
||||
private _componentNameToType = new Map<string, Function>();
|
||||
private _componentNameToId = new Map<string, number>();
|
||||
private _maskCache = new Map<string, BitMask64Data>();
|
||||
private _nextBitIndex = 0;
|
||||
private _hotReloadEnabled = false;
|
||||
private _warnedComponents = new Set<Function>();
|
||||
|
||||
/**
|
||||
* 热更新模式标志,默认禁用
|
||||
* Hot reload mode flag, disabled by default
|
||||
* 编辑器环境应启用此选项以支持脚本热更新
|
||||
* Editor environment should enable this to support script hot reload
|
||||
*/
|
||||
private static hotReloadEnabled = false;
|
||||
|
||||
/**
|
||||
* 已警告过的组件类型集合,避免重复警告
|
||||
* Set of warned component types to avoid duplicate warnings
|
||||
*/
|
||||
private static warnedComponents = new Set<Function>();
|
||||
|
||||
/**
|
||||
* 注册组件类型并分配位掩码
|
||||
* Register component type and allocate bitmask
|
||||
* Register component type and allocate bitmask.
|
||||
* 注册组件类型并分配位掩码。
|
||||
*
|
||||
* @param componentType 组件类型
|
||||
* @returns 分配的位索引
|
||||
* @param componentType - Component constructor | 组件构造函数
|
||||
* @returns Allocated bit index | 分配的位索引
|
||||
*/
|
||||
public static register<T extends Component>(componentType: ComponentType<T>): number {
|
||||
public register<T extends Component>(componentType: ComponentType<T>): number {
|
||||
const typeName = getComponentTypeName(componentType);
|
||||
|
||||
// 检查是否使用了 @ECSComponent 装饰器
|
||||
// Check if @ECSComponent decorator is used
|
||||
if (!hasECSComponentDecorator(componentType) && !this.warnedComponents.has(componentType)) {
|
||||
this.warnedComponents.add(componentType);
|
||||
// 检查是否使用了 @ECSComponent 装饰器
|
||||
if (!hasECSComponentDecorator(componentType) && !this._warnedComponents.has(componentType)) {
|
||||
this._warnedComponents.add(componentType);
|
||||
console.warn(
|
||||
`[ComponentRegistry] Component "${typeName}" is missing @ECSComponent decorator. ` +
|
||||
`This may cause issues with serialization and code minification. ` +
|
||||
@@ -55,51 +60,43 @@ export class ComponentRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.componentTypes.has(componentType)) {
|
||||
const existingIndex = this.componentTypes.get(componentType)!;
|
||||
return existingIndex;
|
||||
if (this._componentTypes.has(componentType)) {
|
||||
return this._componentTypes.get(componentType)!;
|
||||
}
|
||||
|
||||
// 检查是否有同名但不同类的组件已注册(热更新场景)
|
||||
// Check if a component with the same name but different class is registered (hot reload scenario)
|
||||
if (this.hotReloadEnabled && this.componentNameToType.has(typeName)) {
|
||||
const existingType = this.componentNameToType.get(typeName);
|
||||
// Hot reload: check if same-named component exists
|
||||
// 热更新:检查是否有同名组件
|
||||
if (this._hotReloadEnabled && this._componentNameToType.has(typeName)) {
|
||||
const existingType = this._componentNameToType.get(typeName);
|
||||
if (existingType !== componentType) {
|
||||
// 热更新:替换旧的类为新的类,复用相同的 bitIndex
|
||||
// Hot reload: replace old class with new class, reuse the same bitIndex
|
||||
const existingIndex = this.componentTypes.get(existingType!)!;
|
||||
// Reuse old bitIndex, replace class mapping
|
||||
// 复用旧的 bitIndex,替换类映射
|
||||
const existingIndex = this._componentTypes.get(existingType!)!;
|
||||
this._componentTypes.delete(existingType!);
|
||||
this._componentTypes.set(componentType, existingIndex);
|
||||
this._bitIndexToType.set(existingIndex, componentType);
|
||||
this._componentNameToType.set(typeName, componentType);
|
||||
|
||||
// 移除旧类的映射
|
||||
// Remove old class mapping
|
||||
this.componentTypes.delete(existingType!);
|
||||
|
||||
// 用新类更新映射
|
||||
// Update mappings with new class
|
||||
this.componentTypes.set(componentType, existingIndex);
|
||||
this.bitIndexToType.set(existingIndex, componentType);
|
||||
this.componentNameToType.set(typeName, componentType);
|
||||
|
||||
console.log(`[ComponentRegistry] Hot reload: replaced component "${typeName}"`);
|
||||
logger.debug(`Hot reload: replaced component "${typeName}"`);
|
||||
return existingIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const bitIndex = this.nextBitIndex++;
|
||||
this.componentTypes.set(componentType, bitIndex);
|
||||
this.bitIndexToType.set(bitIndex, componentType);
|
||||
this.componentNameToType.set(typeName, componentType);
|
||||
this.componentNameToId.set(typeName, bitIndex);
|
||||
const bitIndex = this._nextBitIndex++;
|
||||
this._componentTypes.set(componentType, bitIndex);
|
||||
this._bitIndexToType.set(bitIndex, componentType);
|
||||
this._componentNameToType.set(typeName, componentType);
|
||||
this._componentNameToId.set(typeName, bitIndex);
|
||||
|
||||
return bitIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取组件类型的位掩码
|
||||
* @param componentType 组件类型
|
||||
* @returns 位掩码
|
||||
* Get component type's bitmask.
|
||||
* 获取组件类型的位掩码。
|
||||
*/
|
||||
public static getBitMask<T extends Component>(componentType: ComponentType<T>): BitMask64Data {
|
||||
const bitIndex = this.componentTypes.get(componentType);
|
||||
public getBitMask<T extends Component>(componentType: ComponentType<T>): BitMask64Data {
|
||||
const bitIndex = this._componentTypes.get(componentType);
|
||||
if (bitIndex === undefined) {
|
||||
const typeName = getComponentTypeName(componentType);
|
||||
throw new Error(`Component type ${typeName} is not registered`);
|
||||
@@ -108,12 +105,11 @@ export class ComponentRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取组件类型的位索引
|
||||
* @param componentType 组件类型
|
||||
* @returns 位索引
|
||||
* Get component type's bit index.
|
||||
* 获取组件类型的位索引。
|
||||
*/
|
||||
public static getBitIndex<T extends Component>(componentType: ComponentType<T>): number {
|
||||
const bitIndex = this.componentTypes.get(componentType);
|
||||
public getBitIndex<T extends Component>(componentType: ComponentType<T>): number {
|
||||
const bitIndex = this._componentTypes.get(componentType);
|
||||
if (bitIndex === undefined) {
|
||||
const typeName = getComponentTypeName(componentType);
|
||||
throw new Error(`Component type ${typeName} is not registered`);
|
||||
@@ -122,90 +118,84 @@ export class ComponentRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查组件类型是否已注册
|
||||
* @param componentType 组件类型
|
||||
* @returns 是否已注册
|
||||
* Check if component type is registered.
|
||||
* 检查组件类型是否已注册。
|
||||
*/
|
||||
public static isRegistered<T extends Component>(componentType: ComponentType<T>): boolean {
|
||||
return this.componentTypes.has(componentType);
|
||||
public isRegistered<T extends Component>(componentType: ComponentType<T>): boolean {
|
||||
return this._componentTypes.has(componentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过位索引获取组件类型
|
||||
* @param bitIndex 位索引
|
||||
* @returns 组件类型构造函数或null
|
||||
* Get component type by bit index.
|
||||
* 通过位索引获取组件类型。
|
||||
*/
|
||||
public static getTypeByBitIndex(bitIndex: number): ComponentType | null {
|
||||
return (this.bitIndexToType.get(bitIndex) as ComponentType) || null;
|
||||
public getTypeByBitIndex(bitIndex: number): ComponentType | null {
|
||||
return (this._bitIndexToType.get(bitIndex) as ComponentType) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前已注册的组件类型数量
|
||||
* @returns 已注册数量
|
||||
* Get registered component count.
|
||||
* 获取已注册的组件数量。
|
||||
*/
|
||||
public static getRegisteredCount(): number {
|
||||
return this.nextBitIndex;
|
||||
public getRegisteredCount(): number {
|
||||
return this._nextBitIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过名称获取组件类型
|
||||
* @param componentName 组件名称
|
||||
* @returns 组件类型构造函数
|
||||
* Get component type by name.
|
||||
* 通过名称获取组件类型。
|
||||
*/
|
||||
public static getComponentType(componentName: string): Function | null {
|
||||
return this.componentNameToType.get(componentName) || null;
|
||||
public getComponentType(componentName: string): Function | null {
|
||||
return this._componentNameToType.get(componentName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的组件类型
|
||||
* @returns 组件类型映射
|
||||
* Get all registered component types.
|
||||
* 获取所有已注册的组件类型。
|
||||
*/
|
||||
public static getAllRegisteredTypes(): Map<Function, number> {
|
||||
return new Map(this.componentTypes);
|
||||
public getAllRegisteredTypes(): Map<Function, number> {
|
||||
return new Map(this._componentTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有组件名称到类型的映射
|
||||
* @returns 名称到类型的映射
|
||||
* Get all component names.
|
||||
* 获取所有组件名称。
|
||||
*/
|
||||
public static getAllComponentNames(): Map<string, Function> {
|
||||
return new Map(this.componentNameToType);
|
||||
public getAllComponentNames(): Map<string, Function> {
|
||||
return new Map(this._componentNameToType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过名称获取组件类型ID
|
||||
* @param componentName 组件名称
|
||||
* @returns 组件类型ID
|
||||
* Get component type ID by name.
|
||||
* 通过名称获取组件类型 ID。
|
||||
*/
|
||||
public static getComponentId(componentName: string): number | undefined {
|
||||
return this.componentNameToId.get(componentName);
|
||||
public getComponentId(componentName: string): number | undefined {
|
||||
return this._componentNameToId.get(componentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册组件类型(通过名称)
|
||||
* @param componentName 组件名称
|
||||
* @returns 分配的组件ID
|
||||
* Register component type by name.
|
||||
* 通过名称注册组件类型。
|
||||
*/
|
||||
public static registerComponentByName(componentName: string): number {
|
||||
if (this.componentNameToId.has(componentName)) {
|
||||
return this.componentNameToId.get(componentName)!;
|
||||
public registerComponentByName(componentName: string): number {
|
||||
if (this._componentNameToId.has(componentName)) {
|
||||
return this._componentNameToId.get(componentName)!;
|
||||
}
|
||||
|
||||
const bitIndex = this.nextBitIndex++;
|
||||
this.componentNameToId.set(componentName, bitIndex);
|
||||
const bitIndex = this._nextBitIndex++;
|
||||
this._componentNameToId.set(componentName, bitIndex);
|
||||
return bitIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个组件的掩码
|
||||
* @param componentName 组件名称
|
||||
* @returns 组件掩码
|
||||
* Create single component mask.
|
||||
* 创建单个组件的掩码。
|
||||
*/
|
||||
public static createSingleComponentMask(componentName: string): BitMask64Data {
|
||||
public createSingleComponentMask(componentName: string): BitMask64Data {
|
||||
const cacheKey = `single:${componentName}`;
|
||||
|
||||
if (this.maskCache.has(cacheKey)) {
|
||||
return this.maskCache.get(cacheKey)!;
|
||||
if (this._maskCache.has(cacheKey)) {
|
||||
return this._maskCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const componentId = this.getComponentId(componentName);
|
||||
@@ -214,21 +204,20 @@ export class ComponentRegistry {
|
||||
}
|
||||
|
||||
const mask = BitMask64Utils.create(componentId);
|
||||
this.maskCache.set(cacheKey, mask);
|
||||
this._maskCache.set(cacheKey, mask);
|
||||
return mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建多个组件的掩码
|
||||
* @param componentNames 组件名称数组
|
||||
* @returns 组合掩码
|
||||
* Create component mask for multiple components.
|
||||
* 创建多个组件的掩码。
|
||||
*/
|
||||
public static createComponentMask(componentNames: string[]): BitMask64Data {
|
||||
public createComponentMask(componentNames: string[]): BitMask64Data {
|
||||
const sortedNames = [...componentNames].sort();
|
||||
const cacheKey = `multi:${sortedNames.join(',')}`;
|
||||
|
||||
if (this.maskCache.has(cacheKey)) {
|
||||
return this.maskCache.get(cacheKey)!;
|
||||
if (this._maskCache.has(cacheKey)) {
|
||||
return this._maskCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||
@@ -240,90 +229,79 @@ export class ComponentRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
this.maskCache.set(cacheKey, mask);
|
||||
this._maskCache.set(cacheKey, mask);
|
||||
return mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除掩码缓存
|
||||
* Clear mask cache.
|
||||
* 清除掩码缓存。
|
||||
*/
|
||||
public static clearMaskCache(): void {
|
||||
this.maskCache.clear();
|
||||
public clearMaskCache(): void {
|
||||
this._maskCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用热更新模式
|
||||
* Enable hot reload mode
|
||||
* 在编辑器环境中调用以支持脚本热更新
|
||||
* Call in editor environment to support script hot reload
|
||||
* Enable hot reload mode.
|
||||
* 启用热更新模式。
|
||||
*/
|
||||
public static enableHotReload(): void {
|
||||
this.hotReloadEnabled = true;
|
||||
public enableHotReload(): void {
|
||||
this._hotReloadEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用热更新模式
|
||||
* Disable hot reload mode
|
||||
* Disable hot reload mode.
|
||||
* 禁用热更新模式。
|
||||
*/
|
||||
public static disableHotReload(): void {
|
||||
this.hotReloadEnabled = false;
|
||||
public disableHotReload(): void {
|
||||
this._hotReloadEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查热更新模式是否启用
|
||||
* Check if hot reload mode is enabled
|
||||
* Check if hot reload mode is enabled.
|
||||
* 检查热更新模式是否启用。
|
||||
*/
|
||||
public static isHotReloadEnabled(): boolean {
|
||||
return this.hotReloadEnabled;
|
||||
public isHotReloadEnabled(): boolean {
|
||||
return this._hotReloadEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销组件类型
|
||||
* Unregister component type
|
||||
*
|
||||
* 用于插件卸载时清理组件。
|
||||
* 注意:这不会释放 bitIndex,以避免索引冲突。
|
||||
*
|
||||
* Used for cleanup during plugin unload.
|
||||
* Note: This does not release bitIndex to avoid index conflicts.
|
||||
*
|
||||
* @param componentName 组件名称 | Component name
|
||||
* Unregister component type.
|
||||
* 注销组件类型。
|
||||
*/
|
||||
public static unregister(componentName: string): void {
|
||||
const componentType = this.componentNameToType.get(componentName);
|
||||
public unregister(componentName: string): void {
|
||||
const componentType = this._componentNameToType.get(componentName);
|
||||
if (!componentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bitIndex = this.componentTypes.get(componentType);
|
||||
const bitIndex = this._componentTypes.get(componentType);
|
||||
|
||||
// 移除类型映射
|
||||
// Remove type mappings
|
||||
this.componentTypes.delete(componentType);
|
||||
// 移除类型映射
|
||||
this._componentTypes.delete(componentType);
|
||||
if (bitIndex !== undefined) {
|
||||
this.bitIndexToType.delete(bitIndex);
|
||||
this._bitIndexToType.delete(bitIndex);
|
||||
}
|
||||
this.componentNameToType.delete(componentName);
|
||||
this.componentNameToId.delete(componentName);
|
||||
this._componentNameToType.delete(componentName);
|
||||
this._componentNameToId.delete(componentName);
|
||||
|
||||
// 清除相关的掩码缓存
|
||||
// Clear related mask cache
|
||||
// 清除相关的掩码缓存
|
||||
this.clearMaskCache();
|
||||
|
||||
this._logger.debug(`Component unregistered: ${componentName}`);
|
||||
logger.debug(`Component unregistered: ${componentName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的组件信息
|
||||
* Get all registered component info
|
||||
*
|
||||
* @returns 组件信息数组 | Array of component info
|
||||
* Get all registered component info.
|
||||
* 获取所有已注册的组件信息。
|
||||
*/
|
||||
public static getRegisteredComponents(): Array<{ name: string; type: Function; bitIndex: number }> {
|
||||
public getRegisteredComponents(): Array<{ name: string; type: Function; bitIndex: number }> {
|
||||
const result: Array<{ name: string; type: Function; bitIndex: number }> = [];
|
||||
|
||||
for (const [name, type] of this.componentNameToType) {
|
||||
const bitIndex = this.componentTypes.get(type);
|
||||
for (const [name, type] of this._componentNameToType) {
|
||||
const bitIndex = this._componentTypes.get(type);
|
||||
if (bitIndex !== undefined) {
|
||||
result.push({ name, type, bitIndex });
|
||||
}
|
||||
@@ -333,17 +311,48 @@ export class ComponentRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置注册表(用于测试)
|
||||
* Reset registry (for testing)
|
||||
* Reset registry.
|
||||
* 重置注册表。
|
||||
*/
|
||||
public static reset(): void {
|
||||
this.componentTypes.clear();
|
||||
this.bitIndexToType.clear();
|
||||
this.componentNameToType.clear();
|
||||
this.componentNameToId.clear();
|
||||
this.maskCache.clear();
|
||||
this.warnedComponents.clear();
|
||||
this.nextBitIndex = 0;
|
||||
this.hotReloadEnabled = false;
|
||||
public reset(): void {
|
||||
this._componentTypes.clear();
|
||||
this._bitIndexToType.clear();
|
||||
this._componentNameToType.clear();
|
||||
this._componentNameToId.clear();
|
||||
this._maskCache.clear();
|
||||
this._warnedComponents.clear();
|
||||
this._nextBitIndex = 0;
|
||||
this._hotReloadEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone component types from another registry.
|
||||
* 从另一个注册表克隆组件类型。
|
||||
*
|
||||
* Used to inherit framework components when creating a new Scene.
|
||||
* 用于在创建新 Scene 时继承框架组件。
|
||||
*/
|
||||
public cloneFrom(source: IComponentRegistry): void {
|
||||
const types = source.getAllRegisteredTypes();
|
||||
for (const [type, index] of types) {
|
||||
this._componentTypes.set(type, index);
|
||||
this._bitIndexToType.set(index, type);
|
||||
const typeName = getComponentTypeName(type as ComponentType);
|
||||
this._componentNameToType.set(typeName, type);
|
||||
this._componentNameToId.set(typeName, index);
|
||||
}
|
||||
this._nextBitIndex = source.getRegisteredCount();
|
||||
this._hotReloadEnabled = source.isHotReloadEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global Component Registry.
|
||||
* 全局组件注册表。
|
||||
*
|
||||
* Used by framework components and decorators.
|
||||
* Scene instances clone from this registry on creation.
|
||||
* 用于框架组件和装饰器。
|
||||
* Scene 实例在创建时从此注册表克隆。
|
||||
*/
|
||||
export const GlobalComponentRegistry = new ComponentRegistry();
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Component Registry Interface.
|
||||
* 组件注册表接口。
|
||||
*
|
||||
* Defines the contract for component type registration and lookup.
|
||||
* Each Scene has its own ComponentRegistry instance for isolation.
|
||||
* 定义组件类型注册和查找的契约。
|
||||
* 每个 Scene 都有自己的 ComponentRegistry 实例以实现隔离。
|
||||
*/
|
||||
|
||||
import type { Component } from '../../Component';
|
||||
import type { BitMask64Data } from '../../Utils/BigIntCompatibility';
|
||||
import type { ComponentType } from './ComponentTypeUtils';
|
||||
|
||||
/**
|
||||
* Component Registry Interface.
|
||||
* 组件注册表接口。
|
||||
*/
|
||||
export interface IComponentRegistry {
|
||||
/**
|
||||
* Register component type and allocate bitmask.
|
||||
* 注册组件类型并分配位掩码。
|
||||
*
|
||||
* @param componentType - Component constructor | 组件构造函数
|
||||
* @returns Allocated bit index | 分配的位索引
|
||||
*/
|
||||
register<T extends Component>(componentType: ComponentType<T>): number;
|
||||
|
||||
/**
|
||||
* Get component type's bitmask.
|
||||
* 获取组件类型的位掩码。
|
||||
*
|
||||
* @param componentType - Component constructor | 组件构造函数
|
||||
* @returns Bitmask | 位掩码
|
||||
*/
|
||||
getBitMask<T extends Component>(componentType: ComponentType<T>): BitMask64Data;
|
||||
|
||||
/**
|
||||
* Get component type's bit index.
|
||||
* 获取组件类型的位索引。
|
||||
*
|
||||
* @param componentType - Component constructor | 组件构造函数
|
||||
* @returns Bit index | 位索引
|
||||
*/
|
||||
getBitIndex<T extends Component>(componentType: ComponentType<T>): number;
|
||||
|
||||
/**
|
||||
* Check if component type is registered.
|
||||
* 检查组件类型是否已注册。
|
||||
*
|
||||
* @param componentType - Component constructor | 组件构造函数
|
||||
* @returns Whether registered | 是否已注册
|
||||
*/
|
||||
isRegistered<T extends Component>(componentType: ComponentType<T>): boolean;
|
||||
|
||||
/**
|
||||
* Get component type by bit index.
|
||||
* 通过位索引获取组件类型。
|
||||
*
|
||||
* @param bitIndex - Bit index | 位索引
|
||||
* @returns Component constructor or null | 组件构造函数或 null
|
||||
*/
|
||||
getTypeByBitIndex(bitIndex: number): ComponentType | null;
|
||||
|
||||
/**
|
||||
* Get component type by name.
|
||||
* 通过名称获取组件类型。
|
||||
*
|
||||
* @param componentName - Component name | 组件名称
|
||||
* @returns Component constructor or null | 组件构造函数或 null
|
||||
*/
|
||||
getComponentType(componentName: string): Function | null;
|
||||
|
||||
/**
|
||||
* Get component type ID by name.
|
||||
* 通过名称获取组件类型 ID。
|
||||
*
|
||||
* @param componentName - Component name | 组件名称
|
||||
* @returns Component type ID or undefined | 组件类型 ID 或 undefined
|
||||
*/
|
||||
getComponentId(componentName: string): number | undefined;
|
||||
|
||||
/**
|
||||
* Get all registered component types.
|
||||
* 获取所有已注册的组件类型。
|
||||
*
|
||||
* @returns Map of component type to bit index | 组件类型到位索引的映射
|
||||
*/
|
||||
getAllRegisteredTypes(): Map<Function, number>;
|
||||
|
||||
/**
|
||||
* Get all component names.
|
||||
* 获取所有组件名称。
|
||||
*
|
||||
* @returns Map of name to component type | 名称到组件类型的映射
|
||||
*/
|
||||
getAllComponentNames(): Map<string, Function>;
|
||||
|
||||
/**
|
||||
* Get registered component count.
|
||||
* 获取已注册的组件数量。
|
||||
*
|
||||
* @returns Count | 数量
|
||||
*/
|
||||
getRegisteredCount(): number;
|
||||
|
||||
/**
|
||||
* Register component type by name.
|
||||
* 通过名称注册组件类型。
|
||||
*
|
||||
* @param componentName - Component name | 组件名称
|
||||
* @returns Allocated component ID | 分配的组件 ID
|
||||
*/
|
||||
registerComponentByName(componentName: string): number;
|
||||
|
||||
/**
|
||||
* Create single component mask.
|
||||
* 创建单个组件的掩码。
|
||||
*
|
||||
* @param componentName - Component name | 组件名称
|
||||
* @returns Component mask | 组件掩码
|
||||
*/
|
||||
createSingleComponentMask(componentName: string): BitMask64Data;
|
||||
|
||||
/**
|
||||
* Create component mask for multiple components.
|
||||
* 创建多个组件的掩码。
|
||||
*
|
||||
* @param componentNames - Component names | 组件名称数组
|
||||
* @returns Combined mask | 组合掩码
|
||||
*/
|
||||
createComponentMask(componentNames: string[]): BitMask64Data;
|
||||
|
||||
/**
|
||||
* Unregister component type.
|
||||
* 注销组件类型。
|
||||
*
|
||||
* @param componentName - Component name | 组件名称
|
||||
*/
|
||||
unregister(componentName: string): void;
|
||||
|
||||
/**
|
||||
* Enable hot reload mode.
|
||||
* 启用热更新模式。
|
||||
*/
|
||||
enableHotReload(): void;
|
||||
|
||||
/**
|
||||
* Disable hot reload mode.
|
||||
* 禁用热更新模式。
|
||||
*/
|
||||
disableHotReload(): void;
|
||||
|
||||
/**
|
||||
* Check if hot reload mode is enabled.
|
||||
* 检查热更新模式是否启用。
|
||||
*
|
||||
* @returns Whether enabled | 是否启用
|
||||
*/
|
||||
isHotReloadEnabled(): boolean;
|
||||
|
||||
/**
|
||||
* Clear mask cache.
|
||||
* 清除掩码缓存。
|
||||
*/
|
||||
clearMaskCache(): void;
|
||||
|
||||
/**
|
||||
* Reset registry.
|
||||
* 重置注册表。
|
||||
*/
|
||||
reset(): void;
|
||||
|
||||
/**
|
||||
* Get all registered component info.
|
||||
* 获取所有已注册的组件信息。
|
||||
*
|
||||
* @returns Array of component info | 组件信息数组
|
||||
*/
|
||||
getRegisteredComponents(): Array<{ name: string; type: Function; bitIndex: number }>;
|
||||
|
||||
/**
|
||||
* Clone component types from another registry.
|
||||
* 从另一个注册表克隆组件类型。
|
||||
*
|
||||
* Used to inherit framework components when creating a new Scene.
|
||||
* 用于在创建新 Scene 时继承框架组件。
|
||||
*
|
||||
* @param source - Source registry | 源注册表
|
||||
*/
|
||||
cloneFrom(source: IComponentRegistry): void;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Entity } from '../Entity';
|
||||
import { Component } from '../Component';
|
||||
import { ComponentRegistry, ComponentType } from './ComponentStorage';
|
||||
import { GlobalComponentRegistry, ComponentType } from './ComponentStorage';
|
||||
import { BitMask64Utils, BitMask64Data } from '../Utils/BigIntCompatibility';
|
||||
import { createLogger } from '../../Utils/Logger';
|
||||
import { getComponentTypeName } from '../Decorators';
|
||||
@@ -932,7 +932,7 @@ export class QuerySystem {
|
||||
// 使用ComponentRegistry确保bitIndex一致
|
||||
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||
for (const type of componentTypes) {
|
||||
const bitMask = ComponentRegistry.getBitMask(type);
|
||||
const bitMask = GlobalComponentRegistry.getBitMask(type);
|
||||
BitMask64Utils.orInPlace(mask, bitMask);
|
||||
}
|
||||
|
||||
@@ -1341,7 +1341,7 @@ export class QueryBuilder {
|
||||
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||
for (const type of componentTypes) {
|
||||
try {
|
||||
const bitMask = ComponentRegistry.getBitMask(type);
|
||||
const bitMask = GlobalComponentRegistry.getBitMask(type);
|
||||
BitMask64Utils.orInPlace(mask, bitMask);
|
||||
} catch (error) {
|
||||
this._logger.warn(`组件类型 ${getComponentTypeName(type)} 未注册,跳过`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ComponentPool, ComponentPoolManager } from '../ComponentPool';
|
||||
export { ComponentStorage, ComponentRegistry } from '../ComponentStorage';
|
||||
export { ComponentStorage, ComponentRegistry, GlobalComponentRegistry } from '../ComponentStorage';
|
||||
export type { IComponentRegistry } from '../ComponentStorage';
|
||||
export { EnableSoA, Float64, Float32, Int32, SerializeMap, SoAStorage } from '../SoAStorage';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask';
|
||||
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'vector4' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask';
|
||||
|
||||
/**
|
||||
* 属性资源类型
|
||||
@@ -102,7 +102,7 @@ interface ColorPropertyOptions extends PropertyOptionsBase {
|
||||
* Vector property options
|
||||
*/
|
||||
interface VectorPropertyOptions extends PropertyOptionsBase {
|
||||
type: 'vector2' | 'vector3';
|
||||
type: 'vector2' | 'vector3' | 'vector4';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +139,7 @@ export type ArrayItemType =
|
||||
| { type: 'asset'; assetType?: PropertyAssetType; extensions?: string[] }
|
||||
| { type: 'vector2' }
|
||||
| { type: 'vector3' }
|
||||
| { type: 'vector4' }
|
||||
| { type: 'color'; alpha?: boolean }
|
||||
| { type: 'enum'; options: EnumOption[] };
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import type { Component } from '../Component';
|
||||
import type { EntitySystem } from '../Systems';
|
||||
import { ComponentRegistry } from '../Core/ComponentStorage/ComponentRegistry';
|
||||
import { GlobalComponentRegistry } from '../Core/ComponentStorage/ComponentRegistry';
|
||||
import {
|
||||
COMPONENT_TYPE_NAME,
|
||||
COMPONENT_DEPENDENCIES,
|
||||
@@ -88,9 +88,9 @@ export function ECSComponent(typeName: string, options?: ComponentOptions) {
|
||||
(target as any)[COMPONENT_EDITOR_OPTIONS] = options.editor;
|
||||
}
|
||||
|
||||
// 自动注册到 ComponentRegistry,使组件可以通过名称查找
|
||||
// Auto-register to ComponentRegistry, enabling lookup by name
|
||||
ComponentRegistry.register(target);
|
||||
// 自动注册到全局 ComponentRegistry,使组件可以通过名称查找
|
||||
// Auto-register to GlobalComponentRegistry, enabling lookup by name
|
||||
GlobalComponentRegistry.register(target);
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from './Component';
|
||||
import { ComponentRegistry, ComponentType } from './Core/ComponentStorage';
|
||||
import { ComponentType, GlobalComponentRegistry } from './Core/ComponentStorage';
|
||||
import { EEntityLifecyclePolicy } from './Core/EntityLifecyclePolicy';
|
||||
import { BitMask64Utils, BitMask64Data } from './Utils/BigIntCompatibility';
|
||||
import { createLogger } from '../Utils/Logger';
|
||||
@@ -293,11 +293,12 @@ export class Entity {
|
||||
}
|
||||
|
||||
const mask = this._componentMask;
|
||||
const maxBitIndex = ComponentRegistry.getRegisteredCount();
|
||||
const registry = this.scene.componentRegistry;
|
||||
const maxBitIndex = registry.getRegisteredCount();
|
||||
|
||||
for (let bitIndex = 0; bitIndex < maxBitIndex; bitIndex++) {
|
||||
if (BitMask64Utils.getBit(mask, bitIndex)) {
|
||||
const componentType = ComponentRegistry.getTypeByBitIndex(bitIndex);
|
||||
const componentType = registry.getTypeByBitIndex(bitIndex);
|
||||
if (componentType) {
|
||||
const component = this.scene.componentStorageManager.getComponent(this.id, componentType);
|
||||
|
||||
@@ -428,7 +429,8 @@ export class Entity {
|
||||
|
||||
// 更新位掩码(组件已通过 @ECSComponent 装饰器自动注册)
|
||||
// Update bitmask (component already registered via @ECSComponent decorator)
|
||||
const componentMask = ComponentRegistry.getBitMask(componentType);
|
||||
const registry = this.scene?.componentRegistry ?? GlobalComponentRegistry;
|
||||
const componentMask = registry.getBitMask(componentType);
|
||||
BitMask64Utils.orInPlace(this._componentMask, componentMask);
|
||||
|
||||
// 使缓存失效
|
||||
@@ -565,11 +567,12 @@ export class Entity {
|
||||
* ```
|
||||
*/
|
||||
public hasComponent<T extends Component>(type: ComponentType<T>): boolean {
|
||||
if (!ComponentRegistry.isRegistered(type)) {
|
||||
const registry = this.scene?.componentRegistry ?? GlobalComponentRegistry;
|
||||
if (!registry.isRegistered(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mask = ComponentRegistry.getBitMask(type);
|
||||
const mask = registry.getBitMask(type);
|
||||
return BitMask64Utils.hasAny(this._componentMask, mask);
|
||||
}
|
||||
|
||||
@@ -641,12 +644,13 @@ export class Entity {
|
||||
*/
|
||||
public removeComponent(component: Component): void {
|
||||
const componentType = component.constructor as ComponentType;
|
||||
const registry = this.scene?.componentRegistry ?? GlobalComponentRegistry;
|
||||
|
||||
if (!ComponentRegistry.isRegistered(componentType)) {
|
||||
if (!registry.isRegistered(componentType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bitIndex = ComponentRegistry.getBitIndex(componentType);
|
||||
const bitIndex = registry.getBitIndex(componentType);
|
||||
|
||||
// 更新位掩码
|
||||
BitMask64Utils.clearBit(this._componentMask, bitIndex);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EntityList } from './Utils/EntityList';
|
||||
import { IdentifierPool } from './Utils/IdentifierPool';
|
||||
import { EntitySystem } from './Systems/EntitySystem';
|
||||
import { ComponentStorageManager, ComponentType } from './Core/ComponentStorage';
|
||||
import type { IComponentRegistry } from './Core/ComponentStorage';
|
||||
import { QuerySystem } from './Core/QuerySystem';
|
||||
import { TypeSafeEventSystem } from './Core/EventSystem';
|
||||
import { EpochManager } from './Core/EpochManager';
|
||||
@@ -57,6 +58,17 @@ export interface IScene {
|
||||
*/
|
||||
readonly componentStorageManager: ComponentStorageManager;
|
||||
|
||||
/**
|
||||
* 组件注册表
|
||||
* Component Registry
|
||||
*
|
||||
* Each scene has its own registry for component type isolation.
|
||||
* Clones from GlobalComponentRegistry on creation.
|
||||
* 每个场景有自己的组件类型注册表以实现隔离。
|
||||
* 创建时从 GlobalComponentRegistry 克隆。
|
||||
*/
|
||||
readonly componentRegistry: IComponentRegistry;
|
||||
|
||||
/**
|
||||
* 查询系统
|
||||
*/
|
||||
@@ -359,10 +371,20 @@ export interface ISceneFactory<T extends IScene> {
|
||||
|
||||
/**
|
||||
* 场景配置接口
|
||||
* Scene configuration interface
|
||||
*/
|
||||
export interface ISceneConfig {
|
||||
/**
|
||||
* 场景名称
|
||||
* Scene name
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* 是否从全局注册表继承组件类型
|
||||
* Whether to inherit component types from global registry
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
inheritGlobalRegistry?: boolean;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ import { Entity } from './Entity';
|
||||
import { EntityList } from './Utils/EntityList';
|
||||
import { IdentifierPool } from './Utils/IdentifierPool';
|
||||
import { EntitySystem } from './Systems/EntitySystem';
|
||||
import { ComponentStorageManager, ComponentRegistry, ComponentType } from './Core/ComponentStorage';
|
||||
import {
|
||||
ComponentStorageManager,
|
||||
ComponentRegistry,
|
||||
GlobalComponentRegistry,
|
||||
ComponentType
|
||||
} from './Core/ComponentStorage';
|
||||
import type { IComponentRegistry } from './Core/ComponentStorage';
|
||||
import { QuerySystem } from './Core/QuerySystem';
|
||||
import { TypeSafeEventSystem } from './Core/EventSystem';
|
||||
import { ReferenceTracker } from './Core/ReferenceTracker';
|
||||
@@ -75,6 +81,15 @@ export class Scene implements IScene {
|
||||
*/
|
||||
public readonly componentStorageManager: ComponentStorageManager;
|
||||
|
||||
/**
|
||||
* 组件注册表
|
||||
* Component Registry
|
||||
*
|
||||
* Each scene has its own registry for component type isolation.
|
||||
* 每个场景有自己的组件类型注册表以实现隔离。
|
||||
*/
|
||||
public readonly componentRegistry: IComponentRegistry;
|
||||
|
||||
/**
|
||||
* 查询系统
|
||||
*
|
||||
@@ -364,11 +379,23 @@ export class Scene implements IScene {
|
||||
|
||||
/**
|
||||
* 创建场景实例
|
||||
* Create scene instance
|
||||
*/
|
||||
constructor(config?: ISceneConfig) {
|
||||
this.entities = new EntityList(this);
|
||||
this.identifierPool = new IdentifierPool();
|
||||
this.componentStorageManager = new ComponentStorageManager();
|
||||
|
||||
// 创建场景级别的组件注册表
|
||||
// Create scene-level component registry
|
||||
this.componentRegistry = new ComponentRegistry();
|
||||
|
||||
// 从全局注册表继承框架组件(默认启用)
|
||||
// Inherit framework components from global registry (enabled by default)
|
||||
if (config?.inheritGlobalRegistry !== false) {
|
||||
this.componentRegistry.cloneFrom(GlobalComponentRegistry);
|
||||
}
|
||||
|
||||
this.querySystem = new QuerySystem();
|
||||
this.eventSystem = new TypeSafeEventSystem();
|
||||
this.referenceTracker = new ReferenceTracker();
|
||||
@@ -671,8 +698,8 @@ export class Scene implements IScene {
|
||||
const notifiedSystems = new Set<EntitySystem>();
|
||||
|
||||
// 如果提供了组件类型,使用索引优化 | If component type provided, use index optimization
|
||||
if (changedComponentType && ComponentRegistry.isRegistered(changedComponentType)) {
|
||||
const componentId = ComponentRegistry.getBitIndex(changedComponentType);
|
||||
if (changedComponentType && this.componentRegistry.isRegistered(changedComponentType)) {
|
||||
const componentId = this.componentRegistry.getBitIndex(changedComponentType);
|
||||
const interestedSystems = this._componentIdToSystems.get(componentId);
|
||||
|
||||
if (interestedSystems) {
|
||||
@@ -760,7 +787,7 @@ export class Scene implements IScene {
|
||||
* @param system 系统 | System
|
||||
*/
|
||||
private addSystemToComponentIndex(componentType: ComponentType, system: EntitySystem): void {
|
||||
const componentId = ComponentRegistry.getBitIndex(componentType);
|
||||
const componentId = this.componentRegistry.getBitIndex(componentType);
|
||||
let systems = this._componentIdToSystems.get(componentId);
|
||||
|
||||
if (!systems) {
|
||||
@@ -1506,7 +1533,7 @@ export class Scene implements IScene {
|
||||
? IncrementalSerializer.deserializeIncremental(incremental as string | Uint8Array)
|
||||
: (incremental as IncrementalSnapshot);
|
||||
|
||||
const registry = componentRegistry || (ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>);
|
||||
const registry = componentRegistry || (this.componentRegistry.getAllComponentNames() as Map<string, ComponentType>);
|
||||
|
||||
IncrementalSerializer.applyIncremental(this, snapshot, registry);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { IScene } from '../IScene';
|
||||
import { Entity } from '../Entity';
|
||||
import { ComponentType, ComponentRegistry } from '../Core/ComponentStorage';
|
||||
import { ComponentType, GlobalComponentRegistry } from '../Core/ComponentStorage';
|
||||
import { EntitySerializer, SerializedEntity } from './EntitySerializer';
|
||||
import { getComponentTypeName } from '../Decorators';
|
||||
import { getSerializationMetadata } from './SerializationDecorators';
|
||||
@@ -565,7 +565,7 @@ export class SceneSerializer {
|
||||
* 从所有已注册的组件类型构建注册表
|
||||
*/
|
||||
private static getGlobalComponentRegistry(): Map<string, ComponentType> {
|
||||
return ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
return GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Entity } from '../Entity';
|
||||
import { ComponentType, ComponentRegistry } from '../Core/ComponentStorage';
|
||||
import { ComponentType, GlobalComponentRegistry } from '../Core/ComponentStorage';
|
||||
import { BitMask64Utils, BitMask64Data } from './BigIntCompatibility';
|
||||
import { SparseSet } from './SparseSet';
|
||||
import { Pool } from '../../Utils/Pool/Pool';
|
||||
@@ -86,7 +86,7 @@ export class ComponentSparseSet {
|
||||
entityComponents.add(componentType);
|
||||
|
||||
// 获取组件位掩码并合并
|
||||
const bitMask = ComponentRegistry.getBitMask(componentType);
|
||||
const bitMask = GlobalComponentRegistry.getBitMask(componentType);
|
||||
BitMask64Utils.orInPlace(componentMask, bitMask);
|
||||
}
|
||||
|
||||
@@ -166,10 +166,10 @@ export class ComponentSparseSet {
|
||||
// 构建目标位掩码
|
||||
const targetMask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||
for (const componentType of componentTypes) {
|
||||
if (!ComponentRegistry.isRegistered(componentType)) {
|
||||
if (!GlobalComponentRegistry.isRegistered(componentType)) {
|
||||
return new Set<Entity>(); // 未注册的组件类型,结果为空
|
||||
}
|
||||
const bitMask = ComponentRegistry.getBitMask(componentType);
|
||||
const bitMask = GlobalComponentRegistry.getBitMask(componentType);
|
||||
BitMask64Utils.orInPlace(targetMask, bitMask);
|
||||
}
|
||||
|
||||
@@ -206,8 +206,8 @@ export class ComponentSparseSet {
|
||||
// 构建目标位掩码
|
||||
const targetMask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||
for (const componentType of componentTypes) {
|
||||
if (ComponentRegistry.isRegistered(componentType)) {
|
||||
const bitMask = ComponentRegistry.getBitMask(componentType);
|
||||
if (GlobalComponentRegistry.isRegistered(componentType)) {
|
||||
const bitMask = GlobalComponentRegistry.getBitMask(componentType);
|
||||
BitMask64Utils.orInPlace(targetMask, bitMask);
|
||||
}
|
||||
}
|
||||
@@ -242,12 +242,12 @@ export class ComponentSparseSet {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ComponentRegistry.isRegistered(componentType)) {
|
||||
if (!GlobalComponentRegistry.isRegistered(componentType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entityMask = this._componentMasks[entityIndex]!;
|
||||
const componentMask = ComponentRegistry.getBitMask(componentType);
|
||||
const componentMask = GlobalComponentRegistry.getBitMask(componentType);
|
||||
|
||||
return BitMask64Utils.hasAny(entityMask, componentMask);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { ComponentRegistry } from '../../../src/ECS/Core/ComponentStorage/ComponentRegistry';
|
||||
import { GlobalComponentRegistry } from '../../../src/ECS/Core/ComponentStorage/ComponentRegistry';
|
||||
import { Entity } from '../../../src/ECS/Entity';
|
||||
import { Scene } from '../../../src/ECS/Scene';
|
||||
|
||||
describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
// 组件类缓存
|
||||
// 组件类缓存 | Component class cache
|
||||
const componentClassCache = new Map<number, any>();
|
||||
|
||||
beforeEach(() => {
|
||||
ComponentRegistry.reset();
|
||||
GlobalComponentRegistry.reset();
|
||||
componentClassCache.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ComponentRegistry.reset();
|
||||
GlobalComponentRegistry.reset();
|
||||
componentClassCache.clear();
|
||||
});
|
||||
|
||||
@@ -39,11 +39,11 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
// 注册 100 个组件类型
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
const bitIndex = ComponentRegistry.register(ComponentClass);
|
||||
const bitIndex = GlobalComponentRegistry.register(ComponentClass);
|
||||
componentTypes.push(ComponentClass);
|
||||
|
||||
expect(bitIndex).toBe(i);
|
||||
expect(ComponentRegistry.isRegistered(ComponentClass)).toBe(true);
|
||||
expect(GlobalComponentRegistry.isRegistered(ComponentClass)).toBe(true);
|
||||
}
|
||||
|
||||
expect(componentTypes.length).toBe(100);
|
||||
@@ -53,14 +53,14 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
// 注册 80 个组件
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
ComponentRegistry.register(ComponentClass);
|
||||
GlobalComponentRegistry.register(ComponentClass);
|
||||
}
|
||||
|
||||
// 验证第 70 个组件的位掩码
|
||||
const Component70 = createTestComponent(70);
|
||||
ComponentRegistry.register(Component70);
|
||||
GlobalComponentRegistry.register(Component70);
|
||||
|
||||
const bitMask = ComponentRegistry.getBitMask(Component70);
|
||||
const bitMask = GlobalComponentRegistry.getBitMask(Component70);
|
||||
expect(bitMask).toBeDefined();
|
||||
expect(bitMask.segments).toBeDefined(); // 应该有扩展段
|
||||
expect(bitMask.segments!.length).toBeGreaterThan(0);
|
||||
@@ -70,11 +70,11 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
// 注册 1500 个组件验证无限制
|
||||
for (let i = 0; i < 1500; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
const bitIndex = ComponentRegistry.register(ComponentClass);
|
||||
const bitIndex = GlobalComponentRegistry.register(ComponentClass);
|
||||
expect(bitIndex).toBe(i);
|
||||
}
|
||||
|
||||
expect(ComponentRegistry.getRegisteredCount()).toBe(1500);
|
||||
expect(GlobalComponentRegistry.getRegisteredCount()).toBe(1500);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -92,10 +92,13 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
const componentTypes: any[] = [];
|
||||
const components: any[] = [];
|
||||
|
||||
// 添加 80 个组件
|
||||
// 添加 80 个组件 | Add 80 components
|
||||
// 需要同时注册到 GlobalComponentRegistry(ArchetypeSystem 使用)和 Scene registry
|
||||
// Need to register to both GlobalComponentRegistry (used by ArchetypeSystem) and Scene registry
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
ComponentRegistry.register(ComponentClass);
|
||||
GlobalComponentRegistry.register(ComponentClass);
|
||||
scene.componentRegistry.register(ComponentClass);
|
||||
componentTypes.push(ComponentClass);
|
||||
|
||||
const component = new ComponentClass();
|
||||
@@ -115,32 +118,35 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
});
|
||||
|
||||
it('应该能够正确检查超过 64 个组件的存在性', () => {
|
||||
// 添加组件 0-79
|
||||
// 添加组件 0-79 | Add components 0-79
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
ComponentRegistry.register(ComponentClass);
|
||||
GlobalComponentRegistry.register(ComponentClass);
|
||||
scene.componentRegistry.register(ComponentClass);
|
||||
entity.addComponent(new ComponentClass());
|
||||
}
|
||||
|
||||
// 验证 hasComponent 对所有组件都工作
|
||||
// 验证 hasComponent 对所有组件都工作 | Verify hasComponent works for all
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
expect(entity.hasComponent(ComponentClass)).toBe(true);
|
||||
}
|
||||
|
||||
// 验证不存在的组件
|
||||
// 验证不存在的组件 | Verify non-existent component
|
||||
const NonExistentComponent = createTestComponent(999);
|
||||
ComponentRegistry.register(NonExistentComponent);
|
||||
GlobalComponentRegistry.register(NonExistentComponent);
|
||||
scene.componentRegistry.register(NonExistentComponent);
|
||||
expect(entity.hasComponent(NonExistentComponent)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该能够移除超过 64 索引的组件', () => {
|
||||
const componentTypes: any[] = [];
|
||||
|
||||
// 添加 80 个组件
|
||||
// 添加 80 个组件 | Add 80 components
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
ComponentRegistry.register(ComponentClass);
|
||||
GlobalComponentRegistry.register(ComponentClass);
|
||||
scene.componentRegistry.register(ComponentClass);
|
||||
componentTypes.push(ComponentClass);
|
||||
entity.addComponent(new ComponentClass());
|
||||
}
|
||||
@@ -162,10 +168,11 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
});
|
||||
|
||||
it('应该能够正确遍历超过 64 个组件', () => {
|
||||
// 添加 80 个组件
|
||||
// 添加 80 个组件 | Add 80 components
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
ComponentRegistry.register(ComponentClass);
|
||||
GlobalComponentRegistry.register(ComponentClass);
|
||||
scene.componentRegistry.register(ComponentClass);
|
||||
entity.addComponent(new ComponentClass());
|
||||
}
|
||||
|
||||
@@ -182,29 +189,29 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
|
||||
describe('热更新模式', () => {
|
||||
it('默认应该禁用热更新模式', () => {
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('应该能够启用和禁用热更新模式', () => {
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
|
||||
ComponentRegistry.enableHotReload();
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(true);
|
||||
GlobalComponentRegistry.enableHotReload();
|
||||
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(true);
|
||||
|
||||
ComponentRegistry.disableHotReload();
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
GlobalComponentRegistry.disableHotReload();
|
||||
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('reset 应该重置热更新模式为禁用', () => {
|
||||
ComponentRegistry.enableHotReload();
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(true);
|
||||
GlobalComponentRegistry.enableHotReload();
|
||||
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(true);
|
||||
|
||||
ComponentRegistry.reset();
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
GlobalComponentRegistry.reset();
|
||||
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('启用热更新时应该替换同名组件类', () => {
|
||||
ComponentRegistry.enableHotReload();
|
||||
GlobalComponentRegistry.enableHotReload();
|
||||
|
||||
// 模拟热更新场景:两个不同的类但有相同的 constructor.name
|
||||
// Simulate hot reload: two different classes with same constructor.name
|
||||
@@ -229,20 +236,20 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
expect(TestComponentV1.name).toBe(TestComponentV2.name);
|
||||
expect(TestComponentV1).not.toBe(TestComponentV2);
|
||||
|
||||
const index1 = ComponentRegistry.register(TestComponentV1);
|
||||
const index2 = ComponentRegistry.register(TestComponentV2);
|
||||
const index1 = GlobalComponentRegistry.register(TestComponentV1);
|
||||
const index2 = GlobalComponentRegistry.register(TestComponentV2);
|
||||
|
||||
// 应该复用相同的 bitIndex
|
||||
expect(index1).toBe(index2);
|
||||
|
||||
// 新类应该替换旧类
|
||||
expect(ComponentRegistry.isRegistered(TestComponentV2)).toBe(true);
|
||||
expect(ComponentRegistry.isRegistered(TestComponentV1)).toBe(false);
|
||||
expect(GlobalComponentRegistry.isRegistered(TestComponentV2)).toBe(true);
|
||||
expect(GlobalComponentRegistry.isRegistered(TestComponentV1)).toBe(false);
|
||||
});
|
||||
|
||||
it('禁用热更新时不应该替换同名组件类', () => {
|
||||
// 确保热更新被禁用
|
||||
ComponentRegistry.disableHotReload();
|
||||
GlobalComponentRegistry.disableHotReload();
|
||||
|
||||
// 创建两个同名组件
|
||||
// Create two classes with same constructor.name
|
||||
@@ -265,15 +272,15 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
expect(TestCompA.name).toBe(TestCompB.name);
|
||||
expect(TestCompA).not.toBe(TestCompB);
|
||||
|
||||
const index1 = ComponentRegistry.register(TestCompA);
|
||||
const index2 = ComponentRegistry.register(TestCompB);
|
||||
const index1 = GlobalComponentRegistry.register(TestCompA);
|
||||
const index2 = GlobalComponentRegistry.register(TestCompB);
|
||||
|
||||
// 应该分配不同的 bitIndex(因为热更新被禁用)
|
||||
expect(index2).toBe(index1 + 1);
|
||||
|
||||
// 两个类都应该被注册
|
||||
expect(ComponentRegistry.isRegistered(TestCompA)).toBe(true);
|
||||
expect(ComponentRegistry.isRegistered(TestCompB)).toBe(true);
|
||||
expect(GlobalComponentRegistry.isRegistered(TestCompA)).toBe(true);
|
||||
expect(GlobalComponentRegistry.isRegistered(TestCompB)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,14 +289,15 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
const scene = new Scene();
|
||||
const entity = scene.createEntity('TestEntity');
|
||||
|
||||
// 注册 65 个组件(跨越 64 位边界)
|
||||
// 注册 65 个组件(跨越 64 位边界)| Register 65 components (crossing 64-bit boundary)
|
||||
for (let i = 0; i < 65; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
ComponentRegistry.register(ComponentClass);
|
||||
GlobalComponentRegistry.register(ComponentClass);
|
||||
scene.componentRegistry.register(ComponentClass);
|
||||
entity.addComponent(new ComponentClass());
|
||||
}
|
||||
|
||||
// 验证第 63, 64, 65 个组件
|
||||
// 验证第 63, 64, 65 个组件 | Verify components 63, 64
|
||||
const Component63 = createTestComponent(63);
|
||||
const Component64 = createTestComponent(64);
|
||||
|
||||
@@ -301,25 +309,27 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
const scene = new Scene();
|
||||
const entity = scene.createEntity('TestEntity');
|
||||
|
||||
// 添加 80 个组件
|
||||
// 添加 80 个组件 | Add 80 components
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
ComponentRegistry.register(ComponentClass);
|
||||
GlobalComponentRegistry.register(ComponentClass);
|
||||
scene.componentRegistry.register(ComponentClass);
|
||||
entity.addComponent(new ComponentClass());
|
||||
}
|
||||
|
||||
// 强制重建缓存(通过访问 components)
|
||||
// 强制重建缓存(通过访问 components)| Force cache rebuild
|
||||
const components1 = entity.components;
|
||||
expect(components1.length).toBe(80);
|
||||
|
||||
// 添加更多组件
|
||||
// 添加更多组件 | Add more components
|
||||
for (let i = 80; i < 90; i++) {
|
||||
const ComponentClass = createTestComponent(i);
|
||||
ComponentRegistry.register(ComponentClass);
|
||||
GlobalComponentRegistry.register(ComponentClass);
|
||||
scene.componentRegistry.register(ComponentClass);
|
||||
entity.addComponent(new ComponentClass());
|
||||
}
|
||||
|
||||
// 重新获取组件数组(应该重建缓存)
|
||||
// 重新获取组件数组(应该重建缓存)| Re-get component array (should rebuild cache)
|
||||
const components2 = entity.components;
|
||||
expect(components2.length).toBe(90);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ComponentRegistry, ComponentStorage, ComponentStorageManager } from '../../../src/ECS/Core/ComponentStorage';
|
||||
import { ComponentRegistry, GlobalComponentRegistry, ComponentStorage, ComponentStorageManager } from '../../../src/ECS/Core/ComponentStorage';
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { BitMask64Utils } from '../../../src/ECS/Utils/BigIntCompatibility';
|
||||
|
||||
// 为测试创建独立的注册表实例 | Create isolated registry instance for tests
|
||||
let testRegistry: ComponentRegistry;
|
||||
|
||||
// 测试组件类(默认使用原始存储)
|
||||
class TestComponent extends Component {
|
||||
public value: number;
|
||||
@@ -51,89 +54,88 @@ class HealthComponent extends Component {
|
||||
|
||||
describe('ComponentRegistry - 组件注册表测试', () => {
|
||||
beforeEach(() => {
|
||||
// 重置注册表状态
|
||||
(ComponentRegistry as any).componentTypes = new Map<Function, number>();
|
||||
(ComponentRegistry as any).nextBitIndex = 0;
|
||||
// 每个测试创建新的注册表实例 | Create new registry instance for each test
|
||||
testRegistry = new ComponentRegistry();
|
||||
});
|
||||
|
||||
describe('组件注册功能', () => {
|
||||
test('应该能够注册组件类型', () => {
|
||||
const bitIndex = ComponentRegistry.register(TestComponent);
|
||||
|
||||
const bitIndex = testRegistry.register(TestComponent);
|
||||
|
||||
expect(bitIndex).toBe(0);
|
||||
expect(ComponentRegistry.isRegistered(TestComponent)).toBe(true);
|
||||
expect(testRegistry.isRegistered(TestComponent)).toBe(true);
|
||||
});
|
||||
|
||||
test('重复注册相同组件应该返回相同的位索引', () => {
|
||||
const bitIndex1 = ComponentRegistry.register(TestComponent);
|
||||
const bitIndex2 = ComponentRegistry.register(TestComponent);
|
||||
|
||||
const bitIndex1 = testRegistry.register(TestComponent);
|
||||
const bitIndex2 = testRegistry.register(TestComponent);
|
||||
|
||||
expect(bitIndex1).toBe(bitIndex2);
|
||||
expect(bitIndex1).toBe(0);
|
||||
});
|
||||
|
||||
test('应该能够注册多个组件类型', () => {
|
||||
const bitIndex1 = ComponentRegistry.register(TestComponent);
|
||||
const bitIndex2 = ComponentRegistry.register(PositionComponent);
|
||||
const bitIndex3 = ComponentRegistry.register(VelocityComponent);
|
||||
|
||||
const bitIndex1 = testRegistry.register(TestComponent);
|
||||
const bitIndex2 = testRegistry.register(PositionComponent);
|
||||
const bitIndex3 = testRegistry.register(VelocityComponent);
|
||||
|
||||
expect(bitIndex1).toBe(0);
|
||||
expect(bitIndex2).toBe(1);
|
||||
expect(bitIndex3).toBe(2);
|
||||
});
|
||||
|
||||
test('应该能够检查组件是否已注册', () => {
|
||||
expect(ComponentRegistry.isRegistered(TestComponent)).toBe(false);
|
||||
|
||||
ComponentRegistry.register(TestComponent);
|
||||
expect(ComponentRegistry.isRegistered(TestComponent)).toBe(true);
|
||||
expect(testRegistry.isRegistered(TestComponent)).toBe(false);
|
||||
|
||||
testRegistry.register(TestComponent);
|
||||
expect(testRegistry.isRegistered(TestComponent)).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('位掩码功能', () => {
|
||||
test('应该能够获取组件的位掩码', () => {
|
||||
ComponentRegistry.register(TestComponent);
|
||||
ComponentRegistry.register(PositionComponent);
|
||||
|
||||
const mask1 = ComponentRegistry.getBitMask(TestComponent);
|
||||
const mask2 = ComponentRegistry.getBitMask(PositionComponent);
|
||||
|
||||
testRegistry.register(TestComponent);
|
||||
testRegistry.register(PositionComponent);
|
||||
|
||||
const mask1 = testRegistry.getBitMask(TestComponent);
|
||||
const mask2 = testRegistry.getBitMask(PositionComponent);
|
||||
|
||||
expect(BitMask64Utils.getBit(mask1,0)).toBe(true); // 2^0
|
||||
expect(BitMask64Utils.getBit(mask2,1)).toBe(true); // 2^1
|
||||
});
|
||||
|
||||
test('应该能够获取组件的位索引', () => {
|
||||
ComponentRegistry.register(TestComponent);
|
||||
ComponentRegistry.register(PositionComponent);
|
||||
|
||||
const index1 = ComponentRegistry.getBitIndex(TestComponent);
|
||||
const index2 = ComponentRegistry.getBitIndex(PositionComponent);
|
||||
|
||||
testRegistry.register(TestComponent);
|
||||
testRegistry.register(PositionComponent);
|
||||
|
||||
const index1 = testRegistry.getBitIndex(TestComponent);
|
||||
const index2 = testRegistry.getBitIndex(PositionComponent);
|
||||
|
||||
expect(index1).toBe(0);
|
||||
expect(index2).toBe(1);
|
||||
});
|
||||
|
||||
test('获取未注册组件的位掩码应该抛出错误', () => {
|
||||
expect(() => {
|
||||
ComponentRegistry.getBitMask(TestComponent);
|
||||
testRegistry.getBitMask(TestComponent);
|
||||
}).toThrow('Component type TestComponent is not registered');
|
||||
});
|
||||
|
||||
test('获取未注册组件的位索引应该抛出错误', () => {
|
||||
expect(() => {
|
||||
ComponentRegistry.getBitIndex(TestComponent);
|
||||
testRegistry.getBitIndex(TestComponent);
|
||||
}).toThrow('Component type TestComponent is not registered');
|
||||
});
|
||||
});
|
||||
|
||||
describe('注册表管理', () => {
|
||||
test('应该能够获取所有已注册的组件类型', () => {
|
||||
ComponentRegistry.register(TestComponent);
|
||||
ComponentRegistry.register(PositionComponent);
|
||||
|
||||
const allTypes = ComponentRegistry.getAllRegisteredTypes();
|
||||
|
||||
testRegistry.register(TestComponent);
|
||||
testRegistry.register(PositionComponent);
|
||||
|
||||
const allTypes = testRegistry.getAllRegisteredTypes();
|
||||
|
||||
expect(allTypes.size).toBe(2);
|
||||
expect(allTypes.has(TestComponent)).toBe(true);
|
||||
expect(allTypes.has(PositionComponent)).toBe(true);
|
||||
@@ -142,12 +144,12 @@ describe('ComponentRegistry - 组件注册表测试', () => {
|
||||
});
|
||||
|
||||
test('返回的注册表副本不应该影响原始数据', () => {
|
||||
ComponentRegistry.register(TestComponent);
|
||||
|
||||
const allTypes = ComponentRegistry.getAllRegisteredTypes();
|
||||
testRegistry.register(TestComponent);
|
||||
|
||||
const allTypes = testRegistry.getAllRegisteredTypes();
|
||||
allTypes.set(PositionComponent, 999);
|
||||
|
||||
expect(ComponentRegistry.isRegistered(PositionComponent)).toBe(false);
|
||||
|
||||
expect(testRegistry.isRegistered(PositionComponent)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -156,10 +158,9 @@ describe('ComponentStorage - 组件存储器测试', () => {
|
||||
let storage: ComponentStorage<TestComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
// 重置注册表
|
||||
(ComponentRegistry as any).componentTypes = new Map<Function, number>();
|
||||
(ComponentRegistry as any).nextBitIndex = 0;
|
||||
|
||||
// 每个测试创建新的注册表实例 | Create new registry instance for each test
|
||||
testRegistry = new ComponentRegistry();
|
||||
|
||||
storage = new ComponentStorage(TestComponent);
|
||||
});
|
||||
|
||||
@@ -358,10 +359,9 @@ describe('ComponentStorageManager - 组件存储管理器测试', () => {
|
||||
let manager: ComponentStorageManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// 重置注册表
|
||||
(ComponentRegistry as any).componentTypes = new Map<Function, number>();
|
||||
(ComponentRegistry as any).nextBitIndex = 0;
|
||||
|
||||
// 重置全局注册表 | Reset global registry
|
||||
GlobalComponentRegistry.reset();
|
||||
|
||||
manager = new ComponentStorageManager();
|
||||
});
|
||||
|
||||
@@ -455,10 +455,10 @@ describe('ComponentStorageManager - 组件存储管理器测试', () => {
|
||||
|
||||
describe('位掩码功能', () => {
|
||||
test('应该能够获取实体的组件位掩码', () => {
|
||||
// 确保组件已注册
|
||||
ComponentRegistry.register(TestComponent);
|
||||
ComponentRegistry.register(PositionComponent);
|
||||
ComponentRegistry.register(VelocityComponent);
|
||||
// 确保组件已注册 | Ensure components are registered
|
||||
GlobalComponentRegistry.register(TestComponent);
|
||||
GlobalComponentRegistry.register(PositionComponent);
|
||||
GlobalComponentRegistry.register(VelocityComponent);
|
||||
|
||||
manager.addComponent(1, new TestComponent(100));
|
||||
manager.addComponent(1, new PositionComponent(10, 20));
|
||||
@@ -475,8 +475,8 @@ describe('ComponentStorageManager - 组件存储管理器测试', () => {
|
||||
});
|
||||
|
||||
test('添加和移除组件应该更新掩码', () => {
|
||||
ComponentRegistry.register(TestComponent);
|
||||
ComponentRegistry.register(PositionComponent);
|
||||
GlobalComponentRegistry.register(TestComponent);
|
||||
GlobalComponentRegistry.register(PositionComponent);
|
||||
|
||||
manager.addComponent(1, new TestComponent(100));
|
||||
let mask = manager.getComponentMask(1);
|
||||
|
||||
@@ -894,10 +894,12 @@ describe('QuerySystem - 查询系统测试', () => {
|
||||
const independentQuerySystem = new QuerySystem();
|
||||
const testEntity = scene.createEntity('ArchetypeTestEntity');
|
||||
|
||||
// 模拟Scene环境(保留componentStorageManager)
|
||||
// 模拟Scene环境(保留componentStorageManager和componentRegistry)
|
||||
// Mock Scene environment (keep componentStorageManager and componentRegistry)
|
||||
const mockScene = {
|
||||
querySystem: independentQuerySystem,
|
||||
componentStorageManager: scene.componentStorageManager,
|
||||
componentRegistry: scene.componentRegistry,
|
||||
clearSystemEntityCaches: jest.fn()
|
||||
};
|
||||
testEntity.scene = mockScene as any;
|
||||
@@ -938,10 +940,12 @@ describe('QuerySystem - 查询系统测试', () => {
|
||||
const independentQuerySystem = new QuerySystem();
|
||||
const testEntity = scene.createEntity('RemoveAllTestEntity');
|
||||
|
||||
// 模拟Scene环境(保留componentStorageManager)
|
||||
// 模拟Scene环境(保留componentStorageManager和componentRegistry)
|
||||
// Mock Scene environment (keep componentStorageManager and componentRegistry)
|
||||
const mockScene = {
|
||||
querySystem: independentQuerySystem,
|
||||
componentStorageManager: scene.componentStorageManager,
|
||||
componentRegistry: scene.componentRegistry,
|
||||
clearSystemEntityCaches: jest.fn()
|
||||
};
|
||||
testEntity.scene = mockScene as any;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Component } from '../../../src/ECS/Component';
|
||||
import { HierarchySystem } from '../../../src/ECS/Systems/HierarchySystem';
|
||||
import { HierarchyComponent } from '../../../src/ECS/Components/HierarchyComponent';
|
||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
||||
import { ComponentRegistry, ComponentType } from '../../../src/ECS/Core/ComponentStorage';
|
||||
import { GlobalComponentRegistry, ComponentType } from '../../../src/ECS/Core/ComponentStorage';
|
||||
import { Serializable, Serialize } from '../../../src/ECS/Serialization';
|
||||
|
||||
@ECSComponent('EntitySerTest_Position')
|
||||
@@ -40,16 +40,18 @@ describe('EntitySerializer', () => {
|
||||
let componentRegistry: Map<string, ComponentType>;
|
||||
|
||||
beforeEach(() => {
|
||||
ComponentRegistry.reset();
|
||||
ComponentRegistry.register(PositionComponent);
|
||||
ComponentRegistry.register(VelocityComponent);
|
||||
ComponentRegistry.register(HierarchyComponent);
|
||||
// 重置全局注册表 | Reset global registry
|
||||
GlobalComponentRegistry.reset();
|
||||
|
||||
GlobalComponentRegistry.register(PositionComponent);
|
||||
GlobalComponentRegistry.register(VelocityComponent);
|
||||
GlobalComponentRegistry.register(HierarchyComponent);
|
||||
|
||||
scene = new Scene({ name: 'EntitySerializerTestScene' });
|
||||
hierarchySystem = new HierarchySystem();
|
||||
scene.addSystem(hierarchySystem);
|
||||
|
||||
componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ChangeOperation
|
||||
} from '../../../src/ECS/Serialization';
|
||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
||||
import { ComponentRegistry } from '../../../src/ECS/Core/ComponentStorage';
|
||||
import { GlobalComponentRegistry } from '../../../src/ECS/Core/ComponentStorage';
|
||||
|
||||
// 测试组件定义
|
||||
@ECSComponent('IncTest_Position')
|
||||
@@ -56,12 +56,14 @@ describe('Incremental Serialization System', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
IncrementalSerializer.resetVersion();
|
||||
ComponentRegistry.reset();
|
||||
|
||||
// 重新注册测试组件
|
||||
ComponentRegistry.register(PositionComponent);
|
||||
ComponentRegistry.register(VelocityComponent);
|
||||
ComponentRegistry.register(HealthComponent);
|
||||
// 重置全局注册表 | Reset global registry
|
||||
GlobalComponentRegistry.reset();
|
||||
|
||||
// 重新注册测试组件 | Re-register test components
|
||||
GlobalComponentRegistry.register(PositionComponent);
|
||||
GlobalComponentRegistry.register(VelocityComponent);
|
||||
GlobalComponentRegistry.register(HealthComponent);
|
||||
|
||||
scene = new Scene({ name: 'IncrementalTestScene' });
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Component } from '../../../src/ECS/Component';
|
||||
import { HierarchySystem } from '../../../src/ECS/Systems/HierarchySystem';
|
||||
import { HierarchyComponent } from '../../../src/ECS/Components/HierarchyComponent';
|
||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
||||
import { ComponentRegistry, ComponentType } from '../../../src/ECS/Core/ComponentStorage';
|
||||
import { GlobalComponentRegistry, ComponentType } from '../../../src/ECS/Core/ComponentStorage';
|
||||
import { Serializable, Serialize } from '../../../src/ECS/Serialization';
|
||||
|
||||
@ECSComponent('SceneSerTest_Position')
|
||||
@@ -40,7 +40,7 @@ describe('SceneSerializer', () => {
|
||||
beforeEach(() => {
|
||||
scene = new Scene({ name: 'SceneSerializerTestScene' });
|
||||
|
||||
componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IntervalSystem } from '../../../src/ECS/Systems/IntervalSystem';
|
||||
import { ProcessingSystem } from '../../../src/ECS/Systems/ProcessingSystem';
|
||||
import { Entity } from '../../../src/ECS/Entity';
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { ComponentRegistry } from '../../../src/ECS/Core/ComponentStorage';
|
||||
import { GlobalComponentRegistry } from '../../../src/ECS/Core/ComponentStorage';
|
||||
import { Time } from '../../../src/Utils/Time';
|
||||
import { Matcher } from '../../../src/ECS/Utils/Matcher';
|
||||
import { Core } from '../../../src/Core';
|
||||
@@ -85,13 +85,15 @@ describe('System Types - 系统类型测试', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
Core.create();
|
||||
// 注册测试组件类型 | Register test component types
|
||||
// 必须在创建 Scene 之前注册,因为 Scene 会克隆 GlobalComponentRegistry
|
||||
// Must register before Scene creation, as Scene clones GlobalComponentRegistry
|
||||
GlobalComponentRegistry.register(TestComponent);
|
||||
GlobalComponentRegistry.register(AnotherComponent);
|
||||
scene = new Scene();
|
||||
entity = scene.createEntity('TestEntity');
|
||||
// 重置时间系统
|
||||
Time.update(0.016);
|
||||
// 注册测试组件类型
|
||||
ComponentRegistry.register(TestComponent);
|
||||
ComponentRegistry.register(AnotherComponent);
|
||||
});
|
||||
|
||||
describe('PassiveSystem - 被动系统', () => {
|
||||
|
||||
@@ -883,6 +883,133 @@ export class EngineBridge implements ITextureEngineBridge {
|
||||
this.getEngine().clearAllTextures();
|
||||
}
|
||||
|
||||
// ===== Texture State API =====
|
||||
// ===== 纹理状态 API =====
|
||||
|
||||
/**
|
||||
* Get texture loading state.
|
||||
* 获取纹理加载状态。
|
||||
*
|
||||
* @param id - Texture ID | 纹理ID
|
||||
* @returns State string: 'loading', 'ready', or 'failed:reason'
|
||||
* 状态字符串:'loading'、'ready' 或 'failed:reason'
|
||||
*/
|
||||
getTextureState(id: number): string {
|
||||
if (!this.initialized) return 'loading';
|
||||
return this.getEngine().getTextureState(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if texture is ready for rendering.
|
||||
* 检查纹理是否已就绪可渲染。
|
||||
*
|
||||
* @param id - Texture ID | 纹理ID
|
||||
* @returns true if texture data is fully loaded | 纹理数据完全加载则返回true
|
||||
*/
|
||||
isTextureReady(id: number): boolean {
|
||||
if (!this.initialized) return false;
|
||||
return this.getEngine().isTextureReady(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of textures currently loading.
|
||||
* 获取当前正在加载的纹理数量。
|
||||
*
|
||||
* @returns Number of textures in 'loading' state | 处于加载状态的纹理数量
|
||||
*/
|
||||
getTextureLoadingCount(): number {
|
||||
if (!this.initialized) return 0;
|
||||
return this.getEngine().getTextureLoadingCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load texture asynchronously with Promise.
|
||||
* 使用Promise异步加载纹理。
|
||||
*
|
||||
* Unlike loadTexture which returns immediately with a placeholder,
|
||||
* 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, rejects on failure
|
||||
* 纹理就绪时解析的Promise,失败时拒绝
|
||||
*/
|
||||
loadTextureAsync(id: number, url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.initialized) {
|
||||
reject(new Error('Engine not initialized'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Start loading the texture
|
||||
// 开始加载纹理
|
||||
this.getEngine().loadTexture(id, url);
|
||||
|
||||
// Poll for state changes
|
||||
// 轮询状态变化
|
||||
const checkInterval = 16; // ~60fps
|
||||
const maxWaitTime = 30000; // 30 seconds timeout
|
||||
let elapsed = 0;
|
||||
|
||||
const checkState = () => {
|
||||
const state = this.getTextureState(id);
|
||||
|
||||
if (state === 'ready') {
|
||||
resolve();
|
||||
} else if (state.startsWith('failed:')) {
|
||||
const reason = state.substring(7);
|
||||
reject(new Error(`Texture load failed: ${reason}`));
|
||||
} else if (elapsed >= maxWaitTime) {
|
||||
reject(new Error(`Texture load timeout after ${maxWaitTime}ms`));
|
||||
} else {
|
||||
elapsed += checkInterval;
|
||||
setTimeout(checkState, checkInterval);
|
||||
}
|
||||
};
|
||||
|
||||
// Start checking after a small delay to allow initial state setup
|
||||
// 稍后开始检查,允许初始状态设置
|
||||
setTimeout(checkState, checkInterval);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all loading textures to complete.
|
||||
* 等待所有加载中的纹理完成。
|
||||
*
|
||||
* @param timeout - Maximum wait time in ms (default: 30000)
|
||||
* 最大等待时间(毫秒,默认30000)
|
||||
* @returns Promise that resolves when all textures are loaded
|
||||
* 所有纹理加载完成时解析的Promise
|
||||
*/
|
||||
waitForAllTextures(timeout: number = 30000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.initialized) {
|
||||
reject(new Error('Engine not initialized'));
|
||||
return;
|
||||
}
|
||||
|
||||
const checkInterval = 16;
|
||||
let elapsed = 0;
|
||||
|
||||
const checkLoading = () => {
|
||||
const loadingCount = this.getTextureLoadingCount();
|
||||
|
||||
if (loadingCount === 0) {
|
||||
resolve();
|
||||
} else if (elapsed >= timeout) {
|
||||
reject(new Error(`Timeout waiting for ${loadingCount} textures to load`));
|
||||
} else {
|
||||
elapsed += checkInterval;
|
||||
setTimeout(checkLoading, checkInterval);
|
||||
}
|
||||
};
|
||||
|
||||
checkLoading();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the bridge and release resources.
|
||||
* 销毁桥接并释放资源。
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
* 用于ECS的引擎渲染系统。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ComponentType, ECSSystem, Component, Core } from '@esengine/ecs-framework';
|
||||
import { TransformComponent, sortingLayerManager } from '@esengine/engine-core';
|
||||
import { Color } from '@esengine/ecs-framework-math';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { Component, ComponentType, Core, ECSSystem, Entity, EntitySystem, Matcher } from '@esengine/ecs-framework';
|
||||
import { Color } from '@esengine/ecs-framework-math';
|
||||
import { TransformComponent, sortingLayerManager } from '@esengine/engine-core';
|
||||
import { getMaterialManager } from '@esengine/material-system';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import type { EngineBridge } from '../core/EngineBridge';
|
||||
import { RenderBatcher } from '../core/RenderBatcher';
|
||||
import type { SpriteRenderData } from '../types';
|
||||
import type { ITransformComponent } from '../core/SpriteRenderHelper';
|
||||
import type { SpriteRenderData } from '../types';
|
||||
|
||||
/**
|
||||
* Render data from a provider
|
||||
@@ -339,14 +339,12 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
}
|
||||
|
||||
// Calculate UV with flip | 计算带翻转的 UV
|
||||
const uv: [number, number, number, number] = [0, 0, 1, 1];
|
||||
if (sprite.flipX || sprite.flipY) {
|
||||
if (sprite.flipX) {
|
||||
[uv[0], uv[2]] = [uv[2], uv[0]];
|
||||
}
|
||||
if (sprite.flipY) {
|
||||
[uv[1], uv[3]] = [uv[3], uv[1]];
|
||||
}
|
||||
const uv: [number, number, number, number] = [...sprite.uv];
|
||||
if (sprite.flipX) {
|
||||
[uv[0], uv[2]] = [uv[2], uv[0]];
|
||||
}
|
||||
if (sprite.flipY) {
|
||||
[uv[1], uv[3]] = [uv[3], uv[1]];
|
||||
}
|
||||
|
||||
// 使用世界变换(由 TransformSystem 计算,考虑父级变换),回退到本地变换
|
||||
@@ -569,6 +567,13 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
const tOffset = i * 7;
|
||||
const uvOffset = i * 4;
|
||||
|
||||
const uv: [number, number, number, number] = [
|
||||
data.uvs[uvOffset],
|
||||
data.uvs[uvOffset + 1],
|
||||
data.uvs[uvOffset + 2],
|
||||
data.uvs[uvOffset + 3]
|
||||
];
|
||||
|
||||
const renderData: SpriteRenderData = {
|
||||
x: data.transforms[tOffset],
|
||||
y: data.transforms[tOffset + 1],
|
||||
@@ -578,7 +583,7 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
originX: data.transforms[tOffset + 5],
|
||||
originY: data.transforms[tOffset + 6],
|
||||
textureId,
|
||||
uv: [data.uvs[uvOffset], data.uvs[uvOffset + 1], data.uvs[uvOffset + 2], data.uvs[uvOffset + 3]],
|
||||
uv,
|
||||
color: data.colors[i]
|
||||
};
|
||||
|
||||
|
||||
@@ -209,11 +209,31 @@ export class GameEngine {
|
||||
* 获取所有已注册的视口ID。
|
||||
*/
|
||||
getViewportIds(): string[];
|
||||
/**
|
||||
* 检查纹理是否已就绪
|
||||
* Check if texture is ready to use
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `id` - Texture ID | 纹理ID
|
||||
*/
|
||||
isTextureReady(id: number): boolean;
|
||||
/**
|
||||
* Add a capsule gizmo outline.
|
||||
* 添加胶囊Gizmo边框。
|
||||
*/
|
||||
addGizmoCapsule(x: number, y: number, radius: number, half_height: number, rotation: number, r: number, g: number, b: number, a: number): void;
|
||||
/**
|
||||
* 获取纹理加载状态
|
||||
* Get texture loading state
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `id` - Texture ID | 纹理ID
|
||||
*
|
||||
* # Returns | 返回
|
||||
* State string: "loading", "ready", or "failed:reason"
|
||||
* 状态字符串:"loading"、"ready" 或 "failed:原因"
|
||||
*/
|
||||
getTextureState(id: number): string;
|
||||
/**
|
||||
* Register a new viewport.
|
||||
* 注册新视口。
|
||||
@@ -361,6 +381,11 @@ export class GameEngine {
|
||||
* 在恢复场景快照时应调用此方法,以确保纹理使用正确的ID重新加载。
|
||||
*/
|
||||
clearTexturePathCache(): void;
|
||||
/**
|
||||
* 获取正在加载中的纹理数量
|
||||
* Get the number of textures currently loading
|
||||
*/
|
||||
getTextureLoadingCount(): number;
|
||||
/**
|
||||
* Create a new game engine instance.
|
||||
* 创建新的游戏引擎实例。
|
||||
@@ -429,6 +454,8 @@ export interface InitOutput {
|
||||
readonly gameengine_getCamera: (a: number) => [number, number];
|
||||
readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
|
||||
readonly gameengine_getTextureIdByPath: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_getTextureLoadingCount: (a: number) => number;
|
||||
readonly gameengine_getTextureState: (a: number, b: number) => [number, number];
|
||||
readonly gameengine_getViewportCamera: (a: number, b: number, c: number) => [number, number];
|
||||
readonly gameengine_getViewportIds: (a: number) => [number, number];
|
||||
readonly gameengine_hasMaterial: (a: number, b: number) => number;
|
||||
@@ -436,6 +463,7 @@ export interface InitOutput {
|
||||
readonly gameengine_height: (a: number) => number;
|
||||
readonly gameengine_isEditorMode: (a: number) => number;
|
||||
readonly gameengine_isKeyDown: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_isTextureReady: (a: number, b: number) => number;
|
||||
readonly gameengine_loadTexture: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
readonly gameengine_loadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
|
||||
readonly gameengine_new: (a: number, b: number) => [number, number, number];
|
||||
|
||||
@@ -254,6 +254,25 @@ pub fn read_file_as_base64(file_path: String) -> Result<String, String> {
|
||||
Ok(general_purpose::STANDARD.encode(&file_content))
|
||||
}
|
||||
|
||||
/// Get file modification time (milliseconds since UNIX epoch)
|
||||
/// 获取文件修改时间(Unix 纪元以来的毫秒数)
|
||||
#[tauri::command]
|
||||
pub fn get_file_mtime(path: String) -> Result<u64, String> {
|
||||
let metadata = fs::metadata(&path)
|
||||
.map_err(|e| format!("Failed to get metadata for {}: {}", path, e))?;
|
||||
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.map_err(|e| format!("Failed to get modified time for {}: {}", path, e))?;
|
||||
|
||||
let millis = modified
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| format!("Time error: {}", e))?
|
||||
.as_millis() as u64;
|
||||
|
||||
Ok(millis)
|
||||
}
|
||||
|
||||
/// Copy file from source to destination
|
||||
#[tauri::command]
|
||||
pub fn copy_file(src: String, dst: String) -> Result<(), String> {
|
||||
|
||||
@@ -65,6 +65,7 @@ fn main() {
|
||||
commands::scan_directory,
|
||||
commands::read_file_as_base64,
|
||||
commands::copy_file,
|
||||
commands::get_file_mtime,
|
||||
// Dialog operations
|
||||
commands::open_folder_dialog,
|
||||
commands::open_file_dialog,
|
||||
@@ -183,18 +184,27 @@ fn handle_project_protocol(
|
||||
}
|
||||
|
||||
/// Get MIME type based on file extension
|
||||
/// 根据文件扩展名获取 MIME 类型
|
||||
fn get_mime_type(file_path: &str) -> &'static str {
|
||||
if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".js") {
|
||||
} else if file_path.ends_with(".js") || file_path.ends_with(".mjs") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else if file_path.ends_with(".wasm") {
|
||||
"application/wasm"
|
||||
} else if file_path.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if file_path.ends_with(".html") {
|
||||
"text/html"
|
||||
} else if file_path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else if file_path.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else {
|
||||
"text/plain"
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
{
|
||||
"identifier": "main",
|
||||
"windows": [
|
||||
"main"
|
||||
"main",
|
||||
"frame-debugger"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
@@ -91,6 +92,9 @@
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"shell:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
|
||||
@@ -40,11 +40,15 @@ import { Inspector } from './components/inspectors/Inspector';
|
||||
import { AssetBrowser } from './components/AssetBrowser';
|
||||
import { Viewport } from './components/Viewport';
|
||||
import { AdvancedProfilerWindow } from './components/AdvancedProfilerWindow';
|
||||
import { RenderDebugPanel } from './components/debug/RenderDebugPanel';
|
||||
import { emit, emitTo, listen } from '@tauri-apps/api/event';
|
||||
import { renderDebugService } from './services/RenderDebugService';
|
||||
import { PortManager } from './components/PortManager';
|
||||
import { SettingsWindow } from './components/SettingsWindow';
|
||||
import { AboutDialog } from './components/AboutDialog';
|
||||
import { ErrorDialog } from './components/ErrorDialog';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { ExternalModificationDialog } from './components/ExternalModificationDialog';
|
||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
|
||||
import { ForumPanel } from './components/forum';
|
||||
@@ -63,6 +67,7 @@ import { useLocale } from './hooks/useLocale';
|
||||
import { useStoreSubscriptions } from './hooks/useStoreSubscriptions';
|
||||
import { en, zh, es } from './locales';
|
||||
import type { Locale } from '@esengine/editor-core';
|
||||
import { UserCodeService } from '@esengine/editor-core';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import './styles/App.css';
|
||||
|
||||
@@ -84,12 +89,24 @@ Core.services.registerInstance(ICompilerRegistry, compilerRegistryInstance);
|
||||
|
||||
const logger = createLogger('App');
|
||||
|
||||
// 检查是否为独立窗口模式 | Check if standalone window mode
|
||||
const isFrameDebuggerMode = new URLSearchParams(window.location.search).get('mode') === 'frame-debugger';
|
||||
|
||||
function App() {
|
||||
const initRef = useRef(false);
|
||||
const layoutContainerRef = useRef<FlexLayoutDockContainerHandle>(null);
|
||||
const [pluginLoader] = useState(() => new PluginLoader());
|
||||
const { showToast, hideToast } = useToast();
|
||||
|
||||
// 如果是独立调试窗口模式,只渲染调试面板 | If standalone debugger mode, only render debug panel
|
||||
if (isFrameDebuggerMode) {
|
||||
return (
|
||||
<div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
||||
<RenderDebugPanel visible={true} onClose={() => window.close()} standalone />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 本地初始化状态(只用于初始化阶段)| Local init state (only for initialization) =====
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
@@ -170,10 +187,40 @@ function App() {
|
||||
showAbout, setShowAbout,
|
||||
showPluginGenerator, setShowPluginGenerator,
|
||||
showBuildSettings, setShowBuildSettings,
|
||||
showRenderDebug, setShowRenderDebug,
|
||||
errorDialog, setErrorDialog,
|
||||
confirmDialog, setConfirmDialog
|
||||
confirmDialog, setConfirmDialog,
|
||||
externalModificationDialog, setExternalModificationDialog
|
||||
} = useDialogStore();
|
||||
|
||||
// 全局监听独立调试窗口的数据请求 | Global listener for standalone debug window requests
|
||||
useEffect(() => {
|
||||
let broadcastInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const unlistenPromise = listen('render-debug-request-data', () => {
|
||||
// 开始定时广播数据 | Start broadcasting data periodically
|
||||
if (!broadcastInterval) {
|
||||
const broadcast = () => {
|
||||
renderDebugService.setEnabled(true);
|
||||
const snap = renderDebugService.collectSnapshot();
|
||||
if (snap) {
|
||||
// 使用 emitTo 发送到独立窗口 | Use emitTo to send to standalone window
|
||||
emitTo('frame-debugger', 'render-debug-snapshot', snap).catch(() => {});
|
||||
}
|
||||
};
|
||||
broadcast(); // 立即广播一次 | Broadcast immediately
|
||||
broadcastInterval = setInterval(broadcast, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenPromise.then(unlisten => unlisten());
|
||||
if (broadcastInterval) {
|
||||
clearInterval(broadcastInterval);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 禁用默认右键菜单
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
@@ -483,6 +530,113 @@ function App() {
|
||||
};
|
||||
}, [initialized]);
|
||||
|
||||
// Handle external scene file changes
|
||||
// 处理外部场景文件变更
|
||||
useEffect(() => {
|
||||
if (!initialized || !messageHubRef.current || !sceneManagerRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
const sm = sceneManagerRef.current;
|
||||
|
||||
const unsubscribe = hub.subscribe('scene:external-change', (data: {
|
||||
path: string;
|
||||
sceneName: string;
|
||||
}) => {
|
||||
logger.info('Scene externally modified:', data.path);
|
||||
|
||||
// Show confirmation dialog to reload the scene
|
||||
// 显示确认对话框以重新加载场景
|
||||
setConfirmDialog({
|
||||
title: t('scene.externalChange.title'),
|
||||
message: t('scene.externalChange.message', { name: data.sceneName }),
|
||||
confirmText: t('scene.externalChange.reload'),
|
||||
cancelText: t('scene.externalChange.ignore'),
|
||||
onConfirm: async () => {
|
||||
setConfirmDialog(null);
|
||||
try {
|
||||
await sm.openScene(data.path);
|
||||
showToast(t('scene.reloadedSuccess', { name: data.sceneName }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to reload scene:', error);
|
||||
showToast(t('scene.reloadFailed'), 'error');
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
// User chose to ignore, do nothing
|
||||
// 用户选择忽略,不做任何操作
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [initialized, t, showToast]);
|
||||
|
||||
// Handle external modification when saving scene
|
||||
// 处理保存场景时的外部修改检测
|
||||
useEffect(() => {
|
||||
if (!initialized || !messageHubRef.current || !sceneManagerRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
const sm = sceneManagerRef.current;
|
||||
|
||||
const unsubscribe = hub.subscribe('scene:externalModification', (data: {
|
||||
path: string;
|
||||
sceneName: string;
|
||||
}) => {
|
||||
logger.info('Scene file externally modified during save:', data.path);
|
||||
|
||||
// Show external modification dialog with three options
|
||||
// 显示外部修改对话框,提供三个选项
|
||||
setExternalModificationDialog({
|
||||
sceneName: data.sceneName,
|
||||
onReload: async () => {
|
||||
setExternalModificationDialog(null);
|
||||
try {
|
||||
await sm.reloadScene();
|
||||
showToast(t('scene.reloadedSuccess', { name: data.sceneName }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to reload scene:', error);
|
||||
showToast(t('scene.reloadFailed'), 'error');
|
||||
}
|
||||
},
|
||||
onOverwrite: async () => {
|
||||
setExternalModificationDialog(null);
|
||||
try {
|
||||
await sm.saveScene(true); // Force save, overwriting external changes
|
||||
showToast(t('scene.savedSuccess', { name: data.sceneName }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to save scene:', error);
|
||||
showToast(t('scene.saveFailed'), 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [initialized, t, showToast, setExternalModificationDialog]);
|
||||
|
||||
// Handle user code compilation results
|
||||
// 处理用户代码编译结果
|
||||
useEffect(() => {
|
||||
if (!initialized || !messageHubRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
|
||||
const unsubscribe = hub.subscribe('usercode:compilation-result', (data: {
|
||||
success: boolean;
|
||||
exports: string[];
|
||||
errors: string[];
|
||||
}) => {
|
||||
if (data.success) {
|
||||
if (data.exports.length > 0) {
|
||||
showToast(t('usercode.compileSuccess', { count: data.exports.length }), 'success');
|
||||
}
|
||||
} else {
|
||||
const errorMsg = data.errors[0] ?? t('usercode.compileError');
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [initialized, t, showToast]);
|
||||
|
||||
const handleOpenRecentProject = async (projectPath: string) => {
|
||||
try {
|
||||
setIsLoading(true, t('loading.step1'));
|
||||
@@ -523,7 +677,6 @@ function App() {
|
||||
const sceneFiles = await TauriAPI.scanDirectory(`${projectPath}/scenes`, '*.ecs');
|
||||
const sceneNames = sceneFiles.map(f => `scenes/${f.split(/[\\/]/).pop()}`);
|
||||
setAvailableScenes(sceneNames);
|
||||
console.log('[App] Found scenes:', sceneNames);
|
||||
} catch (e) {
|
||||
console.warn('[App] Failed to scan scenes:', e);
|
||||
}
|
||||
@@ -545,12 +698,8 @@ function App() {
|
||||
// Load project plugin config and activate plugins (after engine init, before module system init)
|
||||
if (pluginManagerRef.current) {
|
||||
const pluginSettings = projectService.getPluginSettings();
|
||||
console.log('[App] Plugin settings from project:', pluginSettings);
|
||||
if (pluginSettings && pluginSettings.enabledPlugins.length > 0) {
|
||||
console.log('[App] Loading plugin config:', pluginSettings.enabledPlugins);
|
||||
await pluginManagerRef.current.loadConfig({ enabledPlugins: pluginSettings.enabledPlugins });
|
||||
} else {
|
||||
console.log('[App] No plugin settings found in project config');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +715,13 @@ function App() {
|
||||
|
||||
setIsLoading(true, t('loading.step3'));
|
||||
|
||||
// Wait for user code to be compiled and registered before loading scenes
|
||||
// 等待用户代码编译和注册完成后再加载场景
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
await userCodeService.waitForReady();
|
||||
}
|
||||
|
||||
const sceneManagerService = Core.services.resolve(SceneManagerService);
|
||||
if (sceneManagerService) {
|
||||
await sceneManagerService.newScene();
|
||||
@@ -696,6 +852,13 @@ function App() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for user code to be ready before loading scene
|
||||
// 在加载场景前等待用户代码就绪
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
await userCodeService.waitForReady();
|
||||
}
|
||||
|
||||
await sceneManager.openScene();
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
|
||||
@@ -706,13 +869,25 @@ function App() {
|
||||
};
|
||||
|
||||
const handleOpenSceneByPath = useCallback(async (scenePath: string) => {
|
||||
console.log('[App] handleOpenSceneByPath called:', scenePath);
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for user code to be ready before loading scene
|
||||
// 在加载场景前等待用户代码就绪
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
console.log('[App] Waiting for user code service...');
|
||||
await userCodeService.waitForReady();
|
||||
console.log('[App] User code service ready');
|
||||
}
|
||||
|
||||
console.log('[App] Calling sceneManager.openScene...');
|
||||
await sceneManager.openScene(scenePath);
|
||||
console.log('[App] Scene opened successfully');
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
|
||||
} catch (error) {
|
||||
@@ -1087,6 +1262,14 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{externalModificationDialog && (
|
||||
<ExternalModificationDialog
|
||||
sceneName={externalModificationDialog.sceneName}
|
||||
onReload={externalModificationDialog.onReload}
|
||||
onOverwrite={externalModificationDialog.onOverwrite}
|
||||
onCancel={() => setExternalModificationDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1121,6 +1304,7 @@ function App() {
|
||||
onCreatePlugin={handleCreatePlugin}
|
||||
onReloadPlugins={handleReloadPlugins}
|
||||
onOpenBuildSettings={() => setShowBuildSettings(true)}
|
||||
onOpenRenderDebug={() => setShowRenderDebug(true)}
|
||||
/>
|
||||
<MainToolbar
|
||||
messageHub={messageHub || undefined}
|
||||
@@ -1226,6 +1410,12 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 渲染调试面板 | Render Debug Panel */}
|
||||
<RenderDebugPanel
|
||||
visible={showRenderDebug}
|
||||
onClose={() => setShowRenderDebug(false)}
|
||||
/>
|
||||
|
||||
{errorDialog && (
|
||||
<ErrorDialog
|
||||
title={errorDialog.title}
|
||||
@@ -1252,6 +1442,15 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{externalModificationDialog && (
|
||||
<ExternalModificationDialog
|
||||
sceneName={externalModificationDialog.sceneName}
|
||||
onReload={externalModificationDialog.onReload}
|
||||
onOverwrite={externalModificationDialog.onOverwrite}
|
||||
onCancel={() => setExternalModificationDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,4 +38,8 @@ export class TauriFileAPI implements IFileAPI {
|
||||
public async pathExists(path: string): Promise<boolean> {
|
||||
return await TauriAPI.pathExists(path);
|
||||
}
|
||||
|
||||
public async getFileMtime(path: string): Promise<number> {
|
||||
return await TauriAPI.getFileMtime(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,17 @@ export class TauriAPI {
|
||||
return await invoke<void>('copy_file', { src, dst });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件修改时间
|
||||
* Get file modification time
|
||||
*
|
||||
* @param path 文件路径 | File path
|
||||
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
|
||||
*/
|
||||
static async getFileMtime(path: string): Promise<number> {
|
||||
return await invoke<number>('get_file_mtime', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入二进制文件
|
||||
* @param filePath 文件路径
|
||||
|
||||
@@ -6,6 +6,16 @@ interface ErrorDialogData {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部修改对话框数据
|
||||
* External modification dialog data
|
||||
*/
|
||||
export interface ExternalModificationDialogData {
|
||||
sceneName: string;
|
||||
onReload: () => void;
|
||||
onOverwrite: () => void;
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
showProfiler: boolean;
|
||||
showAdvancedProfiler: boolean;
|
||||
@@ -14,8 +24,10 @@ interface DialogState {
|
||||
showAbout: boolean;
|
||||
showPluginGenerator: boolean;
|
||||
showBuildSettings: boolean;
|
||||
showRenderDebug: boolean;
|
||||
errorDialog: ErrorDialogData | null;
|
||||
confirmDialog: ConfirmDialogData | null;
|
||||
externalModificationDialog: ExternalModificationDialogData | null;
|
||||
|
||||
setShowProfiler: (show: boolean) => void;
|
||||
setShowAdvancedProfiler: (show: boolean) => void;
|
||||
@@ -24,8 +36,10 @@ interface DialogState {
|
||||
setShowAbout: (show: boolean) => void;
|
||||
setShowPluginGenerator: (show: boolean) => void;
|
||||
setShowBuildSettings: (show: boolean) => void;
|
||||
setShowRenderDebug: (show: boolean) => void;
|
||||
setErrorDialog: (data: ErrorDialogData | null) => void;
|
||||
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
||||
setExternalModificationDialog: (data: ExternalModificationDialogData | null) => void;
|
||||
closeAllDialogs: () => void;
|
||||
}
|
||||
|
||||
@@ -37,8 +51,10 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
showRenderDebug: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
externalModificationDialog: null,
|
||||
|
||||
setShowProfiler: (show) => set({ showProfiler: show }),
|
||||
setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }),
|
||||
@@ -47,8 +63,10 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
setShowAbout: (show) => set({ showAbout: show }),
|
||||
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
||||
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
|
||||
setShowRenderDebug: (show) => set({ showRenderDebug: show }),
|
||||
setErrorDialog: (data) => set({ errorDialog: data }),
|
||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||
setExternalModificationDialog: (data) => set({ externalModificationDialog: data }),
|
||||
|
||||
closeAllDialogs: () => set({
|
||||
showProfiler: false,
|
||||
@@ -58,7 +76,9 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
showRenderDebug: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null
|
||||
confirmDialog: null,
|
||||
externalModificationDialog: null
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Core, ComponentRegistry as CoreComponentRegistry, PrefabSerializer, ComponentRegistry as ECSComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { Core, GlobalComponentRegistry, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { ComponentType } from '@esengine/ecs-framework';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
@@ -136,8 +136,8 @@ export class ServiceRegistry {
|
||||
|
||||
for (const comp of standardComponents) {
|
||||
// Register to editor registry for UI
|
||||
// 组件已通过 @ECSComponent 装饰器自动注册到 CoreComponentRegistry
|
||||
// Components are auto-registered to CoreComponentRegistry via @ECSComponent decorator
|
||||
// 组件已通过 @ECSComponent 装饰器自动注册到 GlobalComponentRegistry
|
||||
// Components are auto-registered to GlobalComponentRegistry via @ECSComponent decorator
|
||||
componentRegistry.register({
|
||||
name: comp.editorName,
|
||||
type: comp.type,
|
||||
@@ -149,7 +149,7 @@ export class ServiceRegistry {
|
||||
|
||||
// Enable hot reload for editor environment
|
||||
// 在编辑器环境中启用热更新
|
||||
CoreComponentRegistry.enableHotReload();
|
||||
GlobalComponentRegistry.enableHotReload();
|
||||
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
@@ -340,8 +340,14 @@ export class ServiceRegistry {
|
||||
// 编辑器脚本编译错误只记录,不影响运行时
|
||||
console.warn('[UserCodeService] Editor compilation errors:', editorResult.errors);
|
||||
}
|
||||
|
||||
// 编译完成,发出就绪信号 | Compilation done, signal ready
|
||||
userCodeService.signalReady();
|
||||
} catch (error) {
|
||||
console.error('[UserCodeService] Failed to compile/load:', error);
|
||||
// 即使编译失败也要发出就绪信号,避免阻塞场景加载
|
||||
// Signal ready even on failure to avoid blocking scene loading
|
||||
userCodeService.signalReady();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Creates an entity instance from a prefab asset.
|
||||
*/
|
||||
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
@@ -50,9 +50,9 @@ export class InstantiatePrefabCommand extends BaseCommand {
|
||||
}
|
||||
|
||||
// 获取组件注册表 | Get component registry
|
||||
// ComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||
// GlobalComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||
// We need to cast it to Map<string, ComponentType>
|
||||
const componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
const componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
|
||||
// 实例化预制体 | Instantiate prefab
|
||||
this.createdEntity = PrefabSerializer.instantiate(
|
||||
|
||||
@@ -1026,13 +1026,16 @@ export class ${className} {
|
||||
|
||||
// Handle asset double click
|
||||
const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => {
|
||||
console.log('[ContentBrowser] Double click:', asset.name, 'type:', asset.type, 'ext:', asset.extension);
|
||||
if (asset.type === 'folder') {
|
||||
setCurrentPath(asset.path);
|
||||
loadAssets(asset.path);
|
||||
setExpandedFolders(prev => new Set([...prev, asset.path]));
|
||||
} else {
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
console.log('[ContentBrowser] File ext:', ext, 'onOpenScene:', !!onOpenScene);
|
||||
if (ext === 'ecs' && onOpenScene) {
|
||||
console.log('[ContentBrowser] Opening scene:', asset.path);
|
||||
onOpenScene(asset.path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AlertTriangle, X, RefreshCw, Save } from 'lucide-react';
|
||||
import '../styles/ConfirmDialog.css';
|
||||
|
||||
interface ExternalModificationDialogProps {
|
||||
sceneName: string;
|
||||
onReload: () => void;
|
||||
onOverwrite: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部修改对话框
|
||||
* External Modification Dialog
|
||||
*
|
||||
* 当场景文件被外部修改时显示,让用户选择操作
|
||||
* Shown when scene file is modified externally, let user choose action
|
||||
*/
|
||||
export function ExternalModificationDialog({
|
||||
sceneName,
|
||||
onReload,
|
||||
onOverwrite,
|
||||
onCancel
|
||||
}: ExternalModificationDialogProps) {
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog external-modification-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="confirm-dialog-header">
|
||||
<AlertTriangle size={20} className="warning-icon" />
|
||||
<h2>文件已被外部修改</h2>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="confirm-dialog-content">
|
||||
<p>
|
||||
场景 <strong>{sceneName}</strong> 已在编辑器外部被修改。
|
||||
</p>
|
||||
<p className="hint-text">
|
||||
请选择如何处理:
|
||||
</p>
|
||||
</div>
|
||||
<div className="confirm-dialog-footer external-modification-footer">
|
||||
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="confirm-dialog-btn reload" onClick={onReload}>
|
||||
<RefreshCw size={14} />
|
||||
重新加载
|
||||
</button>
|
||||
<button className="confirm-dialog-btn overwrite" onClick={onOverwrite}>
|
||||
<Save size={14} />
|
||||
覆盖保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ interface TitleBarProps {
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
onOpenRenderDebug?: () => void;
|
||||
}
|
||||
|
||||
export function TitleBar({
|
||||
@@ -61,7 +62,8 @@ export function TitleBar({
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings
|
||||
onOpenBuildSettings,
|
||||
onOpenRenderDebug
|
||||
}: TitleBarProps) {
|
||||
const { t } = useLocale();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
@@ -197,6 +199,7 @@ export function TitleBar({
|
||||
{ label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
|
||||
{ separator: true },
|
||||
{ label: t('menu.tools.portManager'), onClick: onOpenPortManager },
|
||||
{ label: t('menu.tools.renderDebug'), onClick: onOpenRenderDebug },
|
||||
{ separator: true },
|
||||
{ label: t('menu.tools.settings'), onClick: onOpenSettings }
|
||||
],
|
||||
|
||||
@@ -8,9 +8,9 @@ import '../styles/Viewport.css';
|
||||
import { useEngine } from '../hooks/useEngine';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
import { Core, Entity, SceneSerializer, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, SceneSerializer, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService } from '@esengine/editor-core';
|
||||
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget } from '@esengine/editor-core';
|
||||
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
|
||||
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
@@ -21,6 +21,8 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||
import { QRCodeDialog } from './QRCodeDialog';
|
||||
import { collectAssetReferences } from '@esengine/asset-system';
|
||||
import { RuntimeSceneManager, type IRuntimeSceneManager } from '@esengine/runtime-core';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
|
||||
import type { ModuleManifest } from '../services/RuntimeResolver';
|
||||
|
||||
@@ -264,6 +266,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
|
||||
const playStateRef = useRef<PlayState>('stopped');
|
||||
|
||||
// Runtime scene manager for play mode scene switching | Play 模式场景切换管理器
|
||||
const runtimeSceneManagerRef = useRef<IRuntimeSceneManager | null>(null);
|
||||
|
||||
// Live transform display state | 实时变换显示状态
|
||||
const [liveTransform, setLiveTransform] = useState<{
|
||||
type: 'move' | 'rotate' | 'scale';
|
||||
@@ -811,7 +816,22 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
return;
|
||||
}
|
||||
// Save scene snapshot before playing
|
||||
// saveSceneSnapshot clears all textures, so we need to reset particle textureIds after
|
||||
// saveSceneSnapshot 会清除所有纹理,所以之后需要重置粒子的 textureId
|
||||
EngineService.getInstance().saveSceneSnapshot();
|
||||
|
||||
// Reset particle component textureIds after snapshot (textures were cleared)
|
||||
// 快照后重置粒子组件的 textureId(纹理已被清除)
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
for (const entity of scene.entities.buffer) {
|
||||
const particleComponent = entity.getComponent(ParticleSystemComponent);
|
||||
if (particleComponent) {
|
||||
particleComponent.textureId = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save editor camera state
|
||||
editorCameraRef.current = { x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom };
|
||||
setPlayState('playing');
|
||||
@@ -820,6 +840,132 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
EngineService.getInstance().setEditorMode(false);
|
||||
// Switch to player camera
|
||||
syncPlayerCamera();
|
||||
|
||||
// Register RuntimeSceneManager for scene switching in play mode
|
||||
// 注册 RuntimeSceneManager 以支持 Play 模式下的场景切换
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const projectPath = projectService?.getCurrentProject()?.path;
|
||||
if (projectPath) {
|
||||
// Create scene loader function that reads scene files using Tauri API
|
||||
// 创建使用 Tauri API 读取场景文件的场景加载器函数
|
||||
const editorSceneLoader = async (scenePath: string): Promise<void> => {
|
||||
try {
|
||||
// Normalize path: handle both relative and absolute paths
|
||||
// 标准化路径:处理相对路径和绝对路径
|
||||
let fullPath = scenePath;
|
||||
if (!scenePath.includes(':') && !scenePath.startsWith('/')) {
|
||||
// Relative path - construct full path
|
||||
// 相对路径 - 构建完整路径
|
||||
const normalizedPath = scenePath.replace(/^\.\//, '').replace(/\//g, '\\');
|
||||
fullPath = `${projectPath}\\${normalizedPath}`;
|
||||
} else {
|
||||
// Absolute path - normalize separators for Windows
|
||||
// 绝对路径 - 为 Windows 规范化分隔符
|
||||
fullPath = scenePath.replace(/\//g, '\\');
|
||||
}
|
||||
|
||||
// Read scene file content
|
||||
// 读取场景文件内容
|
||||
const sceneJson = await TauriAPI.readFileContent(fullPath);
|
||||
|
||||
// Validate scene data
|
||||
// 验证场景数据
|
||||
const validation = SceneSerializer.validate(sceneJson);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid scene: ${validation.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
// Save current scene snapshot (so we can go back)
|
||||
// 保存当前场景快照(以便返回)
|
||||
EngineService.getInstance().saveSceneSnapshot();
|
||||
|
||||
// Load new scene by deserializing into current scene
|
||||
// 通过反序列化加载新场景到当前场景
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
scene.deserialize(sceneJson, { strategy: 'replace' });
|
||||
|
||||
// Reset particle component textureIds after scene switch
|
||||
// 场景切换后重置粒子组件的 textureId
|
||||
// This ensures ParticleUpdateSystem will reload textures
|
||||
// 这确保 ParticleUpdateSystem 会重新加载纹理
|
||||
for (const entity of scene.entities.buffer) {
|
||||
const particleComponent = entity.getComponent(ParticleSystemComponent);
|
||||
if (particleComponent) {
|
||||
particleComponent.textureId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-register user code components and systems after scene switch
|
||||
// 场景切换后重新注册用户代码组件和系统
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime);
|
||||
if (runtimeModule) {
|
||||
// Re-register components (ensures GlobalComponentRegistry has correct references)
|
||||
// 重新注册组件(确保 GlobalComponentRegistry 有正确的引用)
|
||||
userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry);
|
||||
|
||||
// Re-register systems (recreates systems with correct component references)
|
||||
// 重新注册系统(使用正确的组件引用重建系统)
|
||||
userCodeService.registerSystems(runtimeModule, scene);
|
||||
}
|
||||
}
|
||||
|
||||
// Load scene resources (textures, etc.)
|
||||
// 加载场景资源(纹理等)
|
||||
await EngineService.getInstance().loadSceneResources();
|
||||
|
||||
// Sync entity store
|
||||
// 同步实体存储
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService);
|
||||
entityStore?.syncFromScene();
|
||||
}
|
||||
|
||||
console.log(`[Viewport] Scene loaded in play mode: ${scenePath}`);
|
||||
} catch (error) {
|
||||
console.error(`[Viewport] Failed to load scene: ${scenePath}`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Create and register RuntimeSceneManager
|
||||
// 创建并注册 RuntimeSceneManager
|
||||
const sceneManager = new RuntimeSceneManager(
|
||||
editorSceneLoader,
|
||||
`${projectPath}\\scenes`
|
||||
);
|
||||
runtimeSceneManagerRef.current = sceneManager;
|
||||
|
||||
// Register to Core.services with the global key
|
||||
// 使用全局 key 注册到 Core.services
|
||||
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
|
||||
if (!Core.services.isRegistered(GlobalSceneManagerKey)) {
|
||||
Core.services.registerInstance(GlobalSceneManagerKey, sceneManager);
|
||||
}
|
||||
|
||||
console.log('[Viewport] RuntimeSceneManager registered for play mode');
|
||||
}
|
||||
|
||||
// Register user code components and systems before starting engine
|
||||
// 在启动引擎前注册用户代码组件和系统
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime);
|
||||
if (runtimeModule) {
|
||||
// Register components first (ensures GlobalComponentRegistry has correct references)
|
||||
// 先注册组件(确保 GlobalComponentRegistry 有正确的引用)
|
||||
userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry);
|
||||
|
||||
// Then register systems (uses registered component references)
|
||||
// 然后注册系统(使用已注册的组件引用)
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
userCodeService.registerSystems(runtimeModule, scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
engine.start();
|
||||
} else if (playState === 'paused') {
|
||||
setPlayState('playing');
|
||||
@@ -837,6 +983,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const handleStop = async () => {
|
||||
setPlayState('stopped');
|
||||
engine.stop();
|
||||
|
||||
// Unregister RuntimeSceneManager
|
||||
// 注销 RuntimeSceneManager
|
||||
if (runtimeSceneManagerRef.current) {
|
||||
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
|
||||
if (Core.services.isRegistered(GlobalSceneManagerKey)) {
|
||||
Core.services.unregister(GlobalSceneManagerKey);
|
||||
}
|
||||
runtimeSceneManagerRef.current.dispose();
|
||||
runtimeSceneManagerRef.current = null;
|
||||
console.log('[Viewport] RuntimeSceneManager unregistered');
|
||||
}
|
||||
|
||||
// Restore scene snapshot
|
||||
await EngineService.getInstance().restoreSceneSnapshot();
|
||||
// Restore editor camera state
|
||||
|
||||
633
packages/editor-app/src/components/debug/RenderDebugPanel.css
Normal file
633
packages/editor-app/src/components/debug/RenderDebugPanel.css
Normal file
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* 渲染调试面板样式 (浮动窗口)
|
||||
* Render Debug Panel Styles (Floating Window)
|
||||
*/
|
||||
|
||||
/* ==================== Floating Window ==================== */
|
||||
.render-debug-window {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.render-debug-window.dragging {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 独立窗口模式 | Standalone mode */
|
||||
.render-debug-window.standalone {
|
||||
position: relative;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.render-debug-window.standalone .window-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ==================== Window Header ==================== */
|
||||
.render-debug-window .window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
cursor: move;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-window .window-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.render-debug-window .window-title svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.render-debug-window .paused-badge {
|
||||
padding: 2px 6px;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.render-debug-window .window-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.render-debug-window .window-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.render-debug-window .window-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== Toolbar ==================== */
|
||||
.render-debug-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-left,
|
||||
.render-debug-toolbar .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 3px;
|
||||
color: #ccc;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn:hover {
|
||||
background: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn.active {
|
||||
background: #4a9eff;
|
||||
border-color: #4a9eff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn.icon-only {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn.recording {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn .record-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.render-debug-toolbar .history-badge {
|
||||
padding: 2px 6px;
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn:disabled:hover {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* ==================== Timeline ==================== */
|
||||
.render-debug-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: #222;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a9eff;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a9eff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .frame-counter {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* ==================== Main Layout ==================== */
|
||||
.render-debug-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== Left Panel (Event List) ==================== */
|
||||
.render-debug-left {
|
||||
width: 260px;
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #222;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.event-list-header .event-count {
|
||||
font-weight: 400;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.event-list::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.event-list::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.event-list::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.event-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Event Items */
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.event-item.selected {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-left: 2px solid #4a9eff;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.event-item .expand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-item .expand-icon:not(.placeholder):hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.event-item .expand-icon.placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.event-item .event-icon {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.event-item .event-icon.sprite {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.event-item .event-icon.particle {
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.event-item .event-icon.ui {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.event-item .event-icon.batch {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.event-item .event-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-item .event-draws {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
padding: 1px 3px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==================== Right Panel ==================== */
|
||||
.render-debug-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.render-debug-preview {
|
||||
height: 40%;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.preview-canvas-container {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-canvas-container canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Details Section */
|
||||
.render-debug-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.details-header {
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.details-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.details-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Details Grid */
|
||||
.details-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.details-section {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 8px 0 3px 0;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.details-section:first-child {
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 3px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.detail-row .detail-label {
|
||||
width: 100px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-row .detail-value {
|
||||
flex: 1;
|
||||
color: #ccc;
|
||||
font-family: 'Consolas', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-row.highlight .detail-value {
|
||||
color: #4fc3f7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ==================== Stats Bar ==================== */
|
||||
.render-debug-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 6px 12px;
|
||||
background: #262626;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-stats .stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.render-debug-stats .stat-item svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* ==================== Resize Handle ==================== */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: se-resize;
|
||||
background: linear-gradient(135deg, transparent 50%, #3a3a3a 50%);
|
||||
border-radius: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: linear-gradient(135deg, transparent 50%, #4a9eff 50%);
|
||||
}
|
||||
|
||||
/* ==================== TextureSheet Preview ==================== */
|
||||
.texture-sheet-preview {
|
||||
margin-top: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.texture-sheet-preview canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ==================== Texture Preview ==================== */
|
||||
.texture-preview-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 3px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.texture-preview-row .detail-label {
|
||||
width: 100px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.texture-preview-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.texture-thumbnail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.texture-thumbnail {
|
||||
max-width: 100%;
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #333;
|
||||
background: repeating-conic-gradient(#2a2a2a 0% 25%, #1a1a1a 0% 50%) 50% / 8px 8px;
|
||||
}
|
||||
|
||||
.texture-path {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
line-height: 1.3;
|
||||
}
|
||||
1059
packages/editor-app/src/components/debug/RenderDebugPanel.tsx
Normal file
1059
packages/editor-app/src/components/debug/RenderDebugPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
7
packages/editor-app/src/components/debug/index.ts
Normal file
7
packages/editor-app/src/components/debug/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 调试组件导出
|
||||
* Debug components export
|
||||
*/
|
||||
|
||||
export { RenderDebugPanel } from './RenderDebugPanel';
|
||||
export type { default as RenderDebugPanelProps } from './RenderDebugPanel';
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2 } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { AssetRegistryService } from '@esengine/editor-core';
|
||||
import type { ISpriteSettings } from '@esengine/asset-system-editor';
|
||||
import { EngineService } from '../../../services/EngineService';
|
||||
import { AssetFileInfo } from '../types';
|
||||
import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common';
|
||||
@@ -50,6 +51,165 @@ function formatDate(timestamp?: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite Settings Editor Component
|
||||
* 精灵设置编辑器组件
|
||||
*
|
||||
* Allows editing nine-patch slice borders for texture assets.
|
||||
* 允许编辑纹理资源的九宫格切片边框。
|
||||
*/
|
||||
interface SpriteSettingsEditorProps {
|
||||
filePath: string;
|
||||
imageSrc: string;
|
||||
initialSettings?: ISpriteSettings;
|
||||
onSettingsChange: (settings: ISpriteSettings) => void;
|
||||
}
|
||||
|
||||
function SpriteSettingsEditor({ filePath, imageSrc, initialSettings, onSettingsChange }: SpriteSettingsEditorProps) {
|
||||
const [sliceBorder, setSliceBorder] = useState<[number, number, number, number]>(
|
||||
initialSettings?.sliceBorder || [0, 0, 0, 0]
|
||||
);
|
||||
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Sync sliceBorder state when initialSettings changes (async load)
|
||||
// 当 initialSettings 变化时同步 sliceBorder 状态(异步加载)
|
||||
useEffect(() => {
|
||||
if (initialSettings?.sliceBorder) {
|
||||
setSliceBorder(initialSettings.sliceBorder);
|
||||
}
|
||||
}, [initialSettings?.sliceBorder]);
|
||||
|
||||
// Load image to get dimensions
|
||||
// 加载图像以获取尺寸
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageSize({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc]);
|
||||
|
||||
// Draw slice preview
|
||||
// 绘制切片预览
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !imageSize) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Calculate scale to fit canvas
|
||||
// 计算缩放以适应画布
|
||||
const maxSize = 200;
|
||||
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
|
||||
const displayWidth = img.width * scale;
|
||||
const displayHeight = img.height * scale;
|
||||
|
||||
canvas.width = displayWidth;
|
||||
canvas.height = displayHeight;
|
||||
|
||||
// Draw image
|
||||
// 绘制图像
|
||||
ctx.drawImage(img, 0, 0, displayWidth, displayHeight);
|
||||
|
||||
// Draw slice lines
|
||||
// 绘制切片线
|
||||
const [top, right, bottom, left] = sliceBorder;
|
||||
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
|
||||
// Top line
|
||||
if (top > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, top * scale);
|
||||
ctx.lineTo(displayWidth, top * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Bottom line
|
||||
if (bottom > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, displayHeight - bottom * scale);
|
||||
ctx.lineTo(displayWidth, displayHeight - bottom * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Left line
|
||||
if (left > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left * scale, 0);
|
||||
ctx.lineTo(left * scale, displayHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Right line
|
||||
if (right > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(displayWidth - right * scale, 0);
|
||||
ctx.lineTo(displayWidth - right * scale, displayHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc, imageSize, sliceBorder]);
|
||||
|
||||
const handleSliceChange = (index: number, value: number) => {
|
||||
const newSlice = [...sliceBorder] as [number, number, number, number];
|
||||
newSlice[index] = Math.max(0, value);
|
||||
setSliceBorder(newSlice);
|
||||
onSettingsChange({ ...initialSettings, sliceBorder: newSlice });
|
||||
};
|
||||
|
||||
const labels = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const labelsCN = ['上', '右', '下', '左'];
|
||||
|
||||
return (
|
||||
<div className="sprite-settings-editor">
|
||||
{/* Slice Preview Canvas */}
|
||||
<div style={{ marginBottom: '12px', textAlign: 'center' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
{imageSize && (
|
||||
<div style={{ fontSize: '11px', color: '#888', marginTop: '4px' }}>
|
||||
{imageSize.width} × {imageSize.height} px
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slice Border Inputs */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
{sliceBorder.map((value, index) => (
|
||||
<div key={index} className="property-field" style={{ marginBottom: '0' }}>
|
||||
<label className="property-label" style={{ minWidth: '50px' }}>
|
||||
{labelsCN[index]} ({labels[index]})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => handleSliceChange(index, parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
max={imageSize ? (index % 2 === 0 ? imageSize.height : imageSize.width) : 9999}
|
||||
className="property-input property-input-number"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInspectorProps) {
|
||||
const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon;
|
||||
const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9';
|
||||
@@ -60,6 +220,10 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
const [detectedType, setDetectedType] = useState<string | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// State for sprite settings (nine-patch borders)
|
||||
// 精灵设置状态(九宫格边框)
|
||||
const [spriteSettings, setSpriteSettings] = useState<ISpriteSettings | undefined>(undefined);
|
||||
|
||||
// Load meta info and available loader types
|
||||
useEffect(() => {
|
||||
if (fileInfo.isDirectory) return;
|
||||
@@ -76,6 +240,14 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
setCurrentLoaderType(meta.loaderType || null);
|
||||
setDetectedType(meta.type);
|
||||
|
||||
// Get sprite settings from meta (for texture assets)
|
||||
// 从 meta 获取精灵设置(用于纹理资源)
|
||||
if (meta.importSettings?.spriteSettings) {
|
||||
setSpriteSettings(meta.importSettings.spriteSettings as ISpriteSettings);
|
||||
} else {
|
||||
setSpriteSettings(undefined);
|
||||
}
|
||||
|
||||
// Get available loader types from assetManager
|
||||
const assetManager = EngineService.getInstance().getAssetManager();
|
||||
const loaderFactory = assetManager?.getLoaderFactory();
|
||||
@@ -117,6 +289,39 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
}
|
||||
}, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]);
|
||||
|
||||
// Handle sprite settings change
|
||||
// 处理精灵设置更改
|
||||
const handleSpriteSettingsChange = useCallback(async (newSettings: ISpriteSettings) => {
|
||||
if (fileInfo.isDirectory || isUpdating) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
if (!assetRegistry?.isReady) return;
|
||||
|
||||
const metaManager = assetRegistry.metaManager;
|
||||
const meta = await metaManager.getOrCreateMeta(fileInfo.path);
|
||||
|
||||
// Update meta with new sprite settings
|
||||
// 使用新的精灵设置更新 meta
|
||||
const updatedImportSettings = {
|
||||
...meta.importSettings,
|
||||
spriteSettings: newSettings
|
||||
};
|
||||
|
||||
await metaManager.updateMeta(fileInfo.path, {
|
||||
importSettings: updatedImportSettings
|
||||
});
|
||||
|
||||
setSpriteSettings(newSettings);
|
||||
console.log(`[AssetFileInspector] Updated sprite settings for ${fileInfo.name}:`, newSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to update sprite settings:', error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]);
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
@@ -228,6 +433,23 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprite Settings Section - only for image files */}
|
||||
{/* 精灵设置部分 - 仅用于图像文件 */}
|
||||
{isImage && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">
|
||||
<Grid3X3 size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
九宫格设置 (Nine-Patch)
|
||||
</div>
|
||||
<SpriteSettingsEditor
|
||||
filePath={fileInfo.path}
|
||||
imageSrc={convertFileSrc(fileInfo.path)}
|
||||
initialSettings={spriteSettings}
|
||||
onSettingsChange={handleSpriteSettingsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
<div className="inspector-section code-preview-section">
|
||||
<div className="section-title">文件预览</div>
|
||||
|
||||
@@ -141,7 +141,27 @@ export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<Vector4>): React.ReactElement {
|
||||
const v = value || { x: 0, y: 0, z: 0, w: 0 };
|
||||
// Support both object {x,y,z,w} and array [0,1,2,3] formats
|
||||
// 支持对象 {x,y,z,w} 和数组 [0,1,2,3] 两种格式
|
||||
let v: Vector4;
|
||||
const isArray = Array.isArray(value);
|
||||
|
||||
if (isArray) {
|
||||
const arr = value as unknown as number[];
|
||||
v = { x: arr[0] ?? 0, y: arr[1] ?? 0, z: arr[2] ?? 0, w: arr[3] ?? 0 };
|
||||
} else {
|
||||
v = value || { x: 0, y: 0, z: 0, w: 0 };
|
||||
}
|
||||
|
||||
const handleChange = (newV: Vector4) => {
|
||||
if (isArray) {
|
||||
// Return as array if input was array
|
||||
// 如果输入是数组,则返回数组
|
||||
onChange([newV.x, newV.y, newV.z, newV.w] as unknown as Vector4);
|
||||
} else {
|
||||
onChange(newV);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
@@ -150,28 +170,28 @@ export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
||||
<VectorInput
|
||||
label="X"
|
||||
value={v.x}
|
||||
onChange={(x) => onChange({ ...v, x })}
|
||||
onChange={(x) => handleChange({ ...v, x })}
|
||||
readonly={context.readonly}
|
||||
axis="x"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Y"
|
||||
value={v.y}
|
||||
onChange={(y) => onChange({ ...v, y })}
|
||||
onChange={(y) => handleChange({ ...v, y })}
|
||||
readonly={context.readonly}
|
||||
axis="y"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Z"
|
||||
value={v.z}
|
||||
onChange={(z) => onChange({ ...v, z })}
|
||||
onChange={(z) => handleChange({ ...v, z })}
|
||||
readonly={context.readonly}
|
||||
axis="z"
|
||||
/>
|
||||
<VectorInput
|
||||
label="W"
|
||||
value={v.w}
|
||||
onChange={(w) => onChange({ ...v, w })}
|
||||
onChange={(w) => handleChange({ ...v, w })}
|
||||
readonly={context.readonly}
|
||||
axis="w"
|
||||
/>
|
||||
|
||||
@@ -306,7 +306,15 @@ export const en: Translations = {
|
||||
openFailed: 'Failed to open scene',
|
||||
savedSuccess: 'Scene saved: {{name}}',
|
||||
saveFailed: 'Failed to save scene',
|
||||
saveAsFailed: 'Failed to save scene as'
|
||||
saveAsFailed: 'Failed to save scene as',
|
||||
reloadedSuccess: 'Scene reloaded: {{name}}',
|
||||
reloadFailed: 'Failed to reload scene',
|
||||
externalChange: {
|
||||
title: 'Scene Changed',
|
||||
message: 'Scene "{{name}}" has been modified externally. Do you want to reload?',
|
||||
reload: 'Reload',
|
||||
ignore: 'Ignore'
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -371,6 +379,15 @@ export const en: Translations = {
|
||||
dependencies: 'Dependencies'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// User Code
|
||||
// ========================================
|
||||
usercode: {
|
||||
compileSuccess: 'Scripts compiled ({{count}} exports)',
|
||||
compileError: 'Script compilation failed',
|
||||
hotReloadSuccess: 'Scripts hot reloaded'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Loading
|
||||
// ========================================
|
||||
@@ -432,7 +449,8 @@ export const en: Translations = {
|
||||
portManager: 'Port Manager',
|
||||
settings: 'Settings',
|
||||
devtools: 'Developer Tools',
|
||||
build: 'Build Settings'
|
||||
build: 'Build Settings',
|
||||
renderDebug: 'Render Debug'
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
|
||||
@@ -381,7 +381,8 @@ export const es: Translations = {
|
||||
portManager: 'Administrador de Puertos',
|
||||
settings: 'Configuración',
|
||||
devtools: 'Herramientas de Desarrollo',
|
||||
build: 'Configuración de Compilación'
|
||||
build: 'Configuración de Compilación',
|
||||
renderDebug: 'Depuración de Renderizado'
|
||||
},
|
||||
help: {
|
||||
title: 'Ayuda',
|
||||
|
||||
@@ -306,7 +306,15 @@ export const zh: Translations = {
|
||||
openFailed: '打开场景失败',
|
||||
savedSuccess: '场景已保存: {{name}}',
|
||||
saveFailed: '保存场景失败',
|
||||
saveAsFailed: '另存场景失败'
|
||||
saveAsFailed: '另存场景失败',
|
||||
reloadedSuccess: '场景已重新加载: {{name}}',
|
||||
reloadFailed: '重新加载场景失败',
|
||||
externalChange: {
|
||||
title: '场景已更改',
|
||||
message: '场景 "{{name}}" 已被外部修改。是否重新加载?',
|
||||
reload: '重新加载',
|
||||
ignore: '忽略'
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -371,6 +379,15 @@ export const zh: Translations = {
|
||||
dependencies: '依赖'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// User Code
|
||||
// ========================================
|
||||
usercode: {
|
||||
compileSuccess: '脚本编译成功 ({{count}} 个导出)',
|
||||
compileError: '脚本编译失败',
|
||||
hotReloadSuccess: '脚本热更新成功'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Loading
|
||||
// ========================================
|
||||
@@ -432,7 +449,8 @@ export const zh: Translations = {
|
||||
portManager: '端口管理器',
|
||||
settings: '设置',
|
||||
devtools: '开发者工具',
|
||||
build: '构建设置'
|
||||
build: '构建设置',
|
||||
renderDebug: '渲染调试'
|
||||
},
|
||||
help: {
|
||||
title: '帮助',
|
||||
|
||||
@@ -278,12 +278,20 @@ export class EditorEngineSync {
|
||||
* Update sprite in engine entity.
|
||||
* 更新引擎实体的精灵。
|
||||
*
|
||||
* Note: Texture loading is now handled automatically by EngineRenderSystem.
|
||||
* 注意:纹理加载现在由EngineRenderSystem自动处理。
|
||||
* Preloads textures when textureGuid changes to ensure they're available for rendering.
|
||||
* 当 textureGuid 变更时预加载纹理以确保渲染时可用。
|
||||
*/
|
||||
private updateSprite(entity: Entity, sprite: SpriteComponent, property: string, value: any): void {
|
||||
// No manual texture loading needed - EngineRenderSystem handles it
|
||||
// 不需要手动加载纹理 - EngineRenderSystem会处理
|
||||
// When textureGuid changes, trigger texture preload
|
||||
// 当 textureGuid 变更时,触发纹理预加载
|
||||
if (property === 'textureGuid' && value) {
|
||||
const bridge = this.engineService.getBridge();
|
||||
if (bridge) {
|
||||
// Preload the texture so it's ready for the next render frame
|
||||
// 预加载纹理以便下一渲染帧时可用
|
||||
bridge.getOrLoadTextureByPath(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, Plugin
|
||||
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
||||
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
@@ -462,6 +463,43 @@ export class EngineService {
|
||||
if (this._runtime?.bridge) {
|
||||
this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge);
|
||||
|
||||
// 为 EngineIntegration 设置使用 Tauri URL 转换的 PathResolver
|
||||
// Set PathResolver for EngineIntegration that uses Tauri URL conversion
|
||||
this._engineIntegration.setPathResolver({
|
||||
catalogToRuntime: (catalogPath: string): string => {
|
||||
// 空路径直接返回
|
||||
if (!catalogPath) return catalogPath;
|
||||
|
||||
// 已经是 URL 则直接返回
|
||||
if (catalogPath.startsWith('http://') ||
|
||||
catalogPath.startsWith('https://') ||
|
||||
catalogPath.startsWith('data:') ||
|
||||
catalogPath.startsWith('asset://')) {
|
||||
return catalogPath;
|
||||
}
|
||||
|
||||
// 使用 pathTransformerFn 转换路径为 Tauri URL
|
||||
// 路径应该是相对于项目目录的,如 'assets/sparkle_yellow.png'
|
||||
let fullPath = catalogPath;
|
||||
// 如果路径不以 'assets/' 开头,添加前缀
|
||||
if (!catalogPath.startsWith('assets/') && !catalogPath.startsWith('assets\\')) {
|
||||
fullPath = `assets/${catalogPath}`;
|
||||
}
|
||||
return pathTransformerFn(fullPath);
|
||||
},
|
||||
editorToCatalog: (editorPath: string, projectRoot: string): string => {
|
||||
return editorPath; // 不需要在此上下文中使用
|
||||
},
|
||||
setBaseUrl: () => {},
|
||||
getBaseUrl: () => '',
|
||||
normalize: (path: string) => path.replace(/\\/g, '/'),
|
||||
isAbsoluteUrl: (path: string) =>
|
||||
path.startsWith('http://') ||
|
||||
path.startsWith('https://') ||
|
||||
path.startsWith('data:') ||
|
||||
path.startsWith('asset://')
|
||||
});
|
||||
|
||||
this._sceneResourceManager = new SceneResourceManager();
|
||||
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
||||
|
||||
@@ -712,10 +750,15 @@ export class EngineService {
|
||||
return convertFileSrc(absolutePath);
|
||||
}
|
||||
return relativePath;
|
||||
} else {
|
||||
// GUID not found in registry - this could be a timing issue where asset
|
||||
// was just added but not yet registered. Log for debugging.
|
||||
// GUID 在注册表中未找到 - 可能是资源刚添加但尚未注册的时序问题
|
||||
console.warn(`[AssetPathResolver] GUID not found in registry: ${guidOrPath}. Asset may not be registered yet.`);
|
||||
}
|
||||
}
|
||||
// GUID not found, return original value
|
||||
// 未找到 GUID,返回原值
|
||||
// GUID not found, return original value (will result in white block)
|
||||
// 未找到 GUID,返回原值(会显示白块)
|
||||
return guidOrPath;
|
||||
}
|
||||
|
||||
@@ -1029,6 +1072,19 @@ export class EngineService {
|
||||
// 清除 UI 渲染缓存
|
||||
invalidateUIRenderCaches();
|
||||
|
||||
// Reset particle component textureIds before loading resources
|
||||
// 在加载资源前重置粒子组件的 textureId
|
||||
// This ensures ParticleUpdateSystem will reload textures
|
||||
// 这确保 ParticleUpdateSystem 会重新加载纹理
|
||||
if (this._runtime.scene) {
|
||||
for (const entity of this._runtime.scene.entities.buffer) {
|
||||
const particleComponent = entity.getComponent(ParticleSystemComponent);
|
||||
if (particleComponent) {
|
||||
particleComponent.textureId = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载场景资源
|
||||
if (this._sceneResourceManager && this._runtime.scene) {
|
||||
await this._sceneResourceManager.loadSceneResources(this._runtime.scene);
|
||||
@@ -1057,6 +1113,21 @@ export class EngineService {
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load scene resources (textures, audio, etc.)
|
||||
* 加载场景资源(纹理、音频等)
|
||||
*
|
||||
* Used by runtime scene switching in play mode.
|
||||
* 用于 Play 模式下的运行时场景切换。
|
||||
*/
|
||||
async loadSceneResources(): Promise<void> {
|
||||
const scene = this._runtime?.scene;
|
||||
if (!this._sceneResourceManager || !scene) {
|
||||
return;
|
||||
}
|
||||
await this._sceneResourceManager.loadSceneResources(scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a snapshot exists.
|
||||
*/
|
||||
|
||||
591
packages/editor-app/src/services/RenderDebugService.ts
Normal file
591
packages/editor-app/src/services/RenderDebugService.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* 渲染调试服务
|
||||
* Render Debug Service
|
||||
*
|
||||
* 从引擎收集渲染调试数据
|
||||
* Collects render debug data from the engine
|
||||
*/
|
||||
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { UITransformComponent, UIRenderComponent, UITextComponent } from '@esengine/ui';
|
||||
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* 纹理调试信息
|
||||
* Texture debug info
|
||||
*/
|
||||
export interface TextureDebugInfo {
|
||||
id: number;
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
state: 'loading' | 'ready' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite 调试信息
|
||||
* Sprite debug info
|
||||
*/
|
||||
export interface SpriteDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
textureId: number;
|
||||
texturePath: string;
|
||||
/** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */
|
||||
textureUrl?: string;
|
||||
uv: [number, number, number, number];
|
||||
color: string;
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子调试信息
|
||||
* Particle debug info
|
||||
*/
|
||||
export interface ParticleDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
systemName: string;
|
||||
isPlaying: boolean;
|
||||
activeCount: number;
|
||||
maxParticles: number;
|
||||
textureId: number;
|
||||
texturePath: string;
|
||||
/** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */
|
||||
textureUrl?: string;
|
||||
textureSheetAnimation: {
|
||||
enabled: boolean;
|
||||
tilesX: number;
|
||||
tilesY: number;
|
||||
totalFrames: number;
|
||||
} | null;
|
||||
sampleParticles: Array<{
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
frame: number;
|
||||
uv: [number, number, number, number];
|
||||
age: number;
|
||||
lifetime: number;
|
||||
size: number;
|
||||
color: string;
|
||||
alpha: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 元素调试信息
|
||||
* UI element debug info
|
||||
*/
|
||||
export interface UIDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
type: 'rect' | 'image' | 'text' | 'ninepatch' | 'circle' | 'rounded-rect' | 'unknown';
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
rotation: number;
|
||||
visible: boolean;
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
textureGuid?: string;
|
||||
textureUrl?: string;
|
||||
backgroundColor?: string;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试快照
|
||||
* Render debug snapshot
|
||||
*/
|
||||
export interface RenderDebugSnapshot {
|
||||
timestamp: number;
|
||||
frameNumber: number;
|
||||
textures: TextureDebugInfo[];
|
||||
sprites: SpriteDebugInfo[];
|
||||
particles: ParticleDebugInfo[];
|
||||
uiElements: UIDebugInfo[];
|
||||
stats: {
|
||||
totalSprites: number;
|
||||
totalParticles: number;
|
||||
totalUIElements: number;
|
||||
totalTextures: number;
|
||||
drawCalls: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试服务
|
||||
* Render Debug Service
|
||||
*/
|
||||
export class RenderDebugService {
|
||||
private static _instance: RenderDebugService | null = null;
|
||||
private _frameNumber: number = 0;
|
||||
private _enabled: boolean = false;
|
||||
private _snapshots: RenderDebugSnapshot[] = [];
|
||||
private _maxSnapshots: number = 60;
|
||||
|
||||
// 引擎引用 | Engine reference
|
||||
private _engineBridge: any = null;
|
||||
|
||||
static getInstance(): RenderDebugService {
|
||||
if (!RenderDebugService._instance) {
|
||||
RenderDebugService._instance = new RenderDebugService();
|
||||
}
|
||||
return RenderDebugService._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置引擎桥接
|
||||
* Set engine bridge
|
||||
*/
|
||||
setEngineBridge(bridge: any): void {
|
||||
this._engineBridge = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用调试
|
||||
* Enable/disable debugging
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._enabled = enabled;
|
||||
if (!enabled) {
|
||||
this._snapshots = [];
|
||||
}
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
// 纹理 base64 缓存 | Texture base64 cache
|
||||
private _textureCache = new Map<string, string>();
|
||||
private _texturePending = new Set<string>();
|
||||
|
||||
/**
|
||||
* 解析纹理 GUID 为 base64 data URL(从缓存获取)
|
||||
* Resolve texture GUID to base64 data URL (from cache)
|
||||
*/
|
||||
private _resolveTextureUrl(textureGuid: string | null | undefined): string | undefined {
|
||||
if (!textureGuid) return undefined;
|
||||
|
||||
// 从缓存获取 | Get from cache
|
||||
if (this._textureCache.has(textureGuid)) {
|
||||
console.log('[RenderDebugService] Texture from cache:', textureGuid);
|
||||
return this._textureCache.get(textureGuid);
|
||||
}
|
||||
|
||||
// 如果正在加载中,返回 undefined | If loading, return undefined
|
||||
if (this._texturePending.has(textureGuid)) {
|
||||
console.log('[RenderDebugService] Texture loading:', textureGuid);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 异步加载纹理 | Load texture asynchronously
|
||||
console.log('[RenderDebugService] Starting texture load:', textureGuid);
|
||||
this._loadTextureToCache(textureGuid);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步加载纹理到缓存
|
||||
* Load texture to cache asynchronously
|
||||
*/
|
||||
private async _loadTextureToCache(textureGuid: string): Promise<void> {
|
||||
if (this._textureCache.has(textureGuid) || this._texturePending.has(textureGuid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._texturePending.add(textureGuid);
|
||||
|
||||
try {
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
const projectService = Core.services.tryResolve(ProjectService) as { getCurrentProject: () => { path: string } | null } | null;
|
||||
|
||||
let resolvedPath: string | null = null;
|
||||
|
||||
// 检查是否是 GUID 格式 | Check if GUID format
|
||||
const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(textureGuid);
|
||||
|
||||
if (isGuid && assetRegistry) {
|
||||
resolvedPath = assetRegistry.getPathByGuid(textureGuid) || null;
|
||||
} else {
|
||||
resolvedPath = textureGuid;
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
this._texturePending.delete(textureGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是图片 | Check if image
|
||||
const ext = resolvedPath.toLowerCase().split('.').pop() || '';
|
||||
const imageExts: Record<string, string> = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp'
|
||||
};
|
||||
|
||||
const mimeType = imageExts[ext];
|
||||
if (!mimeType) {
|
||||
this._texturePending.delete(textureGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建完整路径 | Build full path
|
||||
const projectPath = projectService?.getCurrentProject()?.path;
|
||||
const fullPath = resolvedPath.startsWith('/') || resolvedPath.includes(':')
|
||||
? resolvedPath
|
||||
: projectPath
|
||||
? `${projectPath}/${resolvedPath}`
|
||||
: resolvedPath;
|
||||
|
||||
// 通过 Tauri command 读取文件并转为 base64 | Read file via Tauri command and convert to base64
|
||||
console.log('[RenderDebugService] Loading texture:', fullPath);
|
||||
const base64 = await invoke<string>('read_file_as_base64', { filePath: fullPath });
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
console.log('[RenderDebugService] Texture loaded, base64 length:', base64.length);
|
||||
this._textureCache.set(textureGuid, dataUrl);
|
||||
} catch (err) {
|
||||
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
|
||||
} finally {
|
||||
this._texturePending.delete(textureGuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集当前帧的调试数据
|
||||
* Collect debug data for current frame
|
||||
*/
|
||||
collectSnapshot(): RenderDebugSnapshot | null {
|
||||
if (!this._enabled) return null;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return null;
|
||||
|
||||
this._frameNumber++;
|
||||
|
||||
const snapshot: RenderDebugSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
frameNumber: this._frameNumber,
|
||||
textures: this._collectTextures(),
|
||||
sprites: this._collectSprites(scene.entities.buffer),
|
||||
particles: this._collectParticles(scene.entities.buffer),
|
||||
uiElements: this._collectUI(scene.entities.buffer),
|
||||
stats: {
|
||||
totalSprites: 0,
|
||||
totalParticles: 0,
|
||||
totalUIElements: 0,
|
||||
totalTextures: 0,
|
||||
drawCalls: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// 计算统计 | Calculate stats
|
||||
snapshot.stats.totalSprites = snapshot.sprites.length;
|
||||
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
|
||||
snapshot.stats.totalUIElements = snapshot.uiElements.length;
|
||||
snapshot.stats.totalTextures = snapshot.textures.length;
|
||||
|
||||
// 保存快照 | Save snapshot
|
||||
this._snapshots.push(snapshot);
|
||||
if (this._snapshots.length > this._maxSnapshots) {
|
||||
this._snapshots.shift();
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新快照
|
||||
* Get latest snapshot
|
||||
*/
|
||||
getLatestSnapshot(): RenderDebugSnapshot | null {
|
||||
return this._snapshots.length > 0 ? this._snapshots[this._snapshots.length - 1] ?? null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有快照
|
||||
* Get all snapshots
|
||||
*/
|
||||
getSnapshots(): RenderDebugSnapshot[] {
|
||||
return [...this._snapshots];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除快照
|
||||
* Clear snapshots
|
||||
*/
|
||||
clearSnapshots(): void {
|
||||
this._snapshots = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集纹理信息
|
||||
* Collect texture info
|
||||
*/
|
||||
private _collectTextures(): TextureDebugInfo[] {
|
||||
const textures: TextureDebugInfo[] = [];
|
||||
|
||||
// TODO: 从 EngineBridge 获取纹理管理器数据
|
||||
// TODO: Get texture manager data from EngineBridge
|
||||
if (this._engineBridge) {
|
||||
// const textureManager = this._engineBridge.getTextureManager();
|
||||
// for (const [id, tex] of textureManager.entries()) {
|
||||
// textures.push({ ... });
|
||||
// }
|
||||
}
|
||||
|
||||
return textures;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 Sprite 信息
|
||||
* Collect sprite info
|
||||
*/
|
||||
private _collectSprites(entities: readonly Entity[]): SpriteDebugInfo[] {
|
||||
const sprites: SpriteDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const sprite = entity.getComponent(SpriteComponent);
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
|
||||
if (!sprite || !transform) continue;
|
||||
|
||||
const pos = transform.worldPosition ?? transform.position;
|
||||
const rot = typeof transform.rotation === 'number'
|
||||
? transform.rotation
|
||||
: transform.rotation.z;
|
||||
|
||||
const textureGuid = sprite.textureGuid ?? '';
|
||||
sprites.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotation: rot,
|
||||
textureId: (sprite as any).textureId ?? 0,
|
||||
texturePath: textureGuid,
|
||||
textureUrl: this._resolveTextureUrl(textureGuid),
|
||||
uv: [...sprite.uv] as [number, number, number, number],
|
||||
color: sprite.color,
|
||||
alpha: sprite.alpha,
|
||||
sortingLayer: sprite.sortingLayer,
|
||||
orderInLayer: sprite.orderInLayer,
|
||||
});
|
||||
}
|
||||
|
||||
return sprites;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集粒子系统信息
|
||||
* Collect particle system info
|
||||
*/
|
||||
private _collectParticles(entities: readonly Entity[]): ParticleDebugInfo[] {
|
||||
const particleSystems: ParticleDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const ps = entity.getComponent(ParticleSystemComponent);
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
|
||||
if (!ps) continue;
|
||||
|
||||
const pool = ps.pool;
|
||||
|
||||
// 通过 getModule 获取 TextureSheetAnimation 模块 | Get TextureSheetAnimation module via getModule
|
||||
const textureSheetAnim = ps.getModule?.('TextureSheetAnimation') as any;
|
||||
|
||||
// 收集所有活跃粒子 | Collect all active particles
|
||||
const sampleParticles: ParticleDebugInfo['sampleParticles'] = [];
|
||||
if (pool) {
|
||||
let count = 0;
|
||||
pool.forEachActive((p: any) => {
|
||||
const tilesX = p._animTilesX ?? 1;
|
||||
const tilesY = p._animTilesY ?? 1;
|
||||
const frame = p._animFrame ?? 0;
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
|
||||
sampleParticles.push({
|
||||
index: count,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
frame,
|
||||
uv: [
|
||||
col * uWidth,
|
||||
row * vHeight,
|
||||
(col + 1) * uWidth,
|
||||
(row + 1) * vHeight,
|
||||
],
|
||||
age: p.age,
|
||||
lifetime: p.lifetime,
|
||||
size: p.size ?? p.startSize ?? 1,
|
||||
color: p.color ?? '#ffffff',
|
||||
alpha: p.alpha ?? 1,
|
||||
});
|
||||
count++;
|
||||
});
|
||||
}
|
||||
|
||||
// 获取模块的 tilesX/tilesY | Get tilesX/tilesY from module
|
||||
const tilesX = textureSheetAnim?.tilesX ?? 1;
|
||||
const tilesY = textureSheetAnim?.tilesY ?? 1;
|
||||
const totalFrames = textureSheetAnim?.actualTotalFrames ?? (tilesX * tilesY);
|
||||
|
||||
const textureGuid = ps.textureGuid ?? '';
|
||||
particleSystems.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
systemName: `ParticleSystem_${entity.id}`,
|
||||
isPlaying: ps.isPlaying,
|
||||
activeCount: pool?.activeCount ?? 0,
|
||||
maxParticles: ps.maxParticles,
|
||||
textureId: ps.textureId ?? 0,
|
||||
texturePath: textureGuid,
|
||||
textureUrl: this._resolveTextureUrl(textureGuid),
|
||||
textureSheetAnimation: textureSheetAnim?.enabled ? {
|
||||
enabled: true,
|
||||
tilesX,
|
||||
tilesY,
|
||||
totalFrames,
|
||||
} : null,
|
||||
sampleParticles,
|
||||
});
|
||||
}
|
||||
|
||||
return particleSystems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 UI 元素信息
|
||||
* Collect UI element info
|
||||
*/
|
||||
private _collectUI(entities: readonly Entity[]): UIDebugInfo[] {
|
||||
const uiElements: UIDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
|
||||
if (!uiTransform) continue;
|
||||
|
||||
const uiRender = entity.getComponent(UIRenderComponent);
|
||||
const uiText = entity.getComponent(UITextComponent);
|
||||
|
||||
// 确定类型 | Determine type
|
||||
let type: UIDebugInfo['type'] = 'unknown';
|
||||
if (uiText) {
|
||||
type = 'text';
|
||||
} else if (uiRender) {
|
||||
switch (uiRender.type) {
|
||||
case 'rect': type = 'rect'; break;
|
||||
case 'image': type = 'image'; break;
|
||||
case 'ninepatch': type = 'ninepatch'; break;
|
||||
case 'circle': type = 'circle'; break;
|
||||
case 'rounded-rect': type = 'rounded-rect'; break;
|
||||
default: type = 'rect';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取纹理 GUID | Get texture GUID
|
||||
const textureGuid = uiRender?.textureGuid?.toString() ?? '';
|
||||
|
||||
// 转换颜色为十六进制字符串 | Convert color to hex string
|
||||
const backgroundColor = uiRender?.backgroundColor !== undefined
|
||||
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
|
||||
: undefined;
|
||||
|
||||
uiElements.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
type,
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
width: uiTransform.width,
|
||||
height: uiTransform.height,
|
||||
worldX: uiTransform.worldX,
|
||||
worldY: uiTransform.worldY,
|
||||
rotation: uiTransform.rotation,
|
||||
visible: uiTransform.visible && uiTransform.worldVisible,
|
||||
alpha: uiTransform.worldAlpha,
|
||||
sortingLayer: uiTransform.sortingLayer,
|
||||
orderInLayer: uiTransform.orderInLayer,
|
||||
textureGuid: textureGuid || undefined,
|
||||
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
|
||||
backgroundColor,
|
||||
text: uiText?.text,
|
||||
fontSize: uiText?.fontSize,
|
||||
});
|
||||
}
|
||||
|
||||
return uiElements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出调试数据为 JSON
|
||||
* Export debug data as JSON
|
||||
*/
|
||||
exportAsJSON(): string {
|
||||
return JSON.stringify({
|
||||
exportTime: new Date().toISOString(),
|
||||
snapshots: this._snapshots,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印当前粒子 UV 到控制台
|
||||
* Print current particle UVs to console
|
||||
*/
|
||||
logParticleUVs(): void {
|
||||
const snapshot = this.collectSnapshot();
|
||||
if (!snapshot) {
|
||||
console.log('[RenderDebugService] No scene available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.group('[RenderDebugService] Particle UV Debug');
|
||||
for (const ps of snapshot.particles) {
|
||||
console.group(`${ps.entityName} (${ps.activeCount} active)`);
|
||||
if (ps.textureSheetAnimation) {
|
||||
console.log(`TextureSheetAnimation: ${ps.textureSheetAnimation.tilesX}x${ps.textureSheetAnimation.tilesY}`);
|
||||
}
|
||||
for (const p of ps.sampleParticles) {
|
||||
console.log(` Particle ${p.index}: frame=${p.frame}, UV=[${p.uv.map(v => v.toFixed(3)).join(', ')}]`);
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// 全局实例 | Global instance
|
||||
export const renderDebugService = RenderDebugService.getInstance();
|
||||
|
||||
// 导出到全局以便控制台使用 | Export to global for console usage
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).renderDebugService = renderDebugService;
|
||||
}
|
||||
@@ -126,3 +126,52 @@
|
||||
.confirm-dialog-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* External Modification Dialog | 外部修改对话框 */
|
||||
.external-modification-dialog .warning-icon {
|
||||
color: #f0ad4e;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.external-modification-dialog .confirm-dialog-header {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.external-modification-dialog .confirm-dialog-header h2 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.external-modification-dialog .hint-text {
|
||||
margin-top: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.external-modification-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.reload {
|
||||
background: #5bc0de;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.reload:hover {
|
||||
background: #7cd0e8;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.overwrite {
|
||||
background: #f0ad4e;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.overwrite:hover {
|
||||
background: #f4be6e;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Unified Plugin Manager
|
||||
*/
|
||||
|
||||
import { createLogger, ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { createLogger, GlobalComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IScene, ServiceContainer, IService } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
ModuleManifest,
|
||||
@@ -670,9 +670,9 @@ export class PluginManager implements IService {
|
||||
// 注册组件(使用包装的 Registry 来跟踪)
|
||||
// Register components (use wrapped registry to track)
|
||||
if (runtimeModule.registerComponents) {
|
||||
const componentsBefore = new Set(ComponentRegistry.getRegisteredComponents().map(c => c.name));
|
||||
runtimeModule.registerComponents(ComponentRegistry);
|
||||
const componentsAfter = ComponentRegistry.getRegisteredComponents();
|
||||
const componentsBefore = new Set(GlobalComponentRegistry.getRegisteredComponents().map(c => c.name));
|
||||
runtimeModule.registerComponents(GlobalComponentRegistry);
|
||||
const componentsAfter = GlobalComponentRegistry.getRegisteredComponents();
|
||||
|
||||
// 跟踪新注册的组件
|
||||
// Track newly registered components
|
||||
@@ -779,7 +779,7 @@ export class PluginManager implements IService {
|
||||
if (resources.componentTypeNames.length > 0) {
|
||||
for (const componentName of resources.componentTypeNames) {
|
||||
try {
|
||||
ComponentRegistry.unregister(componentName);
|
||||
GlobalComponentRegistry.unregister(componentName);
|
||||
logger.debug(`Component unregistered: ${componentName}`);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to unregister component ${componentName}:`, e);
|
||||
@@ -900,7 +900,7 @@ export class PluginManager implements IService {
|
||||
const runtimeModule = plugin.plugin.runtimeModule;
|
||||
if (runtimeModule?.registerComponents) {
|
||||
try {
|
||||
runtimeModule.registerComponents(ComponentRegistry);
|
||||
runtimeModule.registerComponents(GlobalComponentRegistry);
|
||||
logger.debug(`Components registered for: ${pluginId}`);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to register components for ${pluginId}:`, e);
|
||||
|
||||
@@ -394,8 +394,14 @@ export class AssetRegistryService implements IService {
|
||||
// 处理文件创建 - 注册新资产并生成 .meta
|
||||
if (changeType === 'create' || changeType === 'modify') {
|
||||
for (const absolutePath of paths) {
|
||||
// Skip .meta files
|
||||
if (absolutePath.endsWith('.meta')) continue;
|
||||
// Handle .meta file changes - invalidate cache
|
||||
// 处理 .meta 文件变化 - 使缓存失效
|
||||
if (absolutePath.endsWith('.meta')) {
|
||||
const assetPath = absolutePath.slice(0, -5); // Remove '.meta' suffix
|
||||
this._metaManager.invalidateCache(assetPath);
|
||||
logger.debug(`Meta file changed, invalidated cache for: ${assetPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process files in managed directories
|
||||
// 只处理托管目录中的文件
|
||||
@@ -406,8 +412,14 @@ export class AssetRegistryService implements IService {
|
||||
}
|
||||
} else if (changeType === 'remove') {
|
||||
for (const absolutePath of paths) {
|
||||
// Skip .meta files
|
||||
if (absolutePath.endsWith('.meta')) continue;
|
||||
// Handle .meta file deletion - invalidate cache
|
||||
// 处理 .meta 文件删除 - 使缓存失效
|
||||
if (absolutePath.endsWith('.meta')) {
|
||||
const assetPath = absolutePath.slice(0, -5);
|
||||
this._metaManager.invalidateCache(assetPath);
|
||||
logger.debug(`Meta file removed, invalidated cache for: ${assetPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process files in managed directories
|
||||
// 只处理托管目录中的文件
|
||||
|
||||
@@ -95,6 +95,9 @@ export class EntityStoreService implements IService {
|
||||
this.entities.clear();
|
||||
this.rootEntityIds = [];
|
||||
|
||||
// 调试:打印场景实体信息 | Debug: print scene entity info
|
||||
logger.info(`[syncFromScene] Scene name: ${scene.name}, entities.count: ${scene.entities.count}`);
|
||||
|
||||
let entityCount = 0;
|
||||
scene.entities.forEach((entity) => {
|
||||
entityCount++;
|
||||
@@ -106,7 +109,7 @@ export class EntityStoreService implements IService {
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`syncFromScene: synced ${entityCount} entities, ${this.rootEntityIds.length} root entities`);
|
||||
logger.info(`[syncFromScene] Synced ${entityCount} entities, ${this.rootEntityIds.length} root entities`);
|
||||
if (this.rootEntityIds.length > 0) {
|
||||
const rootNames = this.rootEntityIds
|
||||
.map(id => this.entities.get(id)?.name)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Scene,
|
||||
PrefabSerializer,
|
||||
HierarchySystem,
|
||||
ComponentRegistry
|
||||
GlobalComponentRegistry
|
||||
} from '@esengine/ecs-framework';
|
||||
import type { ComponentType } from '@esengine/ecs-framework';
|
||||
import type { SceneResourceManager } from '@esengine/asset-system';
|
||||
@@ -24,6 +24,10 @@ export interface SceneState {
|
||||
sceneName: string;
|
||||
isModified: boolean;
|
||||
isSaved: boolean;
|
||||
/** 文件最后已知的修改时间(毫秒)| Last known file modification time (ms) */
|
||||
lastKnownMtime: number | null;
|
||||
/** 文件是否被外部修改 | Whether file was modified externally */
|
||||
externallyModified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +59,9 @@ export class SceneManagerService implements IService {
|
||||
currentScenePath: null,
|
||||
sceneName: 'Untitled',
|
||||
isModified: false,
|
||||
isSaved: false
|
||||
isSaved: false,
|
||||
lastKnownMtime: null,
|
||||
externallyModified: false
|
||||
};
|
||||
|
||||
/** 预制体编辑模式状态 | Prefab edit mode state */
|
||||
@@ -118,7 +124,9 @@ export class SceneManagerService implements IService {
|
||||
currentScenePath: null,
|
||||
sceneName: 'Untitled',
|
||||
isModified: false,
|
||||
isSaved: false
|
||||
isSaved: false,
|
||||
lastKnownMtime: null,
|
||||
externallyModified: false
|
||||
};
|
||||
|
||||
// 同步到 EntityStore
|
||||
@@ -148,6 +156,18 @@ export class SceneManagerService implements IService {
|
||||
}
|
||||
}
|
||||
|
||||
// 在加载新场景前,清理旧场景的纹理映射(释放 GPU 资源)
|
||||
// Before loading new scene, clear old scene's texture mappings (release GPU resources)
|
||||
// 注意:路径稳定 ID 缓存 (_pathIdCache) 不会被清除
|
||||
// Note: Path-stable ID cache (_pathIdCache) is NOT cleared
|
||||
if (this.sceneResourceManager) {
|
||||
const oldScene = Core.scene as Scene | null;
|
||||
if (oldScene && this.sceneState.currentScenePath) {
|
||||
logger.info(`[openScene] Unloading old scene resources from: ${this.sceneState.currentScenePath}`);
|
||||
await this.sceneResourceManager.unloadSceneResources(oldScene);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonData = await this.fileAPI.readFileContent(path);
|
||||
|
||||
@@ -165,10 +185,42 @@ export class SceneManagerService implements IService {
|
||||
// Ensure isEditorMode is set in editor to defer component lifecycle callbacks
|
||||
scene.isEditorMode = true;
|
||||
|
||||
// 调试:检查缺失的组件类型 | Debug: check missing component types
|
||||
const registeredComponents = GlobalComponentRegistry.getAllComponentNames();
|
||||
try {
|
||||
const sceneData = JSON.parse(jsonData);
|
||||
const requiredTypes = new Set<string>();
|
||||
for (const entity of sceneData.entities || []) {
|
||||
for (const comp of entity.components || []) {
|
||||
requiredTypes.add(comp.type);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查缺失的组件类型 | Check missing component types
|
||||
const missingTypes = Array.from(requiredTypes).filter(t => !registeredComponents.has(t));
|
||||
if (missingTypes.length > 0) {
|
||||
logger.warn(`[SceneManagerService.openScene] Missing component types (scene will load without these):`, missingTypes);
|
||||
logger.debug(`Registered components (${registeredComponents.size}):`, Array.from(registeredComponents.keys()));
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON parsing should not fail at this point since we validated earlier
|
||||
}
|
||||
|
||||
// 调试:反序列化前场景状态 | Debug: scene state before deserialize
|
||||
logger.info(`[openScene] Before deserialize: entities.count = ${scene.entities.count}`);
|
||||
|
||||
scene.deserialize(jsonData, {
|
||||
strategy: 'replace'
|
||||
});
|
||||
|
||||
// 调试:反序列化后场景状态 | Debug: scene state after deserialize
|
||||
logger.info(`[openScene] After deserialize: entities.count = ${scene.entities.count}`);
|
||||
if (scene.entities.count > 0) {
|
||||
const entityNames: string[] = [];
|
||||
scene.entities.forEach(e => entityNames.push(e.name));
|
||||
logger.info(`[openScene] Entity names: ${entityNames.join(', ')}`);
|
||||
}
|
||||
|
||||
// 加载场景资源 / Load scene resources
|
||||
if (this.sceneResourceManager) {
|
||||
await this.sceneResourceManager.loadSceneResources(scene);
|
||||
@@ -179,11 +231,23 @@ export class SceneManagerService implements IService {
|
||||
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
|
||||
const sceneName = fileName.replace('.ecs', '');
|
||||
|
||||
// 获取文件修改时间 | Get file modification time
|
||||
let mtime: number | null = null;
|
||||
if (this.fileAPI.getFileMtime) {
|
||||
try {
|
||||
mtime = await this.fileAPI.getFileMtime(path);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to get file mtime:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.sceneState = {
|
||||
currentScenePath: path,
|
||||
sceneName,
|
||||
isModified: false,
|
||||
isSaved: true
|
||||
isSaved: true,
|
||||
lastKnownMtime: mtime,
|
||||
externallyModified: false
|
||||
};
|
||||
|
||||
this.entityStore?.syncFromScene();
|
||||
@@ -200,12 +264,22 @@ export class SceneManagerService implements IService {
|
||||
}
|
||||
}
|
||||
|
||||
public async saveScene(): Promise<void> {
|
||||
public async saveScene(force: boolean = false): Promise<void> {
|
||||
if (!this.sceneState.currentScenePath) {
|
||||
await this.saveSceneAs();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查文件是否被外部修改 | Check if file was modified externally
|
||||
if (!force && await this.checkExternalModification()) {
|
||||
// 发布事件让 UI 显示确认对话框 | Publish event for UI to show confirmation dialog
|
||||
await this.messageHub.publish('scene:externalModification', {
|
||||
path: this.sceneState.currentScenePath,
|
||||
sceneName: this.sceneState.sceneName
|
||||
});
|
||||
return; // 等待用户确认 | Wait for user confirmation
|
||||
}
|
||||
|
||||
try {
|
||||
const scene = Core.scene as Scene | null;
|
||||
if (!scene) {
|
||||
@@ -219,8 +293,18 @@ export class SceneManagerService implements IService {
|
||||
|
||||
await this.fileAPI.saveProject(this.sceneState.currentScenePath, jsonData);
|
||||
|
||||
// 更新 mtime | Update mtime
|
||||
if (this.fileAPI.getFileMtime) {
|
||||
try {
|
||||
this.sceneState.lastKnownMtime = await this.fileAPI.getFileMtime(this.sceneState.currentScenePath);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to update file mtime after save:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.sceneState.isModified = false;
|
||||
this.sceneState.isSaved = true;
|
||||
this.sceneState.externallyModified = false;
|
||||
|
||||
await this.messageHub.publish('scene:saved', {
|
||||
path: this.sceneState.currentScenePath
|
||||
@@ -232,6 +316,89 @@ export class SceneManagerService implements IService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查场景文件是否被外部修改
|
||||
* Check if scene file was modified externally
|
||||
*
|
||||
* @returns true 如果文件被外部修改 | true if file was modified externally
|
||||
*/
|
||||
public async checkExternalModification(): Promise<boolean> {
|
||||
const path = this.sceneState.currentScenePath;
|
||||
const lastMtime = this.sceneState.lastKnownMtime;
|
||||
|
||||
if (!path || lastMtime === null || !this.fileAPI.getFileMtime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentMtime = await this.fileAPI.getFileMtime(path);
|
||||
const isModified = currentMtime > lastMtime;
|
||||
|
||||
if (isModified) {
|
||||
this.sceneState.externallyModified = true;
|
||||
logger.warn(`Scene file externally modified: ${path} (${lastMtime} -> ${currentMtime})`);
|
||||
}
|
||||
|
||||
return isModified;
|
||||
} catch (e) {
|
||||
logger.warn('Failed to check file mtime:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载当前场景(放弃本地更改)
|
||||
* Reload current scene (discard local changes)
|
||||
*/
|
||||
public async reloadScene(): Promise<void> {
|
||||
const path = this.sceneState.currentScenePath;
|
||||
if (!path) {
|
||||
logger.warn('No scene to reload');
|
||||
return;
|
||||
}
|
||||
|
||||
// 强制打开场景,绕过修改检查 | Force open scene, bypass modification check
|
||||
const scene = Core.scene as Scene | null;
|
||||
if (!scene) {
|
||||
throw new Error('No active scene');
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonData = await this.fileAPI.readFileContent(path);
|
||||
const validation = SceneSerializer.validate(jsonData);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`场景文件损坏: ${validation.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
scene.isEditorMode = true;
|
||||
scene.deserialize(jsonData, { strategy: 'replace' });
|
||||
|
||||
if (this.sceneResourceManager) {
|
||||
await this.sceneResourceManager.loadSceneResources(scene);
|
||||
}
|
||||
|
||||
// 更新 mtime | Update mtime
|
||||
if (this.fileAPI.getFileMtime) {
|
||||
try {
|
||||
this.sceneState.lastKnownMtime = await this.fileAPI.getFileMtime(path);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to update file mtime after reload:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.sceneState.isModified = false;
|
||||
this.sceneState.isSaved = true;
|
||||
this.sceneState.externallyModified = false;
|
||||
|
||||
this.entityStore?.syncFromScene();
|
||||
await this.messageHub.publish('scene:reloaded', { path });
|
||||
logger.info(`Scene reloaded: ${path}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to reload scene:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async saveSceneAs(filePath?: string): Promise<void> {
|
||||
let path: string | null | undefined = filePath;
|
||||
if (!path) {
|
||||
@@ -269,11 +436,23 @@ export class SceneManagerService implements IService {
|
||||
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
|
||||
const sceneName = fileName.replace('.ecs', '');
|
||||
|
||||
// 获取文件修改时间 | Get file modification time
|
||||
let mtime: number | null = null;
|
||||
if (this.fileAPI.getFileMtime) {
|
||||
try {
|
||||
mtime = await this.fileAPI.getFileMtime(path);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to get file mtime after save:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.sceneState = {
|
||||
currentScenePath: path,
|
||||
sceneName,
|
||||
isModified: false,
|
||||
isSaved: true
|
||||
isSaved: true,
|
||||
lastKnownMtime: mtime,
|
||||
externallyModified: false
|
||||
};
|
||||
|
||||
await this.messageHub.publish('scene:saved', { path });
|
||||
@@ -405,11 +584,11 @@ export class SceneManagerService implements IService {
|
||||
}
|
||||
|
||||
// 6. 获取组件注册表 | Get component registry
|
||||
// ComponentRegistry.getAllComponentNames() 返回 Map<string, Function>
|
||||
// GlobalComponentRegistry.getAllComponentNames() 返回 Map<string, Function>
|
||||
// 需要转换为 Map<string, ComponentType>
|
||||
const nameToType = ComponentRegistry.getAllComponentNames();
|
||||
const nameToType = GlobalComponentRegistry.getAllComponentNames();
|
||||
const componentRegistry = new Map<string, ComponentType>();
|
||||
nameToType.forEach((type, name) => {
|
||||
nameToType.forEach((type: Function, name: string) => {
|
||||
componentRegistry.set(name, type as ComponentType);
|
||||
});
|
||||
|
||||
@@ -471,7 +650,9 @@ export class SceneManagerService implements IService {
|
||||
currentScenePath: null,
|
||||
sceneName: `Prefab: ${prefabName}`,
|
||||
isModified: false,
|
||||
isSaved: true
|
||||
isSaved: true,
|
||||
lastKnownMtime: null,
|
||||
externallyModified: false
|
||||
};
|
||||
|
||||
// 11. 同步到 EntityStore | Sync to EntityStore
|
||||
@@ -537,7 +718,9 @@ export class SceneManagerService implements IService {
|
||||
currentScenePath: originalState.originalScenePath,
|
||||
sceneName: originalState.originalSceneName,
|
||||
isModified: originalState.originalSceneModified,
|
||||
isSaved: !originalState.originalSceneModified
|
||||
isSaved: !originalState.originalSceneModified,
|
||||
lastKnownMtime: null,
|
||||
externallyModified: false
|
||||
};
|
||||
|
||||
// 5. 清除预制体编辑模式状态 | Clear prefab edit mode state
|
||||
|
||||
@@ -71,16 +71,14 @@ export interface UserCodeCompileOptions {
|
||||
sourceMap?: boolean;
|
||||
/** Whether to minify output | 是否压缩输出 */
|
||||
minify?: boolean;
|
||||
/** Output format | 输出格式 */
|
||||
/** Output format (default: 'esm') | 输出格式(默认:'esm')*/
|
||||
format?: 'esm' | 'iife';
|
||||
/**
|
||||
* SDK modules for shim generation.
|
||||
* 用于生成 shim 的 SDK 模块列表。
|
||||
* SDK modules information (reserved for future use).
|
||||
* SDK 模块信息(保留供将来使用)。
|
||||
*
|
||||
* If provided, shims will be created for these modules.
|
||||
* Typically obtained from RuntimeResolver.getAvailableModules().
|
||||
* 如果提供,将为这些模块创建 shim。
|
||||
* 通常从 RuntimeResolver.getAvailableModules() 获取。
|
||||
* Currently SDK is handled via external dependencies and global variable.
|
||||
* 当前 SDK 通过外部依赖和全局变量处理。
|
||||
*/
|
||||
sdkModules?: SDKModuleInfo[];
|
||||
}
|
||||
@@ -382,6 +380,37 @@ export interface IUserCodeService {
|
||||
* 检查是否正在监视。
|
||||
*/
|
||||
isWatching(): boolean;
|
||||
|
||||
/**
|
||||
* Wait for user code to be ready (compiled and loaded).
|
||||
* 等待用户代码准备就绪(已编译并加载)。
|
||||
*
|
||||
* This method is used to synchronize scene loading with user code compilation.
|
||||
* Call this before loading a scene to ensure user components are registered.
|
||||
* 此方法用于同步场景加载与用户代码编译。
|
||||
* 在加载场景之前调用此方法以确保用户组件已注册。
|
||||
*
|
||||
* @returns Promise that resolves when user code is ready | 当用户代码就绪时解决的 Promise
|
||||
*/
|
||||
waitForReady(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Signal that user code is ready.
|
||||
* 发出用户代码就绪信号。
|
||||
*
|
||||
* Called after user code compilation and registration is complete.
|
||||
* 在用户代码编译和注册完成后调用。
|
||||
*/
|
||||
signalReady(): void;
|
||||
|
||||
/**
|
||||
* Reset the ready state (for project switching).
|
||||
* 重置就绪状态(用于项目切换)。
|
||||
*
|
||||
* Called when opening a new project to reset the ready promise.
|
||||
* 打开新项目时调用以重置就绪 Promise。
|
||||
*/
|
||||
resetReady(): void;
|
||||
}
|
||||
|
||||
import { EditorConfig } from '../../Config';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Injectable,
|
||||
createLogger,
|
||||
PlatformDetector,
|
||||
ComponentRegistry as CoreComponentRegistry,
|
||||
GlobalComponentRegistry as CoreComponentRegistry,
|
||||
COMPONENT_TYPE_NAME,
|
||||
SYSTEM_TYPE_NAME
|
||||
} from '@esengine/ecs-framework';
|
||||
@@ -82,9 +82,27 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
*/
|
||||
private _hotReloadCoordinator: HotReloadCoordinator;
|
||||
|
||||
/**
|
||||
* 就绪状态 Promise
|
||||
* Ready state promise
|
||||
*/
|
||||
private _readyPromise: Promise<void>;
|
||||
private _readyResolve: (() => void) | undefined;
|
||||
|
||||
constructor(fileSystem: IFileSystem) {
|
||||
this._fileSystem = fileSystem;
|
||||
this._hotReloadCoordinator = new HotReloadCoordinator();
|
||||
this._readyPromise = this._createReadyPromise();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ready promise.
|
||||
* 创建新的就绪 Promise。
|
||||
*/
|
||||
private _createReadyPromise(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
this._readyResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,28 +208,20 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
const entryPath = `${outputDir}${sep}_entry_${options.target}.ts`;
|
||||
await this._fileSystem.writeFile(entryPath, entryContent);
|
||||
|
||||
// Create shim files for framework dependencies | 创建框架依赖的 shim 文件
|
||||
// Returns mapping from package name to shim path
|
||||
// 返回包名到 shim 路径的映射
|
||||
const alias = await this._createDependencyShims(outputDir, options.sdkModules);
|
||||
|
||||
// Determine global name for IIFE output | 确定 IIFE 输出的全局名称
|
||||
const globalName = options.target === UserCodeTarget.Runtime
|
||||
? EditorConfig.globals.userRuntimeExports
|
||||
: EditorConfig.globals.userEditorExports;
|
||||
// Get external dependencies | 获取外部依赖
|
||||
// SDK marked as external, resolved from global variable at runtime
|
||||
// SDK 标记为外部依赖,运行时从全局变量解析
|
||||
const external = this._getExternalDependencies(options.target, options.sdkModules);
|
||||
|
||||
// Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译
|
||||
// Use IIFE format to avoid ES module import issues in Tauri
|
||||
// 使用 IIFE 格式以避免 Tauri 中的 ES 模块导入问题
|
||||
// Use ESM format for dynamic import() loading | 使用 ESM 格式以支持动态 import() 加载
|
||||
const compileResult = await this._runEsbuild({
|
||||
entryPath,
|
||||
outputPath,
|
||||
format: 'iife', // Always use IIFE for Tauri compatibility | 始终使用 IIFE 以兼容 Tauri
|
||||
globalName,
|
||||
format: 'esm', // ESM for standard dynamic import() | ESM 用于标准动态 import()
|
||||
sourceMap: options.sourceMap ?? true,
|
||||
minify: options.minify ?? false,
|
||||
external: [], // Don't use external, use alias instead | 不使用 external,使用 alias
|
||||
alias,
|
||||
external,
|
||||
projectRoot: options.projectPath
|
||||
});
|
||||
|
||||
@@ -259,6 +269,14 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
* Load compiled user code module.
|
||||
* 加载编译后的用户代码模块。
|
||||
*
|
||||
* Uses Blob URL for ESM dynamic import in Tauri environment.
|
||||
* 在 Tauri 环境中使用 Blob URL 进行 ESM 动态导入。
|
||||
*
|
||||
* Note: Browser's import() only supports http://, https://, and blob:// protocols.
|
||||
* Custom protocols like project:// are not supported for ESM imports.
|
||||
* 注意:浏览器的 import() 只支持 http://、https:// 和 blob:// 协议。
|
||||
* 自定义协议如 project:// 不支持 ESM 导入。
|
||||
*
|
||||
* @param modulePath - Path to compiled JS file | 编译后的 JS 文件路径
|
||||
* @param target - Target environment | 目标环境
|
||||
* @returns Loaded module | 加载的模块
|
||||
@@ -268,20 +286,23 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
let moduleExports: Record<string, any>;
|
||||
|
||||
if (PlatformDetector.isTauriEnvironment()) {
|
||||
// In Tauri, read file content and execute via script tag
|
||||
// 在 Tauri 中,读取文件内容并通过 script 标签执行
|
||||
// This avoids CORS and module resolution issues
|
||||
// 这避免了 CORS 和模块解析问题
|
||||
// Read file content via Tauri and load via Blob URL
|
||||
// 通过 Tauri 读取文件内容并通过 Blob URL 加载
|
||||
// Browser's import() doesn't support custom protocols like project://
|
||||
// 浏览器的 import() 不支持自定义协议如 project://
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
|
||||
const content = await invoke<string>('read_file_content', {
|
||||
path: modulePath
|
||||
});
|
||||
|
||||
logger.debug(`Loading module via script injection`, { originalPath: modulePath });
|
||||
logger.debug(`Loading ESM module via Blob URL`, {
|
||||
path: modulePath,
|
||||
contentLength: content.length
|
||||
});
|
||||
|
||||
// Execute module code and capture exports | 执行模块代码并捕获导出
|
||||
moduleExports = await this._executeModuleCode(content, target);
|
||||
// Load ESM via Blob URL | 通过 Blob URL 加载 ESM
|
||||
moduleExports = await this._loadESMFromContent(content);
|
||||
} else {
|
||||
// Fallback to file:// for non-Tauri environments
|
||||
// 非 Tauri 环境使用 file://
|
||||
@@ -924,6 +945,35 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
return this._watching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for user code to be ready (compiled and loaded).
|
||||
* 等待用户代码准备就绪(已编译并加载)。
|
||||
*
|
||||
* @returns Promise that resolves when user code is ready | 当用户代码就绪时解决的 Promise
|
||||
*/
|
||||
waitForReady(): Promise<void> {
|
||||
return this._readyPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that user code is ready.
|
||||
* 发出用户代码就绪信号。
|
||||
*/
|
||||
signalReady(): void {
|
||||
if (this._readyResolve) {
|
||||
this._readyResolve();
|
||||
this._readyResolve = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the ready state (for project switching).
|
||||
* 重置就绪状态(用于项目切换)。
|
||||
*/
|
||||
resetReady(): void {
|
||||
this._readyPromise = this._createReadyPromise();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose service resources.
|
||||
* 释放服务资源。
|
||||
@@ -1058,44 +1108,6 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create shim file that maps SDK global variable to module import.
|
||||
* 创建将 SDK 全局变量映射到模块导入的 shim 文件。
|
||||
*
|
||||
* This is used for IIFE format to resolve external dependencies.
|
||||
* Creates a single shim for @esengine/sdk.
|
||||
* 这用于 IIFE 格式解析外部依赖。
|
||||
* 只创建一个 @esengine/sdk 的 shim。
|
||||
*
|
||||
* @param outputDir - Output directory | 输出目录
|
||||
* @param _sdkModules - Deprecated, not used | 已废弃,不再使用
|
||||
* @returns Mapping from package name to shim path | 包名到 shim 路径的映射
|
||||
*/
|
||||
private async _createDependencyShims(
|
||||
outputDir: string,
|
||||
_sdkModules?: SDKModuleInfo[]
|
||||
): Promise<Record<string, string>> {
|
||||
const sep = outputDir.includes('\\') ? '\\' : '/';
|
||||
const sdkGlobalName = EditorConfig.globals.sdk;
|
||||
|
||||
// Create single SDK shim
|
||||
// 创建单一 SDK shim
|
||||
const shimPath = `${outputDir}${sep}_shim_sdk.js`;
|
||||
const shimContent = `// Shim for @esengine/sdk
|
||||
// Maps to window.${sdkGlobalName}
|
||||
// User code imports from '@esengine/sdk' will use this shim
|
||||
module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {};
|
||||
`;
|
||||
await this._fileSystem.writeFile(shimPath, shimContent);
|
||||
const normalizedPath = shimPath.replace(/\\/g, '/');
|
||||
|
||||
logger.info('Created SDK shim', { path: normalizedPath });
|
||||
|
||||
return {
|
||||
'@esengine/sdk': normalizedPath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get external dependencies that should not be bundled.
|
||||
* 获取不应打包的外部依赖。
|
||||
@@ -1122,16 +1134,24 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
|
||||
*
|
||||
* Uses Tauri command to invoke esbuild CLI.
|
||||
* 使用 Tauri 命令调用 esbuild CLI。
|
||||
*
|
||||
* @param options - Compilation options | 编译选项
|
||||
* @returns Compilation result | 编译结果
|
||||
*/
|
||||
private async _runEsbuild(options: {
|
||||
/** Entry file path | 入口文件路径 */
|
||||
entryPath: string;
|
||||
/** Output file path | 输出文件路径 */
|
||||
outputPath: string;
|
||||
/** Output format (ESM for dynamic import) | 输出格式(ESM 用于动态导入)*/
|
||||
format: 'esm' | 'iife';
|
||||
globalName?: string;
|
||||
/** Generate source maps | 生成源码映射 */
|
||||
sourceMap: boolean;
|
||||
/** Minify output | 压缩输出 */
|
||||
minify: boolean;
|
||||
/** External dependencies (not bundled) | 外部依赖(不打包)*/
|
||||
external: string[];
|
||||
alias?: Record<string, string>;
|
||||
/** Project root for resolving paths | 项目根路径用于解析路径 */
|
||||
projectRoot: string;
|
||||
}): Promise<{ success: boolean; errors: CompileError[] }> {
|
||||
try {
|
||||
@@ -1143,13 +1163,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
|
||||
entry: options.entryPath,
|
||||
output: options.outputPath,
|
||||
format: options.format,
|
||||
aliasCount: options.alias ? Object.keys(options.alias).length : 0
|
||||
external: options.external
|
||||
});
|
||||
|
||||
if (options.alias) {
|
||||
logger.debug('esbuild alias mappings:', options.alias);
|
||||
}
|
||||
|
||||
// Use Tauri command | 使用 Tauri 命令
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
|
||||
@@ -1167,11 +1183,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
|
||||
entryPath: options.entryPath,
|
||||
outputPath: options.outputPath,
|
||||
format: options.format,
|
||||
globalName: options.globalName,
|
||||
sourceMap: options.sourceMap,
|
||||
minify: options.minify,
|
||||
external: options.external,
|
||||
alias: options.alias,
|
||||
projectRoot: options.projectRoot
|
||||
}
|
||||
});
|
||||
@@ -1206,52 +1220,30 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute compiled module code and return exports.
|
||||
* 执行编译后的模块代码并返回导出。
|
||||
* Load ESM module from JavaScript content string.
|
||||
* 从 JavaScript 内容字符串加载 ESM 模块。
|
||||
*
|
||||
* The code should be in IIFE format that sets a global variable.
|
||||
* 代码应该是设置全局变量的 IIFE 格式。
|
||||
* Uses Blob URL to enable dynamic import() of ESM content.
|
||||
* 使用 Blob URL 实现 ESM 内容的动态 import()。
|
||||
*
|
||||
* @param code - Compiled JavaScript code | 编译后的 JavaScript 代码
|
||||
* @param target - Target environment | 目标环境
|
||||
* @param content - JavaScript module content (ESM format) | JavaScript 模块内容(ESM 格式)
|
||||
* @returns Module exports | 模块导出
|
||||
*/
|
||||
private async _executeModuleCode(
|
||||
code: string,
|
||||
target: UserCodeTarget
|
||||
): Promise<Record<string, any>> {
|
||||
// Determine global name based on target | 根据目标确定全局名称
|
||||
const globalName = target === UserCodeTarget.Runtime
|
||||
? EditorConfig.globals.userRuntimeExports
|
||||
: EditorConfig.globals.userEditorExports;
|
||||
|
||||
// Clear any previous exports | 清除之前的导出
|
||||
(window as any)[globalName] = undefined;
|
||||
private async _loadESMFromContent(content: string): Promise<Record<string, any>> {
|
||||
// Create Blob URL for ESM module | 为 ESM 模块创建 Blob URL
|
||||
const blob = new Blob([content], { type: 'application/javascript' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
// esbuild generates: var __USER_RUNTIME_EXPORTS__ = (() => {...})();
|
||||
// When executed via new Function(), var declarations stay in function scope
|
||||
// We need to replace "var globalName" with "window.globalName" to expose it
|
||||
// esbuild 生成: var __USER_RUNTIME_EXPORTS__ = (() => {...})();
|
||||
// 通过 new Function() 执行时,var 声明在函数作用域内
|
||||
// 需要替换 "var globalName" 为 "window.globalName" 以暴露到全局
|
||||
const modifiedCode = code.replace(
|
||||
new RegExp(`^"use strict";\\s*var ${globalName}`, 'm'),
|
||||
`"use strict";\nwindow.${globalName}`
|
||||
);
|
||||
// Dynamic import the ESM module | 动态导入 ESM 模块
|
||||
const moduleExports = await import(/* @vite-ignore */ blobUrl);
|
||||
|
||||
// Execute the IIFE code | 执行 IIFE 代码
|
||||
// eslint-disable-next-line no-new-func
|
||||
const executeScript = new Function(modifiedCode);
|
||||
executeScript();
|
||||
|
||||
// Get exports from global | 从全局获取导出
|
||||
const exports = (window as any)[globalName] || {};
|
||||
|
||||
return exports;
|
||||
} catch (error) {
|
||||
logger.error('Failed to execute user code | 执行用户代码失败:', error);
|
||||
throw error;
|
||||
// Return all exports | 返回所有导出
|
||||
return { ...moduleExports };
|
||||
} finally {
|
||||
// Always revoke Blob URL to prevent memory leaks
|
||||
// 始终撤销 Blob URL 以防止内存泄漏
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,10 @@
|
||||
* ↓
|
||||
* [UserCodeService.scan()] - Discovers all scripts
|
||||
* ↓
|
||||
* [UserCodeService.compile()] - Compiles to JS using esbuild
|
||||
* [UserCodeService.compile()] - Compiles to ESM using esbuild
|
||||
* (@esengine/sdk marked as external)
|
||||
* ↓
|
||||
* [UserCodeService.load()] - Loads compiled module
|
||||
* [UserCodeService.load()] - Loads via project:// protocol + import()
|
||||
* ↓
|
||||
* [registerComponents()] - Registers with ECS runtime
|
||||
* [registerEditorExtensions()] - Registers inspectors/gizmos
|
||||
@@ -53,6 +54,16 @@
|
||||
* [UserCodeService.watch()] - Hot reload on file changes
|
||||
* ```
|
||||
*
|
||||
* # Architecture | 架构
|
||||
*
|
||||
* - **Compilation**: ESM format with `external: ['@esengine/sdk']`
|
||||
* - **Loading**: Reads file via Tauri, loads via Blob URL + import()
|
||||
* - **Runtime**: SDK accessed via `window.__ESENGINE_SDK__` global
|
||||
* - **Hot Reload**: File watching via Rust backend + Tauri events
|
||||
*
|
||||
* Note: Browser's import() only supports http/https/blob protocols.
|
||||
* Custom protocols like project:// are not supported for ESM imports.
|
||||
*
|
||||
* # Example User Component | 用户组件示例
|
||||
*
|
||||
* ```typescript
|
||||
|
||||
@@ -61,4 +61,13 @@ export interface IFileAPI {
|
||||
* @returns 路径是否存在
|
||||
*/
|
||||
pathExists(path: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 获取文件修改时间
|
||||
* Get file modification time
|
||||
*
|
||||
* @param path 文件路径 | File path
|
||||
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
|
||||
*/
|
||||
getFileMtime?(path: string): Promise<number>;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* @see docs/architecture/plugin-system-design.md
|
||||
*/
|
||||
|
||||
import type { ComponentRegistry as ComponentRegistryType, IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IComponentRegistry, IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { PluginServiceRegistry } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from './TransformComponent';
|
||||
import type { ModuleManifest } from './ModuleManifest';
|
||||
@@ -105,7 +105,7 @@ export interface IRuntimeModule {
|
||||
* 注册组件到 ComponentRegistry
|
||||
* Register components to ComponentRegistry
|
||||
*/
|
||||
registerComponents?(registry: typeof ComponentRegistryType): void;
|
||||
registerComponents?(registry: IComponentRegistry): void;
|
||||
|
||||
/**
|
||||
* 注册服务到 ServiceContainer
|
||||
@@ -192,7 +192,7 @@ export type IPlugin<TEditorModule = unknown> = IRuntimePlugin<TEditorModule>;
|
||||
// ============================================================================
|
||||
|
||||
class EngineRuntimeModule implements IRuntimeModule {
|
||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(TransformComponent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,58 @@ export interface IEngineBridge {
|
||||
* Set clear color
|
||||
*/
|
||||
setClearColor(r: number, g: number, b: number, a: number): void;
|
||||
|
||||
// ===== Texture State API (Optional) =====
|
||||
// ===== 纹理状态 API(可选)=====
|
||||
|
||||
/**
|
||||
* 获取纹理加载状态
|
||||
* Get texture loading state
|
||||
*
|
||||
* @param id 纹理 ID | Texture ID
|
||||
* @returns 状态字符串: 'loading', 'ready', 或 'failed:reason'
|
||||
* State string: 'loading', 'ready', or 'failed:reason'
|
||||
*/
|
||||
getTextureState?(id: number): string;
|
||||
|
||||
/**
|
||||
* 检查纹理是否就绪
|
||||
* Check if texture is ready for rendering
|
||||
*
|
||||
* @param id 纹理 ID | Texture ID
|
||||
* @returns 纹理数据已加载则返回 true | true if texture data is loaded
|
||||
*/
|
||||
isTextureReady?(id: number): boolean;
|
||||
|
||||
/**
|
||||
* 获取正在加载的纹理数量
|
||||
* Get count of textures currently loading
|
||||
*
|
||||
* @returns 处于加载状态的纹理数量 | Number of textures in loading state
|
||||
*/
|
||||
getTextureLoadingCount?(): number;
|
||||
|
||||
/**
|
||||
* 异步加载纹理(等待完成)
|
||||
* Load texture asynchronously (wait for completion)
|
||||
*
|
||||
* 与 loadTexture 不同,此方法会等待纹理实际加载完成。
|
||||
* Unlike loadTexture, this method waits until texture is actually loaded.
|
||||
*
|
||||
* @param id 纹理 ID | Texture ID
|
||||
* @param url 图片 URL | Image URL
|
||||
* @returns 纹理就绪时解析的 Promise | Promise that resolves when texture is ready
|
||||
*/
|
||||
loadTextureAsync?(id: number, url: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 等待所有加载中的纹理完成
|
||||
* Wait for all loading textures to complete
|
||||
*
|
||||
* @param timeout 最大等待时间(毫秒,默认30000)| Max wait time in ms (default 30000)
|
||||
* @returns 所有纹理加载完成时解析 | Resolves when all textures are loaded
|
||||
*/
|
||||
waitForAllTextures?(timeout?: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -197,14 +197,6 @@ impl Engine {
|
||||
colors: &[u32],
|
||||
material_ids: &[u32],
|
||||
) -> Result<()> {
|
||||
// Debug: log once
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
if !LOGGED.swap(true, Ordering::Relaxed) {
|
||||
let sprite_count = texture_ids.len();
|
||||
log::info!("Engine submit_sprite_batch: {} sprites, texture_ids: {:?}", sprite_count, texture_ids);
|
||||
}
|
||||
|
||||
self.renderer.submit_batch(
|
||||
transforms,
|
||||
texture_ids,
|
||||
@@ -382,6 +374,24 @@ impl Engine {
|
||||
self.texture_manager.clear_all();
|
||||
}
|
||||
|
||||
/// 获取纹理加载状态
|
||||
/// Get texture loading state
|
||||
pub fn get_texture_state(&self, id: u32) -> crate::renderer::texture::TextureState {
|
||||
self.texture_manager.get_texture_state(id)
|
||||
}
|
||||
|
||||
/// 检查纹理是否已就绪
|
||||
/// Check if texture is ready to use
|
||||
pub fn is_texture_ready(&self, id: u32) -> bool {
|
||||
self.texture_manager.is_texture_ready(id)
|
||||
}
|
||||
|
||||
/// 获取正在加载中的纹理数量
|
||||
/// Get the number of textures currently loading
|
||||
pub fn get_texture_loading_count(&self) -> u32 {
|
||||
self.texture_manager.get_loading_count()
|
||||
}
|
||||
|
||||
/// Check if a key is currently pressed.
|
||||
/// 检查某个键是否当前被按下。
|
||||
pub fn is_key_down(&self, key_code: &str) -> bool {
|
||||
|
||||
@@ -224,6 +224,42 @@ impl GameEngine {
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// 获取纹理加载状态
|
||||
/// Get texture loading state
|
||||
///
|
||||
/// # Arguments | 参数
|
||||
/// * `id` - Texture ID | 纹理ID
|
||||
///
|
||||
/// # Returns | 返回
|
||||
/// State string: "loading", "ready", or "failed:reason"
|
||||
/// 状态字符串:"loading"、"ready" 或 "failed:原因"
|
||||
#[wasm_bindgen(js_name = getTextureState)]
|
||||
pub fn get_texture_state(&self, id: u32) -> String {
|
||||
use crate::renderer::texture::TextureState;
|
||||
match self.engine.get_texture_state(id) {
|
||||
TextureState::Loading => "loading".to_string(),
|
||||
TextureState::Ready => "ready".to_string(),
|
||||
TextureState::Failed(reason) => format!("failed:{}", reason),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查纹理是否已就绪
|
||||
/// Check if texture is ready to use
|
||||
///
|
||||
/// # Arguments | 参数
|
||||
/// * `id` - Texture ID | 纹理ID
|
||||
#[wasm_bindgen(js_name = isTextureReady)]
|
||||
pub fn is_texture_ready(&self, id: u32) -> bool {
|
||||
self.engine.is_texture_ready(id)
|
||||
}
|
||||
|
||||
/// 获取正在加载中的纹理数量
|
||||
/// Get the number of textures currently loading
|
||||
#[wasm_bindgen(js_name = getTextureLoadingCount)]
|
||||
pub fn get_texture_loading_count(&self) -> u32 {
|
||||
self.engine.get_texture_loading_count()
|
||||
}
|
||||
|
||||
/// Check if a key is currently pressed.
|
||||
/// 检查某个键是否当前被按下。
|
||||
///
|
||||
|
||||
@@ -5,4 +5,4 @@ mod texture;
|
||||
mod texture_manager;
|
||||
|
||||
pub use texture::Texture;
|
||||
pub use texture_manager::TextureManager;
|
||||
pub use texture_manager::{TextureManager, TextureState};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Texture loading and management.
|
||||
//! 纹理加载和管理。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{HtmlImageElement, WebGl2RenderingContext, WebGlTexture};
|
||||
@@ -9,6 +11,21 @@ use web_sys::{HtmlImageElement, WebGl2RenderingContext, WebGlTexture};
|
||||
use crate::core::error::{EngineError, Result};
|
||||
use super::Texture;
|
||||
|
||||
/// 纹理加载状态
|
||||
/// Texture loading state
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TextureState {
|
||||
/// 正在加载中
|
||||
/// Loading in progress
|
||||
Loading,
|
||||
/// 加载完成,可以使用
|
||||
/// Loaded and ready to use
|
||||
Ready,
|
||||
/// 加载失败
|
||||
/// Load failed
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Texture manager for loading and caching textures.
|
||||
/// 用于加载和缓存纹理的纹理管理器。
|
||||
pub struct TextureManager {
|
||||
@@ -31,6 +48,10 @@ pub struct TextureManager {
|
||||
/// Default white texture for untextured rendering.
|
||||
/// 用于无纹理渲染的默认白色纹理。
|
||||
default_texture: Option<WebGlTexture>,
|
||||
|
||||
/// 纹理加载状态(使用 Rc<RefCell<>> 以便闭包可以修改)
|
||||
/// Texture loading states (using Rc<RefCell<>> so closures can modify)
|
||||
texture_states: Rc<RefCell<HashMap<u32, TextureState>>>,
|
||||
}
|
||||
|
||||
impl TextureManager {
|
||||
@@ -43,6 +64,7 @@ impl TextureManager {
|
||||
path_to_id: HashMap::new(),
|
||||
next_id: 1, // Start from 1, 0 is reserved for default
|
||||
default_texture: None,
|
||||
texture_states: Rc::new(RefCell::new(HashMap::new())),
|
||||
};
|
||||
|
||||
// Create default white texture | 创建默认白色纹理
|
||||
@@ -90,17 +112,22 @@ impl TextureManager {
|
||||
/// 从URL加载纹理。
|
||||
///
|
||||
/// Note: This is an async operation. The texture will be available
|
||||
/// after the image loads.
|
||||
/// 注意:这是一个异步操作。纹理在图片加载后可用。
|
||||
/// after the image loads. Use `get_texture_state` to check loading status.
|
||||
/// 注意:这是一个异步操作。纹理在图片加载后可用。使用 `get_texture_state` 检查加载状态。
|
||||
pub fn load_texture(&mut self, id: u32, url: &str) -> Result<()> {
|
||||
// 设置初始状态为 Loading | Set initial state to Loading
|
||||
self.texture_states.borrow_mut().insert(id, TextureState::Loading);
|
||||
|
||||
// Create placeholder texture | 创建占位纹理
|
||||
let texture = self.gl
|
||||
.create_texture()
|
||||
.ok_or_else(|| EngineError::TextureLoadFailed("Failed to create texture".into()))?;
|
||||
|
||||
// Set up temporary 1x1 texture | 设置临时1x1纹理
|
||||
// Set up temporary 1x1 transparent texture | 设置临时1x1透明纹理
|
||||
// 使用透明而非灰色,这样未加载完成时不会显示奇怪的颜色
|
||||
// Use transparent instead of gray, so incomplete textures don't show strange colors
|
||||
self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture));
|
||||
let placeholder: [u8; 4] = [128, 128, 128, 255];
|
||||
let placeholder: [u8; 4] = [0, 0, 0, 0];
|
||||
let _ = self.gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array(
|
||||
WebGl2RenderingContext::TEXTURE_2D,
|
||||
0,
|
||||
@@ -119,6 +146,10 @@ impl TextureManager {
|
||||
// Store texture with placeholder size | 存储带占位符尺寸的纹理
|
||||
self.textures.insert(id, Texture::new(texture, 1, 1));
|
||||
|
||||
// Clone state map for closures | 克隆状态映射用于闭包
|
||||
let states_for_onload = Rc::clone(&self.texture_states);
|
||||
let states_for_onerror = Rc::clone(&self.texture_states);
|
||||
|
||||
// Load actual image asynchronously | 异步加载实际图片
|
||||
let gl = self.gl.clone();
|
||||
|
||||
@@ -130,6 +161,7 @@ impl TextureManager {
|
||||
|
||||
// Clone image for use in closure | 克隆图片用于闭包
|
||||
let image_clone = image.clone();
|
||||
let texture_id = id;
|
||||
|
||||
// Set up load callback | 设置加载回调
|
||||
let onload = Closure::wrap(Box::new(move || {
|
||||
@@ -146,7 +178,9 @@ impl TextureManager {
|
||||
);
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to upload texture: {:?} | 纹理上传失败: {:?}", e, e);
|
||||
log::error!("Failed to upload texture {}: {:?} | 纹理 {} 上传失败: {:?}", texture_id, e, texture_id, e);
|
||||
states_for_onload.borrow_mut().insert(texture_id, TextureState::Failed(format!("{:?}", e)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set texture parameters | 设置纹理参数
|
||||
@@ -171,10 +205,22 @@ impl TextureManager {
|
||||
WebGl2RenderingContext::LINEAR as i32,
|
||||
);
|
||||
|
||||
// 标记为就绪 | Mark as ready
|
||||
states_for_onload.borrow_mut().insert(texture_id, TextureState::Ready);
|
||||
|
||||
}) as Box<dyn Fn()>);
|
||||
|
||||
// Set up error callback | 设置错误回调
|
||||
let url_for_error = url.to_string();
|
||||
let onerror = Closure::wrap(Box::new(move || {
|
||||
let error_msg = format!("Failed to load image: {}", url_for_error);
|
||||
states_for_onerror.borrow_mut().insert(texture_id, TextureState::Failed(error_msg));
|
||||
}) as Box<dyn Fn()>);
|
||||
|
||||
image.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
image.set_onerror(Some(onerror.as_ref().unchecked_ref()));
|
||||
onload.forget(); // Prevent closure from being dropped | 防止闭包被销毁
|
||||
onerror.forget();
|
||||
|
||||
image.set_src(url);
|
||||
|
||||
@@ -223,6 +269,56 @@ impl TextureManager {
|
||||
self.textures.contains_key(&id)
|
||||
}
|
||||
|
||||
/// 获取纹理加载状态
|
||||
/// Get texture loading state
|
||||
///
|
||||
/// 返回纹理的当前加载状态:Loading、Ready 或 Failed。
|
||||
/// Returns the current loading state of the texture: Loading, Ready, or Failed.
|
||||
#[inline]
|
||||
pub fn get_texture_state(&self, id: u32) -> TextureState {
|
||||
// ID 0 是默认纹理,始终就绪
|
||||
// ID 0 is default texture, always ready
|
||||
if id == 0 {
|
||||
return TextureState::Ready;
|
||||
}
|
||||
|
||||
self.texture_states
|
||||
.borrow()
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.unwrap_or(TextureState::Failed("Texture not found".to_string()))
|
||||
}
|
||||
|
||||
/// 检查纹理是否已就绪可用
|
||||
/// Check if texture is ready to use
|
||||
///
|
||||
/// 这是 `get_texture_state() == TextureState::Ready` 的便捷方法。
|
||||
/// This is a convenience method for `get_texture_state() == TextureState::Ready`.
|
||||
#[inline]
|
||||
pub fn is_texture_ready(&self, id: u32) -> bool {
|
||||
// ID 0 是默认纹理,始终就绪
|
||||
// ID 0 is default texture, always ready
|
||||
if id == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
self.texture_states.borrow().get(&id),
|
||||
Some(TextureState::Ready)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取正在加载中的纹理数量
|
||||
/// Get the number of textures currently loading
|
||||
#[inline]
|
||||
pub fn get_loading_count(&self) -> u32 {
|
||||
self.texture_states
|
||||
.borrow()
|
||||
.values()
|
||||
.filter(|s| matches!(s, TextureState::Loading))
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
/// Remove texture.
|
||||
/// 移除纹理。
|
||||
pub fn remove_texture(&mut self, id: u32) {
|
||||
@@ -231,6 +327,8 @@ impl TextureManager {
|
||||
}
|
||||
// Also remove from path mapping | 同时从路径映射中移除
|
||||
self.path_to_id.retain(|_, &mut v| v != id);
|
||||
// Remove state | 移除状态
|
||||
self.texture_states.borrow_mut().remove(&id);
|
||||
}
|
||||
|
||||
/// Load texture by path, returning texture ID.
|
||||
@@ -308,6 +406,9 @@ impl TextureManager {
|
||||
// Clear path mapping | 清除路径映射
|
||||
self.path_to_id.clear();
|
||||
|
||||
// Clear texture states | 清除纹理状态
|
||||
self.texture_states.borrow_mut().clear();
|
||||
|
||||
// Reset ID counter (1 is reserved for first texture, 0 for default)
|
||||
// 重置ID计数器(1保留给第一个纹理,0给默认纹理)
|
||||
self.next_id = 1;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
|
||||
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import { TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||
import { AssetManagerToken } from '@esengine/asset-system';
|
||||
@@ -20,7 +20,7 @@ class ParticleRuntimeModule implements IRuntimeModule {
|
||||
private _updateSystem: ParticleUpdateSystem | null = null;
|
||||
private _loaderRegistered = false;
|
||||
|
||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(ParticleSystemComponent);
|
||||
registry.register(ClickFxComponent);
|
||||
}
|
||||
@@ -73,13 +73,10 @@ class ParticleRuntimeModule implements IRuntimeModule {
|
||||
scene.addSystem(this._updateSystem);
|
||||
|
||||
// 添加点击特效系统 | Add click FX system
|
||||
// ClickFxSystem 不再需要 AssetManager,资产由 ParticleUpdateSystem 统一加载
|
||||
// ClickFxSystem no longer needs AssetManager, assets are loaded by ParticleUpdateSystem
|
||||
const clickFxSystem = new ClickFxSystem();
|
||||
|
||||
// 设置资产管理器 | Set asset manager
|
||||
if (assetManager) {
|
||||
clickFxSystem.setAssetManager(assetManager);
|
||||
}
|
||||
|
||||
// 设置 EngineBridge(用于屏幕坐标转世界坐标)
|
||||
// Set EngineBridge (for screen to world coordinate conversion)
|
||||
if (engineBridge) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SizeOverLifetimeModule } from './modules/SizeOverLifetimeModule';
|
||||
import { CollisionModule, BoundaryType, CollisionBehavior } from './modules/CollisionModule';
|
||||
import { ForceFieldModule, ForceFieldType, type ForceField } from './modules/ForceFieldModule';
|
||||
import { Physics2DCollisionModule } from './modules/Physics2DCollisionModule';
|
||||
import { TextureSheetAnimationModule, AnimationPlayMode, AnimationLoopMode } from './modules/TextureSheetAnimationModule';
|
||||
import type { IParticleAsset, IBurstConfig } from './loaders/ParticleLoader';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
@@ -828,6 +829,42 @@ export class ParticleSystemComponent extends Component implements ISortable {
|
||||
this._modules.push(forceModule);
|
||||
break;
|
||||
}
|
||||
case 'TextureSheetAnimation': {
|
||||
// 纹理图集动画模块 | Texture sheet animation module
|
||||
const textureModule = new TextureSheetAnimationModule();
|
||||
// moduleConfig 直接包含属性(非 params 嵌套)
|
||||
// moduleConfig contains properties directly (not nested in params)
|
||||
const cfg = moduleConfig as unknown as Record<string, unknown>;
|
||||
textureModule.enabled = true;
|
||||
if (cfg.tilesX !== undefined) textureModule.tilesX = cfg.tilesX as number;
|
||||
if (cfg.tilesY !== undefined) textureModule.tilesY = cfg.tilesY as number;
|
||||
if (cfg.totalFrames !== undefined) textureModule.totalFrames = cfg.totalFrames as number;
|
||||
if (cfg.startFrame !== undefined) textureModule.startFrame = cfg.startFrame as number;
|
||||
if (cfg.frameRate !== undefined) textureModule.frameRate = cfg.frameRate as number;
|
||||
if (cfg.speedMultiplier !== undefined) textureModule.speedMultiplier = cfg.speedMultiplier as number;
|
||||
if (cfg.cycleCount !== undefined) textureModule.cycleCount = cfg.cycleCount as number;
|
||||
// 播放模式 | Play mode
|
||||
if (cfg.playMode !== undefined) {
|
||||
const playModeMap: Record<string, AnimationPlayMode> = {
|
||||
'lifetimeLoop': AnimationPlayMode.LifetimeLoop,
|
||||
'fixedFps': AnimationPlayMode.FixedFPS,
|
||||
'random': AnimationPlayMode.Random,
|
||||
'speedBased': AnimationPlayMode.SpeedBased,
|
||||
};
|
||||
textureModule.playMode = playModeMap[cfg.playMode as string] ?? AnimationPlayMode.LifetimeLoop;
|
||||
}
|
||||
// 循环模式 | Loop mode
|
||||
if (cfg.loopMode !== undefined) {
|
||||
const loopModeMap: Record<string, AnimationLoopMode> = {
|
||||
'once': AnimationLoopMode.Once,
|
||||
'loop': AnimationLoopMode.Loop,
|
||||
'pingPong': AnimationLoopMode.PingPong,
|
||||
};
|
||||
textureModule.loopMode = loopModeMap[cfg.loopMode as string] ?? AnimationLoopMode.Once;
|
||||
}
|
||||
this._modules.push(textureModule);
|
||||
break;
|
||||
}
|
||||
// 可扩展其他模块类型 | Extensible for other module types
|
||||
default:
|
||||
console.warn(`[ParticleSystem] Unknown module type: ${moduleConfig.type}`);
|
||||
|
||||
401
packages/particle/src/__tests__/particle-e2e-test.html
Normal file
401
packages/particle/src/__tests__/particle-e2e-test.html
Normal file
@@ -0,0 +1,401 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Particle System End-to-End Test</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1a1a1a; color: #fff; padding: 20px; }
|
||||
canvas { border: 1px solid #444; margin: 10px; background: #333; }
|
||||
.section { margin: 20px 0; padding: 15px; background: #252525; border-radius: 8px; }
|
||||
h2 { color: #8cf; margin-top: 0; }
|
||||
pre { background: #333; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 11px; }
|
||||
.pass { color: #2a5; }
|
||||
.fail { color: #f55; }
|
||||
.log { color: #aaa; font-size: 11px; }
|
||||
table { border-collapse: collapse; margin: 10px 0; }
|
||||
td, th { border: 1px solid #444; padding: 5px 10px; text-align: left; }
|
||||
th { background: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Particle System End-to-End Test</h1>
|
||||
<p>This test simulates the COMPLETE particle rendering pipeline.</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 1: Test Texture</h2>
|
||||
<pre>
|
||||
2x2 Spritesheet (128x128 pixels):
|
||||
┌───────────┬───────────┐
|
||||
│ RED (0) │ GREEN (1) │ row=0, v: 0.0 - 0.5
|
||||
├───────────┼───────────┤
|
||||
│ BLUE (2) │ YELLOW(3) │ row=1, v: 0.5 - 1.0
|
||||
└───────────┴───────────┘
|
||||
</pre>
|
||||
<canvas id="texturePreview" width="128" height="128"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 2: TextureSheetAnimationModule._setParticleUV()</h2>
|
||||
<pre id="step2Log"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 3: ParticleRenderDataProvider._updateRenderData()</h2>
|
||||
<pre id="step3Log"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 4: EngineRenderSystem.convertProviderDataToSprites()</h2>
|
||||
<pre id="step4Log"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 5: sprite_batch.rs add_sprite_vertices_to_batch()</h2>
|
||||
<pre id="step5Log"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 6: Final Rendering Result</h2>
|
||||
<canvas id="mainCanvas" width="500" height="150"></canvas>
|
||||
<div id="renderResult"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Test Results</h2>
|
||||
<table>
|
||||
<tr><th>Frame</th><th>Expected</th><th>Got</th><th>Status</th></tr>
|
||||
<tbody id="resultsTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Conclusion</h2>
|
||||
<pre id="conclusion"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ========== Shaders ==========
|
||||
const vsSource = `
|
||||
attribute vec2 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
varying vec2 vTexCoord;
|
||||
uniform mat4 uProjection;
|
||||
void main() {
|
||||
gl_Position = uProjection * vec4(aPosition, 0.0, 1.0);
|
||||
vTexCoord = aTexCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
const fsSource = `
|
||||
precision mediump float;
|
||||
varying vec2 vTexCoord;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||
}
|
||||
`;
|
||||
|
||||
// ========== WebGL Setup ==========
|
||||
function createShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl) {
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
function createTestTexture(gl) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0); // NO FLIP - same as engine
|
||||
|
||||
const data = new Uint8Array([
|
||||
255, 50, 50, 255, 50, 255, 50, 255, // Row 0: Red, Green
|
||||
50, 50, 255, 255, 255, 255, 50, 255 // Row 1: Blue, Yellow
|
||||
]);
|
||||
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
return texture;
|
||||
}
|
||||
|
||||
// ========== Step 2: TextureSheetAnimationModule._setParticleUV ==========
|
||||
function simulateTextureSheetAnimationModule(frameIndex, tilesX, tilesY) {
|
||||
const col = frameIndex % tilesX;
|
||||
const row = Math.floor(frameIndex / tilesX);
|
||||
|
||||
// This is what TextureSheetAnimationModule stores on the particle
|
||||
return {
|
||||
_animFrame: frameIndex,
|
||||
_animTilesX: tilesX,
|
||||
_animTilesY: tilesY,
|
||||
col,
|
||||
row
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Step 3: ParticleRenderDataProvider._updateRenderData ==========
|
||||
function simulateParticleRenderDataProvider(particle, tilesX, tilesY) {
|
||||
const frame = particle._animFrame;
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
|
||||
// This is exactly what ParticleRenderDataProvider does
|
||||
const u0 = col * uWidth;
|
||||
const u1 = (col + 1) * uWidth;
|
||||
const v0 = row * vHeight;
|
||||
const v1 = (row + 1) * vHeight;
|
||||
|
||||
return {
|
||||
uvs: [u0, v0, u1, v1],
|
||||
transforms: [0, 0, 0, 64, 64, 0.5, 0.5], // x, y, rotation, scaleX, scaleY, originX, originY
|
||||
tileCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Step 4: EngineRenderSystem.convertProviderDataToSprites ==========
|
||||
function simulateConvertProviderDataToSprites(providerData, x, y) {
|
||||
const tOffset = 0;
|
||||
const uvOffset = 0;
|
||||
|
||||
return {
|
||||
x: x,
|
||||
y: y,
|
||||
rotation: providerData.transforms[tOffset + 2],
|
||||
scaleX: providerData.transforms[tOffset + 3],
|
||||
scaleY: providerData.transforms[tOffset + 4],
|
||||
originX: providerData.transforms[tOffset + 5],
|
||||
originY: providerData.transforms[tOffset + 6],
|
||||
uv: [
|
||||
providerData.uvs[0],
|
||||
providerData.uvs[1],
|
||||
providerData.uvs[2],
|
||||
providerData.uvs[3]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Step 5: sprite_batch.rs add_sprite_vertices_to_batch ==========
|
||||
function simulateSpriteBatch(sprite, batch) {
|
||||
const { x, y, scaleX: width, scaleY: height, rotation, originX, originY } = sprite;
|
||||
const [u0, v0, u1, v1] = sprite.uv;
|
||||
|
||||
const ox = originX * width;
|
||||
const oy = originY * height;
|
||||
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
|
||||
// Exactly as sprite_batch.rs
|
||||
const corners = [
|
||||
[-ox, height - oy], // 0: Top-left (high Y)
|
||||
[width - ox, height - oy], // 1: Top-right
|
||||
[width - ox, -oy], // 2: Bottom-right (low Y)
|
||||
[-ox, -oy] // 3: Bottom-left
|
||||
];
|
||||
|
||||
const texCoords = [
|
||||
[u0, v0], // 0: Top-left vertex gets (u0, v0)
|
||||
[u1, v0], // 1: Top-right vertex gets (u1, v0)
|
||||
[u1, v1], // 2: Bottom-right vertex gets (u1, v1)
|
||||
[u0, v1] // 3: Bottom-left vertex gets (u0, v1)
|
||||
];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const [lx, ly] = corners[i];
|
||||
const rx = lx * cos - ly * sin;
|
||||
const ry = lx * sin + ly * cos;
|
||||
const px = rx + x;
|
||||
const py = ry + y;
|
||||
|
||||
batch.positions.push(px, py);
|
||||
batch.texCoords.push(texCoords[i][0], texCoords[i][1]);
|
||||
}
|
||||
|
||||
const base = batch.vertexCount;
|
||||
batch.indices.push(base, base + 1, base + 2, base + 2, base + 3, base);
|
||||
batch.vertexCount += 4;
|
||||
|
||||
return { corners, texCoords };
|
||||
}
|
||||
|
||||
// ========== Utility ==========
|
||||
function colorName(r, g, b) {
|
||||
if (r > 200 && g < 100 && b < 100) return 'RED';
|
||||
if (r < 100 && g > 200 && b < 100) return 'GREEN';
|
||||
if (r < 100 && g < 100 && b > 200) return 'BLUE';
|
||||
if (r > 200 && g > 200 && b < 100) return 'YELLOW';
|
||||
return `RGB(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
// ========== Main Test ==========
|
||||
function runTest() {
|
||||
const tilesX = 2, tilesY = 2;
|
||||
const expectedColors = ['RED', 'GREEN', 'BLUE', 'YELLOW'];
|
||||
const results = [];
|
||||
|
||||
let step2Log = '';
|
||||
let step3Log = '';
|
||||
let step4Log = '';
|
||||
let step5Log = '';
|
||||
|
||||
// Draw texture preview
|
||||
const previewCanvas = document.getElementById('texturePreview');
|
||||
const previewCtx = previewCanvas.getContext('2d');
|
||||
previewCtx.fillStyle = '#ff3232'; previewCtx.fillRect(0, 0, 64, 64);
|
||||
previewCtx.fillStyle = '#32ff32'; previewCtx.fillRect(64, 0, 64, 64);
|
||||
previewCtx.fillStyle = '#3232ff'; previewCtx.fillRect(0, 64, 64, 64);
|
||||
previewCtx.fillStyle = '#ffff32'; previewCtx.fillRect(64, 64, 64, 64);
|
||||
previewCtx.fillStyle = '#fff'; previewCtx.font = '20px monospace';
|
||||
previewCtx.fillText('0', 28, 38); previewCtx.fillText('1', 92, 38);
|
||||
previewCtx.fillText('2', 28, 102); previewCtx.fillText('3', 92, 102);
|
||||
|
||||
// Setup WebGL
|
||||
const canvas = document.getElementById('mainCanvas');
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
const program = createProgram(gl);
|
||||
const texture = createTestTexture(gl);
|
||||
|
||||
gl.useProgram(program);
|
||||
|
||||
const projLoc = gl.getUniformLocation(program, 'uProjection');
|
||||
const left = 0, right = 500, bottom = 0, top = 150;
|
||||
const projection = new Float32Array([
|
||||
2/(right-left), 0, 0, 0,
|
||||
0, 2/(top-bottom), 0, 0,
|
||||
0, 0, -1, 0,
|
||||
-(right+left)/(right-left), -(top+bottom)/(top-bottom), 0, 1
|
||||
]);
|
||||
gl.uniformMatrix4fv(projLoc, false, projection);
|
||||
|
||||
gl.viewport(0, 0, 500, 150);
|
||||
gl.clearColor(0.15, 0.15, 0.15, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
const batch = { positions: [], texCoords: [], indices: [], vertexCount: 0 };
|
||||
|
||||
// Process each frame
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const x = 60 + frame * 110;
|
||||
const y = 75;
|
||||
|
||||
// Step 2
|
||||
const particleData = simulateTextureSheetAnimationModule(frame, tilesX, tilesY);
|
||||
step2Log += `Frame ${frame}: _animFrame=${particleData._animFrame}, col=${particleData.col}, row=${particleData.row}\n`;
|
||||
|
||||
// Step 3
|
||||
const providerData = simulateParticleRenderDataProvider(particleData, tilesX, tilesY);
|
||||
step3Log += `Frame ${frame}: uvs=[${providerData.uvs.map(v => v.toFixed(2)).join(', ')}]\n`;
|
||||
|
||||
// Step 4
|
||||
const sprite = simulateConvertProviderDataToSprites(providerData, x, y);
|
||||
step4Log += `Frame ${frame}: uv=[${sprite.uv.map(v => v.toFixed(2)).join(', ')}], pos=(${x}, ${y})\n`;
|
||||
|
||||
// Step 5
|
||||
const batchResult = simulateSpriteBatch(sprite, batch);
|
||||
step5Log += `Frame ${frame}: vertex0(top-left)=[${batchResult.texCoords[0].map(v => v.toFixed(2)).join(', ')}], `;
|
||||
step5Log += `vertex2(bottom-right)=[${batchResult.texCoords[2].map(v => v.toFixed(2)).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
document.getElementById('step2Log').textContent = step2Log;
|
||||
document.getElementById('step3Log').textContent = step3Log;
|
||||
document.getElementById('step4Log').textContent = step4Log;
|
||||
document.getElementById('step5Log').textContent = step5Log;
|
||||
|
||||
// Render
|
||||
const posLoc = gl.getAttribLocation(program, 'aPosition');
|
||||
const texLoc = gl.getAttribLocation(program, 'aTexCoord');
|
||||
|
||||
const posBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.positions), gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const texBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.texCoords), gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(texLoc);
|
||||
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const idxBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(batch.indices), gl.STATIC_DRAW);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
gl.drawElements(gl.TRIANGLES, batch.indices.length, gl.UNSIGNED_SHORT, 0);
|
||||
|
||||
// Read back and verify
|
||||
const tableBody = document.getElementById('resultsTable');
|
||||
let allPassed = true;
|
||||
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const x = 60 + frame * 110;
|
||||
const y = 75;
|
||||
|
||||
const pixels = new Uint8Array(4);
|
||||
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const actual = colorName(pixels[0], pixels[1], pixels[2]);
|
||||
const expected = expectedColors[frame];
|
||||
const passed = actual === expected;
|
||||
|
||||
if (!passed) allPassed = false;
|
||||
results.push({ frame, expected, actual, passed });
|
||||
|
||||
tableBody.innerHTML += `
|
||||
<tr class="${passed ? 'pass' : 'fail'}">
|
||||
<td>Frame ${frame}</td>
|
||||
<td>${expected}</td>
|
||||
<td>${actual}</td>
|
||||
<td>${passed ? '✓ PASS' : '✗ FAIL'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// Conclusion
|
||||
const conclusionEl = document.getElementById('conclusion');
|
||||
if (allPassed) {
|
||||
conclusionEl.innerHTML = `<span class="pass">ALL TESTS PASSED!</span>
|
||||
|
||||
The entire particle rendering pipeline is CORRECT:
|
||||
- TextureSheetAnimationModule ✓
|
||||
- ParticleRenderDataProvider ✓
|
||||
- EngineRenderSystem ✓
|
||||
- sprite_batch.rs ✓
|
||||
|
||||
If your actual particles still show wrong colors, the problem must be:
|
||||
1. Your spritesheet image has a different layout
|
||||
2. Something else is modifying the UV values
|
||||
3. The texture is being loaded differently
|
||||
|
||||
Try using the test image: F:\\ecs-framework\\test_spritesheet_2x2.png`;
|
||||
} else {
|
||||
const failedFrames = results.filter(r => !r.passed);
|
||||
conclusionEl.innerHTML = `<span class="fail">TESTS FAILED!</span>
|
||||
|
||||
Failed frames:
|
||||
${failedFrames.map(f => `Frame ${f.frame}: expected ${f.expected}, got ${f.actual}`).join('\n')}
|
||||
|
||||
This indicates a bug in the rendering pipeline.`;
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = runTest;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
328
packages/particle/src/__tests__/sprite-batch-test.html
Normal file
328
packages/particle/src/__tests__/sprite-batch-test.html
Normal file
@@ -0,0 +1,328 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Sprite Batch Rendering Test (模拟 sprite_batch.rs)</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1a1a1a; color: #fff; padding: 20px; }
|
||||
canvas { border: 1px solid #444; margin: 10px; background: #333; }
|
||||
.test-row { display: flex; align-items: flex-start; margin: 20px 0; gap: 20px; }
|
||||
.info { background: #333; padding: 10px; border-radius: 4px; font-size: 12px; }
|
||||
h2 { color: #8cf; }
|
||||
pre { background: #333; padding: 10px; border-radius: 4px; overflow-x: auto; }
|
||||
.pass { color: #2a5; }
|
||||
.fail { color: #f55; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Sprite Batch Rendering Test</h1>
|
||||
<p>This test simulates exactly how sprite_batch.rs renders sprites with UV coordinates.</p>
|
||||
|
||||
<h2>Test Texture (2x2 spritesheet)</h2>
|
||||
<pre>
|
||||
┌─────────┬─────────┐
|
||||
│ RED (0) │ GREEN(1)│ v: 0.0 - 0.5
|
||||
├─────────┼─────────┤
|
||||
│ BLUE(2) │ YELLOW(3)│ v: 0.5 - 1.0
|
||||
└─────────┴─────────┘
|
||||
</pre>
|
||||
|
||||
<h2>Rendering Test (same as sprite_batch.rs)</h2>
|
||||
<div class="test-row">
|
||||
<div>
|
||||
<canvas id="mainCanvas" width="400" height="300"></canvas>
|
||||
<div>Main rendering canvas</div>
|
||||
</div>
|
||||
<div class="info">
|
||||
<h3>sprite_batch.rs vertex mapping:</h3>
|
||||
<pre>
|
||||
corners = [
|
||||
(-ox, height-oy), // 0: Top-left (high Y)
|
||||
(width-ox, height-oy), // 1: Top-right
|
||||
(width-ox, -oy), // 2: Bottom-right (low Y)
|
||||
(-ox, -oy), // 3: Bottom-left
|
||||
];
|
||||
|
||||
tex_coords = [
|
||||
[u0, v0], // 0: Top-left
|
||||
[u1, v0], // 1: Top-right
|
||||
[u1, v1], // 2: Bottom-right
|
||||
[u0, v1], // 3: Bottom-left
|
||||
];
|
||||
|
||||
indices = [0, 1, 2, 2, 3, 0];
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Frame-by-Frame Test Results</h2>
|
||||
<div id="results"></div>
|
||||
|
||||
<h2>Conclusion</h2>
|
||||
<pre id="conclusion"></pre>
|
||||
|
||||
<script>
|
||||
const vsSource = `
|
||||
attribute vec2 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
varying vec2 vTexCoord;
|
||||
uniform mat4 uProjection;
|
||||
void main() {
|
||||
gl_Position = uProjection * vec4(aPosition, 0.0, 1.0);
|
||||
vTexCoord = aTexCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
const fsSource = `
|
||||
precision mediump float;
|
||||
varying vec2 vTexCoord;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||
}
|
||||
`;
|
||||
|
||||
function createShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl, vsSource, fsSource) {
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
function createTestTexture(gl) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
// NO FLIP_Y - same as our engine
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
||||
|
||||
// 2x2 texture: Red, Green, Blue, Yellow
|
||||
const data = new Uint8Array([
|
||||
255, 0, 0, 255, 0, 255, 0, 255, // Row 0: Red, Green
|
||||
0, 0, 255, 255, 255, 255, 0, 255 // Row 1: Blue, Yellow
|
||||
]);
|
||||
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
// Simulate sprite_batch.rs add_sprite_vertices_to_batch
|
||||
function addSpriteVertices(batch, x, y, width, height, rotation, originX, originY, u0, v0, u1, v1, color) {
|
||||
const ox = originX * width;
|
||||
const oy = originY * height;
|
||||
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
|
||||
// Same as sprite_batch.rs
|
||||
const corners = [
|
||||
[-ox, height - oy], // 0: Top-left
|
||||
[width - ox, height - oy], // 1: Top-right
|
||||
[width - ox, -oy], // 2: Bottom-right
|
||||
[-ox, -oy] // 3: Bottom-left
|
||||
];
|
||||
|
||||
const texCoords = [
|
||||
[u0, v0], // 0: Top-left
|
||||
[u1, v0], // 1: Top-right
|
||||
[u1, v1], // 2: Bottom-right
|
||||
[u0, v1] // 3: Bottom-left
|
||||
];
|
||||
|
||||
// Transform and add vertices
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const [lx, ly] = corners[i];
|
||||
|
||||
// Apply rotation
|
||||
const rx = lx * cos - ly * sin;
|
||||
const ry = lx * sin + ly * cos;
|
||||
|
||||
// Apply translation
|
||||
const px = rx + x;
|
||||
const py = ry + y;
|
||||
|
||||
batch.positions.push(px, py);
|
||||
batch.texCoords.push(texCoords[i][0], texCoords[i][1]);
|
||||
}
|
||||
|
||||
// Add indices (0, 1, 2, 2, 3, 0)
|
||||
const base = batch.vertexCount;
|
||||
batch.indices.push(base, base + 1, base + 2, base + 2, base + 3, base);
|
||||
batch.vertexCount += 4;
|
||||
}
|
||||
|
||||
function calculateUV(frame, tilesX, tilesY) {
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
return {
|
||||
u0: col * uWidth,
|
||||
v0: row * vHeight,
|
||||
u1: (col + 1) * uWidth,
|
||||
v1: (row + 1) * vHeight
|
||||
};
|
||||
}
|
||||
|
||||
function colorName(r, g, b) {
|
||||
if (r > 200 && g < 100 && b < 100) return 'RED';
|
||||
if (r < 100 && g > 200 && b < 100) return 'GREEN';
|
||||
if (r < 100 && g < 100 && b > 200) return 'BLUE';
|
||||
if (r > 200 && g > 200 && b < 100) return 'YELLOW';
|
||||
return `RGB(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function runTest() {
|
||||
const canvas = document.getElementById('mainCanvas');
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
|
||||
if (!gl) {
|
||||
document.getElementById('conclusion').textContent = 'WebGL not supported!';
|
||||
return;
|
||||
}
|
||||
|
||||
const program = createProgram(gl, vsSource, fsSource);
|
||||
const texture = createTestTexture(gl);
|
||||
|
||||
gl.useProgram(program);
|
||||
|
||||
// Set up orthographic projection (Y-up, like our engine)
|
||||
const projLoc = gl.getUniformLocation(program, 'uProjection');
|
||||
const left = 0, right = 400, bottom = 0, top = 300;
|
||||
const projection = new Float32Array([
|
||||
2/(right-left), 0, 0, 0,
|
||||
0, 2/(top-bottom), 0, 0,
|
||||
0, 0, -1, 0,
|
||||
-(right+left)/(right-left), -(top+bottom)/(top-bottom), 0, 1
|
||||
]);
|
||||
gl.uniformMatrix4fv(projLoc, false, projection);
|
||||
|
||||
// Clear
|
||||
gl.viewport(0, 0, 400, 300);
|
||||
gl.clearColor(0.2, 0.2, 0.2, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// Create batch
|
||||
const batch = { positions: [], texCoords: [], indices: [], vertexCount: 0 };
|
||||
|
||||
// Add 4 sprites for 4 frames
|
||||
const spriteSize = 80;
|
||||
const spacing = 90;
|
||||
const startX = 50;
|
||||
const startY = 150;
|
||||
|
||||
const expectedColors = ['RED', 'GREEN', 'BLUE', 'YELLOW'];
|
||||
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const uv = calculateUV(frame, 2, 2);
|
||||
const x = startX + frame * spacing;
|
||||
const y = startY;
|
||||
|
||||
addSpriteVertices(
|
||||
batch,
|
||||
x, y, // position
|
||||
spriteSize, spriteSize, // size
|
||||
0, // rotation
|
||||
0.5, 0.5, // origin (center)
|
||||
uv.u0, uv.v0, uv.u1, uv.v1, // UV
|
||||
[1, 1, 1, 1] // color
|
||||
);
|
||||
}
|
||||
|
||||
// Upload and render
|
||||
const posLoc = gl.getAttribLocation(program, 'aPosition');
|
||||
const texLoc = gl.getAttribLocation(program, 'aTexCoord');
|
||||
|
||||
const posBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.positions), gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const texBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.texCoords), gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(texLoc);
|
||||
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const idxBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(batch.indices), gl.STATIC_DRAW);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
gl.drawElements(gl.TRIANGLES, batch.indices.length, gl.UNSIGNED_SHORT, 0);
|
||||
|
||||
// Read back colors and verify
|
||||
const resultsDiv = document.getElementById('results');
|
||||
let allPassed = true;
|
||||
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const x = startX + frame * spacing;
|
||||
const y = startY;
|
||||
|
||||
const pixels = new Uint8Array(4);
|
||||
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const actual = colorName(pixels[0], pixels[1], pixels[2]);
|
||||
const expected = expectedColors[frame];
|
||||
const passed = actual === expected;
|
||||
|
||||
if (!passed) allPassed = false;
|
||||
|
||||
const uv = calculateUV(frame, 2, 2);
|
||||
resultsDiv.innerHTML += `
|
||||
<div class="${passed ? 'pass' : 'fail'}">
|
||||
Frame ${frame}: UV=[${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]
|
||||
→ Expected: ${expected}, Got: ${actual} ${passed ? '✓' : '✗'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const conclusionEl = document.getElementById('conclusion');
|
||||
if (allPassed) {
|
||||
conclusionEl.innerHTML = `
|
||||
<span class="pass">ALL TESTS PASSED!</span>
|
||||
|
||||
The sprite_batch.rs rendering logic is CORRECT.
|
||||
UV calculation is CORRECT.
|
||||
|
||||
If particles still show wrong frames in the actual engine, possible causes:
|
||||
1. The spritesheet image layout is different (frame 0 not at top-left)
|
||||
2. Image loading is flipping the texture somewhere
|
||||
3. The particle system is using different UV values than expected
|
||||
|
||||
<b>NEXT STEP:</b> Check the actual spritesheet image in the editor.
|
||||
Is frame 0 really at the top-left corner of the image?
|
||||
`;
|
||||
} else {
|
||||
conclusionEl.innerHTML = `
|
||||
<span class="fail">SOME TESTS FAILED!</span>
|
||||
|
||||
There's a bug in the vertex/UV mapping logic.
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = runTest;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
142
packages/particle/src/__tests__/uv-calculation.test.ts
Normal file
142
packages/particle/src/__tests__/uv-calculation.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* UV 计算测试
|
||||
* UV Calculation Test
|
||||
*
|
||||
* 用于验证 TextureSheetAnimation 的 UV 坐标计算是否正确
|
||||
* Used to verify TextureSheetAnimation UV coordinate calculation
|
||||
*/
|
||||
|
||||
/**
|
||||
* 模拟 ParticleRenderDataProvider 中的 UV 计算
|
||||
* Simulate UV calculation from ParticleRenderDataProvider
|
||||
*/
|
||||
function calculateUV(frame: number, tilesX: number, tilesY: number) {
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
|
||||
const u0 = col * uWidth;
|
||||
const u1 = (col + 1) * uWidth;
|
||||
const v0 = row * vHeight;
|
||||
const v1 = (row + 1) * vHeight;
|
||||
|
||||
return { u0, v0, u1, v1, col, row };
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 4x4 spritesheet (16帧)
|
||||
*
|
||||
* 预期布局(标准 spritesheet,从左上角开始):
|
||||
* ┌────┬────┬────┬────┐
|
||||
* │ 0 │ 1 │ 2 │ 3 │ row=0, v: 0.00 - 0.25
|
||||
* ├────┼────┼────┼────┤
|
||||
* │ 4 │ 5 │ 6 │ 7 │ row=1, v: 0.25 - 0.50
|
||||
* ├────┼────┼────┼────┤
|
||||
* │ 8 │ 9 │ 10 │ 11 │ row=2, v: 0.50 - 0.75
|
||||
* ├────┼────┼────┼────┤
|
||||
* │ 12 │ 13 │ 14 │ 15 │ row=3, v: 0.75 - 1.00
|
||||
* └────┴────┴────┴────┘
|
||||
*/
|
||||
function test4x4Spritesheet() {
|
||||
console.log('=== 4x4 Spritesheet UV Test ===\n');
|
||||
|
||||
const tilesX = 4;
|
||||
const tilesY = 4;
|
||||
|
||||
console.log('Expected layout (standard spritesheet, top-left origin):');
|
||||
console.log('Frame 0 should be at TOP-LEFT (v: 0.00-0.25)');
|
||||
console.log('Frame 12 should be at BOTTOM-LEFT (v: 0.75-1.00)\n');
|
||||
|
||||
// 测试关键帧
|
||||
const testFrames = [0, 1, 4, 5, 12, 15];
|
||||
|
||||
for (const frame of testFrames) {
|
||||
const uv = calculateUV(frame, tilesX, tilesY);
|
||||
console.log(`Frame ${frame.toString().padStart(2)}: col=${uv.col}, row=${uv.row}`);
|
||||
console.log(` UV: [${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 2x2 spritesheet (4帧) - 最简单的情况
|
||||
*/
|
||||
function test2x2Spritesheet() {
|
||||
console.log('=== 2x2 Spritesheet UV Test ===\n');
|
||||
|
||||
const tilesX = 2;
|
||||
const tilesY = 2;
|
||||
|
||||
console.log('Layout:');
|
||||
console.log('┌─────┬─────┐');
|
||||
console.log('│ 0 │ 1 │ v: 0.0 - 0.5');
|
||||
console.log('├─────┼─────┤');
|
||||
console.log('│ 2 │ 3 │ v: 0.5 - 1.0');
|
||||
console.log('└─────┴─────┘\n');
|
||||
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const uv = calculateUV(frame, tilesX, tilesY);
|
||||
console.log(`Frame ${frame}: col=${uv.col}, row=${uv.row}`);
|
||||
console.log(` UV: [${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* WebGL 纹理坐标系说明
|
||||
*/
|
||||
function explainWebGLTextureCoords() {
|
||||
console.log('=== WebGL Texture Coordinate System ===\n');
|
||||
|
||||
console.log('Without UNPACK_FLIP_Y_WEBGL:');
|
||||
console.log('- Image row 0 (top of image file) -> stored at texture row 0');
|
||||
console.log('- Texture coordinate V=0 samples texture row 0');
|
||||
console.log('- Therefore: V=0 = image top, V=1 = image bottom');
|
||||
console.log('');
|
||||
|
||||
console.log('sprite_batch.rs vertex mapping:');
|
||||
console.log('- Vertex 0 (top-left on screen, high Y) uses tex_coords[0] = [u0, v0]');
|
||||
console.log('- Vertex 2 (bottom-right on screen, low Y) uses tex_coords[2] = [u1, v1]');
|
||||
console.log('');
|
||||
|
||||
console.log('Expected behavior:');
|
||||
console.log('- Frame 0 UV [0, 0, 0.25, 0.25] should show TOP-LEFT quarter of spritesheet');
|
||||
console.log('- If frame 0 shows BOTTOM-LEFT, the image is being rendered upside down');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断当前问题
|
||||
*/
|
||||
function diagnoseIssue() {
|
||||
console.log('=== Diagnosis ===\n');
|
||||
|
||||
console.log('If TextureSheetAnimation shows wrong frames, check:');
|
||||
console.log('');
|
||||
console.log('1. Is frame 0 showing the TOP-LEFT of the spritesheet?');
|
||||
console.log(' - YES: UV calculation is correct');
|
||||
console.log(' - NO (shows bottom-left): Image is flipped vertically in WebGL');
|
||||
console.log('');
|
||||
console.log('2. Are frames playing in wrong ORDER (e.g., 3,2,1,0 instead of 0,1,2,3)?');
|
||||
console.log(' - Check animation frame index calculation');
|
||||
console.log('');
|
||||
console.log('3. Is the spritesheet itself laid out correctly?');
|
||||
console.log(' - Frame 0 should be at TOP-LEFT of the image file');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 运行所有测试
|
||||
export function runUVTests() {
|
||||
explainWebGLTextureCoords();
|
||||
test2x2Spritesheet();
|
||||
test4x4Spritesheet();
|
||||
diagnoseIssue();
|
||||
}
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (typeof window !== 'undefined') {
|
||||
runUVTests();
|
||||
}
|
||||
|
||||
export { calculateUV, test2x2Spritesheet, test4x4Spritesheet };
|
||||
278
packages/particle/src/__tests__/webgl-uv-test.html
Normal file
278
packages/particle/src/__tests__/webgl-uv-test.html
Normal file
@@ -0,0 +1,278 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>WebGL UV Coordinate Test</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1a1a1a; color: #fff; padding: 20px; }
|
||||
canvas { border: 1px solid #444; margin: 10px; }
|
||||
.test-row { display: flex; align-items: center; margin: 20px 0; }
|
||||
.label { width: 200px; }
|
||||
.result { margin-left: 20px; padding: 5px 10px; border-radius: 4px; }
|
||||
.pass { background: #2a5; }
|
||||
.fail { background: #a33; }
|
||||
h2 { color: #8cf; }
|
||||
pre { background: #333; padding: 10px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebGL UV Coordinate System Test</h1>
|
||||
|
||||
<h2>1. Test Texture (2x2 grid)</h2>
|
||||
<pre>
|
||||
Image file layout (how it looks in image editor):
|
||||
┌─────────┬─────────┐
|
||||
│ RED (0) │ GREEN(1)│ row 0 (top of image file)
|
||||
├─────────┼─────────┤
|
||||
│ BLUE(2) │ YELLOW(3)│ row 1 (bottom of image file)
|
||||
└─────────┴─────────┘
|
||||
</pre>
|
||||
<canvas id="texturePreview" width="128" height="128"></canvas>
|
||||
<span>← This is the source texture</span>
|
||||
|
||||
<h2>2. UV Sampling Test</h2>
|
||||
<p>Each square below samples a different UV region. We test what color appears.</p>
|
||||
|
||||
<div class="test-row">
|
||||
<div class="label">UV [0, 0, 0.5, 0.5] (Frame 0):</div>
|
||||
<canvas id="uv0" width="64" height="64"></canvas>
|
||||
<div id="result0" class="result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-row">
|
||||
<div class="label">UV [0.5, 0, 1, 0.5] (Frame 1):</div>
|
||||
<canvas id="uv1" width="64" height="64"></canvas>
|
||||
<div id="result1" class="result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-row">
|
||||
<div class="label">UV [0, 0.5, 0.5, 1] (Frame 2):</div>
|
||||
<canvas id="uv2" width="64" height="64"></canvas>
|
||||
<div id="result2" class="result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-row">
|
||||
<div class="label">UV [0.5, 0.5, 1, 1] (Frame 3):</div>
|
||||
<canvas id="uv3" width="64" height="64"></canvas>
|
||||
<div id="result3" class="result"></div>
|
||||
</div>
|
||||
|
||||
<h2>3. Conclusion</h2>
|
||||
<pre id="conclusion"></pre>
|
||||
|
||||
<script>
|
||||
// Vertex shader
|
||||
const vsSource = `
|
||||
attribute vec2 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
varying vec2 vTexCoord;
|
||||
void main() {
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
vTexCoord = aTexCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
// Fragment shader
|
||||
const fsSource = `
|
||||
precision mediump float;
|
||||
varying vec2 vTexCoord;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||
}
|
||||
`;
|
||||
|
||||
function createShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl) {
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
// Create 2x2 test texture data
|
||||
// Row 0: Red, Green
|
||||
// Row 1: Blue, Yellow
|
||||
function createTestTextureData() {
|
||||
const size = 2;
|
||||
const data = new Uint8Array(size * size * 4);
|
||||
// Row 0 (will be uploaded first)
|
||||
data[0] = 255; data[1] = 0; data[2] = 0; data[3] = 255; // Red
|
||||
data[4] = 0; data[5] = 255; data[6] = 0; data[7] = 255; // Green
|
||||
// Row 1
|
||||
data[8] = 0; data[9] = 0; data[10] = 255; data[11] = 255; // Blue
|
||||
data[12] = 255; data[13] = 255; data[14] = 0; data[15] = 255; // Yellow
|
||||
return data;
|
||||
}
|
||||
|
||||
function createTexture(gl, flipY = false) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
// Set FLIP_Y if requested
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY ? 1 : 0);
|
||||
|
||||
const data = createTestTextureData();
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
function renderQuad(gl, program, u0, v0, u1, v1) {
|
||||
gl.useProgram(program);
|
||||
|
||||
const posLoc = gl.getAttribLocation(program, 'aPosition');
|
||||
const texLoc = gl.getAttribLocation(program, 'aTexCoord');
|
||||
|
||||
// Full screen quad
|
||||
const positions = new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1,
|
||||
-1, 1, 1, -1, 1, 1
|
||||
]);
|
||||
|
||||
// UV coordinates - map corners to the specified UV region
|
||||
// Vertex order: bottom-left, bottom-right, top-left, top-left, bottom-right, top-right
|
||||
const texCoords = new Float32Array([
|
||||
u0, v1, u1, v1, u0, v0,
|
||||
u0, v0, u1, v1, u1, v0
|
||||
]);
|
||||
|
||||
const posBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const texBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(texLoc);
|
||||
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
function getCanvasColor(canvas) {
|
||||
const ctx = canvas.getContext('2d') || canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
if (ctx instanceof WebGLRenderingContext || ctx instanceof WebGL2RenderingContext) {
|
||||
const pixels = new Uint8Array(4);
|
||||
ctx.readPixels(32, 32, 1, 1, ctx.RGBA, ctx.UNSIGNED_BYTE, pixels);
|
||||
return { r: pixels[0], g: pixels[1], b: pixels[2] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function colorName(r, g, b) {
|
||||
if (r > 200 && g < 100 && b < 100) return 'RED';
|
||||
if (r < 100 && g > 200 && b < 100) return 'GREEN';
|
||||
if (r < 100 && g < 100 && b > 200) return 'BLUE';
|
||||
if (r > 200 && g > 200 && b < 100) return 'YELLOW';
|
||||
return `RGB(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function runTest() {
|
||||
// Draw texture preview
|
||||
const previewCanvas = document.getElementById('texturePreview');
|
||||
const previewCtx = previewCanvas.getContext('2d');
|
||||
previewCtx.fillStyle = 'red';
|
||||
previewCtx.fillRect(0, 0, 64, 64);
|
||||
previewCtx.fillStyle = 'green';
|
||||
previewCtx.fillRect(64, 0, 64, 64);
|
||||
previewCtx.fillStyle = 'blue';
|
||||
previewCtx.fillRect(0, 64, 64, 64);
|
||||
previewCtx.fillStyle = 'yellow';
|
||||
previewCtx.fillRect(64, 64, 64, 64);
|
||||
|
||||
// Test UV regions
|
||||
const uvTests = [
|
||||
{ id: 'uv0', uv: [0, 0, 0.5, 0.5], expected: 'RED' },
|
||||
{ id: 'uv1', uv: [0.5, 0, 1, 0.5], expected: 'GREEN' },
|
||||
{ id: 'uv2', uv: [0, 0.5, 0.5, 1], expected: 'BLUE' },
|
||||
{ id: 'uv3', uv: [0.5, 0.5, 1, 1], expected: 'YELLOW' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of uvTests) {
|
||||
const canvas = document.getElementById(test.id);
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
|
||||
if (!gl) {
|
||||
document.getElementById('result' + test.id.slice(-1)).textContent = 'WebGL not supported';
|
||||
continue;
|
||||
}
|
||||
|
||||
const program = createProgram(gl);
|
||||
const texture = createTexture(gl, false); // No FLIP_Y
|
||||
|
||||
gl.viewport(0, 0, 64, 64);
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
renderQuad(gl, program, ...test.uv);
|
||||
|
||||
// Read back color
|
||||
const pixels = new Uint8Array(4);
|
||||
gl.readPixels(32, 32, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const actual = colorName(pixels[0], pixels[1], pixels[2]);
|
||||
|
||||
const passed = actual === test.expected;
|
||||
results.push({ test: test.id, expected: test.expected, actual, passed });
|
||||
|
||||
const resultEl = document.getElementById('result' + test.id.slice(-1));
|
||||
resultEl.textContent = `Expected: ${test.expected}, Got: ${actual}`;
|
||||
resultEl.className = 'result ' + (passed ? 'pass' : 'fail');
|
||||
}
|
||||
|
||||
// Conclusion
|
||||
const allPassed = results.every(r => r.passed);
|
||||
const conclusionEl = document.getElementById('conclusion');
|
||||
|
||||
if (allPassed) {
|
||||
conclusionEl.textContent = `
|
||||
ALL TESTS PASSED!
|
||||
|
||||
UV coordinate system (without FLIP_Y):
|
||||
- V=0 samples the TOP of the image (row 0)
|
||||
- V=1 samples the BOTTOM of the image (row N)
|
||||
|
||||
This means the current particle UV calculation should be CORRECT:
|
||||
v0 = row * vHeight; // row 0 -> v0=0 -> image top
|
||||
v1 = (row + 1) * vHeight;
|
||||
|
||||
If particles still show wrong frames, the problem is elsewhere.
|
||||
`;
|
||||
} else {
|
||||
const failedTests = results.filter(r => !r.passed);
|
||||
conclusionEl.textContent = `
|
||||
SOME TESTS FAILED!
|
||||
|
||||
${failedTests.map(t => `${t.test}: expected ${t.expected}, got ${t.actual}`).join('\n')}
|
||||
|
||||
This indicates the UV coordinate system behaves differently than expected.
|
||||
The V axis may need to be flipped in particle UV calculation.
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = runTest;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -194,17 +194,40 @@ export class ParticleRenderDataProvider implements IRenderDataProvider {
|
||||
this._transforms[tOffset + 5] = 0.5; // originX
|
||||
this._transforms[tOffset + 6] = 0.5; // originY
|
||||
|
||||
// Texture ID: 设置为 0,让 EngineRenderSystem 通过 textureGuid 解析
|
||||
// Set to 0, let EngineRenderSystem resolve via textureGuid
|
||||
// 这样可以避免场景恢复后 textureId 过期导致的纹理混乱问题
|
||||
// This avoids texture confusion when textureId becomes stale after scene restore
|
||||
this._textureIds[particleIndex] = 0;
|
||||
// Texture ID: 优先使用组件上预加载的 textureId,否则让 EngineRenderSystem 通过 textureGuid 解析
|
||||
// Prefer using pre-loaded textureId from component, otherwise let EngineRenderSystem resolve via textureGuid
|
||||
this._textureIds[particleIndex] = component.textureId;
|
||||
|
||||
// UV (full texture)
|
||||
this._uvs[uvOffset] = 0;
|
||||
this._uvs[uvOffset + 1] = 0;
|
||||
this._uvs[uvOffset + 2] = 1;
|
||||
this._uvs[uvOffset + 3] = 1;
|
||||
// UV - 支持精灵图帧动画 | Support spritesheet animation
|
||||
if (p._animTilesX !== undefined && p._animTilesY !== undefined && p._animFrame !== undefined) {
|
||||
// 计算帧的 UV 坐标 | Calculate frame UV coordinates
|
||||
// WebGL 纹理坐标:V=0 采样纹理行0(即图像顶部)
|
||||
// WebGL texture coords: V=0 samples texture row 0 (image top)
|
||||
const tilesX = p._animTilesX;
|
||||
const tilesY = p._animTilesY;
|
||||
const frame = p._animFrame;
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
|
||||
// UV: u0, v0, u1, v1
|
||||
const u0 = col * uWidth;
|
||||
const u1 = (col + 1) * uWidth;
|
||||
const v0 = row * vHeight;
|
||||
const v1 = (row + 1) * vHeight;
|
||||
|
||||
this._uvs[uvOffset] = u0;
|
||||
this._uvs[uvOffset + 1] = v0;
|
||||
this._uvs[uvOffset + 2] = u1;
|
||||
this._uvs[uvOffset + 3] = v1;
|
||||
} else {
|
||||
// 默认:使用完整纹理 | Default: use full texture
|
||||
this._uvs[uvOffset] = 0;
|
||||
this._uvs[uvOffset + 1] = 0;
|
||||
this._uvs[uvOffset + 2] = 1;
|
||||
this._uvs[uvOffset + 3] = 1;
|
||||
}
|
||||
|
||||
// Color (packed ABGR for WebGL)
|
||||
this._colors[particleIndex] = Color.packABGR(
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem, PluginServiceRegistry, createServiceToken } from '@esengine/ecs-framework';
|
||||
import { Input, MouseButton, TransformComponent, SortingLayers } from '@esengine/engine-core';
|
||||
import type { IAssetManager } from '@esengine/asset-system';
|
||||
import { ClickFxComponent, ClickFxTriggerMode } from '../ClickFxComponent';
|
||||
import { ParticleSystemComponent, RenderSpace } from '../ParticleSystemComponent';
|
||||
import type { IParticleAsset } from '../loaders/ParticleLoader';
|
||||
|
||||
// ============================================================================
|
||||
// 本地服务令牌定义 | Local Service Token Definitions
|
||||
@@ -66,7 +64,6 @@ const RenderSystemToken = createServiceToken<IEngineRenderSystem>('renderSystem'
|
||||
export class ClickFxSystem extends EntitySystem {
|
||||
private _engineBridge: IEngineBridge | null = null;
|
||||
private _renderSystem: IEngineRenderSystem | null = null;
|
||||
private _assetManager: IAssetManager | null = null;
|
||||
private _entitiesToDestroy: Entity[] = [];
|
||||
private _canvas: HTMLCanvasElement | null = null;
|
||||
|
||||
@@ -74,14 +71,6 @@ export class ClickFxSystem extends EntitySystem {
|
||||
super(Matcher.empty().all(ClickFxComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置资产管理器
|
||||
* Set asset manager
|
||||
*/
|
||||
setAssetManager(assetManager: IAssetManager | null): void {
|
||||
this._assetManager = assetManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置服务注册表(用于获取 EngineBridge 和 RenderSystem)
|
||||
* Set service registry (for getting EngineBridge and RenderSystem)
|
||||
@@ -339,8 +328,11 @@ export class ClickFxSystem extends EntitySystem {
|
||||
const transform = effectEntity.addComponent(new TransformComponent(screenSpaceX, screenSpaceY));
|
||||
transform.setScale(clickFx.scale, clickFx.scale, 1);
|
||||
|
||||
// 添加 ParticleSystem | Add ParticleSystem
|
||||
const particleSystem = effectEntity.addComponent(new ParticleSystemComponent());
|
||||
// 创建 ParticleSystemComponent 并预先设置 GUID(在添加到实体前)
|
||||
// Create ParticleSystemComponent and set GUID before adding to entity
|
||||
// 这样 ParticleUpdateSystem.onAdded 触发时已经有 GUID 了
|
||||
// So ParticleUpdateSystem.onAdded has the GUID when triggered
|
||||
const particleSystem = new ParticleSystemComponent();
|
||||
particleSystem.particleAssetGuid = particleGuid;
|
||||
particleSystem.autoPlay = true;
|
||||
// 使用 ScreenOverlay 层和屏幕空间渲染
|
||||
@@ -349,31 +341,12 @@ export class ClickFxSystem extends EntitySystem {
|
||||
particleSystem.orderInLayer = 0;
|
||||
particleSystem.renderSpace = RenderSpace.Screen;
|
||||
|
||||
// 添加组件到实体(触发 ParticleUpdateSystem 的初始化和资产加载)
|
||||
// Add component to entity (triggers ParticleUpdateSystem initialization and asset loading)
|
||||
effectEntity.addComponent(particleSystem);
|
||||
|
||||
// 记录活跃特效 | Record active effect
|
||||
clickFx.addActiveEffect(effectEntity.id);
|
||||
|
||||
// 异步加载并播放 | Async load and play
|
||||
if (this._assetManager) {
|
||||
this._assetManager.loadAsset<IParticleAsset>(particleGuid).then(result => {
|
||||
if (result?.asset) {
|
||||
particleSystem.setAssetData(result.asset);
|
||||
// 应用资产的排序属性 | Apply sorting properties from asset
|
||||
if (result.asset.sortingLayer) {
|
||||
particleSystem.sortingLayer = result.asset.sortingLayer;
|
||||
}
|
||||
if (result.asset.orderInLayer !== undefined) {
|
||||
particleSystem.orderInLayer = result.asset.orderInLayer;
|
||||
}
|
||||
particleSystem.play();
|
||||
} else {
|
||||
console.warn(`[ClickFxSystem] Failed to load particle asset: ${particleGuid}`);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error(`[ClickFxSystem] Error loading particle asset ${particleGuid}:`, error);
|
||||
});
|
||||
} else {
|
||||
console.warn('[ClickFxSystem] AssetManager not set, cannot load particle asset');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -185,6 +185,11 @@ export class ParticleUpdateSystem extends EntitySystem {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果正在初始化中,跳过处理 | Skip processing if initializing
|
||||
if (this._loadingComponents.has(particle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检测资产 GUID 变化并重新加载 | Detect asset GUID change and reload
|
||||
// 这使得编辑器中选择新的粒子资产时能够立即切换
|
||||
// This allows immediate switching when selecting a new particle asset in the editor
|
||||
@@ -205,8 +210,9 @@ export class ParticleUpdateSystem extends EntitySystem {
|
||||
particle.update(deltaTime, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
|
||||
}
|
||||
|
||||
// 尝试加载纹理(如果还没有加载)| Try to load texture if not loaded yet
|
||||
if (particle.textureId === 0) {
|
||||
// 尝试加载纹理(如果还没有加载且不在初始化中)
|
||||
// Try to load texture if not loaded yet and not initializing
|
||||
if (particle.textureId === 0 && !this._loadingComponents.has(particle)) {
|
||||
this.loadParticleTexture(particle);
|
||||
}
|
||||
|
||||
@@ -262,56 +268,65 @@ export class ParticleUpdateSystem extends EntitySystem {
|
||||
* Async initialize particle system
|
||||
*/
|
||||
private async _initializeParticle(entity: Entity, particle: ParticleSystemComponent): Promise<void> {
|
||||
// 如果有资产 GUID,先加载资产 | Load asset first if GUID is set
|
||||
if (particle.particleAssetGuid) {
|
||||
const asset = await this._loadParticleAsset(particle.particleAssetGuid);
|
||||
if (asset) {
|
||||
particle.setAssetData(asset);
|
||||
// 应用资产的排序属性 | Apply sorting properties from asset
|
||||
if (asset.sortingLayer) {
|
||||
particle.sortingLayer = asset.sortingLayer;
|
||||
}
|
||||
if (asset.orderInLayer !== undefined) {
|
||||
particle.orderInLayer = asset.orderInLayer;
|
||||
// 标记为正在初始化,防止 process 中重复调用 loadParticleTexture
|
||||
// Mark as initializing to prevent duplicate loadParticleTexture calls in process
|
||||
this._loadingComponents.add(particle);
|
||||
|
||||
try {
|
||||
// 如果有资产 GUID,先加载资产 | Load asset first if GUID is set
|
||||
if (particle.particleAssetGuid) {
|
||||
const asset = await this._loadParticleAsset(particle.particleAssetGuid);
|
||||
if (asset) {
|
||||
particle.setAssetData(asset);
|
||||
// 应用资产的排序属性 | Apply sorting properties from asset
|
||||
if (asset.sortingLayer) {
|
||||
particle.sortingLayer = asset.sortingLayer;
|
||||
}
|
||||
if (asset.orderInLayer !== undefined) {
|
||||
particle.orderInLayer = asset.orderInLayer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化粒子系统(不自动播放,由下面的逻辑控制)
|
||||
// Initialize particle system (don't auto play, controlled by logic below)
|
||||
particle.ensureBuilt();
|
||||
// 初始化粒子系统(不自动播放,由下面的逻辑控制)
|
||||
// Initialize particle system (don't auto play, controlled by logic below)
|
||||
particle.ensureBuilt();
|
||||
|
||||
// 加载纹理 | Load texture
|
||||
await this.loadParticleTexture(particle);
|
||||
// 加载纹理 | Load texture
|
||||
await this.loadParticleTexture(particle);
|
||||
|
||||
// 注册到渲染数据提供者 | Register to render data provider
|
||||
// 尝试获取 Transform,如果没有则使用默认位置 | Try to get Transform, use default position if not available
|
||||
let transform: ITransformComponent | null = null;
|
||||
if (this._transformType) {
|
||||
transform = entity.getComponent(this._transformType);
|
||||
}
|
||||
// 即使没有 Transform,也要注册粒子系统(使用原点位置) | Register particle system even without Transform (use origin position)
|
||||
if (transform) {
|
||||
this._renderDataProvider.register(particle, transform);
|
||||
} else {
|
||||
this._renderDataProvider.register(particle, { position: { x: 0, y: 0 } });
|
||||
}
|
||||
|
||||
// 记录已加载的资产 GUID | Record loaded asset GUID
|
||||
this._lastLoadedGuids.set(particle, particle.particleAssetGuid);
|
||||
|
||||
// 决定是否自动播放 | Decide whether to auto play
|
||||
// 编辑器模式:有资产时自动播放预览 | Editor mode: auto play preview if has asset
|
||||
// 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay setting
|
||||
const isEditorMode = this.scene?.isEditorMode ?? false;
|
||||
if (particle.particleAssetGuid && particle.loadedAsset) {
|
||||
if (isEditorMode) {
|
||||
// 编辑器模式:始终播放预览 | Editor mode: always play preview
|
||||
particle.play();
|
||||
} else if (particle.autoPlay) {
|
||||
// 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay
|
||||
particle.play();
|
||||
// 注册到渲染数据提供者 | Register to render data provider
|
||||
// 尝试获取 Transform,如果没有则使用默认位置 | Try to get Transform, use default position if not available
|
||||
let transform: ITransformComponent | null = null;
|
||||
if (this._transformType) {
|
||||
transform = entity.getComponent(this._transformType);
|
||||
}
|
||||
// 即使没有 Transform,也要注册粒子系统(使用原点位置) | Register particle system even without Transform (use origin position)
|
||||
if (transform) {
|
||||
this._renderDataProvider.register(particle, transform);
|
||||
} else {
|
||||
this._renderDataProvider.register(particle, { position: { x: 0, y: 0 } });
|
||||
}
|
||||
|
||||
// 记录已加载的资产 GUID | Record loaded asset GUID
|
||||
this._lastLoadedGuids.set(particle, particle.particleAssetGuid);
|
||||
|
||||
// 决定是否自动播放 | Decide whether to auto play
|
||||
// 编辑器模式:有资产时自动播放预览 | Editor mode: auto play preview if has asset
|
||||
// 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay setting
|
||||
const isEditorMode = this.scene?.isEditorMode ?? false;
|
||||
if (particle.particleAssetGuid && particle.loadedAsset) {
|
||||
if (isEditorMode) {
|
||||
// 编辑器模式:始终播放预览 | Editor mode: always play preview
|
||||
particle.play();
|
||||
} else if (particle.autoPlay) {
|
||||
// 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay
|
||||
particle.play();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 初始化完成,移除加载标记 | Initialization complete, remove loading mark
|
||||
this._loadingComponents.delete(particle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,9 +344,25 @@ export class ParticleUpdateSystem extends EntitySystem {
|
||||
const currentGuid = particle.particleAssetGuid;
|
||||
const lastGuid = this._lastLoadedGuids.get(particle);
|
||||
|
||||
// 如果 GUID 没有变化,或者正在加载中,跳过
|
||||
// Skip if GUID hasn't changed or already loading
|
||||
if (currentGuid === lastGuid || this._loadingComponents.has(particle)) {
|
||||
// 如果正在加载中,跳过
|
||||
// Skip if already loading
|
||||
if (this._loadingComponents.has(particle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要重新加载:
|
||||
// 1. GUID 变化了
|
||||
// 2. 或者 GUID 相同但资产数据丢失(场景恢复后)
|
||||
// 3. 或者 GUID 相同但纹理 ID 无效(纹理被清除后)
|
||||
// Check if reload is needed:
|
||||
// 1. GUID changed
|
||||
// 2. Or GUID is same but asset data is lost (after scene restore)
|
||||
// 3. Or GUID is same but texture ID is invalid (after texture clear)
|
||||
const needsReload = currentGuid !== lastGuid ||
|
||||
(currentGuid && !particle.loadedAsset) ||
|
||||
(currentGuid && particle.textureId === 0);
|
||||
|
||||
if (!needsReload) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -410,35 +441,70 @@ export class ParticleUpdateSystem extends EntitySystem {
|
||||
} catch (error) {
|
||||
console.error('[ParticleUpdateSystem] Failed to load texture by GUID:', textureGuid, error);
|
||||
// 加载失败时使用默认纹理 | Use default texture on load failure
|
||||
await this._ensureDefaultTexture();
|
||||
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
||||
const loaded = await this._ensureDefaultTexture();
|
||||
if (loaded) {
|
||||
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有纹理 GUID 时使用默认粒子纹理 | Use default particle texture when no GUID
|
||||
await this._ensureDefaultTexture();
|
||||
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
||||
const loaded = await this._ensureDefaultTexture();
|
||||
if (loaded) {
|
||||
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保默认粒子纹理已加载
|
||||
* Ensure default particle texture is loaded
|
||||
*
|
||||
* 使用 loadTextureAsync API 等待纹理实际加载完成,
|
||||
* 避免显示灰色占位符的问题。
|
||||
* Uses loadTextureAsync API to wait for actual texture completion,
|
||||
* avoiding the gray placeholder issue.
|
||||
*
|
||||
* @returns 是否成功加载 | Whether successfully loaded
|
||||
*/
|
||||
private async _ensureDefaultTexture(): Promise<void> {
|
||||
if (this._defaultTextureLoaded || this._defaultTextureLoading) return;
|
||||
if (!this._engineBridge) return;
|
||||
private async _ensureDefaultTexture(): Promise<boolean> {
|
||||
// 已加载过 | Already loaded
|
||||
if (this._defaultTextureLoaded) return true;
|
||||
|
||||
// 正在加载中,等待完成 | Loading in progress, wait for completion
|
||||
if (this._defaultTextureLoading) {
|
||||
// 轮询等待加载完成 | Poll until loading completes
|
||||
while (this._defaultTextureLoading) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
return this._defaultTextureLoaded;
|
||||
}
|
||||
|
||||
// 没有引擎桥接,无法加载 | No engine bridge, cannot load
|
||||
if (!this._engineBridge) {
|
||||
console.warn('[ParticleUpdateSystem] EngineBridge not set, cannot load default texture');
|
||||
return false;
|
||||
}
|
||||
|
||||
this._defaultTextureLoading = true;
|
||||
try {
|
||||
const dataUrl = generateDefaultParticleTextureDataURL();
|
||||
if (dataUrl) {
|
||||
await this._engineBridge.loadTexture(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl);
|
||||
// 优先使用 loadTextureAsync(等待纹理就绪)
|
||||
// Prefer loadTextureAsync (waits for texture ready)
|
||||
if (this._engineBridge.loadTextureAsync) {
|
||||
await this._engineBridge.loadTextureAsync(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl);
|
||||
} else {
|
||||
// 回退到旧 API(可能显示灰色占位符)
|
||||
// Fallback to old API (may show gray placeholder)
|
||||
await this._engineBridge.loadTexture(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl);
|
||||
}
|
||||
this._defaultTextureLoaded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ParticleUpdateSystem] Failed to create default particle texture:', error);
|
||||
}
|
||||
this._defaultTextureLoading = false;
|
||||
return this._defaultTextureLoaded;
|
||||
}
|
||||
|
||||
protected override onRemoved(entity: Entity): void {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 用于编辑器中的组件序列化/反序列化
|
||||
*/
|
||||
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule } from '@esengine/engine-core';
|
||||
|
||||
// Components (no WASM dependency)
|
||||
@@ -26,8 +26,9 @@ import { PolygonCollider2DComponent } from './components/PolygonCollider2DCompon
|
||||
export class Physics2DComponentsModule implements IRuntimeModule {
|
||||
/**
|
||||
* 注册组件到 ComponentRegistry
|
||||
* Register components to ComponentRegistry
|
||||
*/
|
||||
registerComponents(registry: typeof ComponentRegistry): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(Rigidbody2DComponent);
|
||||
registry.register(BoxCollider2DComponent);
|
||||
registry.register(CircleCollider2DComponent);
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
* 编辑器版本的物理插件,不包含 WASM 依赖。
|
||||
* Editor version of physics plugin, without WASM dependencies.
|
||||
*
|
||||
* 用于编辑器中注册插件清单,但不创建运行时模块。
|
||||
* 运行时使用 PhysicsPlugin from '@esengine/physics-rapier2d/runtime'
|
||||
* 使用轻量级 Physics2DComponentsModule 注册组件,
|
||||
* 使场景中的物理组件可以正确序列化/反序列化。
|
||||
* Uses lightweight Physics2DComponentsModule to register components,
|
||||
* enabling proper serialization/deserialization of physics components in scenes.
|
||||
*/
|
||||
|
||||
import type { IRuntimePlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import { Physics2DComponentsModule } from './Physics2DComponentsModule';
|
||||
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/physics-rapier2d',
|
||||
@@ -30,12 +33,15 @@ const manifest: ModuleManifest = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 编辑器物理插件(无运行时模块)
|
||||
* Editor physics plugin (no runtime module)
|
||||
* 编辑器物理插件(轻量级运行时模块)
|
||||
* Editor physics plugin (lightweight runtime module)
|
||||
*
|
||||
* 编辑器使用此版本注册插件,运行时使用带 WASM 的完整版本。
|
||||
* 使用 Physics2DComponentsModule 注册组件,用于场景反序列化。
|
||||
* 不包含 WASM 依赖,不创建物理系统。
|
||||
* Uses Physics2DComponentsModule for component registration (scene deserialization).
|
||||
* No WASM dependency, no physics system creation.
|
||||
*/
|
||||
export const Physics2DPlugin: IRuntimePlugin = {
|
||||
manifest
|
||||
// No runtime module - editor doesn't need physics simulation
|
||||
manifest,
|
||||
runtimeModule: new Physics2DComponentsModule()
|
||||
};
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* 提供 Rapier2D 物理引擎的 ECS 集成
|
||||
*/
|
||||
|
||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import { WasmLibraryLoaderFactory } from '@esengine/platform-common';
|
||||
import type * as RAPIER from '@esengine/rapier2d';
|
||||
@@ -101,10 +100,11 @@ class PhysicsRuntimeModule implements IRuntimeModule {
|
||||
|
||||
/**
|
||||
* 注册物理组件
|
||||
* Register physics components
|
||||
*
|
||||
* @param registry - 组件注册表
|
||||
* @param registry - 组件注册表 | Component registry
|
||||
*/
|
||||
registerComponents(registry: typeof ComponentRegistry): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(Rigidbody2DComponent);
|
||||
registry.register(BoxCollider2DComponent);
|
||||
registry.register(CircleCollider2DComponent);
|
||||
|
||||
@@ -9,11 +9,16 @@ import { isEditorEnvironment } from '@esengine/platform-common';
|
||||
/**
|
||||
* 获取 WASM 路径
|
||||
* Get WASM path based on environment
|
||||
*
|
||||
* Editor: engine/rapier2d/pkg/rapier_wasm2d_bg.wasm (deployed by vite build plugin)
|
||||
* Runtime: wasm/rapier_wasm2d_bg.wasm (deployed by game build)
|
||||
*/
|
||||
function getWasmPath(): string {
|
||||
const isEditor = isEditorEnvironment();
|
||||
// Editor uses dist/engine/rapier2d/pkg/ structure (from vite copy-engine-modules plugin)
|
||||
// 编辑器使用 dist/engine/rapier2d/pkg/ 结构(来自 vite copy-engine-modules 插件)
|
||||
const path = isEditor
|
||||
? 'engine/physics-rapier2d/rapier_wasm2d_bg.wasm'
|
||||
? 'engine/rapier2d/pkg/rapier_wasm2d_bg.wasm'
|
||||
: 'wasm/rapier_wasm2d_bg.wasm';
|
||||
|
||||
console.log(`[Rapier2D] isEditor=${isEditor}, wasmPath=${path}`);
|
||||
@@ -32,7 +37,7 @@ export const Rapier2DLoaderConfig: WasmLibraryConfig = {
|
||||
web: {
|
||||
/**
|
||||
* WASM 文件路径
|
||||
* 编辑器: engine/physics-rapier2d/rapier_wasm2d_bg.wasm
|
||||
* 编辑器: engine/rapier2d/pkg/rapier_wasm2d_bg.wasm
|
||||
* 运行时: wasm/rapier_wasm2d_bg.wasm
|
||||
*/
|
||||
get wasmPath(): string {
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
BrowserPlatformAdapter,
|
||||
runtimePluginManager,
|
||||
BrowserFileSystemService,
|
||||
type IPlugin
|
||||
RuntimeSceneManager,
|
||||
RuntimeSceneManagerToken,
|
||||
type IPlugin,
|
||||
type IRuntimeSceneManager
|
||||
} from '@esengine/runtime-core';
|
||||
import { isValidGUID, type IAssetManager } from '@esengine/asset-system';
|
||||
import { BrowserAssetReader } from './BrowserAssetReader';
|
||||
@@ -55,6 +58,7 @@ export class BrowserRuntime {
|
||||
private _assetBaseUrl: string;
|
||||
private _fileSystem: BrowserFileSystemService | null = null;
|
||||
private _assetReader: BrowserAssetReader | null = null;
|
||||
private _sceneManager: RuntimeSceneManager | null = null;
|
||||
private _initialized = false;
|
||||
|
||||
constructor(config: RuntimeConfig) {
|
||||
@@ -164,10 +168,60 @@ export class BrowserRuntime {
|
||||
// 为渲染系统设置资产路径解析器
|
||||
this._setupAssetPathResolver();
|
||||
|
||||
// Initialize scene manager
|
||||
// 初始化场景管理器
|
||||
this._initializeSceneManager();
|
||||
|
||||
this._initialized = true;
|
||||
console.log('[Runtime] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the runtime scene manager
|
||||
* 初始化运行时场景管理器
|
||||
*/
|
||||
private _initializeSceneManager(): void {
|
||||
if (!this._runtime) return;
|
||||
|
||||
// Create scene manager with scene loader
|
||||
// 使用场景加载器创建场景管理器
|
||||
this._sceneManager = new RuntimeSceneManager(
|
||||
(url: string) => this._runtime!.loadSceneFromUrl(url),
|
||||
'./scenes'
|
||||
);
|
||||
|
||||
// Auto-discover scenes from catalog
|
||||
// 从目录自动发现场景
|
||||
// scenes 是运行时扩展字段,不在 IAssetCatalog 接口中
|
||||
// scenes is a runtime extension field, not in IAssetCatalog interface
|
||||
const catalog = this._fileSystem?.catalog as { scenes?: Array<{ name: string; path: string }> } | null;
|
||||
if (catalog?.scenes) {
|
||||
const scenes = catalog.scenes.map((scene) => ({
|
||||
name: scene.name,
|
||||
path: `./scenes/${scene.name}.ecs`
|
||||
}));
|
||||
this._sceneManager.registerScenes(scenes);
|
||||
}
|
||||
|
||||
// Register scene manager as a service
|
||||
// 注册场景管理器为服务
|
||||
const serviceRegistry = this._runtime.getServiceRegistry();
|
||||
if (serviceRegistry) {
|
||||
serviceRegistry.register(RuntimeSceneManagerToken, this._sceneManager);
|
||||
}
|
||||
|
||||
// Also register in Core.services for global access (systems can access it)
|
||||
// 同时注册到 Core.services 供全局访问(系统可以访问)
|
||||
// RuntimeSceneManager 实现了 IService 接口(有 dispose 方法)
|
||||
// RuntimeSceneManager implements IService interface (has dispose method)
|
||||
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
|
||||
if (!Core.services.isRegistered(GlobalSceneManagerKey)) {
|
||||
Core.services.registerInstance(GlobalSceneManagerKey, this._sceneManager);
|
||||
}
|
||||
|
||||
console.log('[Runtime] Scene manager initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up asset path resolver for the render system
|
||||
* 为渲染系统设置资产路径解析器
|
||||
@@ -226,12 +280,21 @@ export class BrowserRuntime {
|
||||
/**
|
||||
* Load a scene from URL
|
||||
* 从 URL 加载场景
|
||||
*
|
||||
* @param sceneUrl 场景 URL 或名称 | Scene URL or name
|
||||
*/
|
||||
async loadScene(sceneUrl: string): Promise<void> {
|
||||
if (!this._runtime) {
|
||||
throw new Error('Runtime not initialized. Call initialize() first.');
|
||||
}
|
||||
await this._runtime.loadSceneFromUrl(sceneUrl);
|
||||
|
||||
// Use scene manager if available for proper tracking
|
||||
// 如果可用,使用场景管理器进行正确跟踪
|
||||
if (this._sceneManager) {
|
||||
await this._sceneManager.loadSceneByPath(sceneUrl);
|
||||
} else {
|
||||
await this._runtime.loadSceneFromUrl(sceneUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,6 +350,33 @@ export class BrowserRuntime {
|
||||
return this._runtime?.assetManager ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scene manager
|
||||
* 获取场景管理器
|
||||
*
|
||||
* Use this to load scenes, check available scenes, and listen to scene events.
|
||||
* 使用它来加载场景、检查可用场景和监听场景事件。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Load a scene by name
|
||||
* await runtime.sceneManager?.loadScene('Level1');
|
||||
*
|
||||
* // Get list of available scenes
|
||||
* const scenes = runtime.sceneManager?.availableScenes;
|
||||
*
|
||||
* // Listen to scene load events
|
||||
* runtime.sceneManager?.onLoadComplete((sceneName) => {
|
||||
* console.log(`Scene loaded: ${sceneName}`);
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @returns The scene manager instance, or null if not initialized
|
||||
*/
|
||||
get sceneManager(): IRuntimeSceneManager | null {
|
||||
return this._sceneManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime is initialized
|
||||
* 检查运行时是否已初始化
|
||||
|
||||
@@ -27,6 +27,16 @@ export { default } from './BrowserRuntime';
|
||||
// Asset reader
|
||||
export { BrowserAssetReader } from './BrowserAssetReader';
|
||||
|
||||
// Re-export scene manager for convenience
|
||||
// 重新导出场景管理器以方便使用
|
||||
export {
|
||||
RuntimeSceneManager,
|
||||
RuntimeSceneManagerToken,
|
||||
type IRuntimeSceneManager,
|
||||
type SceneInfo,
|
||||
type SceneLoadOptions
|
||||
} from '@esengine/runtime-core';
|
||||
|
||||
// ============================================
|
||||
// Web Platform Subsystems
|
||||
// ============================================
|
||||
|
||||
@@ -879,11 +879,10 @@ export class GameRuntime {
|
||||
* Save scene snapshot
|
||||
*
|
||||
* 使用二进制格式提升序列化性能,并支持 EntityRef 的正确序列化。
|
||||
* 在保存前清除纹理缓存,确保恢复时能够从干净状态重新加载纹理。
|
||||
* 使用路径稳定 ID 后,不再需要清除纹理缓存。
|
||||
*
|
||||
* Uses binary format for better serialization performance and supports proper
|
||||
* EntityRef serialization. Clears texture cache before saving to ensure
|
||||
* clean reload on restore.
|
||||
* EntityRef serialization. With path-stable IDs, no need to clear texture cache.
|
||||
*
|
||||
* @param options 可选配置
|
||||
* @param options.useJson 是否使用 JSON 格式(用于调试),默认 false 使用二进制
|
||||
@@ -895,13 +894,10 @@ export class GameRuntime {
|
||||
}
|
||||
|
||||
try {
|
||||
// 清除所有纹理缓存(确保恢复时重新加载)
|
||||
// Clear all texture caches (ensures reload on restore)
|
||||
// clearTextureMappings() 内部会同时清除 Rust 层和 JS 层的纹理缓存
|
||||
// clearTextureMappings() internally clears both Rust and JS layer texture caches
|
||||
if (this._engineIntegration) {
|
||||
this._engineIntegration.clearTextureMappings();
|
||||
}
|
||||
// 使用路径稳定 ID 后,不再清除纹理缓存
|
||||
// 组件保存的 textureId 在 Play/Stop 后仍然有效
|
||||
// With path-stable IDs, no longer clear texture cache
|
||||
// Component's saved textureId remains valid after Play/Stop
|
||||
|
||||
// 使用二进制格式提升性能(默认)或 JSON 用于调试
|
||||
// Use binary format for performance (default) or JSON for debugging
|
||||
@@ -927,9 +923,15 @@ export class GameRuntime {
|
||||
* 1. 创建所有实体和组件
|
||||
* 2. 解析所有 EntityRef 引用
|
||||
*
|
||||
* 使用路径稳定 ID 后,不再需要清除纹理缓存。
|
||||
* 组件保存的 textureId 在恢复后仍然有效。
|
||||
*
|
||||
* Uses two-phase deserialization to ensure EntityRef references are properly restored:
|
||||
* 1. Create all entities and components
|
||||
* 2. Resolve all EntityRef references
|
||||
*
|
||||
* With path-stable IDs, no need to clear texture cache.
|
||||
* Component's saved textureId remains valid after restore.
|
||||
*/
|
||||
async restoreSceneSnapshot(): Promise<boolean> {
|
||||
if (!this._scene || !this._sceneSnapshot) {
|
||||
@@ -938,19 +940,17 @@ export class GameRuntime {
|
||||
}
|
||||
|
||||
try {
|
||||
// 清除缓存
|
||||
// 清除 Tilemap 缓存(Tilemap 使用独立的缓存机制)
|
||||
// Clear Tilemap cache (Tilemap uses its own cache mechanism)
|
||||
const tilemapSystem = this._systemContext?.services.get(TilemapSystemToken);
|
||||
if (tilemapSystem) {
|
||||
tilemapSystem.clearCache?.();
|
||||
}
|
||||
|
||||
// 清除所有纹理并重置状态(修复 Play/Stop 后纹理 ID 混乱的问题)
|
||||
// Clear all textures and reset state (fixes texture ID confusion after Play/Stop)
|
||||
// clearTextureMappings() 内部会同时清除 Rust 层和 JS 层的纹理缓存
|
||||
// clearTextureMappings() internally clears both Rust and JS layer texture caches
|
||||
if (this._engineIntegration) {
|
||||
this._engineIntegration.clearTextureMappings();
|
||||
}
|
||||
// 使用路径稳定 ID 后,不再清除纹理缓存
|
||||
// 组件保存的 textureId 在 Play/Stop 后仍然有效
|
||||
// With path-stable IDs, no longer clear texture cache
|
||||
// Component's saved textureId remains valid after Play/Stop
|
||||
|
||||
// 反序列化场景(SceneSerializer 内部使用 SerializationContext 处理 EntityRef)
|
||||
// Deserialize scene (SceneSerializer internally uses SerializationContext for EntityRef)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 运行时插件管理器
|
||||
*/
|
||||
|
||||
import { ComponentRegistry, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { GlobalComponentRegistry, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IScene } from '@esengine/ecs-framework';
|
||||
import type { IRuntimePlugin, IRuntimeModule, SystemContext, ModuleManifest } from '@esengine/engine-core';
|
||||
|
||||
@@ -60,7 +60,7 @@ export class RuntimePluginManager {
|
||||
const mod = plugin.runtimeModule;
|
||||
if (mod?.registerComponents) {
|
||||
try {
|
||||
mod.registerComponents(ComponentRegistry);
|
||||
mod.registerComponents(GlobalComponentRegistry);
|
||||
} catch (e) {
|
||||
console.error(`[PluginManager] Failed to register components for ${id}:`, e);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,16 @@ export {
|
||||
type BrowserFileSystemOptions
|
||||
} from './services/BrowserFileSystemService';
|
||||
|
||||
// Runtime Scene Manager
|
||||
export {
|
||||
RuntimeSceneManager,
|
||||
RuntimeSceneManagerToken,
|
||||
type IRuntimeSceneManager,
|
||||
type SceneInfo,
|
||||
type SceneLoadOptions,
|
||||
type SceneLoader
|
||||
} from './services/RuntimeSceneManager';
|
||||
|
||||
// Re-export catalog types from asset-system (canonical source)
|
||||
// 从 asset-system 重新导出目录类型(规范来源)
|
||||
export type {
|
||||
|
||||
391
packages/runtime-core/src/services/RuntimeSceneManager.ts
Normal file
391
packages/runtime-core/src/services/RuntimeSceneManager.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 运行时场景管理器
|
||||
* Runtime Scene Manager
|
||||
*
|
||||
* 提供场景加载和切换 API,供用户脚本使用
|
||||
* Provides scene loading and transition API for user scripts
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 在用户脚本中获取场景管理器
|
||||
* // Get scene manager in user script
|
||||
* const sceneManager = services.get(RuntimeSceneManagerToken);
|
||||
*
|
||||
* // 加载场景(按名称)
|
||||
* // Load scene by name
|
||||
* await sceneManager.loadScene('GameScene');
|
||||
*
|
||||
* // 加载场景(按路径)
|
||||
* // Load scene by path
|
||||
* await sceneManager.loadSceneByPath('./scenes/Level1.ecs');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createServiceToken } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 场景信息
|
||||
* Scene info
|
||||
*/
|
||||
export interface SceneInfo {
|
||||
/** 场景名称 | Scene name */
|
||||
name: string;
|
||||
/** 场景路径(相对于构建输出目录)| Scene path (relative to build output) */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景加载选项
|
||||
* Scene load options
|
||||
*/
|
||||
export interface SceneLoadOptions {
|
||||
/**
|
||||
* 是否显示加载界面
|
||||
* Whether to show loading screen
|
||||
*/
|
||||
showLoading?: boolean;
|
||||
|
||||
/**
|
||||
* 过渡效果类型
|
||||
* Transition effect type
|
||||
*/
|
||||
transition?: 'none' | 'fade' | 'slide';
|
||||
|
||||
/**
|
||||
* 过渡持续时间(毫秒)
|
||||
* Transition duration in milliseconds
|
||||
*/
|
||||
transitionDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景加载器函数类型
|
||||
* Scene loader function type
|
||||
*/
|
||||
export type SceneLoader = (url: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 运行时场景管理器接口
|
||||
* Runtime Scene Manager Interface
|
||||
*
|
||||
* 继承 IService 的 dispose 模式以兼容 ServiceContainer。
|
||||
* Follows IService dispose pattern for ServiceContainer compatibility.
|
||||
*/
|
||||
export interface IRuntimeSceneManager {
|
||||
/**
|
||||
* 获取当前场景名称
|
||||
* Get current scene name
|
||||
*/
|
||||
readonly currentSceneName: string | null;
|
||||
|
||||
/**
|
||||
* 获取可用场景列表
|
||||
* Get available scene list
|
||||
*/
|
||||
readonly availableScenes: readonly SceneInfo[];
|
||||
|
||||
/**
|
||||
* 是否正在加载场景
|
||||
* Whether a scene is currently loading
|
||||
*/
|
||||
readonly isLoading: boolean;
|
||||
|
||||
/**
|
||||
* 注册可用场景
|
||||
* Register available scenes
|
||||
*/
|
||||
registerScenes(scenes: SceneInfo[]): void;
|
||||
|
||||
/**
|
||||
* 按名称加载场景
|
||||
* Load scene by name
|
||||
*/
|
||||
loadScene(sceneName: string, options?: SceneLoadOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* 按路径加载场景
|
||||
* Load scene by path
|
||||
*/
|
||||
loadSceneByPath(path: string, options?: SceneLoadOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* 重新加载当前场景
|
||||
* Reload current scene
|
||||
*/
|
||||
reloadCurrentScene(options?: SceneLoadOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* 添加场景加载开始监听器
|
||||
* Add scene load start listener
|
||||
*/
|
||||
onLoadStart(callback: (sceneName: string) => void): () => void;
|
||||
|
||||
/**
|
||||
* 添加场景加载完成监听器
|
||||
* Add scene load complete listener
|
||||
*/
|
||||
onLoadComplete(callback: (sceneName: string) => void): () => void;
|
||||
|
||||
/**
|
||||
* 添加场景加载错误监听器
|
||||
* Add scene load error listener
|
||||
*/
|
||||
onLoadError(callback: (error: Error, sceneName: string) => void): () => void;
|
||||
|
||||
/**
|
||||
* 释放资源(IService 兼容)
|
||||
* Dispose resources (IService compatible)
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时场景管理器服务令牌
|
||||
* Runtime Scene Manager Service Token
|
||||
*/
|
||||
export const RuntimeSceneManagerToken = createServiceToken<IRuntimeSceneManager>('runtimeSceneManager');
|
||||
|
||||
/**
|
||||
* 运行时场景管理器实现
|
||||
* Runtime Scene Manager Implementation
|
||||
*
|
||||
* 实现 IService 接口以兼容 ServiceContainer。
|
||||
* Implements IService for ServiceContainer compatibility.
|
||||
*/
|
||||
export class RuntimeSceneManager implements IRuntimeSceneManager {
|
||||
private _scenes = new Map<string, SceneInfo>();
|
||||
private _currentSceneName: string | null = null;
|
||||
private _currentScenePath: string | null = null;
|
||||
private _isLoading = false;
|
||||
private _sceneLoader: SceneLoader | null = null;
|
||||
private _baseUrl: string;
|
||||
private _disposed = false;
|
||||
|
||||
// 事件监听器 | Event listeners
|
||||
private _loadStartListeners = new Set<(sceneName: string) => void>();
|
||||
private _loadCompleteListeners = new Set<(sceneName: string) => void>();
|
||||
private _loadErrorListeners = new Set<(error: Error, sceneName: string) => void>();
|
||||
|
||||
/**
|
||||
* 创建运行时场景管理器
|
||||
* Create runtime scene manager
|
||||
*
|
||||
* @param sceneLoader 场景加载函数 | Scene loader function
|
||||
* @param baseUrl 场景文件基础 URL | Scene files base URL
|
||||
*/
|
||||
constructor(sceneLoader: SceneLoader, baseUrl: string = './scenes') {
|
||||
this._sceneLoader = sceneLoader;
|
||||
this._baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
get currentSceneName(): string | null {
|
||||
return this._currentSceneName;
|
||||
}
|
||||
|
||||
get availableScenes(): readonly SceneInfo[] {
|
||||
return Array.from(this._scenes.values());
|
||||
}
|
||||
|
||||
get isLoading(): boolean {
|
||||
return this._isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置场景加载器
|
||||
* Set scene loader
|
||||
*/
|
||||
setSceneLoader(loader: SceneLoader): void {
|
||||
this._sceneLoader = loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置基础 URL
|
||||
* Set base URL
|
||||
*/
|
||||
setBaseUrl(baseUrl: string): void {
|
||||
this._baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
registerScenes(scenes: SceneInfo[]): void {
|
||||
for (const scene of scenes) {
|
||||
this._scenes.set(scene.name, scene);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从目录或配置自动发现场景
|
||||
* Auto-discover scenes from catalog or config
|
||||
*/
|
||||
registerScenesFromCatalog(
|
||||
catalog: { scenes?: Array<{ name: string; path: string }> }
|
||||
): void {
|
||||
if (catalog.scenes) {
|
||||
this.registerScenes(catalog.scenes);
|
||||
}
|
||||
}
|
||||
|
||||
async loadScene(sceneName: string, options?: SceneLoadOptions): Promise<void> {
|
||||
const sceneInfo = this._scenes.get(sceneName);
|
||||
if (!sceneInfo) {
|
||||
// 尝试使用场景名作为路径
|
||||
// Try using scene name as path
|
||||
const guessedPath = `${this._baseUrl}/${sceneName}.ecs`;
|
||||
return this.loadSceneByPath(guessedPath, options);
|
||||
}
|
||||
|
||||
return this.loadSceneByPath(sceneInfo.path, options);
|
||||
}
|
||||
|
||||
async loadSceneByPath(path: string, options?: SceneLoadOptions): Promise<void> {
|
||||
if (!this._sceneLoader) {
|
||||
throw new Error('[RuntimeSceneManager] Scene loader not set');
|
||||
}
|
||||
|
||||
if (this._isLoading) {
|
||||
console.warn('[RuntimeSceneManager] Scene is already loading, ignoring request');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建完整 URL | Build full URL
|
||||
// Check if path is already absolute (http, relative ./, Unix /, or Windows drive letter)
|
||||
// 检查路径是否已经是绝对路径(http、相对 ./、Unix /、或 Windows 驱动器号)
|
||||
let fullPath = path;
|
||||
const isAbsolutePath = path.startsWith('http') ||
|
||||
path.startsWith('./') ||
|
||||
path.startsWith('/') ||
|
||||
(path.length > 1 && path[1] === ':'); // Windows absolute path like C:\ or F:\
|
||||
|
||||
if (!isAbsolutePath) {
|
||||
fullPath = `${this._baseUrl}/${path}`;
|
||||
}
|
||||
|
||||
// 提取场景名称 | Extract scene name
|
||||
const sceneName = this._extractSceneName(path);
|
||||
|
||||
this._isLoading = true;
|
||||
this._notifyLoadStart(sceneName);
|
||||
|
||||
try {
|
||||
// TODO: 实现过渡效果 | TODO: Implement transition effects
|
||||
// if (options?.transition && options.transition !== 'none') {
|
||||
// await this._startTransition(options.transition, options.transitionDuration);
|
||||
// }
|
||||
|
||||
await this._sceneLoader(fullPath);
|
||||
|
||||
this._currentSceneName = sceneName;
|
||||
this._currentScenePath = fullPath;
|
||||
this._isLoading = false;
|
||||
|
||||
this._notifyLoadComplete(sceneName);
|
||||
|
||||
console.log(`[RuntimeSceneManager] Scene loaded: ${sceneName}`);
|
||||
} catch (error) {
|
||||
this._isLoading = false;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this._notifyLoadError(err, sceneName);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadCurrentScene(options?: SceneLoadOptions): Promise<void> {
|
||||
if (!this._currentScenePath) {
|
||||
throw new Error('[RuntimeSceneManager] No current scene to reload');
|
||||
}
|
||||
|
||||
return this.loadSceneByPath(this._currentScenePath, options);
|
||||
}
|
||||
|
||||
onLoadStart(callback: (sceneName: string) => void): () => void {
|
||||
this._loadStartListeners.add(callback);
|
||||
return () => this._loadStartListeners.delete(callback);
|
||||
}
|
||||
|
||||
onLoadComplete(callback: (sceneName: string) => void): () => void {
|
||||
this._loadCompleteListeners.add(callback);
|
||||
return () => this._loadCompleteListeners.delete(callback);
|
||||
}
|
||||
|
||||
onLoadError(callback: (error: Error, sceneName: string) => void): () => void {
|
||||
this._loadErrorListeners.add(callback);
|
||||
return () => this._loadErrorListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查场景是否已注册
|
||||
* Check if scene is registered
|
||||
*/
|
||||
hasScene(sceneName: string): boolean {
|
||||
return this._scenes.has(sceneName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景路径
|
||||
* Get scene path
|
||||
*/
|
||||
getScenePath(sceneName: string): string | null {
|
||||
return this._scenes.get(sceneName)?.path ?? null;
|
||||
}
|
||||
|
||||
// ==================== 私有方法 | Private Methods ====================
|
||||
|
||||
private _extractSceneName(path: string): string {
|
||||
// 从路径中提取场景名称 | Extract scene name from path
|
||||
// ./scenes/Level1.ecs -> Level1
|
||||
// scenes/GameScene.ecs -> GameScene
|
||||
const fileName = path.split('/').pop() || path;
|
||||
return fileName.replace(/\.ecs$/, '');
|
||||
}
|
||||
|
||||
private _notifyLoadStart(sceneName: string): void {
|
||||
for (const listener of this._loadStartListeners) {
|
||||
try {
|
||||
listener(sceneName);
|
||||
} catch (e) {
|
||||
console.error('[RuntimeSceneManager] Error in load start listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _notifyLoadComplete(sceneName: string): void {
|
||||
for (const listener of this._loadCompleteListeners) {
|
||||
try {
|
||||
listener(sceneName);
|
||||
} catch (e) {
|
||||
console.error('[RuntimeSceneManager] Error in load complete listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _notifyLoadError(error: Error, sceneName: string): void {
|
||||
for (const listener of this._loadErrorListeners) {
|
||||
try {
|
||||
listener(error, sceneName);
|
||||
} catch (e) {
|
||||
console.error('[RuntimeSceneManager] Error in load error listener:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== IService 实现 | IService Implementation ====================
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
* Dispose resources
|
||||
*
|
||||
* 实现 IService 接口,清理所有监听器和状态。
|
||||
* Implements IService interface, cleans up all listeners and state.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
|
||||
this._loadStartListeners.clear();
|
||||
this._loadCompleteListeners.clear();
|
||||
this._loadErrorListeners.clear();
|
||||
this._scenes.clear();
|
||||
this._sceneLoader = null;
|
||||
this._currentSceneName = null;
|
||||
this._currentScenePath = null;
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
|
||||
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from './SpriteComponent';
|
||||
import { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
|
||||
@@ -11,7 +11,7 @@ export type { SystemContext, ModuleManifest, IRuntimeModule, IRuntimePlugin };
|
||||
export { SpriteAnimatorSystemToken } from './tokens';
|
||||
|
||||
class SpriteRuntimeModule implements IRuntimeModule {
|
||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(SpriteComponent);
|
||||
registry.register(SpriteAnimatorComponent);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { IScene } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import { AssetManagerToken } from '@esengine/asset-system';
|
||||
import { RenderSystemToken } from '@esengine/ecs-engine-bindgen';
|
||||
@@ -26,7 +25,7 @@ class TilemapRuntimeModule implements IRuntimeModule {
|
||||
private _tilemapPhysicsSystem: TilemapPhysicsSystem | null = null;
|
||||
private _loaderRegistered = false;
|
||||
|
||||
registerComponents(registry: typeof ComponentRegistry): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(TilemapComponent);
|
||||
registry.register(TilemapCollider2DComponent);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { IScene } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import { EngineBridgeToken } from '@esengine/ecs-engine-bindgen';
|
||||
|
||||
@@ -14,10 +13,14 @@ import {
|
||||
UISliderComponent,
|
||||
UIScrollViewComponent
|
||||
} from './components';
|
||||
import { TextBlinkComponent } from './components/TextBlinkComponent';
|
||||
import { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent';
|
||||
import { UILayoutSystem } from './systems/UILayoutSystem';
|
||||
import { UIInputSystem } from './systems/UIInputSystem';
|
||||
import { UIAnimationSystem } from './systems/UIAnimationSystem';
|
||||
import { UIRenderDataProvider } from './systems/UIRenderDataProvider';
|
||||
import { TextBlinkSystem } from './systems/TextBlinkSystem';
|
||||
import { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem';
|
||||
import {
|
||||
UIRenderBeginSystem,
|
||||
UIRectRenderSystem,
|
||||
@@ -43,7 +46,7 @@ export {
|
||||
} from './tokens';
|
||||
|
||||
class UIRuntimeModule implements IRuntimeModule {
|
||||
registerComponents(registry: typeof ComponentRegistry): void {
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(UITransformComponent);
|
||||
registry.register(UIRenderComponent);
|
||||
registry.register(UIInteractableComponent);
|
||||
@@ -53,6 +56,8 @@ class UIRuntimeModule implements IRuntimeModule {
|
||||
registry.register(UIProgressBarComponent);
|
||||
registry.register(UISliderComponent);
|
||||
registry.register(UIScrollViewComponent);
|
||||
registry.register(TextBlinkComponent);
|
||||
registry.register(SceneLoadTriggerComponent);
|
||||
}
|
||||
|
||||
createSystems(scene: IScene, context: SystemContext): void {
|
||||
@@ -65,6 +70,14 @@ class UIRuntimeModule implements IRuntimeModule {
|
||||
const animationSystem = new UIAnimationSystem();
|
||||
scene.addSystem(animationSystem);
|
||||
|
||||
// 文本闪烁系统 | Text blink system
|
||||
const textBlinkSystem = new TextBlinkSystem();
|
||||
scene.addSystem(textBlinkSystem);
|
||||
|
||||
// 场景加载触发系统 | Scene load trigger system
|
||||
const sceneLoadTriggerSystem = new SceneLoadTriggerSystem();
|
||||
scene.addSystem(sceneLoadTriggerSystem);
|
||||
|
||||
const renderBeginSystem = new UIRenderBeginSystem();
|
||||
scene.addSystem(renderBeginSystem);
|
||||
|
||||
|
||||
61
packages/ui/src/components/SceneLoadTriggerComponent.ts
Normal file
61
packages/ui/src/components/SceneLoadTriggerComponent.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 场景加载触发组件
|
||||
* Scene Load Trigger Component
|
||||
*
|
||||
* 配合 UIInteractable 使用,点击时自动加载指定场景。
|
||||
* Works with UIInteractable to automatically load scene on click.
|
||||
*/
|
||||
|
||||
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 场景加载触发组件
|
||||
* Scene Load Trigger Component
|
||||
*
|
||||
* 添加到带有 UIInteractable 的实体上,点击时会加载 targetScene 指定的场景。
|
||||
* Add to entity with UIInteractable, loads targetScene on click.
|
||||
*
|
||||
* @example
|
||||
* ```json
|
||||
* {
|
||||
* "type": "SceneLoadTrigger",
|
||||
* "data": {
|
||||
* "targetScene": "GameScene",
|
||||
* "enabled": true
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@ECSComponent('SceneLoadTrigger')
|
||||
@Serializable({ version: 1, typeId: 'SceneLoadTrigger' })
|
||||
export class SceneLoadTriggerComponent extends Component {
|
||||
/**
|
||||
* 目标场景名称
|
||||
* Target scene name to load on click
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'string', label: 'Target Scene' })
|
||||
public targetScene: string = '';
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
* Whether the trigger is enabled
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Enabled' })
|
||||
public enabled: boolean = true;
|
||||
|
||||
/**
|
||||
* 点击后是否禁用(防止重复点击)
|
||||
* Disable after click (prevent double clicks)
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Disable On Click' })
|
||||
public disableOnClick: boolean = true;
|
||||
|
||||
/**
|
||||
* 内部标记:回调是否已绑定
|
||||
* Internal flag: whether callback is bound
|
||||
*/
|
||||
public _callbackBound: boolean = false;
|
||||
}
|
||||
101
packages/ui/src/components/TextBlinkComponent.ts
Normal file
101
packages/ui/src/components/TextBlinkComponent.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 文本闪烁组件
|
||||
* Text Blink Component
|
||||
*
|
||||
* 让文本产生闪烁效果,类似 Unity 的 Animation 实现
|
||||
* Creates a blinking effect for text, similar to Unity's Animation implementation
|
||||
*/
|
||||
|
||||
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 文本闪烁组件
|
||||
* Text Blink Component
|
||||
*/
|
||||
@ECSComponent('TextBlink')
|
||||
@Serializable({ version: 1, typeId: 'TextBlink' })
|
||||
export class TextBlinkComponent extends Component {
|
||||
/**
|
||||
* 闪烁速度(周期/秒)
|
||||
* Blink speed (cycles per second)
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Speed', min: 0.1, max: 10, step: 0.1 })
|
||||
public speed: number = 1.5;
|
||||
|
||||
/**
|
||||
* 最小透明度
|
||||
* Minimum alpha
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Min Alpha', min: 0, max: 1, step: 0.05 })
|
||||
public minAlpha: number = 0.3;
|
||||
|
||||
/**
|
||||
* 最大透明度
|
||||
* Maximum alpha
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Max Alpha', min: 0, max: 1, step: 0.05 })
|
||||
public maxAlpha: number = 1.0;
|
||||
|
||||
/**
|
||||
* 是否启用闪烁
|
||||
* Whether blinking is enabled
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Enabled' })
|
||||
public blinkEnabled: boolean = true;
|
||||
|
||||
// ============= 运行时状态(不序列化)| Runtime state (not serialized) =============
|
||||
|
||||
/** 当前时间 | Current time */
|
||||
private _time: number = 0;
|
||||
|
||||
/**
|
||||
* 获取当前时间
|
||||
* Get current time
|
||||
*/
|
||||
public get time(): number {
|
||||
return this._time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
* Update time
|
||||
*/
|
||||
public addTime(deltaTime: number): void {
|
||||
this._time += deltaTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前 alpha 值
|
||||
* Calculate current alpha value
|
||||
*
|
||||
* 使用正弦波实现平滑的闪烁效果
|
||||
* Uses sine wave for smooth blinking effect
|
||||
*/
|
||||
public calculateAlpha(): number {
|
||||
if (!this.blinkEnabled) {
|
||||
return this.maxAlpha;
|
||||
}
|
||||
|
||||
// 使用正弦波:sin 从 -1 到 1,映射到 minAlpha 到 maxAlpha
|
||||
// Using sine wave: sin from -1 to 1, mapped to minAlpha to maxAlpha
|
||||
const t = Math.sin(this._time * this.speed * Math.PI * 2);
|
||||
const normalized = (t + 1) / 2; // 0 到 1
|
||||
return this.minAlpha + normalized * (this.maxAlpha - this.minAlpha);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
* Reset state
|
||||
*/
|
||||
public reset(): void {
|
||||
this._time = 0;
|
||||
}
|
||||
|
||||
override onRemovedFromEntity(): void {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
@@ -120,9 +120,36 @@ export class UIRenderComponent extends Component {
|
||||
/**
|
||||
* 九宫格边距 [top, right, bottom, left]
|
||||
* Nine-patch margins
|
||||
*
|
||||
* Defines the non-stretchable borders for nine-patch rendering.
|
||||
* 定义九宫格渲染时不可拉伸的边框区域。
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'vector4', label: 'Nine-Patch Margins' })
|
||||
public ninePatchMargins: [number, number, number, number] = [0, 0, 0, 0];
|
||||
|
||||
/**
|
||||
* 源纹理宽度(像素)
|
||||
* Source texture width in pixels
|
||||
*
|
||||
* Required for nine-patch UV calculations.
|
||||
* 九宫格 UV 计算所需。
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Texture Width', min: 1 })
|
||||
public textureWidth: number = 0;
|
||||
|
||||
/**
|
||||
* 源纹理高度(像素)
|
||||
* Source texture height in pixels
|
||||
*
|
||||
* Required for nine-patch UV calculations.
|
||||
* 九宫格 UV 计算所需。
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Texture Height', min: 1 })
|
||||
public textureHeight: number = 0;
|
||||
|
||||
// ===== 边框 Border =====
|
||||
|
||||
/**
|
||||
|
||||
@@ -275,6 +275,15 @@ export class UITransformComponent extends Component implements ISortable {
|
||||
*/
|
||||
public worldScaleY: number = 1;
|
||||
|
||||
/**
|
||||
* 计算后的世界层内顺序(考虑父元素和层级深度)
|
||||
* Computed world order in layer (considering parent and hierarchy depth)
|
||||
*
|
||||
* 子元素总是渲染在父元素之上:worldOrderInLayer = parentWorldOrder + depth * 1000 + localOrder
|
||||
* Children always render on top of parents: worldOrderInLayer = parentWorldOrder + depth * 1000 + localOrder
|
||||
*/
|
||||
public worldOrderInLayer: number = 0;
|
||||
|
||||
/**
|
||||
* 本地到世界的 2D 变换矩阵(只读,由 UILayoutSystem 计算)
|
||||
* Local to world 2D transformation matrix (readonly, computed by UILayoutSystem)
|
||||
|
||||
@@ -88,6 +88,9 @@ export {
|
||||
type UIFontWeight
|
||||
} from './components/UITextComponent';
|
||||
|
||||
export { TextBlinkComponent } from './components/TextBlinkComponent';
|
||||
export { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent';
|
||||
|
||||
export {
|
||||
UILayoutComponent,
|
||||
UILayoutType,
|
||||
@@ -124,6 +127,8 @@ export { UILayoutSystem } from './systems/UILayoutSystem';
|
||||
export { UIInputSystem, type UIInputEvent } from './systems/UIInputSystem';
|
||||
export { UIAnimationSystem, UIEasing, type EasingFunction, type EasingName } from './systems/UIAnimationSystem';
|
||||
export { UIRenderDataProvider, type IUIRenderDataProvider } from './systems/UIRenderDataProvider';
|
||||
export { TextBlinkSystem } from './systems/TextBlinkSystem';
|
||||
export { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem';
|
||||
|
||||
// Systems - Render (ECS-compliant render systems)
|
||||
export {
|
||||
|
||||
162
packages/ui/src/systems/SceneLoadTriggerSystem.ts
Normal file
162
packages/ui/src/systems/SceneLoadTriggerSystem.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 场景加载触发系统
|
||||
* Scene Load Trigger System
|
||||
*
|
||||
* 处理 SceneLoadTriggerComponent,绑定 UIInteractable 点击事件到场景加载。
|
||||
* Processes SceneLoadTriggerComponent, binds UIInteractable click to scene loading.
|
||||
*/
|
||||
|
||||
import { Entity, EntitySystem, Matcher, ECSSystem, Core } from '@esengine/ecs-framework';
|
||||
import { SceneLoadTriggerComponent } from '../components/SceneLoadTriggerComponent';
|
||||
import { UIInteractableComponent } from '../components/UIInteractableComponent';
|
||||
|
||||
/**
|
||||
* 场景加载函数类型(与 RuntimeSceneManager.loadScene 兼容)
|
||||
* Scene load function type (compatible with RuntimeSceneManager.loadScene)
|
||||
*/
|
||||
type SceneLoadFunction = (sceneName: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 场景管理器接口(最小化,避免循环依赖)
|
||||
* Scene manager interface (minimal, avoids circular dependency)
|
||||
*
|
||||
* 包含 IService 的 dispose 方法以兼容 ServiceContainer。
|
||||
* Includes IService's dispose method for ServiceContainer compatibility.
|
||||
*/
|
||||
interface ISceneManager {
|
||||
loadScene(sceneName: string): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局场景管理器服务键
|
||||
* Global scene manager service key
|
||||
*
|
||||
* 使用 Symbol.for 确保与 BrowserRuntime 中注册的键一致。
|
||||
* Uses Symbol.for to match the key registered in BrowserRuntime.
|
||||
*/
|
||||
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
|
||||
|
||||
/**
|
||||
* 场景加载触发系统
|
||||
* Scene Load Trigger System
|
||||
*
|
||||
* 自动将 SceneLoadTriggerComponent 的配置连接到 UIInteractable 的点击事件。
|
||||
* Automatically connects SceneLoadTriggerComponent config to UIInteractable click events.
|
||||
*/
|
||||
@ECSSystem('SceneLoadTrigger')
|
||||
export class SceneLoadTriggerSystem extends EntitySystem {
|
||||
private _sceneLoader: SceneLoadFunction | null = null;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(SceneLoadTriggerComponent, UIInteractableComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置场景加载函数
|
||||
* Set scene load function
|
||||
*
|
||||
* 可以直接设置函数,或者系统会尝试从服务注册表获取 RuntimeSceneManager。
|
||||
* Can set function directly, or system will try to get RuntimeSceneManager from service registry.
|
||||
*/
|
||||
public setSceneLoader(loader: SceneLoadFunction): void {
|
||||
this._sceneLoader = loader;
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
// 如果没有设置场景加载器,尝试从服务注册表获取
|
||||
// If no scene loader set, try to get from service registry
|
||||
if (!this._sceneLoader) {
|
||||
this._tryGetSceneManager();
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
const trigger = entity.getComponent(SceneLoadTriggerComponent);
|
||||
const interactable = entity.getComponent(UIInteractableComponent);
|
||||
|
||||
if (!trigger || !interactable) continue;
|
||||
if (!trigger.enabled || !trigger.targetScene) continue;
|
||||
|
||||
// 只绑定一次回调
|
||||
// Only bind callback once
|
||||
if (trigger._callbackBound) continue;
|
||||
|
||||
this._bindClickHandler(entity, trigger, interactable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从全局服务获取场景管理器
|
||||
* Try to get scene manager from global services
|
||||
*/
|
||||
private _tryGetSceneManager(): void {
|
||||
try {
|
||||
// 从 Core.services 获取场景管理器
|
||||
// Get scene manager from Core.services
|
||||
// RuntimeSceneManager 实现了 IService 接口
|
||||
// RuntimeSceneManager implements IService interface
|
||||
const sceneManager = Core.services.tryResolve<ISceneManager>(GlobalSceneManagerKey);
|
||||
if (sceneManager?.loadScene) {
|
||||
this._sceneLoader = (sceneName: string) => sceneManager.loadScene(sceneName);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误,保持 _sceneLoader 为 null
|
||||
// Ignore error, keep _sceneLoader as null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定点击处理器
|
||||
* Bind click handler
|
||||
*/
|
||||
private _bindClickHandler(
|
||||
entity: Entity,
|
||||
trigger: SceneLoadTriggerComponent,
|
||||
interactable: UIInteractableComponent
|
||||
): void {
|
||||
const targetScene = trigger.targetScene;
|
||||
|
||||
// 保存原有的 onClick(如果有)
|
||||
// Save original onClick (if any)
|
||||
const originalOnClick = interactable.onClick;
|
||||
|
||||
interactable.onClick = () => {
|
||||
// 调用原有回调
|
||||
// Call original callback
|
||||
originalOnClick?.();
|
||||
|
||||
// 检查是否启用
|
||||
// Check if enabled
|
||||
if (!trigger.enabled) return;
|
||||
|
||||
// 禁用(防止重复点击)
|
||||
// Disable (prevent double clicks)
|
||||
if (trigger.disableOnClick) {
|
||||
trigger.enabled = false;
|
||||
}
|
||||
|
||||
// 尝试获取场景加载器(可能在回调绑定后才注册)
|
||||
// Try to get scene loader (may be registered after callback binding)
|
||||
if (!this._sceneLoader) {
|
||||
this._tryGetSceneManager();
|
||||
}
|
||||
|
||||
// 加载场景
|
||||
// Load scene
|
||||
if (this._sceneLoader) {
|
||||
this._sceneLoader(targetScene).catch((error) => {
|
||||
console.error(`[SceneLoadTriggerSystem] Failed to load scene "${targetScene}":`, error);
|
||||
// 恢复启用状态
|
||||
// Restore enabled state
|
||||
if (trigger.disableOnClick) {
|
||||
trigger.enabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
// 静默处理:编辑器预览模式下场景切换不可用
|
||||
// Silent handling: scene switching not available in editor preview mode
|
||||
};
|
||||
|
||||
trigger._callbackBound = true;
|
||||
}
|
||||
}
|
||||
37
packages/ui/src/systems/TextBlinkSystem.ts
Normal file
37
packages/ui/src/systems/TextBlinkSystem.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 文本闪烁系统 - 实现 UI 元素的透明度脉冲动画
|
||||
*
|
||||
* Text Blink System - Implements alpha pulse animation for UI elements
|
||||
*/
|
||||
|
||||
import { Entity, EntitySystem, Matcher, Time } from '@esengine/ecs-framework';
|
||||
import { TextBlinkComponent } from '../components/TextBlinkComponent';
|
||||
import { UITransformComponent } from '../components/UITransformComponent';
|
||||
|
||||
/**
|
||||
* 处理 TextBlinkComponent,驱动 UI 元素的透明度动画。
|
||||
* 常用于 "TAP TO START" 等需要吸引注意力的文本效果。
|
||||
*
|
||||
* Processes TextBlinkComponent to drive UI element alpha animation.
|
||||
* Commonly used for attention-grabbing text effects like "TAP TO START".
|
||||
*/
|
||||
export class TextBlinkSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(TextBlinkComponent, UITransformComponent));
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
const deltaTime = Time.deltaTime;
|
||||
|
||||
for (const entity of entities) {
|
||||
if (!entity.enabled) continue;
|
||||
|
||||
const blink = entity.getComponent(TextBlinkComponent);
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
if (!blink || !uiTransform) continue;
|
||||
|
||||
blink.addTime(deltaTime);
|
||||
uiTransform.alpha = blink.calculateAlpha();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
const identityMatrix: Matrix2D = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
|
||||
|
||||
for (const entity of rootEntities) {
|
||||
this.layoutEntity(entity, parentX, parentY, this.canvasWidth, this.canvasHeight, 1, identityMatrix);
|
||||
this.layoutEntity(entity, parentX, parentY, this.canvasWidth, this.canvasHeight, 1, identityMatrix, true, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ export class UILayoutSystem extends EntitySystem {
|
||||
parentHeight: number,
|
||||
parentAlpha: number,
|
||||
parentMatrix: Matrix2D,
|
||||
parentVisible: boolean = true
|
||||
parentVisible: boolean = true,
|
||||
depth: number = 0
|
||||
): void {
|
||||
const transform = entity.getComponent(UITransformComponent);
|
||||
if (!transform) return;
|
||||
@@ -199,6 +200,12 @@ export class UILayoutSystem extends EntitySystem {
|
||||
// Calculate world visibility (if parent is invisible, children are also invisible)
|
||||
transform.worldVisible = parentVisible && transform.visible;
|
||||
|
||||
// 计算世界层内顺序(子元素总是渲染在父元素之上)
|
||||
// Calculate world order in layer (children always render on top of parents)
|
||||
// 公式:depth * 1000 + localOrderInLayer
|
||||
// Formula: depth * 1000 + localOrderInLayer
|
||||
transform.worldOrderInLayer = depth * 1000 + transform.orderInLayer;
|
||||
|
||||
// 使用矩阵乘法计算世界变换
|
||||
this.updateWorldMatrix(transform, parentMatrix);
|
||||
|
||||
@@ -215,7 +222,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
// 检查是否有布局组件
|
||||
const layout = entity.getComponent(UILayoutComponent);
|
||||
if (layout && layout.type !== UILayoutType.None) {
|
||||
this.layoutChildren(layout, transform, children);
|
||||
this.layoutChildren(layout, transform, children, depth + 1);
|
||||
} else {
|
||||
// 无布局组件,直接递归处理子元素
|
||||
for (const child of children) {
|
||||
@@ -227,7 +234,8 @@ export class UILayoutSystem extends EntitySystem {
|
||||
height,
|
||||
transform.worldAlpha,
|
||||
transform.localToWorldMatrix,
|
||||
transform.worldVisible
|
||||
transform.worldVisible,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -240,7 +248,8 @@ export class UILayoutSystem extends EntitySystem {
|
||||
private layoutChildren(
|
||||
layout: UILayoutComponent,
|
||||
parentTransform: UITransformComponent,
|
||||
children: Entity[]
|
||||
children: Entity[],
|
||||
depth: number
|
||||
): void {
|
||||
const contentStartX = parentTransform.worldX + layout.paddingLeft;
|
||||
// Y-up 系统:worldY 是底部,顶部 = worldY + height
|
||||
@@ -252,13 +261,13 @@ export class UILayoutSystem extends EntitySystem {
|
||||
|
||||
switch (layout.type) {
|
||||
case UILayoutType.Horizontal:
|
||||
this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
|
||||
this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth);
|
||||
break;
|
||||
case UILayoutType.Vertical:
|
||||
this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
|
||||
this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth);
|
||||
break;
|
||||
case UILayoutType.Grid:
|
||||
this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
|
||||
this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth);
|
||||
break;
|
||||
default:
|
||||
// 默认按正常方式递归(传递顶部 Y)
|
||||
@@ -270,7 +279,9 @@ export class UILayoutSystem extends EntitySystem {
|
||||
parentTransform.computedWidth,
|
||||
parentTransform.computedHeight,
|
||||
parentTransform.worldAlpha,
|
||||
parentTransform.localToWorldMatrix
|
||||
parentTransform.localToWorldMatrix,
|
||||
parentTransform.worldVisible,
|
||||
depth
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -287,7 +298,8 @@ export class UILayoutSystem extends EntitySystem {
|
||||
startX: number,
|
||||
startY: number,
|
||||
contentWidth: number,
|
||||
contentHeight: number
|
||||
contentHeight: number,
|
||||
depth: number
|
||||
): void {
|
||||
// 计算总子元素宽度
|
||||
const childSizes = children.map(child => {
|
||||
@@ -366,12 +378,14 @@ export class UILayoutSystem extends EntitySystem {
|
||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||
// 传播世界可见性 | Propagate world visibility
|
||||
childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
|
||||
// 计算世界层内顺序 | Calculate world order in layer
|
||||
childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer;
|
||||
// 使用矩阵乘法计算世界旋转和缩放
|
||||
this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
|
||||
childTransform.layoutDirty = false;
|
||||
|
||||
// 递归处理子元素的子元素
|
||||
this.processChildrenRecursive(child, childTransform);
|
||||
this.processChildrenRecursive(child, childTransform, depth);
|
||||
|
||||
offsetX += size.width + gap;
|
||||
}
|
||||
@@ -389,7 +403,8 @@ export class UILayoutSystem extends EntitySystem {
|
||||
startX: number,
|
||||
startY: number,
|
||||
contentWidth: number,
|
||||
contentHeight: number
|
||||
contentHeight: number,
|
||||
depth: number
|
||||
): void {
|
||||
// 计算总子元素高度
|
||||
const childSizes = children.map(child => {
|
||||
@@ -466,11 +481,13 @@ export class UILayoutSystem extends EntitySystem {
|
||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||
// 传播世界可见性 | Propagate world visibility
|
||||
childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
|
||||
// 计算世界层内顺序 | Calculate world order in layer
|
||||
childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer;
|
||||
// 使用矩阵乘法计算世界旋转和缩放
|
||||
this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
|
||||
childTransform.layoutDirty = false;
|
||||
|
||||
this.processChildrenRecursive(child, childTransform);
|
||||
this.processChildrenRecursive(child, childTransform, depth);
|
||||
|
||||
// 移动到下一个元素的顶部位置(向下 = Y 减小)
|
||||
currentTopY -= size.height + gap;
|
||||
@@ -489,7 +506,8 @@ export class UILayoutSystem extends EntitySystem {
|
||||
startX: number,
|
||||
startY: number,
|
||||
contentWidth: number,
|
||||
_contentHeight: number
|
||||
_contentHeight: number,
|
||||
depth: number
|
||||
): void {
|
||||
const columns = layout.columns;
|
||||
const gapX = layout.getHorizontalGap();
|
||||
@@ -524,11 +542,13 @@ export class UILayoutSystem extends EntitySystem {
|
||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||
// 传播世界可见性 | Propagate world visibility
|
||||
childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
|
||||
// 计算世界层内顺序 | Calculate world order in layer
|
||||
childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer;
|
||||
// 使用矩阵乘法计算世界旋转和缩放
|
||||
this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
|
||||
childTransform.layoutDirty = false;
|
||||
|
||||
this.processChildrenRecursive(child, childTransform);
|
||||
this.processChildrenRecursive(child, childTransform, depth);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +585,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
* 递归处理子元素
|
||||
* Recursively process children
|
||||
*/
|
||||
private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent): void {
|
||||
private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent, depth: number): void {
|
||||
const children = this.getUIChildren(entity);
|
||||
if (children.length === 0) return;
|
||||
|
||||
@@ -574,7 +594,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
|
||||
const layout = entity.getComponent(UILayoutComponent);
|
||||
if (layout && layout.type !== UILayoutType.None) {
|
||||
this.layoutChildren(layout, parentTransform, children);
|
||||
this.layoutChildren(layout, parentTransform, children, depth + 1);
|
||||
} else {
|
||||
for (const child of children) {
|
||||
this.layoutEntity(
|
||||
@@ -585,7 +605,8 @@ export class UILayoutSystem extends EntitySystem {
|
||||
parentTransform.computedHeight,
|
||||
parentTransform.worldAlpha,
|
||||
parentTransform.localToWorldMatrix,
|
||||
parentTransform.worldVisible
|
||||
parentTransform.worldVisible,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user