import { Container, Graphics, Matrix } from 'pixi.js' import { getSpineAsset } from './spineAssetRegistry' import type { SpineSceneObject } from './spineTypes' interface RuntimeEntry { root: Container spine: any skeletonDebug: Graphics skinMeshDebug: Graphics runtimeVersion: '4.2' | '4.3' assetVersion: number animation: string } function attachmentDebugColor(key: string) { let hash = 2166136261 for (let i = 0; i < key.length; i++) hash = Math.imul(hash ^ key.charCodeAt(i), 16777619) const hue = (hash >>> 0) % 360 const segment = hue / 60 const chroma = 0.78 const x = chroma * (1 - Math.abs(segment % 2 - 1)) const offset = 1 - chroma const [red, green, blue] = segment < 1 ? [chroma, x, 0] : segment < 2 ? [x, chroma, 0] : segment < 3 ? [0, chroma, x] : segment < 4 ? [0, x, chroma] : segment < 5 ? [x, 0, chroma] : [chroma, 0, x] return (Math.round((red + offset) * 255) << 16) | (Math.round((green + offset) * 255) << 8) | Math.round((blue + offset) * 255) } export interface SpineBoneWorldTransform { x: number y: number rotation: number scaleX: number scaleY: number } export class SpineRuntimeLayer { readonly container = new Container() private entries = new Map() /** 供渲染层级使用:取某个场景 Spine 的运行时实例。 */ getSpine(objectId: number) { return this.entries.get(objectId)?.spine || null } /** 当前帧的绘制顺序(会跟随动画里的 drawOrder timeline)。 */ getDrawOrder(objectId: number): string[] | null { const spine = this.entries.get(objectId)?.spine try { const drawOrder = spine?.skeleton?.drawOrder return Array.isArray(drawOrder) ? drawOrder.map((slot: any) => String(slot?.data?.name || '')) : null } catch { return null } } /** 插槽 wrapper 相对 Spine 的局部矩阵(与 SpinePixi updateSlotObject 保持一致)。 */ getSlotLocalMatrix(objectId: number, slotName: string): Matrix | null { const spine = this.entries.get(objectId)?.spine const slot = spine?.skeleton?.findSlot?.(slotName) const bone = slot?.bone if (!bone?.active) return null return new Matrix(bone.a, bone.c, -bone.b, -bone.d, bone.worldX, bone.worldY) } /** * 插槽 wrapper 在编辑器 world 空间中的父矩阵 = 场景对象 root 变换 × 插槽骨骼矩阵。 * 粒子发射器挂进插槽后用它做逆变换补偿,保持原有的世界坐标表现。 */ getSlotParentMatrix( objectId: number, slotName: string, root: { x: number; y: number; rotation: number; scaleX: number; scaleY: number }, ): Matrix | null { const local = this.getSlotLocalMatrix(objectId, slotName) if (!local) return null const radians = -root.rotation * Math.PI / 180 const cos = Math.cos(radians), sin = Math.sin(radians) const rootMatrix = new Matrix( cos * root.scaleX, sin * root.scaleX, -sin * root.scaleY, cos * root.scaleY, root.x, -root.y, ) return rootMatrix.append(local) } attachSlotObject(objectId: number, slotName: string, container: Container, followAttachmentTimeline: boolean) { const spine = this.entries.get(objectId)?.spine if (!spine?.addSlotObject) return false try { spine.addSlotObject(slotName, container, { followAttachmentTimeline }) return true } catch { return false } } detachSlotObject(objectId: number, slotName: string, container: Container) { const spine = this.entries.get(objectId)?.spine try { spine?.removeSlotObject?.(slotName, container) } catch { /* 运行时已被释放时忽略 */ } } private destroyEntry(entry: RuntimeEntry) { // 先摘掉渲染层级 wrapper,避免随 root 一起销毁编辑器里的粒子发射器。 try { entry.spine?.removeSlotObjects?.() } catch { /* ignore */ } this.container.removeChild(entry.root) entry.root.destroy({ children: true }) } private drawDebug(entry: RuntimeEntry, object: SpineSceneObject) { const { skeletonDebug, skinMeshDebug, root, spine } = entry skeletonDebug.clear() skinMeshDebug.clear() skeletonDebug.visible = object.showBones skinMeshDebug.visible = object.showOnlySkinMesh || object.showRegionAttachments || object.showClippingAttachments if (!object.showBones && !skinMeshDebug.visible) return const screenScale = Math.max( 0.0001, Math.abs(root.scale.x * (this.container.parent?.scale.x ?? 1)), Math.abs(root.scale.y * (this.container.parent?.scale.y ?? 1)), ) const lineWidth = 1 / screenScale const skeleton = spine.skeleton const offsetX = Number(skeleton.x) || 0 const offsetY = Number(skeleton.y) || 0 if (object.showBones) { skeletonDebug.lineStyle(lineWidth, 0x55d6ff, 0.95) for (const bone of skeleton.bones) { if (!bone.active || !bone.parent?.active) continue skeletonDebug.moveTo(offsetX + bone.parent.worldX, offsetY + bone.parent.worldY) skeletonDebug.lineTo(offsetX + bone.worldX, offsetY + bone.worldY) } skeletonDebug.beginFill(0xb8efff, 0.95) for (const bone of skeleton.bones) { if (bone.active) skeletonDebug.drawCircle(offsetX + bone.worldX, offsetY + bone.worldY, 2 / screenScale) } skeletonDebug.endFill() } if (!object.showOnlySkinMesh && !object.showRegionAttachments && !object.showClippingAttachments) return const activeSlots = skeleton.slots.filter((slot: any) => slot.bone?.active) for (const slot of activeSlots) { const attachment = slot.getAttachment() if (!attachment) continue const color = attachmentDebugColor(`${slot.data.index}:${attachment.name}`) const isMesh = Array.isArray(attachment.triangles) && attachment.worldVerticesLength > 0 if (isMesh) { if (!object.showOnlySkinMesh) continue skinMeshDebug.lineStyle(lineWidth, color, 0.9) skinMeshDebug.beginFill(color, 0.18) const vertices = new Float32Array(attachment.worldVerticesLength) attachment.computeWorldVertices(slot, 0, attachment.worldVerticesLength, vertices, 0, 2) for (let i = 0; i < attachment.triangles.length; i += 3) { const a = attachment.triangles[i] * 2 const b = attachment.triangles[i + 1] * 2 const c = attachment.triangles[i + 2] * 2 skinMeshDebug.drawPolygon([ vertices[a], vertices[a + 1], vertices[b], vertices[b + 1], vertices[c], vertices[c + 1], ]) } skinMeshDebug.endFill() continue } if (!object.showRegionAttachments || !attachment.offset || !Number.isFinite(attachment.width) || !Number.isFinite(attachment.height) || typeof attachment.computeWorldVertices !== 'function') { if (object.showClippingAttachments && attachment.endSlot !== undefined && attachment.worldVerticesLength > 0 && typeof attachment.computeWorldVertices === 'function') { const clippingVertices = new Float32Array(attachment.worldVerticesLength) attachment.computeWorldVertices(slot, 0, attachment.worldVerticesLength, clippingVertices, 0, 2) skinMeshDebug.lineStyle(lineWidth, color, 0.9) skinMeshDebug.drawPolygon(Array.from(clippingVertices)) } continue } const regionVertices = new Float32Array(8) attachment.computeWorldVertices(slot, regionVertices, 0, 2) skinMeshDebug.lineStyle(lineWidth, color, 0.9) skinMeshDebug.drawPolygon(Array.from(regionVertices)) } } sync(objects: SpineSceneObject[], frame: number, fps: number) { for (const [id, entry] of this.entries) { if (objects.some((object) => object.id === id)) continue this.destroyEntry(entry) this.entries.delete(id) } for (const object of objects) { const asset = getSpineAsset(object.id) let entry = this.entries.get(object.id) if (entry && entry.assetVersion !== object.assetVersion && !asset) { this.destroyEntry(entry) this.entries.delete(object.id) entry = undefined } if ((!entry || entry.assetVersion !== object.assetVersion) && asset) { if (entry) this.destroyEntry(entry) const root = new Container() const spine = new asset.SpineClass({ skeletonData: asset.skeletonData, autoUpdate: false, darkTint: false }) const skeletonDebug = new Graphics() const skinMeshDebug = new Graphics() skeletonDebug.eventMode = 'none' skinMeshDebug.eventMode = 'none' skeletonDebug.visible = false skinMeshDebug.visible = false if (asset.skeletonData.defaultSkin) spine.skeleton.setSkin(asset.skeletonData.defaultSkin) if (asset.runtimeVersion === '4.3') spine.skeleton.setupPoseSlots() else spine.skeleton.setSlotsToSetupPose() root.addChild(spine, skeletonDebug, skinMeshDebug) this.container.addChild(root) entry = { root, spine, skeletonDebug, skinMeshDebug, runtimeVersion: asset.runtimeVersion, assetVersion: object.assetVersion, animation: '' } this.entries.set(object.id, entry) } if (!entry) continue const root = entry.root const spine = entry.spine const visible = object.visible !== false && object.enabled root.visible = visible root.renderable = visible root.position.set(object.x, -object.y) root.rotation = -object.rotation * Math.PI / 180 root.scale.set(object.scaleX, object.scaleY) if (entry.animation !== object.selectedAnimation) { entry.animation = object.selectedAnimation 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 = 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) track.trackTime = duration > 0 ? Math.min(localTime, duration) : localTime } spine.update(0) this.drawDebug(entry, object) } } getBoneWorldTransform(objectId: number, boneName: string): SpineBoneWorldTransform | null { 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 向上)。 const root = entry.root const cos = Math.cos(root.rotation) const sin = Math.sin(root.rotation) const ra = cos * root.scale.x const rb = -sin * root.scale.y const rc = sin * root.scale.x const rd = cos * root.scale.y 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, rotation: -Math.atan2(c, a) * 180 / Math.PI, scaleX: Math.max(0.0001, Math.hypot(a, c)), scaleY: Math.max(0.0001, Math.hypot(b, d)), } } destroy() { for (const entry of this.entries.values()) { this.destroyEntry(entry) } this.entries.clear() this.container.removeFromParent() this.container.destroy({ children: true }) } }