From fcf3def284f1b05be05abca3e0d3a5d549704fd7 Mon Sep 17 00:00:00 2001
From: YHH <359807859@qq.com>
Date: Wed, 15 Oct 2025 23:24:13 +0800
Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E9=9B=86=E8=BF=9C=E7=AB=AF=E6=95=B0?=
=?UTF-8?q?=E6=8D=AE=E5=86=8Dprofiler=20dockpanel=E4=B8=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/editor-app/src/App.tsx | 95 +++++-
.../src/components/ProfilerDockPanel.tsx | 190 ++++++++++++
.../src/components/ProfilerWindow.tsx | 212 +++++---------
.../src/components/SceneHierarchy.tsx | 66 ++++-
packages/editor-app/src/locales/en.ts | 3 +-
packages/editor-app/src/locales/zh.ts | 3 +-
.../editor-app/src/plugins/ProfilerPlugin.tsx | 37 ++-
.../src/services/ProfilerService.ts | 270 ++++++++++++++++++
packages/editor-app/src/styles/App.css | 27 ++
.../src/styles/ProfilerDockPanel.css | 270 ++++++++++++++++++
.../editor-app/src/styles/SceneHierarchy.css | 64 +++++
11 files changed, 1077 insertions(+), 160 deletions(-)
create mode 100644 packages/editor-app/src/components/ProfilerDockPanel.tsx
create mode 100644 packages/editor-app/src/services/ProfilerService.ts
create mode 100644 packages/editor-app/src/styles/ProfilerDockPanel.css
diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx
index 534efcb0..a33bae4d 100644
--- a/packages/editor-app/src/App.tsx
+++ b/packages/editor-app/src/App.tsx
@@ -46,6 +46,8 @@ function App() {
const [showPluginManager, setShowPluginManager] = useState(false);
const [showProfiler, setShowProfiler] = useState(false);
const [showPortManager, setShowPortManager] = useState(false);
+ const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
+ const [isRemoteConnected, setIsRemoteConnected] = useState(false);
useEffect(() => {
// 禁用默认右键菜单
@@ -60,6 +62,57 @@ function App() {
};
}, []);
+ useEffect(() => {
+ if (messageHub) {
+ const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
+ console.log('[App] Plugin enabled, updating panels');
+ setPluginUpdateTrigger(prev => prev + 1);
+ });
+
+ const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
+ console.log('[App] Plugin disabled, updating panels');
+ setPluginUpdateTrigger(prev => prev + 1);
+ });
+
+ return () => {
+ unsubscribeEnabled();
+ unsubscribeDisabled();
+ };
+ }
+ }, [messageHub]);
+
+ // 监听远程连接状态
+ useEffect(() => {
+ const checkConnection = () => {
+ const profilerService = (window as any).__PROFILER_SERVICE__;
+ if (profilerService && profilerService.isConnected()) {
+ if (!isRemoteConnected) {
+ console.log('[App] Remote game connected');
+ setIsRemoteConnected(true);
+ setStatus(t('header.status.remoteConnected'));
+ }
+ } else {
+ if (isRemoteConnected) {
+ console.log('[App] Remote game disconnected');
+ setIsRemoteConnected(false);
+ if (projectLoaded) {
+ const projectService = Core.services.resolve(ProjectService);
+ const componentRegistry = Core.services.resolve(ComponentRegistry);
+ const componentCount = componentRegistry?.getRegisteredComponents().length || 0;
+ setStatus(t('header.status.projectOpened') + (componentCount > 0 ? ` (${componentCount} components registered)` : ''));
+ } else {
+ setStatus(t('header.status.ready'));
+ }
+ }
+ }
+ };
+
+ checkConnection();
+ const interval = setInterval(checkConnection, 1000);
+
+ return () => clearInterval(interval);
+ }, [projectLoaded, isRemoteConnected, t]);
+
useEffect(() => {
const initializeEditor = async () => {
// 使用 ref 防止 React StrictMode 的双重调用
@@ -244,8 +297,8 @@ function App() {
};
useEffect(() => {
- if (projectLoaded && entityStore && messageHub && logService) {
- setPanels([
+ if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
+ const corePanels: DockablePanel[] = [
{
id: 'scene-hierarchy',
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
@@ -281,9 +334,41 @@ function App() {
content: ,
closable: false
}
- ]);
+ ];
+
+ const enabledPlugins = pluginManager.getAllPluginMetadata()
+ .filter(p => p.enabled)
+ .map(p => p.name);
+
+ const pluginPanels: DockablePanel[] = uiRegistry.getAllPanels()
+ .filter(panelDesc => {
+ if (!panelDesc.component) {
+ return false;
+ }
+ return enabledPlugins.some(pluginName => {
+ const plugin = pluginManager.getEditorPlugin(pluginName);
+ if (plugin && plugin.registerPanels) {
+ const pluginPanels = plugin.registerPanels();
+ return pluginPanels.some(p => p.id === panelDesc.id);
+ }
+ return false;
+ });
+ })
+ .map(panelDesc => {
+ const Component = panelDesc.component;
+ return {
+ id: panelDesc.id,
+ title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title,
+ position: panelDesc.position as any,
+ content: ,
+ closable: panelDesc.closable ?? true
+ };
+ });
+
+ console.log('[App] Loading plugin panels:', pluginPanels);
+ setPanels([...corePanels, ...pluginPanels]);
}
- }, [projectLoaded, entityStore, messageHub, logService, locale, currentProjectPath, t]);
+ }, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger]);
const handlePanelMove = (panelId: string, newPosition: any) => {
setPanels(prevPanels =>
@@ -325,7 +410,7 @@ function App() {
return (
-
+
(null);
+ const [isConnected, setIsConnected] = useState(false);
+ const [isServerRunning, setIsServerRunning] = useState(false);
+
+ useEffect(() => {
+ const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
+
+ if (!profilerService) {
+ console.warn('[ProfilerDockPanel] ProfilerService not available - plugin may be disabled');
+ setIsServerRunning(false);
+ setIsConnected(false);
+ return;
+ }
+
+ // 订阅数据更新
+ const unsubscribe = profilerService.subscribe((data: ProfilerData) => {
+ setProfilerData(data);
+ });
+
+ // 定期检查连接状态
+ const checkStatus = () => {
+ setIsConnected(profilerService.isConnected());
+ setIsServerRunning(profilerService.isServerActive());
+ };
+
+ checkStatus();
+ const interval = setInterval(checkStatus, 1000);
+
+ return () => {
+ unsubscribe();
+ clearInterval(interval);
+ };
+ }, []);
+
+ const fps = profilerData?.fps || 0;
+ const totalFrameTime = profilerData?.totalFrameTime || 0;
+ const systems = (profilerData?.systems || []).slice(0, 5); // Only show top 5 systems in dock panel
+ const entityCount = profilerData?.entityCount || 0;
+ const componentCount = profilerData?.componentCount || 0;
+ const targetFrameTime = 16.67;
+
+ const handleOpenDetails = () => {
+ const messageHub = Core.services.resolve(MessageHub);
+ if (messageHub) {
+ messageHub.publish('ui:openWindow', { windowId: 'profiler' });
+ }
+ };
+
+ return (
+
+
+
Performance Monitor
+
+ {isConnected && (
+
+ )}
+
+ {isConnected ? (
+ <>
+
+ Connected
+ >
+ ) : isServerRunning ? (
+ <>
+
+ Waiting...
+ >
+ ) : (
+ <>
+
+ Server Off
+ >
+ )}
+
+
+
+
+ {!isServerRunning ? (
+
+
+
Profiler server not running
+
Open Profiler window and connect to start monitoring
+
+ ) : !isConnected ? (
+
+
+
Waiting for game connection...
+
Connect your game to port 8080
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
Frame Time
+
targetFrameTime ? 'warning' : ''}`}>
+ {totalFrameTime.toFixed(1)}ms
+
+
+
+
+
+
+
+
+
+
Entities
+
{entityCount}
+
+
+
+
+
+
+
Components
+
{componentCount}
+
+
+
+
+ {systems.length > 0 && (
+
+
Top Systems
+
+ {systems.map((system) => (
+
+
+ {system.name}
+
+ {system.executionTime.toFixed(2)}ms
+
+
+
+
targetFrameTime
+ ? 'var(--color-danger)'
+ : system.executionTime > targetFrameTime * 0.5
+ ? 'var(--color-warning)'
+ : 'var(--color-success)'
+ }}
+ />
+
+
+ {system.percentage.toFixed(1)}%
+ {system.entityCount > 0 && (
+ {system.entityCount} entities
+ )}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/packages/editor-app/src/components/ProfilerWindow.tsx b/packages/editor-app/src/components/ProfilerWindow.tsx
index 43dd014c..a1be5fd8 100644
--- a/packages/editor-app/src/components/ProfilerWindow.tsx
+++ b/packages/editor-app/src/components/ProfilerWindow.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { Core } from '@esengine/ecs-framework';
import { Activity, BarChart3, Clock, Cpu, RefreshCw, Pause, Play, X, Wifi, WifiOff, Server, Search, Table2, TreePine } from 'lucide-react';
-import { invoke } from '@tauri-apps/api/core';
+import { ProfilerService } from '../services/ProfilerService';
import '../styles/ProfilerWindow.css';
interface SystemPerformanceData {
@@ -31,45 +31,27 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
const [dataSource, setDataSource] = useState
('local');
const [viewMode, setViewMode] = useState<'tree' | 'table'>('table');
const [searchQuery, setSearchQuery] = useState('');
- const [wsPort, setWsPort] = useState('8080');
const [isConnected, setIsConnected] = useState(false);
- const [isConnecting, setIsConnecting] = useState(false);
- const [connectionError, setConnectionError] = useState(null);
+ const [isServerRunning, setIsServerRunning] = useState(false);
const animationRef = useRef();
- const wsRef = useRef(null);
- // WebSocket connection management
+ // Check ProfilerService connection status
useEffect(() => {
- if (dataSource === 'remote' && isConnected && wsRef.current) {
- // Keep WebSocket connection alive
- const pingInterval = setInterval(() => {
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify({ type: 'ping' }));
- }
- }, 5000);
+ const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
- return () => clearInterval(pingInterval);
+ if (!profilerService) {
+ return;
}
- }, [dataSource, isConnected]);
- // Cleanup WebSocket and stop server on unmount
- useEffect(() => {
- return () => {
- if (wsRef.current) {
- wsRef.current.close();
- wsRef.current = null;
- }
-
- // Check if server is running and stop it
- invoke('get_profiler_status')
- .then(isRunning => {
- if (isRunning) {
- return invoke('stop_profiler_server');
- }
- })
- .then(() => console.log('[Profiler] Server stopped on unmount'))
- .catch(err => console.error('[Profiler] Failed to stop server on unmount:', err));
+ const checkStatus = () => {
+ setIsConnected(profilerService.isConnected());
+ setIsServerRunning(profilerService.isServerActive());
};
+
+ checkStatus();
+ const interval = setInterval(checkStatus, 1000);
+
+ return () => clearInterval(interval);
}, []);
const buildSystemTree = (flatSystems: Map, statsMap: Map): SystemPerformanceData[] => {
@@ -144,6 +126,7 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
return [coreNode];
};
+ // Subscribe to local performance data
useEffect(() => {
if (dataSource !== 'local') return;
@@ -181,6 +164,39 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
};
}, [isPaused, sortBy, dataSource]);
+ // Subscribe to remote performance data from ProfilerService
+ useEffect(() => {
+ if (dataSource !== 'remote') return;
+
+ const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
+
+ if (!profilerService) {
+ console.warn('[ProfilerWindow] ProfilerService not available');
+ return;
+ }
+
+ const unsubscribe = profilerService.subscribe((data) => {
+ if (isPaused) return;
+
+ handleRemoteDebugData({
+ performance: {
+ frameTime: data.totalFrameTime,
+ systemPerformance: data.systems.map(sys => ({
+ systemName: sys.name,
+ lastExecutionTime: sys.executionTime,
+ averageTime: sys.averageTime,
+ minTime: 0,
+ maxTime: 0,
+ entityCount: sys.entityCount,
+ percentage: sys.percentage
+ }))
+ }
+ });
+ });
+
+ return () => unsubscribe();
+ }, [dataSource, isPaused]);
+
const handleReset = () => {
if (dataSource === 'local') {
const coreInstance = Core.Instance;
@@ -194,84 +210,6 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
}
};
- const handleConnect = async () => {
- if (isConnecting) return;
-
- if (wsRef.current) {
- wsRef.current.close();
- }
-
- setIsConnecting(true);
- setConnectionError(null);
-
- try {
- const port = parseInt(wsPort);
- const result = await invoke('start_profiler_server', { port });
- console.log('[Profiler]', result);
-
- const ws = new WebSocket(`ws://localhost:${wsPort}`);
-
- ws.onopen = () => {
- console.log('[Profiler] Frontend connected to profiler server');
- setIsConnected(true);
- setIsConnecting(false);
- setConnectionError(null);
- };
-
- ws.onclose = () => {
- console.log('[Profiler] Frontend disconnected');
- setIsConnected(false);
- setIsConnecting(false);
- };
-
- ws.onerror = (error) => {
- console.error('[Profiler] WebSocket error:', error);
- setConnectionError(`Failed to connect to profiler server`);
- setIsConnected(false);
- setIsConnecting(false);
- };
-
- ws.onmessage = (event) => {
- try {
- const message = JSON.parse(event.data);
-
- if (message.type === 'debug_data' && message.data) {
- handleRemoteDebugData(message.data);
- } else if (message.type === 'pong') {
- // Ping-pong response, connection is alive
- }
- } catch (error) {
- console.error('[Profiler] Failed to parse message:', error);
- }
- };
-
- wsRef.current = ws;
- } catch (error) {
- console.error('[Profiler] Failed to start server:', error);
- setConnectionError(String(error));
- setIsConnected(false);
- setIsConnecting(false);
- }
- };
-
- const handleDisconnect = async () => {
- if (wsRef.current) {
- wsRef.current.close();
- wsRef.current = null;
- }
-
- try {
- // Stop WebSocket server in Tauri backend
- const result = await invoke('stop_profiler_server');
- console.log('[Profiler]', result);
- } catch (error) {
- console.error('[Profiler] Failed to stop server:', error);
- }
-
- setIsConnected(false);
- setSystems([]);
- setTotalFrameTime(0);
- };
const handleRemoteDebugData = (debugData: any) => {
if (isPaused) return;
@@ -310,9 +248,6 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
- } else if (newSource === 'local' && dataSource === 'remote') {
- // Switching to local
- handleDisconnect();
}
setDataSource(newSource);
setSystems([]);
@@ -424,42 +359,25 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
{dataSource === 'remote' && (
-
setWsPort(e.target.value)}
- disabled={isConnected || isConnecting}
- />
+
+
+ Port: 8080
+
{isConnected ? (
-
- ) : (
-
)}
diff --git a/packages/editor-app/src/components/SceneHierarchy.tsx b/packages/editor-app/src/components/SceneHierarchy.tsx
index cda2457b..e009ab9f 100644
--- a/packages/editor-app/src/components/SceneHierarchy.tsx
+++ b/packages/editor-app/src/components/SceneHierarchy.tsx
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react';
import { Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
-import { Box, Layers } from 'lucide-react';
+import { Box, Layers, Wifi } from 'lucide-react';
+import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
import '../styles/SceneHierarchy.css';
interface SceneHierarchyProps {
@@ -12,9 +13,12 @@ interface SceneHierarchyProps {
export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps) {
const [entities, setEntities] = useState([]);
+ const [remoteEntities, setRemoteEntities] = useState([]);
+ const [isRemoteConnected, setIsRemoteConnected] = useState(false);
const [selectedId, setSelectedId] = useState(null);
const { t } = useLocale();
+ // Subscribe to local entity changes
useEffect(() => {
const updateEntities = () => {
setEntities(entityStore.getRootEntities());
@@ -39,23 +43,79 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
};
}, [entityStore, messageHub]);
+ // Subscribe to remote entity data from ProfilerService
+ useEffect(() => {
+ const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
+
+ if (!profilerService) {
+ console.warn('[SceneHierarchy] ProfilerService not available');
+ return;
+ }
+
+ console.log('[SceneHierarchy] Subscribing to ProfilerService');
+
+ const unsubscribe = profilerService.subscribe((data) => {
+ const connected = profilerService.isConnected();
+ console.log('[SceneHierarchy] Received data, connected:', connected, 'entities:', data.entities?.length || 0);
+ setIsRemoteConnected(connected);
+
+ if (connected && data.entities && data.entities.length > 0) {
+ console.log('[SceneHierarchy] Setting remote entities:', data.entities);
+ setRemoteEntities(data.entities);
+ } else {
+ setRemoteEntities([]);
+ }
+ });
+
+ return () => unsubscribe();
+ }, []);
+
const handleEntityClick = (entity: Entity) => {
entityStore.selectEntity(entity);
};
+ // Determine which entities to display
+ const displayEntities = isRemoteConnected ? remoteEntities : entities;
+ const showRemoteIndicator = isRemoteConnected && remoteEntities.length > 0;
+
return (
{t('hierarchy.title')}
+ {showRemoteIndicator && (
+
+
+
+ )}
- {entities.length === 0 ? (
+ {displayEntities.length === 0 ? (
{t('hierarchy.empty')}
-
Create an entity to get started
+
+ {isRemoteConnected
+ ? 'No entities in remote game'
+ : 'Create an entity to get started'}
+
+ ) : isRemoteConnected ? (
+
+ {remoteEntities.map(entity => (
+ -
+
+ {entity.name}
+ {entity.components.length > 0 && (
+ {entity.components.length}
+ )}
+
+ ))}
+
) : (
{entities.map(entity => (
diff --git a/packages/editor-app/src/locales/en.ts b/packages/editor-app/src/locales/en.ts
index 6d20df46..33c50102 100644
--- a/packages/editor-app/src/locales/en.ts
+++ b/packages/editor-app/src/locales/en.ts
@@ -14,7 +14,8 @@ export const en: Translations = {
initializing: 'Initializing...',
ready: 'Editor Ready',
failed: 'Initialization Failed',
- projectOpened: 'Project Opened'
+ projectOpened: 'Project Opened',
+ remoteConnected: 'Remote Game Connected'
}
},
hierarchy: {
diff --git a/packages/editor-app/src/locales/zh.ts b/packages/editor-app/src/locales/zh.ts
index 2ac86ac6..1acbc527 100644
--- a/packages/editor-app/src/locales/zh.ts
+++ b/packages/editor-app/src/locales/zh.ts
@@ -14,7 +14,8 @@ export const zh: Translations = {
initializing: '初始化中...',
ready: '编辑器就绪',
failed: '初始化失败',
- projectOpened: '项目已打开'
+ projectOpened: '项目已打开',
+ remoteConnected: '远程游戏已连接'
}
},
hierarchy: {
diff --git a/packages/editor-app/src/plugins/ProfilerPlugin.tsx b/packages/editor-app/src/plugins/ProfilerPlugin.tsx
index f76e5496..b4140e52 100644
--- a/packages/editor-app/src/plugins/ProfilerPlugin.tsx
+++ b/packages/editor-app/src/plugins/ProfilerPlugin.tsx
@@ -1,5 +1,7 @@
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
-import { IEditorPlugin, EditorPluginCategory, MenuItem, MessageHub } from '@esengine/editor-core';
+import { IEditorPlugin, EditorPluginCategory, MenuItem, MessageHub, PanelDescriptor } from '@esengine/editor-core';
+import { ProfilerDockPanel } from '../components/ProfilerDockPanel';
+import { ProfilerService } from '../services/ProfilerService';
/**
* Profiler Plugin
@@ -15,14 +17,30 @@ export class ProfilerPlugin implements IEditorPlugin {
readonly icon = '📊';
private messageHub: MessageHub | null = null;
+ private profilerService: ProfilerService | null = null;
async install(_core: Core, services: ServiceContainer): Promise {
this.messageHub = services.resolve(MessageHub);
- console.log('[ProfilerPlugin] Installed');
+
+ // 创建并启动 ProfilerService
+ this.profilerService = new ProfilerService();
+
+ // 将服务实例存储到全局,供组件访问
+ (window as any).__PROFILER_SERVICE__ = this.profilerService;
+
+ console.log('[ProfilerPlugin] Installed and ProfilerService started');
}
async uninstall(): Promise {
- console.log('[ProfilerPlugin] Uninstalled');
+ // 清理 ProfilerService
+ if (this.profilerService) {
+ this.profilerService.destroy();
+ this.profilerService = null;
+ }
+
+ delete (window as any).__PROFILER_SERVICE__;
+
+ console.log('[ProfilerPlugin] Uninstalled and ProfilerService stopped');
}
async onEditorReady(): Promise {
@@ -45,4 +63,17 @@ export class ProfilerPlugin implements IEditorPlugin {
console.log('[ProfilerPlugin] Registering menu items:', items);
return items;
}
+
+ registerPanels(): PanelDescriptor[] {
+ return [
+ {
+ id: 'profiler-monitor',
+ title: 'Performance Monitor',
+ position: 'center' as any,
+ closable: true,
+ component: ProfilerDockPanel,
+ order: 200
+ }
+ ];
+ }
}
diff --git a/packages/editor-app/src/services/ProfilerService.ts b/packages/editor-app/src/services/ProfilerService.ts
new file mode 100644
index 00000000..64e53de0
--- /dev/null
+++ b/packages/editor-app/src/services/ProfilerService.ts
@@ -0,0 +1,270 @@
+import { invoke } from '@tauri-apps/api/core';
+
+export interface SystemPerformanceData {
+ name: string;
+ executionTime: number;
+ entityCount: number;
+ averageTime: number;
+ percentage: number;
+}
+
+export interface RemoteEntity {
+ id: number;
+ name: string;
+ enabled: boolean;
+ components: string[];
+}
+
+export interface ProfilerData {
+ totalFrameTime: number;
+ systems: SystemPerformanceData[];
+ entityCount: number;
+ componentCount: number;
+ fps: number;
+ entities?: RemoteEntity[];
+}
+
+type ProfilerDataListener = (data: ProfilerData) => void;
+
+export class ProfilerService {
+ private ws: WebSocket | null = null;
+ private isServerRunning = false;
+ private wsPort = '8080';
+ private listeners: Set = new Set();
+ private currentData: ProfilerData | null = null;
+ private checkServerInterval: NodeJS.Timeout | null = null;
+ private reconnectTimeout: NodeJS.Timeout | null = null;
+
+ constructor() {
+ this.startServerCheck();
+ }
+
+ public subscribe(listener: ProfilerDataListener): () => void {
+ this.listeners.add(listener);
+
+ // 如果已有数据,立即发送给新订阅者
+ if (this.currentData) {
+ listener(this.currentData);
+ }
+
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ public isConnected(): boolean {
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
+ }
+
+ public isServerActive(): boolean {
+ return this.isServerRunning;
+ }
+
+ private startServerCheck(): void {
+ this.checkServerStatus();
+ this.checkServerInterval = setInterval(() => {
+ this.checkServerStatus();
+ }, 2000);
+ }
+
+ private async checkServerStatus(): Promise {
+ try {
+ const status = await invoke('get_profiler_status');
+ const wasRunning = this.isServerRunning;
+ this.isServerRunning = status;
+
+ // 如果服务器还没运行,自动启动它
+ if (!status) {
+ await this.startServer();
+ return;
+ }
+
+ // 服务器启动了,尝试连接
+ if (status && !this.ws) {
+ this.connectToServer();
+ }
+
+ // 服务器从运行变为停止
+ if (wasRunning && !status) {
+ console.log('[ProfilerService] Server stopped');
+ this.disconnect();
+ }
+ } catch (error) {
+ this.isServerRunning = false;
+ }
+ }
+
+ private async startServer(): Promise {
+ try {
+ const port = parseInt(this.wsPort);
+ const result = await invoke('start_profiler_server', { port });
+ console.log('[ProfilerService]', result);
+ this.isServerRunning = true;
+ } catch (error) {
+ console.error('[ProfilerService] Failed to start server:', error);
+ }
+ }
+
+ private connectToServer(): void {
+ if (this.ws) return;
+
+ try {
+ console.log(`[ProfilerService] Connecting to ws://localhost:${this.wsPort}`);
+ const ws = new WebSocket(`ws://localhost:${this.wsPort}`);
+
+ ws.onopen = () => {
+ console.log('[ProfilerService] Connected to profiler server');
+ this.notifyListeners(this.createEmptyData());
+ };
+
+ ws.onclose = () => {
+ console.log('[ProfilerService] Disconnected from profiler server');
+ this.ws = null;
+
+ // 如果服务器还在运行,尝试重连
+ if (this.isServerRunning && !this.reconnectTimeout) {
+ this.reconnectTimeout = setTimeout(() => {
+ this.reconnectTimeout = null;
+ this.connectToServer();
+ }, 3000);
+ }
+ };
+
+ ws.onerror = (error) => {
+ console.error('[ProfilerService] WebSocket error:', error);
+ this.ws = null;
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ if (message.type === 'debug_data' && message.data) {
+ this.handleDebugData(message.data);
+ }
+ } catch (error) {
+ console.error('[ProfilerService] Failed to parse message:', error);
+ }
+ };
+
+ this.ws = ws;
+ } catch (error) {
+ console.error('[ProfilerService] Failed to create WebSocket:', error);
+ }
+ }
+
+ private handleDebugData(debugData: any): void {
+ const performance = debugData.performance;
+ if (!performance) return;
+
+ const totalFrameTime = performance.frameTime || 0;
+ const fps = totalFrameTime > 0 ? Math.round(1000 / totalFrameTime) : 0;
+
+ let systems: SystemPerformanceData[] = [];
+ if (performance.systemPerformance && Array.isArray(performance.systemPerformance)) {
+ systems = performance.systemPerformance
+ .map((sys: any) => ({
+ name: sys.systemName,
+ executionTime: sys.lastExecutionTime || sys.averageTime || 0,
+ entityCount: sys.entityCount || 0,
+ averageTime: sys.averageTime || 0,
+ percentage: 0
+ }))
+ .sort((a: SystemPerformanceData, b: SystemPerformanceData) =>
+ b.executionTime - a.executionTime
+ );
+
+ const totalTime = performance.frameTime || 1;
+ systems.forEach((sys: SystemPerformanceData) => {
+ sys.percentage = (sys.executionTime / totalTime) * 100;
+ });
+ }
+
+ const entityCount = debugData.entities?.totalCount || 0;
+ const componentTypes = debugData.components?.types || [];
+ const componentCount = componentTypes.length;
+
+ // 解析实体列表
+ console.log('[ProfilerService] debugData.entities:', debugData.entities);
+ let entities: RemoteEntity[] = [];
+
+ // 尝试从 topEntitiesByComponents 获取实体列表
+ if (debugData.entities?.topEntitiesByComponents && Array.isArray(debugData.entities.topEntitiesByComponents)) {
+ console.log('[ProfilerService] Found topEntitiesByComponents, length:', debugData.entities.topEntitiesByComponents.length);
+ entities = debugData.entities.topEntitiesByComponents.map((e: any) => ({
+ id: parseInt(e.id) || 0,
+ name: e.name || `Entity ${e.id}`,
+ enabled: true, // topEntitiesByComponents doesn't have enabled flag, assume true
+ components: [] // componentCount is provided but not component names
+ }));
+ console.log('[ProfilerService] Parsed entities from topEntitiesByComponents:', entities.length);
+ }
+ // 尝试从 entities 获取实体列表(旧格式兼容)
+ else if (debugData.entities?.entities && Array.isArray(debugData.entities.entities)) {
+ console.log('[ProfilerService] Found entities array, length:', debugData.entities.entities.length);
+ entities = debugData.entities.entities.map((e: any) => ({
+ id: e.id,
+ name: e.name || `Entity ${e.id}`,
+ enabled: e.enabled !== false,
+ components: e.components || []
+ }));
+ console.log('[ProfilerService] Parsed entities:', entities.length);
+ } else {
+ console.log('[ProfilerService] No entities array found');
+ }
+
+ this.currentData = {
+ totalFrameTime,
+ systems,
+ entityCount,
+ componentCount,
+ fps,
+ entities
+ };
+
+ this.notifyListeners(this.currentData);
+ }
+
+ private createEmptyData(): ProfilerData {
+ return {
+ totalFrameTime: 0,
+ systems: [],
+ entityCount: 0,
+ componentCount: 0,
+ fps: 0
+ };
+ }
+
+ private notifyListeners(data: ProfilerData): void {
+ this.listeners.forEach(listener => {
+ try {
+ listener(data);
+ } catch (error) {
+ console.error('[ProfilerService] Error in listener:', error);
+ }
+ });
+ }
+
+ private disconnect(): void {
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+
+ if (this.reconnectTimeout) {
+ clearTimeout(this.reconnectTimeout);
+ this.reconnectTimeout = null;
+ }
+ }
+
+ public destroy(): void {
+ this.disconnect();
+
+ if (this.checkServerInterval) {
+ clearInterval(this.checkServerInterval);
+ this.checkServerInterval = null;
+ }
+
+ this.listeners.clear();
+ this.currentData = null;
+ }
+}
diff --git a/packages/editor-app/src/styles/App.css b/packages/editor-app/src/styles/App.css
index 64897baf..d2577e30 100644
--- a/packages/editor-app/src/styles/App.css
+++ b/packages/editor-app/src/styles/App.css
@@ -33,6 +33,33 @@
background-color: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border-default);
flex-shrink: 0;
+ transition: all 0.3s ease;
+}
+
+.editor-header.remote-connected {
+ background-color: rgba(16, 185, 129, 0.15);
+ border-bottom-color: rgba(16, 185, 129, 0.5);
+}
+
+.editor-header.remote-connected .status {
+ color: rgb(16, 185, 129);
+ font-weight: 600;
+}
+
+.editor-header.remote-connected .status::before {
+ background-color: rgb(16, 185, 129);
+ animation: pulse-green 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse-green {
+ 0%, 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.6;
+ transform: scale(1.2);
+ }
}
.header-right {
diff --git a/packages/editor-app/src/styles/ProfilerDockPanel.css b/packages/editor-app/src/styles/ProfilerDockPanel.css
new file mode 100644
index 00000000..eaf44489
--- /dev/null
+++ b/packages/editor-app/src/styles/ProfilerDockPanel.css
@@ -0,0 +1,270 @@
+.profiler-dock-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: var(--color-bg-elevated);
+ overflow: hidden;
+}
+
+.profiler-dock-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--color-border-default);
+ flex-shrink: 0;
+}
+
+.profiler-dock-header h3 {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.profiler-dock-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.profiler-dock-details-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ background: var(--color-bg-inset);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-sm);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.profiler-dock-details-btn:hover {
+ background: var(--color-bg-hover);
+ border-color: var(--color-border-strong);
+ color: var(--color-text-primary);
+}
+
+.profiler-dock-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ padding: 4px 8px;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-inset);
+}
+
+.status-text {
+ font-weight: 500;
+}
+
+.status-text.connected {
+ color: var(--color-success);
+}
+
+.status-text.waiting {
+ color: var(--color-warning);
+}
+
+.status-text.disconnected {
+ color: var(--color-text-tertiary);
+}
+
+.profiler-dock-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ padding: 24px;
+ text-align: center;
+ color: var(--color-text-tertiary);
+ gap: 12px;
+}
+
+.profiler-dock-empty p {
+ margin: 0;
+ font-size: 13px;
+}
+
+.profiler-dock-empty .hint {
+ font-size: 11px;
+ opacity: 0.7;
+}
+
+.profiler-dock-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.profiler-dock-content::-webkit-scrollbar {
+ width: 6px;
+}
+
+.profiler-dock-content::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.profiler-dock-content::-webkit-scrollbar-thumb {
+ background: var(--color-border-default);
+ border-radius: 3px;
+}
+
+.profiler-dock-stats {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+
+.stat-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ background: var(--color-bg-inset);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border-default);
+ transition: all var(--transition-fast);
+}
+
+.stat-card:hover {
+ border-color: var(--color-border-strong);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.stat-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-sm);
+ background: rgba(99, 102, 241, 0.1);
+ color: rgb(99, 102, 241);
+ flex-shrink: 0;
+}
+
+.stat-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.stat-label {
+ font-size: 10px;
+ color: var(--color-text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 500;
+ margin-bottom: 2px;
+}
+
+.stat-value {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--color-text-primary);
+ font-family: var(--font-family-mono);
+}
+
+.stat-value.warning {
+ color: var(--color-warning);
+}
+
+.profiler-dock-systems {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.profiler-dock-systems h4 {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.systems-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.system-item {
+ padding: 10px 12px;
+ background: var(--color-bg-inset);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border-default);
+ transition: all var(--transition-fast);
+}
+
+.system-item:hover {
+ border-color: var(--color-border-strong);
+}
+
+.system-item-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 6px;
+}
+
+.system-item-name {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+ font-family: var(--font-family-mono);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.system-item-time {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--color-text-primary);
+ font-family: var(--font-family-mono);
+ flex-shrink: 0;
+ margin-left: 8px;
+}
+
+.system-item-bar {
+ width: 100%;
+ height: 4px;
+ background: var(--color-bg-elevated);
+ border-radius: 2px;
+ overflow: hidden;
+ margin-bottom: 6px;
+}
+
+.system-item-bar-fill {
+ height: 100%;
+ transition: width 0.3s ease;
+ border-radius: 2px;
+}
+
+.system-item-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 10px;
+ color: var(--color-text-tertiary);
+}
+
+.system-item-percentage {
+ font-weight: 600;
+ font-family: var(--font-family-mono);
+}
+
+.system-item-entities {
+ font-size: 9px;
+}
diff --git a/packages/editor-app/src/styles/SceneHierarchy.css b/packages/editor-app/src/styles/SceneHierarchy.css
index ac9353aa..4a598457 100644
--- a/packages/editor-app/src/styles/SceneHierarchy.css
+++ b/packages/editor-app/src/styles/SceneHierarchy.css
@@ -29,6 +29,20 @@
color: var(--color-text-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
+ flex: 1;
+}
+
+.remote-indicator {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ background-color: rgba(16, 185, 129, 0.15);
+ border: 1px solid rgba(16, 185, 129, 0.5);
+ border-radius: var(--radius-sm);
+ color: rgb(16, 185, 129);
+ font-size: var(--font-size-xs);
+ animation: pulse-green 1.5s ease-in-out infinite;
}
.hierarchy-content {
@@ -133,3 +147,53 @@
.entity-item.selected .entity-name {
font-weight: var(--font-weight-medium);
}
+
+/* Remote entity styles */
+.entity-item.remote-entity {
+ cursor: default;
+ border-left: 2px solid rgba(16, 185, 129, 0.5);
+}
+
+.entity-item.remote-entity:hover {
+ background-color: rgba(16, 185, 129, 0.05);
+}
+
+.entity-item.remote-entity::before {
+ display: none;
+}
+
+.entity-item.remote-entity.disabled {
+ opacity: 0.5;
+}
+
+.entity-item.remote-entity.disabled .entity-name {
+ text-decoration: line-through;
+ color: var(--color-text-tertiary);
+}
+
+.component-count {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 4px;
+ background-color: rgba(16, 185, 129, 0.2);
+ border: 1px solid rgba(16, 185, 129, 0.5);
+ border-radius: var(--radius-full);
+ color: rgb(16, 185, 129);
+ font-size: 10px;
+ font-weight: var(--font-weight-semibold);
+ flex-shrink: 0;
+}
+
+@keyframes pulse-green {
+ 0%, 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.6;
+ transform: scale(1.05);
+ }
+}