新增spine版本支持

This commit is contained in:
tianmo committed 2026-09-01 17:22:18 +08:00
1 parent 8802bbe25e
commit 19278dac01
91 files changed
+69095 -5744

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
import type { SpineJsonDocument, SpineJsonMap } from './spineJsonTypes'
/** 按编辑器导出设置保留简洁的 Spine 3.8 版本标识。 */
export const SPINE_38_JSON_VERSION = '3.8'
function rename(target: SpineJsonMap, nextName: string, oldName: string) {
if (target[nextName] == null && target[oldName] != null) target[nextName] = target[oldName]
delete target[oldName]
}
function removeArrayCurves(value: unknown) {
if (!value || typeof value !== 'object') return
if (Array.isArray(value)) {
for (const item of value) removeArrayCurves(item)
return
}
const record = value as SpineJsonMap
// Spine 3.8 只有一组归一化贝塞尔控制点,无法表达 4.x 的逐通道绝对控制点。
// 导出器在 3.8 模式下会重新生成线性精简关键帧;这里也为未来新增时间轴兜底。
if (Array.isArray(record.curve)) delete record.curve
for (const child of Object.values(record)) removeArrayCurves(child)
}
function downgradeSetup(document: SpineJsonDocument) {
for (const bone of document.bones) rename(bone, 'transform', 'inherit')
for (const constraint of (document as any).transform || []) {
rename(constraint, 'rotateMix', 'mixRotate')
if (constraint.translateMix == null) constraint.translateMix = constraint.mixX ?? constraint.mixY
if (constraint.scaleMix == null) constraint.scaleMix = constraint.mixScaleX ?? constraint.mixScaleY
rename(constraint, 'shearMix', 'mixShearY')
delete constraint.mixX
delete constraint.mixY
delete constraint.mixScaleX
delete constraint.mixScaleY
}
for (const constraint of (document as any).path || []) {
rename(constraint, 'rotateMix', 'mixRotate')
if (constraint.translateMix == null) constraint.translateMix = constraint.mixX ?? constraint.mixY
delete constraint.mixX
delete constraint.mixY
}
for (const skin of document.skins) {
for (const slot of Object.values<SpineJsonMap>(skin.attachments || {})) {
for (const attachment of Object.values<SpineJsonMap>(slot || {})) {
if (attachment.parent != null) rename(attachment, 'deform', 'timelines')
}
}
}
}
function downgradeSlots(animation: SpineJsonMap) {
for (const slot of Object.values<SpineJsonMap>(animation.slots || {})) {
rename(slot, 'color', 'rgba')
rename(slot, 'twoColor', 'rgba2')
}
}
function downgradeBones(animation: SpineJsonMap) {
for (const bone of Object.values<SpineJsonMap>(animation.bones || {})) {
for (const frame of bone.rotate || []) rename(frame, 'angle', 'value')
}
}
function downgradeTransformConstraints(animation: SpineJsonMap) {
for (const frames of Object.values<SpineJsonMap[]>(animation.transform || {})) {
for (const frame of frames) {
rename(frame, 'rotateMix', 'mixRotate')
if (frame.translateMix == null) frame.translateMix = frame.mixX ?? frame.mixY
if (frame.scaleMix == null) frame.scaleMix = frame.mixScaleX ?? frame.mixScaleY
rename(frame, 'shearMix', 'mixShearY')
delete frame.mixX
delete frame.mixY
delete frame.mixScaleX
delete frame.mixScaleY
}
}
}
function downgradePathConstraints(animation: SpineJsonMap) {
for (const constraint of Object.values<SpineJsonMap>(animation.path || {})) {
for (const timelineName of ['position', 'spacing']) {
for (const frame of constraint[timelineName] || []) rename(frame, timelineName, 'value')
}
for (const frame of constraint.mix || []) {
rename(frame, 'rotateMix', 'mixRotate')
if (frame.translateMix == null) frame.translateMix = frame.mixX ?? frame.mixY
delete frame.mixX
delete frame.mixY
}
}
}
function downgradeDeforms(animation: SpineJsonMap) {
if (!animation.attachments) return
const deform = animation.deform || (animation.deform = {})
for (const [skinName, skin] of Object.entries<SpineJsonMap>(animation.attachments)) {
const targetSkin = deform[skinName] || (deform[skinName] = {})
for (const [slotName, slot] of Object.entries<SpineJsonMap>(skin)) {
const targetSlot = targetSkin[slotName] || (targetSkin[slotName] = {})
for (const [attachmentName, timelines] of Object.entries<SpineJsonMap>(slot)) {
if (timelines.deform) targetSlot[attachmentName] = timelines.deform
}
}
}
delete animation.attachments
}
/** 把编辑器内部生成的 4.x 公共结构降级为 Spine 3.8 软件可导入的 JSON。 */
export function downgradeSpineJsonTo38(document: SpineJsonDocument) {
document.skeleton.spine = SPINE_38_JSON_VERSION
downgradeSetup(document)
for (const animation of Object.values(document.animations)) {
downgradeSlots(animation)
downgradeBones(animation)
downgradeTransformConstraints(animation)
downgradePathConstraints(animation)
downgradeDeforms(animation)
}
removeArrayCurves(document.animations)
return document
}
+4 -2
View File
@@ -1,4 +1,6 @@
export type SpineExportVersion = '4.2'
import { normalizeSpineVersion, type SupportedSpineVersion } from '../spine/spineVersion'
export type SpineExportVersion = SupportedSpineVersion
export type SpineExportKeyframeCurve = 'linear' | 'bezier'
/** Spine JSON 导出的全局参数。实际 JSON 生成逻辑将在后续阶段接入。 */
@@ -32,7 +34,7 @@ export function defaultSpineExportSettings(): SpineExportSettings {
export function ensureSpineExportSettings(value?: Partial<SpineExportSettings> | null): SpineExportSettings {
const settings = Object.assign(defaultSpineExportSettings(), value || {})
settings.imagesPath = String(settings.imagesPath || './images/')
settings.spineVersion = '4.2'
settings.spineVersion = normalizeSpineVersion(settings.spineVersion) || '4.2'
settings.keyframeCurve = settings.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
settings.fps = Math.min(240, Math.max(1, Math.round(Number(settings.fps) || 30)))
settings.omitFps = settings.omitFps === true
+11 -6
View File
@@ -2,6 +2,7 @@ import type { EmitterConfig, ParticleImageResource, ParticleState, TrailImageRes
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'
@@ -111,7 +112,7 @@ function resolvedTimelineCurve(mode: '跟随导出' | '线性' | '贝塞尔' | u
function numericTimeline(samples: NumericSample[], fps: number, names: string[], tolerances: number[], settings: SpineExportSettings, forcedFrames: Set<number> = new Set(), curveMode: TimelineCurveMode = settings.keyframeCurve) {
if (!samples.length) return []
const fitted = curveMode === 'bezier'
const fitted = curveMode === 'bezier' && settings.spineVersion !== '3.8'
? reduceBezierSamples(samples, tolerances, forcedFrames)
: { samples: reduceNumericSamples(samples, tolerances, forcedFrames), controls: [] }
const keys = fitted.samples.map((sample) => {
@@ -212,7 +213,7 @@ function rgbaTimeline(colors: number[][], attachments: Array<string | null>, fps
// RGB 保持单通道 1/255,透明度使用 0.01 的视觉容差。
const samples: NumericSample[] = colors.map((values, frame) => ({ frame, values }))
const rgbTolerance = 1 / 255
const fitted = curveMode === 'bezier'
const fitted = curveMode === 'bezier' && settings.spineVersion !== '3.8'
? reduceBezierSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames)
: { samples: reduceNumericSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames), controls: [] }
const keys = fitted.samples.map((sample) => ({ time: time(sample.frame, fps), color: `${byte(sample.values[0])}${byte(sample.values[1])}${byte(sample.values[2])}${byte(sample.values[3])}` } as SpineJsonMap))
@@ -232,7 +233,9 @@ function rgbaTimeline(colors: number[][], attachments: Array<string | null>, fps
function addSlotAnimation(animation: SpineJsonMap, slotName: string, attachments: Array<string | null>, colors: number[][], fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve) {
const slot = animation.slots[slotName] ||= {}
const attachment = discreteTimeline(attachments, fps, (name, frame) => name == null ? { time: time(frame, fps) } : { time: time(frame, fps), name })
// 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 }))
if (attachment.length) slot.attachment = attachment
const rgba = rgbaTimeline(colors, attachments, fps, settings, curveMode)
if (rgba.length) slot.rgba = rgba
@@ -247,7 +250,8 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
const warnings: SpineExportWarning[] = []
const visibleSystems = input.systems.filter((system) => system.visible !== false)
const document: SpineJsonDocument = {
skeleton: { hash: '', spine: '4.2', x: 0, y: 0, width: 0, height: 0, images: settings.imagesPath || './images/' },
// 先生成当前运行时使用的公共结构;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
@@ -426,8 +430,9 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
}
if (!Object.keys(document.animations).length && !settings.excludeEmptyAnimations) document.animations.animation = { bones: {}, slots: {} }
const text = JSON.stringify(document, null, 2)
return { json: document, text, warnings, particleSystemCount: visibleSystems.length, animationCount: Object.keys(document.animations).length, boneCount: document.bones.length }
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) {
+48 -10
View File
@@ -9,6 +9,9 @@ import {
type AttachmentLoader,
type Skin,
} from '@esotericsoftware/spine-core'
import { normalizeSpine38JsonFor42 } from '../spine/spine38Compatibility'
import { normalizeSpineVersion, supportedSpineVersionsText, type SupportedSpineVersion } from '../spine/spineVersion'
import type { SpineJsonMap } from './spineJsonTypes'
class ExportValidationAttachmentLoader implements AttachmentLoader {
newRegionAttachment(_skin: Skin, name: string, path: string, _sequence: any) { return new RegionAttachment(name, path) }
@@ -59,12 +62,12 @@ function validateMesh(mesh: Record<string, any>, attachmentName: string, boneCou
if (offset !== vertices.length || decodedVertices !== vertexCount) throw new Error(`拖尾网格“${attachmentName}”的顶点数量与 UV 不一致`)
}
function validateEditorImportStructure(root: Record<string, any>) {
function validateEditorImportStructure(root: Record<string, any>, version: SupportedSpineVersion) {
const bones = Array.isArray(root.bones) ? root.bones : []
const boneByName = new Map(bones.map((bone) => [String(bone.name || ''), bone]))
const weightedMeshSlots = new Set<string>()
for (const bone of bones) {
if (bone.inherit != null && !VALID_INHERIT.has(bone.inherit)) throw new Error(`骨骼“${bone.name || ''}”的 inherit 值“${bone.inherit}”不符合 Spine 4.2 JSON 格式`)
if (bone.inherit != null && !VALID_INHERIT.has(bone.inherit)) throw new Error(`骨骼“${bone.name || ''}”的 inherit 值“${bone.inherit}”不符合 Spine ${version} JSON 格式`)
if (/_particle_\d+_trail_/.test(String(bone.name || ''))) {
const parent = boneByName.get(String(bone.parent || ''))
if (!parent || /_particle_\d+_trail_/.test(String(parent.name || ''))) {
@@ -94,12 +97,47 @@ function validateEditorImportStructure(root: Record<string, any>) {
}
}
/** 下载前使用项目内置 Spine 4.2 Runtime 做一次结构解析,提前拦截无效 JSON。 */
export function validateSpineJson(text: string) {
const root = JSON.parse(text)
validateEditorImportStructure(root)
const reader = new SkeletonJson(new ExportValidationAttachmentLoader())
const data = reader.readSkeletonData(root)
if (!data.bones.length) throw new Error('导出结果缺少骨骼')
return { bones: data.bones.length, slots: data.slots.length, animations: data.animations.length }
function validateSpine38Structure(root: Record<string, any>) {
if (String(root?.skeleton?.spine || '') !== '3.8') throw new Error('Spine 3.8 导出的版本标识必须为 3.8')
for (const bone of root.bones || []) {
if (bone.inherit != null) throw new Error(`Spine 3.8 骨骼“${bone.name || ''}”应使用 transform,不能使用 inherit`)
}
for (const animation of Object.values(root.animations || {}) as SpineJsonMap[]) {
for (const slot of Object.values(animation.slots || {}) as SpineJsonMap[]) {
if (slot.rgba || slot.rgba2) throw new Error('Spine 3.8 插槽颜色时间轴必须使用 color / twoColor')
for (const frame of slot.attachment || []) {
if (!Object.prototype.hasOwnProperty.call(frame, 'name')) throw new Error('Spine 3.8 附件时间轴的隐藏帧必须显式写入 name: null')
}
}
for (const bone of Object.values(animation.bones || {}) as SpineJsonMap[]) {
for (const frame of bone.rotate || []) {
if (frame.value != null || frame.angle == null) throw new Error('Spine 3.8 旋转时间轴必须使用 angle')
}
}
const stack: unknown[] = [animation]
while (stack.length) {
const value = stack.pop()
if (!value || typeof value !== 'object') continue
if (!Array.isArray(value) && Array.isArray((value as SpineJsonMap).curve)) throw new Error('Spine 3.8 不支持 4.x 数组曲线格式')
stack.push(...Object.values(value))
}
}
}
/**
* 下载前使用项目内置运行时解析共同结构,提前拦截无效 JSON。
* 目标版本由 skeleton.spine 决定;校验不会改写导出的版本字段。
*/
export function validateSpineJson(text: string, expectedVersion?: SupportedSpineVersion) {
const root = JSON.parse(text)
const version = normalizeSpineVersion(root?.skeleton?.spine)
if (!version) throw new Error(`导出版本不受支持,仅支持 Spine ${supportedSpineVersionsText()}`)
if (expectedVersion && version !== expectedVersion) throw new Error(`导出版本不一致:设置为 Spine ${expectedVersion},JSON 标记为 Spine ${version}`)
validateEditorImportStructure(root, version)
if (version === '3.8') validateSpine38Structure(root)
const runtimeRoot = version === '3.8' ? normalizeSpine38JsonFor42(JSON.parse(JSON.stringify(root))) : root
const reader = new SkeletonJson(new ExportValidationAttachmentLoader())
const data = reader.readSkeletonData(runtimeRoot)
if (!data.bones.length) throw new Error('导出结果缺少骨骼')
return { version, bones: data.bones.length, slots: data.slots.length, animations: data.animations.length }
}