* feat(prefab): 实现预制体系统和编辑器 UX 改进 ## 预制体系统 - 新增 PrefabSerializer: 预制体序列化/反序列化 - 新增 PrefabInstanceComponent: 追踪预制体实例来源和修改 - 新增 PrefabService: 预制体核心服务 - 新增 PrefabLoader: 预制体资产加载器 - 新增预制体命令: Create/Instantiate/Apply/Revert/BreakLink ## 预制体编辑模式 - 支持双击 .prefab 文件进入编辑模式 - 预制体编辑模式工具栏 (保存/退出) - 预制体实例指示器和操作菜单 ## 编辑器 UX 改进 - SceneHierarchy 快捷键: F2 重命名, Ctrl+D 复制, ↑↓ 导航 - 支持双击实体名称内联编辑 - 删除实体时显示子节点数量警告 - 右键菜单添加重命名/复制选项及快捷键提示 - 布局持久化和重置功能 ## Bug 修复 - 修复 editor-runtime 组件类重复导致的 TransformComponent 不识别问题 - 修复 .prefab-name 样式覆盖导致预制体工具栏文字不可见 - 修复 Inspector 资源字段高度不正确问题 * feat(editor): 改进编辑器 UX 交互体验 - ContentBrowser: 加载动画 spinner、搜索高亮、改进空状态设计 - SceneHierarchy: 选中项自动滚动到视图、搜索清除按钮 - PropertyInspector: 输入框本地状态管理、Enter/Escape 键处理 - EntityInspector: 组件折叠状态持久化、属性搜索清除按钮 - Viewport: 变换操作实时数值显示 - 国际化: 添加相关文本 (en/zh) * fix(build): 修复 Web 构建资产加载和编辑器 UX 改进 构建系统修复: - 修复 asset-catalog.json 字段名不匹配 (entries vs assets) - 修复 BrowserFileSystemService 支持两种目录格式 - 修复 bundle 策略检测逻辑 (空对象判断) - 修复 module.json 中 assetExtensions 声明和类型推断 行为树修复: - 修复 BehaviorTreeExecutionSystem 使用 loadAsset 替代 loadAssetByPath - 修复 BehaviorTreeAssetType 常量与 module.json 类型名一致 (behavior-tree) 编辑器 UX 改进: - 构建完成对话框添加"打开文件夹"按钮 - 构建完成对话框样式优化 (圆形图标背景、按钮布局) - SceneHierarchy 响应式布局 (窄窗口自动隐藏 Type 列) - SceneHierarchy 隐藏滚动条 错误追踪: - 添加全局错误处理器写入日志文件 (%TEMP%/esengine-editor-crash.log) - 添加 append_to_log Tauri 命令 * feat(render): 修复 UI 渲染和点击特效系统 ## UI 渲染修复 - 修复 GUID 验证 bug,使用统一的 isValidGUID() 函数 - 修复 UI 渲染顺序随机问题,Rust 端使用 IndexMap 替代 HashMap - Web 运行时添加 assetPathResolver 支持 GUID 解析 - UIInteractableComponent.blockEvents 默认值改为 false ## 点击特效系统 - 新增 ClickFxComponent 和 ClickFxSystem - 支持在点击位置播放粒子效果 - 支持多种触发模式和粒子轮换 ## Camera 系统重构 - CameraSystem 从 ecs-engine-bindgen 移至 camera 包 - 新增 CameraManager 统一管理相机 ## 编辑器改进 - 改进属性面板 UI 交互 - 粒子编辑器面板优化 - Transform 命令系统 * feat(render): 实现 Sorting Layer 系统和 Overlay 渲染层 - 新增 SortingLayerManager 管理排序层级 (Background, Default, Foreground, UI, Overlay) - 实现 ISortable 接口,统一 Sprite、UI、Particle 的排序属性 - 修复粒子 Overlay 层被 UI 遮挡问题:添加独立的 Overlay Pass 在 UI 之后渲染 - 更新粒子资产格式:从 sortingOrder 改为 sortingLayer + orderInLayer - 更新粒子编辑器面板支持新的排序属性 - 优化 UI 渲染系统使用新的排序层级 * feat(ci): 集成 SignPath 代码签名服务 - 添加 SignPath 自动签名工作流(Windows) - 配置 release-editor.yml 支持代码签名 - 将构建改为草稿模式,等待签名完成后发布 - 添加证书文件到 .gitignore 防止泄露 * fix(asset): 修复 Web 构建资产路径解析和全局单例移除 ## 资产路径修复 - 修复 Tauri 本地服务器 `/asset?path=...` 路径解析,正确与 root 目录连接 - BrowserPathResolver 支持两种模式: - 'proxy': 使用 /asset?path=... 格式(编辑器 Run in Browser) - 'direct': 使用直接路径 /assets/path.png(独立 Web 构建) - BrowserRuntime 使用 'direct' 模式,无需 Tauri 代理 ## 架构改进 - 移除全局单例 - 移除 globalAssetManager 导出,改用 AssetManagerToken 依赖注入 - 移除 globalPathResolver 导出,改用 PathResolutionService - 移除 globalPathResolutionService 导出 - ParticleUpdateSystem/ClickFxSystem 通过 setAssetManager() 注入依赖 - EngineService 使用 new AssetManager() 替代全局实例 ## 新增服务 - PathResolutionService: 统一路径解析接口 - RuntimeModeService: 运行时模式查询服务 - SerializationContext: EntityRef 序列化上下文 ## 其他改进 - 完善 ServiceToken 注释说明本地定义的意图 - 导出 BrowserPathResolveMode 类型 * fix(build): 添加 world-streaming composite 设置修复类型检查 * fix(build): 移除 world-streaming 引用避免 composite 冲突 * fix(build): 将 const enum 改为 enum 兼容 isolatedModules * fix(build): 添加缺失的 IAssetManager 导入
347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X, LayoutGrid } from 'lucide-react';
|
|
import type { MessageHub, LogService } from '@esengine/editor-core';
|
|
import { ContentBrowser } from './ContentBrowser';
|
|
import { OutputLogPanel } from './OutputLogPanel';
|
|
import { useLocale } from '../hooks/useLocale';
|
|
import '../styles/StatusBar.css';
|
|
|
|
interface StatusBarProps {
|
|
pluginCount?: number;
|
|
entityCount?: number;
|
|
messageHub?: MessageHub | null;
|
|
logService?: LogService | null;
|
|
locale?: string;
|
|
projectPath?: string | null;
|
|
onOpenScene?: (scenePath: string) => void;
|
|
/** 停靠内容管理器到布局中的回调 | Callback to dock content browser in layout */
|
|
onDockContentBrowser?: () => void;
|
|
/** 重置布局回调 | Callback to reset layout */
|
|
onResetLayout?: () => void;
|
|
}
|
|
|
|
type ActiveTab = 'output' | 'cmd';
|
|
|
|
export function StatusBar({
|
|
pluginCount = 0,
|
|
entityCount = 0,
|
|
messageHub,
|
|
logService,
|
|
locale = 'en',
|
|
projectPath,
|
|
onOpenScene,
|
|
onDockContentBrowser,
|
|
onResetLayout
|
|
}: StatusBarProps) {
|
|
const { t } = useLocale();
|
|
const [consoleInput, setConsoleInput] = useState('');
|
|
const [activeTab, setActiveTab] = useState<ActiveTab>('output');
|
|
const [contentDrawerOpen, setContentDrawerOpen] = useState(false);
|
|
const [outputLogDrawerOpen, setOutputLogDrawerOpen] = useState(false);
|
|
const [contentDrawerHeight, setContentDrawerHeight] = useState(300);
|
|
const [outputLogDrawerHeight, setOutputLogDrawerHeight] = useState(300);
|
|
const [isResizingContent, setIsResizingContent] = useState(false);
|
|
const [isResizingOutputLog, setIsResizingOutputLog] = useState(false);
|
|
const [revealPath, setRevealPath] = useState<string | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const startY = useRef(0);
|
|
const startHeight = useRef(0);
|
|
|
|
// Subscribe to asset:reveal event
|
|
useEffect(() => {
|
|
if (!messageHub) return;
|
|
|
|
const unsubscribe = messageHub.subscribe('asset:reveal', (payload: { path: string }) => {
|
|
if (payload.path) {
|
|
// Generate unique key to force re-trigger even with same path
|
|
setRevealPath(`${payload.path}?t=${Date.now()}`);
|
|
setContentDrawerOpen(true);
|
|
setOutputLogDrawerOpen(false);
|
|
}
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [messageHub]);
|
|
|
|
// Clear revealPath when drawer closes
|
|
useEffect(() => {
|
|
if (!contentDrawerOpen) {
|
|
setRevealPath(null);
|
|
}
|
|
}, [contentDrawerOpen]);
|
|
|
|
const handleSelectPanel = useCallback((panelId: string) => {
|
|
if (messageHub) {
|
|
messageHub.publish('panel:select', { panelId });
|
|
}
|
|
}, [messageHub]);
|
|
|
|
const handleContentDrawerClick = useCallback(() => {
|
|
setContentDrawerOpen(!contentDrawerOpen);
|
|
if (!contentDrawerOpen) {
|
|
setOutputLogDrawerOpen(false);
|
|
}
|
|
}, [contentDrawerOpen]);
|
|
|
|
const handleOutputLogClick = useCallback(() => {
|
|
setActiveTab('output');
|
|
setOutputLogDrawerOpen(!outputLogDrawerOpen);
|
|
if (!outputLogDrawerOpen) {
|
|
setContentDrawerOpen(false);
|
|
}
|
|
}, [outputLogDrawerOpen]);
|
|
|
|
const handleCmdClick = useCallback(() => {
|
|
setActiveTab('cmd');
|
|
handleSelectPanel('console');
|
|
setTimeout(() => {
|
|
inputRef.current?.focus();
|
|
}, 100);
|
|
}, [handleSelectPanel]);
|
|
|
|
const handleConsoleSubmit = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter' && consoleInput.trim()) {
|
|
const command = consoleInput.trim();
|
|
|
|
console.info(`> ${command}`);
|
|
|
|
try {
|
|
if (command.startsWith('help')) {
|
|
console.info('Available commands: help, clear, echo <message>');
|
|
} else if (command === 'clear') {
|
|
logService?.clear();
|
|
} else if (command.startsWith('echo ')) {
|
|
console.info(command.substring(5));
|
|
} else {
|
|
console.warn(`Unknown command: ${command}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error executing command: ${error}`);
|
|
}
|
|
|
|
setConsoleInput('');
|
|
}
|
|
}, [consoleInput, logService]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'cmd') {
|
|
inputRef.current?.focus();
|
|
}
|
|
}, [activeTab]);
|
|
|
|
// Handle content drawer resize
|
|
const handleContentResizeStart = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsResizingContent(true);
|
|
startY.current = e.clientY;
|
|
startHeight.current = contentDrawerHeight;
|
|
}, [contentDrawerHeight]);
|
|
|
|
// Handle output log drawer resize
|
|
const handleOutputLogResizeStart = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsResizingOutputLog(true);
|
|
startY.current = e.clientY;
|
|
startHeight.current = outputLogDrawerHeight;
|
|
}, [outputLogDrawerHeight]);
|
|
|
|
useEffect(() => {
|
|
if (!isResizingContent && !isResizingOutputLog) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const delta = startY.current - e.clientY;
|
|
const newHeight = Math.max(200, Math.min(startHeight.current + delta, window.innerHeight * 0.7));
|
|
if (isResizingContent) {
|
|
setContentDrawerHeight(newHeight);
|
|
} else if (isResizingOutputLog) {
|
|
setOutputLogDrawerHeight(newHeight);
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsResizingContent(false);
|
|
setIsResizingOutputLog(false);
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [isResizingContent, isResizingOutputLog]);
|
|
|
|
// Close drawer on Escape
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
if (contentDrawerOpen) {
|
|
setContentDrawerOpen(false);
|
|
}
|
|
if (outputLogDrawerOpen) {
|
|
setOutputLogDrawerOpen(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [contentDrawerOpen, outputLogDrawerOpen]);
|
|
|
|
return (
|
|
<>
|
|
{/* Drawer Backdrop */}
|
|
{(contentDrawerOpen || outputLogDrawerOpen) && (
|
|
<div
|
|
className="drawer-backdrop"
|
|
onClick={() => {
|
|
setContentDrawerOpen(false);
|
|
setOutputLogDrawerOpen(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Content Drawer Panel */}
|
|
<div
|
|
className={`drawer-panel content-drawer-panel ${contentDrawerOpen ? 'open' : ''}`}
|
|
style={{ height: contentDrawerOpen ? contentDrawerHeight : 0 }}
|
|
>
|
|
<div
|
|
className="drawer-resize-handle"
|
|
onMouseDown={handleContentResizeStart}
|
|
/>
|
|
<div className="drawer-header">
|
|
<span className="drawer-title">
|
|
<FolderOpen size={14} />
|
|
Content Browser
|
|
</span>
|
|
<button
|
|
className="drawer-close"
|
|
onClick={() => setContentDrawerOpen(false)}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="drawer-body">
|
|
<ContentBrowser
|
|
projectPath={projectPath ?? null}
|
|
locale={locale}
|
|
onOpenScene={onOpenScene}
|
|
isDrawer={true}
|
|
revealPath={revealPath}
|
|
onDockInLayout={() => {
|
|
// 关闭抽屉并停靠到布局 | Close drawer and dock to layout
|
|
setContentDrawerOpen(false);
|
|
onDockContentBrowser?.();
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Output Log Drawer Panel */}
|
|
<div
|
|
className={`drawer-panel output-log-drawer-panel ${outputLogDrawerOpen ? 'open' : ''}`}
|
|
style={{ height: outputLogDrawerOpen ? outputLogDrawerHeight : 0 }}
|
|
>
|
|
<div
|
|
className="drawer-resize-handle"
|
|
onMouseDown={handleOutputLogResizeStart}
|
|
/>
|
|
<div className="drawer-body output-log-body">
|
|
{logService && (
|
|
<OutputLogPanel
|
|
logService={logService}
|
|
locale={locale}
|
|
onClose={() => setOutputLogDrawerOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Bar */}
|
|
<div className="status-bar">
|
|
<div className="status-bar-left">
|
|
<button
|
|
className={`status-bar-btn drawer-toggle-btn ${contentDrawerOpen ? 'active' : ''}`}
|
|
onClick={handleContentDrawerClick}
|
|
>
|
|
<FolderOpen size={14} />
|
|
<span>{t('statusBar.contentDrawer')}</span>
|
|
{contentDrawerOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
|
|
</button>
|
|
|
|
<div className="status-bar-divider" />
|
|
|
|
<button
|
|
className={`status-bar-tab ${outputLogDrawerOpen ? 'active' : ''}`}
|
|
onClick={handleOutputLogClick}
|
|
>
|
|
<FileText size={12} />
|
|
<span>{t('statusBar.outputLog')}</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`status-bar-tab ${activeTab === 'cmd' ? 'active' : ''}`}
|
|
onClick={handleCmdClick}
|
|
>
|
|
<Terminal size={12} />
|
|
<span>Cmd</span>
|
|
<ChevronDown size={10} />
|
|
</button>
|
|
|
|
<div className="status-bar-console-input">
|
|
<span className="console-prompt">></span>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder={t('statusBar.consolePlaceholder')}
|
|
value={consoleInput}
|
|
onChange={(e) => setConsoleInput(e.target.value)}
|
|
onKeyDown={handleConsoleSubmit}
|
|
onFocus={() => setActiveTab('cmd')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="status-bar-right">
|
|
<button className="status-bar-indicator">
|
|
<Activity size={12} />
|
|
<span>{t('statusBar.trace')}</span>
|
|
<ChevronDown size={10} />
|
|
</button>
|
|
|
|
<div className="status-bar-divider" />
|
|
|
|
<div className="status-bar-icon-group">
|
|
<button
|
|
className="status-bar-icon-btn"
|
|
title={t('statusBar.resetLayout')}
|
|
onClick={onResetLayout}
|
|
>
|
|
<LayoutGrid size={14} />
|
|
</button>
|
|
<button className="status-bar-icon-btn" title={t('statusBar.network')}>
|
|
<Wifi size={14} />
|
|
</button>
|
|
<button className="status-bar-icon-btn" title={t('statusBar.sourceControl')}>
|
|
<GitBranch size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="status-bar-divider" />
|
|
|
|
<div className="status-bar-info">
|
|
<Save size={12} />
|
|
<span>{t('statusBar.allSaved')}</span>
|
|
</div>
|
|
|
|
<div className="status-bar-info">
|
|
<span>{t('statusBar.revisionControl')}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|