diff --git a/spine-ts/spine-webcomponents/example/tutorial.html b/spine-ts/spine-webcomponents/example/tutorial.html index bc9287393..6fdbbf9d0 100644 --- a/spine-ts/spine-webcomponents/example/tutorial.html +++ b/spine-ts/spine-webcomponents/example/tutorial.html @@ -231,11 +231,11 @@ >
- If you want to preserve the original scale, you can use fit="none". + If you want to preserve the original scale, you can use fit="none" (center the bounds) or fit="origin" (center the skeleton origin). In combination with that, you can use the scale attribute to set your desired scale.

- Other fit modes are width, height, cover, and scaleDown. + Other fit modes are width, height, cover, scaleDown..
@@ -363,11 +363,6 @@
- The origin mode centers the animation's world origin with the center of the HTML element. -
- You are responsible for scaling the skeleton when using this mode. -
-
Move the origin by a percentage of the div's width and height using the x-axis and y-axis attributes, respectively.
@@ -375,7 +370,7 @@ atlas="/assets/vine-pma.atlas" skeleton="/assets/vine-pro.skel" animation="grow" - mode="origin" + fit="origin" scale=".5" y-axis="-.5" > @@ -390,7 +385,7 @@ atlas="/assets/vine-pma.atlas" skeleton="/assets/vine-pro.skel" animation="grow" - mode="origin" + fit="origin" scale=".5" y-axis="-.5" > @@ -772,11 +767,11 @@
  • mixDuration: the mix duration between this animation and the previous one (not used for the first animation on a track)
  • -

    To loop a track once it reaches the end, add the special group [loop, trackNumber, holdDurationLastAnimation], where:

    +

    To loop a track once it reaches the end, add the special group [loop, trackNumber, repeatDelay], where:

    The parameters of the first group on each track are passed to the setAnimation method, while the remaining groups use addAnimation.

    @@ -1427,6 +1422,8 @@ function toggleSpinner(element) {
    It's very easy to display your different skins and animations. Simply create a table and use the skin and animation attributes. +
    + skin accepts a comma separated list of skin names. The skins will be combined in a new one, from the first to the last. If multiple skins set the same slot, the latest in the list will be used.
    @@ -3279,7 +3276,7 @@ const darkPicker = document.getElementById("dark-picker");
  • followOpacity: the element opacity is connected to the slot alpha
  • followScale: the element scale is connected to the slot scale
  • followRotation: the element rotation is connected to the slot rotation
  • -
  • followAttachmentAttach: the element is shown/hidden depending if the slot contains an attachment or not
  • +
  • followVisibility: the element is shown/hidden depending if the slot contains an attachment or not
  • hideAttachment: the slot attachment is hidden as if the element replaced the attachment
  • @@ -3304,10 +3301,10 @@ const darkPicker = document.getElementById("dark-picker"); @@ -3331,10 +3328,10 @@ const darkPicker = document.getElementById("dark-picker"); (async () => { const widget = await spine.getSpineWidget("potty").whenReady; - 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 }); + widget.followSlot("rain/rain-color", document.getElementById("rain/rain-color"), { followVisibility: false, hideAttachment: true }); + widget.followSlot("rain/rain-white", document.getElementById("rain/rain-white"), { followVisibility: false, hideAttachment: true }); + widget.followSlot("rain/rain-blue", document.getElementById("rain/rain-blue"), { followVisibility: false, hideAttachment: true }); + widget.followSlot("rain/rain-green", document.getElementById("rain/rain-green"), { followVisibility: false, hideAttachment: true }); })();`);
    @@ -3378,10 +3375,10 @@ const darkPicker = document.getElementById("dark-picker"); @@ -3405,10 +3402,10 @@ const darkPicker = document.getElementById("dark-picker"); (async () => { const widget = await spine.getSpineWidget("potty2").whenReady; - 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 }); + widget.followSlot("rain/rain-color", spine.getSpineWidget("potty2-1"), { followVisibility: false, hideAttachment: true }); + widget.followSlot("rain/rain-white", spine.getSpineWidget("potty2-2"), { followVisibility: false, hideAttachment: true }); + widget.followSlot("rain/rain-blue", spine.getSpineWidget("potty2-3"), { followVisibility: false, hideAttachment: true }); + widget.followSlot("rain/rain-green", spine.getSpineWidget("potty2-4"), { followVisibility: false, hideAttachment: true }); })();`);
    diff --git a/spine-ts/spine-webcomponents/src/SpineWebComponentOverlay.ts b/spine-ts/spine-webcomponents/src/SpineWebComponentOverlay.ts index 56fe8d5a1..8ef026d50 100644 --- a/spine-ts/spine-webcomponents/src/SpineWebComponentOverlay.ts +++ b/spine-ts/spine-webcomponents/src/SpineWebComponentOverlay.ts @@ -483,7 +483,7 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr const tempVector = new Vector3(); for (const widget of this.widgets) { - const { skeleton, pma, bounds, mode, debug, offsetX, offsetY, xAxis, yAxis, dragX, dragY, fit, noSpinner, loading, clip, isDraggable } = widget; + const { skeleton, pma, bounds, debug, offsetX, offsetY, dragX, dragY, fit, noSpinner, loading, clip, isDraggable } = widget; if (widget.isOffScreenAndWasMoved()) continue; const elementRef = widget.getHostElement(); @@ -497,7 +497,7 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr divBounds.y -= offsetTopForOverlay; } - const { padLeft, padRight, padTop, padBottom } = widget + const { padLeft, padRight, padTop, padBottom, xAxis, yAxis } = widget const paddingShiftHorizontal = (padLeft - padRight) / 2; const paddingShiftVertical = (padTop - padBottom) / 2; @@ -525,7 +525,7 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr } if (skeleton) { - if (mode === "inside") { + if (fit !== "origin") { let { x: ax, y: ay, width: aw, height: ah } = bounds; if (aw <= 0 || ah <= 0) continue; @@ -591,8 +591,9 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr } } - const worldOffsetX = divOriginX + offsetX + dragX; - const worldOffsetY = divOriginY + offsetY + dragY; + // const worldOffsetX = divOriginX + offsetX + dragX; + const worldOffsetX = divOriginX + offsetX * window.devicePixelRatio + dragX; + const worldOffsetY = divOriginY + offsetY * window.devicePixelRatio + dragY; widget.worldX = worldOffsetX; widget.worldY = worldOffsetY; @@ -634,12 +635,10 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr renderer.circle(true, root.x + worldOffsetX, root.y + worldOffsetY, 10, red); // show shifted origin - const originX = worldOffsetX - dragX - offsetX; - const originY = worldOffsetY - dragY - offsetY; - renderer.circle(true, originX, originY, 10, green); + renderer.circle(true, divOriginX, divOriginY, 10, green); // show line from origin to bounds center - renderer.line(originX, originY, bbCenterX, bbCenterY, green); + renderer.line(divOriginX, divOriginY, bbCenterX, bbCenterY, green); } if (clip) endScissor(); @@ -654,7 +653,7 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr if (widget.isOffScreenAndWasMoved() || !widget.skeleton) continue; for (const boneFollower of widget.boneFollowerList) { - const { slot, bone, element, followAttachmentAttach, followRotation, followOpacity, followScale } = boneFollower; + const { slot, bone, element, followVisibility, followRotation, followOpacity, followScale } = boneFollower; const { worldX, worldY } = widget; this.worldToScreen(this.tempFollowBoneVector, bone.worldX + worldX, bone.worldY + worldY); @@ -675,7 +674,7 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr element.style.display = "" - if (followAttachmentAttach && !slot.attachment) { + if (followVisibility && !slot.attachment) { element.style.opacity = "0"; } else if (followOpacity) { element.style.opacity = `${slot.color.a}`; @@ -951,7 +950,7 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr private updateWidgetScales () { for (const widget of this.widgets) { // inside mode scale automatically to fit the skeleton within its parent - if (widget.mode !== "origin" && widget.fit !== "none") continue; + if (widget.fit !== "origin" && widget.fit !== "none") continue; const skeleton = widget.skeleton; if (!skeleton) continue; diff --git a/spine-ts/spine-webcomponents/src/SpineWebComponentSkeleton.ts b/spine-ts/spine-webcomponents/src/SpineWebComponentSkeleton.ts index c9644e1ce..34d17dba0 100644 --- a/spine-ts/spine-webcomponents/src/SpineWebComponentSkeleton.ts +++ b/spine-ts/spine-webcomponents/src/SpineWebComponentSkeleton.ts @@ -49,6 +49,7 @@ import { RegionAttachment, MeshAttachment, Bone, + Skin, } from "@esotericsoftware/spine-webgl"; import { AttributeTypes, castValue, isBase64, Rectangle } from "./wcUtils.js"; import { SpineWebComponentOverlay } from "./SpineWebComponentOverlay.js"; @@ -56,11 +57,10 @@ import { SpineWebComponentOverlay } from "./SpineWebComponentOverlay.js"; type UpdateSpineWidgetFunction = (delta: number, skeleton: Skeleton, state: AnimationState) => void; export type OffScreenUpdateBehaviourType = "pause" | "update" | "pose"; -export type ModeType = "inside" | "origin"; -export type FitType = "fill" | "width" | "height" | "contain" | "cover" | "none" | "scaleDown"; +export type FitType = "fill" | "width" | "height" | "contain" | "cover" | "none" | "scaleDown" | "origin"; export type AnimationsInfo = Record }>; export type AnimationsType = { animationName: string | "#EMPTY#", loop?: boolean, delay?: number, mixDuration?: number }; @@ -77,9 +77,8 @@ interface WidgetAttributes { animation?: string animations?: AnimationsInfo defaultMix?: number - skin?: string + skin?: string[] fit: FitType - mode: ModeType xAxis: number yAxis: number offsetX: number @@ -221,14 +220,14 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable * Optional: The name of the skin to be set * Connected to `skin` attribute. */ - public get skin (): string | undefined { + public get skin (): string[] | undefined { return this._skin; } - public set skin (value: string | undefined) { + public set skin (value: string[] | undefined) { this._skin = value; this.initWidget(); } - private _skin?: string + private _skin?: string[] /** * Specify the way the skeleton is sized within the element automatically changing its `scaleX` and `scaleY`. @@ -240,19 +239,11 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable * - `cover`: as small as possible while still covering the entire element container. * - `scaleDown`: scale the skeleton down to ensure that the skeleton fits within the element container. * - `none`: display the skeleton without autoscaling it. + * - `origin`: the skeleton origin is centered with the element container regardless of the bounds. * Connected to `fit` attribute. */ public fit: FitType = "contain"; - /** - * Specify the way the skeleton is centered within the element container: - * - `inside`: the skeleton bounds center is centered with the element container (Default) - * - `origin`: the skeleton origin is centered with the element container regardless of the bounds. - * Origin does not allow to specify any {@link fit} type and guarantee the skeleton to not be autoscaled. - * Connected to `mode` attribute. - */ - public mode: ModeType = "inside"; - /** * The x offset of the skeleton world origin x axis as a percentage of the element container width * Connected to `x-axis` attribute. @@ -705,7 +696,7 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable animations: { propertyName: "animations", type: "animationsInfo", defaultValue: undefined }, "animation-bounds": { propertyName: "animationsBound", type: "array-string", defaultValue: undefined }, "default-mix": { propertyName: "defaultMix", type: "number", defaultValue: 0 }, - skin: { propertyName: "skin", type: "string" }, + skin: { propertyName: "skin", type: "array-string" }, width: { propertyName: "width", type: "number", defaultValue: -1 }, height: { propertyName: "height", type: "number", defaultValue: -1 }, isdraggable: { propertyName: "isDraggable", type: "boolean" }, @@ -731,7 +722,6 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable clip: { propertyName: "clip", type: "boolean" }, pages: { propertyName: "pages", type: "array-number" }, fit: { propertyName: "fit", type: "fitType", defaultValue: "contain" }, - mode: { propertyName: "mode", type: "modeType", defaultValue: "inside" }, offscreen: { propertyName: "offScreenUpdateBehaviour", type: "offScreenUpdateBehaviourType", defaultValue: "pause" }, } @@ -1008,18 +998,28 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable // skeleton.scaleX = this.dprScale; // skeleton.scaleY = this.dprScale; + this.loading = false; + // the bounds are calculated the first time, if no custom bound is provided this.initWidget(this.bounds.width <= 0 || this.bounds.height <= 0); - this.loading = false; return this; } private initWidget (forceRecalculate = false) { + if (this.loading) return; + const { skeleton, state, animation, animations: animationsInfo, skin, defaultMix } = this; if (skin) { - skeleton?.setSkinByName(skin); + if (skin.length === 1) { + skeleton?.setSkinByName(skin[0]); + } else { + const customSkin = new Skin("custom"); + for (const s of skin) customSkin.addSkin(skeleton?.data.findSkin(s) as Skin); + skeleton?.setSkin(customSkin); + } + skeleton?.setSlotsToSetupPose(); } @@ -1027,7 +1027,7 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable state.data.defaultMix = defaultMix; if (animationsInfo) { - for (const [trackIndexString, { cycle, animations, holdDurationLastAnimation }] of Object.entries(animationsInfo)) { + for (const [trackIndexString, { cycle, animations, repeatDelay }] of Object.entries(animationsInfo)) { const cycleFn = () => { const trackIndex = Number(trackIndexString); for (const [index, { animationName, delay, loop, mixDuration }] of animations.entries()) { @@ -1051,8 +1051,8 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable if (cycle && index === animations.length - 1) { track.listener = { complete: () => { - if (holdDurationLastAnimation) - setTimeout(() => cycleFn(), 1000 * holdDurationLastAnimation); + if (repeatDelay) + setTimeout(() => cycleFn(), 1000 * repeatDelay); else cycleFn(); delete track.listener?.complete; @@ -1231,10 +1231,10 @@ export class SpineWebComponentSkeleton 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 } = {}) { + public boneFollowerList: Array<{ slot: Slot, bone: Bone, element: HTMLElement, followVisibility: boolean, followRotation: boolean, followOpacity: boolean, followScale: boolean, hideAttachment: boolean }> = []; + public followSlot (slotName: string | Slot, element: HTMLElement, options: { followVisibility?: boolean, followRotation?: boolean, followOpacity?: boolean, followScale?: boolean, hideAttachment?: boolean } = {}) { const { - followAttachmentAttach = false, + followVisibility = false, followRotation = true, followOpacity = true, followScale = true, @@ -1253,7 +1253,7 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable element.style.left = '0px'; element.style.display = 'none'; - this.boneFollowerList.push({ slot, bone: slot.bone, element, followAttachmentAttach, followRotation, followOpacity, followScale, hideAttachment }); + this.boneFollowerList.push({ slot, bone: slot.bone, element, followVisibility, followRotation, followOpacity, followScale, hideAttachment }); this.overlay.addSlotFollowerElement(element); } public unfollowSlot (element: HTMLElement): HTMLElement | undefined { diff --git a/spine-ts/spine-webcomponents/src/wcUtils.ts b/spine-ts/spine-webcomponents/src/wcUtils.ts index fbf7bbd2d..3e8d0aaa8 100644 --- a/spine-ts/spine-webcomponents/src/wcUtils.ts +++ b/spine-ts/spine-webcomponents/src/wcUtils.ts @@ -27,10 +27,10 @@ * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *****************************************************************************/ -import { AnimationsInfo, FitType, ModeType, OffScreenUpdateBehaviourType } from "./SpineWebComponentSkeleton.js"; +import { AnimationsInfo, FitType, OffScreenUpdateBehaviourType } from "./SpineWebComponentSkeleton.js"; const animatonTypeRegExp = /\[([^\]]+)\]/g; -export type AttributeTypes = "string" | "number" | "boolean" | "array-number" | "array-string" | "object" | "fitType" | "modeType" | "offScreenUpdateBehaviourType" | "animationsInfo"; +export type AttributeTypes = "string" | "number" | "boolean" | "array-number" | "array-string" | "object" | "fitType" | "offScreenUpdateBehaviourType" | "animationsInfo"; export function castValue (type: AttributeTypes, value: string | null, defaultValue?: any) { switch (type) { @@ -48,8 +48,6 @@ export function castValue (type: AttributeTypes, value: string | null, defaultVa return castObject(value, defaultValue); case "fitType": return isFitType(value) ? value : defaultValue; - case "modeType": - return isModeType(value) ? value : defaultValue; case "offScreenUpdateBehaviourType": return isOffScreenUpdateBehaviourType(value) ? value : defaultValue; case "animationsInfo": @@ -104,7 +102,7 @@ function castToAnimationsInfo (value: string | null): AnimationsInfo | undefined if (!matches) return undefined; return matches.reduce((obj, group) => { - const [trackIndexStringOrLoopDefinition, animationNameOrTrackIndexStringCycle, loopOrHoldDurationLastAnimation, delayString, mixDurationString] = group.slice(1, -1).split(',').map(v => v.trim()); + const [trackIndexStringOrLoopDefinition, animationNameOrTrackIndexStringCycle, loopOrRepeatDelay, delayString, mixDurationString] = group.slice(1, -1).split(',').map(v => v.trim()); if (trackIndexStringOrLoopDefinition === "loop") { if (!Number.isInteger(Number(animationNameOrTrackIndexStringCycle))) { @@ -113,12 +111,12 @@ function castToAnimationsInfo (value: string | null): AnimationsInfo | undefined const animationInfoObject = obj[animationNameOrTrackIndexStringCycle] ||= { animations: [] }; animationInfoObject.cycle = true; - if (loopOrHoldDurationLastAnimation !== undefined) { - const holdDurationLastAnimation = Number(loopOrHoldDurationLastAnimation); - if (Number.isNaN(holdDurationLastAnimation)) { - throw new Error(`If present, duration of last animation of cycle in ${group} must be a positive integer number, instead it is ${loopOrHoldDurationLastAnimation}. Original value: ${value}`); + if (loopOrRepeatDelay !== undefined) { + const repeatDelay = Number(loopOrRepeatDelay); + if (Number.isNaN(repeatDelay)) { + throw new Error(`If present, duration of last animation of cycle in ${group} must be a positive integer number, instead it is ${loopOrRepeatDelay}. Original value: ${value}`); } - animationInfoObject.holdDurationLastAnimation = holdDurationLastAnimation; + animationInfoObject.repeatDelay = repeatDelay; } return obj; @@ -148,7 +146,7 @@ function castToAnimationsInfo (value: string | null): AnimationsInfo | undefined const animationInfoObject = obj[trackIndexStringOrLoopDefinition] ||= { animations: [] }; animationInfoObject.animations.push({ animationName: animationNameOrTrackIndexStringCycle, - loop: loopOrHoldDurationLastAnimation.trim().toLowerCase() === "true", + loop: (loopOrRepeatDelay || "").trim().toLowerCase() === "true", delay, mixDuration, }); @@ -164,7 +162,8 @@ function isFitType (value: string | null): value is FitType { value === "contain" || value === "cover" || value === "none" || - value === "scaleDown" + value === "scaleDown" || + value === "origin" ); } @@ -176,12 +175,6 @@ function isOffScreenUpdateBehaviourType (value: string | null): value is OffScre ); } -function isModeType (value: string | null): value is ModeType { - return ( - value === "inside" || - value === "origin" - ); -} const base64RegExp = /^(([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==))$/; export function isBase64 (str: string) { return base64RegExp.test(str);