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;