diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index 5e79b7ea..b9d765a6 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -32,6 +32,8 @@ "@esengine/engine-core": "workspace:*", "@esengine/material-editor": "workspace:*", "@esengine/material-system": "workspace:*", + "@esengine/particle": "workspace:*", + "@esengine/particle-editor": "workspace:*", "@esengine/physics-rapier2d": "workspace:*", "@esengine/physics-rapier2d-editor": "workspace:*", "@esengine/runtime-core": "workspace:*", diff --git a/packages/editor-app/src/app/managers/PluginInstaller.ts b/packages/editor-app/src/app/managers/PluginInstaller.ts index c5361a9d..92a50db0 100644 --- a/packages/editor-app/src/app/managers/PluginInstaller.ts +++ b/packages/editor-app/src/app/managers/PluginInstaller.ts @@ -19,6 +19,7 @@ import { AssetMetaPlugin } from '../../plugins/builtin/AssetMetaPlugin'; // 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor) import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor'; +import { ParticlePlugin } from '@esengine/particle-editor'; import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor'; import { TilemapPlugin } from '@esengine/tilemap-editor'; import { UIPlugin } from '@esengine/ui-editor'; @@ -60,6 +61,7 @@ export class PluginInstaller { { name: 'TilemapPlugin', plugin: TilemapPlugin }, { name: 'UIPlugin', plugin: UIPlugin }, { name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin }, + { name: 'ParticlePlugin', plugin: ParticlePlugin }, { name: 'Physics2DPlugin', plugin: Physics2DPlugin }, { name: 'BlueprintPlugin', plugin: BlueprintPlugin }, { name: 'MaterialPlugin', plugin: MaterialPlugin }, diff --git a/packages/particle-editor/package.json b/packages/particle-editor/package.json new file mode 100644 index 00000000..e3dc3f9f --- /dev/null +++ b/packages/particle-editor/package.json @@ -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" +} diff --git a/packages/particle-editor/src/ParticleEditorModule.ts b/packages/particle-editor/src/ParticleEditorModule.ts new file mode 100644 index 00000000..9ecb1114 --- /dev/null +++ b/packages/particle-editor/src/ParticleEditorModule.ts @@ -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 { + // 注册检视器提供者 | 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 { + // 清理 | 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; diff --git a/packages/particle-editor/src/components/CurveEditor.tsx b/packages/particle-editor/src/components/CurveEditor.tsx new file mode 100644 index 00000000..dbe15075 --- /dev/null +++ b/packages/particle-editor/src/components/CurveEditor.tsx @@ -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(null); + const [isDragging, setIsDragging] = useState(false); + const [hoverIndex, setHoverIndex] = useState(null); + const canvasRef = useRef(null); + const containerRef = useRef(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) => { + 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) => { + 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 ( +
+ {/* 曲线类型选择 | Curve type selector */} + {onCurveTypeChange && ( +
+ + +
+ )} + + {/* 曲线画布 | Curve canvas */} +
+ +
+ + {/* 编辑面板 | Edit panel */} +
+ {selectedKey ? ( + <> + + handleValueChange('time', e.target.value)} + className="curve-value-input" + /> + + handleValueChange('scale', e.target.value)} + className="curve-value-input" + /> + + + ) : ( + Click to add point + )} +
+ + {/* 预设按钮 | Preset buttons */} +
+ + + + + +
+
+ ); +} + +/** + * 计算曲线值 + * 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; +} diff --git a/packages/particle-editor/src/components/GradientEditor.tsx b/packages/particle-editor/src/components/GradientEditor.tsx new file mode 100644 index 00000000..9c40aa9b --- /dev/null +++ b/packages/particle-editor/src/components/GradientEditor.tsx @@ -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(null); + const [isDragging, setIsDragging] = useState(false); + const trackRef = useRef(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) => { + 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) => { + 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) => { + 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 ( +
+ {/* 渐变条 | Gradient bar */} +
+ {/* 棋盘格背景 | Checkerboard background */} +
+ + {/* 关键帧手柄 | Keyframe handles */} + {colorKeys.map((key, index) => ( +
handleStopMouseDown(e, index)} + /> + ))} +
+ + {/* 编辑面板 | Edit panel */} + {selectedKey && ( +
+ + + + + {selectedKey.a.toFixed(2)} + +
+ )} +
+ ); +} + +/** + * 在渐变中插值颜色 + * 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, + }; +} diff --git a/packages/particle-editor/src/components/TexturePicker.tsx b/packages/particle-editor/src/components/TexturePicker.tsx new file mode 100644 index 00000000..12007463 --- /dev/null +++ b/packages/particle-editor/src/components/TexturePicker.tsx @@ -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; + /** 纹理预览 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(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 ( +
+
+ Texture +
+ {/* 缩略图 | Thumbnail */} +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + {previewUrl ? ( + Texture preview + ) : ( + + )} +
+ +
+ {/* 下拉选择器 | Dropdown selector */} +
+ + {displayName || 'None'} + + +
+ + {/* 操作按钮 | Action buttons */} +
+ + {value && ( + + )} +
+
+
+
+
+ ); +} diff --git a/packages/particle-editor/src/components/index.ts b/packages/particle-editor/src/components/index.ts new file mode 100644 index 00000000..71a7a250 --- /dev/null +++ b/packages/particle-editor/src/components/index.ts @@ -0,0 +1,8 @@ +/** + * 粒子编辑器组件 + * Particle editor components + */ + +export { GradientEditor } from './GradientEditor'; +export { CurveEditor } from './CurveEditor'; +export { TexturePicker } from './TexturePicker'; diff --git a/packages/particle-editor/src/index.ts b/packages/particle-editor/src/index.ts new file mode 100644 index 00000000..7d5bdb4d --- /dev/null +++ b/packages/particle-editor/src/index.ts @@ -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'; diff --git a/packages/particle-editor/src/panels/ParticleEditorPanel.tsx b/packages/particle-editor/src/panels/ParticleEditorPanel.tsx new file mode 100644 index 00000000..b1c7fa44 --- /dev/null +++ b/packages/particle-editor/src/panels/ParticleEditorPanel.tsx @@ -0,0 +1,2296 @@ +/** + * 粒子编辑器面板 + * Particle Editor Panel + * + * Main editor panel for editing .particle files with live preview. + * 用于编辑 .particle 文件的主编辑器面板,带实时预览。 + */ + +import React, { useEffect, useCallback, useRef, useState, useMemo } from 'react'; +import { + Play, Pause, RotateCcw, Save, Sparkles, FolderOpen, + ChevronRight, ChevronDown, Plus, X, Image, + Maximize2, Minimize2, MousePointer2, Target, Zap +} from 'lucide-react'; +import { Core } from '@esengine/ecs-framework'; +import { MessageHub, IFileSystemService, IDialogService } from '@esengine/editor-core'; +import type { IFileSystem, IDialog } from '@esengine/editor-core'; +import { + EmissionShape, + ParticleBlendMode, + SimulationSpace, + AllPresets, + getPresetByName, + type IParticleAsset, + type IParticleModuleConfig, + type ParticlePreset, + createDefaultParticleAsset, + valueNoise2D, + ScaleCurveType, + BoundaryType, + CollisionBehavior, + ForceFieldType, + type ColorKey, + type ScaleKey, + type ForceField, +} from '@esengine/particle'; +import { useParticleEditorStore } from '../stores/ParticleEditorStore'; +import { GradientEditor } from '../components/GradientEditor'; +import { CurveEditor } from '../components/CurveEditor'; +import { TexturePicker } from '../components/TexturePicker'; + +// ============= Types ============= + +/** + * 预览粒子数据结构 + * Preview particle data structure + */ +interface PreviewParticle { + /** 位置X | Position X */ + x: number; + /** 位置Y | Position Y */ + y: number; + /** 速度X | Velocity X */ + vx: number; + /** 速度Y | Velocity Y */ + vy: number; + /** 加速度X | Acceleration X */ + ax: number; + /** 加速度Y | Acceleration Y */ + ay: number; + /** 旋转角度 | Rotation angle */ + rotation: number; + /** 角速度 | Angular velocity */ + angularVelocity: number; + /** 缩放X | Scale X */ + scaleX: number; + /** 缩放Y | Scale Y */ + scaleY: number; + /** 初始缩放X | Start scale X */ + startScaleX: number; + /** 初始缩放Y | Start scale Y */ + startScaleY: number; + /** 颜色R | Color R */ + r: number; + /** 颜色G | Color G */ + g: number; + /** 颜色B | Color B */ + b: number; + /** 透明度 | Alpha */ + alpha: number; + /** 初始颜色R | Start color R */ + startR: number; + /** 初始颜色G | Start color G */ + startG: number; + /** 初始颜色B | Start color B */ + startB: number; + /** 初始透明度 | Start alpha */ + startAlpha: number; + /** 当前年龄 | Current age */ + age: number; + /** 生命时间 | Lifetime */ + lifetime: number; + /** 初始世界坐标X | Initial world X */ + startWorldX: number; + /** 初始世界坐标Y | Initial world Y */ + startWorldY: number; +} + + +/** + * 爆发配置 + * Burst configuration + */ +interface BurstConfig { + time: number; + count: number; + cycles: number; + interval: number; +} + +// ============= Utility Functions ============= + +/** 线性插值 | Linear interpolation */ +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** 限制值范围 | Clamp value to range */ +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** 随机范围值 | Random value in range */ +function randomRange(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +/** 评估缩放曲线 | Evaluate scale curve */ +function evaluateScaleCurve(t: number, curveType: string): number { + switch (curveType) { + case 'easeIn': + return t * t; + case 'easeOut': + return 1 - (1 - t) * (1 - t); + case 'easeInOut': + return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + case 'linear': + default: + return t; + } +} + +/** 评估颜色渐变 | Evaluate color gradient */ +function evaluateColorGradient(gradient: ColorKey[], normalizedAge: number): ColorKey { + if (gradient.length === 0) { + return { time: 0, r: 1, g: 1, b: 1, a: 1 }; + } + if (gradient.length === 1) { + return gradient[0]; + } + + let startKey = gradient[0]; + let endKey = gradient[gradient.length - 1]; + + for (let i = 0; i < gradient.length - 1; i++) { + if (normalizedAge >= gradient[i].time && normalizedAge <= gradient[i + 1].time) { + startKey = gradient[i]; + endKey = gradient[i + 1]; + break; + } + } + + const range = endKey.time - startKey.time; + const t = range > 0 ? (normalizedAge - startKey.time) / range : 0; + + return { + time: normalizedAge, + r: lerp(startKey.r, endKey.r, t), + g: lerp(startKey.g, endKey.g, t), + b: lerp(startKey.b, endKey.b, t), + a: lerp(startKey.a, endKey.a, t) + }; +} + +// ============= Preview Hook ============= + +interface PreviewOptions { + followMouse: boolean; + mousePosition: { x: number; y: number }; + triggerBurst: number; // 触发爆发的计数器 | Burst trigger counter +} + +/** + * 完整的粒子预览渲染器 + * Complete particle preview renderer + */ +function useParticlePreview( + canvasRef: React.RefObject, + data: IParticleAsset | null, + isPlaying: boolean, + options: PreviewOptions +) { + const particlesRef = useRef([]); + const emissionAccumulatorRef = useRef(0); + const animationFrameRef = useRef(0); + const lastTimeRef = useRef(0); + const noiseTimeRef = useRef(0); + const elapsedTimeRef = useRef(0); + const burstFiredRef = useRef>(new Set()); + const lastTriggerBurstRef = useRef(0); + + const { followMouse, mousePosition, triggerBurst } = options; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !data) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 调整画布大小 | Adjust canvas size + canvas.width = canvas.clientWidth; + canvas.height = canvas.clientHeight; + + // 发射器位置 - 跟随鼠标或居中 | Emitter position - follow mouse or center + const centerX = followMouse ? mousePosition.x : canvas.width / 2; + const centerY = followMouse ? mousePosition.y : canvas.height / 2; + + // 解析模块配置 | Parse module configurations + const modules = data.modules || []; + const colorModule = modules.find(m => m.type === 'ColorOverLifetime'); + const sizeModule = modules.find(m => m.type === 'SizeOverLifetime'); + const noiseModule = modules.find(m => m.type === 'Noise'); + const rotationModule = modules.find(m => m.type === 'RotationOverLifetime'); + const velocityModule = modules.find(m => m.type === 'VelocityOverLifetime'); + const collisionModule = modules.find(m => m.type === 'Collision'); + const forceFieldModule = modules.find(m => m.type === 'ForceField'); + + // 颜色渐变 | Color gradient + const colorGradient: ColorKey[] = colorModule?.enabled && colorModule.params?.gradient + ? colorModule.params.gradient as ColorKey[] + : [ + { time: 0, r: 1, g: 1, b: 1, a: 1 }, + { time: 1, r: 1, g: 1, b: 1, a: data.endAlpha } + ]; + + // 缩放曲线 | Scale curve + const scaleCurveType: string = sizeModule?.enabled && sizeModule.params?.curveType + ? sizeModule.params.curveType as string + : 'linear'; + const scaleStartMultiplier: number = (sizeModule?.params?.startMultiplier as number) ?? 1; + const scaleEndMultiplier: number = (sizeModule?.params?.endMultiplier as number) ?? data.endScale; + + // 噪声参数 | Noise parameters + const noiseEnabled = noiseModule?.enabled ?? false; + const noisePositionAmount: number = (noiseModule?.params?.positionAmount as number) ?? 0; + const noiseVelocityAmount: number = (noiseModule?.params?.velocityAmount as number) ?? 0; + const noiseRotationAmount: number = (noiseModule?.params?.rotationAmount as number) ?? 0; + const noiseFrequency: number = (noiseModule?.params?.frequency as number) ?? 1; + const noiseScrollSpeed: number = (noiseModule?.params?.scrollSpeed as number) ?? 1; + + // 旋转参数 | Rotation parameters + const rotationEnabled = rotationModule?.enabled ?? false; + const angularVelocityMultiplierStart: number = (rotationModule?.params?.angularVelocityMultiplierStart as number) ?? 1; + const angularVelocityMultiplierEnd: number = (rotationModule?.params?.angularVelocityMultiplierEnd as number) ?? 1; + const additionalRotation: number = (rotationModule?.params?.additionalRotation as number) ?? 0; + + // 速度参数 | Velocity parameters + const velocityEnabled = velocityModule?.enabled ?? false; + const linearDrag: number = (velocityModule?.params?.linearDrag as number) ?? 0; + const speedMultiplierStart: number = (velocityModule?.params?.speedMultiplierStart as number) ?? 1; + const speedMultiplierEnd: number = (velocityModule?.params?.speedMultiplierEnd as number) ?? 1; + const orbitalVelocity: number = (velocityModule?.params?.orbitalVelocity as number) ?? 0; + const radialVelocity: number = (velocityModule?.params?.radialVelocity as number) ?? 0; + + // 碰撞参数 | Collision parameters + const collisionEnabled = collisionModule?.enabled ?? false; + const collisionBoundaryType: string = (collisionModule?.params?.boundaryType as string) ?? 'rectangle'; + const collisionBehavior: string = (collisionModule?.params?.behavior as string) ?? 'kill'; + const collisionLeft: number = (collisionModule?.params?.left as number) ?? -200; + const collisionRight: number = (collisionModule?.params?.right as number) ?? 200; + const collisionTop: number = (collisionModule?.params?.top as number) ?? -200; + const collisionBottom: number = (collisionModule?.params?.bottom as number) ?? 200; + const collisionRadius: number = (collisionModule?.params?.radius as number) ?? 200; + const collisionBounceFactor: number = (collisionModule?.params?.bounceFactor as number) ?? 0.8; + const collisionLifeLoss: number = (collisionModule?.params?.lifeLossOnBounce as number) ?? 0; + const collisionMinVelocity: number = (collisionModule?.params?.minVelocityThreshold as number) ?? 5; + + // 力场参数 | Force field parameters + const forceFieldEnabled = forceFieldModule?.enabled ?? false; + const forceFieldType: string = (forceFieldModule?.params?.type as string) ?? 'wind'; + const forceFieldStrength: number = (forceFieldModule?.params?.strength as number) ?? 100; + const forceFieldDirX: number = (forceFieldModule?.params?.directionX as number) ?? 1; + const forceFieldDirY: number = (forceFieldModule?.params?.directionY as number) ?? 0; + const forceFieldPosX: number = (forceFieldModule?.params?.positionX as number) ?? 0; + const forceFieldPosY: number = (forceFieldModule?.params?.positionY as number) ?? 0; + const forceFieldRadius: number = (forceFieldModule?.params?.radius as number) ?? 100; + const forceFieldFalloff: string = (forceFieldModule?.params?.falloff as string) ?? 'linear'; + const forceFieldCenterX: number = (forceFieldModule?.params?.centerX as number) ?? 0; + const forceFieldCenterY: number = (forceFieldModule?.params?.centerY as number) ?? 0; + const forceFieldInward: number = (forceFieldModule?.params?.inwardStrength as number) ?? 0; + const forceFieldFrequency: number = (forceFieldModule?.params?.frequency as number) ?? 1; + const forceFieldAmplitude: number = (forceFieldModule?.params?.amplitude as number) ?? 50; + + // 爆发配置 | Burst configuration + const bursts: BurstConfig[] = (data as any).bursts || []; + + /** + * 获取发射形状偏移 + * Get emission shape offset + */ + const getShapeOffset = (): [number, number] => { + const shape = data.emissionShape; + const radius = data.shapeRadius || 50; + const width = data.shapeWidth || 100; + const height = data.shapeHeight || 100; + const coneAngle = (data.shapeAngle || 30) * Math.PI / 180; + const direction = (data.direction - 90) * Math.PI / 180; + + switch (shape) { + case EmissionShape.Point: + return [0, 0]; + + case EmissionShape.Circle: { + const angle = Math.random() * Math.PI * 2; + const r = Math.random() * radius; + return [Math.cos(angle) * r, Math.sin(angle) * r]; + } + + case EmissionShape.Ring: { + const angle = Math.random() * Math.PI * 2; + return [Math.cos(angle) * radius, Math.sin(angle) * radius]; + } + + case EmissionShape.Rectangle: { + const x = randomRange(-width / 2, width / 2); + const y = randomRange(-height / 2, height / 2); + return [x, y]; + } + + case EmissionShape.Edge: { + const perimeter = 2 * (width + height); + const t = Math.random() * perimeter; + + if (t < width) { + return [t - width / 2, height / 2]; + } else if (t < width + height) { + return [width / 2, height / 2 - (t - width)]; + } else if (t < 2 * width + height) { + return [width / 2 - (t - width - height), -height / 2]; + } else { + return [-width / 2, -height / 2 + (t - 2 * width - height)]; + } + } + + case EmissionShape.Line: { + const t = Math.random() - 0.5; + const cos = Math.cos(direction + Math.PI / 2); + const sin = Math.sin(direction + Math.PI / 2); + return [cos * width * t, sin * width * t]; + } + + case EmissionShape.Cone: { + const angle = direction + randomRange(-coneAngle / 2, coneAngle / 2); + const r = Math.random() * radius; + return [Math.cos(angle) * r, Math.sin(angle) * r]; + } + + default: + return [0, 0]; + } + }; + + /** + * 发射新粒子 + * Emit new particle + */ + const emitParticle = () => { + const lifetime = randomRange(data.lifetimeMin, data.lifetimeMax); + const speed = randomRange(data.speedMin, data.speedMax); + const scale = randomRange(data.scaleMin, data.scaleMax); + + const baseAngle = (data.direction - 90) * Math.PI / 180; + const spreadAngle = randomRange(-0.5, 0.5) * data.directionSpread * Math.PI / 180; + const angle = baseAngle + spreadAngle; + + const [ox, oy] = getShapeOffset(); + + const startRotation = randomRange(0, Math.PI * 2); + const angularVelocity = randomRange(-1, 1); + + const colorVariance = 0.1; + const startColor = data.startColor || { r: 1, g: 1, b: 1, a: 1 }; + + const worldX = centerX + ox; + const worldY = centerY + oy; + + const p: PreviewParticle = { + x: worldX, + y: worldY, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + ax: data.gravityX || 0, + ay: data.gravityY || 0, + rotation: startRotation, + angularVelocity: angularVelocity, + scaleX: scale, + scaleY: scale, + startScaleX: scale, + startScaleY: scale, + r: clamp(startColor.r + randomRange(-colorVariance, colorVariance), 0, 1), + g: clamp(startColor.g + randomRange(-colorVariance, colorVariance), 0, 1), + b: clamp(startColor.b + randomRange(-colorVariance, colorVariance), 0, 1), + alpha: data.startAlpha, + startR: startColor.r, + startG: startColor.g, + startB: startColor.b, + startAlpha: data.startAlpha, + age: 0, + lifetime: lifetime, + startWorldX: worldX, + startWorldY: worldY + }; + + particlesRef.current.push(p); + }; + + /** + * 更新循环 + * Update loop + */ + const update = (time: number) => { + const deltaTime = lastTimeRef.current ? (time - lastTimeRef.current) / 1000 : 0.016; + lastTimeRef.current = time; + + const dt = isPlaying ? deltaTime * data.playbackSpeed : 0; + noiseTimeRef.current += dt * noiseScrollSpeed; + + // 手动触发爆发(点击触发)| Manual burst trigger (click to emit) + if (triggerBurst !== lastTriggerBurstRef.current) { + lastTriggerBurstRef.current = triggerBurst; + // 触发所有 burst 配置 | Trigger all burst configs + if (bursts.length > 0) { + for (const burst of bursts) { + for (let j = 0; j < burst.count && particlesRef.current.length < data.maxParticles; j++) { + emitParticle(); + } + } + } else { + // 如果没有 burst 配置,发射一些默认粒子 | If no burst config, emit some default particles + const defaultCount = Math.min(data.maxParticles, 50); + for (let j = 0; j < defaultCount && particlesRef.current.length < data.maxParticles; j++) { + emitParticle(); + } + } + } + + if (isPlaying) { + elapsedTimeRef.current += dt; + + // 持续发射 | Continuous emission + emissionAccumulatorRef.current += data.emissionRate * dt; + while (emissionAccumulatorRef.current >= 1 && particlesRef.current.length < data.maxParticles) { + emitParticle(); + emissionAccumulatorRef.current -= 1; + } + + // 爆发发射 | Burst emission + for (let i = 0; i < bursts.length; i++) { + const burst = bursts[i]; + if (!burstFiredRef.current.has(i) && elapsedTimeRef.current >= burst.time) { + for (let j = 0; j < burst.count && particlesRef.current.length < data.maxParticles; j++) { + emitParticle(); + } + burstFiredRef.current.add(i); + } + } + + // 更新粒子 | Update particles + particlesRef.current = particlesRef.current.filter(p => { + p.age += dt; + if (p.age >= p.lifetime) return false; + + const normalizedAge = p.age / p.lifetime; + + // VelocityOverLifetime 模块 | Velocity module + if (velocityEnabled) { + // 阻力 | Drag + if (linearDrag > 0) { + const dragFactor = 1 - linearDrag * dt; + p.vx *= dragFactor; + p.vy *= dragFactor; + } + + // 轨道速度 | Orbital velocity + if (orbitalVelocity !== 0) { + const dx = p.x - centerX; + const dy = p.y - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 0.001) { + const currentAngle = Math.atan2(dy, dx); + const newAngle = currentAngle + orbitalVelocity * dt; + p.x = centerX + Math.cos(newAngle) * dist; + p.y = centerY + Math.sin(newAngle) * dist; + } + } + + // 径向速度 | Radial velocity + if (radialVelocity !== 0) { + const dx = p.x - centerX; + const dy = p.y - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 0.001) { + const nx = dx / dist; + const ny = dy / dist; + p.vx += nx * radialVelocity * dt; + p.vy += ny * radialVelocity * dt; + } + } + } + + // 物理更新 | Physics update + p.vx += p.ax * dt; + p.vy += p.ay * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + + // RotationOverLifetime 模块 | Rotation module + if (rotationEnabled) { + const multiplier = lerp(angularVelocityMultiplierStart, angularVelocityMultiplierEnd, normalizedAge); + p.rotation += p.angularVelocity * multiplier * dt; + p.rotation += additionalRotation * dt; + } else { + p.rotation += p.angularVelocity * dt; + } + + // 颜色渐变 | Color gradient + const color = evaluateColorGradient(colorGradient, normalizedAge); + p.r = p.startR * color.r; + p.g = p.startG * color.g; + p.b = p.startB * color.b; + p.alpha = p.startAlpha * color.a; + + // 缩放曲线 | Scale curve + const scaleT = evaluateScaleCurve(normalizedAge, scaleCurveType); + const scaleMult = lerp(scaleStartMultiplier, scaleEndMultiplier, scaleT); + p.scaleX = p.startScaleX * scaleMult; + p.scaleY = p.startScaleY * scaleMult; + + // 噪声模块 | Noise module + if (noiseEnabled) { + const noiseX = valueNoise2D(p.x * noiseFrequency + noiseTimeRef.current, p.y * noiseFrequency); + const noiseY = valueNoise2D(p.x * noiseFrequency, p.y * noiseFrequency + noiseTimeRef.current); + + if (noisePositionAmount !== 0) { + p.x += noiseX * noisePositionAmount * dt; + p.y += noiseY * noisePositionAmount * dt; + } + if (noiseVelocityAmount !== 0) { + p.vx += noiseX * noiseVelocityAmount * dt; + p.vy += noiseY * noiseVelocityAmount * dt; + } + if (noiseRotationAmount !== 0) { + p.rotation += noiseX * noiseRotationAmount * dt; + } + } + + // 力场模块 | Force field module + if (forceFieldEnabled) { + switch (forceFieldType) { + case 'wind': { + // 风力 | Wind force + const dirLen = Math.sqrt(forceFieldDirX * forceFieldDirX + forceFieldDirY * forceFieldDirY); + if (dirLen > 0.001) { + p.vx += (forceFieldDirX / dirLen) * forceFieldStrength * dt; + p.vy += (forceFieldDirY / dirLen) * forceFieldStrength * dt; + } + break; + } + case 'point': { + // 吸引/排斥力 | Attraction/repulsion + const fieldX = centerX + forceFieldPosX; + const fieldY = centerY + forceFieldPosY; + const dx = fieldX - p.x; + const dy = fieldY - p.y; + const distSq = dx * dx + dy * dy; + const dist = Math.sqrt(distSq); + if (dist > 0.001 && dist < forceFieldRadius) { + let falloffFactor = 1; + if (forceFieldFalloff === 'linear') { + falloffFactor = 1 - dist / forceFieldRadius; + } else if (forceFieldFalloff === 'quadratic') { + falloffFactor = 1 - (distSq / (forceFieldRadius * forceFieldRadius)); + } + const force = forceFieldStrength * falloffFactor * dt; + p.vx += (dx / dist) * force; + p.vy += (dy / dist) * force; + } + break; + } + case 'vortex': { + // 漩涡力 | Vortex force + const vcx = centerX + forceFieldCenterX; + const vcy = centerY + forceFieldCenterY; + const vdx = p.x - vcx; + const vdy = p.y - vcy; + const vdist = Math.sqrt(vdx * vdx + vdy * vdy); + if (vdist > 0.001) { + // 切向力 | Tangential force + const tangentX = -vdy / vdist; + const tangentY = vdx / vdist; + p.vx += tangentX * forceFieldStrength * dt; + p.vy += tangentY * forceFieldStrength * dt; + // 向心力 | Centripetal force + if (forceFieldInward !== 0) { + p.vx -= (vdx / vdist) * forceFieldInward * dt; + p.vy -= (vdy / vdist) * forceFieldInward * dt; + } + } + break; + } + case 'turbulence': { + // 湍流 | Turbulence + const turbNoiseX = Math.sin(p.x * forceFieldFrequency * 0.01 + noiseTimeRef.current * forceFieldFrequency) * + Math.cos(p.y * forceFieldFrequency * 0.013 + noiseTimeRef.current * forceFieldFrequency * 0.7); + const turbNoiseY = Math.cos(p.x * forceFieldFrequency * 0.011 + noiseTimeRef.current * forceFieldFrequency * 0.8) * + Math.sin(p.y * forceFieldFrequency * 0.01 + noiseTimeRef.current * forceFieldFrequency); + p.vx += turbNoiseX * forceFieldAmplitude * dt; + p.vy += turbNoiseY * forceFieldAmplitude * dt; + break; + } + } + } + + // 碰撞模块 | Collision module + if (collisionEnabled && collisionBoundaryType !== 'none') { + const relX = p.x - centerX; + const relY = p.y - centerY; + let collision = false; + let normalX = 0; + let normalY = 0; + + if (collisionBoundaryType === 'rectangle') { + // 矩形边界 | Rectangle boundary + if (relX < collisionLeft) { + collision = true; + normalX = 1; + if (collisionBehavior === 'wrap') { + p.x = centerX + collisionRight; + } else if (collisionBehavior === 'bounce') { + p.x = centerX + collisionLeft; + } + } else if (relX > collisionRight) { + collision = true; + normalX = -1; + if (collisionBehavior === 'wrap') { + p.x = centerX + collisionLeft; + } else if (collisionBehavior === 'bounce') { + p.x = centerX + collisionRight; + } + } + if (relY < collisionTop) { + collision = true; + normalY = 1; + if (collisionBehavior === 'wrap') { + p.y = centerY + collisionBottom; + } else if (collisionBehavior === 'bounce') { + p.y = centerY + collisionTop; + } + } else if (relY > collisionBottom) { + collision = true; + normalY = -1; + if (collisionBehavior === 'wrap') { + p.y = centerY + collisionTop; + } else if (collisionBehavior === 'bounce') { + p.y = centerY + collisionBottom; + } + } + } else if (collisionBoundaryType === 'circle') { + // 圆形边界 | Circle boundary + const dist = Math.sqrt(relX * relX + relY * relY); + if (dist > collisionRadius) { + collision = true; + if (dist > 0.001) { + normalX = -relX / dist; + normalY = -relY / dist; + } + if (collisionBehavior === 'wrap') { + p.x = centerX - relX * (collisionRadius / dist) * 0.9; + p.y = centerY - relY * (collisionRadius / dist) * 0.9; + } else if (collisionBehavior === 'bounce') { + p.x = centerX + relX * (collisionRadius / dist) * 0.99; + p.y = centerY + relY * (collisionRadius / dist) * 0.99; + } + } + } + + if (collision) { + if (collisionBehavior === 'kill') { + return false; + } else if (collisionBehavior === 'bounce') { + // 反弹 | Bounce + if (normalX !== 0) { + p.vx = -p.vx * collisionBounceFactor; + } + if (normalY !== 0) { + p.vy = -p.vy * collisionBounceFactor; + } + // 生命损失 | Life loss + if (collisionLifeLoss > 0) { + p.lifetime *= (1 - collisionLifeLoss); + } + // 检查最小速度 | Check minimum velocity + const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy); + if (speed < collisionMinVelocity) { + return false; + } + } + // wrap 已处理位置 | wrap position already handled + } + } + + return true; + }); + } + + // 渲染 | Render + ctx.fillStyle = '#0a0a12'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // 绘制网格背景 | Draw grid background + ctx.strokeStyle = '#1a1a2a'; + ctx.lineWidth = 1; + const gridSize = 20; + for (let x = gridSize; x < canvas.width; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, canvas.height); + ctx.stroke(); + } + for (let y = gridSize; y < canvas.height; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(canvas.width, y); + ctx.stroke(); + } + + // 绘制发射器位置指示 | Draw emitter position indicator + ctx.strokeStyle = '#444'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(centerX - 8, centerY); + ctx.lineTo(centerX + 8, centerY); + ctx.moveTo(centerX, centerY - 8); + ctx.lineTo(centerX, centerY + 8); + ctx.stroke(); + + // 绘制发射形状轮廓 | Draw emission shape outline + ctx.strokeStyle = '#333'; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + switch (data.emissionShape) { + case EmissionShape.Circle: + case EmissionShape.Ring: + ctx.arc(centerX, centerY, data.shapeRadius || 50, 0, Math.PI * 2); + break; + case EmissionShape.Rectangle: + case EmissionShape.Edge: + ctx.rect( + centerX - (data.shapeWidth || 100) / 2, + centerY - (data.shapeHeight || 100) / 2, + data.shapeWidth || 100, + data.shapeHeight || 100 + ); + break; + case EmissionShape.Line: { + const dir = (data.direction - 90) * Math.PI / 180; + const lineWidth = data.shapeWidth || 100; + const cos = Math.cos(dir + Math.PI / 2); + const sin = Math.sin(dir + Math.PI / 2); + ctx.moveTo(centerX - cos * lineWidth / 2, centerY - sin * lineWidth / 2); + ctx.lineTo(centerX + cos * lineWidth / 2, centerY + sin * lineWidth / 2); + break; + } + case EmissionShape.Cone: { + const dir = (data.direction - 90) * Math.PI / 180; + const coneAngle = (data.shapeAngle || 30) * Math.PI / 180; + const radius = data.shapeRadius || 50; + ctx.moveTo(centerX, centerY); + ctx.lineTo( + centerX + Math.cos(dir - coneAngle / 2) * radius, + centerY + Math.sin(dir - coneAngle / 2) * radius + ); + ctx.arc(centerX, centerY, radius, dir - coneAngle / 2, dir + coneAngle / 2); + ctx.lineTo(centerX, centerY); + break; + } + } + ctx.stroke(); + ctx.setLineDash([]); + + // 设置混合模式 | Set blend mode + if (data.blendMode === ParticleBlendMode.Additive) { + ctx.globalCompositeOperation = 'lighter'; + } else if (data.blendMode === ParticleBlendMode.Multiply) { + ctx.globalCompositeOperation = 'multiply'; + } else { + ctx.globalCompositeOperation = 'source-over'; + } + + // 绘制粒子 | Draw particles + for (const p of particlesRef.current) { + const size = (data.particleSize || 8); + const r = Math.round(clamp(p.r, 0, 1) * 255); + const g = Math.round(clamp(p.g, 0, 1) * 255); + const b = Math.round(clamp(p.b, 0, 1) * 255); + + ctx.globalAlpha = clamp(p.alpha, 0, 1); + + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.rotation); + ctx.scale(p.scaleX, p.scaleY); + + const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, size / 2); + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`); + gradient.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0.6)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, size / 2, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + + // 绘制 UI 信息 | Draw UI info + ctx.fillStyle = '#666'; + ctx.font = '10px monospace'; + ctx.fillText(`Particles: ${particlesRef.current.length}/${data.maxParticles}`, 6, canvas.height - 20); + ctx.fillText(`FPS: ${Math.round(1 / deltaTime)}`, 6, canvas.height - 6); + + ctx.fillStyle = isPlaying ? '#4a9' : '#a94'; + ctx.fillText(isPlaying ? 'Playing' : 'Paused', canvas.width - 50, canvas.height - 6); + + if (followMouse) { + ctx.fillStyle = '#59a'; + ctx.fillText('Mouse Follow', canvas.width - 80, canvas.height - 20); + } + + // 提示点击触发 | Click to trigger hint + if (data.emissionRate === 0 || bursts.length > 0) { + ctx.fillStyle = '#666'; + ctx.fillText('Click to emit', canvas.width - 70, 14); + } + + animationFrameRef.current = requestAnimationFrame(update); + }; + + animationFrameRef.current = requestAnimationFrame(update); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [canvasRef, data, isPlaying, followMouse, mousePosition, triggerBurst]); + + const reset = useCallback(() => { + particlesRef.current = []; + emissionAccumulatorRef.current = 0; + noiseTimeRef.current = 0; + lastTimeRef.current = 0; + elapsedTimeRef.current = 0; + burstFiredRef.current.clear(); + }, []); + + return { reset }; +} + +// ============= Property Components ============= + +interface PropertyInputProps { + label: string; + type: 'text' | 'number'; + value: string | number; + min?: number; + max?: number; + step?: number; + onChange: (value: any) => void; +} + +function PropertyInput({ label, type, value, min, max, step, onChange }: PropertyInputProps) { + return ( +
+ + onChange(type === 'number' ? parseFloat(e.target.value) || 0 : e.target.value)} + /> +
+ ); +} + +interface PropertyCheckboxProps { + label: string; + checked: boolean; + onChange: (value: boolean) => void; +} + +function PropertyCheckbox({ label, checked, onChange }: PropertyCheckboxProps) { + return ( +
+ + onChange(e.target.checked)} + /> +
+ ); +} + +interface PropertySelectProps { + label: string; + value: string; + options: { value: string; label: string }[]; + onChange: (value: string) => void; +} + +function PropertySelect({ label, value, options, onChange }: PropertySelectProps) { + return ( +
+ + +
+ ); +} + +interface PropertyRangeProps { + label: string; + minValue: number; + maxValue: number; + min?: number; + max?: number; + step?: number; + onMinChange: (value: number) => void; + onMaxChange: (value: number) => void; +} + +function PropertyRange({ label, minValue, maxValue, min, max, step = 1, onMinChange, onMaxChange }: PropertyRangeProps) { + return ( +
+ +
+ Min + onMinChange(parseFloat(e.target.value) || 0)} + /> + Max + onMaxChange(parseFloat(e.target.value) || 0)} + /> +
+
+ ); +} + +/** + * Vector2 输入组件 + * Vector2 input component with X/Y axis indicators + */ +function Vector2Field({ + label, + x, + y, + onXChange, + onYChange, + step = 1, +}: { + label: string; + x: number; + y: number; + onXChange: (value: number) => void; + onYChange: (value: number) => void; + step?: number; +}) { + return ( +
+ +
+
+
+ onXChange(parseFloat(e.target.value) || 0)} + /> +
+
+
+ onYChange(parseFloat(e.target.value) || 0)} + /> +
+
+
+ ); +} + +// ============= Module Section Component ============= + +interface ModuleSectionProps { + name: string; + enabled: boolean; + expanded: boolean; + onToggle: (enabled: boolean) => void; + onExpandToggle: () => void; + children: React.ReactNode; +} + +function ModuleSection({ name, enabled, expanded, onToggle, onExpandToggle, children }: ModuleSectionProps) { + return ( +
+
+ + + + { + e.stopPropagation(); + onToggle(e.target.checked); + }} + onClick={e => e.stopPropagation()} + /> + {name} +
+
+ {children} +
+
+ ); +} + +// ============= Property Sections ============= + +interface PropertySectionProps { + data: IParticleAsset; + onChange: (key: K, value: IParticleAsset[K]) => void; +} + +interface BasicPropertiesProps extends PropertySectionProps { + onBrowseTexture?: () => Promise; +} + +function BasicProperties({ data, onChange, onBrowseTexture }: BasicPropertiesProps) { + return ( +
+ onChange('name', v)} + /> + onChange('texturePath' as any, path)} + onBrowse={onBrowseTexture} + /> + onChange('maxParticles', v)} + /> + onChange('looping', v)} + /> + onChange('duration', v)} + /> + onChange('prewarmTime' as any, v)} + /> + onChange('playbackSpeed', v)} + /> + onChange('blendMode', v as ParticleBlendMode)} + /> + onChange('simulationSpace' as any, v)} + /> + onChange('particleSize', v)} + /> + onChange('sortingOrder', v)} + /> +
+ ); +} + +function EmissionProperties({ data, onChange }: PropertySectionProps) { + return ( +
+ onChange('emissionRate', v)} + /> + onChange('emissionShape', v as EmissionShape)} + /> + {(data.emissionShape === EmissionShape.Circle || + data.emissionShape === EmissionShape.Ring || + data.emissionShape === EmissionShape.Cone) && ( + onChange('shapeRadius', v)} + /> + )} + {(data.emissionShape === EmissionShape.Rectangle || + data.emissionShape === EmissionShape.Edge || + data.emissionShape === EmissionShape.Line) && ( + <> + onChange('shapeWidth', v)} + /> + {data.emissionShape !== EmissionShape.Line && ( + onChange('shapeHeight', v)} + /> + )} + + )} + {data.emissionShape === EmissionShape.Cone && ( + onChange('shapeAngle', v)} + /> + )} +
+ ); +} + +function ParticleProperties({ data, onChange }: PropertySectionProps) { + return ( +
+ onChange('lifetimeMin', v)} + onMaxChange={v => onChange('lifetimeMax', v)} + /> + onChange('speedMin', v)} + onMaxChange={v => onChange('speedMax', v)} + /> + onChange('direction', v)} + /> + onChange('directionSpread', v)} + /> + onChange('scaleMin', v)} + onMaxChange={v => onChange('scaleMax', v)} + /> + onChange('gravityX', v)} + onYChange={v => onChange('gravityY', v)} + /> +
+ ); +} + +function ColorProperties({ data, onChange }: PropertySectionProps) { + const hexColor = data.startColor + ? `#${Math.round(data.startColor.r * 255).toString(16).padStart(2, '0')}${Math.round(data.startColor.g * 255).toString(16).padStart(2, '0')}${Math.round(data.startColor.b * 255).toString(16).padStart(2, '0')}` + : '#ffffff'; + + const handleColorChange = (hex: string) => { + 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; + onChange('startColor', { r, g, b, a: data.startColor?.a ?? 1 }); + }; + + return ( +
+
+ + handleColorChange(e.target.value)} + /> +
+ onChange('startAlpha', v)} + /> + onChange('endAlpha', v)} + /> + onChange('endScale', v)} + /> +
+ ); +} + +// ============= Modules Properties ============= + +interface ModulesPropertiesProps { + data: IParticleAsset; + onModuleChange: (moduleType: string, params: Record) => void; + onModuleToggle: (moduleType: string, enabled: boolean) => void; +} + +function ModulesProperties({ data, onModuleChange, onModuleToggle }: ModulesPropertiesProps) { + const [expandedModules, setExpandedModules] = useState>(new Set(['ColorOverLifetime', 'SizeOverLifetime'])); + + const modules = data.modules || []; + + const getModule = (type: string): IParticleModuleConfig | undefined => { + return modules.find(m => m.type === type); + }; + + const toggleExpand = (type: string) => { + setExpandedModules(prev => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + }; + + const colorModule = getModule('ColorOverLifetime'); + const sizeModule = getModule('SizeOverLifetime'); + const velocityModule = getModule('VelocityOverLifetime'); + const rotationModule = getModule('RotationOverLifetime'); + const noiseModule = getModule('Noise'); + const collisionModule = getModule('Collision'); + const forceFieldModule = getModule('ForceField'); + + // 颜色渐变数据 | Color gradient data + const colorGradient: ColorKey[] = (colorModule?.params?.gradient as ColorKey[]) ?? [ + { time: 0, r: 1, g: 1, b: 1, a: 1 }, + { time: 1, r: 1, g: 1, b: 1, a: 0 } + ]; + + // 缩放曲线数据 | Scale curve data + const scaleKeys: ScaleKey[] = (sizeModule?.params?.keys as ScaleKey[]) ?? [ + { time: 0, scale: 1 }, + { time: 1, scale: 0 } + ]; + + const scaleCurveType = (sizeModule?.params?.curveType as ScaleCurveType) ?? ScaleCurveType.Linear; + + return ( +
+ {/* Color Over Lifetime */} + onModuleToggle('ColorOverLifetime', enabled)} + onExpandToggle={() => toggleExpand('ColorOverLifetime')} + > + onModuleChange('ColorOverLifetime', { ...colorModule?.params, gradient: keys })} + /> + + + {/* Size Over Lifetime */} + onModuleToggle('SizeOverLifetime', enabled)} + onExpandToggle={() => toggleExpand('SizeOverLifetime')} + > + onModuleChange('SizeOverLifetime', { ...sizeModule?.params, keys })} + curveType={scaleCurveType} + onCurveTypeChange={type => onModuleChange('SizeOverLifetime', { ...sizeModule?.params, curveType: type })} + minY={0} + maxY={2} + /> + + + {/* Velocity Over Lifetime */} + onModuleToggle('VelocityOverLifetime', enabled)} + onExpandToggle={() => toggleExpand('VelocityOverLifetime')} + > + onModuleChange('VelocityOverLifetime', { ...velocityModule?.params, linearDrag: v })} + /> + onModuleChange('VelocityOverLifetime', { ...velocityModule?.params, orbitalVelocity: v })} + /> + onModuleChange('VelocityOverLifetime', { ...velocityModule?.params, radialVelocity: v })} + /> + + + {/* Rotation Over Lifetime */} + onModuleToggle('RotationOverLifetime', enabled)} + onExpandToggle={() => toggleExpand('RotationOverLifetime')} + > + onModuleChange('RotationOverLifetime', { ...rotationModule?.params, angularVelocityMultiplierStart: v })} + /> + onModuleChange('RotationOverLifetime', { ...rotationModule?.params, angularVelocityMultiplierEnd: v })} + /> + onModuleChange('RotationOverLifetime', { ...rotationModule?.params, additionalRotation: v })} + /> + + + {/* Noise */} + onModuleToggle('Noise', enabled)} + onExpandToggle={() => toggleExpand('Noise')} + > + onModuleChange('Noise', { ...noiseModule?.params, positionAmount: v })} + /> + onModuleChange('Noise', { ...noiseModule?.params, velocityAmount: v })} + /> + onModuleChange('Noise', { ...noiseModule?.params, rotationAmount: v })} + /> + onModuleChange('Noise', { ...noiseModule?.params, frequency: v })} + /> + onModuleChange('Noise', { ...noiseModule?.params, scrollSpeed: v })} + /> + + + {/* Collision */} + onModuleToggle('Collision', enabled)} + onExpandToggle={() => toggleExpand('Collision')} + > + onModuleChange('Collision', { ...collisionModule?.params, boundaryType: v })} + /> + onModuleChange('Collision', { ...collisionModule?.params, behavior: v })} + /> + {((collisionModule?.params?.boundaryType as string) ?? BoundaryType.Rectangle) === BoundaryType.Rectangle && ( + <> + onModuleChange('Collision', { ...collisionModule?.params, left: v })} + /> + onModuleChange('Collision', { ...collisionModule?.params, right: v })} + /> + onModuleChange('Collision', { ...collisionModule?.params, top: v })} + /> + onModuleChange('Collision', { ...collisionModule?.params, bottom: v })} + /> + + )} + {((collisionModule?.params?.boundaryType as string) ?? BoundaryType.Rectangle) === BoundaryType.Circle && ( + onModuleChange('Collision', { ...collisionModule?.params, radius: v })} + /> + )} + {((collisionModule?.params?.behavior as string) ?? CollisionBehavior.Kill) === CollisionBehavior.Bounce && ( + <> + onModuleChange('Collision', { ...collisionModule?.params, bounceFactor: v })} + /> + onModuleChange('Collision', { ...collisionModule?.params, lifeLossOnBounce: v })} + /> + + )} + + + {/* Force Field */} + onModuleToggle('ForceField', enabled)} + onExpandToggle={() => toggleExpand('ForceField')} + > + onModuleChange('ForceField', { ...forceFieldModule?.params, type: v })} + /> + onModuleChange('ForceField', { ...forceFieldModule?.params, strength: v })} + /> + {((forceFieldModule?.params?.type as string) ?? ForceFieldType.Wind) === ForceFieldType.Wind && ( + onModuleChange('ForceField', { ...forceFieldModule?.params, directionX: v })} + onYChange={v => onModuleChange('ForceField', { ...forceFieldModule?.params, directionY: v })} + step={0.1} + /> + )} + {((forceFieldModule?.params?.type as string) ?? ForceFieldType.Wind) === ForceFieldType.Point && ( + <> + onModuleChange('ForceField', { ...forceFieldModule?.params, positionX: v })} + onYChange={v => onModuleChange('ForceField', { ...forceFieldModule?.params, positionY: v })} + /> + onModuleChange('ForceField', { ...forceFieldModule?.params, radius: v })} + /> + onModuleChange('ForceField', { ...forceFieldModule?.params, falloff: v })} + /> + + )} + {((forceFieldModule?.params?.type as string) ?? ForceFieldType.Wind) === ForceFieldType.Vortex && ( + <> + onModuleChange('ForceField', { ...forceFieldModule?.params, centerX: v })} + onYChange={v => onModuleChange('ForceField', { ...forceFieldModule?.params, centerY: v })} + /> + onModuleChange('ForceField', { ...forceFieldModule?.params, inwardStrength: v })} + /> + + )} + {((forceFieldModule?.params?.type as string) ?? ForceFieldType.Wind) === ForceFieldType.Turbulence && ( + <> + onModuleChange('ForceField', { ...forceFieldModule?.params, frequency: v })} + /> + onModuleChange('ForceField', { ...forceFieldModule?.params, amplitude: v })} + /> + + )} + +
+ ); +} + +// ============= Burst Properties ============= + +interface BurstPropertiesProps { + bursts: BurstConfig[]; + onBurstsChange: (bursts: BurstConfig[]) => void; +} + +function BurstProperties({ bursts, onBurstsChange }: BurstPropertiesProps) { + const addBurst = () => { + onBurstsChange([...bursts, { time: 0, count: 10, cycles: 1, interval: 0 }]); + }; + + const removeBurst = (index: number) => { + onBurstsChange(bursts.filter((_, i) => i !== index)); + }; + + const updateBurst = (index: number, field: keyof BurstConfig, value: number) => { + const newBursts = [...bursts]; + newBursts[index] = { ...newBursts[index], [field]: value }; + onBurstsChange(newBursts); + }; + + return ( +
+ {bursts.map((burst, index) => ( +
+ T: + updateBurst(index, 'time', parseFloat(e.target.value) || 0)} + title="Time" + /> + N: + updateBurst(index, 'count', parseInt(e.target.value) || 1)} + title="Count" + /> + +
+ ))} + +
+ ); +} + +// ============= Helper Functions ============= + +function presetToAsset(preset: ParticlePreset): Partial { + const modules: IParticleModuleConfig[] = []; + + // 添加碰撞模块 | Add collision module + if (preset.collision) { + modules.push({ + type: 'Collision', + enabled: true, + params: { + boundaryType: preset.collision.boundaryType, + behavior: preset.collision.behavior, + radius: preset.collision.radius, + bounceFactor: preset.collision.bounceFactor, + left: -200, + right: 200, + top: -200, + bottom: 200, + }, + }); + } + + // 添加力场模块 | Add force field module + if (preset.forceField) { + modules.push({ + type: 'ForceField', + enabled: true, + params: { + type: preset.forceField.type, + strength: preset.forceField.strength, + directionX: preset.forceField.directionX, + directionY: preset.forceField.directionY, + centerX: preset.forceField.centerX, + centerY: preset.forceField.centerY, + inwardStrength: preset.forceField.inwardStrength, + frequency: preset.forceField.frequency, + }, + }); + } + + return { + maxParticles: preset.maxParticles, + looping: preset.looping, + duration: preset.duration, + playbackSpeed: preset.playbackSpeed, + emissionRate: preset.emissionRate, + emissionShape: preset.emissionShape, + shapeRadius: preset.shapeRadius, + shapeWidth: preset.shapeWidth, + shapeHeight: preset.shapeHeight, + shapeAngle: preset.shapeAngle, + lifetimeMin: preset.lifetimeMin, + lifetimeMax: preset.lifetimeMax, + speedMin: preset.speedMin, + speedMax: preset.speedMax, + direction: preset.direction, + directionSpread: preset.directionSpread, + scaleMin: preset.scaleMin, + scaleMax: preset.scaleMax, + gravityX: preset.gravityX, + gravityY: preset.gravityY, + startColor: preset.startColor, + startAlpha: preset.startAlpha, + endAlpha: preset.endAlpha, + endScale: preset.endScale, + particleSize: preset.particleSize, + blendMode: preset.blendMode, + modules: modules.length > 0 ? modules : undefined, + }; +} + +// ============= Main Component ============= + +/** + * 粒子编辑器面板组件 + * Particle editor panel component + */ +export function ParticleEditorPanel() { + const { + filePath, + pendingFilePath, + particleData, + isDirty, + isPlaying, + selectedPreset, + setFilePath, + setPendingFilePath, + setParticleData, + updateProperty, + markSaved, + setPlaying, + setSelectedPreset, + createNew, + } = useParticleEditorStore(); + + const [activeTab, setActiveTab] = useState<'basic' | 'emission' | 'particle' | 'color' | 'modules' | 'burst'>('basic'); + const [isFullscreen, setIsFullscreen] = useState(false); + const [followMouse, setFollowMouse] = useState(false); + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); + const [triggerBurst, setTriggerBurst] = useState(0); + const previewCanvasRef = useRef(null); + const previewContainerRef = useRef(null); + + // 预览选项 | Preview options + const previewOptions = useMemo(() => ({ + followMouse, + mousePosition, + triggerBurst, + }), [followMouse, mousePosition, triggerBurst]); + + const { reset: resetPreview } = useParticlePreview(previewCanvasRef, particleData, isPlaying, previewOptions); + + // 处理鼠标移动 | Handle mouse move + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!followMouse) return; + const canvas = previewCanvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + setMousePosition({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }, [followMouse]); + + // 切换全屏 | Toggle fullscreen + const toggleFullscreen = useCallback(() => { + setIsFullscreen(prev => !prev); + }, []); + + // 触发一次爆发 | Trigger a burst + const handleTriggerBurst = useCallback(() => { + setTriggerBurst(prev => prev + 1); + }, []); + + useEffect(() => { + if (pendingFilePath) { + loadFile(pendingFilePath); + setPendingFilePath(null); + } + }, [pendingFilePath]); + + const loadFile = useCallback(async (path: string) => { + try { + const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null; + if (!fileSystem) { + console.error('[ParticleEditorPanel] FileSystem service not available'); + return; + } + + const content = await fileSystem.readFile(path); + const data = JSON.parse(content) as IParticleAsset; + const defaults = createDefaultParticleAsset(); + setParticleData({ ...defaults, ...data }); + setFilePath(path); + } catch (error) { + console.error('[ParticleEditorPanel] Failed to load file:', error); + } + }, [setParticleData, setFilePath]); + + const handleSave = useCallback(async () => { + if (!particleData) return; + + let savePath = filePath; + + if (!savePath) { + const dialog = Core.services.tryResolve(IDialogService) as IDialog | null; + if (!dialog) return; + + savePath = await dialog.saveDialog({ + title: 'Save Particle Effect', + filters: [{ name: 'Particle Effect', extensions: ['particle'] }], + defaultPath: `${particleData.name || 'new-particle'}.particle`, + }); + + if (!savePath) return; + } + + try { + const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null; + if (!fileSystem) return; + + await fileSystem.writeFile(savePath, JSON.stringify(particleData, null, 2)); + setFilePath(savePath); + markSaved(); + + const messageHub = Core.services.tryResolve(MessageHub); + if (messageHub) { + messageHub.publish('assets:refresh', {}); + } + } catch (error) { + console.error('[ParticleEditorPanel] Failed to save:', error); + } + }, [particleData, filePath, setFilePath, markSaved]); + + const handleOpen = useCallback(async () => { + const dialog = Core.services.tryResolve(IDialogService) as IDialog | null; + if (!dialog) return; + + const path = await dialog.openDialog({ + title: 'Open Particle Effect', + filters: [{ name: 'Particle Effect', extensions: ['particle', 'particle.json'] }], + }); + + if (path && typeof path === 'string') { + await loadFile(path); + } + }, [loadFile]); + + const handleBrowseTexture = useCallback(async (): Promise => { + const dialog = Core.services.tryResolve(IDialogService) as IDialog | null; + if (!dialog) return null; + + const path = await dialog.openDialog({ + title: 'Select Particle Texture', + filters: [{ name: 'Image Files', extensions: ['png', 'jpg', 'jpeg', 'webp'] }], + }); + + if (path && typeof path === 'string') { + return path; + } + return null; + }, []); + + const handleApplyPreset = useCallback((presetName: string) => { + const preset = getPresetByName(presetName); + if (!preset || !particleData) return; + + const assetData = presetToAsset(preset); + + // 对于爆炸类预设(emissionRate=0),自动添加 burst 配置 + // For explosion-type presets (emissionRate=0), auto-add burst config + if (preset.emissionRate === 0) { + (assetData as any).bursts = [ + { + time: 0, + count: Math.min(preset.maxParticles, 100), + cycles: 1, + interval: 0, + } + ]; + } + + setParticleData({ + ...particleData, + ...assetData, + }); + setSelectedPreset(presetName); + + // 重置预览 | Reset preview + resetPreview(); + }, [particleData, setParticleData, setSelectedPreset, resetPreview]); + + const handleNew = useCallback(() => { + createNew(); + }, [createNew]); + + const handleTogglePlay = useCallback(() => { + setPlaying(!isPlaying); + }, [isPlaying, setPlaying]); + + const handleReset = useCallback(() => { + setPlaying(false); + resetPreview(); + }, [setPlaying, resetPreview]); + + const handleModuleChange = useCallback((moduleType: string, params: Record) => { + if (!particleData) return; + + const modules = [...(particleData.modules || [])]; + const index = modules.findIndex(m => m.type === moduleType); + + if (index >= 0) { + modules[index] = { ...modules[index], params }; + } else { + modules.push({ type: moduleType, enabled: true, params }); + } + + setParticleData({ ...particleData, modules }); + }, [particleData, setParticleData]); + + const handleModuleToggle = useCallback((moduleType: string, enabled: boolean) => { + if (!particleData) return; + + const modules = [...(particleData.modules || [])]; + const index = modules.findIndex(m => m.type === moduleType); + + if (index >= 0) { + modules[index] = { ...modules[index], enabled }; + } else { + modules.push({ type: moduleType, enabled, params: {} }); + } + + setParticleData({ ...particleData, modules }); + }, [particleData, setParticleData]); + + const handleBurstsChange = useCallback((bursts: BurstConfig[]) => { + if (!particleData) return; + setParticleData({ ...particleData, bursts } as IParticleAsset); + }, [particleData, setParticleData]); + + if (!particleData) { + return ( +
+ +

