新增spine版本支持
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' / ')
|
||||
}
|
||||
@@ -314,6 +314,7 @@ export const useParticleStore = defineStore('particle', {
|
||||
skeletonFileName: '',
|
||||
atlasFileName: '',
|
||||
textureFileNames: [],
|
||||
dataVersion: '',
|
||||
sourceBundle: null,
|
||||
selectedAnimation: '',
|
||||
selectedBone: '',
|
||||
|
||||
@@ -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
@@ -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.8、4.0、4.1 和 4.2。</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user