/****************************************************************************** * 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 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 } /* 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: 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(parent: HTMLElement, htmlContent: string) { this.dom = createElement(/*html*/`
`); this.dom.innerHTML = htmlContent; parent.appendChild(this.dom); } show () { this.dom.classList.remove("spine-player-hidden"); 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); } } 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; public change: (percentage: number) => void; constructor(public snaps = 0, public snapPercentage = 0.1) { } render(): HTMLElement { this.slider = createElement(/*html*/`
`); this.value = findWithClass(this.slider, "spine-player-slider-value")[0]; this.setValue(0); let input = new spine.webgl.Input(this.slider); var dragging = false; input.addListener({ down: (x, y) => { dragging = true; }, 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); }, 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) + "%"; 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 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.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 (!config.showControls) 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]; this.playButton.onclick = () => { if (this.paused) this.play() else this.pause(); } speedButton.onclick = () => { this.showSpeedDialog(); } this.animationButton.onclick = () => { this.showAnimationsDialog(); } this.skinButton.onclick = () => { this.showSkinsDialog(); } settingsButton.onclick = () => { this.showSettingsDialog(); } fullscreenButton.onclick = () => { 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 { 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(); } }; // Register a global resize handler to redraw and avoid flicker window.onresize = () => { this.drawFrame(false); } return dom; } showSpeedDialog () { let popup = new Popup(this.playerControls, /*html*/`
Speed
0.1x
1x
2x
`); let sliderParent = findWithClass(popup.dom, "spine-player-speed-slider")[0]; let slider = new Slider(2); sliderParent.appendChild(slider.render()); slider.setValue(this.speed / 2); slider.change = (percentage) => { this.speed = percentage * 2; } popup.show(); } showAnimationsDialog () { if (!this.skeleton || this.skeleton.data.animations.length == 0) return; let popup = new Popup(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.animationState.setAnimation(0, this.config.animation, true); } }); popup.show(); } showSkinsDialog () { if (!this.skeleton || this.skeleton.data.animations.length == 0) return; let popup = new Popup(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(); } }); popup.show(); } showSettingsDialog () { if (!this.skeleton || this.skeleton.data.animations.length == 0) return; let popup = new Popup(this.playerControls, /*html*/`
    Debug