539 lines
25 KiB
TypeScript
539 lines
25 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'
|
|
import { DEFAULT_SEQUENCE_FPS, ensureDefaultSequenceFrames } from './particleResources'
|
|
|
|
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',
|
|
sequenceStart: 'first',
|
|
sequenceFps: DEFAULT_SEQUENCE_FPS,
|
|
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,
|
|
renderLayerMode: 'top',
|
|
renderLayerSpineId: 0,
|
|
renderLayerSlotName: '',
|
|
renderLayerSlotPosition: 'above',
|
|
renderLayerFollowAttachment: false,
|
|
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,
|
|
warmupFrames: 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,
|
|
eventEnabled: false,
|
|
events: [],
|
|
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'
|
|
if (config.renderLayerMode !== 'top' && config.renderLayerMode !== 'slot') config.renderLayerMode = 'top'
|
|
config.renderLayerSpineId = Math.max(0, Math.round(Number(config.renderLayerSpineId) || 0))
|
|
if (typeof config.renderLayerSlotName !== 'string') config.renderLayerSlotName = ''
|
|
if (config.renderLayerSlotPosition !== 'above' && config.renderLayerSlotPosition !== 'below') config.renderLayerSlotPosition = 'above'
|
|
if (typeof config.renderLayerFollowAttachment !== 'boolean') config.renderLayerFollowAttachment = false
|
|
if (typeof config.eventEnabled !== 'boolean') config.eventEnabled = false
|
|
if (!Array.isArray(config.events)) config.events = []
|
|
for (const rule of config.events) {
|
|
rule.id = Math.max(1, Math.round(Number(rule.id) || 1))
|
|
if (rule.event !== 'birth' && rule.event !== 'death' && rule.event !== 'collision') rule.event = 'birth'
|
|
rule.targetSystemId = Math.max(0, Math.round(Number(rule.targetSystemId) || 0))
|
|
const probability = Number(rule.probability)
|
|
rule.probability = Math.min(1, Math.max(0, Number.isFinite(probability) ? probability : 1))
|
|
const collisionLimit = Math.round(Number(rule.collisionLimit))
|
|
rule.collisionLimit = Math.min(4, Math.max(1, Number.isFinite(collisionLimit) ? collisionLimit : 1))
|
|
// 旧版本 inherit 是单字符串;统一迁移为可多选的属性数组。
|
|
const legacyInherit = rule.inherit as unknown
|
|
const allowedInherit = ['color', 'scale', 'rotation', 'life']
|
|
const inheritList = Array.isArray(legacyInherit)
|
|
? legacyInherit
|
|
: legacyInherit === 'all'
|
|
? [...allowedInherit]
|
|
: allowedInherit.includes(String(legacyInherit))
|
|
? [String(legacyInherit)]
|
|
: []
|
|
const normalizedInherit = [...new Set(inheritList.filter((name) => allowedInherit.includes(String(name))))] as typeof rule.inherit
|
|
// 只有值确实变化时才写回;ensureEmitterConfig 每帧都会调用,重复赋新数组会触发无限响应式更新。
|
|
const currentInherit = Array.isArray(legacyInherit) ? legacyInherit : null
|
|
if (
|
|
!currentInherit
|
|
|| currentInherit.length !== normalizedInherit.length
|
|
|| normalizedInherit.some((name, index) => name !== currentInherit[index])
|
|
) {
|
|
rule.inherit = normalizedInherit
|
|
}
|
|
if (rule.colorInheritMode !== 'add' && rule.colorInheritMode !== 'replace') rule.colorInheritMode = 'add'
|
|
}
|
|
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)))
|
|
config.warmupFrames = Math.max(0, Math.floor(Number(config.warmupFrames) || 0))
|
|
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 (resource.sequenceStart !== 'random') resource.sequenceStart = 'first'
|
|
if (!Number.isFinite(resource.sequenceFps)) resource.sequenceFps = DEFAULT_SEQUENCE_FPS
|
|
resource.sequenceFps = Math.min(120, Math.max(1, Number(resource.sequenceFps) || DEFAULT_SEQUENCE_FPS))
|
|
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
|
|
}
|
|
ensureDefaultSequenceFrames(resource)
|
|
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
|
|
}
|