UNPKG

@esotericsoftware/spine-player

Version:
854 lines 116 kB
/****************************************************************************** * 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 { AnimationState, AnimationStateData, AtlasAttachmentLoader, Color, MathUtils, MixBlend, MixDirection, Physics, Skeleton, SkeletonBinary, SkeletonJson, TextureFilter, TimeKeeper, Vector2 } from "@esotericsoftware/spine-core"; import { AssetManager, Input, LoadingScreen, ManagedWebGLRenderingContext, ResizeMode, SceneRenderer, Vector3 } from "@esotericsoftware/spine-webgl"; export class SpinePlayer { config; parent; dom; canvas = null; context = null; sceneRenderer = null; loadingScreen = null; assetManager = null; bg = new Color(); bgFullscreen = new Color(); playerControls = null; timelineSlider = null; playButton = null; skinButton = null; animationButton = null; playTime = 0; selectedBones = []; cancelId = 0; popup = null; /* True if the player is unable to load or render the skeleton. */ error = false; /* The player's skeleton. Null until loading is complete (access after config.success). */ skeleton = null; /* The animation state controlling the skeleton. Null until loading is complete (access after config.success). */ animationState = null; paused = true; speed = 1; time = new TimeKeeper(); stopRequestAnimationFrame = false; disposed = false; viewport = {}; currentViewport = {}; previousViewport = {}; viewportTransitionStart = 0; eventListeners = []; constructor(parent, config) { this.config = config; 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.message, e); } 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() { 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, event, func) { this.eventListeners.push({ target: target, event: event, func: func }); target.addEventListener(event, func); } validateConfig(config) { if (!config) throw new Error("A configuration object must be passed to to new SpinePlayer()."); if (config.skelUrl) config.binaryUrl = config.skelUrl; if (!config.jsonUrl && !config.binaryUrl) throw new Error("A URL must be specified for the skeleton JSON or binary file."); if (!config.atlasUrl) throw new Error("A URL must be specified for the atlas file."); 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 = {}; 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; } initialize() { 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"); 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 \nPlease use the latest version of Firefox, Chrome, Edge, or Safari.", e); 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.jsonUrl) this.assetManager.loadJson(config.jsonUrl); else this.assetManager.loadBinary(config.binaryUrl); this.assetManager.loadTextureAtlas(config.atlasUrl); 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]; let controls = this.playerControls.children; let timeline = controls[0]; let buttons = controls[1].children; this.playButton = buttons[0]; let speedButton = buttons[2]; this.animationButton = buttons[3]; this.skinButton = buttons[4]; let settingsButton = buttons[5]; let fullscreenButton = buttons[6]; let logoButton = buttons[7]; 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.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; player.onfullscreenchange = fullscreenChanged; player.onwebkitfullscreenchange = fullscreenChanged; let doc = document; 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; } 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.atlasUrl); 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 = 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.update(true); } // Load skeleton data. let skeletonData; if (config.jsonUrl) { try { let jsonData = this.assetManager.remove(config.jsonUrl); if (!jsonData) throw new Error("Empty JSON data."); if (config.jsonField) { jsonData = jsonData[config.jsonField]; if (!jsonData) throw new Error("JSON field does not exist: " + config.jsonField); } let json = new SkeletonJson(new AtlasAttachmentLoader(atlas)); skeletonData = json.readSkeletonData(jsonData); } catch (e) { this.showError(`Error: Could not load skeleton JSON.\n${e.message}`, e); return; } } else { let binaryData = this.assetManager.remove(config.binaryUrl); let binary = new SkeletonBinary(new AtlasAttachmentLoader(atlas)); try { skeletonData = binary.readSkeletonData(binaryData); } catch (e) { this.showError(`Error: Could not load skeleton binary.\n${e.message}`, e); 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) => { 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(); } } setupInput() { let config = this.config; let controlBones = config.controlBones; if (!controlBones.length && !config.showControls) return; let selectedBones = this.selectedBones = new Array(controlBones.length); let canvas = this.canvas; let target = 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, y) { mouse.set(x, canvas.clientHeight - y, 0); offset.x = offset.y = 0; let bestDistance = 24, index = 0; let best = 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) => { if (ev instanceof MouseEvent) handleHover(ev.clientX, ev.clientY); }); this.addEventListener(document, "touchmove", (ev) => { if (ev instanceof TouchEvent) { let touches = ev.changedTouches; if (touches.length) { let touch = touches[0]; handleHover(touch.clientX, touch.clientY); } } }); let overlap = (mouseX, mouseY, rect) => { 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, mouseY) => { 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, loop = true) { animation = this.setViewport(animation); return this.animationState.setAnimationWith(0, animation, loop); } /* Adds a new animation and viewport on track 0. */ addAnimation(animation, loop = true, delay = 0) { animation = this.setViewport(animation); return this.animationState.addAnimationWith(0, animation, loop, delay); } /* Sets the viewport for the specified animation. */ setViewport(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%" }; 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; } percentageToWorldUnit(size, percentageOrAbsolute) { if (typeof percentageOrAbsolute === "string") return size * parseFloat(percentageOrAbsolute.substr(0, percentageOrAbsolute.length - 1)) / 100; return percentageOrAbsolute; } calculateAnimationViewport(animation, 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(); 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); 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; } drawFrame(requestNextFrame = true) { try { if (this.error) return; if (this.disposed) return; if (requestNextFrame && !this.stopRequestAnimationFrame) requestAnimationFrame(() => this.drawFrame()); let doc = document; 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) { this.animationState.update(playDelta); this.animationState.apply(skeleton); 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; viewport.y = this.currentViewport.y - this.currentViewport.padBottom; viewport.width = this.currentViewport.width + this.currentViewport.padLeft + this.currentViewport.padRight; viewport.height = this.currentViewport.height + this.currentViewport.padBottom + this.currentViewport.padTop; if (this.previousViewport) { let transitionAlpha = (performance.now() - this.viewportTransitionStart) / 1000 / config.viewport.transitionTime; if (transitionAlpha < 1) { let x = this.previousViewport.x - this.previousViewport.padLeft; let y = this.previousViewport.y - this.previousViewport.padBottom; let width = this.previousViewport.width + this.previousViewport.padLeft + this.previousViewport.padRight; let height = this.previousViewport.height + this.previousViewport.padBottom + this.previousViewport.padTop; 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.message}`, e); } } stopRendering() { this.stopRequestAnimationFrame = true; } hidePopup(id) { return this.popup != null && this.popup.hide(id); } showSpeedDialog(speedButton) { 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(); } showAnimationsDialog(animationsButton) { 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(); } showSkinsDialog(skinButton) { 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(); } showSettingsDialog(settingsButton) { 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, /*