feat(particle): 添加完整粒子系统和粒子编辑器 (#284)
* feat(editor-core): 添加用户系统自动注册功能 - IUserCodeService 新增 registerSystems/unregisterSystems/getRegisteredSystems 方法 - UserCodeService 实现系统检测、实例化和场景注册逻辑 - ServiceRegistry 在预览开始时注册用户系统,停止时移除 - 热更新时自动重新加载用户系统 - 更新 System 脚本模板添加 @ECSSystem 装饰器 * feat(editor-core): 添加编辑器脚本支持(Inspector/Gizmo) - registerEditorExtensions 实际注册用户 Inspector 和 Gizmo - 添加 unregisterEditorExtensions 方法 - ServiceRegistry 在项目加载时编译并加载编辑器脚本 - 项目关闭时自动清理编辑器扩展 - 添加 Inspector 和 Gizmo 脚本创建模板 * feat(particle): 添加粒子系统和粒子编辑器 新增两个包: - @esengine/particle: 粒子系统核心库 - @esengine/particle-editor: 粒子编辑器 UI 粒子系统功能: - ECS 组件架构,支持播放/暂停/重置控制 - 7种发射形状:点、圆、环、矩形、边缘、线、锥形 - 5个动画模块:颜色渐变、缩放曲线、速度控制、旋转、噪声 - 纹理动画模块支持精灵表动画 - 3种混合模式:Normal、Additive、Multiply - 11个内置预设:火焰、烟雾、爆炸、雨、雪等 - 对象池优化,支持粒子复用 编辑器功能: - 实时 Canvas 预览,支持全屏和鼠标跟随 - 点击触发爆发效果(用于测试爆炸类特效) - 渐变编辑器:可视化颜色关键帧编辑 - 曲线编辑器:支持缩放曲线和缓动函数 - 预设浏览器:快速应用内置预设 - 模块开关:独立启用/禁用各个模块 - Vector2 样式输入(重力 X/Y) * feat(particle): 完善粒子系统核心功能 1. Burst 定时爆发系统 - BurstConfig 接口支持时间、数量、循环次数、间隔 - 运行时自动处理定时爆发 - 支持无限循环爆发 2. 速度曲线模块 (VelocityOverLifetimeModule) - 6种曲线类型:Constant、Linear、EaseIn、EaseOut、EaseInOut、Custom - 自定义关键帧曲线支持 - 附加速度 X/Y - 轨道速度和径向速度 3. 碰撞边界模块 (CollisionModule) - 矩形和圆形边界类型 - 3种碰撞行为:Kill、Bounce、Wrap - 反弹系数和最小速度阈值 - 反弹时生命损失 * feat(particle): 添加力场模块、碰撞模块和世界/本地空间支持 - 新增 ForceFieldModule 支持风力、吸引点、漩涡、湍流四种力场类型 - 新增 SimulationSpace 枚举支持世界空间和本地空间切换 - ParticleSystemComponent 集成力场模块和空间模式 - 粒子编辑器添加 Collision 和 ForceField 模块的 UI 编辑支持 - 新增 Vortex、Leaves、Bouncing 三个预设展示新功能 - 编辑器预览实现完整的碰撞和力场效果 * fix(particle): 移除未使用的 transform 循环变量
This commit is contained in:
@@ -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:*",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
50
packages/particle-editor/package.json
Normal file
50
packages/particle-editor/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@esengine/particle-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor support for @esengine/particle - particle system inspector and preview",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/particle": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/editor-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
"zustand": "^5.0.8",
|
||||
"@types/react": "^18.3.12",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"particle",
|
||||
"editor"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT"
|
||||
}
|
||||
232
packages/particle-editor/src/ParticleEditorModule.ts
Normal file
232
packages/particle-editor/src/ParticleEditorModule.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 粒子编辑器模块
|
||||
* Particle Editor Module
|
||||
*
|
||||
* Registers file handlers, panels, and templates for .particle files.
|
||||
*/
|
||||
|
||||
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IEditorModuleLoader,
|
||||
PanelDescriptor,
|
||||
EntityCreationTemplate,
|
||||
ComponentInspectorProviderDef,
|
||||
FileActionHandler,
|
||||
FileCreationTemplate,
|
||||
IPlugin,
|
||||
ModuleManifest
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
PanelPosition,
|
||||
InspectorRegistry,
|
||||
EntityStoreService,
|
||||
MessageHub,
|
||||
ComponentRegistry,
|
||||
FileActionRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import {
|
||||
ParticleSystemComponent,
|
||||
ParticleRuntimeModule,
|
||||
createDefaultParticleAsset
|
||||
} from '@esengine/particle';
|
||||
|
||||
import { ParticleEditorPanel } from './panels/ParticleEditorPanel';
|
||||
import { ParticleInspectorProvider } from './providers/ParticleInspectorProvider';
|
||||
import { useParticleEditorStore } from './stores/ParticleEditorStore';
|
||||
|
||||
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM)
|
||||
// Import editor CSS styles (automatically handled and injected by vite)
|
||||
import './styles/ParticleEditor.css';
|
||||
|
||||
/**
|
||||
* 粒子编辑器模块
|
||||
* Particle Editor Module
|
||||
*/
|
||||
export class ParticleEditorModule implements IEditorModuleLoader {
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
// 注册检视器提供者 | Register inspector provider
|
||||
const inspectorRegistry = services.resolve(InspectorRegistry);
|
||||
if (inspectorRegistry) {
|
||||
inspectorRegistry.register(new ParticleInspectorProvider());
|
||||
}
|
||||
|
||||
// 注册组件到编辑器组件注册表 | Register to editor component registry
|
||||
const componentRegistry = services.resolve(ComponentRegistry);
|
||||
if (componentRegistry) {
|
||||
componentRegistry.register({
|
||||
name: 'ParticleSystem',
|
||||
type: ParticleSystemComponent,
|
||||
category: 'components.category.effects',
|
||||
description: 'Particle system for 2D visual effects',
|
||||
icon: 'Sparkles'
|
||||
});
|
||||
}
|
||||
|
||||
// 注册资产创建消息映射 | Register asset creation message mappings
|
||||
const fileActionRegistry = services.resolve(FileActionRegistry);
|
||||
if (fileActionRegistry) {
|
||||
fileActionRegistry.registerAssetCreationMapping({
|
||||
extension: '.particle',
|
||||
createMessage: 'particle:create-asset'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// 清理 | Clean up
|
||||
}
|
||||
|
||||
getPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'particle-editor',
|
||||
title: 'Particle Editor',
|
||||
position: PanelPosition.Center,
|
||||
closable: true,
|
||||
component: ParticleEditorPanel,
|
||||
isDynamic: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getInspectorProviders(): ComponentInspectorProviderDef[] {
|
||||
return [
|
||||
{
|
||||
componentType: 'ParticleSystem',
|
||||
priority: 100,
|
||||
render: (component, entity, onChange) => {
|
||||
const provider = new ParticleInspectorProvider();
|
||||
return provider.render(
|
||||
{ entityId: String(entity.id), component },
|
||||
{ target: component, onChange }
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getEntityCreationTemplates(): EntityCreationTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'create-particle-entity',
|
||||
label: '创建粒子效果',
|
||||
icon: 'Sparkles',
|
||||
category: 'effects',
|
||||
order: 100,
|
||||
create: (): number => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('Scene not available');
|
||||
}
|
||||
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStore || !messageHub) {
|
||||
throw new Error('EntityStoreService or MessageHub not available');
|
||||
}
|
||||
|
||||
const particleCount = entityStore.getAllEntities()
|
||||
.filter((e: Entity) => e.name.startsWith('ParticleSystem ')).length;
|
||||
const entityName = `ParticleSystem ${particleCount + 1}`;
|
||||
|
||||
const entity = scene.createEntity(entityName);
|
||||
entity.addComponent(new TransformComponent());
|
||||
entity.addComponent(new ParticleSystemComponent());
|
||||
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getFileActionHandlers(): FileActionHandler[] {
|
||||
return [
|
||||
{
|
||||
extensions: ['particle', 'json'],
|
||||
onDoubleClick: (filePath: string) => {
|
||||
// 只处理 .particle 和 .particle.json 文件
|
||||
// Only handle .particle and .particle.json files
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
if (!lowerPath.endsWith('.particle') && !lowerPath.endsWith('.particle.json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先设置待打开的文件路径到 store
|
||||
// Set pending file path to store first
|
||||
useParticleEditorStore.getState().setPendingFilePath(filePath);
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
// 打开粒子编辑器面板(面板挂载后会从 store 读取 pendingFilePath)
|
||||
// Open particle editor panel (panel will read pendingFilePath from store after mount)
|
||||
messageHub.publish('dynamic-panel:open', {
|
||||
panelId: 'particle-editor',
|
||||
title: `Particle Editor - ${filePath.split(/[\\/]/).pop()}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getFileCreationTemplates(): FileCreationTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'create-particle',
|
||||
label: 'Particle Effect',
|
||||
extension: 'particle',
|
||||
icon: 'Sparkles',
|
||||
category: 'effects',
|
||||
getContent: (fileName: string) => {
|
||||
const assetData = createDefaultParticleAsset(fileName.replace('.particle', ''));
|
||||
return JSON.stringify(assetData, null, 2);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const particleEditorModule = new ParticleEditorModule();
|
||||
|
||||
/**
|
||||
* 粒子插件清单
|
||||
* Particle Plugin Manifest
|
||||
*/
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/particle',
|
||||
name: '@esengine/particle',
|
||||
displayName: 'Particle System',
|
||||
version: '1.0.0',
|
||||
description: 'Particle system for 2D visual effects',
|
||||
category: 'Rendering',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['ParticleSystemComponent'],
|
||||
systems: ['ParticleUpdateSystem'],
|
||||
loaders: ['ParticleLoader']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的粒子插件(运行时 + 编辑器)
|
||||
* Complete Particle Plugin (runtime + editor)
|
||||
*/
|
||||
export const ParticlePlugin: IPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new ParticleRuntimeModule(),
|
||||
editorModule: particleEditorModule
|
||||
};
|
||||
|
||||
export default particleEditorModule;
|
||||
535
packages/particle-editor/src/components/CurveEditor.tsx
Normal file
535
packages/particle-editor/src/components/CurveEditor.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* 曲线编辑器组件
|
||||
* Curve Editor Component
|
||||
*
|
||||
* A visual editor for animation curves used in particle systems.
|
||||
* 用于粒子系统的动画曲线可视化编辑器。
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import type { ScaleKey } from '@esengine/particle';
|
||||
import { ScaleCurveType } from '@esengine/particle';
|
||||
import { X, Plus } from 'lucide-react';
|
||||
|
||||
interface CurveEditorProps {
|
||||
/** 曲线关键帧 | Curve keyframes */
|
||||
keys: ScaleKey[];
|
||||
/** 变化回调 | Change callback */
|
||||
onChange: (keys: ScaleKey[]) => void;
|
||||
/** 曲线类型 | Curve type */
|
||||
curveType: ScaleCurveType;
|
||||
/** 曲线类型变化回调 | Curve type change callback */
|
||||
onCurveTypeChange?: (type: ScaleCurveType) => void;
|
||||
/** Y 轴最小值 | Y-axis minimum */
|
||||
minY?: number;
|
||||
/** Y 轴最大值 | Y-axis maximum */
|
||||
maxY?: number;
|
||||
}
|
||||
|
||||
// 内边距 | Padding
|
||||
const PADDING = { left: 28, right: 8, top: 8, bottom: 16 };
|
||||
const POINT_RADIUS = 5;
|
||||
const HIT_RADIUS = 10;
|
||||
|
||||
/**
|
||||
* 曲线编辑器
|
||||
* Curve Editor
|
||||
*/
|
||||
export function CurveEditor({
|
||||
keys,
|
||||
onChange,
|
||||
curveType,
|
||||
onCurveTypeChange,
|
||||
minY = 0,
|
||||
maxY = 2,
|
||||
}: CurveEditorProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
// 监听容器大小变化 | Watch container size changes
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateSize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
setCanvasSize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
|
||||
updateSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// 获取绘图区域 | Get drawing area
|
||||
const getDrawArea = useCallback(() => {
|
||||
return {
|
||||
x: PADDING.left,
|
||||
y: PADDING.top,
|
||||
width: canvasSize.width - PADDING.left - PADDING.right,
|
||||
height: canvasSize.height - PADDING.top - PADDING.bottom,
|
||||
};
|
||||
}, [canvasSize]);
|
||||
|
||||
// 数据坐标转画布坐标 | Data to canvas coordinates
|
||||
const dataToCanvas = useCallback((time: number, scale: number) => {
|
||||
const area = getDrawArea();
|
||||
return {
|
||||
x: area.x + time * area.width,
|
||||
y: area.y + area.height - ((scale - minY) / (maxY - minY)) * area.height,
|
||||
};
|
||||
}, [getDrawArea, minY, maxY]);
|
||||
|
||||
// 画布坐标转数据坐标 | Canvas to data coordinates
|
||||
const canvasToData = useCallback((canvasX: number, canvasY: number) => {
|
||||
const area = getDrawArea();
|
||||
const time = Math.max(0, Math.min(1, (canvasX - area.x) / area.width));
|
||||
const scale = maxY - ((canvasY - area.y) / area.height) * (maxY - minY);
|
||||
return {
|
||||
time,
|
||||
scale: Math.max(minY, Math.min(maxY, scale)),
|
||||
};
|
||||
}, [getDrawArea, minY, maxY]);
|
||||
|
||||
// 获取鼠标在 canvas 上的坐标 | Get mouse position on canvas
|
||||
const getMousePos = useCallback((e: React.MouseEvent | MouseEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 查找点击的关键帧 | Find clicked keyframe
|
||||
const findKeyAtPos = useCallback((x: number, y: number): number => {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const pos = dataToCanvas(keys[i].time, keys[i].scale);
|
||||
const dx = pos.x - x;
|
||||
const dy = pos.y - y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < HIT_RADIUS) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [keys, dataToCanvas]);
|
||||
|
||||
// 绘制曲线 | Draw curve
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || canvasSize.width === 0) return;
|
||||
|
||||
// 设置 canvas 实际像素大小 | Set canvas pixel size
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvasSize.width * dpr;
|
||||
canvas.height = canvasSize.height * dpr;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const area = getDrawArea();
|
||||
|
||||
// 清空画布 | Clear canvas
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
|
||||
|
||||
// 绘制绘图区域背景 | Draw area background
|
||||
ctx.fillStyle = '#222';
|
||||
ctx.fillRect(area.x, area.y, area.width, area.height);
|
||||
|
||||
// 绘制网格 | Draw grid
|
||||
ctx.strokeStyle = '#2a2a2a';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// 垂直网格线 (0, 0.25, 0.5, 0.75, 1) | Vertical grid lines
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const x = Math.floor(area.x + (i / 4) * area.width) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, area.y);
|
||||
ctx.lineTo(x, area.y + area.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 水平网格线 | Horizontal grid lines
|
||||
const ySteps = 4;
|
||||
for (let i = 0; i <= ySteps; i++) {
|
||||
const y = Math.floor(area.y + (i / ySteps) * area.height) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(area.x, y);
|
||||
ctx.lineTo(area.x + area.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制 1.0 参考线(如果在范围内)| Draw 1.0 reference line
|
||||
if (minY <= 1 && maxY >= 1) {
|
||||
const onePos = dataToCanvas(0, 1);
|
||||
ctx.strokeStyle = '#444';
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(area.x, onePos.y);
|
||||
ctx.lineTo(area.x + area.width, onePos.y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// 绘制 Y 轴标签 | Draw Y-axis labels
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let i = 0; i <= ySteps; i++) {
|
||||
const value = maxY - (i / ySteps) * (maxY - minY);
|
||||
const y = area.y + (i / ySteps) * area.height;
|
||||
ctx.fillText(value.toFixed(1), area.x - 4, y);
|
||||
}
|
||||
|
||||
// 绘制 X 轴标签 | Draw X-axis labels
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText('0', area.x, area.y + area.height + 2);
|
||||
ctx.fillText('0.5', area.x + area.width / 2, area.y + area.height + 2);
|
||||
ctx.fillText('1', area.x + area.width, area.y + area.height + 2);
|
||||
|
||||
// 绘制曲线 | Draw curve
|
||||
if (keys.length > 0) {
|
||||
const sortedKeys = [...keys].sort((a, b) => a.time - b.time);
|
||||
|
||||
ctx.strokeStyle = '#4a9eff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
// 采样曲线 | Sample curve
|
||||
const samples = 100;
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = i / samples;
|
||||
const value = evaluateCurve(sortedKeys, t, curveType);
|
||||
const pos = dataToCanvas(t, value);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(pos.x, pos.y);
|
||||
} else {
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制关键帧点 | Draw keyframe points
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
const pos = dataToCanvas(key.time, key.scale);
|
||||
const isSelected = selectedIndex === i;
|
||||
const isHovered = hoverIndex === i;
|
||||
|
||||
// 外圈 | Outer ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, POINT_RADIUS + 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isSelected ? '#4a9eff' : (isHovered ? '#666' : 'transparent');
|
||||
ctx.fill();
|
||||
|
||||
// 内圈 | Inner circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, POINT_RADIUS, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isSelected ? '#fff' : '#4a9eff';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}, [keys, curveType, minY, maxY, selectedIndex, hoverIndex, canvasSize, getDrawArea, dataToCanvas]);
|
||||
|
||||
// 处理鼠标移动(悬停效果)| Handle mouse move (hover effect)
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (isDragging) return;
|
||||
const pos = getMousePos(e);
|
||||
const index = findKeyAtPos(pos.x, pos.y);
|
||||
setHoverIndex(index >= 0 ? index : null);
|
||||
}, [isDragging, getMousePos, findKeyAtPos]);
|
||||
|
||||
// 处理鼠标离开 | Handle mouse leave
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (!isDragging) {
|
||||
setHoverIndex(null);
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
// 处理点击 | Handle click
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const pos = getMousePos(e);
|
||||
const index = findKeyAtPos(pos.x, pos.y);
|
||||
|
||||
if (index >= 0) {
|
||||
// 选中现有点 | Select existing point
|
||||
setSelectedIndex(index);
|
||||
setIsDragging(true);
|
||||
} else {
|
||||
// 检查是否在绘图区域内 | Check if in draw area
|
||||
const area = getDrawArea();
|
||||
if (pos.x >= area.x && pos.x <= area.x + area.width &&
|
||||
pos.y >= area.y && pos.y <= area.y + area.height) {
|
||||
// 添加新点 | Add new point
|
||||
const data = canvasToData(pos.x, pos.y);
|
||||
const newKey: ScaleKey = { time: data.time, scale: data.scale };
|
||||
const newKeys = [...keys, newKey].sort((a, b) => a.time - b.time);
|
||||
const newIndex = newKeys.findIndex(k => k.time === data.time && k.scale === data.scale);
|
||||
onChange(newKeys);
|
||||
setSelectedIndex(newIndex);
|
||||
setIsDragging(true);
|
||||
}
|
||||
}
|
||||
}, [getMousePos, findKeyAtPos, getDrawArea, canvasToData, keys, onChange]);
|
||||
|
||||
// 全局拖拽处理 | Global drag handling
|
||||
useEffect(() => {
|
||||
if (!isDragging || selectedIndex === null) return;
|
||||
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
const pos = getMousePos(e);
|
||||
const data = canvasToData(pos.x, pos.y);
|
||||
|
||||
// 更新选中点的位置 | Update selected point position
|
||||
const newKeys = keys.map((key, i) => {
|
||||
if (i === selectedIndex) {
|
||||
return { time: data.time, scale: data.scale };
|
||||
}
|
||||
return key;
|
||||
});
|
||||
|
||||
// 保持当前选中的点,不重新排序(拖拽时保持索引)
|
||||
// Keep current selection, don't resort during drag
|
||||
onChange(newKeys);
|
||||
};
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
// 拖拽结束后排序 | Sort after drag ends
|
||||
const sortedKeys = [...keys].sort((a, b) => a.time - b.time);
|
||||
if (selectedIndex !== null) {
|
||||
const selectedKey = keys[selectedIndex];
|
||||
const newIndex = sortedKeys.findIndex(
|
||||
k => k.time === selectedKey.time && k.scale === selectedKey.scale
|
||||
);
|
||||
setSelectedIndex(newIndex >= 0 ? newIndex : null);
|
||||
}
|
||||
onChange(sortedKeys);
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
}, [isDragging, selectedIndex, keys, onChange, getMousePos, canvasToData]);
|
||||
|
||||
// 删除选中关键帧 | Delete selected keyframe
|
||||
const handleDelete = useCallback(() => {
|
||||
if (selectedIndex === null || keys.length <= 1) return;
|
||||
const newKeys = keys.filter((_, i) => i !== selectedIndex);
|
||||
onChange(newKeys);
|
||||
setSelectedIndex(null);
|
||||
}, [selectedIndex, keys, onChange]);
|
||||
|
||||
// 处理数值输入 | Handle value input
|
||||
const handleValueChange = useCallback((field: 'time' | 'scale', inputValue: string) => {
|
||||
if (selectedIndex === null) return;
|
||||
|
||||
const numValue = parseFloat(inputValue);
|
||||
if (isNaN(numValue)) return;
|
||||
|
||||
const newKeys = [...keys];
|
||||
if (field === 'time') {
|
||||
newKeys[selectedIndex] = {
|
||||
...newKeys[selectedIndex],
|
||||
time: Math.max(0, Math.min(1, numValue))
|
||||
};
|
||||
} else {
|
||||
newKeys[selectedIndex] = {
|
||||
...newKeys[selectedIndex],
|
||||
scale: Math.max(minY, Math.min(maxY, numValue))
|
||||
};
|
||||
}
|
||||
const sortedKeys = newKeys.sort((a, b) => a.time - b.time);
|
||||
const newIndex = sortedKeys.findIndex(
|
||||
k => k.time === newKeys[selectedIndex].time && k.scale === newKeys[selectedIndex].scale
|
||||
);
|
||||
onChange(sortedKeys);
|
||||
setSelectedIndex(newIndex >= 0 ? newIndex : null);
|
||||
}, [selectedIndex, keys, onChange, minY, maxY]);
|
||||
|
||||
// 应用预设 | Apply preset
|
||||
const applyPreset = useCallback((preset: ScaleKey[]) => {
|
||||
onChange(preset);
|
||||
setSelectedIndex(null);
|
||||
}, [onChange]);
|
||||
|
||||
const selectedKey = selectedIndex !== null ? keys[selectedIndex] : null;
|
||||
|
||||
return (
|
||||
<div className="curve-editor">
|
||||
{/* 曲线类型选择 | Curve type selector */}
|
||||
{onCurveTypeChange && (
|
||||
<div className="curve-type-row">
|
||||
<label>Curve</label>
|
||||
<select
|
||||
value={curveType}
|
||||
onChange={e => onCurveTypeChange(e.target.value as ScaleCurveType)}
|
||||
className="curve-type-select"
|
||||
>
|
||||
<option value={ScaleCurveType.Linear}>Linear</option>
|
||||
<option value={ScaleCurveType.EaseIn}>Ease In</option>
|
||||
<option value={ScaleCurveType.EaseOut}>Ease Out</option>
|
||||
<option value={ScaleCurveType.EaseInOut}>Ease In Out</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 曲线画布 | Curve canvas */}
|
||||
<div ref={containerRef} className="curve-canvas-container">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="curve-canvas"
|
||||
style={{ cursor: isDragging ? 'grabbing' : (hoverIndex !== null ? 'grab' : 'crosshair') }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 编辑面板 | Edit panel */}
|
||||
<div className="curve-edit-row">
|
||||
{selectedKey ? (
|
||||
<>
|
||||
<label>T</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={selectedKey.time.toFixed(2)}
|
||||
onChange={e => handleValueChange('time', e.target.value)}
|
||||
className="curve-value-input"
|
||||
/>
|
||||
<label>V</label>
|
||||
<input
|
||||
type="number"
|
||||
min={minY}
|
||||
max={maxY}
|
||||
step={0.1}
|
||||
value={selectedKey.scale.toFixed(2)}
|
||||
onChange={e => handleValueChange('scale', e.target.value)}
|
||||
className="curve-value-input"
|
||||
/>
|
||||
<button
|
||||
className="curve-delete-btn"
|
||||
onClick={handleDelete}
|
||||
disabled={keys.length <= 1}
|
||||
title="Delete point"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="curve-hint">Click to add point</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预设按钮 | Preset buttons */}
|
||||
<div className="curve-presets">
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 1 }, { time: 1, scale: 1 }])}
|
||||
title="Constant value"
|
||||
>
|
||||
—
|
||||
</button>
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 0 }, { time: 1, scale: 1 }])}
|
||||
title="Fade in"
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 1 }, { time: 1, scale: 0 }])}
|
||||
title="Fade out"
|
||||
>
|
||||
↘
|
||||
</button>
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 0 }, { time: 0.5, scale: 1 }, { time: 1, scale: 0 }])}
|
||||
title="Bell curve"
|
||||
>
|
||||
∩
|
||||
</button>
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 1 }, { time: 0.5, scale: 0 }, { time: 1, scale: 1 }])}
|
||||
title="U curve"
|
||||
>
|
||||
∪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算曲线值
|
||||
* Evaluate curve value
|
||||
*/
|
||||
function evaluateCurve(keys: ScaleKey[], t: number, curveType: ScaleCurveType): number {
|
||||
if (keys.length === 0) return 1;
|
||||
if (keys.length === 1) return keys[0].scale;
|
||||
|
||||
// 查找相邻关键帧 | Find adjacent keyframes
|
||||
let left = keys[0];
|
||||
let right = keys[keys.length - 1];
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (keys[i].time <= t && keys[i + 1].time >= t) {
|
||||
left = keys[i];
|
||||
right = keys[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (t <= left.time) return left.scale;
|
||||
if (t >= right.time) return right.scale;
|
||||
|
||||
// 计算插值因子 | Calculate interpolation factor
|
||||
let factor = (t - left.time) / (right.time - left.time);
|
||||
|
||||
// 应用缓动函数 | Apply easing function
|
||||
switch (curveType) {
|
||||
case ScaleCurveType.EaseIn:
|
||||
factor = factor * factor;
|
||||
break;
|
||||
case ScaleCurveType.EaseOut:
|
||||
factor = 1 - (1 - factor) * (1 - factor);
|
||||
break;
|
||||
case ScaleCurveType.EaseInOut:
|
||||
factor = factor < 0.5
|
||||
? 2 * factor * factor
|
||||
: 1 - 2 * (1 - factor) * (1 - factor);
|
||||
break;
|
||||
// Linear - no modification
|
||||
}
|
||||
|
||||
return left.scale + (right.scale - left.scale) * factor;
|
||||
}
|
||||
259
packages/particle-editor/src/components/GradientEditor.tsx
Normal file
259
packages/particle-editor/src/components/GradientEditor.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 渐变编辑器组件
|
||||
* Gradient Editor Component
|
||||
*
|
||||
* A visual editor for color gradients used in particle systems.
|
||||
* 用于粒子系统的颜色渐变可视化编辑器。
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { ColorKey } from '@esengine/particle';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface GradientEditorProps {
|
||||
/** 颜色关键帧 | Color keyframes */
|
||||
colorKeys: ColorKey[];
|
||||
/** 变化回调 | Change callback */
|
||||
onChange: (keys: ColorKey[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渐变编辑器
|
||||
* Gradient Editor
|
||||
*/
|
||||
export function GradientEditor({
|
||||
colorKeys,
|
||||
onChange,
|
||||
}: GradientEditorProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 生成 CSS 渐变 | Generate CSS gradient
|
||||
const gradientStyle = useCallback(() => {
|
||||
if (colorKeys.length === 0) {
|
||||
return 'linear-gradient(to right, #ffffff, #ffffff)';
|
||||
}
|
||||
if (colorKeys.length === 1) {
|
||||
const c = colorKeys[0];
|
||||
const color = `rgba(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}, ${c.a})`;
|
||||
return `linear-gradient(to right, ${color}, ${color})`;
|
||||
}
|
||||
|
||||
const sorted = [...colorKeys].sort((a, b) => a.time - b.time);
|
||||
const stops = sorted.map(k => {
|
||||
const color = `rgba(${Math.round(k.r * 255)}, ${Math.round(k.g * 255)}, ${Math.round(k.b * 255)}, ${k.a})`;
|
||||
return `${color} ${k.time * 100}%`;
|
||||
});
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
}, [colorKeys]);
|
||||
|
||||
// 处理点击添加关键帧 | Handle click to add keyframe
|
||||
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!trackRef.current) return;
|
||||
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const time = Math.max(0, Math.min(1, x / width));
|
||||
|
||||
// 检查是否点击了现有关键帧 | Check if clicking existing keyframe
|
||||
const threshold = 10;
|
||||
const clickedIndex = colorKeys.findIndex(k => {
|
||||
const keyX = k.time * width;
|
||||
return Math.abs(keyX - x) < threshold;
|
||||
});
|
||||
|
||||
if (clickedIndex >= 0) {
|
||||
setSelectedIndex(clickedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加新关键帧 | Add new keyframe
|
||||
const interpolatedColor = interpolateColor(colorKeys, time);
|
||||
const newKey: ColorKey = {
|
||||
time,
|
||||
...interpolatedColor
|
||||
};
|
||||
|
||||
const newKeys = [...colorKeys, newKey].sort((a, b) => a.time - b.time);
|
||||
onChange(newKeys);
|
||||
setSelectedIndex(newKeys.findIndex(k => k.time === time));
|
||||
};
|
||||
|
||||
// 处理拖拽开始 | Handle drag start
|
||||
const handleStopMouseDown = (e: React.MouseEvent, index: number) => {
|
||||
e.stopPropagation();
|
||||
setSelectedIndex(index);
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!trackRef.current || selectedIndex === null) return;
|
||||
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const time = Math.max(0, Math.min(1, x / width));
|
||||
|
||||
const newKeys = [...colorKeys];
|
||||
newKeys[selectedIndex] = { ...newKeys[selectedIndex], time };
|
||||
onChange(newKeys.sort((a, b) => a.time - b.time));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, selectedIndex, colorKeys, onChange]);
|
||||
|
||||
// 处理颜色变化 | Handle color change
|
||||
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (selectedIndex === null) return;
|
||||
|
||||
const hex = e.target.value;
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const newKeys = [...colorKeys];
|
||||
newKeys[selectedIndex] = { ...newKeys[selectedIndex], r, g, b };
|
||||
onChange(newKeys);
|
||||
};
|
||||
|
||||
// 处理 alpha 变化 | Handle alpha change
|
||||
const handleAlphaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (selectedIndex === null) return;
|
||||
|
||||
const newKeys = [...colorKeys];
|
||||
newKeys[selectedIndex] = { ...newKeys[selectedIndex], a: parseFloat(e.target.value) };
|
||||
onChange(newKeys);
|
||||
};
|
||||
|
||||
// 删除选中关键帧 | Delete selected keyframe
|
||||
const handleDelete = () => {
|
||||
if (selectedIndex === null || colorKeys.length <= 1) return;
|
||||
|
||||
const newKeys = colorKeys.filter((_, i) => i !== selectedIndex);
|
||||
onChange(newKeys);
|
||||
setSelectedIndex(null);
|
||||
};
|
||||
|
||||
// 获取选中关键帧的十六进制颜色 | Get selected keyframe hex color
|
||||
const selectedKey = selectedIndex !== null ? colorKeys[selectedIndex] : null;
|
||||
const selectedHex = selectedKey
|
||||
? `#${Math.round(selectedKey.r * 255).toString(16).padStart(2, '0')}${Math.round(selectedKey.g * 255).toString(16).padStart(2, '0')}${Math.round(selectedKey.b * 255).toString(16).padStart(2, '0')}`
|
||||
: '#ffffff';
|
||||
|
||||
return (
|
||||
<div className="gradient-editor">
|
||||
{/* 渐变条 | Gradient bar */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="gradient-track"
|
||||
style={{ background: gradientStyle() }}
|
||||
onClick={handleTrackClick}
|
||||
>
|
||||
{/* 棋盘格背景 | Checkerboard background */}
|
||||
<div className="gradient-track-checker" />
|
||||
|
||||
{/* 关键帧手柄 | Keyframe handles */}
|
||||
{colorKeys.map((key, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`gradient-stop ${selectedIndex === index ? 'selected' : ''}`}
|
||||
style={{
|
||||
left: `${key.time * 100}%`,
|
||||
backgroundColor: `rgba(${Math.round(key.r * 255)}, ${Math.round(key.g * 255)}, ${Math.round(key.b * 255)}, ${key.a})`,
|
||||
}}
|
||||
onMouseDown={e => handleStopMouseDown(e, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 编辑面板 | Edit panel */}
|
||||
{selectedKey && (
|
||||
<div className="gradient-color-row">
|
||||
<label>Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={selectedHex}
|
||||
onChange={handleColorChange}
|
||||
className="gradient-color-input"
|
||||
/>
|
||||
<label>Alpha</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={selectedKey.a}
|
||||
onChange={handleAlphaChange}
|
||||
className="gradient-alpha-slider"
|
||||
/>
|
||||
<span className="gradient-alpha-value">{selectedKey.a.toFixed(2)}</span>
|
||||
<button
|
||||
className="gradient-delete-btn"
|
||||
onClick={handleDelete}
|
||||
disabled={colorKeys.length <= 1}
|
||||
title="Delete stop"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在渐变中插值颜色
|
||||
* Interpolate color in gradient
|
||||
*/
|
||||
function interpolateColor(keys: ColorKey[], time: number): { r: number; g: number; b: number; a: number } {
|
||||
if (keys.length === 0) {
|
||||
return { r: 1, g: 1, b: 1, a: 1 };
|
||||
}
|
||||
if (keys.length === 1) {
|
||||
return { r: keys[0].r, g: keys[0].g, b: keys[0].b, a: keys[0].a };
|
||||
}
|
||||
|
||||
const sorted = [...keys].sort((a, b) => a.time - b.time);
|
||||
|
||||
// 查找相邻关键帧 | Find adjacent keyframes
|
||||
let left = sorted[0];
|
||||
let right = sorted[sorted.length - 1];
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
if (sorted[i].time <= time && sorted[i + 1].time >= time) {
|
||||
left = sorted[i];
|
||||
right = sorted[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (time <= left.time) {
|
||||
return { r: left.r, g: left.g, b: left.b, a: left.a };
|
||||
}
|
||||
if (time >= right.time) {
|
||||
return { r: right.r, g: right.g, b: right.b, a: right.a };
|
||||
}
|
||||
|
||||
const t = (time - left.time) / (right.time - left.time);
|
||||
return {
|
||||
r: left.r + (right.r - left.r) * t,
|
||||
g: left.g + (right.g - left.g) * t,
|
||||
b: left.b + (right.b - left.b) * t,
|
||||
a: left.a + (right.a - left.a) * t,
|
||||
};
|
||||
}
|
||||
139
packages/particle-editor/src/components/TexturePicker.tsx
Normal file
139
packages/particle-editor/src/components/TexturePicker.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 纹理选择器组件
|
||||
* Texture Picker Component
|
||||
*
|
||||
* A component for selecting particle textures.
|
||||
* 用于选择粒子纹理的组件。
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Image, ChevronDown, FolderOpen, X } from 'lucide-react';
|
||||
|
||||
interface TexturePickerProps {
|
||||
/** 当前纹理路径 | Current texture path */
|
||||
value: string | null;
|
||||
/** 变化回调 | Change callback */
|
||||
onChange: (path: string | null) => void;
|
||||
/** 打开文件选择对话框 | Open file selection dialog */
|
||||
onBrowse?: () => Promise<string | null>;
|
||||
/** 纹理预览 URL | Texture preview URL */
|
||||
previewUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纹理选择器
|
||||
* Texture Picker
|
||||
*/
|
||||
export function TexturePicker({
|
||||
value,
|
||||
onChange,
|
||||
onBrowse,
|
||||
previewUrl,
|
||||
}: TexturePickerProps) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 处理浏览按钮点击 | Handle browse button click
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (onBrowse) {
|
||||
const result = await onBrowse();
|
||||
if (result) {
|
||||
onChange(result);
|
||||
}
|
||||
}
|
||||
}, [onBrowse, onChange]);
|
||||
|
||||
// 处理清除 | Handle clear
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
}, [onChange]);
|
||||
|
||||
// 处理拖放 | Handle drag & drop
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type.startsWith('image/')) {
|
||||
// 这里理想情况下需要将文件转换为项目内的路径
|
||||
// In ideal case, we'd convert file to project path
|
||||
// For now, just use the file name
|
||||
onChange(file.name);
|
||||
}
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
// 获取显示名称 | Get display name
|
||||
const displayName = value ? value.split(/[\\/]/).pop() || value : null;
|
||||
|
||||
return (
|
||||
<div className="texture-picker">
|
||||
<div className="asset-field-row">
|
||||
<span className="asset-field-label">Texture</span>
|
||||
<div className="asset-field-content">
|
||||
{/* 缩略图 | Thumbnail */}
|
||||
<div
|
||||
className={`asset-field-thumbnail ${isDragOver ? 'dragging' : ''}`}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="Texture preview" />
|
||||
) : (
|
||||
<Image size={20} className="asset-field-thumbnail-icon" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-field-right">
|
||||
{/* 下拉选择器 | Dropdown selector */}
|
||||
<div
|
||||
className={`asset-field-dropdown ${value ? 'has-value' : ''}`}
|
||||
onClick={handleBrowse}
|
||||
>
|
||||
<span className="asset-field-value">
|
||||
{displayName || 'None'}
|
||||
</span>
|
||||
<ChevronDown size={12} className="asset-field-dropdown-arrow" />
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 | Action buttons */}
|
||||
<div className="asset-field-actions">
|
||||
<button
|
||||
className="asset-field-btn"
|
||||
onClick={handleBrowse}
|
||||
title="Browse..."
|
||||
>
|
||||
<FolderOpen size={12} />
|
||||
</button>
|
||||
{value && (
|
||||
<button
|
||||
className="asset-field-btn asset-field-btn--clear"
|
||||
onClick={handleClear}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
packages/particle-editor/src/components/index.ts
Normal file
8
packages/particle-editor/src/components/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 粒子编辑器组件
|
||||
* Particle editor components
|
||||
*/
|
||||
|
||||
export { GradientEditor } from './GradientEditor';
|
||||
export { CurveEditor } from './CurveEditor';
|
||||
export { TexturePicker } from './TexturePicker';
|
||||
25
packages/particle-editor/src/index.ts
Normal file
25
packages/particle-editor/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 粒子编辑器模块入口
|
||||
* Particle Editor Module Entry
|
||||
*/
|
||||
|
||||
// Module
|
||||
export {
|
||||
ParticleEditorModule,
|
||||
particleEditorModule,
|
||||
ParticlePlugin,
|
||||
particleEditorModule as default
|
||||
} from './ParticleEditorModule';
|
||||
|
||||
// Providers
|
||||
export { ParticleInspectorProvider } from './providers/ParticleInspectorProvider';
|
||||
|
||||
// Panels
|
||||
export { ParticleEditorPanel } from './panels/ParticleEditorPanel';
|
||||
|
||||
// Stores
|
||||
export { useParticleEditorStore } from './stores/ParticleEditorStore';
|
||||
export type { ParticleEditorState } from './stores/ParticleEditorStore';
|
||||
|
||||
// Components
|
||||
export { GradientEditor, CurveEditor } from './components';
|
||||
2296
packages/particle-editor/src/panels/ParticleEditorPanel.tsx
Normal file
2296
packages/particle-editor/src/panels/ParticleEditorPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* 粒子系统 Inspector Provider
|
||||
* Particle System Inspector Provider
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Play, Pause, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
|
||||
import type { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { EmissionShape, ParticleBlendMode } from '@esengine/particle';
|
||||
|
||||
interface ParticleInspectorData {
|
||||
entityId: string;
|
||||
component: ParticleSystemComponent;
|
||||
}
|
||||
|
||||
export class ParticleInspectorProvider implements IInspectorProvider<ParticleInspectorData> {
|
||||
readonly id = 'particle-system-inspector';
|
||||
readonly name = 'Particle System Inspector';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(target: unknown): target is ParticleInspectorData {
|
||||
if (typeof target !== 'object' || target === null) return false;
|
||||
const obj = target as Record<string, unknown>;
|
||||
return 'entityId' in obj && 'component' in obj &&
|
||||
obj.component !== null &&
|
||||
typeof obj.component === 'object' &&
|
||||
'maxParticles' in (obj.component as Record<string, unknown>) &&
|
||||
'emissionRate' in (obj.component as Record<string, unknown>);
|
||||
}
|
||||
|
||||
render(data: ParticleInspectorData, _context: InspectorContext): React.ReactElement {
|
||||
return <ParticleInspectorUI data={data} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface ParticleInspectorUIProps {
|
||||
data: ParticleInspectorData;
|
||||
}
|
||||
|
||||
function ParticleInspectorUI({ data }: ParticleInspectorUIProps) {
|
||||
const { component } = data;
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
const refresh = useCallback(() => forceUpdate({}), []);
|
||||
|
||||
const handlePlay = () => {
|
||||
component.play();
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
component.pause();
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
component.stop(true);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleBurst = () => {
|
||||
component.burst(10);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleChange = <K extends keyof ParticleSystemComponent>(
|
||||
key: K,
|
||||
value: ParticleSystemComponent[K]
|
||||
) => {
|
||||
(component as any)[key] = value;
|
||||
component.markDirty();
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
{/* 控制按钮 | Control buttons */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Controls</div>
|
||||
<div style={{ display: 'flex', gap: '4px', marginBottom: '8px' }}>
|
||||
<button
|
||||
onClick={component.isPlaying ? handlePause : handlePlay}
|
||||
style={buttonStyle}
|
||||
title={component.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{component.isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<button onClick={handleStop} style={buttonStyle} title="Stop">
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button onClick={handleBurst} style={buttonStyle} title="Burst 10">
|
||||
<Sparkles size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Active Particles</label>
|
||||
<span>{component.activeParticleCount} / {component.maxParticles}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基础属性 | Basic Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Basic</div>
|
||||
|
||||
<NumberInput
|
||||
label="Max Particles"
|
||||
value={component.maxParticles}
|
||||
min={1}
|
||||
max={10000}
|
||||
step={100}
|
||||
onChange={v => handleChange('maxParticles', v)}
|
||||
/>
|
||||
|
||||
<CheckboxInput
|
||||
label="Looping"
|
||||
checked={component.looping}
|
||||
onChange={v => handleChange('looping', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Duration"
|
||||
value={component.duration}
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
onChange={v => handleChange('duration', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Playback Speed"
|
||||
value={component.playbackSpeed}
|
||||
min={0.01}
|
||||
max={10}
|
||||
step={0.1}
|
||||
onChange={v => handleChange('playbackSpeed', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 发射属性 | Emission Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Emission</div>
|
||||
|
||||
<NumberInput
|
||||
label="Emission Rate"
|
||||
value={component.emissionRate}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={v => handleChange('emissionRate', v)}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label="Shape"
|
||||
value={component.emissionShape}
|
||||
options={[
|
||||
{ value: EmissionShape.Point, label: 'Point' },
|
||||
{ value: EmissionShape.Circle, label: 'Circle (filled)' },
|
||||
{ value: EmissionShape.Ring, label: 'Ring (edge)' },
|
||||
{ value: EmissionShape.Rectangle, label: 'Rectangle (filled)' },
|
||||
{ value: EmissionShape.Edge, label: 'Edge (rect outline)' },
|
||||
{ value: EmissionShape.Line, label: 'Line' },
|
||||
{ value: EmissionShape.Cone, label: 'Cone' },
|
||||
]}
|
||||
onChange={v => handleChange('emissionShape', v as EmissionShape)}
|
||||
/>
|
||||
|
||||
{component.emissionShape !== EmissionShape.Point && (
|
||||
<NumberInput
|
||||
label="Shape Radius"
|
||||
value={component.shapeRadius}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={v => handleChange('shapeRadius', v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 粒子属性 | Particle Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Particle</div>
|
||||
|
||||
<RangeInput
|
||||
label="Lifetime"
|
||||
minValue={component.lifetimeMin}
|
||||
maxValue={component.lifetimeMax}
|
||||
min={0.01}
|
||||
step={0.1}
|
||||
onMinChange={v => handleChange('lifetimeMin', v)}
|
||||
onMaxChange={v => handleChange('lifetimeMax', v)}
|
||||
/>
|
||||
|
||||
<RangeInput
|
||||
label="Speed"
|
||||
minValue={component.speedMin}
|
||||
maxValue={component.speedMax}
|
||||
min={0}
|
||||
step={1}
|
||||
onMinChange={v => handleChange('speedMin', v)}
|
||||
onMaxChange={v => handleChange('speedMax', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Direction (°)"
|
||||
value={component.direction}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
onChange={v => handleChange('direction', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Spread (°)"
|
||||
value={component.directionSpread}
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
onChange={v => handleChange('directionSpread', v)}
|
||||
/>
|
||||
|
||||
<RangeInput
|
||||
label="Scale"
|
||||
minValue={component.scaleMin}
|
||||
maxValue={component.scaleMax}
|
||||
min={0.01}
|
||||
step={0.1}
|
||||
onMinChange={v => handleChange('scaleMin', v)}
|
||||
onMaxChange={v => handleChange('scaleMax', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Gravity X"
|
||||
value={component.gravityX}
|
||||
step={1}
|
||||
onChange={v => handleChange('gravityX', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Gravity Y"
|
||||
value={component.gravityY}
|
||||
step={1}
|
||||
onChange={v => handleChange('gravityY', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色属性 | Color Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Color</div>
|
||||
|
||||
<ColorInput
|
||||
label="Start Color"
|
||||
value={component.startColor}
|
||||
onChange={v => handleChange('startColor', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Start Alpha"
|
||||
value={component.startAlpha}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => handleChange('startAlpha', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="End Alpha"
|
||||
value={component.endAlpha}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => handleChange('endAlpha', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="End Scale"
|
||||
value={component.endScale}
|
||||
min={0}
|
||||
step={0.1}
|
||||
onChange={v => handleChange('endScale', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 渲染属性 | Rendering Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Rendering</div>
|
||||
|
||||
<NumberInput
|
||||
label="Particle Size"
|
||||
value={component.particleSize}
|
||||
min={1}
|
||||
step={1}
|
||||
onChange={v => handleChange('particleSize', v)}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label="Blend Mode"
|
||||
value={component.blendMode}
|
||||
options={[
|
||||
{ value: ParticleBlendMode.Normal, label: 'Normal' },
|
||||
{ value: ParticleBlendMode.Additive, label: 'Additive' },
|
||||
{ value: ParticleBlendMode.Multiply, label: 'Multiply' },
|
||||
]}
|
||||
onChange={v => handleChange('blendMode', v as ParticleBlendMode)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Sorting Order"
|
||||
value={component.sortingOrder}
|
||||
step={1}
|
||||
onChange={v => handleChange('sortingOrder', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= UI Components =============
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--border-color, #3a3a3a)',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--input-background, #2a2a2a)',
|
||||
color: 'var(--text-color, #e0e0e0)',
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 10px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--button-background, #3a3a3a)',
|
||||
color: 'var(--text-color, #e0e0e0)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
interface NumberInputProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function NumberInput({ label, value, min, max, step = 1, onChange }: NumberInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => onChange(parseFloat(e.target.value) || 0)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RangeInputProps {
|
||||
label: string;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onMinChange: (value: number) => void;
|
||||
onMaxChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function RangeInput({ label, minValue, maxValue, min, max, step = 1, onMinChange, onMaxChange }: RangeInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<div style={{ display: 'flex', gap: '4px', flex: 1 }}>
|
||||
<input
|
||||
type="number"
|
||||
value={minValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => onMinChange(parseFloat(e.target.value) || 0)}
|
||||
style={{ ...inputStyle, width: '50%' }}
|
||||
title="Min"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={maxValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => onMaxChange(parseFloat(e.target.value) || 0)}
|
||||
style={{ ...inputStyle, width: '50%' }}
|
||||
title="Max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxInputProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function CheckboxInput({ label, checked, onChange }: CheckboxInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function SelectInput({ label, value, options, onChange }: SelectInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function ColorInput({ label, value, onChange }: ColorInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={{ ...inputStyle, padding: '2px', height: '24px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
packages/particle-editor/src/stores/ParticleEditorStore.ts
Normal file
121
packages/particle-editor/src/stores/ParticleEditorStore.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 粒子编辑器状态管理
|
||||
* Particle editor state management
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { IParticleAsset } from '@esengine/particle';
|
||||
import { createDefaultParticleAsset } from '@esengine/particle';
|
||||
|
||||
/**
|
||||
* 粒子编辑器状态
|
||||
* Particle editor state
|
||||
*/
|
||||
export interface ParticleEditorState {
|
||||
/** 当前编辑的文件路径 | Current file path being edited */
|
||||
filePath: string | null;
|
||||
|
||||
/** 待打开的文件路径 | Pending file path to open */
|
||||
pendingFilePath: string | null;
|
||||
|
||||
/** 当前粒子数据 | Current particle data */
|
||||
particleData: IParticleAsset | null;
|
||||
|
||||
/** 是否已修改 | Is modified */
|
||||
isDirty: boolean;
|
||||
|
||||
/** 是否正在预览 | Is previewing */
|
||||
isPlaying: boolean;
|
||||
|
||||
/** 选中的预设名称 | Selected preset name */
|
||||
selectedPreset: string | null;
|
||||
|
||||
// Actions
|
||||
/** 设置文件路径 | Set file path */
|
||||
setFilePath: (path: string | null) => void;
|
||||
|
||||
/** 设置待打开的文件路径 | Set pending file path */
|
||||
setPendingFilePath: (path: string | null) => void;
|
||||
|
||||
/** 设置粒子数据 | Set particle data */
|
||||
setParticleData: (data: IParticleAsset | null) => void;
|
||||
|
||||
/** 更新粒子属性 | Update particle property */
|
||||
updateProperty: <K extends keyof IParticleAsset>(key: K, value: IParticleAsset[K]) => void;
|
||||
|
||||
/** 标记为已修改 | Mark as dirty */
|
||||
markDirty: () => void;
|
||||
|
||||
/** 标记为已保存 | Mark as saved */
|
||||
markSaved: () => void;
|
||||
|
||||
/** 设置播放状态 | Set playing state */
|
||||
setPlaying: (playing: boolean) => void;
|
||||
|
||||
/** 设置选中预设 | Set selected preset */
|
||||
setSelectedPreset: (preset: string | null) => void;
|
||||
|
||||
/** 重置编辑器 | Reset editor */
|
||||
reset: () => void;
|
||||
|
||||
/** 创建新粒子效果 | Create new particle effect */
|
||||
createNew: (name?: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子编辑器 Store
|
||||
* Particle editor store
|
||||
*/
|
||||
export const useParticleEditorStore = create<ParticleEditorState>((set) => ({
|
||||
filePath: null,
|
||||
pendingFilePath: null,
|
||||
particleData: null,
|
||||
isDirty: false,
|
||||
isPlaying: false,
|
||||
selectedPreset: null,
|
||||
|
||||
setFilePath: (path) => set({ filePath: path }),
|
||||
|
||||
setPendingFilePath: (path) => set({ pendingFilePath: path }),
|
||||
|
||||
setParticleData: (data) => set({
|
||||
particleData: data,
|
||||
isDirty: false,
|
||||
}),
|
||||
|
||||
updateProperty: (key, value) => set((state) => {
|
||||
if (!state.particleData) return state;
|
||||
return {
|
||||
particleData: {
|
||||
...state.particleData,
|
||||
[key]: value,
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
markDirty: () => set({ isDirty: true }),
|
||||
|
||||
markSaved: () => set({ isDirty: false }),
|
||||
|
||||
setPlaying: (playing) => set({ isPlaying: playing }),
|
||||
|
||||
setSelectedPreset: (preset) => set({ selectedPreset: preset }),
|
||||
|
||||
reset: () => set({
|
||||
filePath: null,
|
||||
pendingFilePath: null,
|
||||
particleData: null,
|
||||
isDirty: false,
|
||||
isPlaying: false,
|
||||
selectedPreset: null,
|
||||
}),
|
||||
|
||||
createNew: (name = 'New Particle') => set({
|
||||
particleData: createDefaultParticleAsset(name),
|
||||
filePath: null,
|
||||
isDirty: true,
|
||||
isPlaying: false,
|
||||
selectedPreset: null,
|
||||
}),
|
||||
}));
|
||||
1323
packages/particle-editor/src/styles/ParticleEditor.css
Normal file
1323
packages/particle-editor/src/styles/ParticleEditor.css
Normal file
File diff suppressed because it is too large
Load Diff
23
packages/particle-editor/tsconfig.build.json
Normal file
23
packages/particle-editor/tsconfig.build.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
11
packages/particle-editor/tsconfig.json
Normal file
11
packages/particle-editor/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
7
packages/particle-editor/tsup.config.ts
Normal file
7
packages/particle-editor/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { editorOnlyPreset } from '../build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...editorOnlyPreset(),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
44
packages/particle/module.json
Normal file
44
packages/particle/module.json
Normal file
@@ -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"
|
||||
}
|
||||
49
packages/particle/package.json
Normal file
49
packages/particle/package.json
Normal file
@@ -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"
|
||||
}
|
||||
233
packages/particle/src/Particle.ts
Normal file
233
packages/particle/src/Particle.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
339
packages/particle/src/ParticleEmitter.ts
Normal file
339
packages/particle/src/ParticleEmitter.ts
Normal file
@@ -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<EmitterConfig>) {
|
||||
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));
|
||||
}
|
||||
87
packages/particle/src/ParticleRuntimeModule.ts
Normal file
87
packages/particle/src/ParticleRuntimeModule.ts
Normal file
@@ -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 };
|
||||
600
packages/particle/src/ParticleSystemComponent.ts
Normal file
600
packages/particle/src/ParticleSystemComponent.ts
Normal file
@@ -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<T extends IParticleModule>(module: T): T {
|
||||
this._modules.push(module);
|
||||
return module;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块
|
||||
* Get module by type
|
||||
*/
|
||||
getModule<T extends IParticleModule>(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 = [];
|
||||
}
|
||||
}
|
||||
89
packages/particle/src/index.ts
Normal file
89
packages/particle/src/index.ts
Normal file
@@ -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';
|
||||
211
packages/particle/src/loaders/ParticleLoader.ts
Normal file
211
packages/particle/src/loaders/ParticleLoader.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子效果资源数据接口
|
||||
* 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<IParticleAsset> {
|
||||
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<IParticleAsset> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
7
packages/particle/src/loaders/index.ts
Normal file
7
packages/particle/src/loaders/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
ParticleLoader,
|
||||
ParticleAssetType,
|
||||
createDefaultParticleAsset,
|
||||
type IParticleAsset,
|
||||
type IParticleModuleConfig
|
||||
} from './ParticleLoader';
|
||||
222
packages/particle/src/modules/CollisionModule.ts
Normal file
222
packages/particle/src/modules/CollisionModule.ts
Normal file
@@ -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<Particle> = new Set();
|
||||
|
||||
/**
|
||||
* 获取需要销毁的粒子
|
||||
* Get particles to kill
|
||||
*/
|
||||
getParticlesToKill(): Set<Particle> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
packages/particle/src/modules/ColorOverLifetimeModule.ts
Normal file
63
packages/particle/src/modules/ColorOverLifetimeModule.ts
Normal file
@@ -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;
|
||||
}
|
||||
305
packages/particle/src/modules/ForceFieldModule.ts
Normal file
305
packages/particle/src/modules/ForceFieldModule.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
26
packages/particle/src/modules/IParticleModule.ts
Normal file
26
packages/particle/src/modules/IParticleModule.ts
Normal file
@@ -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;
|
||||
}
|
||||
100
packages/particle/src/modules/NoiseModule.ts
Normal file
100
packages/particle/src/modules/NoiseModule.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/particle/src/modules/RotationOverLifetimeModule.ts
Normal file
40
packages/particle/src/modules/RotationOverLifetimeModule.ts
Normal file
@@ -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;
|
||||
}
|
||||
119
packages/particle/src/modules/SizeOverLifetimeModule.ts
Normal file
119
packages/particle/src/modules/SizeOverLifetimeModule.ts
Normal file
@@ -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;
|
||||
}
|
||||
232
packages/particle/src/modules/TextureSheetAnimationModule.ts
Normal file
232
packages/particle/src/modules/TextureSheetAnimationModule.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
201
packages/particle/src/modules/VelocityOverLifetimeModule.ts
Normal file
201
packages/particle/src/modules/VelocityOverLifetimeModule.ts
Normal file
@@ -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;
|
||||
}
|
||||
22
packages/particle/src/modules/index.ts
Normal file
22
packages/particle/src/modules/index.ts
Normal file
@@ -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';
|
||||
774
packages/particle/src/presets/index.ts
Normal file
774
packages/particle/src/presets/index.ts
Normal file
@@ -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);
|
||||
}
|
||||
212
packages/particle/src/rendering/ParticleRenderDataProvider.ts
Normal file
212
packages/particle/src/rendering/ParticleRenderDataProvider.ts
Normal file
@@ -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<ParticleSystemComponent, ITransformLike> = 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<number, {
|
||||
component: ParticleSystemComponent;
|
||||
transform: ITransformLike;
|
||||
}[]>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
5
packages/particle/src/rendering/index.ts
Normal file
5
packages/particle/src/rendering/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ParticleRenderDataProvider,
|
||||
type ParticleProviderRenderData,
|
||||
type IRenderDataProvider
|
||||
} from './ParticleRenderDataProvider';
|
||||
118
packages/particle/src/systems/ParticleSystem.ts
Normal file
118
packages/particle/src/systems/ParticleSystem.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
12
packages/particle/tsconfig.build.json
Normal file
12
packages/particle/tsconfig.build.json
Normal file
@@ -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"]
|
||||
}
|
||||
13
packages/particle/tsconfig.json
Normal file
13
packages/particle/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{ "path": "../core" }
|
||||
]
|
||||
}
|
||||
7
packages/particle/tsup.config.ts
Normal file
7
packages/particle/tsup.config.ts
Normal file
@@ -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'
|
||||
});
|
||||
Reference in New Issue
Block a user