Feature/editor optimization (#251)

* refactor: 编辑器/运行时架构拆分与构建系统升级

* feat(core): 层级系统重构与UI变换矩阵修复

* refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题

* fix(physics): 修复跨包组件类引用问题

* feat: 统一运行时架构与浏览器运行支持

* feat(asset): 实现浏览器运行时资产加载系统

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误

* test: 补齐核心模块测试用例,修复CI构建配置

* fix: 修复测试用例中的类型错误和断言问题

* fix: 修复 turbo build:npm 任务的依赖顺序问题

* fix: 修复 CI 构建错误并优化构建性能
This commit is contained in:
YHH
2025-12-01 22:28:51 +08:00
committed by GitHub
parent 189714c727
commit b42a7b4e43
468 changed files with 18301 additions and 9075 deletions

View File

@@ -8,6 +8,19 @@ import '../styles/AdvancedProfiler.css';
/**
* 高级性能数据接口(与 Core 的 IAdvancedProfilerData 对应)
*/
interface HotspotItem {
name: string;
category: string;
inclusiveTime: number;
inclusiveTimePercent: number;
exclusiveTime: number;
exclusiveTimePercent: number;
callCount: number;
avgCallTime: number;
depth: number;
children?: HotspotItem[];
}
interface AdvancedProfilerData {
currentFrame: {
frameNumber: number;
@@ -41,16 +54,7 @@ interface AdvancedProfilerData {
percentOfFrame: number;
}>;
}>;
hotspots: Array<{
name: string;
category: string;
inclusiveTime: number;
inclusiveTimePercent: number;
exclusiveTime: number;
exclusiveTimePercent: number;
callCount: number;
avgCallTime: number;
}>;
hotspots: HotspotItem[];
callGraph: {
currentFunction: string | null;
callers: Array<{
@@ -120,18 +124,72 @@ const CATEGORY_COLORS: Record<string, string> = {
'Custom': '#64748b'
};
type DataMode = 'oneframe' | 'average' | 'maximum';
export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
const [data, setData] = useState<AdvancedProfilerData | null>(null);
const [isPaused, setIsPaused] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [selectedFunction, setSelectedFunction] = useState<string | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['ECS']));
const [expandedHotspots, setExpandedHotspots] = useState<Set<string>>(new Set());
const [sortColumn, setSortColumn] = useState<SortColumn>('incTime');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [viewMode, setViewMode] = useState<'hierarchical' | 'flat'>('hierarchical');
const [dataMode, setDataMode] = useState<DataMode>('average');
const canvasRef = useRef<HTMLCanvasElement>(null);
const frameHistoryRef = useRef<Array<{ time: number; duration: number }>>([]);
const lastDataRef = useRef<AdvancedProfilerData | null>(null);
// 用于计算平均值和最大值的历史数据
const hotspotHistoryRef = useRef<Map<string, { times: number[]; maxTime: number }>>(new Map());
// 更新历史数据
const updateHotspotHistory = useCallback((hotspots: HotspotItem[]) => {
const updateItem = (item: HotspotItem) => {
const history = hotspotHistoryRef.current.get(item.name) || { times: [], maxTime: 0 };
history.times.push(item.inclusiveTime);
// 保留最近 60 帧的数据
if (history.times.length > 60) {
history.times.shift();
}
history.maxTime = Math.max(history.maxTime, item.inclusiveTime);
hotspotHistoryRef.current.set(item.name, history);
if (item.children) {
item.children.forEach(updateItem);
}
};
hotspots.forEach(updateItem);
}, []);
// 根据数据模式处理 hotspots
const processHotspotsWithDataMode = useCallback((hotspots: HotspotItem[], mode: DataMode): HotspotItem[] => {
if (mode === 'oneframe') {
return hotspots;
}
const processItem = (item: HotspotItem): HotspotItem => {
const history = hotspotHistoryRef.current.get(item.name);
let processedTime = item.inclusiveTime;
if (history && history.times.length > 0) {
if (mode === 'average') {
processedTime = history.times.reduce((a, b) => a + b, 0) / history.times.length;
} else if (mode === 'maximum') {
processedTime = history.maxTime;
}
}
return {
...item,
inclusiveTime: processedTime,
avgCallTime: item.callCount > 0 ? processedTime / item.callCount : 0,
children: item.children ? item.children.map(processItem) : undefined
};
};
return hotspots.map(processItem);
}, []);
// 订阅数据更新
useEffect(() => {
@@ -142,18 +200,21 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
// 解析高级性能数据
if (rawData.advancedProfiler) {
// 更新历史数据
updateHotspotHistory(rawData.advancedProfiler.hotspots);
setData(rawData.advancedProfiler);
lastDataRef.current = rawData.advancedProfiler;
} else if (rawData.performance) {
// 从传统数据构建
const advancedData = buildFromLegacyData(rawData);
updateHotspotHistory(advancedData.hotspots);
setData(advancedData);
lastDataRef.current = advancedData;
}
});
return unsubscribe;
}, [profilerService, isPaused]);
}, [profilerService, isPaused, updateHotspotHistory]);
// 当选中函数变化时,通知服务端
useEffect(() => {
@@ -317,44 +378,90 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
return percent.toFixed(1) + '%';
};
// 展平层级数据用于显示
const flattenHotspots = (items: HotspotItem[], result: HotspotItem[] = []): HotspotItem[] => {
for (const item of items) {
// 搜索过滤
const matchesSearch = searchTerm === '' || item.name.toLowerCase().includes(searchTerm.toLowerCase());
if (viewMode === 'flat') {
// 扁平模式:显示所有层级的项目
if (matchesSearch) {
result.push({ ...item, depth: 0 }); // 扁平模式下深度都是0
}
if (item.children) {
flattenHotspots(item.children, result);
}
} else {
// 层级模式:根据展开状态显示
if (matchesSearch || (item.children && item.children.some(c => c.name.toLowerCase().includes(searchTerm.toLowerCase())))) {
result.push(item);
}
if (item.children && expandedHotspots.has(item.name)) {
flattenHotspots(item.children, result);
}
}
}
return result;
};
// 切换展开状态
const toggleHotspotExpand = (name: string) => {
setExpandedHotspots(prev => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
// 排序数据
const getSortedHotspots = () => {
const getSortedHotspots = (): HotspotItem[] => {
if (!data) return [];
const filtered = data.hotspots.filter(h =>
searchTerm === '' || h.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// 先根据数据模式处理 hotspots
const processedHotspots = processHotspotsWithDataMode(data.hotspots, dataMode);
const flattened = flattenHotspots(processedHotspots);
return [...filtered].sort((a, b) => {
let comparison = 0;
switch (sortColumn) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'incTime':
comparison = a.inclusiveTime - b.inclusiveTime;
break;
case 'incPercent':
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
break;
case 'excTime':
comparison = a.exclusiveTime - b.exclusiveTime;
break;
case 'excPercent':
comparison = a.exclusiveTimePercent - b.exclusiveTimePercent;
break;
case 'calls':
comparison = a.callCount - b.callCount;
break;
case 'avgTime':
comparison = a.avgCallTime - b.avgCallTime;
break;
case 'framePercent':
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
// 扁平模式下排序
if (viewMode === 'flat') {
return [...flattened].sort((a, b) => {
let comparison = 0;
switch (sortColumn) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'incTime':
comparison = a.inclusiveTime - b.inclusiveTime;
break;
case 'incPercent':
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
break;
case 'excTime':
comparison = a.exclusiveTime - b.exclusiveTime;
break;
case 'excPercent':
comparison = a.exclusiveTimePercent - b.exclusiveTimePercent;
break;
case 'calls':
comparison = a.callCount - b.callCount;
break;
case 'avgTime':
comparison = a.avgCallTime - b.avgCallTime;
break;
case 'framePercent':
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
}
// 层级模式下保持原有层级顺序
return flattened;
};
const renderSortIcon = (column: SortColumn) => {
@@ -512,7 +619,11 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
<Activity size={14} />
<span className="profiler-graph-title">Call Graph</span>
<div className="profiler-callgraph-controls">
<select className="profiler-callgraph-type-select">
<select
className="profiler-callgraph-type-select"
value={dataMode}
onChange={(e) => setDataMode(e.target.value as DataMode)}
>
<option value="oneframe">One Frame</option>
<option value="average">Average</option>
<option value="maximum">Maximum</option>
@@ -654,49 +765,67 @@ export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
</div>
</div>
<div className="profiler-table-body">
{getSortedHotspots().map((item, index) => (
<div
key={item.name + index}
className={`profiler-table-row ${selectedFunction === item.name ? 'selected' : ''}`}
onClick={() => setSelectedFunction(item.name)}
>
<div className="profiler-table-cell col-name name">
<ChevronRight size={12} className="expand-icon" />
<span
className="category-dot"
style={{ background: CATEGORY_COLORS[item.category] || '#666' }}
/>
{item.name}
</div>
<div className="profiler-table-cell col-inc-time numeric">
{formatTime(item.inclusiveTime)}
</div>
<div className="profiler-table-cell col-inc-percent percent">
<div className="bar-container">
<div
className={`bar ${item.inclusiveTimePercent > 50 ? 'critical' : item.inclusiveTimePercent > 25 ? 'warning' : ''}`}
style={{ width: `${Math.min(item.inclusiveTimePercent, 100)}%` }}
{getSortedHotspots().map((item, index) => {
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedHotspots.has(item.name);
const indentPadding = viewMode === 'hierarchical' ? item.depth * 16 : 0;
return (
<div
key={item.name + index + item.depth}
className={`profiler-table-row ${selectedFunction === item.name ? 'selected' : ''} depth-${item.depth}`}
onClick={() => setSelectedFunction(item.name)}
>
<div className="profiler-table-cell col-name name" style={{ paddingLeft: indentPadding }}>
{hasChildren && viewMode === 'hierarchical' ? (
<span
className={`expand-icon clickable ${isExpanded ? 'expanded' : ''}`}
onClick={(e) => {
e.stopPropagation();
toggleHotspotExpand(item.name);
}}
>
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
) : (
<span className="expand-icon placeholder" style={{ width: 12 }} />
)}
<span
className="category-dot"
style={{ background: CATEGORY_COLORS[item.category] || '#666' }}
/>
<span>{formatPercent(item.inclusiveTimePercent)}</span>
{item.name}
</div>
<div className="profiler-table-cell col-inc-time numeric">
{formatTime(item.inclusiveTime)}
</div>
<div className="profiler-table-cell col-inc-percent percent">
<div className="bar-container">
<div
className={`bar ${item.inclusiveTimePercent > 50 ? 'critical' : item.inclusiveTimePercent > 25 ? 'warning' : ''}`}
style={{ width: `${Math.min(item.inclusiveTimePercent, 100)}%` }}
/>
<span>{formatPercent(item.inclusiveTimePercent)}</span>
</div>
</div>
<div className="profiler-table-cell col-exc-time numeric">
{formatTime(item.exclusiveTime)}
</div>
<div className="profiler-table-cell col-exc-percent percent">
{formatPercent(item.exclusiveTimePercent)}
</div>
<div className="profiler-table-cell col-calls numeric">
{item.callCount}
</div>
<div className="profiler-table-cell col-avg-calls numeric">
{formatTime(item.avgCallTime)}
</div>
<div className="profiler-table-cell col-frame-percent percent">
{formatPercent(item.inclusiveTimePercent)}
</div>
</div>
<div className="profiler-table-cell col-exc-time numeric">
{formatTime(item.exclusiveTime)}
</div>
<div className="profiler-table-cell col-exc-percent percent">
{formatPercent(item.exclusiveTimePercent)}
</div>
<div className="profiler-table-cell col-calls numeric">
{item.callCount}
</div>
<div className="profiler-table-cell col-avg-calls numeric">
{formatTime(item.avgCallTime)}
</div>
<div className="profiler-table-cell col-frame-percent percent">
{formatPercent(item.inclusiveTimePercent)}
</div>
</div>
))}
);
})}
</div>
</div>
</div>
@@ -716,7 +845,7 @@ function buildFromLegacyData(rawData: any): AdvancedProfilerData {
const fps = frameTime > 0 ? Math.round(1000 / frameTime) : 0;
// 构建 hotspots
const hotspots = systems.map((sys: any) => ({
const hotspots: HotspotItem[] = systems.map((sys: any) => ({
name: sys.name || sys.type || 'Unknown',
category: 'ECS',
inclusiveTime: sys.executionTime || 0,
@@ -724,7 +853,8 @@ function buildFromLegacyData(rawData: any): AdvancedProfilerData {
exclusiveTime: sys.executionTime || 0,
exclusiveTimePercent: frameTime > 0 ? (sys.executionTime / frameTime) * 100 : 0,
callCount: 1,
avgCallTime: sys.executionTime || 0
avgCallTime: sys.executionTime || 0,
depth: 0
}));
// 构建 categoryStats

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { X, BarChart3 } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { X, BarChart3, Maximize2, Minimize2 } from 'lucide-react';
import { ProfilerService } from '../services/ProfilerService';
import { AdvancedProfiler } from './AdvancedProfiler';
import '../styles/ProfilerWindow.css';
@@ -15,6 +15,7 @@ interface WindowWithProfiler extends Window {
export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps) {
const [profilerService, setProfilerService] = useState<ProfilerService | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const service = (window as WindowWithProfiler).__PROFILER_SERVICE__;
@@ -36,12 +37,35 @@ export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps)
return () => clearInterval(interval);
}, [profilerService]);
// 处理 ESC 键退出全屏
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) {
setIsFullscreen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isFullscreen]);
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
const windowStyle = isFullscreen
? { width: '100vw', height: '100vh', maxWidth: 'none', borderRadius: 0 }
: { width: '90vw', height: '85vh', maxWidth: '1600px' };
return (
<div className="profiler-window-overlay" onClick={onClose}>
<div
className={`profiler-window-overlay ${isFullscreen ? 'fullscreen' : ''}`}
onClick={isFullscreen ? undefined : onClose}
>
<div
className="profiler-window advanced-profiler-window"
className={`profiler-window advanced-profiler-window ${isFullscreen ? 'fullscreen' : ''}`}
onClick={(e) => e.stopPropagation()}
style={{ width: '90vw', height: '85vh', maxWidth: '1600px' }}
style={windowStyle}
>
<div className="profiler-window-header">
<div className="profiler-window-title">
@@ -53,9 +77,18 @@ export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps)
</span>
)}
</div>
<button className="profiler-window-close" onClick={onClose} title="Close">
<X size={20} />
</button>
<div className="profiler-window-controls">
<button
className="profiler-window-btn"
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
</button>
<button className="profiler-window-close" onClick={onClose} title="Close">
<X size={20} />
</button>
</div>
</div>
<div className="profiler-window-content" style={{ padding: 0 }}>

View File

@@ -1,331 +0,0 @@
import { useState, useEffect, useRef, useMemo, memo } from 'react';
import { LogService, LogEntry } from '@esengine/editor-core';
import { LogLevel } from '@esengine/ecs-framework';
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Wifi } from 'lucide-react';
import { JsonViewer } from './JsonViewer';
import '../styles/ConsolePanel.css';
interface ConsolePanelProps {
logService: LogService;
}
const MAX_LOGS = 1000;
// 提取JSON检测和格式化逻辑
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
try {
const parsed: unknown = JSON.parse(message);
return { isJSON: true, parsed };
} catch {
return { isJSON: false };
}
}
// 格式化时间
function formatTime(date: Date): string {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
}
// 日志等级图标
function getLevelIcon(level: LogLevel) {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
case LogLevel.Info:
return <Info size={14} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
default:
return <AlertCircle size={14} />;
}
}
// 日志等级样式类
function getLevelClass(level: LogLevel): string {
switch (level) {
case LogLevel.Debug:
return 'log-entry-debug';
case LogLevel.Info:
return 'log-entry-info';
case LogLevel.Warn:
return 'log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'log-entry-error';
default:
return '';
}
}
// 单个日志条目组件
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
log: LogEntry;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onOpenJsonViewer: (data: any) => void;
}) => {
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
const shouldTruncate = log.message.length > 200;
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
<div className="log-entry-icon">
{getLevelIcon(log.level)}
</div>
<div className="log-entry-time">
{formatTime(log.timestamp)}
</div>
<div className={`log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
</div>
{log.clientId && (
<div className="log-entry-client" title={`Client: ${log.clientId}`}>
{log.clientId}
</div>
)}
<div className="log-entry-message">
<div className="log-message-container">
<div className="log-message-text">
{shouldTruncate && !isExpanded ? (
<>
<span className="log-message-preview">
{log.message.substring(0, 200)}...
</span>
<button
className="log-expand-btn"
onClick={() => setIsExpanded(true)}
>
Show more
</button>
</>
) : (
<>
<span>{log.message}</span>
{shouldTruncate && (
<button
className="log-expand-btn"
onClick={() => setIsExpanded(false)}
>
Show less
</button>
)}
</>
)}
</div>
{isJSON && parsed !== undefined && (
<button
className="log-open-json-btn"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick={() => onOpenJsonViewer(parsed as any)}
title="Open in JSON Viewer"
>
JSON
</button>
)}
</div>
</div>
</div>
);
});
LogEntryItem.displayName = 'LogEntryItem';
export function ConsolePanel({ logService }: ConsolePanelProps) {
// 状态管理
const [logs, setLogs] = useState<LogEntry[]>(() => logService.getLogs().slice(-MAX_LOGS));
const [filter, setFilter] = useState('');
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(new Set([
LogLevel.Debug,
LogLevel.Info,
LogLevel.Warn,
LogLevel.Error,
LogLevel.Fatal
]));
const [showRemoteOnly, setShowRemoteOnly] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
// 订阅日志更新
useEffect(() => {
const unsubscribe = logService.subscribe((entry) => {
setLogs((prev) => {
const newLogs = [...prev, entry];
return newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs;
});
});
return unsubscribe;
}, [logService]);
// 自动滚动
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
// 处理滚动
const handleScroll = () => {
if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
}
};
// 清空日志
const handleClear = () => {
logService.clear();
setLogs([]);
};
// 切换等级过滤
const toggleLevelFilter = (level: LogLevel) => {
const newFilter = new Set(levelFilter);
if (newFilter.has(level)) {
newFilter.delete(level);
} else {
newFilter.add(level);
}
setLevelFilter(newFilter);
};
// 过滤日志
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
if (!levelFilter.has(log.level)) return false;
if (showRemoteOnly && log.source !== 'remote') return false;
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
return false;
}
return true;
});
}, [logs, levelFilter, showRemoteOnly, filter]);
// 统计各等级日志数量
const levelCounts = useMemo(() => ({
[LogLevel.Debug]: logs.filter((l) => l.level === LogLevel.Debug).length,
[LogLevel.Info]: logs.filter((l) => l.level === LogLevel.Info).length,
[LogLevel.Warn]: logs.filter((l) => l.level === LogLevel.Warn).length,
[LogLevel.Error]: logs.filter((l) => l.level === LogLevel.Error || l.level === LogLevel.Fatal).length
}), [logs]);
const remoteLogCount = useMemo(() =>
logs.filter((l) => l.source === 'remote').length
, [logs]);
return (
<div className="console-panel">
<div className="console-toolbar">
<div className="console-toolbar-left">
<button
className="console-btn"
onClick={handleClear}
title="Clear console"
>
<Trash2 size={14} />
</button>
<div className="console-search">
<Search size={14} />
<input
type="text"
placeholder="Filter logs..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
<div className="console-toolbar-right">
<button
className={`console-filter-btn ${showRemoteOnly ? 'active' : ''}`}
onClick={() => setShowRemoteOnly(!showRemoteOnly)}
title="Show Remote Logs Only"
>
<Wifi size={14} />
{remoteLogCount > 0 && <span>{remoteLogCount}</span>}
</button>
<button
className={`console-filter-btn ${levelFilter.has(LogLevel.Debug) ? 'active' : ''}`}
onClick={() => toggleLevelFilter(LogLevel.Debug)}
title="Debug"
>
<Bug size={14} />
{levelCounts[LogLevel.Debug] > 0 && <span>{levelCounts[LogLevel.Debug]}</span>}
</button>
<button
className={`console-filter-btn ${levelFilter.has(LogLevel.Info) ? 'active' : ''}`}
onClick={() => toggleLevelFilter(LogLevel.Info)}
title="Info"
>
<Info size={14} />
{levelCounts[LogLevel.Info] > 0 && <span>{levelCounts[LogLevel.Info]}</span>}
</button>
<button
className={`console-filter-btn ${levelFilter.has(LogLevel.Warn) ? 'active' : ''}`}
onClick={() => toggleLevelFilter(LogLevel.Warn)}
title="Warnings"
>
<AlertTriangle size={14} />
{levelCounts[LogLevel.Warn] > 0 && <span>{levelCounts[LogLevel.Warn]}</span>}
</button>
<button
className={`console-filter-btn ${levelFilter.has(LogLevel.Error) ? 'active' : ''}`}
onClick={() => toggleLevelFilter(LogLevel.Error)}
title="Errors"
>
<XCircle size={14} />
{levelCounts[LogLevel.Error] > 0 && <span>{levelCounts[LogLevel.Error]}</span>}
</button>
</div>
</div>
<div
className="console-content"
ref={logContainerRef}
onScroll={handleScroll}
>
{filteredLogs.length === 0 ? (
<div className="console-empty">
<AlertCircle size={32} />
<p>No logs to display</p>
</div>
) : (
filteredLogs.map((log, index) => (
<LogEntryItem
key={`${log.id}-${index}`}
log={log}
onOpenJsonViewer={setJsonViewerData}
/>
))
)}
</div>
{jsonViewerData && (
<JsonViewer
data={jsonViewerData}
onClose={() => setJsonViewerData(null)}
/>
)}
{!autoScroll && (
<button
className="console-scroll-to-bottom"
onClick={() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
setAutoScroll(true);
}
}}
>
Scroll to bottom
</button>
)}
</div>
);
}

View File

@@ -26,7 +26,15 @@ import {
Trash2,
Edit3,
ExternalLink,
PanelRightClose
PanelRightClose,
Tag,
Link,
FileSearch,
Globe,
Package,
Clipboard,
RefreshCw,
Settings
} from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core';
@@ -564,9 +572,191 @@ export function ContentBrowser({
icon: <File size={16} />,
onClick: () => handleAssetDoubleClick(asset)
});
items.push({ label: '', separator: true, onClick: () => {} });
// 保存
items.push({
label: locale === 'zh' ? '保存' : 'Save',
icon: <Save size={16} />,
shortcut: 'Ctrl+S',
onClick: () => {
console.log('Save file:', asset.path);
}
});
}
// 重命名
items.push({
label: locale === 'zh' ? '重命名' : 'Rename',
icon: <Edit3 size={16} />,
shortcut: 'F2',
onClick: () => {
setRenameDialog({ asset, newName: asset.name });
setContextMenu(null);
}
});
// 批量重命名
items.push({
label: locale === 'zh' ? '批量重命名' : 'Batch Rename',
icon: <Edit3 size={16} />,
shortcut: 'Shift+F2',
disabled: true,
onClick: () => {
console.log('Batch rename');
}
});
// 复制
items.push({
label: locale === 'zh' ? '复制' : 'Duplicate',
icon: <Clipboard size={16} />,
shortcut: 'Ctrl+D',
onClick: () => {
console.log('Duplicate:', asset.path);
}
});
// 删除
items.push({
label: locale === 'zh' ? '删除' : 'Delete',
icon: <Trash2 size={16} />,
shortcut: 'Delete',
onClick: () => {
setDeleteConfirmDialog(asset);
setContextMenu(null);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 资产操作子菜单
items.push({
label: locale === 'zh' ? '资产操作' : 'Asset Actions',
icon: <Settings size={16} />,
onClick: () => {},
children: [
{
label: locale === 'zh' ? '重新导入' : 'Reimport',
icon: <RefreshCw size={16} />,
onClick: () => {
console.log('Reimport asset:', asset.path);
}
},
{
label: locale === 'zh' ? '导出...' : 'Export...',
icon: <Package size={16} />,
onClick: () => {
console.log('Export asset:', asset.path);
}
},
{ label: '', separator: true, onClick: () => {} },
{
label: locale === 'zh' ? '迁移资产' : 'Migrate Asset',
icon: <Folder size={16} />,
onClick: () => {
console.log('Migrate asset:', asset.path);
}
}
]
});
// 资产本地化子菜单
items.push({
label: locale === 'zh' ? '资产本地化' : 'Asset Localization',
icon: <Globe size={16} />,
onClick: () => {},
children: [
{
label: locale === 'zh' ? '创建本地化资产' : 'Create Localized Asset',
onClick: () => {
console.log('Create localized asset:', asset.path);
}
},
{
label: locale === 'zh' ? '导入翻译' : 'Import Translation',
onClick: () => {
console.log('Import translation:', asset.path);
}
},
{
label: locale === 'zh' ? '导出翻译' : 'Export Translation',
onClick: () => {
console.log('Export translation:', asset.path);
}
}
]
});
items.push({ label: '', separator: true, onClick: () => {} });
// 标签管理
items.push({
label: locale === 'zh' ? '管理标签' : 'Manage Tags',
icon: <Tag size={16} />,
shortcut: 'Ctrl+T',
onClick: () => {
console.log('Manage tags:', asset.path);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 路径复制选项
items.push({
label: locale === 'zh' ? '复制引用' : 'Copy Reference',
icon: <Link size={16} />,
shortcut: 'Ctrl+C',
onClick: () => {
navigator.clipboard.writeText(asset.path);
}
});
items.push({
label: locale === 'zh' ? '拷贝Object路径' : 'Copy Object Path',
icon: <Copy size={16} />,
shortcut: 'Ctrl+Shift+C',
onClick: () => {
const objectPath = asset.path.replace(/\\/g, '/');
navigator.clipboard.writeText(objectPath);
}
});
items.push({
label: locale === 'zh' ? '拷贝包路径' : 'Copy Package Path',
icon: <Package size={16} />,
shortcut: 'Ctrl+Alt+C',
onClick: () => {
const packagePath = '/' + asset.path.replace(/\\/g, '/').split('/').slice(-2).join('/');
navigator.clipboard.writeText(packagePath);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 引用查看器
items.push({
label: locale === 'zh' ? '引用查看器' : 'Reference Viewer',
icon: <FileSearch size={16} />,
shortcut: 'Alt+Shift+R',
onClick: () => {
console.log('Open reference viewer:', asset.path);
}
});
items.push({
label: locale === 'zh' ? '尺寸信息图' : 'Size Map',
icon: <FileSearch size={16} />,
shortcut: 'Alt+Shift+D',
onClick: () => {
console.log('Show size map:', asset.path);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 在文件管理器中显示
items.push({
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
icon: <ExternalLink size={16} />,
@@ -579,34 +769,8 @@ export function ContentBrowser({
}
});
items.push({
label: locale === 'zh' ? '复制路径' : 'Copy Path',
icon: <Copy size={16} />,
onClick: () => navigator.clipboard.writeText(asset.path)
});
items.push({ label: '', separator: true, onClick: () => {} });
items.push({
label: locale === 'zh' ? '重命名' : 'Rename',
icon: <Edit3 size={16} />,
onClick: () => {
setRenameDialog({ asset, newName: asset.name });
setContextMenu(null);
}
});
items.push({
label: locale === 'zh' ? '删除' : 'Delete',
icon: <Trash2 size={16} />,
onClick: () => {
setDeleteConfirmDialog(asset);
setContextMenu(null);
}
});
return items;
}, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder]);
}, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder, setRenameDialog, setDeleteConfirmDialog, setContextMenu, setCreateFileDialog]);
// Render folder tree node
const renderFolderNode = useCallback((node: FolderNode, depth: number = 0) => {
@@ -818,7 +982,10 @@ export function ContentBrowser({
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
onClick={(e) => handleAssetClick(asset, e)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
onContextMenu={(e) => {
e.stopPropagation();
handleContextMenu(e, asset);
}}
draggable={asset.type === 'file'}
onDragStart={(e) => {
if (asset.type === 'file') {

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { ChevronRight } from 'lucide-react';
import '../styles/ContextMenu.css';
export interface ContextMenuItem {
@@ -7,6 +8,10 @@ export interface ContextMenuItem {
onClick: () => void;
disabled?: boolean;
separator?: boolean;
/** 快捷键提示文本 */
shortcut?: string;
/** 子菜单项 */
children?: ContextMenuItem[];
}
interface ContextMenuProps {
@@ -15,9 +20,113 @@ interface ContextMenuProps {
onClose: () => void;
}
interface SubMenuProps {
items: ContextMenuItem[];
parentRect: DOMRect;
onClose: () => void;
}
/**
* 子菜单组件
*/
function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
useEffect(() => {
if (menuRef.current) {
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 默认在父菜单右侧显示
let x = parentRect.right;
let y = parentRect.top;
// 如果右侧空间不足,显示在左侧
if (x + rect.width > viewportWidth) {
x = parentRect.left - rect.width;
}
// 如果底部空间不足,向上调整
if (y + rect.height > viewportHeight) {
y = Math.max(0, viewportHeight - rect.height - 10);
}
setPosition({ x, y });
}
}, [parentRect]);
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
if (item.children && item.children.length > 0) {
setActiveSubmenuIndex(index);
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setSubmenuRect(itemRect);
} else {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}
}, []);
return (
<div
ref={menuRef}
className="context-menu submenu"
style={{
left: `${position.x}px`,
top: `${position.y}px`
}}
>
{items.map((item, index) => {
if (item.separator) {
return <div key={index} className="context-menu-separator" />;
}
const hasChildren = item.children && item.children.length > 0;
return (
<div
key={index}
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
onClick={() => {
if (!item.disabled && !hasChildren) {
item.onClick();
onClose();
}
}}
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
onMouseLeave={() => {
if (!item.children) {
setActiveSubmenuIndex(null);
}
}}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span className="context-menu-label">{item.label}</span>
{item.shortcut && <span className="context-menu-shortcut">{item.shortcut}</span>}
{hasChildren && <ChevronRight size={12} className="context-menu-arrow" />}
{activeSubmenuIndex === index && submenuRect && item.children && (
<SubMenu
items={item.children}
parentRect={submenuRect}
onClose={onClose}
/>
)}
</div>
);
})}
</div>
);
}
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
useEffect(() => {
if (menuRef.current) {
@@ -65,6 +174,17 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
};
}, [onClose]);
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
if (item.children && item.children.length > 0) {
setActiveSubmenuIndex(index);
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setSubmenuRect(itemRect);
} else {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}
}, []);
return (
<div
ref={menuRef}
@@ -79,19 +199,36 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
return <div key={index} className="context-menu-separator" />;
}
const hasChildren = item.children && item.children.length > 0;
return (
<div
key={index}
className={`context-menu-item ${item.disabled ? 'disabled' : ''}`}
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
onClick={() => {
if (!item.disabled) {
if (!item.disabled && !hasChildren) {
item.onClick();
onClose();
}
}}
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
onMouseLeave={() => {
if (!item.children) {
setActiveSubmenuIndex(null);
}
}}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span className="context-menu-label">{item.label}</span>
{item.shortcut && <span className="context-menu-shortcut">{item.shortcut}</span>}
{hasChildren && <ChevronRight size={12} className="context-menu-arrow" />}
{activeSubmenuIndex === index && submenuRect && item.children && (
<SubMenu
items={item.children}
parentRect={submenuRect}
onClose={onClose}
/>
)}
</div>
);
})}

View File

@@ -1,6 +1,9 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import * as LucideIcons from 'lucide-react';
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, Plus } from 'lucide-react';
import {
Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, Plus,
Save, Tag, Link, FileSearch, Globe, Package, Clipboard, RefreshCw, Settings
} from 'lucide-react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
@@ -614,25 +617,187 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
}
}
}
items.push({ label: '', separator: true, onClick: () => {} });
// 文件操作菜单项
items.push({
label: '保存',
icon: <Save size={16} />,
shortcut: 'Ctrl+S',
onClick: () => {
// TODO: 实现保存功能
console.log('Save file:', node.path);
}
});
}
items.push({
label: '重命名',
icon: <Edit3 size={16} />,
shortcut: 'F2',
onClick: () => {
setRenamingNode(node.path);
setNewName(node.name);
}
});
items.push({
label: '批量重命名',
icon: <Edit3 size={16} />,
shortcut: 'Shift+F2',
disabled: true, // TODO: 实现批量重命名
onClick: () => {
console.log('Batch rename');
}
});
items.push({
label: '复制',
icon: <Clipboard size={16} />,
shortcut: 'Ctrl+D',
onClick: () => {
// TODO: 实现复制功能
console.log('Duplicate:', node.path);
}
});
items.push({
label: '删除',
icon: <Trash2 size={16} />,
shortcut: 'Delete',
onClick: () => handleDeleteClick(node)
});
items.push({ label: '', separator: true, onClick: () => {} });
// 资产操作子菜单
items.push({
label: '资产操作',
icon: <Settings size={16} />,
onClick: () => {},
children: [
{
label: '重新导入',
icon: <RefreshCw size={16} />,
onClick: () => {
console.log('Reimport asset:', node.path);
}
},
{
label: '导出...',
icon: <Package size={16} />,
onClick: () => {
console.log('Export asset:', node.path);
}
},
{ label: '', separator: true, onClick: () => {} },
{
label: '迁移资产',
icon: <Folder size={16} />,
onClick: () => {
console.log('Migrate asset:', node.path);
}
}
]
});
// 资产本地化子菜单
items.push({
label: '资产本地化',
icon: <Globe size={16} />,
onClick: () => {},
children: [
{
label: '创建本地化资产',
onClick: () => {
console.log('Create localized asset:', node.path);
}
},
{
label: '导入翻译',
onClick: () => {
console.log('Import translation:', node.path);
}
},
{
label: '导出翻译',
onClick: () => {
console.log('Export translation:', node.path);
}
}
]
});
items.push({ label: '', separator: true, onClick: () => {} });
// 标签和引用
items.push({
label: '管理标签',
icon: <Tag size={16} />,
shortcut: 'Ctrl+T',
onClick: () => {
console.log('Manage tags:', node.path);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 路径复制选项
items.push({
label: '复制引用',
icon: <Link size={16} />,
shortcut: 'Ctrl+C',
onClick: () => {
navigator.clipboard.writeText(node.path);
}
});
items.push({
label: '拷贝Object路径',
icon: <Copy size={16} />,
shortcut: 'Ctrl+Shift+C',
onClick: () => {
// 生成对象路径格式
const objectPath = node.path.replace(/\\/g, '/');
navigator.clipboard.writeText(objectPath);
}
});
items.push({
label: '拷贝包路径',
icon: <Package size={16} />,
shortcut: 'Ctrl+Alt+C',
onClick: () => {
// 生成包路径格式
const packagePath = '/' + node.path.replace(/\\/g, '/').split('/').slice(-2).join('/');
navigator.clipboard.writeText(packagePath);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 引用查看器
items.push({
label: '引用查看器',
icon: <FileSearch size={16} />,
shortcut: 'Alt+Shift+R',
onClick: () => {
console.log('Open reference viewer:', node.path);
}
});
items.push({
label: '尺寸信息图',
icon: <FileSearch size={16} />,
shortcut: 'Alt+Shift+D',
onClick: () => {
console.log('Show size map:', node.path);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
if (node.type === 'folder') {
items.push({
label: '新建文件',
@@ -675,14 +840,6 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
}
});
items.push({
label: '复制路径',
icon: <Copy size={16} />,
onClick: () => {
navigator.clipboard.writeText(node.path);
}
});
return items;
};

View File

@@ -3,9 +3,8 @@ import { LogService, LogEntry } from '@esengine/editor-core';
import { LogLevel } from '@esengine/ecs-framework';
import {
Search, Filter, Settings, X, Trash2, ChevronDown,
Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play
Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play, Copy
} from 'lucide-react';
import { JsonViewer } from './JsonViewer';
import '../styles/OutputLogPanel.css';
interface OutputLogPanelProps {
@@ -16,15 +15,6 @@ interface OutputLogPanelProps {
const MAX_LOGS = 1000;
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
try {
const parsed: unknown = JSON.parse(message);
return { isJSON: true, parsed };
} catch {
return { isJSON: false };
}
}
function formatTime(date: Date): string {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
@@ -33,103 +23,121 @@ function formatTime(date: Date): string {
return `${hours}:${minutes}:${seconds}.${ms}`;
}
function getLevelIcon(level: LogLevel) {
function getLevelIcon(level: LogLevel, size: number = 14) {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
return <Bug size={size} />;
case LogLevel.Info:
return <Info size={14} />;
return <Info size={size} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
return <AlertTriangle size={size} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
return <XCircle size={size} />;
default:
return <AlertCircle size={14} />;
return <AlertCircle size={size} />;
}
}
function getLevelClass(level: LogLevel): string {
switch (level) {
case LogLevel.Debug:
return 'log-entry-debug';
return 'output-log-entry-debug';
case LogLevel.Info:
return 'log-entry-info';
return 'output-log-entry-info';
case LogLevel.Warn:
return 'log-entry-warn';
return 'output-log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'log-entry-error';
return 'output-log-entry-error';
default:
return '';
}
}
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
/**
* 尝试从消息中提取堆栈信息
*/
function extractStackTrace(message: string): { message: string; stack: string | null } {
const stackPattern = /\n\s*at\s+/;
if (stackPattern.test(message)) {
const lines = message.split('\n');
const messageLines: string[] = [];
const stackLines: string[] = [];
let inStack = false;
for (const line of lines) {
if (line.trim().startsWith('at ') || inStack) {
inStack = true;
stackLines.push(line);
} else {
messageLines.push(line);
}
}
return {
message: messageLines.join('\n').trim(),
stack: stackLines.length > 0 ? stackLines.join('\n') : null
};
}
return { message, stack: null };
}
const LogEntryItem = memo(({ log, isExpanded, onToggle, onCopy }: {
log: LogEntry;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onOpenJsonViewer: (data: any) => void;
isExpanded: boolean;
onToggle: () => void;
onCopy: () => void;
}) => {
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
const shouldTruncate = log.message.length > 200;
const [isExpanded, setIsExpanded] = useState(false);
// 优先使用 log.stack否则尝试从 message 中提取
const { message, stack } = useMemo(() => {
if (log.stack) {
return { message: log.message, stack: log.stack };
}
return extractStackTrace(log.message);
}, [log.message, log.stack]);
const hasStack = !!stack;
return (
<div className={`output-log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
<div className="output-log-entry-icon">
{getLevelIcon(log.level)}
<div
className={`output-log-entry ${getLevelClass(log.level)} ${isExpanded ? 'expanded' : ''} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${hasStack ? 'has-stack' : ''}`}
>
<div className="output-log-entry-main" onClick={hasStack ? onToggle : undefined} style={{ cursor: hasStack ? 'pointer' : 'default' }}>
<div className="output-log-entry-icon">
{getLevelIcon(log.level)}
</div>
<div className="output-log-entry-time">
{formatTime(log.timestamp)}
</div>
<div className={`output-log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
[{log.source === 'remote' ? 'Remote' : log.source}]
</div>
<div className="output-log-entry-message">
{message}
</div>
<button
className="output-log-entry-copy"
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
title="复制"
>
<Copy size={12} />
</button>
</div>
<div className="output-log-entry-time">
{formatTime(log.timestamp)}
</div>
<div className={`output-log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
</div>
{log.clientId && (
<div className="output-log-entry-client" title={`Client: ${log.clientId}`}>
{log.clientId}
{isExpanded && stack && (
<div className="output-log-entry-stack">
<div className="output-log-stack-header">:</div>
{stack.split('\n').filter(line => line.trim()).map((line, index) => (
<div key={index} className="output-log-stack-line">
{line}
</div>
))}
</div>
)}
<div className="output-log-entry-message">
<div className="output-log-message-container">
<div className="output-log-message-text">
{shouldTruncate && !isExpanded ? (
<>
<span className="output-log-message-preview">
{log.message.substring(0, 200)}...
</span>
<button
className="output-log-expand-btn"
onClick={() => setIsExpanded(true)}
>
Show more
</button>
</>
) : (
<>
<span>{log.message}</span>
{shouldTruncate && (
<button
className="output-log-expand-btn"
onClick={() => setIsExpanded(false)}
>
Show less
</button>
)}
</>
)}
</div>
{isJSON && parsed !== undefined && (
<button
className="output-log-json-btn"
onClick={() => onOpenJsonViewer(parsed)}
title="Open in JSON Viewer"
>
JSON
</button>
)}
</div>
</div>
</div>
);
});
@@ -150,10 +158,7 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
const [autoScroll, setAutoScroll] = useState(true);
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [showSettingsMenu, setShowSettingsMenu] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
const [showTimestamp, setShowTimestamp] = useState(true);
const [showSource, setShowSource] = useState(true);
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
const logContainerRef = useRef<HTMLDivElement>(null);
const filterMenuRef = useRef<HTMLDivElement>(null);
const settingsMenuRef = useRef<HTMLDivElement>(null);
@@ -174,7 +179,6 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
}
}, [logs, autoScroll]);
// Close menus on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
@@ -199,6 +203,7 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
const handleClear = useCallback(() => {
logService.clear();
setLogs([]);
setExpandedLogIds(new Set());
}, [logService]);
const toggleLevelFilter = useCallback((level: LogLevel) => {
@@ -213,6 +218,22 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
});
}, []);
const toggleLogExpanded = useCallback((logId: string) => {
setExpandedLogIds(prev => {
const newSet = new Set(prev);
if (newSet.has(logId)) {
newSet.delete(logId);
} else {
newSet.add(logId);
}
return newSet;
});
}, []);
const handleCopyLog = useCallback((log: LogEntry) => {
navigator.clipboard.writeText(log.message);
}, []);
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
if (!levelFilter.has(log.level)) return false;
@@ -376,26 +397,6 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
</button>
{showSettingsMenu && (
<div className="output-log-menu settings-menu">
<div className="output-log-menu-header">
{locale === 'zh' ? '显示选项' : 'Display Options'}
</div>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={showTimestamp}
onChange={() => setShowTimestamp(!showTimestamp)}
/>
<span>{locale === 'zh' ? '显示时间戳' : 'Show Timestamp'}</span>
</label>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={showSource}
onChange={() => setShowSource(!showSource)}
/>
<span>{locale === 'zh' ? '显示来源' : 'Show Source'}</span>
</label>
<div className="output-log-menu-divider" />
<button
className="output-log-menu-action"
onClick={handleClear}
@@ -421,7 +422,7 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
{/* Log Content */}
<div
className={`output-log-content ${!showTimestamp ? 'hide-timestamp' : ''} ${!showSource ? 'hide-source' : ''}`}
className="output-log-content"
ref={logContainerRef}
onScroll={handleScroll}
>
@@ -438,7 +439,9 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
<LogEntryItem
key={`${log.id}-${index}`}
log={log}
onOpenJsonViewer={setJsonViewerData}
isExpanded={expandedLogIds.has(String(log.id))}
onToggle={() => toggleLogExpanded(String(log.id))}
onCopy={() => handleCopyLog(log)}
/>
))
)}
@@ -461,14 +464,6 @@ export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLog
</button>
)}
</div>
{/* JSON Viewer Modal */}
{jsonViewerData && (
<JsonViewer
data={jsonViewerData}
onClose={() => setJsonViewerData(null)}
/>
)}
</div>
);
}

