feat: 纹理路径稳定 ID 与架构改进 (#305)

* feat(asset-system): 实现路径稳定 ID 生成器

使用 FNV-1a hash 算法为纹理生成稳定的运行时 ID:
- 新增 _pathIdCache 静态缓存,跨 Play/Stop 循环保持稳定
- 新增 getStableIdForPath() 方法,相同路径永远返回相同 ID
- 修改 loadTextureForComponent/loadTextureByGuid 使用稳定 ID
- clearTextureMappings() 不再清除 _pathIdCache

这解决了 Play/Stop 后纹理 ID 失效的根本问题。

* fix(runtime-core): 移除 Play/Stop 循环中的 clearTextureMappings 调用

使用路径稳定 ID 后,不再需要在快照保存/恢复时清除纹理缓存:
- saveSceneSnapshot() 移除 clearTextureMappings() 调用
- restoreSceneSnapshot() 移除 clearTextureMappings() 调用
- 组件保存的 textureId 在 Play/Stop 后仍然有效

* fix(editor-core): 修复场景切换时的资源泄漏

在 openScene() 加载新场景前先卸载旧场景资源:
- 调用 sceneResourceManager.unloadSceneResources() 释放旧资源
- 使用引用计数机制,仅卸载不再被引用的资源
- 路径稳定 ID 缓存不受影响,保持 ID 稳定性

* fix(runtime-core): 修复 PluginManager 组件注册类型错误

将 ComponentRegistry 类改为 GlobalComponentRegistry 实例:
- registerComponents() 期望 IComponentRegistry 接口实例
- GlobalComponentRegistry 是 ComponentRegistry 的全局实例

* refactor(core): 提取 IComponentRegistry 接口

将组件注册表抽象为接口,支持场景级组件注册:
- 新增 IComponentRegistry 接口定义
- Scene 持有独立的 componentRegistry 实例
- 支持从 GlobalComponentRegistry 克隆
- 各系统支持传入自定义注册表

* refactor(engine-core): 改进插件服务注册机制

- 更新 IComponentRegistry 类型引用
- 优化 PluginServiceRegistry 服务管理

* refactor(modules): 适配新的组件注册接口

更新各模块 RuntimeModule 使用 IComponentRegistry 接口:
- audio, behavior-tree, camera
- sprite, tilemap, world-streaming

* fix(physics-rapier2d): 修复物理插件组件注册

- PhysicsEditorPlugin 添加 runtimeModule 引用
- 适配 IComponentRegistry 接口
- 修复物理组件在场景加载时未注册的问题

* feat(editor-core): 添加 UserCodeService 就绪信号机制

- 新增 waitForReady()/signalReady() API
- 支持等待用户脚本编译完成
- 解决场景加载时组件未注册的时序问题

* fix(editor-app): 在编译完成后调用 signalReady()

确保用户脚本编译完成后发出就绪信号:
- 编译成功后调用 userCodeService.signalReady()
- 编译失败也要发出信号,避免阻塞场景加载

* feat(editor-core): 改进编辑器核心服务

- EntityStoreService 添加调试日志
- AssetRegistryService 优化资产注册
- PluginManager 改进插件管理
- IFileAPI 添加 getFileMtime 接口

* feat(engine): 改进 Rust 纹理管理器

- 支持任意 ID 的纹理加载(非递增)
- 添加纹理状态追踪 API
- 优化纹理缓存清理机制
- 更新 TypeScript 绑定

* feat(ui): 添加场景切换和文本闪烁组件

新增组件:
- SceneLoadTriggerComponent: 场景切换触发器
- TextBlinkComponent: 文本闪烁效果

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

其他改进:
- UIRuntimeModule 适配新组件注册接口
- UI 渲染系统优化

* feat(editor-app): 添加外部文件修改检测

- 新增 ExternalModificationDialog 组件
- TauriFileAPI 支持 getFileMtime
- 场景文件被外部修改时提示用户

* feat(editor-app): 添加渲染调试面板

- 新增 RenderDebugService 和调试面板 UI
- App/ContentBrowser 添加调试日志
- TitleBar/Viewport 优化
- DialogManager 改进

* refactor(editor-app): 编辑器服务和组件优化

- EngineService 改进引擎集成
- EditorEngineSync 同步优化
- AssetFileInspector 改进
- VectorFieldEditors 优化
- InstantiatePrefabCommand 改进

* feat(i18n): 更新国际化翻译

- 添加新功能相关翻译
- 更新中文、英文、西班牙文

* feat(tauri): 添加文件修改时间查询命令

- 新增 get_file_mtime 命令
- 支持检测文件外部修改

* refactor(particle): 粒子系统改进

- 适配新的组件注册接口
- ParticleSystem 优化
- 添加单元测试

* refactor(platform): 平台适配层优化

- BrowserRuntime 改进
- 新增 RuntimeSceneManager 服务
- 导出优化

* refactor(asset-system-editor): 资产元数据改进

- AssetMetaFile 优化
- 导出调整

* fix(asset-system): 移除未使用的 TextureLoader 导入

* fix(tests): 更新测试以使用 GlobalComponentRegistry 实例

修复多个测试文件以适配 ComponentRegistry 从静态类变为实例类的变更:
- ComponentStorage.test.ts: 使用 GlobalComponentRegistry.reset()
- EntitySerializer.test.ts: 使用 GlobalComponentRegistry 实例
- IncrementalSerialization.test.ts: 使用 GlobalComponentRegistry 实例
- SceneSerializer.test.ts: 使用 GlobalComponentRegistry 实例
- ComponentRegistry.extended.test.ts: 使用 GlobalComponentRegistry,同时注册到 scene.componentRegistry
- SystemTypes.test.ts: 在 Scene 创建前注册组件
- QuerySystem.test.ts: mockScene 添加 componentRegistry
This commit is contained in:
YHH
2025-12-16 12:46:14 +08:00
committed by GitHub
parent d834ca5e77
commit ed8f6e283b
107 changed files with 7399 additions and 847 deletions

View File

@@ -1026,13 +1026,16 @@ export class ${className} {
// Handle asset double click
const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => {
console.log('[ContentBrowser] Double click:', asset.name, 'type:', asset.type, 'ext:', asset.extension);
if (asset.type === 'folder') {
setCurrentPath(asset.path);
loadAssets(asset.path);
setExpandedFolders(prev => new Set([...prev, asset.path]));
} else {
const ext = asset.extension?.toLowerCase();
console.log('[ContentBrowser] File ext:', ext, 'onOpenScene:', !!onOpenScene);
if (ext === 'ecs' && onOpenScene) {
console.log('[ContentBrowser] Opening scene:', asset.path);
onOpenScene(asset.path);
return;
}

View File

@@ -0,0 +1,58 @@
import { AlertTriangle, X, RefreshCw, Save } from 'lucide-react';
import '../styles/ConfirmDialog.css';
interface ExternalModificationDialogProps {
sceneName: string;
onReload: () => void;
onOverwrite: () => void;
onCancel: () => void;
}
/**
* 外部修改对话框
* External Modification Dialog
*
* 当场景文件被外部修改时显示,让用户选择操作
* Shown when scene file is modified externally, let user choose action
*/
export function ExternalModificationDialog({
sceneName,
onReload,
onOverwrite,
onCancel
}: ExternalModificationDialogProps) {
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog external-modification-dialog" onClick={(e) => e.stopPropagation()}>
<div className="confirm-dialog-header">
<AlertTriangle size={20} className="warning-icon" />
<h2></h2>
<button className="close-btn" onClick={onCancel}>
<X size={16} />
</button>
</div>
<div className="confirm-dialog-content">
<p>
<strong>{sceneName}</strong>
</p>
<p className="hint-text">
</p>
</div>
<div className="confirm-dialog-footer external-modification-footer">
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
</button>
<button className="confirm-dialog-btn reload" onClick={onReload}>
<RefreshCw size={14} />
</button>
<button className="confirm-dialog-btn overwrite" onClick={onOverwrite}>
<Save size={14} />
</button>
</div>
</div>
</div>
);
}

View File

@@ -38,6 +38,7 @@ interface TitleBarProps {
onCreatePlugin?: () => void;
onReloadPlugins?: () => void;
onOpenBuildSettings?: () => void;
onOpenRenderDebug?: () => void;
}
export function TitleBar({
@@ -61,7 +62,8 @@ export function TitleBar({
onOpenAbout,
onCreatePlugin,
onReloadPlugins,
onOpenBuildSettings
onOpenBuildSettings,
onOpenRenderDebug
}: TitleBarProps) {
const { t } = useLocale();
const [openMenu, setOpenMenu] = useState<string | null>(null);
@@ -197,6 +199,7 @@ export function TitleBar({
{ label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
{ separator: true },
{ label: t('menu.tools.portManager'), onClick: onOpenPortManager },
{ label: t('menu.tools.renderDebug'), onClick: onOpenRenderDebug },
{ separator: true },
{ label: t('menu.tools.settings'), onClick: onOpenSettings }
],

View File

@@ -8,9 +8,9 @@ import '../styles/Viewport.css';
import { useEngine } from '../hooks/useEngine';
import { useLocale } from '../hooks/useLocale';
import { EngineService } from '../services/EngineService';
import { Core, Entity, SceneSerializer, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
import { Core, Entity, SceneSerializer, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService } from '@esengine/editor-core';
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget } from '@esengine/editor-core';
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
import { TransformComponent } from '@esengine/engine-core';
@@ -21,6 +21,8 @@ import { open } from '@tauri-apps/plugin-shell';
import { RuntimeResolver } from '../services/RuntimeResolver';
import { QRCodeDialog } from './QRCodeDialog';
import { collectAssetReferences } from '@esengine/asset-system';
import { RuntimeSceneManager, type IRuntimeSceneManager } from '@esengine/runtime-core';
import { ParticleSystemComponent } from '@esengine/particle';
import type { ModuleManifest } from '../services/RuntimeResolver';
@@ -264,6 +266,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
const playStateRef = useRef<PlayState>('stopped');
// Runtime scene manager for play mode scene switching | Play 模式场景切换管理器
const runtimeSceneManagerRef = useRef<IRuntimeSceneManager | null>(null);
// Live transform display state | 实时变换显示状态
const [liveTransform, setLiveTransform] = useState<{
type: 'move' | 'rotate' | 'scale';
@@ -811,7 +816,22 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
return;
}
// Save scene snapshot before playing
// saveSceneSnapshot clears all textures, so we need to reset particle textureIds after
// saveSceneSnapshot 会清除所有纹理,所以之后需要重置粒子的 textureId
EngineService.getInstance().saveSceneSnapshot();
// Reset particle component textureIds after snapshot (textures were cleared)
// 快照后重置粒子组件的 textureId纹理已被清除
const scene = Core.scene;
if (scene) {
for (const entity of scene.entities.buffer) {
const particleComponent = entity.getComponent(ParticleSystemComponent);
if (particleComponent) {
particleComponent.textureId = 0;
}
}
}
// Save editor camera state
editorCameraRef.current = { x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom };
setPlayState('playing');
@@ -820,6 +840,132 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
EngineService.getInstance().setEditorMode(false);
// Switch to player camera
syncPlayerCamera();
// Register RuntimeSceneManager for scene switching in play mode
// 注册 RuntimeSceneManager 以支持 Play 模式下的场景切换
const projectService = Core.services.tryResolve(ProjectService);
const projectPath = projectService?.getCurrentProject()?.path;
if (projectPath) {
// Create scene loader function that reads scene files using Tauri API
// 创建使用 Tauri API 读取场景文件的场景加载器函数
const editorSceneLoader = async (scenePath: string): Promise<void> => {
try {
// Normalize path: handle both relative and absolute paths
// 标准化路径:处理相对路径和绝对路径
let fullPath = scenePath;
if (!scenePath.includes(':') && !scenePath.startsWith('/')) {
// Relative path - construct full path
// 相对路径 - 构建完整路径
const normalizedPath = scenePath.replace(/^\.\//, '').replace(/\//g, '\\');
fullPath = `${projectPath}\\${normalizedPath}`;
} else {
// Absolute path - normalize separators for Windows
// 绝对路径 - 为 Windows 规范化分隔符
fullPath = scenePath.replace(/\//g, '\\');
}
// Read scene file content
// 读取场景文件内容
const sceneJson = await TauriAPI.readFileContent(fullPath);
// Validate scene data
// 验证场景数据
const validation = SceneSerializer.validate(sceneJson);
if (!validation.valid) {
throw new Error(`Invalid scene: ${validation.errors?.join(', ')}`);
}
// Save current scene snapshot (so we can go back)
// 保存当前场景快照(以便返回)
EngineService.getInstance().saveSceneSnapshot();
// Load new scene by deserializing into current scene
// 通过反序列化加载新场景到当前场景
const scene = Core.scene;
if (scene) {
scene.deserialize(sceneJson, { strategy: 'replace' });
// Reset particle component textureIds after scene switch
// 场景切换后重置粒子组件的 textureId
// This ensures ParticleUpdateSystem will reload textures
// 这确保 ParticleUpdateSystem 会重新加载纹理
for (const entity of scene.entities.buffer) {
const particleComponent = entity.getComponent(ParticleSystemComponent);
if (particleComponent) {
particleComponent.textureId = 0;
}
}
// Re-register user code components and systems after scene switch
// 场景切换后重新注册用户代码组件和系统
const userCodeService = Core.services.tryResolve(UserCodeService);
if (userCodeService) {
const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime);
if (runtimeModule) {
// Re-register components (ensures GlobalComponentRegistry has correct references)
// 重新注册组件(确保 GlobalComponentRegistry 有正确的引用)
userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry);
// Re-register systems (recreates systems with correct component references)
// 重新注册系统(使用正确的组件引用重建系统)
userCodeService.registerSystems(runtimeModule, scene);
}
}
// Load scene resources (textures, etc.)
// 加载场景资源(纹理等)
await EngineService.getInstance().loadSceneResources();
// Sync entity store
// 同步实体存储
const entityStore = Core.services.tryResolve(EntityStoreService);
entityStore?.syncFromScene();
}
console.log(`[Viewport] Scene loaded in play mode: ${scenePath}`);
} catch (error) {
console.error(`[Viewport] Failed to load scene: ${scenePath}`, error);
throw error;
}
};
// Create and register RuntimeSceneManager
// 创建并注册 RuntimeSceneManager
const sceneManager = new RuntimeSceneManager(
editorSceneLoader,
`${projectPath}\\scenes`
);
runtimeSceneManagerRef.current = sceneManager;
// Register to Core.services with the global key
// 使用全局 key 注册到 Core.services
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
if (!Core.services.isRegistered(GlobalSceneManagerKey)) {
Core.services.registerInstance(GlobalSceneManagerKey, sceneManager);
}
console.log('[Viewport] RuntimeSceneManager registered for play mode');
}
// Register user code components and systems before starting engine
// 在启动引擎前注册用户代码组件和系统
const userCodeService = Core.services.tryResolve(UserCodeService);
if (userCodeService) {
const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime);
if (runtimeModule) {
// Register components first (ensures GlobalComponentRegistry has correct references)
// 先注册组件(确保 GlobalComponentRegistry 有正确的引用)
userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry);
// Then register systems (uses registered component references)
// 然后注册系统(使用已注册的组件引用)
const scene = Core.scene;
if (scene) {
userCodeService.registerSystems(runtimeModule, scene);
}
}
}
engine.start();
} else if (playState === 'paused') {
setPlayState('playing');
@@ -837,6 +983,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const handleStop = async () => {
setPlayState('stopped');
engine.stop();
// Unregister RuntimeSceneManager
// 注销 RuntimeSceneManager
if (runtimeSceneManagerRef.current) {
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
if (Core.services.isRegistered(GlobalSceneManagerKey)) {
Core.services.unregister(GlobalSceneManagerKey);
}
runtimeSceneManagerRef.current.dispose();
runtimeSceneManagerRef.current = null;
console.log('[Viewport] RuntimeSceneManager unregistered');
}
// Restore scene snapshot
await EngineService.getInstance().restoreSceneSnapshot();
// Restore editor camera state

View File

@@ -0,0 +1,633 @@
/**
* 渲染调试面板样式 (浮动窗口)
* Render Debug Panel Styles (Floating Window)
*/
/* ==================== Floating Window ==================== */
.render-debug-window {
position: fixed;
display: flex;
flex-direction: column;
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 1000;
overflow: hidden;
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 11px;
color: #ccc;
}
.render-debug-window.dragging {
cursor: move;
user-select: none;
}
/* 独立窗口模式 | Standalone mode */
.render-debug-window.standalone {
position: relative;
border: none;
border-radius: 0;
box-shadow: none;
}
.render-debug-window.standalone .window-header {
cursor: default;
}
/* ==================== Window Header ==================== */
.render-debug-window .window-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #2d2d2d;
border-bottom: 1px solid #1a1a1a;
cursor: move;
flex-shrink: 0;
}
.render-debug-window .window-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #e0e0e0;
}
.render-debug-window .window-title svg {
color: #4a9eff;
}
.render-debug-window .paused-badge {
padding: 2px 6px;
background: #f59e0b;
color: #000;
font-size: 9px;
font-weight: 700;
border-radius: 3px;
letter-spacing: 0.5px;
}
.render-debug-window .window-controls {
display: flex;
gap: 4px;
}
.render-debug-window .window-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: 4px;
color: #888;
cursor: pointer;
transition: all 0.15s;
}
.render-debug-window .window-btn:hover {
background: #3a3a3a;
color: #fff;
}
/* ==================== Toolbar ==================== */
.render-debug-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
flex-shrink: 0;
}
.render-debug-toolbar .toolbar-left,
.render-debug-toolbar .toolbar-right {
display: flex;
align-items: center;
gap: 6px;
}
.render-debug-toolbar .toolbar-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 3px;
color: #ccc;
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
}
.render-debug-toolbar .toolbar-btn:hover {
background: #4a4a4a;
color: #fff;
}
.render-debug-toolbar .toolbar-btn.active {
background: #4a9eff;
border-color: #4a9eff;
color: #fff;
}
.render-debug-toolbar .toolbar-btn.icon-only {
padding: 4px 6px;
}
.render-debug-toolbar .toolbar-btn.recording {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
}
.render-debug-toolbar .toolbar-btn .record-dot {
display: inline-block;
width: 10px;
height: 10px;
background: #ef4444;
border-radius: 50%;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.render-debug-toolbar .history-badge {
padding: 2px 6px;
background: #8b5cf6;
color: #fff;
font-size: 9px;
font-weight: 700;
border-radius: 3px;
letter-spacing: 0.5px;
margin-left: 4px;
}
.render-debug-toolbar .toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.render-debug-toolbar .toolbar-btn:disabled:hover {
background: #3a3a3a;
color: #ccc;
}
/* ==================== Timeline ==================== */
.render-debug-timeline {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background: #222;
border-bottom: 1px solid #1a1a1a;
flex-shrink: 0;
}
.render-debug-timeline .timeline-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: #333;
border-radius: 3px;
cursor: pointer;
}
.render-debug-timeline .timeline-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #4a9eff;
border-radius: 50%;
cursor: grab;
transition: transform 0.1s;
}
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:active {
cursor: grabbing;
transform: scale(1.1);
}
.render-debug-timeline .timeline-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #4a9eff;
border: none;
border-radius: 50%;
cursor: grab;
}
.render-debug-timeline .timeline-info {
display: flex;
justify-content: space-between;
font-size: 9px;
color: #666;
}
.render-debug-toolbar .toolbar-separator {
width: 1px;
height: 16px;
background: #3a3a3a;
}
.render-debug-toolbar .frame-counter {
font-family: 'Consolas', monospace;
font-size: 10px;
color: #888;
padding: 0 6px;
}
/* ==================== Main Layout ==================== */
.render-debug-main {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ==================== Left Panel (Event List) ==================== */
.render-debug-left {
width: 260px;
min-width: 180px;
display: flex;
flex-direction: column;
background: #222;
border-right: 1px solid #1a1a1a;
flex-shrink: 0;
}
.event-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
font-size: 10px;
font-weight: 600;
color: #888;
}
.event-list-header .event-count {
font-weight: 400;
color: #666;
}
.event-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.event-list::-webkit-scrollbar {
width: 5px;
}
.event-list::-webkit-scrollbar-track {
background: #1a1a1a;
}
.event-list::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 2px;
}
.event-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #555;
font-size: 10px;
text-align: center;
padding: 16px;
line-height: 1.5;
}
/* Event Items */
.event-item {
display: flex;
align-items: center;
padding: 3px 6px;
cursor: pointer;
user-select: none;
font-size: 10px;
color: #bbb;
border-bottom: 1px solid #1a1a1a;
gap: 3px;
}
.event-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.event-item.selected {
background: rgba(74, 158, 255, 0.2);
border-left: 2px solid #4a9eff;
padding-left: 4px;
}
.event-item .expand-icon {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
color: #666;
flex-shrink: 0;
}
.event-item .expand-icon:not(.placeholder):hover {
color: #ccc;
}
.event-item .expand-icon.placeholder {
visibility: hidden;
}
.event-item .event-icon {
color: #666;
flex-shrink: 0;
margin-right: 3px;
}
.event-item .event-icon.sprite {
color: #4fc3f7;
}
.event-item .event-icon.particle {
color: #ffb74d;
}
.event-item .event-icon.ui {
color: #81c784;
}
.event-item .event-icon.batch {
color: #81c784;
}
.event-item .event-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-item .event-draws {
font-family: 'Consolas', monospace;
font-size: 9px;
color: #666;
padding: 1px 3px;
background: #1a1a1a;
border-radius: 2px;
flex-shrink: 0;
}
/* ==================== Right Panel ==================== */
.render-debug-right {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
/* Preview Section */
.render-debug-preview {
height: 40%;
min-height: 120px;
display: flex;
flex-direction: column;
border-bottom: 1px solid #1a1a1a;
}
.preview-header {
padding: 6px 10px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
font-size: 10px;
font-weight: 600;
color: #888;
}
.preview-canvas-container {
flex: 1;
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-canvas-container canvas {
width: 100%;
height: 100%;
}
/* Details Section */
.render-debug-details {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.details-header {
padding: 6px 10px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
font-size: 10px;
font-weight: 600;
color: #888;
}
.details-content {
flex: 1;
overflow-y: auto;
padding: 10px;
background: #1e1e1e;
}
.details-content::-webkit-scrollbar {
width: 5px;
}
.details-content::-webkit-scrollbar-track {
background: #1a1a1a;
}
.details-content::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 2px;
}
.details-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #555;
font-size: 10px;
}
/* Details Grid */
.details-grid {
display: flex;
flex-direction: column;
gap: 1px;
}
.details-section {
font-size: 9px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 0 3px 0;
margin-top: 6px;
border-top: 1px solid #333;
}
.details-section:first-child {
margin-top: 0;
border-top: none;
padding-top: 0;
}
.detail-row {
display: flex;
align-items: flex-start;
padding: 3px 0;
font-size: 10px;
}
.detail-row .detail-label {
width: 100px;
color: #888;
flex-shrink: 0;
}
.detail-row .detail-value {
flex: 1;
color: #ccc;
font-family: 'Consolas', monospace;
word-break: break-all;
}
.detail-row.highlight .detail-value {
color: #4fc3f7;
font-weight: 600;
}
/* ==================== Stats Bar ==================== */
.render-debug-stats {
display: flex;
align-items: center;
gap: 16px;
padding: 6px 12px;
background: #262626;
border-top: 1px solid #1a1a1a;
flex-shrink: 0;
}
.render-debug-stats .stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #888;
}
.render-debug-stats .stat-item svg {
color: #4a9eff;
}
/* ==================== Resize Handle ==================== */
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: se-resize;
background: linear-gradient(135deg, transparent 50%, #3a3a3a 50%);
border-radius: 0 0 6px 0;
}
.resize-handle:hover {
background: linear-gradient(135deg, transparent 50%, #4a9eff 50%);
}
/* ==================== TextureSheet Preview ==================== */
.texture-sheet-preview {
margin-top: 8px;
border-radius: 4px;
overflow: hidden;
background: #1a1a1a;
border: 1px solid #333;
}
.texture-sheet-preview canvas {
display: block;
width: 100%;
height: auto;
}
/* ==================== Texture Preview ==================== */
.texture-preview-row {
display: flex;
align-items: flex-start;
padding: 3px 0;
font-size: 10px;
}
.texture-preview-row .detail-label {
width: 100px;
color: #888;
flex-shrink: 0;
}
.texture-preview-content {
flex: 1;
min-width: 0;
}
.texture-thumbnail-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.texture-thumbnail {
max-width: 100%;
max-height: 80px;
object-fit: contain;
border-radius: 3px;
border: 1px solid #333;
background: repeating-conic-gradient(#2a2a2a 0% 25%, #1a1a1a 0% 50%) 50% / 8px 8px;
}
.texture-path {
font-family: 'Consolas', monospace;
font-size: 9px;
color: #666;
word-break: break-all;
line-height: 1.3;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2 } from 'lucide-react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react';
import { convertFileSrc } from '@tauri-apps/api/core';
import { Core } from '@esengine/ecs-framework';
import { AssetRegistryService } from '@esengine/editor-core';
import type { ISpriteSettings } from '@esengine/asset-system-editor';
import { EngineService } from '../../../services/EngineService';
import { AssetFileInfo } from '../types';
import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common';
@@ -50,6 +51,165 @@ function formatDate(timestamp?: number): string {
});
}
/**
* Sprite Settings Editor Component
* 精灵设置编辑器组件
*
* Allows editing nine-patch slice borders for texture assets.
* 允许编辑纹理资源的九宫格切片边框。
*/
interface SpriteSettingsEditorProps {
filePath: string;
imageSrc: string;
initialSettings?: ISpriteSettings;
onSettingsChange: (settings: ISpriteSettings) => void;
}
function SpriteSettingsEditor({ filePath, imageSrc, initialSettings, onSettingsChange }: SpriteSettingsEditorProps) {
const [sliceBorder, setSliceBorder] = useState<[number, number, number, number]>(
initialSettings?.sliceBorder || [0, 0, 0, 0]
);
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Sync sliceBorder state when initialSettings changes (async load)
// 当 initialSettings 变化时同步 sliceBorder 状态(异步加载)
useEffect(() => {
if (initialSettings?.sliceBorder) {
setSliceBorder(initialSettings.sliceBorder);
}
}, [initialSettings?.sliceBorder]);
// Load image to get dimensions
// 加载图像以获取尺寸
useEffect(() => {
const img = new Image();
img.onload = () => {
setImageSize({ width: img.width, height: img.height });
};
img.src = imageSrc;
}, [imageSrc]);
// Draw slice preview
// 绘制切片预览
useEffect(() => {
if (!canvasRef.current || !imageSize) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = new Image();
img.onload = () => {
// Calculate scale to fit canvas
// 计算缩放以适应画布
const maxSize = 200;
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
const displayWidth = img.width * scale;
const displayHeight = img.height * scale;
canvas.width = displayWidth;
canvas.height = displayHeight;
// Draw image
// 绘制图像
ctx.drawImage(img, 0, 0, displayWidth, displayHeight);
// Draw slice lines
// 绘制切片线
const [top, right, bottom, left] = sliceBorder;
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
// Top line
if (top > 0) {
ctx.beginPath();
ctx.moveTo(0, top * scale);
ctx.lineTo(displayWidth, top * scale);
ctx.stroke();
}
// Bottom line
if (bottom > 0) {
ctx.beginPath();
ctx.moveTo(0, displayHeight - bottom * scale);
ctx.lineTo(displayWidth, displayHeight - bottom * scale);
ctx.stroke();
}
// Left line
if (left > 0) {
ctx.beginPath();
ctx.moveTo(left * scale, 0);
ctx.lineTo(left * scale, displayHeight);
ctx.stroke();
}
// Right line
if (right > 0) {
ctx.beginPath();
ctx.moveTo(displayWidth - right * scale, 0);
ctx.lineTo(displayWidth - right * scale, displayHeight);
ctx.stroke();
}
};
img.src = imageSrc;
}, [imageSrc, imageSize, sliceBorder]);
const handleSliceChange = (index: number, value: number) => {
const newSlice = [...sliceBorder] as [number, number, number, number];
newSlice[index] = Math.max(0, value);
setSliceBorder(newSlice);
onSettingsChange({ ...initialSettings, sliceBorder: newSlice });
};
const labels = ['Top', 'Right', 'Bottom', 'Left'];
const labelsCN = ['上', '右', '下', '左'];
return (
<div className="sprite-settings-editor">
{/* Slice Preview Canvas */}
<div style={{ marginBottom: '12px', textAlign: 'center' }}>
<canvas
ref={canvasRef}
style={{
border: '1px solid #444',
borderRadius: '4px',
maxWidth: '100%'
}}
/>
{imageSize && (
<div style={{ fontSize: '11px', color: '#888', marginTop: '4px' }}>
{imageSize.width} × {imageSize.height} px
</div>
)}
</div>
{/* Slice Border Inputs */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{sliceBorder.map((value, index) => (
<div key={index} className="property-field" style={{ marginBottom: '0' }}>
<label className="property-label" style={{ minWidth: '50px' }}>
{labelsCN[index]} ({labels[index]})
</label>
<input
type="number"
value={value}
onChange={(e) => handleSliceChange(index, parseInt(e.target.value) || 0)}
min={0}
max={imageSize ? (index % 2 === 0 ? imageSize.height : imageSize.width) : 9999}
className="property-input property-input-number"
style={{ width: '60px' }}
/>
</div>
))}
</div>
</div>
);
}
export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInspectorProps) {
const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon;
const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9';
@@ -60,6 +220,10 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
const [detectedType, setDetectedType] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
// State for sprite settings (nine-patch borders)
// 精灵设置状态(九宫格边框)
const [spriteSettings, setSpriteSettings] = useState<ISpriteSettings | undefined>(undefined);
// Load meta info and available loader types
useEffect(() => {
if (fileInfo.isDirectory) return;
@@ -76,6 +240,14 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
setCurrentLoaderType(meta.loaderType || null);
setDetectedType(meta.type);
// Get sprite settings from meta (for texture assets)
// 从 meta 获取精灵设置(用于纹理资源)
if (meta.importSettings?.spriteSettings) {
setSpriteSettings(meta.importSettings.spriteSettings as ISpriteSettings);
} else {
setSpriteSettings(undefined);
}
// Get available loader types from assetManager
const assetManager = EngineService.getInstance().getAssetManager();
const loaderFactory = assetManager?.getLoaderFactory();
@@ -117,6 +289,39 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
}
}, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]);
// Handle sprite settings change
// 处理精灵设置更改
const handleSpriteSettingsChange = useCallback(async (newSettings: ISpriteSettings) => {
if (fileInfo.isDirectory || isUpdating) return;
setIsUpdating(true);
try {
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
if (!assetRegistry?.isReady) return;
const metaManager = assetRegistry.metaManager;
const meta = await metaManager.getOrCreateMeta(fileInfo.path);
// Update meta with new sprite settings
// 使用新的精灵设置更新 meta
const updatedImportSettings = {
...meta.importSettings,
spriteSettings: newSettings
};
await metaManager.updateMeta(fileInfo.path, {
importSettings: updatedImportSettings
});
setSpriteSettings(newSettings);
console.log(`[AssetFileInspector] Updated sprite settings for ${fileInfo.name}:`, newSettings);
} catch (error) {
console.error('Failed to update sprite settings:', error);
} finally {
setIsUpdating(false);
}
}, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]);
return (
<div className="entity-inspector">
<div className="inspector-header">
@@ -228,6 +433,23 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
</div>
)}
{/* Sprite Settings Section - only for image files */}
{/* 精灵设置部分 - 仅用于图像文件 */}
{isImage && (
<div className="inspector-section">
<div className="section-title">
<Grid3X3 size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
(Nine-Patch)
</div>
<SpriteSettingsEditor
filePath={fileInfo.path}
imageSrc={convertFileSrc(fileInfo.path)}
initialSettings={spriteSettings}
onSettingsChange={handleSpriteSettingsChange}
/>
</div>
)}
{content && (
<div className="inspector-section code-preview-section">
<div className="section-title"></div>