Add interactivity events.

This commit is contained in:
Davide Tantillo 2024-12-20 15:18:10 +01:00
parent 598fcc5cf4
commit 4d795da488
2 changed files with 199 additions and 164 deletions

View File

@ -2378,13 +2378,14 @@ function createCircleOfDivs(numDivs = 8) {
customElements.whenDefined('spine-widget').then(async () => { customElements.whenDefined('spine-widget').then(async () => {
const widget = spine.getSpineWidget(\`owl\${i}\`); const widget = spine.getSpineWidget(\`owl\${i}\`);
await widget.loadingPromise; await widget.loadingPromise;
widget.state.setAnimation(1, "blink", true) widget.state.setAnimation(1, "blink", true);
const control = widget.skeleton.findBone("control"); const control = widget.skeleton.findBone("control");
const tempVector = new spine.Vector3(); const tempVector = new spine.Vector3();
const mouse = Ola({ x: 0, y: 0 }, 200); const mouseX = Smooth(0, 200);
const mouseY = Smooth(0, 200);
widget.afterUpdateWorldTransforms = () => { widget.afterUpdateWorldTransforms = () => {
updateControl(widget, control, mouse, tempVector); updateControl(widget, control, mouseX, mouseY, tempVector);
} }
}); });
@ -2401,7 +2402,7 @@ const checkbox = document.getElementById('owl-checkbox');
let limitOwl = true; let limitOwl = true;
checkbox.addEventListener('change', () => limitOwl = checkbox.checked); checkbox.addEventListener('change', () => limitOwl = checkbox.checked);
const updateControl = (widget, controlBone, mouse, tempVector) => { const updateControl = (widget, controlBone, mouseX, mouseY, tempVector) => {
controlBone.parent.worldToLocal(tempVector.set( controlBone.parent.worldToLocal(tempVector.set(
widget.cursorWorldX, widget.cursorWorldX,
widget.cursorWorldY, widget.cursorWorldY,
@ -2415,10 +2416,8 @@ const updateControl = (widget, controlBone, mouse, tempVector) => {
y = y / widget.overlay.canvas.height * 30; y = y / widget.overlay.canvas.height * 30;
} }
mouse.set({ x, y }); controlBone.x = controlBone.data.x + mouseX(x);
controlBone.y = controlBone.data.y + mouseY(y);
controlBone.x = controlBone.data.x + mouse.x;
controlBone.y = controlBone.data.y + mouse.y;
} }
` `
);</script> );</script>
@ -2429,99 +2428,98 @@ const updateControl = (widget, controlBone, mouse, tempVector) => {
</div> </div>
<script> <script>
function createCircleOfDivs(numDivs = 8) { function createCircleOfDivs(numDivs = 8) {
const container = document.createElement('div'); const container = document.createElement('div');
container.style.position = 'relative'; container.style.position = 'relative';
container.style.width = '400px'; container.style.width = '400px';
container.style.height = '400px'; container.style.height = '400px';
container.style.backgroundColor = '#f3f4f6'; container.style.backgroundColor = '#f3f4f6';
container.style.borderRadius = '50%'; container.style.borderRadius = '50%';
container.style.display = 'flex'; container.style.display = 'flex';
container.style.justifyContent = 'center'; container.style.justifyContent = 'center';
container.style.alignItems = 'center'; container.style.alignItems = 'center';
const radius = 150; const radius = 150;
for (let i = 0; i < numDivs; i++) { for (let i = 0; i < numDivs; i++) {
const angle = (i / numDivs) * 2 * Math.PI; const angle = (i / numDivs) * 2 * Math.PI;
const x = Math.cos(angle) * radius; const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius; const y = Math.sin(angle) * radius;
const div = document.createElement('div'); const div = document.createElement('div');
div.style.position = 'absolute'; div.style.position = 'absolute';
div.style.width = '100px'; div.style.width = '100px';
div.style.height = '100px'; div.style.height = '100px';
div.style.backgroundColor = '#3b82f6'; div.style.backgroundColor = '#3b82f6';
div.style.borderRadius = '8px'; div.style.borderRadius = '8px';
div.style.display = 'flex'; div.style.display = 'flex';
div.style.justifyContent = 'center'; div.style.justifyContent = 'center';
div.style.alignItems = 'center'; div.style.alignItems = 'center';
div.style.color = 'white'; div.style.color = 'white';
div.style.fontWeight = 'bold'; div.style.fontWeight = 'bold';
div.style.transform = `translate(${x}px, ${y}px)`; div.style.transform = `translate(${x}px, ${y}px)`;
div.innerHTML = ` div.innerHTML = `
<spine-widget <spine-widget
identifier="owl${i}" identifier="owl${i}"
atlas="../demos/assets/atlas2.atlas" atlas="../demos/assets/atlas2.atlas"
skeleton="../demos/assets/demos.json" skeleton="../demos/assets/demos.json"
json-skeleton-key="owl" json-skeleton-key="owl"
animation="idle" animation="idle"
isdraggable isdraggable
></spine-widget> ></spine-widget>
`; `;
container.appendChild(div); container.appendChild(div);
customElements.whenDefined('spine-widget').then(async () => {
const widget = spine.getSpineWidget(`owl${i}`);
await widget.loadingPromise;
widget.state.setAnimation(1, "blink", true);
const control = widget.skeleton.findBone("control");
const tempVector = new spine.Vector3();
const mouse = Ola({ x: 0, y: 0 }, 200);
widget.afterUpdateWorldTransforms = () => {
updateControl(widget, control, mouse, tempVector);
}
});
customElements.whenDefined('spine-widget').then(async () => {
const widget = spine.getSpineWidget(`owl${i}`);
await widget.loadingPromise;
widget.state.setAnimation(1, "blink", true);
const control = widget.skeleton.findBone("control");
const tempVector = new spine.Vector3();
const mouseX = Smooth(0, 200);
const mouseY = Smooth(0, 200);
widget.afterUpdateWorldTransforms = () => {
updateControl(widget, control, mouseX, mouseY, tempVector);
} }
});
return container;
}
document.getElementById('section-owls').appendChild(createCircleOfDivs(8)); }
const checkbox = document.getElementById('owl-checkbox'); return container;
}
let limitOwl = true; document.getElementById('section-owls').appendChild(createCircleOfDivs(8));
checkbox.addEventListener('change', () => limitOwl = checkbox.checked);
const updateControl = (widget, controlBone, mouse, tempVector) => { const checkbox = document.getElementById('owl-checkbox');
controlBone.parent.worldToLocal(tempVector.set(
widget.cursorWorldX,
widget.cursorWorldY,
));
let x = tempVector.x; let limitOwl = true;
let y = tempVector.y; checkbox.addEventListener('change', () => limitOwl = checkbox.checked);
if (limitOwl) { const updateControl = (widget, controlBone, mouseX, mouseY, tempVector) => {
x = x / widget.overlay.canvas.width * 30; controlBone.parent.worldToLocal(tempVector.set(
y = y / widget.overlay.canvas.height * 30; widget.cursorWorldX,
} widget.cursorWorldY,
));
mouse.set({ x, y }); let x = tempVector.x;
let y = tempVector.y;
controlBone.x = controlBone.data.x + mouse.x; if (limitOwl) {
controlBone.y = controlBone.data.y + mouse.y; x = x / widget.overlay.canvas.width * 30;
} y = y / widget.overlay.canvas.height * 30;
}
controlBone.x = controlBone.data.x + mouseX(x);
controlBone.y = controlBone.data.y + mouseY(y);
}
</script> </script>
<script> <script>
(function(global,factory){typeof exports==="object"&&typeof module!=="undefined"?module.exports=factory():typeof define==="function"&&define.amd?define(factory):(global=global||self,global.Ola=factory())})(this,function(){"use strict";const position=(x0,v0,t1,t)=>{const a=(v0*t1+2*x0)/t1**3;const b=-(2*v0*t1+3*x0)/t1**2;const c=v0;const d=x0;return a*t**3+b*t**2+c*t+d};const speed=(x0,v0,t1,t)=>{const a=(v0*t1+2*x0)/t1**3;const b=-(2*v0*t1+3*x0)/t1**2;const c=v0;return 3*a*t**2+2*b*t+c};const each=function(values,cb){const multi=typeof values==="number"?{value:values}:values;Object.entries(multi).map(([key,value])=>cb(value,key))};function Single(init,time){this.start=new Date/1e3;this.time=time;this.from=init;this.current=init;this.to=init;this.speed=0}Single.prototype.get=function(now){const t=now/1e3-this.start;if(t<0){throw new Error("Cannot read in the past")}if(t>=this.time){return this.to}return this.to-position(this.to-this.from,this.speed,this.time,t)};Single.prototype.getSpeed=function(now){const t=now/1e3-this.start;if(t>=this.time){return 0}return speed(this.to-this.from,this.speed,this.time,t)};Single.prototype.set=function(value,time){const now=new Date;const current=this.get(now);this.speed=this.getSpeed(now);this.start=now/1e3;this.from=current;this.to=value;if(time){this.time=time}return current};function Ola(values,time=300){if(typeof values==="number"){values={value:values}}each(values,(init,key)=>{const value=new Single(init,time/1e3);Object.defineProperty(values,"_"+key,{value:value});Object.defineProperty(values,"$"+key,{get:()=>value.to});Object.defineProperty(values,key,{get:()=>value.get(new Date),set:val=>value.set(val),enumerable:true})});Object.defineProperty(values,"get",{get:()=>(function(name="value",now=new Date){return this["_"+name].get(now)})});Object.defineProperty(values,"set",{get:()=>(function(values,time=0){each(values,(value,key)=>{this["_"+key].set(value,time/1e3)})})});return values}return Ola}); function Smooth(f,t){var p=performance,b=p.now(),o=f,s=0,m,n,x,d,k=f;return function(v){n=p.now()-b;m=t*t/n/n;d=o-f;x=s*t+d+d;if(n>0)k=n<t?o-x/m/t*n+(x+x-d)/m-s*n-d:o;if(v!=void 0&&v!=o){f=k;b+=n;o=v;s=n>0&&n<t?s+3*x/m/t-(4*x-d-d)/m/n:0}return k}}
</script> </script>
<!-- <!--
@ -2538,71 +2536,58 @@ const updateControl = (widget, controlBone, mouse, tempVector) => {
<div class="section vertical-split" id="above-popup"> <div class="section vertical-split" id="above-popup">
<div class="split-top split"> <div class="split-left" style="width: 80%; box-sizing: border-box;">
<div class="split-left"> You can attach callback to your widget to react at pointer interactions. Just make it <code>isinteractive</code>.
You can attach callback to your widget to react at pointer interactions. Just make it <code>isinteractive</code>. <br>
<br> <br>
<br> You can attach a callback for interactions with the widget <code>bounds</code> or with <code>slots</code>.
You can attach a callback for interactions with the widget <code>bounds</code> or with <code>slots</code>. The available events are <code>down</code>, <code>up</code>, <code>enter</code>, <code>leave</code>, <code>move</code>, and <code>drag</code>.
The available events are <code>down</code>, <code>up</code>, <code>enter</code>, <code>leave</code>, <code>move</code>, and <code>drag</code>. <br>
<br> <br>
<br> In the following example, if the cursor enters the bounds, the jump animation is set, while the wave animation is set when the cursor leaves.
In the following example, if the cursor enters the bounds, the jump animation is set, while the wave animation is set when the cursor leaves. <br>
<br> If you click on the <code>head-base</code> slot (the face), you can change the normal and dark tint with the colors selected in the two following selectors.
If you click on the <code>head-base</code> slot (the face), you can change the normal and dark tint with the colors selected in the two following selectors.
<ul> <div style="display: flex; align-items: center; justify-content: space-around">
<li>Tint normal: <input type="color" id="color-picker" value="#ffffff" /></li> <div>
<li>Tint black: <input type="color" id="dark-picker" value="#000000" /></li> <p>
</ul> Tint normal: <input type="color" id="color-picker" value="#ffffff" />
</p>
<p>
Tint black: <input type="color" id="dark-picker" value="#000000" />
</p>
</div>
</div>
<div class="split-right">
<spine-widget <spine-widget
identifier="interactive" identifier="interactive0"
atlas="assets/chibi-stickers-pma.atlas", atlas="assets/chibi-stickers-pma.atlas",
skeleton="assets/chibi-stickers.json", skeleton="assets/chibi-stickers.json",
skin="mario" skin="mario"
animation="emotes/wave" animation="emotes/wave"
pad-top="0.05"
pad-bottom="0.05"
isinteractive isinteractive
style="width: 150px; height: 150px;"
></spine-widget> ></spine-widget>
<script> <spine-widget
(async () => { identifier="interactive1"
const colorPicker = document.getElementById("color-picker"); atlas="assets/chibi-stickers-pma.atlas",
const darkPicker = document.getElementById("dark-picker"); skeleton="assets/chibi-stickers.json",
skin="nate"
animation="emotes/wave"
isinteractive
style="width: 150px; height: 150px;"
></spine-widget>
const widget = await spine.getSpineWidget("interactive").loadingPromise;
widget.cursorBoundsEventCallback = (event) => {
if (event === "enter") widget.state.setAnimation(0, "emotes/hooray", true).mixDuration = .15;
if (event === "leave") widget.state.setAnimation(0, "emotes/wave", true).mixDuration = .25;
}
const tempColor = new spine.Color();
const slot = widget.skeleton.findSlot("head-base");
slot.darkColor = new spine.Color(0, 0, 0, 1);
widget.addCursorSlotEventCallbacks(slot, (slot, event) => {
if (event === "down") {
slot.darkColor.setFromColor(spine.Color.fromString(darkPicker.value, tempColor));
slot.color.setFromColor(spine.Color.fromString(colorPicker.value, tempColor));
}
});
})();
</script>
</div> </div>
</div> </div>
<div class="split-bottom"> <script>
<pre><code id="code-display"> const colorPicker = document.getElementById("color-picker");
<script>escapeHTMLandInject(` const darkPicker = document.getElementById("dark-picker");
(async () => {
const colorPicker = document.getElementById("color-picker");
const darkPicker = document.getElementById("dark-picker");
const widget = await spine.getSpineWidget("interactive").loadingPromise; [0, 1].forEach(async (i) => {
const widget = await spine.getSpineWidget(`interactive${i}`).loadingPromise;
widget.cursorBoundsEventCallback = (event) => { widget.cursorBoundsEventCallback = (event) => {
if (event === "enter") widget.state.setAnimation(0, "emotes/hooray", true).mixDuration = .15; if (event === "enter") widget.state.setAnimation(0, "emotes/hooray", true).mixDuration = .15;
@ -2618,7 +2603,50 @@ const updateControl = (widget, controlBone, mouse, tempVector) => {
slot.color.setFromColor(spine.Color.fromString(colorPicker.value, tempColor)); slot.color.setFromColor(spine.Color.fromString(colorPicker.value, tempColor));
} }
}); });
})();` })
</script>
<div class="split-bottom">
<pre><code id="code-display">
<script>escapeHTMLandInject(`
<spine-widget
identifier="interactive0"
atlas="assets/chibi-stickers-pma.atlas",
skeleton="assets/chibi-stickers.json",
skin="mario"
animation="emotes/wave"
isinteractive
style="width: 150px; height: 150px;"
></spine-widget>
<spine-widget
identifier="interactive1"
atlas="assets/chibi-stickers-pma.atlas",
skeleton="assets/chibi-stickers.json",
skin="nate"
animation="emotes/wave"
isinteractive
style="width: 150px; height: 150px;"
></spine-widget>
[0, 1].forEach(async (i) => {
const widget = await spine.getSpineWidget(\`interactive\${i}\`).loadingPromise;
widget.cursorBoundsEventCallback = (event) => {
if (event === "enter") widget.state.setAnimation(0, "emotes/hooray", true).mixDuration = .15;
if (event === "leave") widget.state.setAnimation(0, "emotes/wave", true).mixDuration = .25;
}
const tempColor = new spine.Color();
const slot = widget.skeleton.findSlot("head-base");
slot.darkColor = new spine.Color(0, 0, 0, 1);
widget.addCursorSlotEventCallbacks(slot, (slot, event) => {
if (event === "down") {
slot.darkColor.setFromColor(spine.Color.fromString(darkPicker.value, tempColor));
slot.color.setFromColor(spine.Color.fromString(colorPicker.value, tempColor));
}
});
})`
);</script> );</script>
</code></pre> </code></pre>
</div> </div>

View File

@ -983,7 +983,10 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
} }
} }
private checkCursorInsideBounds (): boolean { /**
* @internal
*/
public checkCursorInsideBounds (): boolean {
if (!this.onScreen || !this.skeleton) return false; if (!this.onScreen || !this.skeleton) return false;
this.pointTemp.set( this.pointTemp.set(
@ -1681,11 +1684,39 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
public cursorWorldX = 1; public cursorWorldX = 1;
public cursorWorldY = 1; public cursorWorldY = 1;
private tempVector = new Vector3();
private cursorUpdate (input: Point) {
this.cursorCanvasX = input.x - window.scrollX;
this.cursorCanvasY = input.y - window.scrollY;
const ref = this.parentElement!.getBoundingClientRect();
if (this.scrollable) {
this.cursorCanvasX -= ref.left;
this.cursorCanvasY -= ref.top;
}
let tempVector = this.tempVector;
tempVector.set(this.cursorCanvasX, this.cursorCanvasY, 0);
this.renderer.camera.screenToWorld(tempVector, this.canvas.clientWidth, this.canvas.clientHeight);
if (Number.isNaN(tempVector.x) || Number.isNaN(tempVector.y)) return;
this.cursorWorldX = tempVector.x;
this.cursorWorldY = tempVector.y;
}
private cursorWidgetUpdate (widget: SpineWebComponentWidget): boolean {
if (widget.worldX === Infinity) return false;
widget.cursorWorldX = this.cursorWorldX - widget.worldX;
widget.cursorWorldY = this.cursorWorldY - widget.worldY;
return true;
}
private setupDragUtility (): Input { private setupDragUtility (): Input {
// TODO: we should use document - body might have some margin that offset the click events - Meanwhile I take event pageX/Y // TODO: we should use document - body might have some margin that offset the click events - Meanwhile I take event pageX/Y
const inputManager = new Input(document.body, false) const inputManager = new Input(document.body, false)
const inputPointTemp: Point = new Vector2(); const inputPointTemp: Point = new Vector2();
const tempVector = new Vector3();
const getInput = (ev?: MouseEvent | TouchEvent): Point => { const getInput = (ev?: MouseEvent | TouchEvent): Point => {
const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0]; const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0];
@ -1694,38 +1725,16 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
return inputPointTemp; return inputPointTemp;
} }
const cursorUpdate = (input: Point) => {
this.cursorCanvasX = input.x - window.scrollX;
this.cursorCanvasY = input.y - window.scrollY;
const ref = this.parentElement!.getBoundingClientRect();
if (this.scrollable) {
this.cursorCanvasX -= ref.left;
this.cursorCanvasY -= ref.top;
}
tempVector.set(this.cursorCanvasX, this.cursorCanvasY, 0);
this.renderer.camera.screenToWorld(tempVector, this.canvas.clientWidth, this.canvas.clientHeight);
if (Number.isNaN(tempVector.x) || Number.isNaN(tempVector.y)) return;
this.cursorWorldX = tempVector.x;
this.cursorWorldY = tempVector.y;
}
let prevX = 0; let prevX = 0;
let prevY = 0; let prevY = 0;
inputManager.addListener({ inputManager.addListener({
// moved is used to pass curson position wrt to canvas and widget position and currently is EXPERIMENTAL // moved is used to pass curson position wrt to canvas and widget position and currently is EXPERIMENTAL
moved: (x, y, ev) => { moved: (x, y, ev) => {
const input = getInput(ev); const input = getInput(ev);
cursorUpdate(input); this.cursorUpdate(input);
this.skeletonList.forEach(widget => { this.skeletonList.forEach(widget => {
if (!this.cursorWidgetUpdate(widget) || !widget.onScreen) return;
widget.cursorWorldX = this.cursorWorldX - widget.worldX;
widget.cursorWorldY = this.cursorWorldY - widget.worldY;
if (!widget.onScreen) return;
widget.cursorEventUpdate("move"); widget.cursorEventUpdate("move");
}); });
@ -1737,12 +1746,13 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
widget.cursorEventUpdate("down"); widget.cursorEventUpdate("down");
if (widget.cursorInsideBounds) { if ((widget.isInteractive && widget.cursorInsideBounds) || (!widget.isInteractive && widget.checkCursorInsideBounds())) {
if (!widget.isDraggable) return; if (!widget.isDraggable) return;
widget.dragging = true; widget.dragging = true;
ev?.preventDefault(); ev?.preventDefault();
} }
}); });
@ -1755,13 +1765,10 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
let dragX = input.x - prevX; let dragX = input.x - prevX;
let dragY = input.y - prevY; let dragY = input.y - prevY;
cursorUpdate(input); this.cursorUpdate(input);
this.skeletonList.forEach(widget => { this.skeletonList.forEach(widget => {
widget.cursorWorldX = this.cursorWorldX - widget.worldX; if (!this.cursorWidgetUpdate(widget) || !widget.onScreen && widget.dragX === 0 && widget.dragY === 0) return;
widget.cursorWorldY = this.cursorWorldY - widget.worldY;
if (!widget.onScreen && widget.dragX === 0 && widget.dragY === 0) return;
widget.cursorEventUpdate("drag"); widget.cursorEventUpdate("drag");