新增spine版本支持
This commit is contained in:
1 parent
8802bbe25e
commit
19278dac01
91 files changed
+69095
-5744
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user