564 lines
32 KiB
TypeScript
564 lines
32 KiB
TypeScript
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<ParticleState | null> }
|
|
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<string>) {
|
|
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<number>()
|
|
const range = loopPlaybackRange(config, sourceFps)
|
|
const scale = Math.max(1, targetFps) / Math.max(1, sourceFps)
|
|
return new Set<number>([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<string>, prefix: string) {
|
|
const keys = new Set<string>()
|
|
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<number> = new Set(), steppedFrames: Set<number> = 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<ParticleState | null>) {
|
|
const forced = new Set<number>()
|
|
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<ParticleState | null>) {
|
|
const stepped = new Set<number>()
|
|
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<TrailState | undefined>) {
|
|
const stepped = new Set<number>()
|
|
for (let frame = 0; frame < states.length; frame++) {
|
|
if (!states[frame]) continue
|
|
if (!states[frame + 1]) stepped.add(frame)
|
|
}
|
|
return stepped
|
|
}
|
|
|
|
function optionalStateBoundaryFrames<T>(states: Array<T | undefined>) {
|
|
const forced = new Set<number>()
|
|
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<T>(values: T[], fps: number, make: (value: T, frame: number) => SpineJsonMap, forcedFrames: Set<number> = 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<string | null>, fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve, steppedFrames: Set<number> = new Set(), boundaryFrames: Set<number> = new Set()) {
|
|
if (!colors.length) return []
|
|
const forcedFrames = new Set<number>()
|
|
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<string | null>, colors: number[][], fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve, steppedFrames: Set<number> = new Set(), boundaryFrames: Set<number> = 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<string>(['root'])
|
|
const animationNames = [...new Set([...(input.timeline.animations.length ? input.timeline.animations : ['animation']), ...visibleSystems.map((system) => system.animation || 'animation')])]
|
|
const animationSystems = new Map<string, Array<{ system: ParticleSystem; tracks: Track[]; targetFrames: number; boundaryFrames: Set<number>; 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<string | null> = [], 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)
|
|
}
|