* 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 循环变量
469 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|