301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
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<number, RuntimeEntry>()
|
|
|
|
/** 供渲染层级使用:取某个场景 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 })
|
|
}
|
|
}
|