/****************************************************************************** * Spine Runtimes License Agreement * Last updated January 1, 2020. Replaces all prior versions. * * Copyright (c) 2013-2020, 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. *****************************************************************************/ 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 .skel file */ skelUrl: string /* the URL of the skeleton .atlas file. Atlas page images are automatically resolved. */ atlasUrl: string /* Raw data URIs, mapping from a path to base 64 encoded raw data. When the player resolves a path of the `jsonUrl`, `skelUrl`, `atlasUrl`, or the image paths referenced in the atlas, it will first look for that path in this array of raw data URIs. This allows embedding of resources directly in HTML/JS. */ rawDataURIs: Map /* 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 default mix time used to switch between two animations. */ 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. */ skins: string[] /* Optional: whether the skeleton uses premultiplied alpha. Default: true. */ 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: list of bone names that the user can control by dragging. */ controlBones: 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: () => void) { 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.remove(); 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[]; private parent: HTMLElement; private stopRequestAnimationFrame = false; constructor(parent: HTMLElement | string, private config: SpinePlayerConfig) { if (typeof parent === "string") this.parent = document.getElementById(parent); else this.parent = parent; this.parent.appendChild(this.render()); } validateConfig(config: SpinePlayerConfig): SpinePlayerConfig { if (!config) throw new Error("Please pass a configuration to new.spine.SpinePlayer()."); if (!config.jsonUrl && !config.skelUrl) throw new Error("Please specify the URL of the skeleton JSON or .skel 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 (typeof config.premultipliedAlpha === "undefined") config.premultipliedAlpha = true; 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 (typeof config.debug.bones === "undefined") config.debug.bones = false; if (typeof config.debug.bounds === "undefined") config.debug.bounds = false; if (typeof config.debug.clipping === "undefined") config.debug.clipping = false; if (typeof config.debug.hulls === "undefined") config.debug.hulls = false; if (typeof config.debug.paths === "undefined") config.debug.paths = false; if (typeof config.debug.points === "undefined") config.debug.points = false; if (typeof config.debug.regions === "undefined") config.debug.regions = false; if (typeof config.debug.meshes === "undefined") 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; if (typeof config.defaultMix === "undefined") config.defaultMix = 0.25; 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); if (config.rawDataURIs) { for (let path in config.rawDataURIs) { let data = config.rawDataURIs[path]; this.assetManager.setRawDataURI(path, data); } } if (config.jsonUrl) this.assetManager.loadText(config.jsonUrl); else this.assetManager.loadBinary(config.skelUrl); 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 as any).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; } private lastPopup: Popup; showSpeedDialog (speedButton: HTMLElement) { if (this.lastPopup) this.lastPopup.dom.remove() if (this.lastPopup && findWithClass(this.lastPopup.dom, "spine-player-popup-title")[0].textContent == "Speed") { this.lastPopup = null; speedButton.classList.remove("spine-player-button-icon-speed-selected") return; } 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") popup.dom.remove(); this.lastPopup = null; }); this.lastPopup = popup; } showAnimationsDialog (animationsButton: HTMLElement) { if (this.lastPopup) this.lastPopup.dom.remove() if (this.lastPopup && findWithClass(this.lastPopup.dom, "spine-player-popup-title")[0].textContent == "Animations") { this.lastPopup = null; animationsButton.classList.remove("spine-player-button-icon-animations-selected") return; } 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") popup.dom.remove(); this.lastPopup = null; }); this.lastPopup = popup; } showSkinsDialog (skinButton: HTMLElement) { if (this.lastPopup) this.lastPopup.dom.remove() if (this.lastPopup && findWithClass(this.lastPopup.dom, "spine-player-popup-title")[0].textContent == "Skins") { this.lastPopup = null; skinButton.classList.remove("spine-player-button-icon-skins-selected") return; } 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") popup.dom.remove(); this.lastPopup = null; }); this.lastPopup = popup; } showSettingsDialog (settingsButton: HTMLElement) { if (this.lastPopup) this.lastPopup.dom.remove() if (this.lastPopup && findWithClass(this.lastPopup.dom, "spine-player-popup-title")[0].textContent == "Debug") { this.lastPopup = null; settingsButton.classList.remove("spine-player-button-icon-settings-selected") return; } if (!this.skeleton || this.skeleton.data.animations.length == 0) return; let popup = new Popup(this.dom, this.playerControls, /*html*/`
      Debug

        `); let rows = findWithClass(popup.dom, "spine-player-list")[0]; let makeItem = (label: string, name: string) => { let row = createElement(/*html*/`
      • `); let s = new Switch(label); row.appendChild(s.render()); s.setEnabled((this.config.debug as any)[name]); s.change = (value) => { (this.config.debug as any)[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"); settingsButton.classList.add("spine-player-button-icon-settings-selected") popup.show(() => { settingsButton.classList.remove("spine-player-button-icon-settings-selected") popup.dom.remove(); this.lastPopup = null; }); this.lastPopup = popup; } drawFrame (requestNextFrame = true) { if (requestNextFrame && !this.stopRequestAnimationFrame) requestAnimationFrame(() => this.drawFrame()); let ctx = this.context; let gl = ctx.gl; // Clear the viewport var doc = document as any; var isFullscreen = doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement; let bg = new Color().setFromString(isFullscreen ? this.config.fullScreenBackgroundColor : this.config.backgroundColor); gl.clearColor(bg.r, bg.g, bg.b, bg.a); gl.clear(gl.COLOR_BUFFER_BIT); // Display loading screen this.loadingScreen.backgroundColor.setFromColor(bg); this.loadingScreen.draw(this.assetManager.isLoadingComplete()); // Have we finished loading the asset? Then set things up if (this.assetManager.isLoadingComplete() && this.skeleton == null) this.loadSkeleton(); // Resize the canvas this.sceneRenderer.resize(webgl.ResizeMode.Expand); // Update and draw the skeleton if (this.loaded) { // Update animation and skeleton based on user selections if (!this.paused && this.config.animation) { this.time.update(); let delta = this.time.delta * this.speed; let animationDuration = this.animationState.getCurrent(0).animation.duration; this.playTime += delta; while (this.playTime >= animationDuration && animationDuration != 0) { this.playTime -= animationDuration; } this.playTime = Math.max(0, Math.min(this.playTime, animationDuration)); this.timelineSlider.setValue(this.playTime / animationDuration); this.animationState.update(delta); this.animationState.apply(this.skeleton); } this.skeleton.updateWorldTransform(); let viewport = { x: this.currentViewport.x - (this.currentViewport.padLeft as number), y: this.currentViewport.y - (this.currentViewport.padBottom as number), width: this.currentViewport.width + (this.currentViewport.padLeft as number) + (this.currentViewport.padRight as number), height: this.currentViewport.height + (this.currentViewport.padBottom as number) + (this.currentViewport.padTop as number) } let transitionAlpha = ((performance.now() - this.viewportTransitionStart) / 1000) / this.config.viewport.transitionTime; if (this.previousViewport && transitionAlpha < 1) { let oldViewport = { x: this.previousViewport.x - (this.previousViewport.padLeft as number), y: this.previousViewport.y - (this.previousViewport.padBottom as number), width: this.previousViewport.width + (this.previousViewport.padLeft as number) + (this.previousViewport.padRight as number), height: this.previousViewport.height + (this.previousViewport.padBottom as number) + (this.previousViewport.padTop as number) } viewport = { x: oldViewport.x + (viewport.x - oldViewport.x) * transitionAlpha, y: oldViewport.y + (viewport.y - oldViewport.y) * transitionAlpha, width: oldViewport.width + (viewport.width - oldViewport.width) * transitionAlpha, height: oldViewport.height + (viewport.height - oldViewport.height) * transitionAlpha } } let viewportSize = this.scale(viewport.width, viewport.height, this.canvas.width, this.canvas.height); this.sceneRenderer.camera.zoom = viewport.width / viewportSize.x; this.sceneRenderer.camera.position.x = viewport.x + viewport.width / 2; this.sceneRenderer.camera.position.y = viewport.y + viewport.height / 2; this.sceneRenderer.begin(); // Draw background image if given if (this.config.backgroundImage && this.config.backgroundImage.url) { let bgImage = this.assetManager.get(this.config.backgroundImage.url); if (!(this.config.backgroundImage.hasOwnProperty("x") && this.config.backgroundImage.hasOwnProperty("y") && this.config.backgroundImage.hasOwnProperty("width") && this.config.backgroundImage.hasOwnProperty("height"))) { this.sceneRenderer.drawTexture(bgImage, viewport.x, viewport.y, viewport.width, viewport.height); } else { this.sceneRenderer.drawTexture(bgImage, this.config.backgroundImage.x, this.config.backgroundImage.y, this.config.backgroundImage.width, this.config.backgroundImage.height); } } // Draw skeleton and debug output this.sceneRenderer.drawSkeleton(this.skeleton, this.config.premultipliedAlpha); this.sceneRenderer.skeletonDebugRenderer.drawBones = this.config.debug.bones; this.sceneRenderer.skeletonDebugRenderer.drawBoundingBoxes = this.config.debug.bounds; this.sceneRenderer.skeletonDebugRenderer.drawClipping = this.config.debug.clipping; this.sceneRenderer.skeletonDebugRenderer.drawMeshHull = this.config.debug.hulls; this.sceneRenderer.skeletonDebugRenderer.drawPaths = this.config.debug.paths; this.sceneRenderer.skeletonDebugRenderer.drawRegionAttachments = this.config.debug.regions; this.sceneRenderer.skeletonDebugRenderer.drawMeshTriangles = this.config.debug.meshes; this.sceneRenderer.drawSkeletonDebug(this.skeleton, this.config.premultipliedAlpha); // Render the selected bones let controlBones = this.config.controlBones; let selectedBones = this.selectedBones; let skeleton = this.skeleton; gl.lineWidth(2); for (var i = 0; i < controlBones.length; i++) { var bone = skeleton.findBone(controlBones[i]); if (!bone) continue; var colorInner = selectedBones[i] !== null ? SpinePlayer.HOVER_COLOR_INNER : SpinePlayer.NON_HOVER_COLOR_INNER; var colorOuter = selectedBones[i] !== null ? SpinePlayer.HOVER_COLOR_OUTER : SpinePlayer.NON_HOVER_COLOR_OUTER; this.sceneRenderer.circle(true, skeleton.x + bone.worldX, skeleton.y + bone.worldY, 20, colorInner); this.sceneRenderer.circle(false, skeleton.x + bone.worldX, skeleton.y + bone.worldY, 20, colorOuter); } gl.lineWidth(1); // Render the viewport bounds if (this.config.viewport.debugRender) { this.sceneRenderer.rect(false, this.currentViewport.x, this.currentViewport.y, this.currentViewport.width, this.currentViewport.height, Color.GREEN); this.sceneRenderer.rect(false, viewport.x, viewport.y, viewport.width, viewport.height, Color.RED); } this.sceneRenderer.end(); this.sceneRenderer.camera.zoom = 0; } } scale(sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number): Vector2 { let targetRatio = targetHeight / targetWidth; let sourceRatio = sourceHeight / sourceWidth; let scale = targetRatio > sourceRatio ? targetWidth / sourceWidth : targetHeight / sourceHeight; let temp = new spine.Vector2(); temp.x = sourceWidth * scale; temp.y = sourceHeight * scale; return temp; } loadSkeleton () { if (this.loaded) return; if (this.assetManager.hasErrors()) { this.showError("Error: assets could not be loaded.

        " + escapeHtml(JSON.stringify(this.assetManager.getErrors()))); return; } let atlas = this.assetManager.get(this.config.atlasUrl); let skeletonData: SkeletonData; if (this.config.jsonUrl) { let jsonText = this.assetManager.get(this.config.jsonUrl); let json = new SkeletonJson(new AtlasAttachmentLoader(atlas)); try { skeletonData = json.readSkeletonData(jsonText); } catch (e) { this.showError("Error: could not load skeleton .json.

        " + e.toString()); return; } } else { let binaryData = this.assetManager.get(this.config.skelUrl); let binary = new SkeletonBinary(new AtlasAttachmentLoader(atlas)); try { skeletonData = binary.readSkeletonData(binaryData); } catch (e) { this.showError("Error: could not load skeleton .skel.

        " + e.toString()); return; } } this.skeleton = new Skeleton(skeletonData); let stateData = new AnimationStateData(skeletonData); stateData.defaultMix = this.config.defaultMix; this.animationState = new AnimationState(stateData); // Check if all controllable bones are in the skeleton if (this.config.controlBones) { this.config.controlBones.forEach(bone => { if (!skeletonData.findBone(bone)) { this.showError(`Error: control bone '${bone}' does not exist in skeleton.`); } }) } // Setup skin if (!this.config.skin) { if (skeletonData.skins.length > 0) { this.config.skin = skeletonData.skins[0].name; } } if (this.config.skins && this.config.skin.length > 0) { this.config.skins.forEach(skin => { if (!this.skeleton.data.findSkin(skin)) { this.showError(`Error: skin '${skin}' in selectable skin list does not exist in skeleton.`); return; } }); } if (this.config.skin) { if (!this.skeleton.data.findSkin(this.config.skin)) { this.showError(`Error: skin '${this.config.skin}' does not exist in skeleton.`); return; } this.skeleton.setSkinByName(this.config.skin); this.skeleton.setSlotsToSetupPose(); } // Setup empty viewport if none is given and check // if all animations for which viewports where given // exist. if (!this.config.viewport) { (this.config.viewport as any) = { animations: {}, debugRender: false, transitionTime: 0.2 } } if (typeof this.config.viewport.debugRender === "undefined") this.config.viewport.debugRender = false; if (typeof this.config.viewport.transitionTime === "undefined") this.config.viewport.transitionTime = 0.2; if (!this.config.viewport.animations) { this.config.viewport.animations = {}; } else { Object.getOwnPropertyNames(this.config.viewport.animations).forEach((animation: string) => { if (!skeletonData.findAnimation(animation)) { this.showError(`Error: animation '${animation}' for which a viewport was specified does not exist in skeleton.`); return; } }); } // Setup the animations after viewport, so default bounds don't get messed up. if (this.config.animations && this.config.animations.length > 0) { this.config.animations.forEach(animation => { if (!this.skeleton.data.findAnimation(animation)) { this.showError(`Error: animation '${animation}' in selectable animation list does not exist in skeleton.`); return; } }); if (!this.config.animation) { this.config.animation = this.config.animations[0]; } } if (!this.config.animation) { if (skeletonData.animations.length > 0) { this.config.animation = skeletonData.animations[0].name; } } if(this.config.animation) { if (!skeletonData.findAnimation(this.config.animation)) { this.showError(`Error: animation '${this.config.animation}' does not exist in skeleton.`); return; } this.play() this.timelineSlider.change = (percentage) => { this.pause(); var animationDuration = this.animationState.getCurrent(0).animation.duration; var time = animationDuration * percentage; this.animationState.update(time - this.playTime); this.animationState.apply(this.skeleton); this.skeleton.updateWorldTransform(); this.playTime = time; } } // Setup the input processor and controllable bones this.setupInput(); // Hide skin and animation if there's only the default skin / no animation if (skeletonData.skins.length == 1 || (this.config.skins && this.config.skins.length == 1)) this.skinButton.classList.add("spine-player-hidden"); if (skeletonData.animations.length == 1 || (this.config.animations && this.config.animations.length == 1)) this.animationButton.classList.add("spine-player-hidden"); this.config.success(this); this.loaded = true; } private cancelId = 0; setupInput () { let controlBones = this.config.controlBones; let selectedBones = this.selectedBones = new Array(this.config.controlBones.length); let canvas = this.canvas; let input = new spine.webgl.Input(canvas); var target:Bone = null; let coords = new spine.webgl.Vector3(); let temp = new spine.webgl.Vector3(); let temp2 = new spine.Vector2(); let skeleton = this.skeleton let renderer = this.sceneRenderer; input.addListener({ down: (x, y) => { for (var i = 0; i < controlBones.length; i++) { var bone = skeleton.findBone(controlBones[i]); if (!bone) continue; renderer.camera.screenToWorld(coords.set(x, y, 0), canvas.width, canvas.height); if (temp.set(skeleton.x + bone.worldX, skeleton.y + bone.worldY, 0).distance(coords) < 30) { target = bone; } } }, up: (x, y) => { if (target) { target = null; } else { if (!this.config.showControls) return; if (this.paused) this.play() else this.pause(); } }, dragged: (x, y) => { if (target != null) { renderer.camera.screenToWorld(coords.set(x, y, 0), canvas.width, canvas.height); if (target.parent !== null) { target.parent.worldToLocal(temp2.set(coords.x - skeleton.x, coords.y - skeleton.y)); target.x = temp2.x; target.y = temp2.y; } else { target.x = coords.x - skeleton.x; target.y = coords.y - skeleton.y; } } }, moved: (x, y) => { for (var i = 0; i < controlBones.length; i++) { var bone = skeleton.findBone(controlBones[i]); if (!bone) continue; renderer.camera.screenToWorld(coords.set(x, y, 0), canvas.width, canvas.height); if (temp.set(skeleton.x + bone.worldX, skeleton.y + bone.worldY, 0).distance(coords) < 30) { selectedBones[i] = bone; } else { selectedBones[i] = null; } } } }); // For the manual hover to work, we need to disable // hidding the 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 :/ var mouseOverControls = true; var mouseOverCanvas = false; document.addEventListener("mousemove", (ev: UIEvent) => { if (ev instanceof MouseEvent) { handleHover(ev.clientX, ev.clientY); } }); document.addEventListener("touchmove", (ev: UIEvent) => { if (ev instanceof TouchEvent) { var touches = ev.changedTouches; if (touches.length > 0) { var touch = touches[0]; handleHover(touch.clientX, touch.clientY); } } }); let handleHover = (mouseX: number, mouseY: number) => { if (!this.config.showControls) return; let popup = findWithClass(this.dom, "spine-player-popup"); mouseOverControls = overlap(mouseX, mouseY, this.playerControls.getBoundingClientRect()); mouseOverCanvas = overlap(mouseX, mouseY, this.canvas.getBoundingClientRect()); clearTimeout(this.cancelId); let hide = popup.length == 0 && !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.length == 0 && !this.paused) { let remove = () => { if (!this.paused) this.playerControls.classList.add("spine-player-controls-hidden"); }; this.cancelId = setTimeout(remove, 1000); } } let overlap = (mouseX: number, mouseY: number, rect: DOMRect | ClientRect): boolean => { let x = mouseX - rect.left; let y = mouseY - rect.top; return x >= 0 && x <= rect.width && y >= 0 && y <= rect.height; } } private play () { this.paused = false; let remove = () => { if (!this.paused) this.playerControls.classList.add("spine-player-controls-hidden"); }; this.cancelId = setTimeout(remove, 1000); this.playButton.classList.remove("spine-player-button-icon-play"); this.playButton.classList.add("spine-player-button-icon-pause"); if (this.config.animation) { if (!this.animationState.getCurrent(0)) { this.setAnimation(this.config.animation); } } } private pause () { this.paused = true; 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"); } public setAnimation (animation: string) { // Determine viewport this.previousViewport = this.currentViewport; let animViewport = this.calculateAnimationViewport(animation); // The calculated animation viewport is the base let viewport: Viewport = { x: animViewport.x, y: animViewport.y, width: animViewport.width, height: animViewport.height, padLeft: "10%", padRight: "10%", padTop: "10%", padBottom: "10%" } // Override with global viewport settings if they exist let globalViewport = this.config.viewport; if (typeof globalViewport.x !== "undefined" && typeof globalViewport.y !== "undefined" && typeof globalViewport.width !== "undefined" && typeof globalViewport.height !== "undefined") { viewport.x = globalViewport.x; viewport.y = globalViewport.y; viewport.width = globalViewport.width; viewport.height = globalViewport.height; } if (typeof globalViewport.padLeft !== "undefined") viewport.padLeft = globalViewport.padLeft; if (typeof globalViewport.padRight !== "undefined") viewport.padRight = globalViewport.padRight; if (typeof globalViewport.padTop !== "undefined") viewport.padTop = globalViewport.padTop; if (typeof globalViewport.padBottom !== "undefined") viewport.padBottom = globalViewport.padBottom; // Override with animation viewport settings given by user for final result. let userAnimViewport = this.config.viewport.animations[animation]; if (userAnimViewport) { if (typeof userAnimViewport.x !== "undefined" && typeof userAnimViewport.y !== "undefined" && typeof userAnimViewport.width !== "undefined" && typeof userAnimViewport.height !== "undefined") { viewport.x = userAnimViewport.x; viewport.y = userAnimViewport.y; viewport.width = userAnimViewport.width; viewport.height = userAnimViewport.height; } if (typeof userAnimViewport.padLeft !== "undefined") viewport.padLeft = userAnimViewport.padLeft; if (typeof userAnimViewport.padRight !== "undefined") viewport.padRight = userAnimViewport.padRight; if (typeof userAnimViewport.padTop !== "undefined") viewport.padTop = userAnimViewport.padTop; if (typeof userAnimViewport.padBottom !== "undefined") viewport.padBottom = userAnimViewport.padBottom; } // Translate percentage paddings 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); // Adjust x, y, width, and height by padding. this.currentViewport = viewport; this.viewportTransitionStart = performance.now(); this.animationState.clearTracks(); this.skeleton.setToSetupPose(); this.animationState.setAnimation(0, animation, true); } private percentageToWorldUnit(size: number, percentageOrAbsolute: string | number): number { if (typeof percentageOrAbsolute === "string") { return size * parseFloat(percentageOrAbsolute.substr(0, percentageOrAbsolute.length - 1)) / 100; } else { return percentageOrAbsolute; } } private calculateAnimationViewport (animationName: string) { let animation = this.skeleton.data.findAnimation(animationName); this.animationState.clearTracks(); this.skeleton.setToSetupPose() this.animationState.setAnimationWith(0, animation, true); let steps = 100; let stepTime = animation.duration > 0 ? animation.duration / steps : 0; let minX = 100000000; let maxX = -100000000; let minY = 100000000; let maxY = -100000000; let offset = new spine.Vector2(); let size = new spine.Vector2(); for (var i = 0; i < steps; i++) { this.animationState.update(stepTime); this.animationState.apply(this.skeleton); this.skeleton.updateWorldTransform(); this.skeleton.getBounds(offset, size); 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 { console.log("Bounds of animation " + animationName + " are NaN"); } } offset.x = minX; offset.y = minY; size.x = maxX - minX; size.y = maxY - minY; return { x: offset.x, y: offset.y, width: size.x, height: size.y }; } public stopRendering() { this.stopRequestAnimationFrame = true; } } function isContained(dom: HTMLElement, needle: HTMLElement): boolean { if (dom === needle) return true; let findRecursive = (dom: HTMLElement, needle: HTMLElement) => { for(var i = 0; i < dom.children.length; i++) { let child = dom.children[i] as HTMLElement; if (child === needle) return true; if (findRecursive(child, needle)) return true; } return false; }; return findRecursive(dom, needle); } function findWithId(dom: HTMLElement, id: string): HTMLElement[] { let found = new Array() let findRecursive = (dom: HTMLElement, id: string, found: HTMLElement[]) => { for(var i = 0; i < dom.children.length; i++) { let child = dom.children[i] as HTMLElement; if (child.id === id) found.push(child); findRecursive(child, id, found); } }; findRecursive(dom, id, found); return found; } function findWithClass(dom: HTMLElement, className: string): HTMLElement[] { let found = new Array() let findRecursive = (dom: HTMLElement, className: string, found: HTMLElement[]) => { for(var i = 0; i < dom.children.length; i++) { let child = dom.children[i] as HTMLElement; if (child.classList.contains(className)) found.push(child); findRecursive(child, className, found); } }; findRecursive(dom, className, found); return found; } function createElement(html: string): HTMLElement { let dom = document.createElement("div"); dom.innerHTML = html; return dom.children[0] as HTMLElement; } function removeClass(elements: HTMLCollection, clazz: string) { for (var i = 0; i < elements.length; i++) { elements[i].classList.remove(clazz); } } function escapeHtml(str: string) { if (!str) return ""; return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } }