overlay 4

This commit is contained in:
Davide Tantillo 2024-08-07 18:32:54 +02:00
parent 9d1109c9dc
commit 3b5d74e0e8
2 changed files with 381 additions and 51 deletions

View File

@ -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;
}
</style>
<script src="../dist/iife/spine-webgl.js"></script>
<!-- <script src="../dist/iife/spine-webgl.js"></script> -->
<!-- <script src="./spine-webgl.min.js"></script> -->
<script src="./spine-webgl.js"></script>
</head>
<body>
<div class="content">
<h1>OverlayCanvas Example</h1>
<h1>Spine Canvas Overlay Example</h1>
<p>Scroll down to div.</p>
<div class="spacer"></div>
<div class="spine-div" div-spine>
<h2>Spine Box 1</h2>
<div id="spineboy1" class="spine-div" style="width: 200px; height: 300px; margin-left: 200px; touch-action:none; position:relative;" div-spine>
<div id="resizeHandle" class="resize-handle"></div>
<h2>Drag and resize me</h2>
<h3>Mode: inside</h3>
<h4>Spineboy will be resize to remain into the div.</h4>
<h4>Skeleton cannot be reused (side effect on skeleton scale).</h4>
</div>
<div class="spacer"></div>
<div id="spineboy2" class="spine-div" style="width: 50%; margin-left: 50%; touch-action:none" div-spine>
<h2>Spine Box 2 (drag me)</h2>
<div id="spineboy2" class="spine-div" style="width: 50%; margin-left: 50%; touch-action:none" div-spine2>
<h2>Drag me</h2>
<h3>Mode: origin</h3>
<h4>You can easily change the position using offset or percentage of html element axis (origin is top-left)</h4>
<h4>Skeleton can be reused.</h4>
</div>
<div class="spacer"></div>
<div id="raptor" class="spine-div" style="width: 50%; margin-left: 50%; transition: transform 1s linear;" div-raptor>
<h2>Raptor Box</h2>
<div id="spineboy3" class="spine-div" style="width: 50%; margin-left: 50%; touch-action:none" div-spine2>
<h3>Skeleton of previous box is being reused here</h3>
</div>
<div class="spacer"></div>
<div class="spine-div" style="width: 50%; margin-left: 20%;" div-celeste>
<h2>Celeste Box</h2>
<div class="spine-div" div-spine3>
<h3>Initializer with NodeList</h3>
</div>
<div class="spine-div" div-spine3>
<h3>Initializer with NodeList</h3>
</div>
<div class="spacer"></div>
<div class="spine-div" style="width: 50%; height: 200px;" div-spine4>
<h3>Initializer with HTMLElement</h3>
</div>
<div class="spacer"></div>
<p>End of content.</p>
</div>
@ -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);
}
</script>
</body>

View File

@ -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<HTMLElement>}>();
private skeletonList = new Array<{
skeleton: Skeleton,
state: AnimationState,
bounds: Rectangle,
htmlOptionsList: Array<OverlayHTMLOptions>,
}>();
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<HTMLElement> = [],
skeletonOptions: OverlaySkeletonOptions,
htmlOptionsList: Array<OverlayHTMLOptions> | Array<HTMLElement> | 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<OverlayHTMLOptions>;
if (htmlOptionsList instanceof HTMLElement) htmlOptionsList = [htmlOptionsList] as Array<HTMLElement>;
if (htmlOptionsList instanceof NodeList) htmlOptionsList = Array.from(htmlOptionsList) as Array<HTMLElement>;
if (htmlOptionsList.length > 0 && htmlOptionsList[0] instanceof HTMLElement) {
list = htmlOptionsList.map(element => ({ element: element } as OverlayHTMLOptions));
} else {
list = htmlOptionsList as Array<OverlayHTMLOptions>;
}
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<number>(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;
}