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 循环变量
This commit is contained in:
YHH
2025-12-05 23:03:31 +08:00
committed by GitHub
parent 690d7859c8
commit 32d35ef2ee
43 changed files with 9704 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
{
"name": "@esengine/particle-editor",
"version": "1.0.0",
"description": "Editor support for @esengine/particle - particle system inspector and preview",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
},
"dependencies": {
"@esengine/particle": "workspace:*"
},
"peerDependencies": {
"@esengine/editor-core": "workspace:*"
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/build-config": "workspace:*",
"lucide-react": "^0.545.0",
"react": "^18.3.1",
"zustand": "^5.0.8",
"@types/react": "^18.3.12",
"rimraf": "^5.0.5",
"tsup": "^8.0.0",
"typescript": "^5.3.3"
},
"keywords": [
"ecs",
"particle",
"editor"
],
"author": "yhh",
"license": "MIT"
}

View File

@@ -0,0 +1,232 @@
/**
* 粒子编辑器模块
* Particle Editor Module
*
* Registers file handlers, panels, and templates for .particle files.
*/
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
import { Core } from '@esengine/ecs-framework';
import type {
IEditorModuleLoader,
PanelDescriptor,
EntityCreationTemplate,
ComponentInspectorProviderDef,
FileActionHandler,
FileCreationTemplate,
IPlugin,
ModuleManifest
} from '@esengine/editor-core';
import {
PanelPosition,
InspectorRegistry,
EntityStoreService,
MessageHub,
ComponentRegistry,
FileActionRegistry
} from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import {
ParticleSystemComponent,
ParticleRuntimeModule,
createDefaultParticleAsset
} from '@esengine/particle';
import { ParticleEditorPanel } from './panels/ParticleEditorPanel';
import { ParticleInspectorProvider } from './providers/ParticleInspectorProvider';
import { useParticleEditorStore } from './stores/ParticleEditorStore';
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM
// Import editor CSS styles (automatically handled and injected by vite)
import './styles/ParticleEditor.css';
/**
* 粒子编辑器模块
* Particle Editor Module
*/
export class ParticleEditorModule implements IEditorModuleLoader {
async install(services: ServiceContainer): Promise<void> {
// 注册检视器提供者 | Register inspector provider
const inspectorRegistry = services.resolve(InspectorRegistry);
if (inspectorRegistry) {
inspectorRegistry.register(new ParticleInspectorProvider());
}
// 注册组件到编辑器组件注册表 | Register to editor component registry
const componentRegistry = services.resolve(ComponentRegistry);
if (componentRegistry) {
componentRegistry.register({
name: 'ParticleSystem',
type: ParticleSystemComponent,
category: 'components.category.effects',
description: 'Particle system for 2D visual effects',
icon: 'Sparkles'
});
}
// 注册资产创建消息映射 | Register asset creation message mappings
const fileActionRegistry = services.resolve(FileActionRegistry);
if (fileActionRegistry) {
fileActionRegistry.registerAssetCreationMapping({
extension: '.particle',
createMessage: 'particle:create-asset'
});
}
}
async uninstall(): Promise<void> {
// 清理 | Clean up
}
getPanels(): PanelDescriptor[] {
return [
{
id: 'particle-editor',
title: 'Particle Editor',
position: PanelPosition.Center,
closable: true,
component: ParticleEditorPanel,
isDynamic: true
}
];
}
getInspectorProviders(): ComponentInspectorProviderDef[] {
return [
{
componentType: 'ParticleSystem',
priority: 100,
render: (component, entity, onChange) => {
const provider = new ParticleInspectorProvider();
return provider.render(
{ entityId: String(entity.id), component },
{ target: component, onChange }
);
}
}
];
}
getEntityCreationTemplates(): EntityCreationTemplate[] {
return [
{
id: 'create-particle-entity',
label: '创建粒子效果',
icon: 'Sparkles',
category: 'effects',
order: 100,
create: (): number => {
const scene = Core.scene;
if (!scene) {
throw new Error('Scene not available');
}
const entityStore = Core.services.resolve(EntityStoreService);
const messageHub = Core.services.resolve(MessageHub);
if (!entityStore || !messageHub) {
throw new Error('EntityStoreService or MessageHub not available');
}
const particleCount = entityStore.getAllEntities()
.filter((e: Entity) => e.name.startsWith('ParticleSystem ')).length;
const entityName = `ParticleSystem ${particleCount + 1}`;
const entity = scene.createEntity(entityName);
entity.addComponent(new TransformComponent());
entity.addComponent(new ParticleSystemComponent());
entityStore.addEntity(entity);
messageHub.publish('entity:added', { entity });
messageHub.publish('scene:modified', {});
entityStore.selectEntity(entity);
return entity.id;
}
}
];
}
getFileActionHandlers(): FileActionHandler[] {
return [
{
extensions: ['particle', 'json'],
onDoubleClick: (filePath: string) => {
// 只处理 .particle 和 .particle.json 文件
// Only handle .particle and .particle.json files
const lowerPath = filePath.toLowerCase();
if (!lowerPath.endsWith('.particle') && !lowerPath.endsWith('.particle.json')) {
return;
}
// 先设置待打开的文件路径到 store
// Set pending file path to store first
useParticleEditorStore.getState().setPendingFilePath(filePath);
const messageHub = Core.services.resolve(MessageHub);
if (messageHub) {
// 打开粒子编辑器面板(面板挂载后会从 store 读取 pendingFilePath
// Open particle editor panel (panel will read pendingFilePath from store after mount)
messageHub.publish('dynamic-panel:open', {
panelId: 'particle-editor',
title: `Particle Editor - ${filePath.split(/[\\/]/).pop()}`
});
}
}
}
];
}
getFileCreationTemplates(): FileCreationTemplate[] {
return [
{
id: 'create-particle',
label: 'Particle Effect',
extension: 'particle',
icon: 'Sparkles',
category: 'effects',
getContent: (fileName: string) => {
const assetData = createDefaultParticleAsset(fileName.replace('.particle', ''));
return JSON.stringify(assetData, null, 2);
}
}
];
}
}
export const particleEditorModule = new ParticleEditorModule();
/**
* 粒子插件清单
* Particle Plugin Manifest
*/
const manifest: ModuleManifest = {
id: '@esengine/particle',
name: '@esengine/particle',
displayName: 'Particle System',
version: '1.0.0',
description: 'Particle system for 2D visual effects',
category: 'Rendering',
isCore: false,
defaultEnabled: true,
isEngineModule: true,
canContainContent: true,
dependencies: ['engine-core'],
exports: {
components: ['ParticleSystemComponent'],
systems: ['ParticleUpdateSystem'],
loaders: ['ParticleLoader']
}
};
/**
* 完整的粒子插件(运行时 + 编辑器)
* Complete Particle Plugin (runtime + editor)
*/
export const ParticlePlugin: IPlugin = {
manifest,
runtimeModule: new ParticleRuntimeModule(),
editorModule: particleEditorModule
};
export default particleEditorModule;

