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:
@@ -254,6 +254,25 @@ pub fn read_file_as_base64(file_path: String) -> Result<String, String> {
|
||||
Ok(general_purpose::STANDARD.encode(&file_content))
|
||||
}
|
||||
|
||||
/// Get file modification time (milliseconds since UNIX epoch)
|
||||
/// 获取文件修改时间(Unix 纪元以来的毫秒数)
|
||||
#[tauri::command]
|
||||
pub fn get_file_mtime(path: String) -> Result<u64, String> {
|
||||
let metadata = fs::metadata(&path)
|
||||
.map_err(|e| format!("Failed to get metadata for {}: {}", path, e))?;
|
||||
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.map_err(|e| format!("Failed to get modified time for {}: {}", path, e))?;
|
||||
|
||||
let millis = modified
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| format!("Time error: {}", e))?
|
||||
.as_millis() as u64;
|
||||
|
||||
Ok(millis)
|
||||
}
|
||||
|
||||
/// Copy file from source to destination
|
||||
#[tauri::command]
|
||||
pub fn copy_file(src: String, dst: String) -> Result<(), String> {
|
||||
|
||||
@@ -65,6 +65,7 @@ fn main() {
|
||||
commands::scan_directory,
|
||||
commands::read_file_as_base64,
|
||||
commands::copy_file,
|
||||
commands::get_file_mtime,
|
||||
// Dialog operations
|
||||
commands::open_folder_dialog,
|
||||
commands::open_file_dialog,
|
||||
@@ -183,18 +184,27 @@ fn handle_project_protocol(
|
||||
}
|
||||
|
||||
/// Get MIME type based on file extension
|
||||
/// 根据文件扩展名获取 MIME 类型
|
||||
fn get_mime_type(file_path: &str) -> &'static str {
|
||||
if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".js") {
|
||||
} else if file_path.ends_with(".js") || file_path.ends_with(".mjs") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else if file_path.ends_with(".wasm") {
|
||||
"application/wasm"
|
||||
} else if file_path.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if file_path.ends_with(".html") {
|
||||
"text/html"
|
||||
} else if file_path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else if file_path.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else {
|
||||
"text/plain"
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
{
|
||||
"identifier": "main",
|
||||
"windows": [
|
||||
"main"
|
||||
"main",
|
||||
"frame-debugger"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
@@ -91,6 +92,9 @@
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"shell:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
|
||||
@@ -40,11 +40,15 @@ import { Inspector } from './components/inspectors/Inspector';
|
||||
import { AssetBrowser } from './components/AssetBrowser';
|
||||
import { Viewport } from './components/Viewport';
|
||||
import { AdvancedProfilerWindow } from './components/AdvancedProfilerWindow';
|
||||
import { RenderDebugPanel } from './components/debug/RenderDebugPanel';
|
||||
import { emit, emitTo, listen } from '@tauri-apps/api/event';
|
||||
import { renderDebugService } from './services/RenderDebugService';
|
||||
import { PortManager } from './components/PortManager';
|
||||
import { SettingsWindow } from './components/SettingsWindow';
|
||||
import { AboutDialog } from './components/AboutDialog';
|
||||
import { ErrorDialog } from './components/ErrorDialog';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { ExternalModificationDialog } from './components/ExternalModificationDialog';
|
||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
|
||||
import { ForumPanel } from './components/forum';
|
||||
@@ -63,6 +67,7 @@ import { useLocale } from './hooks/useLocale';
|
||||
import { useStoreSubscriptions } from './hooks/useStoreSubscriptions';
|
||||
import { en, zh, es } from './locales';
|
||||
import type { Locale } from '@esengine/editor-core';
|
||||
import { UserCodeService } from '@esengine/editor-core';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import './styles/App.css';
|
||||
|
||||
@@ -84,12 +89,24 @@ Core.services.registerInstance(ICompilerRegistry, compilerRegistryInstance);
|
||||
|
||||
const logger = createLogger('App');
|
||||
|
||||
// 检查是否为独立窗口模式 | Check if standalone window mode
|
||||
const isFrameDebuggerMode = new URLSearchParams(window.location.search).get('mode') === 'frame-debugger';
|
||||
|
||||
function App() {
|
||||
const initRef = useRef(false);
|
||||
const layoutContainerRef = useRef<FlexLayoutDockContainerHandle>(null);
|
||||
const [pluginLoader] = useState(() => new PluginLoader());
|
||||
const { showToast, hideToast } = useToast();
|
||||
|
||||
// 如果是独立调试窗口模式,只渲染调试面板 | If standalone debugger mode, only render debug panel
|
||||
if (isFrameDebuggerMode) {
|
||||
return (
|
||||
<div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
||||
<RenderDebugPanel visible={true} onClose={() => window.close()} standalone />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 本地初始化状态(只用于初始化阶段)| Local init state (only for initialization) =====
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
@@ -170,10 +187,40 @@ function App() {
|
||||
showAbout, setShowAbout,
|
||||
showPluginGenerator, setShowPluginGenerator,
|
||||
showBuildSettings, setShowBuildSettings,
|
||||
showRenderDebug, setShowRenderDebug,
|
||||
errorDialog, setErrorDialog,
|
||||
confirmDialog, setConfirmDialog
|
||||
confirmDialog, setConfirmDialog,
|
||||
externalModificationDialog, setExternalModificationDialog
|
||||
} = useDialogStore();
|
||||
|
||||
// 全局监听独立调试窗口的数据请求 | Global listener for standalone debug window requests
|
||||
useEffect(() => {
|
||||
let broadcastInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const unlistenPromise = listen('render-debug-request-data', () => {
|
||||
// 开始定时广播数据 | Start broadcasting data periodically
|
||||
if (!broadcastInterval) {
|
||||
const broadcast = () => {
|
||||
renderDebugService.setEnabled(true);
|
||||
const snap = renderDebugService.collectSnapshot();
|
||||
if (snap) {
|
||||
// 使用 emitTo 发送到独立窗口 | Use emitTo to send to standalone window
|
||||
emitTo('frame-debugger', 'render-debug-snapshot', snap).catch(() => {});
|
||||
}
|
||||
};
|
||||
broadcast(); // 立即广播一次 | Broadcast immediately
|
||||
broadcastInterval = setInterval(broadcast, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenPromise.then(unlisten => unlisten());
|
||||
if (broadcastInterval) {
|
||||
clearInterval(broadcastInterval);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 禁用默认右键菜单
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
@@ -483,6 +530,113 @@ function App() {
|
||||
};
|
||||
}, [initialized]);
|
||||
|
||||
// Handle external scene file changes
|
||||
// 处理外部场景文件变更
|
||||
useEffect(() => {
|
||||
if (!initialized || !messageHubRef.current || !sceneManagerRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
const sm = sceneManagerRef.current;
|
||||
|
||||
const unsubscribe = hub.subscribe('scene:external-change', (data: {
|
||||
path: string;
|
||||
sceneName: string;
|
||||
}) => {
|
||||
logger.info('Scene externally modified:', data.path);
|
||||
|
||||
// Show confirmation dialog to reload the scene
|
||||
// 显示确认对话框以重新加载场景
|
||||
setConfirmDialog({
|
||||
title: t('scene.externalChange.title'),
|
||||
message: t('scene.externalChange.message', { name: data.sceneName }),
|
||||
confirmText: t('scene.externalChange.reload'),
|
||||
cancelText: t('scene.externalChange.ignore'),
|
||||
onConfirm: async () => {
|
||||
setConfirmDialog(null);
|
||||
try {
|
||||
await sm.openScene(data.path);
|
||||
showToast(t('scene.reloadedSuccess', { name: data.sceneName }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to reload scene:', error);
|
||||
showToast(t('scene.reloadFailed'), 'error');
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
// User chose to ignore, do nothing
|
||||
// 用户选择忽略,不做任何操作
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [initialized, t, showToast]);
|
||||
|
||||
// Handle external modification when saving scene
|
||||
// 处理保存场景时的外部修改检测
|
||||
useEffect(() => {
|
||||
if (!initialized || !messageHubRef.current || !sceneManagerRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
const sm = sceneManagerRef.current;
|
||||
|
||||
const unsubscribe = hub.subscribe('scene:externalModification', (data: {
|
||||
path: string;
|
||||
sceneName: string;
|
||||
}) => {
|
||||
logger.info('Scene file externally modified during save:', data.path);
|
||||
|
||||
// Show external modification dialog with three options
|
||||
// 显示外部修改对话框,提供三个选项
|
||||
setExternalModificationDialog({
|
||||
sceneName: data.sceneName,
|
||||
onReload: async () => {
|
||||
setExternalModificationDialog(null);
|
||||
try {
|
||||
await sm.reloadScene();
|
||||
showToast(t('scene.reloadedSuccess', { name: data.sceneName }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to reload scene:', error);
|
||||
showToast(t('scene.reloadFailed'), 'error');
|
||||
}
|
||||
},
|
||||
onOverwrite: async () => {
|
||||
setExternalModificationDialog(null);
|
||||
try {
|
||||
await sm.saveScene(true); // Force save, overwriting external changes
|
||||
showToast(t('scene.savedSuccess', { name: data.sceneName }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to save scene:', error);
|
||||
showToast(t('scene.saveFailed'), 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [initialized, t, showToast, setExternalModificationDialog]);
|
||||
|
||||
// Handle user code compilation results
|
||||
// 处理用户代码编译结果
|
||||
useEffect(() => {
|
||||
if (!initialized || !messageHubRef.current) return;
|
||||
const hub = messageHubRef.current;
|
||||
|
||||
const unsubscribe = hub.subscribe('usercode:compilation-result', (data: {
|
||||
success: boolean;
|
||||
exports: string[];
|
||||
errors: string[];
|
||||
}) => {
|
||||
if (data.success) {
|
||||
if (data.exports.length > 0) {
|
||||
showToast(t('usercode.compileSuccess', { count: data.exports.length }), 'success');
|
||||
}
|
||||
} else {
|
||||
const errorMsg = data.errors[0] ?? t('usercode.compileError');
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [initialized, t, showToast]);
|
||||
|
||||
const handleOpenRecentProject = async (projectPath: string) => {
|
||||
try {
|
||||
setIsLoading(true, t('loading.step1'));
|
||||
@@ -523,7 +677,6 @@ function App() {
|
||||
const sceneFiles = await TauriAPI.scanDirectory(`${projectPath}/scenes`, '*.ecs');
|
||||
const sceneNames = sceneFiles.map(f => `scenes/${f.split(/[\\/]/).pop()}`);
|
||||
setAvailableScenes(sceneNames);
|
||||
console.log('[App] Found scenes:', sceneNames);
|
||||
} catch (e) {
|
||||
console.warn('[App] Failed to scan scenes:', e);
|
||||
}
|
||||
@@ -545,12 +698,8 @@ function App() {
|
||||
// Load project plugin config and activate plugins (after engine init, before module system init)
|
||||
if (pluginManagerRef.current) {
|
||||
const pluginSettings = projectService.getPluginSettings();
|
||||
console.log('[App] Plugin settings from project:', pluginSettings);
|
||||
if (pluginSettings && pluginSettings.enabledPlugins.length > 0) {
|
||||
console.log('[App] Loading plugin config:', pluginSettings.enabledPlugins);
|
||||
await pluginManagerRef.current.loadConfig({ enabledPlugins: pluginSettings.enabledPlugins });
|
||||
} else {
|
||||
console.log('[App] No plugin settings found in project config');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +715,13 @@ function App() {
|
||||
|
||||
setIsLoading(true, t('loading.step3'));
|
||||
|
||||
// Wait for user code to be compiled and registered before loading scenes
|
||||
// 等待用户代码编译和注册完成后再加载场景
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
await userCodeService.waitForReady();
|
||||
}
|
||||
|
||||
const sceneManagerService = Core.services.resolve(SceneManagerService);
|
||||
if (sceneManagerService) {
|
||||
await sceneManagerService.newScene();
|
||||
@@ -696,6 +852,13 @@ function App() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for user code to be ready before loading scene
|
||||
// 在加载场景前等待用户代码就绪
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
await userCodeService.waitForReady();
|
||||
}
|
||||
|
||||
await sceneManager.openScene();
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
|
||||
@@ -706,13 +869,25 @@ function App() {
|
||||
};
|
||||
|
||||
const handleOpenSceneByPath = useCallback(async (scenePath: string) => {
|
||||
console.log('[App] handleOpenSceneByPath called:', scenePath);
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for user code to be ready before loading scene
|
||||
// 在加载场景前等待用户代码就绪
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
console.log('[App] Waiting for user code service...');
|
||||
await userCodeService.waitForReady();
|
||||
console.log('[App] User code service ready');
|
||||
}
|
||||
|
||||
console.log('[App] Calling sceneManager.openScene...');
|
||||
await sceneManager.openScene(scenePath);
|
||||
console.log('[App] Scene opened successfully');
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
|
||||
} catch (error) {
|
||||
@@ -1087,6 +1262,14 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{externalModificationDialog && (
|
||||
<ExternalModificationDialog
|
||||
sceneName={externalModificationDialog.sceneName}
|
||||
onReload={externalModificationDialog.onReload}
|
||||
onOverwrite={externalModificationDialog.onOverwrite}
|
||||
onCancel={() => setExternalModificationDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1121,6 +1304,7 @@ function App() {
|
||||
onCreatePlugin={handleCreatePlugin}
|
||||
onReloadPlugins={handleReloadPlugins}
|
||||
onOpenBuildSettings={() => setShowBuildSettings(true)}
|
||||
onOpenRenderDebug={() => setShowRenderDebug(true)}
|
||||
/>
|
||||
<MainToolbar
|
||||
messageHub={messageHub || undefined}
|
||||
@@ -1226,6 +1410,12 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 渲染调试面板 | Render Debug Panel */}
|
||||
<RenderDebugPanel
|
||||
visible={showRenderDebug}
|
||||
onClose={() => setShowRenderDebug(false)}
|
||||
/>
|
||||
|
||||
{errorDialog && (
|
||||
<ErrorDialog
|
||||
title={errorDialog.title}
|
||||
@@ -1252,6 +1442,15 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{externalModificationDialog && (
|
||||
<ExternalModificationDialog
|
||||
sceneName={externalModificationDialog.sceneName}
|
||||
onReload={externalModificationDialog.onReload}
|
||||
onOverwrite={externalModificationDialog.onOverwrite}
|
||||
onCancel={() => setExternalModificationDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,4 +38,8 @@ export class TauriFileAPI implements IFileAPI {
|
||||
public async pathExists(path: string): Promise<boolean> {
|
||||
return await TauriAPI.pathExists(path);
|
||||
}
|
||||
|
||||
public async getFileMtime(path: string): Promise<number> {
|
||||
return await TauriAPI.getFileMtime(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,17 @@ export class TauriAPI {
|
||||
return await invoke<void>('copy_file', { src, dst });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件修改时间
|
||||
* Get file modification time
|
||||
*
|
||||
* @param path 文件路径 | File path
|
||||
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
|
||||
*/
|
||||
static async getFileMtime(path: string): Promise<number> {
|
||||
return await invoke<number>('get_file_mtime', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入二进制文件
|
||||
* @param filePath 文件路径
|
||||
|
||||
@@ -6,6 +6,16 @@ interface ErrorDialogData {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部修改对话框数据
|
||||
* External modification dialog data
|
||||
*/
|
||||
export interface ExternalModificationDialogData {
|
||||
sceneName: string;
|
||||
onReload: () => void;
|
||||
onOverwrite: () => void;
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
showProfiler: boolean;
|
||||
showAdvancedProfiler: boolean;
|
||||
@@ -14,8 +24,10 @@ interface DialogState {
|
||||
showAbout: boolean;
|
||||
showPluginGenerator: boolean;
|
||||
showBuildSettings: boolean;
|
||||
showRenderDebug: boolean;
|
||||
errorDialog: ErrorDialogData | null;
|
||||
confirmDialog: ConfirmDialogData | null;
|
||||
externalModificationDialog: ExternalModificationDialogData | null;
|
||||
|
||||
setShowProfiler: (show: boolean) => void;
|
||||
setShowAdvancedProfiler: (show: boolean) => void;
|
||||
@@ -24,8 +36,10 @@ interface DialogState {
|
||||
setShowAbout: (show: boolean) => void;
|
||||
setShowPluginGenerator: (show: boolean) => void;
|
||||
setShowBuildSettings: (show: boolean) => void;
|
||||
setShowRenderDebug: (show: boolean) => void;
|
||||
setErrorDialog: (data: ErrorDialogData | null) => void;
|
||||
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
||||
setExternalModificationDialog: (data: ExternalModificationDialogData | null) => void;
|
||||
closeAllDialogs: () => void;
|
||||
}
|
||||
|
||||
@@ -37,8 +51,10 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
showRenderDebug: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
externalModificationDialog: null,
|
||||
|
||||
setShowProfiler: (show) => set({ showProfiler: show }),
|
||||
setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }),
|
||||
@@ -47,8 +63,10 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
setShowAbout: (show) => set({ showAbout: show }),
|
||||
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
||||
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
|
||||
setShowRenderDebug: (show) => set({ showRenderDebug: show }),
|
||||
setErrorDialog: (data) => set({ errorDialog: data }),
|
||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||
setExternalModificationDialog: (data) => set({ externalModificationDialog: data }),
|
||||
|
||||
closeAllDialogs: () => set({
|
||||
showProfiler: false,
|
||||
@@ -58,7 +76,9 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
showRenderDebug: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null
|
||||
confirmDialog: null,
|
||||
externalModificationDialog: null
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Core, ComponentRegistry as CoreComponentRegistry, PrefabSerializer, ComponentRegistry as ECSComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { Core, GlobalComponentRegistry, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { ComponentType } from '@esengine/ecs-framework';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
@@ -136,8 +136,8 @@ export class ServiceRegistry {
|
||||
|
||||
for (const comp of standardComponents) {
|
||||
// Register to editor registry for UI
|
||||
// 组件已通过 @ECSComponent 装饰器自动注册到 CoreComponentRegistry
|
||||
// Components are auto-registered to CoreComponentRegistry via @ECSComponent decorator
|
||||
// 组件已通过 @ECSComponent 装饰器自动注册到 GlobalComponentRegistry
|
||||
// Components are auto-registered to GlobalComponentRegistry via @ECSComponent decorator
|
||||
componentRegistry.register({
|
||||
name: comp.editorName,
|
||||
type: comp.type,
|
||||
@@ -149,7 +149,7 @@ export class ServiceRegistry {
|
||||
|
||||
// Enable hot reload for editor environment
|
||||
// 在编辑器环境中启用热更新
|
||||
CoreComponentRegistry.enableHotReload();
|
||||
GlobalComponentRegistry.enableHotReload();
|
||||
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
@@ -340,8 +340,14 @@ export class ServiceRegistry {
|
||||
// 编辑器脚本编译错误只记录,不影响运行时
|
||||
console.warn('[UserCodeService] Editor compilation errors:', editorResult.errors);
|
||||
}
|
||||
|
||||
// 编译完成,发出就绪信号 | Compilation done, signal ready
|
||||
userCodeService.signalReady();
|
||||
} catch (error) {
|
||||
console.error('[UserCodeService] Failed to compile/load:', error);
|
||||
// 即使编译失败也要发出就绪信号,避免阻塞场景加载
|
||||
// Signal ready even on failure to avoid blocking scene loading
|
||||
userCodeService.signalReady();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Creates an entity instance from a prefab asset.
|
||||
*/
|
||||
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
@@ -50,9 +50,9 @@ export class InstantiatePrefabCommand extends BaseCommand {
|
||||
}
|
||||
|
||||
// 获取组件注册表 | Get component registry
|
||||
// ComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||
// GlobalComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||
// We need to cast it to Map<string, ComponentType>
|
||||
const componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
const componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
|
||||
// 实例化预制体 | Instantiate prefab
|
||||
this.createdEntity = PrefabSerializer.instantiate(
|
||||
|
||||
@@ -1026,13 +1026,16 @@ export class ${className} {
|
||||
|
||||
// Handle asset double click
|
||||
const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => {
|
||||
console.log('[ContentBrowser] Double click:', asset.name, 'type:', asset.type, 'ext:', asset.extension);
|
||||
if (asset.type === 'folder') {
|
||||
setCurrentPath(asset.path);
|
||||
loadAssets(asset.path);
|
||||
setExpandedFolders(prev => new Set([...prev, asset.path]));
|
||||
} else {
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
console.log('[ContentBrowser] File ext:', ext, 'onOpenScene:', !!onOpenScene);
|
||||
if (ext === 'ecs' && onOpenScene) {
|
||||
console.log('[ContentBrowser] Opening scene:', asset.path);
|
||||
onOpenScene(asset.path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AlertTriangle, X, RefreshCw, Save } from 'lucide-react';
|
||||
import '../styles/ConfirmDialog.css';
|
||||
|
||||
interface ExternalModificationDialogProps {
|
||||
sceneName: string;
|
||||
onReload: () => void;
|
||||
onOverwrite: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部修改对话框
|
||||
* External Modification Dialog
|
||||
*
|
||||
* 当场景文件被外部修改时显示,让用户选择操作
|
||||
* Shown when scene file is modified externally, let user choose action
|
||||
*/
|
||||
export function ExternalModificationDialog({
|
||||
sceneName,
|
||||
onReload,
|
||||
onOverwrite,
|
||||
onCancel
|
||||
}: ExternalModificationDialogProps) {
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog external-modification-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="confirm-dialog-header">
|
||||
<AlertTriangle size={20} className="warning-icon" />
|
||||
<h2>文件已被外部修改</h2>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="confirm-dialog-content">
|
||||
<p>
|
||||
场景 <strong>{sceneName}</strong> 已在编辑器外部被修改。
|
||||
</p>
|
||||
<p className="hint-text">
|
||||
请选择如何处理:
|
||||
</p>
|
||||
</div>
|
||||
<div className="confirm-dialog-footer external-modification-footer">
|
||||
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="confirm-dialog-btn reload" onClick={onReload}>
|
||||
<RefreshCw size={14} />
|
||||
重新加载
|
||||
</button>
|
||||
<button className="confirm-dialog-btn overwrite" onClick={onOverwrite}>
|
||||
<Save size={14} />
|
||||
覆盖保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ interface TitleBarProps {
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
onOpenRenderDebug?: () => void;
|
||||
}
|
||||
|
||||
export function TitleBar({
|
||||
@@ -61,7 +62,8 @@ export function TitleBar({
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings
|
||||
onOpenBuildSettings,
|
||||
onOpenRenderDebug
|
||||
}: TitleBarProps) {
|
||||
const { t } = useLocale();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
@@ -197,6 +199,7 @@ export function TitleBar({
|
||||
{ label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
|
||||
{ separator: true },
|
||||
{ label: t('menu.tools.portManager'), onClick: onOpenPortManager },
|
||||
{ label: t('menu.tools.renderDebug'), onClick: onOpenRenderDebug },
|
||||
{ separator: true },
|
||||
{ label: t('menu.tools.settings'), onClick: onOpenSettings }
|
||||
],
|
||||
|
||||
@@ -8,9 +8,9 @@ import '../styles/Viewport.css';
|
||||
import { useEngine } from '../hooks/useEngine';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
import { Core, Entity, SceneSerializer, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, SceneSerializer, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService } from '@esengine/editor-core';
|
||||
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget } from '@esengine/editor-core';
|
||||
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
|
||||
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
@@ -21,6 +21,8 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||
import { QRCodeDialog } from './QRCodeDialog';
|
||||
import { collectAssetReferences } from '@esengine/asset-system';
|
||||
import { RuntimeSceneManager, type IRuntimeSceneManager } from '@esengine/runtime-core';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
|
||||
import type { ModuleManifest } from '../services/RuntimeResolver';
|
||||
|
||||
@@ -264,6 +266,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
|
||||
const playStateRef = useRef<PlayState>('stopped');
|
||||
|
||||
// Runtime scene manager for play mode scene switching | Play 模式场景切换管理器
|
||||
const runtimeSceneManagerRef = useRef<IRuntimeSceneManager | null>(null);
|
||||
|
||||
// Live transform display state | 实时变换显示状态
|
||||
const [liveTransform, setLiveTransform] = useState<{
|
||||
type: 'move' | 'rotate' | 'scale';
|
||||
@@ -811,7 +816,22 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
return;
|
||||
}
|
||||
// Save scene snapshot before playing
|
||||
// saveSceneSnapshot clears all textures, so we need to reset particle textureIds after
|
||||
// saveSceneSnapshot 会清除所有纹理,所以之后需要重置粒子的 textureId
|
||||
EngineService.getInstance().saveSceneSnapshot();
|
||||
|
||||
// Reset particle component textureIds after snapshot (textures were cleared)
|
||||
// 快照后重置粒子组件的 textureId(纹理已被清除)
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
for (const entity of scene.entities.buffer) {
|
||||
const particleComponent = entity.getComponent(ParticleSystemComponent);
|
||||
if (particleComponent) {
|
||||
particleComponent.textureId = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save editor camera state
|
||||
editorCameraRef.current = { x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom };
|
||||
setPlayState('playing');
|
||||
@@ -820,6 +840,132 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
EngineService.getInstance().setEditorMode(false);
|
||||
// Switch to player camera
|
||||
syncPlayerCamera();
|
||||
|
||||
// Register RuntimeSceneManager for scene switching in play mode
|
||||
// 注册 RuntimeSceneManager 以支持 Play 模式下的场景切换
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const projectPath = projectService?.getCurrentProject()?.path;
|
||||
if (projectPath) {
|
||||
// Create scene loader function that reads scene files using Tauri API
|
||||
// 创建使用 Tauri API 读取场景文件的场景加载器函数
|
||||
const editorSceneLoader = async (scenePath: string): Promise<void> => {
|
||||
try {
|
||||
// Normalize path: handle both relative and absolute paths
|
||||
// 标准化路径:处理相对路径和绝对路径
|
||||
let fullPath = scenePath;
|
||||
if (!scenePath.includes(':') && !scenePath.startsWith('/')) {
|
||||
// Relative path - construct full path
|
||||
// 相对路径 - 构建完整路径
|
||||
const normalizedPath = scenePath.replace(/^\.\//, '').replace(/\//g, '\\');
|
||||
fullPath = `${projectPath}\\${normalizedPath}`;
|
||||
} else {
|
||||
// Absolute path - normalize separators for Windows
|
||||
// 绝对路径 - 为 Windows 规范化分隔符
|
||||
fullPath = scenePath.replace(/\//g, '\\');
|
||||
}
|
||||
|
||||
// Read scene file content
|
||||
// 读取场景文件内容
|
||||
const sceneJson = await TauriAPI.readFileContent(fullPath);
|
||||
|
||||
// Validate scene data
|
||||
// 验证场景数据
|
||||
const validation = SceneSerializer.validate(sceneJson);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid scene: ${validation.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
// Save current scene snapshot (so we can go back)
|
||||
// 保存当前场景快照(以便返回)
|
||||
EngineService.getInstance().saveSceneSnapshot();
|
||||
|
||||
// Load new scene by deserializing into current scene
|
||||
// 通过反序列化加载新场景到当前场景
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
scene.deserialize(sceneJson, { strategy: 'replace' });
|
||||
|
||||
// Reset particle component textureIds after scene switch
|
||||
// 场景切换后重置粒子组件的 textureId
|
||||
// This ensures ParticleUpdateSystem will reload textures
|
||||
// 这确保 ParticleUpdateSystem 会重新加载纹理
|
||||
for (const entity of scene.entities.buffer) {
|
||||
const particleComponent = entity.getComponent(ParticleSystemComponent);
|
||||
if (particleComponent) {
|
||||
particleComponent.textureId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-register user code components and systems after scene switch
|
||||
// 场景切换后重新注册用户代码组件和系统
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime);
|
||||
if (runtimeModule) {
|
||||
// Re-register components (ensures GlobalComponentRegistry has correct references)
|
||||
// 重新注册组件(确保 GlobalComponentRegistry 有正确的引用)
|
||||
userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry);
|
||||
|
||||
// Re-register systems (recreates systems with correct component references)
|
||||
// 重新注册系统(使用正确的组件引用重建系统)
|
||||
userCodeService.registerSystems(runtimeModule, scene);
|
||||
}
|
||||
}
|
||||
|
||||
// Load scene resources (textures, etc.)
|
||||
// 加载场景资源(纹理等)
|
||||
await EngineService.getInstance().loadSceneResources();
|
||||
|
||||
// Sync entity store
|
||||
// 同步实体存储
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService);
|
||||
entityStore?.syncFromScene();
|
||||
}
|
||||
|
||||
console.log(`[Viewport] Scene loaded in play mode: ${scenePath}`);
|
||||
} catch (error) {
|
||||
console.error(`[Viewport] Failed to load scene: ${scenePath}`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Create and register RuntimeSceneManager
|
||||
// 创建并注册 RuntimeSceneManager
|
||||
const sceneManager = new RuntimeSceneManager(
|
||||
editorSceneLoader,
|
||||
`${projectPath}\\scenes`
|
||||
);
|
||||
runtimeSceneManagerRef.current = sceneManager;
|
||||
|
||||
// Register to Core.services with the global key
|
||||
// 使用全局 key 注册到 Core.services
|
||||
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
|
||||
if (!Core.services.isRegistered(GlobalSceneManagerKey)) {
|
||||
Core.services.registerInstance(GlobalSceneManagerKey, sceneManager);
|
||||
}
|
||||
|
||||
console.log('[Viewport] RuntimeSceneManager registered for play mode');
|
||||
}
|
||||
|
||||
// Register user code components and systems before starting engine
|
||||
// 在启动引擎前注册用户代码组件和系统
|
||||
const userCodeService = Core.services.tryResolve(UserCodeService);
|
||||
if (userCodeService) {
|
||||
const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime);
|
||||
if (runtimeModule) {
|
||||
// Register components first (ensures GlobalComponentRegistry has correct references)
|
||||
// 先注册组件(确保 GlobalComponentRegistry 有正确的引用)
|
||||
userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry);
|
||||
|
||||
// Then register systems (uses registered component references)
|
||||
// 然后注册系统(使用已注册的组件引用)
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
userCodeService.registerSystems(runtimeModule, scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
engine.start();
|
||||
} else if (playState === 'paused') {
|
||||
setPlayState('playing');
|
||||
@@ -837,6 +983,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const handleStop = async () => {
|
||||
setPlayState('stopped');
|
||||
engine.stop();
|
||||
|
||||
// Unregister RuntimeSceneManager
|
||||
// 注销 RuntimeSceneManager
|
||||
if (runtimeSceneManagerRef.current) {
|
||||
const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
|
||||
if (Core.services.isRegistered(GlobalSceneManagerKey)) {
|
||||
Core.services.unregister(GlobalSceneManagerKey);
|
||||
}
|
||||
runtimeSceneManagerRef.current.dispose();
|
||||
runtimeSceneManagerRef.current = null;
|
||||
console.log('[Viewport] RuntimeSceneManager unregistered');
|
||||
}
|
||||
|
||||
// Restore scene snapshot
|
||||
await EngineService.getInstance().restoreSceneSnapshot();
|
||||
// Restore editor camera state
|
||||
|
||||
633
packages/editor-app/src/components/debug/RenderDebugPanel.css
Normal file
633
packages/editor-app/src/components/debug/RenderDebugPanel.css
Normal file
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* 渲染调试面板样式 (浮动窗口)
|
||||
* Render Debug Panel Styles (Floating Window)
|
||||
*/
|
||||
|
||||
/* ==================== Floating Window ==================== */
|
||||
.render-debug-window {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.render-debug-window.dragging {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 独立窗口模式 | Standalone mode */
|
||||
.render-debug-window.standalone {
|
||||
position: relative;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.render-debug-window.standalone .window-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ==================== Window Header ==================== */
|
||||
.render-debug-window .window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
cursor: move;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-window .window-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.render-debug-window .window-title svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.render-debug-window .paused-badge {
|
||||
padding: 2px 6px;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.render-debug-window .window-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.render-debug-window .window-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.render-debug-window .window-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== Toolbar ==================== */
|
||||
.render-debug-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-left,
|
||||
.render-debug-toolbar .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 3px;
|
||||
color: #ccc;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn:hover {
|
||||
background: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn.active {
|
||||
background: #4a9eff;
|
||||
border-color: #4a9eff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn.icon-only {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn.recording {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn .record-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.render-debug-toolbar .history-badge {
|
||||
padding: 2px 6px;
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn:disabled:hover {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* ==================== Timeline ==================== */
|
||||
.render-debug-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: #222;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a9eff;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a9eff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .frame-counter {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* ==================== Main Layout ==================== */
|
||||
.render-debug-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== Left Panel (Event List) ==================== */
|
||||
.render-debug-left {
|
||||
width: 260px;
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #222;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.event-list-header .event-count {
|
||||
font-weight: 400;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.event-list::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.event-list::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.event-list::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.event-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Event Items */
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.event-item.selected {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-left: 2px solid #4a9eff;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.event-item .expand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-item .expand-icon:not(.placeholder):hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.event-item .expand-icon.placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.event-item .event-icon {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.event-item .event-icon.sprite {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.event-item .event-icon.particle {
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.event-item .event-icon.ui {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.event-item .event-icon.batch {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.event-item .event-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-item .event-draws {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
padding: 1px 3px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==================== Right Panel ==================== */
|
||||
.render-debug-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.render-debug-preview {
|
||||
height: 40%;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.preview-canvas-container {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-canvas-container canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Details Section */
|
||||
.render-debug-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.details-header {
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.details-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.details-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Details Grid */
|
||||
.details-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.details-section {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 8px 0 3px 0;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.details-section:first-child {
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 3px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.detail-row .detail-label {
|
||||
width: 100px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-row .detail-value {
|
||||
flex: 1;
|
||||
color: #ccc;
|
||||
font-family: 'Consolas', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-row.highlight .detail-value {
|
||||
color: #4fc3f7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ==================== Stats Bar ==================== */
|
||||
.render-debug-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 6px 12px;
|
||||
background: #262626;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-stats .stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.render-debug-stats .stat-item svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* ==================== Resize Handle ==================== */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: se-resize;
|
||||
background: linear-gradient(135deg, transparent 50%, #3a3a3a 50%);
|
||||
border-radius: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: linear-gradient(135deg, transparent 50%, #4a9eff 50%);
|
||||
}
|
||||
|
||||
/* ==================== TextureSheet Preview ==================== */
|
||||
.texture-sheet-preview {
|
||||
margin-top: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.texture-sheet-preview canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ==================== Texture Preview ==================== */
|
||||
.texture-preview-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 3px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.texture-preview-row .detail-label {
|
||||
width: 100px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.texture-preview-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.texture-thumbnail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.texture-thumbnail {
|
||||
max-width: 100%;
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #333;
|
||||
background: repeating-conic-gradient(#2a2a2a 0% 25%, #1a1a1a 0% 50%) 50% / 8px 8px;
|
||||
}
|
||||
|
||||
.texture-path {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
line-height: 1.3;
|
||||
}
|
||||
1059
packages/editor-app/src/components/debug/RenderDebugPanel.tsx
Normal file
1059
packages/editor-app/src/components/debug/RenderDebugPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
7
packages/editor-app/src/components/debug/index.ts
Normal file
7
packages/editor-app/src/components/debug/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 调试组件导出
|
||||
* Debug components export
|
||||
*/
|
||||
|
||||
export { RenderDebugPanel } from './RenderDebugPanel';
|
||||
export type { default as RenderDebugPanelProps } from './RenderDebugPanel';
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2 } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { AssetRegistryService } from '@esengine/editor-core';
|
||||
import type { ISpriteSettings } from '@esengine/asset-system-editor';
|
||||
import { EngineService } from '../../../services/EngineService';
|
||||
import { AssetFileInfo } from '../types';
|
||||
import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common';
|
||||
@@ -50,6 +51,165 @@ function formatDate(timestamp?: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite Settings Editor Component
|
||||
* 精灵设置编辑器组件
|
||||
*
|
||||
* Allows editing nine-patch slice borders for texture assets.
|
||||
* 允许编辑纹理资源的九宫格切片边框。
|
||||
*/
|
||||
interface SpriteSettingsEditorProps {
|
||||
filePath: string;
|
||||
imageSrc: string;
|
||||
initialSettings?: ISpriteSettings;
|
||||
onSettingsChange: (settings: ISpriteSettings) => void;
|
||||
}
|
||||
|
||||
function SpriteSettingsEditor({ filePath, imageSrc, initialSettings, onSettingsChange }: SpriteSettingsEditorProps) {
|
||||
const [sliceBorder, setSliceBorder] = useState<[number, number, number, number]>(
|
||||
initialSettings?.sliceBorder || [0, 0, 0, 0]
|
||||
);
|
||||
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Sync sliceBorder state when initialSettings changes (async load)
|
||||
// 当 initialSettings 变化时同步 sliceBorder 状态(异步加载)
|
||||
useEffect(() => {
|
||||
if (initialSettings?.sliceBorder) {
|
||||
setSliceBorder(initialSettings.sliceBorder);
|
||||
}
|
||||
}, [initialSettings?.sliceBorder]);
|
||||
|
||||
// Load image to get dimensions
|
||||
// 加载图像以获取尺寸
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageSize({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc]);
|
||||
|
||||
// Draw slice preview
|
||||
// 绘制切片预览
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !imageSize) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Calculate scale to fit canvas
|
||||
// 计算缩放以适应画布
|
||||
const maxSize = 200;
|
||||
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
|
||||
const displayWidth = img.width * scale;
|
||||
const displayHeight = img.height * scale;
|
||||
|
||||
canvas.width = displayWidth;
|
||||
canvas.height = displayHeight;
|
||||
|
||||
// Draw image
|
||||
// 绘制图像
|
||||
ctx.drawImage(img, 0, 0, displayWidth, displayHeight);
|
||||
|
||||
// Draw slice lines
|
||||
// 绘制切片线
|
||||
const [top, right, bottom, left] = sliceBorder;
|
||||
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
|
||||
// Top line
|
||||
if (top > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, top * scale);
|
||||
ctx.lineTo(displayWidth, top * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Bottom line
|
||||
if (bottom > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, displayHeight - bottom * scale);
|
||||
ctx.lineTo(displayWidth, displayHeight - bottom * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Left line
|
||||
if (left > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left * scale, 0);
|
||||
ctx.lineTo(left * scale, displayHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Right line
|
||||
if (right > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(displayWidth - right * scale, 0);
|
||||
ctx.lineTo(displayWidth - right * scale, displayHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc, imageSize, sliceBorder]);
|
||||
|
||||
const handleSliceChange = (index: number, value: number) => {
|
||||
const newSlice = [...sliceBorder] as [number, number, number, number];
|
||||
newSlice[index] = Math.max(0, value);
|
||||
setSliceBorder(newSlice);
|
||||
onSettingsChange({ ...initialSettings, sliceBorder: newSlice });
|
||||
};
|
||||
|
||||
const labels = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const labelsCN = ['上', '右', '下', '左'];
|
||||
|
||||
return (
|
||||
<div className="sprite-settings-editor">
|
||||
{/* Slice Preview Canvas */}
|
||||
<div style={{ marginBottom: '12px', textAlign: 'center' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
{imageSize && (
|
||||
<div style={{ fontSize: '11px', color: '#888', marginTop: '4px' }}>
|
||||
{imageSize.width} × {imageSize.height} px
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slice Border Inputs */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
{sliceBorder.map((value, index) => (
|
||||
<div key={index} className="property-field" style={{ marginBottom: '0' }}>
|
||||
<label className="property-label" style={{ minWidth: '50px' }}>
|
||||
{labelsCN[index]} ({labels[index]})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => handleSliceChange(index, parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
max={imageSize ? (index % 2 === 0 ? imageSize.height : imageSize.width) : 9999}
|
||||
className="property-input property-input-number"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInspectorProps) {
|
||||
const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon;
|
||||
const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9';
|
||||
@@ -60,6 +220,10 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
const [detectedType, setDetectedType] = useState<string | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// State for sprite settings (nine-patch borders)
|
||||
// 精灵设置状态(九宫格边框)
|
||||
const [spriteSettings, setSpriteSettings] = useState<ISpriteSettings | undefined>(undefined);
|
||||
|
||||
// Load meta info and available loader types
|
||||
useEffect(() => {
|
||||
if (fileInfo.isDirectory) return;
|
||||
@@ -76,6 +240,14 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
setCurrentLoaderType(meta.loaderType || null);
|
||||
setDetectedType(meta.type);
|
||||
|
||||
// Get sprite settings from meta (for texture assets)
|
||||
// 从 meta 获取精灵设置(用于纹理资源)
|
||||
if (meta.importSettings?.spriteSettings) {
|
||||
setSpriteSettings(meta.importSettings.spriteSettings as ISpriteSettings);
|
||||
} else {
|
||||
setSpriteSettings(undefined);
|
||||
}
|
||||
|
||||
// Get available loader types from assetManager
|
||||
const assetManager = EngineService.getInstance().getAssetManager();
|
||||
const loaderFactory = assetManager?.getLoaderFactory();
|
||||
@@ -117,6 +289,39 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
}
|
||||
}, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]);
|
||||
|
||||
// Handle sprite settings change
|
||||
// 处理精灵设置更改
|
||||
const handleSpriteSettingsChange = useCallback(async (newSettings: ISpriteSettings) => {
|
||||
if (fileInfo.isDirectory || isUpdating) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
if (!assetRegistry?.isReady) return;
|
||||
|
||||
const metaManager = assetRegistry.metaManager;
|
||||
const meta = await metaManager.getOrCreateMeta(fileInfo.path);
|
||||
|
||||
// Update meta with new sprite settings
|
||||
// 使用新的精灵设置更新 meta
|
||||
const updatedImportSettings = {
|
||||
...meta.importSettings,
|
||||
spriteSettings: newSettings
|
||||
};
|
||||
|
||||
await metaManager.updateMeta(fileInfo.path, {
|
||||
importSettings: updatedImportSettings
|
||||
});
|
||||
|
||||
setSpriteSettings(newSettings);
|
||||
console.log(`[AssetFileInspector] Updated sprite settings for ${fileInfo.name}:`, newSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to update sprite settings:', error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]);
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
@@ -228,6 +433,23 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprite Settings Section - only for image files */}
|
||||
{/* 精灵设置部分 - 仅用于图像文件 */}
|
||||
{isImage && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">
|
||||
<Grid3X3 size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
九宫格设置 (Nine-Patch)
|
||||
</div>
|
||||
<SpriteSettingsEditor
|
||||
filePath={fileInfo.path}
|
||||
imageSrc={convertFileSrc(fileInfo.path)}
|
||||
initialSettings={spriteSettings}
|
||||
onSettingsChange={handleSpriteSettingsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
<div className="inspector-section code-preview-section">
|
||||
<div className="section-title">文件预览</div>
|
||||
|
||||
@@ -141,7 +141,27 @@ export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<Vector4>): React.ReactElement {
|
||||
const v = value || { x: 0, y: 0, z: 0, w: 0 };
|
||||
// Support both object {x,y,z,w} and array [0,1,2,3] formats
|
||||
// 支持对象 {x,y,z,w} 和数组 [0,1,2,3] 两种格式
|
||||
let v: Vector4;
|
||||
const isArray = Array.isArray(value);
|
||||
|
||||
if (isArray) {
|
||||
const arr = value as unknown as number[];
|
||||
v = { x: arr[0] ?? 0, y: arr[1] ?? 0, z: arr[2] ?? 0, w: arr[3] ?? 0 };
|
||||
} else {
|
||||
v = value || { x: 0, y: 0, z: 0, w: 0 };
|
||||
}
|
||||
|
||||
const handleChange = (newV: Vector4) => {
|
||||
if (isArray) {
|
||||
// Return as array if input was array
|
||||
// 如果输入是数组,则返回数组
|
||||
onChange([newV.x, newV.y, newV.z, newV.w] as unknown as Vector4);
|
||||
} else {
|
||||
onChange(newV);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
@@ -150,28 +170,28 @@ export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
||||
<VectorInput
|
||||
label="X"
|
||||
value={v.x}
|
||||
onChange={(x) => onChange({ ...v, x })}
|
||||
onChange={(x) => handleChange({ ...v, x })}
|
||||
readonly={context.readonly}
|
||||
axis="x"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Y"
|
||||
value={v.y}
|
||||
onChange={(y) => onChange({ ...v, y })}
|
||||
onChange={(y) => handleChange({ ...v, y })}
|
||||
readonly={context.readonly}
|
||||
axis="y"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Z"
|
||||
value={v.z}
|
||||
onChange={(z) => onChange({ ...v, z })}
|
||||
onChange={(z) => handleChange({ ...v, z })}
|
||||
readonly={context.readonly}
|
||||
axis="z"
|
||||
/>
|
||||
<VectorInput
|
||||
label="W"
|
||||
value={v.w}
|
||||
onChange={(w) => onChange({ ...v, w })}
|
||||
onChange={(w) => handleChange({ ...v, w })}
|
||||
readonly={context.readonly}
|
||||
axis="w"
|
||||
/>
|
||||
|
||||
@@ -306,7 +306,15 @@ export const en: Translations = {
|
||||
openFailed: 'Failed to open scene',
|
||||
savedSuccess: 'Scene saved: {{name}}',
|
||||
saveFailed: 'Failed to save scene',
|
||||
saveAsFailed: 'Failed to save scene as'
|
||||
saveAsFailed: 'Failed to save scene as',
|
||||
reloadedSuccess: 'Scene reloaded: {{name}}',
|
||||
reloadFailed: 'Failed to reload scene',
|
||||
externalChange: {
|
||||
title: 'Scene Changed',
|
||||
message: 'Scene "{{name}}" has been modified externally. Do you want to reload?',
|
||||
reload: 'Reload',
|
||||
ignore: 'Ignore'
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -371,6 +379,15 @@ export const en: Translations = {
|
||||
dependencies: 'Dependencies'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// User Code
|
||||
// ========================================
|
||||
usercode: {
|
||||
compileSuccess: 'Scripts compiled ({{count}} exports)',
|
||||
compileError: 'Script compilation failed',
|
||||
hotReloadSuccess: 'Scripts hot reloaded'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Loading
|
||||
// ========================================
|
||||
@@ -432,7 +449,8 @@ export const en: Translations = {
|
||||
portManager: 'Port Manager',
|
||||
settings: 'Settings',
|
||||
devtools: 'Developer Tools',
|
||||
build: 'Build Settings'
|
||||
build: 'Build Settings',
|
||||
renderDebug: 'Render Debug'
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
|
||||
@@ -381,7 +381,8 @@ export const es: Translations = {
|
||||
portManager: 'Administrador de Puertos',
|
||||
settings: 'Configuración',
|
||||
devtools: 'Herramientas de Desarrollo',
|
||||
build: 'Configuración de Compilación'
|
||||
build: 'Configuración de Compilación',
|
||||
renderDebug: 'Depuración de Renderizado'
|
||||
},
|
||||
help: {
|
||||
title: 'Ayuda',
|
||||
|
||||
@@ -306,7 +306,15 @@ export const zh: Translations = {
|
||||
openFailed: '打开场景失败',
|
||||
savedSuccess: '场景已保存: {{name}}',
|
||||
saveFailed: '保存场景失败',
|
||||
saveAsFailed: '另存场景失败'
|
||||
saveAsFailed: '另存场景失败',
|
||||
reloadedSuccess: '场景已重新加载: {{name}}',
|
||||
reloadFailed: '重新加载场景失败',
|
||||
externalChange: {
|
||||
title: '场景已更改',
|
||||
message: '场景 "{{name}}" 已被外部修改。是否重新加载?',
|
||||
reload: '重新加载',
|
||||
ignore: '忽略'
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -371,6 +379,15 @@ export const zh: Translations = {
|
||||
dependencies: '依赖'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// User Code
|
||||
// ========================================
|
||||
usercode: {
|
||||
compileSuccess: '脚本编译成功 ({{count}} 个导出)',
|
||||
compileError: '脚本编译失败',
|
||||
hotReloadSuccess: '脚本热更新成功'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Loading
|
||||
// ========================================
|
||||
@@ -432,7 +449,8 @@ export const zh: Translations = {
|
||||
portManager: '端口管理器',
|
||||
settings: '设置',
|
||||
devtools: '开发者工具',
|
||||
build: '构建设置'
|
||||
build: '构建设置',
|
||||
renderDebug: '渲染调试'
|
||||
},
|
||||
help: {
|
||||
title: '帮助',
|
||||
|
||||
@@ -278,12 +278,20 @@ export class EditorEngineSync {
|
||||
* Update sprite in engine entity.
|
||||
* 更新引擎实体的精灵。
|
||||
*
|
||||
* Note: Texture loading is now handled automatically by EngineRenderSystem.
|
||||
* 注意:纹理加载现在由EngineRenderSystem自动处理。
|
||||
* Preloads textures when textureGuid changes to ensure they're available for rendering.
|
||||
* 当 textureGuid 变更时预加载纹理以确保渲染时可用。
|
||||
*/
|
||||
private updateSprite(entity: Entity, sprite: SpriteComponent, property: string, value: any): void {
|
||||
// No manual texture loading needed - EngineRenderSystem handles it
|
||||
// 不需要手动加载纹理 - EngineRenderSystem会处理
|
||||
// When textureGuid changes, trigger texture preload
|
||||
// 当 textureGuid 变更时,触发纹理预加载
|
||||
if (property === 'textureGuid' && value) {
|
||||
const bridge = this.engineService.getBridge();
|
||||
if (bridge) {
|
||||
// Preload the texture so it's ready for the next render frame
|
||||
// 预加载纹理以便下一渲染帧时可用
|
||||
bridge.getOrLoadTextureByPath(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, Plugin
|
||||
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
||||
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
@@ -462,6 +463,43 @@ export class EngineService {
|
||||
if (this._runtime?.bridge) {
|
||||
this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge);
|
||||
|
||||
// 为 EngineIntegration 设置使用 Tauri URL 转换的 PathResolver
|
||||
// Set PathResolver for EngineIntegration that uses Tauri URL conversion
|
||||
this._engineIntegration.setPathResolver({
|
||||
catalogToRuntime: (catalogPath: string): string => {
|
||||
// 空路径直接返回
|
||||
if (!catalogPath) return catalogPath;
|
||||
|
||||
// 已经是 URL 则直接返回
|
||||
if (catalogPath.startsWith('http://') ||
|
||||
catalogPath.startsWith('https://') ||
|
||||
catalogPath.startsWith('data:') ||
|
||||
catalogPath.startsWith('asset://')) {
|
||||
return catalogPath;
|
||||
}
|
||||
|
||||
// 使用 pathTransformerFn 转换路径为 Tauri URL
|
||||
// 路径应该是相对于项目目录的,如 'assets/sparkle_yellow.png'
|
||||
let fullPath = catalogPath;
|
||||
// 如果路径不以 'assets/' 开头,添加前缀
|
||||
if (!catalogPath.startsWith('assets/') && !catalogPath.startsWith('assets\\')) {
|
||||
fullPath = `assets/${catalogPath}`;
|
||||
}
|
||||
return pathTransformerFn(fullPath);
|
||||
},
|
||||
editorToCatalog: (editorPath: string, projectRoot: string): string => {
|
||||
return editorPath; // 不需要在此上下文中使用
|
||||
},
|
||||
setBaseUrl: () => {},
|
||||
getBaseUrl: () => '',
|
||||
normalize: (path: string) => path.replace(/\\/g, '/'),
|
||||
isAbsoluteUrl: (path: string) =>
|
||||
path.startsWith('http://') ||
|
||||
path.startsWith('https://') ||
|
||||
path.startsWith('data:') ||
|
||||
path.startsWith('asset://')
|
||||
});
|
||||
|
||||
this._sceneResourceManager = new SceneResourceManager();
|
||||
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
||||
|
||||
@@ -712,10 +750,15 @@ export class EngineService {
|
||||
return convertFileSrc(absolutePath);
|
||||
}
|
||||
return relativePath;
|
||||
} else {
|
||||
// GUID not found in registry - this could be a timing issue where asset
|
||||
// was just added but not yet registered. Log for debugging.
|
||||
// GUID 在注册表中未找到 - 可能是资源刚添加但尚未注册的时序问题
|
||||
console.warn(`[AssetPathResolver] GUID not found in registry: ${guidOrPath}. Asset may not be registered yet.`);
|
||||
}
|
||||
}
|
||||
// GUID not found, return original value
|
||||
// 未找到 GUID,返回原值
|
||||
// GUID not found, return original value (will result in white block)
|
||||
// 未找到 GUID,返回原值(会显示白块)
|
||||
return guidOrPath;
|
||||
}
|
||||
|
||||
@@ -1029,6 +1072,19 @@ export class EngineService {
|
||||
// 清除 UI 渲染缓存
|
||||
invalidateUIRenderCaches();
|
||||
|
||||
// Reset particle component textureIds before loading resources
|
||||
// 在加载资源前重置粒子组件的 textureId
|
||||
// This ensures ParticleUpdateSystem will reload textures
|
||||
// 这确保 ParticleUpdateSystem 会重新加载纹理
|
||||
if (this._runtime.scene) {
|
||||
for (const entity of this._runtime.scene.entities.buffer) {
|
||||
const particleComponent = entity.getComponent(ParticleSystemComponent);
|
||||
if (particleComponent) {
|
||||
particleComponent.textureId = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载场景资源
|
||||
if (this._sceneResourceManager && this._runtime.scene) {
|
||||
await this._sceneResourceManager.loadSceneResources(this._runtime.scene);
|
||||
@@ -1057,6 +1113,21 @@ export class EngineService {
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load scene resources (textures, audio, etc.)
|
||||
* 加载场景资源(纹理、音频等)
|
||||
*
|
||||
* Used by runtime scene switching in play mode.
|
||||
* 用于 Play 模式下的运行时场景切换。
|
||||
*/
|
||||
async loadSceneResources(): Promise<void> {
|
||||
const scene = this._runtime?.scene;
|
||||
if (!this._sceneResourceManager || !scene) {
|
||||
return;
|
||||
}
|
||||
await this._sceneResourceManager.loadSceneResources(scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a snapshot exists.
|
||||
*/
|
||||
|
||||
591
packages/editor-app/src/services/RenderDebugService.ts
Normal file
591
packages/editor-app/src/services/RenderDebugService.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* 渲染调试服务
|
||||
* Render Debug Service
|
||||
*
|
||||
* 从引擎收集渲染调试数据
|
||||
* Collects render debug data from the engine
|
||||
*/
|
||||
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { UITransformComponent, UIRenderComponent, UITextComponent } from '@esengine/ui';
|
||||
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* 纹理调试信息
|
||||
* Texture debug info
|
||||
*/
|
||||
export interface TextureDebugInfo {
|
||||
id: number;
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
state: 'loading' | 'ready' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite 调试信息
|
||||
* Sprite debug info
|
||||
*/
|
||||
export interface SpriteDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
textureId: number;
|
||||
texturePath: string;
|
||||
/** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */
|
||||
textureUrl?: string;
|
||||
uv: [number, number, number, number];
|
||||
color: string;
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子调试信息
|
||||
* Particle debug info
|
||||
*/
|
||||
export interface ParticleDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
systemName: string;
|
||||
isPlaying: boolean;
|
||||
activeCount: number;
|
||||
maxParticles: number;
|
||||
textureId: number;
|
||||
texturePath: string;
|
||||
/** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */
|
||||
textureUrl?: string;
|
||||
textureSheetAnimation: {
|
||||
enabled: boolean;
|
||||
tilesX: number;
|
||||
tilesY: number;
|
||||
totalFrames: number;
|
||||
} | null;
|
||||
sampleParticles: Array<{
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
frame: number;
|
||||
uv: [number, number, number, number];
|
||||
age: number;
|
||||
lifetime: number;
|
||||
size: number;
|
||||
color: string;
|
||||
alpha: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 元素调试信息
|
||||
* UI element debug info
|
||||
*/
|
||||
export interface UIDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
type: 'rect' | 'image' | 'text' | 'ninepatch' | 'circle' | 'rounded-rect' | 'unknown';
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
rotation: number;
|
||||
visible: boolean;
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
textureGuid?: string;
|
||||
textureUrl?: string;
|
||||
backgroundColor?: string;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试快照
|
||||
* Render debug snapshot
|
||||
*/
|
||||
export interface RenderDebugSnapshot {
|
||||
timestamp: number;
|
||||
frameNumber: number;
|
||||
textures: TextureDebugInfo[];
|
||||
sprites: SpriteDebugInfo[];
|
||||
particles: ParticleDebugInfo[];
|
||||
uiElements: UIDebugInfo[];
|
||||
stats: {
|
||||
totalSprites: number;
|
||||
totalParticles: number;
|
||||
totalUIElements: number;
|
||||
totalTextures: number;
|
||||
drawCalls: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试服务
|
||||
* Render Debug Service
|
||||
*/
|
||||
export class RenderDebugService {
|
||||
private static _instance: RenderDebugService | null = null;
|
||||
private _frameNumber: number = 0;
|
||||
private _enabled: boolean = false;
|
||||
private _snapshots: RenderDebugSnapshot[] = [];
|
||||
private _maxSnapshots: number = 60;
|
||||
|
||||
// 引擎引用 | Engine reference
|
||||
private _engineBridge: any = null;
|
||||
|
||||
static getInstance(): RenderDebugService {
|
||||
if (!RenderDebugService._instance) {
|
||||
RenderDebugService._instance = new RenderDebugService();
|
||||
}
|
||||
return RenderDebugService._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置引擎桥接
|
||||
* Set engine bridge
|
||||
*/
|
||||
setEngineBridge(bridge: any): void {
|
||||
this._engineBridge = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用调试
|
||||
* Enable/disable debugging
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._enabled = enabled;
|
||||
if (!enabled) {
|
||||
this._snapshots = [];
|
||||
}
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
// 纹理 base64 缓存 | Texture base64 cache
|
||||
private _textureCache = new Map<string, string>();
|
||||
private _texturePending = new Set<string>();
|
||||
|
||||
/**
|
||||
* 解析纹理 GUID 为 base64 data URL(从缓存获取)
|
||||
* Resolve texture GUID to base64 data URL (from cache)
|
||||
*/
|
||||
private _resolveTextureUrl(textureGuid: string | null | undefined): string | undefined {
|
||||
if (!textureGuid) return undefined;
|
||||
|
||||
// 从缓存获取 | Get from cache
|
||||
if (this._textureCache.has(textureGuid)) {
|
||||
console.log('[RenderDebugService] Texture from cache:', textureGuid);
|
||||
return this._textureCache.get(textureGuid);
|
||||
}
|
||||
|
||||
// 如果正在加载中,返回 undefined | If loading, return undefined
|
||||
if (this._texturePending.has(textureGuid)) {
|
||||
console.log('[RenderDebugService] Texture loading:', textureGuid);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 异步加载纹理 | Load texture asynchronously
|
||||
console.log('[RenderDebugService] Starting texture load:', textureGuid);
|
||||
this._loadTextureToCache(textureGuid);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步加载纹理到缓存
|
||||
* Load texture to cache asynchronously
|
||||
*/
|
||||
private async _loadTextureToCache(textureGuid: string): Promise<void> {
|
||||
if (this._textureCache.has(textureGuid) || this._texturePending.has(textureGuid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._texturePending.add(textureGuid);
|
||||
|
||||
try {
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
const projectService = Core.services.tryResolve(ProjectService) as { getCurrentProject: () => { path: string } | null } | null;
|
||||
|
||||
let resolvedPath: string | null = null;
|
||||
|
||||
// 检查是否是 GUID 格式 | Check if GUID format
|
||||
const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(textureGuid);
|
||||
|
||||
if (isGuid && assetRegistry) {
|
||||
resolvedPath = assetRegistry.getPathByGuid(textureGuid) || null;
|
||||
} else {
|
||||
resolvedPath = textureGuid;
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
this._texturePending.delete(textureGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是图片 | Check if image
|
||||
const ext = resolvedPath.toLowerCase().split('.').pop() || '';
|
||||
const imageExts: Record<string, string> = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp'
|
||||
};
|
||||
|
||||
const mimeType = imageExts[ext];
|
||||
if (!mimeType) {
|
||||
this._texturePending.delete(textureGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建完整路径 | Build full path
|
||||
const projectPath = projectService?.getCurrentProject()?.path;
|
||||
const fullPath = resolvedPath.startsWith('/') || resolvedPath.includes(':')
|
||||
? resolvedPath
|
||||
: projectPath
|
||||
? `${projectPath}/${resolvedPath}`
|
||||
: resolvedPath;
|
||||
|
||||
// 通过 Tauri command 读取文件并转为 base64 | Read file via Tauri command and convert to base64
|
||||
console.log('[RenderDebugService] Loading texture:', fullPath);
|
||||
const base64 = await invoke<string>('read_file_as_base64', { filePath: fullPath });
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
console.log('[RenderDebugService] Texture loaded, base64 length:', base64.length);
|
||||
this._textureCache.set(textureGuid, dataUrl);
|
||||
} catch (err) {
|
||||
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
|
||||
} finally {
|
||||
this._texturePending.delete(textureGuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集当前帧的调试数据
|
||||
* Collect debug data for current frame
|
||||
*/
|
||||
collectSnapshot(): RenderDebugSnapshot | null {
|
||||
if (!this._enabled) return null;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return null;
|
||||
|
||||
this._frameNumber++;
|
||||
|
||||
const snapshot: RenderDebugSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
frameNumber: this._frameNumber,
|
||||
textures: this._collectTextures(),
|
||||
sprites: this._collectSprites(scene.entities.buffer),
|
||||
particles: this._collectParticles(scene.entities.buffer),
|
||||
uiElements: this._collectUI(scene.entities.buffer),
|
||||
stats: {
|
||||
totalSprites: 0,
|
||||
totalParticles: 0,
|
||||
totalUIElements: 0,
|
||||
totalTextures: 0,
|
||||
drawCalls: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// 计算统计 | Calculate stats
|
||||
snapshot.stats.totalSprites = snapshot.sprites.length;
|
||||
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
|
||||
snapshot.stats.totalUIElements = snapshot.uiElements.length;
|
||||
snapshot.stats.totalTextures = snapshot.textures.length;
|
||||
|
||||
// 保存快照 | Save snapshot
|
||||
this._snapshots.push(snapshot);
|
||||
if (this._snapshots.length > this._maxSnapshots) {
|
||||
this._snapshots.shift();
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新快照
|
||||
* Get latest snapshot
|
||||
*/
|
||||
getLatestSnapshot(): RenderDebugSnapshot | null {
|
||||
return this._snapshots.length > 0 ? this._snapshots[this._snapshots.length - 1] ?? null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有快照
|
||||
* Get all snapshots
|
||||
*/
|
||||
getSnapshots(): RenderDebugSnapshot[] {
|
||||
return [...this._snapshots];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除快照
|
||||
* Clear snapshots
|
||||
*/
|
||||
clearSnapshots(): void {
|
||||
this._snapshots = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集纹理信息
|
||||
* Collect texture info
|
||||
*/
|
||||
private _collectTextures(): TextureDebugInfo[] {
|
||||
const textures: TextureDebugInfo[] = [];
|
||||
|
||||
// TODO: 从 EngineBridge 获取纹理管理器数据
|
||||
// TODO: Get texture manager data from EngineBridge
|
||||
if (this._engineBridge) {
|
||||
// const textureManager = this._engineBridge.getTextureManager();
|
||||
// for (const [id, tex] of textureManager.entries()) {
|
||||
// textures.push({ ... });
|
||||
// }
|
||||
}
|
||||
|
||||
return textures;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 Sprite 信息
|
||||
* Collect sprite info
|
||||
*/
|
||||
private _collectSprites(entities: readonly Entity[]): SpriteDebugInfo[] {
|
||||
const sprites: SpriteDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const sprite = entity.getComponent(SpriteComponent);
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
|
||||
if (!sprite || !transform) continue;
|
||||
|
||||
const pos = transform.worldPosition ?? transform.position;
|
||||
const rot = typeof transform.rotation === 'number'
|
||||
? transform.rotation
|
||||
: transform.rotation.z;
|
||||
|
||||
const textureGuid = sprite.textureGuid ?? '';
|
||||
sprites.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotation: rot,
|
||||
textureId: (sprite as any).textureId ?? 0,
|
||||
texturePath: textureGuid,
|
||||
textureUrl: this._resolveTextureUrl(textureGuid),
|
||||
uv: [...sprite.uv] as [number, number, number, number],
|
||||
color: sprite.color,
|
||||
alpha: sprite.alpha,
|
||||
sortingLayer: sprite.sortingLayer,
|
||||
orderInLayer: sprite.orderInLayer,
|
||||
});
|
||||
}
|
||||
|
||||
return sprites;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集粒子系统信息
|
||||
* Collect particle system info
|
||||
*/
|
||||
private _collectParticles(entities: readonly Entity[]): ParticleDebugInfo[] {
|
||||
const particleSystems: ParticleDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const ps = entity.getComponent(ParticleSystemComponent);
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
|
||||
if (!ps) continue;
|
||||
|
||||
const pool = ps.pool;
|
||||
|
||||
// 通过 getModule 获取 TextureSheetAnimation 模块 | Get TextureSheetAnimation module via getModule
|
||||
const textureSheetAnim = ps.getModule?.('TextureSheetAnimation') as any;
|
||||
|
||||
// 收集所有活跃粒子 | Collect all active particles
|
||||
const sampleParticles: ParticleDebugInfo['sampleParticles'] = [];
|
||||
if (pool) {
|
||||
let count = 0;
|
||||
pool.forEachActive((p: any) => {
|
||||
const tilesX = p._animTilesX ?? 1;
|
||||
const tilesY = p._animTilesY ?? 1;
|
||||
const frame = p._animFrame ?? 0;
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
|
||||
sampleParticles.push({
|
||||
index: count,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
frame,
|
||||
uv: [
|
||||
col * uWidth,
|
||||
row * vHeight,
|
||||
(col + 1) * uWidth,
|
||||
(row + 1) * vHeight,
|
||||
],
|
||||
age: p.age,
|
||||
lifetime: p.lifetime,
|
||||
size: p.size ?? p.startSize ?? 1,
|
||||
color: p.color ?? '#ffffff',
|
||||
alpha: p.alpha ?? 1,
|
||||
});
|
||||
count++;
|
||||
});
|
||||
}
|
||||
|
||||
// 获取模块的 tilesX/tilesY | Get tilesX/tilesY from module
|
||||
const tilesX = textureSheetAnim?.tilesX ?? 1;
|
||||
const tilesY = textureSheetAnim?.tilesY ?? 1;
|
||||
const totalFrames = textureSheetAnim?.actualTotalFrames ?? (tilesX * tilesY);
|
||||
|
||||
const textureGuid = ps.textureGuid ?? '';
|
||||
particleSystems.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
systemName: `ParticleSystem_${entity.id}`,
|
||||
isPlaying: ps.isPlaying,
|
||||
activeCount: pool?.activeCount ?? 0,
|
||||
maxParticles: ps.maxParticles,
|
||||
textureId: ps.textureId ?? 0,
|
||||
texturePath: textureGuid,
|
||||
textureUrl: this._resolveTextureUrl(textureGuid),
|
||||
textureSheetAnimation: textureSheetAnim?.enabled ? {
|
||||
enabled: true,
|
||||
tilesX,
|
||||
tilesY,
|
||||
totalFrames,
|
||||
} : null,
|
||||
sampleParticles,
|
||||
});
|
||||
}
|
||||
|
||||
return particleSystems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 UI 元素信息
|
||||
* Collect UI element info
|
||||
*/
|
||||
private _collectUI(entities: readonly Entity[]): UIDebugInfo[] {
|
||||
const uiElements: UIDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
|
||||
if (!uiTransform) continue;
|
||||
|
||||
const uiRender = entity.getComponent(UIRenderComponent);
|
||||
const uiText = entity.getComponent(UITextComponent);
|
||||
|
||||
// 确定类型 | Determine type
|
||||
let type: UIDebugInfo['type'] = 'unknown';
|
||||
if (uiText) {
|
||||
type = 'text';
|
||||
} else if (uiRender) {
|
||||
switch (uiRender.type) {
|
||||
case 'rect': type = 'rect'; break;
|
||||
case 'image': type = 'image'; break;
|
||||
case 'ninepatch': type = 'ninepatch'; break;
|
||||
case 'circle': type = 'circle'; break;
|
||||
case 'rounded-rect': type = 'rounded-rect'; break;
|
||||
default: type = 'rect';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取纹理 GUID | Get texture GUID
|
||||
const textureGuid = uiRender?.textureGuid?.toString() ?? '';
|
||||
|
||||
// 转换颜色为十六进制字符串 | Convert color to hex string
|
||||
const backgroundColor = uiRender?.backgroundColor !== undefined
|
||||
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
|
||||
: undefined;
|
||||
|
||||
uiElements.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
type,
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
width: uiTransform.width,
|
||||
height: uiTransform.height,
|
||||
worldX: uiTransform.worldX,
|
||||
worldY: uiTransform.worldY,
|
||||
rotation: uiTransform.rotation,
|
||||
visible: uiTransform.visible && uiTransform.worldVisible,
|
||||
alpha: uiTransform.worldAlpha,
|
||||
sortingLayer: uiTransform.sortingLayer,
|
||||
orderInLayer: uiTransform.orderInLayer,
|
||||
textureGuid: textureGuid || undefined,
|
||||
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
|
||||
backgroundColor,
|
||||
text: uiText?.text,
|
||||
fontSize: uiText?.fontSize,
|
||||
});
|
||||
}
|
||||
|
||||
return uiElements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出调试数据为 JSON
|
||||
* Export debug data as JSON
|
||||
*/
|
||||
exportAsJSON(): string {
|
||||
return JSON.stringify({
|
||||
exportTime: new Date().toISOString(),
|
||||
snapshots: this._snapshots,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印当前粒子 UV 到控制台
|
||||
* Print current particle UVs to console
|
||||
*/
|
||||
logParticleUVs(): void {
|
||||
const snapshot = this.collectSnapshot();
|
||||
if (!snapshot) {
|
||||
console.log('[RenderDebugService] No scene available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.group('[RenderDebugService] Particle UV Debug');
|
||||
for (const ps of snapshot.particles) {
|
||||
console.group(`${ps.entityName} (${ps.activeCount} active)`);
|
||||
if (ps.textureSheetAnimation) {
|
||||
console.log(`TextureSheetAnimation: ${ps.textureSheetAnimation.tilesX}x${ps.textureSheetAnimation.tilesY}`);
|
||||
}
|
||||
for (const p of ps.sampleParticles) {
|
||||
console.log(` Particle ${p.index}: frame=${p.frame}, UV=[${p.uv.map(v => v.toFixed(3)).join(', ')}]`);
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// 全局实例 | Global instance
|
||||
export const renderDebugService = RenderDebugService.getInstance();
|
||||
|
||||
// 导出到全局以便控制台使用 | Export to global for console usage
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).renderDebugService = renderDebugService;
|
||||
}
|
||||
@@ -126,3 +126,52 @@
|
||||
.confirm-dialog-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* External Modification Dialog | 外部修改对话框 */
|
||||
.external-modification-dialog .warning-icon {
|
||||
color: #f0ad4e;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.external-modification-dialog .confirm-dialog-header {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.external-modification-dialog .confirm-dialog-header h2 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.external-modification-dialog .hint-text {
|
||||
margin-top: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.external-modification-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.reload {
|
||||
background: #5bc0de;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.reload:hover {
|
||||
background: #7cd0e8;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.overwrite {
|
||||
background: #f0ad4e;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.overwrite:hover {
|
||||
background: #f4be6e;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user