发射器形状新增
This commit is contained in:
1 parent
0bde824c88
commit
026ee74d48
11 files changed
+1657
-1336
No files matched your search
+91
-21
@@ -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
|
||||
|
||||
Reference in new issue
Block a user