mirror of
https://github.com/EsotericSoftware/spine-runtimes.git
synced 2026-02-04 14:24:53 +08:00
WIP - Add interactivity events. isdraggable is currently broken.
This commit is contained in:
parent
f7316acef5
commit
598fcc5cf4
@ -174,8 +174,8 @@ export class Color {
|
||||
return Number("0x" + hex(this.r) + hex(this.g) + hex(this.b));
|
||||
}
|
||||
|
||||
static fromString (hex: string): Color {
|
||||
return new Color().setFromString(hex);
|
||||
static fromString (hex: string, color = new Color()): Color {
|
||||
return color.setFromString(hex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2524,6 +2524,107 @@ const updateControl = (widget, controlBone, mouse, tempVector) => {
|
||||
(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});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
/////////////////////
|
||||
// end section //
|
||||
/////////////////////
|
||||
-->
|
||||
|
||||
<!--
|
||||
/////////////////////
|
||||
// start section //
|
||||
/////////////////////
|
||||
-->
|
||||
|
||||
<div class="section vertical-split" id="above-popup">
|
||||
|
||||
<div class="split-top split">
|
||||
<div class="split-left">
|
||||
You can attach callback to your widget to react at pointer interactions. Just make it <code>isinteractive</code>.
|
||||
<br>
|
||||
<br>
|
||||
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>.
|
||||
<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.
|
||||
<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.
|
||||
|
||||
<ul>
|
||||
<li>Tint normal: <input type="color" id="color-picker" value="#ffffff" /></li>
|
||||
<li>Tint black: <input type="color" id="dark-picker" value="#000000" /></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
<div class="split-right">
|
||||
<spine-widget
|
||||
identifier="interactive"
|
||||
atlas="assets/chibi-stickers-pma.atlas",
|
||||
skeleton="assets/chibi-stickers.json",
|
||||
skin="mario"
|
||||
animation="emotes/wave"
|
||||
pad-top="0.05"
|
||||
pad-bottom="0.05"
|
||||
isinteractive
|
||||
></spine-widget>
|
||||
|
||||
<script>
|
||||
(async () => {
|
||||
const colorPicker = document.getElementById("color-picker");
|
||||
const darkPicker = document.getElementById("dark-picker");
|
||||
|
||||
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 class="split-bottom">
|
||||
<pre><code id="code-display">
|
||||
<script>escapeHTMLandInject(`
|
||||
(async () => {
|
||||
const colorPicker = document.getElementById("color-picker");
|
||||
const darkPicker = document.getElementById("dark-picker");
|
||||
|
||||
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>
|
||||
</code></pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
/////////////////////
|
||||
// end section //
|
||||
|
||||
@ -51,6 +51,10 @@ import {
|
||||
Vector2,
|
||||
Vector3,
|
||||
Utils,
|
||||
NumberArrayLike,
|
||||
Slot,
|
||||
RegionAttachment,
|
||||
MeshAttachment,
|
||||
} from "./index.js";
|
||||
|
||||
interface Point {
|
||||
@ -98,6 +102,9 @@ function isFitType (value: string | null): value is FitType {
|
||||
|
||||
export type AttributeTypes = "string" | "number" | "boolean" | "string-number" | "fitType" | "modeType" | "offScreenUpdateBehaviourType";
|
||||
|
||||
export type CursorEventTypes = "down" | "up" | "enter" | "leave" | "move" | "drag";
|
||||
export type CursorEventTypesInput = Exclude<CursorEventTypes, "enter" | "leave">;
|
||||
|
||||
// The properties that map to widget attributes
|
||||
interface WidgetAttributes {
|
||||
atlasPath?: string
|
||||
@ -124,6 +131,7 @@ interface WidgetAttributes {
|
||||
width: number
|
||||
height: number
|
||||
isDraggable: boolean
|
||||
isInteractive: boolean
|
||||
debug: boolean
|
||||
identifier: string
|
||||
manualStart: boolean
|
||||
@ -412,13 +420,13 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
|
||||
* The x of the root relative to the canvas/webgl context center in spine world coordinates.
|
||||
* This is an experimental property and might be removed in the future.
|
||||
*/
|
||||
public worldX = 0;
|
||||
public worldX = Infinity;
|
||||
|
||||
/**
|
||||
* The y of the root relative to the canvas/webgl context center in spine world coordinates.
|
||||
* This is an experimental property and might be removed in the future.
|
||||
*/
|
||||
public worldY = 0;
|
||||
public worldY = Infinity;
|
||||
|
||||
/**
|
||||
* The x coordinate of the cursor relative to the cursor relative to the skeleton root in spine world coordinates.
|
||||
@ -432,6 +440,31 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
|
||||
*/
|
||||
public cursorWorldY = 1;
|
||||
|
||||
/**
|
||||
* If true, the widget is interactive
|
||||
* Connected to `isinteractive` attribute.
|
||||
* This is an experimental property and might be removed in the future.
|
||||
*/
|
||||
public isInteractive = false;
|
||||
|
||||
/**
|
||||
* If the widget is interactive, this method is invoked with a {@link CursorEventTypes} when the cursor
|
||||
* performs actions within the widget bounds (for example, it enter or leaves the bounds).
|
||||
* By default, the function does nothing.
|
||||
* This is an experimental property and might be removed in the future.
|
||||
*/
|
||||
public cursorBoundsEventCallback = (event: CursorEventTypes) => {}
|
||||
|
||||
/**
|
||||
* This methods allows to associate to a Slot a callback. For these slots, ff the widget is interactive,
|
||||
* when the cursor performs actions within the slot's attachment the associated callback is invoked with
|
||||
* a {@link CursorEventTypes} (for example, it enter or leaves the slot's attachment bounds).
|
||||
* This is an experimental property and might be removed in the future.
|
||||
*/
|
||||
public addCursorSlotEventCallbacks(slot: Slot, slotFunction: (slot: Slot, event: CursorEventTypes ) => void) {
|
||||
this.cursorSlotEventCallbacks.set(slot, { slotFunction, inside: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, some convenience elements are drawn to show the skeleton world origin (green),
|
||||
* the root (red), and the bounds rectangle (blue)
|
||||
@ -632,6 +665,7 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
|
||||
width: { propertyName: "width", type: "number", defaultValue: -1 },
|
||||
height: { propertyName: "height", type: "number", defaultValue: -1 },
|
||||
isdraggable: { propertyName: "isDraggable", type: "boolean" },
|
||||
isinteractive: { propertyName: "isInteractive", type: "boolean" },
|
||||
"x-axis": { propertyName: "xAxis", type: "number" },
|
||||
"y-axis": { propertyName: "yAxis", type: "number" },
|
||||
"offset-x": { propertyName: "offsetX", type: "number" },
|
||||
@ -894,6 +928,146 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
|
||||
`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Interaction utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public cursorInsideBounds = false;
|
||||
|
||||
private pointTemp = new Vector2();
|
||||
private verticesTemp = Utils.newFloatArray(2 * 1024);
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public cursorSlotEventCallbacks: Map<Slot, {
|
||||
slotFunction: (slot: Slot, event: CursorEventTypes ) => void,
|
||||
inside: boolean,
|
||||
}> = new Map();
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public cursorEventUpdate (type: CursorEventTypesInput) {
|
||||
if (!this.isInteractive) return;
|
||||
|
||||
this.checkBoundsInteraction(type);
|
||||
this.checkSlotInteraction(type);
|
||||
}
|
||||
|
||||
private checkBoundsInteraction (type: CursorEventTypesInput) {
|
||||
if ((type === "down"|| type === "up") && this.cursorInsideBounds) {
|
||||
this.cursorBoundsEventCallback(type);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.checkCursorInsideBounds()) {
|
||||
|
||||
if (!this.cursorInsideBounds) {
|
||||
this.cursorBoundsEventCallback("enter");
|
||||
}
|
||||
this.cursorInsideBounds = true;
|
||||
|
||||
this.cursorBoundsEventCallback(type);
|
||||
|
||||
} else {
|
||||
|
||||
if (this.cursorInsideBounds) {
|
||||
this.cursorBoundsEventCallback("leave");
|
||||
}
|
||||
this.cursorInsideBounds = false;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private checkCursorInsideBounds (): boolean {
|
||||
if (!this.onScreen || !this.skeleton) return false;
|
||||
|
||||
this.pointTemp.set(
|
||||
this.cursorWorldX / this.skeleton.scaleX,
|
||||
this.cursorWorldY / this.skeleton.scaleY,
|
||||
);
|
||||
|
||||
return inside(this.pointTemp, this.bounds);
|
||||
}
|
||||
|
||||
private checkSlotInteraction(type: CursorEventTypesInput) {
|
||||
for (let [slot, interactionState] of this.cursorSlotEventCallbacks) {
|
||||
if (!slot.bone.active) continue;
|
||||
let attachment = slot.getAttachment();
|
||||
|
||||
if (!(attachment instanceof RegionAttachment || attachment instanceof MeshAttachment)) continue;
|
||||
|
||||
const { slotFunction, inside } = interactionState
|
||||
|
||||
if (type === "down" || type === "up") {
|
||||
if (inside) {
|
||||
slotFunction(slot, type);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let vertices = this.verticesTemp;
|
||||
let hullLength = 8;
|
||||
|
||||
// we could probably cache the vertices from rendering if interaction with this slot is enabled
|
||||
if (attachment instanceof RegionAttachment) {
|
||||
let regionAttachment = <RegionAttachment>attachment;
|
||||
regionAttachment.computeWorldVertices(slot, vertices, 0, 2);
|
||||
} else if (attachment instanceof MeshAttachment) {
|
||||
let mesh = <MeshAttachment>attachment;
|
||||
mesh.computeWorldVertices(slot, 0, mesh.worldVerticesLength, vertices, 0, 2);
|
||||
hullLength = mesh.hullLength;
|
||||
}
|
||||
|
||||
// here we have only "move" and "drag" events
|
||||
if (this.isPointInPolygon(vertices, hullLength, [this.cursorWorldX, this.cursorWorldY])) {
|
||||
|
||||
if (!inside) {
|
||||
interactionState.inside = true;
|
||||
slotFunction(slot, "enter");
|
||||
}
|
||||
|
||||
slotFunction(slot, type);
|
||||
|
||||
} else {
|
||||
|
||||
if (inside) {
|
||||
interactionState.inside = false;
|
||||
slotFunction(slot, "leave");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isPointInPolygon(vertices: NumberArrayLike, hullLength: number, point: number[]) {
|
||||
const [px, py] = point;
|
||||
|
||||
if (hullLength < 6) {
|
||||
throw new Error("A polygon must have at least 3 vertices (6 numbers in the array). ");
|
||||
}
|
||||
|
||||
let isInside = false;
|
||||
|
||||
for (let i = 0, j = hullLength - 2; i < hullLength; i += 2) {
|
||||
const xi = vertices[i], yi = vertices[i + 1];
|
||||
const xj = vertices[j], yj = vertices[j + 1];
|
||||
|
||||
const intersects = ((yi > py) !== (yj > py)) &&
|
||||
(px < ((xj - xi) * (py - yi)) / (yj - yi) + xi);
|
||||
|
||||
if (intersects) isInside = !isInside;
|
||||
|
||||
j = i;
|
||||
}
|
||||
|
||||
return isInside;
|
||||
}
|
||||
|
||||
/*
|
||||
* Other utilities
|
||||
*/
|
||||
@ -943,15 +1117,6 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
|
||||
}
|
||||
}
|
||||
|
||||
private pointTemp = new Vector2();
|
||||
public cursorInsideBounds (): boolean {
|
||||
this.pointTemp.set(
|
||||
this.cursorWorldX / (this.skeleton?.scaleX || 1),
|
||||
this.cursorWorldY / (this.skeleton?.scaleY || 1),
|
||||
);
|
||||
return inside(this.pointTemp, this.bounds);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface OverlayAttributes {
|
||||
@ -1519,14 +1684,32 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
|
||||
private setupDragUtility (): Input {
|
||||
// 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 point: Point = { x: 0, y: 0 };
|
||||
const inputPointTemp: Point = new Vector2();
|
||||
const tempVector = new Vector3();
|
||||
|
||||
const getInput = (ev?: MouseEvent | TouchEvent): Point => {
|
||||
const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0];
|
||||
point.x = originalEvent.pageX + this.overflowLeftSize;
|
||||
point.y = originalEvent.pageY + this.overflowTopSize;
|
||||
return point;
|
||||
inputPointTemp.x = originalEvent.pageX + this.overflowLeftSize;
|
||||
inputPointTemp.y = originalEvent.pageY + this.overflowTopSize;
|
||||
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;
|
||||
@ -1535,32 +1718,29 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
|
||||
// moved is used to pass curson position wrt to canvas and widget position and currently is EXPERIMENTAL
|
||||
moved: (x, y, ev) => {
|
||||
const input = getInput(ev);
|
||||
this.cursorCanvasX = input.x - window.scrollX;
|
||||
this.cursorCanvasY = input.y - window.scrollY;
|
||||
cursorUpdate(input);
|
||||
|
||||
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;
|
||||
this.skeletonList.forEach(widget => {
|
||||
|
||||
widget.cursorWorldX = this.cursorWorldX - widget.worldX;
|
||||
widget.cursorWorldY = this.cursorWorldY - widget.worldY;
|
||||
|
||||
if (!widget.onScreen) return;
|
||||
|
||||
widget.cursorEventUpdate("move");
|
||||
});
|
||||
},
|
||||
down: (x, y, ev) => {
|
||||
const input = getInput(ev);
|
||||
this.skeletonList.forEach(widget => {
|
||||
if (!widget.isDraggable || (!widget.onScreen && widget.dragX === 0 && widget.dragY === 0)) return;
|
||||
if (!widget.onScreen && widget.dragX === 0 && widget.dragY === 0) return;
|
||||
|
||||
widget.cursorEventUpdate("down");
|
||||
|
||||
if (widget.cursorInsideBounds) {
|
||||
|
||||
if (!widget.isDraggable) return;
|
||||
|
||||
if (widget.cursorInsideBounds()) {
|
||||
widget.dragging = true;
|
||||
ev?.preventDefault();
|
||||
}
|
||||
@ -1571,10 +1751,22 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
|
||||
},
|
||||
dragged: (x, y, ev) => {
|
||||
const input = getInput(ev);
|
||||
|
||||
let dragX = input.x - prevX;
|
||||
let dragY = input.y - prevY;
|
||||
|
||||
cursorUpdate(input);
|
||||
|
||||
this.skeletonList.forEach(widget => {
|
||||
if (!widget.dragging || (!widget.onScreen && widget.dragX === 0 && widget.dragY === 0)) return;
|
||||
widget.cursorWorldX = this.cursorWorldX - widget.worldX;
|
||||
widget.cursorWorldY = this.cursorWorldY - widget.worldY;
|
||||
|
||||
if (!widget.onScreen && widget.dragX === 0 && widget.dragY === 0) return;
|
||||
|
||||
widget.cursorEventUpdate("drag");
|
||||
|
||||
if (!widget.dragging) return;
|
||||
|
||||
const skeleton = widget.skeleton!;
|
||||
widget.dragX += this.screenToWorldLength(dragX);
|
||||
widget.dragY -= this.screenToWorldLength(dragY);
|
||||
@ -1588,6 +1780,10 @@ class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes,
|
||||
up: () => {
|
||||
this.skeletonList.forEach(widget => {
|
||||
widget.dragging = false;
|
||||
|
||||
if (widget.cursorInsideBounds) {
|
||||
widget.cursorEventUpdate("up");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user