支持spine4.3

This commit is contained in:
tianmo
2026-09-02 13:53:12 +08:00
parent 63e4f61631
commit 0656684a92
206 changed files with 100597 additions and 1320 deletions
+52 -18
View File
@@ -1,25 +1,58 @@
import {
BoundingBoxAttachment,
ClippingAttachment,
MeshAttachment,
PathAttachment,
PointAttachment,
RegionAttachment,
SkeletonJson,
type AttachmentLoader,
type Skin,
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'
class ExportValidationAttachmentLoader implements AttachmentLoader {
newRegionAttachment(_skin: Skin, name: string, path: string, _sequence: any) { return new RegionAttachment(name, path) }
newMeshAttachment(_skin: Skin, name: string, path: string, _sequence: any) { return new MeshAttachment(name, path) }
newBoundingBoxAttachment(_skin: Skin, name: string) { return new BoundingBoxAttachment(name) }
newPathAttachment(_skin: Skin, name: string) { return new PathAttachment(name) }
newPointAttachment(_skin: Skin, name: string) { return new PointAttachment(name) }
newClippingAttachment(_skin: Skin, name: string) { return new ClippingAttachment(name) }
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'])
@@ -136,8 +169,9 @@ export function validateSpineJson(text: string, expectedVersion?: SupportedSpine
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)
const data = version === '4.3'
? new SkeletonJson43(new ExportValidationAttachmentLoader43()).readSkeletonData(runtimeRoot)
: new SkeletonJson42(new ExportValidationAttachmentLoader42()).readSkeletonData(runtimeRoot)
if (!data.bones.length) throw new Error('导出结果缺少骨骼')
return { version, bones: data.bones.length, slots: data.slots.length, animations: data.animations.length }
}
+1 -1
View File
@@ -39,7 +39,7 @@
<button class="clear" @click="clearBundle">清除</button>
</div>
<div class="runtime-note">
支持 Spine 3.8 / 4.0 / 4.1 / 4.2 JSON · 4.2.119 预览运行时 · 本地离线
支持 Spine 3.8 / 4.0 / 4.1 / 4.2 / 4.3 JSON · 对应版本运行时按需加载 · 本地离线
<span v-if="spineObject.dataVersion"> · 当前资源 {{ spineObject.dataVersion }}</span>
</div>
<div v-if="spineObject.error" class="error">{{ spineObject.error }}</div>
+16 -13
View File
@@ -1,11 +1,11 @@
import { Container } from 'pixi.js'
import { Spine } from '@esotericsoftware/spine-pixi-v7'
import { getSpineAsset } from './spineAssetRegistry'
import type { SpineSceneObject } from './spineTypes'
interface RuntimeEntry {
root: Container
spine: Spine
spine: any
runtimeVersion: '4.2' | '4.3'
assetVersion: number
animation: string
}
@@ -45,12 +45,13 @@ export class SpineRuntimeLayer {
entry.root.destroy({ children: true })
}
const root = new Container()
const spine = new Spine({ skeletonData: asset.skeletonData, autoUpdate: false, darkTint: false })
const spine = new asset.SpineClass({ skeletonData: asset.skeletonData, autoUpdate: false, darkTint: false })
if (asset.skeletonData.defaultSkin) spine.skeleton.setSkin(asset.skeletonData.defaultSkin)
spine.skeleton.setSlotsToSetupPose()
if (asset.runtimeVersion === '4.3') spine.skeleton.setupPoseSlots()
else spine.skeleton.setSlotsToSetupPose()
root.addChild(spine)
this.container.addChild(root)
entry = { root, spine, assetVersion: object.assetVersion, animation: '' }
entry = { root, spine, runtimeVersion: asset.runtimeVersion, assetVersion: object.assetVersion, animation: '' }
this.entries.set(object.id, entry)
}
if (!entry) continue
@@ -66,12 +67,13 @@ export class SpineRuntimeLayer {
if (entry.animation !== object.selectedAnimation) {
entry.animation = object.selectedAnimation
spine.skeleton.setToSetupPose()
if (entry.runtimeVersion === '4.3') spine.skeleton.setupPose()
else spine.skeleton.setToSetupPose()
spine.state.clearTracks()
// 编辑器时间轴负责整体循环;单个 Spine 动画自身只播放一次并停在末帧。
if (entry.animation) spine.state.setAnimation(0, entry.animation, false)
}
const track = spine.state.getCurrent(0)
const track = entry.runtimeVersion === '4.3' ? spine.state.getTrack(0) : spine.state.getCurrent(0)
if (track) {
const duration = object.animations.find((animation) => animation.name === object.selectedAnimation)?.duration || 0
const localTime = Math.max(0, frame / Math.max(1, fps) - object.timeOffset)
@@ -85,6 +87,7 @@ export class SpineRuntimeLayer {
const entry = this.entries.get(objectId)
const bone = entry?.spine.skeleton.findBone(boneName)
if (!entry || !bone || !bone.active) return null
const pose = entry.runtimeVersion === '4.3' ? bone.appliedPose : bone
// Spine 骨骼矩阵位于 Spine 对象局部空间;与外层 Pixi 容器矩阵组合后,
// 再把屏幕 Y 向下坐标转换为编辑器使用的数学坐标(Y 向上)。
@@ -95,12 +98,12 @@ export class SpineRuntimeLayer {
const rb = -sin * root.scale.y
const rc = sin * root.scale.x
const rd = cos * root.scale.y
const a = ra * bone.a + rb * bone.c
const b = ra * bone.b + rb * bone.d
const c = rc * bone.a + rd * bone.c
const d = rc * bone.b + rd * bone.d
const screenX = root.position.x + ra * bone.worldX + rb * bone.worldY
const screenY = root.position.y + rc * bone.worldX + rd * bone.worldY
const a = ra * pose.a + rb * pose.c
const b = ra * pose.b + rb * pose.d
const c = rc * pose.a + rd * pose.c
const d = rc * pose.b + rd * pose.d
const screenX = root.position.x + ra * pose.worldX + rb * pose.worldY
const screenY = root.position.y + rc * pose.worldX + rd * pose.worldY
return {
x: screenX,
y: -screenY,
+29
View File
@@ -0,0 +1,29 @@
import {
AtlasAttachmentLoader,
SkeletonBinary,
SkeletonJson,
TextureAtlas,
} from '@esotericsoftware/spine-core'
import { Spine, SpineTexture } from '@esotericsoftware/spine-pixi-v7'
import { bindAtlasTextures } from './spineRuntimeTextures'
import type { RuntimeParseResult, SpineTextureMap } from './spineRuntimeTypes'
function atlasLoader(atlasText: string, textures: SpineTextureMap) {
const atlas = new TextureAtlas(atlasText)
bindAtlasTextures(atlas, textures, SpineTexture)
return new AtlasAttachmentLoader(atlas)
}
export function parseJson42(source: any, atlasText: string, textures: SpineTextureMap): RuntimeParseResult {
return {
skeletonData: new SkeletonJson(atlasLoader(atlasText, textures)).readSkeletonData(source),
SpineClass: Spine,
}
}
export function parseBinary42(source: ArrayBuffer, atlasText: string, textures: SpineTextureMap): RuntimeParseResult {
return {
skeletonData: new SkeletonBinary(atlasLoader(atlasText, textures)).readSkeletonData(source),
SpineClass: Spine,
}
}
+30
View File
@@ -0,0 +1,30 @@
import { bindAtlasTextures } from './spineRuntimeTextures'
import type { RuntimeParseResult, SpineTextureMap } from './spineRuntimeTypes'
async function modules() {
const [core, pixi] = await Promise.all([
import('@spine-particle/spine-core-4.3'),
import('@spine-particle/spine-pixi-v7-4.3'),
])
return { core, pixi }
}
export async function parseJson43(source: any, atlasText: string, textures: SpineTextureMap): Promise<RuntimeParseResult> {
const { core, pixi } = await modules()
const atlas = new core.TextureAtlas(atlasText)
bindAtlasTextures(atlas, textures, pixi.SpineTexture)
return {
skeletonData: new core.SkeletonJson(new core.AtlasAttachmentLoader(atlas)).readSkeletonData(source),
SpineClass: pixi.Spine,
}
}
export async function parseBinary43(source: ArrayBuffer, atlasText: string, textures: SpineTextureMap): Promise<RuntimeParseResult> {
const { core, pixi } = await modules()
const atlas = new core.TextureAtlas(atlasText)
bindAtlasTextures(atlas, textures, pixi.SpineTexture)
return {
skeletonData: new core.SkeletonBinary(new core.AtlasAttachmentLoader(atlas)).readSkeletonData(source),
SpineClass: pixi.Spine,
}
}
+15
View File
@@ -0,0 +1,15 @@
import { ALPHA_MODES } from 'pixi.js'
import type { SpineTextureMap } from './spineRuntimeTypes'
function basename(path: string) {
return path.replace(/\\/g, '/').split('/').pop() || path
}
export function bindAtlasTextures(atlas: any, textures: SpineTextureMap, SpineTexture: { from(baseTexture: any): any }) {
for (const page of atlas.pages) {
const texture = textures.get(basename(page.name).toLowerCase())
if (!texture) throw new Error(`Atlas 引用的纹理未选择:${page.name}`)
texture.baseTexture.alphaMode = page.pma ? ALPHA_MODES.PMA : ALPHA_MODES.UNPACK
page.setTexture(SpineTexture.from(texture.baseTexture))
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Texture } from 'pixi.js'
export type SpineRuntimeVersion = '4.2' | '4.3'
export type SpineClass = new (options: any) => any
export type SpineTextureMap = Map<string, Texture>
export interface RuntimeParseResult {
skeletonData: any
SpineClass: SpineClass
}
+47 -34
View File
@@ -1,16 +1,12 @@
import {
AtlasAttachmentLoader,
SkeletonBinary,
SkeletonJson,
TextureAtlas,
type SkeletonData,
} from '@esotericsoftware/spine-core'
import { SpineTexture } from '@esotericsoftware/spine-pixi-v7'
import { ALPHA_MODES, Texture } from 'pixi.js'
import { Texture } from 'pixi.js'
import type { LoadedSpineAsset } from './spineAssetRegistry'
import { parseBinary42, parseJson42 } from './runtime/spineRuntime42'
import { parseBinary43, parseJson43 } from './runtime/spineRuntime43'
import type { SpineAnimationInfo, SpineBoneInfo } from './spineTypes'
import { normalizeSpine38JsonFor42 } from './spine38Compatibility'
import { normalizeSpineVersion, supportedSpineVersionsText, type SupportedSpineVersion } from './spineVersion'
import { normalizeSpineVersion, spineRuntimeVersion, supportedSpineVersionsText, type SupportedSpineVersion } from './spineVersion'
type CompatibleSkeletonData = LoadedSpineAsset['skeletonData']
export interface ParsedSpineBundle extends LoadedSpineAsset {
animations: SpineAnimationInfo[]
@@ -22,12 +18,19 @@ function basename(path: string) {
return path.replace(/\\/g, '/').split('/').pop() || path
}
async function readTexture(file: File) {
const url = URL.createObjectURL(file)
return await Texture.fromURL(url)
async function readTextures(files: File[]) {
const entries = await Promise.all(files.map(async (file) => {
const url = URL.createObjectURL(file)
try {
return [basename(file.name).toLowerCase(), await Texture.fromURL(url)] as const
} finally {
URL.revokeObjectURL(url)
}
}))
return new Map(entries)
}
function boneMetadata(skeletonData: SkeletonData): SpineBoneInfo[] {
function boneMetadata(skeletonData: CompatibleSkeletonData): SpineBoneInfo[] {
const depths = new Map<string, number>()
return skeletonData.bones.map((bone) => {
const parent = bone.parent?.name || null
@@ -42,27 +45,31 @@ export async function parseSpineBundle(skeletonFile: File, atlasFile: File, text
if (!/\.atlas$/i.test(atlasFile.name)) throw new Error('图集文件仅支持 .atlas')
if (!textureFiles.length) throw new Error('请至少选择一张纹理图片')
const atlas = new TextureAtlas(await atlasFile.text())
const fileMap = new Map(textureFiles.map((file) => [basename(file.name).toLowerCase(), file]))
const textures: Texture[] = []
const atlasText = await atlasFile.text()
const texturesByName = await readTextures(textureFiles)
const textures = [...texturesByName.values()]
try {
for (const page of atlas.pages) {
const file = fileMap.get(basename(page.name).toLowerCase())
if (!file) throw new Error(`Atlas 引用的纹理未选择:${page.name}`)
const texture = await readTexture(file)
// 与 Spine 官方 PixiJS Atlas 加载器保持一致。Spine 导出的 Atlas
// 常使用 pma:true;忽略该标记会让骨架数据正常但附件纹理无法正确合成。
texture.baseTexture.alphaMode = page.pma ? ALPHA_MODES.PMA : ALPHA_MODES.UNPACK
textures.push(texture)
page.setTexture(SpineTexture.from(texture.baseTexture))
}
const attachmentLoader = new AtlasAttachmentLoader(atlas)
let skeletonData: SkeletonData
let skeletonData: CompatibleSkeletonData
let SpineClass: new (options: any) => any
let dataVersion: SupportedSpineVersion | null = null
let runtimeVersion: '4.2' | '4.3'
if (/\.skel$/i.test(skeletonFile.name)) {
const reader = new SkeletonBinary(attachmentLoader)
skeletonData = reader.readSkeletonData(await skeletonFile.arrayBuffer())
const source = await skeletonFile.arrayBuffer()
// 二进制版本只能从数据内部读取:先按 4.3 尝试,再回退到完全保留的旧版链路。
try {
;({ skeletonData, SpineClass } = await parseBinary43(source, atlasText, texturesByName))
runtimeVersion = '4.3'
} catch (error43) {
try {
;({ skeletonData, SpineClass } = parseBinary42(source, atlasText, texturesByName))
runtimeVersion = '4.2'
} catch (error42) {
const message43 = error43 instanceof Error ? error43.message : String(error43)
const message42 = error42 instanceof Error ? error42.message : String(error42)
throw new Error(`Spine 二进制解析失败(4.3${message43};旧版:${message42}`)
}
}
dataVersion = normalizeSpineVersion(skeletonData.version)
} else {
let source: any
@@ -73,15 +80,21 @@ export async function parseSpineBundle(skeletonFile: File, atlasFile: File, text
}
dataVersion = normalizeSpineVersion(source?.skeleton?.spine)
if (!dataVersion) throw new Error(`Spine JSON 版本不受支持,仅支持 ${supportedSpineVersionsText()}`)
runtimeVersion = spineRuntimeVersion(dataVersion)
if (dataVersion === '3.8') normalizeSpine38JsonFor42(source)
const reader = new SkeletonJson(attachmentLoader)
skeletonData = reader.readSkeletonData(source)
const parsed = runtimeVersion === '4.3'
? await parseJson43(source, atlasText, texturesByName)
: parseJson42(source, atlasText, texturesByName)
skeletonData = parsed.skeletonData
SpineClass = parsed.SpineClass
}
if (!dataVersion) throw new Error(`Spine 数据版本不受支持,仅支持 ${supportedSpineVersionsText()}`)
return {
skeletonData,
textures,
runtimeVersion,
SpineClass,
dataVersion,
animations: skeletonData.animations.map((animation) => ({ name: animation.name, duration: animation.duration })),
bones: boneMetadata(skeletonData),
+5 -2
View File
@@ -1,8 +1,11 @@
import type { SkeletonData } from '@esotericsoftware/spine-core'
import type { SkeletonData as SkeletonData42 } from '@esotericsoftware/spine-core'
import type { SkeletonData as SkeletonData43 } from '@spine-particle/spine-core-4.3'
import type { Texture } from 'pixi.js'
export interface LoadedSpineAsset {
skeletonData: SkeletonData
skeletonData: SkeletonData42 | SkeletonData43
runtimeVersion: '4.2' | '4.3'
SpineClass: new (options: any) => any
textures: Texture[]
}
+6 -1
View File
@@ -1,4 +1,4 @@
export const SUPPORTED_SPINE_VERSIONS = ['3.8', '4.0', '4.1', '4.2'] as const
export const SUPPORTED_SPINE_VERSIONS = ['3.8', '4.0', '4.1', '4.2', '4.3'] as const
export type SupportedSpineVersion = typeof SUPPORTED_SPINE_VERSIONS[number]
@@ -14,3 +14,8 @@ export function isSupportedSpineVersion(value: unknown): value is SupportedSpine
export function supportedSpineVersionsText() {
return SUPPORTED_SPINE_VERSIONS.join(' / ')
}
/** 4.3 的约束和姿势模型与旧版不兼容,预览时必须分流到对应的官方运行时。 */
export function spineRuntimeVersion(version: SupportedSpineVersion) {
return version === '4.3' ? '4.3' as const : '4.2' as const
}
+1
View File
@@ -832,6 +832,7 @@
<option value="4.0">Spine 4.0</option>
<option value="4.1">Spine 4.1</option>
<option value="4.2">Spine 4.2</option>
<option value="4.3">Spine 4.3</option>
</select>
</label>
+1 -1
View File
@@ -136,7 +136,7 @@
<li><b>创建对象</b>在左侧场景对象中添加粒子碰撞路径或 Spine</li>
<li><b>编辑粒子</b>选中粒子系统依次调整发射形状属性外观资源和修改器</li>
<li><b>查看效果</b>使用底部时间轴播放暂停或拖动到指定帧修改参数后会自动重新计算</li>
<li><b>保存成果</b>配置文件用于继续编辑导出 Spine Json支持 Spine 3.84.04.1 4.2</li>
<li><b>保存成果</b>配置文件用于继续编辑导出 Spine Json支持 Spine 3.84.04.14.2 4.3</li>
</ol>
</section>