mirror of
https://github.com/EsotericSoftware/spine-runtimes.git
synced 2026-02-04 14:24:53 +08:00
1266 lines
57 KiB
TypeScript
1266 lines
57 KiB
TypeScript
/******************************************************************************
|
|
* Spine Runtimes License Agreement
|
|
* Last updated July 28, 2023. Replaces all prior versions.
|
|
*
|
|
* Copyright (c) 2013-2023, 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 { Animation, AnimationState, AnimationStateData, AtlasAttachmentLoader, Bone, Color, Disposable, Downloader, MathUtils, MixBlend, MixDirection, Physics, Skeleton, SkeletonBinary, SkeletonData, SkeletonJson, StringMap, TextureAtlas, TextureFilter, TimeKeeper, TrackEntry, Vector2 } from "@esotericsoftware/spine-core"
|
|
import { AssetManager, GLTexture, Input, LoadingScreen, ManagedWebGLRenderingContext, ResizeMode, SceneRenderer, Vector3 } from "@esotericsoftware/spine-webgl"
|
|
|
|
export interface SpinePlayerConfig {
|
|
/* The URL of the skeleton JSON (.json) or binary (.skel) file */
|
|
skeleton?: string;
|
|
|
|
/* @deprecated Use skeleton instead. The URL of the skeleton JSON file (.json). Undefined if binaryUrl is given. */
|
|
jsonUrl?: string
|
|
|
|
/* Optional: The name of a field in the JSON that holds the skeleton data. Default: none */
|
|
jsonField?: string
|
|
|
|
/* @deprecated Use skeleton instead. The URL of the skeleton binary file (.skel). Undefined if jsonUrl is given. */
|
|
binaryUrl?: string
|
|
|
|
/* The scale when loading the skeleton data. Default: 1 */
|
|
scale?: number
|
|
|
|
/* @deprecated Use atlas instead. The URL of the skeleton atlas file (.atlas). Atlas page images are automatically resolved. */
|
|
atlasUrl?: string
|
|
|
|
/* The URL of the skeleton atlas file (.atlas). Atlas page images are automatically resolved. */
|
|
atlas?: string;
|
|
|
|
/* Raw data URIs, mapping a path to base64 encoded raw data. When player's asset manager resolves the skeleton,
|
|
atlas, or the image paths referenced in the atlas, it will first look for that path in the raw data URIs. This
|
|
allows embedding assets directly in HTML/JS. Default: none */
|
|
rawDataURIs?: StringMap<string>
|
|
|
|
/* Optional: The name of the animation to be played. Default: empty animation */
|
|
animation?: string
|
|
|
|
/* Optional: List of animation names from which the user can choose. Default: all animations */
|
|
animations?: string[]
|
|
|
|
/* Optional: The default mix time used to switch between two animations. Default: 0.25 */
|
|
defaultMix?: number
|
|
|
|
/* Optional: The name of the skin to be set. Default: the default skin */
|
|
skin?: string
|
|
|
|
/* Optional: List of skin names from which the user can choose. Default: all skins */
|
|
skins?: string[]
|
|
|
|
/* Optional: Whether the skeleton's atlas images use premultiplied alpha. Default: true */
|
|
premultipliedAlpha?: boolean
|
|
|
|
/* Optional: Whether to show the player controls. When false, no external CSS file is needed. Default: true */
|
|
showControls?: boolean
|
|
|
|
/* Optional: Whether to show the loading animation. Default: true */
|
|
showLoading?: boolean
|
|
|
|
/* Optional: Which debugging visualizations are shown. Default: none */
|
|
debug?: {
|
|
bones: boolean
|
|
regions: boolean
|
|
meshes: boolean
|
|
bounds: boolean
|
|
paths: boolean
|
|
clipping: boolean
|
|
points: boolean
|
|
hulls: boolean
|
|
}
|
|
|
|
/* Optional: The position and size of the viewport in the skeleton's world coordinates. Default: the bounding box that fits
|
|
the current animation, 10% padding, 0.25 transition time */
|
|
viewport?: {
|
|
/* Optional: The position and size of the viewport in the skeleton's world coordinates. Default: the bounding box that
|
|
fits the current animation */
|
|
x?: number
|
|
y?: number
|
|
width?: number
|
|
height?: number
|
|
|
|
/* Optional: Padding around the viewport size, given as a number or percentage (eg "25%"). Default: 10% */
|
|
padLeft?: string | number
|
|
padRight?: string | number
|
|
padTop?: string | number
|
|
padBottom?: string | number
|
|
|
|
/* Optional: Whether to draw lines showing the viewport bounds. Default: false */
|
|
debugRender?: boolean,
|
|
|
|
/* Optional: When the current viewport changes, the time to animate to the new viewport. Default: 0.25 */
|
|
transitionTime?: number
|
|
|
|
/* Optional: Viewports for specific animations. Default: none */
|
|
animations?: StringMap<Viewport>
|
|
}
|
|
|
|
/* Optional: Whether the canvas is transparent, allowing the web page behind the canvas to show through when
|
|
backgroundColor alpha is < ff. Default: false */
|
|
alpha?: boolean
|
|
|
|
/* Optional: Whether to preserve the drawing buffer. This is needed if you want to take a screenshot via canvas.getDataURL(), Default: false */
|
|
preserveDrawingBuffer: boolean
|
|
|
|
/* Optional: The canvas background color, given in the format #rrggbb or #rrggbbaa. Default: #000000ff (black) or when
|
|
alpha is true #00000000 (transparent) */
|
|
backgroundColor?: string
|
|
|
|
/* Optional: The background color used in fullscreen mode, given in the format #rrggbb or #rrggbbaa. Default: backgroundColor */
|
|
fullScreenBackgroundColor?: string
|
|
|
|
/* Optional: An image to draw behind the skeleton. Default: none */
|
|
backgroundImage?: {
|
|
url: string
|
|
|
|
/* Optional: The position and size of the background image in the skeleton's world coordinates. Default: fills the viewport */
|
|
x?: number
|
|
y?: number
|
|
width?: number
|
|
height?: number
|
|
}
|
|
|
|
/* Optional: Whether mipmapping and anisotropic filtering are used for highest quality scaling when available, otherwise the
|
|
filter settings from the texture atlas are used. Default: true */
|
|
mipmaps?: boolean
|
|
|
|
/* Optional: List of bone names that the user can drag to position. Default: none */
|
|
controlBones?: string[]
|
|
|
|
/* Optional: Callback when the skeleton and its assets have been successfully loaded. If an animation is set on track 0,
|
|
the player won't set its own animation. Default: none */
|
|
success?: (player: SpinePlayer) => void
|
|
|
|
/* Optional: Callback when the skeleton could not be loaded or rendered. Default: none */
|
|
error?: (player: SpinePlayer, msg: string) => void
|
|
|
|
/* Optional: Callback at the start of each frame, before the skeleton is posed or drawn. Default: none */
|
|
frame?: (player: SpinePlayer, delta: number) => void
|
|
|
|
/* Optional: Callback to update the skeleton's world transform. Default: player.skeleton.updateWorldTransform(spine.Physics.update) is called */
|
|
updateWorldTransform?: (player: SpinePlayer, delta: number) => void
|
|
|
|
/* Optional: Callback after the skeleton is posed each frame, before it is drawn. Default: none */
|
|
update?: (player: SpinePlayer, delta: number) => void
|
|
|
|
/* Optional: Callback after the skeleton is drawn each frame. Default: none */
|
|
draw?: (player: SpinePlayer, delta: number) => void
|
|
|
|
/* Optional: Callback each frame before the skeleton is loaded. Default: none */
|
|
loading?: (player: SpinePlayer, delta: number) => void
|
|
|
|
/* Optional: The downloader used by the player's asset manager. Passing the same downloader to multiple players using the
|
|
same assets ensures the assets are only downloaded once. Default: new instance */
|
|
downloader?: Downloader
|
|
}
|
|
|
|
export interface Viewport {
|
|
/* Optional: The position and size of the viewport in the skeleton's world coordinates. Default: the bounding box that fits
|
|
the current animation */
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
|
|
/* Optional: Padding around the viewport size, given as a number or percentage (eg "25%"). Default: 10% */
|
|
padLeft: string | number
|
|
padRight: string | number
|
|
padTop: string | number
|
|
padBottom: string | number
|
|
}
|
|
|
|
export class SpinePlayer implements Disposable {
|
|
public parent: HTMLElement;
|
|
public dom: HTMLElement;
|
|
public canvas: HTMLCanvasElement | null = null;
|
|
public context: ManagedWebGLRenderingContext | null = null;
|
|
public sceneRenderer: SceneRenderer | null = null;
|
|
public loadingScreen: LoadingScreen | null = null;
|
|
public assetManager: AssetManager | null = null;
|
|
public bg = new Color();
|
|
public bgFullscreen = new Color();
|
|
|
|
private playerControls: HTMLElement | null = null;
|
|
private timelineSlider: Slider | null = null;
|
|
private playButton: HTMLElement | null = null;
|
|
private skinButton: HTMLElement | null = null;
|
|
private animationButton: HTMLElement | null = null;
|
|
|
|
private playTime = 0;
|
|
private selectedBones: (Bone | null)[] = [];
|
|
private cancelId = 0;
|
|
popup: Popup | null = null;
|
|
|
|
/* True if the player is unable to load or render the skeleton. */
|
|
public error: boolean = false;
|
|
/* The player's skeleton. Null until loading is complete (access after config.success). */
|
|
public skeleton: Skeleton | null = null;
|
|
/* The animation state controlling the skeleton. Null until loading is complete (access after config.success). */
|
|
public animationState: AnimationState | null = null;
|
|
|
|
public paused = true;
|
|
public speed = 1;
|
|
public time = new TimeKeeper();
|
|
private stopRequestAnimationFrame = false;
|
|
private disposed = false;
|
|
|
|
private viewport: Viewport = {} as Viewport;
|
|
private currentViewport: Viewport = {} as Viewport;
|
|
private previousViewport: Viewport = {} as Viewport;
|
|
private viewportTransitionStart = 0;
|
|
private eventListeners: Array<{ target: any, event: any, func: any }> = [];
|
|
|
|
constructor (parent: HTMLElement | string, private config: SpinePlayerConfig) {
|
|
let parentDom = typeof parent === "string" ? document.getElementById(parent) : parent;
|
|
if (parentDom == null) throw new Error("SpinePlayer parent not found: " + parent);
|
|
this.parent = parentDom;
|
|
|
|
if (config.showControls === void 0) config.showControls = true;
|
|
let controls = config.showControls ? /*html*/`
|
|
<div class="spine-player-controls spine-player-popup-parent spine-player-controls-hidden">
|
|
<div class="spine-player-timeline"></div>
|
|
<div class="spine-player-buttons">
|
|
<button class="spine-player-button spine-player-button-icon-pause"></button>
|
|
<div class="spine-player-button-spacer"></div>
|
|
<button class="spine-player-button spine-player-button-icon-speed"></button>
|
|
<button class="spine-player-button spine-player-button-icon-animations"></button>
|
|
<button class="spine-player-button spine-player-button-icon-skins"></button>
|
|
<button class="spine-player-button spine-player-button-icon-settings"></button>
|
|
<button class="spine-player-button spine-player-button-icon-fullscreen"></button>
|
|
<img class="spine-player-button-icon-spine-logo" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20104%2031.16%22%3E%3Cpath%20d%3D%22M104%2012.68a1.31%201.31%200%200%201-.37%201%201.28%201.28%200%200%201-.85.31H91.57a10.51%2010.51%200%200%200%20.29%202.55%204.92%204.92%200%200%200%201%202%204.27%204.27%200%200%200%201.64%201.26%206.89%206.89%200%200%200%202.6.44%2010.66%2010.66%200%200%200%202.17-.2%2012.81%2012.81%200%200%200%201.64-.44q.69-.25%201.14-.44a1.87%201.87%200%200%201%20.68-.2.44.44%200%200%201%20.27.04.43.43%200%200%201%20.16.2%201.38%201.38%200%200%201%20.09.37%204.89%204.89%200%200%201%200%20.58%204.14%204.14%200%200%201%200%20.43v.32a.83.83%200%200%201-.09.26%201.1%201.1%200%200%201-.17.22%202.77%202.77%200%200%201-.61.34%208.94%208.94%200%200%201-1.32.46%2018.54%2018.54%200%200%201-1.88.41%2013.78%2013.78%200%200%201-2.28.18%2010.55%2010.55%200%200%201-3.68-.59%206.82%206.82%200%200%201-2.66-1.74%207.44%207.44%200%200%201-1.63-2.89%2013.48%2013.48%200%200%201-.55-4%2012.76%2012.76%200%200%201%20.57-3.94%208.35%208.35%200%200%201%201.64-3%207.15%207.15%200%200%201%202.58-1.87%208.47%208.47%200%200%201%203.39-.65%208.19%208.19%200%200%201%203.41.64%206.46%206.46%200%200%201%202.32%201.73%207%207%200%200%201%201.3%202.54%2011.17%2011.17%200%200%201%20.43%203.13zm-3.14-.93a5.69%205.69%200%200%200-1.09-3.86%204.17%204.17%200%200%200-3.42-1.4%204.52%204.52%200%200%200-2%20.44%204.41%204.41%200%200%200-1.47%201.15A5.29%205.29%200%200%200%2092%209.75a7%207%200%200%200-.36%202zM80.68%2021.94a.42.42%200%200%201-.08.26.59.59%200%200%201-.25.18%201.74%201.74%200%200%201-.47.11%206.31%206.31%200%200%201-.76%200%206.5%206.5%200%200%201-.78%200%201.74%201.74%200%200%201-.47-.11.59.59%200%200%201-.25-.18.42.42%200%200%201-.08-.26V12a9.8%209.8%200%200%200-.23-2.35%204.86%204.86%200%200%200-.66-1.53%202.88%202.88%200%200%200-1.13-1%203.57%203.57%200%200%200-1.6-.34%204%204%200%200%200-2.35.83A12.71%2012.71%200%200%200%2069.11%2010v11.9a.42.42%200%200%201-.08.26.59.59%200%200%201-.25.18%201.74%201.74%200%200%201-.47.11%206.51%206.51%200%200%201-.78%200%206.31%206.31%200%200%201-.76%200%201.88%201.88%200%200%201-.48-.11.52.52%200%200%201-.25-.18.46.46%200%200%201-.07-.26v-17a.53.53%200%200%201%20.03-.21.5.5%200%200%201%20.23-.19%201.28%201.28%200%200%201%20.44-.11%208.53%208.53%200%200%201%201.39%200%201.12%201.12%200%200%201%20.43.11.6.6%200%200%201%20.22.19.47.47%200%200%201%20.07.26V7.2a10.46%2010.46%200%200%201%202.87-2.36%206.17%206.17%200%200%201%202.88-.75%206.41%206.41%200%200%201%202.87.58%205.16%205.16%200%200%201%201.88%201.54%206.15%206.15%200%200%201%201%202.26%2013.46%2013.46%200%200%201%20.31%203.11z%22%20fill%3D%22%23fff%22%2F%3E%3Cpath%20d%3D%22M43.35%202.86c.09%202.6%201.89%204%205.48%204.61%203%20.48%205.79.24%206.69-2.37%201.75-5.09-2.4-3.82-6-4.39s-6.31-2.03-6.17%202.15zm1.08%2010.69c.33%201.94%202.14%203.06%204.91%203s4.84-1.16%205.13-3.25c.53-3.88-2.53-2.38-5.3-2.3s-5.4-1.26-4.74%202.55zM48%2022.44c.55%201.45%202.06%202.06%204.1%201.63s3.45-1.11%203.33-2.76c-.21-3.06-2.22-2.1-4.26-1.66S47%2019.6%2048%2022.44zm1.78%206.78c.16%201.22%201.22%202%202.88%201.93s2.92-.67%203.13-2c.4-2.43-1.46-1.53-3.12-1.51s-3.17-.82-2.89%201.58z%22%20fill%3D%22%23ff4000%22%2F%3E%3Cpath%20d%3D%22M35.28%2013.16a15.33%2015.33%200%200%201-.48%204%208.75%208.75%200%200%201-1.42%203%206.35%206.35%200%200%201-2.32%201.91%207.14%207.14%200%200%201-3.16.67%206.1%206.1%200%200%201-1.4-.15%205.34%205.34%200%200%201-1.26-.47%207.29%207.29%200%200%201-1.24-.81q-.61-.49-1.29-1.15v8.51a.47.47%200%200%201-.08.26.56.56%200%200%201-.25.19%201.74%201.74%200%200%201-.47.11%206.47%206.47%200%200%201-.78%200%206.26%206.26%200%200%201-.76%200%201.89%201.89%200%200%201-.48-.11.49.49%200%200%201-.25-.19.51.51%200%200%201-.07-.26V4.91a.57.57%200%200%201%20.06-.27.46.46%200%200%201%20.23-.18%201.47%201.47%200%200%201%20.44-.1%207.41%207.41%200%200%201%201.3%200%201.45%201.45%200%200%201%20.43.1.52.52%200%200%201%20.24.18.51.51%200%200%201%20.07.27V7.2a18.06%2018.06%200%200%201%201.49-1.38%209%209%200%200%201%201.45-1%206.82%206.82%200%200%201%201.49-.59%207.09%207.09%200%200%201%204.78.52%206%206%200%200%201%202.13%202%208.79%208.79%200%200%201%201.2%202.9%2015.72%2015.72%200%200%201%20.4%203.51zm-3.28.36a15.64%2015.64%200%200%200-.2-2.53%207.32%207.32%200%200%200-.69-2.17%204.06%204.06%200%200%200-1.3-1.51%203.49%203.49%200%200%200-2-.57%204.1%204.1%200%200%200-1.2.18%204.92%204.92%200%200%200-1.2.57%208.54%208.54%200%200%200-1.28%201A15.77%2015.77%200%200%200%2022.76%2010v6.77a13.53%2013.53%200%200%200%202.46%202.4%204.12%204.12%200%200%200%202.44.83%203.56%203.56%200%200%200%202-.57A4.28%204.28%200%200%200%2031%2018a7.58%207.58%200%200%200%20.77-2.12%2011.43%2011.43%200%200%200%20.23-2.36zM12%2017.3a5.39%205.39%200%200%201-.48%202.33%204.73%204.73%200%200%201-1.37%201.72%206.19%206.19%200%200%201-2.12%201.06%209.62%209.62%200%200%201-2.71.36%2010.38%2010.38%200%200%201-3.21-.5A7.63%207.63%200%200%201%201%2021.82a3.25%203.25%200%200%201-.66-.43%201.09%201.09%200%200%201-.3-.53%203.59%203.59%200%200%201-.04-.93%204.06%204.06%200%200%201%200-.61%202%202%200%200%201%20.09-.4.42.42%200%200%201%20.16-.22.43.43%200%200%201%20.24-.07%201.35%201.35%200%200%201%20.61.26q.41.26%201%20.56a9.22%209.22%200%200%200%201.41.55%206.25%206.25%200%200%200%201.87.26%205.62%205.62%200%200%200%201.44-.17%203.48%203.48%200%200%200%201.12-.5%202.23%202.23%200%200%200%20.73-.84%202.68%202.68%200%200%200%20.26-1.21%202%202%200%200%200-.37-1.21%203.55%203.55%200%200%200-1-.87%208.09%208.09%200%200%200-1.36-.66l-1.56-.61a16%2016%200%200%201-1.57-.73%206%206%200%200%201-1.37-1%204.52%204.52%200%200%201-1-1.4%204.69%204.69%200%200%201-.37-2%204.88%204.88%200%200%201%20.39-1.87%204.46%204.46%200%200%201%201.16-1.61%205.83%205.83%200%200%201%201.94-1.11A8.06%208.06%200%200%201%206.53%204a8.28%208.28%200%200%201%201.36.11%209.36%209.36%200%200%201%201.23.28%205.92%205.92%200%200%201%20.94.37%204.09%204.09%200%200%201%20.59.35%201%201%200%200%201%20.26.26.83.83%200%200%201%20.09.26%201.32%201.32%200%200%200%20.06.35%203.87%203.87%200%200%201%200%20.51%204.76%204.76%200%200%201%200%20.56%201.39%201.39%200%200%201-.09.39.5.5%200%200%201-.16.22.35.35%200%200%201-.21.07%201%201%200%200%201-.49-.21%207%207%200%200%200-.83-.44%209.26%209.26%200%200%200-1.2-.44%205.49%205.49%200%200%200-1.58-.16%204.93%204.93%200%200%200-1.4.18%202.69%202.69%200%200%200-1%20.51%202.16%202.16%200%200%200-.59.83%202.43%202.43%200%200%200-.2%201%202%202%200%200%200%20.38%201.24%203.6%203.6%200%200%200%201%20.88%208.25%208.25%200%200%200%201.38.68l1.58.62q.8.32%201.59.72a6%206%200%200%201%201.39%201%204.37%204.37%200%200%201%201%201.36%204.46%204.46%200%200%201%20.37%201.8z%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E">
|
|
</div></div>` : "";
|
|
|
|
this.parent.appendChild(this.dom = createElement(
|
|
/*html*/`<div class="spine-player" style="position:relative;height:100%"><canvas class="spine-player-canvas" style="display:block;width:100%;height:100%"></canvas>${controls}</div>`));
|
|
|
|
try {
|
|
this.validateConfig(config);
|
|
} catch (e) {
|
|
this.showError((e as any).message, e as any);
|
|
}
|
|
|
|
this.initialize();
|
|
|
|
// Register a global resize handler to redraw, avoiding flicker.
|
|
this.addEventListener(window, "resize", () => this.drawFrame(false));
|
|
|
|
// Start the rendering loop.
|
|
requestAnimationFrame(() => this.drawFrame());
|
|
}
|
|
|
|
dispose (): void {
|
|
this.sceneRenderer?.dispose();
|
|
this.loadingScreen?.dispose();
|
|
this.assetManager?.dispose();
|
|
for (var i = 0; i < this.eventListeners.length; i++) {
|
|
var eventListener = this.eventListeners[i];
|
|
eventListener.target.removeEventListener(eventListener.event, eventListener.func);
|
|
}
|
|
this.parent.removeChild(this.dom);
|
|
this.disposed = true;
|
|
}
|
|
|
|
addEventListener (target: any, event: any, func: any) {
|
|
this.eventListeners.push({ target: target, event: event, func: func });
|
|
target.addEventListener(event, func);
|
|
}
|
|
|
|
private validateConfig (config: SpinePlayerConfig) {
|
|
if (!config) throw new Error("A configuration object must be passed to to new SpinePlayer().");
|
|
if ((config as any).skelUrl) config.skeleton = (config as any).skelUrl;
|
|
if (!config.skeleton && !config.jsonUrl && !config.binaryUrl) throw new Error("A URL must be specified for the skeleton JSON or binary file.");
|
|
if (!config.scale) config.scale = 1;
|
|
if (!config.atlas && !config.atlasUrl) throw new Error("A URL must be specified for the atlas file.");
|
|
|
|
if (config.jsonUrl && !config.skeleton) config.skeleton = config.jsonUrl;
|
|
if (config.binaryUrl && !config.skeleton) config.skeleton = config.binaryUrl;
|
|
if (config.atlasUrl && !config.atlas) config.atlas = config.atlasUrl;
|
|
|
|
if (!config.backgroundColor) config.backgroundColor = config.alpha ? "00000000" : "000000";
|
|
if (!config.fullScreenBackgroundColor) config.fullScreenBackgroundColor = config.backgroundColor;
|
|
if (config.backgroundImage && !config.backgroundImage.url) config.backgroundImage = undefined;
|
|
if (config.premultipliedAlpha === void 0) config.premultipliedAlpha = true;
|
|
if (config.preserveDrawingBuffer === void 0) config.preserveDrawingBuffer = false;
|
|
if (config.mipmaps === void 0) config.mipmaps = true;
|
|
if (!config.debug) config.debug = {
|
|
bones: false,
|
|
clipping: false,
|
|
bounds: false,
|
|
hulls: false,
|
|
meshes: false,
|
|
paths: false,
|
|
points: false,
|
|
regions: false
|
|
};
|
|
if (config.animations && config.animation && config.animations.indexOf(config.animation) < 0)
|
|
throw new Error("Animation '" + config.animation + "' is not in the config animation list: " + toString(config.animations));
|
|
if (config.skins && config.skin && config.skins.indexOf(config.skin) < 0)
|
|
throw new Error("Default skin '" + config.skin + "' is not in the config skins list: " + toString(config.skins));
|
|
if (!config.viewport) config.viewport = {} as any;
|
|
if (!config.viewport!.animations) config.viewport!.animations = {};
|
|
if (config.viewport!.debugRender === void 0) config.viewport!.debugRender = false;
|
|
if (config.viewport!.transitionTime === void 0) config.viewport!.transitionTime = 0.25;
|
|
if (!config.controlBones) config.controlBones = [];
|
|
if (config.showLoading === void 0) config.showLoading = true;
|
|
if (config.defaultMix === void 0) config.defaultMix = 0.25;
|
|
}
|
|
|
|
private initialize (): HTMLElement | null {
|
|
let config = this.config;
|
|
let dom = this.dom;
|
|
|
|
if (!config.alpha) { // Prevents a flash before the first frame is drawn.
|
|
let hex = config.backgroundColor!;
|
|
this.dom.style.backgroundColor = (hex.charAt(0) == '#' ? hex : "#" + hex).substr(0, 7);
|
|
}
|
|
|
|
try {
|
|
// Setup the OpenGL context.
|
|
this.canvas = findWithClass(dom, "spine-player-canvas") as HTMLCanvasElement;
|
|
this.context = new ManagedWebGLRenderingContext(this.canvas, { alpha: config.alpha, preserveDrawingBuffer: config.preserveDrawingBuffer });
|
|
|
|
// Setup the scene renderer and loading screen.
|
|
this.sceneRenderer = new SceneRenderer(this.canvas, this.context, true);
|
|
if (config.showLoading) this.loadingScreen = new LoadingScreen(this.sceneRenderer);
|
|
} catch (e) {
|
|
this.showError("Sorry, your browser does not support WebG, or you have disabled WebGL in your browser settings.\nPlease use the latest version of Firefox, Chrome, Edge, or Safari.", e as any);
|
|
return null;
|
|
}
|
|
|
|
// Load the assets.
|
|
this.assetManager = new AssetManager(this.context, "", config.downloader);
|
|
if (config.rawDataURIs) {
|
|
for (let path in config.rawDataURIs)
|
|
this.assetManager.setRawDataURI(path, config.rawDataURIs[path]);
|
|
}
|
|
if (config.skeleton!.endsWith(".json"))
|
|
this.assetManager.loadJson(config.skeleton!);
|
|
else
|
|
this.assetManager.loadBinary(config.skeleton!);
|
|
this.assetManager.loadTextureAtlas(config.atlas!);
|
|
if (config.backgroundImage) this.assetManager.loadTexture(config.backgroundImage.url);
|
|
|
|
// Setup the UI elements.
|
|
this.bg.setFromString(config.backgroundColor!);
|
|
this.bgFullscreen.setFromString(config.fullScreenBackgroundColor!);
|
|
if (config.showControls) {
|
|
this.playerControls = dom.children[1] as HTMLElement;
|
|
let controls = this.playerControls.children;
|
|
let timeline = controls[0] as HTMLElement;
|
|
let buttons = controls[1].children;
|
|
this.playButton = buttons[0] as HTMLElement;
|
|
let speedButton = buttons[2] as HTMLElement;
|
|
this.animationButton = buttons[3] as HTMLElement;
|
|
this.skinButton = buttons[4] as HTMLElement;
|
|
let settingsButton = buttons[5] as HTMLElement;
|
|
let fullscreenButton = buttons[6] as HTMLElement;
|
|
let logoButton = buttons[7] as HTMLElement;
|
|
|
|
this.timelineSlider = new Slider();
|
|
timeline.appendChild(this.timelineSlider.create());
|
|
this.timelineSlider.change = (percentage) => {
|
|
this.pause();
|
|
let animationDuration = this.animationState!.getCurrent(0)!.animation!.duration;
|
|
let time = animationDuration * percentage;
|
|
this.animationState!.update(time - this.playTime);
|
|
this.animationState!.apply(this.skeleton!);
|
|
this.skeleton!.update(time - this.playTime);
|
|
this.skeleton!.updateWorldTransform(Physics.update);
|
|
this.playTime = time;
|
|
};
|
|
|
|
this.playButton.onclick = () => (this.paused ? this.play() : this.pause());
|
|
speedButton.onclick = () => this.showSpeedDialog(speedButton);
|
|
this.animationButton.onclick = () => this.showAnimationsDialog(this.animationButton!);
|
|
this.skinButton.onclick = () => this.showSkinsDialog(this.skinButton!);
|
|
settingsButton.onclick = () => this.showSettingsDialog(settingsButton);
|
|
|
|
let oldWidth = this.canvas.clientWidth, oldHeight = this.canvas.clientHeight;
|
|
let oldStyleWidth = this.canvas.style.width, oldStyleHeight = this.canvas.style.height;
|
|
let isFullscreen = false;
|
|
fullscreenButton.onclick = () => {
|
|
let fullscreenChanged = () => {
|
|
isFullscreen = !isFullscreen;
|
|
if (!isFullscreen) {
|
|
this.canvas!.style.width = oldWidth + "px";
|
|
this.canvas!.style.height = oldHeight + "px";
|
|
this.drawFrame(false);
|
|
// Got to reset the style to whatever the user set after the next layouting.
|
|
requestAnimationFrame(() => {
|
|
this.canvas!.style.width = oldStyleWidth;
|
|
this.canvas!.style.height = oldStyleHeight;
|
|
});
|
|
}
|
|
};
|
|
|
|
let player = dom as any;
|
|
player.onfullscreenchange = fullscreenChanged;
|
|
player.onwebkitfullscreenchange = fullscreenChanged;
|
|
|
|
let doc = document as any;
|
|
if (doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement) {
|
|
if (doc.exitFullscreen) doc.exitFullscreen();
|
|
else if (doc.mozCancelFullScreen) doc.mozCancelFullScreen();
|
|
else if (doc.webkitExitFullscreen) doc.webkitExitFullscreen()
|
|
else if (doc.msExitFullscreen) doc.msExitFullscreen();
|
|
} else {
|
|
oldWidth = this.canvas!.clientWidth;
|
|
oldHeight = this.canvas!.clientHeight;
|
|
oldStyleWidth = this.canvas!.style.width;
|
|
oldStyleHeight = this.canvas!.style.height;
|
|
if (player.requestFullscreen) player.requestFullscreen();
|
|
else if (player.webkitRequestFullScreen) player.webkitRequestFullScreen();
|
|
else if (player.mozRequestFullScreen) player.mozRequestFullScreen();
|
|
else if (player.msRequestFullscreen) player.msRequestFullscreen();
|
|
}
|
|
};
|
|
|
|
logoButton.onclick = () => window.open("http://esotericsoftware.com");
|
|
}
|
|
return dom;
|
|
}
|
|
|
|
private loadSkeleton () {
|
|
if (this.error) return;
|
|
|
|
if (this.assetManager!.hasErrors())
|
|
this.showError("Error: Assets could not be loaded.\n" + toString(this.assetManager!.getErrors()));
|
|
|
|
let config = this.config;
|
|
|
|
// Configure filtering, don't use mipmaps in WebGL1 if the atlas page is non-POT
|
|
let atlas = this.assetManager!.require(config.atlas!) as TextureAtlas;
|
|
let gl = this.context!.gl, anisotropic = gl.getExtension("EXT_texture_filter_anisotropic");
|
|
let isWebGL1 = gl.getParameter(gl.VERSION).indexOf("WebGL 1.0") != -1;
|
|
for (let page of atlas.pages) {
|
|
let minFilter = page.minFilter;
|
|
var useMipMaps: boolean = config.mipmaps!;
|
|
var isPOT = MathUtils.isPowerOfTwo(page.width) && MathUtils.isPowerOfTwo(page.height);
|
|
if (isWebGL1 && !isPOT) useMipMaps = false;
|
|
|
|
if (useMipMaps) {
|
|
if (anisotropic) {
|
|
gl.texParameterf(gl.TEXTURE_2D, anisotropic.TEXTURE_MAX_ANISOTROPY_EXT, 8);
|
|
minFilter = TextureFilter.MipMapLinearLinear;
|
|
} else
|
|
minFilter = TextureFilter.Linear; // Don't use mipmaps without anisotropic.
|
|
page.texture!.setFilters(minFilter, TextureFilter.Nearest);
|
|
}
|
|
if (minFilter != TextureFilter.Nearest && minFilter != TextureFilter.Linear) (page.texture as GLTexture).update(true);
|
|
}
|
|
|
|
// Load skeleton data.
|
|
let skeletonData: SkeletonData;
|
|
try {
|
|
let loader: any, data: any, attachmentLoader = new AtlasAttachmentLoader(atlas);
|
|
if (config.skeleton!.endsWith(".json")) {
|
|
data = this.assetManager!.remove(config.skeleton!);
|
|
if (!data) throw new Error("Empty JSON data.");
|
|
if (config.jsonField) {
|
|
data = data[config.jsonField];
|
|
if (!data) throw new Error("JSON field does not exist: " + config.jsonField);
|
|
}
|
|
loader = new SkeletonJson(attachmentLoader);
|
|
} else {
|
|
data = this.assetManager!.remove(config.skeleton!);
|
|
loader = new SkeletonBinary(attachmentLoader);
|
|
}
|
|
loader.scale = config.scale;
|
|
skeletonData = loader.readSkeletonData(data);
|
|
} catch (e) {
|
|
this.showError(`Error: Could not load skeleton data.\n${(e as any).message}`, e as any);
|
|
return;
|
|
}
|
|
this.skeleton = new Skeleton(skeletonData);
|
|
let stateData = new AnimationStateData(skeletonData);
|
|
stateData.defaultMix = config.defaultMix!;
|
|
this.animationState = new AnimationState(stateData);
|
|
|
|
// Check if all control bones are in the skeleton
|
|
config.controlBones!.forEach(bone => {
|
|
if (!skeletonData.findBone(bone)) this.showError(`Error: Control bone does not exist in skeleton: ${bone}`);
|
|
})
|
|
|
|
// Setup skin.
|
|
if (!config.skin && skeletonData.skins.length) config.skin = skeletonData.skins[0].name;
|
|
if (config.skins && config.skin!.length) {
|
|
config.skins.forEach(skin => {
|
|
if (!this.skeleton!.data.findSkin(skin))
|
|
this.showError(`Error: Skin in config list does not exist in skeleton: ${skin}`);
|
|
});
|
|
}
|
|
if (config.skin) {
|
|
if (!this.skeleton.data.findSkin(config.skin))
|
|
this.showError(`Error: Skin does not exist in skeleton: ${config.skin}`);
|
|
this.skeleton.setSkinByName(config.skin);
|
|
this.skeleton.setSlotsToSetupPose();
|
|
}
|
|
|
|
// Check if all animations given a viewport exist.
|
|
Object.getOwnPropertyNames(config.viewport!.animations).forEach((animation: string) => {
|
|
if (!skeletonData.findAnimation(animation))
|
|
this.showError(`Error: Animation for which a viewport was specified does not exist in skeleton: ${animation}`);
|
|
});
|
|
|
|
// Setup the animations after the viewport, so default bounds don't get messed up.
|
|
if (config.animations && config.animations.length) {
|
|
config.animations.forEach(animation => {
|
|
if (!this.skeleton!.data.findAnimation(animation))
|
|
this.showError(`Error: Animation in config list does not exist in skeleton: ${animation}`);
|
|
});
|
|
if (!config.animation) config.animation = config.animations[0];
|
|
}
|
|
|
|
if (config.animation && !skeletonData.findAnimation(config.animation))
|
|
this.showError(`Error: Animation does not exist in skeleton: ${config.animation}`);
|
|
|
|
// Setup input processing and control bones.
|
|
this.setupInput();
|
|
|
|
if (config.showControls) {
|
|
// Hide skin and animation if there's only the default skin / no animation
|
|
if (skeletonData.skins.length == 1 || (config.skins && config.skins.length == 1)) this.skinButton!.classList.add("spine-player-hidden");
|
|
if (skeletonData.animations.length == 1 || (config.animations && config.animations.length == 1)) this.animationButton!.classList.add("spine-player-hidden");
|
|
}
|
|
|
|
if (config.success) config.success(this);
|
|
|
|
let entry = this.animationState.getCurrent(0);
|
|
if (!entry) {
|
|
if (config.animation) {
|
|
entry = this.setAnimation(config.animation);
|
|
this.play();
|
|
} else {
|
|
entry = this.animationState.setEmptyAnimation(0);
|
|
entry.trackEnd = 100000000;
|
|
this.skeleton.updateWorldTransform(Physics.update);
|
|
this.setViewport(entry.animation!);
|
|
this.pause();
|
|
}
|
|
} else if (!this.currentViewport) {
|
|
this.setViewport(entry.animation!);
|
|
this.play();
|
|
}
|
|
}
|
|
|
|
private setupInput () {
|
|
let config = this.config;
|
|
let controlBones = config.controlBones!;
|
|
if (!controlBones.length && !config.showControls) return;
|
|
let selectedBones = this.selectedBones = new Array<Bone | null>(controlBones.length);
|
|
let canvas = this.canvas!;
|
|
let target: Bone | null = null;
|
|
let offset = new Vector2();
|
|
let coords = new Vector3();
|
|
let mouse = new Vector3();
|
|
let position = new Vector2();
|
|
let skeleton = this.skeleton!;
|
|
let renderer = this.sceneRenderer!;
|
|
|
|
let closest = function (x: number, y: number): Bone | null {
|
|
mouse.set(x, canvas.clientHeight - y, 0)
|
|
offset.x = offset.y = 0;
|
|
let bestDistance = 24, index = 0;
|
|
let best: Bone | null = null;
|
|
for (let i = 0; i < controlBones.length; i++) {
|
|
selectedBones[i] = null;
|
|
let bone = skeleton.findBone(controlBones[i]);
|
|
if (!bone) continue;
|
|
let distance = renderer.camera.worldToScreen(
|
|
coords.set(bone.worldX, bone.worldY, 0),
|
|
canvas.clientWidth, canvas.clientHeight).distance(mouse);
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
best = bone;
|
|
index = i;
|
|
offset.x = coords.x - mouse.x;
|
|
offset.y = coords.y - mouse.y;
|
|
}
|
|
}
|
|
if (best) selectedBones[index] = best;
|
|
return best;
|
|
};
|
|
|
|
new Input(canvas).addListener({
|
|
down: (x, y) => {
|
|
target = closest(x, y);
|
|
},
|
|
up: () => {
|
|
if (target)
|
|
target = null;
|
|
else if (config.showControls)
|
|
(this.paused ? this.play() : this.pause());
|
|
},
|
|
dragged: (x, y) => {
|
|
if (target) {
|
|
x = MathUtils.clamp(x + offset.x, 0, canvas.clientWidth)
|
|
y = MathUtils.clamp(y - offset.y, 0, canvas.clientHeight);
|
|
renderer.camera.screenToWorld(coords.set(x, y, 0), canvas.clientWidth, canvas.clientHeight);
|
|
if (target.parent) {
|
|
target.parent.worldToLocal(position.set(coords.x - skeleton.x, coords.y - skeleton.y));
|
|
target.x = position.x;
|
|
target.y = position.y;
|
|
} else {
|
|
target.x = coords.x - skeleton.x;
|
|
target.y = coords.y - skeleton.y;
|
|
}
|
|
}
|
|
},
|
|
moved: (x, y) => closest(x, y)
|
|
});
|
|
|
|
if (config.showControls) {
|
|
// For manual hover to work, we need to disable hidding controls if the mouse/touch entered the clickable area of a child of the controls.
|
|
// For this we need to register a mouse handler on the document and see if we are within the canvas area.
|
|
this.addEventListener(document, "mousemove", (ev: UIEvent) => {
|
|
if (ev instanceof MouseEvent) handleHover(ev.clientX, ev.clientY);
|
|
});
|
|
this.addEventListener(document, "touchmove", (ev: UIEvent) => {
|
|
if (ev instanceof TouchEvent) {
|
|
let touches = ev.changedTouches;
|
|
if (touches.length) {
|
|
let touch = touches[0];
|
|
handleHover(touch.clientX, touch.clientY);
|
|
}
|
|
|
|
}
|
|
});
|
|
|
|
let overlap = (mouseX: number, mouseY: number, rect: DOMRect | ClientRect): boolean => {
|
|
let x = mouseX - rect.left, y = mouseY - rect.top;
|
|
return x >= 0 && x <= rect.width && y >= 0 && y <= rect.height;
|
|
}
|
|
|
|
let mouseOverControls = true, mouseOverCanvas = false;
|
|
let handleHover = (mouseX: number, mouseY: number) => {
|
|
let popup = findWithClass(this.dom, "spine-player-popup");
|
|
mouseOverControls = overlap(mouseX, mouseY, this.playerControls!.getBoundingClientRect());
|
|
mouseOverCanvas = overlap(mouseX, mouseY, canvas.getBoundingClientRect());
|
|
clearTimeout(this.cancelId);
|
|
let hide = !popup && !mouseOverControls && !mouseOverCanvas && !this.paused;
|
|
if (hide)
|
|
this.playerControls!.classList.add("spine-player-controls-hidden");
|
|
else
|
|
this.playerControls!.classList.remove("spine-player-controls-hidden");
|
|
if (!mouseOverControls && !popup && !this.paused) {
|
|
this.cancelId = setTimeout(() => {
|
|
if (!this.paused) this.playerControls!.classList.add("spine-player-controls-hidden");
|
|
}, 1000);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
play () {
|
|
this.paused = false;
|
|
let config = this.config;
|
|
if (config.showControls) {
|
|
this.cancelId = setTimeout(() => {
|
|
if (!this.paused) this.playerControls!.classList.add("spine-player-controls-hidden");
|
|
}, 1000);
|
|
this.playButton!.classList.remove("spine-player-button-icon-play");
|
|
this.playButton!.classList.add("spine-player-button-icon-pause");
|
|
|
|
// If no config animation, set one when first clicked.
|
|
if (!config.animation) {
|
|
if (config.animations && config.animations.length)
|
|
config.animation = config.animations[0];
|
|
else if (this.skeleton!.data.animations.length)
|
|
config.animation = this.skeleton!.data.animations[0].name;
|
|
if (config.animation) this.setAnimation(config.animation);
|
|
}
|
|
}
|
|
}
|
|
|
|
pause () {
|
|
this.paused = true;
|
|
if (this.config.showControls) {
|
|
this.playerControls!.classList.remove("spine-player-controls-hidden");
|
|
clearTimeout(this.cancelId);
|
|
this.playButton!.classList.remove("spine-player-button-icon-pause");
|
|
this.playButton!.classList.add("spine-player-button-icon-play");
|
|
}
|
|
}
|
|
|
|
/* Sets a new animation and viewport on track 0. */
|
|
setAnimation (animation: string | Animation, loop: boolean = true): TrackEntry {
|
|
animation = this.setViewport(animation);
|
|
return this.animationState!.setAnimationWith(0, animation, loop);
|
|
}
|
|
|
|
/* Adds a new animation and viewport on track 0. */
|
|
addAnimation (animation: string | Animation, loop: boolean = true, delay: number = 0): TrackEntry {
|
|
animation = this.setViewport(animation);
|
|
return this.animationState!.addAnimationWith(0, animation, loop, delay);
|
|
}
|
|
|
|
/* Sets the viewport for the specified animation. */
|
|
setViewport (animation: string | Animation): Animation {
|
|
if (typeof animation == "string") {
|
|
let foundAnimation = this.skeleton!.data.findAnimation(animation);
|
|
if (!foundAnimation) throw new Error("Animation not found: " + animation);
|
|
animation = foundAnimation;
|
|
}
|
|
|
|
this.previousViewport = this.currentViewport;
|
|
|
|
// Determine the base viewport.
|
|
let globalViewport = this.config.viewport!;
|
|
let viewport = this.currentViewport = {
|
|
padLeft: globalViewport.padLeft !== void 0 ? globalViewport.padLeft : "10%",
|
|
padRight: globalViewport.padRight !== void 0 ? globalViewport.padRight : "10%",
|
|
padTop: globalViewport.padTop !== void 0 ? globalViewport.padTop : "10%",
|
|
padBottom: globalViewport.padBottom !== void 0 ? globalViewport.padBottom : "10%"
|
|
} as Viewport;
|
|
if (globalViewport.x !== void 0 && globalViewport.y !== void 0 && globalViewport.width && globalViewport.height) {
|
|
viewport.x = globalViewport.x;
|
|
viewport.y = globalViewport.y;
|
|
viewport.width = globalViewport.width;
|
|
viewport.height = globalViewport.height;
|
|
} else
|
|
this.calculateAnimationViewport(animation, viewport);
|
|
|
|
// Override with the animation specific viewport for the final result.
|
|
let userAnimViewport = this.config.viewport!.animations![animation.name];
|
|
if (userAnimViewport) {
|
|
if (userAnimViewport.x !== void 0 && userAnimViewport.y !== void 0 && userAnimViewport.width && userAnimViewport.height) {
|
|
viewport.x = userAnimViewport.x;
|
|
viewport.y = userAnimViewport.y;
|
|
viewport.width = userAnimViewport.width;
|
|
viewport.height = userAnimViewport.height;
|
|
}
|
|
if (userAnimViewport.padLeft !== void 0) viewport.padLeft = userAnimViewport.padLeft;
|
|
if (userAnimViewport.padRight !== void 0) viewport.padRight = userAnimViewport.padRight;
|
|
if (userAnimViewport.padTop !== void 0) viewport.padTop = userAnimViewport.padTop;
|
|
if (userAnimViewport.padBottom !== void 0) viewport.padBottom = userAnimViewport.padBottom;
|
|
}
|
|
|
|
// Translate percentage padding to world units.
|
|
viewport.padLeft = this.percentageToWorldUnit(viewport.width, viewport.padLeft);
|
|
viewport.padRight = this.percentageToWorldUnit(viewport.width, viewport.padRight);
|
|
viewport.padBottom = this.percentageToWorldUnit(viewport.height, viewport.padBottom);
|
|
viewport.padTop = this.percentageToWorldUnit(viewport.height, viewport.padTop);
|
|
|
|
this.viewportTransitionStart = performance.now();
|
|
return animation;
|
|
}
|
|
|
|
private percentageToWorldUnit (size: number, percentageOrAbsolute: string | number): number {
|
|
if (typeof percentageOrAbsolute === "string")
|
|
return size * parseFloat(percentageOrAbsolute.substr(0, percentageOrAbsolute.length - 1)) / 100;
|
|
return percentageOrAbsolute;
|
|
}
|
|
|
|
private calculateAnimationViewport (animation: Animation, viewport: Viewport) {
|
|
this.skeleton!.setToSetupPose();
|
|
|
|
let steps = 100, stepTime = animation.duration ? animation.duration / steps : 0, time = 0;
|
|
let minX = 100000000, maxX = -100000000, minY = 100000000, maxY = -100000000;
|
|
let offset = new Vector2(), size = new Vector2();
|
|
|
|
const tempArray = new Array<number>(2);
|
|
for (let i = 0; i < steps; i++, time += stepTime) {
|
|
animation.apply(this.skeleton!, time, time, false, [], 1, MixBlend.setup, MixDirection.mixIn);
|
|
this.skeleton!.updateWorldTransform(Physics.update);
|
|
this.skeleton!.getBounds(offset, size, tempArray, this.sceneRenderer!.skeletonRenderer.getSkeletonClipping());
|
|
|
|
if (!isNaN(offset.x) && !isNaN(offset.y) && !isNaN(size.x) && !isNaN(size.y)) {
|
|
minX = Math.min(offset.x, minX);
|
|
maxX = Math.max(offset.x + size.x, maxX);
|
|
minY = Math.min(offset.y, minY);
|
|
maxY = Math.max(offset.y + size.y, maxY);
|
|
} else
|
|
this.showError("Animation bounds are invalid: " + animation.name);
|
|
}
|
|
|
|
viewport.x = minX;
|
|
viewport.y = minY;
|
|
viewport.width = maxX - minX;
|
|
viewport.height = maxY - minY;
|
|
}
|
|
|
|
private drawFrame (requestNextFrame = true) {
|
|
try {
|
|
if (this.error) return;
|
|
if (this.disposed) return;
|
|
if (requestNextFrame && !this.stopRequestAnimationFrame) requestAnimationFrame(() => this.drawFrame());
|
|
|
|
let doc = document as any;
|
|
let isFullscreen = doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement;
|
|
let bg = isFullscreen ? this.bgFullscreen : this.bg;
|
|
|
|
this.time.update();
|
|
let delta = this.time.delta;
|
|
|
|
// Load the skeleton if the assets are ready.
|
|
let loading = this.assetManager!.isLoadingComplete();
|
|
if (!this.skeleton && loading) this.loadSkeleton();
|
|
let skeleton = this.skeleton!;
|
|
let config = this.config!;
|
|
if (skeleton) {
|
|
// Resize the canvas.
|
|
let renderer = this.sceneRenderer!;
|
|
renderer.resize(ResizeMode.Expand);
|
|
|
|
let playDelta = this.paused ? 0 : delta * this.speed;
|
|
if (config.frame) config.frame(this, playDelta);
|
|
|
|
// Update animation time and pose the skeleton.
|
|
if (!this.paused) {
|
|
skeleton.update(playDelta);
|
|
this.animationState!.update(playDelta);
|
|
this.animationState!.apply(skeleton);
|
|
if (config.updateWorldTransform)
|
|
config.updateWorldTransform(this, playDelta);
|
|
else
|
|
skeleton.updateWorldTransform(Physics.update);
|
|
|
|
if (config.showControls) {
|
|
this.playTime += playDelta;
|
|
let entry = this.animationState!.getCurrent(0);
|
|
if (entry) {
|
|
let duration = entry.animation!.duration;
|
|
while (this.playTime >= duration && duration != 0)
|
|
this.playTime -= duration;
|
|
this.playTime = Math.max(0, Math.min(this.playTime, duration));
|
|
this.timelineSlider!.setValue(this.playTime / duration);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine the viewport.
|
|
let viewport = this.viewport;
|
|
viewport.x = this.currentViewport.x - (this.currentViewport.padLeft as number);
|
|
viewport.y = this.currentViewport.y - (this.currentViewport.padBottom as number);
|
|
viewport.width = this.currentViewport.width + (this.currentViewport.padLeft as number) + (this.currentViewport.padRight as number);
|
|
viewport.height = this.currentViewport.height + (this.currentViewport.padBottom as number) + (this.currentViewport.padTop as number);
|
|
|
|
if (this.previousViewport) {
|
|
let transitionAlpha = (performance.now() - this.viewportTransitionStart) / 1000 / config.viewport!.transitionTime!;
|
|
if (transitionAlpha < 1) {
|
|
let x = this.previousViewport.x - (this.previousViewport.padLeft as number);
|
|
let y = this.previousViewport.y - (this.previousViewport.padBottom as number);
|
|
let width = this.previousViewport.width + (this.previousViewport.padLeft as number) + (this.previousViewport.padRight as number);
|
|
let height = this.previousViewport.height + (this.previousViewport.padBottom as number) + (this.previousViewport.padTop as number);
|
|
viewport.x = x + (viewport.x - x) * transitionAlpha;
|
|
viewport.y = y + (viewport.y - y) * transitionAlpha;
|
|
viewport.width = width + (viewport.width - width) * transitionAlpha;
|
|
viewport.height = height + (viewport.height - height) * transitionAlpha;
|
|
}
|
|
}
|
|
|
|
renderer.camera.zoom = this.canvas!.height / this.canvas!.width > viewport.height / viewport.width
|
|
? viewport.width / this.canvas!.width : viewport.height / this.canvas!.height;
|
|
renderer.camera.position.x = viewport.x + viewport.width / 2;
|
|
renderer.camera.position.y = viewport.y + viewport.height / 2;
|
|
|
|
// Clear the screen.
|
|
let gl = this.context!.gl;
|
|
gl.clearColor(bg.r, bg.g, bg.b, bg.a);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
|
|
if (config.update) config.update(this, playDelta);
|
|
|
|
renderer.begin();
|
|
|
|
// Draw the background image.
|
|
let bgImage = config.backgroundImage;
|
|
if (bgImage) {
|
|
let texture = this.assetManager!.require(bgImage.url);
|
|
if (bgImage.x !== void 0 && bgImage.y !== void 0 && bgImage.width && bgImage.height)
|
|
renderer.drawTexture(texture, bgImage.x, bgImage.y, bgImage.width, bgImage.height);
|
|
else
|
|
renderer.drawTexture(texture, viewport.x, viewport.y, viewport.width, viewport.height);
|
|
}
|
|
|
|
// Draw the skeleton and debug output.
|
|
renderer.drawSkeleton(skeleton, config.premultipliedAlpha);
|
|
if (Number(renderer.skeletonDebugRenderer.drawBones = config.debug!.bones! ?? false)
|
|
+ Number(renderer.skeletonDebugRenderer.drawBoundingBoxes = config.debug!.bounds! ?? false)
|
|
+ Number(renderer.skeletonDebugRenderer.drawClipping = config.debug!.clipping! ?? false)
|
|
+ Number(renderer.skeletonDebugRenderer.drawMeshHull = config.debug!.hulls! ?? false)
|
|
+ Number(renderer.skeletonDebugRenderer.drawPaths = config.debug!.paths! ?? false)
|
|
+ Number(renderer.skeletonDebugRenderer.drawRegionAttachments = config.debug!.regions! ?? false)
|
|
+ Number(renderer.skeletonDebugRenderer.drawMeshTriangles = config.debug!.meshes! ?? false) > 0
|
|
) {
|
|
renderer.drawSkeletonDebug(skeleton, config.premultipliedAlpha);
|
|
}
|
|
|
|
// Draw the control bones.
|
|
let controlBones = config.controlBones!;
|
|
if (controlBones.length) {
|
|
let selectedBones = this.selectedBones;
|
|
gl.lineWidth(2);
|
|
for (let i = 0; i < controlBones.length; i++) {
|
|
let bone = skeleton.findBone(controlBones[i]);
|
|
if (!bone) continue;
|
|
let colorInner = selectedBones[i] ? BONE_INNER_OVER : BONE_INNER;
|
|
let colorOuter = selectedBones[i] ? BONE_OUTER_OVER : BONE_OUTER;
|
|
renderer.circle(true, skeleton.x + bone.worldX, skeleton.y + bone.worldY, 20, colorInner);
|
|
renderer.circle(false, skeleton.x + bone.worldX, skeleton.y + bone.worldY, 20, colorOuter);
|
|
}
|
|
}
|
|
|
|
// Draw the viewport bounds.
|
|
if (config.viewport!.debugRender) {
|
|
gl.lineWidth(1);
|
|
renderer.rect(false, this.currentViewport.x, this.currentViewport.y, this.currentViewport.width, this.currentViewport.height, Color.GREEN);
|
|
renderer.rect(false, viewport.x, viewport.y, viewport.width, viewport.height, Color.RED);
|
|
}
|
|
|
|
renderer.end();
|
|
|
|
if (config.draw) config.draw(this, playDelta);
|
|
}
|
|
|
|
// Draw the loading screen.
|
|
if (config.showLoading) {
|
|
this.loadingScreen!.backgroundColor.setFromColor(bg);
|
|
this.loadingScreen!.draw(loading);
|
|
}
|
|
if (loading && config.loading) config.loading(this, delta);
|
|
} catch (e) {
|
|
this.showError(`Error: Unable to render skeleton.\n${(e as any).message}`, e as any);
|
|
}
|
|
}
|
|
|
|
startRendering () {
|
|
this.stopRequestAnimationFrame = false;
|
|
requestAnimationFrame(() => this.drawFrame());
|
|
}
|
|
|
|
stopRendering () {
|
|
this.stopRequestAnimationFrame = true;
|
|
}
|
|
|
|
private hidePopup (id: string): boolean {
|
|
return this.popup != null && this.popup.hide(id);
|
|
}
|
|
|
|
private showSpeedDialog (speedButton: HTMLElement) {
|
|
let id = "speed";
|
|
if (this.hidePopup(id)) return;
|
|
|
|
let popup = new Popup(id, speedButton, this, this.playerControls!, /*html*/`
|
|
<div class="spine-player-popup-title">Speed</div>
|
|
<hr>
|
|
<div class="spine-player-row" style="align-items:center;padding:8px">
|
|
<div class="spine-player-column">
|
|
<div class="spine-player-speed-slider" style="margin-bottom:4px"></div>
|
|
<div class="spine-player-row" style="justify-content:space-between"><div>0.1x</div><div>1x</div><div>2x</div></div>
|
|
</div>
|
|
</div>`);
|
|
let slider = new Slider(2, 0.1, true);
|
|
findWithClass(popup.dom, "spine-player-speed-slider").appendChild(slider.create());
|
|
slider.setValue(this.speed / 2);
|
|
slider.change = (percentage) => this.speed = percentage * 2;
|
|
popup.show();
|
|
}
|
|
|
|
private showAnimationsDialog (animationsButton: HTMLElement) {
|
|
let id = "animations";
|
|
if (this.hidePopup(id)) return;
|
|
if (!this.skeleton || !this.skeleton.data.animations.length) return;
|
|
|
|
let popup = new Popup(id, animationsButton, this, this.playerControls!,
|
|
/*html*/`<div class="spine-player-popup-title">Animations</div><hr><ul class="spine-player-list"></ul>`);
|
|
|
|
let rows = findWithClass(popup.dom, "spine-player-list");
|
|
this.skeleton.data.animations.forEach((animation) => {
|
|
// Skip animations not whitelisted if a whitelist was given.
|
|
if (this.config.animations && this.config.animations.indexOf(animation.name) < 0) return;
|
|
|
|
let row = createElement(
|
|
/*html*/`<li class="spine-player-list-item selectable"><div class="selectable-circle"></div><div class="selectable-text"></div></li>`);
|
|
if (animation.name == this.config.animation) row.classList.add("selected");
|
|
findWithClass(row, "selectable-text").innerText = animation.name;
|
|
rows.appendChild(row);
|
|
row.onclick = () => {
|
|
removeClass(rows.children, "selected");
|
|
row.classList.add("selected");
|
|
this.config.animation = animation.name;
|
|
this.playTime = 0;
|
|
this.setAnimation(animation.name);
|
|
this.play();
|
|
}
|
|
});
|
|
popup.show();
|
|
}
|
|
|
|
private showSkinsDialog (skinButton: HTMLElement) {
|
|
let id = "skins";
|
|
if (this.hidePopup(id)) return;
|
|
if (!this.skeleton || !this.skeleton.data.animations.length) return;
|
|
|
|
let popup = new Popup(id, skinButton, this, this.playerControls!,
|
|
/*html*/`<div class="spine-player-popup-title">Skins</div><hr><ul class="spine-player-list"></ul>`);
|
|
|
|
let rows = findWithClass(popup.dom, "spine-player-list");
|
|
this.skeleton.data.skins.forEach((skin) => {
|
|
// Skip skins not whitelisted if a whitelist was given.
|
|
if (this.config.skins && this.config.skins.indexOf(skin.name) < 0) return;
|
|
|
|
let row = createElement(/*html*/`<li class="spine-player-list-item selectable"><div class="selectable-circle"></div><div class="selectable-text"></div></li>`);
|
|
if (skin.name == this.config.skin) row.classList.add("selected");
|
|
findWithClass(row, "selectable-text").innerText = skin.name;
|
|
rows.appendChild(row);
|
|
row.onclick = () => {
|
|
removeClass(rows.children, "selected");
|
|
row.classList.add("selected");
|
|
this.config.skin = skin.name;
|
|
this.skeleton!.setSkinByName(this.config.skin);
|
|
this.skeleton!.setSlotsToSetupPose();
|
|
}
|
|
});
|
|
popup.show();
|
|
}
|
|
|
|
private showSettingsDialog (settingsButton: HTMLElement) {
|
|
let id = "settings";
|
|
if (this.hidePopup(id)) return;
|
|
if (!this.skeleton || !this.skeleton.data.animations.length) return;
|
|
|
|
let popup = new Popup(id, settingsButton, this, this.playerControls!, /*html*/`<div class="spine-player-popup-title">Debug</div><hr><ul class="spine-player-list"></li>`);
|
|
|
|
let rows = findWithClass(popup.dom, "spine-player-list");
|
|
let makeItem = (label: string, name: string) => {
|
|
let row = createElement(/*html*/`<li class="spine-player-list-item"></li>`);
|
|
let s = new Switch(label);
|
|
row.appendChild(s.create());
|
|
let debug = this.config.debug as any;
|
|
s.setEnabled(debug[name]);
|
|
s.change = (value) => debug[name] = value;
|
|
rows.appendChild(row);
|
|
};
|
|
makeItem("Bones", "bones");
|
|
makeItem("Regions", "regions");
|
|
makeItem("Meshes", "meshes");
|
|
makeItem("Bounds", "bounds");
|
|
makeItem("Paths", "paths");
|
|
makeItem("Clipping", "clipping");
|
|
makeItem("Points", "points");
|
|
makeItem("Hulls", "hulls");
|
|
popup.show();
|
|
}
|
|
|
|
private showError (message: string, error?: Error) {
|
|
if (this.error) {
|
|
if (error) throw error; // Don't lose error if showError throws, is caught, and showError is called again.
|
|
} else {
|
|
this.error = true;
|
|
this.dom.appendChild(createElement(
|
|
/*html*/`<div class="spine-player-error" style="background:#000;color:#fff;position:absolute;top:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;overflow:auto;z-index:999">`
|
|
+ message.replace("\n", "<br><br>") + `</div>`));
|
|
if (this.config.error) this.config.error(this, message);
|
|
throw (error ? error : new Error(message));
|
|
console.log(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
class Popup {
|
|
public dom: HTMLElement;
|
|
private className: string;
|
|
private windowClickListener: any;
|
|
|
|
constructor (private id: string, private button: HTMLElement, private player: SpinePlayer, parent: HTMLElement, htmlContent: string) {
|
|
this.dom = createElement(/*html*/`<div class="spine-player-popup spine-player-hidden"></div>`);
|
|
this.dom.innerHTML = htmlContent;
|
|
parent.appendChild(this.dom);
|
|
this.className = "spine-player-button-icon-" + id + "-selected";
|
|
}
|
|
|
|
dispose () {
|
|
|
|
}
|
|
|
|
hide (id: string): boolean {
|
|
this.dom.remove();
|
|
this.button.classList.remove(this.className);
|
|
if (this.id == id) {
|
|
this.player.popup = null;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
show () {
|
|
this.player.popup = this;
|
|
this.button.classList.add(this.className);
|
|
this.dom.classList.remove("spine-player-hidden");
|
|
|
|
// Make sure the popup isn't bigger than the player.
|
|
let dismissed = false;
|
|
let resize = () => {
|
|
if (!dismissed) requestAnimationFrame(resize);
|
|
let playerDom = this.player.dom;
|
|
let bottomOffset = Math.abs(playerDom.getBoundingClientRect().bottom - playerDom.getBoundingClientRect().bottom);
|
|
let rightOffset = Math.abs(playerDom.getBoundingClientRect().right - playerDom.getBoundingClientRect().right);
|
|
this.dom.style.maxHeight = (playerDom.clientHeight - bottomOffset - rightOffset) + "px";
|
|
}
|
|
requestAnimationFrame(resize);
|
|
|
|
// Dismiss when clicking somewhere outside the popup.
|
|
let justClicked = true;
|
|
let windowClickListener = (event: any) => {
|
|
if (justClicked || this.player.popup != this) {
|
|
justClicked = false;
|
|
return;
|
|
}
|
|
if (!this.dom.contains(event.target)) {
|
|
this.dom.remove();
|
|
window.removeEventListener("click", windowClickListener);
|
|
this.button.classList.remove(this.className);
|
|
this.player.popup = null;
|
|
dismissed = true;
|
|
}
|
|
};
|
|
this.player.addEventListener(window, "click", windowClickListener);
|
|
}
|
|
}
|
|
|
|
class Switch {
|
|
private switch: HTMLElement | null = null;
|
|
private enabled = false;
|
|
public change: (value: boolean) => void = () => { };
|
|
|
|
constructor (private text: string) { }
|
|
|
|
|
|
create (): HTMLElement {
|
|
this.switch = createElement(/*html*/`
|
|
<div class="spine-player-switch">
|
|
<span class="spine-player-switch-text">${this.text}</span>
|
|
<div class="spine-player-switch-knob-area">
|
|
<div class="spine-player-switch-knob"></div>
|
|
</div>
|
|
</div>`);
|
|
this.switch.addEventListener("click", () => {
|
|
this.setEnabled(!this.enabled);
|
|
if (this.change) this.change(this.enabled);
|
|
})
|
|
return this.switch;
|
|
}
|
|
|
|
setEnabled (enabled: boolean) {
|
|
if (enabled) this.switch?.classList.add("active");
|
|
else this.switch?.classList.remove("active");
|
|
this.enabled = enabled;
|
|
}
|
|
|
|
isEnabled (): boolean {
|
|
return this.enabled;
|
|
}
|
|
}
|
|
|
|
class Slider {
|
|
private slider: HTMLElement | null = null;
|
|
private value: HTMLElement | null = null;
|
|
private knob: HTMLElement | null = null;
|
|
public change: (percentage: number) => void = () => { };
|
|
|
|
constructor (public snaps = 0, public snapPercentage = 0.1, public big = false) { }
|
|
|
|
create (): HTMLElement {
|
|
this.slider = createElement(/*html*/`
|
|
<div class="spine-player-slider ${this.big ? "big" : ""}">
|
|
<div class="spine-player-slider-value"></div>
|
|
<!--<div class="spine-player-slider-knob"></div>-->
|
|
</div>`);
|
|
this.value = findWithClass(this.slider, "spine-player-slider-value");
|
|
// this.knob = findWithClass(this.slider, "spine-player-slider-knob");
|
|
this.setValue(0);
|
|
|
|
let dragging = false;
|
|
new Input(this.slider).addListener({
|
|
down: (x, y) => {
|
|
dragging = true;
|
|
this.value?.classList.add("hovering");
|
|
},
|
|
up: (x, y) => {
|
|
dragging = false;
|
|
if (this.change) this.change(this.setValue(x / this.slider!.clientWidth));
|
|
this.value?.classList.remove("hovering");
|
|
},
|
|
moved: (x, y) => {
|
|
if (dragging && this.change) this.change(this.setValue(x / this.slider!.clientWidth));
|
|
},
|
|
dragged: (x, y) => {
|
|
if (this.change) this.change(this.setValue(x / this.slider!.clientWidth));
|
|
}
|
|
});
|
|
|
|
return this.slider;
|
|
}
|
|
|
|
setValue (percentage: number): number {
|
|
percentage = Math.max(0, Math.min(1, percentage));
|
|
if (this.snaps) {
|
|
let snap = 1 / this.snaps;
|
|
let modulo = percentage % snap;
|
|
// floor
|
|
if (modulo < snap * this.snapPercentage)
|
|
percentage = percentage - modulo;
|
|
else if (modulo > snap - snap * this.snapPercentage)
|
|
percentage = percentage - modulo + snap;
|
|
percentage = Math.max(0, Math.min(1, percentage));
|
|
}
|
|
this.value!.style.width = "" + (percentage * 100) + "%";
|
|
// this.knob.style.left = "" + (-8 + percentage * this.slider.clientWidth) + "px";
|
|
return percentage;
|
|
}
|
|
}
|
|
|
|
function findWithClass (element: HTMLElement, className: string): HTMLElement {
|
|
return element.getElementsByClassName(className)[0] as HTMLElement;
|
|
}
|
|
|
|
function createElement (html: string): HTMLElement {
|
|
let div = document.createElement("div");
|
|
div.innerHTML = html;
|
|
return div.children[0] as HTMLElement;
|
|
}
|
|
|
|
function removeClass (elements: HTMLCollection, clazz: string) {
|
|
for (let i = 0; i < elements.length; i++)
|
|
elements[i].classList.remove(clazz);
|
|
}
|
|
|
|
function toString (object: any) {
|
|
return JSON.stringify(object)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
const BONE_INNER_OVER = new Color(0.478, 0, 0, 0.25);
|
|
const BONE_OUTER_OVER = new Color(1, 1, 1, 1);
|
|
const BONE_INNER = new Color(0.478, 0, 0, 0.5);
|
|
const BONE_OUTER = new Color(1, 0, 0, 0.8);
|