Compare commits
24 Commits
@esengine/
...
feat/textu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8662449dcf | ||
|
|
1834bc2068 | ||
|
|
c23c6c21db | ||
|
|
b494283e9c | ||
|
|
9b334f36e1 | ||
|
|
7f8d2eb142 | ||
|
|
9d3eeb1980 | ||
|
|
0bcb675c3b | ||
|
|
574b4d08a3 | ||
|
|
d64e463a71 | ||
|
|
792fd05c85 | ||
|
|
7814b97ace | ||
|
|
75be905f14 | ||
|
|
01293590e8 | ||
|
|
b236b729b4 | ||
|
|
0170dc6e9c | ||
|
|
7834328ae0 | ||
|
|
39fa797299 | ||
|
|
03229ffb59 | ||
|
|
844a770335 | ||
|
|
c8dc9869a3 | ||
|
|
38755c9014 | ||
|
|
5d5537e4c7 | ||
|
|
9da9f5f068 |
48
.github/workflows/release-editor.yml
vendored
48
.github/workflows/release-editor.yml
vendored
@@ -122,33 +122,62 @@ jobs:
|
|||||||
|
|
||||||
# SignPath 代码签名(Windows)
|
# SignPath 代码签名(Windows)
|
||||||
# SignPath OSS code signing for Windows
|
# SignPath OSS code signing for Windows
|
||||||
# 注意:需要先在 https://signpath.io 申请 OSS 证书
|
#
|
||||||
# Note: Apply for OSS certificate at https://signpath.io first
|
# 配置步骤 | Setup Steps:
|
||||||
# 并配置 GitHub Secrets: SIGNPATH_API_TOKEN, SIGNPATH_ORGANIZATION_ID
|
# 1. 在 SignPath 门户创建项目 | Create project in SignPath portal
|
||||||
# Configure GitHub Secrets: SIGNPATH_API_TOKEN, SIGNPATH_ORGANIZATION_ID
|
# 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:
|
sign-windows:
|
||||||
needs: build-tauri
|
needs: build-tauri
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: success() && secrets.SIGNPATH_API_TOKEN != ''
|
# 只有在构建成功时才运行 | Only run on successful build
|
||||||
|
if: success()
|
||||||
|
|
||||||
steps:
|
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
|
- name: Checkout
|
||||||
|
if: steps.check-signpath.outputs.enabled == 'true'
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download Windows artifact
|
- name: Download Windows artifact
|
||||||
|
if: steps.check-signpath.outputs.enabled == 'true'
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-unsigned
|
name: windows-unsigned
|
||||||
path: ./artifacts
|
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
|
- name: Submit to SignPath for code signing
|
||||||
|
if: steps.check-signpath.outputs.enabled == 'true'
|
||||||
id: signpath
|
id: signpath
|
||||||
uses: signpath/github-action-submit-signing-request@v1
|
uses: signpath/github-action-submit-signing-request@v1
|
||||||
with:
|
with:
|
||||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||||
project-slug: 'ecs-framework'
|
project-slug: 'ecs-framework'
|
||||||
signing-policy-slug: 'release-signing'
|
signing-policy-slug: 'test-signing'
|
||||||
artifact-configuration-slug: 'default'
|
artifact-configuration-slug: 'default'
|
||||||
github-artifact-name: 'windows-unsigned'
|
github-artifact-name: 'windows-unsigned'
|
||||||
wait-for-completion: true
|
wait-for-completion: true
|
||||||
@@ -156,6 +185,7 @@ jobs:
|
|||||||
output-artifact-directory: './signed'
|
output-artifact-directory: './signed'
|
||||||
|
|
||||||
- name: Upload signed artifacts to release
|
- name: Upload signed artifacts to release
|
||||||
|
if: steps.check-signpath.outputs.enabled == 'true'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: ./signed/*
|
files: ./signed/*
|
||||||
@@ -165,9 +195,11 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# 构建成功后,创建 PR 更新版本号
|
# 构建成功后,创建 PR 更新版本号
|
||||||
|
# Create PR to update version after successful build
|
||||||
update-version-pr:
|
update-version-pr:
|
||||||
needs: sign-windows
|
needs: [build-tauri, sign-windows]
|
||||||
if: github.event_name == 'workflow_dispatch' && success()
|
# 即使签名跳过也要运行 | Run even if signing is skipped
|
||||||
|
if: github.event_name == 'workflow_dispatch' && !failure()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
AssetMetaManager,
|
AssetMetaManager,
|
||||||
type IAssetMeta,
|
type IAssetMeta,
|
||||||
type IImportSettings,
|
type IImportSettings,
|
||||||
|
type ISpriteSettings,
|
||||||
type IMetaFileSystem,
|
type IMetaFileSystem,
|
||||||
getMetaFilePath,
|
getMetaFilePath,
|
||||||
inferAssetType,
|
inferAssetType,
|
||||||
|
|||||||
@@ -49,6 +49,36 @@ export interface IAssetMeta {
|
|||||||
lastModified?: number;
|
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
|
* Import settings for different asset types
|
||||||
* 不同资产类型的导入设置
|
* 不同资产类型的导入设置
|
||||||
@@ -62,6 +92,9 @@ export interface IImportSettings {
|
|||||||
wrapMode?: 'clamp' | 'repeat' | 'mirror';
|
wrapMode?: 'clamp' | 'repeat' | 'mirror';
|
||||||
premultiplyAlpha?: boolean;
|
premultiplyAlpha?: boolean;
|
||||||
|
|
||||||
|
// Sprite settings | Sprite 设置
|
||||||
|
spriteSettings?: ISpriteSettings;
|
||||||
|
|
||||||
// Audio settings | 音频设置
|
// Audio settings | 音频设置
|
||||||
audioFormat?: 'mp3' | 'ogg' | 'wav';
|
audioFormat?: 'mp3' | 'ogg' | 'wav';
|
||||||
sampleRate?: number;
|
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
|
* Clear cache
|
||||||
* 清除缓存
|
* 清除缓存
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { AssetManager } from '../core/AssetManager';
|
|||||||
import { AssetGUID, AssetType } from '../types/AssetTypes';
|
import { AssetGUID, AssetType } from '../types/AssetTypes';
|
||||||
import { ITextureAsset, IAudioAsset, IJsonAsset } from '../interfaces/IAssetLoader';
|
import { ITextureAsset, IAudioAsset, IJsonAsset } from '../interfaces/IAssetLoader';
|
||||||
import { PathResolutionService, type IPathResolutionService } from '../services/PathResolutionService';
|
import { PathResolutionService, type IPathResolutionService } from '../services/PathResolutionService';
|
||||||
import { TextureLoader } from '../loaders/TextureLoader';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Texture engine bridge interface (for asset system)
|
* Texture engine bridge interface (for asset system)
|
||||||
@@ -67,6 +66,49 @@ export interface ITextureEngineBridge {
|
|||||||
* 清除所有纹理并重置状态(可选)。
|
* 清除所有纹理并重置状态(可选)。
|
||||||
*/
|
*/
|
||||||
clearAllTextures?(): void;
|
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 _textureIdMap = new Map<AssetGUID, number>();
|
||||||
private _pathToTextureId = new Map<string, 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 | 音频资源映射
|
// Audio resource mappings | 音频资源映射
|
||||||
private _audioIdMap = new Map<AssetGUID, number>();
|
private _audioIdMap = new Map<AssetGUID, number>();
|
||||||
private _pathToAudioId = new Map<string, number>();
|
private _pathToAudioId = new Map<string, number>();
|
||||||
@@ -112,6 +158,39 @@ export class EngineIntegration {
|
|||||||
private _dataAssets = new Map<number, DataAssetEntry>();
|
private _dataAssets = new Map<number, DataAssetEntry>();
|
||||||
private static _nextDataId = 1;
|
private static _nextDataId = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据路径生成稳定的 ID(使用 FNV-1a hash)
|
||||||
|
* Generate stable ID from path (using FNV-1a hash)
|
||||||
|
*
|
||||||
|
* 相同路径永远返回相同 ID,即使在 clearTextureMappings 后
|
||||||
|
* Same path always returns same ID, even after clearTextureMappings
|
||||||
|
*
|
||||||
|
* @param path 资源路径 | Resource path
|
||||||
|
* @param type 资源类型 | Resource type
|
||||||
|
* @returns 稳定的运行时 ID | Stable runtime ID
|
||||||
|
*/
|
||||||
|
private static getStableIdForPath(path: string, type: 'texture' | 'audio'): number {
|
||||||
|
const cacheKey = `${type}:${path}`;
|
||||||
|
const cached = EngineIntegration._pathIdCache.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FNV-1a hash 算法 | FNV-1a hash algorithm
|
||||||
|
let hash = 2166136261; // FNV offset basis
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
hash ^= path.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 16777619); // FNV prime
|
||||||
|
hash = hash >>> 0; // Keep as uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 ID > 0(0 保留给默认纹理)
|
||||||
|
// Ensure ID > 0 (0 is reserved for default texture)
|
||||||
|
const id = (hash % 0x7FFFFFFF) + 1;
|
||||||
|
EngineIntegration._pathIdCache.set(cacheKey, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(assetManager: AssetManager, engineBridge?: ITextureEngineBridge, pathResolver?: IPathResolutionService) {
|
constructor(assetManager: AssetManager, engineBridge?: ITextureEngineBridge, pathResolver?: IPathResolutionService) {
|
||||||
this._assetManager = assetManager;
|
this._assetManager = assetManager;
|
||||||
this._engineBridge = engineBridge;
|
this._engineBridge = engineBridge;
|
||||||
@@ -138,63 +217,56 @@ export class EngineIntegration {
|
|||||||
* Load texture for component
|
* Load texture for component
|
||||||
* 为组件加载纹理
|
* 为组件加载纹理
|
||||||
*
|
*
|
||||||
* 使用 Rust 引擎作为纹理 ID 的唯一分配源。
|
* 使用路径稳定 ID 确保相同路径在 Play/Stop 循环后返回相同 ID。
|
||||||
* Uses Rust engine as the single source of truth for texture ID allocation.
|
* 这样组件保存的 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 内部会处理路径解析,这里只需传入原始路径。
|
||||||
* AssetManager handles path resolution internally, just pass the original path here.
|
* AssetManager handles path resolution internally, just pass the original path here.
|
||||||
*/
|
*/
|
||||||
async loadTextureForComponent(texturePath: string): Promise<number> {
|
async loadTextureForComponent(texturePath: string): Promise<number> {
|
||||||
// 检查缓存(使用原始路径作为键)
|
// 生成路径稳定 ID(相同路径永远返回相同 ID)
|
||||||
// Check cache (using original path as key)
|
// 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);
|
const existingId = this._pathToTextureId.get(texturePath);
|
||||||
if (existingId) {
|
if (existingId === stableId) {
|
||||||
return existingId;
|
return stableId; // 已加载,直接返回 | Already loaded, return directly
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析路径为引擎可用的 URL
|
// 解析路径为引擎可用的 URL
|
||||||
// Resolve path to engine-compatible URL
|
// Resolve path to engine-compatible URL
|
||||||
const engineUrl = this._pathResolver.catalogToRuntime(texturePath);
|
const engineUrl = this._pathResolver.catalogToRuntime(texturePath);
|
||||||
|
|
||||||
// 优先使用 getOrLoadTextureByPath(Rust 分配 ID)
|
// 使用稳定 ID 加载纹理到 GPU
|
||||||
// Prefer getOrLoadTextureByPath (Rust allocates ID)
|
// Load texture to GPU with stable ID
|
||||||
// 这确保纹理 ID 由 Rust 引擎统一分配,避免 JS/Rust 层 ID 不同步问题
|
if (this._engineBridge) {
|
||||||
// This ensures texture IDs are allocated by Rust engine uniformly,
|
// 优先使用异步加载(支持加载状态追踪)
|
||||||
// avoiding JS/Rust layer ID desync issues
|
// Prefer async loading (supports loading state tracking)
|
||||||
if (this._engineBridge?.getOrLoadTextureByPath) {
|
if (this._engineBridge.loadTextureAsync) {
|
||||||
const rustTextureId = this._engineBridge.getOrLoadTextureByPath(engineUrl);
|
await this._engineBridge.loadTextureAsync(stableId, engineUrl);
|
||||||
if (rustTextureId > 0) {
|
} else {
|
||||||
// 缓存映射
|
await this._engineBridge.loadTexture(stableId, engineUrl);
|
||||||
// Cache mapping
|
|
||||||
this._pathToTextureId.set(texturePath, rustTextureId);
|
|
||||||
return rustTextureId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退:通过资产系统加载(兼容旧流程)
|
// 缓存映射
|
||||||
// Fallback: Load through asset system (for backward compatibility)
|
// Cache mapping
|
||||||
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath);
|
this._pathToTextureId.set(texturePath, stableId);
|
||||||
const textureAsset = result.asset;
|
|
||||||
|
|
||||||
// 如果有引擎桥接,上传到GPU
|
return stableId;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load texture by GUID
|
* Load texture by GUID
|
||||||
* 通过GUID加载纹理
|
* 通过GUID加载纹理
|
||||||
*
|
*
|
||||||
* 使用 Rust 引擎作为纹理 ID 的唯一分配源。
|
* 使用路径稳定 ID 确保相同路径在 Play/Stop 循环后返回相同 ID。
|
||||||
* Uses Rust engine as the single source of truth for texture ID allocation.
|
* Uses path-stable ID to ensure same path returns same ID across Play/Stop cycles.
|
||||||
*/
|
*/
|
||||||
async loadTextureByGuid(guid: AssetGUID): Promise<number> {
|
async loadTextureByGuid(guid: AssetGUID): Promise<number> {
|
||||||
// 检查是否已有纹理ID / Check if texture ID exists
|
// 检查是否已有纹理ID / Check if texture ID exists
|
||||||
@@ -206,31 +278,38 @@ export class EngineIntegration {
|
|||||||
// 通过资产系统加载获取元数据和路径 / Load through asset system to get metadata and path
|
// 通过资产系统加载获取元数据和路径 / Load through asset system to get metadata and path
|
||||||
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
|
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
|
||||||
const metadata = result.metadata;
|
const metadata = result.metadata;
|
||||||
const engineUrl = this._pathResolver.catalogToRuntime(metadata.path);
|
const assetPath = metadata.path;
|
||||||
|
|
||||||
// 优先使用 getOrLoadTextureByPath(Rust 分配 ID)
|
// 生成路径稳定 ID
|
||||||
// Prefer getOrLoadTextureByPath (Rust allocates ID)
|
// Generate path-stable ID
|
||||||
if (this._engineBridge?.getOrLoadTextureByPath) {
|
const stableId = EngineIntegration.getStableIdForPath(assetPath, 'texture');
|
||||||
const rustTextureId = this._engineBridge.getOrLoadTextureByPath(engineUrl);
|
|
||||||
if (rustTextureId > 0) {
|
// 检查是否已加载到 GPU
|
||||||
// 缓存映射
|
// Check if already loaded to GPU
|
||||||
// Cache mapping
|
if (this._pathToTextureId.get(assetPath) === stableId) {
|
||||||
this._textureIdMap.set(guid, rustTextureId);
|
this._textureIdMap.set(guid, stableId);
|
||||||
return rustTextureId;
|
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
|
// 缓存映射 / 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
|
* 注意:使用路径稳定 ID 后,不应在 Play/Stop 循环中调用此方法。
|
||||||
* texture cache to ensure textures are fully reloaded.
|
* 此方法仅用于场景切换时释放旧场景的纹理资源。
|
||||||
* 这会清除本地纹理 ID 映射和 AssetManager 的纹理缓存,确保纹理完全重新加载。
|
|
||||||
*
|
*
|
||||||
* IMPORTANT: This also clears the Rust engine's texture cache to ensure
|
* NOTE: With path-stable IDs, this should NOT be called during Play/Stop cycle.
|
||||||
* both JS and Rust layers are in sync.
|
* This method is only for releasing old scene's texture resources during scene switching.
|
||||||
* 重要:这也会清除 Rust 引擎的纹理缓存,确保 JS 和 Rust 层同步。
|
*
|
||||||
|
* _pathIdCache 不会被清除,确保相同路径始终返回相同 ID。
|
||||||
|
* _pathIdCache is NOT cleared, ensuring same path always returns same ID.
|
||||||
*/
|
*/
|
||||||
clearTextureMappings(): void {
|
clearTextureMappings(): void {
|
||||||
// 1. 清除本地映射
|
// 1. 清除加载状态映射(不清除 _pathIdCache)
|
||||||
// Clear local mappings
|
// Clear load state mappings (NOT clearing _pathIdCache)
|
||||||
this._textureIdMap.clear();
|
this._textureIdMap.clear();
|
||||||
this._pathToTextureId.clear();
|
this._pathToTextureId.clear();
|
||||||
|
|
||||||
// 2. 清除 Rust 引擎的纹理缓存(如果可用)
|
// 2. 清除 Rust 引擎的 GPU 纹理资源
|
||||||
// Clear Rust engine's texture cache (if available)
|
// Clear Rust engine's GPU texture resources
|
||||||
// 这确保下次加载时 Rust 会重新分配 ID
|
|
||||||
// This ensures Rust will reallocate IDs on next load
|
|
||||||
if (this._engineBridge?.clearAllTextures) {
|
if (this._engineBridge?.clearAllTextures) {
|
||||||
this._engineBridge.clearAllTextures();
|
this._engineBridge.clearAllTextures();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 清除 AssetManager 中的纹理资产缓存
|
// 3. 清除 AssetManager 中的纹理资产缓存
|
||||||
// Clear texture asset cache in AssetManager
|
// Clear texture asset cache in AssetManager
|
||||||
// 强制清除以确保纹理使用新的 ID 重新加载
|
|
||||||
// Force clear to ensure textures are reloaded with new IDs
|
|
||||||
this._assetManager.unloadAssetsByType(AssetType.Texture, true);
|
this._assetManager.unloadAssetsByType(AssetType.Texture, true);
|
||||||
|
|
||||||
// 4. 重置 TextureLoader 的 ID 计数器(保持向后兼容)
|
// 注意:不再重置 TextureLoader 的 ID 计数器,因为现在使用路径稳定 ID
|
||||||
// Reset TextureLoader's ID counter (for backward compatibility)
|
// NOTE: No longer reset TextureLoader's ID counter as we now use path-stable IDs
|
||||||
TextureLoader.resetTextureIdCounter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 type { IRuntimeModule, IRuntimePlugin, ModuleManifest } from '@esengine/engine-core';
|
||||||
import { AudioSourceComponent } from './AudioSourceComponent';
|
import { AudioSourceComponent } from './AudioSourceComponent';
|
||||||
|
|
||||||
class AudioRuntimeModule implements IRuntimeModule {
|
class AudioRuntimeModule implements IRuntimeModule {
|
||||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
registerComponents(registry: IComponentRegistry): void {
|
||||||
registry.register(AudioSourceComponent);
|
registry.register(AudioSourceComponent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
|
||||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
|
||||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||||
import { AssetManagerToken } from '@esengine/asset-system';
|
import { AssetManagerToken } from '@esengine/asset-system';
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ export { BehaviorTreeSystemToken } from './tokens';
|
|||||||
class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
||||||
private _loaderRegistered = false;
|
private _loaderRegistered = false;
|
||||||
|
|
||||||
registerComponents(registry: typeof ComponentRegistry): void {
|
registerComponents(registry: IComponentRegistry): void {
|
||||||
registry.register(BehaviorTreeRuntimeComponent);
|
registry.register(BehaviorTreeRuntimeComponent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
|
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
|
||||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||||
import { EngineBridgeToken } from '@esengine/engine-core';
|
import { EngineBridgeToken } from '@esengine/engine-core';
|
||||||
import { CameraComponent } from './CameraComponent';
|
import { CameraComponent } from './CameraComponent';
|
||||||
import { CameraSystem } from './CameraSystem';
|
import { CameraSystem } from './CameraSystem';
|
||||||
|
|
||||||
class CameraRuntimeModule implements IRuntimeModule {
|
class CameraRuntimeModule implements IRuntimeModule {
|
||||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
registerComponents(registry: IComponentRegistry): void {
|
||||||
registry.register(CameraComponent);
|
registry.register(CameraComponent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Entity } from '../Entity';
|
import { Entity } from '../Entity';
|
||||||
import { ComponentType, ComponentRegistry } from './ComponentStorage';
|
import { ComponentType, GlobalComponentRegistry } from './ComponentStorage';
|
||||||
import { BitMask64Data, BitMask64Utils } from '../Utils';
|
import { BitMask64Data, BitMask64Utils } from '../Utils';
|
||||||
import { BitMaskHashMap } from '../Utils/BitMaskHashMap';
|
import { BitMaskHashMap } from '../Utils/BitMaskHashMap';
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ export class ArchetypeSystem {
|
|||||||
private generateArchetypeId(componentTypes: ComponentType[]): ArchetypeId {
|
private generateArchetypeId(componentTypes: ComponentType[]): ArchetypeId {
|
||||||
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||||
for (const type of componentTypes) {
|
for (const type of componentTypes) {
|
||||||
const bitMask = ComponentRegistry.getBitMask(type);
|
const bitMask = GlobalComponentRegistry.getBitMask(type);
|
||||||
BitMask64Utils.orInPlace(mask, bitMask);
|
BitMask64Utils.orInPlace(mask, bitMask);
|
||||||
}
|
}
|
||||||
return mask;
|
return mask;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Entity } from '../Entity';
|
import { Entity } from '../Entity';
|
||||||
import { Component } from '../Component';
|
import { Component } from '../Component';
|
||||||
import { ComponentType, ComponentRegistry } from './ComponentStorage';
|
import { ComponentType, GlobalComponentRegistry } from './ComponentStorage';
|
||||||
import { IScene } from '../IScene';
|
import { IScene } from '../IScene';
|
||||||
import { createLogger } from '../../Utils/Logger';
|
import { createLogger } from '../../Utils/Logger';
|
||||||
|
|
||||||
@@ -198,10 +198,10 @@ export class CommandBuffer {
|
|||||||
private getTypeId(componentOrType: Component | ComponentType): number {
|
private getTypeId(componentOrType: Component | ComponentType): number {
|
||||||
if (typeof componentOrType === 'function') {
|
if (typeof componentOrType === 'function') {
|
||||||
// ComponentType
|
// ComponentType
|
||||||
return ComponentRegistry.getBitIndex(componentOrType);
|
return GlobalComponentRegistry.getBitIndex(componentOrType);
|
||||||
} else {
|
} else {
|
||||||
// Component instance
|
// 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) {
|
if (ops.removes && ops.removes.size > 0) {
|
||||||
for (const typeId of ops.removes) {
|
for (const typeId of ops.removes) {
|
||||||
try {
|
try {
|
||||||
const componentType = ComponentRegistry.getTypeByBitIndex(typeId);
|
const componentType = GlobalComponentRegistry.getTypeByBitIndex(typeId);
|
||||||
if (componentType) {
|
if (componentType) {
|
||||||
entity.removeComponentByType(componentType);
|
entity.removeComponentByType(componentType);
|
||||||
commandCount++;
|
commandCount++;
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { BitMask64Utils, BitMask64Data } from '../Utils/BigIntCompatibility';
|
|||||||
import { SoAStorage, SupportedTypedArray } from './SoAStorage';
|
import { SoAStorage, SupportedTypedArray } from './SoAStorage';
|
||||||
import { createLogger } from '../../Utils/Logger';
|
import { createLogger } from '../../Utils/Logger';
|
||||||
import { getComponentTypeName, ComponentType } from '../Decorators';
|
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 };
|
export type { ComponentType };
|
||||||
|
|
||||||
|
|
||||||
@@ -333,15 +336,18 @@ export class ComponentStorageManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取实体的组件位掩码
|
* 获取实体的组件位掩码
|
||||||
* @param entityId 实体ID
|
* Get component bitmask for entity
|
||||||
* @returns 组件位掩码
|
*
|
||||||
|
* @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);
|
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||||
|
|
||||||
for (const [componentType, storage] of this.storages.entries()) {
|
for (const [componentType, storage] of this.storages.entries()) {
|
||||||
if (storage.hasComponent(entityId)) {
|
if (storage.hasComponent(entityId)) {
|
||||||
const componentMask = ComponentRegistry.getBitMask(componentType as ComponentType);
|
const componentMask = registry.getBitMask(componentType as ComponentType);
|
||||||
BitMask64Utils.orInPlace(mask, componentMask);
|
BitMask64Utils.orInPlace(mask, componentMask);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Component Registry Implementation.
|
||||||
|
* 组件注册表实现。
|
||||||
|
*
|
||||||
|
* Manages component type bitmask allocation.
|
||||||
|
* Each Scene has its own registry instance for isolation.
|
||||||
|
* 管理组件类型的位掩码分配。
|
||||||
|
* 每个 Scene 都有自己的注册表实例以实现隔离。
|
||||||
|
*/
|
||||||
|
|
||||||
import { Component } from '../../Component';
|
import { Component } from '../../Component';
|
||||||
import { BitMask64Utils, BitMask64Data } from '../../Utils/BigIntCompatibility';
|
import { BitMask64Utils, BitMask64Data } from '../../Utils/BigIntCompatibility';
|
||||||
import { createLogger } from '../../../Utils/Logger';
|
import { createLogger } from '../../../Utils/Logger';
|
||||||
@@ -6,48 +16,43 @@ import {
|
|||||||
getComponentTypeName,
|
getComponentTypeName,
|
||||||
hasECSComponentDecorator
|
hasECSComponentDecorator
|
||||||
} from './ComponentTypeUtils';
|
} 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 {
|
export class ComponentRegistry implements IComponentRegistry {
|
||||||
protected static readonly _logger = createLogger('ComponentStorage');
|
private _componentTypes = new Map<Function, number>();
|
||||||
private static componentTypes = new Map<Function, number>();
|
private _bitIndexToType = new Map<number, Function>();
|
||||||
private static bitIndexToType = new Map<number, Function>();
|
private _componentNameToType = new Map<string, Function>();
|
||||||
private static componentNameToType = new Map<string, Function>();
|
private _componentNameToId = new Map<string, number>();
|
||||||
private static componentNameToId = new Map<string, number>();
|
private _maskCache = new Map<string, BitMask64Data>();
|
||||||
private static maskCache = new Map<string, BitMask64Data>();
|
private _nextBitIndex = 0;
|
||||||
private static nextBitIndex = 0;
|
private _hotReloadEnabled = false;
|
||||||
|
private _warnedComponents = new Set<Function>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 热更新模式标志,默认禁用
|
* Register component type and allocate bitmask.
|
||||||
* 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
|
|
||||||
*
|
*
|
||||||
* @param componentType 组件类型
|
* @param componentType - Component constructor | 组件构造函数
|
||||||
* @returns 分配的位索引
|
* @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);
|
const typeName = getComponentTypeName(componentType);
|
||||||
|
|
||||||
// 检查是否使用了 @ECSComponent 装饰器
|
|
||||||
// Check if @ECSComponent decorator is used
|
// Check if @ECSComponent decorator is used
|
||||||
if (!hasECSComponentDecorator(componentType) && !this.warnedComponents.has(componentType)) {
|
// 检查是否使用了 @ECSComponent 装饰器
|
||||||
this.warnedComponents.add(componentType);
|
if (!hasECSComponentDecorator(componentType) && !this._warnedComponents.has(componentType)) {
|
||||||
|
this._warnedComponents.add(componentType);
|
||||||
console.warn(
|
console.warn(
|
||||||
`[ComponentRegistry] Component "${typeName}" is missing @ECSComponent decorator. ` +
|
`[ComponentRegistry] Component "${typeName}" is missing @ECSComponent decorator. ` +
|
||||||
`This may cause issues with serialization and code minification. ` +
|
`This may cause issues with serialization and code minification. ` +
|
||||||
@@ -55,51 +60,43 @@ export class ComponentRegistry {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.componentTypes.has(componentType)) {
|
if (this._componentTypes.has(componentType)) {
|
||||||
const existingIndex = this.componentTypes.get(componentType)!;
|
return this._componentTypes.get(componentType)!;
|
||||||
return existingIndex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有同名但不同类的组件已注册(热更新场景)
|
// Hot reload: check if same-named component exists
|
||||||
// Check if a component with the same name but different class is registered (hot reload scenario)
|
// 热更新:检查是否有同名组件
|
||||||
if (this.hotReloadEnabled && this.componentNameToType.has(typeName)) {
|
if (this._hotReloadEnabled && this._componentNameToType.has(typeName)) {
|
||||||
const existingType = this.componentNameToType.get(typeName);
|
const existingType = this._componentNameToType.get(typeName);
|
||||||
if (existingType !== componentType) {
|
if (existingType !== componentType) {
|
||||||
// 热更新:替换旧的类为新的类,复用相同的 bitIndex
|
// Reuse old bitIndex, replace class mapping
|
||||||
// Hot reload: replace old class with new class, reuse the same bitIndex
|
// 复用旧的 bitIndex,替换类映射
|
||||||
const existingIndex = this.componentTypes.get(existingType!)!;
|
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);
|
||||||
|
|
||||||
// 移除旧类的映射
|
logger.debug(`Hot reload: replaced component "${typeName}"`);
|
||||||
// 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}"`);
|
|
||||||
return existingIndex;
|
return existingIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bitIndex = this.nextBitIndex++;
|
const bitIndex = this._nextBitIndex++;
|
||||||
this.componentTypes.set(componentType, bitIndex);
|
this._componentTypes.set(componentType, bitIndex);
|
||||||
this.bitIndexToType.set(bitIndex, componentType);
|
this._bitIndexToType.set(bitIndex, componentType);
|
||||||
this.componentNameToType.set(typeName, componentType);
|
this._componentNameToType.set(typeName, componentType);
|
||||||
this.componentNameToId.set(typeName, bitIndex);
|
this._componentNameToId.set(typeName, bitIndex);
|
||||||
|
|
||||||
return bitIndex;
|
return bitIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取组件类型的位掩码
|
* Get component type's bitmask.
|
||||||
* @param componentType 组件类型
|
* 获取组件类型的位掩码。
|
||||||
* @returns 位掩码
|
|
||||||
*/
|
*/
|
||||||
public static getBitMask<T extends Component>(componentType: ComponentType<T>): BitMask64Data {
|
public getBitMask<T extends Component>(componentType: ComponentType<T>): BitMask64Data {
|
||||||
const bitIndex = this.componentTypes.get(componentType);
|
const bitIndex = this._componentTypes.get(componentType);
|
||||||
if (bitIndex === undefined) {
|
if (bitIndex === undefined) {
|
||||||
const typeName = getComponentTypeName(componentType);
|
const typeName = getComponentTypeName(componentType);
|
||||||
throw new Error(`Component type ${typeName} is not registered`);
|
throw new Error(`Component type ${typeName} is not registered`);
|
||||||
@@ -108,12 +105,11 @@ export class ComponentRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取组件类型的位索引
|
* Get component type's bit index.
|
||||||
* @param componentType 组件类型
|
* 获取组件类型的位索引。
|
||||||
* @returns 位索引
|
|
||||||
*/
|
*/
|
||||||
public static getBitIndex<T extends Component>(componentType: ComponentType<T>): number {
|
public getBitIndex<T extends Component>(componentType: ComponentType<T>): number {
|
||||||
const bitIndex = this.componentTypes.get(componentType);
|
const bitIndex = this._componentTypes.get(componentType);
|
||||||
if (bitIndex === undefined) {
|
if (bitIndex === undefined) {
|
||||||
const typeName = getComponentTypeName(componentType);
|
const typeName = getComponentTypeName(componentType);
|
||||||
throw new Error(`Component type ${typeName} is not registered`);
|
throw new Error(`Component type ${typeName} is not registered`);
|
||||||
@@ -122,90 +118,84 @@ export class ComponentRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查组件类型是否已注册
|
* Check if component type is registered.
|
||||||
* @param componentType 组件类型
|
* 检查组件类型是否已注册。
|
||||||
* @returns 是否已注册
|
|
||||||
*/
|
*/
|
||||||
public static isRegistered<T extends Component>(componentType: ComponentType<T>): boolean {
|
public isRegistered<T extends Component>(componentType: ComponentType<T>): boolean {
|
||||||
return this.componentTypes.has(componentType);
|
return this._componentTypes.has(componentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过位索引获取组件类型
|
* Get component type by bit index.
|
||||||
* @param bitIndex 位索引
|
* 通过位索引获取组件类型。
|
||||||
* @returns 组件类型构造函数或null
|
|
||||||
*/
|
*/
|
||||||
public static getTypeByBitIndex(bitIndex: number): ComponentType | null {
|
public getTypeByBitIndex(bitIndex: number): ComponentType | null {
|
||||||
return (this.bitIndexToType.get(bitIndex) as ComponentType) || null;
|
return (this._bitIndexToType.get(bitIndex) as ComponentType) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前已注册的组件类型数量
|
* Get registered component count.
|
||||||
* @returns 已注册数量
|
* 获取已注册的组件数量。
|
||||||
*/
|
*/
|
||||||
public static getRegisteredCount(): number {
|
public getRegisteredCount(): number {
|
||||||
return this.nextBitIndex;
|
return this._nextBitIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过名称获取组件类型
|
* Get component type by name.
|
||||||
* @param componentName 组件名称
|
* 通过名称获取组件类型。
|
||||||
* @returns 组件类型构造函数
|
|
||||||
*/
|
*/
|
||||||
public static getComponentType(componentName: string): Function | null {
|
public getComponentType(componentName: string): Function | null {
|
||||||
return this.componentNameToType.get(componentName) || null;
|
return this._componentNameToType.get(componentName) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有已注册的组件类型
|
* Get all registered component types.
|
||||||
* @returns 组件类型映射
|
* 获取所有已注册的组件类型。
|
||||||
*/
|
*/
|
||||||
public static getAllRegisteredTypes(): Map<Function, number> {
|
public getAllRegisteredTypes(): Map<Function, number> {
|
||||||
return new Map(this.componentTypes);
|
return new Map(this._componentTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有组件名称到类型的映射
|
* Get all component names.
|
||||||
* @returns 名称到类型的映射
|
* 获取所有组件名称。
|
||||||
*/
|
*/
|
||||||
public static getAllComponentNames(): Map<string, Function> {
|
public getAllComponentNames(): Map<string, Function> {
|
||||||
return new Map(this.componentNameToType);
|
return new Map(this._componentNameToType);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过名称获取组件类型ID
|
* Get component type ID by name.
|
||||||
* @param componentName 组件名称
|
* 通过名称获取组件类型 ID。
|
||||||
* @returns 组件类型ID
|
|
||||||
*/
|
*/
|
||||||
public static getComponentId(componentName: string): number | undefined {
|
public getComponentId(componentName: string): number | undefined {
|
||||||
return this.componentNameToId.get(componentName);
|
return this._componentNameToId.get(componentName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册组件类型(通过名称)
|
* Register component type by name.
|
||||||
* @param componentName 组件名称
|
* 通过名称注册组件类型。
|
||||||
* @returns 分配的组件ID
|
|
||||||
*/
|
*/
|
||||||
public static registerComponentByName(componentName: string): number {
|
public registerComponentByName(componentName: string): number {
|
||||||
if (this.componentNameToId.has(componentName)) {
|
if (this._componentNameToId.has(componentName)) {
|
||||||
return this.componentNameToId.get(componentName)!;
|
return this._componentNameToId.get(componentName)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bitIndex = this.nextBitIndex++;
|
const bitIndex = this._nextBitIndex++;
|
||||||
this.componentNameToId.set(componentName, bitIndex);
|
this._componentNameToId.set(componentName, bitIndex);
|
||||||
return bitIndex;
|
return bitIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建单个组件的掩码
|
* Create single component mask.
|
||||||
* @param componentName 组件名称
|
* 创建单个组件的掩码。
|
||||||
* @returns 组件掩码
|
|
||||||
*/
|
*/
|
||||||
public static createSingleComponentMask(componentName: string): BitMask64Data {
|
public createSingleComponentMask(componentName: string): BitMask64Data {
|
||||||
const cacheKey = `single:${componentName}`;
|
const cacheKey = `single:${componentName}`;
|
||||||
|
|
||||||
if (this.maskCache.has(cacheKey)) {
|
if (this._maskCache.has(cacheKey)) {
|
||||||
return this.maskCache.get(cacheKey)!;
|
return this._maskCache.get(cacheKey)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentId = this.getComponentId(componentName);
|
const componentId = this.getComponentId(componentName);
|
||||||
@@ -214,21 +204,20 @@ export class ComponentRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mask = BitMask64Utils.create(componentId);
|
const mask = BitMask64Utils.create(componentId);
|
||||||
this.maskCache.set(cacheKey, mask);
|
this._maskCache.set(cacheKey, mask);
|
||||||
return mask;
|
return mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建多个组件的掩码
|
* Create component mask for multiple components.
|
||||||
* @param componentNames 组件名称数组
|
* 创建多个组件的掩码。
|
||||||
* @returns 组合掩码
|
|
||||||
*/
|
*/
|
||||||
public static createComponentMask(componentNames: string[]): BitMask64Data {
|
public createComponentMask(componentNames: string[]): BitMask64Data {
|
||||||
const sortedNames = [...componentNames].sort();
|
const sortedNames = [...componentNames].sort();
|
||||||
const cacheKey = `multi:${sortedNames.join(',')}`;
|
const cacheKey = `multi:${sortedNames.join(',')}`;
|
||||||
|
|
||||||
if (this.maskCache.has(cacheKey)) {
|
if (this._maskCache.has(cacheKey)) {
|
||||||
return this.maskCache.get(cacheKey)!;
|
return this._maskCache.get(cacheKey)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
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;
|
return mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除掩码缓存
|
* Clear mask cache.
|
||||||
|
* 清除掩码缓存。
|
||||||
*/
|
*/
|
||||||
public static clearMaskCache(): void {
|
public clearMaskCache(): void {
|
||||||
this.maskCache.clear();
|
this._maskCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启用热更新模式
|
* Enable hot reload mode.
|
||||||
* Enable hot reload mode
|
* 启用热更新模式。
|
||||||
* 在编辑器环境中调用以支持脚本热更新
|
|
||||||
* Call in editor environment to support script hot reload
|
|
||||||
*/
|
*/
|
||||||
public static enableHotReload(): void {
|
public enableHotReload(): void {
|
||||||
this.hotReloadEnabled = true;
|
this._hotReloadEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 禁用热更新模式
|
* Disable hot reload mode.
|
||||||
* Disable hot reload mode
|
* 禁用热更新模式。
|
||||||
*/
|
*/
|
||||||
public static disableHotReload(): void {
|
public disableHotReload(): void {
|
||||||
this.hotReloadEnabled = false;
|
this._hotReloadEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查热更新模式是否启用
|
* Check if hot reload mode is enabled.
|
||||||
* Check if hot reload mode is enabled
|
* 检查热更新模式是否启用。
|
||||||
*/
|
*/
|
||||||
public static isHotReloadEnabled(): boolean {
|
public isHotReloadEnabled(): boolean {
|
||||||
return this.hotReloadEnabled;
|
return this._hotReloadEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注销组件类型
|
* Unregister component type.
|
||||||
* Unregister component type
|
* 注销组件类型。
|
||||||
*
|
|
||||||
* 用于插件卸载时清理组件。
|
|
||||||
* 注意:这不会释放 bitIndex,以避免索引冲突。
|
|
||||||
*
|
|
||||||
* Used for cleanup during plugin unload.
|
|
||||||
* Note: This does not release bitIndex to avoid index conflicts.
|
|
||||||
*
|
|
||||||
* @param componentName 组件名称 | Component name
|
|
||||||
*/
|
*/
|
||||||
public static unregister(componentName: string): void {
|
public unregister(componentName: string): void {
|
||||||
const componentType = this.componentNameToType.get(componentName);
|
const componentType = this._componentNameToType.get(componentName);
|
||||||
if (!componentType) {
|
if (!componentType) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bitIndex = this.componentTypes.get(componentType);
|
const bitIndex = this._componentTypes.get(componentType);
|
||||||
|
|
||||||
// 移除类型映射
|
|
||||||
// Remove type mappings
|
// Remove type mappings
|
||||||
this.componentTypes.delete(componentType);
|
// 移除类型映射
|
||||||
|
this._componentTypes.delete(componentType);
|
||||||
if (bitIndex !== undefined) {
|
if (bitIndex !== undefined) {
|
||||||
this.bitIndexToType.delete(bitIndex);
|
this._bitIndexToType.delete(bitIndex);
|
||||||
}
|
}
|
||||||
this.componentNameToType.delete(componentName);
|
this._componentNameToType.delete(componentName);
|
||||||
this.componentNameToId.delete(componentName);
|
this._componentNameToId.delete(componentName);
|
||||||
|
|
||||||
// 清除相关的掩码缓存
|
|
||||||
// Clear related mask cache
|
// Clear related mask cache
|
||||||
|
// 清除相关的掩码缓存
|
||||||
this.clearMaskCache();
|
this.clearMaskCache();
|
||||||
|
|
||||||
this._logger.debug(`Component unregistered: ${componentName}`);
|
logger.debug(`Component unregistered: ${componentName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有已注册的组件信息
|
* Get all registered component info.
|
||||||
* Get all registered component info
|
* 获取所有已注册的组件信息。
|
||||||
*
|
|
||||||
* @returns 组件信息数组 | Array of 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 }> = [];
|
const result: Array<{ name: string; type: Function; bitIndex: number }> = [];
|
||||||
|
|
||||||
for (const [name, type] of this.componentNameToType) {
|
for (const [name, type] of this._componentNameToType) {
|
||||||
const bitIndex = this.componentTypes.get(type);
|
const bitIndex = this._componentTypes.get(type);
|
||||||
if (bitIndex !== undefined) {
|
if (bitIndex !== undefined) {
|
||||||
result.push({ name, type, bitIndex });
|
result.push({ name, type, bitIndex });
|
||||||
}
|
}
|
||||||
@@ -333,17 +311,48 @@ export class ComponentRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置注册表(用于测试)
|
* Reset registry.
|
||||||
* Reset registry (for testing)
|
* 重置注册表。
|
||||||
*/
|
*/
|
||||||
public static reset(): void {
|
public reset(): void {
|
||||||
this.componentTypes.clear();
|
this._componentTypes.clear();
|
||||||
this.bitIndexToType.clear();
|
this._bitIndexToType.clear();
|
||||||
this.componentNameToType.clear();
|
this._componentNameToType.clear();
|
||||||
this.componentNameToId.clear();
|
this._componentNameToId.clear();
|
||||||
this.maskCache.clear();
|
this._maskCache.clear();
|
||||||
this.warnedComponents.clear();
|
this._warnedComponents.clear();
|
||||||
this.nextBitIndex = 0;
|
this._nextBitIndex = 0;
|
||||||
this.hotReloadEnabled = false;
|
this._hotReloadEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone component types from another registry.
|
||||||
|
* 从另一个注册表克隆组件类型。
|
||||||
|
*
|
||||||
|
* Used to inherit framework components when creating a new Scene.
|
||||||
|
* 用于在创建新 Scene 时继承框架组件。
|
||||||
|
*/
|
||||||
|
public cloneFrom(source: IComponentRegistry): void {
|
||||||
|
const types = source.getAllRegisteredTypes();
|
||||||
|
for (const [type, index] of types) {
|
||||||
|
this._componentTypes.set(type, index);
|
||||||
|
this._bitIndexToType.set(index, type);
|
||||||
|
const typeName = getComponentTypeName(type as ComponentType);
|
||||||
|
this._componentNameToType.set(typeName, type);
|
||||||
|
this._componentNameToId.set(typeName, index);
|
||||||
|
}
|
||||||
|
this._nextBitIndex = source.getRegisteredCount();
|
||||||
|
this._hotReloadEnabled = source.isHotReloadEnabled();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Component Registry.
|
||||||
|
* 全局组件注册表。
|
||||||
|
*
|
||||||
|
* Used by framework components and decorators.
|
||||||
|
* Scene instances clone from this registry on creation.
|
||||||
|
* 用于框架组件和装饰器。
|
||||||
|
* Scene 实例在创建时从此注册表克隆。
|
||||||
|
*/
|
||||||
|
export const GlobalComponentRegistry = new ComponentRegistry();
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Component Registry Interface.
|
||||||
|
* 组件注册表接口。
|
||||||
|
*
|
||||||
|
* Defines the contract for component type registration and lookup.
|
||||||
|
* Each Scene has its own ComponentRegistry instance for isolation.
|
||||||
|
* 定义组件类型注册和查找的契约。
|
||||||
|
* 每个 Scene 都有自己的 ComponentRegistry 实例以实现隔离。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Component } from '../../Component';
|
||||||
|
import type { BitMask64Data } from '../../Utils/BigIntCompatibility';
|
||||||
|
import type { ComponentType } from './ComponentTypeUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component Registry Interface.
|
||||||
|
* 组件注册表接口。
|
||||||
|
*/
|
||||||
|
export interface IComponentRegistry {
|
||||||
|
/**
|
||||||
|
* Register component type and allocate bitmask.
|
||||||
|
* 注册组件类型并分配位掩码。
|
||||||
|
*
|
||||||
|
* @param componentType - Component constructor | 组件构造函数
|
||||||
|
* @returns Allocated bit index | 分配的位索引
|
||||||
|
*/
|
||||||
|
register<T extends Component>(componentType: ComponentType<T>): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get component type's bitmask.
|
||||||
|
* 获取组件类型的位掩码。
|
||||||
|
*
|
||||||
|
* @param componentType - Component constructor | 组件构造函数
|
||||||
|
* @returns Bitmask | 位掩码
|
||||||
|
*/
|
||||||
|
getBitMask<T extends Component>(componentType: ComponentType<T>): BitMask64Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get component type's bit index.
|
||||||
|
* 获取组件类型的位索引。
|
||||||
|
*
|
||||||
|
* @param componentType - Component constructor | 组件构造函数
|
||||||
|
* @returns Bit index | 位索引
|
||||||
|
*/
|
||||||
|
getBitIndex<T extends Component>(componentType: ComponentType<T>): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if component type is registered.
|
||||||
|
* 检查组件类型是否已注册。
|
||||||
|
*
|
||||||
|
* @param componentType - Component constructor | 组件构造函数
|
||||||
|
* @returns Whether registered | 是否已注册
|
||||||
|
*/
|
||||||
|
isRegistered<T extends Component>(componentType: ComponentType<T>): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get component type by bit index.
|
||||||
|
* 通过位索引获取组件类型。
|
||||||
|
*
|
||||||
|
* @param bitIndex - Bit index | 位索引
|
||||||
|
* @returns Component constructor or null | 组件构造函数或 null
|
||||||
|
*/
|
||||||
|
getTypeByBitIndex(bitIndex: number): ComponentType | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get component type by name.
|
||||||
|
* 通过名称获取组件类型。
|
||||||
|
*
|
||||||
|
* @param componentName - Component name | 组件名称
|
||||||
|
* @returns Component constructor or null | 组件构造函数或 null
|
||||||
|
*/
|
||||||
|
getComponentType(componentName: string): Function | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get component type ID by name.
|
||||||
|
* 通过名称获取组件类型 ID。
|
||||||
|
*
|
||||||
|
* @param componentName - Component name | 组件名称
|
||||||
|
* @returns Component type ID or undefined | 组件类型 ID 或 undefined
|
||||||
|
*/
|
||||||
|
getComponentId(componentName: string): number | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered component types.
|
||||||
|
* 获取所有已注册的组件类型。
|
||||||
|
*
|
||||||
|
* @returns Map of component type to bit index | 组件类型到位索引的映射
|
||||||
|
*/
|
||||||
|
getAllRegisteredTypes(): Map<Function, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all component names.
|
||||||
|
* 获取所有组件名称。
|
||||||
|
*
|
||||||
|
* @returns Map of name to component type | 名称到组件类型的映射
|
||||||
|
*/
|
||||||
|
getAllComponentNames(): Map<string, Function>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get registered component count.
|
||||||
|
* 获取已注册的组件数量。
|
||||||
|
*
|
||||||
|
* @returns Count | 数量
|
||||||
|
*/
|
||||||
|
getRegisteredCount(): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register component type by name.
|
||||||
|
* 通过名称注册组件类型。
|
||||||
|
*
|
||||||
|
* @param componentName - Component name | 组件名称
|
||||||
|
* @returns Allocated component ID | 分配的组件 ID
|
||||||
|
*/
|
||||||
|
registerComponentByName(componentName: string): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create single component mask.
|
||||||
|
* 创建单个组件的掩码。
|
||||||
|
*
|
||||||
|
* @param componentName - Component name | 组件名称
|
||||||
|
* @returns Component mask | 组件掩码
|
||||||
|
*/
|
||||||
|
createSingleComponentMask(componentName: string): BitMask64Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create component mask for multiple components.
|
||||||
|
* 创建多个组件的掩码。
|
||||||
|
*
|
||||||
|
* @param componentNames - Component names | 组件名称数组
|
||||||
|
* @returns Combined mask | 组合掩码
|
||||||
|
*/
|
||||||
|
createComponentMask(componentNames: string[]): BitMask64Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister component type.
|
||||||
|
* 注销组件类型。
|
||||||
|
*
|
||||||
|
* @param componentName - Component name | 组件名称
|
||||||
|
*/
|
||||||
|
unregister(componentName: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable hot reload mode.
|
||||||
|
* 启用热更新模式。
|
||||||
|
*/
|
||||||
|
enableHotReload(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable hot reload mode.
|
||||||
|
* 禁用热更新模式。
|
||||||
|
*/
|
||||||
|
disableHotReload(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if hot reload mode is enabled.
|
||||||
|
* 检查热更新模式是否启用。
|
||||||
|
*
|
||||||
|
* @returns Whether enabled | 是否启用
|
||||||
|
*/
|
||||||
|
isHotReloadEnabled(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear mask cache.
|
||||||
|
* 清除掩码缓存。
|
||||||
|
*/
|
||||||
|
clearMaskCache(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset registry.
|
||||||
|
* 重置注册表。
|
||||||
|
*/
|
||||||
|
reset(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered component info.
|
||||||
|
* 获取所有已注册的组件信息。
|
||||||
|
*
|
||||||
|
* @returns Array of component info | 组件信息数组
|
||||||
|
*/
|
||||||
|
getRegisteredComponents(): Array<{ name: string; type: Function; bitIndex: number }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone component types from another registry.
|
||||||
|
* 从另一个注册表克隆组件类型。
|
||||||
|
*
|
||||||
|
* Used to inherit framework components when creating a new Scene.
|
||||||
|
* 用于在创建新 Scene 时继承框架组件。
|
||||||
|
*
|
||||||
|
* @param source - Source registry | 源注册表
|
||||||
|
*/
|
||||||
|
cloneFrom(source: IComponentRegistry): void;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Entity } from '../Entity';
|
import { Entity } from '../Entity';
|
||||||
import { Component } from '../Component';
|
import { Component } from '../Component';
|
||||||
import { ComponentRegistry, ComponentType } from './ComponentStorage';
|
import { GlobalComponentRegistry, ComponentType } from './ComponentStorage';
|
||||||
import { BitMask64Utils, BitMask64Data } from '../Utils/BigIntCompatibility';
|
import { BitMask64Utils, BitMask64Data } from '../Utils/BigIntCompatibility';
|
||||||
import { createLogger } from '../../Utils/Logger';
|
import { createLogger } from '../../Utils/Logger';
|
||||||
import { getComponentTypeName } from '../Decorators';
|
import { getComponentTypeName } from '../Decorators';
|
||||||
@@ -932,7 +932,7 @@ export class QuerySystem {
|
|||||||
// 使用ComponentRegistry确保bitIndex一致
|
// 使用ComponentRegistry确保bitIndex一致
|
||||||
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||||
for (const type of componentTypes) {
|
for (const type of componentTypes) {
|
||||||
const bitMask = ComponentRegistry.getBitMask(type);
|
const bitMask = GlobalComponentRegistry.getBitMask(type);
|
||||||
BitMask64Utils.orInPlace(mask, bitMask);
|
BitMask64Utils.orInPlace(mask, bitMask);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,7 +1341,7 @@ export class QueryBuilder {
|
|||||||
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
const mask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||||
for (const type of componentTypes) {
|
for (const type of componentTypes) {
|
||||||
try {
|
try {
|
||||||
const bitMask = ComponentRegistry.getBitMask(type);
|
const bitMask = GlobalComponentRegistry.getBitMask(type);
|
||||||
BitMask64Utils.orInPlace(mask, bitMask);
|
BitMask64Utils.orInPlace(mask, bitMask);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._logger.warn(`组件类型 ${getComponentTypeName(type)} 未注册,跳过`);
|
this._logger.warn(`组件类型 ${getComponentTypeName(type)} 未注册,跳过`);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { ComponentPool, ComponentPoolManager } from '../ComponentPool';
|
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';
|
export { EnableSoA, Float64, Float32, Int32, SerializeMap, SoAStorage } from '../SoAStorage';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'reflect-metadata';
|
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
|
* Vector property options
|
||||||
*/
|
*/
|
||||||
interface VectorPropertyOptions extends PropertyOptionsBase {
|
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: 'asset'; assetType?: PropertyAssetType; extensions?: string[] }
|
||||||
| { type: 'vector2' }
|
| { type: 'vector2' }
|
||||||
| { type: 'vector3' }
|
| { type: 'vector3' }
|
||||||
|
| { type: 'vector4' }
|
||||||
| { type: 'color'; alpha?: boolean }
|
| { type: 'color'; alpha?: boolean }
|
||||||
| { type: 'enum'; options: EnumOption[] };
|
| { type: 'enum'; options: EnumOption[] };
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import type { Component } from '../Component';
|
import type { Component } from '../Component';
|
||||||
import type { EntitySystem } from '../Systems';
|
import type { EntitySystem } from '../Systems';
|
||||||
import { ComponentRegistry } from '../Core/ComponentStorage/ComponentRegistry';
|
import { GlobalComponentRegistry } from '../Core/ComponentStorage/ComponentRegistry';
|
||||||
import {
|
import {
|
||||||
COMPONENT_TYPE_NAME,
|
COMPONENT_TYPE_NAME,
|
||||||
COMPONENT_DEPENDENCIES,
|
COMPONENT_DEPENDENCIES,
|
||||||
@@ -88,9 +88,9 @@ export function ECSComponent(typeName: string, options?: ComponentOptions) {
|
|||||||
(target as any)[COMPONENT_EDITOR_OPTIONS] = options.editor;
|
(target as any)[COMPONENT_EDITOR_OPTIONS] = options.editor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动注册到 ComponentRegistry,使组件可以通过名称查找
|
// 自动注册到全局 ComponentRegistry,使组件可以通过名称查找
|
||||||
// Auto-register to ComponentRegistry, enabling lookup by name
|
// Auto-register to GlobalComponentRegistry, enabling lookup by name
|
||||||
ComponentRegistry.register(target);
|
GlobalComponentRegistry.register(target);
|
||||||
|
|
||||||
return target;
|
return target;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component } from './Component';
|
import { Component } from './Component';
|
||||||
import { ComponentRegistry, ComponentType } from './Core/ComponentStorage';
|
import { ComponentType, GlobalComponentRegistry } from './Core/ComponentStorage';
|
||||||
import { EEntityLifecyclePolicy } from './Core/EntityLifecyclePolicy';
|
import { EEntityLifecyclePolicy } from './Core/EntityLifecyclePolicy';
|
||||||
import { BitMask64Utils, BitMask64Data } from './Utils/BigIntCompatibility';
|
import { BitMask64Utils, BitMask64Data } from './Utils/BigIntCompatibility';
|
||||||
import { createLogger } from '../Utils/Logger';
|
import { createLogger } from '../Utils/Logger';
|
||||||
@@ -293,11 +293,12 @@ export class Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mask = this._componentMask;
|
const mask = this._componentMask;
|
||||||
const maxBitIndex = ComponentRegistry.getRegisteredCount();
|
const registry = this.scene.componentRegistry;
|
||||||
|
const maxBitIndex = registry.getRegisteredCount();
|
||||||
|
|
||||||
for (let bitIndex = 0; bitIndex < maxBitIndex; bitIndex++) {
|
for (let bitIndex = 0; bitIndex < maxBitIndex; bitIndex++) {
|
||||||
if (BitMask64Utils.getBit(mask, bitIndex)) {
|
if (BitMask64Utils.getBit(mask, bitIndex)) {
|
||||||
const componentType = ComponentRegistry.getTypeByBitIndex(bitIndex);
|
const componentType = registry.getTypeByBitIndex(bitIndex);
|
||||||
if (componentType) {
|
if (componentType) {
|
||||||
const component = this.scene.componentStorageManager.getComponent(this.id, componentType);
|
const component = this.scene.componentStorageManager.getComponent(this.id, componentType);
|
||||||
|
|
||||||
@@ -428,7 +429,8 @@ export class Entity {
|
|||||||
|
|
||||||
// 更新位掩码(组件已通过 @ECSComponent 装饰器自动注册)
|
// 更新位掩码(组件已通过 @ECSComponent 装饰器自动注册)
|
||||||
// Update bitmask (component already registered via @ECSComponent decorator)
|
// 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);
|
BitMask64Utils.orInPlace(this._componentMask, componentMask);
|
||||||
|
|
||||||
// 使缓存失效
|
// 使缓存失效
|
||||||
@@ -565,11 +567,12 @@ export class Entity {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
public hasComponent<T extends Component>(type: ComponentType<T>): boolean {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mask = ComponentRegistry.getBitMask(type);
|
const mask = registry.getBitMask(type);
|
||||||
return BitMask64Utils.hasAny(this._componentMask, mask);
|
return BitMask64Utils.hasAny(this._componentMask, mask);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,12 +644,13 @@ export class Entity {
|
|||||||
*/
|
*/
|
||||||
public removeComponent(component: Component): void {
|
public removeComponent(component: Component): void {
|
||||||
const componentType = component.constructor as ComponentType;
|
const componentType = component.constructor as ComponentType;
|
||||||
|
const registry = this.scene?.componentRegistry ?? GlobalComponentRegistry;
|
||||||
|
|
||||||
if (!ComponentRegistry.isRegistered(componentType)) {
|
if (!registry.isRegistered(componentType)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bitIndex = ComponentRegistry.getBitIndex(componentType);
|
const bitIndex = registry.getBitIndex(componentType);
|
||||||
|
|
||||||
// 更新位掩码
|
// 更新位掩码
|
||||||
BitMask64Utils.clearBit(this._componentMask, bitIndex);
|
BitMask64Utils.clearBit(this._componentMask, bitIndex);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { EntityList } from './Utils/EntityList';
|
|||||||
import { IdentifierPool } from './Utils/IdentifierPool';
|
import { IdentifierPool } from './Utils/IdentifierPool';
|
||||||
import { EntitySystem } from './Systems/EntitySystem';
|
import { EntitySystem } from './Systems/EntitySystem';
|
||||||
import { ComponentStorageManager, ComponentType } from './Core/ComponentStorage';
|
import { ComponentStorageManager, ComponentType } from './Core/ComponentStorage';
|
||||||
|
import type { IComponentRegistry } from './Core/ComponentStorage';
|
||||||
import { QuerySystem } from './Core/QuerySystem';
|
import { QuerySystem } from './Core/QuerySystem';
|
||||||
import { TypeSafeEventSystem } from './Core/EventSystem';
|
import { TypeSafeEventSystem } from './Core/EventSystem';
|
||||||
import { EpochManager } from './Core/EpochManager';
|
import { EpochManager } from './Core/EpochManager';
|
||||||
@@ -57,6 +58,17 @@ export interface IScene {
|
|||||||
*/
|
*/
|
||||||
readonly componentStorageManager: ComponentStorageManager;
|
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 {
|
export interface ISceneConfig {
|
||||||
/**
|
/**
|
||||||
* 场景名称
|
* 场景名称
|
||||||
|
* Scene name
|
||||||
*/
|
*/
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否从全局注册表继承组件类型
|
||||||
|
* Whether to inherit component types from global registry
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
inheritGlobalRegistry?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { Entity } from './Entity';
|
|||||||
import { EntityList } from './Utils/EntityList';
|
import { EntityList } from './Utils/EntityList';
|
||||||
import { IdentifierPool } from './Utils/IdentifierPool';
|
import { IdentifierPool } from './Utils/IdentifierPool';
|
||||||
import { EntitySystem } from './Systems/EntitySystem';
|
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 { QuerySystem } from './Core/QuerySystem';
|
||||||
import { TypeSafeEventSystem } from './Core/EventSystem';
|
import { TypeSafeEventSystem } from './Core/EventSystem';
|
||||||
import { ReferenceTracker } from './Core/ReferenceTracker';
|
import { ReferenceTracker } from './Core/ReferenceTracker';
|
||||||
@@ -75,6 +81,15 @@ export class Scene implements IScene {
|
|||||||
*/
|
*/
|
||||||
public readonly componentStorageManager: ComponentStorageManager;
|
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) {
|
constructor(config?: ISceneConfig) {
|
||||||
this.entities = new EntityList(this);
|
this.entities = new EntityList(this);
|
||||||
this.identifierPool = new IdentifierPool();
|
this.identifierPool = new IdentifierPool();
|
||||||
this.componentStorageManager = new ComponentStorageManager();
|
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.querySystem = new QuerySystem();
|
||||||
this.eventSystem = new TypeSafeEventSystem();
|
this.eventSystem = new TypeSafeEventSystem();
|
||||||
this.referenceTracker = new ReferenceTracker();
|
this.referenceTracker = new ReferenceTracker();
|
||||||
@@ -671,8 +698,8 @@ export class Scene implements IScene {
|
|||||||
const notifiedSystems = new Set<EntitySystem>();
|
const notifiedSystems = new Set<EntitySystem>();
|
||||||
|
|
||||||
// 如果提供了组件类型,使用索引优化 | If component type provided, use index optimization
|
// 如果提供了组件类型,使用索引优化 | If component type provided, use index optimization
|
||||||
if (changedComponentType && ComponentRegistry.isRegistered(changedComponentType)) {
|
if (changedComponentType && this.componentRegistry.isRegistered(changedComponentType)) {
|
||||||
const componentId = ComponentRegistry.getBitIndex(changedComponentType);
|
const componentId = this.componentRegistry.getBitIndex(changedComponentType);
|
||||||
const interestedSystems = this._componentIdToSystems.get(componentId);
|
const interestedSystems = this._componentIdToSystems.get(componentId);
|
||||||
|
|
||||||
if (interestedSystems) {
|
if (interestedSystems) {
|
||||||
@@ -760,7 +787,7 @@ export class Scene implements IScene {
|
|||||||
* @param system 系统 | System
|
* @param system 系统 | System
|
||||||
*/
|
*/
|
||||||
private addSystemToComponentIndex(componentType: ComponentType, system: EntitySystem): void {
|
private addSystemToComponentIndex(componentType: ComponentType, system: EntitySystem): void {
|
||||||
const componentId = ComponentRegistry.getBitIndex(componentType);
|
const componentId = this.componentRegistry.getBitIndex(componentType);
|
||||||
let systems = this._componentIdToSystems.get(componentId);
|
let systems = this._componentIdToSystems.get(componentId);
|
||||||
|
|
||||||
if (!systems) {
|
if (!systems) {
|
||||||
@@ -1506,7 +1533,7 @@ export class Scene implements IScene {
|
|||||||
? IncrementalSerializer.deserializeIncremental(incremental as string | Uint8Array)
|
? IncrementalSerializer.deserializeIncremental(incremental as string | Uint8Array)
|
||||||
: (incremental as IncrementalSnapshot);
|
: (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);
|
IncrementalSerializer.applyIncremental(this, snapshot, registry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import type { IScene } from '../IScene';
|
import type { IScene } from '../IScene';
|
||||||
import { Entity } from '../Entity';
|
import { Entity } from '../Entity';
|
||||||
import { ComponentType, ComponentRegistry } from '../Core/ComponentStorage';
|
import { ComponentType, GlobalComponentRegistry } from '../Core/ComponentStorage';
|
||||||
import { EntitySerializer, SerializedEntity } from './EntitySerializer';
|
import { EntitySerializer, SerializedEntity } from './EntitySerializer';
|
||||||
import { getComponentTypeName } from '../Decorators';
|
import { getComponentTypeName } from '../Decorators';
|
||||||
import { getSerializationMetadata } from './SerializationDecorators';
|
import { getSerializationMetadata } from './SerializationDecorators';
|
||||||
@@ -565,7 +565,7 @@ export class SceneSerializer {
|
|||||||
* 从所有已注册的组件类型构建注册表
|
* 从所有已注册的组件类型构建注册表
|
||||||
*/
|
*/
|
||||||
private static getGlobalComponentRegistry(): Map<string, ComponentType> {
|
private static getGlobalComponentRegistry(): Map<string, ComponentType> {
|
||||||
return ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
return GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Entity } from '../Entity';
|
import { Entity } from '../Entity';
|
||||||
import { ComponentType, ComponentRegistry } from '../Core/ComponentStorage';
|
import { ComponentType, GlobalComponentRegistry } from '../Core/ComponentStorage';
|
||||||
import { BitMask64Utils, BitMask64Data } from './BigIntCompatibility';
|
import { BitMask64Utils, BitMask64Data } from './BigIntCompatibility';
|
||||||
import { SparseSet } from './SparseSet';
|
import { SparseSet } from './SparseSet';
|
||||||
import { Pool } from '../../Utils/Pool/Pool';
|
import { Pool } from '../../Utils/Pool/Pool';
|
||||||
@@ -86,7 +86,7 @@ export class ComponentSparseSet {
|
|||||||
entityComponents.add(componentType);
|
entityComponents.add(componentType);
|
||||||
|
|
||||||
// 获取组件位掩码并合并
|
// 获取组件位掩码并合并
|
||||||
const bitMask = ComponentRegistry.getBitMask(componentType);
|
const bitMask = GlobalComponentRegistry.getBitMask(componentType);
|
||||||
BitMask64Utils.orInPlace(componentMask, bitMask);
|
BitMask64Utils.orInPlace(componentMask, bitMask);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +166,10 @@ export class ComponentSparseSet {
|
|||||||
// 构建目标位掩码
|
// 构建目标位掩码
|
||||||
const targetMask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
const targetMask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||||
for (const componentType of componentTypes) {
|
for (const componentType of componentTypes) {
|
||||||
if (!ComponentRegistry.isRegistered(componentType)) {
|
if (!GlobalComponentRegistry.isRegistered(componentType)) {
|
||||||
return new Set<Entity>(); // 未注册的组件类型,结果为空
|
return new Set<Entity>(); // 未注册的组件类型,结果为空
|
||||||
}
|
}
|
||||||
const bitMask = ComponentRegistry.getBitMask(componentType);
|
const bitMask = GlobalComponentRegistry.getBitMask(componentType);
|
||||||
BitMask64Utils.orInPlace(targetMask, bitMask);
|
BitMask64Utils.orInPlace(targetMask, bitMask);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +206,8 @@ export class ComponentSparseSet {
|
|||||||
// 构建目标位掩码
|
// 构建目标位掩码
|
||||||
const targetMask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
const targetMask = BitMask64Utils.clone(BitMask64Utils.ZERO);
|
||||||
for (const componentType of componentTypes) {
|
for (const componentType of componentTypes) {
|
||||||
if (ComponentRegistry.isRegistered(componentType)) {
|
if (GlobalComponentRegistry.isRegistered(componentType)) {
|
||||||
const bitMask = ComponentRegistry.getBitMask(componentType);
|
const bitMask = GlobalComponentRegistry.getBitMask(componentType);
|
||||||
BitMask64Utils.orInPlace(targetMask, bitMask);
|
BitMask64Utils.orInPlace(targetMask, bitMask);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,12 +242,12 @@ export class ComponentSparseSet {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ComponentRegistry.isRegistered(componentType)) {
|
if (!GlobalComponentRegistry.isRegistered(componentType)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityMask = this._componentMasks[entityIndex]!;
|
const entityMask = this._componentMasks[entityIndex]!;
|
||||||
const componentMask = ComponentRegistry.getBitMask(componentType);
|
const componentMask = GlobalComponentRegistry.getBitMask(componentType);
|
||||||
|
|
||||||
return BitMask64Utils.hasAny(entityMask, componentMask);
|
return BitMask64Utils.hasAny(entityMask, componentMask);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { Component } from '../../../src/ECS/Component';
|
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 { Entity } from '../../../src/ECS/Entity';
|
||||||
import { Scene } from '../../../src/ECS/Scene';
|
import { Scene } from '../../../src/ECS/Scene';
|
||||||
|
|
||||||
describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||||
// 组件类缓存
|
// 组件类缓存 | Component class cache
|
||||||
const componentClassCache = new Map<number, any>();
|
const componentClassCache = new Map<number, any>();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ComponentRegistry.reset();
|
GlobalComponentRegistry.reset();
|
||||||
componentClassCache.clear();
|
componentClassCache.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
ComponentRegistry.reset();
|
GlobalComponentRegistry.reset();
|
||||||
componentClassCache.clear();
|
componentClassCache.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,11 +39,11 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
|||||||
// 注册 100 个组件类型
|
// 注册 100 个组件类型
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
const bitIndex = ComponentRegistry.register(ComponentClass);
|
const bitIndex = GlobalComponentRegistry.register(ComponentClass);
|
||||||
componentTypes.push(ComponentClass);
|
componentTypes.push(ComponentClass);
|
||||||
|
|
||||||
expect(bitIndex).toBe(i);
|
expect(bitIndex).toBe(i);
|
||||||
expect(ComponentRegistry.isRegistered(ComponentClass)).toBe(true);
|
expect(GlobalComponentRegistry.isRegistered(ComponentClass)).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(componentTypes.length).toBe(100);
|
expect(componentTypes.length).toBe(100);
|
||||||
@@ -53,14 +53,14 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
|||||||
// 注册 80 个组件
|
// 注册 80 个组件
|
||||||
for (let i = 0; i < 80; i++) {
|
for (let i = 0; i < 80; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
ComponentRegistry.register(ComponentClass);
|
GlobalComponentRegistry.register(ComponentClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证第 70 个组件的位掩码
|
// 验证第 70 个组件的位掩码
|
||||||
const Component70 = createTestComponent(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).toBeDefined();
|
||||||
expect(bitMask.segments).toBeDefined(); // 应该有扩展段
|
expect(bitMask.segments).toBeDefined(); // 应该有扩展段
|
||||||
expect(bitMask.segments!.length).toBeGreaterThan(0);
|
expect(bitMask.segments!.length).toBeGreaterThan(0);
|
||||||
@@ -70,11 +70,11 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
|||||||
// 注册 1500 个组件验证无限制
|
// 注册 1500 个组件验证无限制
|
||||||
for (let i = 0; i < 1500; i++) {
|
for (let i = 0; i < 1500; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
const bitIndex = ComponentRegistry.register(ComponentClass);
|
const bitIndex = GlobalComponentRegistry.register(ComponentClass);
|
||||||
expect(bitIndex).toBe(i);
|
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 componentTypes: any[] = [];
|
||||||
const components: any[] = [];
|
const components: any[] = [];
|
||||||
|
|
||||||
// 添加 80 个组件
|
// 添加 80 个组件 | Add 80 components
|
||||||
|
// 需要同时注册到 GlobalComponentRegistry(ArchetypeSystem 使用)和 Scene registry
|
||||||
|
// Need to register to both GlobalComponentRegistry (used by ArchetypeSystem) and Scene registry
|
||||||
for (let i = 0; i < 80; i++) {
|
for (let i = 0; i < 80; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
ComponentRegistry.register(ComponentClass);
|
GlobalComponentRegistry.register(ComponentClass);
|
||||||
|
scene.componentRegistry.register(ComponentClass);
|
||||||
componentTypes.push(ComponentClass);
|
componentTypes.push(ComponentClass);
|
||||||
|
|
||||||
const component = new ComponentClass();
|
const component = new ComponentClass();
|
||||||
@@ -115,32 +118,35 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('应该能够正确检查超过 64 个组件的存在性', () => {
|
it('应该能够正确检查超过 64 个组件的存在性', () => {
|
||||||
// 添加组件 0-79
|
// 添加组件 0-79 | Add components 0-79
|
||||||
for (let i = 0; i < 80; i++) {
|
for (let i = 0; i < 80; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
ComponentRegistry.register(ComponentClass);
|
GlobalComponentRegistry.register(ComponentClass);
|
||||||
|
scene.componentRegistry.register(ComponentClass);
|
||||||
entity.addComponent(new ComponentClass());
|
entity.addComponent(new ComponentClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证 hasComponent 对所有组件都工作
|
// 验证 hasComponent 对所有组件都工作 | Verify hasComponent works for all
|
||||||
for (let i = 0; i < 80; i++) {
|
for (let i = 0; i < 80; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
expect(entity.hasComponent(ComponentClass)).toBe(true);
|
expect(entity.hasComponent(ComponentClass)).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证不存在的组件
|
// 验证不存在的组件 | Verify non-existent component
|
||||||
const NonExistentComponent = createTestComponent(999);
|
const NonExistentComponent = createTestComponent(999);
|
||||||
ComponentRegistry.register(NonExistentComponent);
|
GlobalComponentRegistry.register(NonExistentComponent);
|
||||||
|
scene.componentRegistry.register(NonExistentComponent);
|
||||||
expect(entity.hasComponent(NonExistentComponent)).toBe(false);
|
expect(entity.hasComponent(NonExistentComponent)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该能够移除超过 64 索引的组件', () => {
|
it('应该能够移除超过 64 索引的组件', () => {
|
||||||
const componentTypes: any[] = [];
|
const componentTypes: any[] = [];
|
||||||
|
|
||||||
// 添加 80 个组件
|
// 添加 80 个组件 | Add 80 components
|
||||||
for (let i = 0; i < 80; i++) {
|
for (let i = 0; i < 80; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
ComponentRegistry.register(ComponentClass);
|
GlobalComponentRegistry.register(ComponentClass);
|
||||||
|
scene.componentRegistry.register(ComponentClass);
|
||||||
componentTypes.push(ComponentClass);
|
componentTypes.push(ComponentClass);
|
||||||
entity.addComponent(new ComponentClass());
|
entity.addComponent(new ComponentClass());
|
||||||
}
|
}
|
||||||
@@ -162,10 +168,11 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('应该能够正确遍历超过 64 个组件', () => {
|
it('应该能够正确遍历超过 64 个组件', () => {
|
||||||
// 添加 80 个组件
|
// 添加 80 个组件 | Add 80 components
|
||||||
for (let i = 0; i < 80; i++) {
|
for (let i = 0; i < 80; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
ComponentRegistry.register(ComponentClass);
|
GlobalComponentRegistry.register(ComponentClass);
|
||||||
|
scene.componentRegistry.register(ComponentClass);
|
||||||
entity.addComponent(new ComponentClass());
|
entity.addComponent(new ComponentClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,29 +189,29 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
|||||||
|
|
||||||
describe('热更新模式', () => {
|
describe('热更新模式', () => {
|
||||||
it('默认应该禁用热更新模式', () => {
|
it('默认应该禁用热更新模式', () => {
|
||||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该能够启用和禁用热更新模式', () => {
|
it('应该能够启用和禁用热更新模式', () => {
|
||||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||||
|
|
||||||
ComponentRegistry.enableHotReload();
|
GlobalComponentRegistry.enableHotReload();
|
||||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(true);
|
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(true);
|
||||||
|
|
||||||
ComponentRegistry.disableHotReload();
|
GlobalComponentRegistry.disableHotReload();
|
||||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reset 应该重置热更新模式为禁用', () => {
|
it('reset 应该重置热更新模式为禁用', () => {
|
||||||
ComponentRegistry.enableHotReload();
|
GlobalComponentRegistry.enableHotReload();
|
||||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(true);
|
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(true);
|
||||||
|
|
||||||
ComponentRegistry.reset();
|
GlobalComponentRegistry.reset();
|
||||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('启用热更新时应该替换同名组件类', () => {
|
it('启用热更新时应该替换同名组件类', () => {
|
||||||
ComponentRegistry.enableHotReload();
|
GlobalComponentRegistry.enableHotReload();
|
||||||
|
|
||||||
// 模拟热更新场景:两个不同的类但有相同的 constructor.name
|
// 模拟热更新场景:两个不同的类但有相同的 constructor.name
|
||||||
// Simulate hot reload: two different classes with same 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.name).toBe(TestComponentV2.name);
|
||||||
expect(TestComponentV1).not.toBe(TestComponentV2);
|
expect(TestComponentV1).not.toBe(TestComponentV2);
|
||||||
|
|
||||||
const index1 = ComponentRegistry.register(TestComponentV1);
|
const index1 = GlobalComponentRegistry.register(TestComponentV1);
|
||||||
const index2 = ComponentRegistry.register(TestComponentV2);
|
const index2 = GlobalComponentRegistry.register(TestComponentV2);
|
||||||
|
|
||||||
// 应该复用相同的 bitIndex
|
// 应该复用相同的 bitIndex
|
||||||
expect(index1).toBe(index2);
|
expect(index1).toBe(index2);
|
||||||
|
|
||||||
// 新类应该替换旧类
|
// 新类应该替换旧类
|
||||||
expect(ComponentRegistry.isRegistered(TestComponentV2)).toBe(true);
|
expect(GlobalComponentRegistry.isRegistered(TestComponentV2)).toBe(true);
|
||||||
expect(ComponentRegistry.isRegistered(TestComponentV1)).toBe(false);
|
expect(GlobalComponentRegistry.isRegistered(TestComponentV1)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('禁用热更新时不应该替换同名组件类', () => {
|
it('禁用热更新时不应该替换同名组件类', () => {
|
||||||
// 确保热更新被禁用
|
// 确保热更新被禁用
|
||||||
ComponentRegistry.disableHotReload();
|
GlobalComponentRegistry.disableHotReload();
|
||||||
|
|
||||||
// 创建两个同名组件
|
// 创建两个同名组件
|
||||||
// Create two classes with same constructor.name
|
// Create two classes with same constructor.name
|
||||||
@@ -265,15 +272,15 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
|||||||
expect(TestCompA.name).toBe(TestCompB.name);
|
expect(TestCompA.name).toBe(TestCompB.name);
|
||||||
expect(TestCompA).not.toBe(TestCompB);
|
expect(TestCompA).not.toBe(TestCompB);
|
||||||
|
|
||||||
const index1 = ComponentRegistry.register(TestCompA);
|
const index1 = GlobalComponentRegistry.register(TestCompA);
|
||||||
const index2 = ComponentRegistry.register(TestCompB);
|
const index2 = GlobalComponentRegistry.register(TestCompB);
|
||||||
|
|
||||||
// 应该分配不同的 bitIndex(因为热更新被禁用)
|
// 应该分配不同的 bitIndex(因为热更新被禁用)
|
||||||
expect(index2).toBe(index1 + 1);
|
expect(index2).toBe(index1 + 1);
|
||||||
|
|
||||||
// 两个类都应该被注册
|
// 两个类都应该被注册
|
||||||
expect(ComponentRegistry.isRegistered(TestCompA)).toBe(true);
|
expect(GlobalComponentRegistry.isRegistered(TestCompA)).toBe(true);
|
||||||
expect(ComponentRegistry.isRegistered(TestCompB)).toBe(true);
|
expect(GlobalComponentRegistry.isRegistered(TestCompB)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,14 +289,15 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
|||||||
const scene = new Scene();
|
const scene = new Scene();
|
||||||
const entity = scene.createEntity('TestEntity');
|
const entity = scene.createEntity('TestEntity');
|
||||||
|
|
||||||
// 注册 65 个组件(跨越 64 位边界)
|
// 注册 65 个组件(跨越 64 位边界)| Register 65 components (crossing 64-bit boundary)
|
||||||
for (let i = 0; i < 65; i++) {
|
for (let i = 0; i < 65; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
ComponentRegistry.register(ComponentClass);
|
GlobalComponentRegistry.register(ComponentClass);
|
||||||
|
scene.componentRegistry.register(ComponentClass);
|
||||||
entity.addComponent(new ComponentClass());
|
entity.addComponent(new ComponentClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证第 63, 64, 65 个组件
|
// 验证第 63, 64, 65 个组件 | Verify components 63, 64
|
||||||
const Component63 = createTestComponent(63);
|
const Component63 = createTestComponent(63);
|
||||||
const Component64 = createTestComponent(64);
|
const Component64 = createTestComponent(64);
|
||||||
|
|
||||||
@@ -301,25 +309,27 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
|||||||
const scene = new Scene();
|
const scene = new Scene();
|
||||||
const entity = scene.createEntity('TestEntity');
|
const entity = scene.createEntity('TestEntity');
|
||||||
|
|
||||||
// 添加 80 个组件
|
// 添加 80 个组件 | Add 80 components
|
||||||
for (let i = 0; i < 80; i++) {
|
for (let i = 0; i < 80; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
ComponentRegistry.register(ComponentClass);
|
GlobalComponentRegistry.register(ComponentClass);
|
||||||
|
scene.componentRegistry.register(ComponentClass);
|
||||||
entity.addComponent(new ComponentClass());
|
entity.addComponent(new ComponentClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 强制重建缓存(通过访问 components)
|
// 强制重建缓存(通过访问 components)| Force cache rebuild
|
||||||
const components1 = entity.components;
|
const components1 = entity.components;
|
||||||
expect(components1.length).toBe(80);
|
expect(components1.length).toBe(80);
|
||||||
|
|
||||||
// 添加更多组件
|
// 添加更多组件 | Add more components
|
||||||
for (let i = 80; i < 90; i++) {
|
for (let i = 80; i < 90; i++) {
|
||||||
const ComponentClass = createTestComponent(i);
|
const ComponentClass = createTestComponent(i);
|
||||||
ComponentRegistry.register(ComponentClass);
|
GlobalComponentRegistry.register(ComponentClass);
|
||||||
|
scene.componentRegistry.register(ComponentClass);
|
||||||
entity.addComponent(new ComponentClass());
|
entity.addComponent(new ComponentClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新获取组件数组(应该重建缓存)
|
// 重新获取组件数组(应该重建缓存)| Re-get component array (should rebuild cache)
|
||||||
const components2 = entity.components;
|
const components2 = entity.components;
|
||||||
expect(components2.length).toBe(90);
|
expect(components2.length).toBe(90);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { ComponentRegistry, ComponentStorage, ComponentStorageManager } from '../../../src/ECS/Core/ComponentStorage';
|
import { ComponentRegistry, GlobalComponentRegistry, ComponentStorage, ComponentStorageManager } from '../../../src/ECS/Core/ComponentStorage';
|
||||||
import { Component } from '../../../src/ECS/Component';
|
import { Component } from '../../../src/ECS/Component';
|
||||||
import { BitMask64Utils } from '../../../src/ECS/Utils/BigIntCompatibility';
|
import { BitMask64Utils } from '../../../src/ECS/Utils/BigIntCompatibility';
|
||||||
|
|
||||||
|
// 为测试创建独立的注册表实例 | Create isolated registry instance for tests
|
||||||
|
let testRegistry: ComponentRegistry;
|
||||||
|
|
||||||
// 测试组件类(默认使用原始存储)
|
// 测试组件类(默认使用原始存储)
|
||||||
class TestComponent extends Component {
|
class TestComponent extends Component {
|
||||||
public value: number;
|
public value: number;
|
||||||
@@ -51,89 +54,88 @@ class HealthComponent extends Component {
|
|||||||
|
|
||||||
describe('ComponentRegistry - 组件注册表测试', () => {
|
describe('ComponentRegistry - 组件注册表测试', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// 重置注册表状态
|
// 每个测试创建新的注册表实例 | Create new registry instance for each test
|
||||||
(ComponentRegistry as any).componentTypes = new Map<Function, number>();
|
testRegistry = new ComponentRegistry();
|
||||||
(ComponentRegistry as any).nextBitIndex = 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('组件注册功能', () => {
|
describe('组件注册功能', () => {
|
||||||
test('应该能够注册组件类型', () => {
|
test('应该能够注册组件类型', () => {
|
||||||
const bitIndex = ComponentRegistry.register(TestComponent);
|
const bitIndex = testRegistry.register(TestComponent);
|
||||||
|
|
||||||
expect(bitIndex).toBe(0);
|
expect(bitIndex).toBe(0);
|
||||||
expect(ComponentRegistry.isRegistered(TestComponent)).toBe(true);
|
expect(testRegistry.isRegistered(TestComponent)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('重复注册相同组件应该返回相同的位索引', () => {
|
test('重复注册相同组件应该返回相同的位索引', () => {
|
||||||
const bitIndex1 = ComponentRegistry.register(TestComponent);
|
const bitIndex1 = testRegistry.register(TestComponent);
|
||||||
const bitIndex2 = ComponentRegistry.register(TestComponent);
|
const bitIndex2 = testRegistry.register(TestComponent);
|
||||||
|
|
||||||
expect(bitIndex1).toBe(bitIndex2);
|
expect(bitIndex1).toBe(bitIndex2);
|
||||||
expect(bitIndex1).toBe(0);
|
expect(bitIndex1).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('应该能够注册多个组件类型', () => {
|
test('应该能够注册多个组件类型', () => {
|
||||||
const bitIndex1 = ComponentRegistry.register(TestComponent);
|
const bitIndex1 = testRegistry.register(TestComponent);
|
||||||
const bitIndex2 = ComponentRegistry.register(PositionComponent);
|
const bitIndex2 = testRegistry.register(PositionComponent);
|
||||||
const bitIndex3 = ComponentRegistry.register(VelocityComponent);
|
const bitIndex3 = testRegistry.register(VelocityComponent);
|
||||||
|
|
||||||
expect(bitIndex1).toBe(0);
|
expect(bitIndex1).toBe(0);
|
||||||
expect(bitIndex2).toBe(1);
|
expect(bitIndex2).toBe(1);
|
||||||
expect(bitIndex3).toBe(2);
|
expect(bitIndex3).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('应该能够检查组件是否已注册', () => {
|
test('应该能够检查组件是否已注册', () => {
|
||||||
expect(ComponentRegistry.isRegistered(TestComponent)).toBe(false);
|
expect(testRegistry.isRegistered(TestComponent)).toBe(false);
|
||||||
|
|
||||||
ComponentRegistry.register(TestComponent);
|
testRegistry.register(TestComponent);
|
||||||
expect(ComponentRegistry.isRegistered(TestComponent)).toBe(true);
|
expect(testRegistry.isRegistered(TestComponent)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('位掩码功能', () => {
|
describe('位掩码功能', () => {
|
||||||
test('应该能够获取组件的位掩码', () => {
|
test('应该能够获取组件的位掩码', () => {
|
||||||
ComponentRegistry.register(TestComponent);
|
testRegistry.register(TestComponent);
|
||||||
ComponentRegistry.register(PositionComponent);
|
testRegistry.register(PositionComponent);
|
||||||
|
|
||||||
const mask1 = ComponentRegistry.getBitMask(TestComponent);
|
const mask1 = testRegistry.getBitMask(TestComponent);
|
||||||
const mask2 = ComponentRegistry.getBitMask(PositionComponent);
|
const mask2 = testRegistry.getBitMask(PositionComponent);
|
||||||
|
|
||||||
expect(BitMask64Utils.getBit(mask1,0)).toBe(true); // 2^0
|
expect(BitMask64Utils.getBit(mask1,0)).toBe(true); // 2^0
|
||||||
expect(BitMask64Utils.getBit(mask2,1)).toBe(true); // 2^1
|
expect(BitMask64Utils.getBit(mask2,1)).toBe(true); // 2^1
|
||||||
});
|
});
|
||||||
|
|
||||||
test('应该能够获取组件的位索引', () => {
|
test('应该能够获取组件的位索引', () => {
|
||||||
ComponentRegistry.register(TestComponent);
|
testRegistry.register(TestComponent);
|
||||||
ComponentRegistry.register(PositionComponent);
|
testRegistry.register(PositionComponent);
|
||||||
|
|
||||||
const index1 = ComponentRegistry.getBitIndex(TestComponent);
|
const index1 = testRegistry.getBitIndex(TestComponent);
|
||||||
const index2 = ComponentRegistry.getBitIndex(PositionComponent);
|
const index2 = testRegistry.getBitIndex(PositionComponent);
|
||||||
|
|
||||||
expect(index1).toBe(0);
|
expect(index1).toBe(0);
|
||||||
expect(index2).toBe(1);
|
expect(index2).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('获取未注册组件的位掩码应该抛出错误', () => {
|
test('获取未注册组件的位掩码应该抛出错误', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
ComponentRegistry.getBitMask(TestComponent);
|
testRegistry.getBitMask(TestComponent);
|
||||||
}).toThrow('Component type TestComponent is not registered');
|
}).toThrow('Component type TestComponent is not registered');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('获取未注册组件的位索引应该抛出错误', () => {
|
test('获取未注册组件的位索引应该抛出错误', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
ComponentRegistry.getBitIndex(TestComponent);
|
testRegistry.getBitIndex(TestComponent);
|
||||||
}).toThrow('Component type TestComponent is not registered');
|
}).toThrow('Component type TestComponent is not registered');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('注册表管理', () => {
|
describe('注册表管理', () => {
|
||||||
test('应该能够获取所有已注册的组件类型', () => {
|
test('应该能够获取所有已注册的组件类型', () => {
|
||||||
ComponentRegistry.register(TestComponent);
|
testRegistry.register(TestComponent);
|
||||||
ComponentRegistry.register(PositionComponent);
|
testRegistry.register(PositionComponent);
|
||||||
|
|
||||||
const allTypes = ComponentRegistry.getAllRegisteredTypes();
|
const allTypes = testRegistry.getAllRegisteredTypes();
|
||||||
|
|
||||||
expect(allTypes.size).toBe(2);
|
expect(allTypes.size).toBe(2);
|
||||||
expect(allTypes.has(TestComponent)).toBe(true);
|
expect(allTypes.has(TestComponent)).toBe(true);
|
||||||
expect(allTypes.has(PositionComponent)).toBe(true);
|
expect(allTypes.has(PositionComponent)).toBe(true);
|
||||||
@@ -142,12 +144,12 @@ describe('ComponentRegistry - 组件注册表测试', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('返回的注册表副本不应该影响原始数据', () => {
|
test('返回的注册表副本不应该影响原始数据', () => {
|
||||||
ComponentRegistry.register(TestComponent);
|
testRegistry.register(TestComponent);
|
||||||
|
|
||||||
const allTypes = ComponentRegistry.getAllRegisteredTypes();
|
const allTypes = testRegistry.getAllRegisteredTypes();
|
||||||
allTypes.set(PositionComponent, 999);
|
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>;
|
let storage: ComponentStorage<TestComponent>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// 重置注册表
|
// 每个测试创建新的注册表实例 | Create new registry instance for each test
|
||||||
(ComponentRegistry as any).componentTypes = new Map<Function, number>();
|
testRegistry = new ComponentRegistry();
|
||||||
(ComponentRegistry as any).nextBitIndex = 0;
|
|
||||||
|
|
||||||
storage = new ComponentStorage(TestComponent);
|
storage = new ComponentStorage(TestComponent);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -358,10 +359,9 @@ describe('ComponentStorageManager - 组件存储管理器测试', () => {
|
|||||||
let manager: ComponentStorageManager;
|
let manager: ComponentStorageManager;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// 重置注册表
|
// 重置全局注册表 | Reset global registry
|
||||||
(ComponentRegistry as any).componentTypes = new Map<Function, number>();
|
GlobalComponentRegistry.reset();
|
||||||
(ComponentRegistry as any).nextBitIndex = 0;
|
|
||||||
|
|
||||||
manager = new ComponentStorageManager();
|
manager = new ComponentStorageManager();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -455,10 +455,10 @@ describe('ComponentStorageManager - 组件存储管理器测试', () => {
|
|||||||
|
|
||||||
describe('位掩码功能', () => {
|
describe('位掩码功能', () => {
|
||||||
test('应该能够获取实体的组件位掩码', () => {
|
test('应该能够获取实体的组件位掩码', () => {
|
||||||
// 确保组件已注册
|
// 确保组件已注册 | Ensure components are registered
|
||||||
ComponentRegistry.register(TestComponent);
|
GlobalComponentRegistry.register(TestComponent);
|
||||||
ComponentRegistry.register(PositionComponent);
|
GlobalComponentRegistry.register(PositionComponent);
|
||||||
ComponentRegistry.register(VelocityComponent);
|
GlobalComponentRegistry.register(VelocityComponent);
|
||||||
|
|
||||||
manager.addComponent(1, new TestComponent(100));
|
manager.addComponent(1, new TestComponent(100));
|
||||||
manager.addComponent(1, new PositionComponent(10, 20));
|
manager.addComponent(1, new PositionComponent(10, 20));
|
||||||
@@ -475,8 +475,8 @@ describe('ComponentStorageManager - 组件存储管理器测试', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('添加和移除组件应该更新掩码', () => {
|
test('添加和移除组件应该更新掩码', () => {
|
||||||
ComponentRegistry.register(TestComponent);
|
GlobalComponentRegistry.register(TestComponent);
|
||||||
ComponentRegistry.register(PositionComponent);
|
GlobalComponentRegistry.register(PositionComponent);
|
||||||
|
|
||||||
manager.addComponent(1, new TestComponent(100));
|
manager.addComponent(1, new TestComponent(100));
|
||||||
let mask = manager.getComponentMask(1);
|
let mask = manager.getComponentMask(1);
|
||||||
|
|||||||
@@ -894,10 +894,12 @@ describe('QuerySystem - 查询系统测试', () => {
|
|||||||
const independentQuerySystem = new QuerySystem();
|
const independentQuerySystem = new QuerySystem();
|
||||||
const testEntity = scene.createEntity('ArchetypeTestEntity');
|
const testEntity = scene.createEntity('ArchetypeTestEntity');
|
||||||
|
|
||||||
// 模拟Scene环境(保留componentStorageManager)
|
// 模拟Scene环境(保留componentStorageManager和componentRegistry)
|
||||||
|
// Mock Scene environment (keep componentStorageManager and componentRegistry)
|
||||||
const mockScene = {
|
const mockScene = {
|
||||||
querySystem: independentQuerySystem,
|
querySystem: independentQuerySystem,
|
||||||
componentStorageManager: scene.componentStorageManager,
|
componentStorageManager: scene.componentStorageManager,
|
||||||
|
componentRegistry: scene.componentRegistry,
|
||||||
clearSystemEntityCaches: jest.fn()
|
clearSystemEntityCaches: jest.fn()
|
||||||
};
|
};
|
||||||
testEntity.scene = mockScene as any;
|
testEntity.scene = mockScene as any;
|
||||||
@@ -938,10 +940,12 @@ describe('QuerySystem - 查询系统测试', () => {
|
|||||||
const independentQuerySystem = new QuerySystem();
|
const independentQuerySystem = new QuerySystem();
|
||||||
const testEntity = scene.createEntity('RemoveAllTestEntity');
|
const testEntity = scene.createEntity('RemoveAllTestEntity');
|
||||||
|
|
||||||
// 模拟Scene环境(保留componentStorageManager)
|
// 模拟Scene环境(保留componentStorageManager和componentRegistry)
|
||||||
|
// Mock Scene environment (keep componentStorageManager and componentRegistry)
|
||||||
const mockScene = {
|
const mockScene = {
|
||||||
querySystem: independentQuerySystem,
|
querySystem: independentQuerySystem,
|
||||||
componentStorageManager: scene.componentStorageManager,
|
componentStorageManager: scene.componentStorageManager,
|
||||||
|
componentRegistry: scene.componentRegistry,
|
||||||
clearSystemEntityCaches: jest.fn()
|
clearSystemEntityCaches: jest.fn()
|
||||||
};
|
};
|
||||||
testEntity.scene = mockScene as any;
|
testEntity.scene = mockScene as any;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Component } from '../../../src/ECS/Component';
|
|||||||
import { HierarchySystem } from '../../../src/ECS/Systems/HierarchySystem';
|
import { HierarchySystem } from '../../../src/ECS/Systems/HierarchySystem';
|
||||||
import { HierarchyComponent } from '../../../src/ECS/Components/HierarchyComponent';
|
import { HierarchyComponent } from '../../../src/ECS/Components/HierarchyComponent';
|
||||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
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';
|
import { Serializable, Serialize } from '../../../src/ECS/Serialization';
|
||||||
|
|
||||||
@ECSComponent('EntitySerTest_Position')
|
@ECSComponent('EntitySerTest_Position')
|
||||||
@@ -40,16 +40,18 @@ describe('EntitySerializer', () => {
|
|||||||
let componentRegistry: Map<string, ComponentType>;
|
let componentRegistry: Map<string, ComponentType>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ComponentRegistry.reset();
|
// 重置全局注册表 | Reset global registry
|
||||||
ComponentRegistry.register(PositionComponent);
|
GlobalComponentRegistry.reset();
|
||||||
ComponentRegistry.register(VelocityComponent);
|
|
||||||
ComponentRegistry.register(HierarchyComponent);
|
GlobalComponentRegistry.register(PositionComponent);
|
||||||
|
GlobalComponentRegistry.register(VelocityComponent);
|
||||||
|
GlobalComponentRegistry.register(HierarchyComponent);
|
||||||
|
|
||||||
scene = new Scene({ name: 'EntitySerializerTestScene' });
|
scene = new Scene({ name: 'EntitySerializerTestScene' });
|
||||||
hierarchySystem = new HierarchySystem();
|
hierarchySystem = new HierarchySystem();
|
||||||
scene.addSystem(hierarchySystem);
|
scene.addSystem(hierarchySystem);
|
||||||
|
|
||||||
componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
ChangeOperation
|
ChangeOperation
|
||||||
} from '../../../src/ECS/Serialization';
|
} from '../../../src/ECS/Serialization';
|
||||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
import { ECSComponent } from '../../../src/ECS/Decorators';
|
||||||
import { ComponentRegistry } from '../../../src/ECS/Core/ComponentStorage';
|
import { GlobalComponentRegistry } from '../../../src/ECS/Core/ComponentStorage';
|
||||||
|
|
||||||
// 测试组件定义
|
// 测试组件定义
|
||||||
@ECSComponent('IncTest_Position')
|
@ECSComponent('IncTest_Position')
|
||||||
@@ -56,12 +56,14 @@ describe('Incremental Serialization System', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
IncrementalSerializer.resetVersion();
|
IncrementalSerializer.resetVersion();
|
||||||
ComponentRegistry.reset();
|
|
||||||
|
|
||||||
// 重新注册测试组件
|
// 重置全局注册表 | Reset global registry
|
||||||
ComponentRegistry.register(PositionComponent);
|
GlobalComponentRegistry.reset();
|
||||||
ComponentRegistry.register(VelocityComponent);
|
|
||||||
ComponentRegistry.register(HealthComponent);
|
// 重新注册测试组件 | Re-register test components
|
||||||
|
GlobalComponentRegistry.register(PositionComponent);
|
||||||
|
GlobalComponentRegistry.register(VelocityComponent);
|
||||||
|
GlobalComponentRegistry.register(HealthComponent);
|
||||||
|
|
||||||
scene = new Scene({ name: 'IncrementalTestScene' });
|
scene = new Scene({ name: 'IncrementalTestScene' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Component } from '../../../src/ECS/Component';
|
|||||||
import { HierarchySystem } from '../../../src/ECS/Systems/HierarchySystem';
|
import { HierarchySystem } from '../../../src/ECS/Systems/HierarchySystem';
|
||||||
import { HierarchyComponent } from '../../../src/ECS/Components/HierarchyComponent';
|
import { HierarchyComponent } from '../../../src/ECS/Components/HierarchyComponent';
|
||||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
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';
|
import { Serializable, Serialize } from '../../../src/ECS/Serialization';
|
||||||
|
|
||||||
@ECSComponent('SceneSerTest_Position')
|
@ECSComponent('SceneSerTest_Position')
|
||||||
@@ -40,7 +40,7 @@ describe('SceneSerializer', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
scene = new Scene({ name: 'SceneSerializerTestScene' });
|
scene = new Scene({ name: 'SceneSerializerTestScene' });
|
||||||
|
|
||||||
componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IntervalSystem } from '../../../src/ECS/Systems/IntervalSystem';
|
|||||||
import { ProcessingSystem } from '../../../src/ECS/Systems/ProcessingSystem';
|
import { ProcessingSystem } from '../../../src/ECS/Systems/ProcessingSystem';
|
||||||
import { Entity } from '../../../src/ECS/Entity';
|
import { Entity } from '../../../src/ECS/Entity';
|
||||||
import { Component } from '../../../src/ECS/Component';
|
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 { Time } from '../../../src/Utils/Time';
|
||||||
import { Matcher } from '../../../src/ECS/Utils/Matcher';
|
import { Matcher } from '../../../src/ECS/Utils/Matcher';
|
||||||
import { Core } from '../../../src/Core';
|
import { Core } from '../../../src/Core';
|
||||||
@@ -85,13 +85,15 @@ describe('System Types - 系统类型测试', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Core.create();
|
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();
|
scene = new Scene();
|
||||||
entity = scene.createEntity('TestEntity');
|
entity = scene.createEntity('TestEntity');
|
||||||
// 重置时间系统
|
// 重置时间系统
|
||||||
Time.update(0.016);
|
Time.update(0.016);
|
||||||
// 注册测试组件类型
|
|
||||||
ComponentRegistry.register(TestComponent);
|
|
||||||
ComponentRegistry.register(AnotherComponent);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PassiveSystem - 被动系统', () => {
|
describe('PassiveSystem - 被动系统', () => {
|
||||||
|
|||||||
@@ -883,6 +883,133 @@ export class EngineBridge implements ITextureEngineBridge {
|
|||||||
this.getEngine().clearAllTextures();
|
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.
|
* Dispose the bridge and release resources.
|
||||||
* 销毁桥接并释放资源。
|
* 销毁桥接并释放资源。
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
* 用于ECS的引擎渲染系统。
|
* 用于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 { 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 { getMaterialManager } from '@esengine/material-system';
|
||||||
|
import { SpriteComponent } from '@esengine/sprite';
|
||||||
import type { EngineBridge } from '../core/EngineBridge';
|
import type { EngineBridge } from '../core/EngineBridge';
|
||||||
import { RenderBatcher } from '../core/RenderBatcher';
|
import { RenderBatcher } from '../core/RenderBatcher';
|
||||||
import type { SpriteRenderData } from '../types';
|
|
||||||
import type { ITransformComponent } from '../core/SpriteRenderHelper';
|
import type { ITransformComponent } from '../core/SpriteRenderHelper';
|
||||||
|
import type { SpriteRenderData } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render data from a provider
|
* Render data from a provider
|
||||||
@@ -339,14 +339,12 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate UV with flip | 计算带翻转的 UV
|
// Calculate UV with flip | 计算带翻转的 UV
|
||||||
const uv: [number, number, number, number] = [0, 0, 1, 1];
|
const uv: [number, number, number, number] = [...sprite.uv];
|
||||||
if (sprite.flipX || sprite.flipY) {
|
if (sprite.flipX) {
|
||||||
if (sprite.flipX) {
|
[uv[0], uv[2]] = [uv[2], uv[0]];
|
||||||
[uv[0], uv[2]] = [uv[2], uv[0]];
|
}
|
||||||
}
|
if (sprite.flipY) {
|
||||||
if (sprite.flipY) {
|
[uv[1], uv[3]] = [uv[3], uv[1]];
|
||||||
[uv[1], uv[3]] = [uv[3], uv[1]];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用世界变换(由 TransformSystem 计算,考虑父级变换),回退到本地变换
|
// 使用世界变换(由 TransformSystem 计算,考虑父级变换),回退到本地变换
|
||||||
@@ -569,6 +567,13 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
const tOffset = i * 7;
|
const tOffset = i * 7;
|
||||||
const uvOffset = i * 4;
|
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 = {
|
const renderData: SpriteRenderData = {
|
||||||
x: data.transforms[tOffset],
|
x: data.transforms[tOffset],
|
||||||
y: data.transforms[tOffset + 1],
|
y: data.transforms[tOffset + 1],
|
||||||
@@ -578,7 +583,7 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
originX: data.transforms[tOffset + 5],
|
originX: data.transforms[tOffset + 5],
|
||||||
originY: data.transforms[tOffset + 6],
|
originY: data.transforms[tOffset + 6],
|
||||||
textureId,
|
textureId,
|
||||||
uv: [data.uvs[uvOffset], data.uvs[uvOffset + 1], data.uvs[uvOffset + 2], data.uvs[uvOffset + 3]],
|
uv,
|
||||||
color: data.colors[i]
|
color: data.colors[i]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -209,11 +209,31 @@ export class GameEngine {
|
|||||||
* 获取所有已注册的视口ID。
|
* 获取所有已注册的视口ID。
|
||||||
*/
|
*/
|
||||||
getViewportIds(): string[];
|
getViewportIds(): string[];
|
||||||
|
/**
|
||||||
|
* 检查纹理是否已就绪
|
||||||
|
* Check if texture is ready to use
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `id` - Texture ID | 纹理ID
|
||||||
|
*/
|
||||||
|
isTextureReady(id: number): boolean;
|
||||||
/**
|
/**
|
||||||
* Add a capsule gizmo outline.
|
* Add a capsule gizmo outline.
|
||||||
* 添加胶囊Gizmo边框。
|
* 添加胶囊Gizmo边框。
|
||||||
*/
|
*/
|
||||||
addGizmoCapsule(x: number, y: number, radius: number, half_height: number, rotation: number, r: number, g: number, b: number, a: number): void;
|
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.
|
* Register a new viewport.
|
||||||
* 注册新视口。
|
* 注册新视口。
|
||||||
@@ -361,6 +381,11 @@ export class GameEngine {
|
|||||||
* 在恢复场景快照时应调用此方法,以确保纹理使用正确的ID重新加载。
|
* 在恢复场景快照时应调用此方法,以确保纹理使用正确的ID重新加载。
|
||||||
*/
|
*/
|
||||||
clearTexturePathCache(): void;
|
clearTexturePathCache(): void;
|
||||||
|
/**
|
||||||
|
* 获取正在加载中的纹理数量
|
||||||
|
* Get the number of textures currently loading
|
||||||
|
*/
|
||||||
|
getTextureLoadingCount(): number;
|
||||||
/**
|
/**
|
||||||
* Create a new game engine instance.
|
* Create a new game engine instance.
|
||||||
* 创建新的游戏引擎实例。
|
* 创建新的游戏引擎实例。
|
||||||
@@ -429,6 +454,8 @@ export interface InitOutput {
|
|||||||
readonly gameengine_getCamera: (a: number) => [number, number];
|
readonly gameengine_getCamera: (a: number) => [number, number];
|
||||||
readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [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_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_getViewportCamera: (a: number, b: number, c: number) => [number, number];
|
||||||
readonly gameengine_getViewportIds: (a: number) => [number, number];
|
readonly gameengine_getViewportIds: (a: number) => [number, number];
|
||||||
readonly gameengine_hasMaterial: (a: number, b: 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_height: (a: number) => number;
|
||||||
readonly gameengine_isEditorMode: (a: number) => number;
|
readonly gameengine_isEditorMode: (a: number) => number;
|
||||||
readonly gameengine_isKeyDown: (a: number, b: number, c: 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_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_loadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
|
||||||
readonly gameengine_new: (a: number, b: number) => [number, number, number];
|
readonly gameengine_new: (a: number, b: number) => [number, number, number];
|
||||||
|
|||||||
@@ -254,6 +254,25 @@ pub fn read_file_as_base64(file_path: String) -> Result<String, String> {
|
|||||||
Ok(general_purpose::STANDARD.encode(&file_content))
|
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
|
/// Copy file from source to destination
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn copy_file(src: String, dst: String) -> Result<(), String> {
|
pub fn copy_file(src: String, dst: String) -> Result<(), String> {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ fn main() {
|
|||||||
commands::scan_directory,
|
commands::scan_directory,
|
||||||
commands::read_file_as_base64,
|
commands::read_file_as_base64,
|
||||||
commands::copy_file,
|
commands::copy_file,
|
||||||
|
commands::get_file_mtime,
|
||||||
// Dialog operations
|
// Dialog operations
|
||||||
commands::open_folder_dialog,
|
commands::open_folder_dialog,
|
||||||
commands::open_file_dialog,
|
commands::open_file_dialog,
|
||||||
@@ -183,18 +184,27 @@ fn handle_project_protocol(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get MIME type based on file extension
|
/// Get MIME type based on file extension
|
||||||
|
/// 根据文件扩展名获取 MIME 类型
|
||||||
fn get_mime_type(file_path: &str) -> &'static str {
|
fn get_mime_type(file_path: &str) -> &'static str {
|
||||||
if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
|
if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
|
||||||
"application/javascript"
|
"application/javascript"
|
||||||
} else if file_path.ends_with(".js") {
|
} else if file_path.ends_with(".js") || file_path.ends_with(".mjs") {
|
||||||
"application/javascript"
|
"application/javascript"
|
||||||
} else if file_path.ends_with(".json") {
|
} else if file_path.ends_with(".json") {
|
||||||
"application/json"
|
"application/json"
|
||||||
|
} else if file_path.ends_with(".wasm") {
|
||||||
|
"application/wasm"
|
||||||
} else if file_path.ends_with(".css") {
|
} else if file_path.ends_with(".css") {
|
||||||
"text/css"
|
"text/css"
|
||||||
} else if file_path.ends_with(".html") {
|
} else if file_path.ends_with(".html") {
|
||||||
"text/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 {
|
} else {
|
||||||
"text/plain"
|
"application/octet-stream"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@
|
|||||||
{
|
{
|
||||||
"identifier": "main",
|
"identifier": "main",
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main",
|
||||||
|
"frame-debugger"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
@@ -91,6 +92,9 @@
|
|||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
|
"core:window:allow-create",
|
||||||
|
"core:webview:allow-create-webview",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
"shell:default",
|
"shell:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
|
|||||||
@@ -40,11 +40,15 @@ import { Inspector } from './components/inspectors/Inspector';
|
|||||||
import { AssetBrowser } from './components/AssetBrowser';
|
import { AssetBrowser } from './components/AssetBrowser';
|
||||||
import { Viewport } from './components/Viewport';
|
import { Viewport } from './components/Viewport';
|
||||||
import { AdvancedProfilerWindow } from './components/AdvancedProfilerWindow';
|
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 { PortManager } from './components/PortManager';
|
||||||
import { SettingsWindow } from './components/SettingsWindow';
|
import { SettingsWindow } from './components/SettingsWindow';
|
||||||
import { AboutDialog } from './components/AboutDialog';
|
import { AboutDialog } from './components/AboutDialog';
|
||||||
import { ErrorDialog } from './components/ErrorDialog';
|
import { ErrorDialog } from './components/ErrorDialog';
|
||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
|
import { ExternalModificationDialog } from './components/ExternalModificationDialog';
|
||||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||||
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
|
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
|
||||||
import { ForumPanel } from './components/forum';
|
import { ForumPanel } from './components/forum';
|
||||||
@@ -63,6 +67,7 @@ import { useLocale } from './hooks/useLocale';
|
|||||||
import { useStoreSubscriptions } from './hooks/useStoreSubscriptions';
|
import { useStoreSubscriptions } from './hooks/useStoreSubscriptions';
|
||||||
import { en, zh, es } from './locales';
|
import { en, zh, es } from './locales';
|
||||||
import type { Locale } from '@esengine/editor-core';
|
import type { Locale } from '@esengine/editor-core';
|
||||||
|
import { UserCodeService } from '@esengine/editor-core';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import './styles/App.css';
|
import './styles/App.css';
|
||||||
|
|
||||||
@@ -84,12 +89,24 @@ Core.services.registerInstance(ICompilerRegistry, compilerRegistryInstance);
|
|||||||
|
|
||||||
const logger = createLogger('App');
|
const logger = createLogger('App');
|
||||||
|
|
||||||
|
// 检查是否为独立窗口模式 | Check if standalone window mode
|
||||||
|
const isFrameDebuggerMode = new URLSearchParams(window.location.search).get('mode') === 'frame-debugger';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const initRef = useRef(false);
|
const initRef = useRef(false);
|
||||||
const layoutContainerRef = useRef<FlexLayoutDockContainerHandle>(null);
|
const layoutContainerRef = useRef<FlexLayoutDockContainerHandle>(null);
|
||||||
const [pluginLoader] = useState(() => new PluginLoader());
|
const [pluginLoader] = useState(() => new PluginLoader());
|
||||||
const { showToast, hideToast } = useToast();
|
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) =====
|
// ===== 本地初始化状态(只用于初始化阶段)| Local init state (only for initialization) =====
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
@@ -170,10 +187,40 @@ function App() {
|
|||||||
showAbout, setShowAbout,
|
showAbout, setShowAbout,
|
||||||
showPluginGenerator, setShowPluginGenerator,
|
showPluginGenerator, setShowPluginGenerator,
|
||||||
showBuildSettings, setShowBuildSettings,
|
showBuildSettings, setShowBuildSettings,
|
||||||
|
showRenderDebug, setShowRenderDebug,
|
||||||
errorDialog, setErrorDialog,
|
errorDialog, setErrorDialog,
|
||||||
confirmDialog, setConfirmDialog
|
confirmDialog, setConfirmDialog,
|
||||||
|
externalModificationDialog, setExternalModificationDialog
|
||||||
} = useDialogStore();
|
} = 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(() => {
|
useEffect(() => {
|
||||||
// 禁用默认右键菜单
|
// 禁用默认右键菜单
|
||||||
const handleContextMenu = (e: MouseEvent) => {
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
@@ -483,6 +530,113 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [initialized]);
|
}, [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) => {
|
const handleOpenRecentProject = async (projectPath: string) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true, t('loading.step1'));
|
setIsLoading(true, t('loading.step1'));
|
||||||
@@ -523,7 +677,6 @@ function App() {
|
|||||||
const sceneFiles = await TauriAPI.scanDirectory(`${projectPath}/scenes`, '*.ecs');
|
const sceneFiles = await TauriAPI.scanDirectory(`${projectPath}/scenes`, '*.ecs');
|
||||||
const sceneNames = sceneFiles.map(f => `scenes/${f.split(/[\\/]/).pop()}`);
|
const sceneNames = sceneFiles.map(f => `scenes/${f.split(/[\\/]/).pop()}`);
|
||||||
setAvailableScenes(sceneNames);
|
setAvailableScenes(sceneNames);
|
||||||
console.log('[App] Found scenes:', sceneNames);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[App] Failed to scan scenes:', 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)
|
// Load project plugin config and activate plugins (after engine init, before module system init)
|
||||||
if (pluginManagerRef.current) {
|
if (pluginManagerRef.current) {
|
||||||
const pluginSettings = projectService.getPluginSettings();
|
const pluginSettings = projectService.getPluginSettings();
|
||||||
console.log('[App] Plugin settings from project:', pluginSettings);
|
|
||||||
if (pluginSettings && pluginSettings.enabledPlugins.length > 0) {
|
if (pluginSettings && pluginSettings.enabledPlugins.length > 0) {
|
||||||
console.log('[App] Loading plugin config:', pluginSettings.enabledPlugins);
|
|
||||||
await pluginManagerRef.current.loadConfig({ enabledPlugins: 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'));
|
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);
|
const sceneManagerService = Core.services.resolve(SceneManagerService);
|
||||||
if (sceneManagerService) {
|
if (sceneManagerService) {
|
||||||
await sceneManagerService.newScene();
|
await sceneManagerService.newScene();
|
||||||
@@ -696,6 +852,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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();
|
await sceneManager.openScene();
|
||||||
const sceneState = sceneManager.getSceneState();
|
const sceneState = sceneManager.getSceneState();
|
||||||
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
|
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
|
||||||
@@ -706,13 +869,25 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenSceneByPath = useCallback(async (scenePath: string) => {
|
const handleOpenSceneByPath = useCallback(async (scenePath: string) => {
|
||||||
|
console.log('[App] handleOpenSceneByPath called:', scenePath);
|
||||||
if (!sceneManager) {
|
if (!sceneManager) {
|
||||||
console.error('SceneManagerService not available');
|
console.error('SceneManagerService not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
await sceneManager.openScene(scenePath);
|
||||||
|
console.log('[App] Scene opened successfully');
|
||||||
const sceneState = sceneManager.getSceneState();
|
const sceneState = sceneManager.getSceneState();
|
||||||
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
|
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
|
||||||
} catch (error) {
|
} 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}
|
onCreatePlugin={handleCreatePlugin}
|
||||||
onReloadPlugins={handleReloadPlugins}
|
onReloadPlugins={handleReloadPlugins}
|
||||||
onOpenBuildSettings={() => setShowBuildSettings(true)}
|
onOpenBuildSettings={() => setShowBuildSettings(true)}
|
||||||
|
onOpenRenderDebug={() => setShowRenderDebug(true)}
|
||||||
/>
|
/>
|
||||||
<MainToolbar
|
<MainToolbar
|
||||||
messageHub={messageHub || undefined}
|
messageHub={messageHub || undefined}
|
||||||
@@ -1226,6 +1410,12 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 渲染调试面板 | Render Debug Panel */}
|
||||||
|
<RenderDebugPanel
|
||||||
|
visible={showRenderDebug}
|
||||||
|
onClose={() => setShowRenderDebug(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{errorDialog && (
|
{errorDialog && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
title={errorDialog.title}
|
title={errorDialog.title}
|
||||||
@@ -1252,6 +1442,15 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{externalModificationDialog && (
|
||||||
|
<ExternalModificationDialog
|
||||||
|
sceneName={externalModificationDialog.sceneName}
|
||||||
|
onReload={externalModificationDialog.onReload}
|
||||||
|
onOverwrite={externalModificationDialog.onOverwrite}
|
||||||
|
onCancel={() => setExternalModificationDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,4 +38,8 @@ export class TauriFileAPI implements IFileAPI {
|
|||||||
public async pathExists(path: string): Promise<boolean> {
|
public async pathExists(path: string): Promise<boolean> {
|
||||||
return await TauriAPI.pathExists(path);
|
return await TauriAPI.pathExists(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getFileMtime(path: string): Promise<number> {
|
||||||
|
return await TauriAPI.getFileMtime(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,17 @@ export class TauriAPI {
|
|||||||
return await invoke<void>('copy_file', { src, dst });
|
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 文件路径
|
* @param filePath 文件路径
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ interface ErrorDialogData {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外部修改对话框数据
|
||||||
|
* External modification dialog data
|
||||||
|
*/
|
||||||
|
export interface ExternalModificationDialogData {
|
||||||
|
sceneName: string;
|
||||||
|
onReload: () => void;
|
||||||
|
onOverwrite: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface DialogState {
|
interface DialogState {
|
||||||
showProfiler: boolean;
|
showProfiler: boolean;
|
||||||
showAdvancedProfiler: boolean;
|
showAdvancedProfiler: boolean;
|
||||||
@@ -14,8 +24,10 @@ interface DialogState {
|
|||||||
showAbout: boolean;
|
showAbout: boolean;
|
||||||
showPluginGenerator: boolean;
|
showPluginGenerator: boolean;
|
||||||
showBuildSettings: boolean;
|
showBuildSettings: boolean;
|
||||||
|
showRenderDebug: boolean;
|
||||||
errorDialog: ErrorDialogData | null;
|
errorDialog: ErrorDialogData | null;
|
||||||
confirmDialog: ConfirmDialogData | null;
|
confirmDialog: ConfirmDialogData | null;
|
||||||
|
externalModificationDialog: ExternalModificationDialogData | null;
|
||||||
|
|
||||||
setShowProfiler: (show: boolean) => void;
|
setShowProfiler: (show: boolean) => void;
|
||||||
setShowAdvancedProfiler: (show: boolean) => void;
|
setShowAdvancedProfiler: (show: boolean) => void;
|
||||||
@@ -24,8 +36,10 @@ interface DialogState {
|
|||||||
setShowAbout: (show: boolean) => void;
|
setShowAbout: (show: boolean) => void;
|
||||||
setShowPluginGenerator: (show: boolean) => void;
|
setShowPluginGenerator: (show: boolean) => void;
|
||||||
setShowBuildSettings: (show: boolean) => void;
|
setShowBuildSettings: (show: boolean) => void;
|
||||||
|
setShowRenderDebug: (show: boolean) => void;
|
||||||
setErrorDialog: (data: ErrorDialogData | null) => void;
|
setErrorDialog: (data: ErrorDialogData | null) => void;
|
||||||
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
||||||
|
setExternalModificationDialog: (data: ExternalModificationDialogData | null) => void;
|
||||||
closeAllDialogs: () => void;
|
closeAllDialogs: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +51,10 @@ export const useDialogStore = create<DialogState>((set) => ({
|
|||||||
showAbout: false,
|
showAbout: false,
|
||||||
showPluginGenerator: false,
|
showPluginGenerator: false,
|
||||||
showBuildSettings: false,
|
showBuildSettings: false,
|
||||||
|
showRenderDebug: false,
|
||||||
errorDialog: null,
|
errorDialog: null,
|
||||||
confirmDialog: null,
|
confirmDialog: null,
|
||||||
|
externalModificationDialog: null,
|
||||||
|
|
||||||
setShowProfiler: (show) => set({ showProfiler: show }),
|
setShowProfiler: (show) => set({ showProfiler: show }),
|
||||||
setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }),
|
setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }),
|
||||||
@@ -47,8 +63,10 @@ export const useDialogStore = create<DialogState>((set) => ({
|
|||||||
setShowAbout: (show) => set({ showAbout: show }),
|
setShowAbout: (show) => set({ showAbout: show }),
|
||||||
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
||||||
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
|
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
|
||||||
|
setShowRenderDebug: (show) => set({ showRenderDebug: show }),
|
||||||
setErrorDialog: (data) => set({ errorDialog: data }),
|
setErrorDialog: (data) => set({ errorDialog: data }),
|
||||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||||
|
setExternalModificationDialog: (data) => set({ externalModificationDialog: data }),
|
||||||
|
|
||||||
closeAllDialogs: () => set({
|
closeAllDialogs: () => set({
|
||||||
showProfiler: false,
|
showProfiler: false,
|
||||||
@@ -58,7 +76,9 @@ export const useDialogStore = create<DialogState>((set) => ({
|
|||||||
showAbout: false,
|
showAbout: false,
|
||||||
showPluginGenerator: false,
|
showPluginGenerator: false,
|
||||||
showBuildSettings: false,
|
showBuildSettings: false,
|
||||||
|
showRenderDebug: false,
|
||||||
errorDialog: null,
|
errorDialog: null,
|
||||||
confirmDialog: null
|
confirmDialog: null,
|
||||||
|
externalModificationDialog: null
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Core, ComponentRegistry as CoreComponentRegistry, PrefabSerializer, ComponentRegistry as ECSComponentRegistry } from '@esengine/ecs-framework';
|
import { Core, GlobalComponentRegistry, PrefabSerializer } from '@esengine/ecs-framework';
|
||||||
import type { ComponentType } from '@esengine/ecs-framework';
|
import type { ComponentType } from '@esengine/ecs-framework';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import {
|
import {
|
||||||
@@ -136,8 +136,8 @@ export class ServiceRegistry {
|
|||||||
|
|
||||||
for (const comp of standardComponents) {
|
for (const comp of standardComponents) {
|
||||||
// Register to editor registry for UI
|
// Register to editor registry for UI
|
||||||
// 组件已通过 @ECSComponent 装饰器自动注册到 CoreComponentRegistry
|
// 组件已通过 @ECSComponent 装饰器自动注册到 GlobalComponentRegistry
|
||||||
// Components are auto-registered to CoreComponentRegistry via @ECSComponent decorator
|
// Components are auto-registered to GlobalComponentRegistry via @ECSComponent decorator
|
||||||
componentRegistry.register({
|
componentRegistry.register({
|
||||||
name: comp.editorName,
|
name: comp.editorName,
|
||||||
type: comp.type,
|
type: comp.type,
|
||||||
@@ -149,7 +149,7 @@ export class ServiceRegistry {
|
|||||||
|
|
||||||
// Enable hot reload for editor environment
|
// Enable hot reload for editor environment
|
||||||
// 在编辑器环境中启用热更新
|
// 在编辑器环境中启用热更新
|
||||||
CoreComponentRegistry.enableHotReload();
|
GlobalComponentRegistry.enableHotReload();
|
||||||
|
|
||||||
const projectService = new ProjectService(messageHub, fileAPI);
|
const projectService = new ProjectService(messageHub, fileAPI);
|
||||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||||
@@ -340,8 +340,14 @@ export class ServiceRegistry {
|
|||||||
// 编辑器脚本编译错误只记录,不影响运行时
|
// 编辑器脚本编译错误只记录,不影响运行时
|
||||||
console.warn('[UserCodeService] Editor compilation errors:', editorResult.errors);
|
console.warn('[UserCodeService] Editor compilation errors:', editorResult.errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 编译完成,发出就绪信号 | Compilation done, signal ready
|
||||||
|
userCodeService.signalReady();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[UserCodeService] Failed to compile/load:', error);
|
console.error('[UserCodeService] Failed to compile/load:', error);
|
||||||
|
// 即使编译失败也要发出就绪信号,避免阻塞场景加载
|
||||||
|
// Signal ready even on failure to avoid blocking scene loading
|
||||||
|
userCodeService.signalReady();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Creates an entity instance from a prefab asset.
|
* 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 { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||||
import { BaseCommand } from '../BaseCommand';
|
import { BaseCommand } from '../BaseCommand';
|
||||||
@@ -50,9 +50,9 @@ export class InstantiatePrefabCommand extends BaseCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取组件注册表 | Get component registry
|
// 获取组件注册表 | Get component registry
|
||||||
// ComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
// GlobalComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||||
// We need to cast it to Map<string, ComponentType>
|
// 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
|
// 实例化预制体 | Instantiate prefab
|
||||||
this.createdEntity = PrefabSerializer.instantiate(
|
this.createdEntity = PrefabSerializer.instantiate(
|
||||||
|
|||||||
@@ -1026,13 +1026,16 @@ export class ${className} {
|
|||||||
|
|
||||||
// Handle asset double click
|
// Handle asset double click
|
||||||
const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => {
|
const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => {
|
||||||
|
console.log('[ContentBrowser] Double click:', asset.name, 'type:', asset.type, 'ext:', asset.extension);
|
||||||
if (asset.type === 'folder') {
|
if (asset.type === 'folder') {
|
||||||
setCurrentPath(asset.path);
|
setCurrentPath(asset.path);
|
||||||
loadAssets(asset.path);
|
loadAssets(asset.path);
|
||||||
setExpandedFolders(prev => new Set([...prev, asset.path]));
|
setExpandedFolders(prev => new Set([...prev, asset.path]));
|
||||||
} else {
|
} else {
|
||||||
const ext = asset.extension?.toLowerCase();
|
const ext = asset.extension?.toLowerCase();
|
||||||
|
console.log('[ContentBrowser] File ext:', ext, 'onOpenScene:', !!onOpenScene);
|
||||||
if (ext === 'ecs' && onOpenScene) {
|
if (ext === 'ecs' && onOpenScene) {
|
||||||
|
console.log('[ContentBrowser] Opening scene:', asset.path);
|
||||||
onOpenScene(asset.path);
|
onOpenScene(asset.path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AlertTriangle, X, RefreshCw, Save } from 'lucide-react';
|
||||||
|
import '../styles/ConfirmDialog.css';
|
||||||
|
|
||||||
|
interface ExternalModificationDialogProps {
|
||||||
|
sceneName: string;
|
||||||
|
onReload: () => void;
|
||||||
|
onOverwrite: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外部修改对话框
|
||||||
|
* External Modification Dialog
|
||||||
|
*
|
||||||
|
* 当场景文件被外部修改时显示,让用户选择操作
|
||||||
|
* Shown when scene file is modified externally, let user choose action
|
||||||
|
*/
|
||||||
|
export function ExternalModificationDialog({
|
||||||
|
sceneName,
|
||||||
|
onReload,
|
||||||
|
onOverwrite,
|
||||||
|
onCancel
|
||||||
|
}: ExternalModificationDialogProps) {
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||||
|
<div className="confirm-dialog external-modification-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="confirm-dialog-header">
|
||||||
|
<AlertTriangle size={20} className="warning-icon" />
|
||||||
|
<h2>文件已被外部修改</h2>
|
||||||
|
<button className="close-btn" onClick={onCancel}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="confirm-dialog-content">
|
||||||
|
<p>
|
||||||
|
场景 <strong>{sceneName}</strong> 已在编辑器外部被修改。
|
||||||
|
</p>
|
||||||
|
<p className="hint-text">
|
||||||
|
请选择如何处理:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="confirm-dialog-footer external-modification-footer">
|
||||||
|
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="confirm-dialog-btn reload" onClick={onReload}>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
<button className="confirm-dialog-btn overwrite" onClick={onOverwrite}>
|
||||||
|
<Save size={14} />
|
||||||
|
覆盖保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ interface TitleBarProps {
|
|||||||
onCreatePlugin?: () => void;
|
onCreatePlugin?: () => void;
|
||||||
onReloadPlugins?: () => void;
|
onReloadPlugins?: () => void;
|
||||||
onOpenBuildSettings?: () => void;
|
onOpenBuildSettings?: () => void;
|
||||||
|
onOpenRenderDebug?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TitleBar({
|
export function TitleBar({
|
||||||
@@ -61,7 +62,8 @@ export function TitleBar({
|
|||||||
onOpenAbout,
|
onOpenAbout,
|
||||||
onCreatePlugin,
|
onCreatePlugin,
|
||||||
onReloadPlugins,
|
onReloadPlugins,
|
||||||
onOpenBuildSettings
|
onOpenBuildSettings,
|
||||||
|
onOpenRenderDebug
|
||||||
}: TitleBarProps) {
|
}: TitleBarProps) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
@@ -197,6 +199,7 @@ export function TitleBar({
|
|||||||
{ label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
|
{ label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: t('menu.tools.portManager'), onClick: onOpenPortManager },
|
{ label: t('menu.tools.portManager'), onClick: onOpenPortManager },
|
||||||
|
{ label: t('menu.tools.renderDebug'), onClick: onOpenRenderDebug },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: t('menu.tools.settings'), onClick: onOpenSettings }
|
{ label: t('menu.tools.settings'), onClick: onOpenSettings }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import '../styles/Viewport.css';
|
|||||||
import { useEngine } from '../hooks/useEngine';
|
import { useEngine } from '../hooks/useEngine';
|
||||||
import { useLocale } from '../hooks/useLocale';
|
import { useLocale } from '../hooks/useLocale';
|
||||||
import { EngineService } from '../services/EngineService';
|
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 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 { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
|
||||||
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
|
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
|
||||||
import { TransformComponent } from '@esengine/engine-core';
|
import { TransformComponent } from '@esengine/engine-core';
|
||||||
@@ -21,6 +21,8 @@ import { open } from '@tauri-apps/plugin-shell';
|
|||||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||||
import { QRCodeDialog } from './QRCodeDialog';
|
import { QRCodeDialog } from './QRCodeDialog';
|
||||||
import { collectAssetReferences } from '@esengine/asset-system';
|
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';
|
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 editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
|
||||||
const playStateRef = useRef<PlayState>('stopped');
|
const playStateRef = useRef<PlayState>('stopped');
|
||||||
|
|
||||||
|
// Runtime scene manager for play mode scene switching | Play 模式场景切换管理器
|
||||||
|
const runtimeSceneManagerRef = useRef<IRuntimeSceneManager | null>(null);
|
||||||
|
|
||||||
// Live transform display state | 实时变换显示状态
|
// Live transform display state | 实时变换显示状态
|
||||||
const [liveTransform, setLiveTransform] = useState<{
|
const [liveTransform, setLiveTransform] = useState<{
|
||||||
type: 'move' | 'rotate' | 'scale';
|
type: 'move' | 'rotate' | 'scale';
|
||||||
@@ -811,7 +816,22 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Save scene snapshot before playing
|
// Save scene snapshot before playing
|
||||||
|
// saveSceneSnapshot clears all textures, so we need to reset particle textureIds after
|
||||||
|
// saveSceneSnapshot 会清除所有纹理,所以之后需要重置粒子的 textureId
|
||||||
EngineService.getInstance().saveSceneSnapshot();
|
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
|
// Save editor camera state
|
||||||
editorCameraRef.current = { x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom };
|
editorCameraRef.current = { x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom };
|
||||||
setPlayState('playing');
|
setPlayState('playing');
|
||||||
@@ -820,6 +840,132 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
EngineService.getInstance().setEditorMode(false);
|
EngineService.getInstance().setEditorMode(false);
|
||||||
// Switch to player camera
|
// Switch to player camera
|
||||||
syncPlayerCamera();
|
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();
|
engine.start();
|
||||||
} else if (playState === 'paused') {
|
} else if (playState === 'paused') {
|
||||||
setPlayState('playing');
|
setPlayState('playing');
|
||||||
@@ -837,6 +983,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
setPlayState('stopped');
|
setPlayState('stopped');
|
||||||
engine.stop();
|
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
|
// Restore scene snapshot
|
||||||
await EngineService.getInstance().restoreSceneSnapshot();
|
await EngineService.getInstance().restoreSceneSnapshot();
|
||||||
// Restore editor camera state
|
// Restore editor camera state
|
||||||
|
|||||||
633
packages/editor-app/src/components/debug/RenderDebugPanel.css
Normal file
633
packages/editor-app/src/components/debug/RenderDebugPanel.css
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
/**
|
||||||
|
* 渲染调试面板样式 (浮动窗口)
|
||||||
|
* Render Debug Panel Styles (Floating Window)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== Floating Window ==================== */
|
||||||
|
.render-debug-window {
|
||||||
|
position: fixed;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-window.dragging {
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 独立窗口模式 | Standalone mode */
|
||||||
|
.render-debug-window.standalone {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-window.standalone .window-header {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Window Header ==================== */
|
||||||
|
.render-debug-window .window-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
cursor: move;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-window .window-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-window .window-title svg {
|
||||||
|
color: #4a9eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-window .paused-badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #000;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 3px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-window .window-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-window .window-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-window .window-btn:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Toolbar ==================== */
|
||||||
|
.render-debug-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #262626;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-left,
|
||||||
|
.render-debug-toolbar .toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #3a3a3a;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-btn:hover {
|
||||||
|
background: #4a4a4a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-btn.active {
|
||||||
|
background: #4a9eff;
|
||||||
|
border-color: #4a9eff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-btn.icon-only {
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-btn.recording {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-btn .record-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .history-badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 3px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-btn:disabled:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Timeline ==================== */
|
||||||
|
.render-debug-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #222;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-timeline .timeline-slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-timeline .timeline-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #4a9eff;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-timeline .timeline-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #4a9eff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-timeline .timeline-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .toolbar-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-toolbar .frame-counter {
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Main Layout ==================== */
|
||||||
|
.render-debug-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Left Panel (Event List) ==================== */
|
||||||
|
.render-debug-left {
|
||||||
|
width: 260px;
|
||||||
|
min-width: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #222;
|
||||||
|
border-right: 1px solid #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #262626;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list-header .event-count {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list::-webkit-scrollbar-track {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #555;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event Items */
|
||||||
|
.event-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bbb;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item.selected {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
border-left: 2px solid #4a9eff;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .expand-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .expand-icon:not(.placeholder):hover {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .expand-icon.placeholder {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .event-icon {
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .event-icon.sprite {
|
||||||
|
color: #4fc3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .event-icon.particle {
|
||||||
|
color: #ffb74d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .event-icon.ui {
|
||||||
|
color: #81c784;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .event-icon.batch {
|
||||||
|
color: #81c784;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .event-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .event-draws {
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #666;
|
||||||
|
padding: 1px 3px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Right Panel ==================== */
|
||||||
|
.render-debug-right {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Section */
|
||||||
|
.render-debug-preview {
|
||||||
|
height: 40%;
|
||||||
|
min-height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #262626;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
background: #1a1a1a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-canvas-container canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details Section */
|
||||||
|
.render-debug-details {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-header {
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #262626;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-content::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-content::-webkit-scrollbar-track {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-content::-webkit-scrollbar-thumb {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #555;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details Grid */
|
||||||
|
.details-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-section {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 8px 0 3px 0;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-section:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row .detail-label {
|
||||||
|
width: 100px;
|
||||||
|
color: #888;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row .detail-value {
|
||||||
|
flex: 1;
|
||||||
|
color: #ccc;
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row.highlight .detail-value {
|
||||||
|
color: #4fc3f7;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Stats Bar ==================== */
|
||||||
|
.render-debug-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #262626;
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-stats .stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-stats .stat-item svg {
|
||||||
|
color: #4a9eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Resize Handle ==================== */
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: se-resize;
|
||||||
|
background: linear-gradient(135deg, transparent 50%, #3a3a3a 50%);
|
||||||
|
border-radius: 0 0 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover {
|
||||||
|
background: linear-gradient(135deg, transparent 50%, #4a9eff 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== TextureSheet Preview ==================== */
|
||||||
|
.texture-sheet-preview {
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.texture-sheet-preview canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Texture Preview ==================== */
|
||||||
|
.texture-preview-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.texture-preview-row .detail-label {
|
||||||
|
width: 100px;
|
||||||
|
color: #888;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.texture-preview-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.texture-thumbnail-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.texture-thumbnail {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
background: repeating-conic-gradient(#2a2a2a 0% 25%, #1a1a1a 0% 50%) 50% / 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.texture-path {
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #666;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
1059
packages/editor-app/src/components/debug/RenderDebugPanel.tsx
Normal file
1059
packages/editor-app/src/components/debug/RenderDebugPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
7
packages/editor-app/src/components/debug/index.ts
Normal file
7
packages/editor-app/src/components/debug/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 调试组件导出
|
||||||
|
* Debug components export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { RenderDebugPanel } from './RenderDebugPanel';
|
||||||
|
export type { default as RenderDebugPanelProps } from './RenderDebugPanel';
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2 } from 'lucide-react';
|
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { Core } from '@esengine/ecs-framework';
|
import { Core } from '@esengine/ecs-framework';
|
||||||
import { AssetRegistryService } from '@esengine/editor-core';
|
import { AssetRegistryService } from '@esengine/editor-core';
|
||||||
|
import type { ISpriteSettings } from '@esengine/asset-system-editor';
|
||||||
import { EngineService } from '../../../services/EngineService';
|
import { EngineService } from '../../../services/EngineService';
|
||||||
import { AssetFileInfo } from '../types';
|
import { AssetFileInfo } from '../types';
|
||||||
import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common';
|
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) {
|
export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInspectorProps) {
|
||||||
const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon;
|
const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon;
|
||||||
const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9';
|
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 [detectedType, setDetectedType] = useState<string | null>(null);
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
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
|
// Load meta info and available loader types
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fileInfo.isDirectory) return;
|
if (fileInfo.isDirectory) return;
|
||||||
@@ -76,6 +240,14 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
|||||||
setCurrentLoaderType(meta.loaderType || null);
|
setCurrentLoaderType(meta.loaderType || null);
|
||||||
setDetectedType(meta.type);
|
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
|
// Get available loader types from assetManager
|
||||||
const assetManager = EngineService.getInstance().getAssetManager();
|
const assetManager = EngineService.getInstance().getAssetManager();
|
||||||
const loaderFactory = assetManager?.getLoaderFactory();
|
const loaderFactory = assetManager?.getLoaderFactory();
|
||||||
@@ -117,6 +289,39 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
|||||||
}
|
}
|
||||||
}, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]);
|
}, [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 (
|
return (
|
||||||
<div className="entity-inspector">
|
<div className="entity-inspector">
|
||||||
<div className="inspector-header">
|
<div className="inspector-header">
|
||||||
@@ -228,6 +433,23 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
|||||||
</div>
|
</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 && (
|
{content && (
|
||||||
<div className="inspector-section code-preview-section">
|
<div className="inspector-section code-preview-section">
|
||||||
<div className="section-title">文件预览</div>
|
<div className="section-title">文件预览</div>
|
||||||
|
|||||||
@@ -141,7 +141,27 @@ export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render({ label, value, onChange, context }: FieldEditorProps<Vector4>): React.ReactElement {
|
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 (
|
return (
|
||||||
<div className="property-field">
|
<div className="property-field">
|
||||||
@@ -150,28 +170,28 @@ export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
|||||||
<VectorInput
|
<VectorInput
|
||||||
label="X"
|
label="X"
|
||||||
value={v.x}
|
value={v.x}
|
||||||
onChange={(x) => onChange({ ...v, x })}
|
onChange={(x) => handleChange({ ...v, x })}
|
||||||
readonly={context.readonly}
|
readonly={context.readonly}
|
||||||
axis="x"
|
axis="x"
|
||||||
/>
|
/>
|
||||||
<VectorInput
|
<VectorInput
|
||||||
label="Y"
|
label="Y"
|
||||||
value={v.y}
|
value={v.y}
|
||||||
onChange={(y) => onChange({ ...v, y })}
|
onChange={(y) => handleChange({ ...v, y })}
|
||||||
readonly={context.readonly}
|
readonly={context.readonly}
|
||||||
axis="y"
|
axis="y"
|
||||||
/>
|
/>
|
||||||
<VectorInput
|
<VectorInput
|
||||||
label="Z"
|
label="Z"
|
||||||
value={v.z}
|
value={v.z}
|
||||||
onChange={(z) => onChange({ ...v, z })}
|
onChange={(z) => handleChange({ ...v, z })}
|
||||||
readonly={context.readonly}
|
readonly={context.readonly}
|
||||||
axis="z"
|
axis="z"
|
||||||
/>
|
/>
|
||||||
<VectorInput
|
<VectorInput
|
||||||
label="W"
|
label="W"
|
||||||
value={v.w}
|
value={v.w}
|
||||||
onChange={(w) => onChange({ ...v, w })}
|
onChange={(w) => handleChange({ ...v, w })}
|
||||||
readonly={context.readonly}
|
readonly={context.readonly}
|
||||||
axis="w"
|
axis="w"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -306,7 +306,15 @@ export const en: Translations = {
|
|||||||
openFailed: 'Failed to open scene',
|
openFailed: 'Failed to open scene',
|
||||||
savedSuccess: 'Scene saved: {{name}}',
|
savedSuccess: 'Scene saved: {{name}}',
|
||||||
saveFailed: 'Failed to save scene',
|
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'
|
dependencies: 'Dependencies'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// User Code
|
||||||
|
// ========================================
|
||||||
|
usercode: {
|
||||||
|
compileSuccess: 'Scripts compiled ({{count}} exports)',
|
||||||
|
compileError: 'Script compilation failed',
|
||||||
|
hotReloadSuccess: 'Scripts hot reloaded'
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Loading
|
// Loading
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -432,7 +449,8 @@ export const en: Translations = {
|
|||||||
portManager: 'Port Manager',
|
portManager: 'Port Manager',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
devtools: 'Developer Tools',
|
devtools: 'Developer Tools',
|
||||||
build: 'Build Settings'
|
build: 'Build Settings',
|
||||||
|
renderDebug: 'Render Debug'
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
title: 'Help',
|
title: 'Help',
|
||||||
|
|||||||
@@ -381,7 +381,8 @@ export const es: Translations = {
|
|||||||
portManager: 'Administrador de Puertos',
|
portManager: 'Administrador de Puertos',
|
||||||
settings: 'Configuración',
|
settings: 'Configuración',
|
||||||
devtools: 'Herramientas de Desarrollo',
|
devtools: 'Herramientas de Desarrollo',
|
||||||
build: 'Configuración de Compilación'
|
build: 'Configuración de Compilación',
|
||||||
|
renderDebug: 'Depuración de Renderizado'
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
title: 'Ayuda',
|
title: 'Ayuda',
|
||||||
|
|||||||
@@ -306,7 +306,15 @@ export const zh: Translations = {
|
|||||||
openFailed: '打开场景失败',
|
openFailed: '打开场景失败',
|
||||||
savedSuccess: '场景已保存: {{name}}',
|
savedSuccess: '场景已保存: {{name}}',
|
||||||
saveFailed: '保存场景失败',
|
saveFailed: '保存场景失败',
|
||||||
saveAsFailed: '另存场景失败'
|
saveAsFailed: '另存场景失败',
|
||||||
|
reloadedSuccess: '场景已重新加载: {{name}}',
|
||||||
|
reloadFailed: '重新加载场景失败',
|
||||||
|
externalChange: {
|
||||||
|
title: '场景已更改',
|
||||||
|
message: '场景 "{{name}}" 已被外部修改。是否重新加载?',
|
||||||
|
reload: '重新加载',
|
||||||
|
ignore: '忽略'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -371,6 +379,15 @@ export const zh: Translations = {
|
|||||||
dependencies: '依赖'
|
dependencies: '依赖'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// User Code
|
||||||
|
// ========================================
|
||||||
|
usercode: {
|
||||||
|
compileSuccess: '脚本编译成功 ({{count}} 个导出)',
|
||||||
|
compileError: '脚本编译失败',
|
||||||
|
hotReloadSuccess: '脚本热更新成功'
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Loading
|
// Loading
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -432,7 +449,8 @@ export const zh: Translations = {
|
|||||||
portManager: '端口管理器',
|
portManager: '端口管理器',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
devtools: '开发者工具',
|
devtools: '开发者工具',
|
||||||
build: '构建设置'
|
build: '构建设置',
|
||||||
|
renderDebug: '渲染调试'
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
title: '帮助',
|
title: '帮助',
|
||||||
|
|||||||
@@ -278,12 +278,20 @@ export class EditorEngineSync {
|
|||||||
* Update sprite in engine entity.
|
* Update sprite in engine entity.
|
||||||
* 更新引擎实体的精灵。
|
* 更新引擎实体的精灵。
|
||||||
*
|
*
|
||||||
* Note: Texture loading is now handled automatically by EngineRenderSystem.
|
* Preloads textures when textureGuid changes to ensure they're available for rendering.
|
||||||
* 注意:纹理加载现在由EngineRenderSystem自动处理。
|
* 当 textureGuid 变更时预加载纹理以确保渲染时可用。
|
||||||
*/
|
*/
|
||||||
private updateSprite(entity: Entity, sprite: SpriteComponent, property: string, value: any): void {
|
private updateSprite(entity: Entity, sprite: SpriteComponent, property: string, value: any): void {
|
||||||
// No manual texture loading needed - EngineRenderSystem handles it
|
// When textureGuid changes, trigger texture preload
|
||||||
// 不需要手动加载纹理 - EngineRenderSystem会处理
|
// 当 textureGuid 变更时,触发纹理预加载
|
||||||
|
if (property === 'textureGuid' && value) {
|
||||||
|
const bridge = this.engineService.getBridge();
|
||||||
|
if (bridge) {
|
||||||
|
// Preload the texture so it's ready for the next render frame
|
||||||
|
// 预加载纹理以便下一渲染帧时可用
|
||||||
|
bridge.getOrLoadTextureByPath(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, Plugin
|
|||||||
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
||||||
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||||
|
import { ParticleSystemComponent } from '@esengine/particle';
|
||||||
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
|
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
|
||||||
import * as esEngine from '@esengine/engine';
|
import * as esEngine from '@esengine/engine';
|
||||||
import {
|
import {
|
||||||
@@ -462,6 +463,43 @@ export class EngineService {
|
|||||||
if (this._runtime?.bridge) {
|
if (this._runtime?.bridge) {
|
||||||
this._engineIntegration = new EngineIntegration(this._assetManager, 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 = new SceneResourceManager();
|
||||||
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
||||||
|
|
||||||
@@ -712,10 +750,15 @@ export class EngineService {
|
|||||||
return convertFileSrc(absolutePath);
|
return convertFileSrc(absolutePath);
|
||||||
}
|
}
|
||||||
return relativePath;
|
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 not found, return original value (will result in white block)
|
||||||
// 未找到 GUID,返回原值
|
// 未找到 GUID,返回原值(会显示白块)
|
||||||
return guidOrPath;
|
return guidOrPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,6 +1072,19 @@ export class EngineService {
|
|||||||
// 清除 UI 渲染缓存
|
// 清除 UI 渲染缓存
|
||||||
invalidateUIRenderCaches();
|
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) {
|
if (this._sceneResourceManager && this._runtime.scene) {
|
||||||
await this._sceneResourceManager.loadSceneResources(this._runtime.scene);
|
await this._sceneResourceManager.loadSceneResources(this._runtime.scene);
|
||||||
@@ -1057,6 +1113,21 @@ export class EngineService {
|
|||||||
return success;
|
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.
|
* Check if a snapshot exists.
|
||||||
*/
|
*/
|
||||||
|
|||||||
591
packages/editor-app/src/services/RenderDebugService.ts
Normal file
591
packages/editor-app/src/services/RenderDebugService.ts
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
/**
|
||||||
|
* 渲染调试服务
|
||||||
|
* Render Debug Service
|
||||||
|
*
|
||||||
|
* 从引擎收集渲染调试数据
|
||||||
|
* Collects render debug data from the engine
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Core, Entity } from '@esengine/ecs-framework';
|
||||||
|
import { TransformComponent } from '@esengine/engine-core';
|
||||||
|
import { SpriteComponent } from '@esengine/sprite';
|
||||||
|
import { ParticleSystemComponent } from '@esengine/particle';
|
||||||
|
import { UITransformComponent, UIRenderComponent, UITextComponent } from '@esengine/ui';
|
||||||
|
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纹理调试信息
|
||||||
|
* Texture debug info
|
||||||
|
*/
|
||||||
|
export interface TextureDebugInfo {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
state: 'loading' | 'ready' | 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprite 调试信息
|
||||||
|
* Sprite debug info
|
||||||
|
*/
|
||||||
|
export interface SpriteDebugInfo {
|
||||||
|
entityId: number;
|
||||||
|
entityName: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
textureId: number;
|
||||||
|
texturePath: string;
|
||||||
|
/** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */
|
||||||
|
textureUrl?: string;
|
||||||
|
uv: [number, number, number, number];
|
||||||
|
color: string;
|
||||||
|
alpha: number;
|
||||||
|
sortingLayer: string;
|
||||||
|
orderInLayer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 粒子调试信息
|
||||||
|
* Particle debug info
|
||||||
|
*/
|
||||||
|
export interface ParticleDebugInfo {
|
||||||
|
entityId: number;
|
||||||
|
entityName: string;
|
||||||
|
systemName: string;
|
||||||
|
isPlaying: boolean;
|
||||||
|
activeCount: number;
|
||||||
|
maxParticles: number;
|
||||||
|
textureId: number;
|
||||||
|
texturePath: string;
|
||||||
|
/** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */
|
||||||
|
textureUrl?: string;
|
||||||
|
textureSheetAnimation: {
|
||||||
|
enabled: boolean;
|
||||||
|
tilesX: number;
|
||||||
|
tilesY: number;
|
||||||
|
totalFrames: number;
|
||||||
|
} | null;
|
||||||
|
sampleParticles: Array<{
|
||||||
|
index: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
frame: number;
|
||||||
|
uv: [number, number, number, number];
|
||||||
|
age: number;
|
||||||
|
lifetime: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
alpha: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI 元素调试信息
|
||||||
|
* UI element debug info
|
||||||
|
*/
|
||||||
|
export interface UIDebugInfo {
|
||||||
|
entityId: number;
|
||||||
|
entityName: string;
|
||||||
|
type: 'rect' | 'image' | 'text' | 'ninepatch' | 'circle' | 'rounded-rect' | 'unknown';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
worldX: number;
|
||||||
|
worldY: number;
|
||||||
|
rotation: number;
|
||||||
|
visible: boolean;
|
||||||
|
alpha: number;
|
||||||
|
sortingLayer: string;
|
||||||
|
orderInLayer: number;
|
||||||
|
textureGuid?: string;
|
||||||
|
textureUrl?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
text?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染调试快照
|
||||||
|
* Render debug snapshot
|
||||||
|
*/
|
||||||
|
export interface RenderDebugSnapshot {
|
||||||
|
timestamp: number;
|
||||||
|
frameNumber: number;
|
||||||
|
textures: TextureDebugInfo[];
|
||||||
|
sprites: SpriteDebugInfo[];
|
||||||
|
particles: ParticleDebugInfo[];
|
||||||
|
uiElements: UIDebugInfo[];
|
||||||
|
stats: {
|
||||||
|
totalSprites: number;
|
||||||
|
totalParticles: number;
|
||||||
|
totalUIElements: number;
|
||||||
|
totalTextures: number;
|
||||||
|
drawCalls: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染调试服务
|
||||||
|
* Render Debug Service
|
||||||
|
*/
|
||||||
|
export class RenderDebugService {
|
||||||
|
private static _instance: RenderDebugService | null = null;
|
||||||
|
private _frameNumber: number = 0;
|
||||||
|
private _enabled: boolean = false;
|
||||||
|
private _snapshots: RenderDebugSnapshot[] = [];
|
||||||
|
private _maxSnapshots: number = 60;
|
||||||
|
|
||||||
|
// 引擎引用 | Engine reference
|
||||||
|
private _engineBridge: any = null;
|
||||||
|
|
||||||
|
static getInstance(): RenderDebugService {
|
||||||
|
if (!RenderDebugService._instance) {
|
||||||
|
RenderDebugService._instance = new RenderDebugService();
|
||||||
|
}
|
||||||
|
return RenderDebugService._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置引擎桥接
|
||||||
|
* Set engine bridge
|
||||||
|
*/
|
||||||
|
setEngineBridge(bridge: any): void {
|
||||||
|
this._engineBridge = bridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用/禁用调试
|
||||||
|
* Enable/disable debugging
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
this._enabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
this._snapshots = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this._enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纹理 base64 缓存 | Texture base64 cache
|
||||||
|
private _textureCache = new Map<string, string>();
|
||||||
|
private _texturePending = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析纹理 GUID 为 base64 data URL(从缓存获取)
|
||||||
|
* Resolve texture GUID to base64 data URL (from cache)
|
||||||
|
*/
|
||||||
|
private _resolveTextureUrl(textureGuid: string | null | undefined): string | undefined {
|
||||||
|
if (!textureGuid) return undefined;
|
||||||
|
|
||||||
|
// 从缓存获取 | Get from cache
|
||||||
|
if (this._textureCache.has(textureGuid)) {
|
||||||
|
console.log('[RenderDebugService] Texture from cache:', textureGuid);
|
||||||
|
return this._textureCache.get(textureGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在加载中,返回 undefined | If loading, return undefined
|
||||||
|
if (this._texturePending.has(textureGuid)) {
|
||||||
|
console.log('[RenderDebugService] Texture loading:', textureGuid);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步加载纹理 | Load texture asynchronously
|
||||||
|
console.log('[RenderDebugService] Starting texture load:', textureGuid);
|
||||||
|
this._loadTextureToCache(textureGuid);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步加载纹理到缓存
|
||||||
|
* Load texture to cache asynchronously
|
||||||
|
*/
|
||||||
|
private async _loadTextureToCache(textureGuid: string): Promise<void> {
|
||||||
|
if (this._textureCache.has(textureGuid) || this._texturePending.has(textureGuid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._texturePending.add(textureGuid);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||||
|
const projectService = Core.services.tryResolve(ProjectService) as { getCurrentProject: () => { path: string } | null } | null;
|
||||||
|
|
||||||
|
let resolvedPath: string | null = null;
|
||||||
|
|
||||||
|
// 检查是否是 GUID 格式 | Check if GUID format
|
||||||
|
const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(textureGuid);
|
||||||
|
|
||||||
|
if (isGuid && assetRegistry) {
|
||||||
|
resolvedPath = assetRegistry.getPathByGuid(textureGuid) || null;
|
||||||
|
} else {
|
||||||
|
resolvedPath = textureGuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedPath) {
|
||||||
|
this._texturePending.delete(textureGuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是图片 | Check if image
|
||||||
|
const ext = resolvedPath.toLowerCase().split('.').pop() || '';
|
||||||
|
const imageExts: Record<string, string> = {
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'webp': 'image/webp',
|
||||||
|
'bmp': 'image/bmp'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mimeType = imageExts[ext];
|
||||||
|
if (!mimeType) {
|
||||||
|
this._texturePending.delete(textureGuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整路径 | Build full path
|
||||||
|
const projectPath = projectService?.getCurrentProject()?.path;
|
||||||
|
const fullPath = resolvedPath.startsWith('/') || resolvedPath.includes(':')
|
||||||
|
? resolvedPath
|
||||||
|
: projectPath
|
||||||
|
? `${projectPath}/${resolvedPath}`
|
||||||
|
: resolvedPath;
|
||||||
|
|
||||||
|
// 通过 Tauri command 读取文件并转为 base64 | Read file via Tauri command and convert to base64
|
||||||
|
console.log('[RenderDebugService] Loading texture:', fullPath);
|
||||||
|
const base64 = await invoke<string>('read_file_as_base64', { filePath: fullPath });
|
||||||
|
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||||
|
|
||||||
|
console.log('[RenderDebugService] Texture loaded, base64 length:', base64.length);
|
||||||
|
this._textureCache.set(textureGuid, dataUrl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
|
||||||
|
} finally {
|
||||||
|
this._texturePending.delete(textureGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集当前帧的调试数据
|
||||||
|
* Collect debug data for current frame
|
||||||
|
*/
|
||||||
|
collectSnapshot(): RenderDebugSnapshot | null {
|
||||||
|
if (!this._enabled) return null;
|
||||||
|
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (!scene) return null;
|
||||||
|
|
||||||
|
this._frameNumber++;
|
||||||
|
|
||||||
|
const snapshot: RenderDebugSnapshot = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
frameNumber: this._frameNumber,
|
||||||
|
textures: this._collectTextures(),
|
||||||
|
sprites: this._collectSprites(scene.entities.buffer),
|
||||||
|
particles: this._collectParticles(scene.entities.buffer),
|
||||||
|
uiElements: this._collectUI(scene.entities.buffer),
|
||||||
|
stats: {
|
||||||
|
totalSprites: 0,
|
||||||
|
totalParticles: 0,
|
||||||
|
totalUIElements: 0,
|
||||||
|
totalTextures: 0,
|
||||||
|
drawCalls: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算统计 | Calculate stats
|
||||||
|
snapshot.stats.totalSprites = snapshot.sprites.length;
|
||||||
|
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
|
||||||
|
snapshot.stats.totalUIElements = snapshot.uiElements.length;
|
||||||
|
snapshot.stats.totalTextures = snapshot.textures.length;
|
||||||
|
|
||||||
|
// 保存快照 | Save snapshot
|
||||||
|
this._snapshots.push(snapshot);
|
||||||
|
if (this._snapshots.length > this._maxSnapshots) {
|
||||||
|
this._snapshots.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新快照
|
||||||
|
* Get latest snapshot
|
||||||
|
*/
|
||||||
|
getLatestSnapshot(): RenderDebugSnapshot | null {
|
||||||
|
return this._snapshots.length > 0 ? this._snapshots[this._snapshots.length - 1] ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有快照
|
||||||
|
* Get all snapshots
|
||||||
|
*/
|
||||||
|
getSnapshots(): RenderDebugSnapshot[] {
|
||||||
|
return [...this._snapshots];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除快照
|
||||||
|
* Clear snapshots
|
||||||
|
*/
|
||||||
|
clearSnapshots(): void {
|
||||||
|
this._snapshots = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集纹理信息
|
||||||
|
* Collect texture info
|
||||||
|
*/
|
||||||
|
private _collectTextures(): TextureDebugInfo[] {
|
||||||
|
const textures: TextureDebugInfo[] = [];
|
||||||
|
|
||||||
|
// TODO: 从 EngineBridge 获取纹理管理器数据
|
||||||
|
// TODO: Get texture manager data from EngineBridge
|
||||||
|
if (this._engineBridge) {
|
||||||
|
// const textureManager = this._engineBridge.getTextureManager();
|
||||||
|
// for (const [id, tex] of textureManager.entries()) {
|
||||||
|
// textures.push({ ... });
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
return textures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集 Sprite 信息
|
||||||
|
* Collect sprite info
|
||||||
|
*/
|
||||||
|
private _collectSprites(entities: readonly Entity[]): SpriteDebugInfo[] {
|
||||||
|
const sprites: SpriteDebugInfo[] = [];
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
const sprite = entity.getComponent(SpriteComponent);
|
||||||
|
const transform = entity.getComponent(TransformComponent);
|
||||||
|
|
||||||
|
if (!sprite || !transform) continue;
|
||||||
|
|
||||||
|
const pos = transform.worldPosition ?? transform.position;
|
||||||
|
const rot = typeof transform.rotation === 'number'
|
||||||
|
? transform.rotation
|
||||||
|
: transform.rotation.z;
|
||||||
|
|
||||||
|
const textureGuid = sprite.textureGuid ?? '';
|
||||||
|
sprites.push({
|
||||||
|
entityId: entity.id,
|
||||||
|
entityName: entity.name,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotation: rot,
|
||||||
|
textureId: (sprite as any).textureId ?? 0,
|
||||||
|
texturePath: textureGuid,
|
||||||
|
textureUrl: this._resolveTextureUrl(textureGuid),
|
||||||
|
uv: [...sprite.uv] as [number, number, number, number],
|
||||||
|
color: sprite.color,
|
||||||
|
alpha: sprite.alpha,
|
||||||
|
sortingLayer: sprite.sortingLayer,
|
||||||
|
orderInLayer: sprite.orderInLayer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprites;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集粒子系统信息
|
||||||
|
* Collect particle system info
|
||||||
|
*/
|
||||||
|
private _collectParticles(entities: readonly Entity[]): ParticleDebugInfo[] {
|
||||||
|
const particleSystems: ParticleDebugInfo[] = [];
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
const ps = entity.getComponent(ParticleSystemComponent);
|
||||||
|
const transform = entity.getComponent(TransformComponent);
|
||||||
|
|
||||||
|
if (!ps) continue;
|
||||||
|
|
||||||
|
const pool = ps.pool;
|
||||||
|
|
||||||
|
// 通过 getModule 获取 TextureSheetAnimation 模块 | Get TextureSheetAnimation module via getModule
|
||||||
|
const textureSheetAnim = ps.getModule?.('TextureSheetAnimation') as any;
|
||||||
|
|
||||||
|
// 收集所有活跃粒子 | Collect all active particles
|
||||||
|
const sampleParticles: ParticleDebugInfo['sampleParticles'] = [];
|
||||||
|
if (pool) {
|
||||||
|
let count = 0;
|
||||||
|
pool.forEachActive((p: any) => {
|
||||||
|
const tilesX = p._animTilesX ?? 1;
|
||||||
|
const tilesY = p._animTilesY ?? 1;
|
||||||
|
const frame = p._animFrame ?? 0;
|
||||||
|
const col = frame % tilesX;
|
||||||
|
const row = Math.floor(frame / tilesX);
|
||||||
|
const uWidth = 1 / tilesX;
|
||||||
|
const vHeight = 1 / tilesY;
|
||||||
|
|
||||||
|
sampleParticles.push({
|
||||||
|
index: count,
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
frame,
|
||||||
|
uv: [
|
||||||
|
col * uWidth,
|
||||||
|
row * vHeight,
|
||||||
|
(col + 1) * uWidth,
|
||||||
|
(row + 1) * vHeight,
|
||||||
|
],
|
||||||
|
age: p.age,
|
||||||
|
lifetime: p.lifetime,
|
||||||
|
size: p.size ?? p.startSize ?? 1,
|
||||||
|
color: p.color ?? '#ffffff',
|
||||||
|
alpha: p.alpha ?? 1,
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模块的 tilesX/tilesY | Get tilesX/tilesY from module
|
||||||
|
const tilesX = textureSheetAnim?.tilesX ?? 1;
|
||||||
|
const tilesY = textureSheetAnim?.tilesY ?? 1;
|
||||||
|
const totalFrames = textureSheetAnim?.actualTotalFrames ?? (tilesX * tilesY);
|
||||||
|
|
||||||
|
const textureGuid = ps.textureGuid ?? '';
|
||||||
|
particleSystems.push({
|
||||||
|
entityId: entity.id,
|
||||||
|
entityName: entity.name,
|
||||||
|
systemName: `ParticleSystem_${entity.id}`,
|
||||||
|
isPlaying: ps.isPlaying,
|
||||||
|
activeCount: pool?.activeCount ?? 0,
|
||||||
|
maxParticles: ps.maxParticles,
|
||||||
|
textureId: ps.textureId ?? 0,
|
||||||
|
texturePath: textureGuid,
|
||||||
|
textureUrl: this._resolveTextureUrl(textureGuid),
|
||||||
|
textureSheetAnimation: textureSheetAnim?.enabled ? {
|
||||||
|
enabled: true,
|
||||||
|
tilesX,
|
||||||
|
tilesY,
|
||||||
|
totalFrames,
|
||||||
|
} : null,
|
||||||
|
sampleParticles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return particleSystems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集 UI 元素信息
|
||||||
|
* Collect UI element info
|
||||||
|
*/
|
||||||
|
private _collectUI(entities: readonly Entity[]): UIDebugInfo[] {
|
||||||
|
const uiElements: UIDebugInfo[] = [];
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
const uiTransform = entity.getComponent(UITransformComponent);
|
||||||
|
|
||||||
|
if (!uiTransform) continue;
|
||||||
|
|
||||||
|
const uiRender = entity.getComponent(UIRenderComponent);
|
||||||
|
const uiText = entity.getComponent(UITextComponent);
|
||||||
|
|
||||||
|
// 确定类型 | Determine type
|
||||||
|
let type: UIDebugInfo['type'] = 'unknown';
|
||||||
|
if (uiText) {
|
||||||
|
type = 'text';
|
||||||
|
} else if (uiRender) {
|
||||||
|
switch (uiRender.type) {
|
||||||
|
case 'rect': type = 'rect'; break;
|
||||||
|
case 'image': type = 'image'; break;
|
||||||
|
case 'ninepatch': type = 'ninepatch'; break;
|
||||||
|
case 'circle': type = 'circle'; break;
|
||||||
|
case 'rounded-rect': type = 'rounded-rect'; break;
|
||||||
|
default: type = 'rect';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取纹理 GUID | Get texture GUID
|
||||||
|
const textureGuid = uiRender?.textureGuid?.toString() ?? '';
|
||||||
|
|
||||||
|
// 转换颜色为十六进制字符串 | Convert color to hex string
|
||||||
|
const backgroundColor = uiRender?.backgroundColor !== undefined
|
||||||
|
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
uiElements.push({
|
||||||
|
entityId: entity.id,
|
||||||
|
entityName: entity.name,
|
||||||
|
type,
|
||||||
|
x: uiTransform.x,
|
||||||
|
y: uiTransform.y,
|
||||||
|
width: uiTransform.width,
|
||||||
|
height: uiTransform.height,
|
||||||
|
worldX: uiTransform.worldX,
|
||||||
|
worldY: uiTransform.worldY,
|
||||||
|
rotation: uiTransform.rotation,
|
||||||
|
visible: uiTransform.visible && uiTransform.worldVisible,
|
||||||
|
alpha: uiTransform.worldAlpha,
|
||||||
|
sortingLayer: uiTransform.sortingLayer,
|
||||||
|
orderInLayer: uiTransform.orderInLayer,
|
||||||
|
textureGuid: textureGuid || undefined,
|
||||||
|
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
|
||||||
|
backgroundColor,
|
||||||
|
text: uiText?.text,
|
||||||
|
fontSize: uiText?.fontSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return uiElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出调试数据为 JSON
|
||||||
|
* Export debug data as JSON
|
||||||
|
*/
|
||||||
|
exportAsJSON(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
exportTime: new Date().toISOString(),
|
||||||
|
snapshots: this._snapshots,
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打印当前粒子 UV 到控制台
|
||||||
|
* Print current particle UVs to console
|
||||||
|
*/
|
||||||
|
logParticleUVs(): void {
|
||||||
|
const snapshot = this.collectSnapshot();
|
||||||
|
if (!snapshot) {
|
||||||
|
console.log('[RenderDebugService] No scene available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.group('[RenderDebugService] Particle UV Debug');
|
||||||
|
for (const ps of snapshot.particles) {
|
||||||
|
console.group(`${ps.entityName} (${ps.activeCount} active)`);
|
||||||
|
if (ps.textureSheetAnimation) {
|
||||||
|
console.log(`TextureSheetAnimation: ${ps.textureSheetAnimation.tilesX}x${ps.textureSheetAnimation.tilesY}`);
|
||||||
|
}
|
||||||
|
for (const p of ps.sampleParticles) {
|
||||||
|
console.log(` Particle ${p.index}: frame=${p.frame}, UV=[${p.uv.map(v => v.toFixed(3)).join(', ')}]`);
|
||||||
|
}
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局实例 | Global instance
|
||||||
|
export const renderDebugService = RenderDebugService.getInstance();
|
||||||
|
|
||||||
|
// 导出到全局以便控制台使用 | Export to global for console usage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as any).renderDebugService = renderDebugService;
|
||||||
|
}
|
||||||
@@ -126,3 +126,52 @@
|
|||||||
.confirm-dialog-btn:active {
|
.confirm-dialog-btn:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* External Modification Dialog | 外部修改对话框 */
|
||||||
|
.external-modification-dialog .warning-icon {
|
||||||
|
color: #f0ad4e;
|
||||||
|
margin-right: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-modification-dialog .confirm-dialog-header {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-modification-dialog .confirm-dialog-header h2 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-modification-dialog .hint-text {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-modification-footer {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-btn.reload {
|
||||||
|
background: #5bc0de;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-btn.reload:hover {
|
||||||
|
background: #7cd0e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-btn.overwrite {
|
||||||
|
background: #f0ad4e;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-btn.overwrite:hover {
|
||||||
|
background: #f4be6e;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Unified Plugin Manager
|
* 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 { IScene, ServiceContainer, IService } from '@esengine/ecs-framework';
|
||||||
import type {
|
import type {
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
@@ -670,9 +670,9 @@ export class PluginManager implements IService {
|
|||||||
// 注册组件(使用包装的 Registry 来跟踪)
|
// 注册组件(使用包装的 Registry 来跟踪)
|
||||||
// Register components (use wrapped registry to track)
|
// Register components (use wrapped registry to track)
|
||||||
if (runtimeModule.registerComponents) {
|
if (runtimeModule.registerComponents) {
|
||||||
const componentsBefore = new Set(ComponentRegistry.getRegisteredComponents().map(c => c.name));
|
const componentsBefore = new Set(GlobalComponentRegistry.getRegisteredComponents().map(c => c.name));
|
||||||
runtimeModule.registerComponents(ComponentRegistry);
|
runtimeModule.registerComponents(GlobalComponentRegistry);
|
||||||
const componentsAfter = ComponentRegistry.getRegisteredComponents();
|
const componentsAfter = GlobalComponentRegistry.getRegisteredComponents();
|
||||||
|
|
||||||
// 跟踪新注册的组件
|
// 跟踪新注册的组件
|
||||||
// Track newly registered components
|
// Track newly registered components
|
||||||
@@ -779,7 +779,7 @@ export class PluginManager implements IService {
|
|||||||
if (resources.componentTypeNames.length > 0) {
|
if (resources.componentTypeNames.length > 0) {
|
||||||
for (const componentName of resources.componentTypeNames) {
|
for (const componentName of resources.componentTypeNames) {
|
||||||
try {
|
try {
|
||||||
ComponentRegistry.unregister(componentName);
|
GlobalComponentRegistry.unregister(componentName);
|
||||||
logger.debug(`Component unregistered: ${componentName}`);
|
logger.debug(`Component unregistered: ${componentName}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to unregister component ${componentName}:`, e);
|
logger.error(`Failed to unregister component ${componentName}:`, e);
|
||||||
@@ -900,7 +900,7 @@ export class PluginManager implements IService {
|
|||||||
const runtimeModule = plugin.plugin.runtimeModule;
|
const runtimeModule = plugin.plugin.runtimeModule;
|
||||||
if (runtimeModule?.registerComponents) {
|
if (runtimeModule?.registerComponents) {
|
||||||
try {
|
try {
|
||||||
runtimeModule.registerComponents(ComponentRegistry);
|
runtimeModule.registerComponents(GlobalComponentRegistry);
|
||||||
logger.debug(`Components registered for: ${pluginId}`);
|
logger.debug(`Components registered for: ${pluginId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to register components for ${pluginId}:`, e);
|
logger.error(`Failed to register components for ${pluginId}:`, e);
|
||||||
|
|||||||
@@ -394,8 +394,14 @@ export class AssetRegistryService implements IService {
|
|||||||
// 处理文件创建 - 注册新资产并生成 .meta
|
// 处理文件创建 - 注册新资产并生成 .meta
|
||||||
if (changeType === 'create' || changeType === 'modify') {
|
if (changeType === 'create' || changeType === 'modify') {
|
||||||
for (const absolutePath of paths) {
|
for (const absolutePath of paths) {
|
||||||
// Skip .meta files
|
// Handle .meta file changes - invalidate cache
|
||||||
if (absolutePath.endsWith('.meta')) continue;
|
// 处理 .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
|
// Only process files in managed directories
|
||||||
// 只处理托管目录中的文件
|
// 只处理托管目录中的文件
|
||||||
@@ -406,8 +412,14 @@ export class AssetRegistryService implements IService {
|
|||||||
}
|
}
|
||||||
} else if (changeType === 'remove') {
|
} else if (changeType === 'remove') {
|
||||||
for (const absolutePath of paths) {
|
for (const absolutePath of paths) {
|
||||||
// Skip .meta files
|
// Handle .meta file deletion - invalidate cache
|
||||||
if (absolutePath.endsWith('.meta')) continue;
|
// 处理 .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
|
// Only process files in managed directories
|
||||||
// 只处理托管目录中的文件
|
// 只处理托管目录中的文件
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export class EntityStoreService implements IService {
|
|||||||
this.entities.clear();
|
this.entities.clear();
|
||||||
this.rootEntityIds = [];
|
this.rootEntityIds = [];
|
||||||
|
|
||||||
|
// 调试:打印场景实体信息 | Debug: print scene entity info
|
||||||
|
logger.info(`[syncFromScene] Scene name: ${scene.name}, entities.count: ${scene.entities.count}`);
|
||||||
|
|
||||||
let entityCount = 0;
|
let entityCount = 0;
|
||||||
scene.entities.forEach((entity) => {
|
scene.entities.forEach((entity) => {
|
||||||
entityCount++;
|
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) {
|
if (this.rootEntityIds.length > 0) {
|
||||||
const rootNames = this.rootEntityIds
|
const rootNames = this.rootEntityIds
|
||||||
.map(id => this.entities.get(id)?.name)
|
.map(id => this.entities.get(id)?.name)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Scene,
|
Scene,
|
||||||
PrefabSerializer,
|
PrefabSerializer,
|
||||||
HierarchySystem,
|
HierarchySystem,
|
||||||
ComponentRegistry
|
GlobalComponentRegistry
|
||||||
} from '@esengine/ecs-framework';
|
} from '@esengine/ecs-framework';
|
||||||
import type { ComponentType } from '@esengine/ecs-framework';
|
import type { ComponentType } from '@esengine/ecs-framework';
|
||||||
import type { SceneResourceManager } from '@esengine/asset-system';
|
import type { SceneResourceManager } from '@esengine/asset-system';
|
||||||
@@ -24,6 +24,10 @@ export interface SceneState {
|
|||||||
sceneName: string;
|
sceneName: string;
|
||||||
isModified: boolean;
|
isModified: boolean;
|
||||||
isSaved: 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,
|
currentScenePath: null,
|
||||||
sceneName: 'Untitled',
|
sceneName: 'Untitled',
|
||||||
isModified: false,
|
isModified: false,
|
||||||
isSaved: false
|
isSaved: false,
|
||||||
|
lastKnownMtime: null,
|
||||||
|
externallyModified: false
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 预制体编辑模式状态 | Prefab edit mode state */
|
/** 预制体编辑模式状态 | Prefab edit mode state */
|
||||||
@@ -118,7 +124,9 @@ export class SceneManagerService implements IService {
|
|||||||
currentScenePath: null,
|
currentScenePath: null,
|
||||||
sceneName: 'Untitled',
|
sceneName: 'Untitled',
|
||||||
isModified: false,
|
isModified: false,
|
||||||
isSaved: false
|
isSaved: false,
|
||||||
|
lastKnownMtime: null,
|
||||||
|
externallyModified: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// 同步到 EntityStore
|
// 同步到 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 {
|
try {
|
||||||
const jsonData = await this.fileAPI.readFileContent(path);
|
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
|
// Ensure isEditorMode is set in editor to defer component lifecycle callbacks
|
||||||
scene.isEditorMode = true;
|
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, {
|
scene.deserialize(jsonData, {
|
||||||
strategy: 'replace'
|
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
|
// 加载场景资源 / Load scene resources
|
||||||
if (this.sceneResourceManager) {
|
if (this.sceneResourceManager) {
|
||||||
await this.sceneResourceManager.loadSceneResources(scene);
|
await this.sceneResourceManager.loadSceneResources(scene);
|
||||||
@@ -179,11 +231,23 @@ export class SceneManagerService implements IService {
|
|||||||
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
|
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
|
||||||
const sceneName = fileName.replace('.ecs', '');
|
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 = {
|
this.sceneState = {
|
||||||
currentScenePath: path,
|
currentScenePath: path,
|
||||||
sceneName,
|
sceneName,
|
||||||
isModified: false,
|
isModified: false,
|
||||||
isSaved: true
|
isSaved: true,
|
||||||
|
lastKnownMtime: mtime,
|
||||||
|
externallyModified: false
|
||||||
};
|
};
|
||||||
|
|
||||||
this.entityStore?.syncFromScene();
|
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) {
|
if (!this.sceneState.currentScenePath) {
|
||||||
await this.saveSceneAs();
|
await this.saveSceneAs();
|
||||||
return;
|
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 {
|
try {
|
||||||
const scene = Core.scene as Scene | null;
|
const scene = Core.scene as Scene | null;
|
||||||
if (!scene) {
|
if (!scene) {
|
||||||
@@ -219,8 +293,18 @@ export class SceneManagerService implements IService {
|
|||||||
|
|
||||||
await this.fileAPI.saveProject(this.sceneState.currentScenePath, jsonData);
|
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.isModified = false;
|
||||||
this.sceneState.isSaved = true;
|
this.sceneState.isSaved = true;
|
||||||
|
this.sceneState.externallyModified = false;
|
||||||
|
|
||||||
await this.messageHub.publish('scene:saved', {
|
await this.messageHub.publish('scene:saved', {
|
||||||
path: this.sceneState.currentScenePath
|
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> {
|
public async saveSceneAs(filePath?: string): Promise<void> {
|
||||||
let path: string | null | undefined = filePath;
|
let path: string | null | undefined = filePath;
|
||||||
if (!path) {
|
if (!path) {
|
||||||
@@ -269,11 +436,23 @@ export class SceneManagerService implements IService {
|
|||||||
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
|
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
|
||||||
const sceneName = fileName.replace('.ecs', '');
|
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 = {
|
this.sceneState = {
|
||||||
currentScenePath: path,
|
currentScenePath: path,
|
||||||
sceneName,
|
sceneName,
|
||||||
isModified: false,
|
isModified: false,
|
||||||
isSaved: true
|
isSaved: true,
|
||||||
|
lastKnownMtime: mtime,
|
||||||
|
externallyModified: false
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.messageHub.publish('scene:saved', { path });
|
await this.messageHub.publish('scene:saved', { path });
|
||||||
@@ -405,11 +584,11 @@ export class SceneManagerService implements IService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. 获取组件注册表 | Get component registry
|
// 6. 获取组件注册表 | Get component registry
|
||||||
// ComponentRegistry.getAllComponentNames() 返回 Map<string, Function>
|
// GlobalComponentRegistry.getAllComponentNames() 返回 Map<string, Function>
|
||||||
// 需要转换为 Map<string, ComponentType>
|
// 需要转换为 Map<string, ComponentType>
|
||||||
const nameToType = ComponentRegistry.getAllComponentNames();
|
const nameToType = GlobalComponentRegistry.getAllComponentNames();
|
||||||
const componentRegistry = new Map<string, ComponentType>();
|
const componentRegistry = new Map<string, ComponentType>();
|
||||||
nameToType.forEach((type, name) => {
|
nameToType.forEach((type: Function, name: string) => {
|
||||||
componentRegistry.set(name, type as ComponentType);
|
componentRegistry.set(name, type as ComponentType);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -471,7 +650,9 @@ export class SceneManagerService implements IService {
|
|||||||
currentScenePath: null,
|
currentScenePath: null,
|
||||||
sceneName: `Prefab: ${prefabName}`,
|
sceneName: `Prefab: ${prefabName}`,
|
||||||
isModified: false,
|
isModified: false,
|
||||||
isSaved: true
|
isSaved: true,
|
||||||
|
lastKnownMtime: null,
|
||||||
|
externallyModified: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// 11. 同步到 EntityStore | Sync to EntityStore
|
// 11. 同步到 EntityStore | Sync to EntityStore
|
||||||
@@ -537,7 +718,9 @@ export class SceneManagerService implements IService {
|
|||||||
currentScenePath: originalState.originalScenePath,
|
currentScenePath: originalState.originalScenePath,
|
||||||
sceneName: originalState.originalSceneName,
|
sceneName: originalState.originalSceneName,
|
||||||
isModified: originalState.originalSceneModified,
|
isModified: originalState.originalSceneModified,
|
||||||
isSaved: !originalState.originalSceneModified
|
isSaved: !originalState.originalSceneModified,
|
||||||
|
lastKnownMtime: null,
|
||||||
|
externallyModified: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. 清除预制体编辑模式状态 | Clear prefab edit mode state
|
// 5. 清除预制体编辑模式状态 | Clear prefab edit mode state
|
||||||
|
|||||||
@@ -71,16 +71,14 @@ export interface UserCodeCompileOptions {
|
|||||||
sourceMap?: boolean;
|
sourceMap?: boolean;
|
||||||
/** Whether to minify output | 是否压缩输出 */
|
/** Whether to minify output | 是否压缩输出 */
|
||||||
minify?: boolean;
|
minify?: boolean;
|
||||||
/** Output format | 输出格式 */
|
/** Output format (default: 'esm') | 输出格式(默认:'esm')*/
|
||||||
format?: 'esm' | 'iife';
|
format?: 'esm' | 'iife';
|
||||||
/**
|
/**
|
||||||
* SDK modules for shim generation.
|
* SDK modules information (reserved for future use).
|
||||||
* 用于生成 shim 的 SDK 模块列表。
|
* SDK 模块信息(保留供将来使用)。
|
||||||
*
|
*
|
||||||
* If provided, shims will be created for these modules.
|
* Currently SDK is handled via external dependencies and global variable.
|
||||||
* Typically obtained from RuntimeResolver.getAvailableModules().
|
* 当前 SDK 通过外部依赖和全局变量处理。
|
||||||
* 如果提供,将为这些模块创建 shim。
|
|
||||||
* 通常从 RuntimeResolver.getAvailableModules() 获取。
|
|
||||||
*/
|
*/
|
||||||
sdkModules?: SDKModuleInfo[];
|
sdkModules?: SDKModuleInfo[];
|
||||||
}
|
}
|
||||||
@@ -382,6 +380,37 @@ export interface IUserCodeService {
|
|||||||
* 检查是否正在监视。
|
* 检查是否正在监视。
|
||||||
*/
|
*/
|
||||||
isWatching(): boolean;
|
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';
|
import { EditorConfig } from '../../Config';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
createLogger,
|
createLogger,
|
||||||
PlatformDetector,
|
PlatformDetector,
|
||||||
ComponentRegistry as CoreComponentRegistry,
|
GlobalComponentRegistry as CoreComponentRegistry,
|
||||||
COMPONENT_TYPE_NAME,
|
COMPONENT_TYPE_NAME,
|
||||||
SYSTEM_TYPE_NAME
|
SYSTEM_TYPE_NAME
|
||||||
} from '@esengine/ecs-framework';
|
} from '@esengine/ecs-framework';
|
||||||
@@ -82,9 +82,27 @@ export class UserCodeService implements IService, IUserCodeService {
|
|||||||
*/
|
*/
|
||||||
private _hotReloadCoordinator: HotReloadCoordinator;
|
private _hotReloadCoordinator: HotReloadCoordinator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 就绪状态 Promise
|
||||||
|
* Ready state promise
|
||||||
|
*/
|
||||||
|
private _readyPromise: Promise<void>;
|
||||||
|
private _readyResolve: (() => void) | undefined;
|
||||||
|
|
||||||
constructor(fileSystem: IFileSystem) {
|
constructor(fileSystem: IFileSystem) {
|
||||||
this._fileSystem = fileSystem;
|
this._fileSystem = fileSystem;
|
||||||
this._hotReloadCoordinator = new HotReloadCoordinator();
|
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`;
|
const entryPath = `${outputDir}${sep}_entry_${options.target}.ts`;
|
||||||
await this._fileSystem.writeFile(entryPath, entryContent);
|
await this._fileSystem.writeFile(entryPath, entryContent);
|
||||||
|
|
||||||
// Create shim files for framework dependencies | 创建框架依赖的 shim 文件
|
// Get external dependencies | 获取外部依赖
|
||||||
// Returns mapping from package name to shim path
|
// SDK marked as external, resolved from global variable at runtime
|
||||||
// 返回包名到 shim 路径的映射
|
// SDK 标记为外部依赖,运行时从全局变量解析
|
||||||
const alias = await this._createDependencyShims(outputDir, options.sdkModules);
|
const external = this._getExternalDependencies(options.target, options.sdkModules);
|
||||||
|
|
||||||
// Determine global name for IIFE output | 确定 IIFE 输出的全局名称
|
|
||||||
const globalName = options.target === UserCodeTarget.Runtime
|
|
||||||
? EditorConfig.globals.userRuntimeExports
|
|
||||||
: EditorConfig.globals.userEditorExports;
|
|
||||||
|
|
||||||
// Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译
|
// Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译
|
||||||
// Use IIFE format to avoid ES module import issues in Tauri
|
// Use ESM format for dynamic import() loading | 使用 ESM 格式以支持动态 import() 加载
|
||||||
// 使用 IIFE 格式以避免 Tauri 中的 ES 模块导入问题
|
|
||||||
const compileResult = await this._runEsbuild({
|
const compileResult = await this._runEsbuild({
|
||||||
entryPath,
|
entryPath,
|
||||||
outputPath,
|
outputPath,
|
||||||
format: 'iife', // Always use IIFE for Tauri compatibility | 始终使用 IIFE 以兼容 Tauri
|
format: 'esm', // ESM for standard dynamic import() | ESM 用于标准动态 import()
|
||||||
globalName,
|
|
||||||
sourceMap: options.sourceMap ?? true,
|
sourceMap: options.sourceMap ?? true,
|
||||||
minify: options.minify ?? false,
|
minify: options.minify ?? false,
|
||||||
external: [], // Don't use external, use alias instead | 不使用 external,使用 alias
|
external,
|
||||||
alias,
|
|
||||||
projectRoot: options.projectPath
|
projectRoot: options.projectPath
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,6 +269,14 @@ export class UserCodeService implements IService, IUserCodeService {
|
|||||||
* Load compiled user code module.
|
* 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 modulePath - Path to compiled JS file | 编译后的 JS 文件路径
|
||||||
* @param target - Target environment | 目标环境
|
* @param target - Target environment | 目标环境
|
||||||
* @returns Loaded module | 加载的模块
|
* @returns Loaded module | 加载的模块
|
||||||
@@ -268,20 +286,23 @@ export class UserCodeService implements IService, IUserCodeService {
|
|||||||
let moduleExports: Record<string, any>;
|
let moduleExports: Record<string, any>;
|
||||||
|
|
||||||
if (PlatformDetector.isTauriEnvironment()) {
|
if (PlatformDetector.isTauriEnvironment()) {
|
||||||
// In Tauri, read file content and execute via script tag
|
// Read file content via Tauri and load via Blob URL
|
||||||
// 在 Tauri 中,读取文件内容并通过 script 标签执行
|
// 通过 Tauri 读取文件内容并通过 Blob URL 加载
|
||||||
// This avoids CORS and module resolution issues
|
// Browser's import() doesn't support custom protocols like project://
|
||||||
// 这避免了 CORS 和模块解析问题
|
// 浏览器的 import() 不支持自定义协议如 project://
|
||||||
const { invoke } = await import('@tauri-apps/api/core');
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
|
||||||
const content = await invoke<string>('read_file_content', {
|
const content = await invoke<string>('read_file_content', {
|
||||||
path: modulePath
|
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 | 执行模块代码并捕获导出
|
// Load ESM via Blob URL | 通过 Blob URL 加载 ESM
|
||||||
moduleExports = await this._executeModuleCode(content, target);
|
moduleExports = await this._loadESMFromContent(content);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to file:// for non-Tauri environments
|
// Fallback to file:// for non-Tauri environments
|
||||||
// 非 Tauri 环境使用 file://
|
// 非 Tauri 环境使用 file://
|
||||||
@@ -924,6 +945,35 @@ export class UserCodeService implements IService, IUserCodeService {
|
|||||||
return this._watching;
|
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.
|
* Dispose service resources.
|
||||||
* 释放服务资源。
|
* 释放服务资源。
|
||||||
@@ -1058,44 +1108,6 @@ export class UserCodeService implements IService, IUserCodeService {
|
|||||||
return lines.join('\n');
|
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.
|
* 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.
|
* Uses Tauri command to invoke esbuild CLI.
|
||||||
* 使用 Tauri 命令调用 esbuild CLI。
|
* 使用 Tauri 命令调用 esbuild CLI。
|
||||||
|
*
|
||||||
|
* @param options - Compilation options | 编译选项
|
||||||
|
* @returns Compilation result | 编译结果
|
||||||
*/
|
*/
|
||||||
private async _runEsbuild(options: {
|
private async _runEsbuild(options: {
|
||||||
|
/** Entry file path | 入口文件路径 */
|
||||||
entryPath: string;
|
entryPath: string;
|
||||||
|
/** Output file path | 输出文件路径 */
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
|
/** Output format (ESM for dynamic import) | 输出格式(ESM 用于动态导入)*/
|
||||||
format: 'esm' | 'iife';
|
format: 'esm' | 'iife';
|
||||||
globalName?: string;
|
/** Generate source maps | 生成源码映射 */
|
||||||
sourceMap: boolean;
|
sourceMap: boolean;
|
||||||
|
/** Minify output | 压缩输出 */
|
||||||
minify: boolean;
|
minify: boolean;
|
||||||
|
/** External dependencies (not bundled) | 外部依赖(不打包)*/
|
||||||
external: string[];
|
external: string[];
|
||||||
alias?: Record<string, string>;
|
/** Project root for resolving paths | 项目根路径用于解析路径 */
|
||||||
projectRoot: string;
|
projectRoot: string;
|
||||||
}): Promise<{ success: boolean; errors: CompileError[] }> {
|
}): Promise<{ success: boolean; errors: CompileError[] }> {
|
||||||
try {
|
try {
|
||||||
@@ -1143,13 +1163,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
|
|||||||
entry: options.entryPath,
|
entry: options.entryPath,
|
||||||
output: options.outputPath,
|
output: options.outputPath,
|
||||||
format: options.format,
|
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 命令
|
// Use Tauri command | 使用 Tauri 命令
|
||||||
const { invoke } = await import('@tauri-apps/api/core');
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
|
||||||
@@ -1167,11 +1183,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {
|
|||||||
entryPath: options.entryPath,
|
entryPath: options.entryPath,
|
||||||
outputPath: options.outputPath,
|
outputPath: options.outputPath,
|
||||||
format: options.format,
|
format: options.format,
|
||||||
globalName: options.globalName,
|
|
||||||
sourceMap: options.sourceMap,
|
sourceMap: options.sourceMap,
|
||||||
minify: options.minify,
|
minify: options.minify,
|
||||||
external: options.external,
|
external: options.external,
|
||||||
alias: options.alias,
|
|
||||||
projectRoot: options.projectRoot
|
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.
|
* Uses Blob URL to enable dynamic import() of ESM content.
|
||||||
* 代码应该是设置全局变量的 IIFE 格式。
|
* 使用 Blob URL 实现 ESM 内容的动态 import()。
|
||||||
*
|
*
|
||||||
* @param code - Compiled JavaScript code | 编译后的 JavaScript 代码
|
* @param content - JavaScript module content (ESM format) | JavaScript 模块内容(ESM 格式)
|
||||||
* @param target - Target environment | 目标环境
|
|
||||||
* @returns Module exports | 模块导出
|
* @returns Module exports | 模块导出
|
||||||
*/
|
*/
|
||||||
private async _executeModuleCode(
|
private async _loadESMFromContent(content: string): Promise<Record<string, any>> {
|
||||||
code: string,
|
// Create Blob URL for ESM module | 为 ESM 模块创建 Blob URL
|
||||||
target: UserCodeTarget
|
const blob = new Blob([content], { type: 'application/javascript' });
|
||||||
): Promise<Record<string, any>> {
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
// 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;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// esbuild generates: var __USER_RUNTIME_EXPORTS__ = (() => {...})();
|
// Dynamic import the ESM module | 动态导入 ESM 模块
|
||||||
// When executed via new Function(), var declarations stay in function scope
|
const moduleExports = await import(/* @vite-ignore */ blobUrl);
|
||||||
// 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}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Execute the IIFE code | 执行 IIFE 代码
|
// Return all exports | 返回所有导出
|
||||||
// eslint-disable-next-line no-new-func
|
return { ...moduleExports };
|
||||||
const executeScript = new Function(modifiedCode);
|
} finally {
|
||||||
executeScript();
|
// Always revoke Blob URL to prevent memory leaks
|
||||||
|
// 始终撤销 Blob URL 以防止内存泄漏
|
||||||
// Get exports from global | 从全局获取导出
|
URL.revokeObjectURL(blobUrl);
|
||||||
const exports = (window as any)[globalName] || {};
|
|
||||||
|
|
||||||
return exports;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to execute user code | 执行用户代码失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,10 @@
|
|||||||
* ↓
|
* ↓
|
||||||
* [UserCodeService.scan()] - Discovers all scripts
|
* [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
|
* [registerComponents()] - Registers with ECS runtime
|
||||||
* [registerEditorExtensions()] - Registers inspectors/gizmos
|
* [registerEditorExtensions()] - Registers inspectors/gizmos
|
||||||
@@ -53,6 +54,16 @@
|
|||||||
* [UserCodeService.watch()] - Hot reload on file changes
|
* [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 | 用户组件示例
|
* # Example User Component | 用户组件示例
|
||||||
*
|
*
|
||||||
* ```typescript
|
* ```typescript
|
||||||
|
|||||||
@@ -61,4 +61,13 @@ export interface IFileAPI {
|
|||||||
* @returns 路径是否存在
|
* @returns 路径是否存在
|
||||||
*/
|
*/
|
||||||
pathExists(path: string): Promise<boolean>;
|
pathExists(path: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件修改时间
|
||||||
|
* Get file modification time
|
||||||
|
*
|
||||||
|
* @param path 文件路径 | File path
|
||||||
|
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
|
||||||
|
*/
|
||||||
|
getFileMtime?(path: string): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* @see docs/architecture/plugin-system-design.md
|
* @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 { PluginServiceRegistry } from '@esengine/ecs-framework';
|
||||||
import { TransformComponent } from './TransformComponent';
|
import { TransformComponent } from './TransformComponent';
|
||||||
import type { ModuleManifest } from './ModuleManifest';
|
import type { ModuleManifest } from './ModuleManifest';
|
||||||
@@ -105,7 +105,7 @@ export interface IRuntimeModule {
|
|||||||
* 注册组件到 ComponentRegistry
|
* 注册组件到 ComponentRegistry
|
||||||
* Register components to ComponentRegistry
|
* Register components to ComponentRegistry
|
||||||
*/
|
*/
|
||||||
registerComponents?(registry: typeof ComponentRegistryType): void;
|
registerComponents?(registry: IComponentRegistry): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册服务到 ServiceContainer
|
* 注册服务到 ServiceContainer
|
||||||
@@ -192,7 +192,7 @@ export type IPlugin<TEditorModule = unknown> = IRuntimePlugin<TEditorModule>;
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
class EngineRuntimeModule implements IRuntimeModule {
|
class EngineRuntimeModule implements IRuntimeModule {
|
||||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
registerComponents(registry: IComponentRegistry): void {
|
||||||
registry.register(TransformComponent);
|
registry.register(TransformComponent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,58 @@ export interface IEngineBridge {
|
|||||||
* Set clear color
|
* Set clear color
|
||||||
*/
|
*/
|
||||||
setClearColor(r: number, g: number, b: number, a: number): void;
|
setClearColor(r: number, g: number, b: number, a: number): void;
|
||||||
|
|
||||||
|
// ===== Texture State API (Optional) =====
|
||||||
|
// ===== 纹理状态 API(可选)=====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取纹理加载状态
|
||||||
|
* Get texture loading state
|
||||||
|
*
|
||||||
|
* @param id 纹理 ID | Texture ID
|
||||||
|
* @returns 状态字符串: 'loading', 'ready', 或 'failed:reason'
|
||||||
|
* State string: 'loading', 'ready', or 'failed:reason'
|
||||||
|
*/
|
||||||
|
getTextureState?(id: number): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查纹理是否就绪
|
||||||
|
* Check if texture is ready for rendering
|
||||||
|
*
|
||||||
|
* @param id 纹理 ID | Texture ID
|
||||||
|
* @returns 纹理数据已加载则返回 true | true if texture data is loaded
|
||||||
|
*/
|
||||||
|
isTextureReady?(id: number): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取正在加载的纹理数量
|
||||||
|
* Get count of textures currently loading
|
||||||
|
*
|
||||||
|
* @returns 处于加载状态的纹理数量 | Number of textures in loading state
|
||||||
|
*/
|
||||||
|
getTextureLoadingCount?(): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步加载纹理(等待完成)
|
||||||
|
* Load texture asynchronously (wait for completion)
|
||||||
|
*
|
||||||
|
* 与 loadTexture 不同,此方法会等待纹理实际加载完成。
|
||||||
|
* Unlike loadTexture, this method waits until texture is actually loaded.
|
||||||
|
*
|
||||||
|
* @param id 纹理 ID | Texture ID
|
||||||
|
* @param url 图片 URL | Image URL
|
||||||
|
* @returns 纹理就绪时解析的 Promise | Promise that resolves when texture is ready
|
||||||
|
*/
|
||||||
|
loadTextureAsync?(id: number, url: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待所有加载中的纹理完成
|
||||||
|
* Wait for all loading textures to complete
|
||||||
|
*
|
||||||
|
* @param timeout 最大等待时间(毫秒,默认30000)| Max wait time in ms (default 30000)
|
||||||
|
* @returns 所有纹理加载完成时解析 | Resolves when all textures are loaded
|
||||||
|
*/
|
||||||
|
waitForAllTextures?(timeout?: number): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -197,14 +197,6 @@ impl Engine {
|
|||||||
colors: &[u32],
|
colors: &[u32],
|
||||||
material_ids: &[u32],
|
material_ids: &[u32],
|
||||||
) -> Result<()> {
|
) -> 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(
|
self.renderer.submit_batch(
|
||||||
transforms,
|
transforms,
|
||||||
texture_ids,
|
texture_ids,
|
||||||
@@ -382,6 +374,24 @@ impl Engine {
|
|||||||
self.texture_manager.clear_all();
|
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.
|
/// Check if a key is currently pressed.
|
||||||
/// 检查某个键是否当前被按下。
|
/// 检查某个键是否当前被按下。
|
||||||
pub fn is_key_down(&self, key_code: &str) -> bool {
|
pub fn is_key_down(&self, key_code: &str) -> bool {
|
||||||
|
|||||||
@@ -224,6 +224,42 @@ impl GameEngine {
|
|||||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
.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.
|
/// Check if a key is currently pressed.
|
||||||
/// 检查某个键是否当前被按下。
|
/// 检查某个键是否当前被按下。
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ mod texture;
|
|||||||
mod texture_manager;
|
mod texture_manager;
|
||||||
|
|
||||||
pub use texture::Texture;
|
pub use texture::Texture;
|
||||||
pub use texture_manager::TextureManager;
|
pub use texture_manager::{TextureManager, TextureState};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! Texture loading and management.
|
//! Texture loading and management.
|
||||||
//! 纹理加载和管理。
|
//! 纹理加载和管理。
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
use web_sys::{HtmlImageElement, WebGl2RenderingContext, WebGlTexture};
|
use web_sys::{HtmlImageElement, WebGl2RenderingContext, WebGlTexture};
|
||||||
@@ -9,6 +11,21 @@ use web_sys::{HtmlImageElement, WebGl2RenderingContext, WebGlTexture};
|
|||||||
use crate::core::error::{EngineError, Result};
|
use crate::core::error::{EngineError, Result};
|
||||||
use super::Texture;
|
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.
|
/// Texture manager for loading and caching textures.
|
||||||
/// 用于加载和缓存纹理的纹理管理器。
|
/// 用于加载和缓存纹理的纹理管理器。
|
||||||
pub struct TextureManager {
|
pub struct TextureManager {
|
||||||
@@ -31,6 +48,10 @@ pub struct TextureManager {
|
|||||||
/// Default white texture for untextured rendering.
|
/// Default white texture for untextured rendering.
|
||||||
/// 用于无纹理渲染的默认白色纹理。
|
/// 用于无纹理渲染的默认白色纹理。
|
||||||
default_texture: Option<WebGlTexture>,
|
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 {
|
impl TextureManager {
|
||||||
@@ -43,6 +64,7 @@ impl TextureManager {
|
|||||||
path_to_id: HashMap::new(),
|
path_to_id: HashMap::new(),
|
||||||
next_id: 1, // Start from 1, 0 is reserved for default
|
next_id: 1, // Start from 1, 0 is reserved for default
|
||||||
default_texture: None,
|
default_texture: None,
|
||||||
|
texture_states: Rc::new(RefCell::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create default white texture | 创建默认白色纹理
|
// Create default white texture | 创建默认白色纹理
|
||||||
@@ -90,17 +112,22 @@ impl TextureManager {
|
|||||||
/// 从URL加载纹理。
|
/// 从URL加载纹理。
|
||||||
///
|
///
|
||||||
/// Note: This is an async operation. The texture will be available
|
/// 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<()> {
|
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 | 创建占位纹理
|
// Create placeholder texture | 创建占位纹理
|
||||||
let texture = self.gl
|
let texture = self.gl
|
||||||
.create_texture()
|
.create_texture()
|
||||||
.ok_or_else(|| EngineError::TextureLoadFailed("Failed to create texture".into()))?;
|
.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));
|
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(
|
let _ = self.gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array(
|
||||||
WebGl2RenderingContext::TEXTURE_2D,
|
WebGl2RenderingContext::TEXTURE_2D,
|
||||||
0,
|
0,
|
||||||
@@ -119,6 +146,10 @@ impl TextureManager {
|
|||||||
// Store texture with placeholder size | 存储带占位符尺寸的纹理
|
// Store texture with placeholder size | 存储带占位符尺寸的纹理
|
||||||
self.textures.insert(id, Texture::new(texture, 1, 1));
|
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 | 异步加载实际图片
|
// Load actual image asynchronously | 异步加载实际图片
|
||||||
let gl = self.gl.clone();
|
let gl = self.gl.clone();
|
||||||
|
|
||||||
@@ -130,6 +161,7 @@ impl TextureManager {
|
|||||||
|
|
||||||
// Clone image for use in closure | 克隆图片用于闭包
|
// Clone image for use in closure | 克隆图片用于闭包
|
||||||
let image_clone = image.clone();
|
let image_clone = image.clone();
|
||||||
|
let texture_id = id;
|
||||||
|
|
||||||
// Set up load callback | 设置加载回调
|
// Set up load callback | 设置加载回调
|
||||||
let onload = Closure::wrap(Box::new(move || {
|
let onload = Closure::wrap(Box::new(move || {
|
||||||
@@ -146,7 +178,9 @@ impl TextureManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if let Err(e) = result {
|
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 | 设置纹理参数
|
// Set texture parameters | 设置纹理参数
|
||||||
@@ -171,10 +205,22 @@ impl TextureManager {
|
|||||||
WebGl2RenderingContext::LINEAR as i32,
|
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()>);
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
image.set_onload(Some(onload.as_ref().unchecked_ref()));
|
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 | 防止闭包被销毁
|
onload.forget(); // Prevent closure from being dropped | 防止闭包被销毁
|
||||||
|
onerror.forget();
|
||||||
|
|
||||||
image.set_src(url);
|
image.set_src(url);
|
||||||
|
|
||||||
@@ -223,6 +269,56 @@ impl TextureManager {
|
|||||||
self.textures.contains_key(&id)
|
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.
|
/// Remove texture.
|
||||||
/// 移除纹理。
|
/// 移除纹理。
|
||||||
pub fn remove_texture(&mut self, id: u32) {
|
pub fn remove_texture(&mut self, id: u32) {
|
||||||
@@ -231,6 +327,8 @@ impl TextureManager {
|
|||||||
}
|
}
|
||||||
// Also remove from path mapping | 同时从路径映射中移除
|
// Also remove from path mapping | 同时从路径映射中移除
|
||||||
self.path_to_id.retain(|_, &mut v| v != id);
|
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.
|
/// Load texture by path, returning texture ID.
|
||||||
@@ -308,6 +406,9 @@ impl TextureManager {
|
|||||||
// Clear path mapping | 清除路径映射
|
// Clear path mapping | 清除路径映射
|
||||||
self.path_to_id.clear();
|
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)
|
// Reset ID counter (1 is reserved for first texture, 0 for default)
|
||||||
// 重置ID计数器(1保留给第一个纹理,0给默认纹理)
|
// 重置ID计数器(1保留给第一个纹理,0给默认纹理)
|
||||||
self.next_id = 1;
|
self.next_id = 1;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
|
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
|
||||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||||
import { TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
import { TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||||
import { AssetManagerToken } from '@esengine/asset-system';
|
import { AssetManagerToken } from '@esengine/asset-system';
|
||||||
@@ -20,7 +20,7 @@ class ParticleRuntimeModule implements IRuntimeModule {
|
|||||||
private _updateSystem: ParticleUpdateSystem | null = null;
|
private _updateSystem: ParticleUpdateSystem | null = null;
|
||||||
private _loaderRegistered = false;
|
private _loaderRegistered = false;
|
||||||
|
|
||||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
registerComponents(registry: IComponentRegistry): void {
|
||||||
registry.register(ParticleSystemComponent);
|
registry.register(ParticleSystemComponent);
|
||||||
registry.register(ClickFxComponent);
|
registry.register(ClickFxComponent);
|
||||||
}
|
}
|
||||||
@@ -73,13 +73,10 @@ class ParticleRuntimeModule implements IRuntimeModule {
|
|||||||
scene.addSystem(this._updateSystem);
|
scene.addSystem(this._updateSystem);
|
||||||
|
|
||||||
// 添加点击特效系统 | Add click FX system
|
// 添加点击特效系统 | Add click FX system
|
||||||
|
// ClickFxSystem 不再需要 AssetManager,资产由 ParticleUpdateSystem 统一加载
|
||||||
|
// ClickFxSystem no longer needs AssetManager, assets are loaded by ParticleUpdateSystem
|
||||||
const clickFxSystem = new ClickFxSystem();
|
const clickFxSystem = new ClickFxSystem();
|
||||||
|
|
||||||
// 设置资产管理器 | Set asset manager
|
|
||||||
if (assetManager) {
|
|
||||||
clickFxSystem.setAssetManager(assetManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置 EngineBridge(用于屏幕坐标转世界坐标)
|
// 设置 EngineBridge(用于屏幕坐标转世界坐标)
|
||||||
// Set EngineBridge (for screen to world coordinate conversion)
|
// Set EngineBridge (for screen to world coordinate conversion)
|
||||||
if (engineBridge) {
|
if (engineBridge) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SizeOverLifetimeModule } from './modules/SizeOverLifetimeModule';
|
|||||||
import { CollisionModule, BoundaryType, CollisionBehavior } from './modules/CollisionModule';
|
import { CollisionModule, BoundaryType, CollisionBehavior } from './modules/CollisionModule';
|
||||||
import { ForceFieldModule, ForceFieldType, type ForceField } from './modules/ForceFieldModule';
|
import { ForceFieldModule, ForceFieldType, type ForceField } from './modules/ForceFieldModule';
|
||||||
import { Physics2DCollisionModule } from './modules/Physics2DCollisionModule';
|
import { Physics2DCollisionModule } from './modules/Physics2DCollisionModule';
|
||||||
|
import { TextureSheetAnimationModule, AnimationPlayMode, AnimationLoopMode } from './modules/TextureSheetAnimationModule';
|
||||||
import type { IParticleAsset, IBurstConfig } from './loaders/ParticleLoader';
|
import type { IParticleAsset, IBurstConfig } from './loaders/ParticleLoader';
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
// Re-export for backward compatibility
|
||||||
@@ -828,6 +829,42 @@ export class ParticleSystemComponent extends Component implements ISortable {
|
|||||||
this._modules.push(forceModule);
|
this._modules.push(forceModule);
|
||||||
break;
|
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
|
// 可扩展其他模块类型 | Extensible for other module types
|
||||||
default:
|
default:
|
||||||
console.warn(`[ParticleSystem] Unknown module type: ${moduleConfig.type}`);
|
console.warn(`[ParticleSystem] Unknown module type: ${moduleConfig.type}`);
|
||||||
|
|||||||
401
packages/particle/src/__tests__/particle-e2e-test.html
Normal file
401
packages/particle/src/__tests__/particle-e2e-test.html
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Particle System End-to-End Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: monospace; background: #1a1a1a; color: #fff; padding: 20px; }
|
||||||
|
canvas { border: 1px solid #444; margin: 10px; background: #333; }
|
||||||
|
.section { margin: 20px 0; padding: 15px; background: #252525; border-radius: 8px; }
|
||||||
|
h2 { color: #8cf; margin-top: 0; }
|
||||||
|
pre { background: #333; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 11px; }
|
||||||
|
.pass { color: #2a5; }
|
||||||
|
.fail { color: #f55; }
|
||||||
|
.log { color: #aaa; font-size: 11px; }
|
||||||
|
table { border-collapse: collapse; margin: 10px 0; }
|
||||||
|
td, th { border: 1px solid #444; padding: 5px 10px; text-align: left; }
|
||||||
|
th { background: #333; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Particle System End-to-End Test</h1>
|
||||||
|
<p>This test simulates the COMPLETE particle rendering pipeline.</p>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Step 1: Test Texture</h2>
|
||||||
|
<pre>
|
||||||
|
2x2 Spritesheet (128x128 pixels):
|
||||||
|
┌───────────┬───────────┐
|
||||||
|
│ RED (0) │ GREEN (1) │ row=0, v: 0.0 - 0.5
|
||||||
|
├───────────┼───────────┤
|
||||||
|
│ BLUE (2) │ YELLOW(3) │ row=1, v: 0.5 - 1.0
|
||||||
|
└───────────┴───────────┘
|
||||||
|
</pre>
|
||||||
|
<canvas id="texturePreview" width="128" height="128"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Step 2: TextureSheetAnimationModule._setParticleUV()</h2>
|
||||||
|
<pre id="step2Log"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Step 3: ParticleRenderDataProvider._updateRenderData()</h2>
|
||||||
|
<pre id="step3Log"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Step 4: EngineRenderSystem.convertProviderDataToSprites()</h2>
|
||||||
|
<pre id="step4Log"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Step 5: sprite_batch.rs add_sprite_vertices_to_batch()</h2>
|
||||||
|
<pre id="step5Log"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Step 6: Final Rendering Result</h2>
|
||||||
|
<canvas id="mainCanvas" width="500" height="150"></canvas>
|
||||||
|
<div id="renderResult"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Test Results</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Frame</th><th>Expected</th><th>Got</th><th>Status</th></tr>
|
||||||
|
<tbody id="resultsTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Conclusion</h2>
|
||||||
|
<pre id="conclusion"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ========== Shaders ==========
|
||||||
|
const vsSource = `
|
||||||
|
attribute vec2 aPosition;
|
||||||
|
attribute vec2 aTexCoord;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
uniform mat4 uProjection;
|
||||||
|
void main() {
|
||||||
|
gl_Position = uProjection * vec4(aPosition, 0.0, 1.0);
|
||||||
|
vTexCoord = aTexCoord;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fsSource = `
|
||||||
|
precision mediump float;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
uniform sampler2D uTexture;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ========== WebGL Setup ==========
|
||||||
|
function createShader(gl, type, source) {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(gl) {
|
||||||
|
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||||
|
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||||
|
const program = gl.createProgram();
|
||||||
|
gl.attachShader(program, vs);
|
||||||
|
gl.attachShader(program, fs);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestTexture(gl) {
|
||||||
|
const texture = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0); // NO FLIP - same as engine
|
||||||
|
|
||||||
|
const data = new Uint8Array([
|
||||||
|
255, 50, 50, 255, 50, 255, 50, 255, // Row 0: Red, Green
|
||||||
|
50, 50, 255, 255, 255, 255, 50, 255 // Row 1: Blue, Yellow
|
||||||
|
]);
|
||||||
|
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Step 2: TextureSheetAnimationModule._setParticleUV ==========
|
||||||
|
function simulateTextureSheetAnimationModule(frameIndex, tilesX, tilesY) {
|
||||||
|
const col = frameIndex % tilesX;
|
||||||
|
const row = Math.floor(frameIndex / tilesX);
|
||||||
|
|
||||||
|
// This is what TextureSheetAnimationModule stores on the particle
|
||||||
|
return {
|
||||||
|
_animFrame: frameIndex,
|
||||||
|
_animTilesX: tilesX,
|
||||||
|
_animTilesY: tilesY,
|
||||||
|
col,
|
||||||
|
row
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Step 3: ParticleRenderDataProvider._updateRenderData ==========
|
||||||
|
function simulateParticleRenderDataProvider(particle, tilesX, tilesY) {
|
||||||
|
const frame = particle._animFrame;
|
||||||
|
const col = frame % tilesX;
|
||||||
|
const row = Math.floor(frame / tilesX);
|
||||||
|
const uWidth = 1 / tilesX;
|
||||||
|
const vHeight = 1 / tilesY;
|
||||||
|
|
||||||
|
// This is exactly what ParticleRenderDataProvider does
|
||||||
|
const u0 = col * uWidth;
|
||||||
|
const u1 = (col + 1) * uWidth;
|
||||||
|
const v0 = row * vHeight;
|
||||||
|
const v1 = (row + 1) * vHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
uvs: [u0, v0, u1, v1],
|
||||||
|
transforms: [0, 0, 0, 64, 64, 0.5, 0.5], // x, y, rotation, scaleX, scaleY, originX, originY
|
||||||
|
tileCount: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Step 4: EngineRenderSystem.convertProviderDataToSprites ==========
|
||||||
|
function simulateConvertProviderDataToSprites(providerData, x, y) {
|
||||||
|
const tOffset = 0;
|
||||||
|
const uvOffset = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
rotation: providerData.transforms[tOffset + 2],
|
||||||
|
scaleX: providerData.transforms[tOffset + 3],
|
||||||
|
scaleY: providerData.transforms[tOffset + 4],
|
||||||
|
originX: providerData.transforms[tOffset + 5],
|
||||||
|
originY: providerData.transforms[tOffset + 6],
|
||||||
|
uv: [
|
||||||
|
providerData.uvs[0],
|
||||||
|
providerData.uvs[1],
|
||||||
|
providerData.uvs[2],
|
||||||
|
providerData.uvs[3]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Step 5: sprite_batch.rs add_sprite_vertices_to_batch ==========
|
||||||
|
function simulateSpriteBatch(sprite, batch) {
|
||||||
|
const { x, y, scaleX: width, scaleY: height, rotation, originX, originY } = sprite;
|
||||||
|
const [u0, v0, u1, v1] = sprite.uv;
|
||||||
|
|
||||||
|
const ox = originX * width;
|
||||||
|
const oy = originY * height;
|
||||||
|
|
||||||
|
const cos = Math.cos(rotation);
|
||||||
|
const sin = Math.sin(rotation);
|
||||||
|
|
||||||
|
// Exactly as sprite_batch.rs
|
||||||
|
const corners = [
|
||||||
|
[-ox, height - oy], // 0: Top-left (high Y)
|
||||||
|
[width - ox, height - oy], // 1: Top-right
|
||||||
|
[width - ox, -oy], // 2: Bottom-right (low Y)
|
||||||
|
[-ox, -oy] // 3: Bottom-left
|
||||||
|
];
|
||||||
|
|
||||||
|
const texCoords = [
|
||||||
|
[u0, v0], // 0: Top-left vertex gets (u0, v0)
|
||||||
|
[u1, v0], // 1: Top-right vertex gets (u1, v0)
|
||||||
|
[u1, v1], // 2: Bottom-right vertex gets (u1, v1)
|
||||||
|
[u0, v1] // 3: Bottom-left vertex gets (u0, v1)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const [lx, ly] = corners[i];
|
||||||
|
const rx = lx * cos - ly * sin;
|
||||||
|
const ry = lx * sin + ly * cos;
|
||||||
|
const px = rx + x;
|
||||||
|
const py = ry + y;
|
||||||
|
|
||||||
|
batch.positions.push(px, py);
|
||||||
|
batch.texCoords.push(texCoords[i][0], texCoords[i][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = batch.vertexCount;
|
||||||
|
batch.indices.push(base, base + 1, base + 2, base + 2, base + 3, base);
|
||||||
|
batch.vertexCount += 4;
|
||||||
|
|
||||||
|
return { corners, texCoords };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Utility ==========
|
||||||
|
function colorName(r, g, b) {
|
||||||
|
if (r > 200 && g < 100 && b < 100) return 'RED';
|
||||||
|
if (r < 100 && g > 200 && b < 100) return 'GREEN';
|
||||||
|
if (r < 100 && g < 100 && b > 200) return 'BLUE';
|
||||||
|
if (r > 200 && g > 200 && b < 100) return 'YELLOW';
|
||||||
|
return `RGB(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Test ==========
|
||||||
|
function runTest() {
|
||||||
|
const tilesX = 2, tilesY = 2;
|
||||||
|
const expectedColors = ['RED', 'GREEN', 'BLUE', 'YELLOW'];
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
let step2Log = '';
|
||||||
|
let step3Log = '';
|
||||||
|
let step4Log = '';
|
||||||
|
let step5Log = '';
|
||||||
|
|
||||||
|
// Draw texture preview
|
||||||
|
const previewCanvas = document.getElementById('texturePreview');
|
||||||
|
const previewCtx = previewCanvas.getContext('2d');
|
||||||
|
previewCtx.fillStyle = '#ff3232'; previewCtx.fillRect(0, 0, 64, 64);
|
||||||
|
previewCtx.fillStyle = '#32ff32'; previewCtx.fillRect(64, 0, 64, 64);
|
||||||
|
previewCtx.fillStyle = '#3232ff'; previewCtx.fillRect(0, 64, 64, 64);
|
||||||
|
previewCtx.fillStyle = '#ffff32'; previewCtx.fillRect(64, 64, 64, 64);
|
||||||
|
previewCtx.fillStyle = '#fff'; previewCtx.font = '20px monospace';
|
||||||
|
previewCtx.fillText('0', 28, 38); previewCtx.fillText('1', 92, 38);
|
||||||
|
previewCtx.fillText('2', 28, 102); previewCtx.fillText('3', 92, 102);
|
||||||
|
|
||||||
|
// Setup WebGL
|
||||||
|
const canvas = document.getElementById('mainCanvas');
|
||||||
|
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||||
|
const program = createProgram(gl);
|
||||||
|
const texture = createTestTexture(gl);
|
||||||
|
|
||||||
|
gl.useProgram(program);
|
||||||
|
|
||||||
|
const projLoc = gl.getUniformLocation(program, 'uProjection');
|
||||||
|
const left = 0, right = 500, bottom = 0, top = 150;
|
||||||
|
const projection = new Float32Array([
|
||||||
|
2/(right-left), 0, 0, 0,
|
||||||
|
0, 2/(top-bottom), 0, 0,
|
||||||
|
0, 0, -1, 0,
|
||||||
|
-(right+left)/(right-left), -(top+bottom)/(top-bottom), 0, 1
|
||||||
|
]);
|
||||||
|
gl.uniformMatrix4fv(projLoc, false, projection);
|
||||||
|
|
||||||
|
gl.viewport(0, 0, 500, 150);
|
||||||
|
gl.clearColor(0.15, 0.15, 0.15, 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
const batch = { positions: [], texCoords: [], indices: [], vertexCount: 0 };
|
||||||
|
|
||||||
|
// Process each frame
|
||||||
|
for (let frame = 0; frame < 4; frame++) {
|
||||||
|
const x = 60 + frame * 110;
|
||||||
|
const y = 75;
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
const particleData = simulateTextureSheetAnimationModule(frame, tilesX, tilesY);
|
||||||
|
step2Log += `Frame ${frame}: _animFrame=${particleData._animFrame}, col=${particleData.col}, row=${particleData.row}\n`;
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
const providerData = simulateParticleRenderDataProvider(particleData, tilesX, tilesY);
|
||||||
|
step3Log += `Frame ${frame}: uvs=[${providerData.uvs.map(v => v.toFixed(2)).join(', ')}]\n`;
|
||||||
|
|
||||||
|
// Step 4
|
||||||
|
const sprite = simulateConvertProviderDataToSprites(providerData, x, y);
|
||||||
|
step4Log += `Frame ${frame}: uv=[${sprite.uv.map(v => v.toFixed(2)).join(', ')}], pos=(${x}, ${y})\n`;
|
||||||
|
|
||||||
|
// Step 5
|
||||||
|
const batchResult = simulateSpriteBatch(sprite, batch);
|
||||||
|
step5Log += `Frame ${frame}: vertex0(top-left)=[${batchResult.texCoords[0].map(v => v.toFixed(2)).join(', ')}], `;
|
||||||
|
step5Log += `vertex2(bottom-right)=[${batchResult.texCoords[2].map(v => v.toFixed(2)).join(', ')}]\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('step2Log').textContent = step2Log;
|
||||||
|
document.getElementById('step3Log').textContent = step3Log;
|
||||||
|
document.getElementById('step4Log').textContent = step4Log;
|
||||||
|
document.getElementById('step5Log').textContent = step5Log;
|
||||||
|
|
||||||
|
// Render
|
||||||
|
const posLoc = gl.getAttribLocation(program, 'aPosition');
|
||||||
|
const texLoc = gl.getAttribLocation(program, 'aTexCoord');
|
||||||
|
|
||||||
|
const posBuf = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.positions), gl.STATIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(posLoc);
|
||||||
|
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const texBuf = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.texCoords), gl.STATIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(texLoc);
|
||||||
|
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const idxBuf = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
|
||||||
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(batch.indices), gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
|
||||||
|
gl.drawElements(gl.TRIANGLES, batch.indices.length, gl.UNSIGNED_SHORT, 0);
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
const tableBody = document.getElementById('resultsTable');
|
||||||
|
let allPassed = true;
|
||||||
|
|
||||||
|
for (let frame = 0; frame < 4; frame++) {
|
||||||
|
const x = 60 + frame * 110;
|
||||||
|
const y = 75;
|
||||||
|
|
||||||
|
const pixels = new Uint8Array(4);
|
||||||
|
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||||
|
const actual = colorName(pixels[0], pixels[1], pixels[2]);
|
||||||
|
const expected = expectedColors[frame];
|
||||||
|
const passed = actual === expected;
|
||||||
|
|
||||||
|
if (!passed) allPassed = false;
|
||||||
|
results.push({ frame, expected, actual, passed });
|
||||||
|
|
||||||
|
tableBody.innerHTML += `
|
||||||
|
<tr class="${passed ? 'pass' : 'fail'}">
|
||||||
|
<td>Frame ${frame}</td>
|
||||||
|
<td>${expected}</td>
|
||||||
|
<td>${actual}</td>
|
||||||
|
<td>${passed ? '✓ PASS' : '✗ FAIL'}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conclusion
|
||||||
|
const conclusionEl = document.getElementById('conclusion');
|
||||||
|
if (allPassed) {
|
||||||
|
conclusionEl.innerHTML = `<span class="pass">ALL TESTS PASSED!</span>
|
||||||
|
|
||||||
|
The entire particle rendering pipeline is CORRECT:
|
||||||
|
- TextureSheetAnimationModule ✓
|
||||||
|
- ParticleRenderDataProvider ✓
|
||||||
|
- EngineRenderSystem ✓
|
||||||
|
- sprite_batch.rs ✓
|
||||||
|
|
||||||
|
If your actual particles still show wrong colors, the problem must be:
|
||||||
|
1. Your spritesheet image has a different layout
|
||||||
|
2. Something else is modifying the UV values
|
||||||
|
3. The texture is being loaded differently
|
||||||
|
|
||||||
|
Try using the test image: F:\\ecs-framework\\test_spritesheet_2x2.png`;
|
||||||
|
} else {
|
||||||
|
const failedFrames = results.filter(r => !r.passed);
|
||||||
|
conclusionEl.innerHTML = `<span class="fail">TESTS FAILED!</span>
|
||||||
|
|
||||||
|
Failed frames:
|
||||||
|
${failedFrames.map(f => `Frame ${f.frame}: expected ${f.expected}, got ${f.actual}`).join('\n')}
|
||||||
|
|
||||||
|
This indicates a bug in the rendering pipeline.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = runTest;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
328
packages/particle/src/__tests__/sprite-batch-test.html
Normal file
328
packages/particle/src/__tests__/sprite-batch-test.html
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Sprite Batch Rendering Test (模拟 sprite_batch.rs)</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: monospace; background: #1a1a1a; color: #fff; padding: 20px; }
|
||||||
|
canvas { border: 1px solid #444; margin: 10px; background: #333; }
|
||||||
|
.test-row { display: flex; align-items: flex-start; margin: 20px 0; gap: 20px; }
|
||||||
|
.info { background: #333; padding: 10px; border-radius: 4px; font-size: 12px; }
|
||||||
|
h2 { color: #8cf; }
|
||||||
|
pre { background: #333; padding: 10px; border-radius: 4px; overflow-x: auto; }
|
||||||
|
.pass { color: #2a5; }
|
||||||
|
.fail { color: #f55; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Sprite Batch Rendering Test</h1>
|
||||||
|
<p>This test simulates exactly how sprite_batch.rs renders sprites with UV coordinates.</p>
|
||||||
|
|
||||||
|
<h2>Test Texture (2x2 spritesheet)</h2>
|
||||||
|
<pre>
|
||||||
|
┌─────────┬─────────┐
|
||||||
|
│ RED (0) │ GREEN(1)│ v: 0.0 - 0.5
|
||||||
|
├─────────┼─────────┤
|
||||||
|
│ BLUE(2) │ YELLOW(3)│ v: 0.5 - 1.0
|
||||||
|
└─────────┴─────────┘
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h2>Rendering Test (same as sprite_batch.rs)</h2>
|
||||||
|
<div class="test-row">
|
||||||
|
<div>
|
||||||
|
<canvas id="mainCanvas" width="400" height="300"></canvas>
|
||||||
|
<div>Main rendering canvas</div>
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<h3>sprite_batch.rs vertex mapping:</h3>
|
||||||
|
<pre>
|
||||||
|
corners = [
|
||||||
|
(-ox, height-oy), // 0: Top-left (high Y)
|
||||||
|
(width-ox, height-oy), // 1: Top-right
|
||||||
|
(width-ox, -oy), // 2: Bottom-right (low Y)
|
||||||
|
(-ox, -oy), // 3: Bottom-left
|
||||||
|
];
|
||||||
|
|
||||||
|
tex_coords = [
|
||||||
|
[u0, v0], // 0: Top-left
|
||||||
|
[u1, v0], // 1: Top-right
|
||||||
|
[u1, v1], // 2: Bottom-right
|
||||||
|
[u0, v1], // 3: Bottom-left
|
||||||
|
];
|
||||||
|
|
||||||
|
indices = [0, 1, 2, 2, 3, 0];
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Frame-by-Frame Test Results</h2>
|
||||||
|
<div id="results"></div>
|
||||||
|
|
||||||
|
<h2>Conclusion</h2>
|
||||||
|
<pre id="conclusion"></pre>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const vsSource = `
|
||||||
|
attribute vec2 aPosition;
|
||||||
|
attribute vec2 aTexCoord;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
uniform mat4 uProjection;
|
||||||
|
void main() {
|
||||||
|
gl_Position = uProjection * vec4(aPosition, 0.0, 1.0);
|
||||||
|
vTexCoord = aTexCoord;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fsSource = `
|
||||||
|
precision mediump float;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
uniform sampler2D uTexture;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createShader(gl, type, source) {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
console.error(gl.getShaderInfoLog(shader));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(gl, vsSource, fsSource) {
|
||||||
|
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||||
|
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||||
|
const program = gl.createProgram();
|
||||||
|
gl.attachShader(program, vs);
|
||||||
|
gl.attachShader(program, fs);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestTexture(gl) {
|
||||||
|
const texture = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
|
||||||
|
// NO FLIP_Y - same as our engine
|
||||||
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
||||||
|
|
||||||
|
// 2x2 texture: Red, Green, Blue, Yellow
|
||||||
|
const data = new Uint8Array([
|
||||||
|
255, 0, 0, 255, 0, 255, 0, 255, // Row 0: Red, Green
|
||||||
|
0, 0, 255, 255, 255, 255, 0, 255 // Row 1: Blue, Yellow
|
||||||
|
]);
|
||||||
|
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate sprite_batch.rs add_sprite_vertices_to_batch
|
||||||
|
function addSpriteVertices(batch, x, y, width, height, rotation, originX, originY, u0, v0, u1, v1, color) {
|
||||||
|
const ox = originX * width;
|
||||||
|
const oy = originY * height;
|
||||||
|
|
||||||
|
const cos = Math.cos(rotation);
|
||||||
|
const sin = Math.sin(rotation);
|
||||||
|
|
||||||
|
// Same as sprite_batch.rs
|
||||||
|
const corners = [
|
||||||
|
[-ox, height - oy], // 0: Top-left
|
||||||
|
[width - ox, height - oy], // 1: Top-right
|
||||||
|
[width - ox, -oy], // 2: Bottom-right
|
||||||
|
[-ox, -oy] // 3: Bottom-left
|
||||||
|
];
|
||||||
|
|
||||||
|
const texCoords = [
|
||||||
|
[u0, v0], // 0: Top-left
|
||||||
|
[u1, v0], // 1: Top-right
|
||||||
|
[u1, v1], // 2: Bottom-right
|
||||||
|
[u0, v1] // 3: Bottom-left
|
||||||
|
];
|
||||||
|
|
||||||
|
// Transform and add vertices
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const [lx, ly] = corners[i];
|
||||||
|
|
||||||
|
// Apply rotation
|
||||||
|
const rx = lx * cos - ly * sin;
|
||||||
|
const ry = lx * sin + ly * cos;
|
||||||
|
|
||||||
|
// Apply translation
|
||||||
|
const px = rx + x;
|
||||||
|
const py = ry + y;
|
||||||
|
|
||||||
|
batch.positions.push(px, py);
|
||||||
|
batch.texCoords.push(texCoords[i][0], texCoords[i][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add indices (0, 1, 2, 2, 3, 0)
|
||||||
|
const base = batch.vertexCount;
|
||||||
|
batch.indices.push(base, base + 1, base + 2, base + 2, base + 3, base);
|
||||||
|
batch.vertexCount += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateUV(frame, tilesX, tilesY) {
|
||||||
|
const col = frame % tilesX;
|
||||||
|
const row = Math.floor(frame / tilesX);
|
||||||
|
const uWidth = 1 / tilesX;
|
||||||
|
const vHeight = 1 / tilesY;
|
||||||
|
return {
|
||||||
|
u0: col * uWidth,
|
||||||
|
v0: row * vHeight,
|
||||||
|
u1: (col + 1) * uWidth,
|
||||||
|
v1: (row + 1) * vHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorName(r, g, b) {
|
||||||
|
if (r > 200 && g < 100 && b < 100) return 'RED';
|
||||||
|
if (r < 100 && g > 200 && b < 100) return 'GREEN';
|
||||||
|
if (r < 100 && g < 100 && b > 200) return 'BLUE';
|
||||||
|
if (r > 200 && g > 200 && b < 100) return 'YELLOW';
|
||||||
|
return `RGB(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTest() {
|
||||||
|
const canvas = document.getElementById('mainCanvas');
|
||||||
|
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||||
|
|
||||||
|
if (!gl) {
|
||||||
|
document.getElementById('conclusion').textContent = 'WebGL not supported!';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = createProgram(gl, vsSource, fsSource);
|
||||||
|
const texture = createTestTexture(gl);
|
||||||
|
|
||||||
|
gl.useProgram(program);
|
||||||
|
|
||||||
|
// Set up orthographic projection (Y-up, like our engine)
|
||||||
|
const projLoc = gl.getUniformLocation(program, 'uProjection');
|
||||||
|
const left = 0, right = 400, bottom = 0, top = 300;
|
||||||
|
const projection = new Float32Array([
|
||||||
|
2/(right-left), 0, 0, 0,
|
||||||
|
0, 2/(top-bottom), 0, 0,
|
||||||
|
0, 0, -1, 0,
|
||||||
|
-(right+left)/(right-left), -(top+bottom)/(top-bottom), 0, 1
|
||||||
|
]);
|
||||||
|
gl.uniformMatrix4fv(projLoc, false, projection);
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
gl.viewport(0, 0, 400, 300);
|
||||||
|
gl.clearColor(0.2, 0.2, 0.2, 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
// Create batch
|
||||||
|
const batch = { positions: [], texCoords: [], indices: [], vertexCount: 0 };
|
||||||
|
|
||||||
|
// Add 4 sprites for 4 frames
|
||||||
|
const spriteSize = 80;
|
||||||
|
const spacing = 90;
|
||||||
|
const startX = 50;
|
||||||
|
const startY = 150;
|
||||||
|
|
||||||
|
const expectedColors = ['RED', 'GREEN', 'BLUE', 'YELLOW'];
|
||||||
|
|
||||||
|
for (let frame = 0; frame < 4; frame++) {
|
||||||
|
const uv = calculateUV(frame, 2, 2);
|
||||||
|
const x = startX + frame * spacing;
|
||||||
|
const y = startY;
|
||||||
|
|
||||||
|
addSpriteVertices(
|
||||||
|
batch,
|
||||||
|
x, y, // position
|
||||||
|
spriteSize, spriteSize, // size
|
||||||
|
0, // rotation
|
||||||
|
0.5, 0.5, // origin (center)
|
||||||
|
uv.u0, uv.v0, uv.u1, uv.v1, // UV
|
||||||
|
[1, 1, 1, 1] // color
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload and render
|
||||||
|
const posLoc = gl.getAttribLocation(program, 'aPosition');
|
||||||
|
const texLoc = gl.getAttribLocation(program, 'aTexCoord');
|
||||||
|
|
||||||
|
const posBuf = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.positions), gl.STATIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(posLoc);
|
||||||
|
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const texBuf = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.texCoords), gl.STATIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(texLoc);
|
||||||
|
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const idxBuf = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
|
||||||
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(batch.indices), gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
|
||||||
|
gl.drawElements(gl.TRIANGLES, batch.indices.length, gl.UNSIGNED_SHORT, 0);
|
||||||
|
|
||||||
|
// Read back colors and verify
|
||||||
|
const resultsDiv = document.getElementById('results');
|
||||||
|
let allPassed = true;
|
||||||
|
|
||||||
|
for (let frame = 0; frame < 4; frame++) {
|
||||||
|
const x = startX + frame * spacing;
|
||||||
|
const y = startY;
|
||||||
|
|
||||||
|
const pixels = new Uint8Array(4);
|
||||||
|
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||||
|
const actual = colorName(pixels[0], pixels[1], pixels[2]);
|
||||||
|
const expected = expectedColors[frame];
|
||||||
|
const passed = actual === expected;
|
||||||
|
|
||||||
|
if (!passed) allPassed = false;
|
||||||
|
|
||||||
|
const uv = calculateUV(frame, 2, 2);
|
||||||
|
resultsDiv.innerHTML += `
|
||||||
|
<div class="${passed ? 'pass' : 'fail'}">
|
||||||
|
Frame ${frame}: UV=[${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]
|
||||||
|
→ Expected: ${expected}, Got: ${actual} ${passed ? '✓' : '✗'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conclusionEl = document.getElementById('conclusion');
|
||||||
|
if (allPassed) {
|
||||||
|
conclusionEl.innerHTML = `
|
||||||
|
<span class="pass">ALL TESTS PASSED!</span>
|
||||||
|
|
||||||
|
The sprite_batch.rs rendering logic is CORRECT.
|
||||||
|
UV calculation is CORRECT.
|
||||||
|
|
||||||
|
If particles still show wrong frames in the actual engine, possible causes:
|
||||||
|
1. The spritesheet image layout is different (frame 0 not at top-left)
|
||||||
|
2. Image loading is flipping the texture somewhere
|
||||||
|
3. The particle system is using different UV values than expected
|
||||||
|
|
||||||
|
<b>NEXT STEP:</b> Check the actual spritesheet image in the editor.
|
||||||
|
Is frame 0 really at the top-left corner of the image?
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
conclusionEl.innerHTML = `
|
||||||
|
<span class="fail">SOME TESTS FAILED!</span>
|
||||||
|
|
||||||
|
There's a bug in the vertex/UV mapping logic.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = runTest;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
142
packages/particle/src/__tests__/uv-calculation.test.ts
Normal file
142
packages/particle/src/__tests__/uv-calculation.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* UV 计算测试
|
||||||
|
* UV Calculation Test
|
||||||
|
*
|
||||||
|
* 用于验证 TextureSheetAnimation 的 UV 坐标计算是否正确
|
||||||
|
* Used to verify TextureSheetAnimation UV coordinate calculation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟 ParticleRenderDataProvider 中的 UV 计算
|
||||||
|
* Simulate UV calculation from ParticleRenderDataProvider
|
||||||
|
*/
|
||||||
|
function calculateUV(frame: number, tilesX: number, tilesY: number) {
|
||||||
|
const col = frame % tilesX;
|
||||||
|
const row = Math.floor(frame / tilesX);
|
||||||
|
const uWidth = 1 / tilesX;
|
||||||
|
const vHeight = 1 / tilesY;
|
||||||
|
|
||||||
|
const u0 = col * uWidth;
|
||||||
|
const u1 = (col + 1) * uWidth;
|
||||||
|
const v0 = row * vHeight;
|
||||||
|
const v1 = (row + 1) * vHeight;
|
||||||
|
|
||||||
|
return { u0, v0, u1, v1, col, row };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 4x4 spritesheet (16帧)
|
||||||
|
*
|
||||||
|
* 预期布局(标准 spritesheet,从左上角开始):
|
||||||
|
* ┌────┬────┬────┬────┐
|
||||||
|
* │ 0 │ 1 │ 2 │ 3 │ row=0, v: 0.00 - 0.25
|
||||||
|
* ├────┼────┼────┼────┤
|
||||||
|
* │ 4 │ 5 │ 6 │ 7 │ row=1, v: 0.25 - 0.50
|
||||||
|
* ├────┼────┼────┼────┤
|
||||||
|
* │ 8 │ 9 │ 10 │ 11 │ row=2, v: 0.50 - 0.75
|
||||||
|
* ├────┼────┼────┼────┤
|
||||||
|
* │ 12 │ 13 │ 14 │ 15 │ row=3, v: 0.75 - 1.00
|
||||||
|
* └────┴────┴────┴────┘
|
||||||
|
*/
|
||||||
|
function test4x4Spritesheet() {
|
||||||
|
console.log('=== 4x4 Spritesheet UV Test ===\n');
|
||||||
|
|
||||||
|
const tilesX = 4;
|
||||||
|
const tilesY = 4;
|
||||||
|
|
||||||
|
console.log('Expected layout (standard spritesheet, top-left origin):');
|
||||||
|
console.log('Frame 0 should be at TOP-LEFT (v: 0.00-0.25)');
|
||||||
|
console.log('Frame 12 should be at BOTTOM-LEFT (v: 0.75-1.00)\n');
|
||||||
|
|
||||||
|
// 测试关键帧
|
||||||
|
const testFrames = [0, 1, 4, 5, 12, 15];
|
||||||
|
|
||||||
|
for (const frame of testFrames) {
|
||||||
|
const uv = calculateUV(frame, tilesX, tilesY);
|
||||||
|
console.log(`Frame ${frame.toString().padStart(2)}: col=${uv.col}, row=${uv.row}`);
|
||||||
|
console.log(` UV: [${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]`);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 2x2 spritesheet (4帧) - 最简单的情况
|
||||||
|
*/
|
||||||
|
function test2x2Spritesheet() {
|
||||||
|
console.log('=== 2x2 Spritesheet UV Test ===\n');
|
||||||
|
|
||||||
|
const tilesX = 2;
|
||||||
|
const tilesY = 2;
|
||||||
|
|
||||||
|
console.log('Layout:');
|
||||||
|
console.log('┌─────┬─────┐');
|
||||||
|
console.log('│ 0 │ 1 │ v: 0.0 - 0.5');
|
||||||
|
console.log('├─────┼─────┤');
|
||||||
|
console.log('│ 2 │ 3 │ v: 0.5 - 1.0');
|
||||||
|
console.log('└─────┴─────┘\n');
|
||||||
|
|
||||||
|
for (let frame = 0; frame < 4; frame++) {
|
||||||
|
const uv = calculateUV(frame, tilesX, tilesY);
|
||||||
|
console.log(`Frame ${frame}: col=${uv.col}, row=${uv.row}`);
|
||||||
|
console.log(` UV: [${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebGL 纹理坐标系说明
|
||||||
|
*/
|
||||||
|
function explainWebGLTextureCoords() {
|
||||||
|
console.log('=== WebGL Texture Coordinate System ===\n');
|
||||||
|
|
||||||
|
console.log('Without UNPACK_FLIP_Y_WEBGL:');
|
||||||
|
console.log('- Image row 0 (top of image file) -> stored at texture row 0');
|
||||||
|
console.log('- Texture coordinate V=0 samples texture row 0');
|
||||||
|
console.log('- Therefore: V=0 = image top, V=1 = image bottom');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('sprite_batch.rs vertex mapping:');
|
||||||
|
console.log('- Vertex 0 (top-left on screen, high Y) uses tex_coords[0] = [u0, v0]');
|
||||||
|
console.log('- Vertex 2 (bottom-right on screen, low Y) uses tex_coords[2] = [u1, v1]');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('Expected behavior:');
|
||||||
|
console.log('- Frame 0 UV [0, 0, 0.25, 0.25] should show TOP-LEFT quarter of spritesheet');
|
||||||
|
console.log('- If frame 0 shows BOTTOM-LEFT, the image is being rendered upside down');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断当前问题
|
||||||
|
*/
|
||||||
|
function diagnoseIssue() {
|
||||||
|
console.log('=== Diagnosis ===\n');
|
||||||
|
|
||||||
|
console.log('If TextureSheetAnimation shows wrong frames, check:');
|
||||||
|
console.log('');
|
||||||
|
console.log('1. Is frame 0 showing the TOP-LEFT of the spritesheet?');
|
||||||
|
console.log(' - YES: UV calculation is correct');
|
||||||
|
console.log(' - NO (shows bottom-left): Image is flipped vertically in WebGL');
|
||||||
|
console.log('');
|
||||||
|
console.log('2. Are frames playing in wrong ORDER (e.g., 3,2,1,0 instead of 0,1,2,3)?');
|
||||||
|
console.log(' - Check animation frame index calculation');
|
||||||
|
console.log('');
|
||||||
|
console.log('3. Is the spritesheet itself laid out correctly?');
|
||||||
|
console.log(' - Frame 0 should be at TOP-LEFT of the image file');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行所有测试
|
||||||
|
export function runUVTests() {
|
||||||
|
explainWebGLTextureCoords();
|
||||||
|
test2x2Spritesheet();
|
||||||
|
test4x4Spritesheet();
|
||||||
|
diagnoseIssue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果直接运行此文件
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
runUVTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { calculateUV, test2x2Spritesheet, test4x4Spritesheet };
|
||||||
278
packages/particle/src/__tests__/webgl-uv-test.html
Normal file
278
packages/particle/src/__tests__/webgl-uv-test.html
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>WebGL UV Coordinate Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: monospace; background: #1a1a1a; color: #fff; padding: 20px; }
|
||||||
|
canvas { border: 1px solid #444; margin: 10px; }
|
||||||
|
.test-row { display: flex; align-items: center; margin: 20px 0; }
|
||||||
|
.label { width: 200px; }
|
||||||
|
.result { margin-left: 20px; padding: 5px 10px; border-radius: 4px; }
|
||||||
|
.pass { background: #2a5; }
|
||||||
|
.fail { background: #a33; }
|
||||||
|
h2 { color: #8cf; }
|
||||||
|
pre { background: #333; padding: 10px; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>WebGL UV Coordinate System Test</h1>
|
||||||
|
|
||||||
|
<h2>1. Test Texture (2x2 grid)</h2>
|
||||||
|
<pre>
|
||||||
|
Image file layout (how it looks in image editor):
|
||||||
|
┌─────────┬─────────┐
|
||||||
|
│ RED (0) │ GREEN(1)│ row 0 (top of image file)
|
||||||
|
├─────────┼─────────┤
|
||||||
|
│ BLUE(2) │ YELLOW(3)│ row 1 (bottom of image file)
|
||||||
|
└─────────┴─────────┘
|
||||||
|
</pre>
|
||||||
|
<canvas id="texturePreview" width="128" height="128"></canvas>
|
||||||
|
<span>← This is the source texture</span>
|
||||||
|
|
||||||
|
<h2>2. UV Sampling Test</h2>
|
||||||
|
<p>Each square below samples a different UV region. We test what color appears.</p>
|
||||||
|
|
||||||
|
<div class="test-row">
|
||||||
|
<div class="label">UV [0, 0, 0.5, 0.5] (Frame 0):</div>
|
||||||
|
<canvas id="uv0" width="64" height="64"></canvas>
|
||||||
|
<div id="result0" class="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-row">
|
||||||
|
<div class="label">UV [0.5, 0, 1, 0.5] (Frame 1):</div>
|
||||||
|
<canvas id="uv1" width="64" height="64"></canvas>
|
||||||
|
<div id="result1" class="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-row">
|
||||||
|
<div class="label">UV [0, 0.5, 0.5, 1] (Frame 2):</div>
|
||||||
|
<canvas id="uv2" width="64" height="64"></canvas>
|
||||||
|
<div id="result2" class="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-row">
|
||||||
|
<div class="label">UV [0.5, 0.5, 1, 1] (Frame 3):</div>
|
||||||
|
<canvas id="uv3" width="64" height="64"></canvas>
|
||||||
|
<div id="result3" class="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>3. Conclusion</h2>
|
||||||
|
<pre id="conclusion"></pre>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Vertex shader
|
||||||
|
const vsSource = `
|
||||||
|
attribute vec2 aPosition;
|
||||||
|
attribute vec2 aTexCoord;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||||
|
vTexCoord = aTexCoord;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fragment shader
|
||||||
|
const fsSource = `
|
||||||
|
precision mediump float;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
uniform sampler2D uTexture;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createShader(gl, type, source) {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
console.error(gl.getShaderInfoLog(shader));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(gl) {
|
||||||
|
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||||
|
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||||
|
const program = gl.createProgram();
|
||||||
|
gl.attachShader(program, vs);
|
||||||
|
gl.attachShader(program, fs);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 2x2 test texture data
|
||||||
|
// Row 0: Red, Green
|
||||||
|
// Row 1: Blue, Yellow
|
||||||
|
function createTestTextureData() {
|
||||||
|
const size = 2;
|
||||||
|
const data = new Uint8Array(size * size * 4);
|
||||||
|
// Row 0 (will be uploaded first)
|
||||||
|
data[0] = 255; data[1] = 0; data[2] = 0; data[3] = 255; // Red
|
||||||
|
data[4] = 0; data[5] = 255; data[6] = 0; data[7] = 255; // Green
|
||||||
|
// Row 1
|
||||||
|
data[8] = 0; data[9] = 0; data[10] = 255; data[11] = 255; // Blue
|
||||||
|
data[12] = 255; data[13] = 255; data[14] = 0; data[15] = 255; // Yellow
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTexture(gl, flipY = false) {
|
||||||
|
const texture = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
|
||||||
|
// Set FLIP_Y if requested
|
||||||
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY ? 1 : 0);
|
||||||
|
|
||||||
|
const data = createTestTextureData();
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||||
|
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuad(gl, program, u0, v0, u1, v1) {
|
||||||
|
gl.useProgram(program);
|
||||||
|
|
||||||
|
const posLoc = gl.getAttribLocation(program, 'aPosition');
|
||||||
|
const texLoc = gl.getAttribLocation(program, 'aTexCoord');
|
||||||
|
|
||||||
|
// Full screen quad
|
||||||
|
const positions = new Float32Array([
|
||||||
|
-1, -1, 1, -1, -1, 1,
|
||||||
|
-1, 1, 1, -1, 1, 1
|
||||||
|
]);
|
||||||
|
|
||||||
|
// UV coordinates - map corners to the specified UV region
|
||||||
|
// Vertex order: bottom-left, bottom-right, top-left, top-left, bottom-right, top-right
|
||||||
|
const texCoords = new Float32Array([
|
||||||
|
u0, v1, u1, v1, u0, v0,
|
||||||
|
u0, v0, u1, v1, u1, v0
|
||||||
|
]);
|
||||||
|
|
||||||
|
const posBuf = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(posLoc);
|
||||||
|
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const texBuf = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(texLoc);
|
||||||
|
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasColor(canvas) {
|
||||||
|
const ctx = canvas.getContext('2d') || canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||||
|
if (ctx instanceof WebGLRenderingContext || ctx instanceof WebGL2RenderingContext) {
|
||||||
|
const pixels = new Uint8Array(4);
|
||||||
|
ctx.readPixels(32, 32, 1, 1, ctx.RGBA, ctx.UNSIGNED_BYTE, pixels);
|
||||||
|
return { r: pixels[0], g: pixels[1], b: pixels[2] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorName(r, g, b) {
|
||||||
|
if (r > 200 && g < 100 && b < 100) return 'RED';
|
||||||
|
if (r < 100 && g > 200 && b < 100) return 'GREEN';
|
||||||
|
if (r < 100 && g < 100 && b > 200) return 'BLUE';
|
||||||
|
if (r > 200 && g > 200 && b < 100) return 'YELLOW';
|
||||||
|
return `RGB(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTest() {
|
||||||
|
// Draw texture preview
|
||||||
|
const previewCanvas = document.getElementById('texturePreview');
|
||||||
|
const previewCtx = previewCanvas.getContext('2d');
|
||||||
|
previewCtx.fillStyle = 'red';
|
||||||
|
previewCtx.fillRect(0, 0, 64, 64);
|
||||||
|
previewCtx.fillStyle = 'green';
|
||||||
|
previewCtx.fillRect(64, 0, 64, 64);
|
||||||
|
previewCtx.fillStyle = 'blue';
|
||||||
|
previewCtx.fillRect(0, 64, 64, 64);
|
||||||
|
previewCtx.fillStyle = 'yellow';
|
||||||
|
previewCtx.fillRect(64, 64, 64, 64);
|
||||||
|
|
||||||
|
// Test UV regions
|
||||||
|
const uvTests = [
|
||||||
|
{ id: 'uv0', uv: [0, 0, 0.5, 0.5], expected: 'RED' },
|
||||||
|
{ id: 'uv1', uv: [0.5, 0, 1, 0.5], expected: 'GREEN' },
|
||||||
|
{ id: 'uv2', uv: [0, 0.5, 0.5, 1], expected: 'BLUE' },
|
||||||
|
{ id: 'uv3', uv: [0.5, 0.5, 1, 1], expected: 'YELLOW' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const test of uvTests) {
|
||||||
|
const canvas = document.getElementById(test.id);
|
||||||
|
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||||
|
|
||||||
|
if (!gl) {
|
||||||
|
document.getElementById('result' + test.id.slice(-1)).textContent = 'WebGL not supported';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = createProgram(gl);
|
||||||
|
const texture = createTexture(gl, false); // No FLIP_Y
|
||||||
|
|
||||||
|
gl.viewport(0, 0, 64, 64);
|
||||||
|
gl.clearColor(0, 0, 0, 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
renderQuad(gl, program, ...test.uv);
|
||||||
|
|
||||||
|
// Read back color
|
||||||
|
const pixels = new Uint8Array(4);
|
||||||
|
gl.readPixels(32, 32, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||||
|
const actual = colorName(pixels[0], pixels[1], pixels[2]);
|
||||||
|
|
||||||
|
const passed = actual === test.expected;
|
||||||
|
results.push({ test: test.id, expected: test.expected, actual, passed });
|
||||||
|
|
||||||
|
const resultEl = document.getElementById('result' + test.id.slice(-1));
|
||||||
|
resultEl.textContent = `Expected: ${test.expected}, Got: ${actual}`;
|
||||||
|
resultEl.className = 'result ' + (passed ? 'pass' : 'fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conclusion
|
||||||
|
const allPassed = results.every(r => r.passed);
|
||||||
|
const conclusionEl = document.getElementById('conclusion');
|
||||||
|
|
||||||
|
if (allPassed) {
|
||||||
|
conclusionEl.textContent = `
|
||||||
|
ALL TESTS PASSED!
|
||||||
|
|
||||||
|
UV coordinate system (without FLIP_Y):
|
||||||
|
- V=0 samples the TOP of the image (row 0)
|
||||||
|
- V=1 samples the BOTTOM of the image (row N)
|
||||||
|
|
||||||
|
This means the current particle UV calculation should be CORRECT:
|
||||||
|
v0 = row * vHeight; // row 0 -> v0=0 -> image top
|
||||||
|
v1 = (row + 1) * vHeight;
|
||||||
|
|
||||||
|
If particles still show wrong frames, the problem is elsewhere.
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const failedTests = results.filter(r => !r.passed);
|
||||||
|
conclusionEl.textContent = `
|
||||||
|
SOME TESTS FAILED!
|
||||||
|
|
||||||
|
${failedTests.map(t => `${t.test}: expected ${t.expected}, got ${t.actual}`).join('\n')}
|
||||||
|
|
||||||
|
This indicates the UV coordinate system behaves differently than expected.
|
||||||
|
The V axis may need to be flipped in particle UV calculation.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = runTest;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -194,17 +194,40 @@ export class ParticleRenderDataProvider implements IRenderDataProvider {
|
|||||||
this._transforms[tOffset + 5] = 0.5; // originX
|
this._transforms[tOffset + 5] = 0.5; // originX
|
||||||
this._transforms[tOffset + 6] = 0.5; // originY
|
this._transforms[tOffset + 6] = 0.5; // originY
|
||||||
|
|
||||||
// Texture ID: 设置为 0,让 EngineRenderSystem 通过 textureGuid 解析
|
// Texture ID: 优先使用组件上预加载的 textureId,否则让 EngineRenderSystem 通过 textureGuid 解析
|
||||||
// Set to 0, let EngineRenderSystem resolve via textureGuid
|
// Prefer using pre-loaded textureId from component, otherwise let EngineRenderSystem resolve via textureGuid
|
||||||
// 这样可以避免场景恢复后 textureId 过期导致的纹理混乱问题
|
this._textureIds[particleIndex] = component.textureId;
|
||||||
// This avoids texture confusion when textureId becomes stale after scene restore
|
|
||||||
this._textureIds[particleIndex] = 0;
|
|
||||||
|
|
||||||
// UV (full texture)
|
// UV - 支持精灵图帧动画 | Support spritesheet animation
|
||||||
this._uvs[uvOffset] = 0;
|
if (p._animTilesX !== undefined && p._animTilesY !== undefined && p._animFrame !== undefined) {
|
||||||
this._uvs[uvOffset + 1] = 0;
|
// 计算帧的 UV 坐标 | Calculate frame UV coordinates
|
||||||
this._uvs[uvOffset + 2] = 1;
|
// WebGL 纹理坐标:V=0 采样纹理行0(即图像顶部)
|
||||||
this._uvs[uvOffset + 3] = 1;
|
// 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)
|
// Color (packed ABGR for WebGL)
|
||||||
this._colors[particleIndex] = Color.packABGR(
|
this._colors[particleIndex] = Color.packABGR(
|
||||||
|
|||||||
@@ -8,10 +8,8 @@
|
|||||||
|
|
||||||
import { EntitySystem, Matcher, Entity, ECSSystem, PluginServiceRegistry, createServiceToken } from '@esengine/ecs-framework';
|
import { EntitySystem, Matcher, Entity, ECSSystem, PluginServiceRegistry, createServiceToken } from '@esengine/ecs-framework';
|
||||||
import { Input, MouseButton, TransformComponent, SortingLayers } from '@esengine/engine-core';
|
import { Input, MouseButton, TransformComponent, SortingLayers } from '@esengine/engine-core';
|
||||||
import type { IAssetManager } from '@esengine/asset-system';
|
|
||||||
import { ClickFxComponent, ClickFxTriggerMode } from '../ClickFxComponent';
|
import { ClickFxComponent, ClickFxTriggerMode } from '../ClickFxComponent';
|
||||||
import { ParticleSystemComponent, RenderSpace } from '../ParticleSystemComponent';
|
import { ParticleSystemComponent, RenderSpace } from '../ParticleSystemComponent';
|
||||||
import type { IParticleAsset } from '../loaders/ParticleLoader';
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 本地服务令牌定义 | Local Service Token Definitions
|
// 本地服务令牌定义 | Local Service Token Definitions
|
||||||
@@ -66,7 +64,6 @@ const RenderSystemToken = createServiceToken<IEngineRenderSystem>('renderSystem'
|
|||||||
export class ClickFxSystem extends EntitySystem {
|
export class ClickFxSystem extends EntitySystem {
|
||||||
private _engineBridge: IEngineBridge | null = null;
|
private _engineBridge: IEngineBridge | null = null;
|
||||||
private _renderSystem: IEngineRenderSystem | null = null;
|
private _renderSystem: IEngineRenderSystem | null = null;
|
||||||
private _assetManager: IAssetManager | null = null;
|
|
||||||
private _entitiesToDestroy: Entity[] = [];
|
private _entitiesToDestroy: Entity[] = [];
|
||||||
private _canvas: HTMLCanvasElement | null = null;
|
private _canvas: HTMLCanvasElement | null = null;
|
||||||
|
|
||||||
@@ -74,14 +71,6 @@ export class ClickFxSystem extends EntitySystem {
|
|||||||
super(Matcher.empty().all(ClickFxComponent));
|
super(Matcher.empty().all(ClickFxComponent));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置资产管理器
|
|
||||||
* Set asset manager
|
|
||||||
*/
|
|
||||||
setAssetManager(assetManager: IAssetManager | null): void {
|
|
||||||
this._assetManager = assetManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置服务注册表(用于获取 EngineBridge 和 RenderSystem)
|
* 设置服务注册表(用于获取 EngineBridge 和 RenderSystem)
|
||||||
* Set service registry (for getting EngineBridge and 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));
|
const transform = effectEntity.addComponent(new TransformComponent(screenSpaceX, screenSpaceY));
|
||||||
transform.setScale(clickFx.scale, clickFx.scale, 1);
|
transform.setScale(clickFx.scale, clickFx.scale, 1);
|
||||||
|
|
||||||
// 添加 ParticleSystem | Add ParticleSystem
|
// 创建 ParticleSystemComponent 并预先设置 GUID(在添加到实体前)
|
||||||
const particleSystem = effectEntity.addComponent(new ParticleSystemComponent());
|
// 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.particleAssetGuid = particleGuid;
|
||||||
particleSystem.autoPlay = true;
|
particleSystem.autoPlay = true;
|
||||||
// 使用 ScreenOverlay 层和屏幕空间渲染
|
// 使用 ScreenOverlay 层和屏幕空间渲染
|
||||||
@@ -349,31 +341,12 @@ export class ClickFxSystem extends EntitySystem {
|
|||||||
particleSystem.orderInLayer = 0;
|
particleSystem.orderInLayer = 0;
|
||||||
particleSystem.renderSpace = RenderSpace.Screen;
|
particleSystem.renderSpace = RenderSpace.Screen;
|
||||||
|
|
||||||
|
// 添加组件到实体(触发 ParticleUpdateSystem 的初始化和资产加载)
|
||||||
|
// Add component to entity (triggers ParticleUpdateSystem initialization and asset loading)
|
||||||
|
effectEntity.addComponent(particleSystem);
|
||||||
|
|
||||||
// 记录活跃特效 | Record active effect
|
// 记录活跃特效 | Record active effect
|
||||||
clickFx.addActiveEffect(effectEntity.id);
|
clickFx.addActiveEffect(effectEntity.id);
|
||||||
|
|
||||||
// 异步加载并播放 | Async load and play
|
|
||||||
if (this._assetManager) {
|
|
||||||
this._assetManager.loadAsset<IParticleAsset>(particleGuid).then(result => {
|
|
||||||
if (result?.asset) {
|
|
||||||
particleSystem.setAssetData(result.asset);
|
|
||||||
// 应用资产的排序属性 | Apply sorting properties from asset
|
|
||||||
if (result.asset.sortingLayer) {
|
|
||||||
particleSystem.sortingLayer = result.asset.sortingLayer;
|
|
||||||
}
|
|
||||||
if (result.asset.orderInLayer !== undefined) {
|
|
||||||
particleSystem.orderInLayer = result.asset.orderInLayer;
|
|
||||||
}
|
|
||||||
particleSystem.play();
|
|
||||||
} else {
|
|
||||||
console.warn(`[ClickFxSystem] Failed to load particle asset: ${particleGuid}`);
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
console.error(`[ClickFxSystem] Error loading particle asset ${particleGuid}:`, error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn('[ClickFxSystem] AssetManager not set, cannot load particle asset');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -185,6 +185,11 @@ export class ParticleUpdateSystem extends EntitySystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果正在初始化中,跳过处理 | Skip processing if initializing
|
||||||
|
if (this._loadingComponents.has(particle)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 检测资产 GUID 变化并重新加载 | Detect asset GUID change and reload
|
// 检测资产 GUID 变化并重新加载 | Detect asset GUID change and reload
|
||||||
// 这使得编辑器中选择新的粒子资产时能够立即切换
|
// 这使得编辑器中选择新的粒子资产时能够立即切换
|
||||||
// This allows immediate switching when selecting a new particle asset in the editor
|
// 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);
|
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);
|
this.loadParticleTexture(particle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,56 +268,65 @@ export class ParticleUpdateSystem extends EntitySystem {
|
|||||||
* Async initialize particle system
|
* Async initialize particle system
|
||||||
*/
|
*/
|
||||||
private async _initializeParticle(entity: Entity, particle: ParticleSystemComponent): Promise<void> {
|
private async _initializeParticle(entity: Entity, particle: ParticleSystemComponent): Promise<void> {
|
||||||
// 如果有资产 GUID,先加载资产 | Load asset first if GUID is set
|
// 标记为正在初始化,防止 process 中重复调用 loadParticleTexture
|
||||||
if (particle.particleAssetGuid) {
|
// Mark as initializing to prevent duplicate loadParticleTexture calls in process
|
||||||
const asset = await this._loadParticleAsset(particle.particleAssetGuid);
|
this._loadingComponents.add(particle);
|
||||||
if (asset) {
|
|
||||||
particle.setAssetData(asset);
|
try {
|
||||||
// 应用资产的排序属性 | Apply sorting properties from asset
|
// 如果有资产 GUID,先加载资产 | Load asset first if GUID is set
|
||||||
if (asset.sortingLayer) {
|
if (particle.particleAssetGuid) {
|
||||||
particle.sortingLayer = asset.sortingLayer;
|
const asset = await this._loadParticleAsset(particle.particleAssetGuid);
|
||||||
}
|
if (asset) {
|
||||||
if (asset.orderInLayer !== undefined) {
|
particle.setAssetData(asset);
|
||||||
particle.orderInLayer = asset.orderInLayer;
|
// 应用资产的排序属性 | 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)
|
// Initialize particle system (don't auto play, controlled by logic below)
|
||||||
particle.ensureBuilt();
|
particle.ensureBuilt();
|
||||||
|
|
||||||
// 加载纹理 | Load texture
|
// 加载纹理 | Load texture
|
||||||
await this.loadParticleTexture(particle);
|
await this.loadParticleTexture(particle);
|
||||||
|
|
||||||
// 注册到渲染数据提供者 | Register to render data provider
|
// 注册到渲染数据提供者 | Register to render data provider
|
||||||
// 尝试获取 Transform,如果没有则使用默认位置 | Try to get Transform, use default position if not available
|
// 尝试获取 Transform,如果没有则使用默认位置 | Try to get Transform, use default position if not available
|
||||||
let transform: ITransformComponent | null = null;
|
let transform: ITransformComponent | null = null;
|
||||||
if (this._transformType) {
|
if (this._transformType) {
|
||||||
transform = entity.getComponent(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();
|
|
||||||
}
|
}
|
||||||
|
// 即使没有 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 currentGuid = particle.particleAssetGuid;
|
||||||
const lastGuid = this._lastLoadedGuids.get(particle);
|
const lastGuid = this._lastLoadedGuids.get(particle);
|
||||||
|
|
||||||
// 如果 GUID 没有变化,或者正在加载中,跳过
|
// 如果正在加载中,跳过
|
||||||
// Skip if GUID hasn't changed or already loading
|
// Skip if already loading
|
||||||
if (currentGuid === lastGuid || this._loadingComponents.has(particle)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,35 +441,70 @@ export class ParticleUpdateSystem extends EntitySystem {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ParticleUpdateSystem] Failed to load texture by GUID:', textureGuid, error);
|
console.error('[ParticleUpdateSystem] Failed to load texture by GUID:', textureGuid, error);
|
||||||
// 加载失败时使用默认纹理 | Use default texture on load failure
|
// 加载失败时使用默认纹理 | Use default texture on load failure
|
||||||
await this._ensureDefaultTexture();
|
const loaded = await this._ensureDefaultTexture();
|
||||||
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
if (loaded) {
|
||||||
|
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有纹理 GUID 时使用默认粒子纹理 | Use default particle texture when no GUID
|
// 没有纹理 GUID 时使用默认粒子纹理 | Use default particle texture when no GUID
|
||||||
await this._ensureDefaultTexture();
|
const loaded = await this._ensureDefaultTexture();
|
||||||
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
if (loaded) {
|
||||||
|
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确保默认粒子纹理已加载
|
* 确保默认粒子纹理已加载
|
||||||
* Ensure default particle texture is loaded
|
* 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> {
|
private async _ensureDefaultTexture(): Promise<boolean> {
|
||||||
if (this._defaultTextureLoaded || this._defaultTextureLoading) return;
|
// 已加载过 | Already loaded
|
||||||
if (!this._engineBridge) return;
|
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;
|
this._defaultTextureLoading = true;
|
||||||
try {
|
try {
|
||||||
const dataUrl = generateDefaultParticleTextureDataURL();
|
const dataUrl = generateDefaultParticleTextureDataURL();
|
||||||
if (dataUrl) {
|
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;
|
this._defaultTextureLoaded = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ParticleUpdateSystem] Failed to create default particle texture:', error);
|
console.error('[ParticleUpdateSystem] Failed to create default particle texture:', error);
|
||||||
}
|
}
|
||||||
this._defaultTextureLoading = false;
|
this._defaultTextureLoading = false;
|
||||||
|
return this._defaultTextureLoaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onRemoved(entity: Entity): void {
|
protected override onRemoved(entity: Entity): void {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* 用于编辑器中的组件序列化/反序列化
|
* 用于编辑器中的组件序列化/反序列化
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
import type { IComponentRegistry } from '@esengine/ecs-framework';
|
||||||
import type { IRuntimeModule } from '@esengine/engine-core';
|
import type { IRuntimeModule } from '@esengine/engine-core';
|
||||||
|
|
||||||
// Components (no WASM dependency)
|
// Components (no WASM dependency)
|
||||||
@@ -26,8 +26,9 @@ import { PolygonCollider2DComponent } from './components/PolygonCollider2DCompon
|
|||||||
export class Physics2DComponentsModule implements IRuntimeModule {
|
export class Physics2DComponentsModule implements IRuntimeModule {
|
||||||
/**
|
/**
|
||||||
* 注册组件到 ComponentRegistry
|
* 注册组件到 ComponentRegistry
|
||||||
|
* Register components to ComponentRegistry
|
||||||
*/
|
*/
|
||||||
registerComponents(registry: typeof ComponentRegistry): void {
|
registerComponents(registry: IComponentRegistry): void {
|
||||||
registry.register(Rigidbody2DComponent);
|
registry.register(Rigidbody2DComponent);
|
||||||
registry.register(BoxCollider2DComponent);
|
registry.register(BoxCollider2DComponent);
|
||||||
registry.register(CircleCollider2DComponent);
|
registry.register(CircleCollider2DComponent);
|
||||||
|
|||||||
@@ -4,11 +4,14 @@
|
|||||||
* 编辑器版本的物理插件,不包含 WASM 依赖。
|
* 编辑器版本的物理插件,不包含 WASM 依赖。
|
||||||
* Editor version of physics plugin, without WASM dependencies.
|
* Editor version of physics plugin, without WASM dependencies.
|
||||||
*
|
*
|
||||||
* 用于编辑器中注册插件清单,但不创建运行时模块。
|
* 使用轻量级 Physics2DComponentsModule 注册组件,
|
||||||
* 运行时使用 PhysicsPlugin from '@esengine/physics-rapier2d/runtime'
|
* 使场景中的物理组件可以正确序列化/反序列化。
|
||||||
|
* Uses lightweight Physics2DComponentsModule to register components,
|
||||||
|
* enabling proper serialization/deserialization of physics components in scenes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IRuntimePlugin, ModuleManifest } from '@esengine/engine-core';
|
import type { IRuntimePlugin, ModuleManifest } from '@esengine/engine-core';
|
||||||
|
import { Physics2DComponentsModule } from './Physics2DComponentsModule';
|
||||||
|
|
||||||
const manifest: ModuleManifest = {
|
const manifest: ModuleManifest = {
|
||||||
id: '@esengine/physics-rapier2d',
|
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 = {
|
export const Physics2DPlugin: IRuntimePlugin = {
|
||||||
manifest
|
manifest,
|
||||||
// No runtime module - editor doesn't need physics simulation
|
runtimeModule: new Physics2DComponentsModule()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
* 提供 Rapier2D 物理引擎的 ECS 集成
|
* 提供 Rapier2D 物理引擎的 ECS 集成
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
|
||||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
|
||||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||||
import { WasmLibraryLoaderFactory } from '@esengine/platform-common';
|
import { WasmLibraryLoaderFactory } from '@esengine/platform-common';
|
||||||
import type * as RAPIER from '@esengine/rapier2d';
|
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(Rigidbody2DComponent);
|
||||||
registry.register(BoxCollider2DComponent);
|
registry.register(BoxCollider2DComponent);
|
||||||
registry.register(CircleCollider2DComponent);
|
registry.register(CircleCollider2DComponent);
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ import { isEditorEnvironment } from '@esengine/platform-common';
|
|||||||
/**
|
/**
|
||||||
* 获取 WASM 路径
|
* 获取 WASM 路径
|
||||||
* Get WASM path based on environment
|
* 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 {
|
function getWasmPath(): string {
|
||||||
const isEditor = isEditorEnvironment();
|
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
|
const path = isEditor
|
||||||
? 'engine/physics-rapier2d/rapier_wasm2d_bg.wasm'
|
? 'engine/rapier2d/pkg/rapier_wasm2d_bg.wasm'
|
||||||
: 'wasm/rapier_wasm2d_bg.wasm';
|
: 'wasm/rapier_wasm2d_bg.wasm';
|
||||||
|
|
||||||
console.log(`[Rapier2D] isEditor=${isEditor}, wasmPath=${path}`);
|
console.log(`[Rapier2D] isEditor=${isEditor}, wasmPath=${path}`);
|
||||||
@@ -32,7 +37,7 @@ export const Rapier2DLoaderConfig: WasmLibraryConfig = {
|
|||||||
web: {
|
web: {
|
||||||
/**
|
/**
|
||||||
* WASM 文件路径
|
* WASM 文件路径
|
||||||
* 编辑器: engine/physics-rapier2d/rapier_wasm2d_bg.wasm
|
* 编辑器: engine/rapier2d/pkg/rapier_wasm2d_bg.wasm
|
||||||
* 运行时: wasm/rapier_wasm2d_bg.wasm
|
* 运行时: wasm/rapier_wasm2d_bg.wasm
|
||||||
*/
|
*/
|
||||||
get wasmPath(): string {
|
get wasmPath(): string {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
BrowserPlatformAdapter,
|
BrowserPlatformAdapter,
|
||||||
runtimePluginManager,
|
runtimePluginManager,
|
||||||
BrowserFileSystemService,
|
BrowserFileSystemService,
|
||||||
type IPlugin
|
RuntimeSceneManager,
|
||||||
|
RuntimeSceneManagerToken,
|
||||||
|
type IPlugin,
|
||||||
|
type IRuntimeSceneManager
|
||||||
} from '@esengine/runtime-core';
|
} from '@esengine/runtime-core';
|
||||||
import { isValidGUID, type IAssetManager } from '@esengine/asset-system';
|
import { isValidGUID, type IAssetManager } from '@esengine/asset-system';
|
||||||
import { BrowserAssetReader } from './BrowserAssetReader';
|
import { BrowserAssetReader } from './BrowserAssetReader';
|
||||||
@@ -55,6 +58,7 @@ export class BrowserRuntime {
|
|||||||
private _assetBaseUrl: string;
|
private _assetBaseUrl: string;
|
||||||
private _fileSystem: BrowserFileSystemService | null = null;
|
private _fileSystem: BrowserFileSystemService | null = null;
|
||||||
private _assetReader: BrowserAssetReader | null = null;
|
private _assetReader: BrowserAssetReader | null = null;
|
||||||
|
private _sceneManager: RuntimeSceneManager | null = null;
|
||||||
private _initialized = false;
|
private _initialized = false;
|
||||||
|
|
||||||
constructor(config: RuntimeConfig) {
|
constructor(config: RuntimeConfig) {
|
||||||
@@ -164,10 +168,60 @@ export class BrowserRuntime {
|
|||||||
// 为渲染系统设置资产路径解析器
|
// 为渲染系统设置资产路径解析器
|
||||||
this._setupAssetPathResolver();
|
this._setupAssetPathResolver();
|
||||||
|
|
||||||
|
// Initialize scene manager
|
||||||
|
// 初始化场景管理器
|
||||||
|
this._initializeSceneManager();
|
||||||
|
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
console.log('[Runtime] Initialized');
|
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
|
* Set up asset path resolver for the render system
|
||||||
* 为渲染系统设置资产路径解析器
|
* 为渲染系统设置资产路径解析器
|
||||||
@@ -226,12 +280,21 @@ export class BrowserRuntime {
|
|||||||
/**
|
/**
|
||||||
* Load a scene from URL
|
* Load a scene from URL
|
||||||
* 从 URL 加载场景
|
* 从 URL 加载场景
|
||||||
|
*
|
||||||
|
* @param sceneUrl 场景 URL 或名称 | Scene URL or name
|
||||||
*/
|
*/
|
||||||
async loadScene(sceneUrl: string): Promise<void> {
|
async loadScene(sceneUrl: string): Promise<void> {
|
||||||
if (!this._runtime) {
|
if (!this._runtime) {
|
||||||
throw new Error('Runtime not initialized. Call initialize() first.');
|
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;
|
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
|
* Check if runtime is initialized
|
||||||
* 检查运行时是否已初始化
|
* 检查运行时是否已初始化
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ export { default } from './BrowserRuntime';
|
|||||||
// Asset reader
|
// Asset reader
|
||||||
export { BrowserAssetReader } from './BrowserAssetReader';
|
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
|
// Web Platform Subsystems
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -879,11 +879,10 @@ export class GameRuntime {
|
|||||||
* Save scene snapshot
|
* Save scene snapshot
|
||||||
*
|
*
|
||||||
* 使用二进制格式提升序列化性能,并支持 EntityRef 的正确序列化。
|
* 使用二进制格式提升序列化性能,并支持 EntityRef 的正确序列化。
|
||||||
* 在保存前清除纹理缓存,确保恢复时能够从干净状态重新加载纹理。
|
* 使用路径稳定 ID 后,不再需要清除纹理缓存。
|
||||||
*
|
*
|
||||||
* Uses binary format for better serialization performance and supports proper
|
* Uses binary format for better serialization performance and supports proper
|
||||||
* EntityRef serialization. Clears texture cache before saving to ensure
|
* EntityRef serialization. With path-stable IDs, no need to clear texture cache.
|
||||||
* clean reload on restore.
|
|
||||||
*
|
*
|
||||||
* @param options 可选配置
|
* @param options 可选配置
|
||||||
* @param options.useJson 是否使用 JSON 格式(用于调试),默认 false 使用二进制
|
* @param options.useJson 是否使用 JSON 格式(用于调试),默认 false 使用二进制
|
||||||
@@ -895,13 +894,10 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 清除所有纹理缓存(确保恢复时重新加载)
|
// 使用路径稳定 ID 后,不再清除纹理缓存
|
||||||
// Clear all texture caches (ensures reload on restore)
|
// 组件保存的 textureId 在 Play/Stop 后仍然有效
|
||||||
// clearTextureMappings() 内部会同时清除 Rust 层和 JS 层的纹理缓存
|
// With path-stable IDs, no longer clear texture cache
|
||||||
// clearTextureMappings() internally clears both Rust and JS layer texture caches
|
// Component's saved textureId remains valid after Play/Stop
|
||||||
if (this._engineIntegration) {
|
|
||||||
this._engineIntegration.clearTextureMappings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用二进制格式提升性能(默认)或 JSON 用于调试
|
// 使用二进制格式提升性能(默认)或 JSON 用于调试
|
||||||
// Use binary format for performance (default) or JSON for debugging
|
// Use binary format for performance (default) or JSON for debugging
|
||||||
@@ -927,9 +923,15 @@ export class GameRuntime {
|
|||||||
* 1. 创建所有实体和组件
|
* 1. 创建所有实体和组件
|
||||||
* 2. 解析所有 EntityRef 引用
|
* 2. 解析所有 EntityRef 引用
|
||||||
*
|
*
|
||||||
|
* 使用路径稳定 ID 后,不再需要清除纹理缓存。
|
||||||
|
* 组件保存的 textureId 在恢复后仍然有效。
|
||||||
|
*
|
||||||
* Uses two-phase deserialization to ensure EntityRef references are properly restored:
|
* Uses two-phase deserialization to ensure EntityRef references are properly restored:
|
||||||
* 1. Create all entities and components
|
* 1. Create all entities and components
|
||||||
* 2. Resolve all EntityRef references
|
* 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> {
|
async restoreSceneSnapshot(): Promise<boolean> {
|
||||||
if (!this._scene || !this._sceneSnapshot) {
|
if (!this._scene || !this._sceneSnapshot) {
|
||||||
@@ -938,19 +940,17 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 清除缓存
|
// 清除 Tilemap 缓存(Tilemap 使用独立的缓存机制)
|
||||||
|
// Clear Tilemap cache (Tilemap uses its own cache mechanism)
|
||||||
const tilemapSystem = this._systemContext?.services.get(TilemapSystemToken);
|
const tilemapSystem = this._systemContext?.services.get(TilemapSystemToken);
|
||||||
if (tilemapSystem) {
|
if (tilemapSystem) {
|
||||||
tilemapSystem.clearCache?.();
|
tilemapSystem.clearCache?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除所有纹理并重置状态(修复 Play/Stop 后纹理 ID 混乱的问题)
|
// 使用路径稳定 ID 后,不再清除纹理缓存
|
||||||
// Clear all textures and reset state (fixes texture ID confusion after Play/Stop)
|
// 组件保存的 textureId 在 Play/Stop 后仍然有效
|
||||||
// clearTextureMappings() 内部会同时清除 Rust 层和 JS 层的纹理缓存
|
// With path-stable IDs, no longer clear texture cache
|
||||||
// clearTextureMappings() internally clears both Rust and JS layer texture caches
|
// Component's saved textureId remains valid after Play/Stop
|
||||||
if (this._engineIntegration) {
|
|
||||||
this._engineIntegration.clearTextureMappings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 反序列化场景(SceneSerializer 内部使用 SerializationContext 处理 EntityRef)
|
// 反序列化场景(SceneSerializer 内部使用 SerializationContext 处理 EntityRef)
|
||||||
// Deserialize scene (SceneSerializer internally uses SerializationContext for EntityRef)
|
// Deserialize scene (SceneSerializer internally uses SerializationContext for EntityRef)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* 运行时插件管理器
|
* 运行时插件管理器
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ComponentRegistry, ServiceContainer } from '@esengine/ecs-framework';
|
import { GlobalComponentRegistry, ServiceContainer } from '@esengine/ecs-framework';
|
||||||
import type { IScene } from '@esengine/ecs-framework';
|
import type { IScene } from '@esengine/ecs-framework';
|
||||||
import type { IRuntimePlugin, IRuntimeModule, SystemContext, ModuleManifest } from '@esengine/engine-core';
|
import type { IRuntimePlugin, IRuntimeModule, SystemContext, ModuleManifest } from '@esengine/engine-core';
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export class RuntimePluginManager {
|
|||||||
const mod = plugin.runtimeModule;
|
const mod = plugin.runtimeModule;
|
||||||
if (mod?.registerComponents) {
|
if (mod?.registerComponents) {
|
||||||
try {
|
try {
|
||||||
mod.registerComponents(ComponentRegistry);
|
mod.registerComponents(GlobalComponentRegistry);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[PluginManager] Failed to register components for ${id}:`, e);
|
console.error(`[PluginManager] Failed to register components for ${id}:`, e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ export {
|
|||||||
type BrowserFileSystemOptions
|
type BrowserFileSystemOptions
|
||||||
} from './services/BrowserFileSystemService';
|
} 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)
|
// Re-export catalog types from asset-system (canonical source)
|
||||||
// 从 asset-system 重新导出目录类型(规范来源)
|
// 从 asset-system 重新导出目录类型(规范来源)
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
391
packages/runtime-core/src/services/RuntimeSceneManager.ts
Normal file
391
packages/runtime-core/src/services/RuntimeSceneManager.ts
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
/**
|
||||||
|
* 运行时场景管理器
|
||||||
|
* Runtime Scene Manager
|
||||||
|
*
|
||||||
|
* 提供场景加载和切换 API,供用户脚本使用
|
||||||
|
* Provides scene loading and transition API for user scripts
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // 在用户脚本中获取场景管理器
|
||||||
|
* // Get scene manager in user script
|
||||||
|
* const sceneManager = services.get(RuntimeSceneManagerToken);
|
||||||
|
*
|
||||||
|
* // 加载场景(按名称)
|
||||||
|
* // Load scene by name
|
||||||
|
* await sceneManager.loadScene('GameScene');
|
||||||
|
*
|
||||||
|
* // 加载场景(按路径)
|
||||||
|
* // Load scene by path
|
||||||
|
* await sceneManager.loadSceneByPath('./scenes/Level1.ecs');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServiceToken } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景信息
|
||||||
|
* Scene info
|
||||||
|
*/
|
||||||
|
export interface SceneInfo {
|
||||||
|
/** 场景名称 | Scene name */
|
||||||
|
name: string;
|
||||||
|
/** 场景路径(相对于构建输出目录)| Scene path (relative to build output) */
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景加载选项
|
||||||
|
* Scene load options
|
||||||
|
*/
|
||||||
|
export interface SceneLoadOptions {
|
||||||
|
/**
|
||||||
|
* 是否显示加载界面
|
||||||
|
* Whether to show loading screen
|
||||||
|
*/
|
||||||
|
showLoading?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过渡效果类型
|
||||||
|
* Transition effect type
|
||||||
|
*/
|
||||||
|
transition?: 'none' | 'fade' | 'slide';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过渡持续时间(毫秒)
|
||||||
|
* Transition duration in milliseconds
|
||||||
|
*/
|
||||||
|
transitionDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景加载器函数类型
|
||||||
|
* Scene loader function type
|
||||||
|
*/
|
||||||
|
export type SceneLoader = (url: string) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时场景管理器接口
|
||||||
|
* Runtime Scene Manager Interface
|
||||||
|
*
|
||||||
|
* 继承 IService 的 dispose 模式以兼容 ServiceContainer。
|
||||||
|
* Follows IService dispose pattern for ServiceContainer compatibility.
|
||||||
|
*/
|
||||||
|
export interface IRuntimeSceneManager {
|
||||||
|
/**
|
||||||
|
* 获取当前场景名称
|
||||||
|
* Get current scene name
|
||||||
|
*/
|
||||||
|
readonly currentSceneName: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可用场景列表
|
||||||
|
* Get available scene list
|
||||||
|
*/
|
||||||
|
readonly availableScenes: readonly SceneInfo[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否正在加载场景
|
||||||
|
* Whether a scene is currently loading
|
||||||
|
*/
|
||||||
|
readonly isLoading: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册可用场景
|
||||||
|
* Register available scenes
|
||||||
|
*/
|
||||||
|
registerScenes(scenes: SceneInfo[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按名称加载场景
|
||||||
|
* Load scene by name
|
||||||
|
*/
|
||||||
|
loadScene(sceneName: string, options?: SceneLoadOptions): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按路径加载场景
|
||||||
|
* Load scene by path
|
||||||
|
*/
|
||||||
|
loadSceneByPath(path: string, options?: SceneLoadOptions): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新加载当前场景
|
||||||
|
* Reload current scene
|
||||||
|
*/
|
||||||
|
reloadCurrentScene(options?: SceneLoadOptions): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加场景加载开始监听器
|
||||||
|
* Add scene load start listener
|
||||||
|
*/
|
||||||
|
onLoadStart(callback: (sceneName: string) => void): () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加场景加载完成监听器
|
||||||
|
* Add scene load complete listener
|
||||||
|
*/
|
||||||
|
onLoadComplete(callback: (sceneName: string) => void): () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加场景加载错误监听器
|
||||||
|
* Add scene load error listener
|
||||||
|
*/
|
||||||
|
onLoadError(callback: (error: Error, sceneName: string) => void): () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放资源(IService 兼容)
|
||||||
|
* Dispose resources (IService compatible)
|
||||||
|
*/
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时场景管理器服务令牌
|
||||||
|
* Runtime Scene Manager Service Token
|
||||||
|
*/
|
||||||
|
export const RuntimeSceneManagerToken = createServiceToken<IRuntimeSceneManager>('runtimeSceneManager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时场景管理器实现
|
||||||
|
* Runtime Scene Manager Implementation
|
||||||
|
*
|
||||||
|
* 实现 IService 接口以兼容 ServiceContainer。
|
||||||
|
* Implements IService for ServiceContainer compatibility.
|
||||||
|
*/
|
||||||
|
export class RuntimeSceneManager implements IRuntimeSceneManager {
|
||||||
|
private _scenes = new Map<string, SceneInfo>();
|
||||||
|
private _currentSceneName: string | null = null;
|
||||||
|
private _currentScenePath: string | null = null;
|
||||||
|
private _isLoading = false;
|
||||||
|
private _sceneLoader: SceneLoader | null = null;
|
||||||
|
private _baseUrl: string;
|
||||||
|
private _disposed = false;
|
||||||
|
|
||||||
|
// 事件监听器 | Event listeners
|
||||||
|
private _loadStartListeners = new Set<(sceneName: string) => void>();
|
||||||
|
private _loadCompleteListeners = new Set<(sceneName: string) => void>();
|
||||||
|
private _loadErrorListeners = new Set<(error: Error, sceneName: string) => void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建运行时场景管理器
|
||||||
|
* Create runtime scene manager
|
||||||
|
*
|
||||||
|
* @param sceneLoader 场景加载函数 | Scene loader function
|
||||||
|
* @param baseUrl 场景文件基础 URL | Scene files base URL
|
||||||
|
*/
|
||||||
|
constructor(sceneLoader: SceneLoader, baseUrl: string = './scenes') {
|
||||||
|
this._sceneLoader = sceneLoader;
|
||||||
|
this._baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentSceneName(): string | null {
|
||||||
|
return this._currentSceneName;
|
||||||
|
}
|
||||||
|
|
||||||
|
get availableScenes(): readonly SceneInfo[] {
|
||||||
|
return Array.from(this._scenes.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this._isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置场景加载器
|
||||||
|
* Set scene loader
|
||||||
|
*/
|
||||||
|
setSceneLoader(loader: SceneLoader): void {
|
||||||
|
this._sceneLoader = loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置基础 URL
|
||||||
|
* Set base URL
|
||||||
|
*/
|
||||||
|
setBaseUrl(baseUrl: string): void {
|
||||||
|
this._baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerScenes(scenes: SceneInfo[]): void {
|
||||||
|
for (const scene of scenes) {
|
||||||
|
this._scenes.set(scene.name, scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从目录或配置自动发现场景
|
||||||
|
* Auto-discover scenes from catalog or config
|
||||||
|
*/
|
||||||
|
registerScenesFromCatalog(
|
||||||
|
catalog: { scenes?: Array<{ name: string; path: string }> }
|
||||||
|
): void {
|
||||||
|
if (catalog.scenes) {
|
||||||
|
this.registerScenes(catalog.scenes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadScene(sceneName: string, options?: SceneLoadOptions): Promise<void> {
|
||||||
|
const sceneInfo = this._scenes.get(sceneName);
|
||||||
|
if (!sceneInfo) {
|
||||||
|
// 尝试使用场景名作为路径
|
||||||
|
// Try using scene name as path
|
||||||
|
const guessedPath = `${this._baseUrl}/${sceneName}.ecs`;
|
||||||
|
return this.loadSceneByPath(guessedPath, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loadSceneByPath(sceneInfo.path, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSceneByPath(path: string, options?: SceneLoadOptions): Promise<void> {
|
||||||
|
if (!this._sceneLoader) {
|
||||||
|
throw new Error('[RuntimeSceneManager] Scene loader not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._isLoading) {
|
||||||
|
console.warn('[RuntimeSceneManager] Scene is already loading, ignoring request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整 URL | Build full URL
|
||||||
|
// Check if path is already absolute (http, relative ./, Unix /, or Windows drive letter)
|
||||||
|
// 检查路径是否已经是绝对路径(http、相对 ./、Unix /、或 Windows 驱动器号)
|
||||||
|
let fullPath = path;
|
||||||
|
const isAbsolutePath = path.startsWith('http') ||
|
||||||
|
path.startsWith('./') ||
|
||||||
|
path.startsWith('/') ||
|
||||||
|
(path.length > 1 && path[1] === ':'); // Windows absolute path like C:\ or F:\
|
||||||
|
|
||||||
|
if (!isAbsolutePath) {
|
||||||
|
fullPath = `${this._baseUrl}/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取场景名称 | Extract scene name
|
||||||
|
const sceneName = this._extractSceneName(path);
|
||||||
|
|
||||||
|
this._isLoading = true;
|
||||||
|
this._notifyLoadStart(sceneName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: 实现过渡效果 | TODO: Implement transition effects
|
||||||
|
// if (options?.transition && options.transition !== 'none') {
|
||||||
|
// await this._startTransition(options.transition, options.transitionDuration);
|
||||||
|
// }
|
||||||
|
|
||||||
|
await this._sceneLoader(fullPath);
|
||||||
|
|
||||||
|
this._currentSceneName = sceneName;
|
||||||
|
this._currentScenePath = fullPath;
|
||||||
|
this._isLoading = false;
|
||||||
|
|
||||||
|
this._notifyLoadComplete(sceneName);
|
||||||
|
|
||||||
|
console.log(`[RuntimeSceneManager] Scene loaded: ${sceneName}`);
|
||||||
|
} catch (error) {
|
||||||
|
this._isLoading = false;
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this._notifyLoadError(err, sceneName);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadCurrentScene(options?: SceneLoadOptions): Promise<void> {
|
||||||
|
if (!this._currentScenePath) {
|
||||||
|
throw new Error('[RuntimeSceneManager] No current scene to reload');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loadSceneByPath(this._currentScenePath, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoadStart(callback: (sceneName: string) => void): () => void {
|
||||||
|
this._loadStartListeners.add(callback);
|
||||||
|
return () => this._loadStartListeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoadComplete(callback: (sceneName: string) => void): () => void {
|
||||||
|
this._loadCompleteListeners.add(callback);
|
||||||
|
return () => this._loadCompleteListeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoadError(callback: (error: Error, sceneName: string) => void): () => void {
|
||||||
|
this._loadErrorListeners.add(callback);
|
||||||
|
return () => this._loadErrorListeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查场景是否已注册
|
||||||
|
* Check if scene is registered
|
||||||
|
*/
|
||||||
|
hasScene(sceneName: string): boolean {
|
||||||
|
return this._scenes.has(sceneName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取场景路径
|
||||||
|
* Get scene path
|
||||||
|
*/
|
||||||
|
getScenePath(sceneName: string): string | null {
|
||||||
|
return this._scenes.get(sceneName)?.path ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 私有方法 | Private Methods ====================
|
||||||
|
|
||||||
|
private _extractSceneName(path: string): string {
|
||||||
|
// 从路径中提取场景名称 | Extract scene name from path
|
||||||
|
// ./scenes/Level1.ecs -> Level1
|
||||||
|
// scenes/GameScene.ecs -> GameScene
|
||||||
|
const fileName = path.split('/').pop() || path;
|
||||||
|
return fileName.replace(/\.ecs$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _notifyLoadStart(sceneName: string): void {
|
||||||
|
for (const listener of this._loadStartListeners) {
|
||||||
|
try {
|
||||||
|
listener(sceneName);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[RuntimeSceneManager] Error in load start listener:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _notifyLoadComplete(sceneName: string): void {
|
||||||
|
for (const listener of this._loadCompleteListeners) {
|
||||||
|
try {
|
||||||
|
listener(sceneName);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[RuntimeSceneManager] Error in load complete listener:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _notifyLoadError(error: Error, sceneName: string): void {
|
||||||
|
for (const listener of this._loadErrorListeners) {
|
||||||
|
try {
|
||||||
|
listener(error, sceneName);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[RuntimeSceneManager] Error in load error listener:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== IService 实现 | IService Implementation ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放资源
|
||||||
|
* Dispose resources
|
||||||
|
*
|
||||||
|
* 实现 IService 接口,清理所有监听器和状态。
|
||||||
|
* Implements IService interface, cleans up all listeners and state.
|
||||||
|
*/
|
||||||
|
dispose(): void {
|
||||||
|
if (this._disposed) return;
|
||||||
|
|
||||||
|
this._loadStartListeners.clear();
|
||||||
|
this._loadCompleteListeners.clear();
|
||||||
|
this._loadErrorListeners.clear();
|
||||||
|
this._scenes.clear();
|
||||||
|
this._sceneLoader = null;
|
||||||
|
this._currentSceneName = null;
|
||||||
|
this._currentScenePath = null;
|
||||||
|
this._disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
|
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
|
||||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||||
import { SpriteComponent } from './SpriteComponent';
|
import { SpriteComponent } from './SpriteComponent';
|
||||||
import { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
|
import { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
|
||||||
@@ -11,7 +11,7 @@ export type { SystemContext, ModuleManifest, IRuntimeModule, IRuntimePlugin };
|
|||||||
export { SpriteAnimatorSystemToken } from './tokens';
|
export { SpriteAnimatorSystemToken } from './tokens';
|
||||||
|
|
||||||
class SpriteRuntimeModule implements IRuntimeModule {
|
class SpriteRuntimeModule implements IRuntimeModule {
|
||||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
registerComponents(registry: IComponentRegistry): void {
|
||||||
registry.register(SpriteComponent);
|
registry.register(SpriteComponent);
|
||||||
registry.register(SpriteAnimatorComponent);
|
registry.register(SpriteAnimatorComponent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { IScene } from '@esengine/ecs-framework';
|
import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
|
||||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
|
||||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||||
import { AssetManagerToken } from '@esengine/asset-system';
|
import { AssetManagerToken } from '@esengine/asset-system';
|
||||||
import { RenderSystemToken } from '@esengine/ecs-engine-bindgen';
|
import { RenderSystemToken } from '@esengine/ecs-engine-bindgen';
|
||||||
@@ -26,7 +25,7 @@ class TilemapRuntimeModule implements IRuntimeModule {
|
|||||||
private _tilemapPhysicsSystem: TilemapPhysicsSystem | null = null;
|
private _tilemapPhysicsSystem: TilemapPhysicsSystem | null = null;
|
||||||
private _loaderRegistered = false;
|
private _loaderRegistered = false;
|
||||||
|
|
||||||
registerComponents(registry: typeof ComponentRegistry): void {
|
registerComponents(registry: IComponentRegistry): void {
|
||||||
registry.register(TilemapComponent);
|
registry.register(TilemapComponent);
|
||||||
registry.register(TilemapCollider2DComponent);
|
registry.register(TilemapCollider2DComponent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { IScene } from '@esengine/ecs-framework';
|
import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
|
||||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
|
||||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||||
import { EngineBridgeToken } from '@esengine/ecs-engine-bindgen';
|
import { EngineBridgeToken } from '@esengine/ecs-engine-bindgen';
|
||||||
|
|
||||||
@@ -14,10 +13,14 @@ import {
|
|||||||
UISliderComponent,
|
UISliderComponent,
|
||||||
UIScrollViewComponent
|
UIScrollViewComponent
|
||||||
} from './components';
|
} from './components';
|
||||||
|
import { TextBlinkComponent } from './components/TextBlinkComponent';
|
||||||
|
import { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent';
|
||||||
import { UILayoutSystem } from './systems/UILayoutSystem';
|
import { UILayoutSystem } from './systems/UILayoutSystem';
|
||||||
import { UIInputSystem } from './systems/UIInputSystem';
|
import { UIInputSystem } from './systems/UIInputSystem';
|
||||||
import { UIAnimationSystem } from './systems/UIAnimationSystem';
|
import { UIAnimationSystem } from './systems/UIAnimationSystem';
|
||||||
import { UIRenderDataProvider } from './systems/UIRenderDataProvider';
|
import { UIRenderDataProvider } from './systems/UIRenderDataProvider';
|
||||||
|
import { TextBlinkSystem } from './systems/TextBlinkSystem';
|
||||||
|
import { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem';
|
||||||
import {
|
import {
|
||||||
UIRenderBeginSystem,
|
UIRenderBeginSystem,
|
||||||
UIRectRenderSystem,
|
UIRectRenderSystem,
|
||||||
@@ -43,7 +46,7 @@ export {
|
|||||||
} from './tokens';
|
} from './tokens';
|
||||||
|
|
||||||
class UIRuntimeModule implements IRuntimeModule {
|
class UIRuntimeModule implements IRuntimeModule {
|
||||||
registerComponents(registry: typeof ComponentRegistry): void {
|
registerComponents(registry: IComponentRegistry): void {
|
||||||
registry.register(UITransformComponent);
|
registry.register(UITransformComponent);
|
||||||
registry.register(UIRenderComponent);
|
registry.register(UIRenderComponent);
|
||||||
registry.register(UIInteractableComponent);
|
registry.register(UIInteractableComponent);
|
||||||
@@ -53,6 +56,8 @@ class UIRuntimeModule implements IRuntimeModule {
|
|||||||
registry.register(UIProgressBarComponent);
|
registry.register(UIProgressBarComponent);
|
||||||
registry.register(UISliderComponent);
|
registry.register(UISliderComponent);
|
||||||
registry.register(UIScrollViewComponent);
|
registry.register(UIScrollViewComponent);
|
||||||
|
registry.register(TextBlinkComponent);
|
||||||
|
registry.register(SceneLoadTriggerComponent);
|
||||||
}
|
}
|
||||||
|
|
||||||
createSystems(scene: IScene, context: SystemContext): void {
|
createSystems(scene: IScene, context: SystemContext): void {
|
||||||
@@ -65,6 +70,14 @@ class UIRuntimeModule implements IRuntimeModule {
|
|||||||
const animationSystem = new UIAnimationSystem();
|
const animationSystem = new UIAnimationSystem();
|
||||||
scene.addSystem(animationSystem);
|
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();
|
const renderBeginSystem = new UIRenderBeginSystem();
|
||||||
scene.addSystem(renderBeginSystem);
|
scene.addSystem(renderBeginSystem);
|
||||||
|
|
||||||
|
|||||||
61
packages/ui/src/components/SceneLoadTriggerComponent.ts
Normal file
61
packages/ui/src/components/SceneLoadTriggerComponent.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 场景加载触发组件
|
||||||
|
* Scene Load Trigger Component
|
||||||
|
*
|
||||||
|
* 配合 UIInteractable 使用,点击时自动加载指定场景。
|
||||||
|
* Works with UIInteractable to automatically load scene on click.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景加载触发组件
|
||||||
|
* Scene Load Trigger Component
|
||||||
|
*
|
||||||
|
* 添加到带有 UIInteractable 的实体上,点击时会加载 targetScene 指定的场景。
|
||||||
|
* Add to entity with UIInteractable, loads targetScene on click.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "type": "SceneLoadTrigger",
|
||||||
|
* "data": {
|
||||||
|
* "targetScene": "GameScene",
|
||||||
|
* "enabled": true
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@ECSComponent('SceneLoadTrigger')
|
||||||
|
@Serializable({ version: 1, typeId: 'SceneLoadTrigger' })
|
||||||
|
export class SceneLoadTriggerComponent extends Component {
|
||||||
|
/**
|
||||||
|
* 目标场景名称
|
||||||
|
* Target scene name to load on click
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'string', label: 'Target Scene' })
|
||||||
|
public targetScene: string = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用
|
||||||
|
* Whether the trigger is enabled
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'boolean', label: 'Enabled' })
|
||||||
|
public enabled: boolean = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击后是否禁用(防止重复点击)
|
||||||
|
* Disable after click (prevent double clicks)
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'boolean', label: 'Disable On Click' })
|
||||||
|
public disableOnClick: boolean = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部标记:回调是否已绑定
|
||||||
|
* Internal flag: whether callback is bound
|
||||||
|
*/
|
||||||
|
public _callbackBound: boolean = false;
|
||||||
|
}
|
||||||
101
packages/ui/src/components/TextBlinkComponent.ts
Normal file
101
packages/ui/src/components/TextBlinkComponent.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 文本闪烁组件
|
||||||
|
* Text Blink Component
|
||||||
|
*
|
||||||
|
* 让文本产生闪烁效果,类似 Unity 的 Animation 实现
|
||||||
|
* Creates a blinking effect for text, similar to Unity's Animation implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本闪烁组件
|
||||||
|
* Text Blink Component
|
||||||
|
*/
|
||||||
|
@ECSComponent('TextBlink')
|
||||||
|
@Serializable({ version: 1, typeId: 'TextBlink' })
|
||||||
|
export class TextBlinkComponent extends Component {
|
||||||
|
/**
|
||||||
|
* 闪烁速度(周期/秒)
|
||||||
|
* Blink speed (cycles per second)
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Speed', min: 0.1, max: 10, step: 0.1 })
|
||||||
|
public speed: number = 1.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最小透明度
|
||||||
|
* Minimum alpha
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Min Alpha', min: 0, max: 1, step: 0.05 })
|
||||||
|
public minAlpha: number = 0.3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最大透明度
|
||||||
|
* Maximum alpha
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Max Alpha', min: 0, max: 1, step: 0.05 })
|
||||||
|
public maxAlpha: number = 1.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用闪烁
|
||||||
|
* Whether blinking is enabled
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'boolean', label: 'Enabled' })
|
||||||
|
public blinkEnabled: boolean = true;
|
||||||
|
|
||||||
|
// ============= 运行时状态(不序列化)| Runtime state (not serialized) =============
|
||||||
|
|
||||||
|
/** 当前时间 | Current time */
|
||||||
|
private _time: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前时间
|
||||||
|
* Get current time
|
||||||
|
*/
|
||||||
|
public get time(): number {
|
||||||
|
return this._time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
* Update time
|
||||||
|
*/
|
||||||
|
public addTime(deltaTime: number): void {
|
||||||
|
this._time += deltaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算当前 alpha 值
|
||||||
|
* Calculate current alpha value
|
||||||
|
*
|
||||||
|
* 使用正弦波实现平滑的闪烁效果
|
||||||
|
* Uses sine wave for smooth blinking effect
|
||||||
|
*/
|
||||||
|
public calculateAlpha(): number {
|
||||||
|
if (!this.blinkEnabled) {
|
||||||
|
return this.maxAlpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用正弦波:sin 从 -1 到 1,映射到 minAlpha 到 maxAlpha
|
||||||
|
// Using sine wave: sin from -1 to 1, mapped to minAlpha to maxAlpha
|
||||||
|
const t = Math.sin(this._time * this.speed * Math.PI * 2);
|
||||||
|
const normalized = (t + 1) / 2; // 0 到 1
|
||||||
|
return this.minAlpha + normalized * (this.maxAlpha - this.minAlpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置状态
|
||||||
|
* Reset state
|
||||||
|
*/
|
||||||
|
public reset(): void {
|
||||||
|
this._time = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
override onRemovedFromEntity(): void {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,9 +120,36 @@ export class UIRenderComponent extends Component {
|
|||||||
/**
|
/**
|
||||||
* 九宫格边距 [top, right, bottom, left]
|
* 九宫格边距 [top, right, bottom, left]
|
||||||
* Nine-patch margins
|
* 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];
|
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 =====
|
// ===== 边框 Border =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -275,6 +275,15 @@ export class UITransformComponent extends Component implements ISortable {
|
|||||||
*/
|
*/
|
||||||
public worldScaleY: number = 1;
|
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 计算)
|
* 本地到世界的 2D 变换矩阵(只读,由 UILayoutSystem 计算)
|
||||||
* Local to world 2D transformation matrix (readonly, computed by UILayoutSystem)
|
* Local to world 2D transformation matrix (readonly, computed by UILayoutSystem)
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export {
|
|||||||
type UIFontWeight
|
type UIFontWeight
|
||||||
} from './components/UITextComponent';
|
} from './components/UITextComponent';
|
||||||
|
|
||||||
|
export { TextBlinkComponent } from './components/TextBlinkComponent';
|
||||||
|
export { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
UILayoutComponent,
|
UILayoutComponent,
|
||||||
UILayoutType,
|
UILayoutType,
|
||||||
@@ -124,6 +127,8 @@ export { UILayoutSystem } from './systems/UILayoutSystem';
|
|||||||
export { UIInputSystem, type UIInputEvent } from './systems/UIInputSystem';
|
export { UIInputSystem, type UIInputEvent } from './systems/UIInputSystem';
|
||||||
export { UIAnimationSystem, UIEasing, type EasingFunction, type EasingName } from './systems/UIAnimationSystem';
|
export { UIAnimationSystem, UIEasing, type EasingFunction, type EasingName } from './systems/UIAnimationSystem';
|
||||||
export { UIRenderDataProvider, type IUIRenderDataProvider } from './systems/UIRenderDataProvider';
|
export { UIRenderDataProvider, type IUIRenderDataProvider } from './systems/UIRenderDataProvider';
|
||||||
|
export { TextBlinkSystem } from './systems/TextBlinkSystem';
|
||||||
|
export { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem';
|
||||||
|
|
||||||
// Systems - Render (ECS-compliant render systems)
|
// Systems - Render (ECS-compliant render systems)
|
||||||
export {
|
export {
|
||||||
|
|||||||
162
packages/ui/src/systems/SceneLoadTriggerSystem.ts
Normal file
162
packages/ui/src/systems/SceneLoadTriggerSystem.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* 场景加载触发系统
|
||||||
|
* Scene Load Trigger System
|
||||||
|
*
|
||||||
|
* 处理 SceneLoadTriggerComponent,绑定 UIInteractable 点击事件到场景加载。
|
||||||
|
* Processes SceneLoadTriggerComponent, binds UIInteractable click to scene loading.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Entity, EntitySystem, Matcher, ECSSystem, Core } from '@esengine/ecs-framework';
|
||||||
|
import { SceneLoadTriggerComponent } from '../components/SceneLoadTriggerComponent';
|
||||||
|
import { UIInteractableComponent } from '../components/UIInteractableComponent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景加载函数类型(与 RuntimeSceneManager.loadScene 兼容)
|
||||||
|
* Scene load function type (compatible with RuntimeSceneManager.loadScene)
|
||||||
|
*/
|
||||||
|
type SceneLoadFunction = (sceneName: string) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景管理器接口(最小化,避免循环依赖)
|
||||||
|
* Scene manager interface (minimal, avoids circular dependency)
|
||||||
|
*
|
||||||
|
* 包含 IService 的 dispose 方法以兼容 ServiceContainer。
|
||||||
|
* Includes IService's dispose method for ServiceContainer compatibility.
|
||||||
|
*/
|
||||||
|
interface ISceneManager {
|
||||||
|
loadScene(sceneName: string): Promise<void>;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局场景管理器服务键
|
||||||
|
* Global scene manager service key
|
||||||
|
*
|
||||||
|
* 使用 Symbol.for 确保与 BrowserRuntime 中注册的键一致。
|
||||||
|
* Uses Symbol.for to match the key registered in BrowserRuntime.
|
||||||
|
*/
|
||||||
|
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景加载触发系统
|
||||||
|
* Scene Load Trigger System
|
||||||
|
*
|
||||||
|
* 自动将 SceneLoadTriggerComponent 的配置连接到 UIInteractable 的点击事件。
|
||||||
|
* Automatically connects SceneLoadTriggerComponent config to UIInteractable click events.
|
||||||
|
*/
|
||||||
|
@ECSSystem('SceneLoadTrigger')
|
||||||
|
export class SceneLoadTriggerSystem extends EntitySystem {
|
||||||
|
private _sceneLoader: SceneLoadFunction | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(Matcher.empty().all(SceneLoadTriggerComponent, UIInteractableComponent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置场景加载函数
|
||||||
|
* Set scene load function
|
||||||
|
*
|
||||||
|
* 可以直接设置函数,或者系统会尝试从服务注册表获取 RuntimeSceneManager。
|
||||||
|
* Can set function directly, or system will try to get RuntimeSceneManager from service registry.
|
||||||
|
*/
|
||||||
|
public setSceneLoader(loader: SceneLoadFunction): void {
|
||||||
|
this._sceneLoader = loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override process(entities: readonly Entity[]): void {
|
||||||
|
// 如果没有设置场景加载器,尝试从服务注册表获取
|
||||||
|
// If no scene loader set, try to get from service registry
|
||||||
|
if (!this._sceneLoader) {
|
||||||
|
this._tryGetSceneManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
const trigger = entity.getComponent(SceneLoadTriggerComponent);
|
||||||
|
const interactable = entity.getComponent(UIInteractableComponent);
|
||||||
|
|
||||||
|
if (!trigger || !interactable) continue;
|
||||||
|
if (!trigger.enabled || !trigger.targetScene) continue;
|
||||||
|
|
||||||
|
// 只绑定一次回调
|
||||||
|
// Only bind callback once
|
||||||
|
if (trigger._callbackBound) continue;
|
||||||
|
|
||||||
|
this._bindClickHandler(entity, trigger, interactable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试从全局服务获取场景管理器
|
||||||
|
* Try to get scene manager from global services
|
||||||
|
*/
|
||||||
|
private _tryGetSceneManager(): void {
|
||||||
|
try {
|
||||||
|
// 从 Core.services 获取场景管理器
|
||||||
|
// Get scene manager from Core.services
|
||||||
|
// RuntimeSceneManager 实现了 IService 接口
|
||||||
|
// RuntimeSceneManager implements IService interface
|
||||||
|
const sceneManager = Core.services.tryResolve<ISceneManager>(GlobalSceneManagerKey);
|
||||||
|
if (sceneManager?.loadScene) {
|
||||||
|
this._sceneLoader = (sceneName: string) => sceneManager.loadScene(sceneName);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略错误,保持 _sceneLoader 为 null
|
||||||
|
// Ignore error, keep _sceneLoader as null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定点击处理器
|
||||||
|
* Bind click handler
|
||||||
|
*/
|
||||||
|
private _bindClickHandler(
|
||||||
|
entity: Entity,
|
||||||
|
trigger: SceneLoadTriggerComponent,
|
||||||
|
interactable: UIInteractableComponent
|
||||||
|
): void {
|
||||||
|
const targetScene = trigger.targetScene;
|
||||||
|
|
||||||
|
// 保存原有的 onClick(如果有)
|
||||||
|
// Save original onClick (if any)
|
||||||
|
const originalOnClick = interactable.onClick;
|
||||||
|
|
||||||
|
interactable.onClick = () => {
|
||||||
|
// 调用原有回调
|
||||||
|
// Call original callback
|
||||||
|
originalOnClick?.();
|
||||||
|
|
||||||
|
// 检查是否启用
|
||||||
|
// Check if enabled
|
||||||
|
if (!trigger.enabled) return;
|
||||||
|
|
||||||
|
// 禁用(防止重复点击)
|
||||||
|
// Disable (prevent double clicks)
|
||||||
|
if (trigger.disableOnClick) {
|
||||||
|
trigger.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试获取场景加载器(可能在回调绑定后才注册)
|
||||||
|
// Try to get scene loader (may be registered after callback binding)
|
||||||
|
if (!this._sceneLoader) {
|
||||||
|
this._tryGetSceneManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载场景
|
||||||
|
// Load scene
|
||||||
|
if (this._sceneLoader) {
|
||||||
|
this._sceneLoader(targetScene).catch((error) => {
|
||||||
|
console.error(`[SceneLoadTriggerSystem] Failed to load scene "${targetScene}":`, error);
|
||||||
|
// 恢复启用状态
|
||||||
|
// Restore enabled state
|
||||||
|
if (trigger.disableOnClick) {
|
||||||
|
trigger.enabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 静默处理:编辑器预览模式下场景切换不可用
|
||||||
|
// Silent handling: scene switching not available in editor preview mode
|
||||||
|
};
|
||||||
|
|
||||||
|
trigger._callbackBound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/ui/src/systems/TextBlinkSystem.ts
Normal file
37
packages/ui/src/systems/TextBlinkSystem.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 文本闪烁系统 - 实现 UI 元素的透明度脉冲动画
|
||||||
|
*
|
||||||
|
* Text Blink System - Implements alpha pulse animation for UI elements
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Entity, EntitySystem, Matcher, Time } from '@esengine/ecs-framework';
|
||||||
|
import { TextBlinkComponent } from '../components/TextBlinkComponent';
|
||||||
|
import { UITransformComponent } from '../components/UITransformComponent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 TextBlinkComponent,驱动 UI 元素的透明度动画。
|
||||||
|
* 常用于 "TAP TO START" 等需要吸引注意力的文本效果。
|
||||||
|
*
|
||||||
|
* Processes TextBlinkComponent to drive UI element alpha animation.
|
||||||
|
* Commonly used for attention-grabbing text effects like "TAP TO START".
|
||||||
|
*/
|
||||||
|
export class TextBlinkSystem extends EntitySystem {
|
||||||
|
constructor() {
|
||||||
|
super(Matcher.empty().all(TextBlinkComponent, UITransformComponent));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override process(entities: readonly Entity[]): void {
|
||||||
|
const deltaTime = Time.deltaTime;
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (!entity.enabled) continue;
|
||||||
|
|
||||||
|
const blink = entity.getComponent(TextBlinkComponent);
|
||||||
|
const uiTransform = entity.getComponent(UITransformComponent);
|
||||||
|
if (!blink || !uiTransform) continue;
|
||||||
|
|
||||||
|
blink.addTime(deltaTime);
|
||||||
|
uiTransform.alpha = blink.calculateAlpha();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
const identityMatrix: Matrix2D = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
|
const identityMatrix: Matrix2D = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
|
||||||
|
|
||||||
for (const entity of rootEntities) {
|
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,
|
parentHeight: number,
|
||||||
parentAlpha: number,
|
parentAlpha: number,
|
||||||
parentMatrix: Matrix2D,
|
parentMatrix: Matrix2D,
|
||||||
parentVisible: boolean = true
|
parentVisible: boolean = true,
|
||||||
|
depth: number = 0
|
||||||
): void {
|
): void {
|
||||||
const transform = entity.getComponent(UITransformComponent);
|
const transform = entity.getComponent(UITransformComponent);
|
||||||
if (!transform) return;
|
if (!transform) return;
|
||||||
@@ -199,6 +200,12 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
// Calculate world visibility (if parent is invisible, children are also invisible)
|
// Calculate world visibility (if parent is invisible, children are also invisible)
|
||||||
transform.worldVisible = parentVisible && transform.visible;
|
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);
|
this.updateWorldMatrix(transform, parentMatrix);
|
||||||
|
|
||||||
@@ -215,7 +222,7 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
// 检查是否有布局组件
|
// 检查是否有布局组件
|
||||||
const layout = entity.getComponent(UILayoutComponent);
|
const layout = entity.getComponent(UILayoutComponent);
|
||||||
if (layout && layout.type !== UILayoutType.None) {
|
if (layout && layout.type !== UILayoutType.None) {
|
||||||
this.layoutChildren(layout, transform, children);
|
this.layoutChildren(layout, transform, children, depth + 1);
|
||||||
} else {
|
} else {
|
||||||
// 无布局组件,直接递归处理子元素
|
// 无布局组件,直接递归处理子元素
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
@@ -227,7 +234,8 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
height,
|
height,
|
||||||
transform.worldAlpha,
|
transform.worldAlpha,
|
||||||
transform.localToWorldMatrix,
|
transform.localToWorldMatrix,
|
||||||
transform.worldVisible
|
transform.worldVisible,
|
||||||
|
depth + 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,7 +248,8 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
private layoutChildren(
|
private layoutChildren(
|
||||||
layout: UILayoutComponent,
|
layout: UILayoutComponent,
|
||||||
parentTransform: UITransformComponent,
|
parentTransform: UITransformComponent,
|
||||||
children: Entity[]
|
children: Entity[],
|
||||||
|
depth: number
|
||||||
): void {
|
): void {
|
||||||
const contentStartX = parentTransform.worldX + layout.paddingLeft;
|
const contentStartX = parentTransform.worldX + layout.paddingLeft;
|
||||||
// Y-up 系统:worldY 是底部,顶部 = worldY + height
|
// Y-up 系统:worldY 是底部,顶部 = worldY + height
|
||||||
@@ -252,13 +261,13 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
|
|
||||||
switch (layout.type) {
|
switch (layout.type) {
|
||||||
case UILayoutType.Horizontal:
|
case UILayoutType.Horizontal:
|
||||||
this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
|
this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth);
|
||||||
break;
|
break;
|
||||||
case UILayoutType.Vertical:
|
case UILayoutType.Vertical:
|
||||||
this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
|
this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth);
|
||||||
break;
|
break;
|
||||||
case UILayoutType.Grid:
|
case UILayoutType.Grid:
|
||||||
this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
|
this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// 默认按正常方式递归(传递顶部 Y)
|
// 默认按正常方式递归(传递顶部 Y)
|
||||||
@@ -270,7 +279,9 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
parentTransform.computedWidth,
|
parentTransform.computedWidth,
|
||||||
parentTransform.computedHeight,
|
parentTransform.computedHeight,
|
||||||
parentTransform.worldAlpha,
|
parentTransform.worldAlpha,
|
||||||
parentTransform.localToWorldMatrix
|
parentTransform.localToWorldMatrix,
|
||||||
|
parentTransform.worldVisible,
|
||||||
|
depth
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,7 +298,8 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
startX: number,
|
startX: number,
|
||||||
startY: number,
|
startY: number,
|
||||||
contentWidth: number,
|
contentWidth: number,
|
||||||
contentHeight: number
|
contentHeight: number,
|
||||||
|
depth: number
|
||||||
): void {
|
): void {
|
||||||
// 计算总子元素宽度
|
// 计算总子元素宽度
|
||||||
const childSizes = children.map(child => {
|
const childSizes = children.map(child => {
|
||||||
@@ -366,12 +378,14 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||||
// 传播世界可见性 | Propagate world visibility
|
// 传播世界可见性 | Propagate world visibility
|
||||||
childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
|
childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
|
||||||
|
// 计算世界层内顺序 | Calculate world order in layer
|
||||||
|
childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer;
|
||||||
// 使用矩阵乘法计算世界旋转和缩放
|
// 使用矩阵乘法计算世界旋转和缩放
|
||||||
this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
|
this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
|
||||||
childTransform.layoutDirty = false;
|
childTransform.layoutDirty = false;
|
||||||
|
|
||||||
// 递归处理子元素的子元素
|
// 递归处理子元素的子元素
|
||||||
this.processChildrenRecursive(child, childTransform);
|
this.processChildrenRecursive(child, childTransform, depth);
|
||||||
|
|
||||||
offsetX += size.width + gap;
|
offsetX += size.width + gap;
|
||||||
}
|
}
|
||||||
@@ -389,7 +403,8 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
startX: number,
|
startX: number,
|
||||||
startY: number,
|
startY: number,
|
||||||
contentWidth: number,
|
contentWidth: number,
|
||||||
contentHeight: number
|
contentHeight: number,
|
||||||
|
depth: number
|
||||||
): void {
|
): void {
|
||||||
// 计算总子元素高度
|
// 计算总子元素高度
|
||||||
const childSizes = children.map(child => {
|
const childSizes = children.map(child => {
|
||||||
@@ -466,11 +481,13 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||||
// 传播世界可见性 | Propagate world visibility
|
// 传播世界可见性 | Propagate world visibility
|
||||||
childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
|
childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
|
||||||
|
// 计算世界层内顺序 | Calculate world order in layer
|
||||||
|
childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer;
|
||||||
// 使用矩阵乘法计算世界旋转和缩放
|
// 使用矩阵乘法计算世界旋转和缩放
|
||||||
this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
|
this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
|
||||||
childTransform.layoutDirty = false;
|
childTransform.layoutDirty = false;
|
||||||
|
|
||||||
this.processChildrenRecursive(child, childTransform);
|
this.processChildrenRecursive(child, childTransform, depth);
|
||||||
|
|
||||||
// 移动到下一个元素的顶部位置(向下 = Y 减小)
|
// 移动到下一个元素的顶部位置(向下 = Y 减小)
|
||||||
currentTopY -= size.height + gap;
|
currentTopY -= size.height + gap;
|
||||||
@@ -489,7 +506,8 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
startX: number,
|
startX: number,
|
||||||
startY: number,
|
startY: number,
|
||||||
contentWidth: number,
|
contentWidth: number,
|
||||||
_contentHeight: number
|
_contentHeight: number,
|
||||||
|
depth: number
|
||||||
): void {
|
): void {
|
||||||
const columns = layout.columns;
|
const columns = layout.columns;
|
||||||
const gapX = layout.getHorizontalGap();
|
const gapX = layout.getHorizontalGap();
|
||||||
@@ -524,11 +542,13 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||||
// 传播世界可见性 | Propagate world visibility
|
// 传播世界可见性 | Propagate world visibility
|
||||||
childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
|
childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
|
||||||
|
// 计算世界层内顺序 | Calculate world order in layer
|
||||||
|
childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer;
|
||||||
// 使用矩阵乘法计算世界旋转和缩放
|
// 使用矩阵乘法计算世界旋转和缩放
|
||||||
this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
|
this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
|
||||||
childTransform.layoutDirty = false;
|
childTransform.layoutDirty = false;
|
||||||
|
|
||||||
this.processChildrenRecursive(child, childTransform);
|
this.processChildrenRecursive(child, childTransform, depth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +585,7 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
* 递归处理子元素
|
* 递归处理子元素
|
||||||
* Recursively process children
|
* Recursively process children
|
||||||
*/
|
*/
|
||||||
private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent): void {
|
private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent, depth: number): void {
|
||||||
const children = this.getUIChildren(entity);
|
const children = this.getUIChildren(entity);
|
||||||
if (children.length === 0) return;
|
if (children.length === 0) return;
|
||||||
|
|
||||||
@@ -574,7 +594,7 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
|
|
||||||
const layout = entity.getComponent(UILayoutComponent);
|
const layout = entity.getComponent(UILayoutComponent);
|
||||||
if (layout && layout.type !== UILayoutType.None) {
|
if (layout && layout.type !== UILayoutType.None) {
|
||||||
this.layoutChildren(layout, parentTransform, children);
|
this.layoutChildren(layout, parentTransform, children, depth + 1);
|
||||||
} else {
|
} else {
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
this.layoutEntity(
|
this.layoutEntity(
|
||||||
@@ -585,7 +605,8 @@ export class UILayoutSystem extends EntitySystem {
|
|||||||
parentTransform.computedHeight,
|
parentTransform.computedHeight,
|
||||||
parentTransform.worldAlpha,
|
parentTransform.worldAlpha,
|
||||||
parentTransform.localToWorldMatrix,
|
parentTransform.localToWorldMatrix,
|
||||||
parentTransform.worldVisible
|
parentTransform.worldVisible,
|
||||||
|
depth + 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user