From 2da5b06c2d1f8a80b06f87173ad876f0009c0a57 Mon Sep 17 00:00:00 2001 From: Davide Tantillo Date: Wed, 14 Aug 2024 15:22:58 +0200 Subject: [PATCH] overlay --- spine-ts/spine-webgl/example/canvas4.html | 206 ++++-- spine-ts/spine-webgl/example/canvas5.html | 669 ++++++++++++++++++ spine-ts/spine-webgl/src/Input.ts | 39 +- spine-ts/spine-webgl/src/SceneRenderer.ts | 2 +- spine-ts/spine-webgl/src/SkeletonRenderer.ts | 2 + spine-ts/spine-webgl/src/SpineCanvas.ts | 15 +- .../spine-webgl/src/SpineCanvasOverlay.ts | 483 +++++++++---- 7 files changed, 1165 insertions(+), 251 deletions(-) create mode 100644 spine-ts/spine-webgl/example/canvas5.html diff --git a/spine-ts/spine-webgl/example/canvas4.html b/spine-ts/spine-webgl/example/canvas4.html index a05d900e3..3ce920e71 100644 --- a/spine-ts/spine-webgl/example/canvas4.html +++ b/spine-ts/spine-webgl/example/canvas4.html @@ -3,48 +3,44 @@ - OverlayCanvas Example + WebGL Overlay Example - + - + -
-

Spine Canvas Overlay Example

+ a -
-
+
+ +

Drag and resize me

Mode: inside

Spineboy will be resize to remain into the div.

@@ -53,7 +49,7 @@
-
+

Drag me

Mode: origin

You can easily change the position using offset or percentage of html element axis (origin is top-left)

@@ -62,32 +58,37 @@
-
+

Skeleton of previous box is being reused here

-
+

Initializer with NodeList

-
+

Initializer with NodeList

-
+

Initializer with HTMLElement

+
+

Bounds using a Spine ounding box

+
+ +
+

End of content.

diff --git a/spine-ts/spine-webgl/example/canvas5.html b/spine-ts/spine-webgl/example/canvas5.html new file mode 100644 index 000000000..a0cb548ef --- /dev/null +++ b/spine-ts/spine-webgl/example/canvas5.html @@ -0,0 +1,669 @@ + + + + + + + JS Library Showcase + + + + + + + + +
+ +
+
+
+
+ The skeleton origin is centered into the div by default. + The skeleton will be scaled to fit the current animation into the div. +
+
+ + +
+

+const overlay = new spine.SpineCanvasOverlay();
+overlay.addSkeleton(
+    {
+        atlasPath: "assets/spineboy-pma.atlas",
+        skeletonPath: "assets/spineboy-pro.skel",
+        animation: 'walk',
+    },
+    document.getElementById(`section1-element`),
+);
+            
+
+
+ + + + + + + + + +
+ +
+
+ Mode origin uses the HTML element top-left corner as origin for the skeleton.
+ You are responsible to scale the skeleton using this mode.
+ Move the origin by a percentage of the div width and height by using xAxis and yAxis respectively. +
+
+
+
+ + +
+

+overlay.addSkeleton(
+    {
+        atlasPath: "assets/spineboy-pma.atlas",
+        skeletonPath: "assets/spineboy-pro.skel",
+        animation: 'run',
+        scale: .25,
+    },
+    {
+        element: document.getElementById(`section2-element`),
+        mode: 'origin',
+        xAxis: .25,
+        yAxis: .75,
+    },
+);
+            
+
+
+ + + + + +
+ +
+
+
+
+ Use offsetX and offsetY to move you skeleton left or right by the pixel amount you specify. + This works for both mode origin and inside. +
+
+ + +
+

+overlay.addSkeleton(
+    {
+        atlasPath: "assets/spineboy-pma.atlas",
+        skeletonPath: "assets/spineboy-pro.skel",
+        animation: 'run',
+    },
+    {
+        element: document.getElementById(`section3-element`),
+        mode: 'inside', // default
+        offsetX: 100,
+        offsetY: 50,
+    },
+);
+            
+
+
+ + + + + +
+ +
+
+ You can easily access the Skeleton and the AnimationState of your character, and use them as if you were using spine-webgl.
+ If you change animation, you can ask to scale the skeleton based on the new animation. +
+
+ +
+
+ + +
+

