/****************************************************************************** * Spine Runtimes Software License v2.5 * * Copyright (c) 2013-2016, Esoteric Software * All rights reserved. * * You are granted a perpetual, non-exclusive, non-sublicensable, and * non-transferable license to use, install, execute, and perform the Spine * Runtimes software and derivative works solely for personal or internal * use. Without the written permission of Esoteric Software (see Section 2 of * the Spine Software License Agreement), you may not (a) modify, translate, * adapt, or develop new applications using the Spine Runtimes or otherwise * create derivative works or improvements of the Spine Runtimes or (b) remove, * delete, alter, or obscure any trademarks or any copyright, trademark, patent, * or other intellectual property or proprietary rights notices on or in the * Software, including any copy thereof. Redistributions in binary or source * form must include this license and terms. * * THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "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 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 THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. *****************************************************************************/ module spine { export interface Viewport { x: number, y: number, width: number, height: number, padLeft: string | number padRight: string | number padTop: string | number padBottom: string | number } export interface SpinePlayerConfig { /* the URL of the skeleton .json file */ jsonUrl: string /* the URL of the skeleton .atlas file. Atlas page images are automatically resolved. */ atlasUrl: string /* Optional: the name of the animation to be played. Default: first animation in the skeleton. */ animation: string /* Optional: list of animation names from which the user can choose. */ animations: string[] /* 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. */ skins: string[] /* Optional: list of bone names that the user can control by dragging. */ controlBones: string[] /* Optional: whether the skeleton uses premultiplied alpha. Default: false. */ premultipliedAlpha: boolean /* Optional: whether to show the player controls. Default: true. */ showControls: boolean /* Optional: which debugging visualizations should be one. 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 world coordinates of the skeleton. Default: the setup pose bounding box. */ viewport: { x: number y: number width: number height: number padLeft: string | number padRight: string | number padTop: string | number padBottom: string | number animations: Map debugRender: boolean, transitionTime: number } /* Optional: whether the canvas should be transparent. Default: false. */ alpha: boolean /* Optional: the background color. Must be given in the format #rrggbbaa. Default: #000000ff. */ backgroundColor: string /* Optional: the background image. Default: none. */ backgroundImage: { /* The URL of the background image */ url: string /* Optional: the position and size of the background image in world coordinates. Default: viewport. */ x: number y: number width: number height: number } /* Optional: the background color used in fullscreen mode. Must be given in the format #rrggbbaa. Default: backgroundColor. */ fullScreenBackgroundColor: string /* Optional: callback when the widget and its assets have been successfully loaded. */ success: (widget: SpinePlayer) => void /* Optional: callback when the widget could not be loaded. */ error: (widget: SpinePlayer, msg: string) => void } class Popup { public dom: HTMLElement; constructor(private player: HTMLElement, parent: HTMLElement, htmlContent: string) { this.dom = createElement(/*html*/`
`); this.dom.innerHTML = htmlContent; parent.appendChild(this.dom); } show (dismissedListener = () => {}) { this.dom.classList.remove("spine-player-hidden"); // Make sure the popup isn't bigger than the player. var dismissed = false; let resize = () => { if (!dismissed) requestAnimationFrame(resize); let bottomOffset = Math.abs(this.dom.getBoundingClientRect().bottom - this.player.getBoundingClientRect().bottom); let rightOffset = Math.abs(this.dom.getBoundingClientRect().right - this.player.getBoundingClientRect().right); let maxHeight = this.player.clientHeight - bottomOffset - rightOffset; this.dom.style.maxHeight = maxHeight + "px"; } requestAnimationFrame(resize); // Dismiss when clicking somewhere else outside // the popup var justClicked = true; let windowClickListener = (event: any) => { if (justClicked) { justClicked = false; return; } if (!isContained(this.dom, event.target)) { this.dom.parentNode.removeChild(this.dom); window.removeEventListener("click", windowClickListener); dismissedListener(); dismissed = true; } } window.addEventListener("click", windowClickListener); } } class Switch { private switch: HTMLElement; private enabled = false; public change: (value: boolean) => void; constructor(private text: string) {} render(): HTMLElement { this.switch = createElement(/*html*/`
${this.text}
`); 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; private value: HTMLElement; private knob: HTMLElement; public change: (percentage: number) => void; constructor(public snaps = 0, public snapPercentage = 0.1, public big = false) { } render(): HTMLElement { this.slider = createElement(/*html*/`
`); this.value = findWithClass(this.slider, "spine-player-slider-value")[0]; // this.knob = findWithClass(this.slider, "spine-player-slider-knob")[0]; this.setValue(0); let input = new spine.webgl.Input(this.slider); var dragging = false; input.addListener({ down: (x, y) => { dragging = true; this.value.classList.add("hovering"); }, up: (x, y) => { dragging = false; let percentage = x / this.slider.clientWidth; percentage = percentage = Math.max(0, Math.min(percentage, 1)); this.setValue(x / this.slider.clientWidth); if (this.change) this.change(percentage); this.value.classList.remove("hovering"); }, moved: (x, y) => { if (dragging) { let percentage = x / this.slider.clientWidth; percentage = Math.max(0, Math.min(percentage, 1)); percentage = this.setValue(x / this.slider.clientWidth); if (this.change) this.change(percentage); } }, dragged: (x, y) => { let percentage = x / this.slider.clientWidth; percentage = Math.max(0, Math.min(percentage, 1)); percentage = this.setValue(x / this.slider.clientWidth); if (this.change) this.change(percentage); } }); return this.slider; } setValue(percentage: number): number { percentage = Math.max(0, Math.min(1, percentage)); if (this.snaps > 0) { let modulo = percentage % (1 / this.snaps); // floor if (modulo < (1 / this.snaps) * this.snapPercentage) { percentage = percentage - modulo; } else if (modulo > (1 / this.snaps) - (1 / this.snaps) * this.snapPercentage) { percentage = percentage - modulo + (1 / this.snaps); } 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; } } export class SpinePlayer { static HOVER_COLOR_INNER = new spine.Color(0.478, 0, 0, 0.25); static HOVER_COLOR_OUTER = new spine.Color(1, 1, 1, 1); static NON_HOVER_COLOR_INNER = new spine.Color(0.478, 0, 0, 0.5); static NON_HOVER_COLOR_OUTER = new spine.Color(1, 0, 0, 0.8); private sceneRenderer: spine.webgl.SceneRenderer; private dom: HTMLElement; private playerControls: HTMLElement; private canvas: HTMLCanvasElement; private timelineSlider: Slider; private playButton: HTMLElement; private skinButton: HTMLElement; private animationButton: HTMLElement; private context: spine.webgl.ManagedWebGLRenderingContext; private loadingScreen: spine.webgl.LoadingScreen; private assetManager: spine.webgl.AssetManager; private loaded: boolean; private skeleton: Skeleton; private animationState: AnimationState; private time = new TimeKeeper(); private paused = true; private playTime = 0; private speed = 1; private animationViewports: Map = {} private currentViewport: Viewport = null; private previousViewport: Viewport = null; private viewportTransitionStart = 0; private selectedBones: Bone[]; constructor(parent: HTMLElement, private config: SpinePlayerConfig) { parent.appendChild(this.render()); } validateConfig(config: SpinePlayerConfig): SpinePlayerConfig { if (!config) throw new Error("Please pass a configuration to new.spine.SpinePlayer()."); if (!config.jsonUrl) throw new Error("Please specify the URL of the skeleton JSON file."); if (!config.atlasUrl) throw new Error("Please specify the URL of the atlas file."); if (!config.alpha) config.alpha = false; if (!config.backgroundColor) config.backgroundColor = "#000000"; if (!config.fullScreenBackgroundColor) config.fullScreenBackgroundColor = config.backgroundColor; if (!config.premultipliedAlpha) config.premultipliedAlpha = false; if (!config.success) config.success = (widget) => {}; if (!config.error) config.error = (widget, msg) => {}; if (!config.debug) config.debug = { bones: false, regions: false, meshes: false, bounds: false, clipping: false, paths: false, points: false, hulls: false } if (!config.debug.bones) config.debug.bones = false; if (!config.debug.bounds) config.debug.bounds = false; if (!config.debug.clipping) config.debug.clipping = false; if (!config.debug.hulls) config.debug.hulls = false; if (!config.debug.paths) config.debug.paths = false; if (!config.debug.points) config.debug.points = false; if (!config.debug.regions) config.debug.regions = false; if (!config.debug.meshes) config.debug.meshes = false; if (config.animations && config.animation) { if (config.animations.indexOf(config.animation) < 0) throw new Error("Default animation '" + config.animation + "' is not contained in the list of selectable animations " + escapeHtml(JSON.stringify(this.config.animations)) + "."); } if (config.skins && config.skin) { if (config.skins.indexOf(config.skin) < 0) throw new Error("Default skin '" + config.skin + "' is not contained in the list of selectable skins " + escapeHtml(JSON.stringify(this.config.skins)) + "."); } if (!config.controlBones) config.controlBones = []; if (typeof config.showControls === "undefined") config.showControls = true; return config; } showError(error: string) { let errorDom = findWithClass(this.dom, "spine-player-error")[0]; errorDom.classList.remove("spine-player-hidden"); errorDom.innerHTML = `

${error}

`; this.config.error(this, error); } render(): HTMLElement { let config = this.config; let dom = this.dom = createElement(/*html*/`
`) try { // Validate the configuration this.config = this.validateConfig(config); } catch (e) { this.showError(e); return dom } try { // Setup the scene renderer and OpenGL context this.canvas = findWithClass(dom, "spine-player-canvas")[0] as HTMLCanvasElement; var webglConfig = { alpha: config.alpha }; this.context = new spine.webgl.ManagedWebGLRenderingContext(this.canvas, webglConfig); // Setup the scene renderer and loading screen this.sceneRenderer = new spine.webgl.SceneRenderer(this.canvas, this.context, true); this.loadingScreen = new spine.webgl.LoadingScreen(this.sceneRenderer); } catch (e) { this.showError("Sorry, your browser does not support WebGL.

Please use the latest version of Firefox, Chrome, Edge, or Safari."); return dom; } // Load the assets this.assetManager = new spine.webgl.AssetManager(this.context); this.assetManager.loadText(config.jsonUrl); this.assetManager.loadTextureAtlas(config.atlasUrl); if (config.backgroundImage && config.backgroundImage.url) this.assetManager.loadTexture(config.backgroundImage.url); // Setup rendering loop requestAnimationFrame(() => this.drawFrame()); // Setup the event listeners for UI elements this.playerControls = findWithClass(dom, "spine-player-controls")[0]; let timeline = findWithClass(dom, "spine-player-timeline")[0]; this.timelineSlider = new Slider(); timeline.appendChild(this.timelineSlider.render()); this.playButton = findWithId(dom, "spine-player-button-play-pause")[0]; let speedButton = findWithId(dom, "spine-player-button-speed")[0]; this.animationButton = findWithId(dom, "spine-player-button-animation")[0]; this.skinButton = findWithId(dom, "spine-player-button-skin")[0]; let settingsButton = findWithId(dom, "spine-player-button-settings")[0]; let fullscreenButton = findWithId(dom, "spine-player-button-fullscreen")[0]; let logoButton = findWithId(dom, "spine-player-button-logo")[0]; this.playButton.onclick = () => { if (this.paused) this.play() else 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; let oldHeight = this.canvas.clientHeight; let oldStyleWidth = this.canvas.style.width; let oldStyleHeight = this.canvas.style.height; var 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 doc = document as any; (dom as any).onfullscreenchange = fullscreenChanged; dom.onwebkitfullscreenchange = fullscreenChanged; 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; let player = dom as any; 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"); }; // Register a global resize handler to redraw and avoid flicker window.onresize = () => { this.drawFrame(false); } return dom; } showSpeedDialog (speedButton: HTMLElement) { let popup = new Popup(this.dom, this.playerControls, /*html*/`
Speed

0.1x
1x
2x
`); let sliderParent = findWithClass(popup.dom, "spine-player-speed-slider")[0]; let slider = new Slider(2, 0.1, true); sliderParent.appendChild(slider.render()); slider.setValue(this.speed / 2); slider.change = (percentage) => { this.speed = percentage * 2; } speedButton.classList.add("spine-player-button-icon-speed-selected") popup.show(() => { speedButton.classList.remove("spine-player-button-icon-speed-selected") }); } showAnimationsDialog (animationsButton: HTMLElement) { if (!this.skeleton || this.skeleton.data.animations.length == 0) return; let popup = new Popup(this.dom, this.playerControls, /*html*/`
Animations

`); let rows = findWithClass(popup.dom, "spine-player-list")[0]; this.skeleton.data.animations.forEach((animation) => { // skip animations not whitelisted if a whitelist is given if (this.config.animations && this.config.animations.indexOf(animation.name) < 0) { return; } let row = createElement(/*html*/`
  • `); if (animation.name == this.config.animation) row.classList.add("selected"); findWithClass(row, "selectable-text")[0].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); } }); animationsButton.classList.add("spine-player-button-icon-animations-selected") popup.show(() => { animationsButton.classList.remove("spine-player-button-icon-animations-selected") }); } showSkinsDialog (skinButton: HTMLElement) { if (!this.skeleton || this.skeleton.data.animations.length == 0) return; let popup = new Popup(this.dom, this.playerControls, /*html*/`
    Skins

    `); let rows = findWithClass(popup.dom, "spine-player-list")[0]; this.skeleton.data.skins.forEach((skin) => { // skip skins not whitelisted if a whitelist is given if (this.config.skins && this.config.skins.indexOf(skin.name) < 0) { return; } let row = createElement(/*html*/`
  • `); if (skin.name == this.config.skin) row.classList.add("selected"); findWithClass(row, "selectable-text")[0].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(); } }); skinButton.classList.add("spine-player-button-icon-skins-selected") popup.show(() => { skinButton.classList.remove("spine-player-button-icon-skins-selected") }); } showSettingsDialog (settingsButton: HTMLElement) { if (!this.skeleton || this.skeleton.data.animations.length == 0) return; let popup = new Popup(this.dom, this.playerControls, /*html*/`
    Debug