Add followSlot method

This commit is contained in:
Davide Tantillo 2025-01-09 11:08:24 +01:00
parent f9d73920d2
commit a636ef0964
8 changed files with 1059 additions and 14 deletions

View File

@ -0,0 +1,14 @@
button.png
size:372,510
filter:Linear,Linear
Button
bounds:220,2,400,150
offsets:1,1,402,152
rotate:90
CLICK ME
bounds:2,470,231,38
offsets:1,1,233,40
Shadow
bounds:2,2,466,216
offsets:9,9,484,234
rotate:90

View File

@ -0,0 +1,139 @@
{
"skeleton": {
"hash": "yil4eBV+4V0",
"spine": "4.2.38",
"x": -241,
"y": -139,
"width": 484,
"height": 234,
"images": "./images/",
"audio": "./audio"
},
"bones": [
{ "name": "root" },
{ "name": "button-small", "parent": "root" },
{ "name": "button-big", "parent": "button-small" },
{ "name": "follower", "parent": "button-small", "rotation": 45, "x": 100, "y": 200 }
],
"slots": [
{ "name": "Shadow", "bone": "button-small", "attachment": "Shadow" },
{ "name": "button-big", "bone": "button-big", "attachment": "Button" },
{ "name": "button-small", "bone": "button-small", "attachment": "Button" },
{ "name": "CLICK ME", "bone": "button-small", "attachment": "CLICK ME" },
{ "name": "follower", "bone": "follower" }
],
"skins": [
{
"name": "default",
"attachments": {
"button-big": {
"Button": { "width": 402, "height": 152 }
},
"button-small": {
"Button": { "width": 402, "height": 152 }
},
"CLICK ME": {
"CLICK ME": { "x": 0.5, "y": -1, "width": 233, "height": 40 }
},
"Shadow": {
"Shadow": { "x": 1, "y": -22, "width": 484, "height": 234 }
}
}
}
],
"animations": {
"enhance-in": {
"slots": {
"button-big": {
"rgba": [
{ "color": "ffffffff" },
{ "time": 0.3333, "color": "ffffff00" }
]
}
},
"bones": {
"button-big": {
"scale": [
{},
{ "time": 0.3333, "x": 1.5, "y": 1.5 }
]
}
}
},
"enhance-out": {
"slots": {
"button-big": {
"rgba": [
{ "color": "ffffff00" },
{ "time": 0.3333, "color": "ffffffff" }
]
}
},
"bones": {
"button-big": {
"scale": [
{ "x": 1.5, "y": 1.5 },
{ "time": 0.3333 }
]
}
}
},
"idle": {
"slots": {
"button-big": {
"attachment": [
{}
]
},
"Shadow": {
"attachment": [
{}
]
}
}
},
"jump": {
"bones": {
"button-small": {
"translate": [
{
"curve": [ 0.078, 0, 0.156, 0, 0.078, 0, 0.156, 10 ]
},
{
"time": 0.2333,
"y": 10,
"curve": [ 0.311, 0, 0.389, 0, 0.311, 10, 0.389, 0 ]
},
{ "time": 0.4667 }
]
}
}
},
"shadow-in": {
"slots": {
"Shadow": {
"rgba": [
{ "color": "ffffff00" },
{ "time": 0.3333, "color": "ffffffff" }
],
"attachment": [
{ "name": "Shadow" }
]
}
}
},
"shadow-out": {
"slots": {
"Shadow": {
"rgba": [
{ "color": "ffffffff" },
{ "time": 0.3333, "color": "ffffff00" }
],
"attachment": [
{ "name": "Shadow" }
]
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,390 @@
chibi-stickers-pro-pwd-test.png
size:2047,501
filter:Linear,Linear
pma:true
scale:0.5
common/angry-mark
bounds:762,2,42,41
common/big-purple-fear
bounds:1016,147,134,71
offsets:0,0,134,72
common/big-tear
bounds:65,38,33,82
common/eye-3
bounds:934,245,15,26
rotate:90
common/eye-closed-happy
bounds:131,217,25,9
common/eye-dafault
bounds:2019,398,22,21
common/eye-equal
bounds:934,228,25,15
common/eye-fire
bounds:1574,14,26,28
rotate:90
common/eye-half-open
bounds:2019,348,26,16
rotate:90
common/eye-heart
bounds:2019,473,26,23
rotate:90
common/eye-reverse-v
bounds:682,355,26,16
rotate:90
common/eye-sideway-v
bounds:682,413,21,23
common/eye-slant-close
bounds:2,196,23,16
common/eye-small-dot
bounds:689,339,14,14
offsets:0,1,15,15
common/eye-sparkle
bounds:2006,12,30,29
rotate:90
common/eye-star
bounds:1506,13,29,27
common/eye-twirl
bounds:682,438,21,23
common/eye-u
bounds:572,353,24,17
common/eye-x
bounds:2019,446,25,22
rotate:90
common/lamp
bounds:867,230,47,65
rotate:90
common/mouth-3
bounds:682,383,15,28
common/mouth-bracket
bounds:1731,201,34,11
common/mouth-doubt
bounds:934,262,26,15
common/mouth-fangs
bounds:1288,77,39,14
common/mouth-line
bounds:698,463,36,7
rotate:90
common/mouth-neutral
bounds:1218,27,27,12
common/mouth-o-tall
bounds:1604,18,22,33
rotate:90
common/mouth-open-smile
bounds:1468,18,36,22
common/mouth-rectangle
bounds:1537,19,35,21
common/mouth-reverse-v
bounds:284,57,27,10
common/mouth-s
bounds:366,76,41,11
common/mouth-smile-little
bounds:1672,21,33,19
common/mouth-toungue-sticking-out
bounds:1639,19,31,21
common/mouth-u
bounds:728,81,36,19
common/mouth-v
bounds:548,221,27,14
common/mouth-x
bounds:2019,376,21,20
common/purple-fear-lines
bounds:1418,12,48,28
common/shadow
bounds:955,44,111,29
offsets:1,1,113,31
common/small-drop-line
bounds:303,198,16,17
rotate:90
common/small-purple-fear
bounds:2,18,54,38
common/tear
bounds:140,228,20,19
erikari/arm
bounds:438,33,28,90
rotate:90
erikari/arm-shoulder-decoration
bounds:153,98,32,43
rotate:90
erikari/back-hair
bounds:2,317,158,141
erikari/back-hair-long
bounds:706,279,220,254
rotate:90
erikari/blush
bounds:1126,21,29,18
erikari/body
bounds:460,63,70,98
rotate:90
erikari/bracelet
bounds:249,69,33,11
erikari/collar
bounds:2,58,61,62
erikari/ear
bounds:1792,7,34,42
rotate:90
erikari/eyebrow
bounds:303,170,19,12
offsets:0,0,20,12
erikari/hair-front
bounds:1895,147,130,65
erikari/hair-side
bounds:258,82,43,132
erikari/hat-border
bounds:2,460,254,39
erikari/hat-top
bounds:1299,152,160,60
erikari/head-base
bounds:1154,204,143,125
erikari/leg
bounds:560,38,28,101
rotate:90
erikari/leg-decoration
bounds:328,74,36,13
erikari/skirt
bounds:162,216,164,101
erikari/strawberries-decoration
bounds:560,68,112,56
harri/arm
bounds:850,35,28,90
rotate:90
harri/back-hair
bounds:412,321,158,141
harri/back-hair-long
bounds:509,239,40,80
harri/beard
bounds:572,340,10,11
harri/blush
bounds:1095,21,29,18
luke/blush
bounds:1095,21,29,18
nate/blush
bounds:1095,21,29,18
spineboy/blush
bounds:1095,21,29,18
harri/body
bounds:855,65,70,98
rotate:90
harri/body-decoration
bounds:1216,74,70,67
harri/ear
bounds:1748,7,34,42
rotate:90
soeren/ear
bounds:1748,7,34,42
rotate:90
spineboy/ear
bounds:1748,7,34,42
rotate:90
harri/eyebrow
bounds:303,184,22,12
harri/hair-front
bounds:303,27,143,90
harri/head-base
bounds:1884,214,143,125
luke/head-base
bounds:1884,214,143,125
soeren/head-base
bounds:1884,214,143,125
spineboy/head-base
bounds:1884,214,143,125
harri/leg
bounds:1171,41,28,101
rotate:90
harri/sword
bounds:962,220,185,82
luke/arm
bounds:192,35,28,90
rotate:90
luke/arm-shoulder-decoration
bounds:754,250,31,27
luke/back-hair
bounds:1876,341,158,141
rotate:90
luke/body
bounds:1116,71,70,98
rotate:90
luke/eyebrow
bounds:1189,27,27,12
nate/eyebrow
bounds:1189,27,27,12
spineboy/eyebrow
bounds:1189,27,27,12
luke/face-cover
bounds:552,198,169,153
rotate:90
luke/glasses-shadow
bounds:867,137,147,81
luke/hair-decoration
bounds:328,119,130,107
luke/hair-front
bounds:1294,93,122,57
luke/leg
bounds:1068,41,28,101
rotate:90
luke/shield
bounds:162,354,88,104
luke/skirt
bounds:1879,12,81,31
luke/sword
bounds:2,122,102,71
offsets:0,0,104,71
mario/arm
bounds:100,35,28,90
rotate:90
mario/back-hair
bounds:1154,331,168,148
rotate:90
mario/back-hair-long
bounds:460,135,86,91
mario/beard
bounds:706,70,147,93
mario/blush
bounds:1064,21,29,18
mario/body
bounds:1618,72,70,98
rotate:90
mario/ear
bounds:1962,8,34,42
rotate:90
mario/eyebrow
bounds:1352,23,32,17
mario/hair-front
bounds:1461,146,137,66
mario/head-base
bounds:1739,214,143,125
mario/leg
bounds:1521,42,28,101
rotate:90
misaki/arm
bounds:1624,42,28,90
rotate:90
misaki/back-hair
bounds:1733,341,158,141
rotate:90
misaki/back-hair-long
bounds:962,304,190,195
misaki/belt
bounds:1274,19,76,26
misaki/blush
bounds:1836,22,29,18
misaki/body
bounds:1518,72,70,98
rotate:90
misaki/ear
bounds:986,8,34,42
rotate:90
misaki/eyebrow
bounds:1157,27,30,12
misaki/glasses
bounds:555,464,141,35
misaki/glasses-side
bounds:728,269,8,23
rotate:90
misaki/hair-front
bounds:1152,143,140,59
misaki/hair-side
bounds:1277,42,47,140
rotate:90
misaki/head-base
bounds:1594,214,143,125
misaki/leg
bounds:1418,42,28,101
rotate:90
misaki/skirt
bounds:572,372,108,90
nate/arm
bounds:1748,43,28,90
rotate:90
nate/back-hair
bounds:1590,341,158,141
rotate:90
nate/beard
bounds:1600,144,147,68
nate/body
bounds:1749,73,70,98
rotate:90
nate/ear
bounds:942,8,34,42
rotate:90
nate/glasses
bounds:412,464,141,35
nate/glasses-side
bounds:2037,358,8,16
nate/hair-front
bounds:106,65,142,65
nate/head-base
bounds:1449,214,143,125
nate/leg
bounds:1879,45,28,101
rotate:90
sinisa/arm
bounds:324,89,28,90
rotate:90
sinisa/back-hair
bounds:1447,341,158,141
rotate:90
sinisa/beard
bounds:709,33,139,45
sinisa/blush
bounds:1386,22,29,18
sinisa/body
bounds:1418,74,70,98
rotate:90
sinisa/body-decoration
bounds:530,34,27,27
sinisa/ear
bounds:284,12,34,42
sinisa/eyebrow
bounds:791,83,38,18
offsets:0,0,38,19
sinisa/hair-front
bounds:561,126,143,92
sinisa/head-base
bounds:1304,214,143,125
sinisa/leg
bounds:1718,41,28,101
soeren/arm
bounds:162,324,28,90
rotate:90
soeren/back-hair
bounds:258,319,150,141
soeren/beard
bounds:1749,144,145,68
soeren/blush
bounds:787,259,29,18
soeren/body
bounds:1895,75,70,98
rotate:90
soeren/eyebrow
bounds:838,151,27,12
soeren/glasses
bounds:258,462,152,37
soeren/glasses-side
bounds:509,230,7,20
rotate:90
soeren/glove
bounds:955,82,42,53
soeren/hair-front
bounds:706,164,159,113
soeren/leg
bounds:1849,42,28,101
spineboy/arm
bounds:674,34,28,90
spineboy/arm-decoration
bounds:1030,13,32,29
spineboy/arm-shoulder-decoration
bounds:2019,421,23,23
spineboy/back-hair
bounds:1304,341,158,141
rotate:90
spineboy/body
bounds:1016,75,70,98
rotate:90
spineboy/glasses
bounds:328,228,179,89
spineboy/glasses-shadow
bounds:117,132,139,82
spineboy/hair-front
bounds:2,195,145,120
spineboy/leg
bounds:1995,44,29,101

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

File diff suppressed because one or more lines are too long

View File

@ -27,13 +27,14 @@
display: flex;
justify-content: center;
align-items: center;
flex-grow: 1;
}
.full-width {
width: 100%;
}
.split-left, .split-right {
width: 50%;
min-height: 50%;
min-height: 300px;
padding: 1rem;
margin: 1rem;
border: 1px solid salmon;
@ -72,7 +73,7 @@
.split-top {
width: 100%;
height: 600px;
min-height: 600px;
}
.split-bottom {
@ -801,7 +802,7 @@
<div class="split" style="width: 100%; flex-direction: column;">
<div class="split-left" style="width: 80%; box-sizing: border-box;">
<div class="split-left" style="width: 80%; box-sizing: border-box; min-height: 0;">
It's super easy to show your different skins and animations. Just make a table and use the <code>skin</code> and <code>animation</code> attributes.
</div>
@ -1039,7 +1040,7 @@
<div class="split" style="width: 100%; flex-direction: column;">
<div class="split-left" style="width: 80%; box-sizing: border-box;">
<div class="split-left" style="width: 80%; box-sizing: border-box; min-height: 0;">
If you have many atlas pages, for example one for each skin, and you want to show only some of the skins,
pass to the <code>pages</code> the atlas pages you want to load as a comma concatenated list of indices.
</div>
@ -1302,7 +1303,7 @@
<div class="split" style="width: 100%; flex-direction: column;">
<div class="split-left" style="width: 80%; box-sizing: border-box;">
<div class="split-left" style="width: 80%; box-sizing: border-box; min-height: 0;">
Let's do the same thing above, but programmatically!
Create two arrays, one for the skin and the other for the animations, and loop over them.
<br>
@ -1424,7 +1425,7 @@ skins.forEach((skin, i) => {
<div class="split" style="width: 100%; flex-direction: column;">
<div class="split-left" style="width: 80%; box-sizing: border-box;">
<div class="split-left" style="width: 80%; box-sizing: border-box; min-height: 0;">
When the widget (or the parent element) enters in the viewport, the callback <code>onScreenFunction</code> is invoked.
<br>
<br>
@ -1617,7 +1618,7 @@ function loadPageDragon(pageIndex) {
<div class="split" style="width: 100%; flex-direction: column;">
<div class="split-left" style="width: 80%; box-sizing: border-box;">
<div class="split-left" style="width: 80%; box-sizing: border-box; min-height: 0;">
Widgets are not rendered while they are off screen.
<br>
<br>
@ -1633,7 +1634,7 @@ function loadPageDragon(pageIndex) {
In that it's your responsibility to skip the update/apply. You can use the <code>onScreen</code> property for convinience.
</div>
<div class="split-left" style="width: 80%; box-sizing: border-box; height: 150px;">
<div class="split-left" style="width: 80%; box-sizing: border-box; height: 150px; min-height: 0;">
<spine-widget
atlas="assets/stretchyman-pma.atlas"
skeleton="assets/stretchyman-pro.skel"
@ -1642,7 +1643,7 @@ function loadPageDragon(pageIndex) {
></spine-widget>
</div>
<div class="split-left" style="width: 80%; box-sizing: border-box; height: 150px;">
<div class="split-left" style="width: 80%; box-sizing: border-box; height: 150px; min-height: 0;">
<spine-widget
identifier="stretchyman"
atlas="assets/stretchyman-pma.atlas"
@ -1720,7 +1721,7 @@ stretchyman.update = (canvas, delta, skeleton, state) => {
<div class="split" style="width: 100%; flex-direction: column;">
<div class="split-left" style="width: 80%; box-sizing: border-box;">
<div class="split-left" style="width: 80%; box-sizing: border-box; min-height: 0;">
If for some reason your skeleton bounds go outside the div,
you can use the <code>clip</code> property to clip everything is outside the html container.
<br>
@ -1728,7 +1729,7 @@ stretchyman.update = (canvas, delta, skeleton, state) => {
Be aware that this will break batching!
</div>
<div class="split-left" style="width: 80%; box-sizing: border-box; height: 150px;">
<div class="split-left" style="width: 80%; box-sizing: border-box; height: 150px; min-height: 0;">
<spine-widget
identifier="tank2"
atlas="assets/tank-pma.atlas"
@ -1737,7 +1738,7 @@ stretchyman.update = (canvas, delta, skeleton, state) => {
></spine-widget>
</div>
<div class="split-left" style="width: 30%; box-sizing: border-box; height: 150px;">
<div class="split-left" style="width: 30%; box-sizing: border-box; height: 150px; min-height: 0;">
<spine-widget
identifier="tank"
atlas="assets/tank-pma.atlas"
@ -1823,7 +1824,7 @@ stretchyman.update = (canvas, delta, skeleton, state) => {
-->
<div class="section vertical-split">
<div class="split-left" style="width: 80%; box-sizing: border-box;">
<div class="split-left" style="width: 80%; box-sizing: border-box; min-height: 0;">
More examples for <code>clip</code> attribute.
</div>
@ -2659,6 +2660,426 @@ const darkPicker = document.getElementById("dark-picker");
/////////////////////
-->
<!--
/////////////////////
// start section //
/////////////////////
-->
<div class="section vertical-split">
<div class="split-top split">
<div class="split-left">
You can make your <code>HTMLElement</code>s follow slots. This feature is convenient when you need to generate dynamic text or content that integrates with your animation.
<p>
Invoke the `followSlot` function that takes as input:
<ol>
<li>The <code>Slot</code> or the slot name to follow</li>
<li>The <code>HTMLElement</code> that follows the slot</li>
<li>
An object with the following options:
<ul>
<li><code>followOpacity</code>: the element opacity is connected to the slot alpha</li>
<li><code>followScale</code>: the element scale is connected to the slot scale</li>
<li><code>followRotation</code>: the element rotation is connected to the slot rotation</li>
<li><code>followAttachmentAttach</code>: the element is shown/hidden depending if the slots contains an attachment or not</li>
<li><code>hideAttachment</code>: the slot attachment is hidden as if the element replaced the attachment</li>
</ul>
</li>
</ol>
</p>
</div>
<div class="split-right">
<spine-widget
identifier="potty"
atlas="assets/cloud-pot-pma.atlas"
skeleton="assets/cloud-pot.skel"
animation="playing-in-the-rain"
></spine-widget>
</div>
</div>
<div id="rain/rain-color" style="font-size: 50px; display: none;">A</div>
<div id="rain/rain-white" style="font-size: 50px; display: none;">B</div>
<div id="rain/rain-blue" style="font-size: 50px; display: none;">C</div>
<div id="rain/rain-green" style="font-size: 50px; display: none;">D</div>
<script>
(async () => {
const widget = await spine.getSpineWidget("potty").loadingPromise;
widget.followSlot("rain/rain-color", document.getElementById("rain/rain-color"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-white", document.getElementById("rain/rain-white"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-blue", document.getElementById("rain/rain-blue"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-green", document.getElementById("rain/rain-green"), { followAttachmentAttach: false, hideAttachment: true });
})();
</script>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
<spine-widget
identifier="potty"
atlas="assets/cloud-pot-pma.atlas"
skeleton="assets/cloud-pot.skel"
animation="playing-in-the-rain"
></spine-widget>
<div id="rain/rain-color" style="font-size: 50px; display: none;">A</div>
<div id="rain/rain-white" style="font-size: 50px; display: none;">B</div>
<div id="rain/rain-blue" style="font-size: 50px; display: none;">C</div>
<div id="rain/rain-green" style="font-size: 50px; display: none;">D</div>
...
(async () => {
const widget = await spine.getSpineWidget("potty").loadingPromise;
widget.followSlot("rain/rain-color", document.getElementById("rain/rain-color"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-white", document.getElementById("rain/rain-white"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-blue", document.getElementById("rain/rain-blue"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-green", document.getElementById("rain/rain-green"), { followAttachmentAttach: false, hideAttachment: true });
})();`);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section //
///////
-->
<!--
/////////////////////
// start section //
/////////////////////
-->
<div class="section vertical-split">
<div class="split-top split">
<div class="split-left">
`followSlot` works even with other spine widgets! It works even if you drag it :D
</div>
<div class="split-right">
<spine-widget
identifier="potty2"
atlas="assets/cloud-pot-pma.atlas"
skeleton="assets/cloud-pot.skel"
animation="rain"
isdraggable
></spine-widget>
</div>
</div>
<spine-widget identifier="potty2-1" atlas="assets/raptor-pma.atlas" skeleton="assets/raptor-pro.skel" animation="walk" style="height:200px; width: 200px;"></spine-widget>
<spine-widget identifier="potty2-2" atlas="assets/spineboy-pma.atlas" skeleton="assets/spineboy-pro.skel" animation="walk" style="height:200px; width: 200px;"></spine-widget>
<spine-widget identifier="potty2-3" atlas="assets/celestial-circus-pma.atlas" skeleton="assets/celestial-circus-pro.skel" animation="wings-and-feet" style="height:200px; width: 200px;"></spine-widget>
<spine-widget identifier="potty2-4" atlas="assets/goblins-pma.atlas" skeleton="assets/goblins-pro.skel" skin="goblingirl" animation="walk" style="height:200px; width: 200px;"></spine-widget>
<script>
(async () => {
const widget = await spine.getSpineWidget("potty2").loadingPromise;
widget.followSlot("rain/rain-color", spine.getSpineWidget("potty2-1"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-white", spine.getSpineWidget("potty2-2"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-blue", spine.getSpineWidget("potty2-3"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-green", spine.getSpineWidget("potty2-4"), { followAttachmentAttach: false, hideAttachment: true });
})();
</script>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
<spine-widget
identifier="potty2"
atlas="assets/cloud-pot-pma.atlas"
skeleton="assets/cloud-pot.skel"
animation="playing-in-the-rain"
></spine-widget>
<spine-widget identifier="potty2-1" atlas="assets/raptor-pma.atlas" skeleton="assets/raptor-pro.skel" animation="walk" style="height:200px; width: 200px;"></spine-widget>
<spine-widget identifier="potty2-2" atlas="assets/spineboy-pma.atlas" skeleton="assets/spineboy-pro.skel" animation="walk" style="height:200px; width: 200px;"></spine-widget>
<spine-widget identifier="potty2-3" atlas="assets/celestial-circus-pma.atlas" skeleton="assets/celestial-circus-pro.skel" animation="wings-and-feet" style="height:200px; width: 200px;"></spine-widget>
<spine-widget identifier="potty2-4" atlas="assets/goblins-pma.atlas" skeleton="assets/goblins-pro.skel" skin="goblingirl" animation="walk" style="height:200px; width: 200px;"></spine-widget>
...
(async () => {
const widget = await spine.getSpineWidget("potty2").loadingPromise;
widget.followSlot("rain/rain-color", spine.getSpineWidget("potty2-1"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-white", spine.getSpineWidget("potty2-2"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-blue", spine.getSpineWidget("potty2-3"), { followAttachmentAttach: false, hideAttachment: true });
widget.followSlot("rain/rain-green", spine.getSpineWidget("potty2-4"), { followAttachmentAttach: false, hideAttachment: true });
})();`);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section //
///////
-->
<!--
/////////////////////
// start section //
/////////////////////
-->
<div class="section vertical-split">
<div class="split-top split">
<div class="split-left">
A login UI made using the chibi stickers and a button made using Spine.
<p>
The chibi sticker does the following:
<ul>
<li>It looks at the cursor when no input field is selected</li>
<li>Look at the caret when username input field is selected</li>
<li>Cover its eyes when password input field is selected</li>
<li>React in two different ways depending on the password</li>
</ul>
</p>
<p>
The button does the following:
<ul>
<li>Starts some animation when cursor enters, leaves, stays, or click the button</li>
<li>Appends a div containing the <code>LOGIN</code> text to a slot</li>
<li>Submits the form on click</li>
</ul>
</p>
</div>
<span id="ruler" style="visibility: hidden; white-space: nowrap; position: absolute"></span>
<div style="background-color: white; width: 350px; padding: 30px; text-align: center;">
<div style="display: flex; justify-content: center;">
<div style="width: 150px; height:150px; border-radius: 5%; border: 1px solid rgb(113, 113, 113); background-color: rgb(211, 211, 211); margin-bottom: 30px;">
<spine-widget
identifier="spineboy-login"
atlas="assets/pwd/chibi-stickers-pro-pwd-test.atlas"
skeleton="assets/pwd/chibi-stickers.json"
skin="spineboy"
bounds-x="-177"
bounds-y="238"
bounds-width="364"
bounds-height="412"
animation="interactive/head/idle"
clip
isinteractive
></spine-widget>
</div>
</div>
<div class="input-group" style="margin-bottom: 15px;"><select id="skinSelect"></select></div>
<form id="loginForm">
<div class="input-group" style="margin-bottom: 15px;">
<input style="width: 100%; padding: 10px; box-sizing: border-box;" type="text" id="username" name="username" placeholder="Username" autocomplete="off" required>
</div>
<div class="input-group" style="margin-bottom: 15px;">
<input style="width: 100%; padding: 10px; box-sizing: border-box;" type="password" id="password" name="password" placeholder="Password" required>
</div>
<div style="margin-bottom: 10px; color: black">Password is <code>password</code></div>
<div style="height: 75px; cursor: pointer;">
<div id="button-text" style="font-size: xx-large; cursor: pointer; user-select: none; display: none;"> LOGIN </div>
<spine-widget
identifier="button-login"
atlas="assets/pwd/button.atlas"
skeleton="assets/pwd/button.json"
animation="idle"
isinteractive
fit="fill"
></spine-widget>
</div>
</form>
</div>
<script>
const mouseX = Smooth(0, 200);
const mouseY = Smooth(0, 200);
(async () => {
const form = document.getElementById('loginForm');
const widgetButton = spine.getSpineWidget("button-login");
await widgetButton.loadingPromise;
widgetButton.skeleton.color.set(.85, .85, .85, 1);
widgetButton.cursorBoundsEventCallback = (event) => {
if (event === "enter") {
widgetButton.state.setAnimation(0, "enhance-in", false);
widgetButton.state.setAnimation(1, "shadow-in", false);
widgetButton.state.setAnimation(2, "jump", true);
};
if (event === "leave") {
widgetButton.state.setAnimation(0, "enhance-out", false).timeScale = 2;
widgetButton.state.setAnimation(1, "shadow-out", false);
widgetButton.state.setEmptyAnimation(2, 0).mixDuration = .25;
};
if (event === "down") {
widgetButton.state.setAnimation(0, "enhance-in", false).timeScale = 2;
if (form.reportValidity()) {
if (passwordInput.value === "password") {
widget.state.setAnimation(0, "interactive/pwd/hooray", 0);
widget.state.addAnimation(0, "interactive/head/idle", true);
} else {
widget.state.setAnimation(0, "interactive/pwd/sad", 0);
widget.state.addAnimation(0, "interactive/head/idle", true);
}
}
};
}
const textSlot = widgetButton.skeleton.findSlot("CLICK ME");
textSlot.setAttachment(null);
const divText = document.getElementById("button-text");
widgetButton.followSlot("CLICK ME", divText, { followScale: false });
const widget = spine.getSpineWidget("spineboy-login");
await widget.loadingPromise;
// default settings
widget.state.data.defaultMix = 0.15;
// Skin selection
const skinSelect = document.getElementById('skinSelect');
widget.skeleton.data.skins.forEach(({ name }) => {
if (name === "default") return;
skinSelect.add(new Option(name, name, name === "spineboy"));
});
skinSelect.value = "spineboy";
skinSelect.addEventListener('change', (e) => widget.skin = e.target.value);
// click on spineboy
widget.cursorBoundsEventCallback = (event) => {
if (event === "down") {
widget.state.setAnimation(0, "interactive/pwd/touch", false);
widget.state.addAnimation(0, "interactive/head/idle", true);
}
if (event === "enter") widget.state.setAnimation(0, "emotes/wave", true);
if (event === "leave") widget.state.setAnimation(0, "interactive/head/idle", false);
}
// Head follow logic
let focusInput = false;
// Password input field
const passwordInput = document.getElementById('password');
passwordInput.addEventListener('focus', () => {
focusInput = true;
resetBlendTracks();
widget.state.setAnimation(0, "interactive/pwd/hide", 0);
});
passwordInput.addEventListener('blur', () => {
focusInput = false;
widget.state.setAnimation(0, "interactive/head/idle", 0);
});
// Username input field
const usernameInput = document.getElementById('username');
usernameInput.addEventListener('input', () => {
setBlendTracks();
});
usernameInput.addEventListener('focus', () => {
focusInput = true;
setBlendTracks();
});
usernameInput.addEventListener('blur', () => {
focusInput = false;
setBlendTracks();
});
// Animation left/down blending
const ALPHA_LEFT_RANGE = 4;
const ALPHA_DOWN = .8;
const ALPHA_DOWN_RANGE = 2;
const moveVector = [0, 0];
const leftTrack = widget.state.setAnimation(1, "interactive/head/left", true);
const downTrack = widget.state.setAnimation(2, "interactive/head/down", true);
leftTrack.mixBlend = spine.MixBlend.add;
downTrack.mixBlend = spine.MixBlend.add;
const setBlendTracks = () => {
moveVector[0] = getAlphaLeft();
moveVector[1] = ALPHA_DOWN;
}
const resetBlendTracks = () => {
moveVector[0] = 0;
moveVector[1] = 0;
}
resetBlendTracks();
const getWidthOfText = (txt) => {
const ruler = document.getElementById("ruler");
ruler.innerHTML = txt;
return ruler.offsetWidth;
}
const getAlphaLeft = () => {
const { width } = usernameInput.getBoundingClientRect();
const length = getWidthOfText(usernameInput.value);
const alpha = Math.min(1, length / width) * 2
return 1 - alpha;
};
widget.afterUpdateWorldTransforms = () => {
if (focusInput) {
blendTo(moveVector[0], moveVector[1]);
} else {
const { x, y, width, height} = widget.getHTMLElementReference().getBoundingClientRect();
const l = -((widget.overlay.cursorCanvasX - (x + width)) / window.innerWidth) * ALPHA_LEFT_RANGE;
const d = ((widget.overlay.cursorCanvasY - (y + height)) / window.innerHeight) * ALPHA_DOWN_RANGE;
blendTo(l, d);
}
};
const blendTo = (x, y) => {
leftTrack.alpha = mouseX(x);
downTrack.alpha = mouseY(y);
}
})();
</script>
</div>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
TODO`
);</script>
</code></pre>
</div>
</div>
<!--
/////////////////////
// end section //
/////////////////////
-->
<script>
spine.SpineWebComponentWidget.SHOW_FPS = true;

View File

@ -55,6 +55,7 @@ import {
Slot,
RegionAttachment,
MeshAttachment,
Bone,
} from "./index.js";
interface Point {
@ -708,7 +709,7 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
});
}
connectedCallback () {
connectedCallback (): void {
if (this.disposed) {
throw new Error("You cannot attach a disposed widget");
};
@ -1075,6 +1076,38 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
* Other utilities
*/
public boneFollowerList: Array<{ slot: Slot, bone: Bone, element: HTMLElement, followAttachmentAttach: boolean, followRotation: boolean, followOpacity: boolean, followScale: boolean, hideAttachment: boolean }> = [];
public followSlot(slotName: string | Slot, element: HTMLElement, options: { followAttachmentAttach?: boolean, followRotation?: boolean, followOpacity?: boolean, followScale?: boolean, hideAttachment?: boolean } = {}) {
const {
followAttachmentAttach = false,
followRotation = true,
followOpacity = true,
followScale = true,
hideAttachment = false,
} = options;
const slot = typeof slotName === 'string' ? this.skeleton?.findSlot(slotName) : slotName;
if (!slot) return;
if (hideAttachment) {
slot.setAttachment(null);
}
element.style.position = 'absolute';
element.style.top = '0px';
element.style.left = '0px';
element.style.display = 'none';
this.boneFollowerList.push({ slot, bone: slot.bone, element, followAttachmentAttach, followRotation, followOpacity, followScale, hideAttachment });
this.overlay.slotFollowerElementsHolder.appendChild(element);
}
public unfollowSlot(element: HTMLElement): HTMLElement | undefined {
const index = this.boneFollowerList.findIndex(e => e.element === element);
if (index > -1) {
return this.boneFollowerList.splice(index, 1)[0].element;
}
}
private calculateAnimationViewport (animation?: Animation): Rectangle {
const renderer = this.overlay.renderer;
const { skeleton } = this;
@ -1230,6 +1263,7 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
private root: ShadowRoot;
private div: HTMLDivElement;
public slotFollowerElementsHolder: HTMLDivElement;
private canvas: HTMLCanvasElement;
private fps: HTMLSpanElement;
private fpsAppended = false;
@ -1263,12 +1297,21 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
this.root.appendChild(this.div);
this.canvas = document.createElement("canvas");
this.slotFollowerElementsHolder = document.createElement("div");
this.div.appendChild(this.canvas);
this.canvas.style.position = "absolute";
this.canvas.style.top = "0";
this.canvas.style.left = "0";
this.div.appendChild(this.slotFollowerElementsHolder);
this.slotFollowerElementsHolder.style.position = "absolute";
this.slotFollowerElementsHolder.style.top = "0";
this.slotFollowerElementsHolder.style.left = "0";
this.slotFollowerElementsHolder.style.whiteSpace = "nowrap";
this.slotFollowerElementsHolder.style.setProperty("pointer-events", "none");
this.slotFollowerElementsHolder.style.transform = `translate(0px,0px)`;
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
@ -1425,6 +1468,7 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
}
}
private tempFollowBoneVector = new Vector3();
private startRenderingLoop () {
if (this.running) return;
@ -1660,6 +1704,41 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
renderer.end();
}
const updateFollowSlotsPosition = () => {
this.skeletonList.forEach((widget) => {
if (widget.skeleton && widget.onScreen) {
widget.boneFollowerList.forEach(({ slot, bone, element, followAttachmentAttach, followRotation, followOpacity, followScale, hideAttachment }) => {
this.worldToScreen(this.tempFollowBoneVector, bone.worldX + widget.worldX, bone.worldY + widget.worldY);
if (Number.isNaN(this.tempFollowBoneVector.x)) return;
let x = this.tempFollowBoneVector.x - this.overflowLeftSize;
let y = this.tempFollowBoneVector.y - this.overflowTopSize;
if (!this.scrollable) {
x += window.scrollX;
y += window.scrollY;
}
element.style.transform = `translate(calc(-50% + ${x.toFixed(2)}px),calc(-50% + ${y.toFixed(2)}px))`
+ (followRotation ? ` rotate(${-bone.getWorldRotationX()}deg)` : "")
+ (followScale ? ` scale(${bone.getWorldScaleX()}, ${bone.getWorldScaleY()})` : "")
;
element.style.display = ""
if (followAttachmentAttach && !slot.attachment) {
element.style.opacity = "0";
} else if (followOpacity) {
element.style.opacity = `${slot.color.a}`;
}
});
};
});
}
const loop = () => {
if (this.disposed || !this.isConnected) {
this.running = false;
@ -1671,6 +1750,7 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
this.translateCanvas();
updateWidgets();
renderWidgets();
updateFollowSlotsPosition();
}
requestAnimationFrame(loop);