import type { EmitterConfig, ParticleImageResource, ParticleState, TrailImageResource, TrailState } from '../core/particleEmitter' import type { ParticleSystem, TimelineState } from '../store/particleStore' import type { SpineExportSettings } from './spineExportSettings' import type { SpineExportResult, SpineExportWarning, SpineJsonDocument, SpineJsonMap } from './spineJsonTypes' import { downgradeSpineJsonTo38 } from './spine38JsonExporter' import { reduceBezierSamples, reduceNumericSamples, roundSpine, unwrapDegrees, type NumericSample } from './spineKeyframeReducer' import { buildWeightedTrailMesh } from './spineMeshBuilder' import { loopPlaybackRange, loopSegmentTiming } from '../core/loopSegmentTiming' import { collectSubEmitterIds } from '../core/subEmitterIds' import { translate } from '../i18n' type Track = { key: string; name: string; states: Array } type ExportInput = { systems: ParticleSystem[]; timeline: TimelineState; settings: SpineExportSettings; playbackEnd?: number } const BLENDS = { normal: 'normal', add: 'additive', multiply: 'multiply', screen: 'screen' } as const const PARTICLE_POSITION_TOLERANCE = 0.1 const TRAIL_POSITION_TOLERANCE = 0.1 const ROTATION_TOLERANCE = 0.01 const SCALE_TOLERANCE = 0.01 function safeName(value: string, fallback: string) { const name = String(value || '').trim().replace(/[\\/:*?"<>|\s]+/g, '_').replace(/^_+|_+$/g, '') return name || fallback } function uniqueName(base: string, used: Set) { let value = base let index = 2 while (used.has(value)) value = `${base}_${index++}` used.add(value) return value } function stripExtension(value: string) { return value.replace(/\.[^./\\]+$/, '') } function normalizePath(folder: string, file: string) { return stripExtension([folder, file].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/^\.\//, '')) } function textureSize(texture: any) { const width = Number(texture?.orig?.width ?? texture?.width) || 0 const height = Number(texture?.orig?.height ?? texture?.height) || 0 return { width, height } } function byte(value: number) { return Math.round(Math.max(0, Math.min(1, value)) * 255).toString(16).padStart(2, '0') } function rgbaValues(value: number, alpha: number) { return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255, Math.max(0, Math.min(1, alpha))] } function time(frame: number, fps: number) { return roundSpine(frame / fps) } function particleDurationFrames(system: ParticleSystem, fps: number) { const c = system.config const life = c.lifeMode === 'fixed' ? Math.max(0, Number(c.lifeMin) || 0) : Math.max(0, Number(c.lifeMin) || 0, Number(c.lifeMax) || 0) const delay = Math.max(0, Number(c.delay) || 0) let end: number if (c.mode !== 'stream') end = delay + life else if (c.streamBehavior !== 'loop') end = delay + Math.max(1 / fps, Number(c.duration) || 0) + life else { end = delay + loopSegmentTiming(c, fps).total / fps } return Math.max(1, Math.ceil(end * fps) + 1) } /** * 循环持续模式下的段边界(开始→循环、循环→结束)在导出时间轴上的帧号。 * 这些位置必须对**所有动画通道**补关键帧,才能在 Spine 里直接拆成 * 开始 / 循环 / 结束三段动画,而不必手动 K 帧。 * 返回的是目标导出 fps 下的帧号(源帧号按比例换算)。 */ function loopSegmentBoundaryFrames(system: ParticleSystem, sourceFps: number, targetFps: number) { const config = system.config if (config.mode !== 'stream' || config.streamBehavior !== 'loop') return new Set() const range = loopPlaybackRange(config, sourceFps) const scale = Math.max(1, targetFps) / Math.max(1, sourceFps) return new Set([Math.round(range.start * scale), Math.round(range.end * scale)]) } function stateIdentity(state: ParticleState, bonePool: boolean) { return bonePool ? state.boneName : `${state.spawnId ?? 'legacy'}_${state.boneName}` } function collectTracks(frames: ParticleState[][], bonePool: boolean, usedNames: Set, prefix: string) { const keys = new Set() for (const frame of frames) for (const state of frame) keys.add(stateIdentity(state, bonePool)) return [...keys].sort().map((key, index): Track => ({ key, // 粒子骨骼命名:系统名_序号 name: uniqueName(`${prefix}_${index}`, usedNames), states: frames.map((frame) => frame.find((state) => stateIdentity(state, bonePool) === key) || null), })) } /** * 轨道在时间轴上真正使用到的图片资源(骨骼池模式下同一轨道先后出生多颗粒子,可能用到多张)。 * 只有用到的图片才会挂到该轨道的插槽下,避免没用到的附件也占位。 */ function trackImageResources(system: ParticleSystem, track: Track) { const usedIds = new Set(track.states.map((state) => state?.resourceId).filter((id): id is number => typeof id === 'number')) return system.config.imageResources.filter((resource) => usedIds.has(resource.id)) } function sourceStateAt(track: Track, targetFrame: number, sourceFps: number, targetFps: number) { const source = targetFrame * sourceFps / targetFps const aIndex = Math.min(track.states.length - 1, Math.max(0, Math.floor(source))) const bIndex = Math.min(track.states.length - 1, aIndex + 1) const a = track.states[aIndex] const b = track.states[bIndex] if (!a || !b || stateIdentity(a, true) !== stateIdentity(b, true) || a.spawnId !== b.spawnId) return a const amount = source - aIndex if (amount <= 1e-6) return a const lerp = (x: number | undefined, y: number | undefined) => (x ?? 0) + ((y ?? x ?? 0) - (x ?? 0)) * amount const trailState = a.trailState && b.trailState && a.trailState.resourceId === b.trailState.resourceId ? { ...a.trailState, width: lerp(a.trailState.width, b.trailState.width), alpha: lerp(a.trailState.alpha, b.trailState.alpha), bones: a.trailState.bones.map((bone, index) => { const next = b.trailState!.bones[index] || bone return { ...bone, worldX: lerp(bone.worldX ?? bone.x, next.worldX ?? next.x), worldY: lerp(bone.worldY ?? bone.y, next.worldY ?? next.y), worldRotation: lerp(bone.worldRotation ?? -bone.rotation, next.worldRotation ?? -next.rotation) } }), } : a.trailState return { ...a, worldX: lerp(a.worldX, b.worldX), worldY: lerp(a.worldY, b.worldY), worldRotation: lerp(a.worldRotation, b.worldRotation), worldScaleX: lerp(a.worldScaleX, b.worldScaleX), worldScaleY: lerp(a.worldScaleY, b.worldScaleY), alpha: lerp(a.alpha, b.alpha), trailState, } } type TimelineCurveMode = 'linear' | 'bezier' function resolvedTimelineCurve(mode: '跟随导出' | '线性' | '贝塞尔' | undefined, settings: SpineExportSettings): TimelineCurveMode { if (!mode || mode === '跟随导出') return settings.keyframeCurve return mode === '贝塞尔' ? 'bezier' : 'linear' } function numericTimeline(samples: NumericSample[], fps: number, names: string[], tolerances: number[], settings: SpineExportSettings, forcedFrames: Set = new Set(), steppedFrames: Set = new Set(), curveMode: TimelineCurveMode = settings.keyframeCurve) { if (!samples.length) return [] // 阶跃帧必须保留,且所在段按“保持起始值”拟合,避免被补出过渡关键帧。 const forced = new Set(forcedFrames) for (const frame of steppedFrames) forced.add(frame) const fitted = curveMode === 'bezier' && settings.spineVersion !== '3.8' ? reduceBezierSamples(samples, tolerances, forced, steppedFrames) : { samples: reduceNumericSamples(samples, tolerances, forced, steppedFrames), controls: [] } const keys = fitted.samples.map((sample) => { const key: SpineJsonMap = { time: time(sample.frame, fps) } names.forEach((name, index) => { key[name] = roundSpine(sample.values[index]) }) // Spine 用字符串 "stepped" 表示该关键帧保持到下一帧再硬切(4.2/4.3 运行时均支持)。 if (steppedFrames.has(sample.frame)) key.curve = 'stepped' return key }) for (let index = 0; index < fitted.controls.length; index++) { if (keys[index].curve === 'stepped') continue const startTime = Number(keys[index].time) || 0 const endTime = Number(keys[index + 1].time) || 0 const curve: number[] = [] for (let dimension = 0; dimension < names.length; dimension++) { const controls = fitted.controls[index].values[dimension] curve.push(roundSpine(startTime + (endTime - startTime) / 3), roundSpine(controls[0]), roundSpine(startTime + (endTime - startTime) * 2 / 3), roundSpine(controls[1])) } keys[index].curve = curve } return keys } function stateBoundaryFrames(states: Array) { const forced = new Set() for (let frame = 0; frame < states.length; frame++) { const current = states[frame] const previous = frame ? states[frame - 1] : null const changedParticle = !!current && !!previous && current.spawnId !== previous.spawnId if ((!!current !== !!previous) || changedParticle) { if (frame > 0 && previous) forced.add(frame - 1) if (current) forced.add(frame) } } return forced } /** * 一段连续粒子(同一 spawnId)的最后一帧。 * 该帧之后是死亡(进入隐藏/假死)或骨骼池把同一根骨骼复用给新粒子, * 必须写成阶跃关键帧硬切,不能让变换在死区/复用点之间插值出滑动轨迹。 */ function particleSteppedFrames(states: Array) { const stepped = new Set() for (let frame = 0; frame < states.length; frame++) { const current = states[frame] if (!current?.active) continue const next = states[frame + 1] if (!next?.active || next.spawnId !== current.spawnId) stepped.add(frame) } return stepped } /** 一段连续拖尾的最后一帧;之后进入“透明假死”,同样需要用阶跃硬切。 */ function trailSteppedFrames(states: Array) { const stepped = new Set() for (let frame = 0; frame < states.length; frame++) { if (!states[frame]) continue if (!states[frame + 1]) stepped.add(frame) } return stepped } function optionalStateBoundaryFrames(states: Array) { const forced = new Set() for (let frame = 0; frame < states.length; frame++) { const current = states[frame], previous = frame ? states[frame - 1] : undefined if (!!current !== !!previous) { if (frame > 0 && previous) forced.add(frame - 1) if (current) forced.add(frame) } } return forced } function trailShapeAt(resource: TrailImageResource, t: number) { if (!resource.shapeEnabled || !resource.shapeCurve?.length) return 1 const points = [...resource.shapeCurve].sort((a, b) => a.x - b.x) if (t <= points[0].x) return Math.max(0, points[0].y) for (let index = 1; index < points.length; index++) { const a = points[index - 1], b = points[index] if (t <= b.x) { const amount = (t - a.x) / Math.max(0.000001, b.x - a.x) return Math.max(0, a.y + (b.y - a.y) * amount) } } return Math.max(0, points[points.length - 1].y) } function discreteTimeline(values: T[], fps: number, make: (value: T, frame: number) => SpineJsonMap, forcedFrames: Set = new Set()) { const keys: SpineJsonMap[] = [] let previous: T | symbol = Symbol() // 强制帧即使值没变化也补一个关键帧(循环段边界需要在每个通道上都有键)。 values.forEach((value, frame) => { if (value !== previous || forcedFrames.has(frame)) { keys.push(make(value, frame)); previous = value } }) return keys } function resourceAttachment(resource: ParticleImageResource, frameIndex: number, warnings: SpineExportWarning[]) { const frame = resource.imageMode === 'sequence' ? resource.sequenceFrames[frameIndex] : undefined const file = frame?.name || resource.textureName || `image_${resource.id}` const texture = frame?.texture || resource.texture const size = textureSize(texture) if (!size.width || !size.height) warnings.push({ code: 'missing-image', message: translate('errors.missingImageWarning', { file }) }) const width = size.width || 1, height = size.height || 1 return { path: normalizePath(resource.textureFolder, file), x: roundSpine((0.5 - resource.anchorX) * width), y: roundSpine((resource.anchorY - 0.5) * height), width: roundSpine(width), height: roundSpine(height), } } /** * 图片附件命名:默认使用系统名;同一插槽内存在序列帧或多张图片时追加序号尾缀。 * 返回 key(资源id_帧号) -> 附件名,供蒙皮定义与动画时间轴共用,保证两边名字一致。 */ function imageAttachmentNameMap(prefix: string, resources: ParticleImageResource[]) { const keys: string[] = [] for (const resource of resources) { const count = resource.imageMode === 'sequence' ? Math.max(1, resource.sequenceFrames.length) : 1 for (let frame = 0; frame < count; frame++) keys.push(`${resource.id}_${frame}`) } const multiple = keys.length > 1 return new Map(keys.map((key, index) => [key, multiple ? `${prefix}_${index}` : prefix])) } function rgbaTimeline(colors: number[][], attachments: Array, fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve, steppedFrames: Set = new Set(), boundaryFrames: Set = new Set()) { if (!colors.length) return [] const forcedFrames = new Set() for (let frame = 1; frame < attachments.length; frame++) { const current = attachments[frame] const previous = attachments[frame - 1] // 序列帧图片名变化不影响同一插槽上的 RGBA 插值;仅在显隐或资源卡片切换时锁定边界。 const currentFamily = current?.replace(/_\d+$/, '') ?? null const previousFamily = previous?.replace(/_\d+$/, '') ?? null if (currentFamily !== previousFamily) { forcedFrames.add(frame) forcedFrames.add(frame - 1) } } // 阶跃帧必须保留,且所在段按“保持起始值”拟合(颜色/透明度不在粒子池复用时被平滑)。 for (const frame of steppedFrames) forcedFrames.add(frame) // 循环段边界:即使颜色/透明度连续也要在边界打键,方便在 Spine 里拆分动画。 for (const frame of boundaryFrames) forcedFrames.add(frame) // 透明度使用未量化的浮点值参与精简,线性渐变能直接收敛到首尾关键帧; // RGB 保持单通道 1/255,透明度使用 0.01 的视觉容差。 const samples: NumericSample[] = colors.map((values, frame) => ({ frame, values })) const rgbTolerance = 1 / 255 const fitted = curveMode === 'bezier' && settings.spineVersion !== '3.8' ? reduceBezierSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames, steppedFrames) : { samples: reduceNumericSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames, steppedFrames), controls: [] } const keys = fitted.samples.map((sample) => { const key = { time: time(sample.frame, fps), color: `${byte(sample.values[0])}${byte(sample.values[1])}${byte(sample.values[2])}${byte(sample.values[3])}` } as SpineJsonMap if (steppedFrames.has(sample.frame)) key.curve = 'stepped' return key }) if (fitted.controls.length) { for (let index = 0; index < fitted.controls.length; index++) { if (keys[index].curve === 'stepped') continue const t0 = time(fitted.samples[index].frame, fps), t1 = time(fitted.samples[index + 1].frame, fps) const curve: number[] = [] for (let channel = 0; channel < 4; channel++) { const controls = fitted.controls[index].values[channel] curve.push(roundSpine(t0 + (t1 - t0) / 3), roundSpine(controls[0]), roundSpine(t0 + (t1 - t0) * 2 / 3), roundSpine(controls[1])) } keys[index].curve = curve } } return keys } function addSlotAnimation(animation: SpineJsonMap, slotName: string, attachments: Array, colors: number[][], fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve, steppedFrames: Set = new Set(), boundaryFrames: Set = new Set()) { const slot = animation.slots[slotName] ||= {} // Spine 3.8 编辑器要求附件时间轴的每一帧都存在 name;隐藏附件必须显式写 null, // 省略字段会在导入时触发 “Named value not found: name”。4.x 同样接受该标准写法。 const attachment = discreteTimeline(attachments, fps, (name, frame) => ({ time: time(frame, fps), name: name ?? null }), boundaryFrames) if (attachment.length) slot.attachment = attachment const rgba = rgbaTimeline(colors, attachments, fps, settings, curveMode, steppedFrames, boundaryFrames) if (rgba.length) slot.rgba = rgba } export function buildSpineJson(input: ExportInput): SpineExportResult { const settings = input.settings const sourceFps = Math.max(1, Number(input.timeline.fps) || 30) const targetFps = Math.max(1, Math.round(settings.fps || 30)) // 关闭整数帧对齐时保留编辑器原始采样时刻,仅修改 Spine 的 FPS 元数据。 const fps = settings.integerFrameAlignment ? targetFps : sourceFps const warnings: SpineExportWarning[] = [] const visibleSystems = input.systems.filter((system) => system.visible !== false) const subEmitterIds = collectSubEmitterIds(input.systems) const document: SpineJsonDocument = { // 先生成当前运行时使用的公共结构;3.8 会在最终序列化前降级字段与时间轴格式。 skeleton: { hash: '', spine: settings.spineVersion, x: 0, y: 0, width: 0, height: 0, images: settings.imagesPath || './images/' }, bones: [{ name: 'root' }], slots: [], skins: [{ name: 'default', attachments: {} }], animations: {}, } if (!settings.omitFps) document.skeleton.fps = targetFps const skin = document.skins[0].attachments as SpineJsonMap const usedNames = new Set(['root']) const animationNames = [...new Set([...(input.timeline.animations.length ? input.timeline.animations : ['animation']), ...visibleSystems.map((system) => system.animation || 'animation')])] const animationSystems = new Map; attachmentPrefix: string }>>() for (const system of visibleSystems) { const frameLength = system.frames?.length || 0 const playbackFrameCount = Number.isFinite(input.playbackEnd) ? Math.min(frameLength, Math.max(1, Math.floor(Number(input.playbackEnd)) + 1)) : frameLength const sourceCount = subEmitterIds.has(system.id) ? playbackFrameCount : Math.min(playbackFrameCount, particleDurationFrames(system, sourceFps)) const frames = (system.frames || []).slice(0, sourceCount) if (!frames.length) warnings.push({ code: 'empty-cache', message: translate('errors.emptyCacheWarning', { name: system.config.name }) }) const prefix = uniqueName(safeName(system.config.name, `system_${system.id}`), usedNames) document.bones.push({ name: prefix, parent: 'root' }) const tracks = collectTracks(frames, settings.bonePool, usedNames, prefix) const duration = Math.max(0, (sourceCount - 1) / sourceFps) const targetFrames = Math.max(1, Math.ceil(duration * fps) + 1) // 循环持续:开始在/循环/结束的段边界帧,所有通道都要补关键帧。 const boundaryFrames = loopSegmentBoundaryFrames(system, sourceFps, fps) const group = animationSystems.get(system.animation) || [] group.push({ system, tracks, targetFrames, boundaryFrames, attachmentPrefix: prefix }) animationSystems.set(system.animation, group) for (const track of tracks) { document.bones.push({ name: track.name, parent: prefix }) const imageResources = trackImageResources(system, track) const usedImageBlends = new Set(imageResources.map((item) => item.blend)) const imageSlotNames: string[] = [] const trailSlotNames: string[] = [] for (const blend of usedImageBlends) { // 插槽命名:系统名_序号_颜色模式(轨道名已经是“系统名_序号”) const slotName = `${track.name}_${blend}` imageSlotNames.push(slotName) document.slots.push({ name: slotName, bone: track.name, color: 'ffffff00', blend: BLENDS[blend] }) const slotSkin = skin[slotName] ||= {} const blendResources = imageResources.filter((item) => item.blend === blend) const attachmentNames = imageAttachmentNameMap(prefix, blendResources) // 只挂该轨道真正用到的图片;序列帧资源包含全部帧(每帧都会被播放到)。 for (const resource of blendResources) { const count = resource.imageMode === 'sequence' ? Math.max(1, resource.sequenceFrames.length) : 1 for (let frame = 0; frame < count; frame++) { const name = attachmentNames.get(`${resource.id}_${frame}`) if (name) slotSkin[name] = resourceAttachment(resource, frame, warnings) } } } if (system.config.trail) { for (const resource of system.config.trailResources) { if (!track.states.some((state) => state?.trailState?.resourceId === resource.id)) continue const count = Math.max(2, Math.min(16, Math.round(resource.boneCount || 3))) const trailNames: string[] = [] for (let i = 0; i < count; i++) { const name = `${track.name}_trail_${resource.id}_${i}` trailNames.push(name) document.bones.push({ name, parent: prefix, }) } const slotName = `${track.name}_trail_${resource.id}` trailSlotNames.push(slotName) document.slots.push({ name: slotName, bone: trailNames[0], color: 'ffffff00', blend: BLENDS[resource.blend] }) const indices = trailNames.map((name) => document.bones.findIndex((bone) => bone.name === name)) const path = normalizePath('', resource.textureName || `trail_${resource.id}`) ;(skin[slotName] ||= {})[`trail_${resource.id}`] = buildWeightedTrailMesh(system.config, resource, indices, path) if (!resource.texture) warnings.push({ code: 'missing-image', message: translate('errors.missingTrailImageWarning', { file: resource.textureName || resource.id }) }) } } // Spine 按 slots 数组顺序绘制。保持每个粒子的拖尾在图片下方。 const ownNames = new Set([...imageSlotNames, ...trailSlotNames]) const ownSlots = document.slots.filter((slot) => ownNames.has(String(slot.name))) if (ownSlots.length) { document.slots = document.slots.filter((slot) => !ownSlots.includes(slot)) document.slots.push( ...ownSlots.filter((slot) => trailSlotNames.includes(String(slot.name))), ...ownSlots.filter((slot) => imageSlotNames.includes(String(slot.name))), ) } } } for (const animationName of animationNames) { const entries = animationSystems.get(animationName) || [] const animation: SpineJsonMap = { bones: {}, slots: {} } let hasContent = false for (const { system, tracks, targetFrames, boundaryFrames, attachmentPrefix } of entries) { for (const track of tracks) { const states = Array.from({ length: targetFrames }, (_, frame) => sourceStateAt(track, frame, sourceFps, fps)) const active = states.map((state) => !!state?.active) const positions: NumericSample[] = [], rotations: number[] = [], scales: NumericSample[] = [] states.forEach((state, frame) => { if (!state?.active) return positions.push({ frame, values: [state.worldX ?? state.x, state.worldY ?? state.y] }) rotations.push(state.worldRotation ?? state.rotation) scales.push({ frame, values: [state.worldScaleX ?? state.scaleX, state.worldScaleY ?? state.scaleY] }) }) const rotationValues = unwrapDegrees(rotations) const rotationSamples: NumericSample[] = positions.map((sample, index) => ({ frame: sample.frame, values: [rotationValues[index] ?? 0] })) const motionBoundaries = stateBoundaryFrames(states) // 循环段边界也要在变换通道上补键(即使该处运动是连续的)。 for (const frame of boundaryFrames) motionBoundaries.add(frame) // 死亡/骨骼池复用点写成阶跃硬切,避免变换在死区里插值出滑动。 const stepped = particleSteppedFrames(states) const boneTimeline: SpineJsonMap = {} const translate = numericTimeline(positions, fps, ['x', 'y'], [PARTICLE_POSITION_TOLERANCE, PARTICLE_POSITION_TOLERANCE], settings, motionBoundaries, stepped) const rotate = numericTimeline(rotationSamples, fps, ['value'], [ROTATION_TOLERANCE], settings, motionBoundaries, stepped) const scale = numericTimeline(scales, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, motionBoundaries, stepped) if (translate.length) boneTimeline.translate = translate if (rotate.length) boneTimeline.rotate = rotate if (scale.length) boneTimeline.scale = scale if (Object.keys(boneTimeline).length) { animation.bones[track.name] = boneTimeline; hasContent = true } const imageResources = trackImageResources(system, track) const usedBlends = new Set(imageResources.map((item) => item.blend)) for (const blend of usedBlends) { const blendResources = imageResources.filter((item) => item.blend === blend) const attachmentNames = imageAttachmentNameMap(attachmentPrefix, blendResources) const attachments: Array = [], colors: number[][] = [] let lastColor = 0xffffff for (let frame = 0; frame < targetFrames; frame++) { const state = states[frame] const resource = state ? system.config.imageResources.find((item) => item.id === state.resourceId) : undefined const visible = !!state && active[frame] && state.imageVisible !== false && resource?.blend === blend if (state && resource?.blend === blend) lastColor = state.colorHex const attachmentName = visible ? attachmentNames.get(`${resource!.id}_${state!.imageFrameIndex ?? 0}`) : undefined attachments.push(attachmentName ?? null) colors.push(rgbaValues(lastColor, visible ? state!.alpha : 0)) } const requestedModes = blendResources .filter((item) => item.independentAlpha === 'curve') .map((item) => resolvedTimelineCurve(item.alphaCurveMode, settings)) const rgbaCurveMode = requestedModes.length ? (requestedModes.includes('bezier') ? 'bezier' : 'linear') : resolvedTimelineCurve(system.config.alphaMode === 'curve' ? system.config.curveMode : undefined, settings) addSlotAnimation(animation, `${track.name}_${blend}`, attachments, colors, fps, settings, rgbaCurveMode, stepped, boundaryFrames) if (attachments.some(Boolean)) hasContent = true } if (system.config.trail) for (const resource of system.config.trailResources) { const count = Math.max(2, Math.min(16, Math.round(resource.boneCount || 3))) const trailStates = states.map((state) => state?.trailState?.resourceId === resource.id ? state.trailState : undefined) const firstTrailState = trailStates.find((trail) => !!trail) if (!firstTrailState) continue const trailBoundaries = optionalStateBoundaryFrames(trailStates) // 拖尾死亡后进入“透明假死”,保持段必须是阶跃,复活时硬切。 const trailStepped = trailSteppedFrames(trailStates) const referenceWidth = resource.widthSizeMode === 'random' ? Math.max(1, Number(resource.widthMin) || 0, Number(resource.widthMax) || 0) : Math.max(0.001, Number(resource.width) || 100) // 加权 Mesh 的骨骼和附件属于永久对象池,不能随粒子死亡而增删。 // 出生前使用首次有效姿态,死亡后保持最近姿态;整组骨骼同帧缩放控制显隐。 let heldTrailState = firstTrailState const rigStates = trailStates.map((trail) => { if (trail) heldTrailState = trail return heldTrailState }) for (let boneIndex = 0; boneIndex < count; boneIndex++) { const samplesTranslate: NumericSample[] = [], samplesRotate: NumericSample[] = [], samplesScale: NumericSample[] = [] const boneBoundaries = new Set(trailBoundaries) for (const frame of boundaryFrames) boneBoundaries.add(frame) const shapeScale = trailShapeAt(resource, boneIndex / Math.max(1, count - 1)) rigStates.forEach((trail, frame) => { const bone = trail?.bones[Math.min(trail.bones.length - 1, boneIndex)] if (!bone) return const world = { x: bone.worldX ?? bone.x, y: bone.worldY ?? bone.y, rotation: bone.worldRotation ?? -bone.rotation } // 所有拖尾骨骼均直接挂在粒子系统骨骼下,动画写入独立世界姿态, // 避免骨骼链带来的旋转、缩放和位置累计误差。 samplesTranslate.push({ frame, values: [world.x, world.y] }) samplesRotate.push({ frame, values: [world.rotation] }) samplesScale.push({ frame, // 假死只修改插槽透明度;骨骼缩放始终保留,用于宽度和形状曲线。 values: [1, Math.max(0, trail.width / referenceWidth) * shapeScale], }) }) const name = `${track.name}_trail_${resource.id}_${boneIndex}` const timeline: SpineJsonMap = {} const translate = numericTimeline(samplesTranslate, fps, ['x', 'y'], [TRAIL_POSITION_TOLERANCE, TRAIL_POSITION_TOLERANCE], settings, boneBoundaries, trailStepped) const rotate = numericTimeline(samplesRotate, fps, ['value'], [ROTATION_TOLERANCE], settings, boneBoundaries, trailStepped) const scale = numericTimeline(samplesScale, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, boneBoundaries, trailStepped) if (translate.length) timeline.translate = translate if (rotate.length) timeline.rotate = rotate if (scale.length) timeline.scale = scale if (Object.keys(timeline).length) animation.bones[name] = timeline } // Mesh 始终绑定在插槽上,不再用 null 附件模拟死亡,避免拓扑在时间轴上消失/重建。 const attachments = trailStates.map(() => `trail_${resource.id}`) let lastTrailColor = firstTrailState.colorHex const colors = trailStates.map((trail) => { if (trail) { lastTrailColor = trail.colorHex } // 整组拖尾骨骼同生同死,因此可由唯一插槽统一透明假死。 return rgbaValues(lastTrailColor, trail ? trail.alpha : 0) }) addSlotAnimation(animation, `${track.name}_trail_${resource.id}`, attachments, colors, fps, settings, settings.keyframeCurve, trailStepped, boundaryFrames) hasContent = true } } } if (hasContent || !settings.excludeEmptyAnimations) document.animations[animationName] = animation } if (!Object.keys(document.animations).length && !settings.excludeEmptyAnimations) document.animations.animation = { bones: {}, slots: {} } const output = settings.spineVersion === '3.8' ? downgradeSpineJsonTo38(document) : document const text = JSON.stringify(output, null, 2) return { json: output, text, warnings, particleSystemCount: visibleSystems.length, animationCount: Object.keys(output.animations).length, boneCount: output.bones.length } } export function downloadSpineJson(result: SpineExportResult, fileName: string) { const blob = new Blob([result.text], { type: 'application/json;charset=utf-8' }) const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = /\.json$/i.test(fileName) ? fileName : `${fileName || 'SpineParticle'}.json` anchor.style.display = 'none' document.body.appendChild(anchor) anchor.click() anchor.remove() // 给浏览器足够时间接管 Blob,避免立即释放导致下载被静默取消。 setTimeout(() => URL.revokeObjectURL(url), 1000) }