fix(particle): 修复粒子系统在浏览器预览中的资产加载和渲染 (#290)
* fix(editor): 修复粒子实体创建和优化检视器 - 添加 effects 分类到右键菜单,修复粒子实体无法创建的问题 - 添加粒子效果的本地化标签 - 简化粒子组件检视器,优先显示资产文件选择 - 高级属性只在未选择资产时显示,且默认折叠 - 添加可折叠的属性分组提升用户体验 * fix(particle): 修复粒子系统在浏览器预览中的资产加载和渲染 - 添加粒子 Gizmo 支持,显示发射形状并响应 Transform 缩放/旋转 - 修复资产热重载:添加 reloadAsset() 方法和 assets:refresh 事件监听 - 修复 VectorFieldEditors 数值输入精度(step 改为 0.01) - 修复浏览器预览中粒子资产加载失败的问题: - 将相对路径转换为绝对路径以正确复制资产文件 - 使用原始 GUID 而非生成的 GUID 构建 asset catalog - 初始化全局 assetManager 单例的 catalog 和 loader - 在 GameRuntime 的 systemContext 中添加 engineIntegration - 公开 AssetManager.initializeFromCatalog 方法供运行时使用
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
import { ParticleEditorPanel } from './panels/ParticleEditorPanel';
|
||||
import { ParticleInspectorProvider } from './providers/ParticleInspectorProvider';
|
||||
import { useParticleEditorStore } from './stores/ParticleEditorStore';
|
||||
import { registerParticleGizmo, unregisterParticleGizmo } from './gizmos/ParticleGizmo';
|
||||
|
||||
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM)
|
||||
// Import editor CSS styles (automatically handled and injected by vite)
|
||||
@@ -45,6 +46,8 @@ import './styles/ParticleEditor.css';
|
||||
* Particle Editor Module
|
||||
*/
|
||||
export class ParticleEditorModule implements IEditorModuleLoader {
|
||||
private _assetsRefreshUnsubscribe: (() => void) | null = null;
|
||||
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
// 注册检视器提供者 | Register inspector provider
|
||||
const inspectorRegistry = services.resolve(InspectorRegistry);
|
||||
@@ -72,10 +75,61 @@ export class ParticleEditorModule implements IEditorModuleLoader {
|
||||
createMessage: 'particle:create-asset'
|
||||
});
|
||||
}
|
||||
|
||||
// 注册 Gizmo | Register gizmo
|
||||
registerParticleGizmo();
|
||||
|
||||
// 监听资产刷新事件,当 .particle 文件保存时重新加载所有粒子组件
|
||||
// Listen for assets refresh event to reload particle components when .particle files are saved
|
||||
const messageHub = services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
this._assetsRefreshUnsubscribe = messageHub.subscribe('assets:refresh', () => {
|
||||
this._reloadAllParticleAssets();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// 清理 | Clean up
|
||||
// 取消订阅事件 | Unsubscribe events
|
||||
if (this._assetsRefreshUnsubscribe) {
|
||||
this._assetsRefreshUnsubscribe();
|
||||
this._assetsRefreshUnsubscribe = null;
|
||||
}
|
||||
|
||||
// 取消注册 Gizmo | Unregister gizmo
|
||||
unregisterParticleGizmo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载所有粒子资产
|
||||
* Reload all particle assets
|
||||
*
|
||||
* 当资产文件变化时调用,强制所有粒子组件重新加载资产。
|
||||
* Called when asset files change, forcing all particle components to reload.
|
||||
*/
|
||||
private _reloadAllParticleAssets(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 遍历所有带有 ParticleSystemComponent 的实体
|
||||
// Iterate all entities with ParticleSystemComponent
|
||||
scene.entities.forEach((entity: Entity) => {
|
||||
const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null;
|
||||
if (particle && particle.particleAssetGuid) {
|
||||
// 异步重新加载资产 | Async reload asset
|
||||
particle.reloadAsset().then((success: boolean) => {
|
||||
if (success) {
|
||||
console.log(`[ParticleEditorModule] Reloaded particle asset for entity: ${entity.name}`);
|
||||
// 标记需要重建并重新播放 | Mark dirty and replay
|
||||
particle.markDirty();
|
||||
if (particle.isPlaying) {
|
||||
particle.stop(true);
|
||||
particle.play();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPanels(): PanelDescriptor[] {
|
||||
|
||||
310
packages/particle-editor/src/gizmos/ParticleGizmo.ts
Normal file
310
packages/particle-editor/src/gizmos/ParticleGizmo.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Particle System Gizmo Implementation
|
||||
* 粒子系统 Gizmo 实现
|
||||
*
|
||||
* 显示粒子发射区域形状,支持 Transform 缩放和旋转。
|
||||
* Displays particle emission shape, supports Transform scale and rotation.
|
||||
*/
|
||||
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IGizmoRenderData,
|
||||
IRectGizmoData,
|
||||
ICircleGizmoData,
|
||||
ILineGizmoData,
|
||||
GizmoColor
|
||||
} from '@esengine/editor-core';
|
||||
import { GizmoRegistry } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { ParticleSystemComponent, EmissionShape } from '@esengine/particle';
|
||||
|
||||
/**
|
||||
* 粒子 Gizmo 颜色配置
|
||||
* Particle gizmo color configuration
|
||||
*/
|
||||
const ParticleGizmoColors = {
|
||||
emissionShape: { r: 1.0, g: 0.6, b: 0.0, a: 0.8 } as GizmoColor,
|
||||
emissionShapeSelected: { r: 1.0, g: 0.8, b: 0.2, a: 1.0 } as GizmoColor,
|
||||
direction: { r: 0.0, g: 0.8, b: 1.0, a: 0.9 } as GizmoColor,
|
||||
centerMark: { r: 1.0, g: 1.0, b: 1.0, a: 0.8 } as GizmoColor,
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建中心点标记 Gizmo (十字形)
|
||||
* Create center mark gizmo (cross shape)
|
||||
*/
|
||||
function createCenterMarkGizmo(x: number, y: number, size: number, color: GizmoColor): ILineGizmoData[] {
|
||||
const halfSize = size / 2;
|
||||
return [
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: x - halfSize, y: y },
|
||||
{ x: x + halfSize, y: y }
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: x, y: y - halfSize },
|
||||
{ x: x, y: y + halfSize }
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建方向箭头
|
||||
* Create direction arrow
|
||||
*/
|
||||
function createDirectionArrow(
|
||||
x: number,
|
||||
y: number,
|
||||
direction: number,
|
||||
length: number,
|
||||
color: GizmoColor
|
||||
): ILineGizmoData[] {
|
||||
const endX = x + Math.cos(direction) * length;
|
||||
const endY = y + Math.sin(direction) * length;
|
||||
const arrowSize = length * 0.2;
|
||||
const arrowAngle = Math.PI / 6;
|
||||
|
||||
return [
|
||||
// 主线
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x, y },
|
||||
{ x: endX, y: endY }
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
},
|
||||
// 箭头左
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: endX, y: endY },
|
||||
{
|
||||
x: endX - arrowSize * Math.cos(direction - arrowAngle),
|
||||
y: endY - arrowSize * Math.sin(direction - arrowAngle)
|
||||
}
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
},
|
||||
// 箭头右
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: endX, y: endY },
|
||||
{
|
||||
x: endX - arrowSize * Math.cos(direction + arrowAngle),
|
||||
y: endY - arrowSize * Math.sin(direction + arrowAngle)
|
||||
}
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ParticleSystemComponent gizmo provider
|
||||
* 粒子系统组件 gizmo 提供者
|
||||
*/
|
||||
function particleSystemGizmoProvider(
|
||||
particle: ParticleSystemComponent,
|
||||
entity: Entity,
|
||||
isSelected: boolean
|
||||
): IGizmoRenderData[] {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (!transform) return [];
|
||||
|
||||
const gizmos: IGizmoRenderData[] = [];
|
||||
const color = isSelected
|
||||
? ParticleGizmoColors.emissionShapeSelected
|
||||
: ParticleGizmoColors.emissionShape;
|
||||
|
||||
// 获取 Transform 数据 | Get transform data
|
||||
const worldX = transform.worldPosition?.x ?? transform.position.x;
|
||||
const worldY = transform.worldPosition?.y ?? transform.position.y;
|
||||
|
||||
const rot = transform.worldRotation ?? transform.rotation;
|
||||
const worldRotation = rot?.z ?? 0;
|
||||
|
||||
const scale = transform.worldScale ?? transform.scale;
|
||||
const scaleX = scale?.x ?? 1;
|
||||
const scaleY = scale?.y ?? 1;
|
||||
|
||||
// 从加载的资产获取发射形状配置 | Get emission shape config from loaded asset
|
||||
const asset = particle.loadedAsset;
|
||||
const emissionShape = asset?.emissionShape ?? EmissionShape.Point;
|
||||
const shapeRadius = (asset?.shapeRadius ?? 0) * Math.max(scaleX, scaleY);
|
||||
const shapeWidth = (asset?.shapeWidth ?? 0) * scaleX;
|
||||
const shapeHeight = (asset?.shapeHeight ?? 0) * scaleY;
|
||||
const shapeAngle = (asset?.shapeAngle ?? 30) * Math.PI / 180; // 转换为弧度
|
||||
const direction = ((asset?.direction ?? 90) * Math.PI / 180) + worldRotation; // 转换为弧度并应用世界旋转
|
||||
|
||||
// 根据发射形状绘制 Gizmo | Draw gizmo based on emission shape
|
||||
switch (emissionShape) {
|
||||
case EmissionShape.Point:
|
||||
// 点发射:显示中心点和方向 | Point: show center and direction
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 10, color));
|
||||
if (isSelected) {
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, 30, ParticleGizmoColors.direction));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Circle:
|
||||
// 填充圆形:显示圆形区域 | Filled circle: show circle area
|
||||
gizmos.push({
|
||||
type: 'circle',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
radius: shapeRadius || 20,
|
||||
color
|
||||
} as ICircleGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, shapeRadius + 20 || 40, ParticleGizmoColors.direction));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Ring:
|
||||
// 圆环:显示圆环边缘 | Ring: show ring edge
|
||||
gizmos.push({
|
||||
type: 'circle',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
radius: shapeRadius || 20,
|
||||
color
|
||||
} as ICircleGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Rectangle:
|
||||
// 矩形:显示矩形区域 | Rectangle: show rect area
|
||||
gizmos.push({
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: shapeWidth || 40,
|
||||
height: shapeHeight || 20,
|
||||
rotation: worldRotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
} as IRectGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, Math.max(shapeWidth, shapeHeight) / 2 + 20 || 40, ParticleGizmoColors.direction));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Edge:
|
||||
// 矩形边缘:显示矩形边框 | Rectangle edge: show rect border
|
||||
gizmos.push({
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: shapeWidth || 40,
|
||||
height: shapeHeight || 20,
|
||||
rotation: worldRotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
} as IRectGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Line:
|
||||
// 线段:显示发射线 | Line: show emission line
|
||||
{
|
||||
const halfWidth = (shapeWidth || 40) / 2;
|
||||
const cos = Math.cos(direction + Math.PI / 2);
|
||||
const sin = Math.sin(direction + Math.PI / 2);
|
||||
gizmos.push({
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: worldX + cos * halfWidth, y: worldY + sin * halfWidth },
|
||||
{ x: worldX - cos * halfWidth, y: worldY - sin * halfWidth }
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
} as ILineGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, halfWidth + 20, ParticleGizmoColors.direction));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Cone:
|
||||
// 圆锥/扇形:显示扇形区域 | Cone/fan: show fan area
|
||||
{
|
||||
const radius = shapeRadius || 30;
|
||||
const halfAngle = shapeAngle / 2;
|
||||
const startAngle = direction - halfAngle;
|
||||
const endAngle = direction + halfAngle;
|
||||
|
||||
// 绘制扇形的两条边和弧线 | Draw two edges and arc of the fan
|
||||
const segments = 16;
|
||||
const points: { x: number; y: number }[] = [{ x: worldX, y: worldY }];
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const angle = startAngle + (endAngle - startAngle) * (i / segments);
|
||||
points.push({
|
||||
x: worldX + Math.cos(angle) * radius,
|
||||
y: worldY + Math.sin(angle) * radius
|
||||
});
|
||||
}
|
||||
points.push({ x: worldX, y: worldY });
|
||||
|
||||
gizmos.push({
|
||||
type: 'line',
|
||||
points,
|
||||
color,
|
||||
closed: true
|
||||
} as ILineGizmoData);
|
||||
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, radius + 20, ParticleGizmoColors.direction));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 默认:显示中心点 | Default: show center point
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 10, color));
|
||||
break;
|
||||
}
|
||||
|
||||
return gizmos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register gizmo provider for ParticleSystemComponent.
|
||||
* 为 ParticleSystemComponent 注册 gizmo 提供者。
|
||||
*/
|
||||
export function registerParticleGizmo(): void {
|
||||
GizmoRegistry.register(ParticleSystemComponent, particleSystemGizmoProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister gizmo provider for ParticleSystemComponent.
|
||||
* 取消注册 ParticleSystemComponent 的 gizmo 提供者。
|
||||
*/
|
||||
export function unregisterParticleGizmo(): void {
|
||||
GizmoRegistry.unregister(ParticleSystemComponent);
|
||||
}
|
||||
@@ -23,3 +23,6 @@ export type { ParticleEditorState } from './stores/ParticleEditorStore';
|
||||
|
||||
// Components
|
||||
export { GradientEditor, CurveEditor } from './components';
|
||||
|
||||
// Gizmos
|
||||
export { registerParticleGizmo, unregisterParticleGizmo } from './gizmos/ParticleGizmo';
|
||||
|
||||
@@ -2003,6 +2003,18 @@ export function ParticleEditorPanel() {
|
||||
}
|
||||
}, [particleData, filePath, setFilePath, markSaved]);
|
||||
|
||||
// 面板容器 ref | Panel container ref
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 键盘快捷键处理 | Keyboard shortcut handler
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSave();
|
||||
}
|
||||
}, [handleSave]);
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
const dialog = Core.services.tryResolve(IDialogService) as IDialog | null;
|
||||
if (!dialog) return;
|
||||
@@ -2130,7 +2142,12 @@ export function ParticleEditorPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="particle-editor-panel">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="particle-editor-panel"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="particle-editor-toolbar">
|
||||
<div className="toolbar-left">
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* 粒子系统 Inspector Provider
|
||||
* Particle System Inspector Provider
|
||||
*
|
||||
* 检视器显示控制按钮,资产选择通过 @Property 装饰器自动渲染
|
||||
* Inspector shows control buttons, asset selection is auto-rendered via @Property decorator
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Play, Pause, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Play, Pause, RotateCcw, Sparkles, Loader2 } from 'lucide-react';
|
||||
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
|
||||
import type { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { EmissionShape, ParticleBlendMode } from '@esengine/particle';
|
||||
|
||||
interface ParticleInspectorData {
|
||||
entityId: string;
|
||||
@@ -41,37 +43,65 @@ interface ParticleInspectorUIProps {
|
||||
function ParticleInspectorUI({ data }: ParticleInspectorUIProps) {
|
||||
const { component } = data;
|
||||
const [, forceUpdate] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastGuid, setLastGuid] = useState(component.particleAssetGuid);
|
||||
|
||||
const refresh = useCallback(() => forceUpdate({}), []);
|
||||
|
||||
const handlePlay = () => {
|
||||
// 当资产 GUID 变化时自动加载资产
|
||||
// Auto-load asset when GUID changes
|
||||
useEffect(() => {
|
||||
const currentGuid = component.particleAssetGuid;
|
||||
if (currentGuid !== lastGuid) {
|
||||
setLastGuid(currentGuid);
|
||||
if (currentGuid) {
|
||||
setIsLoading(true);
|
||||
component.loadAsset(currentGuid).finally(() => {
|
||||
setIsLoading(false);
|
||||
refresh();
|
||||
});
|
||||
} else {
|
||||
// 清除已加载的资产
|
||||
component.setAssetData(null);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
}, [component, component.particleAssetGuid, lastGuid, refresh]);
|
||||
|
||||
const handlePlay = useCallback(async () => {
|
||||
// 如果有资产 GUID 但尚未加载,先加载
|
||||
if (component.particleAssetGuid && !component.loadedAsset) {
|
||||
setIsLoading(true);
|
||||
await component.loadAsset(component.particleAssetGuid);
|
||||
setIsLoading(false);
|
||||
}
|
||||
component.play();
|
||||
refresh();
|
||||
};
|
||||
}, [component, refresh]);
|
||||
|
||||
const handlePause = () => {
|
||||
const handlePause = useCallback(() => {
|
||||
component.pause();
|
||||
refresh();
|
||||
};
|
||||
}, [component, refresh]);
|
||||
|
||||
const handleStop = () => {
|
||||
const handleStop = useCallback(() => {
|
||||
component.stop(true);
|
||||
refresh();
|
||||
};
|
||||
}, [component, refresh]);
|
||||
|
||||
const handleBurst = () => {
|
||||
component.burst(10);
|
||||
const handleBurst = useCallback(async () => {
|
||||
// 如果有资产 GUID 但尚未加载,先加载
|
||||
if (component.particleAssetGuid && !component.loadedAsset) {
|
||||
setIsLoading(true);
|
||||
await component.loadAsset(component.particleAssetGuid);
|
||||
setIsLoading(false);
|
||||
}
|
||||
component.emit(10);
|
||||
refresh();
|
||||
};
|
||||
}, [component, refresh]);
|
||||
|
||||
const handleChange = <K extends keyof ParticleSystemComponent>(
|
||||
key: K,
|
||||
value: ParticleSystemComponent[K]
|
||||
) => {
|
||||
(component as any)[key] = value;
|
||||
component.markDirty();
|
||||
refresh();
|
||||
};
|
||||
const hasAsset = !!component.particleAssetGuid;
|
||||
const isAssetLoaded = !!component.loadedAsset;
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
@@ -83,247 +113,50 @@ function ParticleInspectorUI({ data }: ParticleInspectorUIProps) {
|
||||
onClick={component.isPlaying ? handlePause : handlePlay}
|
||||
style={buttonStyle}
|
||||
title={component.isPlaying ? 'Pause' : 'Play'}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{component.isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||
{isLoading ? <Loader2 size={14} className="animate-spin" /> :
|
||||
component.isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<button onClick={handleStop} style={buttonStyle} title="Stop">
|
||||
<button onClick={handleStop} style={buttonStyle} title="Stop" disabled={isLoading}>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button onClick={handleBurst} style={buttonStyle} title="Burst 10">
|
||||
<button onClick={handleBurst} style={buttonStyle} title="Burst 10" disabled={isLoading}>
|
||||
<Sparkles size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Active Particles</label>
|
||||
<span>{component.activeParticleCount} / {component.maxParticles}</span>
|
||||
<span>{component.activeParticleCount} / {component.loadedAsset?.maxParticles ?? component.maxParticles}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基础属性 | Basic Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Basic</div>
|
||||
|
||||
<NumberInput
|
||||
label="Max Particles"
|
||||
value={component.maxParticles}
|
||||
min={1}
|
||||
max={10000}
|
||||
step={100}
|
||||
onChange={v => handleChange('maxParticles', v)}
|
||||
/>
|
||||
|
||||
<CheckboxInput
|
||||
label="Looping"
|
||||
checked={component.looping}
|
||||
onChange={v => handleChange('looping', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Duration"
|
||||
value={component.duration}
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
onChange={v => handleChange('duration', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Playback Speed"
|
||||
value={component.playbackSpeed}
|
||||
min={0.01}
|
||||
max={10}
|
||||
step={0.1}
|
||||
onChange={v => handleChange('playbackSpeed', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 发射属性 | Emission Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Emission</div>
|
||||
|
||||
<NumberInput
|
||||
label="Emission Rate"
|
||||
value={component.emissionRate}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={v => handleChange('emissionRate', v)}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label="Shape"
|
||||
value={component.emissionShape}
|
||||
options={[
|
||||
{ value: EmissionShape.Point, label: 'Point' },
|
||||
{ value: EmissionShape.Circle, label: 'Circle (filled)' },
|
||||
{ value: EmissionShape.Ring, label: 'Ring (edge)' },
|
||||
{ value: EmissionShape.Rectangle, label: 'Rectangle (filled)' },
|
||||
{ value: EmissionShape.Edge, label: 'Edge (rect outline)' },
|
||||
{ value: EmissionShape.Line, label: 'Line' },
|
||||
{ value: EmissionShape.Cone, label: 'Cone' },
|
||||
]}
|
||||
onChange={v => handleChange('emissionShape', v as EmissionShape)}
|
||||
/>
|
||||
|
||||
{component.emissionShape !== EmissionShape.Point && (
|
||||
<NumberInput
|
||||
label="Shape Radius"
|
||||
value={component.shapeRadius}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={v => handleChange('shapeRadius', v)}
|
||||
/>
|
||||
{hasAsset && (
|
||||
<div className="property-row">
|
||||
<label>Asset Status</label>
|
||||
<span style={{ color: isAssetLoaded ? 'var(--color-success, #4caf50)' : 'var(--color-warning, #ff9800)' }}>
|
||||
{isLoading ? 'Loading...' : isAssetLoaded ? 'Loaded' : 'Not loaded'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 粒子属性 | Particle Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Particle</div>
|
||||
|
||||
<RangeInput
|
||||
label="Lifetime"
|
||||
minValue={component.lifetimeMin}
|
||||
maxValue={component.lifetimeMax}
|
||||
min={0.01}
|
||||
step={0.1}
|
||||
onMinChange={v => handleChange('lifetimeMin', v)}
|
||||
onMaxChange={v => handleChange('lifetimeMax', v)}
|
||||
/>
|
||||
|
||||
<RangeInput
|
||||
label="Speed"
|
||||
minValue={component.speedMin}
|
||||
maxValue={component.speedMax}
|
||||
min={0}
|
||||
step={1}
|
||||
onMinChange={v => handleChange('speedMin', v)}
|
||||
onMaxChange={v => handleChange('speedMax', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Direction (°)"
|
||||
value={component.direction}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
onChange={v => handleChange('direction', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Spread (°)"
|
||||
value={component.directionSpread}
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
onChange={v => handleChange('directionSpread', v)}
|
||||
/>
|
||||
|
||||
<RangeInput
|
||||
label="Scale"
|
||||
minValue={component.scaleMin}
|
||||
maxValue={component.scaleMax}
|
||||
min={0.01}
|
||||
step={0.1}
|
||||
onMinChange={v => handleChange('scaleMin', v)}
|
||||
onMaxChange={v => handleChange('scaleMax', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Gravity X"
|
||||
value={component.gravityX}
|
||||
step={1}
|
||||
onChange={v => handleChange('gravityX', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Gravity Y"
|
||||
value={component.gravityY}
|
||||
step={1}
|
||||
onChange={v => handleChange('gravityY', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色属性 | Color Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Color</div>
|
||||
|
||||
<ColorInput
|
||||
label="Start Color"
|
||||
value={component.startColor}
|
||||
onChange={v => handleChange('startColor', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Start Alpha"
|
||||
value={component.startAlpha}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => handleChange('startAlpha', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="End Alpha"
|
||||
value={component.endAlpha}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => handleChange('endAlpha', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="End Scale"
|
||||
value={component.endScale}
|
||||
min={0}
|
||||
step={0.1}
|
||||
onChange={v => handleChange('endScale', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 渲染属性 | Rendering Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Rendering</div>
|
||||
|
||||
<NumberInput
|
||||
label="Particle Size"
|
||||
value={component.particleSize}
|
||||
min={1}
|
||||
step={1}
|
||||
onChange={v => handleChange('particleSize', v)}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label="Blend Mode"
|
||||
value={component.blendMode}
|
||||
options={[
|
||||
{ value: ParticleBlendMode.Normal, label: 'Normal' },
|
||||
{ value: ParticleBlendMode.Additive, label: 'Additive' },
|
||||
{ value: ParticleBlendMode.Multiply, label: 'Multiply' },
|
||||
]}
|
||||
onChange={v => handleChange('blendMode', v as ParticleBlendMode)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Sorting Order"
|
||||
value={component.sortingOrder}
|
||||
step={1}
|
||||
onChange={v => handleChange('sortingOrder', v)}
|
||||
/>
|
||||
</div>
|
||||
{/* 提示信息 | Hint */}
|
||||
{!hasAsset && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--color-warning-bg, rgba(255, 193, 7, 0.1))',
|
||||
border: '1px solid var(--color-warning-border, rgba(255, 193, 7, 0.3))',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-color, #e0e0e0)',
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
No particle asset selected. Drag a <code>.particle</code> file to the Particle Asset field above, or create one in Content Browser.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= UI Components =============
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--border-color, #3a3a3a)',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--input-background, #2a2a2a)',
|
||||
color: 'var(--text-color, #e0e0e0)',
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -336,133 +169,3 @@ const buttonStyle: React.CSSProperties = {
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
interface NumberInputProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function NumberInput({ label, value, min, max, step = 1, onChange }: NumberInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => onChange(parseFloat(e.target.value) || 0)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RangeInputProps {
|
||||
label: string;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onMinChange: (value: number) => void;
|
||||
onMaxChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function RangeInput({ label, minValue, maxValue, min, max, step = 1, onMinChange, onMaxChange }: RangeInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<div style={{ display: 'flex', gap: '4px', flex: 1 }}>
|
||||
<input
|
||||
type="number"
|
||||
value={minValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => onMinChange(parseFloat(e.target.value) || 0)}
|
||||
style={{ ...inputStyle, width: '50%' }}
|
||||
title="Min"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={maxValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => onMaxChange(parseFloat(e.target.value) || 0)}
|
||||
style={{ ...inputStyle, width: '50%' }}
|
||||
title="Max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxInputProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function CheckboxInput({ label, checked, onChange }: CheckboxInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function SelectInput({ label, value, options, onChange }: SelectInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function ColorInput({ label, value, onChange }: ColorInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={{ ...inputStyle, padding: '2px', height: '24px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,10 +78,14 @@ export const useParticleEditorStore = create<ParticleEditorState>((set) => ({
|
||||
|
||||
setPendingFilePath: (path) => set({ pendingFilePath: path }),
|
||||
|
||||
setParticleData: (data) => set({
|
||||
setParticleData: (data) => set((state) => ({
|
||||
particleData: data,
|
||||
isDirty: false,
|
||||
}),
|
||||
// 如果有文件路径,修改数据时应该标记为 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;
|
||||
@@ -115,7 +119,7 @@ export const useParticleEditorStore = create<ParticleEditorState>((set) => ({
|
||||
particleData: createDefaultParticleAsset(name),
|
||||
filePath: null,
|
||||
isDirty: true,
|
||||
isPlaying: false,
|
||||
isPlaying: true, // 自动播放 | Auto play
|
||||
selectedPreset: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user