首次上传

This commit is contained in:
tianmo committed 2026-08-27 17:23:39 +08:00
1 parent af9bda066c
commit 9b156a32c9
29 files changed
+14567

No files matched your search

+465
View File
@@ -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]
}