Particle Editor

+

Double-click a .particle file to open, or create a new effect

+
+ + +
+
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ + + + + + +
+
+ + {filePath ? filePath.split(/[\\/]/).pop() : 'Unsaved'} + {isDirty && ' *'} + +
+
+ +
+ {/* Preview Area */} +
+ {/* 预览控制栏 | Preview control bar */} +
+ +
+ + +
+ +
+ { + const canvas = previewCanvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + // 更新鼠标位置 | Update mouse position + if (!followMouse) { + setMousePosition({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + // 触发爆发 | Trigger burst + handleTriggerBurst(); + }} + /> +
+ + {/* Properties Panel */} +
+ {/* Preset Selector */} +
+
Presets
+
+ {AllPresets.map(preset => ( + + ))} +
+
+ + {/* Tabs */} +
+ + + + + + +
+ + {/* Property Content */} +
+ {activeTab === 'basic' && ( + + )} + {activeTab === 'emission' && ( + + )} + {activeTab === 'particle' && ( + + )} + {activeTab === 'color' && ( + + )} + {activeTab === 'modules' && ( + + )} + {activeTab === 'burst' && ( + + )} +
+
+
+
+ ); +} diff --git a/packages/particle-editor/src/providers/ParticleInspectorProvider.tsx b/packages/particle-editor/src/providers/ParticleInspectorProvider.tsx new file mode 100644 index 00000000..801469ea --- /dev/null +++ b/packages/particle-editor/src/providers/ParticleInspectorProvider.tsx @@ -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 { + 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; + return 'entityId' in obj && 'component' in obj && + obj.component !== null && + typeof obj.component === 'object' && + 'maxParticles' in (obj.component as Record) && + 'emissionRate' in (obj.component as Record); + } + + render(data: ParticleInspectorData, _context: InspectorContext): React.ReactElement { + return ; + } +} + +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 = ( + key: K, + value: ParticleSystemComponent[K] + ) => { + (component as any)[key] = value; + component.markDirty(); + refresh(); + }; + + return ( +
+ {/* 控制按钮 | Control buttons */} +
+
Controls
+
+ + + +
+
+ + {component.activeParticleCount} / {component.maxParticles} +
+
+ + {/* 基础属性 | Basic Properties */} +
+
Basic
+ + handleChange('maxParticles', v)} + /> + + handleChange('looping', v)} + /> + + handleChange('duration', v)} + /> + + handleChange('playbackSpeed', v)} + /> +
+ + {/* 发射属性 | Emission Properties */} +
+
Emission
+ + handleChange('emissionRate', v)} + /> + + handleChange('emissionShape', v as EmissionShape)} + /> + + {component.emissionShape !== EmissionShape.Point && ( + handleChange('shapeRadius', v)} + /> + )} +
+ + {/* 粒子属性 | Particle Properties */} +
+
Particle
+ + handleChange('lifetimeMin', v)} + onMaxChange={v => handleChange('lifetimeMax', v)} + /> + + handleChange('speedMin', v)} + onMaxChange={v => handleChange('speedMax', v)} + /> + + handleChange('direction', v)} + /> + + handleChange('directionSpread', v)} + /> + + handleChange('scaleMin', v)} + onMaxChange={v => handleChange('scaleMax', v)} + /> + + handleChange('gravityX', v)} + /> + + handleChange('gravityY', v)} + /> +
+ + {/* 颜色属性 | Color Properties */} +
+
Color
+ + handleChange('startColor', v)} + /> + + handleChange('startAlpha', v)} + /> + + handleChange('endAlpha', v)} + /> + + handleChange('endScale', v)} + /> +
+ + {/* 渲染属性 | Rendering Properties */} +
+
Rendering
+ + handleChange('particleSize', v)} + /> + + handleChange('blendMode', v as ParticleBlendMode)} + /> + + handleChange('sortingOrder', v)} + /> +
+
+ ); +} + +// ============= 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 ( +
+ + onChange(parseFloat(e.target.value) || 0)} + style={inputStyle} + /> +
+ ); +} + +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 ( +
+ +
+ onMinChange(parseFloat(e.target.value) || 0)} + style={{ ...inputStyle, width: '50%' }} + title="Min" + /> + onMaxChange(parseFloat(e.target.value) || 0)} + style={{ ...inputStyle, width: '50%' }} + title="Max" + /> +
+
+ ); +} + +interface CheckboxInputProps { + label: string; + checked: boolean; + onChange: (value: boolean) => void; +} + +function CheckboxInput({ label, checked, onChange }: CheckboxInputProps) { + return ( +
+ + onChange(e.target.checked)} + /> +
+ ); +} + +interface SelectInputProps { + label: string; + value: string; + options: { value: string; label: string }[]; + onChange: (value: string) => void; +} + +function SelectInput({ label, value, options, onChange }: SelectInputProps) { + return ( +
+ + +
+ ); +} + +interface ColorInputProps { + label: string; + value: string; + onChange: (value: string) => void; +} + +function ColorInput({ label, value, onChange }: ColorInputProps) { + return ( +
+ + onChange(e.target.value)} + style={{ ...inputStyle, padding: '2px', height: '24px' }} + /> +
+ ); +} diff --git a/packages/particle-editor/src/stores/ParticleEditorStore.ts b/packages/particle-editor/src/stores/ParticleEditorStore.ts new file mode 100644 index 00000000..9bdb8416 --- /dev/null +++ b/packages/particle-editor/src/stores/ParticleEditorStore.ts @@ -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: (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((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, + }), +})); diff --git a/packages/particle-editor/src/styles/ParticleEditor.css b/packages/particle-editor/src/styles/ParticleEditor.css new file mode 100644 index 00000000..6ec04f76 --- /dev/null +++ b/packages/particle-editor/src/styles/ParticleEditor.css @@ -0,0 +1,1323 @@ +/** + * Particle Editor Styles + * 粒子编辑器样式 + * + * Matches editor-app property panel styles exactly. + * 与 editor-app 属性面板样式完全匹配。 + */ + +/* ==================== Empty State ==================== */ +.particle-editor-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #888; + text-align: center; + padding: 24px; + background: #262626; +} + +.particle-editor-empty h3 { + margin: 16px 0 8px; + color: #ccc; + font-size: 14px; + font-weight: 600; +} + +.particle-editor-empty p { + margin: 0 0 16px; + font-size: 11px; +} + +/* ==================== Button Styles ==================== */ +.editor-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid #3a3a3a; + border-radius: 3px; + background: #2a2a2a; + color: #ccc; + font-size: 11px; + cursor: pointer; + transition: all 0.1s ease; +} + +.editor-button:hover { + background: #333; + border-color: #4a4a4a; +} + +.editor-button.primary { + background: #3b82f6; + border-color: #3b82f6; + color: #fff; +} + +.editor-button.primary:hover { + background: #2563eb; +} + +/* ==================== Main Panel Layout ==================== */ +.particle-editor-panel { + display: flex; + flex-direction: column; + height: 100%; + background: #262626; + overflow: hidden; + font-size: 11px; + color: #ccc; +} + +/* ==================== Toolbar ==================== */ +.particle-editor-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 6px; + background: #2d2d2d; + border-bottom: 1px solid #1a1a1a; + height: 24px; + flex-shrink: 0; +} + +.particle-editor-toolbar .toolbar-left, +.particle-editor-toolbar .toolbar-right { + display: flex; + align-items: center; + gap: 2px; +} + +.particle-editor-toolbar button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 2px; + background: transparent; + color: #888; + cursor: pointer; + transition: all 0.1s ease; +} + +.particle-editor-toolbar button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: #ccc; +} + +.particle-editor-toolbar button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.particle-editor-toolbar .separator { + width: 1px; + height: 14px; + background: #444; + margin: 0 4px; +} + +.particle-editor-toolbar .file-name { + font-size: 11px; + color: #666; + padding-right: 8px; +} + +/* ==================== Content Area ==================== */ +.particle-editor-content { + display: flex; + flex: 1; + overflow: hidden; + min-height: 0; +} + +/* ==================== Preview Area ==================== */ +.particle-preview-area { + flex: 1; + display: flex; + flex-direction: column; + background: #1a1a1a; + position: relative; + min-width: 300px; + overflow: hidden; +} + +.particle-preview-area.fullscreen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + min-width: 100%; +} + +.particle-preview-canvas { + flex: 1; + width: 100%; + background: #0a0a12; + cursor: crosshair; +} + +/* ==================== Preview Controls ==================== */ +.preview-controls { + display: flex; + align-items: center; + gap: 2px; + padding: 4px 8px; + background: #1e1e1e; + border-bottom: 1px solid #333; + flex-shrink: 0; +} + +.preview-control-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 22px; + padding: 0 4px; + border: none; + border-radius: 2px; + background: transparent; + color: #888; + font-size: 10px; + cursor: pointer; + transition: all 0.1s ease; +} + +.preview-control-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #ccc; +} + +.preview-control-btn.active { + background: #3b82f6; + color: #fff; +} + +.preview-control-separator { + width: 1px; + height: 14px; + background: #444; + margin: 0 4px; +} + +.preview-control-btn.burst-btn { + gap: 4px; + padding: 0 8px; + background: #3a3a3a; + border: 1px solid #4a4a4a; +} + +.preview-control-btn.burst-btn:hover { + background: #f59e0b; + border-color: #f59e0b; + color: #000; +} + +.preview-control-btn.burst-btn:active { + background: #d97706; + border-color: #d97706; +} + +/* ==================== Vector Field (X/Y) ==================== */ +.vector-field-row { + display: flex; + align-items: center; + padding: 3px 8px; + min-height: 22px; + gap: 4px; +} + +.vector-field-row:hover { + background: rgba(255, 255, 255, 0.02); +} + +.vector-field-row > label { + width: 64px; + min-width: 64px; + flex-shrink: 0; + font-size: 11px; + color: #999; + font-weight: 400; +} + +.vector-field-inputs { + flex: 1; + display: flex; + gap: 2px; + min-width: 0; +} + +.vector-axis-input { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + position: relative; + background: #1a1a1a; + border: 1px solid #3a3a3a; + border-radius: 2px; + overflow: hidden; +} + +.vector-axis-input:hover { + border-color: #4a4a4a; +} + +.vector-axis-input:focus-within { + border-color: #4a9eff; +} + +.vector-axis-bar { + width: 4px; + height: 100%; + flex-shrink: 0; +} + +.vector-axis-bar.x { + background: #c04040; +} + +.vector-axis-bar.y { + background: #40a040; +} + +.vector-axis-bar.z { + background: #4060c0; +} + +.vector-axis-input input { + flex: 1; + min-width: 0; + height: 16px; + padding: 0 4px; + background: transparent; + border: none; + font-size: 10px; + font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + color: #ddd; + -moz-appearance: textfield; +} + +.vector-axis-input input::-webkit-outer-spin-button, +.vector-axis-input input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.vector-axis-input input:focus { + outline: none; +} + +/* ==================== Range Field (Min/Max) ==================== */ +.range-field-row { + display: flex; + align-items: center; + padding: 3px 8px; + min-height: 22px; + gap: 4px; +} + +.range-field-row:hover { + background: rgba(255, 255, 255, 0.02); +} + +.range-field-row > label { + width: 64px; + min-width: 64px; + flex-shrink: 0; + font-size: 11px; + color: #999; + font-weight: 400; +} + +.range-field-inputs { + flex: 1; + display: flex; + gap: 4px; + min-width: 0; + align-items: center; +} + +.range-field-inputs span { + font-size: 9px; + color: #666; +} + +.range-field-inputs input { + flex: 1; + min-width: 0; + height: 18px; + padding: 0 4px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 2px; + font-size: 10px; + font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + color: #ddd; + -moz-appearance: textfield; +} + +.range-field-inputs input::-webkit-outer-spin-button, +.range-field-inputs input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.range-field-inputs input:hover { + border-color: #4a4a4a; +} + +.range-field-inputs input:focus { + outline: none; + border-color: #4a9eff; +} + +/* ==================== Properties Panel ==================== */ +.particle-properties-panel { + width: 280px; + display: flex; + flex-direction: column; + background: #262626; + border-left: 1px solid #1a1a1a; + overflow: hidden; +} + +/* ==================== Property Section ==================== */ +.property-section { + padding: 6px; + border-bottom: 1px solid #1a1a1a; +} + +.section-header { + font-size: 10px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 6px; +} + +/* ==================== Preset Grid ==================== */ +.preset-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2px; +} + +.preset-button { + padding: 3px 2px; + border: 1px solid #333; + border-radius: 2px; + background: #1e1e1e; + color: #888; + font-size: 9px; + cursor: pointer; + transition: all 0.1s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.preset-button:hover { + background: #2a2a2a; + border-color: #444; + color: #ccc; +} + +.preset-button.selected { + background: #3b82f6; + border-color: #3b82f6; + color: #fff; +} + +/* ==================== Property Tabs ==================== */ +.property-tabs { + display: flex; + background: #262626; + border-bottom: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.property-tabs button { + flex: 1; + padding: 4px 2px; + border: none; + background: transparent; + color: #666; + font-size: 10px; + cursor: pointer; + transition: all 0.1s ease; + border-bottom: 2px solid transparent; +} + +.property-tabs button:hover { + color: #999; + background: rgba(255, 255, 255, 0.03); +} + +.property-tabs button.active { + color: #ccc; + border-bottom-color: #3b82f6; +} + +/* ==================== Property Content ==================== */ +.property-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 0; +} + +.property-content::-webkit-scrollbar { + width: 8px; +} + +.property-content::-webkit-scrollbar-track { + background: transparent; +} + +.property-content::-webkit-scrollbar-thumb { + background: rgba(121, 121, 121, 0.4); + border-radius: 4px; +} + +.property-content::-webkit-scrollbar-thumb:hover { + background: rgba(100, 100, 100, 0.7); +} + +/* ==================== Property Group ==================== */ +.property-group { + display: flex; + flex-direction: column; + gap: 0; +} + +/* ==================== Property Row - Matches EntityInspector ==================== */ +.property-row { + display: flex; + align-items: center; + padding: 3px 8px; + min-height: 22px; + gap: 4px; +} + +.property-row:hover { + background: rgba(255, 255, 255, 0.02); +} + +.property-row > label { + width: 64px; + min-width: 64px; + flex-shrink: 0; + font-size: 11px; + color: #999; + font-weight: 400; +} + +/* ==================== Number Input - Matches Transform Field ==================== */ +.property-row > input[type="text"], +.property-row > input[type="number"] { + flex: 1; + min-width: 0; + height: 18px; + padding: 0 4px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 2px; + font-size: 10px; + font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + color: #ddd; + -moz-appearance: textfield; +} + +.property-row > input[type="text"]::-webkit-outer-spin-button, +.property-row > input[type="text"]::-webkit-inner-spin-button, +.property-row > input[type="number"]::-webkit-outer-spin-button, +.property-row > input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.property-row > input[type="text"]:hover, +.property-row > input[type="number"]:hover { + border-color: #4a4a4a; + background: #222; +} + +.property-row > input[type="text"]:focus, +.property-row > input[type="number"]:focus { + outline: none; + border-color: #4a9eff; + background: rgba(74, 158, 255, 0.05); +} + +/* ==================== Select Input ==================== */ +.property-row > select { + flex: 1; + min-width: 0; + height: 18px; + padding: 0 4px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 2px; + font-size: 10px; + color: #ddd; + cursor: pointer; +} + +.property-row > select:hover { + border-color: #4a4a4a; + background: #222; +} + +.property-row > select:focus { + outline: none; + border-color: #4a9eff; +} + +/* ==================== Checkbox ==================== */ +.property-row > input[type="checkbox"] { + width: 14px; + height: 14px; + margin: 0; + cursor: pointer; + accent-color: #3b82f6; +} + +/* ==================== Color Input ==================== */ +.property-row > input[type="color"] { + width: 60px; + height: 18px; + padding: 0; + border: 1px solid #3a3a3a; + border-radius: 2px; + cursor: pointer; + background: transparent; +} + +.property-row > input[type="color"]:hover { + border-color: #4a4a4a; +} + +/* ==================== Range Inputs (Min/Max) ==================== */ +.property-row > div { + flex: 1; + display: flex; + gap: 3px; + min-width: 0; +} + +.property-row > div input[type="number"] { + flex: 1; + min-width: 0; + height: 18px; + padding: 0 4px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 2px; + font-size: 10px; + font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + color: #ddd; + -moz-appearance: textfield; +} + +.property-row > div input[type="number"]::-webkit-outer-spin-button, +.property-row > div input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.property-row > div input[type="number"]:hover { + border-color: #4a4a4a; + background: #222; +} + +.property-row > div input[type="number"]:focus { + outline: none; + border-color: #4a9eff; + background: rgba(74, 158, 255, 0.05); +} + +/* ==================== Module Section - Matches Component Item ==================== */ +.module-section { + background: #262626; + border-bottom: 1px solid #1a1a1a; +} + +.module-header { + display: flex; + align-items: center; + padding: 0 8px; + background: #2d2d2d; + cursor: pointer; + user-select: none; + height: 24px; + gap: 6px; +} + +.module-header:hover { + background: #333; +} + +.module-expand-icon { + display: flex; + align-items: center; + color: #888; + width: 12px; + height: 12px; + transition: transform 0.15s ease; +} + +.module-expand-icon.expanded { + transform: rotate(90deg); +} + +.module-checkbox { + width: 14px; + height: 14px; + margin: 0; + cursor: pointer; + accent-color: #3b82f6; +} + +.module-name { + flex: 1; + font-size: 11px; + font-weight: 500; + color: #ccc; +} + +.module-section.disabled .module-name { + color: #666; +} + +.module-content { + padding: 4px 0; + background: #262626; +} + +.module-content.collapsed { + display: none; +} + +/* ==================== Asset Field - Matches AssetField.css ==================== */ +.asset-field-row { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 4px 8px; +} + +.asset-field-label { + width: 64px; + min-width: 64px; + flex-shrink: 0; + font-size: 11px; + color: #999; + padding-top: 3px; +} + +.asset-field-content { + flex: 1; + display: flex; + align-items: flex-start; + gap: 6px; + min-width: 0; +} + +.asset-field-thumbnail { + width: 44px; + height: 44px; + background: #1a1a1a; + border: 1px solid #3a3a3a; + border-radius: 2px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + transition: border-color 0.15s ease; +} + +.asset-field-thumbnail:hover { + border-color: #4a4a4a; +} + +.asset-field-thumbnail.dragging { + border-color: #3b82f6; + background: rgba(59, 130, 246, 0.1); +} + +.asset-field-thumbnail img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.asset-field-thumbnail-icon { + color: #555; +} + +.asset-field-right { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.asset-field-dropdown { + display: flex; + align-items: center; + height: 22px; + padding: 0 8px; + background: #1a1a1a; + border: 1px solid #3a3a3a; + border-radius: 2px; + cursor: pointer; + transition: border-color 0.15s ease; + min-width: 0; +} + +.asset-field-dropdown:hover { + border-color: #4a4a4a; +} + +.asset-field-value { + flex: 1; + font-size: 11px; + color: #888; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-style: italic; +} + +.asset-field-dropdown.has-value .asset-field-value { + color: #ddd; + font-style: normal; +} + +.asset-field-dropdown-arrow { + color: #666; + flex-shrink: 0; + margin-left: 4px; +} + +.asset-field-actions { + display: flex; + align-items: center; + gap: 2px; +} + +.asset-field-btn { + width: 20px; + height: 20px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 2px; + color: #888; + cursor: pointer; + transition: all 0.15s ease; +} + +.asset-field-btn:hover { + background: #3a3a3a; + border-color: #4a4a4a; + color: #ccc; +} + +.asset-field-btn--clear:hover { + background: #4a2020; + border-color: #5a3030; + color: #f87171; +} + +/* ==================== Burst Section ==================== */ +.burst-list { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 8px; +} + +.burst-item { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; +} + +.burst-item input { + flex: 1; + min-width: 0; + height: 18px; + padding: 0 4px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 2px; + font-size: 10px; + font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + color: #ddd; + -moz-appearance: textfield; +} + +.burst-item input::-webkit-outer-spin-button, +.burst-item input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.burst-item input:hover { + border-color: #4a4a4a; +} + +.burst-item input:focus { + outline: none; + border-color: #4a9eff; +} + +.burst-item-label { + font-size: 9px; + color: #666; + width: 16px; + text-align: right; +} + +.burst-add-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 100%; + padding: 4px; + margin-top: 4px; + border: 1px dashed #3a3a3a; + border-radius: 2px; + background: transparent; + color: #666; + font-size: 10px; + cursor: pointer; + transition: all 0.1s ease; +} + +.burst-add-btn:hover { + border-color: #3b82f6; + color: #3b82f6; + background: rgba(59, 130, 246, 0.05); +} + +.burst-remove-btn { + width: 16px; + height: 16px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: #555; + cursor: pointer; + border-radius: 2px; + opacity: 0; + transition: all 0.1s ease; +} + +.burst-item:hover .burst-remove-btn { + opacity: 1; +} + +.burst-remove-btn:hover { + background: #ef4444; + color: #fff; +} + +/* ==================== Gradient Preview ==================== */ +.gradient-preview { + flex: 1; + height: 18px; + border: 1px solid #3a3a3a; + border-radius: 2px; + cursor: pointer; + position: relative; +} + +.gradient-preview:hover { + border-color: #4a4a4a; +} + +/* ==================== Curve Preview ==================== */ +.curve-preview { + flex: 1; + height: 32px; + border: 1px solid #3a3a3a; + border-radius: 2px; + background: #1e1e1e; + cursor: pointer; +} + +.curve-preview:hover { + border-color: #4a4a4a; +} + +/* ==================== Collapsible Group ==================== */ +.collapsible-group { + border-bottom: 1px solid #1a1a1a; +} + +.collapsible-header { + display: flex; + align-items: center; + gap: 4px; + padding: 0 6px; + background: rgba(0, 0, 0, 0.3); + cursor: pointer; + user-select: none; + height: 20px; +} + +.collapsible-header:hover { + background: rgba(0, 0, 0, 0.4); +} + +.collapsible-title { + flex: 1; + font-size: 10px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.collapsible-content { + padding: 0; +} + +.collapsible-content.collapsed { + display: none; +} + +/* ==================== Gradient Editor ==================== */ +.gradient-editor { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 8px; +} + +.gradient-track { + position: relative; + height: 20px; + border-radius: 2px; + cursor: crosshair; + border: 1px solid #3a3a3a; +} + +.gradient-track:hover { + border-color: #4a4a4a; +} + +.gradient-track-checker { + position: absolute; + inset: 0; + z-index: -1; + border-radius: 1px; + background: + linear-gradient(45deg, #404040 25%, transparent 25%), + linear-gradient(-45deg, #404040 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #404040 75%), + linear-gradient(-45deg, transparent 75%, #404040 75%); + background-size: 6px 6px; + background-position: 0 0, 0 3px, 3px -3px, -3px 0; +} + +.gradient-stop { + position: absolute; + top: 100%; + margin-top: 4px; + width: 8px; + height: 8px; + border: 2px solid #aaa; + border-radius: 50%; + transform: translateX(-50%); + cursor: ew-resize; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + transition: border-color 0.1s ease; +} + +.gradient-stop:hover { + border-color: #ccc; +} + +.gradient-stop.selected { + border-color: #4a9eff; +} + +.gradient-color-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.gradient-color-row label { + font-size: 10px; + color: #888; + min-width: 32px; +} + +.gradient-color-input { + width: 28px; + height: 18px; + padding: 0; + border: 1px solid #3a3a3a; + border-radius: 2px; + cursor: pointer; + background: transparent; +} + +.gradient-color-input:hover { + border-color: #4a4a4a; +} + +.gradient-alpha-slider { + flex: 1; + height: 4px; + min-width: 40px; + cursor: pointer; + accent-color: #4a9eff; +} + +.gradient-alpha-value { + font-size: 9px; + font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + color: #888; + min-width: 28px; + text-align: right; +} + +.gradient-delete-btn { + width: 16px; + height: 16px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: #666; + cursor: pointer; + border-radius: 2px; + transition: all 0.1s ease; +} + +.gradient-delete-btn:hover:not(:disabled) { + background: #ef4444; + color: #fff; +} + +.gradient-delete-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* ==================== Curve Editor ==================== */ +.curve-editor { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 8px; +} + +.curve-type-row { + display: flex; + align-items: center; + gap: 6px; +} + +.curve-type-row label { + font-size: 10px; + color: #888; + min-width: 32px; +} + +.curve-type-select { + flex: 1; + height: 18px; + padding: 0 4px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 2px; + font-size: 10px; + color: #ddd; + cursor: pointer; +} + +.curve-type-select:hover { + border-color: #4a4a4a; +} + +.curve-type-select:focus { + outline: none; + border-color: #4a9eff; +} + +.curve-canvas-container { + position: relative; + height: 100px; + border: 1px solid #3a3a3a; + border-radius: 3px; + overflow: hidden; + background: #1a1a1a; +} + +.curve-canvas-container:hover { + border-color: #4a4a4a; +} + +.curve-canvas { + display: block; + width: 100%; + height: 100%; +} + +.curve-edit-row { + display: flex; + align-items: center; + gap: 4px; + min-height: 20px; +} + +.curve-edit-row label { + font-size: 10px; + color: #888; + min-width: 12px; +} + +.curve-hint { + font-size: 10px; + color: #555; + font-style: italic; +} + +.curve-value-input { + width: 50px; + height: 18px; + padding: 0 4px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 2px; + font-size: 10px; + font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + color: #ddd; + -moz-appearance: textfield; +} + +.curve-value-input::-webkit-outer-spin-button, +.curve-value-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.curve-value-input:hover { + border-color: #4a4a4a; +} + +.curve-value-input:focus { + outline: none; + border-color: #4a9eff; +} + +.curve-delete-btn { + width: 18px; + height: 18px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: #2a2a2a; + border: 1px solid #3a3a3a; + color: #888; + cursor: pointer; + border-radius: 2px; + margin-left: auto; + transition: all 0.1s ease; +} + +.curve-delete-btn:hover:not(:disabled) { + background: #ef4444; + border-color: #ef4444; + color: #fff; +} + +.curve-delete-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.curve-presets { + display: flex; + gap: 3px; +} + +.curve-preset-btn { + flex: 1; + padding: 4px 6px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 2px; + color: #888; + font-size: 12px; + cursor: pointer; + transition: all 0.1s ease; +} + +.curve-preset-btn:hover { + background: #2a2a2a; + border-color: #4a9eff; + color: #4a9eff; +} + +.curve-preset-btn:active { + background: #4a9eff; + color: #fff; +} + +/* ==================== Texture Sheet Animation ==================== */ +.texture-sheet-preview { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; +} + +.texture-sheet-grid { + display: grid; + gap: 1px; + background: #333; + border: 1px solid #3a3a3a; + border-radius: 2px; + overflow: hidden; +} + +.texture-sheet-cell { + background: #1e1e1e; + aspect-ratio: 1; +} + +.texture-sheet-cell.active { + background: rgba(59, 130, 246, 0.3); +} diff --git a/packages/particle-editor/tsconfig.build.json b/packages/particle-editor/tsconfig.build.json new file mode 100644 index 00000000..ba0684d9 --- /dev/null +++ b/packages/particle-editor/tsconfig.build.json @@ -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"] +} diff --git a/packages/particle-editor/tsconfig.json b/packages/particle-editor/tsconfig.json new file mode 100644 index 00000000..d099ddd8 --- /dev/null +++ b/packages/particle-editor/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/particle-editor/tsup.config.ts b/packages/particle-editor/tsup.config.ts new file mode 100644 index 00000000..b4f49f5d --- /dev/null +++ b/packages/particle-editor/tsup.config.ts @@ -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' +}); diff --git a/packages/particle/module.json b/packages/particle/module.json new file mode 100644 index 00000000..76cadfde --- /dev/null +++ b/packages/particle/module.json @@ -0,0 +1,44 @@ +{ + "id": "particle", + "name": "@esengine/particle", + "displayName": "Particle System", + "description": "2D particle system for visual effects | 2D 粒子系统用于视觉特效", + "version": "1.0.0", + "category": "Rendering", + "icon": "Sparkles", + "tags": [ + "2d", + "particle", + "effects", + "vfx" + ], + "isCore": false, + "defaultEnabled": true, + "isEngineModule": true, + "canContainContent": true, + "platforms": [ + "web", + "desktop" + ], + "dependencies": [ + "core", + "math", + "engine-core", + "asset-system" + ], + "exports": { + "components": [ + "ParticleSystemComponent" + ], + "systems": [ + "ParticleUpdateSystem" + ], + "loaders": [ + "ParticleLoader" + ] + }, + "editorPackage": "@esengine/particle-editor", + "requiresWasm": false, + "outputPath": "dist/index.js", + "pluginExport": "ParticlePlugin" +} diff --git a/packages/particle/package.json b/packages/particle/package.json new file mode 100644 index 00000000..cb2403da --- /dev/null +++ b/packages/particle/package.json @@ -0,0 +1,49 @@ +{ + "name": "@esengine/particle", + "version": "1.0.0", + "description": "ECS-based 2D particle system", + "esengine": { + "plugin": true, + "pluginExport": "ParticlePlugin", + "category": "rendering", + "isEnginePlugin": true + }, + "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" + }, + "devDependencies": { + "@esengine/asset-system": "workspace:*", + "@esengine/ecs-framework": "workspace:*", + "@esengine/ecs-framework-math": "workspace:*", + "@esengine/engine-core": "workspace:*", + "@esengine/build-config": "workspace:*", + "rimraf": "^5.0.5", + "tsup": "^8.0.0", + "typescript": "^5.3.3" + }, + "keywords": [ + "ecs", + "particle", + "2d", + "webgl", + "effects" + ], + "author": "yhh", + "license": "MIT" +} diff --git a/packages/particle/src/Particle.ts b/packages/particle/src/Particle.ts new file mode 100644 index 00000000..10947c4b --- /dev/null +++ b/packages/particle/src/Particle.ts @@ -0,0 +1,233 @@ +/** + * 粒子数据结构 + * Particle data structure + * + * Represents a single particle with all its runtime state. + * 表示单个粒子及其所有运行时状态。 + */ +export interface Particle { + /** 是否存活 | Whether particle is alive */ + alive: boolean; + + /** 位置X | Position X */ + x: number; + /** 位置Y | Position Y */ + y: number; + + /** 速度X | Velocity X */ + vx: number; + /** 速度Y | Velocity Y */ + vy: number; + + /** 加速度X | Acceleration X */ + ax: number; + /** 加速度Y | Acceleration Y */ + ay: number; + + /** 旋转角度(弧度)| Rotation (radians) */ + rotation: number; + /** 角速度 | Angular velocity */ + angularVelocity: number; + + /** 缩放X | Scale X */ + scaleX: number; + /** 缩放Y | Scale Y */ + scaleY: number; + + /** 颜色R (0-1) | Color R (0-1) */ + r: number; + /** 颜色G (0-1) | Color G (0-1) */ + g: number; + /** 颜色B (0-1) | Color B (0-1) */ + b: number; + /** 透明度 (0-1) | Alpha (0-1) */ + alpha: number; + + /** 当前生命时间(秒)| Current lifetime (seconds) */ + age: number; + /** 总生命时间(秒)| Total lifetime (seconds) */ + lifetime: number; + + /** 初始缩放X | Initial scale X */ + startScaleX: number; + /** 初始缩放Y | Initial scale Y */ + startScaleY: number; + + /** 初始颜色R | Initial color R */ + startR: number; + /** 初始颜色G | Initial color G */ + startG: number; + /** 初始颜色B | Initial color B */ + startB: number; + /** 初始透明度 | Initial alpha */ + startAlpha: number; + + /** 自定义数据槽 | Custom data slot */ + userData?: any; +} + +/** + * 创建新粒子 + * Create a new particle + */ +export function createParticle(): Particle { + return { + alive: false, + x: 0, + y: 0, + vx: 0, + vy: 0, + ax: 0, + ay: 0, + rotation: 0, + angularVelocity: 0, + scaleX: 1, + scaleY: 1, + r: 1, + g: 1, + b: 1, + alpha: 1, + age: 0, + lifetime: 1, + startScaleX: 1, + startScaleY: 1, + startR: 1, + startG: 1, + startB: 1, + startAlpha: 1 + }; +} + +/** + * 重置粒子状态 + * Reset particle state + */ +export function resetParticle(p: Particle): void { + p.alive = false; + p.x = 0; + p.y = 0; + p.vx = 0; + p.vy = 0; + p.ax = 0; + p.ay = 0; + p.rotation = 0; + p.angularVelocity = 0; + p.scaleX = 1; + p.scaleY = 1; + p.r = 1; + p.g = 1; + p.b = 1; + p.alpha = 1; + p.age = 0; + p.lifetime = 1; + p.startScaleX = 1; + p.startScaleY = 1; + p.startR = 1; + p.startG = 1; + p.startB = 1; + p.startAlpha = 1; + p.userData = undefined; +} + +/** + * 粒子池 + * Particle pool for efficient memory management + */ +export class ParticlePool { + private _particles: Particle[] = []; + private _capacity: number; + private _activeCount: number = 0; + + constructor(capacity: number) { + this._capacity = capacity; + for (let i = 0; i < capacity; i++) { + this._particles.push(createParticle()); + } + } + + /** 池容量 | Pool capacity */ + get capacity(): number { + return this._capacity; + } + + /** 活跃粒子数 | Active particle count */ + get activeCount(): number { + return this._activeCount; + } + + /** 所有粒子(包括不活跃的)| All particles (including inactive) */ + get particles(): readonly Particle[] { + return this._particles; + } + + /** + * 获取一个空闲粒子 + * Get an inactive particle + */ + spawn(): Particle | null { + for (const p of this._particles) { + if (!p.alive) { + p.alive = true; + this._activeCount++; + return p; + } + } + return null; + } + + /** + * 回收粒子 + * Recycle a particle + */ + recycle(p: Particle): void { + if (p.alive) { + p.alive = false; + this._activeCount--; + } + } + + /** + * 回收所有粒子 + * Recycle all particles + */ + recycleAll(): void { + for (const p of this._particles) { + p.alive = false; + } + this._activeCount = 0; + } + + /** + * 遍历活跃粒子 + * Iterate over active particles + */ + forEachActive(callback: (p: Particle, index: number) => void): void { + let index = 0; + for (const p of this._particles) { + if (p.alive) { + callback(p, index++); + } + } + } + + /** + * 调整池大小 + * Resize the pool + */ + resize(newCapacity: number): void { + if (newCapacity > this._capacity) { + for (let i = this._capacity; i < newCapacity; i++) { + this._particles.push(createParticle()); + } + } else if (newCapacity < this._capacity) { + // 回收超出容量的活跃粒子 | Recycle active particles beyond capacity + for (let i = newCapacity; i < this._capacity; i++) { + if (this._particles[i].alive) { + this._activeCount--; + } + } + this._particles.length = newCapacity; + } + this._capacity = newCapacity; + } +} diff --git a/packages/particle/src/ParticleEmitter.ts b/packages/particle/src/ParticleEmitter.ts new file mode 100644 index 00000000..f5d01876 --- /dev/null +++ b/packages/particle/src/ParticleEmitter.ts @@ -0,0 +1,339 @@ +import type { Particle, ParticlePool } from './Particle'; + +/** + * 发射形状类型 + * Emission shape type + */ +export enum EmissionShape { + /** 点发射 | Point emission */ + Point = 'point', + /** 圆形发射(填充)| Circle emission (filled) */ + Circle = 'circle', + /** 矩形发射 | Rectangle emission */ + Rectangle = 'rectangle', + /** 线段发射 | Line emission */ + Line = 'line', + /** 圆锥/扇形发射 | Cone/fan emission */ + Cone = 'cone', + /** 圆环发射(边缘)| Ring emission (edge only) */ + Ring = 'ring', + /** 矩形边缘发射 | Rectangle edge emission */ + Edge = 'edge' +} + +/** + * 数值范围 + * Value range for randomization + */ +export interface ValueRange { + min: number; + max: number; +} + +/** + * 颜色值 + * Color value (RGBA) + */ +export interface ColorValue { + r: number; + g: number; + b: number; + a: number; +} + +/** + * 发射器配置 + * Emitter configuration + */ +export interface EmitterConfig { + /** 每秒发射数量 | Particles per second */ + emissionRate: number; + + /** 单次爆发数量(0表示持续发射)| Burst count (0 for continuous) */ + burstCount: number; + + /** 粒子生命时间范围(秒)| Particle lifetime range (seconds) */ + lifetime: ValueRange; + + /** 发射形状 | Emission shape */ + shape: EmissionShape; + + /** 形状半径(用于圆形/圆锥)| Shape radius (for circle/cone) */ + shapeRadius: number; + + /** 形状宽度(用于矩形/线段)| Shape width (for rectangle/line) */ + shapeWidth: number; + + /** 形状高度(用于矩形)| Shape height (for rectangle) */ + shapeHeight: number; + + /** 圆锥角度(弧度,用于圆锥发射)| Cone angle (radians, for cone shape) */ + coneAngle: number; + + /** 发射方向(弧度,0=右)| Emission direction (radians, 0=right) */ + direction: number; + + /** 发射方向随机范围(弧度)| Direction random spread (radians) */ + directionSpread: number; + + /** 初始速度范围 | Initial speed range */ + speed: ValueRange; + + /** 初始角速度范围 | Initial angular velocity range */ + angularVelocity: ValueRange; + + /** 初始缩放范围 | Initial scale range */ + startScale: ValueRange; + + /** 初始旋转范围(弧度)| Initial rotation range (radians) */ + startRotation: ValueRange; + + /** 初始颜色 | Initial color */ + startColor: ColorValue; + + /** 初始颜色变化范围 | Initial color variance */ + startColorVariance: ColorValue; + + /** 重力X | Gravity X */ + gravityX: number; + + /** 重力Y | Gravity Y */ + gravityY: number; +} + +/** + * 创建默认发射器配置 + * Create default emitter configuration + */ +export function createDefaultEmitterConfig(): EmitterConfig { + return { + emissionRate: 10, + burstCount: 0, + lifetime: { min: 1, max: 2 }, + shape: EmissionShape.Point, + shapeRadius: 0, + shapeWidth: 0, + shapeHeight: 0, + coneAngle: Math.PI / 6, + direction: -Math.PI / 2, + directionSpread: 0, + speed: { min: 50, max: 100 }, + angularVelocity: { min: 0, max: 0 }, + startScale: { min: 1, max: 1 }, + startRotation: { min: 0, max: 0 }, + startColor: { r: 1, g: 1, b: 1, a: 1 }, + startColorVariance: { r: 0, g: 0, b: 0, a: 0 }, + gravityX: 0, + gravityY: 0 + }; +} + +/** + * 粒子发射器 + * Particle emitter - handles particle spawning + */ +export class ParticleEmitter { + public config: EmitterConfig; + + private _emissionAccumulator: number = 0; + private _isEmitting: boolean = true; + + constructor(config?: Partial) { + this.config = { ...createDefaultEmitterConfig(), ...config }; + } + + /** 是否正在发射 | Whether emitter is active */ + get isEmitting(): boolean { + return this._isEmitting; + } + + set isEmitting(value: boolean) { + this._isEmitting = value; + } + + /** + * 发射粒子 + * Emit particles + * + * @param pool - Particle pool + * @param dt - Delta time in seconds + * @param worldX - World position X + * @param worldY - World position Y + * @returns Number of particles emitted + */ + emit(pool: ParticlePool, dt: number, worldX: number, worldY: number): number { + if (!this._isEmitting) return 0; + + let emitted = 0; + + if (this.config.burstCount > 0) { + // 爆发模式 | Burst mode + for (let i = 0; i < this.config.burstCount; i++) { + const p = pool.spawn(); + if (p) { + this._initParticle(p, worldX, worldY); + emitted++; + } + } + this._isEmitting = false; + } else { + // 持续发射 | Continuous emission + this._emissionAccumulator += this.config.emissionRate * dt; + while (this._emissionAccumulator >= 1) { + const p = pool.spawn(); + if (p) { + this._initParticle(p, worldX, worldY); + emitted++; + } + this._emissionAccumulator -= 1; + } + } + + return emitted; + } + + /** + * 立即爆发发射 + * Burst emit immediately + */ + burst(pool: ParticlePool, count: number, worldX: number, worldY: number): number { + let emitted = 0; + for (let i = 0; i < count; i++) { + const p = pool.spawn(); + if (p) { + this._initParticle(p, worldX, worldY); + emitted++; + } + } + return emitted; + } + + /** + * 重置发射器 + * Reset emitter + */ + reset(): void { + this._emissionAccumulator = 0; + this._isEmitting = true; + } + + private _initParticle(p: Particle, worldX: number, worldY: number): void { + const config = this.config; + + // 位置 | Position + const [ox, oy] = this._getShapeOffset(); + p.x = worldX + ox; + p.y = worldY + oy; + + // 生命时间 | Lifetime + p.lifetime = randomRange(config.lifetime.min, config.lifetime.max); + p.age = 0; + + // 速度方向 | Velocity direction + const dir = config.direction + randomRange(-config.directionSpread / 2, config.directionSpread / 2); + const speed = randomRange(config.speed.min, config.speed.max); + p.vx = Math.cos(dir) * speed; + p.vy = Math.sin(dir) * speed; + + // 加速度(重力)| Acceleration (gravity) + p.ax = config.gravityX; + p.ay = config.gravityY; + + // 旋转 | Rotation + p.rotation = randomRange(config.startRotation.min, config.startRotation.max); + p.angularVelocity = randomRange(config.angularVelocity.min, config.angularVelocity.max); + + // 缩放 | Scale + const scale = randomRange(config.startScale.min, config.startScale.max); + p.scaleX = scale; + p.scaleY = scale; + p.startScaleX = scale; + p.startScaleY = scale; + + // 颜色 | Color + p.r = clamp(config.startColor.r + randomRange(-config.startColorVariance.r, config.startColorVariance.r), 0, 1); + p.g = clamp(config.startColor.g + randomRange(-config.startColorVariance.g, config.startColorVariance.g), 0, 1); + p.b = clamp(config.startColor.b + randomRange(-config.startColorVariance.b, config.startColorVariance.b), 0, 1); + p.alpha = clamp(config.startColor.a + randomRange(-config.startColorVariance.a, config.startColorVariance.a), 0, 1); + p.startR = p.r; + p.startG = p.g; + p.startB = p.b; + p.startAlpha = p.alpha; + } + + private _getShapeOffset(): [number, number] { + const config = this.config; + + switch (config.shape) { + case EmissionShape.Point: + return [0, 0]; + + case EmissionShape.Circle: { + // 填充圆形 | Filled circle + const angle = Math.random() * Math.PI * 2; + const radius = Math.random() * config.shapeRadius; + return [Math.cos(angle) * radius, Math.sin(angle) * radius]; + } + + case EmissionShape.Ring: { + // 圆环边缘 | Ring edge only + const angle = Math.random() * Math.PI * 2; + return [Math.cos(angle) * config.shapeRadius, Math.sin(angle) * config.shapeRadius]; + } + + case EmissionShape.Rectangle: { + // 填充矩形 | Filled rectangle + const x = randomRange(-config.shapeWidth / 2, config.shapeWidth / 2); + const y = randomRange(-config.shapeHeight / 2, config.shapeHeight / 2); + return [x, y]; + } + + case EmissionShape.Edge: { + // 矩形边缘 | Rectangle edge only + const perimeter = 2 * (config.shapeWidth + config.shapeHeight); + const t = Math.random() * perimeter; + + const w = config.shapeWidth; + const h = config.shapeHeight; + + if (t < w) { + // 上边 | Top edge + return [t - w / 2, h / 2]; + } else if (t < w + h) { + // 右边 | Right edge + return [w / 2, h / 2 - (t - w)]; + } else if (t < 2 * w + h) { + // 下边 | Bottom edge + return [w / 2 - (t - w - h), -h / 2]; + } else { + // 左边 | Left edge + return [-w / 2, -h / 2 + (t - 2 * w - h)]; + } + } + + case EmissionShape.Line: { + const t = Math.random() - 0.5; + const cos = Math.cos(config.direction + Math.PI / 2); + const sin = Math.sin(config.direction + Math.PI / 2); + return [cos * config.shapeWidth * t, sin * config.shapeWidth * t]; + } + + case EmissionShape.Cone: { + const angle = config.direction + randomRange(-config.coneAngle / 2, config.coneAngle / 2); + const radius = Math.random() * config.shapeRadius; + return [Math.cos(angle) * radius, Math.sin(angle) * radius]; + } + + default: + return [0, 0]; + } + } +} + +function randomRange(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/packages/particle/src/ParticleRuntimeModule.ts b/packages/particle/src/ParticleRuntimeModule.ts new file mode 100644 index 00000000..1fe96ea5 --- /dev/null +++ b/packages/particle/src/ParticleRuntimeModule.ts @@ -0,0 +1,87 @@ +import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework'; +import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core'; +import { ParticleSystemComponent } from './ParticleSystemComponent'; +import { ParticleUpdateSystem } from './systems/ParticleSystem'; + +export type { SystemContext, ModuleManifest, IRuntimeModule as IRuntimeModuleLoader, IPlugin as IPluginLoader }; + +/** + * 粒子系统上下文 + * Particle system context + */ +export interface ParticleSystemContext extends SystemContext { + particleUpdateSystem?: ParticleUpdateSystem; + /** Transform 组件类型 | Transform component type */ + transformType?: new (...args: any[]) => any; + /** 渲染系统(用于注册渲染数据提供者)| Render system (for registering render data provider) */ + renderSystem?: { + addRenderDataProvider(provider: any): void; + removeRenderDataProvider(provider: any): void; + }; +} + +class ParticleRuntimeModule implements IRuntimeModule { + private _updateSystem: ParticleUpdateSystem | null = null; + + registerComponents(registry: typeof ComponentRegistryType): void { + registry.register(ParticleSystemComponent); + } + + createSystems(scene: IScene, context: SystemContext): void { + const particleContext = context as ParticleSystemContext; + + this._updateSystem = new ParticleUpdateSystem(); + + // 设置 Transform 组件类型 | Set Transform component type + if (particleContext.transformType) { + this._updateSystem.setTransformType(particleContext.transformType); + } + + // 在编辑器中禁用系统(手动控制)| Disable in editor (manual control) + if (context.isEditor) { + this._updateSystem.enabled = false; + } + + scene.addSystem(this._updateSystem); + particleContext.particleUpdateSystem = this._updateSystem; + + // 注册渲染数据提供者 | Register render data provider + if (particleContext.renderSystem) { + const renderDataProvider = this._updateSystem.getRenderDataProvider(); + particleContext.renderSystem.addRenderDataProvider(renderDataProvider); + } + } + + /** + * 获取粒子更新系统 + * Get particle update system + */ + get updateSystem(): ParticleUpdateSystem | null { + return this._updateSystem; + } +} + +const manifest: ModuleManifest = { + id: 'particle', + name: '@esengine/particle', + displayName: 'Particle System', + version: '1.0.0', + description: 'Particle system for 2D effects', + category: 'Rendering', + icon: 'Sparkles', + isCore: false, + defaultEnabled: false, + isEngineModule: true, + canContainContent: true, + dependencies: ['core', 'math', 'sprite'], + exports: { components: ['ParticleSystemComponent'] }, + editorPackage: '@esengine/particle-editor', + requiresWasm: false +}; + +export const ParticlePlugin: IPlugin = { + manifest, + runtimeModule: new ParticleRuntimeModule() +}; + +export { ParticleRuntimeModule }; diff --git a/packages/particle/src/ParticleSystemComponent.ts b/packages/particle/src/ParticleSystemComponent.ts new file mode 100644 index 00000000..e39e941e --- /dev/null +++ b/packages/particle/src/ParticleSystemComponent.ts @@ -0,0 +1,600 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import { ParticlePool } from './Particle'; +import { ParticleEmitter, EmissionShape, createDefaultEmitterConfig, type EmitterConfig, type ValueRange, type ColorValue } from './ParticleEmitter'; +import type { IParticleModule } from './modules/IParticleModule'; +import { ColorOverLifetimeModule } from './modules/ColorOverLifetimeModule'; +import { SizeOverLifetimeModule } from './modules/SizeOverLifetimeModule'; +import { CollisionModule } from './modules/CollisionModule'; +import { ForceFieldModule } from './modules/ForceFieldModule'; + +/** + * 爆发配置 + * Burst configuration + */ +export interface BurstConfig { + /** 触发时间(秒)| Trigger time (seconds) */ + time: number; + /** 发射数量 | Particle count */ + count: number; + /** 循环次数(0=无限)| Number of cycles (0=infinite) */ + cycles: number; + /** 循环间隔(秒)| Interval between cycles (seconds) */ + interval: number; +} + +/** + * 粒子混合模式 + * Particle blend mode + */ +export enum ParticleBlendMode { + /** 正常混合 | Normal blend */ + Normal = 'normal', + /** 叠加 | Additive */ + Additive = 'additive', + /** 正片叠底 | Multiply */ + Multiply = 'multiply' +} + +/** + * 模拟空间 + * Simulation space + */ +export enum SimulationSpace { + /** 本地空间(粒子跟随发射器)| Local space (particles follow emitter) */ + Local = 'local', + /** 世界空间(粒子不跟随发射器)| World space (particles don't follow emitter) */ + World = 'world' +} + +/** + * 粒子系统组件 + * Particle system component + * + * Manages particle emission, simulation, and provides data for rendering. + * 管理粒子发射、模拟,并为渲染提供数据。 + */ +@ECSComponent('ParticleSystem') +@Serializable({ version: 1, typeId: 'ParticleSystem' }) +export class ParticleSystemComponent extends Component { + // ============= 基础属性 | Basic Properties ============= + + /** 最大粒子数量 | Maximum particle count */ + @Serialize() + @Property({ type: 'integer', label: 'Max Particles', min: 1, max: 10000 }) + public maxParticles: number = 1000; + + /** 是否循环播放 | Whether to loop */ + @Serialize() + @Property({ type: 'boolean', label: 'Looping' }) + public looping: boolean = true; + + /** 预热时间(秒)| Prewarm time (seconds) */ + @Serialize() + @Property({ type: 'number', label: 'Prewarm Time', min: 0 }) + public prewarmTime: number = 0; + + /** 持续时间(秒,非循环时使用)| Duration (seconds, for non-looping) */ + @Serialize() + @Property({ type: 'number', label: 'Duration', min: 0.1 }) + public duration: number = 5; + + /** 播放速度倍率 | Playback speed multiplier */ + @Serialize() + @Property({ type: 'number', label: 'Playback Speed', min: 0.01, max: 10 }) + public playbackSpeed: number = 1; + + /** 模拟空间 | Simulation space */ + @Serialize() + @Property({ type: 'enum', label: 'Simulation Space', options: [ + { value: 'world', label: 'World' }, + { value: 'local', label: 'Local' } + ]}) + public simulationSpace: SimulationSpace = SimulationSpace.World; + + // ============= 发射器属性 | Emitter Properties ============= + + /** 每秒发射数量 | Emission rate (particles per second) */ + @Serialize() + @Property({ type: 'number', label: 'Emission Rate', min: 0 }) + public emissionRate: number = 10; + + /** 发射形状 | Emission shape */ + @Serialize() + @Property({ type: 'enum', label: 'Shape', options: [ + { value: 'point', label: 'Point' }, + { value: 'circle', label: 'Circle' }, + { value: 'rectangle', label: 'Rectangle' }, + { value: 'line', label: 'Line' }, + { value: 'cone', label: 'Cone' } + ]}) + public emissionShape: EmissionShape = EmissionShape.Point; + + /** 形状半径 | Shape radius */ + @Serialize() + @Property({ type: 'number', label: 'Shape Radius', min: 0 }) + public shapeRadius: number = 0; + + /** 形状宽度 | Shape width */ + @Serialize() + @Property({ type: 'number', label: 'Shape Width', min: 0 }) + public shapeWidth: number = 0; + + /** 形状高度 | Shape height */ + @Serialize() + @Property({ type: 'number', label: 'Shape Height', min: 0 }) + public shapeHeight: number = 0; + + // ============= 粒子属性 | Particle Properties ============= + + /** 粒子生命时间最小值(秒)| Particle lifetime min (seconds) */ + @Serialize() + @Property({ type: 'number', label: 'Lifetime Min', min: 0.01 }) + public lifetimeMin: number = 1; + + /** 粒子生命时间最大值(秒)| Particle lifetime max (seconds) */ + @Serialize() + @Property({ type: 'number', label: 'Lifetime Max', min: 0.01 }) + public lifetimeMax: number = 2; + + /** 初始速度最小值 | Initial speed min */ + @Serialize() + @Property({ type: 'number', label: 'Speed Min', min: 0 }) + public speedMin: number = 50; + + /** 初始速度最大值 | Initial speed max */ + @Serialize() + @Property({ type: 'number', label: 'Speed Max', min: 0 }) + public speedMax: number = 100; + + /** 发射方向(角度)| Emission direction (degrees) */ + @Serialize() + @Property({ type: 'number', label: 'Direction', min: -180, max: 180 }) + public direction: number = -90; + + /** 发射方向扩散(角度)| Direction spread (degrees) */ + @Serialize() + @Property({ type: 'number', label: 'Direction Spread', min: 0, max: 360 }) + public directionSpread: number = 0; + + /** 初始缩放最小值 | Initial scale min */ + @Serialize() + @Property({ type: 'number', label: 'Scale Min', min: 0.01 }) + public scaleMin: number = 1; + + /** 初始缩放最大值 | Initial scale max */ + @Serialize() + @Property({ type: 'number', label: 'Scale Max', min: 0.01 }) + public scaleMax: number = 1; + + /** 重力X | Gravity X */ + @Serialize() + @Property({ type: 'number', label: 'Gravity X' }) + public gravityX: number = 0; + + /** 重力Y | Gravity Y */ + @Serialize() + @Property({ type: 'number', label: 'Gravity Y' }) + public gravityY: number = 0; + + // ============= 颜色属性 | Color Properties ============= + + /** 起始颜色 | Start color */ + @Serialize() + @Property({ type: 'color', label: 'Start Color' }) + public startColor: string = '#ffffff'; + + /** 起始透明度 | Start alpha */ + @Serialize() + @Property({ type: 'number', label: 'Start Alpha', min: 0, max: 1, step: 0.01 }) + public startAlpha: number = 1; + + /** 结束透明度(淡出)| End alpha (fade out) */ + @Serialize() + @Property({ type: 'number', label: 'End Alpha', min: 0, max: 1, step: 0.01 }) + public endAlpha: number = 0; + + /** 结束缩放乘数 | End scale multiplier */ + @Serialize() + @Property({ type: 'number', label: 'End Scale', min: 0 }) + public endScale: number = 0; + + // ============= 渲染属性 | Rendering Properties ============= + + /** 粒子纹理 | Particle texture */ + @Serialize() + @Property({ type: 'asset', label: 'Texture', assetType: 'texture' }) + public texture: string = ''; + + /** 粒子尺寸(像素)| Particle size (pixels) */ + @Serialize() + @Property({ type: 'number', label: 'Particle Size', min: 1 }) + public particleSize: number = 8; + + /** 混合模式 | Blend mode */ + @Serialize() + @Property({ type: 'enum', label: 'Blend Mode', options: [ + { value: 'normal', label: 'Normal' }, + { value: 'additive', label: 'Additive' }, + { value: 'multiply', label: 'Multiply' } + ]}) + public blendMode: ParticleBlendMode = ParticleBlendMode.Additive; + + /** 排序顺序 | Sorting order */ + @Serialize() + @Property({ type: 'integer', label: 'Sorting Order' }) + public sortingOrder: number = 0; + + // ============= 爆发配置 | Burst Configuration ============= + + /** 爆发列表 | Burst list */ + @Serialize() + public bursts: BurstConfig[] = []; + + // ============= 运行时状态 | Runtime State ============= + + private _pool: ParticlePool | null = null; + private _emitter: ParticleEmitter | null = null; + private _modules: IParticleModule[] = []; + private _isPlaying: boolean = false; + private _elapsedTime: number = 0; + private _needsRebuild: boolean = true; + /** 爆发状态追踪 | Burst state tracking */ + private _burstStates: { firedCount: number; lastFireTime: number }[] = []; + /** 上一帧发射器位置(本地空间用)| Last frame emitter position (for local space) */ + private _lastEmitterX: number = 0; + private _lastEmitterY: number = 0; + + /** 纹理ID(运行时)| Texture ID (runtime) */ + public textureId: number = 0; + + /** 是否正在播放 | Whether playing */ + get isPlaying(): boolean { + return this._isPlaying; + } + + /** 已播放时间 | Elapsed time */ + get elapsedTime(): number { + return this._elapsedTime; + } + + /** 活跃粒子数 | Active particle count */ + get activeParticleCount(): number { + return this._pool?.activeCount ?? 0; + } + + /** 粒子池 | Particle pool */ + get pool(): ParticlePool | null { + return this._pool; + } + + /** 粒子模块列表 | Particle modules */ + get modules(): IParticleModule[] { + return this._modules; + } + + /** + * 初始化粒子系统 + * Initialize particle system + */ + initialize(): void { + this._rebuildIfNeeded(); + } + + /** + * 播放粒子系统 + * Play particle system + * + * @param worldX - Initial world position X for prewarm | 预热时的初始世界坐标X + * @param worldY - Initial world position Y for prewarm | 预热时的初始世界坐标Y + */ + play(worldX: number = 0, worldY: number = 0): void { + this._rebuildIfNeeded(); + this._isPlaying = true; + this._emitter!.isEmitting = true; + this._elapsedTime = 0; + // 初始化爆发状态 | Initialize burst states + this._burstStates = this.bursts.map(() => ({ firedCount: 0, lastFireTime: -Infinity })); + // 初始化发射器位置 | Initialize emitter position + this._lastEmitterX = worldX; + this._lastEmitterY = worldY; + + if (this.prewarmTime > 0) { + this._simulate(this.prewarmTime, worldX, worldY); + } + } + + /** + * 停止粒子系统 + * Stop particle system + */ + stop(clearParticles: boolean = false): void { + this._isPlaying = false; + this._emitter!.isEmitting = false; + this._elapsedTime = 0; + // 重置爆发状态 | Reset burst states + this._burstStates = this.bursts.map(() => ({ firedCount: 0, lastFireTime: -Infinity })); + + if (clearParticles) { + this._pool?.recycleAll(); + } + } + + /** + * 暂停粒子系统 + * Pause particle system + */ + pause(): void { + this._isPlaying = false; + } + + /** + * 立即爆发发射 + * Burst emit + * + * @param count - Number of particles to emit | 发射的粒子数量 + * @param worldX - World position X | 世界坐标X + * @param worldY - World position Y | 世界坐标Y + */ + burst(count: number, worldX: number = 0, worldY: number = 0): void { + this._rebuildIfNeeded(); + if (!this._emitter || !this._pool) return; + + this._emitter.burst(this._pool, count, worldX, worldY); + } + + /** + * 更新粒子系统 + * Update particle system + * + * @param dt - Delta time in seconds | 时间增量(秒) + * @param worldX - World position X for emission | 发射位置世界坐标X + * @param worldY - World position Y for emission | 发射位置世界坐标Y + */ + update(dt: number, worldX: number = 0, worldY: number = 0): void { + if (!this._isPlaying || !this._pool || !this._emitter) return; + + const scaledDt = dt * this.playbackSpeed; + this._simulate(scaledDt, worldX, worldY); + this._elapsedTime += scaledDt; + + // 检查持续时间 | Check duration + if (!this.looping && this._elapsedTime >= this.duration) { + this._emitter.isEmitting = false; + if (this._pool.activeCount === 0) { + this._isPlaying = false; + } + } + } + + /** + * 添加模块 + * Add module + */ + addModule(module: T): T { + this._modules.push(module); + return module; + } + + /** + * 获取模块 + * Get module by type + */ + getModule(name: string): T | undefined { + return this._modules.find(m => m.name === name) as T | undefined; + } + + /** + * 移除模块 + * Remove module + */ + removeModule(module: IParticleModule): boolean { + const index = this._modules.indexOf(module); + if (index >= 0) { + this._modules.splice(index, 1); + return true; + } + return false; + } + + /** + * 标记需要重建 + * Mark for rebuild + */ + markDirty(): void { + this._needsRebuild = true; + } + + private _rebuildIfNeeded(): void { + if (!this._needsRebuild && this._pool && this._emitter) return; + + // 创建/调整粒子池 | Create/resize particle pool + if (!this._pool) { + this._pool = new ParticlePool(this.maxParticles); + } else if (this._pool.capacity !== this.maxParticles) { + this._pool.resize(this.maxParticles); + } + + // 解析颜色 | Parse color + const color = this._parseColor(this.startColor); + + // 创建发射器配置 | Create emitter config + const config: EmitterConfig = { + ...createDefaultEmitterConfig(), + emissionRate: this.emissionRate, + burstCount: 0, + lifetime: { min: this.lifetimeMin, max: this.lifetimeMax }, + shape: this.emissionShape, + shapeRadius: this.shapeRadius, + shapeWidth: this.shapeWidth, + shapeHeight: this.shapeHeight, + coneAngle: Math.PI / 6, + direction: this.direction * Math.PI / 180, + directionSpread: this.directionSpread * Math.PI / 180, + speed: { min: this.speedMin, max: this.speedMax }, + angularVelocity: { min: 0, max: 0 }, + startScale: { min: this.scaleMin, max: this.scaleMax }, + startRotation: { min: 0, max: 0 }, + startColor: { ...color, a: this.startAlpha }, + startColorVariance: { r: 0, g: 0, b: 0, a: 0 }, + gravityX: this.gravityX, + gravityY: this.gravityY + }; + + if (!this._emitter) { + this._emitter = new ParticleEmitter(config); + } else { + this._emitter.config = config; + } + + // 设置默认模块 | Setup default modules + if (this._modules.length === 0) { + // 颜色模块(淡出)| Color module (fade out) + const colorModule = new ColorOverLifetimeModule(); + colorModule.gradient = [ + { time: 0, r: 1, g: 1, b: 1, a: 1 }, + { time: 1, r: 1, g: 1, b: 1, a: this.endAlpha } + ]; + this._modules.push(colorModule); + + // 缩放模块 | Size module + const sizeModule = new SizeOverLifetimeModule(); + sizeModule.startMultiplier = 1; + sizeModule.endMultiplier = this.endScale; + this._modules.push(sizeModule); + } + + this._needsRebuild = false; + } + + private _simulate(dt: number, worldX: number, worldY: number): void { + if (!this._pool || !this._emitter) return; + + // 本地空间:计算发射器移动量 | Local space: calculate emitter movement + const isLocalSpace = this.simulationSpace === SimulationSpace.Local; + const emitterDeltaX = worldX - this._lastEmitterX; + const emitterDeltaY = worldY - this._lastEmitterY; + + // 发射新粒子 | Emit new particles + this._emitter.emit(this._pool, dt, worldX, worldY); + + // 处理定时爆发 | Process timed bursts + this._processBursts(worldX, worldY); + + // 查找碰撞模块并更新发射器位置 | Find collision module and update emitter position + const collisionModule = this._modules.find(m => m instanceof CollisionModule) as CollisionModule | undefined; + if (collisionModule) { + collisionModule.emitterX = worldX; + collisionModule.emitterY = worldY; + collisionModule.clearDeathFlags(); + } + + // 查找力场模块并更新发射器位置 | Find force field module and update emitter position + const forceFieldModule = this._modules.find(m => m instanceof ForceFieldModule) as ForceFieldModule | undefined; + if (forceFieldModule) { + forceFieldModule.emitterX = worldX; + forceFieldModule.emitterY = worldY; + } + + // 更新粒子 | Update particles + this._pool.forEachActive((p) => { + // 本地空间:粒子跟随发射器移动 | Local space: particles follow emitter + if (isLocalSpace) { + p.x += emitterDeltaX; + p.y += emitterDeltaY; + } + + // 物理更新 | Physics update + p.vx += p.ax * dt; + p.vy += p.ay * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.age += dt; + + // 应用模块 | Apply modules + const normalizedAge = p.age / p.lifetime; + for (const module of this._modules) { + if (module.enabled) { + module.update(p, dt, normalizedAge); + } + } + + // 检查生命周期 | Check lifetime + if (p.age >= p.lifetime) { + this._pool!.recycle(p); + } + }); + + // 处理碰撞模块标记的死亡粒子 | Process particles marked for death by collision module + if (collisionModule) { + const particlesToKill = collisionModule.getParticlesToKill(); + for (const p of particlesToKill) { + this._pool.recycle(p); + } + } + + // 记录发射器位置供下一帧使用 | Record emitter position for next frame + this._lastEmitterX = worldX; + this._lastEmitterY = worldY; + } + + /** + * 处理定时爆发 + * Process timed bursts + */ + private _processBursts(worldX: number, worldY: number): void { + if (!this._pool || !this._emitter || this.bursts.length === 0) return; + + // 确保爆发状态数组与配置同步 | Ensure burst states array is synced with config + while (this._burstStates.length < this.bursts.length) { + this._burstStates.push({ firedCount: 0, lastFireTime: -Infinity }); + } + + const currentTime = this._elapsedTime; + + for (let i = 0; i < this.bursts.length; i++) { + const burst = this.bursts[i]; + const state = this._burstStates[i]; + + // 检查是否已达到循环次数上限 | Check if reached cycle limit + if (burst.cycles > 0 && state.firedCount >= burst.cycles) { + continue; + } + + // 计算下次触发时间 | Calculate next fire time + let nextFireTime: number; + if (state.firedCount === 0) { + // 首次触发 | First fire + nextFireTime = burst.time; + } else { + // 循环触发 | Cycle fire + nextFireTime = state.lastFireTime + burst.interval; + } + + // 检查是否应该触发 | Check if should fire + if (currentTime >= nextFireTime) { + this._emitter.burst(this._pool, burst.count, worldX, worldY); + state.firedCount++; + state.lastFireTime = currentTime; + } + } + } + + private _parseColor(hex: string): { r: number; g: number; b: number } { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result) { + return { + r: parseInt(result[1], 16) / 255, + g: parseInt(result[2], 16) / 255, + b: parseInt(result[3], 16) / 255 + }; + } + return { r: 1, g: 1, b: 1 }; + } + + onDestroy(): void { + this._pool?.recycleAll(); + this._pool = null; + this._emitter = null; + this._modules = []; + } +} diff --git a/packages/particle/src/index.ts b/packages/particle/src/index.ts new file mode 100644 index 00000000..06a2bc7e --- /dev/null +++ b/packages/particle/src/index.ts @@ -0,0 +1,89 @@ +// Core types +export { type Particle, createParticle, resetParticle, ParticlePool } from './Particle'; + +// Emitter +export { + ParticleEmitter, + EmissionShape, + createDefaultEmitterConfig, + type EmitterConfig, + type ValueRange, + type ColorValue +} from './ParticleEmitter'; + +// Component +export { ParticleSystemComponent, ParticleBlendMode, SimulationSpace, type BurstConfig } from './ParticleSystemComponent'; + +// System +export { ParticleUpdateSystem } from './systems/ParticleSystem'; + +// Modules +export { + type IParticleModule, + ColorOverLifetimeModule, + type ColorKey, + SizeOverLifetimeModule, + ScaleCurveType, + type ScaleKey, + VelocityOverLifetimeModule, + VelocityCurveType, + type VelocityKey, + RotationOverLifetimeModule, + NoiseModule, + valueNoise2D, + noiseHash, + TextureSheetAnimationModule, + AnimationPlayMode, + AnimationLoopMode, + CollisionModule, + BoundaryType, + CollisionBehavior, + ForceFieldModule, + ForceFieldType, + createDefaultForceField, + type ForceField +} from './modules'; + +// Rendering +export { + ParticleRenderDataProvider, + type ParticleProviderRenderData, + type IRenderDataProvider +} from './rendering'; + +// Presets +export { + type ParticlePreset, + PresetCategory, + AllPresets, + DefaultPreset, + FirePreset, + SmokePreset, + SparklePreset, + ExplosionPreset, + RainPreset, + SnowPreset, + MagicAuraPreset, + DustPreset, + BubblePreset, + StarTrailPreset, + VortexPreset, + LeavesPreset, + BouncingPreset, + getPresetsByCategory, + getPresetNames, + getPresetByName, +} from './presets'; + +// Loaders +export { + ParticleLoader, + ParticleAssetType, + createDefaultParticleAsset, + type IParticleAsset, + type IParticleModuleConfig +} from './loaders'; + +// Plugin +export { ParticleRuntimeModule, ParticlePlugin } from './ParticleRuntimeModule'; +export type { ParticleSystemContext } from './ParticleRuntimeModule'; diff --git a/packages/particle/src/loaders/ParticleLoader.ts b/packages/particle/src/loaders/ParticleLoader.ts new file mode 100644 index 00000000..17fe5560 --- /dev/null +++ b/packages/particle/src/loaders/ParticleLoader.ts @@ -0,0 +1,211 @@ +/** + * 粒子效果资源加载器 + * Particle effect asset loader + */ + +import type { + IAssetLoader, + IAssetContent, + IAssetParseContext, + AssetContentType +} from '@esengine/asset-system'; +import { EmissionShape, type ColorValue } from '../ParticleEmitter'; +import { ParticleBlendMode } from '../ParticleSystemComponent'; + +/** + * 粒子资产类型常量 + * Particle asset type constant + */ +export const ParticleAssetType = 'particle'; + +/** + * 粒子模块配置 + * Particle module configuration + */ +export interface IParticleModuleConfig { + /** 模块类型 | Module type */ + type: string; + /** 是否启用 | Enabled */ + enabled: boolean; + /** 模块参数 | Module parameters */ + params: Record; +} + +/** + * 粒子效果资源数据接口 + * Particle effect asset data interface + */ +export interface IParticleAsset { + /** 资源版本 | Asset version */ + version: number; + /** 效果名称 | Effect name */ + name: string; + /** 效果描述 | Effect description */ + description?: string; + + // 基础属性 | Basic properties + /** 最大粒子数 | Maximum particles */ + maxParticles: number; + /** 是否循环 | Looping */ + looping: boolean; + /** 持续时间 | Duration in seconds */ + duration: number; + /** 播放速度 | Playback speed */ + playbackSpeed: number; + /** 启动时自动播放 | Auto play on start */ + playOnAwake: boolean; + + // 发射属性 | Emission properties + /** 发射速率 | Emission rate */ + emissionRate: number; + /** 发射形状 | Emission shape */ + emissionShape: EmissionShape; + /** 形状半径 | Shape radius */ + shapeRadius: number; + /** 形状宽度 | Shape width */ + shapeWidth: number; + /** 形状高度 | Shape height */ + shapeHeight: number; + /** 圆锥角度 | Cone angle */ + shapeAngle: number; + + // 粒子属性 | Particle properties + /** 生命时间最小值 | Lifetime min */ + lifetimeMin: number; + /** 生命时间最大值 | Lifetime max */ + lifetimeMax: number; + /** 速度最小值 | Speed min */ + speedMin: number; + /** 速度最大值 | Speed max */ + speedMax: number; + /** 发射方向(度数)| Direction in degrees */ + direction: number; + /** 方向扩散(度数)| Direction spread in degrees */ + directionSpread: number; + /** 缩放最小值 | Scale min */ + scaleMin: number; + /** 缩放最大值 | Scale max */ + scaleMax: number; + /** 重力 X | Gravity X */ + gravityX: number; + /** 重力 Y | Gravity Y */ + gravityY: number; + + // 颜色属性 | Color properties + /** 起始颜色 | Start color */ + startColor: ColorValue; + /** 起始透明度 | Start alpha */ + startAlpha: number; + /** 结束透明度 | End alpha */ + endAlpha: number; + /** 结束缩放 | End scale */ + endScale: number; + + // 渲染属性 | Rendering properties + /** 粒子大小 | Particle size */ + particleSize: number; + /** 混合模式 | Blend mode */ + blendMode: ParticleBlendMode; + /** 排序顺序 | Sorting order */ + sortingOrder: number; + /** 纹理路径 | Texture path */ + texture?: string; + + // 模块配置 | Module configurations + /** 模块列表 | Module list */ + modules?: IParticleModuleConfig[]; + + // 纹理动画(可选)| Texture animation (optional) + /** 纹理图集列数 | Texture sheet columns */ + textureTilesX?: number; + /** 纹理图集行数 | Texture sheet rows */ + textureTilesY?: number; + /** 动画帧率 | Animation frame rate */ + textureAnimationFPS?: number; +} + +/** + * 创建默认粒子资源数据 + * Create default particle asset data + */ +export function createDefaultParticleAsset(name: string = 'New Particle'): IParticleAsset { + return { + version: 1, + name, + description: '', + + maxParticles: 100, + looping: true, + duration: 5, + playbackSpeed: 1, + playOnAwake: true, + + emissionRate: 10, + emissionShape: EmissionShape.Point, + shapeRadius: 0, + shapeWidth: 0, + shapeHeight: 0, + shapeAngle: 30, + + lifetimeMin: 1, + lifetimeMax: 2, + speedMin: 50, + speedMax: 100, + direction: 90, + directionSpread: 30, + scaleMin: 1, + scaleMax: 1, + gravityX: 0, + gravityY: 0, + + startColor: { r: 1, g: 1, b: 1, a: 1 }, + startAlpha: 1, + endAlpha: 0, + endScale: 1, + + particleSize: 8, + blendMode: ParticleBlendMode.Normal, + sortingOrder: 0, + + modules: [], + }; +} + +/** + * 粒子效果加载器实现 + * Particle effect loader implementation + */ +export class ParticleLoader implements IAssetLoader { + readonly supportedType = ParticleAssetType; + readonly supportedExtensions = ['.particle', '.particle.json']; + readonly contentType: AssetContentType = 'text'; + + /** + * 从文本内容解析粒子资源 + * Parse particle asset from text content + */ + async parse(content: IAssetContent, _context: IAssetParseContext): Promise { + if (!content.text) { + throw new Error('Particle content is empty'); + } + + const jsonData = JSON.parse(content.text) as IParticleAsset; + + // 验证必要字段 | Validate required fields + if (jsonData.maxParticles === undefined) { + throw new Error('Invalid particle format: missing maxParticles'); + } + + // 填充默认值 | Fill default values + const defaults = createDefaultParticleAsset(); + return { ...defaults, ...jsonData }; + } + + /** + * 释放已加载的资源 + * Dispose loaded asset + */ + dispose(asset: IParticleAsset): void { + (asset as any).modules = null; + } +} diff --git a/packages/particle/src/loaders/index.ts b/packages/particle/src/loaders/index.ts new file mode 100644 index 00000000..babe5fb7 --- /dev/null +++ b/packages/particle/src/loaders/index.ts @@ -0,0 +1,7 @@ +export { + ParticleLoader, + ParticleAssetType, + createDefaultParticleAsset, + type IParticleAsset, + type IParticleModuleConfig +} from './ParticleLoader'; diff --git a/packages/particle/src/modules/CollisionModule.ts b/packages/particle/src/modules/CollisionModule.ts new file mode 100644 index 00000000..a3dbf346 --- /dev/null +++ b/packages/particle/src/modules/CollisionModule.ts @@ -0,0 +1,222 @@ +import type { Particle } from '../Particle'; +import type { IParticleModule } from './IParticleModule'; + +/** + * 边界类型 + * Boundary type + */ +export enum BoundaryType { + /** 无边界 | No boundary */ + None = 'none', + /** 矩形边界 | Rectangle boundary */ + Rectangle = 'rectangle', + /** 圆形边界 | Circle boundary */ + Circle = 'circle' +} + +/** + * 碰撞行为 + * Collision behavior + */ +export enum CollisionBehavior { + /** 销毁粒子 | Kill particle */ + Kill = 'kill', + /** 反弹 | Bounce */ + Bounce = 'bounce', + /** 环绕(从另一边出现)| Wrap (appear on opposite side) */ + Wrap = 'wrap' +} + +/** + * 碰撞模块 + * Collision module for particle boundaries + */ +export class CollisionModule implements IParticleModule { + readonly name = 'Collision'; + enabled = true; + + // ============= 边界设置 | Boundary Settings ============= + + /** 边界类型 | Boundary type */ + boundaryType: BoundaryType = BoundaryType.Rectangle; + + /** 碰撞行为 | Collision behavior */ + behavior: CollisionBehavior = CollisionBehavior.Kill; + + // ============= 矩形边界 | Rectangle Boundary ============= + + /** 左边界(相对于发射器)| Left boundary (relative to emitter) */ + left: number = -200; + + /** 右边界(相对于发射器)| Right boundary (relative to emitter) */ + right: number = 200; + + /** 上边界(相对于发射器)| Top boundary (relative to emitter) */ + top: number = -200; + + /** 下边界(相对于发射器)| Bottom boundary (relative to emitter) */ + bottom: number = 200; + + // ============= 圆形边界 | Circle Boundary ============= + + /** 圆形边界半径 | Circle boundary radius */ + radius: number = 200; + + // ============= 反弹设置 | Bounce Settings ============= + + /** 反弹系数 (0-1),1 = 完全弹性 | Bounce factor (0-1), 1 = fully elastic */ + bounceFactor: number = 0.8; + + /** 最小速度阈值(低于此速度时销毁)| Min velocity threshold (kill if below) */ + minVelocityThreshold: number = 5; + + /** 反弹时的生命损失 (0-1) | Life loss on bounce (0-1) */ + lifeLossOnBounce: number = 0; + + // ============= 发射器位置(运行时设置)| Emitter Position (set at runtime) ============= + + /** 发射器 X 坐标 | Emitter X position */ + emitterX: number = 0; + + /** 发射器 Y 坐标 | Emitter Y position */ + emitterY: number = 0; + + /** 粒子死亡标记数组 | Particle death flag array */ + private _particlesToKill: Set = new Set(); + + /** + * 获取需要销毁的粒子 + * Get particles to kill + */ + getParticlesToKill(): Set { + return this._particlesToKill; + } + + /** + * 清除死亡标记 + * Clear death flags + */ + clearDeathFlags(): void { + this._particlesToKill.clear(); + } + + update(p: Particle, _dt: number, _normalizedAge: number): void { + if (this.boundaryType === BoundaryType.None) return; + + // 计算相对于发射器的位置 | Calculate position relative to emitter + const relX = p.x - this.emitterX; + const relY = p.y - this.emitterY; + + let collision = false; + let normalX = 0; + let normalY = 0; + + if (this.boundaryType === BoundaryType.Rectangle) { + // 矩形边界检测 | Rectangle boundary detection + if (relX < this.left) { + collision = true; + normalX = 1; + if (this.behavior === CollisionBehavior.Wrap) { + p.x = this.emitterX + this.right; + } else if (this.behavior === CollisionBehavior.Bounce) { + p.x = this.emitterX + this.left; + } + } else if (relX > this.right) { + collision = true; + normalX = -1; + if (this.behavior === CollisionBehavior.Wrap) { + p.x = this.emitterX + this.left; + } else if (this.behavior === CollisionBehavior.Bounce) { + p.x = this.emitterX + this.right; + } + } + + if (relY < this.top) { + collision = true; + normalY = 1; + if (this.behavior === CollisionBehavior.Wrap) { + p.y = this.emitterY + this.bottom; + } else if (this.behavior === CollisionBehavior.Bounce) { + p.y = this.emitterY + this.top; + } + } else if (relY > this.bottom) { + collision = true; + normalY = -1; + if (this.behavior === CollisionBehavior.Wrap) { + p.y = this.emitterY + this.top; + } else if (this.behavior === CollisionBehavior.Bounce) { + p.y = this.emitterY + this.bottom; + } + } + } else if (this.boundaryType === BoundaryType.Circle) { + // 圆形边界检测 | Circle boundary detection + const dist = Math.sqrt(relX * relX + relY * relY); + if (dist > this.radius) { + collision = true; + if (dist > 0.001) { + normalX = -relX / dist; + normalY = -relY / dist; + } + + if (this.behavior === CollisionBehavior.Wrap) { + // 移动到对面 | Move to opposite side + p.x = this.emitterX - relX * (this.radius / dist) * 0.9; + p.y = this.emitterY - relY * (this.radius / dist) * 0.9; + } else if (this.behavior === CollisionBehavior.Bounce) { + // 移回边界内 | Move back inside boundary + p.x = this.emitterX + relX * (this.radius / dist) * 0.99; + p.y = this.emitterY + relY * (this.radius / dist) * 0.99; + } + } + } + + if (collision) { + switch (this.behavior) { + case CollisionBehavior.Kill: + this._particlesToKill.add(p); + break; + + case CollisionBehavior.Bounce: + this._applyBounce(p, normalX, normalY); + break; + + case CollisionBehavior.Wrap: + // 位置已经在上面处理 | Position already handled above + break; + } + } + } + + /** + * 应用反弹 + * Apply bounce effect + */ + private _applyBounce(p: Particle, normalX: number, normalY: number): void { + // 反弹速度计算 | Calculate bounce velocity + if (normalX !== 0) { + p.vx = -p.vx * this.bounceFactor; + } + if (normalY !== 0) { + p.vy = -p.vy * this.bounceFactor; + } + + // 更新存储的初始速度(如果有速度曲线模块)| Update stored initial velocity + if ('startVx' in p && normalX !== 0) { + (p as any).startVx = -((p as any).startVx) * this.bounceFactor; + } + if ('startVy' in p && normalY !== 0) { + (p as any).startVy = -((p as any).startVy) * this.bounceFactor; + } + + // 应用生命损失 | Apply life loss + if (this.lifeLossOnBounce > 0) { + p.lifetime *= (1 - this.lifeLossOnBounce); + } + + // 检查最小速度 | Check minimum velocity + const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy); + if (speed < this.minVelocityThreshold) { + this._particlesToKill.add(p); + } + } +} diff --git a/packages/particle/src/modules/ColorOverLifetimeModule.ts b/packages/particle/src/modules/ColorOverLifetimeModule.ts new file mode 100644 index 00000000..447e4a68 --- /dev/null +++ b/packages/particle/src/modules/ColorOverLifetimeModule.ts @@ -0,0 +1,63 @@ +import type { Particle } from '../Particle'; +import type { IParticleModule } from './IParticleModule'; + +/** + * 颜色关键帧 + * Color keyframe + */ +export interface ColorKey { + /** 时间点 (0-1) | Time (0-1) */ + time: number; + /** 颜色R (0-1) | Color R (0-1) */ + r: number; + /** 颜色G (0-1) | Color G (0-1) */ + g: number; + /** 颜色B (0-1) | Color B (0-1) */ + b: number; + /** 透明度 (0-1) | Alpha (0-1) */ + a: number; +} + +/** + * 颜色随生命周期变化模块 + * Color over lifetime module + */ +export class ColorOverLifetimeModule implements IParticleModule { + readonly name = 'ColorOverLifetime'; + enabled = true; + + /** 颜色渐变关键帧 | Color gradient keyframes */ + gradient: ColorKey[] = [ + { time: 0, r: 1, g: 1, b: 1, a: 1 }, + { time: 1, r: 1, g: 1, b: 1, a: 0 } + ]; + + update(p: Particle, _dt: number, normalizedAge: number): void { + if (this.gradient.length === 0) return; + + // 找到当前时间点的两个关键帧 | Find the two keyframes around current time + let startKey = this.gradient[0]; + let endKey = this.gradient[this.gradient.length - 1]; + + for (let i = 0; i < this.gradient.length - 1; i++) { + if (normalizedAge >= this.gradient[i].time && normalizedAge <= this.gradient[i + 1].time) { + startKey = this.gradient[i]; + endKey = this.gradient[i + 1]; + break; + } + } + + // 在两个关键帧之间插值 | Interpolate between keyframes + const range = endKey.time - startKey.time; + const t = range > 0 ? (normalizedAge - startKey.time) / range : 0; + + p.r = p.startR * lerp(startKey.r, endKey.r, t); + p.g = p.startG * lerp(startKey.g, endKey.g, t); + p.b = p.startB * lerp(startKey.b, endKey.b, t); + p.alpha = p.startAlpha * lerp(startKey.a, endKey.a, t); + } +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} diff --git a/packages/particle/src/modules/ForceFieldModule.ts b/packages/particle/src/modules/ForceFieldModule.ts new file mode 100644 index 00000000..a8271801 --- /dev/null +++ b/packages/particle/src/modules/ForceFieldModule.ts @@ -0,0 +1,305 @@ +import type { Particle } from '../Particle'; +import type { IParticleModule } from './IParticleModule'; + +/** + * 力场类型 + * Force field type + */ +export enum ForceFieldType { + /** 风力(方向性力)| Wind (directional force) */ + Wind = 'wind', + /** 吸引/排斥点 | Attraction/repulsion point */ + Point = 'point', + /** 漩涡 | Vortex */ + Vortex = 'vortex', + /** 湍流 | Turbulence */ + Turbulence = 'turbulence' +} + +/** + * 力场配置 + * Force field configuration + */ +export interface ForceField { + /** 力场类型 | Force field type */ + type: ForceFieldType; + /** 启用 | Enabled */ + enabled: boolean; + /** 强度 | Strength */ + strength: number; + + // 风力参数 | Wind parameters + /** 风向 X | Wind direction X */ + directionX?: number; + /** 风向 Y | Wind direction Y */ + directionY?: number; + + // 点力场参数 | Point force parameters + /** 力场位置 X(相对于发射器)| Position X (relative to emitter) */ + positionX?: number; + /** 力场位置 Y(相对于发射器)| Position Y (relative to emitter) */ + positionY?: number; + /** 影响半径 | Influence radius */ + radius?: number; + /** 衰减类型 | Falloff type */ + falloff?: 'none' | 'linear' | 'quadratic'; + + // 漩涡参数 | Vortex parameters + /** 漩涡轴心 X | Vortex center X */ + centerX?: number; + /** 漩涡轴心 Y | Vortex center Y */ + centerY?: number; + /** 向内拉力 | Inward pull strength */ + inwardStrength?: number; + + // 湍流参数 | Turbulence parameters + /** 湍流频率 | Turbulence frequency */ + frequency?: number; + /** 湍流振幅 | Turbulence amplitude */ + amplitude?: number; +} + +/** + * 创建默认力场配置 + * Create default force field + */ +export function createDefaultForceField(type: ForceFieldType): ForceField { + const base = { + type, + enabled: true, + strength: 100, + }; + + switch (type) { + case ForceFieldType.Wind: + return { ...base, directionX: 1, directionY: 0 }; + case ForceFieldType.Point: + return { ...base, positionX: 0, positionY: 0, radius: 100, falloff: 'linear' as const }; + case ForceFieldType.Vortex: + return { ...base, centerX: 0, centerY: 0, inwardStrength: 0 }; + case ForceFieldType.Turbulence: + return { ...base, frequency: 1, amplitude: 50 }; + default: + return base; + } +} + +/** + * 力场模块 + * Force field module for applying external forces to particles + */ +export class ForceFieldModule implements IParticleModule { + readonly name = 'ForceField'; + enabled = true; + + /** 力场列表 | Force field list */ + forceFields: ForceField[] = []; + + /** 发射器位置(运行时设置)| Emitter position (set at runtime) */ + emitterX: number = 0; + emitterY: number = 0; + + /** 时间累计(用于湍流)| Time accumulator (for turbulence) */ + private _time: number = 0; + + /** + * 添加力场 + * Add force field + */ + addForceField(field: ForceField): void { + this.forceFields.push(field); + } + + /** + * 添加风力 + * Add wind force + */ + addWind(directionX: number, directionY: number, strength: number = 100): ForceField { + const field: ForceField = { + type: ForceFieldType.Wind, + enabled: true, + strength, + directionX, + directionY, + }; + this.forceFields.push(field); + return field; + } + + /** + * 添加吸引/排斥点 + * Add attraction/repulsion point + */ + addAttractor(x: number, y: number, strength: number = 100, radius: number = 100): ForceField { + const field: ForceField = { + type: ForceFieldType.Point, + enabled: true, + strength, // 正数=吸引,负数=排斥 | positive=attract, negative=repel + positionX: x, + positionY: y, + radius, + falloff: 'linear', + }; + this.forceFields.push(field); + return field; + } + + /** + * 添加漩涡 + * Add vortex + */ + addVortex(x: number, y: number, strength: number = 100, inwardStrength: number = 0): ForceField { + const field: ForceField = { + type: ForceFieldType.Vortex, + enabled: true, + strength, + centerX: x, + centerY: y, + inwardStrength, + }; + this.forceFields.push(field); + return field; + } + + /** + * 添加湍流 + * Add turbulence + */ + addTurbulence(strength: number = 50, frequency: number = 1): ForceField { + const field: ForceField = { + type: ForceFieldType.Turbulence, + enabled: true, + strength, + frequency, + amplitude: strength, + }; + this.forceFields.push(field); + return field; + } + + /** + * 清除所有力场 + * Clear all force fields + */ + clearForceFields(): void { + this.forceFields = []; + } + + update(p: Particle, dt: number, _normalizedAge: number): void { + this._time += dt; + + for (const field of this.forceFields) { + if (!field.enabled) continue; + + switch (field.type) { + case ForceFieldType.Wind: + this._applyWind(p, field, dt); + break; + case ForceFieldType.Point: + this._applyPointForce(p, field, dt); + break; + case ForceFieldType.Vortex: + this._applyVortex(p, field, dt); + break; + case ForceFieldType.Turbulence: + this._applyTurbulence(p, field, dt); + break; + } + } + } + + /** + * 应用风力 + * Apply wind force + */ + private _applyWind(p: Particle, field: ForceField, dt: number): void { + const dx = field.directionX ?? 1; + const dy = field.directionY ?? 0; + // 归一化方向 | Normalize direction + const len = Math.sqrt(dx * dx + dy * dy); + if (len > 0.001) { + p.vx += (dx / len) * field.strength * dt; + p.vy += (dy / len) * field.strength * dt; + } + } + + /** + * 应用点力场(吸引/排斥) + * Apply point force (attraction/repulsion) + */ + private _applyPointForce(p: Particle, field: ForceField, dt: number): void { + const fieldX = this.emitterX + (field.positionX ?? 0); + const fieldY = this.emitterY + (field.positionY ?? 0); + const radius = field.radius ?? 100; + + const dx = fieldX - p.x; + const dy = fieldY - p.y; + const distSq = dx * dx + dy * dy; + const dist = Math.sqrt(distSq); + + if (dist < 0.001 || dist > radius) return; + + // 计算衰减 | Calculate falloff + let falloffFactor = 1; + switch (field.falloff) { + case 'linear': + falloffFactor = 1 - dist / radius; + break; + case 'quadratic': + falloffFactor = 1 - (distSq / (radius * radius)); + break; + // 'none' - no falloff + } + + // 归一化方向并应用力 | Normalize direction and apply force + const force = field.strength * falloffFactor * dt; + p.vx += (dx / dist) * force; + p.vy += (dy / dist) * force; + } + + /** + * 应用漩涡力 + * Apply vortex force + */ + private _applyVortex(p: Particle, field: ForceField, dt: number): void { + const centerX = this.emitterX + (field.centerX ?? 0); + const centerY = this.emitterY + (field.centerY ?? 0); + + const dx = p.x - centerX; + const dy = p.y - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 0.001) return; + + // 切向力(旋转)| Tangential force (rotation) + const tangentX = -dy / dist; + const tangentY = dx / dist; + p.vx += tangentX * field.strength * dt; + p.vy += tangentY * field.strength * dt; + + // 向心力(可选)| Centripetal force (optional) + const inward = field.inwardStrength ?? 0; + if (inward !== 0) { + p.vx -= (dx / dist) * inward * dt; + p.vy -= (dy / dist) * inward * dt; + } + } + + /** + * 应用湍流 + * Apply turbulence + */ + private _applyTurbulence(p: Particle, field: ForceField, dt: number): void { + const freq = field.frequency ?? 1; + const amp = field.amplitude ?? field.strength; + + // 使用简单的正弦波噪声 | Use simple sine wave noise + const noiseX = Math.sin(p.x * freq * 0.01 + this._time * freq) * + Math.cos(p.y * freq * 0.013 + this._time * freq * 0.7); + const noiseY = Math.cos(p.x * freq * 0.011 + this._time * freq * 0.8) * + Math.sin(p.y * freq * 0.01 + this._time * freq); + + p.vx += noiseX * amp * dt; + p.vy += noiseY * amp * dt; + } +} diff --git a/packages/particle/src/modules/IParticleModule.ts b/packages/particle/src/modules/IParticleModule.ts new file mode 100644 index 00000000..2b8d12d5 --- /dev/null +++ b/packages/particle/src/modules/IParticleModule.ts @@ -0,0 +1,26 @@ +import type { Particle } from '../Particle'; + +/** + * 粒子模块接口 + * Particle module interface + * + * Modules modify particle properties over their lifetime. + * 模块在粒子生命周期内修改粒子属性。 + */ +export interface IParticleModule { + /** 模块名称 | Module name */ + readonly name: string; + + /** 是否启用 | Whether enabled */ + enabled: boolean; + + /** + * 更新粒子 + * Update particle + * + * @param p - Particle to update + * @param dt - Delta time in seconds + * @param normalizedAge - Age / Lifetime (0-1) + */ + update(p: Particle, dt: number, normalizedAge: number): void; +} diff --git a/packages/particle/src/modules/NoiseModule.ts b/packages/particle/src/modules/NoiseModule.ts new file mode 100644 index 00000000..2a3e008e --- /dev/null +++ b/packages/particle/src/modules/NoiseModule.ts @@ -0,0 +1,100 @@ +import type { Particle } from '../Particle'; +import type { IParticleModule } from './IParticleModule'; + +/** + * 值噪声哈希函数 + * Value noise hash function + * + * 使用经典的整数哈希算法生成伪随机值 + * Uses classic integer hash algorithm to generate pseudo-random values + */ +export function noiseHash(x: number, y: number): number { + const n = x + y * 57; + const shifted = (n << 13) ^ n; + return ((shifted * (shifted * shifted * 15731 + 789221) + 1376312589) & 0x7fffffff) / 0x7fffffff; +} + +/** + * 2D 值噪声函数 + * 2D value noise function + * + * 基于双线性插值的简化值噪声实现,返回 [-1, 1] 范围的值 + * Simplified value noise using bilinear interpolation, returns value in [-1, 1] range + */ +export function valueNoise2D(x: number, y: number): number { + const ix = Math.floor(x); + const iy = Math.floor(y); + const fx = x - ix; + const fy = y - iy; + + const n00 = noiseHash(ix, iy); + const n10 = noiseHash(ix + 1, iy); + const n01 = noiseHash(ix, iy + 1); + const n11 = noiseHash(ix + 1, iy + 1); + + // 双线性插值 | Bilinear interpolation + const nx0 = n00 + (n10 - n00) * fx; + const nx1 = n01 + (n11 - n01) * fx; + return (nx0 + (nx1 - nx0) * fy) * 2 - 1; +} + +/** + * 噪声模块 - 添加随机扰动 + * Noise module - adds random perturbation + */ +export class NoiseModule implements IParticleModule { + readonly name = 'Noise'; + enabled = true; + + /** 位置噪声强度 | Position noise strength */ + positionAmount: number = 0; + + /** 速度噪声强度 | Velocity noise strength */ + velocityAmount: number = 0; + + /** 旋转噪声强度 | Rotation noise strength */ + rotationAmount: number = 0; + + /** 缩放噪声强度 | Scale noise strength */ + scaleAmount: number = 0; + + /** 噪声频率 | Noise frequency */ + frequency: number = 1; + + /** 噪声滚动速度 | Noise scroll speed */ + scrollSpeed: number = 1; + + private _time: number = 0; + + update(p: Particle, dt: number, _normalizedAge: number): void { + this._time += dt * this.scrollSpeed; + + // 基于粒子位置和时间的噪声 | Noise based on particle position and time + const noiseX = valueNoise2D(p.x * this.frequency + this._time, p.y * this.frequency); + const noiseY = valueNoise2D(p.x * this.frequency, p.y * this.frequency + this._time); + + // 位置噪声 | Position noise + if (this.positionAmount !== 0) { + p.x += noiseX * this.positionAmount * dt; + p.y += noiseY * this.positionAmount * dt; + } + + // 速度噪声 | Velocity noise + if (this.velocityAmount !== 0) { + p.vx += noiseX * this.velocityAmount * dt; + p.vy += noiseY * this.velocityAmount * dt; + } + + // 旋转噪声 | Rotation noise + if (this.rotationAmount !== 0) { + p.rotation += noiseX * this.rotationAmount * dt; + } + + // 缩放噪声 | Scale noise + if (this.scaleAmount !== 0) { + const scaleDelta = noiseX * this.scaleAmount * dt; + p.scaleX += scaleDelta; + p.scaleY += scaleDelta; + } + } +} diff --git a/packages/particle/src/modules/RotationOverLifetimeModule.ts b/packages/particle/src/modules/RotationOverLifetimeModule.ts new file mode 100644 index 00000000..845a1cef --- /dev/null +++ b/packages/particle/src/modules/RotationOverLifetimeModule.ts @@ -0,0 +1,40 @@ +import type { Particle } from '../Particle'; +import type { IParticleModule } from './IParticleModule'; + +/** + * 旋转随生命周期变化模块 + * Rotation over lifetime module + */ +export class RotationOverLifetimeModule implements IParticleModule { + readonly name = 'RotationOverLifetime'; + enabled = true; + + /** 角速度乘数起点 | Angular velocity multiplier start */ + angularVelocityMultiplierStart: number = 1; + + /** 角速度乘数终点 | Angular velocity multiplier end */ + angularVelocityMultiplierEnd: number = 1; + + /** 附加旋转(随生命周期累加的旋转量)| Additional rotation over lifetime */ + additionalRotation: number = 0; + + update(p: Particle, dt: number, normalizedAge: number): void { + // 应用角速度乘数 | Apply angular velocity multiplier + const multiplier = lerp( + this.angularVelocityMultiplierStart, + this.angularVelocityMultiplierEnd, + normalizedAge + ); + + p.rotation += p.angularVelocity * multiplier * dt; + + // 附加旋转 | Additional rotation + if (this.additionalRotation !== 0) { + p.rotation += this.additionalRotation * dt; + } + } +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} diff --git a/packages/particle/src/modules/SizeOverLifetimeModule.ts b/packages/particle/src/modules/SizeOverLifetimeModule.ts new file mode 100644 index 00000000..fcb67007 --- /dev/null +++ b/packages/particle/src/modules/SizeOverLifetimeModule.ts @@ -0,0 +1,119 @@ +import type { Particle } from '../Particle'; +import type { IParticleModule } from './IParticleModule'; + +/** + * 缩放关键帧 + * Scale keyframe + */ +export interface ScaleKey { + /** 时间点 (0-1) | Time (0-1) */ + time: number; + /** 缩放值 | Scale value */ + scale: number; +} + +/** + * 缩放曲线类型 + * Scale curve type + */ +export enum ScaleCurveType { + /** 线性 | Linear */ + Linear = 'linear', + /** 缓入 | Ease in */ + EaseIn = 'easeIn', + /** 缓出 | Ease out */ + EaseOut = 'easeOut', + /** 缓入缓出 | Ease in out */ + EaseInOut = 'easeInOut', + /** 自定义关键帧 | Custom keyframes */ + Custom = 'custom' +} + +/** + * 缩放随生命周期变化模块 + * Size over lifetime module + */ +export class SizeOverLifetimeModule implements IParticleModule { + readonly name = 'SizeOverLifetime'; + enabled = true; + + /** 曲线类型 | Curve type */ + curveType: ScaleCurveType = ScaleCurveType.Linear; + + /** 起始缩放乘数 | Start scale multiplier */ + startMultiplier: number = 1; + + /** 结束缩放乘数 | End scale multiplier */ + endMultiplier: number = 0; + + /** 自定义关键帧(当 curveType 为 Custom 时使用)| Custom keyframes */ + customCurve: ScaleKey[] = []; + + /** X/Y 分离缩放 | Separate X/Y scaling */ + separateAxes: boolean = false; + + /** X轴结束缩放乘数 | End scale multiplier for X axis */ + endMultiplierX: number = 0; + + /** Y轴结束缩放乘数 | End scale multiplier for Y axis */ + endMultiplierY: number = 0; + + update(p: Particle, _dt: number, normalizedAge: number): void { + let t: number; + + switch (this.curveType) { + case ScaleCurveType.Linear: + t = normalizedAge; + break; + case ScaleCurveType.EaseIn: + t = normalizedAge * normalizedAge; + break; + case ScaleCurveType.EaseOut: + t = 1 - (1 - normalizedAge) * (1 - normalizedAge); + break; + case ScaleCurveType.EaseInOut: + t = normalizedAge < 0.5 + ? 2 * normalizedAge * normalizedAge + : 1 - Math.pow(-2 * normalizedAge + 2, 2) / 2; + break; + case ScaleCurveType.Custom: + t = this._evaluateCustomCurve(normalizedAge); + break; + default: + t = normalizedAge; + } + + if (this.separateAxes) { + p.scaleX = p.startScaleX * lerp(this.startMultiplier, this.endMultiplierX, t); + p.scaleY = p.startScaleY * lerp(this.startMultiplier, this.endMultiplierY, t); + } else { + const scale = lerp(this.startMultiplier, this.endMultiplier, t); + p.scaleX = p.startScaleX * scale; + p.scaleY = p.startScaleY * scale; + } + } + + private _evaluateCustomCurve(normalizedAge: number): number { + if (this.customCurve.length === 0) return normalizedAge; + if (this.customCurve.length === 1) return this.customCurve[0].scale; + + let startKey = this.customCurve[0]; + let endKey = this.customCurve[this.customCurve.length - 1]; + + for (let i = 0; i < this.customCurve.length - 1; i++) { + if (normalizedAge >= this.customCurve[i].time && normalizedAge <= this.customCurve[i + 1].time) { + startKey = this.customCurve[i]; + endKey = this.customCurve[i + 1]; + break; + } + } + + const range = endKey.time - startKey.time; + const t = range > 0 ? (normalizedAge - startKey.time) / range : 0; + return lerp(startKey.scale, endKey.scale, t); + } +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} diff --git a/packages/particle/src/modules/TextureSheetAnimationModule.ts b/packages/particle/src/modules/TextureSheetAnimationModule.ts new file mode 100644 index 00000000..6801e872 --- /dev/null +++ b/packages/particle/src/modules/TextureSheetAnimationModule.ts @@ -0,0 +1,232 @@ +import type { Particle } from '../Particle'; +import type { IParticleModule } from './IParticleModule'; + +/** + * 动画播放模式 + * Animation playback mode + */ +export enum AnimationPlayMode { + /** 单次播放(生命周期内完成一次循环)| Single loop over lifetime */ + LifetimeLoop = 'lifetimeLoop', + /** 固定帧率播放 | Fixed frame rate */ + FixedFPS = 'fixedFps', + /** 随机选择帧 | Random frame selection */ + Random = 'random', + /** 使用速度控制帧 | Speed-based frame */ + SpeedBased = 'speedBased', +} + +/** + * 动画循环模式 + * Animation loop mode + */ +export enum AnimationLoopMode { + /** 不循环(停在最后一帧)| No loop (stop at last frame) */ + Once = 'once', + /** 循环播放 | Loop continuously */ + Loop = 'loop', + /** 往返循环 | Ping-pong loop */ + PingPong = 'pingPong', +} + +/** + * 纹理图集动画模块 + * Texture sheet animation module + * + * Animates particles through sprite sheet frames. + * 通过精灵图帧动画化粒子。 + */ +export class TextureSheetAnimationModule implements IParticleModule { + readonly name = 'TextureSheetAnimation'; + enabled = false; + + // 图集配置 | Sheet configuration + /** 水平帧数 | Number of columns */ + tilesX: number = 1; + + /** 垂直帧数 | Number of rows */ + tilesY: number = 1; + + /** 总帧数(0=自动计算为 tilesX * tilesY)| Total frames (0 = auto-calculate) */ + totalFrames: number = 0; + + /** 起始帧 | Start frame index */ + startFrame: number = 0; + + // 播放配置 | Playback configuration + /** 播放模式 | Playback mode */ + playMode: AnimationPlayMode = AnimationPlayMode.LifetimeLoop; + + /** 循环模式 | Loop mode */ + loopMode: AnimationLoopMode = AnimationLoopMode.Loop; + + /** 固定帧率(FPS,用于 FixedFPS 模式)| Fixed frame rate (for FixedFPS mode) */ + frameRate: number = 30; + + /** 播放速度乘数 | Playback speed multiplier */ + speedMultiplier: number = 1; + + /** 循环次数(0=无限)| Number of loops (0 = infinite) */ + cycleCount: number = 0; + + // 内部状态 | Internal state + private _cachedTotalFrames: number = 0; + + /** + * 获取实际总帧数 + * Get actual total frames + */ + get actualTotalFrames(): number { + return this.totalFrames > 0 ? this.totalFrames : this.tilesX * this.tilesY; + } + + /** + * 更新粒子 + * Update particle + * + * @param p - 粒子 | Particle + * @param dt - 增量时间 | Delta time + * @param normalizedAge - 归一化年龄 (0-1) | Normalized age + */ + update(p: Particle, dt: number, normalizedAge: number): void { + const frameCount = this.actualTotalFrames; + if (frameCount <= 1) return; + + let frameIndex: number; + + switch (this.playMode) { + case AnimationPlayMode.LifetimeLoop: + frameIndex = this._getLifetimeFrame(normalizedAge, frameCount); + break; + + case AnimationPlayMode.FixedFPS: + frameIndex = this._getFixedFPSFrame(p, dt, frameCount); + break; + + case AnimationPlayMode.Random: + frameIndex = this._getRandomFrame(p, frameCount); + break; + + case AnimationPlayMode.SpeedBased: + frameIndex = this._getSpeedBasedFrame(p, frameCount); + break; + + default: + frameIndex = this.startFrame; + } + + // 设置粒子的 UV 坐标 | Set particle UV coordinates + this._setParticleUV(p, frameIndex); + } + + /** + * 生命周期帧计算 + * Calculate frame based on lifetime + */ + private _getLifetimeFrame(normalizedAge: number, frameCount: number): number { + const progress = normalizedAge * this.speedMultiplier; + return this._applyLoopMode(progress, frameCount); + } + + /** + * 固定帧率计算 + * Calculate frame based on fixed FPS + */ + private _getFixedFPSFrame(p: Particle, dt: number, frameCount: number): number { + // 使用粒子的 age 来计算当前帧 | Use particle age to calculate current frame + const animTime = p.age * this.frameRate * this.speedMultiplier; + const progress = animTime / frameCount; + return this._applyLoopMode(progress, frameCount); + } + + /** + * 随机帧选择(每个粒子使用固定随机帧) + * Random frame selection (each particle uses fixed random frame) + */ + private _getRandomFrame(p: Particle, frameCount: number): number { + // 使用粒子的起始位置作为随机种子(确保一致性) + // Use particle's start position as random seed (for consistency) + const seed = Math.abs(p.startR * 1000 + p.startG * 100 + p.startB * 10) % 1; + return Math.floor(seed * frameCount); + } + + /** + * 基于速度的帧计算 + * Calculate frame based on particle speed + */ + private _getSpeedBasedFrame(p: Particle, frameCount: number): number { + const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy); + // 归一化速度(假设最大速度为 500)| Normalize speed (assume max 500) + const normalizedSpeed = Math.min(speed / 500, 1); + const frameIndex = Math.floor(normalizedSpeed * (frameCount - 1)); + return this.startFrame + frameIndex; + } + + /** + * 应用循环模式 + * Apply loop mode to progress + */ + private _applyLoopMode(progress: number, frameCount: number): number { + let loopProgress = progress; + + switch (this.loopMode) { + case AnimationLoopMode.Once: + // 停在最后一帧 | Stop at last frame + loopProgress = Math.min(progress, 0.9999); + break; + + case AnimationLoopMode.Loop: + // 简单循环 | Simple loop + loopProgress = progress % 1; + break; + + case AnimationLoopMode.PingPong: + // 往返循环 | Ping-pong + const cycle = Math.floor(progress); + const remainder = progress - cycle; + loopProgress = (cycle % 2 === 0) ? remainder : (1 - remainder); + break; + } + + // 检查循环次数限制 | Check cycle count limit + if (this.cycleCount > 0) { + const currentCycle = Math.floor(progress); + if (currentCycle >= this.cycleCount) { + loopProgress = 0.9999; // 停在最后一帧 | Stop at last frame + } + } + + // 计算帧索引 | Calculate frame index + const frameIndex = Math.floor(loopProgress * frameCount); + return this.startFrame + Math.min(frameIndex, frameCount - 1); + } + + /** + * 设置粒子 UV 坐标 + * Set particle UV coordinates + */ + private _setParticleUV(p: Particle, frameIndex: number): void { + // 计算 UV 坐标 | Calculate UV coordinates + const col = frameIndex % this.tilesX; + const row = Math.floor(frameIndex / this.tilesX); + + const uWidth = 1 / this.tilesX; + const vHeight = 1 / this.tilesY; + + // UV 坐标(左上角为原点)| UV coordinates (top-left origin) + const u0 = col * uWidth; + const v0 = row * vHeight; + const u1 = u0 + uWidth; + const v1 = v0 + vHeight; + + // 存储 UV 到粒子的自定义属性 + // Store UV in particle's custom properties + // 这里我们使用粒子的 startR/startG/startB 不太合适,需要扩展 Particle + // 暂时通过覆盖 rotation 的高位来存储帧索引(临时方案) + // Temporary: store frame index in a way that can be read by renderer + // The actual UV calculation will be done in the render data provider + (p as any)._animFrame = frameIndex; + (p as any)._animTilesX = this.tilesX; + (p as any)._animTilesY = this.tilesY; + } +} diff --git a/packages/particle/src/modules/VelocityOverLifetimeModule.ts b/packages/particle/src/modules/VelocityOverLifetimeModule.ts new file mode 100644 index 00000000..070c3de7 --- /dev/null +++ b/packages/particle/src/modules/VelocityOverLifetimeModule.ts @@ -0,0 +1,201 @@ +import type { Particle } from '../Particle'; +import type { IParticleModule } from './IParticleModule'; + +/** + * 速度关键帧 + * Velocity keyframe + */ +export interface VelocityKey { + /** 时间点 (0-1) | Time (0-1) */ + time: number; + /** 速度乘数 | Velocity multiplier */ + multiplier: number; +} + +/** + * 速度曲线类型 + * Velocity curve type + */ +export enum VelocityCurveType { + /** 常量(无变化)| Constant (no change) */ + Constant = 'constant', + /** 线性 | Linear */ + Linear = 'linear', + /** 缓入(先慢后快)| Ease in (slow then fast) */ + EaseIn = 'easeIn', + /** 缓出(先快后慢)| Ease out (fast then slow) */ + EaseOut = 'easeOut', + /** 缓入缓出 | Ease in out */ + EaseInOut = 'easeInOut', + /** 自定义关键帧 | Custom keyframes */ + Custom = 'custom' +} + +/** + * 速度随生命周期变化模块 + * Velocity over lifetime module + */ +export class VelocityOverLifetimeModule implements IParticleModule { + readonly name = 'VelocityOverLifetime'; + enabled = true; + + // ============= 速度曲线 | Velocity Curve ============= + + /** 速度曲线类型 | Velocity curve type */ + curveType: VelocityCurveType = VelocityCurveType.Constant; + + /** 起始速度乘数 | Start velocity multiplier */ + startMultiplier: number = 1; + + /** 结束速度乘数 | End velocity multiplier */ + endMultiplier: number = 1; + + /** 自定义关键帧(当 curveType 为 Custom 时使用)| Custom keyframes */ + customCurve: VelocityKey[] = []; + + // ============= 阻力 | Drag ============= + + /** 线性阻力 (0-1),每秒速度衰减比例 | Linear drag (0-1), velocity decay per second */ + linearDrag: number = 0; + + // ============= 额外速度 | Additional Velocity ============= + + /** 轨道速度(绕发射点旋转)| Orbital velocity (rotation around emitter) */ + orbitalVelocity: number = 0; + + /** 径向速度(向外/向内扩散)| Radial velocity (expand/contract) */ + radialVelocity: number = 0; + + /** 附加 X 速度 | Additional X velocity */ + additionalVelocityX: number = 0; + + /** 附加 Y 速度 | Additional Y velocity */ + additionalVelocityY: number = 0; + + update(p: Particle, dt: number, normalizedAge: number): void { + // 计算速度乘数 | Calculate velocity multiplier + const multiplier = this._evaluateMultiplier(normalizedAge); + + // 应用速度乘数到当前速度 | Apply multiplier to current velocity + // 我们需要存储初始速度来正确应用曲线 | We need to store initial velocity to properly apply curve + if (!('startVx' in p)) { + (p as any).startVx = p.vx; + (p as any).startVy = p.vy; + } + + const startVx = (p as any).startVx; + const startVy = (p as any).startVy; + + // 应用曲线乘数 | Apply curve multiplier + p.vx = startVx * multiplier; + p.vy = startVy * multiplier; + + // 应用阻力(在曲线乘数之后)| Apply drag (after curve multiplier) + if (this.linearDrag > 0) { + const dragFactor = Math.pow(1 - this.linearDrag, dt); + p.vx *= dragFactor; + p.vy *= dragFactor; + // 更新存储的起始速度以反映阻力 | Update stored start velocity to reflect drag + (p as any).startVx *= dragFactor; + (p as any).startVy *= dragFactor; + } + + // 附加速度 | Additional velocity + if (this.additionalVelocityX !== 0 || this.additionalVelocityY !== 0) { + p.vx += this.additionalVelocityX * dt; + p.vy += this.additionalVelocityY * dt; + } + + // 轨道速度 | Orbital velocity + if (this.orbitalVelocity !== 0) { + const angle = Math.atan2(p.y, p.x) + this.orbitalVelocity * dt; + const dist = Math.sqrt(p.x * p.x + p.y * p.y); + p.x = Math.cos(angle) * dist; + p.y = Math.sin(angle) * dist; + } + + // 径向速度 | Radial velocity + if (this.radialVelocity !== 0) { + const dist = Math.sqrt(p.x * p.x + p.y * p.y); + if (dist > 0.001) { + const nx = p.x / dist; + const ny = p.y / dist; + p.vx += nx * this.radialVelocity * dt; + p.vy += ny * this.radialVelocity * dt; + } + } + } + + /** + * 计算速度乘数 + * Evaluate velocity multiplier + */ + private _evaluateMultiplier(normalizedAge: number): number { + let t: number; + + switch (this.curveType) { + case VelocityCurveType.Constant: + return this.startMultiplier; + + case VelocityCurveType.Linear: + t = normalizedAge; + break; + + case VelocityCurveType.EaseIn: + t = normalizedAge * normalizedAge; + break; + + case VelocityCurveType.EaseOut: + t = 1 - (1 - normalizedAge) * (1 - normalizedAge); + break; + + case VelocityCurveType.EaseInOut: + t = normalizedAge < 0.5 + ? 2 * normalizedAge * normalizedAge + : 1 - Math.pow(-2 * normalizedAge + 2, 2) / 2; + break; + + case VelocityCurveType.Custom: + return this._evaluateCustomCurve(normalizedAge); + + default: + t = normalizedAge; + } + + return lerp(this.startMultiplier, this.endMultiplier, t); + } + + /** + * 计算自定义曲线值 + * Evaluate custom curve value + */ + private _evaluateCustomCurve(normalizedAge: number): number { + if (this.customCurve.length === 0) return this.startMultiplier; + if (this.customCurve.length === 1) return this.customCurve[0].multiplier; + + // 在边界外返回边界值 | Return boundary values outside range + if (normalizedAge <= this.customCurve[0].time) { + return this.customCurve[0].multiplier; + } + if (normalizedAge >= this.customCurve[this.customCurve.length - 1].time) { + return this.customCurve[this.customCurve.length - 1].multiplier; + } + + // 找到相邻关键帧 | Find adjacent keyframes + for (let i = 0; i < this.customCurve.length - 1; i++) { + const start = this.customCurve[i]; + const end = this.customCurve[i + 1]; + if (normalizedAge >= start.time && normalizedAge <= end.time) { + const range = end.time - start.time; + const t = range > 0 ? (normalizedAge - start.time) / range : 0; + return lerp(start.multiplier, end.multiplier, t); + } + } + + return this.startMultiplier; + } +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} diff --git a/packages/particle/src/modules/index.ts b/packages/particle/src/modules/index.ts new file mode 100644 index 00000000..294e7469 --- /dev/null +++ b/packages/particle/src/modules/index.ts @@ -0,0 +1,22 @@ +export type { IParticleModule } from './IParticleModule'; +export { ColorOverLifetimeModule, type ColorKey } from './ColorOverLifetimeModule'; +export { SizeOverLifetimeModule, ScaleCurveType, type ScaleKey } from './SizeOverLifetimeModule'; +export { VelocityOverLifetimeModule, VelocityCurveType, type VelocityKey } from './VelocityOverLifetimeModule'; +export { RotationOverLifetimeModule } from './RotationOverLifetimeModule'; +export { NoiseModule, valueNoise2D, noiseHash } from './NoiseModule'; +export { + TextureSheetAnimationModule, + AnimationPlayMode, + AnimationLoopMode +} from './TextureSheetAnimationModule'; +export { + CollisionModule, + BoundaryType, + CollisionBehavior +} from './CollisionModule'; +export { + ForceFieldModule, + ForceFieldType, + createDefaultForceField, + type ForceField +} from './ForceFieldModule'; diff --git a/packages/particle/src/presets/index.ts b/packages/particle/src/presets/index.ts new file mode 100644 index 00000000..7572acf2 --- /dev/null +++ b/packages/particle/src/presets/index.ts @@ -0,0 +1,774 @@ +/** + * 粒子效果预设 + * Particle effect presets + * + * Collection of pre-configured particle system settings. + * 预配置的粒子系统设置集合。 + */ + +import { EmissionShape, type ColorValue } from '../ParticleEmitter'; +import { ParticleBlendMode, SimulationSpace } from '../ParticleSystemComponent'; +import { ForceFieldType } from '../modules/ForceFieldModule'; +import { BoundaryType, CollisionBehavior } from '../modules/CollisionModule'; + +/** + * 辅助函数:十六进制转 ColorValue + * Helper: hex to ColorValue + */ +function hexToColor(hex: string, alpha = 1): ColorValue { + const h = hex.replace('#', ''); + return { + r: parseInt(h.slice(0, 2), 16) / 255, + g: parseInt(h.slice(2, 4), 16) / 255, + b: parseInt(h.slice(4, 6), 16) / 255, + a: alpha, + }; +} + +/** + * 预设配置接口 + * Preset configuration interface + */ +export interface ParticlePreset { + /** 预设名称 | Preset name */ + name: string; + /** 预设描述 | Preset description */ + description: string; + /** 预设分类 | Preset category */ + category: PresetCategory; + /** 预设图标 | Preset icon */ + icon?: string; + + // 基础属性 | Basic properties + maxParticles: number; + looping: boolean; + duration: number; + playbackSpeed: number; + + // 发射属性 | Emission properties + emissionRate: number; + emissionShape: EmissionShape; + shapeRadius: number; + shapeWidth: number; + shapeHeight: number; + shapeAngle: number; + + // 粒子属性 | Particle properties + lifetimeMin: number; + lifetimeMax: number; + speedMin: number; + speedMax: number; + direction: number; + directionSpread: number; + scaleMin: number; + scaleMax: number; + gravityX: number; + gravityY: number; + + // 颜色属性 | Color properties + startColor: ColorValue; + endColor?: ColorValue; + startAlpha: number; + endAlpha: number; + endScale: number; + + // 渲染属性 | Rendering properties + particleSize: number; + blendMode: ParticleBlendMode; + + // 可选模块 | Optional modules + simulationSpace?: SimulationSpace; + forceField?: { + type: ForceFieldType; + strength: number; + directionX?: number; + directionY?: number; + centerX?: number; + centerY?: number; + inwardStrength?: number; + frequency?: number; + }; + collision?: { + boundaryType: BoundaryType; + behavior: CollisionBehavior; + radius?: number; + bounceFactor?: number; + }; +} + +/** + * 预设分类 + * Preset category + */ +export enum PresetCategory { + /** 自然效果 | Natural effects */ + Nature = 'nature', + /** 魔法效果 | Magic effects */ + Magic = 'magic', + /** 爆炸效果 | Explosion effects */ + Explosion = 'explosion', + /** 环境效果 | Environment effects */ + Environment = 'environment', + /** UI 效果 | UI effects */ + UI = 'ui', + /** 基础效果 | Basic effects */ + Basic = 'basic', +} + +/** + * 火焰预设 + * Fire preset + */ +export const FirePreset: ParticlePreset = { + name: 'Fire', + description: 'Realistic fire effect with hot colors', + category: PresetCategory.Nature, + icon: 'Flame', + + maxParticles: 200, + looping: true, + duration: 5, + playbackSpeed: 1, + + emissionRate: 40, + emissionShape: EmissionShape.Rectangle, + shapeRadius: 20, + shapeWidth: 30, + shapeHeight: 5, + shapeAngle: 30, + + lifetimeMin: 0.5, + lifetimeMax: 1.2, + speedMin: 80, + speedMax: 150, + direction: 90, + directionSpread: 25, + scaleMin: 0.8, + scaleMax: 1.2, + gravityX: 0, + gravityY: 50, + + startColor: hexToColor('#ff6600'), + endColor: hexToColor('#ff0000'), + startAlpha: 1, + endAlpha: 0, + endScale: 0.3, + + particleSize: 16, + blendMode: ParticleBlendMode.Additive, +}; + +/** + * 烟雾预设 + * Smoke preset + */ +export const SmokePreset: ParticlePreset = { + name: 'Smoke', + description: 'Soft rising smoke effect', + category: PresetCategory.Nature, + icon: 'Cloud', + + maxParticles: 150, + looping: true, + duration: 5, + playbackSpeed: 1, + + emissionRate: 15, + emissionShape: EmissionShape.Circle, + shapeRadius: 15, + shapeWidth: 30, + shapeHeight: 30, + shapeAngle: 30, + + lifetimeMin: 2, + lifetimeMax: 4, + speedMin: 20, + speedMax: 50, + direction: 90, + directionSpread: 20, + scaleMin: 0.5, + scaleMax: 1, + gravityX: 10, + gravityY: -5, + + startColor: hexToColor('#888888'), + endColor: hexToColor('#cccccc'), + startAlpha: 0.6, + endAlpha: 0, + endScale: 2.5, + + particleSize: 32, + blendMode: ParticleBlendMode.Normal, +}; + +/** + * 火花预设 + * Sparkle preset + */ +export const SparklePreset: ParticlePreset = { + name: 'Sparkle', + description: 'Twinkling star-like particles', + category: PresetCategory.Magic, + icon: 'Sparkles', + + maxParticles: 100, + looping: true, + duration: 5, + playbackSpeed: 1, + + emissionRate: 20, + emissionShape: EmissionShape.Circle, + shapeRadius: 40, + shapeWidth: 40, + shapeHeight: 40, + shapeAngle: 360, + + lifetimeMin: 0.3, + lifetimeMax: 0.8, + speedMin: 10, + speedMax: 30, + direction: 90, + directionSpread: 360, + scaleMin: 0.5, + scaleMax: 1.5, + gravityX: 0, + gravityY: -20, + + startColor: hexToColor('#ffffff'), + startAlpha: 1, + endAlpha: 0, + endScale: 0, + + particleSize: 8, + blendMode: ParticleBlendMode.Additive, +}; + +/** + * 爆炸预设 + * Explosion preset + */ +export const ExplosionPreset: ParticlePreset = { + name: 'Explosion', + description: 'Radial burst explosion effect', + category: PresetCategory.Explosion, + icon: 'Zap', + + maxParticles: 300, + looping: false, + duration: 1, + playbackSpeed: 1, + + emissionRate: 0, // Burst only + emissionShape: EmissionShape.Point, + shapeRadius: 5, + shapeWidth: 10, + shapeHeight: 10, + shapeAngle: 360, + + lifetimeMin: 0.3, + lifetimeMax: 0.8, + speedMin: 200, + speedMax: 400, + direction: 0, + directionSpread: 360, + scaleMin: 0.8, + scaleMax: 1.2, + gravityX: 0, + gravityY: 200, + + startColor: hexToColor('#ffaa00'), + endColor: hexToColor('#ff4400'), + startAlpha: 1, + endAlpha: 0, + endScale: 0.2, + + particleSize: 12, + blendMode: ParticleBlendMode.Additive, +}; + +/** + * 雨滴预设 + * Rain preset + */ +export const RainPreset: ParticlePreset = { + name: 'Rain', + description: 'Falling rain drops', + category: PresetCategory.Environment, + icon: 'CloudRain', + + maxParticles: 500, + looping: true, + duration: 10, + playbackSpeed: 1, + + emissionRate: 80, + emissionShape: EmissionShape.Line, + shapeRadius: 200, + shapeWidth: 400, + shapeHeight: 10, + shapeAngle: 0, + + lifetimeMin: 0.5, + lifetimeMax: 1, + speedMin: 400, + speedMax: 600, + direction: -80, + directionSpread: 5, + scaleMin: 0.8, + scaleMax: 1, + gravityX: 0, + gravityY: 0, + + startColor: hexToColor('#88ccff'), + startAlpha: 0.7, + endAlpha: 0.3, + endScale: 1, + + particleSize: 6, + blendMode: ParticleBlendMode.Normal, +}; + +/** + * 雪花预设 + * Snow preset + */ +export const SnowPreset: ParticlePreset = { + name: 'Snow', + description: 'Gently falling snowflakes', + category: PresetCategory.Environment, + icon: 'Snowflake', + + maxParticles: 300, + looping: true, + duration: 10, + playbackSpeed: 1, + + emissionRate: 30, + emissionShape: EmissionShape.Line, + shapeRadius: 200, + shapeWidth: 400, + shapeHeight: 10, + shapeAngle: 0, + + lifetimeMin: 3, + lifetimeMax: 6, + speedMin: 20, + speedMax: 50, + direction: -90, + directionSpread: 30, + scaleMin: 0.3, + scaleMax: 1, + gravityX: 0, + gravityY: 0, + + startColor: hexToColor('#ffffff'), + startAlpha: 0.9, + endAlpha: 0.5, + endScale: 1, + + particleSize: 8, + blendMode: ParticleBlendMode.Normal, +}; + +/** + * 魔法光环预设 + * Magic aura preset + */ +export const MagicAuraPreset: ParticlePreset = { + name: 'Magic Aura', + description: 'Mystical swirling aura', + category: PresetCategory.Magic, + icon: 'Star', + + maxParticles: 100, + looping: true, + duration: 5, + playbackSpeed: 1, + + emissionRate: 20, + emissionShape: EmissionShape.Circle, + shapeRadius: 50, + shapeWidth: 50, + shapeHeight: 50, + shapeAngle: 360, + + lifetimeMin: 1, + lifetimeMax: 2, + speedMin: 5, + speedMax: 15, + direction: 0, + directionSpread: 360, + scaleMin: 0.5, + scaleMax: 1, + gravityX: 0, + gravityY: -30, + + startColor: hexToColor('#aa55ff'), + endColor: hexToColor('#5555ff'), + startAlpha: 0.8, + endAlpha: 0, + endScale: 0.5, + + particleSize: 10, + blendMode: ParticleBlendMode.Additive, +}; + +/** + * 灰尘预设 + * Dust preset + */ +export const DustPreset: ParticlePreset = { + name: 'Dust', + description: 'Floating dust particles', + category: PresetCategory.Environment, + icon: 'Wind', + + maxParticles: 200, + looping: true, + duration: 10, + playbackSpeed: 1, + + emissionRate: 15, + emissionShape: EmissionShape.Rectangle, + shapeRadius: 100, + shapeWidth: 200, + shapeHeight: 150, + shapeAngle: 0, + + lifetimeMin: 4, + lifetimeMax: 8, + speedMin: 5, + speedMax: 15, + direction: 45, + directionSpread: 90, + scaleMin: 0.2, + scaleMax: 0.6, + gravityX: 10, + gravityY: -2, + + startColor: hexToColor('#ccbb99'), + startAlpha: 0.3, + endAlpha: 0.1, + endScale: 1.2, + + particleSize: 4, + blendMode: ParticleBlendMode.Normal, +}; + +/** + * 泡泡预设 + * Bubble preset + */ +export const BubblePreset: ParticlePreset = { + name: 'Bubbles', + description: 'Rising soap bubbles', + category: PresetCategory.Environment, + icon: 'CircleDot', + + maxParticles: 50, + looping: true, + duration: 10, + playbackSpeed: 1, + + emissionRate: 5, + emissionShape: EmissionShape.Rectangle, + shapeRadius: 40, + shapeWidth: 80, + shapeHeight: 20, + shapeAngle: 0, + + lifetimeMin: 2, + lifetimeMax: 4, + speedMin: 30, + speedMax: 60, + direction: 90, + directionSpread: 20, + scaleMin: 0.5, + scaleMax: 1.5, + gravityX: 10, + gravityY: -10, + + startColor: hexToColor('#aaddff'), + startAlpha: 0.5, + endAlpha: 0.2, + endScale: 1.3, + + particleSize: 16, + blendMode: ParticleBlendMode.Normal, +}; + +/** + * 星轨预设 + * Star trail preset + */ +export const StarTrailPreset: ParticlePreset = { + name: 'Star Trail', + description: 'Glowing star trail effect', + category: PresetCategory.Magic, + icon: 'Sparkle', + + maxParticles: 150, + looping: true, + duration: 5, + playbackSpeed: 1, + + emissionRate: 50, + emissionShape: EmissionShape.Point, + shapeRadius: 2, + shapeWidth: 4, + shapeHeight: 4, + shapeAngle: 0, + + lifetimeMin: 0.2, + lifetimeMax: 0.6, + speedMin: 5, + speedMax: 20, + direction: 180, + directionSpread: 30, + scaleMin: 0.8, + scaleMax: 1.2, + gravityX: 0, + gravityY: 0, + + startColor: hexToColor('#ffff88'), + endColor: hexToColor('#ffaa44'), + startAlpha: 1, + endAlpha: 0, + endScale: 0.1, + + particleSize: 6, + blendMode: ParticleBlendMode.Additive, +}; + +/** + * 默认预设(简单) + * Default preset (simple) + */ +export const DefaultPreset: ParticlePreset = { + name: 'Default', + description: 'Basic particle emitter', + category: PresetCategory.Basic, + icon: 'Circle', + + maxParticles: 100, + looping: true, + duration: 5, + playbackSpeed: 1, + + emissionRate: 10, + emissionShape: EmissionShape.Point, + shapeRadius: 0, + shapeWidth: 0, + shapeHeight: 0, + shapeAngle: 0, + + lifetimeMin: 1, + lifetimeMax: 2, + speedMin: 50, + speedMax: 100, + direction: 90, + directionSpread: 30, + scaleMin: 1, + scaleMax: 1, + gravityX: 0, + gravityY: 0, + + startColor: hexToColor('#ffffff'), + startAlpha: 1, + endAlpha: 0, + endScale: 1, + + particleSize: 8, + blendMode: ParticleBlendMode.Normal, +}; + +/** + * 漩涡预设 + * Vortex preset + */ +export const VortexPreset: ParticlePreset = { + name: 'Vortex', + description: 'Swirling vortex with inward pull', + category: PresetCategory.Magic, + icon: 'Tornado', + + maxParticles: 200, + looping: true, + duration: 10, + playbackSpeed: 1, + + emissionRate: 30, + emissionShape: EmissionShape.Circle, + shapeRadius: 80, + shapeWidth: 160, + shapeHeight: 160, + shapeAngle: 360, + + lifetimeMin: 2, + lifetimeMax: 4, + speedMin: 10, + speedMax: 30, + direction: 0, + directionSpread: 360, + scaleMin: 0.5, + scaleMax: 1, + gravityX: 0, + gravityY: 0, + + startColor: hexToColor('#88ccff'), + startAlpha: 0.8, + endAlpha: 0, + endScale: 0.3, + + particleSize: 8, + blendMode: ParticleBlendMode.Additive, + + forceField: { + type: ForceFieldType.Vortex, + strength: 150, + centerX: 0, + centerY: 0, + inwardStrength: 30, + }, +}; + +/** + * 落叶预设 + * Falling leaves preset + */ +export const LeavesPreset: ParticlePreset = { + name: 'Leaves', + description: 'Falling leaves with wind effect', + category: PresetCategory.Environment, + icon: 'Leaf', + + maxParticles: 100, + looping: true, + duration: 10, + playbackSpeed: 1, + + emissionRate: 8, + emissionShape: EmissionShape.Line, + shapeRadius: 200, + shapeWidth: 400, + shapeHeight: 10, + shapeAngle: 0, + + lifetimeMin: 4, + lifetimeMax: 8, + speedMin: 30, + speedMax: 60, + direction: -90, + directionSpread: 20, + scaleMin: 0.6, + scaleMax: 1.2, + gravityX: 0, + gravityY: 20, + + startColor: hexToColor('#dd8844'), + startAlpha: 0.9, + endAlpha: 0.6, + endScale: 1, + + particleSize: 12, + blendMode: ParticleBlendMode.Normal, + + forceField: { + type: ForceFieldType.Turbulence, + strength: 40, + frequency: 0.5, + }, +}; + +/** + * 弹球预设 + * Bouncing balls preset + */ +export const BouncingPreset: ParticlePreset = { + name: 'Bouncing', + description: 'Bouncing particles in a box', + category: PresetCategory.Basic, + icon: 'Circle', + + maxParticles: 50, + looping: true, + duration: 10, + playbackSpeed: 1, + + emissionRate: 5, + emissionShape: EmissionShape.Point, + shapeRadius: 0, + shapeWidth: 0, + shapeHeight: 0, + shapeAngle: 0, + + lifetimeMin: 8, + lifetimeMax: 12, + speedMin: 100, + speedMax: 200, + direction: 90, + directionSpread: 60, + scaleMin: 0.8, + scaleMax: 1.2, + gravityX: 0, + gravityY: 200, + + startColor: hexToColor('#66aaff'), + startAlpha: 1, + endAlpha: 0.8, + endScale: 1, + + particleSize: 16, + blendMode: ParticleBlendMode.Normal, + + collision: { + boundaryType: BoundaryType.Rectangle, + behavior: CollisionBehavior.Bounce, + bounceFactor: 0.8, + }, +}; + +/** + * 所有预设 + * All presets + */ +export const AllPresets: ParticlePreset[] = [ + DefaultPreset, + FirePreset, + SmokePreset, + SparklePreset, + ExplosionPreset, + RainPreset, + SnowPreset, + MagicAuraPreset, + DustPreset, + BubblePreset, + StarTrailPreset, + VortexPreset, + LeavesPreset, + BouncingPreset, +]; + +/** + * 按分类获取预设 + * Get presets by category + */ +export function getPresetsByCategory(category: PresetCategory): ParticlePreset[] { + return AllPresets.filter(p => p.category === category); +} + +/** + * 获取预设名称列表 + * Get preset name list + */ +export function getPresetNames(): string[] { + return AllPresets.map(p => p.name); +} + +/** + * 按名称获取预设 + * Get preset by name + */ +export function getPresetByName(name: string): ParticlePreset | undefined { + return AllPresets.find(p => p.name === name); +} diff --git a/packages/particle/src/rendering/ParticleRenderDataProvider.ts b/packages/particle/src/rendering/ParticleRenderDataProvider.ts new file mode 100644 index 00000000..ea1f219b --- /dev/null +++ b/packages/particle/src/rendering/ParticleRenderDataProvider.ts @@ -0,0 +1,212 @@ +import type { ParticleSystemComponent } from '../ParticleSystemComponent'; +import { Color } from '@esengine/ecs-framework-math'; + +/** + * 粒子渲染数据(与 EngineRenderSystem 兼容) + * Particle render data (compatible with EngineRenderSystem) + * + * This interface is compatible with ProviderRenderData from EngineRenderSystem. + * 此接口与 EngineRenderSystem 的 ProviderRenderData 兼容。 + */ +export interface ParticleProviderRenderData { + transforms: Float32Array; + textureIds: Uint32Array; + uvs: Float32Array; + colors: Uint32Array; + tileCount: number; + sortingOrder: number; + texturePath?: string; +} + +/** + * Transform 接口(避免直接依赖 engine-core) + * Transform interface (avoid direct dependency on engine-core) + */ +interface ITransformLike { + worldPosition?: { x: number; y: number }; + position: { x: number; y: number }; +} + +/** + * 渲染数据提供者接口(与 EngineRenderSystem 兼容) + * Render data provider interface (compatible with EngineRenderSystem) + * + * This interface matches IRenderDataProvider from @esengine/ecs-engine-bindgen. + * 此接口与 @esengine/ecs-engine-bindgen 的 IRenderDataProvider 匹配。 + */ +export interface IRenderDataProvider { + getRenderData(): readonly ParticleProviderRenderData[]; +} + +/** + * 粒子渲染数据提供者 + * Particle render data provider + * + * Collects render data from all active particle systems. + * 从所有活跃的粒子系统收集渲染数据。 + * + * Implements IRenderDataProvider for integration with EngineRenderSystem. + * 实现 IRenderDataProvider 以便与 EngineRenderSystem 集成。 + */ +export class ParticleRenderDataProvider implements IRenderDataProvider { + private _particleSystems: Map = new Map(); + private _renderDataCache: ParticleProviderRenderData[] = []; + private _dirty: boolean = true; + + // 预分配的缓冲区 | Pre-allocated buffers + private _maxParticles: number = 0; + private _transforms: Float32Array = new Float32Array(0); + private _textureIds: Uint32Array = new Uint32Array(0); + private _uvs: Float32Array = new Float32Array(0); + private _colors: Uint32Array = new Uint32Array(0); + + /** + * 注册粒子系统 + * Register particle system + */ + register(component: ParticleSystemComponent, transform: ITransformLike): void { + this._particleSystems.set(component, transform); + this._dirty = true; + } + + /** + * 注销粒子系统 + * Unregister particle system + */ + unregister(component: ParticleSystemComponent): void { + this._particleSystems.delete(component); + this._dirty = true; + } + + /** + * 标记为脏 + * Mark as dirty + */ + markDirty(): void { + this._dirty = true; + } + + /** + * 获取渲染数据 + * Get render data + */ + getRenderData(): readonly ParticleProviderRenderData[] { + this._updateRenderData(); + return this._renderDataCache; + } + + private _updateRenderData(): void { + this._renderDataCache.length = 0; + + // 计算总粒子数 | Calculate total particle count + let totalParticles = 0; + for (const [component] of this._particleSystems) { + if (component.isPlaying && component.pool) { + totalParticles += component.pool.activeCount; + } + } + + if (totalParticles === 0) return; + + // 确保缓冲区足够大 | Ensure buffers are large enough + if (totalParticles > this._maxParticles) { + this._maxParticles = Math.max(totalParticles, this._maxParticles * 2, 1000); + this._transforms = new Float32Array(this._maxParticles * 7); + this._textureIds = new Uint32Array(this._maxParticles); + this._uvs = new Float32Array(this._maxParticles * 4); + this._colors = new Uint32Array(this._maxParticles); + } + + // 按 sortingOrder 分组 | Group by sortingOrder + const groups = new Map(); + + for (const [component, transform] of this._particleSystems) { + if (!component.isPlaying || !component.pool || component.pool.activeCount === 0) { + continue; + } + + const order = component.sortingOrder; + if (!groups.has(order)) { + groups.set(order, []); + } + groups.get(order)!.push({ component, transform }); + } + + // 为每个 sortingOrder 组生成渲染数据 | Generate render data for each sortingOrder group + for (const [sortingOrder, systems] of groups) { + let particleIndex = 0; + + for (const { component } of systems) { + const pool = component.pool!; + const size = component.particleSize; + const textureId = component.textureId; + + // 世界偏移 | World offset (particles are already in world space after emission) + // 不需要额外偏移,因为粒子发射时已经使用了世界坐标 + // No additional offset needed since particles use world coords at emission + + pool.forEachActive((p) => { + const tOffset = particleIndex * 7; + const uvOffset = particleIndex * 4; + + // Transform: x, y, rotation, scaleX, scaleY, originX, originY + this._transforms[tOffset] = p.x; + this._transforms[tOffset + 1] = p.y; + this._transforms[tOffset + 2] = p.rotation; + this._transforms[tOffset + 3] = size * p.scaleX; + this._transforms[tOffset + 4] = size * p.scaleY; + this._transforms[tOffset + 5] = 0.5; // originX + this._transforms[tOffset + 6] = 0.5; // originY + + // Texture ID + this._textureIds[particleIndex] = textureId; + + // UV (full texture) + this._uvs[uvOffset] = 0; + this._uvs[uvOffset + 1] = 0; + this._uvs[uvOffset + 2] = 1; + this._uvs[uvOffset + 3] = 1; + + // Color (packed ABGR for WebGL) + this._colors[particleIndex] = Color.packABGR( + Math.round(p.r * 255), + Math.round(p.g * 255), + Math.round(p.b * 255), + p.alpha + ); + + particleIndex++; + }); + } + + if (particleIndex > 0) { + // 创建当前组的渲染数据 | Create render data for current group + const renderData: ParticleProviderRenderData = { + transforms: this._transforms.subarray(0, particleIndex * 7), + textureIds: this._textureIds.subarray(0, particleIndex), + uvs: this._uvs.subarray(0, particleIndex * 4), + colors: this._colors.subarray(0, particleIndex), + tileCount: particleIndex, + sortingOrder, + texturePath: systems[0]?.component.texture || undefined + }; + + this._renderDataCache.push(renderData); + } + } + + this._dirty = false; + } + + /** + * 清理 + * Cleanup + */ + dispose(): void { + this._particleSystems.clear(); + this._renderDataCache.length = 0; + } +} diff --git a/packages/particle/src/rendering/index.ts b/packages/particle/src/rendering/index.ts new file mode 100644 index 00000000..4f5c8cef --- /dev/null +++ b/packages/particle/src/rendering/index.ts @@ -0,0 +1,5 @@ +export { + ParticleRenderDataProvider, + type ParticleProviderRenderData, + type IRenderDataProvider +} from './ParticleRenderDataProvider'; diff --git a/packages/particle/src/systems/ParticleSystem.ts b/packages/particle/src/systems/ParticleSystem.ts new file mode 100644 index 00000000..2ceb6aa7 --- /dev/null +++ b/packages/particle/src/systems/ParticleSystem.ts @@ -0,0 +1,118 @@ +import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-framework'; +import { ParticleSystemComponent } from '../ParticleSystemComponent'; +import { ParticleRenderDataProvider } from '../rendering/ParticleRenderDataProvider'; + +/** + * Transform 组件接口(避免直接依赖 engine-core) + * Transform component interface (avoid direct dependency on engine-core) + */ +interface ITransformComponent { + worldPosition?: { x: number; y: number; z: number }; + position: { x: number; y: number; z: number }; +} + +/** + * 粒子更新系统 + * Particle update system + * + * Updates all ParticleSystemComponents with their entity's world position. + * 使用实体的世界坐标更新所有粒子系统组件。 + */ +@ECSSystem('ParticleUpdate', { updateOrder: 100 }) +export class ParticleUpdateSystem extends EntitySystem { + private _transformType: (new (...args: any[]) => ITransformComponent) | null = null; + private _renderDataProvider: ParticleRenderDataProvider; + + constructor() { + super(Matcher.empty().all(ParticleSystemComponent)); + this._renderDataProvider = new ParticleRenderDataProvider(); + } + + /** + * 设置 Transform 组件类型 + * Set Transform component type + * + * @param transformType - Transform component class | Transform 组件类 + */ + setTransformType(transformType: new (...args: any[]) => ITransformComponent): void { + this._transformType = transformType; + } + + /** + * 获取渲染数据提供者 + * Get render data provider + */ + getRenderDataProvider(): ParticleRenderDataProvider { + return this._renderDataProvider; + } + + protected override process(entities: readonly Entity[]): void { + const deltaTime = Time.deltaTime; + + for (const entity of entities) { + if (!entity.enabled) continue; + + const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null; + if (!particle) continue; + + let worldX = 0; + let worldY = 0; + let transform: ITransformComponent | null = null; + + // 获取 Transform 位置 | Get Transform position + if (this._transformType) { + transform = entity.getComponent(this._transformType as any) as ITransformComponent | null; + if (transform) { + const pos = transform.worldPosition ?? transform.position; + worldX = pos.x; + worldY = pos.y; + } + } + + // 更新粒子系统 | Update particle system + if (particle.isPlaying) { + particle.update(deltaTime, worldX, worldY); + } + + // 更新渲染数据提供者的 Transform 引用 | Update render data provider's Transform reference + if (transform) { + this._renderDataProvider.register(particle, transform); + } + } + + // 标记渲染数据需要更新 | Mark render data as dirty + this._renderDataProvider.markDirty(); + } + + protected override onAdded(entity: Entity): void { + const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null; + if (particle) { + particle.initialize(); + + // 注册到渲染数据提供者 | Register to render data provider + if (this._transformType) { + const transform = entity.getComponent(this._transformType as any) as ITransformComponent | null; + if (transform) { + this._renderDataProvider.register(particle, transform); + } + } + } + } + + protected override onRemoved(entity: Entity): void { + const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null; + if (particle) { + // 从渲染数据提供者注销 | Unregister from render data provider + this._renderDataProvider.unregister(particle); + } + } + + /** + * 系统销毁时清理 + * Cleanup on system destroy + */ + public override destroy(): void { + super.destroy(); + this._renderDataProvider.dispose(); + } +} diff --git a/packages/particle/tsconfig.build.json b/packages/particle/tsconfig.build.json new file mode 100644 index 00000000..f39a0594 --- /dev/null +++ b/packages/particle/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/particle/tsconfig.json b/packages/particle/tsconfig.json new file mode 100644 index 00000000..02f5f187 --- /dev/null +++ b/packages/particle/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../core" } + ] +} diff --git a/packages/particle/tsup.config.ts b/packages/particle/tsup.config.ts new file mode 100644 index 00000000..f704a430 --- /dev/null +++ b/packages/particle/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsup'; +import { runtimeOnlyPreset } from '../build-config/src/presets/plugin-tsup'; + +export default defineConfig({ + ...runtimeOnlyPreset(), + tsconfig: 'tsconfig.build.json' +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae5872ad..3a3c475f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -592,6 +592,12 @@ importers: '@esengine/material-system': specifier: workspace:* version: link:../material-system + '@esengine/particle': + specifier: workspace:* + version: link:../particle + '@esengine/particle-editor': + specifier: workspace:* + version: link:../particle-editor '@esengine/physics-rapier2d': specifier: workspace:* version: link:../physics-rapier2d @@ -1043,6 +1049,73 @@ importers: specifier: ^5.0.8 version: 5.0.8(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + packages/particle: + devDependencies: + '@esengine/asset-system': + specifier: workspace:* + version: link:../asset-system + '@esengine/build-config': + specifier: workspace:* + version: link:../build-config + '@esengine/ecs-framework': + specifier: workspace:* + version: link:../core + '@esengine/ecs-framework-math': + specifier: workspace:* + version: link:../math + '@esengine/engine-core': + specifier: workspace:* + version: link:../engine-core + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + tsup: + specifier: ^8.0.0 + version: 8.5.1(@microsoft/api-extractor@7.55.1(@types/node@20.19.25))(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + + packages/particle-editor: + dependencies: + '@esengine/particle': + specifier: workspace:* + version: link:../particle + devDependencies: + '@esengine/build-config': + specifier: workspace:* + version: link:../build-config + '@esengine/ecs-framework': + specifier: workspace:* + version: link:../core + '@esengine/editor-core': + specifier: workspace:* + version: link:../editor-core + '@esengine/engine-core': + specifier: workspace:* + version: link:../engine-core + '@types/react': + specifier: ^18.3.12 + version: 18.3.27 + lucide-react: + specifier: ^0.545.0 + version: 0.545.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + tsup: + specifier: ^8.0.0 + version: 8.5.1(@microsoft/api-extractor@7.55.1(@types/node@20.19.25))(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + zustand: + specifier: ^5.0.8 + version: 5.0.8(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + packages/physics-rapier2d: dependencies: '@esengine/platform-common':