Files
SpineParticlesWeb/src/spine/SpineRuntimeLayer.ts
T
2026-11-10 15:50:16 +08:00

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 })
}
}