新增spine版本支持

This commit is contained in:
tianmo
2026-09-01 17:22:18 +08:00
parent 8802bbe25e
commit 19278dac01
91 changed files with 69095 additions and 5744 deletions
+5 -1
View File
@@ -186,7 +186,10 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
const colliders = cloneEditorValue(config.scene.colliders) as unknown as CollisionBody[]
const paths = cloneEditorValue(config.scene.paths) as unknown as ScenePath[]
const spines = cloneEditorValue(config.scene.spines) as unknown as SpineSceneObject[]
for (const spine of spines) if (spine.sourceBundle == null) spine.sourceBundle = null
for (const spine of spines) {
if (spine.sourceBundle == null) spine.sourceBundle = null
if (spine.dataVersion == null) spine.dataVersion = ''
}
await hydrateParticleResources(systems)
const parsedSpines = await parseEmbeddedSpines(spines)
@@ -221,6 +224,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
continue
}
registerSpineAsset(spine.id, parsed)
spine.dataVersion = parsed.dataVersion
spine.animations = parsed.animations
spine.bones = parsed.bones
if (!parsed.animations.some((animation) => animation.name === spine.selectedAnimation)) {
+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 }
}
+7 -1
View File
@@ -38,7 +38,10 @@
</button>
<button class="clear" @click="clearBundle">清除</button>
</div>
<div class="runtime-note">Spine 4.2.119 · 官方 PixiJS 7 运行时 · 本地离线</div>
<div class="runtime-note">
支持 Spine 3.8 / 4.0 / 4.1 / 4.2 JSON · 4.2.119 预览运行时 · 本地离线
<span v-if="spineObject.dataVersion"> · 当前资源 {{ spineObject.dataVersion }}</span>
</div>
<div v-if="spineObject.error" class="error">{{ spineObject.error }}</div>
<div class="section-box">
@@ -125,6 +128,7 @@ const textureFiles = ref<File[]>([])
// 兼容热更新或旧场景数据;骨骼选择默认保持为空。
if (props.spineObject.selectedBone == null) props.spineObject.selectedBone = ''
if (props.spineObject.sourceBundle == null) props.spineObject.sourceBundle = null
if (props.spineObject.dataVersion == null) props.spineObject.dataVersion = ''
const skeletonName = computed(() => skeletonFile.value?.name || props.spineObject.skeletonFileName)
const atlasName = computed(() => atlasFile.value?.name || props.spineObject.atlasFileName)
@@ -221,6 +225,7 @@ async function loadBundle() {
props.spineObject.skeletonFileName = skeleton.name
props.spineObject.atlasFileName = atlas.name
props.spineObject.textureFileNames = textureFiles.value.map((file) => file.name)
props.spineObject.dataVersion = parsed.dataVersion
props.spineObject.animations = parsed.animations
props.spineObject.bones = parsed.bones
props.spineObject.selectedBone = ''
@@ -257,6 +262,7 @@ function clearBundle() {
props.spineObject.skeletonFileName = ''
props.spineObject.atlasFileName = ''
props.spineObject.textureFileNames = []
props.spineObject.dataVersion = ''
props.spineObject.sourceBundle = null
props.spineObject.animations = []
props.spineObject.bones = []
+219
View File
@@ -0,0 +1,219 @@
type JsonRecord = Record<string, any>
function valueOf(frame: JsonRecord, key: string, fallback: number) {
const value = Number(frame[key])
return Number.isFinite(value) ? value : fallback
}
function setRenamedValue(frame: JsonRecord, target: string, source: string, fallback: number) {
if (frame[target] == null) frame[target] = valueOf(frame, source, fallback)
}
function legacyCurveToArray(
frame: JsonRecord,
next: JsonRecord,
values: Array<[number, number]>,
) {
if (typeof frame.curve !== 'number') return
const time1 = valueOf(frame, 'time', 0)
const time2 = valueOf(next, 'time', 0)
const timeRange = time2 - time1
const x1 = frame.curve
const y1 = valueOf(frame, 'c2', 0)
const x2 = valueOf(frame, 'c3', 1)
const y2 = valueOf(frame, 'c4', 1)
frame.curve = values.flatMap(([start, end]) => [
time1 + timeRange * x1,
start + (end - start) * y1,
time1 + timeRange * x2,
start + (end - start) * y2,
])
delete frame.c2
delete frame.c3
delete frame.c4
}
function convertTimelineCurves(
frames: JsonRecord[] | undefined,
channels: Array<{ key: string; fallback: number }>,
) {
if (!Array.isArray(frames)) return
for (let index = 0; index < frames.length - 1; index++) {
const frame = frames[index]
const next = frames[index + 1]
legacyCurveToArray(frame, next, channels.map(({ key, fallback }) => [
valueOf(frame, key, fallback),
valueOf(next, key, fallback),
]))
}
}
function convertNormalizedTimelineCurves(frames: JsonRecord[] | undefined) {
if (!Array.isArray(frames)) return
for (let index = 0; index < frames.length - 1; index++) {
legacyCurveToArray(frames[index], frames[index + 1], [[0, 1]])
}
}
function colorChannels(value: unknown, includeAlpha: boolean) {
const input = typeof value === 'string' ? value.replace(/^#/, '') : ''
const normalized = input.length === 6 ? `${input}ff` : input.padEnd(8, 'f').slice(0, 8)
const channels = [0, 2, 4, 6].map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16) / 255)
return includeAlpha ? channels : channels.slice(0, 3)
}
function convertColorTimelineCurves(frames: JsonRecord[] | undefined, twoColor: boolean) {
if (!Array.isArray(frames)) return
for (let index = 0; index < frames.length - 1; index++) {
const frame = frames[index]
const next = frames[index + 1]
const start = twoColor
? [...colorChannels(frame.light, true), ...colorChannels(frame.dark, false)]
: colorChannels(frame.color, true)
const end = twoColor
? [...colorChannels(next.light, true), ...colorChannels(next.dark, false)]
: colorChannels(next.color, true)
legacyCurveToArray(frame, next, start.map((value, channel) => [value, end[channel]]))
}
}
function normalizeConstraintSetup(root: JsonRecord) {
for (const bone of root.bones || []) {
if (bone.inherit == null && bone.transform != null) bone.inherit = bone.transform
}
for (const constraint of root.transform || []) {
setRenamedValue(constraint, 'mixRotate', 'rotateMix', 1)
setRenamedValue(constraint, 'mixX', 'translateMix', 1)
if (constraint.mixY == null) constraint.mixY = constraint.mixX
setRenamedValue(constraint, 'mixScaleX', 'scaleMix', 1)
if (constraint.mixScaleY == null) constraint.mixScaleY = constraint.mixScaleX
setRenamedValue(constraint, 'mixShearY', 'shearMix', 1)
}
for (const constraint of root.path || []) {
setRenamedValue(constraint, 'mixRotate', 'rotateMix', 1)
setRenamedValue(constraint, 'mixX', 'translateMix', 1)
if (constraint.mixY == null) constraint.mixY = constraint.mixX
}
}
function normalizeLinkedMeshes(root: JsonRecord) {
for (const skin of root.skins || []) {
for (const slot of Object.values<JsonRecord>(skin.attachments || {})) {
for (const attachment of Object.values<JsonRecord>(slot || {})) {
if (attachment.parent != null && attachment.timelines == null && attachment.deform != null) {
attachment.timelines = attachment.deform
}
}
}
}
}
function normalizeSlotTimelines(animation: JsonRecord) {
for (const slot of Object.values<JsonRecord>(animation.slots || {})) {
if (slot.color && !slot.rgba) {
slot.rgba = slot.color
delete slot.color
}
if (slot.twoColor && !slot.rgba2) {
slot.rgba2 = slot.twoColor
delete slot.twoColor
}
convertColorTimelineCurves(slot.rgba, false)
convertColorTimelineCurves(slot.rgba2, true)
}
}
function normalizeBoneTimelines(animation: JsonRecord) {
for (const bone of Object.values<JsonRecord>(animation.bones || {})) {
for (const frame of bone.rotate || []) setRenamedValue(frame, 'value', 'angle', 0)
convertTimelineCurves(bone.rotate, [{ key: 'value', fallback: 0 }])
convertTimelineCurves(bone.translate, [{ key: 'x', fallback: 0 }, { key: 'y', fallback: 0 }])
convertTimelineCurves(bone.scale, [{ key: 'x', fallback: 1 }, { key: 'y', fallback: 1 }])
convertTimelineCurves(bone.shear, [{ key: 'x', fallback: 0 }, { key: 'y', fallback: 0 }])
}
}
function normalizeIkTimelines(animation: JsonRecord) {
for (const frames of Object.values<JsonRecord[]>(animation.ik || {})) {
convertTimelineCurves(frames, [{ key: 'mix', fallback: 1 }, { key: 'softness', fallback: 0 }])
}
}
function normalizeTransformTimelines(animation: JsonRecord) {
for (const frames of Object.values<JsonRecord[]>(animation.transform || {})) {
for (const frame of frames) {
setRenamedValue(frame, 'mixRotate', 'rotateMix', 1)
setRenamedValue(frame, 'mixX', 'translateMix', 1)
if (frame.mixY == null) frame.mixY = frame.mixX
setRenamedValue(frame, 'mixScaleX', 'scaleMix', 1)
if (frame.mixScaleY == null) frame.mixScaleY = frame.mixScaleX
setRenamedValue(frame, 'mixShearY', 'shearMix', 1)
}
convertTimelineCurves(frames, [
{ key: 'mixRotate', fallback: 1 },
{ key: 'mixX', fallback: 1 },
{ key: 'mixY', fallback: 1 },
{ key: 'mixScaleX', fallback: 1 },
{ key: 'mixScaleY', fallback: 1 },
{ key: 'mixShearY', fallback: 1 },
])
}
}
function normalizePathTimelines(animation: JsonRecord) {
for (const constraint of Object.values<JsonRecord>(animation.path || {})) {
for (const timelineName of ['position', 'spacing'] as const) {
const frames = constraint[timelineName] as JsonRecord[] | undefined
for (const frame of frames || []) setRenamedValue(frame, 'value', timelineName, 0)
convertTimelineCurves(frames, [{ key: 'value', fallback: 0 }])
}
const mixFrames = constraint.mix as JsonRecord[] | undefined
for (const frame of mixFrames || []) {
setRenamedValue(frame, 'mixRotate', 'rotateMix', 1)
setRenamedValue(frame, 'mixX', 'translateMix', 1)
if (frame.mixY == null) frame.mixY = frame.mixX
}
convertTimelineCurves(mixFrames, [
{ key: 'mixRotate', fallback: 1 },
{ key: 'mixX', fallback: 1 },
{ key: 'mixY', fallback: 1 },
])
}
}
function normalizeDeformTimelines(animation: JsonRecord) {
if (!animation.deform) return
const attachments = animation.attachments || (animation.attachments = {})
for (const [skinName, skin] of Object.entries<JsonRecord>(animation.deform)) {
const targetSkin = attachments[skinName] || (attachments[skinName] = {})
for (const [slotName, slot] of Object.entries<JsonRecord>(skin)) {
const targetSlot = targetSkin[slotName] || (targetSkin[slotName] = {})
for (const [attachmentName, frames] of Object.entries<JsonRecord[]>(slot)) {
const targetAttachment = targetSlot[attachmentName] || (targetSlot[attachmentName] = {})
targetAttachment.deform = frames
convertNormalizedTimelineCurves(frames)
}
}
}
delete animation.deform
}
/**
* Spine 3.8 的曲线、旋转值和约束字段名与 4.x 不同。当前预览层统一使用
* Spine 4.2 Runtime,因此只在内存中把 3.8 JSON 转成 4.2 Runtime 可识别的等价结构。
* 原始文件、保存配置中的源文件以及 skeleton.spine 版本标识均保持不变。
*/
export function normalizeSpine38JsonFor42(source: JsonRecord) {
normalizeConstraintSetup(source)
normalizeLinkedMeshes(source)
for (const animation of Object.values<JsonRecord>(source.animations || {})) {
normalizeSlotTimelines(animation)
normalizeBoneTimelines(animation)
normalizeIkTimelines(animation)
normalizeTransformTimelines(animation)
normalizePathTimelines(animation)
normalizeDeformTimelines(animation)
if (!animation.drawOrder && animation.draworder) animation.drawOrder = animation.draworder
}
return source
}
+17 -1
View File
@@ -9,10 +9,13 @@ import { SpineTexture } from '@esotericsoftware/spine-pixi-v7'
import { ALPHA_MODES, Texture } from 'pixi.js'
import type { LoadedSpineAsset } from './spineAssetRegistry'
import type { SpineAnimationInfo, SpineBoneInfo } from './spineTypes'
import { normalizeSpine38JsonFor42 } from './spine38Compatibility'
import { normalizeSpineVersion, supportedSpineVersionsText, type SupportedSpineVersion } from './spineVersion'
export interface ParsedSpineBundle extends LoadedSpineAsset {
animations: SpineAnimationInfo[]
bones: SpineBoneInfo[]
dataVersion: SupportedSpineVersion
}
function basename(path: string) {
@@ -56,17 +59,30 @@ export async function parseSpineBundle(skeletonFile: File, atlasFile: File, text
const attachmentLoader = new AtlasAttachmentLoader(atlas)
let skeletonData: SkeletonData
let dataVersion: SupportedSpineVersion | null = null
if (/\.skel$/i.test(skeletonFile.name)) {
const reader = new SkeletonBinary(attachmentLoader)
skeletonData = reader.readSkeletonData(await skeletonFile.arrayBuffer())
dataVersion = normalizeSpineVersion(skeletonData.version)
} else {
let source: any
try {
source = JSON.parse(await skeletonFile.text())
} catch {
throw new Error('Spine JSON 文件格式无效')
}
dataVersion = normalizeSpineVersion(source?.skeleton?.spine)
if (!dataVersion) throw new Error(`Spine JSON 版本不受支持,仅支持 ${supportedSpineVersionsText()}`)
if (dataVersion === '3.8') normalizeSpine38JsonFor42(source)
const reader = new SkeletonJson(attachmentLoader)
skeletonData = reader.readSkeletonData(await skeletonFile.text())
skeletonData = reader.readSkeletonData(source)
}
if (!dataVersion) throw new Error(`Spine 数据版本不受支持,仅支持 ${supportedSpineVersionsText()}`)
return {
skeletonData,
textures,
dataVersion,
animations: skeletonData.animations.map((animation) => ({ name: animation.name, duration: animation.duration })),
bones: boneMetadata(skeletonData),
}
+3
View File
@@ -1,4 +1,5 @@
import type { SpineSourceBundle } from './spineSourceBundle'
import type { SupportedSpineVersion } from './spineVersion'
export interface SpineAnimationInfo {
name: string
@@ -25,6 +26,8 @@ export interface SpineSceneObject {
skeletonFileName: string
atlasFileName: string
textureFileNames: string[]
/** 当前已加载资源的 Spine 主版本。 */
dataVersion: SupportedSpineVersion | ''
/** 保存配置时使用的可移植原始 Spine 文件。 */
sourceBundle: SpineSourceBundle | null
selectedAnimation: string
+16
View File
@@ -0,0 +1,16 @@
export const SUPPORTED_SPINE_VERSIONS = ['3.8', '4.0', '4.1', '4.2'] as const
export type SupportedSpineVersion = typeof SUPPORTED_SPINE_VERSIONS[number]
export function normalizeSpineVersion(value: unknown): SupportedSpineVersion | null {
const version = String(value || '').trim()
return SUPPORTED_SPINE_VERSIONS.find((candidate) => version === candidate || version.startsWith(`${candidate}.`)) || null
}
export function isSupportedSpineVersion(value: unknown): value is SupportedSpineVersion {
return normalizeSpineVersion(value) !== null
}
export function supportedSpineVersionsText() {
return SUPPORTED_SPINE_VERSIONS.join(' / ')
}
+1
View File
@@ -314,6 +314,7 @@ export const useParticleStore = defineStore('particle', {
skeletonFileName: '',
atlasFileName: '',
textureFileNames: [],
dataVersion: '',
sourceBundle: null,
selectedAnimation: '',
selectedBone: '',
+7 -3
View File
@@ -828,6 +828,9 @@
<label class="export-field">
<span>Spine 版本</span>
<select v-model="exportSettings.spineVersion" class="inp export-input">
<option value="3.8">Spine 3.8</option>
<option value="4.0">Spine 4.0</option>
<option value="4.1">Spine 4.1</option>
<option value="4.2">Spine 4.2</option>
</select>
</label>
@@ -840,6 +843,7 @@
</select>
</label>
<div class="export-help">平滑贝塞尔会在误差允许范围内拟合连续运动出生死亡附件切换和突变仍使用阶跃关键帧</div>
<div v-if="exportSettings.spineVersion === '3.8'" class="export-help">Spine 3.8 不支持 4.x 的逐通道数组贝塞尔导出时会转换为经过容差精简的分段线性关键帧</div>
<div class="export-fps-row">
<label class="export-field export-fps-field">
@@ -869,7 +873,7 @@
</template>
</div>
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : '导出可重新导入 Spine 4.2 的 JSON'" @click="exportSpineJson">
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
{{ exportBusy ? '正在准备导出' : '导出Spine Json' }}
</button>
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">{{ exportMessage }}</div>
@@ -1000,10 +1004,10 @@ async function exportSpineJson() {
try {
await prepareExportFrames()
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
validateSpineJson(result.text)
validateSpineJson(result.text, exportSettings.value.spineVersion)
downloadSpineJson(result, exportSettings.value.fileName)
const warning = result.warnings.length ? `${result.warnings.length} 条资源提示(缺图仍已导出)` : ''
exportMessage.value = `已导出 ${result.particleSystemCount} 个粒子系统、${result.animationCount} 个动画、${result.boneCount} 根骨骼${warning}`
exportMessage.value = `已导出 Spine ${exportSettings.value.spineVersion}${result.particleSystemCount} 个粒子系统、${result.animationCount} 个动画、${result.boneCount} 根骨骼${warning}`
} catch (error) {
exportError.value = true
exportMessage.value = error instanceof Error ? error.message : String(error)
+1 -1
View File
@@ -125,7 +125,7 @@
<li><b>创建对象</b>在左侧场景对象中添加粒子碰撞路径或 Spine</li>
<li><b>编辑粒子</b>选中粒子系统依次调整发射形状属性外观资源和修改器</li>
<li><b>查看效果</b>使用底部时间轴播放暂停或拖动到指定帧修改参数后会自动重新计算</li>
<li><b>保存成果</b>配置文件用于继续编辑导出 Spine Json用于导入 Spine 4.2</li>
<li><b>保存成果</b>配置文件用于继续编辑导出 Spine Json支持 Spine 3.84.04.1 4.2</li>
</ol>
</section>