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:
YHH
2025-12-13 19:44:08 +08:00
committed by GitHub
parent a716d8006c
commit beaa1d09de
258 changed files with 17725 additions and 3030 deletions

View File

@@ -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",

View File

@@ -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' && (

View File

@@ -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;
}
},
}))
);

View File

@@ -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" }
]
}