Compare commits

...

24 Commits

Author SHA1 Message Date
yhh
8662449dcf feat(ci): 改进 SignPath 代码签名集成
- 添加 SignPath 配置检查步骤
- 使用 test-signing 策略进行测试
- 即使签名跳过也能继续版本更新 PR
2025-12-16 13:09:39 +08:00
yhh
1834bc2068 fix(tests): 更新测试以使用 GlobalComponentRegistry 实例
修复多个测试文件以适配 ComponentRegistry 从静态类变为实例类的变更:
- ComponentStorage.test.ts: 使用 GlobalComponentRegistry.reset()
- EntitySerializer.test.ts: 使用 GlobalComponentRegistry 实例
- IncrementalSerialization.test.ts: 使用 GlobalComponentRegistry 实例
- SceneSerializer.test.ts: 使用 GlobalComponentRegistry 实例
- ComponentRegistry.extended.test.ts: 使用 GlobalComponentRegistry,同时注册到 scene.componentRegistry
- SystemTypes.test.ts: 在 Scene 创建前注册组件
- QuerySystem.test.ts: mockScene 添加 componentRegistry
2025-12-16 12:38:14 +08:00
yhh
c23c6c21db fix(asset-system): 移除未使用的 TextureLoader 导入 2025-12-16 12:06:09 +08:00
yhh
b494283e9c refactor(asset-system-editor): 资产元数据改进
- AssetMetaFile 优化
- 导出调整
2025-12-16 11:55:39 +08:00
yhh
9b334f36e1 refactor(platform): 平台适配层优化
- BrowserRuntime 改进
- 新增 RuntimeSceneManager 服务
- 导出优化
2025-12-16 11:55:06 +08:00
yhh
7f8d2eb142 refactor(particle): 粒子系统改进
- 适配新的组件注册接口
- ParticleSystem 优化
- 添加单元测试
2025-12-16 11:52:21 +08:00
yhh
9d3eeb1980 feat(tauri): 添加文件修改时间查询命令
- 新增 get_file_mtime 命令
- 支持检测文件外部修改
2025-12-16 11:51:58 +08:00
yhh
0bcb675c3b feat(i18n): 更新国际化翻译
- 添加新功能相关翻译
- 更新中文、英文、西班牙文
2025-12-16 11:29:14 +08:00
yhh
574b4d08a3 refactor(editor-app): 编辑器服务和组件优化
- EngineService 改进引擎集成
- EditorEngineSync 同步优化
- AssetFileInspector 改进
- VectorFieldEditors 优化
- InstantiatePrefabCommand 改进
2025-12-16 11:28:50 +08:00
yhh
d64e463a71 feat(editor-app): 添加渲染调试面板
- 新增 RenderDebugService 和调试面板 UI
- App/ContentBrowser 添加调试日志
- TitleBar/Viewport 优化
- DialogManager 改进
2025-12-16 11:28:34 +08:00
yhh
792fd05c85 feat(editor-app): 添加外部文件修改检测
- 新增 ExternalModificationDialog 组件
- TauriFileAPI 支持 getFileMtime
- 场景文件被外部修改时提示用户
2025-12-16 11:28:08 +08:00
yhh
7814b97ace feat(ui): 添加场景切换和文本闪烁组件
新增组件:
- SceneLoadTriggerComponent: 场景切换触发器
- TextBlinkComponent: 文本闪烁效果

新增系统:
- SceneLoadTriggerSystem: 处理场景切换逻辑
- TextBlinkSystem: 处理文本闪烁动画

