From 4536789e39c5dfb2d83bb952a0592e6f9e8137ad Mon Sep 17 00:00:00 2001 From: Davide Tantillo Date: Wed, 25 Sep 2024 17:11:28 +0200 Subject: [PATCH] WIP - Refactor + animation and skin attribute change will reinit the widget --- spine-ts/spine-core/src/AssetManagerBase.ts | 46 ++ spine-ts/spine-webgl/example/canvas9.html | 10 +- .../src/SpineCanvasOverlay.bkp1.ts | 627 -------------- .../src/SpineCanvasOverlay.bkpthursday.ts | 580 ------------- .../src/SpineWebComponentWidget.ts | 763 +++++++++--------- 5 files changed, 453 insertions(+), 1573 deletions(-) delete mode 100644 spine-ts/spine-webgl/src/SpineCanvasOverlay.bkp1.ts delete mode 100644 spine-ts/spine-webgl/src/SpineCanvasOverlay.bkpthursday.ts diff --git a/spine-ts/spine-core/src/AssetManagerBase.ts b/spine-ts/spine-core/src/AssetManagerBase.ts index 20973a360..627edef84 100644 --- a/spine-ts/spine-core/src/AssetManagerBase.ts +++ b/spine-ts/spine-core/src/AssetManagerBase.ts @@ -273,6 +273,52 @@ export class AssetManagerBase implements Disposable { }); } + // Promisified versions of load function + async loadBinaryAsync(path: string) { + return new Promise((resolve, reject) => { + this.loadBinary(path, + (_, binary) => resolve(binary), + (_, message) => reject(message), + ); + }); + } + + async loadJsonAsync(path: string) { + return new Promise((resolve, reject) => { + this.loadJson(path, + (_, object) => resolve(object), + (_, message) => reject(message), + ); + }); + } + + async loadTextureAsync(path: string) { + return new Promise((resolve, reject) => { + this.loadTexture(path, + (_, texture) => resolve(texture), + (_, message) => reject(message), + ); + }); + } + + async loadTextureAtlasAsync(path: string) { + return new Promise((resolve, reject) => { + this.loadTextureAtlas(path, + (_, atlas) => resolve(atlas), + (_, message) => reject(message), + ); + }); + } + + async loadTextureAtlasButNoTexturesAsync(path: string) { + return new Promise((resolve, reject) => { + this.loadTextureAtlasButNoTextures(path, + (_, atlas) => resolve(atlas), + (_, message) => reject(message), + ); + }); + } + get (path: string) { return this.assets[this.pathPrefix + path]; } diff --git a/spine-ts/spine-webgl/example/canvas9.html b/spine-ts/spine-webgl/example/canvas9.html index 30af77349..827995575 100644 --- a/spine-ts/spine-webgl/example/canvas9.html +++ b/spine-ts/spine-webgl/example/canvas9.html @@ -1198,8 +1198,8 @@ // create the widget const widgetSection = spine.createSpineWidget({ - atlas: "assets/chibi-stickers-pma.atlas", - skeleton: "assets/chibi-stickers.json", + atlasPath: "assets/chibi-stickers-pma.atlas", + skeletonPath: "assets/chibi-stickers.json", animation, skin, pages, @@ -1246,8 +1246,8 @@ skins.forEach((skin, i) => { // create the widget const widgetSection = spine.createSpineWidget({ - atlas: "assets/chibi-stickers-pma.atlas", - skeleton: "assets/chibi-stickers.json", + atlasPath: "assets/chibi-stickers-pma.atlas", + skeletonPath: "assets/chibi-stickers.json", animation, skin, pages, @@ -1826,7 +1826,7 @@ stretchyman.update = (canvas, delta, skeleton, state) => { atlas="assets/celestial-circus-pma.atlas" skeleton="assets/celestial-circus-pro.skel" animation="wings-and-feet" - draggable="true" + draggable > diff --git a/spine-ts/spine-webgl/src/SpineCanvasOverlay.bkp1.ts b/spine-ts/spine-webgl/src/SpineCanvasOverlay.bkp1.ts deleted file mode 100644 index ba4e7e021..000000000 --- a/spine-ts/spine-webgl/src/SpineCanvasOverlay.bkp1.ts +++ /dev/null @@ -1,627 +0,0 @@ -/****************************************************************************** - * 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 { SpineCanvas, SpineCanvasApp, AtlasAttachmentLoader, SkeletonBinary, SkeletonJson, Skeleton, Animation, AnimationState, AnimationStateData, Physics, Vector2, Vector3, ResizeMode, Color, MixBlend, MixDirection, SceneRenderer, SkeletonData, Input } from "./index.js"; - -interface Rectangle { - x: number, - y: number, - width: number, - height: number, -} - -interface OverlaySkeletonOptions { - atlasPath: string, - skeletonPath: string, - scale: number, - animation?: string, - skeletonData?: SkeletonData, - update?: UpdateSpineFunction; -} - -type UpdateSpineFunction = (canvas: SpineCanvas, delta: number, skeleton: Skeleton, state: AnimationState) => void; - -interface OverlayHTMLOptions { - element: HTMLElement, - mode?: OverlayElementMode, - debug?: boolean, - offsetX?: number, - offsetY?: number, - xAxis?: number, - yAxis?: number, - draggable?: boolean, -} - -type OverlayHTMLElement = Required & { element: HTMLElement, worldOffsetX: number, worldOffsetY: number, dragging: boolean, dragX: number, dragY: number }; - -type OverlayElementMode = 'inside' | 'origin'; - -/** Manages the life-cycle and WebGL context of a {@link SpineCanvasOverlay}. */ -export class SpineCanvasOverlay { - - private spineCanvas:SpineCanvas; - private canvas:HTMLCanvasElement; - private input:Input; - - private skeletonList = new Array<{ - skeleton: Skeleton, - state: AnimationState, - bounds: Rectangle, - htmlOptionsList: Array, - update?: UpdateSpineFunction, - }>(); - - private resizeObserver:ResizeObserver; - private disposed = false; - - private currentTranslateX = 0; - private currentTranslateY = 0; - private additionalPixelsBottom = 200; - private offsetHeight = 50; - private offsetHeightDraw: number; - /** Constructs a new spine canvas, rendering to the provided HTML canvas. */ - constructor () { - this.canvas = document.createElement('canvas'); - document.body.appendChild(this.canvas); - this.canvas.style.position = "absolute"; - this.canvas.style.top = "0"; - this.canvas.style.left = "0"; - this.canvas.style.setProperty("pointer-events", "none"); - this.offsetHeightDraw = this.offsetHeight; - this.canvas.style.transform =`translate(${this.currentTranslateX}px,${this.currentTranslateY}px)`; - // this.canvas.style.display = "inline"; - // this.canvas.style.overflow = "hidden"; // useless - // this.canvas.style.setProperty("will-change", "transform"); // performance seems to be even worse with this uncommented - this.updateCanvasSize(); - - this.resizeObserver = new ResizeObserver(() => { - this.updateCanvasSize(); - this.spineCanvas.renderer.resize(ResizeMode.Expand); - }); - this.resizeObserver.observe(document.body); - - window.addEventListener('scroll', this.scrollHandler); - - - this.spineCanvas = new SpineCanvas(this.canvas, { app: this.setupSpineCanvasApp() }); - - this.input = new Input(document.body, false); - this.setupDragUtility(); - } - - // add a skeleton to the overlay and set the bounds to the given animation or to the setup pose - public async addSkeleton( - skeletonOptions: OverlaySkeletonOptions, - htmlOptionsList: Array | OverlayHTMLOptions | Array | HTMLElement | NodeList = [], - ) { - const { atlasPath, skeletonPath, scale = 1, animation, skeletonData: skeletonDataInput, update } = skeletonOptions; - const isBinary = skeletonPath.endsWith(".skel"); - await Promise.all([ - isBinary ? this.loadBinary(skeletonPath) : this.loadJson(skeletonPath), - this.loadTextureAtlas(atlasPath), - ]); - - const atlas = this.spineCanvas.assetManager.require(atlasPath); - const atlasLoader = new AtlasAttachmentLoader(atlas); - - const skeletonLoader = isBinary ? new SkeletonBinary(atlasLoader) : new SkeletonJson(atlasLoader); - skeletonLoader.scale = scale; - - const skeletonFile = this.spineCanvas.assetManager.require(skeletonPath); - const skeletonData = skeletonDataInput ?? skeletonLoader.readSkeletonData(skeletonFile); - - const skeleton = new Skeleton(skeletonData); - const animationStateData = new AnimationStateData(skeletonData); - const state = new AnimationState(animationStateData); - - let animationData; - if (animation) { - state.setAnimation(0, animation, true); - animationData = animation ? skeleton.data.findAnimation(animation)! : undefined; - } - const bounds = this.calculateAnimationViewport(skeleton, animationData); - - let list: Array; - if (htmlOptionsList instanceof HTMLElement) htmlOptionsList = [htmlOptionsList] as Array; - if (htmlOptionsList instanceof NodeList) htmlOptionsList = Array.from(htmlOptionsList) as Array; - if ('element' in htmlOptionsList) htmlOptionsList = [htmlOptionsList] as Array; - - if (htmlOptionsList.length > 0 && htmlOptionsList[0] instanceof HTMLElement) { - list = htmlOptionsList.map(element => ({ element } as OverlayHTMLOptions)); - } else { - list = htmlOptionsList as Array; - } - - const mapList = list.map(({ element, mode: givenMode, debug = false, offsetX = 0, offsetY = 0, xAxis = 0, yAxis = 0, draggable = false, }, i) => { - const mode = givenMode ?? 'inside'; - if (mode == 'inside' && i > 0) { - console.warn("inside option works with multiple html elements only if the elements have the same dimension" - + "This is because the skeleton is scaled to stay into the div." - + "You can call addSkeleton several time (skeleton data can be reuse, if given)."); - } - return { - element: element as HTMLElement, - mode, - debug, - offsetX, - offsetY, - xAxis, - yAxis, - draggable, - dragX: 0, - dragY: 0, - worldOffsetX: 0, - worldOffsetY: 0, - dragging: false, - } - }); - this.skeletonList.push({ skeleton, state, update, bounds, htmlOptionsList: mapList }); - - return { skeleton, state }; - } - - // calculate bounds of the current animation on track 0, then set it - public recalculateBounds(skeleton: Skeleton) { - const element = this.skeletonList.find(element => element.skeleton === skeleton); - if (!element) return; - const track = element.state.getCurrent(0); - const animation = track?.animation as (Animation | undefined); - const bounds = this.calculateAnimationViewport(skeleton, animation); - this.setBounds(skeleton, bounds); - } - - // set the given bounds on the current skeleton - // bounds is used to center the skeleton in inside mode and as a input area for click events - public setBounds(skeleton: Skeleton, bounds: Rectangle) { - bounds.x /= skeleton.scaleX; - bounds.y /= skeleton.scaleY; - bounds.width /= skeleton.scaleX; - bounds.height /= skeleton.scaleY; - const element = this.skeletonList.find(element => element.skeleton === skeleton); - if (element) { - element.bounds = bounds; - } - } - - /* - * Load assets utilities - */ - - public async loadBinary(path: string) { - return new Promise((resolve, reject) => { - this.spineCanvas.assetManager.loadBinary(path, - (_, binary) => resolve(binary), - (_, message) => reject(message), - ); - }); - } - - public async loadJson(path: string) { - return new Promise((resolve, reject) => { - this.spineCanvas.assetManager.loadJson(path, - (_, object) => resolve(object), - (_, message) => reject(message), - ); - }); - } - - public async loadTextureAtlas(path: string) { - return new Promise((resolve, reject) => { - this.spineCanvas.assetManager.loadTextureAtlas(path, - (_, atlas) => resolve(atlas), - (_, message) => reject(message), - ); - }); - } - - /* - * Init utilities - */ - - private setupSpineCanvasApp(): SpineCanvasApp { - const red = new Color(1, 0, 0, 1); - const green = new Color(0, 1, 0, 1); - const blue = new Color(0, 0, 1, 1); - - return { - update: (canvas: SpineCanvas, delta: number) => { - this.skeletonList.forEach(({ skeleton, state, update, htmlOptionsList }) => { - if (htmlOptionsList.length === 0) return; - if (update) update(canvas, delta, skeleton, state) - else { - state.update(delta); - state.apply(skeleton); - skeleton.update(delta); - skeleton.updateWorldTransform(Physics.update); - } - }); - (document.body.querySelector("#fps")! as HTMLElement).innerText = canvas.time.framesPerSecond.toFixed(2) + " fps"; - }, - - render: (canvas: SpineCanvas) => { - // canvas.clear(1, 0, 0, .1); - let renderer = canvas.renderer; - renderer.begin(); - - const devicePixelRatio = window.devicePixelRatio; - const tempVector = new Vector3(); - this.skeletonList.forEach(({ skeleton, htmlOptionsList, bounds }) => { - if (htmlOptionsList.length === 0) return; - - let { x: ax, y: ay, width: aw, height: ah } = bounds; - - htmlOptionsList.forEach((list) => { - const { element, mode, debug, offsetX, offsetY, xAxis, yAxis, dragX, dragY } = list; - const divBounds = element.getBoundingClientRect(); - // divBounds.y += this.offsetHeightDraw; - - let x = 0, y = 0; - if (mode === 'inside') { - // scale ratio - const scaleWidth = divBounds.width * devicePixelRatio / aw; - const scaleHeight = divBounds.height * devicePixelRatio / ah; - - // attempt to use width ratio - let ratio = scaleWidth; - let scaledW = aw * ratio; - let scaledH = ah * ratio; - - // if scaled height is bigger than div height, use height ratio instead - if (scaledH > divBounds.height * devicePixelRatio) ratio = scaleHeight; - - // get the center of the bounds - const boundsX = (ax + aw / 2) * ratio; - const boundsY = (ay + ah / 2) * ratio; - - // get the center of the div in world coordinate - // const divX = divBounds.x + divBounds.width / 2 + window.scrollX; - // const divY = divBounds.y - 1 + divBounds.height / 2 + window.scrollY; - const divX = divBounds.x + divBounds.width / 2; - const divY = divBounds.y - 1 + divBounds.height / 2; - this.screenToWorld(tempVector, divX, divY); - - // get vertices offset: calculate the distance between div center and bounds center - x = tempVector.x - boundsX; - y = tempVector.y - boundsY; - - // scale the skeleton - skeleton.scaleX = ratio; - skeleton.scaleY = ratio; - } else { - - // TODO: window.devicePixelRatio to manage browser zoom - - // get the center of the div in world coordinate - // const divX = divBounds.x + divBounds.width * xAxis + window.scrollX; - // const divY = divBounds.y + divBounds.height * yAxis + window.scrollY; - const divX = divBounds.x + divBounds.width * xAxis; - const divY = divBounds.y + divBounds.height * yAxis; - this.screenToWorld(tempVector, divX, divY); - // console.log(tempVector.x, tempVector.y) - // console.log(window.devicePixelRatio) - - // get vertices offset - x = tempVector.x; - y = tempVector.y; - } - - - list.worldOffsetX = x + offsetX + dragX; - list.worldOffsetY = y + offsetY + dragY; - - renderer.drawSkeleton(skeleton, true, -1, -1, (vertices, size, vertexSize) => { - for (let i = 0; i < size; i+=vertexSize) { - vertices[i] = vertices[i] + list.worldOffsetX; - vertices[i+1] = vertices[i+1] + list.worldOffsetY; - } - }); - - // drawing debug stuff - if (debug) { - // if (true) { - // show bounds and its center - renderer.rect(false, - ax * skeleton.scaleX + list.worldOffsetX, - ay * skeleton.scaleY + list.worldOffsetY, - aw * skeleton.scaleX, - ah * skeleton.scaleY, - blue); - const bbCenterX = (ax + aw / 2) * skeleton.scaleX + list.worldOffsetX; - const bbCenterY = (ay + ah / 2) * skeleton.scaleY + list.worldOffsetY; - renderer.circle(true, bbCenterX, bbCenterY, 10, blue); - - // show skeleton root - const root = skeleton.getRootBone()!; - renderer.circle(true, root.x + list.worldOffsetX, root.y + list.worldOffsetY, 10, red); - - // show shifted origin - const originX = list.worldOffsetX - dragX - offsetX; - const originY = list.worldOffsetY - dragY - offsetY; - renderer.circle(true, originX, originY, 10, green); - - // show line from origin to bounds center - renderer.line(originX, originY, bbCenterX, bbCenterY, green); - } - - }); - - }); - - renderer.end(); - }, - } - - } - - private setupDragUtility() { - // TODO: we should use document - body might have some margin that offset the click events - Meanwhile I take event pageX/Y - const tempVectorInput = new Vector3(); - - let prevX = 0; - let prevY = 0; - this.input.addListener({ - down: (x, y, ev) => { - const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0]; - tempVectorInput.set(originalEvent.pageX - window.scrollX, originalEvent.pageY - window.scrollY, 0); - this.spineCanvas.renderer.camera.screenToWorld(tempVectorInput, this.canvas.clientWidth, this.canvas.clientHeight); - this.skeletonList.forEach(({ htmlOptionsList, bounds, skeleton }) => { - htmlOptionsList.forEach((element) => { - if (!element.draggable) return; - - const { worldOffsetX, worldOffsetY } = element; - const newBounds: Rectangle = { - x: bounds.x * skeleton.scaleX + worldOffsetX, - y: bounds.y * skeleton.scaleY + worldOffsetY, - width: bounds.width * skeleton.scaleX, - height: bounds.height * skeleton.scaleY, - }; - - if (this.inside(tempVectorInput, newBounds)) { - element.dragging = true; - ev?.preventDefault(); - } - - }); - }); - prevX = tempVectorInput.x; - prevY = tempVectorInput.y; - }, - dragged: (x, y, ev) => { - const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0]; - tempVectorInput.set(originalEvent.pageX - window.scrollX, originalEvent.pageY - window.scrollY, 0); - this.spineCanvas.renderer.camera.screenToWorld(tempVectorInput, this.canvas.clientWidth, this.canvas.clientHeight); - let dragX = tempVectorInput.x - prevX; - let dragY = tempVectorInput.y - prevY; - this.skeletonList.forEach(({ htmlOptionsList, bounds, skeleton }) => { - htmlOptionsList.forEach((element) => { - const { dragging } = element; - - if (dragging) { - skeleton.physicsTranslate(dragX, dragY); - element.dragX += dragX; - element.dragY += dragY; - ev?.preventDefault(); - ev?.stopPropagation() - } - - }); - }); - prevX = tempVectorInput.x; - prevY = tempVectorInput.y; - }, - up: () => { - this.skeletonList.forEach(({ htmlOptionsList }) => { - htmlOptionsList.forEach((element) => { - element.dragging = false; - }); - }); - } - }) - } - - /* - * Resize/scroll utilities - */ - - private updateCanvasSize() { - // const pageSize = this.getPageSize(); - // this.canvas.style.width = pageSize.width + "px"; - // this.canvas.style.height = pageSize.height + "px"; - - // const displayWidth = window.innerWidth; - // const displayHeight = window.innerHeight; - - - const displayWidth = document.documentElement.clientWidth; - const displayHeight = document.documentElement.clientHeight; - this.canvas.style.width = displayWidth + "px"; - this.canvas.style.height = displayHeight + "px"; - // this.canvas.style.height = displayHeight + this.additionalPixelsBottom + "px"; - } - - // private scrollHandler = () => { - // const { width, height } = this.getPageSize() - - // // const viewportHeightWithScrollbar = window.innerHeight; - // // const viewportHeightNoScrollbar = document.documentElement.clientHeight; - // // const scrollbarHeight = viewportHeightWithScrollbar - viewportHeightNoScrollbar; - // // const bottomY = viewportHeightNoScrollbar + window.scrollY; - - // // const viewportWidthWithScrollbar = window.innerWidth; - // // const viewportWidthNoScrollbar = document.documentElement.clientWidth; - // // const scrollbarWidth = viewportWidthWithScrollbar - viewportWidthNoScrollbar; - // // const bottomX = viewportWidthNoScrollbar + window.scrollY; - - // // if (bottomX + scrollbarWidth <= width) { - // // this.currentTranslateX = window.scrollX; - // // } - - // // if (bottomY + scrollbarHeight <= height) { - // // this.currentTranslateY = window.scrollY; - // // } - - // const viewportHeightNoScrollbar = document.documentElement.clientHeight; - // const bottomY = viewportHeightNoScrollbar + window.scrollY; - - // const viewportWidthNoScrollbar = document.documentElement.clientWidth; - // const bottomX = viewportWidthNoScrollbar + window.scrollX; - - // if (bottomX <= width) { - // this.currentTranslateX = window.scrollX; - // } - - // if (window.scrollY <= this.offsetHeight) { - // console.log(window.scrollY); - // console.log("aaa") - // this.currentTranslateY = 0; - // this.offsetHeightDraw = this.offsetHeight; - // } else if (bottomY + this.additionalPixelsBottom - this.offsetHeight <= height) { - // console.log("bbb") - // this.currentTranslateY = window.scrollY - this.offsetHeight; - // this.offsetHeightDraw = this.offsetHeight; - // } else { - // console.log("ccc") - // this.currentTranslateY = window.scrollY - this.additionalPixelsBottom; - // this.offsetHeightDraw = this.additionalPixelsBottom; - // } - - // // translate should be faster - // this.canvas.style.transform =`translate(${this.currentTranslateX}px,${this.currentTranslateY}px)`; - // console.log(`translate(${this.currentTranslateX}px,${this.currentTranslateY}px)`) - // // this.canvas.style.top = `${this.currentTranslateY}px`; - // // this.canvas.style.left = `${this.currentTranslateX}px`; - // } - - private scrollHandler = () => { - const { width, height } = this.getPageSize() - - const viewportHeightNoScrollbar = document.documentElement.clientHeight; - const bottomY = viewportHeightNoScrollbar + window.scrollY; - - const viewportWidthNoScrollbar = document.documentElement.clientWidth; - const bottomX = viewportWidthNoScrollbar + window.scrollY; - - if (bottomX <= width) { - this.currentTranslateX = window.scrollX; - } - - if (bottomY <= height) { - this.currentTranslateY = window.scrollY; - } - - this.canvas.style.transform =`translate(${this.currentTranslateX}px,${this.currentTranslateY}px)`; - - } - - private getPageSize() { - const width = Math.max( - document.body.scrollWidth, - document.documentElement.scrollWidth, - document.body.offsetWidth, - document.documentElement.offsetWidth, - document.documentElement.clientWidth - ); - - const height = Math.max( - document.body.scrollHeight, - document.documentElement.scrollHeight, - document.body.offsetHeight, - document.documentElement.offsetHeight, - document.documentElement.clientHeight - ); - - return { width, height }; - } - - /* - * Other utilities - */ - - private calculateAnimationViewport (skeleton: Skeleton, animation?: Animation): Rectangle { - skeleton.setToSetupPose(); - - let offset = new Vector2(), size = new Vector2(); - const tempArray = new Array(2); - if (!animation) { - skeleton.updateWorldTransform(Physics.update); - skeleton.getBounds(offset, size, tempArray, this.spineCanvas.renderer.skeletonRenderer.getSkeletonClipping()); - return { - x: offset.x, - y: offset.y, - width: size.x, - height: size.y, - } - } - - let steps = 100, stepTime = animation.duration ? animation.duration / steps : 0, time = 0; - let minX = 100000000, maxX = -100000000, minY = 100000000, maxY = -100000000; - for (let i = 0; i < steps; i++, time += stepTime) { - animation.apply(skeleton, time, time, false, [], 1, MixBlend.setup, MixDirection.mixIn); - skeleton.updateWorldTransform(Physics.update); - skeleton.getBounds(offset, size, tempArray, this.spineCanvas.renderer.skeletonRenderer.getSkeletonClipping()); - - if (!isNaN(offset.x) && !isNaN(offset.y) && !isNaN(size.x) && !isNaN(size.y)) { - minX = Math.min(offset.x, minX); - maxX = Math.max(offset.x + size.x, maxX); - minY = Math.min(offset.y, minY); - maxY = Math.max(offset.y + size.y, maxY); - } else - console.error("Animation bounds are invalid: " + animation.name); - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - } - } - - private screenToWorld(vec: Vector3, x: number, y: number) { - vec.set(x, y, 0); - this.spineCanvas.renderer.camera.screenToWorld(vec, this.canvas.clientWidth, this.canvas.clientHeight); - } - - private inside(point: { x: number; y: number }, rectangle: Rectangle): boolean { - return ( - point.x >= rectangle.x && - point.x <= rectangle.x + rectangle.width && - point.y >= rectangle.y && - point.y <= rectangle.y + rectangle.height - ); - } - - // TODO - dispose () { - this.spineCanvas.dispose(); - this.canvas.remove(); - this.disposed = true; - this.resizeObserver.disconnect(); - } -} diff --git a/spine-ts/spine-webgl/src/SpineCanvasOverlay.bkpthursday.ts b/spine-ts/spine-webgl/src/SpineCanvasOverlay.bkpthursday.ts deleted file mode 100644 index 4a5944c23..000000000 --- a/spine-ts/spine-webgl/src/SpineCanvasOverlay.bkpthursday.ts +++ /dev/null @@ -1,580 +0,0 @@ -/****************************************************************************** - * 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 { SpineCanvas, SpineCanvasApp, AtlasAttachmentLoader, SkeletonBinary, SkeletonJson, Skeleton, Animation, AnimationState, AnimationStateData, Physics, Vector2, Vector3, ResizeMode, Color, MixBlend, MixDirection, SceneRenderer, SkeletonData, Input } from "./index.js"; - -interface Rectangle { - x: number, - y: number, - width: number, - height: number, -} - -interface OverlaySkeletonOptions { - atlasPath: string, - skeletonPath: string, - scale: number, - animation?: string, - skeletonData?: SkeletonData, - update?: UpdateSpineFunction; -} - -type UpdateSpineFunction = (canvas: SpineCanvas, delta: number, skeleton: Skeleton, state: AnimationState) => void; - -interface OverlayHTMLOptions { - element: HTMLElement, - mode?: OverlayElementMode, - debug?: boolean, - offsetX?: number, - offsetY?: number, - xAxis?: number, - yAxis?: number, - draggable?: boolean, -} - -type OverlayHTMLElement = Required & { element: HTMLElement, worldOffsetX: number, worldOffsetY: number, dragging: boolean, dragX: number, dragY: number }; - -type OverlayElementMode = 'inside' | 'origin'; - -/** Manages the life-cycle and WebGL context of a {@link SpineCanvasOverlay}. */ -export class SpineCanvasOverlay { - - private spineCanvas:SpineCanvas; - private canvas:HTMLCanvasElement; - private input:Input; - - private skeletonList = new Array<{ - skeleton: Skeleton, - state: AnimationState, - bounds: Rectangle, - htmlOptionsList: Array, - update?: UpdateSpineFunction, - }>(); - - private resizeObserver:ResizeObserver; - private disposed = false; - - private currentTranslateX = 0; - private currentTranslateY = 0; - /** Constructs a new spine canvas, rendering to the provided HTML canvas. */ - constructor () { - this.canvas = document.createElement('canvas'); - document.body.appendChild(this.canvas); - this.canvas.style.position = "absolute"; - this.canvas.style.top = "0"; - this.canvas.style.left = "0"; - this.canvas.style.display = "inline"; - this.canvas.style.setProperty("pointer-events", "none"); - // this.canvas.style.setProperty("will-change", "transform"); - this.updateCanvasSize(); - - this.resizeObserver = new ResizeObserver(() => { - this.updateCanvasSize(); - this.spineCanvas.renderer.resize(ResizeMode.Expand); - }); - this.resizeObserver.observe(document.body); - - window.addEventListener('scroll', () => { - const { width, height } = this.getPageSize() - - // const viewportHeightWithScrollbar = window.innerHeight; - // const viewportHeightNoScrollbar = document.documentElement.clientHeight; - // const scrollbarHeight = viewportHeightWithScrollbar - viewportHeightNoScrollbar; - // const bottomY = viewportHeightNoScrollbar + window.scrollY; - - // const viewportWidthWithScrollbar = window.innerWidth; - // const viewportWidthNoScrollbar = document.documentElement.clientWidth; - // const scrollbarWidth = viewportWidthWithScrollbar - viewportWidthNoScrollbar; - // const bottomX = viewportWidthNoScrollbar + window.scrollY; - - // if (bottomX + scrollbarWidth <= width) { - // this.currentTranslateX = window.scrollX; - // } - - // if (bottomY + scrollbarHeight <= height) { - // this.currentTranslateY = window.scrollY; - // } - - const viewportHeightNoScrollbar = document.documentElement.clientHeight; - const bottomY = viewportHeightNoScrollbar + window.scrollY; - - const viewportWidthNoScrollbar = document.documentElement.clientWidth; - const bottomX = viewportWidthNoScrollbar + window.scrollY; - - if (bottomX <= width) { - this.currentTranslateX = window.scrollX; - } - - if (bottomY <= height) { - this.currentTranslateY = window.scrollY; - } - - this.canvas.style.transform =`translate(${this.currentTranslateX}px,${this.currentTranslateY}px)`; - - }); - - this.spineCanvas = new SpineCanvas(this.canvas, { app: this.setupSpineCanvasApp() }); - - this.input = new Input(document.body, false); - this.setupDragUtility(); - } - - // add a skeleton to the overlay and set the bounds to the given animation or to the setup pose - public async addSkeleton( - skeletonOptions: OverlaySkeletonOptions, - htmlOptionsList: Array | OverlayHTMLOptions | Array | HTMLElement | NodeList = [], - ) { - const { atlasPath, skeletonPath, scale = 1, animation, skeletonData: skeletonDataInput, update } = skeletonOptions; - const isBinary = skeletonPath.endsWith(".skel"); - await Promise.all([ - isBinary ? this.loadBinary(skeletonPath) : this.loadJson(skeletonPath), - this.loadTextureAtlas(atlasPath), - ]); - - const atlas = this.spineCanvas.assetManager.require(atlasPath); - const atlasLoader = new AtlasAttachmentLoader(atlas); - - const skeletonLoader = isBinary ? new SkeletonBinary(atlasLoader) : new SkeletonJson(atlasLoader); - skeletonLoader.scale = scale; - - const skeletonFile = this.spineCanvas.assetManager.require(skeletonPath); - const skeletonData = skeletonDataInput ?? skeletonLoader.readSkeletonData(skeletonFile); - - const skeleton = new Skeleton(skeletonData); - const animationStateData = new AnimationStateData(skeletonData); - const state = new AnimationState(animationStateData); - - let animationData; - if (animation) { - state.setAnimation(0, animation, true); - animationData = animation ? skeleton.data.findAnimation(animation)! : undefined; - } - const bounds = this.calculateAnimationViewport(skeleton, animationData); - - let list: Array; - if (htmlOptionsList instanceof HTMLElement) htmlOptionsList = [htmlOptionsList] as Array; - if (htmlOptionsList instanceof NodeList) htmlOptionsList = Array.from(htmlOptionsList) as Array; - if ('element' in htmlOptionsList) htmlOptionsList = [htmlOptionsList] as Array; - - if (htmlOptionsList.length > 0 && htmlOptionsList[0] instanceof HTMLElement) { - list = htmlOptionsList.map(element => ({ element } as OverlayHTMLOptions)); - } else { - list = htmlOptionsList as Array; - } - - const mapList = list.map(({ element, mode: givenMode, debug = false, offsetX = 0, offsetY = 0, xAxis = 0, yAxis = 0, draggable = false, }, i) => { - const mode = givenMode ?? 'inside'; - if (mode == 'inside' && i > 0) { - console.warn("inside option works with multiple html elements only if the elements have the same dimension" - + "This is because the skeleton is scaled to stay into the div." - + "You can call addSkeleton several time (skeleton data can be reuse, if given)."); - } - return { - element: element as HTMLElement, - mode, - debug, - offsetX, - offsetY, - xAxis, - yAxis, - draggable, - dragX: 0, - dragY: 0, - worldOffsetX: 0, - worldOffsetY: 0, - dragging: false, - } - }); - this.skeletonList.push({ skeleton, state, update, bounds, htmlOptionsList: mapList }); - - return { skeleton, state }; - } - - // calculate bounds of the current animation on track 0, then set it - public recalculateBounds(skeleton: Skeleton) { - const element = this.skeletonList.find(element => element.skeleton === skeleton); - if (!element) return; - const track = element.state.getCurrent(0); - const animation = track?.animation as (Animation | undefined); - const bounds = this.calculateAnimationViewport(skeleton, animation); - this.setBounds(skeleton, bounds); - } - - // set the given bounds on the current skeleton - // bounds is used to center the skeleton in inside mode and as a input area for click events - public setBounds(skeleton: Skeleton, bounds: Rectangle) { - bounds.x /= skeleton.scaleX; - bounds.y /= skeleton.scaleY; - bounds.width /= skeleton.scaleX; - bounds.height /= skeleton.scaleY; - const element = this.skeletonList.find(element => element.skeleton === skeleton); - if (element) { - element.bounds = bounds; - } - } - - /* - * Load assets utilities - */ - - public async loadBinary(path: string) { - return new Promise((resolve, reject) => { - this.spineCanvas.assetManager.loadBinary(path, - (_, binary) => resolve(binary), - (_, message) => reject(message), - ); - }); - } - - public async loadJson(path: string) { - return new Promise((resolve, reject) => { - this.spineCanvas.assetManager.loadJson(path, - (_, object) => resolve(object), - (_, message) => reject(message), - ); - }); - } - - public async loadTextureAtlas(path: string) { - return new Promise((resolve, reject) => { - this.spineCanvas.assetManager.loadTextureAtlas(path, - (_, atlas) => resolve(atlas), - (_, message) => reject(message), - ); - }); - } - - /* - * Init utilities - */ - - private setupSpineCanvasApp(): SpineCanvasApp { - const red = new Color(1, 0, 0, 1); - const green = new Color(0, 1, 0, 1); - const blue = new Color(0, 0, 1, 1); - - return { - update: (canvas: SpineCanvas, delta: number) => { - this.skeletonList.forEach(({ skeleton, state, update, htmlOptionsList }) => { - if (htmlOptionsList.length === 0) return; - if (update) update(canvas, delta, skeleton, state) - else { - state.update(delta); - state.apply(skeleton); - skeleton.update(delta); - skeleton.updateWorldTransform(Physics.update); - } - }); - (document.body.querySelector("#fps")! as HTMLElement).innerText = canvas.time.framesPerSecond.toFixed(2) + " fps"; - }, - - render: (canvas: SpineCanvas) => { - let renderer = canvas.renderer; - renderer.begin(); - - const devicePixelRatio = window.devicePixelRatio; - const tempVector = new Vector3(); - this.skeletonList.forEach(({ skeleton, htmlOptionsList, bounds }) => { - if (htmlOptionsList.length === 0) return; - - let { x: ax, y: ay, width: aw, height: ah } = bounds; - - htmlOptionsList.forEach((list) => { - const { element, mode, debug, offsetX, offsetY, xAxis, yAxis, dragX, dragY } = list; - const divBounds = element.getBoundingClientRect(); - - let x = 0, y = 0; - if (mode === 'inside') { - // scale ratio - const scaleWidth = divBounds.width * devicePixelRatio / aw; - const scaleHeight = divBounds.height * devicePixelRatio / ah; - - // attempt to use width ratio - let ratio = scaleWidth; - let scaledW = aw * ratio; - let scaledH = ah * ratio; - - // if scaled height is bigger than div height, use height ratio instead - if (scaledH > divBounds.height * devicePixelRatio) ratio = scaleHeight; - - // get the center of the bounds - const boundsX = (ax + aw / 2) * ratio; - const boundsY = (ay + ah / 2) * ratio; - - // get the center of the div in world coordinate - // const divX = divBounds.x + divBounds.width / 2 + window.scrollX; - // const divY = divBounds.y - 1 + divBounds.height / 2 + window.scrollY; - const divX = divBounds.x + divBounds.width / 2; - const divY = divBounds.y - 1 + divBounds.height / 2; - this.screenToWorld(tempVector, divX, divY); - - // get vertices offset: calculate the distance between div center and bounds center - x = tempVector.x - boundsX; - y = tempVector.y - boundsY; - - // scale the skeleton - skeleton.scaleX = ratio; - skeleton.scaleY = ratio; - } else { - - // TODO: window.devicePixelRatio to manage browser zoom - - // get the center of the div in world coordinate - // const divX = divBounds.x + divBounds.width * xAxis + window.scrollX; - // const divY = divBounds.y + divBounds.height * yAxis + window.scrollY; - const divX = divBounds.x + divBounds.width * xAxis; - const divY = divBounds.y + divBounds.height * yAxis; - this.screenToWorld(tempVector, divX, divY); - // console.log(tempVector.x, tempVector.y) - // console.log(window.devicePixelRatio) - - // get vertices offset - x = tempVector.x; - y = tempVector.y; - } - - - list.worldOffsetX = x + offsetX + dragX; - list.worldOffsetY = y + offsetY + dragY; - - renderer.drawSkeleton(skeleton, true, -1, -1, (vertices, size, vertexSize) => { - for (let i = 0; i < size; i+=vertexSize) { - vertices[i] = vertices[i] + list.worldOffsetX; - vertices[i+1] = vertices[i+1] + list.worldOffsetY; - } - }); - - // drawing debug stuff - if (debug) { - // if (true) { - // show bounds and its center - renderer.rect(false, - ax * skeleton.scaleX + list.worldOffsetX, - ay * skeleton.scaleY + list.worldOffsetY, - aw * skeleton.scaleX, - ah * skeleton.scaleY, - blue); - const bbCenterX = (ax + aw / 2) * skeleton.scaleX + list.worldOffsetX; - const bbCenterY = (ay + ah / 2) * skeleton.scaleY + list.worldOffsetY; - renderer.circle(true, bbCenterX, bbCenterY, 10, blue); - - // show skeleton root - const root = skeleton.getRootBone()!; - renderer.circle(true, root.x + list.worldOffsetX, root.y + list.worldOffsetY, 10, red); - - // show shifted origin - const originX = list.worldOffsetX - dragX - offsetX; - const originY = list.worldOffsetY - dragY - offsetY; - renderer.circle(true, originX, originY, 10, green); - - // show line from origin to bounds center - renderer.line(originX, originY, bbCenterX, bbCenterY, green); - } - - }); - - }); - - renderer.end(); - }, - } - - } - - private setupDragUtility() { - // TODO: we should use document - body might have some margin that offset the click events - Meanwhile I take event pageX/Y - const tempVectorInput = new Vector3(); - - let prevX = 0; - let prevY = 0; - this.input.addListener({ - down: (x, y, ev) => { - const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0]; - tempVectorInput.set(originalEvent.pageX - window.scrollX, originalEvent.pageY - window.scrollY, 0); - this.spineCanvas.renderer.camera.screenToWorld(tempVectorInput, this.canvas.clientWidth, this.canvas.clientHeight); - this.skeletonList.forEach(({ htmlOptionsList, bounds, skeleton }) => { - htmlOptionsList.forEach((element) => { - if (!element.draggable) return; - - const { worldOffsetX, worldOffsetY } = element; - const newBounds: Rectangle = { - x: bounds.x * skeleton.scaleX + worldOffsetX, - y: bounds.y * skeleton.scaleY + worldOffsetY, - width: bounds.width * skeleton.scaleX, - height: bounds.height * skeleton.scaleY, - }; - - if (this.inside(tempVectorInput, newBounds)) { - element.dragging = true; - ev?.preventDefault(); - } - - }); - }); - prevX = tempVectorInput.x; - prevY = tempVectorInput.y; - }, - dragged: (x, y, ev) => { - const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0]; - tempVectorInput.set(originalEvent.pageX - window.scrollX, originalEvent.pageY - window.scrollY, 0); - this.spineCanvas.renderer.camera.screenToWorld(tempVectorInput, this.canvas.clientWidth, this.canvas.clientHeight); - let dragX = tempVectorInput.x - prevX; - let dragY = tempVectorInput.y - prevY; - this.skeletonList.forEach(({ htmlOptionsList, bounds, skeleton }) => { - htmlOptionsList.forEach((element) => { - const { dragging } = element; - - if (dragging) { - skeleton.physicsTranslate(dragX, dragY); - element.dragX += dragX; - element.dragY += dragY; - ev?.preventDefault(); - ev?.stopPropagation() - } - - }); - }); - prevX = tempVectorInput.x; - prevY = tempVectorInput.y; - }, - up: () => { - this.skeletonList.forEach(({ htmlOptionsList }) => { - htmlOptionsList.forEach((element) => { - element.dragging = false; - }); - }); - } - }) - } - - /* - * Resize utilities - */ - - private updateCanvasSize() { - // const pageSize = this.getPageSize(); - // this.canvas.style.width = pageSize.width + "px"; - // this.canvas.style.height = pageSize.height + "px"; - - // const displayWidth = window.innerWidth; - // const displayHeight = window.innerHeight; - - - const displayWidth = document.documentElement.clientWidth; - const displayHeight = document.documentElement.clientHeight; - this.canvas.style.width = displayWidth + "px"; - this.canvas.style.height = displayHeight + "px"; - } - - private getPageSize() { - const width = Math.max( - document.body.scrollWidth, - document.documentElement.scrollWidth, - document.body.offsetWidth, - document.documentElement.offsetWidth, - document.documentElement.clientWidth - ); - - const height = Math.max( - document.body.scrollHeight, - document.documentElement.scrollHeight, - document.body.offsetHeight, - document.documentElement.offsetHeight, - document.documentElement.clientHeight - ); - - return { width, height }; - } - - /* - * Other utilities - */ - - private calculateAnimationViewport (skeleton: Skeleton, animation?: Animation): Rectangle { - skeleton.setToSetupPose(); - - let offset = new Vector2(), size = new Vector2(); - const tempArray = new Array(2); - if (!animation) { - skeleton.updateWorldTransform(Physics.update); - skeleton.getBounds(offset, size, tempArray, this.spineCanvas.renderer.skeletonRenderer.getSkeletonClipping()); - return { - x: offset.x, - y: offset.y, - width: size.x, - height: size.y, - } - } - - let steps = 100, stepTime = animation.duration ? animation.duration / steps : 0, time = 0; - let minX = 100000000, maxX = -100000000, minY = 100000000, maxY = -100000000; - for (let i = 0; i < steps; i++, time += stepTime) { - animation.apply(skeleton, time, time, false, [], 1, MixBlend.setup, MixDirection.mixIn); - skeleton.updateWorldTransform(Physics.update); - skeleton.getBounds(offset, size, tempArray, this.spineCanvas.renderer.skeletonRenderer.getSkeletonClipping()); - - if (!isNaN(offset.x) && !isNaN(offset.y) && !isNaN(size.x) && !isNaN(size.y)) { - minX = Math.min(offset.x, minX); - maxX = Math.max(offset.x + size.x, maxX); - minY = Math.min(offset.y, minY); - maxY = Math.max(offset.y + size.y, maxY); - } else - console.error("Animation bounds are invalid: " + animation.name); - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - } - } - - private screenToWorld(vec: Vector3, x: number, y: number) { - vec.set(x, y, 0); - this.spineCanvas.renderer.camera.screenToWorld(vec, this.canvas.clientWidth, this.canvas.clientHeight); - } - - private inside(point: { x: number; y: number }, rectangle: Rectangle): boolean { - return ( - point.x >= rectangle.x && - point.x <= rectangle.x + rectangle.width && - point.y >= rectangle.y && - point.y <= rectangle.y + rectangle.height - ); - } - - // TODO - dispose () { - this.spineCanvas.dispose(); - this.canvas.remove(); - this.disposed = true; - this.resizeObserver.disconnect(); - } -} diff --git a/spine-ts/spine-webgl/src/SpineWebComponentWidget.ts b/spine-ts/spine-webgl/src/SpineWebComponentWidget.ts index d7745eca3..94bed38dc 100644 --- a/spine-ts/spine-webgl/src/SpineWebComponentWidget.ts +++ b/spine-ts/spine-webgl/src/SpineWebComponentWidget.ts @@ -27,7 +27,29 @@ * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *****************************************************************************/ -import { AtlasAttachmentLoader, SkeletonBinary, SkeletonJson, Skeleton, Animation, AnimationState, AnimationStateData, Physics, Vector2, Vector3, Color, MixBlend, MixDirection, SceneRenderer, SkeletonData, Input, LoadingScreenWidget, TextureAtlas, Texture, ManagedWebGLRenderingContext, AssetManager, TimeKeeper } from "./index.js"; +import { + Animation, + AnimationState, + AnimationStateData, + AtlasAttachmentLoader, + AssetManager, + Color, + Input, + LoadingScreenWidget, + ManagedWebGLRenderingContext, + MixBlend, + MixDirection, + Physics, + SceneRenderer, + SkeletonBinary, + SkeletonData, + SkeletonJson, + Skeleton, + TextureAtlas, + TimeKeeper, + Vector2, + Vector3, +} from "./index.js"; interface Point { x: number, @@ -43,7 +65,7 @@ type BeforeAfterUpdateSpineWidgetFunction = (skeleton: Skeleton, state: Animatio type UpdateSpineWidgetFunction = (delta: number, skeleton: Skeleton, state: AnimationState) => void; type OffScreenUpdateBehaviourType = "pause" | "update" | "pose"; -function isOffScreenUpdateBehaviourType(value: string): value is OffScreenUpdateBehaviourType { +function isOffScreenUpdateBehaviourType(value: string | null): value is OffScreenUpdateBehaviourType { return ( value === "pause" || value === "update" || @@ -51,8 +73,8 @@ function isOffScreenUpdateBehaviourType(value: string): value is OffScreenUpdate ); } -type ModeType = 'inside' | 'origin'; -function isModeType(value: string): value is ModeType { +type ModeType = "inside" | "origin"; +function isModeType(value: string | null): value is ModeType { return ( value === "inside" || value === "origin" @@ -60,7 +82,7 @@ function isModeType(value: string): value is ModeType { } type FitType = "fill" | "width" | "height" | "contain" | "cover" | "none" | "scaleDown"; -function isFitType(value: string): value is FitType { +function isFitType(value: string | null): value is FitType { return ( value === "fill" || value === "width" || @@ -72,24 +94,48 @@ function isFitType(value: string): value is FitType { ); } -interface WidgetLayoutOptions { +type AttributeTypes = "string" | "number" | "boolean" | "string-number" | "fitType" | "modeType" | "offScreenUpdateBehaviourType"; + +interface WidgetAttributes { + atlasPath: string + skeletonPath: string + scale: number + animation?: string + skin?: string + fit: FitType mode: ModeType - debug: boolean - offsetX: number - offsetY: number xAxis: number yAxis: number - draggable: boolean - fit: FitType + offsetX: number + offsetY: number width: number height: number + draggable: boolean + debug: boolean identifier: string + manualStart: boolean + pages?: Array + clip: boolean + offScreenUpdateBehaviour: OffScreenUpdateBehaviourType + loadingSpinner: boolean +} + +interface WidgetOverridableMethods { + update?: UpdateSpineWidgetFunction; + beforeUpdateWorldTransforms: BeforeAfterUpdateSpineWidgetFunction; + afterUpdateWorldTransforms: BeforeAfterUpdateSpineWidgetFunction; } interface WidgetPublicState { skeleton: Skeleton state: AnimationState bounds: Rectangle + onScreen: boolean + onScreenAtLeastOnce: boolean + loadingPromise: Promise + loading: boolean + started: boolean + textureAtlas: TextureAtlas } interface WidgetInternalState { @@ -101,37 +147,183 @@ interface WidgetInternalState { // TODO: add missing assets to main assets folder (chibi) -class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions, WidgetInternalState, Partial { +class SpineWebComponentWidget extends HTMLElement implements WidgetAttributes, WidgetOverridableMethods, WidgetInternalState, Partial { /** * The URL of the skeleton atlas file (.atlas) + * Connected to `atlas` attribute. */ public atlasPath: string; /** * The URL of the skeleton JSON (.json) or binary (.skel) file + * Connected to `skeleton` attribute. */ public skeletonPath: string; /** * The scale when loading the skeleton data. Default: 1 + * Connected to `scale` attribute. */ public scale = 1; /** * Optional: The name of the animation to be played + * Connected to `animation` attribute. */ - public animation?: string; + public get animation() : string | undefined { + return this._animation; + } + public set animation(value: string | undefined) { + this._animation = value; + this.initWidget(); + } + private _animation?: string /** * Optional: The name of the skin to be set + * Connected to `skin` attribute. */ - public skin?: string; + public get skin() : string | undefined { + return this._skin; + } + public set skin(value: string | undefined) { + this._skin = value; + this.initWidget(); + } + private _skin?: string /** - * Optional: Pass a `SkeletonData`, if you want to avoid creating a new one + * Specify the way the skeleton is sized within the element automatically changing its `scaleX` and `scaleY`. + * It works only with {@link mode} `inside`. Possible values are: + * - `contain`: as large as possible while still containing the skeleton entirely within the element container (Default). + * - `fill`: fill the element container by distorting the skeleton's aspect ratio. + * - `width`: make sure the full width of the source is shown, regardless of whether this means the skeleton overflows the element container vertically. + * - `height`: make sure the full height of the source is shown, regardless of whether this means the skeleton overflows the element container horizontally. + * - `cover`: as small as possible while still covering the entire element container. + * - `scaleDown`: scale the skeleton down to ensure that the skeleton fits within the element container. + * - `none`: display the skeleton without autoscaling it. + * Connected to `fit` attribute. */ - public skeletonData?: SkeletonData; + public fit: FitType = "contain"; + + /** + * Specify the way the skeleton is centered within the div: + * - `inside`: the skeleton bounds center is centered with the div container (Default) + * - `origin`: the skeleton origin is centered with the div container regardless of the bounds. + * Origin does not allow to specify any {@link fit} type and guarantee the skeleton to not be autoscaled. + * Connected to `mode` attribute. + */ + public mode: ModeType = "inside"; + + /** + * The x offset of the skeleton world origin x axis in div width units + * Connected to `x-axis` attribute. + */ + public xAxis = 0; + + /** + * The y offset of the skeleton world origin x axis in div width units + * Connected to `y-axis` attribute. + */ + public yAxis = 0; + + /** + * The x offset of the root in pixels wrt to the skeleton world origin + * Connected to `offset-x` attribute. + */ + public offsetX = 0; + + /** + * The y offset of the root in pixels wrt to the skeleton world origin + * Connected to `offset-y` attribute. + */ + public offsetY = 0; + + /** + * Specify a fixed width for the widget. If at least one of `width` and `height` is > 0, + * the widget will have an actual size and the div reference is the widget itself, not the div parent. + * Connected to `width` attribute. + */ + public get width() : number { + return this._width; + } + public set width(value: number) { + this._width = value; + this.render(); + } + private _width = -1 + + /** + * Specify a fixed height for the widget. If at least one of `width` and `height` is > 0, + * the widget will have an actual size and the div reference is the widget itself, not the div parent. + * Connected to `height` attribute. + */ + public get height() : number { + return this._height; + } + public set height(value: number) { + this._height = value; + this.render(); + } + private _height = -1 + + /** + * If true, the widget is draggable + * Connected to `draggable` attribute. + */ + public draggable = false; + + /** + * If true, some convenience elements are drawn to show the skeleton world origin (green), + * the root (red), and the bounds rectangle (blue) + * Connected to `debug` attribute. + */ + public debug = false; + + /** + * An identifier to obtain a reference to this widget using the getSpineWidget function + * Connected to `identifier` attribute. + */ + public identifier = ""; + + /** + * If true, assets loading are loaded immediately and the skeleton shown as soon as the assets are loaded + * If false, it is necessary to invoke the start method to start the loading process + * Connected to `manual-start` attribute. + */ + public manualStart = false; + + /** + * An array of indexes indicating the atlas pages indexes to be loaded. + * If undefined, all pages are loaded. If empty (default), no page is loaded; + * in this case the user can add later the indexes of the pages they want to load + * and call the loadTexturesInPagesAttribute, to lazily load them. + * Connected to `pages` attribute. + */ + public pages?: Array; + + /** + * If `true`, the skeleton is clipped to the container div bounds. + * Be careful on using this feature because it breaks batching! + * Connected to `clip` attribute. + */ + public clip = false; + + /** + * The widget update/apply behaviour when the skeleton div container is offscreen: + * - `pause`: the state is not updated, neither applied (Default) + * - `update`: the state is updated, but not applied + * - `pose`: the state is updated and applied + * Connected to `offscreen` attribute. + */ + public offScreenUpdateBehaviour: OffScreenUpdateBehaviourType = "pause"; + + /** + * If true, the a Spine loading spinner is shown during asset loading + * Connected to `spinner` attribute. + */ + public loadingSpinner = true; /** * Replace the default state and skeleton update logic for this widget. @@ -151,103 +343,6 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions */ afterUpdateWorldTransforms: BeforeAfterUpdateSpineWidgetFunction= () => {}; - /** - * Specify the way the skeleton is sized within the element automatically changing its `scaleX` and `scaleY`. - * It works only with {@link mode} `inside`. Possible values are: - * - `contain`: as large as possible while still containing the skeleton entirely within the element container (Default). - * - `fill`: fill the element container by distorting the skeleton's aspect ratio. - * - `width`: make sure the full width of the source is shown, regardless of whether this means the skeleton overflows the element container vertically. - * - `height`: make sure the full height of the source is shown, regardless of whether this means the skeleton overflows the element container horizontally. - * - `cover`: as small as possible while still covering the entire element container. - * - `scaleDown`: scale the skeleton down to ensure that the skeleton fits within the element container. - * - `none`: display the skeleton without autoscaling it. - */ - public fit: FitType = "contain"; - - /** - * Specify the way the skeleton is centered within the div: - * - `inside`: the skeleton bounds center is centered with the div container (Default) - * - `origin`: the skeleton origin is centered with the div container regardless of the bounds. - * Origin does not allow to specify any {@link fit} type and guarantee the skeleton to not be autoscaled. - */ - public mode: ModeType = "inside"; - - /** - * The x offset of the skeleton world origin x axis in div width units - */ - public xAxis = 0; - - /** - * The y offset of the skeleton world origin x axis in div width units - */ - public yAxis = 0; - - /** - * The x offset of the root in pixels wrt to the skeleton world origin - */ - public offsetX = 0; - - /** - * The y offset of the root in pixels wrt to the skeleton world origin - */ - public offsetY = 0; - - /** - * Specify a fixed width for the widget. If at least one of `width` and `height` is > 0, - * the widget will have an actual size and the div reference is the widget itself, not the div parent. - */ - public width = -1; - - /** - * Specify a fixed height for the widget. If at least one of `width` and `height` is > 0, - * the widget will have an actual size and the div reference is the widget itself, not the div parent. - */ - public height = -1; - - /** - * If true, the widget is draggable - */ - public draggable = false; - - /** - * If true, some convenience elements are drawn to show the skeleton world origin (green), - * the root (red), and the bounds rectangle (blue) - */ - public debug = false; - - /** - * An identifier to obtain a reference to this widget using the getSpineWidget function - */ - public identifier = ""; - - /** - * If true, assets loading are loaded immediately and the skeleton shown as soon as the assets are loaded - * If false, it is necessary to invoke the start method to start the loading process - */ - public manualStart = false; - - /** - * An array of indexes indicating the atlas pages indexes to be loaded. - * If undefined, all pages are loaded. If empty (default), no page is loaded; - * in this case the user can add later the indexes of the pages they want to load - * and call the loadTexturesInPagesAttribute, to lazily load them. - */ - public pages?: Array; - - /** - * If `true`, the skeleton is clipped to the container div bounds. - * Be careful on using this feature because it breaks batching! - */ - public clip = false; - - /** - * The widget update/apply behaviour when the skeleton div container is offscreen: - * - `pause`: the state is not updated, neither applied (Default) - * - `update`: the state is updated, but not applied - * - `pose`: the state is updated and applied - */ - public offScreenUpdateBehaviour: OffScreenUpdateBehaviourType = "pause"; - /** * The skeleton hosted by this widget. It's ready once assets are loaded. * Safely acces this property by using {@link loadingPromise}. @@ -288,11 +383,6 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions */ public loading = true; - /** - * If true, the a Spine loading spinner is shown during asset loading - */ - public loadingSpinner = true; - /** * A reference to the {@link LoadingScreenWidget} of this widget. * This is instantiated only if it is really necessary. @@ -362,44 +452,79 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions } } + // ---- + // ---- + // ---- + /** + * Optional: Pass a `SkeletonData`, if you want to avoid creating a new one + */ + public skeletonData?: SkeletonData; + + // Reference to the webcomponent shadow root private root: ShadowRoot; + + // Reference to the overlay webcomponent private overlay: SpineWebComponentOverlay; + static attributesDescription: Record = { + atlas: { propertyName: "atlasPath", type: "string" }, + skeleton: { propertyName: "skeletonPath", type: "string" }, + scale: { propertyName: "scale", type: "number" }, + animation: { propertyName: "animation", type: "string" }, + skin: { propertyName: "skin", type: "string" }, + width: { propertyName: "width", type: "number", defaultValue: -1 }, + height: { propertyName: "height", type: "number", defaultValue: -1 }, + draggable: { propertyName: "draggable", type: "boolean" }, + "x-axis": { propertyName: "xAxis", type: "number" }, + "y-axis": { propertyName: "yAxis", type: "number" }, + "offset-x": { propertyName: "offsetX", type: "number" }, + "offset-y": { propertyName: "offsetY", type: "number" }, + identifier: { propertyName: "identifier", type: "string" }, + debug: { propertyName: "debug", type: "boolean" }, + "manual-start": { propertyName: "manualStart", type: "boolean" }, + spinner: { propertyName: "loadingSpinner", type: "boolean" }, + clip: { propertyName: "clip", type: "boolean" }, + pages: { propertyName: "pages", type: "string-number" }, + fit: { propertyName: "fit", type: "fitType", defaultValue: "contain" }, + mode: { propertyName: "mode", type: "modeType", defaultValue: "inside" }, + offscreen: { propertyName: "offScreenUpdateBehaviour", type: "offScreenUpdateBehaviourType", defaultValue: "pause" }, + } + static get observedAttributes(): string[] { return [ - "atlas", - "skeleton", - "scale", - "animation", - "skin", - "fit", - "width", - "height", - "draggable", - "mode", - "x-axis", - "y-axis", - "identifier", - "offset-x", - "offset-y", - "debug", - "manual-start", - "spinner", - "pages", - "offscreen", - "clip", + "atlas", // atlasPath + "skeleton", // skeletonPath + "scale", // scale + "animation", // animation + "skin", // skin + "fit", // fit + "width", // width + "height", // height + "draggable", // draggable + "mode", // mode + "x-axis", // xAxis + "y-axis", // yAxis + "offset-x", // offsetX + "offset-y", // offsetY + "identifier", // identifier + "debug", // debug + "manual-start", // manualStart + "spinner", // loadingSpinner + "pages", // pages + "offscreen", // offScreenUpdateBehaviour + "clip", // clip ]; } constructor() { super(); - this.root = this.attachShadow({ mode: "open" }); + this.root = this.attachShadow({ mode: "closed" }); this.overlay = this.initializeOverlay(); this.atlasPath = "TODO"; this.skeletonPath = "TODO"; - this.debugDragDiv = document.createElement('div'); + this.debugDragDiv = document.createElement("div"); this.debugDragDiv.style.position = "absolute"; this.debugDragDiv.style.backgroundColor = "rgba(0, 1, 1, 0.3)"; this.debugDragDiv.style.setProperty("pointer-events", "none"); @@ -431,112 +556,65 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions }); } - attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { - if (newValue !== null) { - if (name === "identifier") { - this.identifier = newValue; - } - - if (name === "atlas") { - this.atlasPath = newValue; - } - - if (name === "skeleton") { - this.skeletonPath = newValue; - } - - if (name === "skin") { - this.skin = newValue; - } - - if (name === "fit") { - this.fit = isFitType(newValue) ? newValue : "contain"; - } - - if (name === "mode") { - this.mode = isModeType(newValue) ? newValue : "inside"; - } - - if (name === "offscreen") { - this.offScreenUpdateBehaviour = isOffScreenUpdateBehaviourType(newValue) ? newValue : "pause"; - } - - if (name === "x-axis") { - let float = this.xAxis; - float = parseFloat(newValue); - this.xAxis = float; - } - if (name === "y-axis") { - let float = this.yAxis; - float = parseFloat(newValue); - this.yAxis = float; - } - if (name === "offset-x") { - let float = 0; - float = parseInt(newValue); - this.offsetX = float; - } - if (name === "offset-y") { - let float = 0; - float = parseInt(newValue); - this.offsetY = float; - } - - if (name === "scale") { - let scaleFloat = 1; - scaleFloat = parseFloat(newValue); - this.scale = scaleFloat; - } - - if (name === "width") { - let widthFloat = 1; - widthFloat = parseFloat(newValue); - this.width = widthFloat; - } - - if (name === "height") { - let heightFloat = 1; - heightFloat = parseFloat(newValue); - this.height = heightFloat; - } - - if (name === "animation") { - this.animation = newValue; - } - - if (name === "draggable") { - this.draggable = Boolean(newValue); - } - - if (name === "debug") { - this.debug = Boolean(newValue); - } - - if (name === "spinner") { - this.loadingSpinner = Boolean(newValue); - } - - if (name === "manual-start") { - this.manualStart = Boolean(newValue); - } - - if (name === "clip") { - this.clip = Boolean(newValue); - } - - if (name === "pages") { - this.pages = newValue.split(",").reduce((acc, pageIndex) => { - const index = parseInt(pageIndex); - if (!isNaN(index)) acc.push(index); - return acc; - }, [] as Array); - } - } - + private static castBoolean(value: string | null, defaultValue = "") { + return value === "true" || value === "" ? true : false; } - // calculate bounds of the current animation on track 0, then set it - public recalculateBounds() { + private static castString(value: string | null, defaultValue = "") { + return value === null ? defaultValue : value; + } + + private static castNumber(value: string | null, defaultValue = 0) { + if (value === null) return defaultValue; + + const parsed = parseFloat(value); + if (Number.isNaN(parsed)) return defaultValue; + return parsed; + } + + private static castArrayNumber(value: string | null, defaultValue = undefined) { + if (value === null) return defaultValue; + return value.split(",").reduce((acc, pageIndex) => { + const index = parseInt(pageIndex); + if (!isNaN(index)) acc.push(index); + return acc; + }, [] as Array); + } + + private static castValue(type: AttributeTypes, value: string | null, defaultValue?: any) { + switch (type) { + case "string": + return SpineWebComponentWidget.castString(value, defaultValue); + case "number": + return SpineWebComponentWidget.castNumber(value, defaultValue); + case "boolean": + return SpineWebComponentWidget.castBoolean(value, defaultValue); + case "string-number": + return SpineWebComponentWidget.castArrayNumber(value, defaultValue); + case "fitType": + return isFitType(value) ? value : defaultValue; + case "modeType": + return isModeType(value) ? value : defaultValue; + case "offScreenUpdateBehaviourType": + return isOffScreenUpdateBehaviourType(value) ? value : defaultValue; + default: + break; + } + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { + const { type, propertyName, defaultValue } = SpineWebComponentWidget.attributesDescription[name]; + const val = SpineWebComponentWidget.castValue(type, newValue, defaultValue ?? this[propertyName]); + (this as any)[propertyName] = val; + return; + } + + /** + * Recalculates and sets the bounds of the current animation on track 0. + * Useful when animations or skins are set programmatically. + * @returns void + */ + public recalculateBounds(): void { const { skeleton, state } = this; if (!skeleton || !state) return; const track = state.getCurrent(0); @@ -545,9 +623,15 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions this.setBounds(bounds); } - // set the given bounds on the current skeleton - // bounds is used to center the skeleton in inside mode and as a input area for click events - public setBounds(bounds: Rectangle) { + /** + * Set the given bounds on the current skeleton. + * Useful when you want you skeleton to have a fixed size, or you want to + * focus a certain detail of the skeleton. If the skeleton overflow the element container + * consider setting {@link clip} to `true`. + * @param bounds + * @returns + */ + public setBounds(bounds: Rectangle): void { const { skeleton } = this; if (!skeleton) return; bounds.x /= skeleton.scaleX; @@ -557,15 +641,10 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions this.bounds = bounds; } - private initializeOverlay(): SpineWebComponentOverlay { - let overlay = document.querySelector("spine-overlay") as SpineWebComponentOverlay; - if (!overlay) { - overlay = document.createElement("spine-overlay") as SpineWebComponentOverlay; - document.body.appendChild(overlay); - } - return overlay; - } - + /** + * Starts the widget. Starting the widget means to load the assets currently set into + * {@link atlasPath} and {@link skeletonPath}. + */ public start() { if (this.started) { console.warn("If you want to start again the widget, first reset it"); @@ -578,20 +657,37 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions }); } - public async loadTexturesInPagesAttribute(atlas: TextureAtlas) { + /** + * Loads the texture pages in the given `atlas` corresponding to the indexes set into {@link pages}. + * This method is automatically called during asset loading. When `pages` is undefined (default), + * all pages are loaded. This method is useful when you want to load a subset of pages programmatically. + * In that case, set `pages` to an empty array at the beginning. + * Then set the pages you want to load and invoke this method. + * @param atlas the `TextureAtlas` from which to get the `TextureAtlasPage`s + * @returns The list of loaded assets + */ + public async loadTexturesInPagesAttribute(atlas: TextureAtlas): Promise> { const pagesIndexToLoad = this.pages ?? atlas.pages.map((_, i) => i); // if no pages provided, loads all - - const atlasPath = this.atlasPath.includes('/') ? this.atlasPath.substring(0, this.atlasPath.lastIndexOf('/') + 1) : ''; - const promisePageList: Array> = []; + const atlasPath = this.atlasPath.includes("/") ? this.atlasPath.substring(0, this.atlasPath.lastIndexOf("/") + 1) : ""; + const promisePageList: Array> = []; pagesIndexToLoad.forEach((index) => { const page = atlas.pages[index]; - const promiseTextureLoad = this.loadTexture(`${atlasPath}${page.name}`).then(texture => page.setTexture(texture)); + const promiseTextureLoad = this.overlay.assetManager.loadTextureAsync(`${atlasPath}${page.name}`).then(texture => page.setTexture(texture)); promisePageList.push(promiseTextureLoad); }); return Promise.all(promisePageList) } + /** + * @returns The `HTMLElement` where the widget is hosted. + */ + public getHTMLElementReference(): HTMLElement { + return this.width <= 0 || this.width <= 0 + ? this.parentElement! + : this; + } + // add a skeleton to the overlay and set the bounds to the given animation or to the setup pose private async loadSkeleton() { this.loading = true; @@ -605,8 +701,8 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions // - []: no page is loaded // - undefined: all pages are loaded (default) await Promise.all([ - isBinary ? this.loadBinary(skeletonPath) : this.loadJson(skeletonPath), - this.loadTextureAtlasButNoTextures(atlasPath).then(atlas => this.loadTexturesInPagesAttribute(atlas)), + isBinary ? this.overlay.assetManager.loadBinaryAsync(skeletonPath) : this.overlay.assetManager.loadJsonAsync(skeletonPath), + this.overlay.assetManager.loadTextureAtlasButNoTexturesAsync(atlasPath).then(atlas => this.loadTexturesInPagesAttribute(atlas)), ]); const atlas = this.overlay.assetManager.require(atlasPath); @@ -616,21 +712,15 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions skeletonLoader.scale = scale; const skeletonFile = this.overlay.assetManager.require(skeletonPath); - const skeletonData = skeletonDataInput ?? skeletonLoader.readSkeletonData(skeletonFile); + const skeletonData = (skeletonDataInput || this.skeleton?.data) ?? skeletonLoader.readSkeletonData(skeletonFile); const skeleton = new Skeleton(skeletonData); const animationStateData = new AnimationStateData(skeletonData); const state = new AnimationState(animationStateData); - if (skin) { - skeleton.setSkinByName(skin); - } - - let animationData; - if (animation) { - state.setAnimation(0, animation, true); - animationData = animation ? skeleton.data.findAnimation(animation)! : undefined; - } + this.skeleton = skeleton; + this.state = state; + this.textureAtlas = atlas; // ideally we would know the dpi and the zoom, however they are combined // to simplify we just assume that the user wants to load the skeleton at scale 1 @@ -640,19 +730,18 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions // skeleton.scaleX = this.currentScaleDpi; // skeleton.scaleY = this.currentScaleDpi; - this.skeleton = skeleton; - this.state = state; - this.textureAtlas = atlas; + this.initWidget(); - const bounds = this.calculateAnimationViewport(animationData); - this.bounds = bounds; return this; } - public getHTMLElementReference(): HTMLElement { - return this.width <= 0 || this.width <= 0 - ? this.parentElement! - : this; + private initWidget() { + const { skeleton, state, animation, skin } = this; + + if (skin) skeleton?.setSkinByName(skin); + if (animation) state?.setAnimation(0, animation, true); + + this.recalculateBounds(); } private render(): void { @@ -678,54 +767,17 @@ class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions `; } - /* - * Load assets utilities - */ - - public async loadBinary(path: string) { - return new Promise((resolve, reject) => { - this.overlay.assetManager.loadBinary(path, - (_, binary) => resolve(binary), - (_, message) => reject(message), - ); - }); - } - - public async loadJson(path: string) { - return new Promise((resolve, reject) => { - this.overlay.assetManager.loadJson(path, - (_, object) => resolve(object), - (_, message) => reject(message), - ); - }); - } - - public async loadTexture(path: string) { - return new Promise((resolve, reject) => { - this.overlay.assetManager.loadTexture(path, - (_, texture) => resolve(texture), - (_, message) => reject(message), - ); - }); - } - - public async loadTextureAtlas(path: string) { - return new Promise((resolve, reject) => { - this.overlay.assetManager.loadTextureAtlas(path, - (_, atlas) => resolve(atlas), - (_, message) => reject(message), - ); - }); - } - - public async loadTextureAtlasButNoTextures(path: string) { - return new Promise((resolve, reject) => { - this.overlay.assetManager.loadTextureAtlasButNoTextures(path, - (_, atlas) => resolve(atlas), - (_, message) => reject(message), - ); - }); - } + // Create a new overlay webcomponent, if no one exists yet. + // TODO: allow the possibility to instantiate multiple overlay (eg: background, foreground), + // to give them an identifier, and to specify which overlay is assigned to a widget + private initializeOverlay(): SpineWebComponentOverlay { + let overlay = document.querySelector("spine-overlay") as SpineWebComponentOverlay; + if (!overlay) { + overlay = document.createElement("spine-overlay") as SpineWebComponentOverlay; + document.body.appendChild(overlay); + } + return overlay; + } /* * Other utilities @@ -808,9 +860,9 @@ class SpineWebComponentOverlay extends HTMLElement { constructor() { super(); - this.root = this.attachShadow({ mode: 'open' }); + this.root = this.attachShadow({ mode: "closed" }); - this.div = document.createElement('div'); + this.div = document.createElement("div"); this.div.style.position = "absolute"; this.div.style.top = "0"; this.div.style.left = "0"; @@ -820,7 +872,7 @@ class SpineWebComponentOverlay extends HTMLElement { this.root.appendChild(this.div); - this.canvas = document.createElement('canvas'); + this.canvas = document.createElement("canvas"); this.div.appendChild(this.canvas); this.canvas.style.position = "absolute"; this.canvas.style.top = "0"; @@ -830,7 +882,7 @@ class SpineWebComponentOverlay extends HTMLElement { this.canvas.style.transform =`translate(0px,0px)`; // this.canvas.style.setProperty("will-change", "transform"); // performance seems to be even worse with this uncommented - this.fps = document.createElement('span'); + this.fps = document.createElement("span"); this.fps.style.position = "fixed"; this.fps.style.top = "0"; this.fps.style.left = "0"; @@ -866,7 +918,7 @@ class SpineWebComponentOverlay extends HTMLElement { this.scrollHandler(); } - window.addEventListener('scroll', this.scrollHandler); + window.addEventListener("scroll", this.scrollHandler); this.scrollHandler(); this.input = new Input(document.body, false); @@ -994,7 +1046,7 @@ class SpineWebComponentOverlay extends HTMLElement { } if (skeleton) { - if (mode === 'inside') { + if (mode === "inside") { let { x: ax, y: ay, width: aw, height: ah } = bounds!; // scale ratio @@ -1144,8 +1196,6 @@ class SpineWebComponentOverlay extends HTMLElement { } connectedCallback(): void { - // TODO: move the intersectio observer to the canvas - so that we can instantiate a single one rather than one per widget - this.intersectionObserver = new IntersectionObserver((widgets) => { widgets.forEach(({ isIntersecting, target }) => { @@ -1162,6 +1212,7 @@ class SpineWebComponentOverlay extends HTMLElement { disconnectedCallback(): void { } + // TODO: drag is bugged when zoom on browser (just zoom and activare debug to see the drag surface has some offset) private setupDragUtility() { // TODO: we should use document - body might have some margin that offset the click events - Meanwhile I take event pageX/Y const point: Point = { x: 0, y: 0 }; @@ -1265,7 +1316,7 @@ class SpineWebComponentOverlay extends HTMLElement { private zoomHandler = () => { this.skeletonList.forEach((widget) => { // inside mode scale automatically to fit the skeleton within its parent - if (widget.mode !== 'origin' && widget.fit !== 'none') return; + if (widget.mode !== "origin" && widget.fit !== "none") return; const skeleton = widget.skeleton; if (!skeleton) return; @@ -1326,33 +1377,23 @@ const inside = (point: { x: number; y: number }, rectangle: Rectangle): boolean ); } -customElements.define('spine-widget', SpineWebComponentWidget); -customElements.define('spine-overlay', SpineWebComponentOverlay); +customElements.define("spine-widget", SpineWebComponentWidget); +customElements.define("spine-overlay", SpineWebComponentOverlay); -export function getSpineWidget(identifier: string) { - return document.querySelector(`spine-widget[identifier=${identifier}]`); +export function getSpineWidget(identifier: string): SpineWebComponentWidget { + return document.querySelector(`spine-widget[identifier=${identifier}]`) as SpineWebComponentWidget; } -export function createSpineWidget(parameters: { atlas: string, skeleton: string, animation: string, skin: string, manualStart?: boolean, pages: Array }): SpineWebComponentWidget { - const { - atlas, - skeleton, - animation, - skin, - manualStart = false, - pages = [], - } = parameters; - +export function createSpineWidget(parameters: WidgetAttributes): SpineWebComponentWidget { const widget = document.createElement("spine-widget") as SpineWebComponentWidget; - widget.setAttribute("skeleton", skeleton); - widget.setAttribute("atlas", atlas); - widget.setAttribute("skin", skin); - widget.setAttribute("animation", animation); - widget.setAttribute("manual-start", `${manualStart}`); - widget.setAttribute("pages", `${pages.join(",")}`); + Object.entries(SpineWebComponentWidget.attributesDescription).forEach(entry => { + const [key, { propertyName }] = entry; + const value = parameters[propertyName]; + if (value) widget.setAttribute(key, value as any); + }); - if (!manualStart) { + if (!widget.manualStart) { widget.start(); }