171 lines
8.0 KiB
JavaScript
171 lines
8.0 KiB
JavaScript
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: '',
|
|
anchorX: 0.5, anchorY: 0.5, blend: 'normal', independentAlpha: 'off',
|
|
}
|
|
const frames = Array.from({ length: 31 }, (_, frame) => [{
|
|
active: true, boneName: 'particle', spawnId: 1, resourceId: 1, imageFrameIndex: 0,
|
|
imageVisible: true, colorHex: 0xffffff, alpha: 1,
|
|
x: frame, y: frame / 2, rotation: frame, scaleX: 1, scaleY: 1,
|
|
worldX: frame, worldY: frame / 2, worldRotation: frame, worldScaleX: 1, worldScaleY: 1,
|
|
}])
|
|
const system = {
|
|
id: 1, animation: 'animation', visible: true, frames,
|
|
config: {
|
|
name: 'verify', mode: 'burst', lifeMode: 'fixed', lifeMin: 1, lifeMax: 1, delay: 0,
|
|
imageResources: [resource], trail: false, trailResources: [],
|
|
},
|
|
}
|
|
const timeline = { fps: 30, animations: ['animation'] }
|
|
|
|
let failures = 0
|
|
for (const version of ['3.8', '4.0', '4.1', '4.2', '4.3']) {
|
|
const settings = {
|
|
imagesPath: './images/', spineVersion: version, keyframeCurve: 'linear', fps: 30,
|
|
omitFps: false, integerFrameAlignment: true, bonePool: true,
|
|
excludeEmptyAnimations: false, fileName: 'verify.json',
|
|
}
|
|
const result = buildSpineJson({ systems: [system], timeline, settings })
|
|
try {
|
|
const validation = validateSpineJson(result.text, version)
|
|
console.log('Spine ' + version + ':', validation.bones + ' bones,', validation.slots + ' slots,', validation.animations + ' animations')
|
|
} catch (error) {
|
|
failures++
|
|
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.verify_0
|
|
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))
|
|
}
|
|
|
|
// 子发射器的帧从父事件时刻才开始,不能按自身生命周期从 0f 截断。
|
|
const childFrames = Array.from({ length: 80 }, (_, frame) => (
|
|
frame >= 50 && frame < 65 ? [poolState(frame, 1, frame, 0)] : []
|
|
))
|
|
const subEmitterSystem = {
|
|
id: 2, animation: 'animation', visible: true, frames: childFrames,
|
|
config: {
|
|
name: 'verify_child', mode: 'burst', lifeMode: 'fixed', lifeMin: 0.5, lifeMax: 0.5, delay: 0,
|
|
imageResources: [resource], trail: false, trailResources: [], eventEnabled: false, events: [],
|
|
},
|
|
}
|
|
const parentSystem = {
|
|
id: 1, animation: 'animation', visible: true, frames: Array.from({ length: 80 }, () => []),
|
|
config: {
|
|
name: 'verify_parent', mode: 'burst', lifeMode: 'fixed', lifeMin: 0.5, lifeMax: 0.5, delay: 0,
|
|
imageResources: [resource], trail: false, trailResources: [], eventEnabled: true,
|
|
events: [{ id: 1, event: 'death', targetSystemId: 2, probability: 1 }],
|
|
},
|
|
}
|
|
const subResult = buildSpineJson({ systems: [parentSystem, subEmitterSystem], timeline, settings: poolSettings })
|
|
const subBone = subResult.json.animations.animation.bones.verify_child_0
|
|
const subFirstFrame = subBone?.translate ? Math.round(subBone.translate[0].time * 30) : -1
|
|
if (subFirstFrame !== 50) {
|
|
failures++
|
|
console.error('子发射器:父事件之后的帧被按自身生命周期截断,实际首帧为 ' + subFirstFrame)
|
|
}
|
|
|
|
// 循环持续(开始 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.loop_0
|
|
const loopSlot = loopAnimation.slots.loop_0_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
|
|
`
|
|
|
|
const result = await build({
|
|
stdin: { contents: source, resolveDir: process.cwd(), sourcefile: 'verify-spine-json.ts', loader: 'ts' },
|
|
bundle: true,
|
|
platform: 'node',
|
|
format: 'esm',
|
|
write: false,
|
|
logLevel: 'silent',
|
|
})
|
|
|
|
const code = Buffer.from(result.outputFiles[0].contents).toString('base64')
|
|
await import(`data:text/javascript;base64,${code}`)
|