修正时间轴末帧,导出补阶跃与循环段边界关键帧

- 时间轴:末帧按时长实际覆盖的整数帧计算,修掉 Spine 时长小数舍入多出的一帧,B 端与回放自动停在最后一帧
- 导出:粒子死亡、骨骼池复用、拖尾假死复活处统一写 curve: stepped 硬切,避免死区插值出滑动轨迹
- 导出:循环持续在开始/循环/结束段边界(如 30f/110f)对所有动画通道补关键帧,便于在 Spine 中直接拆分
- 精简器支持阶跃段,阶跃区间内不再补出过渡关键帧
- 补充 test:spine-json 回归用例:骨骼池复用阶跃边界、循环段边界
This commit is contained in:
tianmo committed 2026-10-09 17:05:53 +08:00
1 parent f249c019bc
commit d6232c9d66
9 files changed
+290 -82

No files matched your search

+88
View File
@@ -3,6 +3,7 @@ import { build } from 'esbuild'
const source = `
import { buildSpineJson } from './src/export/spineJsonExporter.ts'
import { validateSpineJson } from './src/export/spineJsonValidator.ts'
import { bakeLoopFrames } from './src/core/loopFrameBaker.ts'
const resource = {
id: 1, imageMode: 'single', sequenceFrames: [], textureName: 'star.png', textureFolder: '',
@@ -39,6 +40,93 @@ const source = `
console.error('Spine ' + version + ': ' + (error instanceof Error ? error.message : String(error)))
}
}
// 假死 / 骨骼池复用:死区内的变换必须保持并硬切(阶跃关键帧),
// 不能在死区里插值出滑动轨迹,也不能在死区补出中间关键帧。
const poolState = (frame, spawnId, x, y) => ({
active: true, boneName: 'p_0', spawnId, resourceId: 1, imageFrameIndex: 0,
imageVisible: true, colorHex: 0xffffff, alpha: 1,
x, y, rotation: 0, scaleX: 1, scaleY: 1,
worldX: x, worldY: y, worldRotation: 0, worldScaleX: 1, worldScaleY: 1,
})
const poolFrames = Array.from({ length: 20 }, (_, frame) => {
if (frame <= 4) return [poolState(frame, 1, 10 + frame, 0)]
if (frame <= 9) return []
return [poolState(frame, 2, -200 + (frame - 10), 300)]
})
const poolSystem = {
id: 1, animation: 'animation', visible: true, frames: poolFrames,
config: {
name: 'verify', mode: 'burst', lifeMode: 'fixed', lifeMin: 1, lifeMax: 1, delay: 0,
imageResources: [resource], trail: false, trailResources: [],
},
}
const poolSettings = {
imagesPath: './images/', spineVersion: '4.2', keyframeCurve: 'bezier', fps: 30,
omitFps: false, integerFrameAlignment: true, bonePool: true,
excludeEmptyAnimations: false, fileName: 'verify.json',
}
const poolResult = buildSpineJson({ systems: [poolSystem], timeline, settings: poolSettings })
const poolBone = poolResult.json.animations.animation.bones._particle_000
const poolFramesAt = poolBone.translate.map((key) => Math.round(key.time * 30))
if (JSON.stringify(poolFramesAt) !== JSON.stringify([0, 4, 10, 19])) {
failures++
console.error('骨骼池复用:死区内不应插入过渡关键帧,实际为 ' + JSON.stringify(poolFramesAt))
}
const poolStepped = poolBone.translate.filter((key) => key.curve === 'stepped').map((key) => Math.round(key.time * 30))
if (JSON.stringify(poolStepped) !== JSON.stringify([4, 19])) {
failures++
console.error('骨骼池复用:死亡/复用边界应写阶跃关键帧,实际为 ' + JSON.stringify(poolStepped))
}
// 循环持续(开始 30f / 循环 80f / 结束 30f):段边界 30f、110f 必须在所有动画通道上打关键帧。
const loopConfig = {
name: 'loop', mode: 'stream', streamBehavior: 'loop', rate: 4, delay: 0,
loopDurationFrames: 80,
generateLoopStartAnimation: true, loopStartUseCustomDuration: true, loopStartDurationFrames: 30,
generateLoopEndAnimation: true, loopEndUseCustomDuration: true, loopEndDurationFrames: 30,
lifeMode: 'fixed', lifeMin: 1, lifeMax: 1,
imageResources: [resource], trail: false, trailResources: [],
}
const loopEmitter = {
step: 0, activeCount: 1,
reset() { this.step = 0; this.activeCount = 1 },
restartEmissionCycle() { this.step = 0 },
update(_dt, mode) { if (mode === 'off') this.activeCount = 0; else this.step++; return this.capture() },
capture() {
return this.activeCount ? [{
active: true, boneName: 'p_0', spawnId: 1, resourceId: 1, imageFrameIndex: 0,
imageVisible: true, colorHex: 0xffffff, alpha: 1,
x: this.step, y: 0, rotation: 0, scaleX: 1, scaleY: 1,
worldX: this.step, worldY: 0, worldRotation: 0, worldScaleX: 1, worldScaleY: 1,
}] : []
},
apply() {},
}
const loopSystem = { id: 1, animation: 'animation', visible: true, config: loopConfig }
bakeLoopFrames(loopSystem, loopEmitter, 30, 141)
const loopResult = buildSpineJson({ systems: [loopSystem], timeline, settings: poolSettings })
const loopAnimation = loopResult.json.animations.animation
const boundaryFramesOf = (keys) => keys.filter((key) => {
const frame = Math.round(key.time * 30)
return frame === 30 || frame === 110
}).map((key) => Math.round(key.time * 30))
const expectedBoundaries = JSON.stringify([30, 110])
const loopBone = loopAnimation.bones._particle_000
const loopSlot = loopAnimation.slots._particle_000_image_normal
for (const channel of ['translate', 'rotate', 'scale']) {
if (JSON.stringify(boundaryFramesOf(loopBone[channel] || [])) !== expectedBoundaries) {
failures++
console.error('循环持续:骨骼 ' + channel + ' 通道缺少 30f/110f 段边界关键帧')
}
}
for (const channel of ['attachment', 'rgba']) {
if (JSON.stringify(boundaryFramesOf(loopSlot[channel] || [])) !== expectedBoundaries) {
failures++
console.error('循环持续:插槽 ' + channel + ' 通道缺少 30f/110f 段边界关键帧')
}
}
if (failures) process.exitCode = 1
`