首次上传
This commit is contained in:
1 parent
af9bda066c
commit
9b156a32c9
29 files changed
+14567
No files matched your search
@@ -0,0 +1,465 @@
|
||||
import { Container, Sprite, Texture, IPointData } from 'pixi.js'
|
||||
|
||||
/** 发射模式 */
|
||||
export type EmitMode = 'stream' | 'burst'
|
||||
/** 发射形状 */
|
||||
export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'orbit'
|
||||
/** 混合模式 */
|
||||
export type Blend = 'normal' | 'add' | 'multiply' | 'screen'
|
||||
|
||||
/** 粒子配置(引用保持,面板改字段即实时生效) */
|
||||
export interface EmitterConfig {
|
||||
texture: Texture<any>
|
||||
name: string
|
||||
// 发射
|
||||
mode: EmitMode
|
||||
rate: number // 持续:每秒发射数
|
||||
burstCount: number // 爆发:单次数量
|
||||
burstLoop: boolean // 爆发是否每秒重复
|
||||
/** 延迟发射(秒)= 粒子条左端位置 */
|
||||
delay: number
|
||||
/** 发射总时长(秒)= 粒子条宽度,超过后停止发射新粒子 */
|
||||
duration: number
|
||||
shape: EmitShape // 发射形状
|
||||
radius: number
|
||||
rectW: number
|
||||
rectH: number
|
||||
/** 发射器中心 X/Y */
|
||||
centerX: number
|
||||
centerY: number
|
||||
// 根节点(root)变换:作用于整个粒子系统(旋转/缩放),与场景对象参数联动
|
||||
rootRotation: number
|
||||
rootScaleX: number
|
||||
rootScaleY: number
|
||||
// 方向
|
||||
direction: number
|
||||
spread: number
|
||||
/** 锥形开口角度(发电器形状=锥体时,粒子在锥形扇区内均匀散开) */
|
||||
coneAngle: number
|
||||
// 属性
|
||||
lifeMin: number
|
||||
lifeMax: number
|
||||
speedMin: number
|
||||
speedMax: number
|
||||
scaleMin: number
|
||||
scaleMax: number
|
||||
scaleEndRatio: number
|
||||
alpha: number
|
||||
alphaEnd: number
|
||||
rotationSpeedMin: number
|
||||
rotationSpeedMax: number
|
||||
// 物理
|
||||
gravity: number
|
||||
/** 阻尼(速度衰减系数,0=无) */
|
||||
damping: number
|
||||
/** 随机种子(确定性随机,0=每次不同) */
|
||||
seed: number
|
||||
// 颜色
|
||||
colorStart: string
|
||||
colorEnd: string
|
||||
colorOverLife: boolean
|
||||
// 混合
|
||||
blend: Blend
|
||||
/** 透明度模式:fixed(固定)/ curve(曲线) */
|
||||
alphaMode: 'fixed' | 'curve'
|
||||
/** 曲线模式(透明度插值方式) */
|
||||
curveMode: '跟随导出' | '线性' | '贝塞尔'
|
||||
/** 透明度曲线关键帧:x=生命周期时刻(0~1),y=透明度(0~1,1=完全不透明) */
|
||||
alphaCurve: { x: number; y: number }[]
|
||||
/** 图片名称(贴图资源名,默认 dot) */
|
||||
textureName: string
|
||||
/** 子文件夹(图片资源路径,占位) */
|
||||
textureFolder: string
|
||||
/** 占比(0~1) */
|
||||
alphaScale: number
|
||||
/** 贴图锚点/轴心 x,y(0~1) */
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
/** 独立透明度:off / fixed / curve */
|
||||
independentAlpha: 'off' | 'fixed' | 'curve'
|
||||
// 发射点偏移
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
// ===== 修改器 (Modifiers) =====
|
||||
/** 开启移动噪声 */
|
||||
noise: boolean
|
||||
noiseAmt: number
|
||||
/** 开启重力(由 gravity 控制) */
|
||||
useGravity: boolean
|
||||
/** 开启风力 */
|
||||
wind: boolean
|
||||
windX: number
|
||||
windY: number
|
||||
/** 开启力场(吸力/斥力) */
|
||||
forceField: boolean
|
||||
forceStrength: number
|
||||
forceRadius: number
|
||||
/** 开启拖尾 */
|
||||
trail: boolean
|
||||
/** 开启碰撞(预留) */
|
||||
collision: boolean
|
||||
/** 开启路径跟随(预留) */
|
||||
pathFollow: boolean
|
||||
}
|
||||
|
||||
/** 粒子瞬间状态(供骨骼同步 / 导出) */
|
||||
export interface ParticleState {
|
||||
/** 对应骨骼在 root 下的命名 p_<i> */
|
||||
boneName: string
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
alpha: number
|
||||
colorHex: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export function defaultConfig(): EmitterConfig {
|
||||
return {
|
||||
texture: null as unknown as Texture,
|
||||
name: 'ParticleSystem1',
|
||||
mode: 'stream',
|
||||
rate: 20,
|
||||
burstCount: 50,
|
||||
burstLoop: false, // 一次性爆发(持续爆发开关已移除)
|
||||
delay: 0,
|
||||
duration: 0, // 0 = 无限发射/爆发,不受时长限制(发射时长 UI 已移除)
|
||||
shape: 'point',
|
||||
radius: 30,
|
||||
rectW: 60,
|
||||
rectH: 40,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rootRotation: 0,
|
||||
rootScaleX: 1,
|
||||
rootScaleY: 1,
|
||||
direction: 0,
|
||||
spread: 360,
|
||||
coneAngle: 60,
|
||||
lifeMin: 0.6,
|
||||
lifeMax: 1.8,
|
||||
speedMin: 30,
|
||||
speedMax: 140,
|
||||
scaleMin: 0.3,
|
||||
scaleMax: 0.8,
|
||||
scaleEndRatio: 0.3,
|
||||
alpha: 0.9,
|
||||
alphaEnd: 0,
|
||||
rotationSpeedMin: -180,
|
||||
rotationSpeedMax: 180,
|
||||
gravity: 0,
|
||||
damping: 0,
|
||||
seed: 0,
|
||||
colorStart: '#ffffff',
|
||||
colorEnd: '#ff4d4d',
|
||||
colorOverLife: false,
|
||||
blend: 'add',
|
||||
alphaMode: 'curve',
|
||||
curveMode: '线性',
|
||||
alphaCurve: [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1, y: 0 }],
|
||||
textureName: 'star',
|
||||
textureFolder: '',
|
||||
alphaScale: 1,
|
||||
anchorX: 0.5,
|
||||
anchorY: 0.5,
|
||||
independentAlpha: 'off',
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
noise: false,
|
||||
noiseAmt: 0,
|
||||
useGravity: true,
|
||||
wind: false,
|
||||
windX: 0,
|
||||
windY: 0,
|
||||
forceField: false,
|
||||
forceStrength: 0,
|
||||
forceRadius: 100,
|
||||
trail: false,
|
||||
collision: false,
|
||||
pathFollow: false,
|
||||
}
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
sprite: Sprite
|
||||
active: boolean
|
||||
boneName: string
|
||||
life: number
|
||||
maxLife: number
|
||||
x: number; y: number
|
||||
vx: number; vy: number
|
||||
scaleStart: number; scaleEnd: number
|
||||
alphaStart: number; alphaEnd: number
|
||||
rotation: number; rotationSpeed: number
|
||||
}
|
||||
|
||||
const BLEND_MAP: Record<Blend, number> = { normal: 0, add: 1, multiply: 2, screen: 3 }
|
||||
|
||||
const MAX_PARTICLES = 400
|
||||
|
||||
export class ParticleEmitter extends Container {
|
||||
private pool: Particle[] = []
|
||||
private cfg: EmitterConfig
|
||||
private accumulator = 0
|
||||
private burstTimer = 0
|
||||
/** 发射器累计运行时间(秒) — 非响应式,避免污染 config */
|
||||
private _elapsed = 0
|
||||
private _emitterPos: (() => IPointData) | null = null // root 世界坐标(0,0)
|
||||
/** 活动粒子状态快照(供骨架 / 导出) */
|
||||
states: ParticleState[] = []
|
||||
/** 确定性随机(种子>0 时可复现) */
|
||||
private rng: () => number = Math.random
|
||||
private _lastSeed = -1
|
||||
|
||||
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
||||
super()
|
||||
this.cfg = config
|
||||
this._ensurePool(maxParticles)
|
||||
}
|
||||
|
||||
/** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */
|
||||
private reseed() {
|
||||
const s = this.cfg.seed
|
||||
if (s === this._lastSeed) return
|
||||
this._lastSeed = s
|
||||
if (s > 0) {
|
||||
this.rng = mulberry32(s >>> 0)
|
||||
} else {
|
||||
this.rng = Math.random
|
||||
}
|
||||
}
|
||||
|
||||
private _ensurePool(n: number) {
|
||||
while (this.pool.length < n) {
|
||||
const sprite = new Sprite(this.cfg.texture)
|
||||
sprite.blendMode = BLEND_MAP[this.cfg.blend] as any
|
||||
sprite.anchor.set(0.5)
|
||||
sprite.visible = false
|
||||
this.addChild(sprite)
|
||||
this.pool.push({
|
||||
sprite, active: false, boneName: 'p_' + this.pool.length,
|
||||
life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0,
|
||||
scaleStart: 1, scaleEnd: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setEmitterPos(fn: () => IPointData) { this._emitterPos = fn }
|
||||
|
||||
/** 每帧更新;返回活动粒子状态快照(与骨骼一一对应) */
|
||||
update(dt: number): ParticleState[] {
|
||||
const cfg = this.cfg
|
||||
this.reseed() // 种子变化即重建随机源
|
||||
// 累计发射时间
|
||||
this._elapsed += dt
|
||||
const inWindow = cfg.delay <= 0 || this._elapsed >= cfg.delay
|
||||
const withinDuration = cfg.duration <= 0 || this._elapsed < cfg.delay + cfg.duration
|
||||
// 发射(仅在窗口内且未超时长)
|
||||
if (inWindow && withinDuration) {
|
||||
if (cfg.mode === 'stream') {
|
||||
this.accumulator += dt * cfg.rate
|
||||
while (this.accumulator >= 1) { this.accumulator -= 1; this.spawnOne() }
|
||||
} else {
|
||||
this.burstTimer += dt
|
||||
if (cfg.burstLoop) {
|
||||
if (this.burstTimer >= 1) { this.burstTimer = 0; this.emitBurst() }
|
||||
} else if (this.burstTimer <= dt) {
|
||||
this.emitBurst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const base = this._emitterPos ? this._emitterPos() : { x: this.cfg.offsetX, y: this.cfg.offsetY }
|
||||
const states: ParticleState[] = []
|
||||
for (let i = 0; i < this.pool.length; i++) {
|
||||
const p = this.pool[i]
|
||||
if (!p.active) continue
|
||||
p.life -= dt
|
||||
if (p.life <= 0) { this.kill(i); continue }
|
||||
if (cfg.useGravity) p.vy += cfg.gravity * dt
|
||||
// 风力
|
||||
if (cfg.wind) { p.vx += cfg.windX * dt; p.vy += cfg.windY * dt }
|
||||
// 力场(吸力/斥力,作用在发射点附近)
|
||||
if (cfg.forceField && cfg.forceStrength !== 0) {
|
||||
const dx = base.x - p.x, dy = base.y - p.y
|
||||
const dist = Math.hypot(dx, dy) || 0.0001
|
||||
if (dist < cfg.forceRadius) {
|
||||
const f = cfg.forceStrength * (1 - dist / cfg.forceRadius)
|
||||
p.vx += (dx / dist) * f * dt * 10
|
||||
p.vy += (dy / dist) * f * dt * 10
|
||||
}
|
||||
}
|
||||
// 阻尼:速度按衰减系数逐渐减小
|
||||
if (cfg.damping > 0) {
|
||||
const f = Math.max(0, 1 - cfg.damping * dt)
|
||||
p.vx *= f; p.vy *= f
|
||||
}
|
||||
p.x += p.vx * dt
|
||||
p.y += p.vy * dt
|
||||
p.rotation += p.rotationSpeed * dt
|
||||
const t = 1 - p.life / p.maxLife
|
||||
const s = p.sprite
|
||||
s.x = p.x; s.y = p.y; s.rotation = -p.rotation * Math.PI / 180
|
||||
s.scale.set(p.scaleStart + (p.scaleEnd - p.scaleStart) * t)
|
||||
s.alpha = cfg.alphaMode === 'curve' ? curveAt(cfg.alphaCurve, t) * cfg.alphaScale : (p.alphaStart + (p.alphaEnd - p.alphaStart) * t) * cfg.alphaScale
|
||||
const rgb = cfg.colorOverLife ? blendRgb(cfg.colorStart, cfg.colorEnd, t) : hexToNumber(cfg.colorStart)
|
||||
s.tint = rgb
|
||||
states.push({ boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, active: true })
|
||||
}
|
||||
this.states = states
|
||||
return states
|
||||
}
|
||||
|
||||
private emitBurst() {
|
||||
for (let i = 0; i < this.cfg.burstCount; i++) this.spawnOne()
|
||||
}
|
||||
|
||||
private spawnOrigin(): { x: number; y: number } {
|
||||
const cfg = this.cfg
|
||||
// 局部原点 = 0(root 中心,centerX 由发射器容器 position 补回);粒子相对 root 中心 = 偏移X/Y
|
||||
let ox = cfg.offsetX, oy = cfg.offsetY
|
||||
// 采用数学坐标系(Y 向上为正):world 为屏幕坐标(向下为正),故 y 取负
|
||||
oy = -oy
|
||||
const s = cfg.shape
|
||||
if (s === 'circle') {
|
||||
const a = this.rng() * Math.PI * 2, r = this.rng() * cfg.radius
|
||||
return { x: ox + Math.cos(a) * r, y: oy + Math.sin(a) * r }
|
||||
} else if (s === 'rect') {
|
||||
return { x: ox + (this.rng() - 0.5) * cfg.rectW, y: oy + (this.rng() - 0.5) * cfg.rectH }
|
||||
} else if (s === 'cone') {
|
||||
// 锥体:粒子从中心(锥形顶点)发射,方向由 spawnOne 在 [direction ± coneAngle/2] 扇形内随机
|
||||
return { x: ox, y: oy }
|
||||
}
|
||||
return { x: ox, y: oy }
|
||||
}
|
||||
|
||||
private spawnOne() {
|
||||
const cfg = this.cfg
|
||||
let idx = this.pool.findIndex((p) => !p.active)
|
||||
if (idx === -1) return // 池满
|
||||
const { x: ox, y: oy } = this.spawnOrigin()
|
||||
const life = lerp(cfg.lifeMin, cfg.lifeMax, this.rng())
|
||||
const speed = lerp(cfg.speedMin, cfg.speedMax, this.rng())
|
||||
// 锥体:发射方向用锥形开口角度(coneAngle)散布,且 y 朝数学上正(与扇区绘制一致);其余形状沿用 spread + 屏幕坐标
|
||||
let dirAng: number
|
||||
if (cfg.shape === 'cone') {
|
||||
dirAng = (cfg.direction + lerp(-cfg.coneAngle / 2, cfg.coneAngle / 2, this.rng())) * Math.PI / 180
|
||||
} else {
|
||||
dirAng = (cfg.direction + lerp(-cfg.spread / 2, cfg.spread / 2, this.rng())) * Math.PI / 180
|
||||
}
|
||||
const p = this.pool[idx]
|
||||
p.active = true; p.life = life; p.maxLife = life
|
||||
p.x = ox; p.y = oy
|
||||
p.vx = Math.cos(dirAng) * speed
|
||||
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
|
||||
p.scaleStart = lerp(cfg.scaleMin, cfg.scaleMax, this.rng())
|
||||
p.scaleEnd = p.scaleStart * cfg.scaleEndRatio
|
||||
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
|
||||
p.rotation = this.rng() * 360; p.rotationSpeed = lerp(cfg.rotationSpeedMin, cfg.rotationSpeedMax, this.rng())
|
||||
p.sprite.tint = hexToNumber(cfg.colorStart)
|
||||
p.sprite.visible = true
|
||||
}
|
||||
|
||||
private kill(i: number) {
|
||||
const p = this.pool[i]
|
||||
p.active = false
|
||||
p.sprite.visible = false
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const p of this.pool) { p.active = false; p.sprite.visible = false }
|
||||
}
|
||||
|
||||
get activeCount() {
|
||||
let n = 0
|
||||
for (const p of this.pool) if (p.active) n++
|
||||
return n
|
||||
}
|
||||
|
||||
/** 录制:返回当前活动粒子的深拷贝快照 */
|
||||
capture(): ParticleState[] {
|
||||
const out: ParticleState[] = []
|
||||
for (const p of this.pool) {
|
||||
if (!p.active) continue
|
||||
out.push({
|
||||
boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation,
|
||||
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
|
||||
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, active: true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 回放:把快照还原到 sprite(非活动粒子隐藏) */
|
||||
apply(states: ParticleState[]) {
|
||||
const byName = new Map<string, ParticleState>()
|
||||
for (const s of states) byName.set(s.boneName, s)
|
||||
for (const p of this.pool) {
|
||||
const st = byName.get(p.boneName)
|
||||
if (!st) { p.sprite.visible = false; continue }
|
||||
const s = p.sprite
|
||||
s.visible = true
|
||||
s.x = st.x; s.y = st.y
|
||||
s.rotation = -st.rotation * Math.PI / 180
|
||||
s.scale.set(st.scaleX, st.scaleY)
|
||||
s.alpha = st.alpha
|
||||
s.tint = st.colorHex
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空所有粒子并重置发射计时 */
|
||||
reset() {
|
||||
for (const p of this.pool) { p.active = false; p.sprite.visible = false }
|
||||
this.accumulator = 0
|
||||
this.burstTimer = 0
|
||||
this._elapsed = 0
|
||||
// 从头重新模拟:若种子>0,重设随机源到序列头,保证同一种子每次从头录制结果一致
|
||||
if (this.cfg.seed > 0) { this._lastSeed = this.cfg.seed; this.rng = mulberry32(this.cfg.seed >>> 0) }
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
|
||||
/** 在透明度曲线关键帧上按 x(生命周期时刻 0~1)求 y(透明度 0~1)。保证有序、端点外取端点 */
|
||||
function curveAt(pts: { x: number; y: number }[], x: number): number {
|
||||
if (!pts || pts.length === 0) return 1
|
||||
const p = [...pts].sort((a, b) => a.x - b.x)
|
||||
if (x <= p[0].x) return p[0].y
|
||||
if (x >= p[p.length - 1].x) return p[p.length - 1].y
|
||||
for (let i = 0; i < p.length - 1; i++) {
|
||||
if (x >= p[i].x && x <= p[i + 1].x) {
|
||||
const t = (x - p[i].x) / (p[i + 1].x - p[i].x || 1)
|
||||
return lerp(p[i].y, p[i + 1].y, t)
|
||||
}
|
||||
}
|
||||
return p[p.length - 1].y
|
||||
}
|
||||
/** 确定性伪随机(mulberry32),种子相同则序列相同 */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
function hexToNumber(hex: string): number {
|
||||
let h = hex.replace('#', '')
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
||||
return parseInt(h, 16)
|
||||
}
|
||||
function blendRgb(start: string, end: string, t: number): number {
|
||||
const s = hexToRgb(start), e = hexToRgb(end)
|
||||
const r = Math.round(s[0] + (e[0] - s[0]) * t)
|
||||
const g = Math.round(s[1] + (e[1] - s[1]) * t)
|
||||
const b = Math.round(s[2] + (e[2] - s[2]) * t)
|
||||
return (r << 16) | (g << 8) | b
|
||||
}
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
let h = hex.replace('#', '')
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
||||
const n = parseInt(h, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
}
|
||||
Reference in new issue
Block a user