发射器形状新增

This commit is contained in:
tianmo committed 2026-09-05 17:31:32 +08:00
1 parent 0bde824c88
commit 026ee74d48
11 files changed
+1657 -1336

No files matched your search

+91 -21
View File
@@ -1,9 +1,10 @@
import { Container, Sprite, Texture, IPointData, SimpleMesh, Graphics } from 'pixi.js'
import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAngle, type EmissionAngleMode, type EmitterShapeSample } from './emitterShape'
/** 发射模式 */
export type EmitMode = 'stream' | 'burst'
/** 发射形状 */
export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'orbit'
export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'path' | 'orbit'
/** 混合模式 */
export type Blend = 'normal' | 'add' | 'multiply' | 'screen'
/** 粒子属性取值方式 */
@@ -140,6 +141,20 @@ export interface EmitterConfig {
/** 持续发射时长(秒)= 时间轴绿色段;爆发模式不使用该时长 */
duration: number
shape: EmitShape // 发射形状
/** 发射范围结构版本,用于旧配置迁移。 */
shapeRangeVersion: number
/** 圆形和锥形发射范围的内外半径。 */
innerRadius: number
outerRadius: number
/** 矩形发射范围:外矩形减去内矩形。 */
innerRectW: number
innerRectH: number
outerRectW: number
outerRectH: number
/** 粒子出生路径;与修改器中的发射器整体跟随路径完全独立。 */
spawnPathId: number
spawnPathDistribution: 'fixed' | 'random'
/** 旧版形状尺寸字段,保留用于配置迁移。 */
radius: number
rectW: number
rectH: number
@@ -151,8 +166,11 @@ export interface EmitterConfig {
rootScaleX: number
rootScaleY: number
// 方向
emissionAngleMode: EmissionAngleMode
direction: number
spread: number
/** 锥形区域朝向;与粒子发射角度独立。 */
coneDirection: number
/** 锥形开口角度(发电器形状=锥体时,粒子在锥形扇区内均匀散开) */
coneAngle: number
// 属性
@@ -423,6 +441,15 @@ export function defaultConfig(): EmitterConfig {
delay: 0,
duration: 1, // 持续发射时长;默认 1 秒(30f)。爆发模式运行时按 0 处理。
shape: 'point',
shapeRangeVersion: 1,
innerRadius: 0,
outerRadius: 30,
innerRectW: 0,
innerRectH: 0,
outerRectW: 60,
outerRectH: 40,
spawnPathId: 0,
spawnPathDistribution: 'fixed',
radius: 30,
rectW: 60,
rectH: 40,
@@ -431,8 +458,10 @@ export function defaultConfig(): EmitterConfig {
rootRotation: 0,
rootScaleX: 1,
rootScaleY: 1,
emissionAngleMode: 'random',
direction: 0,
spread: 360,
coneDirection: 90,
coneAngle: 60,
lifeMin: 0.6,
lifeMax: 1.8,
@@ -604,6 +633,13 @@ export function defaultConfig(): EmitterConfig {
export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
const defaults = defaultConfig() as unknown as Record<string, unknown>
const target = config as unknown as Record<string, unknown>
const needsShapeRangeMigration = target.shapeRangeVersion !== 1
const legacyRadius = Number(target.radius)
const legacyRectW = Number(target.rectW)
const legacyRectH = Number(target.rectH)
const legacyDirection = Number(target.direction)
const legacySpread = Number(target.spread)
const legacyShape = target.shape
const needsImageWeightMigration = target.imageWeightVersion !== 1
const needsLoopDurationMigration = target.loopDurationFrames == null
const needsGravityMigration = target.gravityMode == null
@@ -625,6 +661,30 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
? value.map((item) => typeof item === 'object' && item !== null ? { ...item } : item)
: value
}
if (needsShapeRangeMigration) {
config.innerRadius = 0
config.outerRadius = Math.max(0, Number.isFinite(legacyRadius) ? legacyRadius : defaults.outerRadius as number)
config.innerRectW = 0
config.innerRectH = 0
config.outerRectW = Math.max(0, Number.isFinite(legacyRectW) ? legacyRectW : defaults.outerRectW as number)
config.outerRectH = Math.max(0, Number.isFinite(legacyRectH) ? legacyRectH : defaults.outerRectH as number)
config.coneDirection = Number.isFinite(legacyDirection) ? legacyDirection : 90
config.emissionAngleMode = Number.isFinite(legacySpread) && legacySpread > 0 ? 'random' : 'fixed'
if (legacyShape === 'cone') config.spread = Math.max(0, Number(config.coneAngle) || 0)
config.shapeRangeVersion = 1
}
if (config.shape === 'orbit') config.shape = 'path'
if (!['fixed', 'random', 'radial', 'path-normal', 'path-tangent'].includes(config.emissionAngleMode)) config.emissionAngleMode = 'fixed'
if (!['circle', 'rect', 'cone'].includes(config.shape) && config.emissionAngleMode === 'radial') config.emissionAngleMode = 'fixed'
if (config.shape !== 'path' && (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent')) config.emissionAngleMode = 'fixed'
config.spawnPathId = Math.max(0, Math.round(Number(config.spawnPathId) || 0))
if (config.spawnPathDistribution !== 'fixed' && config.spawnPathDistribution !== 'random') config.spawnPathDistribution = 'fixed'
config.innerRadius = Math.max(0, Number(config.innerRadius) || 0)
config.outerRadius = Math.max(config.innerRadius, Number(config.outerRadius) || 0)
config.innerRectW = Math.max(0, Number(config.innerRectW) || 0)
config.innerRectH = Math.max(0, Number(config.innerRectH) || 0)
config.outerRectW = Math.max(config.innerRectW, Number(config.outerRectW) || 0)
config.outerRectH = Math.max(config.innerRectH, Number(config.outerRectH) || 0)
if (config.streamBehavior !== 'normal' && config.streamBehavior !== 'loop') config.streamBehavior = 'normal'
if (needsLoopDurationMigration || !Number.isFinite(config.loopDurationFrames)) config.loopDurationFrames = Math.ceil(Math.max(1 / 30, Number(config.duration) || 1) * 30)
config.loopDurationFrames = Math.min(1800, Math.max(1, Math.ceil(config.loopDurationFrames)))
@@ -869,6 +929,8 @@ export class ParticleEmitter extends Container {
private followSpawnX = 0
private followSpawnY = 0
private followSpawnAngle = 0
private spawnPathSampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null = null
private spawnPathClosed = false
private followExportCurveMode: '线性' | '贝塞尔' = '线性'
private simulationTransform: { centerX: number; centerY: number; rotation: number; scaleX?: number; scaleY?: number } | null = null
@@ -936,6 +998,10 @@ export class ParticleEmitter extends Container {
}
setEmitterPos(fn: () => IPointData) { this._emitterPos = fn }
setSpawnPathSampler(sampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null, closed = false) {
this.spawnPathSampler = sampler
this.spawnPathClosed = closed
}
setColliders(colliders: SceneCollider[]) { this.colliders = colliders }
setEmitterFollowSpawn(x: number, y: number, angleDegrees: number) {
this.followSpawnX = x
@@ -989,6 +1055,7 @@ export class ParticleEmitter extends Container {
const cfg = this.cfg
if (
cfg.lifeMode == null || cfg.speedMode == null || cfg.scaleMode == null ||
cfg.shapeRangeVersion !== 1 ||
cfg.initialRotationMode == null || cfg.speedOverLifeEnabled == null ||
cfg.scaleOverLifeEnabled == null || cfg.rotationOverLifeEnabled == null ||
!Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null || cfg.trailBoneCount == null
@@ -1484,23 +1551,31 @@ export class ParticleEmitter extends Container {
for (let i = 0; i < this.cfg.burstCount; i++) this.spawnOne()
}
private spawnOrigin(): { x: number; y: number } {
private spawnOrigin(): EmitterShapeSample {
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 this.applyFollowToOrigin(ox + Math.cos(a) * r, oy + Math.sin(a) * r)
} else if (s === 'rect') {
return this.applyFollowToOrigin(ox + (this.rng() - 0.5) * cfg.rectW, oy + (this.rng() - 0.5) * cfg.rectH)
} else if (s === 'cone') {
// 锥体:粒子从中心(锥形顶点)发射,方向由 spawnOne 在 [direction ± coneAngle/2] 扇形内随机
return this.applyFollowToOrigin(ox, oy)
if (cfg.shape === 'path' && cfg.spawnPathId > 0 && this.spawnPathSampler) {
const count = this.estimatedEmissionCount()
const progress = pathDistributionProgress(cfg.spawnPathDistribution, this._spawnSerial, count, this.spawnPathClosed, this.rng)
const pathSample = this.spawnPathSampler(progress)
if (pathSample) {
const origin = this.applyFollowToOrigin(pathSample.x + ox, pathSample.y + oy)
return { ...origin, radialAngle: 0, pathAngle: pathSample.angle }
}
}
return this.applyFollowToOrigin(ox, oy)
const sample = sampleEmitterShape(cfg, this.rng)
const origin = this.applyFollowToOrigin(ox + sample.x, oy + sample.y)
return { ...origin, radialAngle: sample.radialAngle }
}
private estimatedEmissionCount() {
const cfg = this.cfg
return cfg.mode === 'burst'
? Math.max(1, Math.round(cfg.burstCount))
: Math.max(1, Math.floor(cfg.rate * (cfg.streamBehavior === 'loop' ? Math.max(1, cfg.loopDurationFrames) / 30 : Math.max(0, cfg.duration))))
}
private applyFollowToOrigin(x: number, y: number) {
@@ -1516,16 +1591,11 @@ export class ParticleEmitter extends Container {
const cfg = this.cfg
let idx = this.pool.findIndex((p) => !p.active)
if (idx === -1) return // 池满
const { x: ox, y: oy } = this.spawnOrigin()
const origin = this.spawnOrigin()
const { x: ox, y: oy } = origin
const life = initialValue(cfg.lifeMode, cfg.lifeMin, cfg.lifeMax, cfg.lifeCurve, this.rng())
const speed = initialValue(cfg.speedMode, cfg.speedMin, cfg.speedMax, cfg.speedCurve, this.rng())
// 锥体:发射方向用锥形开口角度(coneAngle)散布,且 y 朝数学上正(与扇区绘制一致);其余形状沿用 spread + 屏幕坐标
let dirAng: number
if (cfg.shape === 'cone') {
dirAng = (cfg.direction + this.followSpawnAngle + lerp(-cfg.coneAngle / 2, cfg.coneAngle / 2, this.rng())) * Math.PI / 180
} else {
dirAng = (cfg.direction + this.followSpawnAngle + lerp(-cfg.spread / 2, cfg.spread / 2, this.rng())) * Math.PI / 180
}
const dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng)
const p = this.pool[idx]
const resource = this.pickImageResource()
const spawnSerial = this._spawnSerial++
@@ -1572,7 +1642,7 @@ export class ParticleEmitter extends Container {
// 从出生点开始积累轨迹,使第一次更新时整组拖尾骨骼就能在首段轨迹上等距插值。
if (p.trailEnabled) p.trailHistory.push({ x: ox, y: oy })
p.vx = Math.cos(dirAng) * speed
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
p.vy = Math.sin(dirAng) * speed
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale
p.scaleStartY = cfg.scaleAxisMode === 'separate'
? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng()) * resource.scale