其他改进:
- UIRuntimeModule 适配新组件注册接口
- UI 渲染系统优化
2025-12-16 11:25:49 +08:00
yhh
75be905f14 feat(engine): 改进 Rust 纹理管理器
- 支持任意 ID 的纹理加载(非递增)
- 添加纹理状态追踪 API
- 优化纹理缓存清理机制
- 更新 TypeScript 绑定
2025-12-16 11:25:28 +08:00
yhh
01293590e8 feat(editor-core): 改进编辑器核心服务
- EntityStoreService 添加调试日志
- AssetRegistryService 优化资产注册
- PluginManager 改进插件管理
- IFileAPI 添加 getFileMtime 接口
2025-12-16 11:23:50 +08:00
yhh
b236b729b4 fix(editor-app): 在编译完成后调用 signalReady()
确保用户脚本编译完成后发出就绪信号:
- 编译成功后调用 userCodeService.signalReady()
- 编译失败也要发出信号,避免阻塞场景加载
2025-12-16 11:21:57 +08:00
yhh
0170dc6e9c feat(editor-core): 添加 UserCodeService 就绪信号机制
- 新增 waitForReady()/signalReady() API
- 支持等待用户脚本编译完成
- 解决场景加载时组件未注册的时序问题
2025-12-16 11:17:19 +08:00
yhh
7834328ae0 fix(physics-rapier2d): 修复物理插件组件注册
- PhysicsEditorPlugin 添加 runtimeModule 引用
- 适配 IComponentRegistry 接口
- 修复物理组件在场景加载时未注册的问题
2025-12-16 11:12:50 +08:00
yhh
39fa797299 refactor(modules): 适配新的组件注册接口
更新各模块 RuntimeModule 使用 IComponentRegistry 接口:
- audio, behavior-tree, camera
- sprite, tilemap, world-streaming
2025-12-16 11:12:17 +08:00
yhh
03229ffb59 refactor(engine-core): 改进插件服务注册机制
- 更新 IComponentRegistry 类型引用
- 优化 PluginServiceRegistry 服务管理
2025-12-16 11:11:48 +08:00
yhh
844a770335 refactor(core): 提取 IComponentRegistry 接口
将组件注册表抽象为接口,支持场景级组件注册:
- 新增 IComponentRegistry 接口定义
- Scene 持有独立的 componentRegistry 实例
- 支持从 GlobalComponentRegistry 克隆
- 各系统支持传入自定义注册表
2025-12-16 11:11:29 +08:00
yhh
c8dc9869a3 fix(runtime-core): 修复 PluginManager 组件注册类型错误
将 ComponentRegistry 类改为 GlobalComponentRegistry 实例:
- registerComponents() 期望 IComponentRegistry 接口实例
- GlobalComponentRegistry 是 ComponentRegistry 的全局实例
2025-12-16 11:08:09 +08:00
yhh
38755c9014 fix(editor-core): 修复场景切换时的资源泄漏
在 openScene() 加载新场景前先卸载旧场景资源:
- 调用 sceneResourceManager.unloadSceneResources() 释放旧资源
- 使用引用计数机制,仅卸载不再被引用的资源
- 路径稳定 ID 缓存不受影响,保持 ID 稳定性
2025-12-16 11:07:48 +08:00
yhh
5d5537e4c7 fix(runtime-core): 移除 Play/Stop 循环中的 clearTextureMappings 调用
使用路径稳定 ID 后,不再需要在快照保存/恢复时清除纹理缓存:
- saveSceneSnapshot() 移除 clearTextureMappings() 调用
- restoreSceneSnapshot() 移除 clearTextureMappings() 调用
- 组件保存的 textureId 在 Play/Stop 后仍然有效
2025-12-16 11:07:15 +08:00
yhh
9da9f5f068 feat(asset-system): 实现路径稳定 ID 生成器
使用 FNV-1a hash 算法为纹理生成稳定的运行时 ID:
- 新增 _pathIdCache 静态缓存,跨 Play/Stop 循环保持稳定
- 新增 getStableIdForPath() 方法,相同路径永远返回相同 ID
- 修改 loadTextureForComponent/loadTextureByGuid 使用稳定 ID
- clearTextureMappings() 不再清除 _pathIdCache

这解决了 Play/Stop 后纹理 ID 失效的根本问题。
2025-12-16 11:06:59 +08:00
108 changed files with 7439 additions and 855 deletions

View File

@@ -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:

View File

@@ -18,6 +18,7 @@ export {
AssetMetaManager,
type IAssetMeta,
type IImportSettings,
type ISpriteSettings,
type IMetaFileSystem,
getMetaFilePath,
inferAssetType,

View File

@@ -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
* 清除缓存

View File

@@ -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 > 00 保留给默认纹理)
// Ensure ID > 0 (0 is reserved for default texture)
const id = (hash % 0x7FFFFFFF) + 1;
EngineIntegration._pathIdCache.set(cacheKey, id);
return id;
}
constructor(assetManager: AssetManager, engineBridge?: ITextureEngineBridge, pathResolver?: IPathResolutionService) {
this._assetManager = assetManager;
this._engineBridge = engineBridge;
@@ -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);
// 优先使用 getOrLoadTextureByPathRust 分配 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;
// 优先使用 getOrLoadTextureByPathRust 分配 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
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -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++;

View File

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

View File

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

View File

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

View File

@@ -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)} 未注册,跳过`);

View File

@@ -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';

View File

@@ -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[] };

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>;
}
/**

View File

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

View File

@@ -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
// 需要同时注册到 GlobalComponentRegistryArchetypeSystem 使用)和 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);
});

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

@@ -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(() => {

View File

@@ -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 - 被动系统', () => {

View File

@@ -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.
* 销毁桥接并释放资源。

View File

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

View File

@@ -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];

View File

@@ -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> {

View File

@@ -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"
}
}

View File

@@ -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",

View File

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

View File

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

View File

@@ -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 文件路径

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

@@ -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 }
],

View File

@@ -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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
/**
* 调试组件导出
* Debug components export
*/
export { RenderDebugPanel } from './RenderDebugPanel';
export type { default as RenderDebugPanelProps } from './RenderDebugPanel';

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: '帮助',

View File

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

View File

@@ -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.
*/

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

View File

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

View File

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

View File

@@ -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
// 只处理托管目录中的文件

View File

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

View File

@@ -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

View File

@@ -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';

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -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>;
}
/**

View File

@@ -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 {

View File

@@ -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.
/// 检查某个键是否当前被按下。
///

View File

@@ -5,4 +5,4 @@ mod texture;
mod texture_manager;
pub use texture::Texture;
pub use texture_manager::TextureManager;
pub use texture_manager::{TextureManager, TextureState};

View File

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

View File

@@ -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) {

View File

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

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

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

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

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

View File

@@ -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(

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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
* 检查运行时是否已初始化

View File

@@ -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
// ============================================

View File

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

View File

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

View File

@@ -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 {

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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 =====
/**

View File

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

View File

@@ -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 {

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

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

View File

@@ -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