View File

@@ -11,8 +11,8 @@
import { useState, useEffect } from 'react';
import { Core } from '@esengine/ecs-framework';
import { PluginManager, type RegisteredPlugin, type PluginCategory } from '@esengine/editor-core';
import { Check, Lock, RefreshCw, Package } from 'lucide-react';
import { PluginManager, type RegisteredPlugin, type PluginCategory, ProjectService } from '@esengine/editor-core';
import { Check, Lock, Package } from 'lucide-react';
import { NotificationService } from '../services/NotificationService';
import '../styles/PluginListSetting.css';
@@ -30,14 +30,14 @@ const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
networking: { zh: '网络', en: 'Networking' },
tools: { zh: '工具', en: 'Tools' },
scripting: { zh: '脚本', en: 'Scripting' },
content: { zh: '内容', en: 'Content' }
content: { zh: '内容', en: 'Content' },
tilemap: { zh: '瓦片地图', en: 'Tilemap' }
};
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tools', 'content'];
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tilemap', 'tools', 'content'];
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
const [pendingChanges, setPendingChanges] = useState<Map<string, boolean>>(new Map());
useEffect(() => {
loadPlugins();
@@ -55,11 +55,11 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
}
};
const handleToggle = (pluginId: string) => {
const plugin = plugins.find(p => p.loader.descriptor.id === pluginId);
const handleToggle = async (pluginId: string) => {
const plugin = plugins.find(p => p.plugin.descriptor.id === pluginId);
if (!plugin) return;
const descriptor = plugin.loader.descriptor;
const descriptor = plugin.plugin.descriptor;
// 核心插件不可禁用
if (descriptor.isCore) {
@@ -69,11 +69,11 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
const newEnabled = !plugin.enabled;
// 检查依赖
// 检查依赖(启用时)
if (newEnabled) {
const deps = descriptor.dependencies || [];
const missingDeps = deps.filter(dep => {
const depPlugin = plugins.find(p => p.loader.descriptor.id === dep.id);
const depPlugin = plugins.find(p => p.plugin.descriptor.id === dep.id);
return depPlugin && !depPlugin.enabled;
});
@@ -81,44 +81,76 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`);
return;
}
} else {
// 检查是否有其他插件依赖此插件
const dependents = plugins.filter(p => {
if (!p.enabled || p.loader.descriptor.id === pluginId) return false;
const deps = p.loader.descriptor.dependencies || [];
return deps.some(d => d.id === pluginId);
});
if (dependents.length > 0) {
showWarning(`以下插件依赖此插件: ${dependents.map(p => p.loader.descriptor.name).join(', ')}`);
return;
}
}
// 记录待处理的更改
const newPendingChanges = new Map(pendingChanges);
newPendingChanges.set(pluginId, newEnabled);
setPendingChanges(newPendingChanges);
// 调用 PluginManager 的动态启用/禁用方法
console.log(`[PluginListSetting] ${newEnabled ? 'Enabling' : 'Disabling'} plugin: ${pluginId}`);
let success: boolean;
if (newEnabled) {
success = await pluginManager.enable(pluginId);
} else {
success = await pluginManager.disable(pluginId);
}
console.log(`[PluginListSetting] ${newEnabled ? 'Enable' : 'Disable'} result: ${success}`);
if (!success) {
showWarning(newEnabled ? '启用插件失败' : '禁用插件失败');
return;
}
// 更新本地状态
setPlugins(plugins.map(p => {
if (p.loader.descriptor.id === pluginId) {
if (p.plugin.descriptor.id === pluginId) {
return { ...p, enabled: newEnabled };
}
return p;
}));
// 调用 PluginManager 的启用/禁用方法
if (newEnabled) {
pluginManager.enable(pluginId);
} else {
pluginManager.disable(pluginId);
// 保存到项目配置
savePluginConfigToProject();
// 通知用户(如果有编辑器模块变更)
const hasEditorModule = !!plugin.plugin.editorModule;
if (hasEditorModule) {
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
if (notificationService) {
notificationService.show(
newEnabled ? `已启用插件: ${descriptor.name}` : `已禁用插件: ${descriptor.name}`,
'success',
2000
);
}
}
};
/**
* 保存插件配置到项目文件
*/
const savePluginConfigToProject = async () => {
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
if (!projectService || !projectService.isProjectOpen()) {
console.warn('[PluginListSetting] Cannot save: project not open');
return;
}
// 获取当前启用的插件列表(排除核心插件)
const enabledPlugins = pluginManager.getEnabledPlugins()
.filter(p => !p.plugin.descriptor.isCore)
.map(p => p.plugin.descriptor.id);
console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins);
try {
await projectService.setEnabledPlugins(enabledPlugins);
console.log('[PluginListSetting] Plugin config saved successfully');
} catch (error) {
console.error('[PluginListSetting] Failed to save plugin config:', error);
}
};
// 按类别分组并排序
const groupedPlugins = plugins.reduce((acc, plugin) => {
const category = plugin.loader.descriptor.category;
const category = plugin.plugin.descriptor.category;
if (!acc[category]) {
acc[category] = [];
}
@@ -131,13 +163,6 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
return (
<div className="plugin-list-setting">
{pendingChanges.size > 0 && (
<div className="plugin-list-notice">
<RefreshCw size={14} />
<span></span>
</div>
)}
{sortedCategories.map(category => (
<div key={category} className="plugin-category">
<div className="plugin-category-header">
@@ -145,9 +170,9 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
</div>
<div className="plugin-list">
{groupedPlugins[category].map(plugin => {
const descriptor = plugin.loader.descriptor;
const hasRuntime = !!plugin.loader.runtimeModule;
const hasEditor = !!plugin.loader.editorModule;
const descriptor = plugin.plugin.descriptor;
const hasRuntime = !!plugin.plugin.runtimeModule;
const hasEditor = !!plugin.plugin.editorModule;
return (
<div

View File

@@ -18,12 +18,16 @@ export function PortManager({ onClose }: PortManagerProps) {
useEffect(() => {
const settings = SettingsService.getInstance();
setServerPort(settings.get('profiler.port', '8080'));
const savedPort = settings.get('profiler.port', 8080);
console.log('[PortManager] Initial port from settings:', savedPort);
setServerPort(String(savedPort));
const handleSettingsChange = ((event: CustomEvent) => {
console.log('[PortManager] settings:changed event received:', event.detail);
const newPort = event.detail['profiler.port'];
if (newPort) {
setServerPort(newPort);
if (newPort !== undefined) {
console.log('[PortManager] Updating port to:', newPort);
setServerPort(String(newPort));
}
}) as EventListener;

View File

@@ -1,16 +1,16 @@
import { useState, useEffect, useRef } from 'react';
import { Entity, Core } from '@esengine/ecs-framework';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Entity, Core, HierarchySystem, HierarchyComponent, EntityTags, isFolder } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import * as LucideIcons from 'lucide-react';
import {
Box, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight, ChevronDown,
Eye, Star, Lock, Settings, Filter, Folder, Sun, Cloud, Mountain, Flag,
SquareStack
SquareStack, FolderPlus
} from 'lucide-react';
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
import { confirm } from '@tauri-apps/plugin-dialog';
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
import { CreateEntityCommand, DeleteEntityCommand, ReparentEntityCommand, DropPosition } from '../application/commands/entity';
import '../styles/SceneHierarchy.css';
function getIconComponent(iconName: string | undefined, size: number = 14): React.ReactNode {
@@ -61,8 +61,19 @@ interface SceneHierarchyProps {
interface EntityNode {
entity: Entity;
children: EntityNode[];
isExpanded: boolean;
depth: number;
bIsFolder: boolean;
hasChildren: boolean;
}
/**
* 拖放指示器位置
*/
enum DropIndicator {
NONE = 'none',
BEFORE = 'before',
INSIDE = 'inside',
AFTER = 'after'
}
export function SceneHierarchy({ entityStore, messageHub, commandManager, isProfilerMode = false }: SceneHierarchyProps) {
@@ -78,9 +89,9 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const [isSceneModified, setIsSceneModified] = useState<boolean>(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entityId: number | null } | null>(null);
const [draggedEntityId, setDraggedEntityId] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const [dropTarget, setDropTarget] = useState<{ entityId: number; indicator: DropIndicator } | null>(null);
const [pluginTemplates, setPluginTemplates] = useState<EntityCreationTemplate[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<number>>(new Set());
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set([-1])); // -1 is scene root
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [showFilterMenu, setShowFilterMenu] = useState(false);
@@ -89,6 +100,68 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const isShowingRemote = viewMode === 'remote' && isRemoteConnected;
const selectedId = selectedIds.size > 0 ? Array.from(selectedIds)[0] : null;
/**
* 构建层级树结构
*/
const buildEntityTree = useCallback((rootEntities: Entity[]): EntityNode[] => {
const scene = Core.scene;
if (!scene) return [];
const buildNode = (entity: Entity, depth: number): EntityNode => {
const hierarchy = entity.getComponent(HierarchyComponent);
const childIds = hierarchy?.childIds ?? [];
const bIsEntityFolder = isFolder(entity.tag);
const children: EntityNode[] = [];
for (const childId of childIds) {
const childEntity = scene.findEntityById(childId);
if (childEntity) {
children.push(buildNode(childEntity, depth + 1));
}
}
return {
entity,
children,
depth,
bIsFolder: bIsEntityFolder,
hasChildren: children.length > 0
};
};
return rootEntities.map((entity) => buildNode(entity, 1));
}, []);
/**
* 扁平化树为带深度信息的列表(用于渲染)
*/
const flattenTree = useCallback((nodes: EntityNode[], expandedSet: Set<number>): EntityNode[] => {
const result: EntityNode[] = [];
const traverse = (nodeList: EntityNode[]) => {
for (const node of nodeList) {
result.push(node);
const bIsExpanded = expandedSet.has(node.entity.id);
if (bIsExpanded && node.children.length > 0) {
traverse(node.children);
}
}
};
traverse(nodes);
return result;
}, []);
/**
* 层级树和扁平化列表
*/
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree]);
const flattenedEntities = useMemo(
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds) : [],
[entityTree, expandedIds, flattenTree]
);
// Get entity creation templates from plugins
useEffect(() => {
const updateTemplates = () => {
@@ -171,7 +244,9 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const unsubSelect = messageHub.subscribe('entity:selected', handleSelection);
const unsubSceneLoaded = messageHub.subscribe('scene:loaded', updateEntities);
const unsubSceneNew = messageHub.subscribe('scene:new', updateEntities);
const unsubSceneRestored = messageHub.subscribe('scene:restored', updateEntities);
const unsubReordered = messageHub.subscribe('entity:reordered', updateEntities);
const unsubReparented = messageHub.subscribe('entity:reparented', updateEntities);
return () => {
unsubAdd();
@@ -180,7 +255,9 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
unsubSelect();
unsubSceneLoaded();
unsubSceneNew();
unsubSceneRestored();
unsubReordered();
unsubReparented();
};
}, [entityStore, messageHub]);
@@ -258,35 +335,110 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
}
};
const handleDragStart = (e: React.DragEvent, entityId: number) => {
const handleDragStart = useCallback((e: React.DragEvent, entityId: number) => {
setDraggedEntityId(entityId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', entityId.toString());
};
}, []);
const handleDragOver = (e: React.DragEvent, index: number) => {
/**
* 根据鼠标位置计算拖放指示器位置
* 上20%区域 = BEFORE, 中间60% = INSIDE, 下20% = AFTER
* 所有实体都支持作为父节点接收子节点
*/
const calculateDropIndicator = useCallback((e: React.DragEvent, _targetNode: EntityNode): DropIndicator => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const y = e.clientY - rect.top;
const height = rect.height;
if (y < height * 0.2) {
return DropIndicator.BEFORE;
} else if (y > height * 0.8) {
return DropIndicator.AFTER;
} else {
return DropIndicator.INSIDE;
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent, targetNode: EntityNode) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropTargetIndex(index);
};
const handleDragLeave = () => {
setDropTargetIndex(null);
};
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (draggedEntityId !== null) {
entityStore.reorderEntity(draggedEntityId, targetIndex);
// 不能拖放到自己
if (draggedEntityId === targetNode.entity.id) {
setDropTarget(null);
return;
}
setDraggedEntityId(null);
setDropTargetIndex(null);
};
const handleDragEnd = () => {
// 检查是否拖到自己的子节点
const scene = Core.scene;
if (scene && draggedEntityId !== null) {
const hierarchySystem = scene.getSystem(HierarchySystem);
const draggedEntity = scene.findEntityById(draggedEntityId);
if (draggedEntity && hierarchySystem?.isAncestorOf(draggedEntity, targetNode.entity)) {
setDropTarget(null);
return;
}
}
const indicator = calculateDropIndicator(e, targetNode);
setDropTarget({ entityId: targetNode.entity.id, indicator });
}, [draggedEntityId, calculateDropIndicator]);
const handleDragLeave = useCallback(() => {
setDropTarget(null);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetNode: EntityNode) => {
e.preventDefault();
if (draggedEntityId === null || !dropTarget) {
setDraggedEntityId(null);
setDropTarget(null);
return;
}
const scene = Core.scene;
if (!scene) return;
const draggedEntity = scene.findEntityById(draggedEntityId);
if (!draggedEntity) return;
// 转换 DropIndicator 到 DropPosition
let dropPosition: DropPosition;
switch (dropTarget.indicator) {
case DropIndicator.BEFORE:
dropPosition = DropPosition.BEFORE;
break;
case DropIndicator.INSIDE:
dropPosition = DropPosition.INSIDE;
// 自动展开目标节点
setExpandedIds(prev => new Set([...prev, targetNode.entity.id]));
break;
case DropIndicator.AFTER:
dropPosition = DropPosition.AFTER;
break;
default:
dropPosition = DropPosition.AFTER;
}
const command = new ReparentEntityCommand(
entityStore,
messageHub,
draggedEntity,
targetNode.entity,
dropPosition
);
commandManager.execute(command);
setDraggedEntityId(null);
setDropTargetIndex(null);
};
setDropTarget(null);
}, [draggedEntityId, dropTarget, entityStore, messageHub, commandManager]);
const handleDragEnd = useCallback(() => {
setDraggedEntityId(null);
setDropTarget(null);
}, []);
const handleRemoteEntityClick = (entity: RemoteEntity) => {
setSelectedIds(new Set([entity.id]));
@@ -373,8 +525,8 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, isShowingRemote]);
const toggleFolderExpand = (entityId: number) => {
setExpandedFolders(prev => {
const toggleExpand = useCallback((entityId: number) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(entityId)) {
next.delete(entityId);
@@ -383,7 +535,29 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
}
return next;
});
};
}, []);
/**
* 创建文件夹实体
*/
const handleCreateFolder = useCallback(() => {
const entityCount = entityStore.getAllEntities().length;
const folderName = locale === 'zh' ? `文件夹 ${entityCount + 1}` : `Folder ${entityCount + 1}`;
const scene = Core.scene;
if (!scene) return;
const entity = scene.createEntity(folderName);
entity.tag = EntityTags.FOLDER;
// 添加 HierarchyComponent 支持层级结构
entity.addComponent(new HierarchyComponent());
entityStore.addEntity(entity);
entityStore.selectEntity(entity);
messageHub.publish('entity:added', { entity });
messageHub.publish('scene:modified', {});
}, [entityStore, messageHub, locale]);
const handleSortClick = (column: SortColumn) => {
if (sortColumn === column) {
@@ -394,20 +568,33 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
}
};
// Get entity type for display
const getEntityType = (entity: Entity): string => {
/**
* 获取实体类型显示名称
*/
const getEntityType = useCallback((entity: Entity): string => {
if (isFolder(entity.tag)) {
return 'Folder';
}
const components = entity.components || [];
if (components.length > 0) {
const firstComponent = components[0];
return firstComponent?.constructor?.name || 'Entity';
}
return 'Entity';
};
}, []);
// Get icon for entity type
const getEntityIcon = (entityType: string): React.ReactNode => {
/**
* 获取实体类型图标
*/
const getEntityIcon = useCallback((entity: Entity): React.ReactNode => {
if (isFolder(entity.tag)) {
return <Folder size={14} className="entity-type-icon folder" />;
}
const entityType = getEntityType(entity);
return entityTypeIcons[entityType] || <Box size={14} className="entity-type-icon default" />;
};
}, [getEntityType]);
// Filter entities based on search query
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
@@ -443,13 +630,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
});
};
const displayEntities = isShowingRemote
? filterRemoteEntities(remoteEntities)
: filterLocalEntities(entities);
const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0;
const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName;
const totalCount = displayEntities.length;
const totalCount = isShowingRemote ? remoteEntities.length : entityStore.getAllEntities().length;
const selectedCount = selectedIds.size;
return (
@@ -479,13 +663,22 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
<div className="outliner-toolbar-right">
{!isShowingRemote && (
<button
className="outliner-action-btn"
onClick={handleCreateEntity}
title={locale === 'zh' ? '添加' : 'Add'}
>
<Plus size={14} />
</button>
<>
<button
className="outliner-action-btn"
onClick={handleCreateEntity}
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
>
<Plus size={14} />
</button>
<button
className="outliner-action-btn"
onClick={handleCreateFolder}
title={locale === 'zh' ? '创建文件夹' : 'Create Folder'}
>
<FolderPlus size={14} />
</button>
</>
)}
<button
className="outliner-action-btn"
@@ -550,94 +743,129 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
{/* Entity List */}
<div className="outliner-content" onContextMenu={(e) => !isShowingRemote && handleContextMenu(e, null)}>
{displayEntities.length === 0 ? (
<div className="empty-state">
<Box size={32} strokeWidth={1.5} className="empty-icon" />
<div className="empty-hint">
{isShowingRemote
? (locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game')
: (locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started')}
{isShowingRemote ? (
// Remote entities view (flat list)
remoteEntities.length === 0 ? (
<div className="empty-state">
<Box size={32} strokeWidth={1.5} className="empty-icon" />
<div className="empty-hint">
{locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game'}
</div>
</div>
</div>
) : isShowingRemote ? (
<div className="outliner-list">
{(displayEntities as RemoteEntity[]).map((entity) => (
<div
key={entity.id}
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
onClick={() => handleRemoteEntityClick(entity)}
>
<div className="outliner-item-icons">
<Eye size={12} className="item-icon visibility" />
</div>
<div className="outliner-item-content">
<span className="outliner-item-expand" />
{getEntityIcon(entity.componentTypes?.[0] || 'Entity')}
<span className="outliner-item-name">{entity.name}</span>
</div>
<div className="outliner-item-type">
{entity.componentTypes?.[0] || 'Entity'}
</div>
</div>
))}
</div>
) : (
<div className="outliner-list">
{/* World/Scene Root */}
<div
className={`outliner-item world-item ${expandedFolders.has(-1) ? 'expanded' : ''}`}
onClick={() => toggleFolderExpand(-1)}
>
<div className="outliner-item-icons">
<Eye size={12} className="item-icon visibility" />
</div>
<div className="outliner-item-content">
<span
className="outliner-item-expand"
onClick={(e) => { e.stopPropagation(); toggleFolderExpand(-1); }}
>
{expandedFolders.has(-1) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
<Mountain size={14} className="entity-type-icon world" />
<span className="outliner-item-name">{displaySceneName} (Editor)</span>
</div>
<div className="outliner-item-type">World</div>
</div>
{/* Entity Items */}
{expandedFolders.has(-1) && entities.map((entity, index) => {
const entityType = getEntityType(entity);
return (
) : (
<div className="outliner-list">
{filterRemoteEntities(remoteEntities).map((entity) => (
<div
key={entity.id}
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${draggedEntityId === entity.id ? 'dragging' : ''} ${dropTargetIndex === index ? 'drop-target' : ''}`}
style={{ paddingLeft: '32px' }}
draggable
onClick={(e) => handleEntityClick(entity, e)}
onDragStart={(e) => handleDragStart(e, entity.id)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
handleEntityClick(entity, e);
handleContextMenu(e, entity.id);
}}
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
onClick={() => handleRemoteEntityClick(entity)}
>
<div className="outliner-item-icons">
<Eye size={12} className="item-icon visibility" />
</div>
<div className="outliner-item-content">
<span className="outliner-item-expand" />
{getEntityIcon(entityType)}
<span className="outliner-item-name">{entity.name || `Entity ${entity.id}`}</span>
{entityTypeIcons[entity.componentTypes?.[0] || 'Entity'] || <Box size={14} className="entity-type-icon default" />}
<span className="outliner-item-name">{entity.name}</span>
</div>
<div className="outliner-item-type">
{entity.componentTypes?.[0] || 'Entity'}
</div>
<div className="outliner-item-type">{entityType}</div>
</div>
);
})}
</div>
))}
</div>
)
) : (
// Local entities view (hierarchical tree)
entities.length === 0 ? (
<div className="empty-state">
<Box size={32} strokeWidth={1.5} className="empty-icon" />
<div className="empty-hint">
{locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started'}
</div>
</div>
) : (
<div className="outliner-list">
{/* World/Scene Root */}
<div
className={`outliner-item world-item ${expandedIds.has(-1) ? 'expanded' : ''}`}
onClick={() => toggleExpand(-1)}
>
<div className="outliner-item-icons">
<Eye size={12} className="item-icon visibility" />
</div>
<div className="outliner-item-content">
<span
className="outliner-item-expand"
onClick={(e) => { e.stopPropagation(); toggleExpand(-1); }}
>
{expandedIds.has(-1) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
<Mountain size={14} className="entity-type-icon world" />
<span className="outliner-item-name">{displaySceneName} (Editor)</span>
</div>
<div className="outliner-item-type">World</div>
</div>
{/* Hierarchical Entity Items */}
{flattenedEntities.map((node) => {
const { entity, depth, hasChildren, bIsFolder } = node;
const bIsExpanded = expandedIds.has(entity.id);
const bIsSelected = selectedIds.has(entity.id);
const bIsDragging = draggedEntityId === entity.id;
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
// 计算缩进 (每层 16px加上基础 8px)
const indent = 8 + depth * 16;
// 构建 drop indicator 类名
let dropIndicatorClass = '';
if (currentDropTarget) {
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
}
return (
<div
key={entity.id}
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass}`}
style={{ paddingLeft: `${indent}px` }}
draggable
onClick={(e) => handleEntityClick(entity, e)}
onDragStart={(e) => handleDragStart(e, entity.id)}
onDragOver={(e) => handleDragOver(e, node)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
handleEntityClick(entity, e);
handleContextMenu(e, entity.id);
}}
>
<div className="outliner-item-icons">
<Eye size={12} className="item-icon visibility" />
</div>
<div className="outliner-item-content">
{/* 展开/折叠按钮 */}
{hasChildren || bIsFolder ? (
<span
className="outliner-item-expand clickable"
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
>
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
) : (
<span className="outliner-item-expand" />
)}
{getEntityIcon(entity)}
<span className="outliner-item-name">{entity.name || `Entity ${entity.id}`}</span>
</div>
<div className="outliner-item-type">{getEntityType(entity)}</div>
</div>
);
})}
</div>
)
)}
</div>
@@ -657,6 +885,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
entityId={contextMenu.entityId}
pluginTemplates={pluginTemplates}
onCreateEmpty={() => { handleCreateEntity(); closeContextMenu(); }}
onCreateFolder={() => { handleCreateFolder(); closeContextMenu(); }}
onCreateFromTemplate={async (template) => {
await template.create(contextMenu.entityId ?? undefined);
closeContextMenu();
@@ -676,6 +905,7 @@ interface ContextMenuWithSubmenuProps {
entityId: number | null;
pluginTemplates: EntityCreationTemplate[];
onCreateEmpty: () => void;
onCreateFolder: () => void;
onCreateFromTemplate: (template: EntityCreationTemplate) => void;
onDelete: () => void;
onClose: () => void;
@@ -683,7 +913,7 @@ interface ContextMenuWithSubmenuProps {
function ContextMenuWithSubmenu({
x, y, locale, entityId, pluginTemplates,
onCreateEmpty, onCreateFromTemplate, onDelete
onCreateEmpty, onCreateFolder, onCreateFromTemplate, onDelete
}: ContextMenuWithSubmenuProps) {
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
@@ -738,6 +968,11 @@ function ContextMenuWithSubmenu({
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
</button>
<button onClick={onCreateFolder}>
<Folder size={12} />
<span>{locale === 'zh' ? '创建文件夹' : 'Create Folder'}</span>
</button>
{sortedCategories.length > 0 && <div className="context-menu-divider" />}
{sortedCategories.map(([category, templates]) => (

View File

@@ -148,9 +148,14 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
} else {
const value = settings.get(key, descriptor.defaultValue);
initialValues.set(key, value);
if (key.startsWith('profiler.')) {
console.log(`[SettingsWindow] Loading ${key}: stored=${settings.get(key, undefined)}, default=${descriptor.defaultValue}, using=${value}`);
}
}
}
console.log('[SettingsWindow] Initial values for profiler:',
Array.from(initialValues.entries()).filter(([k]) => k.startsWith('profiler.')));
setValues(initialValues);
}, [settingsRegistry, initialCategoryId]);
@@ -162,10 +167,24 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const newErrors = new Map(errors);
if (!settingsRegistry.validateSetting(descriptor, value)) {
newErrors.set(key, descriptor.validator?.errorMessage || '无效值');
setErrors(newErrors);
return; // 验证失败,不保存
} else {
newErrors.delete(key);
}
setErrors(newErrors);
// 实时保存设置
const settings = SettingsService.getInstance();
if (!key.startsWith('project.')) {
settings.set(key, value);
console.log(`[SettingsWindow] Saved ${key}:`, value);
// 触发设置变更事件
window.dispatchEvent(new CustomEvent('settings:changed', {
detail: { [key]: value }
}));
}
};
const handleSave = async () => {
@@ -208,6 +227,7 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
}
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
window.dispatchEvent(new CustomEvent('settings:changed', {
detail: changedSettings
}));

View File

@@ -11,7 +11,6 @@ interface StartupPageProps {
onOpenProject: () => void;
onCreateProject: () => void;
onOpenRecentProject?: (projectPath: string) => void;
onProfilerMode?: () => void;
onLocaleChange?: (locale: Locale) => void;
recentProjects?: string[];
locale: string;
@@ -22,7 +21,7 @@ const LANGUAGES = [
{ code: 'zh', name: '中文' }
];
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, onLocaleChange, recentProjects = [], locale }: StartupPageProps) {
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onLocaleChange, recentProjects = [], locale }: StartupPageProps) {
const [showLogo, setShowLogo] = useState(true);
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string>('');
@@ -62,10 +61,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
subtitle: 'Professional Game Development Tool',
openProject: 'Open Project',
createProject: 'Create Project',
profilerMode: 'Profiler Mode',
recentProjects: 'Recent Projects',
noRecentProjects: 'No recent projects',
comingSoon: 'Coming Soon',
updateAvailable: 'New version available',
updateNow: 'Update Now',
installing: 'Installing...',
@@ -76,10 +73,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
subtitle: '专业游戏开发工具',
openProject: '打开项目',
createProject: '创建新项目',
profilerMode: '性能分析模式',
recentProjects: '最近的项目',
noRecentProjects: '没有最近的项目',
comingSoon: '即将推出',
updateAvailable: '发现新版本',
updateNow: '立即更新',
installing: '正在安装...',
@@ -126,13 +121,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
</svg>
<span>{t.createProject}</span>
</button>
<button className="startup-action-btn" onClick={onProfilerMode}>
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>{t.profilerMode}</span>
</button>
</div>
<div className="startup-recent">

View File

@@ -8,8 +8,9 @@ import '../styles/Viewport.css';
import { useEngine } from '../hooks/useEngine';
import { EngineService } from '../services/EngineService';
import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
import { MessageHub, ProjectService, AssetRegistryService } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import { CameraComponent } from '@esengine/camera';
import { UITransformComponent } from '@esengine/ui';
import { TauriAPI } from '../api/tauri';
import { open } from '@tauri-apps/plugin-shell';
@@ -59,7 +60,8 @@ function generateRuntimeHtml(): string {
const runtime = ECSRuntime.create({
canvasId: 'runtime-canvas',
width: window.innerWidth,
height: window.innerHeight
height: window.innerHeight,
projectConfigUrl: '/ecs-editor.config.json'
});
await runtime.initialize(esEngine);
@@ -354,11 +356,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale';
const value = propertyName === 'position' ? transform.position :
propertyName === 'rotation' ? transform.rotation : transform.scale;
messageHubRef.current.publish('component:property:changed', {
entity,
component: transform,
propertyName,
value: transform[propertyName]
value
});
}
}
@@ -373,16 +377,29 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const rotationSpeed = 0.01;
uiTransform.rotation += deltaX * rotationSpeed;
} else if (mode === 'scale') {
const width = uiTransform.width * uiTransform.scaleX;
const height = uiTransform.height * uiTransform.scaleY;
const centerX = uiTransform.x + width * uiTransform.pivotX;
const centerY = uiTransform.y + height * uiTransform.pivotY;
const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2);
const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2);
const oldWidth = uiTransform.width * uiTransform.scaleX;
const oldHeight = uiTransform.height * uiTransform.scaleY;
// pivot点的世界坐标缩放前
const pivotWorldX = uiTransform.x + oldWidth * uiTransform.pivotX;
const pivotWorldY = uiTransform.y + oldHeight * uiTransform.pivotY;
const startDist = Math.sqrt((worldStart.x - pivotWorldX) ** 2 + (worldStart.y - pivotWorldY) ** 2);
const endDist = Math.sqrt((worldEnd.x - pivotWorldX) ** 2 + (worldEnd.y - pivotWorldY) ** 2);
if (startDist > 0) {
const scaleFactor = endDist / startDist;
uiTransform.scaleX *= scaleFactor;
uiTransform.scaleY *= scaleFactor;
const newScaleX = uiTransform.scaleX * scaleFactor;
const newScaleY = uiTransform.scaleY * scaleFactor;
const newWidth = uiTransform.width * newScaleX;
const newHeight = uiTransform.height * newScaleY;
// 调整位置使pivot点保持不动
uiTransform.x = pivotWorldX - newWidth * uiTransform.pivotX;
uiTransform.y = pivotWorldY - newHeight * uiTransform.pivotY;
uiTransform.scaleX = newScaleX;
uiTransform.scaleY = newScaleY;
}
}
@@ -689,46 +706,118 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
// Write scene data and HTML (always update)
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneData);
// Copy texture assets referenced in the scene
// 复制场景中引用的纹理资产
const sceneObj = JSON.parse(sceneData);
const texturePathSet = new Set<string>();
// Find all texture paths in sprite components
if (sceneObj.entities) {
for (const entity of sceneObj.entities) {
if (entity.components) {
for (const comp of entity.components) {
if (comp.type === 'Sprite' && comp.data?.texture) {
texturePathSet.add(comp.data.texture);
}
}
}
// Copy project config file (for plugin settings)
// 复制项目配置文件(用于插件设置)
const projectService = Core.services.tryResolve(ProjectService);
const projectPath = projectService?.getCurrentProject()?.path;
if (projectPath) {
const configPath = `${projectPath}\\ecs-editor.config.json`;
const configExists = await TauriAPI.pathExists(configPath);
if (configExists) {
await TauriAPI.copyFile(configPath, `${runtimeDir}\\ecs-editor.config.json`);
console.log('[Viewport] Copied project config to runtime dir');
}
}
// Create assets directory and copy textures
// Create assets directory
// 创建资产目录
const assetsDir = `${runtimeDir}\\assets`;
const assetsDirExists = await TauriAPI.pathExists(assetsDir);
if (!assetsDirExists) {
await TauriAPI.createDirectory(assetsDir);
}
for (const texturePath of texturePathSet) {
if (texturePath && (texturePath.includes(':\\') || texturePath.startsWith('/'))) {
try {
const filename = texturePath.split(/[/\\]/).pop() || '';
const destPath = `${assetsDir}\\${filename}`;
const exists = await TauriAPI.pathExists(texturePath);
if (exists) {
await TauriAPI.copyFile(texturePath, destPath);
// Collect all asset paths from scene
// 从场景中收集所有资产路径
const sceneObj = JSON.parse(sceneData);
const assetPaths = new Set<string>();
// Scan all components for asset references
if (sceneObj.entities) {
for (const entity of sceneObj.entities) {
if (entity.components) {
for (const comp of entity.components) {
// Sprite textures
if (comp.type === 'Sprite' && comp.data?.texture) {
assetPaths.add(comp.data.texture);
}
// Behavior tree assets
if (comp.type === 'BehaviorTreeRuntime' && comp.data?.treeAssetId) {
assetPaths.add(comp.data.treeAssetId);
}
// Tilemap assets
if (comp.type === 'Tilemap' && comp.data?.tmxPath) {
assetPaths.add(comp.data.tmxPath);
}
// Audio assets
if (comp.type === 'AudioSource' && comp.data?.clip) {
assetPaths.add(comp.data.clip);
}
}
} catch (error) {
console.error(`Failed to copy texture ${texturePath}:`, error);
}
}
}
// Build asset catalog and copy files
// 构建资产目录并复制文件
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string }> = {};
for (const assetPath of assetPaths) {
if (!assetPath || (!assetPath.includes(':\\') && !assetPath.startsWith('/'))) continue;
try {
const exists = await TauriAPI.pathExists(assetPath);
if (!exists) {
console.warn(`[Viewport] Asset not found: ${assetPath}`);
continue;
}
// Get filename and determine relative path
const filename = assetPath.split(/[/\\]/).pop() || '';
const destPath = `${assetsDir}\\${filename}`;
const relativePath = `assets/${filename}`;
// Copy file
await TauriAPI.copyFile(assetPath, destPath);
// Determine asset type from extension
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
const typeMap: Record<string, string> = {
'.png': 'texture', '.jpg': 'texture', '.jpeg': 'texture', '.webp': 'texture',
'.btree': 'btree',
'.tmx': 'tilemap', '.tsx': 'tileset',
'.mp3': 'audio', '.ogg': 'audio', '.wav': 'audio',
'.json': 'json'
};
const assetType = typeMap[ext] || 'binary';
// Generate simple GUID based on path
const guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36);
catalogEntries[guid] = {
guid,
path: relativePath,
type: assetType,
size: 0,
hash: ''
};
console.log(`[Viewport] Copied asset: ${filename}`);
} catch (error) {
console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error);
}
}
// Write asset catalog
// 写入资产目录
const assetCatalog = {
version: '1.0.0',
createdAt: Date.now(),
entries: catalogEntries
};
await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2));
console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`);
const runtimeHtml = generateRuntimeHtml();
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, runtimeHtml);
@@ -781,6 +870,19 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
await runtimeResolver.initialize();
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
// Copy project config file (for plugin settings)
const projectService = Core.services.tryResolve(ProjectService);
if (projectService) {
const currentProject = projectService.getCurrentProject();
if (currentProject?.path) {
const configPath = `${currentProject.path}\\ecs-editor.config.json`;
const configExists = await TauriAPI.pathExists(configPath);
if (configExists) {
await TauriAPI.copyFile(configPath, `${runtimeDir}\\ecs-editor.config.json`);
}
}
}
// Write scene data and HTML
const sceneDataStr = typeof sceneData === 'string' ? sceneData : new TextDecoder().decode(sceneData);
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneDataStr);

View File

@@ -124,7 +124,15 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
setComponentVersion((prev) => prev + 1);
};
const handleSceneRestored = () => {
// 场景恢复后,清除当前选中的实体(因为旧引用已无效)
// 用户需要重新选择实体
setTarget(null);
setComponentVersion(0);
};
const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection);
const unsubSceneRestored = messageHub.subscribe('scene:restored', handleSceneRestored);
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection);
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
@@ -136,6 +144,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
return () => {
unsubEntitySelect();
unsubSceneRestored();
unsubRemoteSelect();
unsubNodeSelect();
unsubAssetFileSelect();

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Component } from '@esengine/ecs-framework';
import { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/ecs-components';
import { TransformComponent } from '@esengine/engine-core';
import { ChevronDown, Lock, Unlock } from 'lucide-react';
import '../../../styles/TransformInspector.css';