Explorar o código

[ts][pixi-v7] Allow to define a bounds providers for the Spine game object. See #2734.

Davide Tantillo hai 7 meses
pai
achega
f8bcbb3e59

+ 2 - 0
spine-ts/index.html

@@ -53,6 +53,7 @@
         <li><a href="/spine-pixi-v7/example/physics3.html">Physics III</a></li>
         <li><a href="/spine-pixi-v7/example/physics3.html">Physics III</a></li>
         <li><a href="/spine-pixi-v7/example/physics4.html">Physics IV</a></li>
         <li><a href="/spine-pixi-v7/example/physics4.html">Physics IV</a></li>
         <li><a href="/spine-pixi-v7/example/slot-objects.html">Slot Objects</a></li>
         <li><a href="/spine-pixi-v7/example/slot-objects.html">Slot Objects</a></li>
+        <li><a href="/spine-pixi-v7/example/bounds.html">Bounds</a></li>
         <li><a href="/spine-pixi-v7/example/bunnymark.html?count=500">Bunny Mark</a></li>
         <li><a href="/spine-pixi-v7/example/bunnymark.html?count=500">Bunny Mark</a></li>
       </ul>
       </ul>
       <li>PixiJS v8</li>
       <li>PixiJS v8</li>
@@ -79,6 +80,7 @@
         <li><a href="/spine-pixi-v8/example/physics3.html">Physics III</a></li>
         <li><a href="/spine-pixi-v8/example/physics3.html">Physics III</a></li>
         <li><a href="/spine-pixi-v8/example/physics4.html">Physics IV</a></li>
         <li><a href="/spine-pixi-v8/example/physics4.html">Physics IV</a></li>
         <li><a href="/spine-pixi-v8/example/slot-objects.html">Slot Objects</a></li>
         <li><a href="/spine-pixi-v8/example/slot-objects.html">Slot Objects</a></li>
+        <li><a href="/spine-pixi-v8/example/bounds.html">Bounds</a></li>
         <li><a href="/spine-pixi-v8/example/bunnymark.html?count=500&renderer=webgpu">Bunny Mark</a></li>
         <li><a href="/spine-pixi-v8/example/bunnymark.html?count=500&renderer=webgpu">Bunny Mark</a></li>
       </ul>
       </ul>
       <li>Phaser</li>
       <li>Phaser</li>

+ 12 - 0
spine-ts/package-lock.json

