diff --git a/spine-ts/index.html b/spine-ts/index.html
index 555b4ce4ff..9a774d0696 100644
--- a/spine-ts/index.html
+++ b/spine-ts/index.html
@@ -53,6 +53,7 @@
spine-ts Examples
Physics III
Physics IV
Slot Objects
+ Bounds
Bunny Mark
PixiJS v8
@@ -79,6 +80,7 @@ spine-ts Examples
Physics III
Physics IV
Slot Objects
+ Bounds
Bunny Mark
Phaser
diff --git a/spine-ts/package-lock.json b/spine-ts/package-lock.json
index 615c2dc8a5..a6c7826e8a 100644
--- a/spine-ts/package-lock.json
+++ b/spine-ts/package-lock.json
@@ -155,6 +155,17 @@
"@pixi/core": "7.4.2"
}
},
+ "node_modules/@pixi/events": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/@pixi/events/-/events-7.4.2.tgz",
+ "integrity": "sha512-Jw/w57heZjzZShIXL0bxOvKB+XgGIevyezhGtfF2ZSzQoSBWo+Fj1uE0QwKd0RIaXegZw/DhSmiMJSbNmcjifA==",
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "@pixi/core": "7.4.2",
+ "@pixi/display": "7.4.2"
+ }
+ },
"node_modules/@pixi/extensions": {
"version": "7.4.2",
"license": "MIT",
@@ -3181,6 +3192,7 @@
"@pixi/assets": "^7.2.4",
"@pixi/core": "^7.2.4",
"@pixi/display": "^7.2.4",
+ "@pixi/events": "^7.2.4",
"@pixi/graphics": "^7.2.4",
"@pixi/mesh": "^7.2.4",
"@pixi/text": "^7.2.4"
diff --git a/spine-ts/spine-pixi-v7/example/bounds.html b/spine-ts/spine-pixi-v7/example/bounds.html
new file mode 100644
index 0000000000..092db04784
--- /dev/null
+++ b/spine-ts/spine-pixi-v7/example/bounds.html
@@ -0,0 +1,122 @@
+
+
+
+ spine-pixi-v7
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spine-ts/spine-pixi-v7/package.json b/spine-ts/spine-pixi-v7/package.json
index e938e56d68..9fbded1c77 100644
--- a/spine-ts/spine-pixi-v7/package.json
+++ b/spine-ts/spine-pixi-v7/package.json
@@ -39,6 +39,7 @@
"@pixi/graphics": "^7.2.4",
"@pixi/text": "^7.2.4",
"@pixi/assets": "^7.2.4",
- "@pixi/mesh": "^7.2.4"
+ "@pixi/mesh": "^7.2.4",
+ "@pixi/events": "^7.2.4"
}
}
\ No newline at end of file
diff --git a/spine-ts/spine-pixi-v7/src/Spine.ts b/spine-ts/spine-pixi-v7/src/Spine.ts
index 913637e28b..eb49e192b8 100644
--- a/spine-ts/spine-pixi-v7/src/Spine.ts
+++ b/spine-ts/spine-pixi-v7/src/Spine.ts
@@ -43,6 +43,7 @@ import {
SkeletonClipping,
SkeletonData,
SkeletonJson,
+ Skin,
Utils,
Vector2,
} from "@esotericsoftware/spine-core";
@@ -51,11 +52,12 @@ import { SlotMesh } from "./SlotMesh.js";
import { DarkSlotMesh } from "./DarkSlotMesh.js";
import type { ISpineDebugRenderer, SpineDebugRenderer } from "./SpineDebugRenderer.js";
import { Assets } from "@pixi/assets";
-import type { IPointData } from "@pixi/core";
+import { IPointData, Point, Rectangle } from "@pixi/core";
import { Ticker } from "@pixi/core";
import type { IDestroyOptions, DisplayObject } from "@pixi/display";
-import { Container } from "@pixi/display";
+import { Bounds, Container } from "@pixi/display";
import { Graphics } from "@pixi/graphics";
+import "@pixi/events";
/**
* @deprecated Use SpineFromOptions and SpineOptions.
@@ -97,6 +99,9 @@ export interface SpineFromOptions {
* If `undefined`, use the dark tint renderer if at least one slot has tint black
*/
darkTint?: boolean;
+
+ /** The bounds provider to use. If undefined the bounds will be dynamic, calculated when requested and based on the current frame. */
+ boundsProvider?: SpineBoundsProvider,
};
export interface SpineOptions {
@@ -108,6 +113,9 @@ export interface SpineOptions {
/** See {@link SpineFromOptions.darkTint}. */
darkTint?: boolean;
+
+ /** See {@link SpineFromOptions.boundsProvider}. */
+ boundsProvider?: SpineBoundsProvider,
}
/**
@@ -122,6 +130,138 @@ export interface SpineEvents {
start: [trackEntry: TrackEntry];
}
+/** A bounds provider calculates the bounding box for a skeleton, which is then assigned as the size of the SpineGameObject. */
+export interface SpineBoundsProvider {
+ /** Returns the bounding box for the skeleton, in skeleton space. */
+ calculateBounds (gameObject: Spine): {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ };
+}
+
+/** A bounds provider that provides a fixed size given by the user. */
+export class AABBRectangleBoundsProvider implements SpineBoundsProvider {
+ 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 SpineBoundsProvider {
+ /**
+ * @param clipping If true, clipping attachments are used to compute the bounds. False, by default.
+ */
+ constructor (
+ private clipping = false,
+ ) { }
+
+ calculateBounds (gameObject: Spine) {
+ 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.setToSetupPose();
+ 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 SpineBoundsProvider {
+ /**
+ * @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: Spine): {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ } {
+ if (!gameObject.skeleton || !gameObject.state)
+ 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.state.data);
+ const skeleton = new Skeleton(gameObject.skeleton.data);
+ const clipper = this.clipping ? new SkeletonClipping() : undefined;
+ const data = skeleton.data;
+ if (this.skins.length > 0) {
+ let 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.setToSetupPose();
+
+ 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.setAnimationWith(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;
+ }
+ }
+}
+
/**
* The class to instantiate a {@link Spine} game object in Pixi.
* The static method {@link Spine.from} should be used to instantiate a Spine game object.
@@ -186,6 +326,27 @@ export class Spine extends Container {
private darkColor = new Color();
private clippingVertAux = new Float32Array(6);
+ private _boundsProvider?: SpineBoundsProvider;
+ /** The bounds provider to use. If undefined the bounds will be dynamic, calculated when requested and based on the current frame. */
+ public get boundsProvider (): SpineBoundsProvider | undefined {
+ return this._boundsProvider;
+ }
+ public set boundsProvider (value: SpineBoundsProvider | undefined) {
+ this._boundsProvider = value;
+ if (value) {
+ this._boundsSpineID = -1;
+ this._boundsSpineDirty = true;
+ this.interactiveChildren = false;
+ } else {
+ this.interactiveChildren = true;
+ this.hitArea = null;
+ }
+ this.calculateBounds();
+ }
+ private _boundsPoint = new Point();
+ private _boundsSpineID = -1;
+ private _boundsSpineDirty = true;
+
constructor (options: SpineOptions | SkeletonData, oldOptions?: ISpineOptions) {
if (options instanceof SkeletonData) {
options = {
@@ -215,6 +376,8 @@ export class Spine extends Container {
}
this.autoUpdate = options?.autoUpdate ?? true;
+
+ this.boundsProvider = options.boundsProvider;
}
/*
@@ -615,6 +778,52 @@ export class Spine extends Container {
Spine.clipper.clipEnd();
}
+ calculateBounds () {
+ if (!this._boundsProvider) {
+ super.calculateBounds();
+ return;
+ }
+
+ const transform = this.transform;
+ if (this._boundsSpineID === transform._worldID) return;
+
+ this.updateBounds();
+
+ const bounds = this._localBounds;
+ const p = this._boundsPoint;
+
+ p.set(bounds.minX, bounds.minY);
+ transform.worldTransform.apply(p, p);
+ this._bounds.minX = p.x
+ this._bounds.minY = p.y;
+
+ p.set(bounds.maxX, bounds.maxY)
+ transform.worldTransform.apply(p, p);
+ this._bounds.maxX = p.x
+ this._bounds.maxY = p.y;
+ }
+
+ updateBounds () {
+ if (!this._boundsProvider || !this._boundsSpineDirty) return;
+
+ this._boundsSpineDirty = false;
+
+ if (!this._localBounds) {
+ this._localBounds = new Bounds();
+ }
+
+ const boundsSpine = this._boundsProvider.calculateBounds(this);
+
+ const bounds = this._localBounds;
+ bounds.clear();
+ bounds.minX = boundsSpine.x;
+ bounds.minY = boundsSpine.y;
+ bounds.maxX = boundsSpine.x + boundsSpine.width;
+ bounds.maxY = boundsSpine.y + boundsSpine.height;
+
+ this.hitArea = this._localBounds.getRectangle();
+ }
+
/**
* Set the position of the bone given in input through a {@link IPointData}.
* @param bone: the bone name or the bone instance to set the position
@@ -733,20 +942,19 @@ export class Spine extends Container {
return Spine.oldFrom(paramOne, atlasAssetName!, options);
}
- const { skeleton, atlas, scale = 1, darkTint, autoUpdate } = paramOne;
+ const { skeleton, atlas, scale = 1, darkTint, autoUpdate, boundsProvider } = paramOne;
const cacheKey = `${skeleton}-${atlas}-${scale}`;
let skeletonData = Spine.skeletonCache[cacheKey];
- if (skeletonData) {
- return new Spine({ skeletonData, darkTint, autoUpdate });
+ if (!skeletonData) {
+ const skeletonAsset = Assets.get(skeleton);
+ const atlasAsset = Assets.get(atlas);
+ const attachmentLoader = new AtlasAttachmentLoader(atlasAsset);
+ let parser = skeletonAsset instanceof Uint8Array ? new SkeletonBinary(attachmentLoader) : new SkeletonJson(attachmentLoader);
+ parser.scale = scale;
+ skeletonData = parser.readSkeletonData(skeletonAsset);
+ Spine.skeletonCache[cacheKey] = skeletonData;
}
- const skeletonAsset = Assets.get(skeleton);
- const atlasAsset = Assets.get(atlas);
- const attachmentLoader = new AtlasAttachmentLoader(atlasAsset);
- let parser = skeletonAsset instanceof Uint8Array ? new SkeletonBinary(attachmentLoader) : new SkeletonJson(attachmentLoader);
- parser.scale = scale;
- skeletonData = parser.readSkeletonData(skeletonAsset);
- Spine.skeletonCache[cacheKey] = skeletonData;
- return new Spine({ skeletonData, darkTint, autoUpdate });
+ return new Spine({ skeletonData, darkTint, autoUpdate, boundsProvider });
}
diff --git a/spine-ts/spine-pixi-v8/src/Spine.ts b/spine-ts/spine-pixi-v8/src/Spine.ts
index c25922b79c..6870300ce0 100644
--- a/spine-ts/spine-pixi-v8/src/Spine.ts
+++ b/spine-ts/spine-pixi-v8/src/Spine.ts
@@ -368,7 +368,7 @@ export class Spine extends ViewContainer {
this._autoUpdate = value;
}
- public _boundsProvider?: SpineBoundsProvider;
+ private _boundsProvider?: SpineBoundsProvider;
/** The bounds provider to use. If undefined the bounds will be dynamic, calculated when requested and based on the current frame. */
public get boundsProvider (): SpineBoundsProvider | undefined {
return this._boundsProvider;