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; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-grow: 1;
} }
.full-width { .full-width {
width: 100%; width: 100%;
} }
.split-left, .split-right { .split-left, .split-right {
width: 50%; width: 50%;
min-height: 50%; min-height: 300px;
padding: 1rem; padding: 1rem;
margin: 1rem; margin: 1rem;
border: 1px solid salmon; border: 1px solid salmon;
@ -72,7 +73,7 @@
.split-top { .split-top {
width: 100%; width: 100%;
height: 600px; min-height: 600px;
} }
.split-bottom { .split-bottom {
@ -801,7 +802,7 @@
<div class="split" style="width: 100%; flex-direction: column;"> <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. 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> </div>
@ -1039,7 +1040,7 @@
<div class="split" style="width: 100%; flex-direction: column;"> <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, 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. pass to the <code>pages</code> the atlas pages you want to load as a comma concatenated list of indices.
</div> </div>
@ -1302,7 +1303,7 @@
<div class="split" style="width: 100%; flex-direction: column;"> <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! 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. Create two arrays, one for the skin and the other for the animations, and loop over them.
<br> <br>
@ -1424,7 +1425,7 @@ skins.forEach((skin, i) => {
<div class="split" style="width: 100%; flex-direction: column;"> <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. When the widget (or the parent element) enters in the viewport, the callback <code>onScreenFunction</code> is invoked.
<br> <br>
<br> <br>
@ -1617,7 +1618,7 @@ function loadPageDragon(pageIndex) {
<div class="split" style="width: 100%; flex-direction: column;"> <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. Widgets are not rendered while they are off screen.
<br> <br>
<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. In that it's your responsibility to skip the update/apply. You can use the <code>onScreen</code> property for convinience.
</div> </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 <spine-widget
atlas="assets/stretchyman-pma.atlas" atlas="assets/stretchyman-pma.atlas"
skeleton="assets/stretchyman-pro.skel" skeleton="assets/stretchyman-pro.skel"
@ -1642,7 +1643,7 @@ function loadPageDragon(pageIndex) {
></spine-widget> ></spine-widget>
</div> </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 <spine-widget
identifier="stretchyman" identifier="stretchyman"
atlas="assets/stretchyman-pma.atlas" 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" 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, 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. you can use the <code>clip</code> property to clip everything is outside the html container.
<br> <br>
@ -1728,7 +1729,7 @@ stretchyman.update = (canvas, delta, skeleton, state) => {
Be aware that this will break batching! Be aware that this will break batching!
</div> </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 <spine-widget
identifier="tank2" identifier="tank2"
atlas="assets/tank-pma.atlas" atlas="assets/tank-pma.atlas"
@ -1737,7 +1738,7 @@ stretchyman.update = (canvas, delta, skeleton, state) => {
></spine-widget> ></spine-widget>
</div> </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 <spine-widget
identifier="tank" identifier="tank"
atlas="assets/tank-pma.atlas" atlas="assets/tank-pma.atlas"
@ -1823,7 +1824,7 @@ stretchyman.update = (canvas, delta, skeleton, state) => {
--> -->
<div class="section vertical-split"> <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. More examples for <code>clip</code> attribute.
</div> </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> <script>
spine.SpineWebComponentWidget.SHOW_FPS = true; spine.SpineWebComponentWidget.SHOW_FPS = true;

View File

@ -55,6 +55,7 @@ import {
Slot, Slot,
RegionAttachment, RegionAttachment,
MeshAttachment, MeshAttachment,
Bone,
} from "./index.js"; } from "./index.js";
interface Point { interface Point {
@ -708,7 +709,7 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
}); });
} }
connectedCallback () { connectedCallback (): void {
if (this.disposed) { if (this.disposed) {
throw new Error("You cannot attach a disposed widget"); throw new Error("You cannot attach a disposed widget");
}; };
@ -1075,6 +1076,38 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
* Other utilities * 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 { private calculateAnimationViewport (animation?: Animation): Rectangle {
const renderer = this.overlay.renderer; const renderer = this.overlay.renderer;
const { skeleton } = this; const { skeleton } = this;
@ -1230,6 +1263,7 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
private root: ShadowRoot; private root: ShadowRoot;
private div: HTMLDivElement; private div: HTMLDivElement;
public slotFollowerElementsHolder: HTMLDivElement;
private canvas: HTMLCanvasElement; private canvas: HTMLCanvasElement;
private fps: HTMLSpanElement; private fps: HTMLSpanElement;
private fpsAppended = false; private fpsAppended = false;
@ -1263,12 +1297,21 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
this.root.appendChild(this.div); this.root.appendChild(this.div);
this.canvas = document.createElement("canvas"); this.canvas = document.createElement("canvas");
this.slotFollowerElementsHolder = document.createElement("div");
this.div.appendChild(this.canvas); this.div.appendChild(this.canvas);
this.canvas.style.position = "absolute"; this.canvas.style.position = "absolute";
this.canvas.style.top = "0"; this.canvas.style.top = "0";
this.canvas.style.left = "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.setProperty("pointer-events", "none");
this.canvas.style.transform = `translate(0px,0px)`; this.canvas.style.transform = `translate(0px,0px)`;
// this.canvas.style.setProperty("will-change", "transform"); // performance seems to be even worse with this uncommented // 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 () { private startRenderingLoop () {
if (this.running) return; if (this.running) return;
@ -1660,6 +1704,41 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
renderer.end(); 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 = () => { const loop = () => {
if (this.disposed || !this.isConnected) { if (this.disposed || !this.isConnected) {
this.running = false; this.running = false;
@ -1671,6 +1750,7 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
this.translateCanvas(); this.translateCanvas();
updateWidgets(); updateWidgets();
renderWidgets(); renderWidgets();
updateFollowSlotsPosition();
} }
requestAnimationFrame(loop); requestAnimationFrame(loop);