mirror of
https://github.com/EsotericSoftware/spine-runtimes.git
synced 2026-02-09 08:38:43 +08:00
[ts][pixi-v8] Allow to define a bounds providers for the Spine game object. See #2734.
This commit is contained in:
parent
01d676f53e
commit
9fb49c2166
115
spine-ts/spine-pixi-v8/example/bounds.html
Normal file
115
spine-ts/spine-pixi-v8/example/bounds.html
Normal file
@ -0,0 +1,115 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>spine-pixi-v8</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pixi.js@8.4.1/dist/pixi.min.js"></script>
|
||||
<script src="../dist/iife/spine-pixi-v8.js"></script>
|
||||
<link rel="stylesheet" href="../../index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script>
|
||||
(async function () {
|
||||
|
||||
var app = new PIXI.Application();
|
||||
await app.init({
|
||||
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 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({
|
||||
text: texts[i],
|
||||
style: {
|
||||
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().rect(bound.x, bound.y, bound.width, bound.height).stroke({ width: 2, color: 0xfeeb77 }).fill({ color: 0xff0000, alpha: pointerOn[i] ? .2 : 0 });
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -61,6 +61,7 @@ import {
|
||||
SkeletonClipping,
|
||||
SkeletonData,
|
||||
SkeletonJson,
|
||||
Skin,
|
||||
Slot,
|
||||
type TextureAtlas,
|
||||
TrackEntry,
|
||||
@ -89,6 +90,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,
|
||||
};
|
||||
|
||||
const vectorAux = new Vector2();
|
||||
@ -97,6 +101,138 @@ Skeleton.yDown = true;
|
||||
|
||||
const clipper = new SkeletonClipping();
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SpineOptions extends ContainerOptions {
|
||||
/** the {@link SkeletonData} used to instantiate the skeleton */
|
||||
skeletonData: SkeletonData;
|
||||
@ -106,6 +242,9 @@ export interface SpineOptions extends ContainerOptions {
|
||||
|
||||
/** See {@link SpineFromOptions.darkTint}. */
|
||||
darkTint?: boolean;
|
||||
|
||||
/** See {@link SpineFromOptions.boundsProvider}. */
|
||||
boundsProvider?: SpineBoundsProvider,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -229,6 +368,19 @@ export class Spine extends ViewContainer {
|
||||
this._autoUpdate = value;
|
||||
}
|
||||
|
||||
public _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._boundsDirty = false;
|
||||
}
|
||||
this.updateBounds();
|
||||
}
|
||||
|
||||
private hasNeverUpdated = true;
|
||||
constructor (options: SpineOptions | SkeletonData) {
|
||||
if (options instanceof SkeletonData) {
|
||||
@ -255,6 +407,8 @@ export class Spine extends ViewContainer {
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
this.attachmentCacheData[i] = Object.create(null);
|
||||
}
|
||||
|
||||
this._boundsProvider = options.boundsProvider;
|
||||
}
|
||||
|
||||
/** If {@link Spine.autoUpdate} is `false`, this method allows to update the AnimationState and the Skeleton with the given delta. */
|
||||
@ -357,8 +511,6 @@ export class Spine extends ViewContainer {
|
||||
|
||||
this._stateChanged = true;
|
||||
|
||||
this._boundsDirty = true;
|
||||
|
||||
this.onViewUpdate();
|
||||
}
|
||||
|
||||
@ -692,7 +844,9 @@ export class Spine extends ViewContainer {
|
||||
protected onViewUpdate () {
|
||||
// increment from the 12th bit!
|
||||
this._didViewChangeTick++;
|
||||
this._boundsDirty = true;
|
||||
if (!this._boundsProvider) {
|
||||
this._boundsDirty = true;
|
||||
}
|
||||
|
||||
if (this.didViewUpdate) return;
|
||||
this.didViewUpdate = true;
|
||||
@ -806,7 +960,18 @@ export class Spine extends ViewContainer {
|
||||
|
||||
skeletonBounds.update(this.skeleton, true);
|
||||
|
||||
if (skeletonBounds.minX === Infinity) {
|
||||
if (this._boundsProvider) {
|
||||
const boundsSpine = this._boundsProvider.calculateBounds(this);
|
||||
|
||||
const bounds = this._bounds;
|
||||
bounds.clear();
|
||||
|
||||
bounds.x = boundsSpine.x;
|
||||
bounds.y = boundsSpine.y;
|
||||
bounds.width = boundsSpine.width;
|
||||
bounds.height = boundsSpine.height;
|
||||
|
||||
} else if (skeletonBounds.minX === Infinity) {
|
||||
if (this.hasNeverUpdated) {
|
||||
this._updateAndApplyState(0);
|
||||
this._boundsDirty = false;
|
||||
@ -898,11 +1063,16 @@ export class Spine extends ViewContainer {
|
||||
* @param options - Options to configure the Spine game object. See {@link SpineFromOptions}
|
||||
* @returns {Spine} The Spine game object instantiated
|
||||
*/
|
||||
static from ({ skeleton, atlas, scale = 1, darkTint, autoUpdate = true }: SpineFromOptions) {
|
||||
static from ({ skeleton, atlas, scale = 1, darkTint, autoUpdate = true, boundsProvider }: SpineFromOptions) {
|
||||
const cacheKey = `${skeleton}-${atlas}-${scale}`;
|
||||
|
||||
if (Cache.has(cacheKey)) {
|
||||
return new Spine(Cache.get<SkeletonData>(cacheKey));
|
||||
return new Spine({
|
||||
skeletonData: Cache.get<SkeletonData>(cacheKey),
|
||||
darkTint,
|
||||
autoUpdate,
|
||||
boundsProvider,
|
||||
});
|
||||
}
|
||||
|
||||
const skeletonAsset = Assets.get<any | Uint8Array>(skeleton);
|
||||
@ -922,6 +1092,7 @@ export class Spine extends ViewContainer {
|
||||
skeletonData,
|
||||
darkTint,
|
||||
autoUpdate,
|
||||
boundsProvider,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user