mirror of
https://github.com/EsotericSoftware/spine-runtimes.git
synced 2026-02-04 14:24:53 +08:00
452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
/******************************************************************************
|
|
* 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 premultipliedAlpha = false;
|
|
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.premultipliedAlpha = this.plugin.isAtlasPremultiplied(atlasKey);
|
|
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.use();
|
|
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,
|
|
src.premultipliedAlpha,
|
|
-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();
|
|
}
|
|
}
|