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 const target = config as unknown as Record 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 | 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 }