web component

This commit is contained in:
Davide Tantillo 2024-08-22 18:24:56 +02:00
parent d92046f325
commit 221e3f6624
5 changed files with 1861 additions and 181 deletions

View File

@ -37,6 +37,11 @@
margin: 1rem;
border: 1px solid salmon;
}
.split-nosize {
/* padding: 1rem; */
/* margin: 1rem; */
border: 1px solid salmon;
}
.navigation {
display: flex;
position: fixed;
@ -96,7 +101,38 @@
</head>
<body>
<span id="fps" style="position: fixed; top: 0; left: 0">FPS</span>
<!-- <img src="assets/spineboy-pma.png" alt=""> -->
<!--
/////////////////////
// start section 0 //
/////////////////////
-->
<div id="section0" class="section vertical-split">
<div class="split-top split">
<div class="split-left">
aaa
</div>
<div class="split-right" id="section0-element">
<spine identifier="section0" createDiv="true" width="220" height="50"/>
</div>
</div>
<div class="split-bottom">
<pre><code id="code-display">
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section 0 //
/////////////////////
-->
<!--
/////////////////////
// start section 1 //
@ -108,7 +144,7 @@
<div class="split-top split">
<div class="split-left" id="section1-element">
</div>
<div class="split-right" id="section1-element-x">
<div class="split-right">
</div>
</div>
@ -473,7 +509,6 @@ overlay.addSkeleton(
// }
// );
/////////////////////
// start section 1 //
/////////////////////
@ -483,144 +518,194 @@ overlay.addSkeleton(
skeletonPath: "assets/spineboy-pro.skel",
animation: 'walk',
},
document.querySelectorAll(`#section1-element`),
[
{
identifier: `section0`,
debug: true,
}
]
);
/////////////////////
// end section 1 //
/////////////////////
/////////////////////
// start section 2 //
/////////////////////
overlay.addSkeleton(
{
atlasPath: "assets/spineboy-pma.atlas",
skeletonPath: "assets/spineboy-pro.skel",
animation: 'run',
scale: .25,
},
{
element: document.getElementById(`section2-element`),
mode: 'origin',
xAxis: .5,
yAxis: 1,
},
);
/////////////////////
// end section 2 //
/////////////////////
/////////////////////
// start section 3 //
/////////////////////
overlay.addSkeleton(
{
atlasPath: "assets/spineboy-pma.atlas",
skeletonPath: "assets/spineboy-pro.skel",
animation: 'jump',
},
{
element: document.getElementById(`section3-element`),
mode: 'inside', // default
offsetX: 100,
offsetY: 50,
},
);
/////////////////////
// end section 3 //
/////////////////////
// /////////////////////
// // start section 1 //
// /////////////////////
// overlay.addSkeleton(
// {
// atlasPath: "assets/spineboy-pma.atlas",
// skeletonPath: "assets/spineboy-pro.skel",
// animation: 'walk',
// },
// document.querySelectorAll(`#section1-element`),
// );
// /////////////////////
// // end section 1 //
// /////////////////////
/////////////////////
// start section 4 //
/////////////////////
const { skeleton, state } = await overlay.addSkeleton(
{
atlasPath: "assets/raptor-pma.atlas",
skeletonPath: "assets/raptor-pro.skel",
animation: 'walk',
},
document.getElementById(`section4-element`)
);
// /////////////////////
// // start section 2 //
// /////////////////////
// overlay.addSkeleton(
// {
// atlasPath: "assets/spineboy-pma.atlas",
// skeletonPath: "assets/spineboy-pro.skel",
// animation: 'run',
// scale: .25,
// },
// {
// element: document.getElementById(`section2-element`),
// mode: 'origin',
// xAxis: .5,
// yAxis: 1,
// },
// );
// /////////////////////
// // end section 2 //
// /////////////////////
let isRoaring = false;
setInterval(() => {
const newAnimation = isRoaring ? "walk" : "roar";
state.setAnimation(0, newAnimation, true);
overlay.recalculateBounds(skeleton);
isRoaring = !isRoaring;
}, 4000);
/////////////////////
// end section 4 //
/////////////////////
/////////////////////
// start section 5 //
/////////////////////
/////////////////////
// end section 5 //
/////////////////////
/////////////////////
// start section 6 //
/////////////////////
overlay.addSkeleton(
{
atlasPath: "assets/cloud-pot-pma.atlas",
skeletonPath: "assets/cloud-pot.skel",
animation: 'playing-in-the-rain',
},
document.getElementById(`section6-element`)
);
/////////////////////
// end section 6 //
/////////////////////
// /////////////////////
// // start section 3 //
// /////////////////////
// overlay.addSkeleton(
// {
// atlasPath: "assets/spineboy-pma.atlas",
// skeletonPath: "assets/spineboy-pro.skel",
// animation: 'jump',
// },
// {
// element: document.getElementById(`section3-element`),
// mode: 'inside', // default
// offsetX: 100,
// offsetY: 50,
// },
// );
// /////////////////////
// // end section 3 //
// /////////////////////
/////////////////////
// start section 7 //
/////////////////////
overlay.addSkeleton(
{
atlasPath: "assets/owl-pma.atlas",
skeletonPath: "assets/owl-pro.skel",
animation: 'idle',
},
{
element: document.getElementById(`section7-element`),
debug: true,
}
);
//////////////////////
// end section 7 //
//////////////////////
// /////////////////////
// // start section 4 //
// /////////////////////
// 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);
// isRoaring = !isRoaring;
// }, 4000);
// /////////////////////
// // end section 4 //
// /////////////////////
// /////////////////////
// // start section 5 //
// /////////////////////
// // const { skeleton: skeleton5 } = await overlay.addSkeleton(
// // {
// // atlasPath: "assets/spineboy-pma.atlas",
// // skeletonPath: "assets/spineboy-pro.skel",
// // animation: 'walk',
// // },
// // document.getElementById(`section5-element`)
// // );
// // const bbAttachmentSlot = skeleton5.findSlot("head-bb");
// // const currentAttachment = bbAttachmentSlot.attachment;
// // skeleton5.setAttachment("head-bb", "head");
// // const bbAttachment = bbAttachmentSlot.attachment;
// // const computedVertices = [];
// // bbAttachment.computeWorldVertices(bbAttachmentSlot, 0, bbAttachment.worldVerticesLength, computedVertices, 0, 2);
// // const vertices = computedVertices;
// // let x = Infinity, maxX = -Infinity, y = Infinity, maxY = -Infinity;
// // for (let i = 0; i < vertices.length; i+=2) {
// // x = Math.min(vertices[i], x);
// // y = Math.min(vertices[i+1], y);
// // maxX = Math.max(vertices[i], maxX);
// // maxY = Math.max(vertices[i+1], maxY);
// // }
// // const width = maxX - x;
// // const height = maxY - y;
// // console.log({ x, y, width, height })
// // overlay.setBounds(skeleton5, { x, y, width, height })
// // bbAttachmentSlot.setAttachment(currentAttachment)
// /////////////////////
// // end section 5 //
// /////////////////////
// /////////////////////
// // start section 6 //
// /////////////////////
// overlay.addSkeleton(
// {
// atlasPath: "assets/cloud-pot-pma.atlas",
// skeletonPath: "assets/cloud-pot.skel",
// animation: 'playing-in-the-rain',
// },
// document.getElementById(`section6-element`)
// );
// /////////////////////
// // end section 6 //
// /////////////////////
/////////////////////
// start section 8 //
/////////////////////
overlay.addSkeleton(
{
atlasPath: "assets/celestial-circus-pma.atlas",
skeletonPath: "assets/celestial-circus-pro.skel",
animation: 'wings-and-feet',
},
{
element: document.getElementById(`section8-element`),
draggable: true,
debug: true,
}
);
//////////////////////
// end section 8 //
//////////////////////
// /////////////////////
// // start section 7 //
// /////////////////////
// overlay.addSkeleton(
// {
// atlasPath: "assets/owl-pma.atlas",
// skeletonPath: "assets/owl-pro.skel",
// animation: 'idle',
// },
// {
// element: document.getElementById(`section7-element`),
// debug: true,
// }
// );
// //////////////////////
// // end section 7 //
// //////////////////////
// /////////////////////
// // start section 8 //
// /////////////////////
// overlay.addSkeleton(
// {
// atlasPath: "assets/celestial-circus-pma.atlas",
// skeletonPath: "assets/celestial-circus-pro.skel",
// animation: 'wings-and-feet',
// },
// {
// element: document.getElementById(`section8-element`),
// draggable: true,
// debug: true,
// }
// );
// //////////////////////
// // end section 8 //
// //////////////////////
})();