View File

@@ -0,0 +1,535 @@
/**
* 曲线编辑器组件
* Curve Editor Component
*
* A visual editor for animation curves used in particle systems.
* 用于粒子系统的动画曲线可视化编辑器。
*/
import React, { useState, useCallback, useRef, useEffect, useLayoutEffect } from 'react';
import type { ScaleKey } from '@esengine/particle';
import { ScaleCurveType } from '@esengine/particle';
import { X, Plus } from 'lucide-react';
interface CurveEditorProps {
/** 曲线关键帧 | Curve keyframes */
keys: ScaleKey[];
/** 变化回调 | Change callback */
onChange: (keys: ScaleKey[]) => void;
/** 曲线类型 | Curve type */
curveType: ScaleCurveType;
/** 曲线类型变化回调 | Curve type change callback */
onCurveTypeChange?: (type: ScaleCurveType) => void;
/** Y 轴最小值 | Y-axis minimum */
minY?: number;
/** Y 轴最大值 | Y-axis maximum */
maxY?: number;
}
// 内边距 | Padding
const PADDING = { left: 28, right: 8, top: 8, bottom: 16 };
const POINT_RADIUS = 5;
const HIT_RADIUS = 10;
/**
* 曲线编辑器
* Curve Editor
*/
export function CurveEditor({
keys,
onChange,
curveType,
onCurveTypeChange,
minY = 0,
maxY = 2,
}: CurveEditorProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
// 监听容器大小变化 | Watch container size changes
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateSize = () => {
const rect = container.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
};
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, []);
// 获取绘图区域 | Get drawing area
const getDrawArea = useCallback(() => {
return {
x: PADDING.left,
y: PADDING.top,
width: canvasSize.width - PADDING.left - PADDING.right,
height: canvasSize.height - PADDING.top - PADDING.bottom,
};
}, [canvasSize]);
// 数据坐标转画布坐标 | Data to canvas coordinates
const dataToCanvas = useCallback((time: number, scale: number) => {
const area = getDrawArea();
return {
x: area.x + time * area.width,
y: area.y + area.height - ((scale - minY) / (maxY - minY)) * area.height,
};
}, [getDrawArea, minY, maxY]);
// 画布坐标转数据坐标 | Canvas to data coordinates
const canvasToData = useCallback((canvasX: number, canvasY: number) => {
const area = getDrawArea();
const time = Math.max(0, Math.min(1, (canvasX - area.x) / area.width));
const scale = maxY - ((canvasY - area.y) / area.height) * (maxY - minY);
return {
time,
scale: Math.max(minY, Math.min(maxY, scale)),
};
}, [getDrawArea, minY, maxY]);
// 获取鼠标在 canvas 上的坐标 | Get mouse position on canvas
const getMousePos = useCallback((e: React.MouseEvent | MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
};
}, []);
// 查找点击的关键帧 | Find clicked keyframe
const findKeyAtPos = useCallback((x: number, y: number): number => {
for (let i = 0; i < keys.length; i++) {
const pos = dataToCanvas(keys[i].time, keys[i].scale);
const dx = pos.x - x;
const dy = pos.y - y;
if (Math.sqrt(dx * dx + dy * dy) < HIT_RADIUS) {
return i;
}
}
return -1;
}, [keys, dataToCanvas]);
// 绘制曲线 | Draw curve
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || canvasSize.width === 0) return;
// 设置 canvas 实际像素大小 | Set canvas pixel size
const dpr = window.devicePixelRatio || 1;
canvas.width = canvasSize.width * dpr;
canvas.height = canvasSize.height * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
const area = getDrawArea();
// 清空画布 | Clear canvas
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
// 绘制绘图区域背景 | Draw area background
ctx.fillStyle = '#222';
ctx.fillRect(area.x, area.y, area.width, area.height);
// 绘制网格 | Draw grid
ctx.strokeStyle = '#2a2a2a';
ctx.lineWidth = 1;
// 垂直网格线 (0, 0.25, 0.5, 0.75, 1) | Vertical grid lines
for (let i = 0; i <= 4; i++) {
const x = Math.floor(area.x + (i / 4) * area.width) + 0.5;
ctx.beginPath();
ctx.moveTo(x, area.y);
ctx.lineTo(x, area.y + area.height);
ctx.stroke();
}
// 水平网格线 | Horizontal grid lines
const ySteps = 4;
for (let i = 0; i <= ySteps; i++) {
const y = Math.floor(area.y + (i / ySteps) * area.height) + 0.5;
ctx.beginPath();
ctx.moveTo(area.x, y);
ctx.lineTo(area.x + area.width, y);
ctx.stroke();
}
// 绘制 1.0 参考线(如果在范围内)| Draw 1.0 reference line
if (minY <= 1 && maxY >= 1) {
const onePos = dataToCanvas(0, 1);
ctx.strokeStyle = '#444';
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(area.x, onePos.y);
ctx.lineTo(area.x + area.width, onePos.y);
ctx.stroke();
ctx.setLineDash([]);
}
// 绘制 Y 轴标签 | Draw Y-axis labels
ctx.fillStyle = '#666';
ctx.font = '9px monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= ySteps; i++) {
const value = maxY - (i / ySteps) * (maxY - minY);
const y = area.y + (i / ySteps) * area.height;
ctx.fillText(value.toFixed(1), area.x - 4, y);
}
// 绘制 X 轴标签 | Draw X-axis labels
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('0', area.x, area.y + area.height + 2);
ctx.fillText('0.5', area.x + area.width / 2, area.y + area.height + 2);
ctx.fillText('1', area.x + area.width, area.y + area.height + 2);
// 绘制曲线 | Draw curve
if (keys.length > 0) {
const sortedKeys = [...keys].sort((a, b) => a.time - b.time);
ctx.strokeStyle = '#4a9eff';
ctx.lineWidth = 2;
ctx.beginPath();
// 采样曲线 | Sample curve
const samples = 100;
for (let i = 0; i <= samples; i++) {
const t = i / samples;
const value = evaluateCurve(sortedKeys, t, curveType);
const pos = dataToCanvas(t, value);
if (i === 0) {
ctx.moveTo(pos.x, pos.y);
} else {
ctx.lineTo(pos.x, pos.y);
}
}
ctx.stroke();
// 绘制关键帧点 | Draw keyframe points
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const pos = dataToCanvas(key.time, key.scale);
const isSelected = selectedIndex === i;
const isHovered = hoverIndex === i;
// 外圈 | Outer ring
ctx.beginPath();
ctx.arc(pos.x, pos.y, POINT_RADIUS + 2, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? '#4a9eff' : (isHovered ? '#666' : 'transparent');
ctx.fill();
// 内圈 | Inner circle
ctx.beginPath();
ctx.arc(pos.x, pos.y, POINT_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? '#fff' : '#4a9eff';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
}
}
}, [keys, curveType, minY, maxY, selectedIndex, hoverIndex, canvasSize, getDrawArea, dataToCanvas]);
// 处理鼠标移动(悬停效果)| Handle mouse move (hover effect)
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDragging) return;
const pos = getMousePos(e);
const index = findKeyAtPos(pos.x, pos.y);
setHoverIndex(index >= 0 ? index : null);
}, [isDragging, getMousePos, findKeyAtPos]);
// 处理鼠标离开 | Handle mouse leave
const handleMouseLeave = useCallback(() => {
if (!isDragging) {
setHoverIndex(null);
}
}, [isDragging]);
// 处理点击 | Handle click
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const pos = getMousePos(e);
const index = findKeyAtPos(pos.x, pos.y);
if (index >= 0) {
// 选中现有点 | Select existing point
setSelectedIndex(index);
setIsDragging(true);
} else {
// 检查是否在绘图区域内 | Check if in draw area
const area = getDrawArea();
if (pos.x >= area.x && pos.x <= area.x + area.width &&
pos.y >= area.y && pos.y <= area.y + area.height) {
// 添加新点 | Add new point
const data = canvasToData(pos.x, pos.y);
const newKey: ScaleKey = { time: data.time, scale: data.scale };
const newKeys = [...keys, newKey].sort((a, b) => a.time - b.time);
const newIndex = newKeys.findIndex(k => k.time === data.time && k.scale === data.scale);
onChange(newKeys);
setSelectedIndex(newIndex);
setIsDragging(true);
}
}
}, [getMousePos, findKeyAtPos, getDrawArea, canvasToData, keys, onChange]);
// 全局拖拽处理 | Global drag handling
useEffect(() => {
if (!isDragging || selectedIndex === null) return;
const handleGlobalMouseMove = (e: MouseEvent) => {
const pos = getMousePos(e);
const data = canvasToData(pos.x, pos.y);
// 更新选中点的位置 | Update selected point position
const newKeys = keys.map((key, i) => {
if (i === selectedIndex) {
return { time: data.time, scale: data.scale };
}
return key;
});
// 保持当前选中的点,不重新排序(拖拽时保持索引)
// Keep current selection, don't resort during drag
onChange(newKeys);
};
const handleGlobalMouseUp = () => {
// 拖拽结束后排序 | Sort after drag ends
const sortedKeys = [...keys].sort((a, b) => a.time - b.time);
if (selectedIndex !== null) {
const selectedKey = keys[selectedIndex];
const newIndex = sortedKeys.findIndex(
k => k.time === selectedKey.time && k.scale === selectedKey.scale
);
setSelectedIndex(newIndex >= 0 ? newIndex : null);
}
onChange(sortedKeys);
setIsDragging(false);
};
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => {
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, [isDragging, selectedIndex, keys, onChange, getMousePos, canvasToData]);
// 删除选中关键帧 | Delete selected keyframe
const handleDelete = useCallback(() => {
if (selectedIndex === null || keys.length <= 1) return;
const newKeys = keys.filter((_, i) => i !== selectedIndex);
onChange(newKeys);
setSelectedIndex(null);
}, [selectedIndex, keys, onChange]);
// 处理数值输入 | Handle value input
const handleValueChange = useCallback((field: 'time' | 'scale', inputValue: string) => {
if (selectedIndex === null) return;
const numValue = parseFloat(inputValue);
if (isNaN(numValue)) return;
const newKeys = [...keys];
if (field === 'time') {
newKeys[selectedIndex] = {
...newKeys[selectedIndex],
time: Math.max(0, Math.min(1, numValue))
};
} else {
newKeys[selectedIndex] = {
...newKeys[selectedIndex],
scale: Math.max(minY, Math.min(maxY, numValue))
};
}
const sortedKeys = newKeys.sort((a, b) => a.time - b.time);
const newIndex = sortedKeys.findIndex(
k => k.time === newKeys[selectedIndex].time && k.scale === newKeys[selectedIndex].scale
);
onChange(sortedKeys);
setSelectedIndex(newIndex >= 0 ? newIndex : null);
}, [selectedIndex, keys, onChange, minY, maxY]);
// 应用预设 | Apply preset
const applyPreset = useCallback((preset: ScaleKey[]) => {
onChange(preset);
setSelectedIndex(null);
}, [onChange]);
const selectedKey = selectedIndex !== null ? keys[selectedIndex] : null;
return (
<div className="curve-editor">
{/* 曲线类型选择 | Curve type selector */}
{onCurveTypeChange && (
<div className="curve-type-row">
<label>Curve</label>
<select
value={curveType}
onChange={e => onCurveTypeChange(e.target.value as ScaleCurveType)}
className="curve-type-select"
>
<option value={ScaleCurveType.Linear}>Linear</option>
<option value={ScaleCurveType.EaseIn}>Ease In</option>
<option value={ScaleCurveType.EaseOut}>Ease Out</option>
<option value={ScaleCurveType.EaseInOut}>Ease In Out</option>
</select>
</div>
)}
{/* 曲线画布 | Curve canvas */}
<div ref={containerRef} className="curve-canvas-container">
<canvas
ref={canvasRef}
className="curve-canvas"
style={{ cursor: isDragging ? 'grabbing' : (hoverIndex !== null ? 'grab' : 'crosshair') }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
</div>
{/* 编辑面板 | Edit panel */}
<div className="curve-edit-row">
{selectedKey ? (
<>
<label>T</label>
<input
type="number"
min={0}
max={1}
step={0.01}
value={selectedKey.time.toFixed(2)}
onChange={e => handleValueChange('time', e.target.value)}
className="curve-value-input"
/>
<label>V</label>
<input
type="number"
min={minY}
max={maxY}
step={0.1}
value={selectedKey.scale.toFixed(2)}
onChange={e => handleValueChange('scale', e.target.value)}
className="curve-value-input"
/>
<button
className="curve-delete-btn"
onClick={handleDelete}
disabled={keys.length <= 1}
title="Delete point"
>
<X size={10} />
</button>
</>
) : (
<span className="curve-hint">Click to add point</span>
)}
</div>
{/* 预设按钮 | Preset buttons */}
<div className="curve-presets">
<button
className="curve-preset-btn"
onClick={() => applyPreset([{ time: 0, scale: 1 }, { time: 1, scale: 1 }])}
title="Constant value"
>
</button>
<button
className="curve-preset-btn"
onClick={() => applyPreset([{ time: 0, scale: 0 }, { time: 1, scale: 1 }])}
title="Fade in"
>
</button>
<button
className="curve-preset-btn"
onClick={() => applyPreset([{ time: 0, scale: 1 }, { time: 1, scale: 0 }])}
title="Fade out"
>
</button>
<button
className="curve-preset-btn"
onClick={() => applyPreset([{ time: 0, scale: 0 }, { time: 0.5, scale: 1 }, { time: 1, scale: 0 }])}
title="Bell curve"
>
</button>
<button
className="curve-preset-btn"
onClick={() => applyPreset([{ time: 0, scale: 1 }, { time: 0.5, scale: 0 }, { time: 1, scale: 1 }])}
title="U curve"
>
</button>
</div>
</div>
);
}
/**
* 计算曲线值
* Evaluate curve value
*/
function evaluateCurve(keys: ScaleKey[], t: number, curveType: ScaleCurveType): number {
if (keys.length === 0) return 1;
if (keys.length === 1) return keys[0].scale;
// 查找相邻关键帧 | Find adjacent keyframes
let left = keys[0];
let right = keys[keys.length - 1];
for (let i = 0; i < keys.length - 1; i++) {
if (keys[i].time <= t && keys[i + 1].time >= t) {
left = keys[i];
right = keys[i + 1];
break;
}
}
if (t <= left.time) return left.scale;
if (t >= right.time) return right.scale;
// 计算插值因子 | Calculate interpolation factor
let factor = (t - left.time) / (right.time - left.time);
// 应用缓动函数 | Apply easing function
switch (curveType) {
case ScaleCurveType.EaseIn:
factor = factor * factor;
break;
case ScaleCurveType.EaseOut:
factor = 1 - (1 - factor) * (1 - factor);
break;
case ScaleCurveType.EaseInOut:
factor = factor < 0.5
? 2 * factor * factor
: 1 - 2 * (1 - factor) * (1 - factor);
break;
// Linear - no modification
}
return left.scale + (right.scale - left.scale) * factor;
}

View File

@@ -0,0 +1,259 @@
/**
* 渐变编辑器组件
* Gradient Editor Component
*
* A visual editor for color gradients used in particle systems.
* 用于粒子系统的颜色渐变可视化编辑器。
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import type { ColorKey } from '@esengine/particle';
import { X } from 'lucide-react';
interface GradientEditorProps {
/** 颜色关键帧 | Color keyframes */
colorKeys: ColorKey[];
/** 变化回调 | Change callback */
onChange: (keys: ColorKey[]) => void;
}
/**
* 渐变编辑器
* Gradient Editor
*/
export function GradientEditor({
colorKeys,
onChange,
}: GradientEditorProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
// 生成 CSS 渐变 | Generate CSS gradient
const gradientStyle = useCallback(() => {
if (colorKeys.length === 0) {
return 'linear-gradient(to right, #ffffff, #ffffff)';
}
if (colorKeys.length === 1) {
const c = colorKeys[0];
const color = `rgba(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}, ${c.a})`;
return `linear-gradient(to right, ${color}, ${color})`;
}
const sorted = [...colorKeys].sort((a, b) => a.time - b.time);
const stops = sorted.map(k => {
const color = `rgba(${Math.round(k.r * 255)}, ${Math.round(k.g * 255)}, ${Math.round(k.b * 255)}, ${k.a})`;
return `${color} ${k.time * 100}%`;
});
return `linear-gradient(to right, ${stops.join(', ')})`;
}, [colorKeys]);
// 处理点击添加关键帧 | Handle click to add keyframe
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!trackRef.current) return;
const rect = trackRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const width = rect.width;
const time = Math.max(0, Math.min(1, x / width));
// 检查是否点击了现有关键帧 | Check if clicking existing keyframe
const threshold = 10;
const clickedIndex = colorKeys.findIndex(k => {
const keyX = k.time * width;
return Math.abs(keyX - x) < threshold;
});
if (clickedIndex >= 0) {
setSelectedIndex(clickedIndex);
return;
}
// 添加新关键帧 | Add new keyframe
const interpolatedColor = interpolateColor(colorKeys, time);
const newKey: ColorKey = {
time,
...interpolatedColor
};
const newKeys = [...colorKeys, newKey].sort((a, b) => a.time - b.time);
onChange(newKeys);
setSelectedIndex(newKeys.findIndex(k => k.time === time));
};
// 处理拖拽开始 | Handle drag start
const handleStopMouseDown = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
setSelectedIndex(index);
setIsDragging(true);
};
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!trackRef.current || selectedIndex === null) return;
const rect = trackRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const width = rect.width;
const time = Math.max(0, Math.min(1, x / width));
const newKeys = [...colorKeys];
newKeys[selectedIndex] = { ...newKeys[selectedIndex], time };
onChange(newKeys.sort((a, b) => a.time - b.time));
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, selectedIndex, colorKeys, onChange]);
// 处理颜色变化 | Handle color change
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (selectedIndex === null) return;
const hex = e.target.value;
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const newKeys = [...colorKeys];
newKeys[selectedIndex] = { ...newKeys[selectedIndex], r, g, b };
onChange(newKeys);
};
// 处理 alpha 变化 | Handle alpha change
const handleAlphaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (selectedIndex === null) return;
const newKeys = [...colorKeys];
newKeys[selectedIndex] = { ...newKeys[selectedIndex], a: parseFloat(e.target.value) };
onChange(newKeys);
};
// 删除选中关键帧 | Delete selected keyframe
const handleDelete = () => {
if (selectedIndex === null || colorKeys.length <= 1) return;
const newKeys = colorKeys.filter((_, i) => i !== selectedIndex);
onChange(newKeys);
setSelectedIndex(null);
};
// 获取选中关键帧的十六进制颜色 | Get selected keyframe hex color
const selectedKey = selectedIndex !== null ? colorKeys[selectedIndex] : null;
const selectedHex = selectedKey
? `#${Math.round(selectedKey.r * 255).toString(16).padStart(2, '0')}${Math.round(selectedKey.g * 255).toString(16).padStart(2, '0')}${Math.round(selectedKey.b * 255).toString(16).padStart(2, '0')}`
: '#ffffff';
return (
<div className="gradient-editor">
{/* 渐变条 | Gradient bar */}
<div
ref={trackRef}
className="gradient-track"
style={{ background: gradientStyle() }}
onClick={handleTrackClick}
>
{/* 棋盘格背景 | Checkerboard background */}
<div className="gradient-track-checker" />
{/* 关键帧手柄 | Keyframe handles */}
{colorKeys.map((key, index) => (
<div
key={index}
className={`gradient-stop ${selectedIndex === index ? 'selected' : ''}`}
style={{
left: `${key.time * 100}%`,
backgroundColor: `rgba(${Math.round(key.r * 255)}, ${Math.round(key.g * 255)}, ${Math.round(key.b * 255)}, ${key.a})`,
}}
onMouseDown={e => handleStopMouseDown(e, index)}
/>
))}
</div>
{/* 编辑面板 | Edit panel */}
{selectedKey && (
<div className="gradient-color-row">
<label>Color</label>
<input
type="color"
value={selectedHex}
onChange={handleColorChange}
className="gradient-color-input"
/>
<label>Alpha</label>
<input
type="range"
min={0}
max={1}
step={0.01}
value={selectedKey.a}
onChange={handleAlphaChange}
className="gradient-alpha-slider"
/>
<span className="gradient-alpha-value">{selectedKey.a.toFixed(2)}</span>
<button
className="gradient-delete-btn"
onClick={handleDelete}
disabled={colorKeys.length <= 1}
title="Delete stop"
>
<X size={10} />
</button>
</div>
)}
</div>
);
}
/**
* 在渐变中插值颜色
* Interpolate color in gradient
*/
function interpolateColor(keys: ColorKey[], time: number): { r: number; g: number; b: number; a: number } {
if (keys.length === 0) {
return { r: 1, g: 1, b: 1, a: 1 };
}
if (keys.length === 1) {
return { r: keys[0].r, g: keys[0].g, b: keys[0].b, a: keys[0].a };
}
const sorted = [...keys].sort((a, b) => a.time - b.time);
// 查找相邻关键帧 | Find adjacent keyframes
let left = sorted[0];
let right = sorted[sorted.length - 1];
for (let i = 0; i < sorted.length - 1; i++) {
if (sorted[i].time <= time && sorted[i + 1].time >= time) {
left = sorted[i];
right = sorted[i + 1];
break;
}
}
if (time <= left.time) {
return { r: left.r, g: left.g, b: left.b, a: left.a };
}
if (time >= right.time) {
return { r: right.r, g: right.g, b: right.b, a: right.a };
}
const t = (time - left.time) / (right.time - left.time);
return {
r: left.r + (right.r - left.r) * t,
g: left.g + (right.g - left.g) * t,
b: left.b + (right.b - left.b) * t,
a: left.a + (right.a - left.a) * t,
};
}