@@ -155,6 +155,17 @@
         "@pixi/core": "7.4.2"
         "@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": {
     "node_modules/@pixi/extensions": {
       "version": "7.4.2",
       "version": "7.4.2",
       "license": "MIT",
       "license": "MIT",
@@ -3181,6 +3192,7 @@
         "@pixi/assets": "^7.2.4",
         "@pixi/assets": "^7.2.4",
         "@pixi/core": "^7.2.4",
         "@pixi/core": "^7.2.4",
         "@pixi/display": "^7.2.4",
         "@pixi/display": "^7.2.4",
+        "@pixi/events": "^7.2.4",
         "@pixi/graphics": "^7.2.4",
         "@pixi/graphics": "^7.2.4",
         "@pixi/mesh": "^7.2.4",
         "@pixi/mesh": "^7.2.4",
         "@pixi/text": "^7.2.4"
         "@pixi/text": "^7.2.4"

+ 122 - 0
spine-ts/spine-pixi-v7/example/bounds.html

@@ -0,0 +1,122 @@
+<html>
+  <head>
+    <meta charset="UTF-8" />
+    <title>spine-pixi-v7</title>
+    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/pixi.min.js"></script>
+    <script src="../dist/iife/spine-pixi-v7.js"></script>
+    <link rel="stylesheet" href="../../index.css">
+  </head>
+
+  <body>
+    <script>
+      (async function () {
+
+        var app = new PIXI.Application({
+          width: window.innerWidth,
+          height: window.innerHeight,
+          resolution: window.devicePixelRatio || 1,
+          autoDensity: true,
+          resizeTo: window,
+          backgroundColor: 0x2c3e50,
+          hello: true,
+        });
+        document.body.appendChild(app.view);
+
+        // Pre-load the skeleton data and atlas. You can also load .json skeleton data.
+        PIXI.Assets.add({alias: "spineboyData", src: "./assets/spineboy-pro.skel"});
+        PIXI.Assets.add({alias: "spineboyAtlas", src: "./assets/spineboy-pma.atlas"});
+        await PIXI.Assets.load(["spineboyData", "spineboyAtlas"]);
+
+
+        // Create the spine display object
+        const spineboy1 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2 });
+
+        const spineboy2 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2,
+          boundsProvider: new spine.SetupPoseBoundsProvider(),
+        });
+
+        const spineboy3 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2,
+          boundsProvider: new spine.SkinsAndAnimationBoundsProvider("portal", undefined, undefined, false),
+        });
+
+        const spineboy4 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2,
+          boundsProvider: new spine.SkinsAndAnimationBoundsProvider("portal", undefined, undefined, true),
+        });
+
+        const spineboy5 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2,
+          boundsProvider: new spine.AABBRectangleBoundsProvider(-100, -100, 100, 100),
+        });
+
+        const maxHeight = spineboy3.getBounds().height;
+        const scaleFactor = 1 / (maxHeight * 5 / window.innerHeight);
+        const scaledMaxHeight = maxHeight * scaleFactor;
+
+        const texts = [
+          "Default bounds: dynamic, recomputed when queried",
+          "Set up pose bound: fixed, based on setup pose",
+          "Skin and animations based bound: fixed, the max AABB rectangle containing the skeleton with the given skin and given animations (clipping is ignored)",
+          "Skin and animations based bound: same as above, but with clipping true. The bounds is smaller because clipped attachments' parts are not considered",
+          "AABB Rectangle bounds: fixed, manually provided bounds. The origin is in skeleton root and size are in skeleton space",
+        ]
+
+        const pointerOn = [];
+
+        const elements = [spineboy1, spineboy2, spineboy3, spineboy4, spineboy5].map((spineboy, i) => {
+        // const elements = [spineboy1].map((spineboy, i) => {
+
+          spineboy.x = window.innerWidth / 2;
+          spineboy.y = window.innerHeight / 2 + spineboy.getBounds().height / 2;
+
+          const x = 300 * scaleFactor;
+
+          // spineboy placement
+          spineboy.scale.set(scaleFactor);
+          spineboy.state.setAnimation(0, "portal", true);
+          spineboy.x = x;
+          spineboy.y = 70 * scaleFactor + (window.innerHeight / 10 * (1 + 2*i));
+
+          app.stage.addChild(spineboy);
+
+          // yellow rectangle to show bounds
+          const graphics = new PIXI.Graphics();
+          app.stage.addChild(graphics);
+
+          // text
+          const basicText = new PIXI.Text(
+            texts[i],
+            {
+              fontSize: 20 * scaleFactor,
+              fill: "white",
+              wordWrap: true,
+              wordWrapWidth: 400 * scaleFactor,
+            }
+          );
+          basicText.x = x + scaledMaxHeight + 0 * scaleFactor;
+          basicText.y = scaledMaxHeight * (i + .5);
+          basicText.anchor.set(0, 0.5);
+          app.stage.addChild(basicText);
+
+          // pointer events
+          spineboy.eventMode = "static";
+          spineboy.cursor = "pointer";
+          spineboy.on("pointerenter", () => pointerOn[i] = true);
+          spineboy.on("pointerleave", () => pointerOn[i] = false);
+
+          return [spineboy, graphics];
+        })
+
+        app.ticker.add((delta) => {
+          elements.forEach(([spineboy, graphic], i) => {
+            const bound = spineboy.getBounds();
+            graphic.clear();
+            graphic.lineStyle(2, 0xfeeb77);
+            graphic.beginFill(0xff0000, pointerOn[i] ? .2 : 0);
+            graphic.drawRect(bound.x, bound.y, bound.width, bound.height);
+            graphic.endFill();
+          })
+        })
+
+      })();
+    </script>
+  </body>
+</html>

+ 2 - 1
spine-ts/spine-pixi-v7/package.json

@@ -39,6 +39,7 @@
     "@pixi/graphics": "^7.2.4",
     "@pixi/graphics": "^7.2.4",
     "@pixi/text": "^7.2.4",
     "@pixi/text": "^7.2.4",
     "@pixi/assets": "^7.2.4",
     "@pixi/assets": "^7.2.4",
-    "@pixi/mesh": "^7.2.4"
+    "@pixi/mesh": "^7.2.4",
+    "@pixi/events": "^7.2.4"
   }
   }
 }
 }

