180 lines
10 KiB
TypeScript
180 lines
10 KiB
TypeScript
import {
|
|
BoundingBoxAttachment as BoundingBoxAttachment43,
|
|
ClippingAttachment as ClippingAttachment43,
|
|
MeshAttachment as MeshAttachment43,
|
|
PathAttachment as PathAttachment43,
|
|
PointAttachment as PointAttachment43,
|
|
RegionAttachment as RegionAttachment43,
|
|
SkeletonJson as SkeletonJson43,
|
|
TextureRegion as TextureRegion43,
|
|
type AttachmentLoader as AttachmentLoader43,
|
|
type Sequence as Sequence43,
|
|
type Skin as Skin43,
|
|
} from '@spine-particle/spine-core-4.3'
|
|
import {
|
|
BoundingBoxAttachment as BoundingBoxAttachment42,
|
|
ClippingAttachment as ClippingAttachment42,
|
|
MeshAttachment as MeshAttachment42,
|
|
PathAttachment as PathAttachment42,
|
|
PointAttachment as PointAttachment42,
|
|
RegionAttachment as RegionAttachment42,
|
|
SkeletonJson as SkeletonJson42,
|
|
type AttachmentLoader as AttachmentLoader42,
|
|
type Skin as Skin42,
|
|
} from '@esotericsoftware/spine-core'
|
|
import { normalizeSpine38JsonFor42 } from '../spine/spine38Compatibility'
|
|
import { normalizeSpineVersion, supportedSpineVersionsText, type SupportedSpineVersion } from '../spine/spineVersion'
|
|
import type { SpineJsonMap } from './spineJsonTypes'
|
|
import { translate } from '../i18n'
|
|
|
|
class ExportValidationAttachmentLoader42 implements AttachmentLoader42 {
|
|
newRegionAttachment(_skin: Skin42, name: string, path: string, _sequence: any) { return new RegionAttachment42(name, path) }
|
|
newMeshAttachment(_skin: Skin42, name: string, path: string, _sequence: any) { return new MeshAttachment42(name, path) }
|
|
newBoundingBoxAttachment(_skin: Skin42, name: string) { return new BoundingBoxAttachment42(name) }
|
|
newPathAttachment(_skin: Skin42, name: string) { return new PathAttachment42(name) }
|
|
newPointAttachment(_skin: Skin42, name: string) { return new PointAttachment42(name) }
|
|
newClippingAttachment(_skin: Skin42, name: string) { return new ClippingAttachment42(name) }
|
|
}
|
|
|
|
class ExportValidationAttachmentLoader43 implements AttachmentLoader43 {
|
|
private sequence(sequence: Sequence43) {
|
|
for (let index = 0; index < sequence.regions.length; index++) {
|
|
if (sequence.regions[index]) continue
|
|
const region = new TextureRegion43()
|
|
region.u = 0; region.v = 0; region.u2 = 1; region.v2 = 1
|
|
region.width = 1; region.height = 1; region.originalWidth = 1; region.originalHeight = 1
|
|
region.offsetX = 0; region.offsetY = 0; region.degrees = 0
|
|
sequence.regions[index] = region
|
|
}
|
|
return sequence
|
|
}
|
|
newRegionAttachment(_skin: Skin43, _placeholder: string, name: string, _path: string, sequence: Sequence43) { return new RegionAttachment43(name, this.sequence(sequence)) }
|
|
newMeshAttachment(_skin: Skin43, _placeholder: string, name: string, _path: string, sequence: Sequence43) { return new MeshAttachment43(name, this.sequence(sequence)) }
|
|
newBoundingBoxAttachment(_skin: Skin43, _placeholder: string, name: string) { return new BoundingBoxAttachment43(name) }
|
|
newPathAttachment(_skin: Skin43, _placeholder: string, name: string) { return new PathAttachment43(name) }
|
|
newPointAttachment(_skin: Skin43, _placeholder: string, name: string) { return new PointAttachment43(name) }
|
|
newClippingAttachment(_skin: Skin43, _placeholder: string, name: string) { return new ClippingAttachment43(name) }
|
|
}
|
|
|
|
const VALID_INHERIT = new Set(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection'])
|
|
|
|
function validateMesh(mesh: Record<string, any>, attachmentName: string, boneCount: number) {
|
|
const uvs = mesh.uvs
|
|
const vertices = mesh.vertices
|
|
const triangles = mesh.triangles
|
|
if (!Array.isArray(uvs) || !uvs.length || uvs.length % 2) throw new Error(translate('errors.invalidTrailUV', { name: attachmentName }))
|
|
if (!Array.isArray(vertices) || !vertices.length) throw new Error(translate('errors.missingTrailVertices', { name: attachmentName }))
|
|
const vertexCount = uvs.length / 2
|
|
if (!Array.isArray(triangles) || triangles.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount)) {
|
|
throw new Error(translate('errors.trailIndexOutOfRange', { name: attachmentName }))
|
|
}
|
|
const hull = Number(mesh.hull) || 0
|
|
if (!Number.isInteger(hull) || hull < 0 || hull > vertexCount) throw new Error(translate('errors.invalidTrailHull', { name: attachmentName }))
|
|
if (Array.isArray(mesh.edges) && mesh.edges.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount * 2 || index % 2)) {
|
|
throw new Error(translate('errors.trailHullIndexOutOfRange', { name: attachmentName }))
|
|
}
|
|
// 未加权 Mesh 每个顶点只有 x/y;拖尾必须是逐顶点的骨骼权重编码。
|
|
if (vertices.length === uvs.length) return
|
|
let offset = 0
|
|
let decodedVertices = 0
|
|
while (offset < vertices.length) {
|
|
const influences = vertices[offset++]
|
|
if (!Number.isInteger(influences) || influences < 1) throw new Error(translate('errors.invalidInfluenceCount', { name: attachmentName }))
|
|
let weightSum = 0
|
|
for (let influence = 0; influence < influences; influence++) {
|
|
const boneIndex = vertices[offset++]
|
|
const x = vertices[offset++]
|
|
const y = vertices[offset++]
|
|
const weight = vertices[offset++]
|
|
if (!Number.isInteger(boneIndex) || boneIndex < 0 || boneIndex >= boneCount) throw new Error(translate('errors.missingBoneReference', { name: attachmentName }))
|
|
if (![x, y, weight].every(Number.isFinite) || weight < 0) throw new Error(translate('errors.invalidWeight', { name: attachmentName }))
|
|
weightSum += weight
|
|
}
|
|
if (Math.abs(weightSum - 1) > 0.001) throw new Error(translate('errors.weightSum', { name: attachmentName }))
|
|
decodedVertices++
|
|
}
|
|
if (offset !== vertices.length || decodedVertices !== vertexCount) throw new Error(translate('errors.vertexCountMismatch', { name: attachmentName }))
|
|
}
|
|
|
|
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(translate('errors.invalidInherit', { name: bone.name || '', inherit: bone.inherit, version }))
|
|
// 拖尾骨骼命名:系统名_序号_trail_资源id_骨骼序号,必须直接挂在粒子系统骨骼下。
|
|
if (/_trail_\d+_\d+$/.test(String(bone.name || ''))) {
|
|
const parent = boneByName.get(String(bone.parent || ''))
|
|
if (!parent || /_trail_\d+_\d+$/.test(String(parent.name || ''))) {
|
|
throw new Error(translate('errors.trailBoneParent', { name: bone.name || '' }))
|
|
}
|
|
}
|
|
}
|
|
for (const skin of Array.isArray(root.skins) ? root.skins : []) {
|
|
const attachments = skin?.attachments || {}
|
|
for (const slotName of Object.keys(attachments)) {
|
|
for (const attachmentName of Object.keys(attachments[slotName] || {})) {
|
|
const attachment = attachments[slotName][attachmentName]
|
|
if (attachment?.type === 'mesh') {
|
|
validateMesh(attachment, attachmentName, bones.length)
|
|
if (attachment.vertices?.length !== attachment.uvs?.length) weightedMeshSlots.add(slotName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const animation of Object.values(root.animations || {}) as Array<Record<string, any>>) {
|
|
for (const slotName of weightedMeshSlots) {
|
|
const attachmentKeys = animation?.slots?.[slotName]?.attachment
|
|
if (Array.isArray(attachmentKeys) && attachmentKeys.some((key) => !key?.name)) {
|
|
throw new Error(translate('errors.trailSlotEmpty', { name: slotName }))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateSpine38Structure(root: Record<string, any>) {
|
|
if (String(root?.skeleton?.spine || '') !== '3.8') throw new Error(translate('errors.spine38Version'))
|
|
for (const bone of root.bones || []) {
|
|
if (bone.inherit != null) throw new Error(translate('errors.spine38Inherit', { name: bone.name || '' }))
|
|
}
|
|
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(translate('errors.spine38SlotColor'))
|
|
for (const frame of slot.attachment || []) {
|
|
if (!Object.prototype.hasOwnProperty.call(frame, 'name')) throw new Error(translate('errors.spine38AttachmentName'))
|
|
}
|
|
}
|
|
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(translate('errors.spine38RotationAngle'))
|
|
}
|
|
}
|
|
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(translate('errors.spine38Curve'))
|
|
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(translate('errors.unsupportedExportVersion', { versions: supportedSpineVersionsText() }))
|
|
if (expectedVersion && version !== expectedVersion) throw new Error(translate('errors.exportVersionMismatch', { expected: expectedVersion, actual: version }))
|
|
validateEditorImportStructure(root, version)
|
|
if (version === '3.8') validateSpine38Structure(root)
|
|
const runtimeRoot = version === '3.8' ? normalizeSpine38JsonFor42(JSON.parse(JSON.stringify(root))) : root
|
|
const data = version === '4.3'
|
|
? new SkeletonJson43(new ExportValidationAttachmentLoader43()).readSkeletonData(runtimeRoot)
|
|
: new SkeletonJson42(new ExportValidationAttachmentLoader42()).readSkeletonData(runtimeRoot)
|
|
if (!data.bones.length) throw new Error(translate('errors.exportMissingBones'))
|
|
return { version, bones: data.bones.length, slots: data.slots.length, animations: data.animations.length }
|
|
}
|