Files
esengine/packages/particle-editor/src/providers/ParticleInspectorProvider.tsx
YHH 32d35ef2ee feat(particle): 添加完整粒子系统和粒子编辑器 (#284)
* feat(editor-core): 添加用户系统自动注册功能

- IUserCodeService 新增 registerSystems/unregisterSystems/getRegisteredSystems 方法
- UserCodeService 实现系统检测、实例化和场景注册逻辑
- ServiceRegistry 在预览开始时注册用户系统,停止时移除
- 热更新时自动重新加载用户系统
- 更新 System 脚本模板添加 @ECSSystem 装饰器

* feat(editor-core): 添加编辑器脚本支持(Inspector/Gizmo)

- registerEditorExtensions 实际注册用户 Inspector 和 Gizmo
- 添加 unregisterEditorExtensions 方法
- ServiceRegistry 在项目加载时编译并加载编辑器脚本
- 项目关闭时自动清理编辑器扩展
- 添加 Inspector 和 Gizmo 脚本创建模板

* feat(particle): 添加粒子系统和粒子编辑器

新增两个包:
- @esengine/particle: 粒子系统核心库
- @esengine/particle-editor: 粒子编辑器 UI

粒子系统功能:
- ECS 组件架构,支持播放/暂停/重置控制
- 7种发射形状:点、圆、环、矩形、边缘、线、锥形
- 5个动画模块:颜色渐变、缩放曲线、速度控制、旋转、噪声
- 纹理动画模块支持精灵表动画
- 3种混合模式:Normal、Additive、Multiply
- 11个内置预设:火焰、烟雾、爆炸、雨、雪等
- 对象池优化,支持粒子复用

编辑器功能:
- 实时 Canvas 预览,支持全屏和鼠标跟随
- 点击触发爆发效果(用于测试爆炸类特效)
- 渐变编辑器:可视化颜色关键帧编辑
- 曲线编辑器:支持缩放曲线和缓动函数
- 预设浏览器:快速应用内置预设
- 模块开关:独立启用/禁用各个模块
- Vector2 样式输入(重力 X/Y)

* feat(particle): 完善粒子系统核心功能

1. Burst 定时爆发系统
   - BurstConfig 接口支持时间、数量、循环次数、间隔
   - 运行时自动处理定时爆发
   - 支持无限循环爆发

2. 速度曲线模块 (VelocityOverLifetimeModule)
   - 6种曲线类型:Constant、Linear、EaseIn、EaseOut、EaseInOut、Custom
   - 自定义关键帧曲线支持
   - 附加速度 X/Y
   - 轨道速度和径向速度

3. 碰撞边界模块 (CollisionModule)
   - 矩形和圆形边界类型
   - 3种碰撞行为:Kill、Bounce、Wrap
   - 反弹系数和最小速度阈值
   - 反弹时生命损失

* feat(particle): 添加力场模块、碰撞模块和世界/本地空间支持

- 新增 ForceFieldModule 支持风力、吸引点、漩涡、湍流四种力场类型
- 新增 SimulationSpace 枚举支持世界空间和本地空间切换
- ParticleSystemComponent 集成力场模块和空间模式
- 粒子编辑器添加 Collision 和 ForceField 模块的 UI 编辑支持
- 新增 Vortex、Leaves、Bouncing 三个预设展示新功能
- 编辑器预览实现完整的碰撞和力场效果

* fix(particle): 移除未使用的 transform 循环变量
2025-12-05 23:03:31 +08:00

469 lines
15 KiB
TypeScript

/**
* 粒子系统 Inspector Provider
* Particle System Inspector Provider
*/
import React, { useState, useCallback } from 'react';
import { Play, Pause, RotateCcw, Sparkles } 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;
component: ParticleSystemComponent;
}
export class ParticleInspectorProvider implements IInspectorProvider<ParticleInspectorData> {
readonly id = 'particle-system-inspector';
readonly name = 'Particle System Inspector';
readonly priority = 100;
canHandle(target: unknown): target is ParticleInspectorData {
if (typeof target !== 'object' || target === null) return false;
const obj = target as Record<string, unknown>;
return 'entityId' in obj && 'component' in obj &&
obj.component !== null &&
typeof obj.component === 'object' &&
'maxParticles' in (obj.component as Record<string, unknown>) &&
'emissionRate' in (obj.component as Record<string, unknown>);
}
render(data: ParticleInspectorData, _context: InspectorContext): React.ReactElement {
return <ParticleInspectorUI data={data} />;
}
}
interface ParticleInspectorUIProps {
data: ParticleInspectorData;
}
function ParticleInspectorUI({ data }: ParticleInspectorUIProps) {
const { component } = data;
const [, forceUpdate] = useState({});
const refresh = useCallback(() => forceUpdate({}), []);
const handlePlay = () => {
component.play();
refresh();
};
const handlePause = () => {
component.pause();
refresh();
};
const handleStop = () => {
component.stop(true);
refresh();
};
const handleBurst = () => {
component.burst(10);
refresh();
};
const handleChange = <K extends keyof ParticleSystemComponent>(
key: K,
value: ParticleSystemComponent[K]
) => {
(component as any)[key] = value;
component.markDirty();
refresh();
};
return (
<div className="entity-inspector">
{/* 控制按钮 | Control buttons */}
<div className="inspector-section">
<div className="section-title">Controls</div>
<div style={{ display: 'flex', gap: '4px', marginBottom: '8px' }}>
<button
onClick={component.isPlaying ? handlePause : handlePlay}
style={buttonStyle}
title={component.isPlaying ? 'Pause' : 'Play'}
>
{component.isPlaying ? <Pause size={14} /> : <Play size={14} />}
</button>
<button onClick={handleStop} style={buttonStyle} title="Stop">
<RotateCcw size={14} />
</button>
<button onClick={handleBurst} style={buttonStyle} title="Burst 10">
<Sparkles size={14} />
</button>
</div>
<div className="property-row">
<label>Active Particles</label>
<span>{component.activeParticleCount} / {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)}
/>
)}
</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>
</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',
justifyContent: 'center',
padding: '6px 10px',
border: 'none',
borderRadius: '4px',
background: 'var(--button-background, #3a3a3a)',
color: 'var(--text-color, #e0e0e0)',
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>
);
}