+ 221 - 13
spine-ts/spine-pixi-v7/src/Spine.ts

@@ -43,6 +43,7 @@ import {
 	SkeletonClipping,
 	SkeletonClipping,
 	SkeletonData,
 	SkeletonData,
 	SkeletonJson,
 	SkeletonJson,
+	Skin,
 	Utils,
 	Utils,
 	Vector2,
 	Vector2,
 } from "@esotericsoftware/spine-core";
 } from "@esotericsoftware/spine-core";
@@ -51,11 +52,12 @@ import { SlotMesh } from "./SlotMesh.js";
 import { DarkSlotMesh } from "./DarkSlotMesh.js";
 import { DarkSlotMesh } from "./DarkSlotMesh.js";
 import type { ISpineDebugRenderer, SpineDebugRenderer } from "./SpineDebugRenderer.js";
 import type { ISpineDebugRenderer, SpineDebugRenderer } from "./SpineDebugRenderer.js";
 import { Assets } from "@pixi/assets";
 import { Assets } from "@pixi/assets";
-import type { IPointData } from "@pixi/core";
+import { IPointData, Point, Rectangle } from "@pixi/core";
 import { Ticker } from "@pixi/core";
 import { Ticker } from "@pixi/core";
 import type { IDestroyOptions, DisplayObject } from "@pixi/display";
 import type { IDestroyOptions, DisplayObject } from "@pixi/display";
-import { Container } from "@pixi/display";
+import { Bounds, Container } from "@pixi/display";
 import { Graphics } from "@pixi/graphics";
 import { Graphics } from "@pixi/graphics";
+import "@pixi/events";
 
 
 /**
 /**
  * @deprecated Use SpineFromOptions and SpineOptions.
  * @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
 	 * If `undefined`, use the dark tint renderer if at least one slot has tint black
 	 */
 	 */
 	darkTint?: boolean;
 	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 {
 export interface SpineOptions {
@@ -108,6 +113,9 @@ export interface SpineOptions {
 
 
 	/**  See {@link SpineFromOptions.darkTint}. */
 	/**  See {@link SpineFromOptions.darkTint}. */
 	darkTint?: boolean;
 	darkTint?: boolean;
+
+	/**  See {@link SpineFromOptions.boundsProvider}. */
+	boundsProvider?: SpineBoundsProvider,
 }
 }
 
 
 /**
 /**
@@ -122,6 +130,138 @@ export interface SpineEvents {
 	start: [trackEntry: TrackEntry];
 	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 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.
  * 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 darkColor = new Color();
 	private clippingVertAux = new Float32Array(6);
 	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) {
 	constructor (options: SpineOptions | SkeletonData, oldOptions?: ISpineOptions) {
 		if (options instanceof SkeletonData) {
 		if (options instanceof SkeletonData) {
 			options = {
 			options = {
@@ -215,6 +376,8 @@ export class Spine extends Container {
 		}
 		}
 
 
 		this.autoUpdate = options?.autoUpdate ?? true;
 		this.autoUpdate = options?.autoUpdate ?? true;
+
+		this.boundsProvider = options.boundsProvider;
 	}
 	}
 
 
 	/*
 	/*
@@ -615,6 +778,52 @@ export class Spine extends Container {
 		Spine.clipper.clipEnd();
 		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}.
 	 * 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
 	 * @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);
 			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}`;
 		const cacheKey = `${skeleton}-${atlas}-${scale}`;
 		let skeletonData = Spine.skeletonCache[cacheKey];
 		let skeletonData = Spine.skeletonCache[cacheKey];
-		if (skeletonData) {
-			return new Spine({ skeletonData, darkTint, autoUpdate });
+		if (!skeletonData) {
+			const skeletonAsset = Assets.get<any | Uint8Array>(skeleton);
+			const atlasAsset = Assets.get<TextureAtlas>(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<any | Uint8Array>(skeleton);
-		const atlasAsset = Assets.get<TextureAtlas>(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 });
 	}
 	}
 
 
 
 

+ 1 - 1
spine-ts/spine-pixi-v8/src/Spine.ts

@@ -368,7 +368,7 @@ export class Spine extends ViewContainer {
 		this._autoUpdate = value;
 		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. */
 	/** 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 {
 	public get boundsProvider (): SpineBoundsProvider | undefined {
 		return this._boundsProvider;
 		return this._boundsProvider;