feat: 预制体系统与架构改进 (#303)
* 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 导入
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Maximize2, Minimize2, MousePointer2, Target, Zap
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, IFileSystemService, IDialogService } from '@esengine/editor-core';
|
||||
import { MessageHub, IFileSystemService, IDialogService, AssetRegistryService } from '@esengine/editor-core';
|
||||
import type { IFileSystem, IDialog } from '@esengine/editor-core';
|
||||
import {
|
||||
EmissionShape,
|
||||
@@ -34,11 +34,15 @@ import {
|
||||
type ScaleKey,
|
||||
type ForceField,
|
||||
} from '@esengine/particle';
|
||||
import { PathResolutionService } from '@esengine/asset-system';
|
||||
import { useParticleEditorStore } from '../stores/ParticleEditorStore';
|
||||
import { GradientEditor } from '../components/GradientEditor';
|
||||
import { CurveEditor } from '../components/CurveEditor';
|
||||
import { TexturePicker } from '../components/TexturePicker';
|
||||
|
||||
// 创建路径解析服务实例 | Create path resolution service instance
|
||||
const pathResolver = new PathResolutionService();
|
||||
|
||||
// ============= Types =============
|
||||
|
||||
/**
|
||||
@@ -220,9 +224,61 @@ function useParticlePreview(
|
||||
const elapsedTimeRef = useRef<number>(0);
|
||||
const burstFiredRef = useRef<Set<number>>(new Set());
|
||||
const lastTriggerBurstRef = useRef<number>(0);
|
||||
const textureImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const textureLoadedRef = useRef<string | null>(null); // 记录已加载的 GUID | Track loaded GUID
|
||||
|
||||
const { followMouse, mousePosition, triggerBurst } = options;
|
||||
|
||||
// 加载纹理图片 | Load texture image
|
||||
useEffect(() => {
|
||||
const textureGuid = data?.textureGuid;
|
||||
if (!textureGuid) {
|
||||
textureImageRef.current = null;
|
||||
textureLoadedRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经加载了相同的纹理,跳过 | Skip if same texture already loaded
|
||||
if (textureLoadedRef.current === textureGuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 通过 AssetRegistryService 解析 GUID 到路径 | Resolve GUID to path via AssetRegistryService
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
if (!assetRegistry) {
|
||||
console.warn('[ParticlePreview] AssetRegistryService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = assetRegistry.getAsset(textureGuid);
|
||||
if (!metadata) {
|
||||
console.warn('[ParticlePreview] Asset not found for GUID:', textureGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 PathResolutionService 将资产路径转换为可加载的 URL
|
||||
// Use PathResolutionService to convert asset path to loadable URL
|
||||
const textureUrl = pathResolver.catalogToRuntime(metadata.path);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.onload = () => {
|
||||
textureImageRef.current = img;
|
||||
textureLoadedRef.current = textureGuid;
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('[ParticlePreview] Failed to load texture:', textureUrl);
|
||||
textureImageRef.current = null;
|
||||
textureLoadedRef.current = null;
|
||||
};
|
||||
img.src = textureUrl;
|
||||
|
||||
return () => {
|
||||
// 清理 | Cleanup
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [data?.textureGuid]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !data) return;
|
||||
@@ -821,6 +877,7 @@ function useParticlePreview(
|
||||
}
|
||||
|
||||
// 绘制粒子 | Draw particles
|
||||
const textureImg = textureImageRef.current;
|
||||
for (const p of particlesRef.current) {
|
||||
const size = (data.particleSize || 8);
|
||||
const r = Math.round(clamp(p.r, 0, 1) * 255);
|
||||
@@ -834,15 +891,22 @@ function useParticlePreview(
|
||||
ctx.rotate(p.rotation);
|
||||
ctx.scale(p.scaleX, p.scaleY);
|
||||
|
||||
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, size / 2);
|
||||
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`);
|
||||
gradient.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0.6)`);
|
||||
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
|
||||
// 如果有纹理,绘制纹理图片;否则绘制渐变圆 | Draw texture if available, otherwise gradient circle
|
||||
if (textureImg) {
|
||||
// 绘制带颜色调制的纹理 | Draw texture with color modulation
|
||||
const halfSize = size / 2;
|
||||
ctx.drawImage(textureImg, -halfSize, -halfSize, size, size);
|
||||
} else {
|
||||
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, size / 2);
|
||||
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`);
|
||||
gradient.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0.6)`);
|
||||
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, size / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, size / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -1092,9 +1156,24 @@ interface PropertySectionProps {
|
||||
|
||||
interface BasicPropertiesProps extends PropertySectionProps {
|
||||
onBrowseTexture?: () => Promise<string | null>;
|
||||
/** 通过 GUID 获取资产显示名称 | Get asset display name by GUID */
|
||||
resolveGuidToName?: (guid: string) => string | null;
|
||||
}
|
||||
|
||||
function BasicProperties({ data, onChange, onBrowseTexture }: BasicPropertiesProps) {
|
||||
function BasicProperties({ data, onChange, onBrowseTexture, resolveGuidToName }: BasicPropertiesProps) {
|
||||
// 调试日志 | Debug log
|
||||
|
||||
// 解析 textureGuid 为显示名称 | Resolve textureGuid to display name
|
||||
const textureDisplayName = useMemo(() => {
|
||||
if (!data.textureGuid) return null;
|
||||
if (resolveGuidToName) {
|
||||
const name = resolveGuidToName(data.textureGuid);
|
||||
return name;
|
||||
}
|
||||
// 如果没有解析函数,显示 GUID 的前8位 | If no resolver, show first 8 chars of GUID
|
||||
return data.textureGuid.substring(0, 8) + '...';
|
||||
}, [data.textureGuid, resolveGuidToName]);
|
||||
|
||||
return (
|
||||
<div className="property-group">
|
||||
<PropertyInput
|
||||
@@ -1104,8 +1183,8 @@ function BasicProperties({ data, onChange, onBrowseTexture }: BasicPropertiesPro
|
||||
onChange={v => onChange('name', v)}
|
||||
/>
|
||||
<TexturePicker
|
||||
value={(data as any).texturePath || null}
|
||||
onChange={path => onChange('texturePath' as any, path)}
|
||||
value={textureDisplayName}
|
||||
onChange={() => {/* GUID 通过 onBrowse 设置 | GUID is set via onBrowse */}}
|
||||
onBrowse={onBrowseTexture}
|
||||
/>
|
||||
<PropertyInput
|
||||
@@ -1172,11 +1251,25 @@ function BasicProperties({ data, onChange, onBrowseTexture }: BasicPropertiesPro
|
||||
min={1}
|
||||
onChange={v => onChange('particleSize', v)}
|
||||
/>
|
||||
<PropertySelect
|
||||
label="Sorting Layer"
|
||||
value={data.sortingLayer || 'Default'}
|
||||
options={[
|
||||
{ value: 'Background', label: 'Background' },
|
||||
{ value: 'Default', label: 'Default' },
|
||||
{ value: 'Foreground', label: 'Foreground' },
|
||||
{ value: 'WorldOverlay', label: 'World Overlay' },
|
||||
{ value: 'UI', label: 'UI' },
|
||||
{ value: 'ScreenOverlay', label: 'Screen Overlay' },
|
||||
{ value: 'Modal', label: 'Modal' },
|
||||
]}
|
||||
onChange={v => onChange('sortingLayer', v)}
|
||||
/>
|
||||
<PropertyInput
|
||||
label="Sort Order"
|
||||
label="Order in Layer"
|
||||
type="number"
|
||||
value={data.sortingOrder}
|
||||
onChange={v => onChange('sortingOrder', v)}
|
||||
value={data.orderInLayer ?? 0}
|
||||
onChange={v => onChange('orderInLayer', v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1886,28 +1979,35 @@ function presetToAsset(preset: ParticlePreset): Partial<IParticleAsset> {
|
||||
* Particle editor panel component
|
||||
*/
|
||||
export function ParticleEditorPanel() {
|
||||
// 从 Store 获取所有状态和 actions | Get all state and actions from Store
|
||||
const {
|
||||
filePath,
|
||||
pendingFilePath,
|
||||
particleData,
|
||||
isDirty,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
selectedPreset,
|
||||
setFilePath,
|
||||
setPendingFilePath,
|
||||
activeTab,
|
||||
isFullscreen,
|
||||
followMouse,
|
||||
burstTrigger,
|
||||
setParticleData,
|
||||
updateProperty,
|
||||
markSaved,
|
||||
setPlaying,
|
||||
setSelectedPreset,
|
||||
createNew,
|
||||
setActiveTab,
|
||||
toggleFullscreen,
|
||||
toggleFollowMouse,
|
||||
triggerBurst: storeTriggerBurst,
|
||||
loadFile: storeLoadFile,
|
||||
saveFile: storeSaveFile,
|
||||
markSaved,
|
||||
} = useParticleEditorStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'emission' | 'particle' | 'color' | 'modules' | 'burst'>('basic');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [followMouse, setFollowMouse] = useState(false);
|
||||
// mousePosition 保留为本地状态,因为更新频率极高 | Keep mousePosition as local state due to high update frequency
|
||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||
const [triggerBurst, setTriggerBurst] = useState(0);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previewContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1915,8 +2015,8 @@ export function ParticleEditorPanel() {
|
||||
const previewOptions = useMemo<PreviewOptions>(() => ({
|
||||
followMouse,
|
||||
mousePosition,
|
||||
triggerBurst,
|
||||
}), [followMouse, mousePosition, triggerBurst]);
|
||||
triggerBurst: burstTrigger,
|
||||
}), [followMouse, mousePosition, burstTrigger]);
|
||||
|
||||
const { reset: resetPreview } = useParticlePreview(previewCanvasRef, particleData, isPlaying, previewOptions);
|
||||
|
||||
@@ -1933,84 +2033,60 @@ export function ParticleEditorPanel() {
|
||||
});
|
||||
}, [followMouse]);
|
||||
|
||||
// 切换全屏 | Toggle fullscreen
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// 触发一次爆发 | Trigger a burst
|
||||
const handleTriggerBurst = useCallback(() => {
|
||||
setTriggerBurst(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// 处理待打开文件 | Handle pending file - 使用 subscribeWithSelector 替代 useEffect
|
||||
// Using store subscription instead of useEffect
|
||||
useEffect(() => {
|
||||
if (pendingFilePath) {
|
||||
loadFile(pendingFilePath);
|
||||
setPendingFilePath(null);
|
||||
}
|
||||
}, [pendingFilePath]);
|
||||
|
||||
const loadFile = useCallback(async (path: string) => {
|
||||
try {
|
||||
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
|
||||
if (!fileSystem) {
|
||||
console.error('[ParticleEditorPanel] FileSystem service not available');
|
||||
return;
|
||||
if (fileSystem) {
|
||||
storeLoadFile(pendingFilePath, fileSystem);
|
||||
}
|
||||
|
||||
const content = await fileSystem.readFile(path);
|
||||
const data = JSON.parse(content) as IParticleAsset;
|
||||
const defaults = createDefaultParticleAsset();
|
||||
setParticleData({ ...defaults, ...data });
|
||||
setFilePath(path);
|
||||
} catch (error) {
|
||||
console.error('[ParticleEditorPanel] Failed to load file:', error);
|
||||
}
|
||||
}, [setParticleData, setFilePath]);
|
||||
}, [pendingFilePath, storeLoadFile]);
|
||||
|
||||
// 保存处理 | Save handler - 使用 Store action
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!particleData) return;
|
||||
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
|
||||
if (!fileSystem) return;
|
||||
|
||||
let savePath = filePath;
|
||||
|
||||
if (!savePath) {
|
||||
const dialog = Core.services.tryResolve(IDialogService) as IDialog | null;
|
||||
if (!dialog) return;
|
||||
|
||||
savePath = await dialog.saveDialog({
|
||||
title: 'Save Particle Effect',
|
||||
filters: [{ name: 'Particle Effect', extensions: ['particle'] }],
|
||||
defaultPath: `${particleData.name || 'new-particle'}.particle`,
|
||||
});
|
||||
|
||||
if (!savePath) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
|
||||
if (!fileSystem) return;
|
||||
|
||||
await fileSystem.writeFile(savePath, JSON.stringify(particleData, null, 2));
|
||||
setFilePath(savePath);
|
||||
markSaved();
|
||||
const dialog = Core.services.tryResolve(IDialogService) as IDialog | null;
|
||||
const success = await storeSaveFile(
|
||||
fileSystem,
|
||||
dialog ? { saveDialog: (opts) => dialog.saveDialog(opts) } : undefined
|
||||
);
|
||||
|
||||
if (success) {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('assets:refresh', {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ParticleEditorPanel] Failed to save:', error);
|
||||
}
|
||||
}, [particleData, filePath, setFilePath, markSaved]);
|
||||
}, [storeSaveFile]);
|
||||
|
||||
// 面板容器 ref | Panel container ref
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动获取焦点以接收键盘事件 | Auto focus to receive keyboard events
|
||||
useEffect(() => {
|
||||
// 延迟获取焦点,确保面板已挂载 | Delay focus to ensure panel is mounted
|
||||
const timer = setTimeout(() => {
|
||||
panelRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// 点击面板时获取焦点 | Focus on panel click
|
||||
const handlePanelClick = useCallback(() => {
|
||||
panelRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// 键盘快捷键处理 | Keyboard shortcut handler
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// 阻止原生事件传播到全局处理器 | Stop native event from reaching global handler
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
handleSave();
|
||||
}
|
||||
}, [handleSave]);
|
||||
@@ -2025,9 +2101,12 @@ export function ParticleEditorPanel() {
|
||||
});
|
||||
|
||||
if (path && typeof path === 'string') {
|
||||
await loadFile(path);
|
||||
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
|
||||
if (fileSystem) {
|
||||
await storeLoadFile(path, fileSystem);
|
||||
}
|
||||
}
|
||||
}, [loadFile]);
|
||||
}, [storeLoadFile]);
|
||||
|
||||
const handleBrowseTexture = useCallback(async (): Promise<string | null> => {
|
||||
const dialog = Core.services.tryResolve(IDialogService) as IDialog | null;
|
||||
@@ -2039,12 +2118,40 @@ export function ParticleEditorPanel() {
|
||||
});
|
||||
|
||||
if (path && typeof path === 'string') {
|
||||
return path;
|
||||
// 将路径转换为 GUID | Convert path to GUID
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
if (assetRegistry) {
|
||||
const relativePath = assetRegistry.absoluteToRelative(path);
|
||||
if (relativePath) {
|
||||
const guid = assetRegistry.getGuidByPath(relativePath);
|
||||
if (guid) {
|
||||
// 设置 textureGuid | Set textureGuid
|
||||
updateProperty('textureGuid', guid);
|
||||
return guid;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果无法获取 GUID,返回 null | Return null if cannot get GUID
|
||||
console.warn('[ParticleEditor] Failed to get GUID for texture path:', path);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}, [updateProperty]);
|
||||
|
||||
// 通过 GUID 获取资产显示名称 | Get asset display name by GUID
|
||||
const resolveGuidToName = useCallback((guid: string): string | null => {
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
if (!assetRegistry) return null;
|
||||
|
||||
const metadata = assetRegistry.getAsset(guid);
|
||||
if (metadata) {
|
||||
return metadata.name;
|
||||
}
|
||||
// 如果找不到,返回 GUID 前8位 | If not found, return first 8 chars of GUID
|
||||
return guid.substring(0, 8) + '...';
|
||||
}, []);
|
||||
|
||||
const handleApplyPreset = useCallback((presetName: string) => {
|
||||
const handleApplyPreset = useCallback(async (presetName: string) => {
|
||||
const preset = getPresetByName(presetName);
|
||||
if (!preset || !particleData) return;
|
||||
|
||||
@@ -2071,7 +2178,28 @@ export function ParticleEditorPanel() {
|
||||
|
||||
// 重置预览 | Reset preview
|
||||
resetPreview();
|
||||
}, [particleData, setParticleData, setSelectedPreset, resetPreview]);
|
||||
|
||||
// 自动保存(如果已有文件路径)| Auto-save if file path exists
|
||||
if (filePath) {
|
||||
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
|
||||
if (fileSystem) {
|
||||
const newData = { ...particleData, ...assetData };
|
||||
if (preset.emissionRate === 0) {
|
||||
(newData as any).bursts = (assetData as any).bursts;
|
||||
}
|
||||
try {
|
||||
await fileSystem.writeFile(filePath, JSON.stringify(newData, null, 2));
|
||||
// 标记为已保存 | Mark as saved
|
||||
markSaved();
|
||||
// 通知资产刷新 | Notify asset refresh
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
messageHub?.publish('assets:refresh', {});
|
||||
} catch (error) {
|
||||
console.error('[ParticleEditor] Auto-save failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [particleData, filePath, setParticleData, setSelectedPreset, resetPreview, markSaved]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
createNew();
|
||||
@@ -2146,7 +2274,8 @@ export function ParticleEditorPanel() {
|
||||
ref={panelRef}
|
||||
className="particle-editor-panel"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
onClick={handlePanelClick}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="particle-editor-toolbar">
|
||||
@@ -2186,7 +2315,7 @@ export function ParticleEditorPanel() {
|
||||
<div className="preview-controls">
|
||||
<button
|
||||
className="preview-control-btn burst-btn"
|
||||
onClick={handleTriggerBurst}
|
||||
onClick={storeTriggerBurst}
|
||||
title="Trigger Burst (Click canvas also works)"
|
||||
>
|
||||
<Zap size={14} />
|
||||
@@ -2195,7 +2324,7 @@ export function ParticleEditorPanel() {
|
||||
<div className="preview-control-separator" />
|
||||
<button
|
||||
className={`preview-control-btn ${followMouse ? 'active' : ''}`}
|
||||
onClick={() => setFollowMouse(!followMouse)}
|
||||
onClick={toggleFollowMouse}
|
||||
title="Mouse Follow Mode"
|
||||
>
|
||||
<MousePointer2 size={14} />
|
||||
@@ -2232,7 +2361,7 @@ export function ParticleEditorPanel() {
|
||||
});
|
||||
}
|
||||
// 触发爆发 | Trigger burst
|
||||
handleTriggerBurst();
|
||||
storeTriggerBurst();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -2303,6 +2432,7 @@ export function ParticleEditorPanel() {
|
||||
data={particleData}
|
||||
onChange={updateProperty}
|
||||
onBrowseTexture={handleBrowseTexture}
|
||||
resolveGuidToName={resolveGuidToName}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'emission' && (
|
||||
|
||||
@@ -4,122 +4,212 @@
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import type { IParticleAsset } from '@esengine/particle';
|
||||
import { createDefaultParticleAsset } from '@esengine/particle';
|
||||
|
||||
/** Tab 类型 | Tab type */
|
||||
export type ParticleEditorTab = 'basic' | 'emission' | 'particle' | 'color' | 'modules' | 'burst';
|
||||
|
||||
/**
|
||||
* 粒子编辑器状态
|
||||
* Particle editor state
|
||||
*/
|
||||
export interface ParticleEditorState {
|
||||
// ===== 文件状态 | File State =====
|
||||
/** 当前编辑的文件路径 | Current file path being edited */
|
||||
filePath: string | null;
|
||||
|
||||
/** 待打开的文件路径 | Pending file path to open */
|
||||
pendingFilePath: string | null;
|
||||
|
||||
/** 当前粒子数据 | Current particle data */
|
||||
particleData: IParticleAsset | null;
|
||||
|
||||
/** 是否已修改 | Is modified */
|
||||
isDirty: boolean;
|
||||
/** 是否正在加载 | Is loading */
|
||||
isLoading: boolean;
|
||||
|
||||
/** 是否正在预览 | Is previewing */
|
||||
isPlaying: boolean;
|
||||
|
||||
// ===== UI 状态 | UI State =====
|
||||
/** 当前激活的 Tab | Current active tab */
|
||||
activeTab: ParticleEditorTab;
|
||||
/** 是否全屏 | Is fullscreen */
|
||||
isFullscreen: boolean;
|
||||
/** 选中的预设名称 | Selected preset name */
|
||||
selectedPreset: string | null;
|
||||
|
||||
// Actions
|
||||
// ===== 预览状态 | Preview State =====
|
||||
/** 是否正在预览 | Is previewing */
|
||||
isPlaying: boolean;
|
||||
/** 是否跟随鼠标 | Is following mouse */
|
||||
followMouse: boolean;
|
||||
/** 爆发触发计数器 | Burst trigger counter */
|
||||
burstTrigger: number;
|
||||
|
||||
// ===== Actions =====
|
||||
/** 设置文件路径 | Set file path */
|
||||
setFilePath: (path: string | null) => void;
|
||||
|
||||
/** 设置待打开的文件路径 | Set pending file path */
|
||||
setPendingFilePath: (path: string | null) => void;
|
||||
|
||||
/** 设置粒子数据 | Set particle data */
|
||||
setParticleData: (data: IParticleAsset | null) => void;
|
||||
|
||||
/** 更新粒子属性 | Update particle property */
|
||||
updateProperty: <K extends keyof IParticleAsset>(key: K, value: IParticleAsset[K]) => void;
|
||||
|
||||
/** 标记为已修改 | Mark as dirty */
|
||||
markDirty: () => void;
|
||||
|
||||
/** 标记为已保存 | Mark as saved */
|
||||
markSaved: () => void;
|
||||
|
||||
/** 设置播放状态 | Set playing state */
|
||||
setPlaying: (playing: boolean) => void;
|
||||
|
||||
/** 设置选中预设 | Set selected preset */
|
||||
setSelectedPreset: (preset: string | null) => void;
|
||||
|
||||
/** 重置编辑器 | Reset editor */
|
||||
reset: () => void;
|
||||
|
||||
/** 创建新粒子效果 | Create new particle effect */
|
||||
createNew: (name?: string) => void;
|
||||
|
||||
// ===== UI Actions =====
|
||||
/** 设置激活的 Tab | Set active tab */
|
||||
setActiveTab: (tab: ParticleEditorTab) => void;
|
||||
/** 切换全屏 | Toggle fullscreen */
|
||||
toggleFullscreen: () => void;
|
||||
/** 切换跟随鼠标 | Toggle follow mouse */
|
||||
toggleFollowMouse: () => void;
|
||||
/** 触发爆发 | Trigger burst */
|
||||
triggerBurst: () => void;
|
||||
|
||||
// ===== 文件操作 | File Operations =====
|
||||
/** 加载文件 | Load file */
|
||||
loadFile: (path: string, fileSystem: { readFile: (path: string) => Promise<string> }) => Promise<void>;
|
||||
/** 保存文件 | Save file */
|
||||
saveFile: (fileSystem: { writeFile: (path: string, content: string) => Promise<void> }, dialogService?: { saveDialog: (options: any) => Promise<string | null> }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子编辑器 Store
|
||||
* Particle editor store
|
||||
*/
|
||||
export const useParticleEditorStore = create<ParticleEditorState>((set) => ({
|
||||
filePath: null,
|
||||
pendingFilePath: null,
|
||||
particleData: null,
|
||||
isDirty: false,
|
||||
isPlaying: false,
|
||||
selectedPreset: null,
|
||||
|
||||
setFilePath: (path) => set({ filePath: path }),
|
||||
|
||||
setPendingFilePath: (path) => set({ pendingFilePath: path }),
|
||||
|
||||
setParticleData: (data) => set((state) => ({
|
||||
particleData: data,
|
||||
// 如果有文件路径,修改数据时应该标记为 dirty
|
||||
// 如果没有文件路径且之前也没有数据,则是加载文件,不标记 dirty
|
||||
// If has file path, mark as dirty when data changes
|
||||
// If no file path and no previous data, it's loading, don't mark dirty
|
||||
isDirty: state.filePath !== null || state.particleData !== null,
|
||||
})),
|
||||
|
||||
updateProperty: (key, value) => set((state) => {
|
||||
if (!state.particleData) return state;
|
||||
return {
|
||||
particleData: {
|
||||
...state.particleData,
|
||||
[key]: value,
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
markDirty: () => set({ isDirty: true }),
|
||||
|
||||
markSaved: () => set({ isDirty: false }),
|
||||
|
||||
setPlaying: (playing) => set({ isPlaying: playing }),
|
||||
|
||||
setSelectedPreset: (preset) => set({ selectedPreset: preset }),
|
||||
|
||||
reset: () => set({
|
||||
export const useParticleEditorStore = create<ParticleEditorState>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
// ===== 初始状态 | Initial State =====
|
||||
filePath: null,
|
||||
pendingFilePath: null,
|
||||
particleData: null,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
activeTab: 'basic' as ParticleEditorTab,
|
||||
isFullscreen: false,
|
||||
selectedPreset: null,
|
||||
isPlaying: false,
|
||||
selectedPreset: null,
|
||||
}),
|
||||
followMouse: false,
|
||||
burstTrigger: 0,
|
||||
|
||||
createNew: (name = 'New Particle') => set({
|
||||
particleData: createDefaultParticleAsset(name),
|
||||
filePath: null,
|
||||
isDirty: true,
|
||||
isPlaying: true, // 自动播放 | Auto play
|
||||
selectedPreset: null,
|
||||
}),
|
||||
}));
|
||||
// ===== 基础 Actions | Basic Actions =====
|
||||
setFilePath: (path) => set({ filePath: path }),
|
||||
|
||||
setPendingFilePath: (path) => set({ pendingFilePath: path }),
|
||||
|
||||
setParticleData: (data) => set((state) => ({
|
||||
particleData: data,
|
||||
// 加载时不标记 dirty | Don't mark dirty when loading
|
||||
isDirty: state.isLoading ? false : (state.filePath !== null || state.particleData !== null),
|
||||
})),
|
||||
|
||||
updateProperty: (key, value) => set((state) => {
|
||||
if (!state.particleData) return state;
|
||||
return {
|
||||
particleData: {
|
||||
...state.particleData,
|
||||
[key]: value,
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
markDirty: () => set({ isDirty: true }),
|
||||
|
||||
markSaved: () => set({ isDirty: false }),
|
||||
|
||||
setPlaying: (playing) => set({ isPlaying: playing }),
|
||||
|
||||
setSelectedPreset: (preset) => set({ selectedPreset: preset }),
|
||||
|
||||
reset: () => set({
|
||||
filePath: null,
|
||||
pendingFilePath: null,
|
||||
particleData: null,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
activeTab: 'basic',
|
||||
isFullscreen: false,
|
||||
selectedPreset: null,
|
||||
isPlaying: false,
|
||||
followMouse: false,
|
||||
burstTrigger: 0,
|
||||
}),
|
||||
|
||||
createNew: (name = 'New Particle') => set({
|
||||
particleData: createDefaultParticleAsset(name),
|
||||
filePath: null,
|
||||
isDirty: true,
|
||||
isPlaying: true, // 自动播放 | Auto play
|
||||
selectedPreset: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
// ===== UI Actions =====
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
|
||||
toggleFullscreen: () => set((state) => ({ isFullscreen: !state.isFullscreen })),
|
||||
|
||||
toggleFollowMouse: () => set((state) => ({ followMouse: !state.followMouse })),
|
||||
|
||||
triggerBurst: () => set((state) => ({ burstTrigger: state.burstTrigger + 1 })),
|
||||
|
||||
// ===== 文件操作 | File Operations =====
|
||||
loadFile: async (path, fileSystem) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const content = await fileSystem.readFile(path);
|
||||
const data = JSON.parse(content) as IParticleAsset;
|
||||
const defaults = createDefaultParticleAsset();
|
||||
set({
|
||||
particleData: { ...defaults, ...data },
|
||||
filePath: path,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
pendingFilePath: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ParticleEditorStore] Failed to load file:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
saveFile: async (fileSystem, dialogService) => {
|
||||
const state = get();
|
||||
if (!state.particleData) return false;
|
||||
|
||||
let savePath = state.filePath;
|
||||
|
||||
// 如果没有路径,弹出保存对话框 | If no path, show save dialog
|
||||
if (!savePath && dialogService) {
|
||||
savePath = await dialogService.saveDialog({
|
||||
title: 'Save Particle Effect',
|
||||
filters: [{ name: 'Particle Effect', extensions: ['particle'] }],
|
||||
defaultPath: `${state.particleData.name || 'new-particle'}.particle`,
|
||||
});
|
||||
if (!savePath) return false;
|
||||
}
|
||||
|
||||
if (!savePath) return false;
|
||||
|
||||
try {
|
||||
await fileSystem.writeFile(savePath, JSON.stringify(state.particleData, null, 2));
|
||||
set({ filePath: savePath, isDirty: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[ParticleEditorStore] Failed to save:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../editor-core" },
|
||||
{ "path": "../particle" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user