View File

@ -0,0 +1,741 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="../dist/iife/spine-webgl.js"></script>
<!-- <script src="./spine-webgl.js"></script> -->
<title>JS Library Showcase</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: Arial, sans-serif;
}
.section {
/* height: 100lvh; */
/* height: 800px; */
display: flex;
justify-content: center;
align-items: center;
color: white;
background-color: #3498db;
}
.split {
display: flex;
justify-content: center;
align-items: center;
}
.full-width {
width: 100%;
}
.split-left, .split-right {
width: 50%;
min-height: 50%;
padding: 1rem;
margin: 1rem;
border: 1px solid salmon;
}
.split-nosize {
border: 1px solid salmon;
}
.split-size {
padding: 1rem;
margin: 1rem;
}
.navigation {
display: flex;
position: fixed;
left: 20px;
bottom: 20px;
transform: translateY(-50%);
}
.nav-btn {
display: block;
margin: 0px 5px;
padding: 10px;
background-color: rgba(255, 255, 255, 0.7);
border: none;
cursor: pointer;
}
.vertical-split {
display: flex;
flex-direction: column;
}
.high-page {
height: 600px;
}
.split-top {
width: 100%;
height: 600px;
}
.split-bottom {
width: 100%;
/* height: 600px; */
}
.split-bottom {
background-color: #1e1e1e;
color: #d4d4d4;
overflow: auto;
}
.split-bottom pre {
height: 100%;
margin: 0;
}
.split-bottom code {
font-family: 'Consolas', 'Courier New', monospace;
font-size: 12px;
line-height: 1.5;
display: block;
padding: 1rem;
}
</style>
<script>
function escapeHTMLandInject(text) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
document.currentScript.parentElement.innerHTML = escaped;
}
</script>
</head>
<body>
<!--
/////////////////////
// start section 0 //
/////////////////////
-->
<!-- <div id="section0" class="section vertical-split">
<div class="split-top split">
<div class="split-left">
aaa
</div>
<div class="split-right" id="section0-element">
<spine identifier="section0" createDiv="true" width="220" height="50"/>
</div>
</div>
<div class="split-bottom">
<pre><code id="code-display">
</code></pre>
</div>
</div> -->
<!--
/////////////////////
// end section 0 //
/////////////////////
-->
<!--
/////////////////////
// start section 1 //
/////////////////////
-->
<div id="section1" class="section vertical-split">
<div class="split-top split">
<div class="split-left">
<spine-widget
atlas="assets/spineboy-pma.atlas"
skeleton="assets/spineboy-pro.skel"
animation="walk"
></spine-widget>
</div>
<div class="split-right">
The <code>&lt;spine-widget&gt;</code> tag allows you to place your Spine animations into a web page.
<br>
<br>
By default, the animation bounds are calculated using the given animation, or the setup pose if no animation is provided.
<br>
The bounds is centered and scaled to fit the parent container.
</div>
</div>
<div class="split-bottom">
<pre><code id="code-display">
<script>
escapeHTMLandInject(`
<spine-widget
atlas="assets/spineboy-pma.atlas"
skeleton="assets/spineboy-pro.skel"
animation="walk"
></spine-widget>`)
</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section 1 //
/////////////////////
-->
<!--
/////////////////////
// start section 2 //
/////////////////////
-->
<div id="section1" class="section vertical-split">
<div class="full-width">
<div class="split">
<div class="split-left" style="height: 300px;">
<spine-widget
atlas="assets/raptor-pma.atlas"
skeleton="assets/raptor-pro.skel"
animation="walk"
fit="fill"
debug="true"
></spine-widget>
</div>
<div class="split-right">
You can change the fit mode of your Spine animation using the <code>fit</code> attribute.
<br>
<br>
This is <code>fit="fill"</code>. Default fit value is <code>fit="contain"</code>.
</div>
</div>
<div class="split">
<div class="split-left" style="height: 300px;">
<spine-widget
atlas="assets/raptor-pma.atlas"
skeleton="assets/raptor-pro.skel"
animation="walk"
scale=".125"
fit="none"
></spine-widget>
</div>
<div class="split-right">
If you want to preserve the original scale, you can use the <code>fit="none"</code>.
In combination with that, you can use the <code>scale</code> attribute to choose you desired scale.
<br>
<br>
Other fit modes are <code>fitWidth</code>, <code>fitWidth</code>, <code>fitHeight</code>, <code>cover</code>,and <code>scaleDown</code>.
</div>
</div>
</div>
<div class="split-bottom">
<pre><code id="code-display">
<script>
escapeHTMLandInject(`
<spine-widget
atlas="assets/raptor-pma.atlas"
skeleton="assets/raptor-pro.skel"
animation="walk"
fit="fill"
></spine-widget>
<spine-widget
atlas="assets/raptor-pma.atlas"
skeleton="assets/raptor-pro.skel"
animation="walk"
scale=".125"
fit="none"
></spine-widget>`)
</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section 2 //
/////////////////////
-->
<!--
/////////////////////
// start section 3 //
/////////////////////
-->
<div id="section2" class="section vertical-split">
<div class="split-top split">
<div class="split-left">
Mode <code>origin</code> center the animation world origin with the center of the HTML element.
<br>
You are responsible to scale the skeleton using this mode.
<br>
<br>
Move the origin by a percentage of the div width and height by using <code>x-axis</code> and <code>y-axis</code> respectively.
</div>
<div class="split-right">
<spine-widget
atlas="assets/vine-pma.atlas"
skeleton="assets/vine-pro.skel"
animation="grow"
mode="origin"
scale=".5"
y-axis="-.5"
></spine-widget>
</div>
</div>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
<spine-widget
atlas="assets/vine-pma.atlas"
skeleton="assets/vine-pro.skel"
animation="grow"
mode="origin"
scale=".5"
y-axis="-.5"
></spine-widget>
`);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section 3 //
/////////////////////
-->
<!--
/////////////////////
// start section 3 //
/////////////////////
-->
<div id="section3" class="section vertical-split">
<div class="split-top split">
<div class="split-left">
<spine-widget
atlas="assets/snowglobe-pma.atlas"
skeleton="assets/snowglobe-pro.skel"
animation="shake"
offset-x="100"
offset-y="-100"
></spine-widget>
</div>
<div class="split-right">
Use <code>offset-x</code> and <code>offset-y</code> to move you skeleton left or right by the pixel amount you specify.
</div>
</div>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
<spine-widget
atlas="assets/snowglobe-pma.atlas"
skeleton="assets/snowglobe-pro.skel"
animation="shake"
offset-x="100"
offset-y="-100"
></spine-widget>
`);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section 3 //
/////////////////////
-->
<!--
/////////////////////
// start section 4 //
/////////////////////
-->
<div id="section4" class="section vertical-split">
<div class="split-top split">
<div class="split-left">
Give an <code>identifier</code> to your widget to get it by using the <code>spine.getSpineWidget</code> function.
You can easily access the <code>Skeleton</code> and the <code>AnimationState</code> of your character, and use them as if you were using <code>spine-webgl</code>.
<br>
<br>
If you change animation, you can ask the widget to scale the skeleton based on the new animation.
</div>
<div class="split-right">
<spine-widget
identifier="raptor"
atlas="assets/raptor-pma.atlas"
skeleton="assets/raptor-pro.skel"
animation="walk"
></spine-widget>
</div>
</div>
<script>
(async () => {
const widget = spine.getSpineWidget("raptor");
const { state } = await widget.loadingPromise;
let isRoaring = false;
setInterval(() => {
const newAnimation = isRoaring ? "walk" : "roar";
state.setAnimation(0, newAnimation, true);
widget.recalculateBounds(); // scale the skeleton based on the new animation
isRoaring = !isRoaring;
}, 4000);
})();
</script>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
// access the spine widget
<spine-widget
identifier="raptor"
atlas="assets/raptor-pma.atlas"
skeleton="assets/raptor-pro.skel"
animation="walk"
></spine-widget>
...
// using js, access the skeleton and the state asynchronously
(async () => {
const widget = document.querySelector("spine-widget[identifier=raptor]");
const { state } = await widget.loadingPromise;
let isRoaring = false;
setInterval(() => {
const newAnimation = isRoaring ? "walk" : "roar";
state.setAnimation(0, newAnimation, true);
widget.recalculateBounds(); // scale the skeleton based on the new animation
isRoaring = !isRoaring;
}, 4000);
})();
`);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section 4 //
/////////////////////
-->
<!--
/////////////////////
// start section 6 //
/////////////////////
-->
<div id="section6" class="section vertical-split">
<div class="full-width high-page" style="padding: 1em;">
<div class="split-nosize" style="padding: 1em; margin-bottom: 1em;">
Moving the div will move the skeleton origin. <br>
Resizing the div will resize the skeleton in <code>inside</code> mode, but not in <code>origin</code> mode.
</div>
<div class="split-nosize" style="width: 100%; height: 50%;" id="section6-element">
<spine-widget
atlas="assets/cloud-pot-pma.atlas"
skeleton="assets/cloud-pot.skel"
animation="playing-in-the-rain"
></spine-widget>
</div>
</div>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
<spine-widget
atlas="assets/cloud-pot-pma.atlas"
skeleton="assets/cloud-pot.skel"
animation="playing-in-the-rain"
></spine-widget>`
);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section 6 //
/////////////////////
-->
<!--
/////////////////////
// start section 7 //
/////////////////////
-->
<div id="section7" class="section vertical-split">
<div class="split-top split">
<div class="split-left">
You can view the skeleton world origin (green), the root bone position (red), and the bounds rectangle and center (blue) by setting <code>debug</code> to <code>true</code>.
</div>
<div class="split-right" id="section7-element">
<spine-widget
atlas="assets/sack-pma.atlas"
skeleton="assets/sack-pro.skel"
animation="cape-follow-example"
debug="true"
></spine-widget>
</div>
</div>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
<spine-widget
atlas="assets/sack-pma.atlas"
skeleton="assets/sack-pro.skel"
animation="cape-follow-example"
debug="true"
></spine-widget>`
);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section 7 //
/////////////////////
-->
<!--
/////////////////////
// start section 8 //
/////////////////////
-->
<div id="section8" class="section vertical-split">
<div class="split-top split">
<div class="split-left">
As a bonus item, you can move you skeleton around just by setting the <code>draggable</code> property to <code>true</code>.
</div>
<div class="split-right" id="section8-element">
<spine-widget
atlas="assets/celestial-circus-pma.atlas"
skeleton="assets/celestial-circus-pro.skel"
animation="wings-and-feet"
draggable="true"
></spine-widget>
</div>
</div>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
<spine-widget
atlas="assets/celestial-circus-pma.atlas"
skeleton="assets/celestial-circus-pro.skel"
animation="wings-and-feet"
draggable="true"
></spine-widget>`
);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section 8 //
/////////////////////
-->
<!-- <div class="navigation">
<button class="nav-btn" onclick="scrollToSection('section1')">1</button>
<button class="nav-btn" onclick="scrollToSection('section2')">2</button>
<button class="nav-btn" onclick="scrollToSection('section3')">3</button>
<button class="nav-btn" onclick="scrollToSection('section4')">4</button>
<button class="nav-btn" onclick="scrollToSection('section5')">5</button>
<button class="nav-btn" onclick="scrollToSection('section6')">6</button>
<button class="nav-btn" onclick="scrollToSection('section7')">7</button>
<button class="nav-btn" onclick="scrollToSection('section8')">8</button>
</div> -->
<script>
function scrollToSection(id) {
document.getElementById(id).scrollIntoView({ behavior: 'smooth' });
}
let sections = document.querySelectorAll('.section');
let currentSection = 0;
// window.addEventListener('wheel', (e) => {
// if (e.deltaY > 0 && currentSection < sections.length - 1) {
// currentSection++;
// } else if (e.deltaY < 0 && currentSection > 0) {
// currentSection--;
// }
// sections[currentSection].scrollIntoView({ behavior: 'smooth' });
// });
</script>
<script>
//////////////////////////////////////////////////////
//////////////////////////////////////////////////////
//////////////////////////////////////////////////////
//////////////////////////////////////////////////////
// Drag utility
function makeDraggable(element) {
element.style["touch-action"] = "none";
let isDragging = false;
let startX, startY;
let originalX, originalY;
element.addEventListener('pointerdown', startDragging);
document.addEventListener('pointermove', drag);
document.addEventListener('pointerup', stopDragging);
function startDragging(e) {
if (e.target === element.querySelector('#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)`;
}
function stopDragging(e) {
isDragging = false;
}
}
//////////////////////////////////////////////////////
//////////////////////////////////////////////////////
//////////////////////////////////////////////////////
//////////////////////////////////////////////////////
// Resize utility
function makeResizable(element) {
const resizeHandle = document.createElement('div');
element.appendChild(resizeHandle);
resizeHandle.id = "resizeHandle";
resizeHandle.style.width = "20%";
resizeHandle.style.height = "20%";
resizeHandle.style.bottom = "0";
resizeHandle.style.right = "0";
resizeHandle.style.position = "absolute";
resizeHandle.style["background-color"] = "#007bff";
resizeHandle.style["cursor"] = "se-resize";
element.style["position"] = "relative";
element.style["touch-action"] = "none";
let isResizing = false;
let startX, startY, startWidth, startHeight, startPaddingLeft, startPaddingRight, startPaddingTop, startPaddingBottom;
resizeHandle.addEventListener('pointerdown', initResize);
function initResize(e) {
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startWidth = element.offsetWidth;
startHeight = element.offsetHeight;
startPaddingLeft = parseFloat(window.getComputedStyle(element).paddingLeft);
startPaddingRight = parseFloat(window.getComputedStyle(element).paddingRight);
startPaddingTop = parseFloat(window.getComputedStyle(element).paddingTop);
startPaddingBottom = parseFloat(window.getComputedStyle(element).paddingBottom);
document.addEventListener('pointermove', resize);
document.addEventListener('pointerup', stopResize);
}
function resize(e) {
if (!isResizing) return;
const width = startWidth + (e.clientX - startX) - startPaddingLeft - startPaddingRight;
const height = startHeight + (e.clientY - startY) - startPaddingTop - startPaddingBottom;
element.style.width = width + 'px';
element.style.height = height + 'px';
}
function stopResize() {
isResizing = false;
document.removeEventListener('pointermove', resize);
document.removeEventListener('pointerup', stopResize);
}
}
makeDraggable(document.getElementById(`section6-element`));
makeResizable(document.getElementById(`section6-element`));
</script>
</body>
</html>

View File

@ -37,8 +37,8 @@ interface Rectangle {
}
interface OverlaySkeletonOptions {
atlasPath: string,
skeletonPath: string,
atlas: string,
skeleton: string,
scale: number,
animation?: string,
skeletonData?: SkeletonData,
@ -48,7 +48,8 @@ interface OverlaySkeletonOptions {
type UpdateSpineFunction = (canvas: SpineCanvas, delta: number, skeleton: Skeleton, state: AnimationState) => void;
interface OverlayHTMLOptions {
element: HTMLElement,
identifier: string,
createDivInElement?: boolean,
mode?: OverlayElementMode,
debug?: boolean,
offsetX?: number,
@ -58,7 +59,7 @@ interface OverlayHTMLOptions {
draggable?: boolean,
}
type OverlayHTMLElement = Required<OverlayHTMLOptions> & { element: HTMLElement, scaleDpi: number, worldOffsetX: number, worldOffsetY: number, dragging: boolean, dragX: number, dragY: number };
type OverlayHTMLElement = Required<Omit<OverlayHTMLOptions, "identifier">> & { element: HTMLElement, scaleDpi: number, worldOffsetX: number, worldOffsetY: number, dragging: boolean, dragX: number, dragY: number };
type OverlayElementMode = 'inside' | 'origin';
@ -108,7 +109,6 @@ export class SpineCanvasOverlay {
this.canvas.style.left = "0";
this.canvas.style.setProperty("pointer-events", "none");
this.canvas.style.transform =`translate(0px,0px)`;
// this.canvas.style.display = "inline";
// this.canvas.style.setProperty("will-change", "transform"); // performance seems to be even worse with this uncommented
// resize and zoom
@ -130,8 +130,6 @@ export class SpineCanvasOverlay {
window.addEventListener('scroll', this.scrollHandler);
this.scrollHandler();
// zoom
this.spineCanvas = new SpineCanvas(this.canvas, { app: this.setupSpineCanvasApp() });
this.input = new Input(document.body, false);
@ -141,17 +139,17 @@ export class SpineCanvasOverlay {
// 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> | OverlayHTMLOptions | Array<HTMLElement> | HTMLElement | NodeList = [],
htmlOptionsList: Array<OverlayHTMLOptions>,
) {
const { atlasPath, skeletonPath, scale = 1, animation, skeletonData: skeletonDataInput, update } = skeletonOptions;
const { atlas, skeleton: 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),
this.loadTextureAtlas(atlas),
]);
const atlas = this.spineCanvas.assetManager.require(atlasPath);
const atlasLoader = new AtlasAttachmentLoader(atlas);
const atlasLoaded = this.spineCanvas.assetManager.require(atlas);
const atlasLoader = new AtlasAttachmentLoader(atlasLoaded);
const skeletonLoader = isBinary ? new SkeletonBinary(atlasLoader) : new SkeletonJson(atlasLoader);
skeletonLoader.scale = scale;
@ -170,44 +168,56 @@ export class SpineCanvasOverlay {
}
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 ('element' in htmlOptionsList) htmlOptionsList = [htmlOptionsList] as Array<OverlayHTMLOptions>;
const halfDpi = window.devicePixelRatio / 2;
if (htmlOptionsList.length > 0 && htmlOptionsList[0] instanceof HTMLElement) {
list = htmlOptionsList.map(element => ({ element } as OverlayHTMLOptions));
} else {
list = htmlOptionsList as Array<OverlayHTMLOptions>;
const { identifier, createDivInElement = false, mode: givenMode, debug = false, offsetX = 0, offsetY = 0, xAxis = 0, yAxis = 0, draggable = false, } = htmlOptionsList[0];
const mode = givenMode ?? 'inside';
const el = document.querySelector(`spine[identifier="${identifier}"]`) as HTMLElement;
if (!el) {
throw new Error("Element not found with identifier: " + identifier);
}
const halfDpi = window.devicePixelRatio / 2;
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,
// change this name to something like initialScaleDpi
scaleDpi: halfDpi,
// scaleDpi: 1,
dragging: false,
}
});
let parent = el.parentElement;
if (createDivInElement) {
const width = el.getAttribute('width');
const height = el.getAttribute('height');
parent = el;
parent.style.width = `${width}px`;
parent.style.height = `${height}px`;
parent.style.display = 'block';
if (debug) parent.style.backgroundColor = "rgba(0, 0, 0, .5)";
}
if (!parent) {
throw new Error("Parent of element not found");
}
console.log(el)
console.log(parent)
const obj = {
element: parent,
createDivInElement,
mode,
debug,
offsetX,
offsetY,
xAxis,
yAxis,
draggable,
dragX: 0,
dragY: 0,
worldOffsetX: 0,
worldOffsetY: 0,
// change this name to something like initialScaleDpi
scaleDpi: halfDpi,
// scaleDpi: 1,
dragging: false,
}
const mapList = [obj];
skeleton.scaleX = halfDpi;
skeleton.scaleY = halfDpi;
@ -312,6 +322,7 @@ export class SpineCanvasOverlay {
divBounds.x += this.overflowLeftSize;
divBounds.y += this.overflowTopSize;
const fit: "fill" | "fitWidth" | "fitHeight" | "contain" | "cover" | "none" | "scaleDown" = "scaleDown";
let x = 0, y = 0;
if (mode === 'inside') {
// scale ratio
@ -319,16 +330,52 @@ export class SpineCanvasOverlay {
const scaleHeight = divBounds.height * devicePixelRatio / ah;
// attempt to use width ratio
let ratio = scaleWidth;
let scaledW = aw * ratio;
let scaledH = ah * ratio;
let ratioW = 1;
let ratioH = 1;
// if scaled height is bigger than div height, use height ratio instead
if (scaledH > divBounds.height * devicePixelRatio) ratio = scaleHeight;
if (fit === "fill") { // Fill the target box by distorting the source's aspect ratio.
ratioW = scaleWidth;
ratioH = scaleHeight;
} else if (fit === "fitWidth") {
ratioW = scaleWidth;
ratioH = scaleWidth;
} else if (fit === "fitHeight") {
ratioW = scaleHeight;
ratioH = scaleHeight;
} else if (fit === "contain") {
// if scaled height is bigger than div height, use height ratio instead
if (ah * scaleWidth > divBounds.height * devicePixelRatio){
ratioW = scaleHeight;
ratioH = scaleHeight;
} else {
ratioW = scaleWidth;
ratioH = scaleWidth;
}
} else if (fit === "cover") {
if (ah * scaleWidth < divBounds.height * devicePixelRatio){
ratioW = scaleHeight;
ratioH = scaleHeight;
} else {
ratioW = scaleWidth;
ratioH = scaleWidth;
}
} else if (fit === "scaleDown") {
if (aw > divBounds.width * devicePixelRatio || ah > divBounds.height * devicePixelRatio) {
if (ah * scaleWidth > divBounds.height * devicePixelRatio){
ratioW = scaleHeight;
ratioH = scaleHeight;
} else {
ratioW = scaleWidth;
ratioH = scaleWidth;
}
}
} else if (fit === "none") {
}
// get the center of the bounds
const boundsX = (ax + aw / 2) * ratio;
const boundsY = (ay + ah / 2) * ratio;
const boundsX = (ax + aw / 2) * ratioW;
const boundsY = (ay + ah / 2) * ratioH;
// get the center of the div in world coordinate
const divX = divBounds.x + divBounds.width / 2;
@ -340,8 +387,8 @@ export class SpineCanvasOverlay {
y = tempVector.y - boundsY;
// scale the skeleton
skeleton.scaleX = ratio;
skeleton.scaleY = ratio;
skeleton.scaleX = ratioW;
skeleton.scaleY = ratioH;
} else {
// get the center of the div in world coordinate
const divX = divBounds.x + divBounds.width * xAxis;

View File

@ -0,0 +1,806 @@
/******************************************************************************
* 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,
}
type UpdateSpineFunction = (canvas: SpineCanvas, delta: number, skeleton: Skeleton, state: AnimationState) => void;
type ModeType = 'inside' | 'origin';
function isModeType(value: string): value is ModeType {
return (
value === "inside" ||
value === "origin"
);
}
type FitType = "fill" | "fitWidth" | "fitHeight" | "contain" | "cover" | "none" | "scaleDown";
function isFitType(value: string): value is FitType {
return (
value === "fill" ||
value === "fitWidth" ||
value === "fitHeight" ||
value === "contain" ||
value === "cover" ||
value === "none" ||
value === "scaleDown"
);
}
interface WidgetLayoutOptions {
mode: ModeType
debug: boolean
offsetX: number
offsetY: number
xAxis: number
yAxis: number
draggable: boolean
fit: FitType
width: number
height: number
identifier: string
}
interface WidgetPublicState {
skeleton: Skeleton
state: AnimationState
bounds: Rectangle
}
interface WidgetInternalState {
currentScaleDpi: number
worldOffsetX: number
worldOffsetY: number
dragging: boolean
dragX: number
dragY: number
}
class SpineWebComponentWidget extends HTMLElement implements WidgetLayoutOptions, WidgetInternalState, Partial<WidgetPublicState> {
// skeleton options
public atlasPath: string;
public skeletonPath: string;
public scale = 1;
public animation?: string;
skeletonData?: SkeletonData; // TODO
update?: UpdateSpineFunction; // TODO
// layout options
public fit: FitType = "contain";
public mode: ModeType = "inside";
public offsetX = 0;
public offsetY = 0;
public xAxis = 0;
public yAxis = 0;
public width = 0;
public height = 0;
public draggable = false;
public debug = false;
public identifier = "";
// state
public skeleton?: Skeleton;
public state?: AnimationState;
public bounds?: Rectangle;
public loadingPromise?: Promise<WidgetPublicState>;
// TODO: makes the interface exposes getter, make getter and make these private
// internal state
public currentScaleDpi = 1;
public worldOffsetX = 0;
public worldOffsetY = 0;
public dragX = 0;
public dragY = 0;
public dragging = false;
private root: ShadowRoot;
private overlay: SpineWebComponentOverlay;
static get observedAttributes(): string[] {
return ["atlas", "skeleton", "scale", "animation", "fit", "width", "height", "draggable", "mode", "x-axis", "y-axis", "identifier", "offset-x", "offset-y", "debug"];
}
constructor() {
super();
this.root = this.attachShadow({ mode: "open" });
this.overlay = this.initializeOverlay();
this.atlasPath = "TODO";
this.skeletonPath = "TODO";
}
connectedCallback() {
if (!this.atlasPath) {
throw new Error("Missing atlas attribute");
}
if (!this.skeletonPath) {
throw new Error("Missing skeleton attribute");
}
this.loadingPromise = this.loadSkeleton();
this.loadingPromise.then(() => {
this.overlay.skeletonList.push(this);
}); // async
this.render();
}
disconnectedCallback(): void {
this.loadingPromise?.then(() => {
const index = this.overlay.skeletonList.indexOf(this);
if (index !== -1) {
this.overlay.skeletonList.splice(index, 1);
}
});
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
if (newValue) {
if (name === "identifier") {
this.identifier = newValue;
}
if (name === "atlas") {
this.atlasPath = newValue;
}
if (name === "skeleton") {
this.skeletonPath = newValue;
}
if (name === "fit") {
this.fit = isFitType(newValue) ? newValue : "contain";
}
if (name === "mode") {
this.mode = isModeType(newValue) ? newValue : "inside";
}
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);
}
}
}
// calculate bounds of the current animation on track 0, then set it
public recalculateBounds() {
const { skeleton, state } = this;
if (!skeleton || !state) return;
const track = state.getCurrent(0);
const animation = track?.animation as (Animation | undefined);
const bounds = this.calculateAnimationViewport(animation);
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) {
const { skeleton } = this;
if (!skeleton) return;
bounds.x /= skeleton.scaleX;
bounds.y /= skeleton.scaleY;
bounds.width /= skeleton.scaleX;
bounds.height /= skeleton.scaleY;
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;
}
// add a skeleton to the overlay and set the bounds to the given animation or to the setup pose
private async loadSkeleton() {
// if (this.identifier !== "TODELETE") return Promise.reject();
const { atlasPath, skeletonPath, scale = 1, animation, skeletonData: skeletonDataInput, update } = this;
const isBinary = skeletonPath.endsWith(".skel");
// TODO: when multiple component are loaded, they do no reuse the asset manager cache.
// TODO: we need to reuse the same texture atlas to allow batching when skeletons use the same texture
await Promise.all([
isBinary ? this.loadBinary(skeletonPath) : this.loadJson(skeletonPath),
this.loadTextureAtlas(atlasPath),
]);
const atlas = this.overlay.spineCanvas.assetManager.require(atlasPath);
const atlasLoader = new AtlasAttachmentLoader(atlas);
const skeletonLoader = isBinary ? new SkeletonBinary(atlasLoader) : new SkeletonJson(atlasLoader);
skeletonLoader.scale = scale;
const skeletonFile = this.overlay.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;
}
// 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
// at the current browser zoom level
this.currentScaleDpi = window.devicePixelRatio;
// skeleton.scaleX = this.currentScaleDpi;
// skeleton.scaleY = this.currentScaleDpi;
this.skeleton = skeleton;
this.state = state;
const bounds = this.calculateAnimationViewport(animationData);
this.bounds = bounds;
return { skeleton, state, bounds };
}
public getHTMLElementReference(): HTMLElement {
return this.width <= 0 || this.width <= 0
? this.parentElement!
: this;
}
private render(): void {
this.root.innerHTML = `
<style>
:host {
display: inline-block;
width: ${this.width}px;
height: ${this.height}px;
background-color: red;
}
</style>
`;
}
/*
* Load assets utilities
*/
private async loadBinary(path: string) {
return new Promise((resolve, reject) => {
this.overlay.spineCanvas.assetManager.loadBinary(path,
(_, binary) => resolve(binary),
(_, message) => reject(message),
);
});
}
private async loadJson(path: string) {
return new Promise((resolve, reject) => {
this.overlay.spineCanvas.assetManager.loadJson(path,
(_, object) => resolve(object),
(_, message) => reject(message),
);
});
}
private async loadTextureAtlas(path: string) {
return new Promise((resolve, reject) => {
this.overlay.spineCanvas.assetManager.loadTextureAtlas(path,
(_, atlas) => resolve(atlas),
(_, message) => reject(message),
);
});
}
/*
* Other utilities
*/
private calculateAnimationViewport (animation?: Animation): Rectangle {
const renderer = this.overlay.spineCanvas.renderer;
const { skeleton } = this;
if (!skeleton) return { x: 0, y: 0, width: 0, height: 0 };
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, 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, 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,
}
}
}
class SpineWebComponentOverlay extends HTMLElement {
private root: ShadowRoot;
public spineCanvas:SpineCanvas;
private div: HTMLDivElement;
private canvas:HTMLCanvasElement;
private fps: HTMLSpanElement;
public skeletonList = new Array<SpineWebComponentWidget>();
private resizeObserver:ResizeObserver;
private input: Input;
// how many pixels to add to the
private overflowTop = .1;
private overflowBottom = .2;
private overflowLeft = .1;
private overflowRight = .1;
private overflowLeftSize: number
private overflowTopSize: number;
constructor() {
super();
this.root = this.attachShadow({ mode: 'open' });
this.div = document.createElement('div');
this.div.style.position = "absolute";
this.div.style.top = "0";
this.div.style.left = "0";
this.div.style.setProperty("pointer-events", "none");
this.div.style.overflow = "hidden"
// this.div.style.backgroundColor = "rgba(0, 255, 0, 0.3)";
this.root.appendChild(this.div);
this.canvas = document.createElement('canvas');
this.div.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.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.style.position = "fixed";
this.fps.style.top = "0";
this.fps.style.left = "0";
this.root.appendChild(this.fps);
// resize and zoom
// TODO: should I use the resize event?
this.resizeObserver = new ResizeObserver(() => {
this.updateCanvasSize();
this.zoomHandler();
this.spineCanvas.renderer.resize(ResizeMode.Expand);
});
this.resizeObserver.observe(document.body);
this.updateCanvasSize();
this.overflowLeftSize = this.overflowLeft * document.documentElement.clientWidth;
this.overflowTopSize = this.overflowTop * document.documentElement.clientHeight;
this.zoomHandler();
// scroll
window.addEventListener('scroll', this.scrollHandler);
this.scrollHandler();
this.spineCanvas = new SpineCanvas(this.canvas, { app: this.setupSpineCanvasApp() });
this.input = new Input(document.body, false);
this.setupDragUtility();
}
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 }) => {
if (!skeleton || !state) return;
if (update) update(canvas, delta, skeleton, state)
else {
state.update(delta);
state.apply(skeleton);
skeleton.update(delta);
skeleton.updateWorldTransform(Physics.update);
}
});
this.fps.innerText = canvas.time.framesPerSecond.toFixed(2) + " fps";
},
render: (canvas: SpineCanvas) => {
// canvas.clear(0, 0 , 0, 0);
let renderer = canvas.renderer;
renderer.begin();
const devicePixelRatio = window.devicePixelRatio;
const tempVector = new Vector3();
this.skeletonList.forEach((widget) => {
const { skeleton, bounds, mode, debug, offsetX, offsetY, xAxis, yAxis, dragX, dragY, fit } = widget;
if (!skeleton) return;
const divBounds = widget.getHTMLElementReference().getBoundingClientRect();
divBounds.x += this.overflowLeftSize;
divBounds.y += this.overflowTopSize;
// get the desired point into the the div (center by default) in world coordinate
const divX = divBounds.x + divBounds.width * (xAxis + .5);
const divY = divBounds.y + divBounds.height * (-yAxis + .5);
this.screenToWorld(tempVector, divX, divY);
let x = tempVector.x;
let y = tempVector.y;
if (mode === 'inside') {
let { x: ax, y: ay, width: aw, height: ah } = bounds!;
// scale ratio
const scaleWidth = divBounds.width * devicePixelRatio / aw;
const scaleHeight = divBounds.height * devicePixelRatio / ah;
let ratioW = skeleton.scaleX;
let ratioH = skeleton.scaleY;
if (fit === "fill") { // Fill the target box by distorting the source's aspect ratio.
ratioW = scaleWidth;
ratioH = scaleHeight;
} else if (fit === "fitWidth") {
ratioW = scaleWidth;
ratioH = scaleWidth;
} else if (fit === "fitHeight") {
ratioW = scaleHeight;
ratioH = scaleHeight;
} else if (fit === "contain") {
// if scaled height is bigger than div height, use height ratio instead
if (ah * scaleWidth > divBounds.height * devicePixelRatio){
ratioW = scaleHeight;
ratioH = scaleHeight;
} else {
ratioW = scaleWidth;
ratioH = scaleWidth;
}
} else if (fit === "cover") {
if (ah * scaleWidth < divBounds.height * devicePixelRatio){
ratioW = scaleHeight;
ratioH = scaleHeight;
} else {
ratioW = scaleWidth;
ratioH = scaleWidth;
}
} else if (fit === "scaleDown") {
if (aw > divBounds.width * devicePixelRatio || ah > divBounds.height * devicePixelRatio) {
if (ah * scaleWidth > divBounds.height * devicePixelRatio){
ratioW = scaleHeight;
ratioH = scaleHeight;
} else {
ratioW = scaleWidth;
ratioH = scaleWidth;
}
}
}
// get the center of the bounds
const boundsX = (ax + aw / 2) * ratioW;
const boundsY = (ay + ah / 2) * ratioH;
// get vertices offset: calculate the distance between div center and bounds center
x = tempVector.x - boundsX;
y = tempVector.y - boundsY;
if (fit !== "none") {
// scale the skeleton
skeleton.scaleX = ratioW;
skeleton.scaleY = ratioH;
}
}
widget.worldOffsetX = x + offsetX + dragX;
widget.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] + widget.worldOffsetX;
vertices[i+1] = vertices[i+1] + widget.worldOffsetY;
}
});
// drawing debug stuff
if (debug) {
// if (true) {
let { x: ax, y: ay, width: aw, height: ah } = bounds!;
// show bounds and its center
renderer.rect(false,
ax * skeleton.scaleX + widget.worldOffsetX,
ay * skeleton.scaleY + widget.worldOffsetY,
aw * skeleton.scaleX,
ah * skeleton.scaleY,
blue);
const bbCenterX = (ax + aw / 2) * skeleton.scaleX + widget.worldOffsetX;
const bbCenterY = (ay + ah / 2) * skeleton.scaleY + widget.worldOffsetY;
renderer.circle(true, bbCenterX, bbCenterY, 10, blue);
// show skeleton root
const root = skeleton.getRootBone()!;
renderer.circle(true, root.x + widget.worldOffsetX, root.y + widget.worldOffsetY, 10, red);
// show shifted origin
const originX = widget.worldOffsetX - dragX - offsetX;
const originY = widget.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();
},
}
}
connectedCallback(): void {
}
disconnectedCallback(): void {
}
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 + this.overflowLeftSize, originalEvent.pageY - window.scrollY + this.overflowTopSize, 0);
this.spineCanvas.renderer.camera.screenToWorld(tempVectorInput, this.canvas.clientWidth, this.canvas.clientHeight);
this.skeletonList.forEach(widget => {
if (!widget.draggable) return;
const { worldOffsetX, worldOffsetY } = widget;
const bounds = widget.bounds!;
const skeleton = widget.skeleton!;
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 (inside(tempVectorInput, newBounds)) {
widget.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 + this.overflowLeftSize, originalEvent.pageY - window.scrollY + this.overflowTopSize, 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(widget => {
if (widget.dragging) {
const skeleton = widget.skeleton!;
skeleton.physicsTranslate(dragX, dragY);
widget.dragX += dragX;
widget.dragY += dragY;
ev?.preventDefault();
ev?.stopPropagation()
}
});
prevX = tempVectorInput.x;
prevY = tempVectorInput.y;
},
up: () => {
this.skeletonList.forEach(widget => {
widget.dragging = false;
});
}
})
}
/*
* Resize/scroll utilities
*/
private updateCanvasSize() {
// resize canvas
this.resizeCanvas();
// recalculate overflow left and size since canvas size changed
// we could keep the initial values, avoid this and the translation below - even though we don't have a great gain
this.translateCanvas();
// temporarely remove the div to get the page size without considering the div
// this is necessary otherwise if the bigger element in the page is remove and the div
// was the second bigger element, now it would be the div to dtermine the page size
this.div.remove();
const { width, height } = this.getPageSize();
this.root.appendChild(this.div);
this.div.style.width = width + "px";
this.div.style.height = height + "px";
}
private resizeCanvas() {
const displayWidth = document.documentElement.clientWidth;
const displayHeight = document.documentElement.clientHeight;
this.canvas.style.width = displayWidth * (1 + (this.overflowLeft + this.overflowRight)) + "px";
this.canvas.style.height = displayHeight * (1 + (this.overflowTop + this.overflowBottom)) + "px";
if (this.spineCanvas) this.spineCanvas.renderer.resize(ResizeMode.Expand);
}
private scrollHandler = () => {
this.translateCanvas();
}
private translateCanvas() {
const displayWidth = document.documentElement.clientWidth;
const displayHeight = document.documentElement.clientHeight;
this.overflowLeftSize = this.overflowLeft * displayWidth;
this.overflowTopSize = this.overflowTop * displayHeight;
const scrollPositionX = window.scrollX - this.overflowLeftSize;
const scrollPositionY = window.scrollY - this.overflowTopSize;
this.canvas.style.transform =`translate(${scrollPositionX}px,${scrollPositionY}px)`;
}
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;
const skeleton = widget.skeleton;
if (!skeleton) return;
const scale = window.devicePixelRatio;
skeleton.scaleX = skeleton.scaleX / widget.currentScaleDpi * scale;
skeleton.scaleY = skeleton.scaleY / widget.currentScaleDpi * scale;
widget.currentScaleDpi = scale;
})
}
private getPageSize() {
// we need the bounding client rect otherwise decimals won't be returned
// this means that during zoom it might occurs that the div would be resized
// rounded 1px more making a scrollbar appear
return document.body.getBoundingClientRect();
}
/*
* Other utilities
*/
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);
}
}
const 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
);
}
customElements.define('spine-widget', SpineWebComponentWidget);
customElements.define('spine-overlay', SpineWebComponentOverlay);
export function getSpineWidget(identifier: string) {
return document.querySelector(`spine-widget[identifier=${identifier}]`);
}

View File

@ -14,6 +14,7 @@ export * from "./SkeletonDebugRenderer.js";
export * from "./SkeletonRenderer.js";
export * from "./SpineCanvas.js";
export * from "./SpineCanvasOverlay.js";
export * from "./SpineWebComponent.js";
export * from "./Vector3.js";
export * from "./WebGL.js";
export * from "@esotericsoftware/spine-core";