Files
SpineParticlesWeb/src/core/particleConfig.ts
T

489 lines
22 KiB
TypeScript

import { Texture } from 'pixi.js'
import type { EmitMode, EmitShape, Blend, AttributeMode, RotationMode, ScaleAxisMode, CurvePoint, ColorGradientStop, ColliderShape, CollisionResponse, SceneCollider, ParticleImageResource, ParticleSequenceFrame, TrailImageResource, EmitterConfig, ParticleState, TrailBoneState, TrailState } from './particleTypes'
import { BLEND_MAP } from './particleConstants'
export function defaultConfig(): EmitterConfig {
return {
texture: null as unknown as Texture,
imageResources: [{
id: 1,
texture: null,
textureName: 'star',
textureFolder: '',
previewUrl: '/images/star.png',
imageMode: 'fixed',
sequencePlayback: 'loop-forward',
sequenceFps: 30,
sequenceFrames: [],
weight: 100,
locked: false,
scale: 1,
anchorX: 0.5,
anchorY: 0.5,
colorMode: 'fixed',
colorStart: '#ffffff',
colorEnd: '#ff4d4d',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
blend: 'normal',
independentAlpha: 'off',
alpha: 1,
alphaCurveMode: '跟随导出',
alphaCurve: [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }],
}],
imageWeightVersion: 1,
name: 'ParticleSystem1',
mode: 'stream',
rate: 20,
streamBehavior: 'normal',
loopDurationFrames: 30,
generateLoopStartAnimation: false,
loopStartUseCustomDuration: false,
loopStartDurationFrames: 30,
generateLoopEndAnimation: false,
loopEndUseCustomDuration: false,
loopEndDurationFrames: 30,
burstCount: 50,
burstLoop: false, // 一次性爆发(持续爆发开关已移除)
delay: 0,
duration: 1, // 持续发射时长;默认 1 秒(30f)。爆发模式运行时按 0 处理。
shape: 'point',
showShape: true,
shapeRangeVersion: 1,
innerRadius: 0,
outerRadius: 30,
innerRectW: 0,
innerRectH: 0,
outerRectW: 60,
outerRectH: 40,
spawnPathId: 0,
spawnPathDistribution: 'fixed',
radius: 30,
rectW: 60,
rectH: 40,
centerX: 0,
centerY: 0,
rootRotation: 0,
rootScaleX: 1,
rootScaleY: 1,
emissionAngleMode: 'random',
direction: 0,
spread: 360,
coneDirection: 90,
coneAngle: 60,
lifeMin: 0.6,
lifeMax: 1.8,
lifeMode: 'random',
lifeCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
speedMin: 30,
speedMax: 140,
speedMode: 'random',
speedCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
scaleMin: 0.3,
scaleMax: 0.8,
scaleMode: 'random',
scaleAxisMode: 'uniform',
scaleYMin: 0.3,
scaleYMax: 0.8,
scaleCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
scaleYCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
scaleEndRatio: 0.3,
initialRotationMode: 'random',
initialRotationMin: 0,
initialRotationMax: 360,
initialRotationCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
alpha: 0.9,
alphaEnd: 0,
rotationSpeedMin: -180,
rotationSpeedMax: 180,
speedOverLifeEnabled: false,
speedOverLifeMode: 'fixed',
speedOverLifeMin: 1,
speedOverLifeMax: 1,
speedOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
scaleOverLifeEnabled: false,
scaleOverLifeMode: 'curve',
scaleOverLifeAxisMode: 'uniform',
scaleOverLifeMin: 0,
scaleOverLifeMax: 1,
scaleYOverLifeMin: 0,
scaleYOverLifeMax: 1,
scaleOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
scaleYOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
rotationOverLifeEnabled: false,
rotationOverLifeMode: 'random',
rotationOverLifeCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
gravity: 980,
gravityMode: 'fixed',
gravityMin: 980,
gravityMax: 980,
gravityDirection: -90,
gravityCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
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: 30,
noiseRange: 100,
noiseSpeed: 1,
useGravity: false,
wind: false,
windX: 0,
windY: 0,
windMode: 'fixed',
windMin: 100,
windMax: 100,
windDirection: 0,
windCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
forceField: false,
forceStrength: 10,
forceRadius: 100,
forceCenterX: 0,
forceCenterY: 0,
forceFalloff: 'linear',
showForceFieldShape: true,
vortex: false,
vortexStrength: 10,
vortexRadius: 100,
vortexCenterX: 0,
vortexCenterY: 0,
vortexFalloff: 'linear',
showVortexShape: true,
attraction: false,
attractionCenterX: 200,
attractionCenterY: 0,
attractionRadius: 20,
attractionDuration: 0.5,
attractionDelay: 0.1,
attractionDelayMode: 'particle',
attractionPath: 'curve',
showAttractionShape: true,
trail: false,
trailConfigVersion: 3,
trailResources: [{
id: 1,
texture: null,
textureName: 'trail',
previewUrl: '/images/trail.png',
weight: 100,
locked: false,
rotation: 0,
colorMode: 'particle',
color: '#ffffff',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
blend: 'normal',
alphaMode: 'particle',
alpha: 1,
alphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
boneCount: 3,
trailRatio: 100,
lengthMode: 'fixed',
length: 100,
lengthMin: 100,
lengthMax: 100,
widthSizeMode: 'fixed',
width: 100,
widthMin: 100,
widthMax: 100,
widthMode: 'particle',
lifeLengthEnabled: false,
lifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
shapeEnabled: false,
shapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
}],
trailResourceVersion: 6,
trailTexture: null,
trailTextureName: 'trail',
trailPreviewUrl: '/images/trail.png',
trailColorMode: 'particle',
trailColor: '#ffffff',
trailColorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
trailAlphaMode: 'particle',
trailAlpha: 1,
trailAlphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
trailBoneCount: 3,
trailLength: 100,
trailWidth: 100,
trailWidthMode: 'particle',
trailLifeLengthEnabled: false,
trailLifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
trailShapeEnabled: false,
trailShapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
trailGridRows: 4,
trailGridCols: 4,
collision: false,
collisionColliderIds: [],
pathFollow: false,
emitterFollow: false,
emitterFollowMode: 'none',
emitterFollowPathId: 0,
emitterFollowSpineId: 0,
emitterFollowBoneName: '',
emitterFollowBoneOffsetX: 0,
emitterFollowBoneOffsetY: 0,
emitterFollowBoneRotation: 0,
emitterFollowBoneScaleX: 1,
emitterFollowBoneScaleY: 1,
emitterAngleMode: 'fixed',
emitterFollowCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
emitterFollowDuration: 2,
emitterFollowSpace: 'world',
emitterFollowDirection: 'forward',
emitterFollowOffset: 0,
}
}
/** 为热更新或旧版配置补齐后来新增的字段,避免 undefined 进入粒子计算。 */
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
const legacyGravity = Number(target.gravity)
const needsWindMigration = target.windMode == null
const legacyWindX = Number(target.windX)
const legacyWindY = Number(target.windY)
const needsForceFieldMigration = target.forceFalloff == null
const legacyForceStrength = Number(target.forceStrength)
const needsTrailCurveMigration = target.trailConfigVersion !== 3
const needsTrailResourceMigration = !Array.isArray(target.trailResources)
const needsTrailResourceStyleMigration = target.trailResourceVersion !== 6
const legacyTrailTexture = target.trailTexture as Texture<any> | null | undefined
const legacyTrailTextureName = String(target.trailTextureName || 'trail')
const legacyTrailPreviewUrl = String(target.trailPreviewUrl || '/images/trail.png').replace('/trails/trail.png', '/images/trail.png')
for (const [key, value] of Object.entries(defaults)) {
if (target[key] !== undefined && target[key] !== null) continue
target[key] = Array.isArray(value)
? 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)
const normalizedCollisionColliderIds = Array.from(new Set(
(Array.isArray(config.collisionColliderIds) ? config.collisionColliderIds : [])
.map((id) => Math.round(Number(id)))
.filter((id) => Number.isFinite(id) && id > 0),
))
if (normalizedCollisionColliderIds.length !== config.collisionColliderIds.length
|| normalizedCollisionColliderIds.some((id, index) => id !== config.collisionColliderIds[index])) {
config.collisionColliderIds = normalizedCollisionColliderIds
}
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)))
if (typeof config.generateLoopStartAnimation !== 'boolean') config.generateLoopStartAnimation = false
if (typeof config.generateLoopEndAnimation !== 'boolean') config.generateLoopEndAnimation = false
if (typeof config.loopStartUseCustomDuration !== 'boolean') config.loopStartUseCustomDuration = false
if (typeof config.loopEndUseCustomDuration !== 'boolean') config.loopEndUseCustomDuration = false
config.loopStartDurationFrames = Math.min(1800, Math.max(1, Math.ceil(Number(config.loopStartDurationFrames) || 30)))
config.loopEndDurationFrames = Math.min(1800, Math.max(1, Math.ceil(Number(config.loopEndDurationFrames) || 30)))
if (!Array.isArray(config.imageResources) || config.imageResources.length === 0) {
config.imageResources = defaultConfig().imageResources
}
for (const resource of config.imageResources) {
if (!resource.texture && config.texture) resource.texture = config.texture
if (!resource.textureName) resource.textureName = 'star'
if (resource.previewUrl === '/particles/star.png') resource.previewUrl = '/images/star.png'
if (!resource.previewUrl) resource.previewUrl = resource.textureName === 'star' ? '/images/star.png' : ''
if (resource.imageMode !== 'fixed' && resource.imageMode !== 'sequence') resource.imageMode = 'fixed'
if ((resource.sequencePlayback as string) === 'once') resource.sequencePlayback = 'once-hold'
else if ((resource.sequencePlayback as string) === 'loop') resource.sequencePlayback = 'loop-forward'
else if (!['once-hide', 'once-hold', 'loop-forward', 'loop-reverse'].includes(resource.sequencePlayback)) {
resource.sequencePlayback = 'loop-forward'
}
if (!Number.isFinite(resource.sequenceFps)) resource.sequenceFps = 30
resource.sequenceFps = Math.min(120, Math.max(1, Number(resource.sequenceFps) || 30))
if (!Array.isArray(resource.sequenceFrames)) resource.sequenceFrames = []
for (const frame of resource.sequenceFrames) {
if (typeof frame.name !== 'string') frame.name = ''
if (typeof frame.previewUrl !== 'string') frame.previewUrl = ''
if (frame.texture === undefined) frame.texture = null
}
if (!Number.isFinite(resource.weight)) resource.weight = 100
if (typeof resource.locked !== 'boolean') resource.locked = false
if (!Number.isFinite(resource.scale)) resource.scale = 1
if (!Number.isFinite(resource.anchorX)) resource.anchorX = 0.5
if (!Number.isFinite(resource.anchorY)) resource.anchorY = 0.5
if (resource.colorMode !== 'fixed' && resource.colorMode !== 'random' && resource.colorMode !== 'lifetime') {
resource.colorMode = config.colorOverLife ? 'lifetime' : 'fixed'
}
if (!resource.colorStart) resource.colorStart = config.colorStart || '#ffffff'
if (!resource.colorEnd) resource.colorEnd = config.colorEnd || resource.colorStart
if (!Array.isArray(resource.colorGradient) || resource.colorGradient.length < 2) {
resource.colorGradient = [
{ position: 0, color: resource.colorStart },
{ position: 1, color: resource.colorEnd },
]
}
if (!Object.prototype.hasOwnProperty.call(BLEND_MAP, resource.blend)) resource.blend = config.blend || 'normal'
if (!['off', 'fixed', 'curve'].includes(resource.independentAlpha)) resource.independentAlpha = config.independentAlpha || 'off'
if (!Number.isFinite(resource.alpha)) resource.alpha = Number.isFinite(config.alphaScale) ? config.alphaScale : 1
if (!['跟随导出', '线性', '贝塞尔'].includes(resource.alphaCurveMode)) resource.alphaCurveMode = config.curveMode || '跟随导出'
if (!Array.isArray(resource.alphaCurve) || resource.alphaCurve.length < 2) {
resource.alphaCurve = (config.alphaCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
}
if (config.imageResources.length === 1) {
config.imageResources[0].weight = 100
config.imageResources[0].locked = false
} else if (needsImageWeightMigration) {
const equalWeight = 100 / config.imageResources.length
config.imageResources.forEach((resource, index) => {
resource.weight = index === config.imageResources.length - 1
? 100 - equalWeight * (config.imageResources.length - 1)
: equalWeight
resource.locked = false
})
}
config.imageWeightVersion = 1
if (needsGravityMigration) {
const migratedStrength = Number.isFinite(legacyGravity) && legacyGravity !== 0 ? legacyGravity : 980
config.gravity = migratedStrength
config.gravityMin = migratedStrength
config.gravityMax = migratedStrength
}
if (needsWindMigration) {
const hasLegacyWind = Number.isFinite(legacyWindX) && Number.isFinite(legacyWindY) && (legacyWindX !== 0 || legacyWindY !== 0)
config.windMin = hasLegacyWind ? Math.hypot(legacyWindX, legacyWindY) : 100
config.windMax = config.windMin
config.windDirection = hasLegacyWind ? Math.atan2(-legacyWindY, legacyWindX) * 180 / Math.PI : 0
}
if (needsForceFieldMigration) {
config.forceStrength = Number.isFinite(legacyForceStrength) && legacyForceStrength !== 0 ? legacyForceStrength : 10
}
if (needsTrailCurveMigration) {
config.trailConfigVersion = 3
config.trailAlphaCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
config.trailLifeLengthCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
config.trailShapeCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
config.trailGridRows = 4
config.trailGridCols = 4
}
if (needsTrailResourceMigration || !config.trailResources.length) {
config.trailResources = [{
id: 1,
texture: legacyTrailTexture || null,
textureName: legacyTrailTextureName,
previewUrl: legacyTrailPreviewUrl,
weight: 100,
locked: false,
rotation: 0,
colorMode: config.trailColorMode,
color: config.trailColor,
colorGradient: config.trailColorGradient.map((stop) => ({ ...stop })),
blend: 'normal',
alphaMode: config.trailAlphaMode,
alpha: config.trailAlpha,
alphaCurve: config.trailAlphaCurve.map((point) => ({ ...point })),
boneCount: config.trailBoneCount,
trailRatio: 100,
lengthMode: 'fixed',
length: config.trailLength,
lengthMin: config.trailLength,
lengthMax: config.trailLength,
widthSizeMode: 'fixed',
width: config.trailWidth,
widthMin: config.trailWidth,
widthMax: config.trailWidth,
widthMode: config.trailWidthMode,
lifeLengthEnabled: config.trailLifeLengthEnabled,
lifeLengthCurve: config.trailLifeLengthCurve.map((point) => ({ ...point })),
shapeEnabled: config.trailShapeEnabled,
shapeCurve: config.trailShapeCurve.map((point) => ({ ...point })),
}]
}
for (const resource of config.trailResources) {
if (!resource.textureName) resource.textureName = 'trail'
if (resource.previewUrl === '/trails/trail.png') resource.previewUrl = '/images/trail.png'
if (!resource.previewUrl) resource.previewUrl = '/images/trail.png'
if (!Number.isFinite(resource.weight)) resource.weight = 100
if (typeof resource.locked !== 'boolean') resource.locked = false
if (!Number.isFinite(resource.rotation)) resource.rotation = 0
if (!['particle', 'fixed', 'curve'].includes(resource.colorMode)) resource.colorMode = config.trailColorMode || 'particle'
if (!resource.color) resource.color = config.trailColor || '#ffffff'
if (!Array.isArray(resource.colorGradient) || resource.colorGradient.length < 2) {
resource.colorGradient = (config.trailColorGradient || [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }]).map((stop) => ({ ...stop }))
}
if (!Object.prototype.hasOwnProperty.call(BLEND_MAP, resource.blend)) resource.blend = 'normal'
if (!['particle', 'fixed', 'curve'].includes(resource.alphaMode)) resource.alphaMode = config.trailAlphaMode || 'particle'
if (!Number.isFinite(resource.alpha)) resource.alpha = Number.isFinite(config.trailAlpha) ? config.trailAlpha : 1
if (!Array.isArray(resource.alphaCurve) || resource.alphaCurve.length < 2) {
resource.alphaCurve = (config.trailAlphaCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
if (!Number.isFinite(resource.boneCount)) resource.boneCount = Number.isFinite(config.trailBoneCount) ? config.trailBoneCount : 3
if (!Number.isFinite(resource.trailRatio)) resource.trailRatio = 100
resource.trailRatio = Math.min(100, Math.max(0, resource.trailRatio))
if (!['fixed', 'random'].includes(resource.lengthMode)) resource.lengthMode = 'fixed'
if (!Number.isFinite(resource.length)) resource.length = Number.isFinite(config.trailLength) ? config.trailLength : 100
if (!Number.isFinite(resource.lengthMin)) resource.lengthMin = resource.length
if (!Number.isFinite(resource.lengthMax)) resource.lengthMax = resource.length
if (!['fixed', 'random'].includes(resource.widthSizeMode)) resource.widthSizeMode = 'fixed'
if (!Number.isFinite(resource.width)) resource.width = Number.isFinite(config.trailWidth) ? config.trailWidth : 100
if (!Number.isFinite(resource.widthMin)) resource.widthMin = resource.width
if (!Number.isFinite(resource.widthMax)) resource.widthMax = resource.width
if (!['particle', 'fixed'].includes(resource.widthMode)) resource.widthMode = config.trailWidthMode || 'particle'
if (typeof resource.lifeLengthEnabled !== 'boolean') resource.lifeLengthEnabled = Boolean(config.trailLifeLengthEnabled)
if (!Array.isArray(resource.lifeLengthCurve) || resource.lifeLengthCurve.length < 2) {
resource.lifeLengthCurve = (config.trailLifeLengthCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
if (typeof resource.shapeEnabled !== 'boolean') resource.shapeEnabled = Boolean(config.trailShapeEnabled)
if (!Array.isArray(resource.shapeCurve) || resource.shapeCurve.length < 2) {
resource.shapeCurve = (config.trailShapeCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
}
if (config.trailResources.length === 1) {
config.trailResources[0].weight = 100
config.trailResources[0].locked = false
}
if (needsTrailResourceStyleMigration) {
for (const resource of config.trailResources) {
resource.alphaCurve = resource.alphaCurve?.length >= 2 ? resource.alphaCurve : [{ x: 0, y: 1 }, { x: 1, y: 1 }]
}
}
config.trailResourceVersion = 6
return config
}