From 3b5d74e0e85645199291bf425f94a649f36ca635 Mon Sep 17 00:00:00 2001 From: Davide Tantillo Date: Wed, 7 Aug 2024 18:32:54 +0200 Subject: [PATCH] overlay 4 --- spine-ts/spine-webgl/example/canvas4.html | 212 +++++++++++++++-- .../spine-webgl/src/SpineCanvasOverlay.ts | 220 +++++++++++++++--- 2 files changed, 381 insertions(+), 51 deletions(-) diff --git a/spine-ts/spine-webgl/example/canvas4.html b/spine-ts/spine-webgl/example/canvas4.html index 1340a068c..a05d900e3 100644 --- a/spine-ts/spine-webgl/example/canvas4.html +++ b/spine-ts/spine-webgl/example/canvas4.html @@ -15,7 +15,7 @@ } .spine-div { border: 1px solid black; - padding: 20px; + /* padding: 20px; */ margin-bottom: 20px; } .spacer { @@ -24,39 +24,64 @@ #canvas { will-change: transform; } + + .resize-handle { + width: 20%; + height: 20%; + background-color: #007bff; + position: absolute; + bottom: 0; + right: 0; + cursor: se-resize; + } - + +
-

OverlayCanvas Example

+

Spine Canvas Overlay Example

-

Scroll down to div.

- -
- -
-

Spine Box 1

+
+
+

Drag and resize me

+

Mode: inside

+

Spineboy will be resize to remain into the div.

+

Skeleton cannot be reused (side effect on skeleton scale).

-
-

Spine Box 2 (drag me)

+
+

Drag me

+

Mode: origin

+

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

+

Skeleton can be reused.

-
-

Raptor Box

+
+

Skeleton of previous box is being reused here

-
-

Celeste Box

+
+

Initializer with NodeList

+
+

Initializer with NodeList

+
+ +
+ +
+

Initializer with HTMLElement

+
+ +

End of content.

@@ -66,18 +91,161 @@ const divs = document.querySelectorAll(`[div-spine]`); const overlay = new spine.SpineCanvasOverlay(); - const p = overlay.addSkeleton({ - atlasPath: "assets/spineboy-pma.atlas", - skeletonPath: "assets/spineboy-pro.skel", + const p = overlay.addSkeleton( + { + atlasPath: "assets/spineboy-pma.atlas", + skeletonPath: "assets/spineboy-pro.skel", + scale: .5, + animation: 'walk', + }, + [ + { + element: divs[0], + mode: 'inside', + showBounds: true, + }, + ], + ); + + setTimeout(async () => { + const { skeleton, state } = await p; + state.setAnimation(0, "run", true); + overlay.recalculateBounds(skeleton, state); + }, 1000) + + const divs2 = document.querySelectorAll(`[div-spine2]`); + const p2 = overlay.addSkeleton({ + atlasPath: "assets/celestial-circus-pma.atlas", + skeletonPath: "assets/celestial-circus-pro.skel", + animation: 'swing', scale: .5, - }, divs); + }, + [ + { + element: divs2[0], + mode: 'origin', + showBounds: true, + xAxis: .5, + yAxis: 1, + // offsetX: 100 + }, + { + element: divs2[1], + mode: 'origin', + showBounds: true, + offsetX: 100, + offsetY: -50 + }, + ],); + + p2.then(({ state }) => state.setAnimation(1, "eyeblink", true)); + + const divs3 = document.querySelectorAll(`[div-spine3]`); + const p3 = overlay.addSkeleton({ + atlasPath: "assets/raptor-pma.atlas", + skeletonPath: "assets/raptor-pro.skel", + animation: 'walk', + scale: .5, + }, divs3); + + const divs4 = document.querySelectorAll(`[div-spine4]`); + const p4 = overlay.addSkeleton({ + atlasPath: "assets/tank-pma.atlas", + skeletonPath: "assets/tank-pro.skel", + animation: 'shoot', + scale: .5, + }, divs4[0]); + + ////////////////////////////////////////////////////// + ////////////////////////////////////////////////////// + ////////////////////////////////////////////////////// + ////////////////////////////////////////////////////// + // Drag utility - p.then(({ skeleton, state }) => { - state.setAnimation(0, "walk", true); - }) + function makeDraggable(element) { + let isDragging = false; + let startX, startY; + let originalX, originalY; + + element.addEventListener('pointerdown', startDragging); + document.addEventListener('pointermove', drag); + document.addEventListener('pointerup', stopDragging); + + function startDragging(e) { + e.preventDefault(); + if (e.target === document.getElementById('resizeHandle')) return; + + isDragging = true; + startX = e.clientX; + startY = e.clientY; + + const translate = element.style.transform; + if (translate !== '') { + const translateValues = translate.match(/translate\(([^)]+)\)/)[1].split(', '); + originalX = parseFloat(translateValues[0]); + originalY = parseFloat(translateValues[1]); + } else { + originalX = 0; + originalY = 0; + } + } + + function drag(e) { + if (!isDragging) return; + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + element.style.transform = `translate(${originalX + deltaX}px, ${originalY + deltaY}px)`; + e.preventDefault(); + } + + function stopDragging(e) { + isDragging = false; + e.preventDefault(); + } + } + makeDraggable(document.getElementById('spineboy1')); + makeDraggable(document.getElementById('spineboy2')); + ////////////////////////////////////////////////////// + ////////////////////////////////////////////////////// + ////////////////////////////////////////////////////// + ////////////////////////////////////////////////////// + // Resize utility + + const resizableDiv = document.getElementById('spineboy1'); + const resizeHandle = document.getElementById('resizeHandle'); + let isResizing = false; + let startX, startY, startWidth, startHeight; + + resizeHandle.addEventListener('pointerdown', initResize); + + function initResize(e) { + isResizing = true; + startX = e.clientX; + startY = e.clientY; + startWidth = resizableDiv.offsetWidth; + startHeight = resizableDiv.offsetHeight; + document.addEventListener('pointermove', resize); + document.addEventListener('pointerup', stopResize); + } + + function resize(e) { + if (!isResizing) return; + const width = startWidth + (e.clientX - startX); + const height = startHeight + (e.clientY - startY); + resizableDiv.style.width = width + 'px'; + resizableDiv.style.height = height + 'px'; + } + + function stopResize() { + isResizing = false; + document.removeEventListener('pointermove', resize); + document.removeEventListener('pointerup', stopResize); + } diff --git a/spine-ts/spine-webgl/src/SpineCanvasOverlay.ts b/spine-ts/spine-webgl/src/SpineCanvasOverlay.ts index 973c60850..ca5b821cd 100644 --- a/spine-ts/spine-webgl/src/SpineCanvasOverlay.ts +++ b/spine-ts/spine-webgl/src/SpineCanvasOverlay.ts @@ -27,21 +27,50 @@ * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *****************************************************************************/ -import { SpineCanvas, SpineCanvasApp, AtlasAttachmentLoader, SkeletonBinary, SkeletonJson, Skeleton, AnimationState, AnimationStateData, Physics, Vector3, ResizeMode, Color } from "./index.js"; +import { SpineCanvas, SpineCanvasApp, AtlasAttachmentLoader, SkeletonBinary, SkeletonJson, Skeleton, Animation, AnimationState, AnimationStateData, Physics, Vector2, Vector3, ResizeMode, Color, MixBlend, MixDirection, SceneRenderer, SkeletonData } from "./index.js"; -/** Manages the life-cycle and WebGL context of a {@link SpineCanvasApp}. The app loads - * assets and initializes itself, then updates and renders its state at the screen refresh rate. */ +interface Rectangle { + x: number, + y: number, + width: number, + height: number, +} + +interface OverlaySkeletonOptions { + atlasPath: string, + skeletonPath: string, + scale: number, + animation?: string, + skeletonData?: SkeletonData, +} + +interface OverlayHTMLOptions { + element: HTMLElement, + mode?: OverlayElementMode, + showBounds?: boolean, + offsetX?: number, + offsetY?: number, + xAxis?: number, + yAxis?: 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 skeletonList = new Array<{ skeleton: Skeleton, state: AnimationState, htmlElements: Array}>(); + private skeletonList = new Array<{ + skeleton: Skeleton, + state: AnimationState, + bounds: Rectangle, + htmlOptionsList: Array, + }>(); private disposed = false; - - /** Constructs a new spine canvas, rendering to the provided HTML canvas. */ constructor () { this.canvas = document.createElement('canvas'); @@ -60,11 +89,12 @@ export class SpineCanvasOverlay { resizeObserver.observe(document.body); const red = new Color(1, 0, 0, 1); + const blue = new Color(0, 0, 1, 1); const spineCanvasApp: SpineCanvasApp = { update: (canvas: SpineCanvas, delta: number) => { - this.skeletonList.forEach(({ skeleton, state, htmlElements }) => { - if (htmlElements.length === 0) return; + this.skeletonList.forEach(({ skeleton, state, htmlOptionsList }) => { + if (htmlOptionsList.length === 0) return; state.update(delta); state.apply(skeleton); skeleton.update(delta); @@ -81,29 +111,73 @@ export class SpineCanvasOverlay { renderer.camera.worldToScreen(vec3, canvas.htmlCanvas.clientWidth, canvas.htmlCanvas.clientHeight); const devicePixelRatio = window.devicePixelRatio; - this.skeletonList.forEach(({ skeleton, htmlElements }) => { - if (htmlElements.length === 0) return; + const tempVector = new Vector3(); + this.skeletonList.forEach(({ skeleton, htmlOptionsList, bounds }) => { + if (htmlOptionsList.length === 0) return; - htmlElements.forEach((div) => { + let { x: ax, y: ay, width: aw, height: ah } = bounds; - const bounds = div.getBoundingClientRect(); - const x = (bounds.x + window.scrollX - vec3.x) * devicePixelRatio; - const y = (bounds.y + window.scrollY - vec3.y) * devicePixelRatio; + 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; - vertices[i+1] = vertices[i+1] - y; + vertices[i] = vertices[i] + x + offsetX; + vertices[i+1] = vertices[i+1] + y + offsetY; } }); - // show skeleton center (root) - const root = skeleton.getRootBone()!; - const vec3Root = new Vector3(root.x, root.y); - renderer.camera.worldToScreen(vec3Root, canvas.htmlCanvas.clientWidth, canvas.htmlCanvas.clientHeight); - const rootX = (vec3Root.x - vec3.x) * devicePixelRatio; - const rootY = (vec3Root.y - vec3.y) * devicePixelRatio; - renderer.circle(true, x + rootX, -y + rootY, 20, red); }); }); @@ -118,6 +192,7 @@ export class SpineCanvasOverlay { }) } + // TODO: Reject error public async loadBinary(path: string) { return new Promise((resolve, reject) => { this.spineCanvas.assetManager.loadBinary(path, () => resolve(null)); @@ -137,10 +212,10 @@ export class SpineCanvasOverlay { } public async addSkeleton( - skeletonOptions: { atlasPath: string, skeletonPath: string, scale: number }, - elements: Array = [], + skeletonOptions: OverlaySkeletonOptions, + htmlOptionsList: Array | Array | HTMLElement | NodeList = [], ) { - const { atlasPath, skeletonPath, scale } = skeletonOptions; + const { atlasPath, skeletonPath, scale = 1, animation, skeletonData: skeletonDataInput } = skeletonOptions; const isBinary = skeletonPath.endsWith(".skel"); await Promise.all([ isBinary ? this.loadBinary(skeletonPath) : this.loadJson(skeletonPath), @@ -154,17 +229,104 @@ export class SpineCanvasOverlay { skeletonLoader.scale = scale; const skeletonFile = this.spineCanvas.assetManager.require(skeletonPath); - const skeletonData = skeletonLoader.readSkeletonData(skeletonFile); + const skeletonData = skeletonDataInput ?? skeletonLoader.readSkeletonData(skeletonFile); const skeleton = new Skeleton(skeletonData); const animationStateData = new AnimationStateData(skeletonData); const state = new AnimationState(animationStateData); - this.skeletonList.push({ skeleton, state, htmlElements: [...elements] }); + 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 (htmlOptionsList.length > 0 && htmlOptionsList[0] instanceof HTMLElement) { + list = htmlOptionsList.map(element => ({ 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 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, + mode, + showBounds, + offsetX, + offsetY, + xAxis, + yAxis, + } + }); + this.skeletonList.push({ skeleton, state, bounds, htmlOptionsList: mapList }); return { skeleton, state } } + public recalculateBounds(skeleton: Skeleton, state: AnimationState) { + const track = state.getCurrent(0); + const animation = track?.animation as (Animation | undefined); + const bounds = this.calculateAnimationViewport(skeleton, animation); + 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; + } + } + + 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 updateCanvasSize() { const pageSize = this.getPageSize(); @@ -192,7 +354,7 @@ export class SpineCanvasOverlay { return { width, height }; } - /** Disposes the app, so the update() and render() functions are no longer called. Calls the dispose() callback.*/ + // TODO dispose () { this.disposed = true; }