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._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 ` 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}`)