+// access the skeleton and the state asynchronously
+const { skeleton, state } = await overlay.addSkeleton(
+    {
+        atlasPath: "assets/raptor-pma.atlas",
+        skeletonPath: "assets/raptor-pro.skel",
+        animation: 'walk',
+    },
+    document.getElementById(`section4-element`)
+);
+
+let isRoaring = false;
+setInterval(() => {
+    const newAnimation = isRoaring ? "walk" : "roar";
+    state.setAnimation(0, newAnimation, true);
+    overlay.recalculateBounds(skeleton); // scale the skeleton based on the new animation
+    isRoaring = !isRoaring;
+}, 4000);
+            
+
+
+ + + + + +
+ +
+
+ You can also set a custom bounds to center a specific element or area of you animation in the div. +
+
+ TODO +
+
+ + +
+

+            
+
+
+ + + + + + +
+ +
+
+ Moving the div will move the skeleton origin.
+ Resizing the div will resize the skeleton in inside mode, but not in origin mode. +
+
+
+
+ + +
+

+            
+
+
+ + + + + + + +
+ +
+
+ As a bonus item, you can move you skeleton around just by setting the draggable property to true. +
+
+
+
+ + +
+

+overlay.addSkeleton(
+    {
+        atlasPath: "assets/celestial-circus-pma.atlas",
+        skeletonPath: "assets/celestial-circus-pro.skel",
+        animation: 'wings-and-feet',
+    },
+    {
+        element: document.getElementById(`section7-element`),
+        draggable: true,
+    }
+);
+            
+
+
+ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spine-ts/spine-webgl/src/Input.ts b/spine-ts/spine-webgl/src/Input.ts index afe8f1044..36fb4d53a 100644 --- a/spine-ts/spine-webgl/src/Input.ts +++ b/spine-ts/spine-webgl/src/Input.ts @@ -36,10 +36,11 @@ export class Input { touch1: Touch | null = null; initialPinchDistance = 0; private listeners = new Array(); - private eventListeners: Array<{ target: any, event: any, func: any }> = []; + private preventDefault: boolean; - constructor (element: HTMLElement) { + constructor (element: HTMLElement, preventDefault = true) { this.element = element; + this.preventDefault = preventDefault; this.setupCallbacks(element); } @@ -50,7 +51,7 @@ export class Input { this.mouseX = ev.clientX - rect.left;; this.mouseY = ev.clientY - rect.top; this.buttonDown = true; - this.listeners.map((listener) => { if (listener.down) listener.down(this.mouseX, this.mouseY); }); + this.listeners.map((listener) => { if (listener.down) listener.down(this.mouseX, this.mouseY, ev); }); document.addEventListener("mousemove", mouseMove); document.addEventListener("mouseup", mouseUp); @@ -60,12 +61,12 @@ export class Input { let mouseMove = (ev: UIEvent) => { if (ev instanceof MouseEvent) { let rect = element.getBoundingClientRect(); - this.mouseX = ev.clientX - rect.left;; + this.mouseX = ev.clientX - rect.left; this.mouseY = ev.clientY - rect.top; this.listeners.map((listener) => { if (this.buttonDown) { - if (listener.dragged) listener.dragged(this.mouseX, this.mouseY); + if (listener.dragged) listener.dragged(this.mouseX, this.mouseY, ev); } else { if (listener.moved) listener.moved(this.mouseX, this.mouseY); } @@ -86,11 +87,11 @@ export class Input { } } - let mouseWheel = (e: WheelEvent) => { - e.preventDefault(); - let deltaY = e.deltaY; - if (e.deltaMode == WheelEvent.DOM_DELTA_LINE) deltaY *= 8; - if (e.deltaMode == WheelEvent.DOM_DELTA_PAGE) deltaY *= 24; + let mouseWheel = (ev: WheelEvent) => { + if (this.preventDefault) ev.preventDefault(); + let deltaY = ev.deltaY; + if (ev.deltaMode == WheelEvent.DOM_DELTA_LINE) deltaY *= 8; + if (ev.deltaMode == WheelEvent.DOM_DELTA_PAGE) deltaY *= 24; this.listeners.map((listener) => { if (listener.wheel) listener.wheel(e.deltaY); }); }; @@ -115,7 +116,7 @@ export class Input { if (!this.touch0) { this.touch0 = touch; - this.listeners.map((listener) => { if (listener.down) listener.down(touch.x, touch.y) }) + this.listeners.map((listener) => { if (listener.down) listener.down(touch.x, touch.y, ev) }) } else if (!this.touch1) { this.touch1 = touch; let dx = this.touch1.x - this.touch0.x; @@ -124,8 +125,8 @@ export class Input { this.listeners.map((listener) => { if (listener.zoom) listener.zoom(this.initialPinchDistance, this.initialPinchDistance) }); } } - ev.preventDefault(); - }, false); + if (this.preventDefault) ev.preventDefault(); + }, { passive: this.preventDefault }); element.addEventListener("touchmove", (ev: TouchEvent) => { if (this.touch0) { @@ -139,7 +140,7 @@ export class Input { if (this.touch0.identifier === nativeTouch.identifier) { this.touch0.x = this.mouseX = x; this.touch0.y = this.mouseY = y; - this.listeners.map((listener) => { if (listener.dragged) listener.dragged(x, y) }); + this.listeners.map((listener) => { if (listener.dragged) listener.dragged(x, y, ev) }); } if (this.touch1 && this.touch1.identifier === nativeTouch.identifier) { this.touch1.x = this.mouseX = x; @@ -153,8 +154,8 @@ export class Input { this.listeners.map((listener) => { if (listener.zoom) listener.zoom(this.initialPinchDistance, distance) }); } } - ev.preventDefault(); - }, false); + if (this.preventDefault) ev.preventDefault(); + }, { passive: this.preventDefault }); let touchEnd = (ev: TouchEvent) => { if (this.touch0) { @@ -190,7 +191,7 @@ export class Input { } } } - ev.preventDefault(); + if (this.preventDefault) ev.preventDefault(); }; element.addEventListener("touchend", touchEnd, false); element.addEventListener("touchcancel", touchEnd); @@ -214,10 +215,10 @@ export class Touch { } export interface InputListener { - down?(x: number, y: number): void; + down?(x: number, y: number, ev?: MouseEvent | TouchEvent): void; up?(x: number, y: number): void; moved?(x: number, y: number): void; - dragged?(x: number, y: number): void; + dragged?(x: number, y: number, ev?: MouseEvent | TouchEvent): void; wheel?(delta: number): void; zoom?(initialDistance: number, distance: number): void; } diff --git a/spine-ts/spine-webgl/src/SceneRenderer.ts b/spine-ts/spine-webgl/src/SceneRenderer.ts index 652e8e0fb..c4e4c9bc1 100644 --- a/spine-ts/spine-webgl/src/SceneRenderer.ts +++ b/spine-ts/spine-webgl/src/SceneRenderer.ts @@ -82,7 +82,7 @@ export class SceneRenderer implements Disposable { } begin () { - // this.camera.update(); + this.camera.update(); this.enableRenderer(this.batcher); } diff --git a/spine-ts/spine-webgl/src/SkeletonRenderer.ts b/spine-ts/spine-webgl/src/SkeletonRenderer.ts index ad81b6a3d..e8b927333 100644 --- a/spine-ts/spine-webgl/src/SkeletonRenderer.ts +++ b/spine-ts/spine-webgl/src/SkeletonRenderer.ts @@ -203,6 +203,8 @@ export class SkeletonRenderer { clipper.clipEndWithSlot(slot); } + + // console.log(renderable.vertices[1]) clipper.clipEnd(); } diff --git a/spine-ts/spine-webgl/src/SpineCanvas.ts b/spine-ts/spine-webgl/src/SpineCanvas.ts index 31500fdd4..e11f67c1f 100644 --- a/spine-ts/spine-webgl/src/SpineCanvas.ts +++ b/spine-ts/spine-webgl/src/SpineCanvas.ts @@ -76,9 +76,6 @@ export class SpineCanvas { /** The input processor used to listen to mouse, touch, and keyboard events. */ readonly input: Input; - public reqAnimationFrameId?:number; - public loop: FrameRequestCallback; - private disposed = false; /** Constructs a new spine canvas, rendering to the provided HTML canvas. */ @@ -96,16 +93,16 @@ export class SpineCanvas { this.htmlCanvas = canvas; this.context = new ManagedWebGLRenderingContext(canvas, config.webglConfig); - this.renderer = new SceneRenderer(canvas, this.context, false); + this.renderer = new SceneRenderer(canvas, this.context); this.gl = this.context.gl; this.assetManager = new AssetManager(this.context, config.pathPrefix); this.input = new Input(canvas); if (config.app.loadAssets) config.app.loadAssets(this); - this.loop = () => { + let loop = () => { if (this.disposed) return; - this.reqAnimationFrameId = requestAnimationFrame(this.loop); + requestAnimationFrame(loop); this.time.update(); if (config.app.update) config.app.update(this, this.time.delta); if (config.app.render) config.app.render(this); @@ -118,13 +115,13 @@ export class SpineCanvas { if (config.app.error) config.app.error(this, this.assetManager.getErrors()); } else { if (config.app.initialize) config.app.initialize(this); - this.loop(0); + loop(); } return; } - this.reqAnimationFrameId = requestAnimationFrame(waitForAssets); + requestAnimationFrame(waitForAssets); } - this.reqAnimationFrameId = requestAnimationFrame(waitForAssets); + requestAnimationFrame(waitForAssets); } /** Clears the canvas with the given color. The color values are given in the range [0,1]. */ diff --git a/spine-ts/spine-webgl/src/SpineCanvasOverlay.ts b/spine-ts/spine-webgl/src/SpineCanvasOverlay.ts index ca5b821cd..d68d39805 100644 --- a/spine-ts/spine-webgl/src/SpineCanvasOverlay.ts +++ b/spine-ts/spine-webgl/src/SpineCanvasOverlay.ts @@ -27,7 +27,7 @@ * 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 } from "./index.js"; +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, @@ -42,8 +42,11 @@ interface OverlaySkeletonOptions { 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, @@ -52,8 +55,11 @@ interface OverlayHTMLOptions { 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}. */ @@ -61,161 +67,50 @@ export class SpineCanvasOverlay { private spineCanvas:SpineCanvas; private canvas:HTMLCanvasElement; + private input:Input; private skeletonList = new Array<{ skeleton: Skeleton, state: AnimationState, bounds: Rectangle, - htmlOptionsList: Array, + htmlOptionsList: Array, + update?: UpdateSpineFunction, }>(); + private resizeObserver:ResizeObserver; private disposed = false; /** Constructs a new spine canvas, rendering to the provided HTML canvas. */ constructor () { this.canvas = document.createElement('canvas'); - document.body.appendChild(this.canvas); // adds the canvas to the body element + 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.width = "100%"; + // this.canvas.style.height = "100%"; this.updateCanvasSize(); - const resizeObserver = new ResizeObserver(() => { + this.resizeObserver = new ResizeObserver(() => { this.updateCanvasSize(); this.spineCanvas.renderer.resize(ResizeMode.Expand); }); - resizeObserver.observe(document.body); + this.resizeObserver.observe(document.body); - const red = new Color(1, 0, 0, 1); - const blue = new Color(0, 0, 1, 1); - const spineCanvasApp: SpineCanvasApp = { + this.spineCanvas = new SpineCanvas(this.canvas, { app: this.setupSpineCanvasApp() }); - update: (canvas: SpineCanvas, delta: number) => { - this.skeletonList.forEach(({ skeleton, state, htmlOptionsList }) => { - if (htmlOptionsList.length === 0) return; - state.update(delta); - state.apply(skeleton); - skeleton.update(delta); - skeleton.updateWorldTransform(Physics.update); - }); - }, - - render: (canvas: SpineCanvas) => { - let renderer = canvas.renderer; - renderer.begin(); - - // webgl canvas center - const vec3 = new Vector3(0, 0); - renderer.camera.worldToScreen(vec3, canvas.htmlCanvas.clientWidth, canvas.htmlCanvas.clientHeight); - - 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(({ element, mode, showBounds, offsetX = 0, offsetY = 0, xAxis = 0, yAxis = 0 }) => { - - 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; - - const scaledX = (ax + aw / 2) * ratio; - const scaledY = (ay + ah / 2) * ratio; - - const divX = divBounds.x + divBounds.width / 2 + window.scrollX; - const divY = divBounds.y - 1 + divBounds.height / 2 + window.scrollY; - - tempVector.set(divX, divY, 0); - renderer.camera.screenToWorld(tempVector, canvas.htmlCanvas.clientWidth, canvas.htmlCanvas.clientHeight); - - x = tempVector.x - scaledX; - y = tempVector.y - scaledY; - - skeleton.scaleX = ratio; - skeleton.scaleY = ratio; - - if (showBounds) { - renderer.circle(true, tempVector.x, tempVector.y, 10, blue); - renderer.rect(false, ax * ratio + x + offsetX, ay * ratio + y + offsetY, aw * ratio, ah * ratio, blue); - } - - } else { - const divX = divBounds.x + divBounds.width * xAxis + window.scrollX; - const divY = divBounds.y + divBounds.height * yAxis + window.scrollY; - - tempVector.set(divX, divY, 0); - renderer.camera.screenToWorld(tempVector, canvas.htmlCanvas.clientWidth, canvas.htmlCanvas.clientHeight); - - x = tempVector.x; - y = tempVector.y; - - if (showBounds) { - // show skeleton root - const root = skeleton.getRootBone()!; - renderer.circle(true, x + root.x + offsetX, y + root.y + offsetY, 10, red); - } - } - - renderer.drawSkeleton(skeleton, true, -1, -1, (vertices, size, vertexSize) => { - for (let i = 0; i < size; i+=vertexSize) { - vertices[i] = vertices[i] + x + offsetX; - vertices[i+1] = vertices[i+1] + y + offsetY; - } - }); - - }); - - }); - - // Complete rendering. - renderer.end(); - }, - } - - this.spineCanvas = new SpineCanvas(this.canvas, { - app: spineCanvasApp, - }) - } - - // TODO: Reject error - public async loadBinary(path: string) { - return new Promise((resolve, reject) => { - this.spineCanvas.assetManager.loadBinary(path, () => resolve(null)); - }); - } - - public async loadJson(path: string) { - return new Promise((resolve, reject) => { - this.spineCanvas.assetManager.loadJson(path, () => resolve(null)); - }); - } - - public async loadTextureAtlas(path: string) { - return new Promise((resolve, reject) => { - this.spineCanvas.assetManager.loadTextureAtlas(path, () => resolve(null)); - }); + 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 | Array | HTMLElement | NodeList = [], + htmlOptionsList: Array | OverlayHTMLOptions | Array | HTMLElement | NodeList = [], ) { - const { atlasPath, skeletonPath, scale = 1, animation, skeletonData: skeletonDataInput } = skeletonOptions; + 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), @@ -245,14 +140,15 @@ export class SpineCanvasOverlay { 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: element } as OverlayHTMLOptions)); + list = htmlOptionsList.map(element => ({ element } as OverlayHTMLOptions)); } else { list = htmlOptionsList as Array; } - const mapList = list.map(({ element, mode: givenMode, showBounds = false, offsetX = 0, offsetY = 0, xAxis = 0, yAxis = 0 }, i) => { + const mapList = list.map(({ element, mode: givenMode, showBounds = 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" @@ -260,24 +156,39 @@ export class SpineCanvasOverlay { + "You can call addSkeleton several time (skeleton data can be reuse, if given)."); } return { - element, + element: element as HTMLElement, mode, showBounds, offsetX, offsetY, xAxis, yAxis, + draggable, + dragX: 0, + dragY: 0, + worldOffsetX: 0, + worldOffsetY: 0, + dragging: false, } }); - this.skeletonList.push({ skeleton, state, bounds, htmlOptionsList: mapList }); + this.skeletonList.push({ skeleton, state, update, bounds, htmlOptionsList: mapList }); - return { skeleton, state } + return { skeleton, state }; } - public recalculateBounds(skeleton: Skeleton, state: AnimationState) { - const track = state.getCurrent(0); + // 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; @@ -288,6 +199,276 @@ export class SpineCanvasOverlay { } } + /* + * 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(); + + // console.log(canvas.gl.getParameter(canvas.gl.MAX_RENDERBUFFER_SIZE)); + + 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, showBounds, offsetX, offsetY, xAxis, yAxis, dragX, dragY } = list; + const divBounds = element.getBoundingClientRect(); + + // console.log(divBounds.x, divBounds.y, divBounds.width, divBounds.height) + + 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; + 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; + 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; + + console.log(list.worldOffsetY) + // console.log("----") + + 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 (showBounds) { + // 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, originalEvent.pageY, 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, originalEvent.pageY, 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"; + } + + 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(); @@ -328,34 +509,26 @@ export class SpineCanvasOverlay { } } - private updateCanvasSize() { - const pageSize = this.getPageSize(); - this.canvas.style.width = pageSize.width + "px"; - this.canvas.style.height = pageSize.height + "px"; + 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); + // console.log(this.canvas.clientWidth, this.canvas.clientHeight); } - private getPageSize() { - const width = Math.max( - document.body.scrollWidth, - document.documentElement.scrollWidth, - document.body.offsetWidth, - document.documentElement.offsetWidth, - document.documentElement.clientWidth + 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 ); - - const height = Math.max( - document.body.scrollHeight, - document.documentElement.scrollHeight, - document.body.offsetHeight, - document.documentElement.offsetHeight, - document.documentElement.clientHeight - ); - - return { width, height }; } // TODO dispose () { + this.spineCanvas.dispose(); + this.canvas.remove(); this.disposed = true; + this.resizeObserver.disconnect(); } }