/****************************************************************************** * Spine Runtimes License Agreement * Last updated April 5, 2025. Replaces all prior versions. * * Copyright (c) 2013-2025, Esoteric Software LLC * * Integration of the Spine Runtimes into software or otherwise creating * derivative works of the Spine Runtimes is permitted under the terms and * conditions of Section 2 of the Spine Editor License Agreement: * http://esotericsoftware.com/spine-editor-license * * Otherwise, it is permitted to integrate the Spine Runtimes into software * or otherwise create derivative works of the Spine Runtimes (collectively, * "Products"), provided that each user of the Products must obtain their own * Spine Editor license and redistribution of the Products in any form must * include this license and copyright notice. * * THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, * BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF * THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *****************************************************************************/ import { AnimationState, AnimationStateData, type Bone, MathUtils, Physics, Skeleton, SkeletonClipping, Skin, type Vector2, } from "@esotericsoftware/spine-core"; import { SPINE_GAME_OBJECT_TYPE } from "./keys.js"; import { AlphaMixin, ComputedSizeMixin, DepthMixin, FlipMixin, OriginMixin, ScrollFactorMixin, TransformMixin, VisibleMixin, } from "./mixins.js"; import type { SpinePlugin } from "./SpinePlugin.js"; class BaseSpineGameObject extends Phaser.GameObjects.GameObject { constructor (scene: Phaser.Scene, type: string) { super(scene, type); } } /** A bounds provider calculates the bounding box for a skeleton, which is then assigned as the size of the SpineGameObject. */ export interface SpineGameObjectBoundsProvider { // Returns the bounding box for the skeleton, in skeleton space. calculateBounds (gameObject: SpineGameObject): { x: number; y: number; width: number; height: number; }; } /** A bounds provider that provides a fixed size given by the user. */ export class AABBRectangleBoundsProvider implements SpineGameObjectBoundsProvider { constructor ( private x: number, private y: number, private width: number, private height: number, ) { } calculateBounds () { return { x: this.x, y: this.y, width: this.width, height: this.height }; } } /** A bounds provider that calculates the bounding box from the setup pose. */ export class SetupPoseBoundsProvider implements SpineGameObjectBoundsProvider { /** * @param clipping If true, clipping attachments are used to compute the bounds. False, by default. */ constructor ( private clipping = false, ) { } calculateBounds (gameObject: SpineGameObject) { if (!gameObject.skeleton) return { x: 0, y: 0, width: 0, height: 0 }; // Make a copy of animation state and skeleton as this might be called while // the skeleton in the GameObject has already been heavily modified. We can not // reconstruct that state. const skeleton = new Skeleton(gameObject.skeleton.data); skeleton.setupPose(); skeleton.updateWorldTransform(Physics.update); const bounds = skeleton.getBoundsRect(this.clipping ? new SkeletonClipping() : undefined); return bounds.width === Number.NEGATIVE_INFINITY ? { x: 0, y: 0, width: 0, height: 0 } : bounds; } } /** A bounds provider that calculates the bounding box by taking the maximumg bounding box for a combination of skins and specific animation. */ export class SkinsAndAnimationBoundsProvider implements SpineGameObjectBoundsProvider { /** * @param animation The animation to use for calculating the bounds. If null, the setup pose is used. * @param skins The skins to use for calculating the bounds. If empty, the default skin is used. * @param timeStep The time step to use for calculating the bounds. A smaller time step means more precision, but slower calculation. * @param clipping If true, clipping attachments are used to compute the bounds. False, by default. */ constructor ( private animation: string | null, private skins: string[] = [], private timeStep: number = 0.05, private clipping = false, ) { } calculateBounds (gameObject: SpineGameObject): { x: number; y: number; width: number; height: number; } { if (!gameObject.skeleton || !gameObject.animationState) return { x: 0, y: 0, width: 0, height: 0 }; // Make a copy of animation state and skeleton as this might be called while // the skeleton in the GameObject has already been heavily modified. We can not // reconstruct that state. const animationState = new AnimationState(gameObject.animationState.data); const skeleton = new Skeleton(gameObject.skeleton.data); const clipper = this.clipping ? new SkeletonClipping() : undefined; const data = skeleton.data; if (this.skins.length > 0) { const customSkin = new Skin("custom-skin"); for (const skinName of this.skins) { const skin = data.findSkin(skinName); if (skin == null) continue; customSkin.addSkin(skin); } skeleton.setSkin(customSkin); } skeleton.setupPose(); const animation = this.animation != null ? data.findAnimation(this.animation) : null; if (animation == null) { skeleton.updateWorldTransform(Physics.update); const bounds = skeleton.getBoundsRect(clipper); return bounds.width === Number.NEGATIVE_INFINITY ? { x: 0, y: 0, width: 0, height: 0 } : bounds; } else { let minX = Number.POSITIVE_INFINITY, minY = Number.POSITIVE_INFINITY, maxX = Number.NEGATIVE_INFINITY, maxY = Number.NEGATIVE_INFINITY; animationState.clearTracks(); animationState.setAnimation(0, animation, false); const steps = Math.max(animation.duration / this.timeStep, 1.0); for (let i = 0; i < steps; i++) { const delta = i > 0 ? this.timeStep : 0; animationState.update(delta); animationState.apply(skeleton); skeleton.update(delta); skeleton.updateWorldTransform(Physics.update); const bounds = skeleton.getBoundsRect(clipper); minX = Math.min(minX, bounds.x); minY = Math.min(minY, bounds.y); maxX = Math.max(maxX, bounds.x + bounds.width); maxY = Math.max(maxY, bounds.y + bounds.height); } const bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }; return bounds.width === Number.NEGATIVE_INFINITY ? { x: 0, y: 0, width: 0, height: 0 } : bounds; } } } /** * A SpineGameObject is a Phaser {@link GameObject} that can be added to a Phaser Scene and render a Spine skeleton. * * The Spine GameObject is a thin wrapper around a Spine {@link Skeleton}, {@link AnimationState} and {@link AnimationStateData}. It is responsible for: * - updating the animation state * - applying the animation state to the skeleton's bones, slots, attachments, and draw order. * - updating the skeleton's bone world transforms * - rendering the skeleton * * See the {@link SpinePlugin} class for more information on how to create a `SpineGameObject`. * * The skeleton, animation state, and animation state data can be accessed via the repsective fields. They can be manually updated via {@link updatePose}. * * To modify the bone hierarchy before the world transforms are computed, a callback can be set via the {@link beforeUpdateWorldTransforms} field. * * To modify the bone hierarchy after the world transforms are computed, a callback can be set via the {@link afterUpdateWorldTransforms} field. * * The class also features methods to convert between the skeleton coordinate system and the Phaser coordinate system. * * See {@link skeletonToPhaserWorldCoordinates}, {@link phaserWorldCoordinatesToSkeleton}, and {@link phaserWorldCoordinatesToBoneLocal.} */ export class SpineGameObject extends DepthMixin( OriginMixin( ComputedSizeMixin( FlipMixin( ScrollFactorMixin( TransformMixin(VisibleMixin(AlphaMixin(BaseSpineGameObject))) ) ) ) ) ) { blendMode = -1; skeleton: Skeleton; animationStateData: AnimationStateData; animationState: AnimationState; beforeUpdateWorldTransforms: (object: SpineGameObject) => void = () => { }; afterUpdateWorldTransforms: (object: SpineGameObject) => void = () => { }; private offsetX = 0; private offsetY = 0; constructor ( scene: Phaser.Scene, private plugin: SpinePlugin, x: number, y: number, dataKey: string, atlasKey: string, public boundsProvider: SpineGameObjectBoundsProvider = new SetupPoseBoundsProvider() ) { // biome-ignore lint/suspicious/noExplicitAny: necessary for phaser super(scene, (window as any).SPINE_GAME_OBJECT_TYPE ? (window as any).SPINE_GAME_OBJECT_TYPE : SPINE_GAME_OBJECT_TYPE); this.setPosition(x, y); this.skeleton = this.plugin.createSkeleton(dataKey, atlasKey); this.animationStateData = new AnimationStateData(this.skeleton.data); this.animationState = new AnimationState(this.animationStateData); this.skeleton.updateWorldTransform(Physics.update); this.updateSize(); } updateSize () { if (!this.skeleton) return; const bounds = this.boundsProvider.calculateBounds(this); this.width = bounds.width; this.height = bounds.height; this.setDisplayOrigin(-bounds.x, -bounds.y); this.offsetX = -bounds.x; this.offsetY = -bounds.y; } /** Converts a point from the skeleton coordinate system to the Phaser world coordinate system. */ skeletonToPhaserWorldCoordinates (point: { x: number; y: number }) { const transform = this.getWorldTransformMatrix(); const a = transform.a, b = transform.b, c = transform.c, d = transform.d, tx = transform.tx, ty = transform.ty; const x = point.x; const y = point.y; point.x = x * a + y * c + tx; point.y = x * b + y * d + ty; } /** Converts a point from the Phaser world coordinate system to the skeleton coordinate system. */ phaserWorldCoordinatesToSkeleton (point: { x: number; y: number }) { let transform = this.getWorldTransformMatrix(); transform = transform.invert(); const a = transform.a, b = transform.b, c = transform.c, d = transform.d, tx = transform.tx, ty = transform.ty; const x = point.x; const y = point.y; point.x = x * a + y * c + tx; point.y = x * b + y * d + ty; } /** Converts a point from the Phaser world coordinate system to the bone's local coordinate system. */ phaserWorldCoordinatesToBone (point: { x: number; y: number }, bone: Bone) { this.phaserWorldCoordinatesToSkeleton(point); if (bone.parent) { bone.parent.applied.worldToLocal(point as Vector2); } else { bone.applied.worldToLocal(point as Vector2); } } /** * Updates the {@link AnimationState}, applies it to the {@link Skeleton}, then updates the world transforms of all bones. * @param delta The time delta in milliseconds */ updatePose (delta: number) { this.animationState.update(delta / 1000); this.animationState.apply(this.skeleton); this.beforeUpdateWorldTransforms(this); this.skeleton.update(delta / 1000); this.skeleton.updateWorldTransform(Physics.update); this.afterUpdateWorldTransforms(this); } preUpdate (time: number, delta: number) { if (!this.skeleton || !this.animationState) return; this.updatePose(delta); } preDestroy () { // FIXME tear down any event emitters } willRender (camera: Phaser.Cameras.Scene2D.Camera) { const GameObjectRenderMask = 0xf; let result = !this.skeleton || !(GameObjectRenderMask !== this.renderFlags || (this.cameraFilter !== 0 && this.cameraFilter & camera.id)); if (!this.visible) result = false; if (!result && this.parentContainer && this.plugin.webGLRenderer) { const sceneRenderer = this.plugin.webGLRenderer; if (this.plugin.gl && this.plugin.phaserRenderer instanceof Phaser.Renderer.WebGL.WebGLRenderer && sceneRenderer.batcher.isDrawing) { sceneRenderer.end(); this.plugin.phaserRenderer.renderNodes.getNode("RebindContext")?.run(); } } return result; } renderWebGL ( renderer: Phaser.Renderer.WebGL.WebGLRenderer, src: SpineGameObject, drawingContext: Phaser.Renderer.WebGL.DrawingContext, parentMatrix: Phaser.GameObjects.Components.TransformMatrix, renderStep: number, displayList: Phaser.GameObjects.GameObject[], displayListIndex: number ) { const camera = drawingContext.camera; if (!camera || !src.skeleton || !src.animationState || !src.plugin.webGLRenderer) return; const sceneRenderer = src.plugin.webGLRenderer; // Determine object type in context. const previousGameObject = displayList[displayListIndex - 1]; const nextGameObject = displayList[displayListIndex + 1]; const newType = !previousGameObject || previousGameObject.type !== src.type; const nextTypeMatch = nextGameObject && nextGameObject.type === src.type; if (newType) { // Ensure framebuffer is properly set up. if (drawingContext.renderer.renderNodes.currentBatchDrawingContext !== drawingContext) { drawingContext.renderer.renderNodes.finishBatch(); drawingContext.beginDraw(); } // Yield Phaser context. renderer.renderNodes.getNode('YieldContext')?.run(drawingContext); // Enter Spine renderer. sceneRenderer.begin(); } camera.addToRenderList(src); const transform = Phaser.GameObjects.GetCalcMatrix( src, camera, parentMatrix ).calc; const a = transform.a, b = transform.b, c = transform.c, d = transform.d, tx = transform.tx, ty = transform.ty; const offsetX = src.offsetX - src.displayOriginX; const offsetY = src.offsetY - src.displayOriginY; sceneRenderer.drawSkeleton( src.skeleton, -1, -1, (vertices, numVertices, stride) => { for (let i = 0; i < numVertices; i += stride) { const vx = vertices[i] + offsetX; const vy = vertices[i + 1] + offsetY; vertices[i] = vx * a + vy * c + tx; vertices[i + 1] = vx * b + vy * d + ty; } } ); if (!nextTypeMatch) { // Exit Spine renderer. sceneRenderer.end(); // Rebind Phaser state. renderer.renderNodes.getNode('RebindContext')?.run(drawingContext); } } renderCanvas ( renderer: Phaser.Renderer.Canvas.CanvasRenderer, src: SpineGameObject, camera: Phaser.Cameras.Scene2D.Camera, parentMatrix: Phaser.GameObjects.Components.TransformMatrix ) { if (!this.skeleton || !this.animationState || !this.plugin.canvasRenderer) return; const context = renderer.currentContext; const skeletonRenderer = this.plugin.canvasRenderer; // biome-ignore lint/suspicious/noExplicitAny: necessary for phaser (skeletonRenderer as any).ctx = context; camera.addToRenderList(src); const transform = Phaser.GameObjects.GetCalcMatrix( src, camera, parentMatrix ).calc; const skeleton = this.skeleton; skeleton.x = transform.tx; skeleton.y = transform.ty; skeleton.scaleX = transform.scaleX; skeleton.scaleY = transform.scaleY; const root = skeleton.getRootBone() as Bone; root.applied.rotation = -MathUtils.radiansToDegrees * transform.rotationNormalized; this.skeleton.updateWorldTransform(Physics.update); context.save(); skeletonRenderer.draw(skeleton); context.restore(); } }