View File

@@ -0,0 +1,139 @@
/**
* 纹理选择器组件
* Texture Picker Component
*
* A component for selecting particle textures.
* 用于选择粒子纹理的组件。
*/
import React, { useState, useCallback, useRef } from 'react';
import { Image, ChevronDown, FolderOpen, X } from 'lucide-react';
interface TexturePickerProps {
/** 当前纹理路径 | Current texture path */
value: string | null;
/** 变化回调 | Change callback */
onChange: (path: string | null) => void;
/** 打开文件选择对话框 | Open file selection dialog */
onBrowse?: () => Promise<string | null>;
/** 纹理预览 URL | Texture preview URL */
previewUrl?: string | null;
}
/**
* 纹理选择器
* Texture Picker
*/
export function TexturePicker({
value,
onChange,
onBrowse,
previewUrl,
}: TexturePickerProps) {
const [isHovering, setIsHovering] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// 处理浏览按钮点击 | Handle browse button click
const handleBrowse = useCallback(async () => {
if (onBrowse) {
const result = await onBrowse();
if (result) {
onChange(result);
}
}
}, [onBrowse, onChange]);
// 处理清除 | Handle clear
const handleClear = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onChange(null);
}, [onChange]);
// 处理拖放 | Handle drag & drop
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
// 这里理想情况下需要将文件转换为项目内的路径
// In ideal case, we'd convert file to project path
// For now, just use the file name
onChange(file.name);
}
}
}, [onChange]);
// 获取显示名称 | Get display name
const displayName = value ? value.split(/[\\/]/).pop() || value : null;
return (
<div className="texture-picker">
<div className="asset-field-row">
<span className="asset-field-label">Texture</span>
<div className="asset-field-content">
{/* 缩略图 | Thumbnail */}
<div
className={`asset-field-thumbnail ${isDragOver ? 'dragging' : ''}`}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{previewUrl ? (
<img src={previewUrl} alt="Texture preview" />
) : (
<Image size={20} className="asset-field-thumbnail-icon" />
)}
</div>
<div className="asset-field-right">
{/* 下拉选择器 | Dropdown selector */}
<div
className={`asset-field-dropdown ${value ? 'has-value' : ''}`}
onClick={handleBrowse}
>
<span className="asset-field-value">
{displayName || 'None'}
</span>
<ChevronDown size={12} className="asset-field-dropdown-arrow" />
</div>
{/* 操作按钮 | Action buttons */}
<div className="asset-field-actions">
<button
className="asset-field-btn"
onClick={handleBrowse}
title="Browse..."
>
<FolderOpen size={12} />
</button>
{value && (
<button
className="asset-field-btn asset-field-btn--clear"
onClick={handleClear}
title="Clear"
>
<X size={12} />
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
/**
* 粒子编辑器组件
* Particle editor components
*/
export { GradientEditor } from './GradientEditor';
export { CurveEditor } from './CurveEditor';
export { TexturePicker } from './TexturePicker';

View File

@@ -0,0 +1,25 @@
/**
* 粒子编辑器模块入口
* Particle Editor Module Entry
*/
// Module
export {
ParticleEditorModule,
particleEditorModule,
ParticlePlugin,
particleEditorModule as default
} from './ParticleEditorModule';
// Providers
export { ParticleInspectorProvider } from './providers/ParticleInspectorProvider';
// Panels
export { ParticleEditorPanel } from './panels/ParticleEditorPanel';
// Stores
export { useParticleEditorStore } from './stores/ParticleEditorStore';
export type { ParticleEditorState } from './stores/ParticleEditorStore';
// Components
export { GradientEditor, CurveEditor } from './components';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
/**
* 粒子系统 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>
);
}

View File

@@ -0,0 +1,121 @@
/**
* 粒子编辑器状态管理
* Particle editor state management
*/
import { create } from 'zustand';
import type { IParticleAsset } from '@esengine/particle';
import { createDefaultParticleAsset } from '@esengine/particle';
/**
* 粒子编辑器状态
* Particle editor state
*/
export interface ParticleEditorState {
/** 当前编辑的文件路径 | 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 previewing */
isPlaying: boolean;
/** 选中的预设名称 | Selected preset name */
selectedPreset: string | null;
// 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;
}
/**
* 粒子编辑器 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({
particleData: data,
isDirty: false,
}),
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,
isPlaying: false,
selectedPreset: null,
}),
createNew: (name = 'New Particle') => set({
particleData: createDefaultParticleAsset(name),
filePath: null,
isDirty: true,
isPlaying: false,
selectedPreset: null,
}),
}));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"jsx": "react-jsx",
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsup';
import { editorOnlyPreset } from '../build-config/src/presets/plugin-tsup';
export default defineConfig({
...editorOnlyPreset(),
tsconfig: 'tsconfig.build.json'
});