소스 검색

General refactor. spine-widget to spine-skeleton.

Davide Tantillo 4 달 전
부모
커밋
0abcaff7fe

+ 8 - 8
spine-ts/spine-widget/example/app.html

@@ -201,7 +201,7 @@
                 <!-- SECTION 1 -->
                 <div class="screen">
                     <div class="top-section">
-                        <spine-widget
+                        <spine-skeleton
                             identifier="list"
 
                             overlay-id="phone"
@@ -212,7 +212,7 @@
 
                             isinteractive
 
-                        ></spine-widget>
+                        ></spine-skeleton>
                     </div>
 
                     <div class="bottom-section">
@@ -268,7 +268,7 @@
                 <div class="screen">
                     <div class="top-section">
 
-                        <spine-widget
+                        <spine-skeleton
                             identifier="pan"
 
                             overlay-id="phone"
@@ -277,7 +277,7 @@
                             skeleton="assets/food/pan-cooking-pro.json"
                             animation="animation"
 
-                        ></spine-widget>
+                        ></spine-skeleton>
 
                     </div>
 
@@ -333,7 +333,7 @@
                 <div class="screen">
                     <div class="top-section">
 
-                        <spine-widget
+                        <spine-skeleton
                             identifier="delivery"
 
                             overlay-id="phone"
@@ -342,7 +342,7 @@
                             skeleton="assets/food/meal-delivery-pro.json"
                             animation="animation"
 
-                        ></spine-widget>
+                        ></spine-skeleton>
 
                     </div>
 
@@ -372,7 +372,7 @@
                 <div class="screen">
                     <div class="top-section">
 
-                        <spine-widget
+                        <spine-skeleton
                             identifier="ready"
 
                             overlay-id="phone"
@@ -382,7 +382,7 @@
                             animation="base"
 
                             isinteractive
-                        ></spine-widget>
+                        ></spine-skeleton>
 
                     </div>
 

+ 4 - 4
spine-ts/spine-widget/example/game.html

@@ -37,20 +37,20 @@
                     </div>
 
                     <div class="bottom" style="flex: 80%; background-color: rgb(24, 149, 89); display: flex; align-items: center; justify-content: center;">
-                        <spine-widget
+                        <spine-skeleton
                             identifier="windmill-game"
                             atlas="assets/windmill-ess.atlas"
                             skeleton="assets/windmill-ess.json"
                             animation="animation"
                             isinteractive
-                        ></spine-widget>
-                        <spine-widget
+                        ></spine-skeleton>
+                        <spine-skeleton
                             identifier="spineboy-game"
                             atlas="assets/spineboy-pma.atlas"
                             skeleton="assets/spineboy-pro.json"
                             animation="hoverboard"
                             fit="none"
-                        ></spine-widget>
+                        ></spine-skeleton>
                     </div>
                 </div>
 

+ 2 - 2
spine-ts/spine-widget/example/gui.html

@@ -41,13 +41,13 @@
 
     <div class="container">
         <div class="left-column">
-            <spine-widget
+            <spine-skeleton
                 identifier="boi"
                 atlas="assets/spineboy-pma.atlas"
                 skeleton="assets/spineboy-pro.skel"
                 auto-calculate-bounds
                 debug
-            ></spine-widget>
+            ></spine-skeleton>
         </div>
         <div class="right-column">
             <div id="lil"></div>

+ 4 - 4
spine-ts/spine-widget/example/login.html

@@ -21,7 +21,7 @@
         <div style="background-color: white; width: 250px; padding: 30px; text-align: center; border-radius: 10px; border: 3px solid black; box-shadow: 5px 5px rgb(0, 0, 0);">
             <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
+                    <spine-skeleton
                         identifier="spineboy-login"
                         atlas="assets/pwd/chibi-stickers-pro-pwd-test.atlas"
                         skeleton="assets/pwd/chibi-stickers.json"
@@ -33,7 +33,7 @@
                         animation="interactive/head/idle"
                         clip
                         isinteractive
-                    ></spine-widget>
+                    ></spine-skeleton>
                 </div>
             </div>
 
@@ -51,14 +51,14 @@
 
                 <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
+                    <spine-skeleton
                         identifier="button-login"
                         atlas="assets/pwd/button.atlas"
                         skeleton="assets/pwd/button.json"
                         animation="idle"
                         isinteractive
                         fit="fill"
-                    ></spine-widget>
+                    ></spine-skeleton>
                 </div>
 
             </form>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 173 - 173
spine-ts/spine-widget/example/tutorial.html


+ 1023 - 0
spine-ts/spine-widget/src/SpineWebComponentOverlay.ts

@@ -0,0 +1,1023 @@
+/******************************************************************************
+ * Spine Runtimes License Agreement
+ * Last updated July 28, 2023. Replaces all prior versions.
+ *
+ * Copyright (c) 2013-2023, Esoteric Software LLC
+ *
+ * Integration of the Spine Runtimes into software or otherwise creating
+ * derivative works of the Spine Runtimes is permitted under the terms and
+ * conditions of Section 2 of the Spine Editor License Agreement:
+ * http://esotericsoftware.com/spine-editor-license
+ *
+ * Otherwise, it is permitted to integrate the Spine Runtimes into software or
+ * otherwise create derivative works of the Spine Runtimes (collectively,
+ * "Products"), provided that each user of the Products must obtain their own
+ * Spine Editor license and redistribution of the Products in any form must
+ * include this license and copyright notice.
+ *
+ * THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
+ * BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THE
+ * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *****************************************************************************/
+
+import { AssetManager, Color, Input, LoadingScreen, ManagedWebGLRenderingContext, Physics, SceneRenderer, TimeKeeper, Vector2, Vector3 } from "@esotericsoftware/spine-webgl"
+import { SpineWebComponentSkeleton } from "./SpineWebComponentSkeleton"
+import { AttributeTypes, castValue, Point, Rectangle } from "./wcUtils"
+
+interface OverlayAttributes {
+	overlayId?: string
+	noAutoParentTransform: boolean
+	overflowTop: number
+	overflowBottom: number
+	overflowLeft: number
+	overflowRight: number
+}
+
+export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes, Disposable {
+	private static OVERLAY_ID = "spine-overlay-default-identifier";
+	private static OVERLAY_LIST = new Map<string, SpineWebComponentOverlay>();
+
+	/**
+	 * @internal
+	 */
+	static getOrCreateOverlay (overlayId: string | null): SpineWebComponentOverlay {
+		let overlay = SpineWebComponentOverlay.OVERLAY_LIST.get(overlayId || SpineWebComponentOverlay.OVERLAY_ID);
+		if (!overlay) {
+			overlay = document.createElement('spine-overlay') as SpineWebComponentOverlay;
+			overlay.setAttribute('overlay-id', SpineWebComponentOverlay.OVERLAY_ID);
+			document.body.appendChild(overlay);
+		}
+		return overlay;
+	}
+
+	/**
+	 * A list holding the widgets added to this overlay.
+	 */
+	public widgets = new Array<SpineWebComponentSkeleton>();
+
+	/**
+	 * The {@link SceneRenderer} used by this overlay.
+	 */
+	public renderer: SceneRenderer;
+
+	/**
+	 * The {@link AssetManager} used by this overlay.
+	 */
+	public assetManager: AssetManager;
+
+	/**
+	 * The identifier of this overlay. This is necessary when multiply overlay are created.
+	   * Connected to `overlay-id` attribute.
+	 */
+	public overlayId?: string;
+
+	/**
+	 * If `false` (default value), the overlay container style will be affected adding `transform: translateZ(0);` to it.
+	 * The `transform` is not affected if it already exists on the container.
+	 * This is necessary to make the scrolling works with containers that scroll in a different way with respect to the page, as explained in {@link appendedToBody}.
+	 * Connected to `no-auto-parent-transform` attribute.
+	 */
+	public noAutoParentTransform = false;
+
+	/**
+	 * The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
+	 * If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
+	 * This parameter defines, as percentage of the viewport height, the pixels to add to the top of the canvas to prevent this effect.
+	 * Making the canvas too big might reduce performance.
+	 * Default value: 0.2.
+	 * Connected to `overflow-top` attribute.
+	 */
+	public overflowTop = .2;
+
+	/**
+	 * The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
+	 * If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
+	 * This parameter defines, as percentage of the viewport height, the pixels to add to the bottom of the canvas to prevent this effect.
+	 * Making the canvas too big might reduce performance.
+	 * Default value: 0.
+	 * Connected to `overflow-bottom` attribute.
+	 */
+	public overflowBottom = .0;
+
+	/**
+	 * The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
+	 * If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
+	 * This parameter defines, as percentage of the viewport width, the pixels to add to the left of the canvas to prevent this effect.
+	 * Making the canvas too big might reduce performance.
+	 * Default value: 0.
+	 * Connected to `overflow-left` attribute.
+	 */
+	public overflowLeft = .0;
+
+	/**
+	 * The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
+	 * If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
+	 * This parameter defines, as percentage of the viewport width, the pixels to add to the right of the canvas to prevent this effect.
+	 * Making the canvas too big might reduce performance.
+	 * Default value: 0.
+	 * Connected to `overflow-right` attribute.
+	 */
+	public overflowRight = .0;
+
+	private root: ShadowRoot;
+
+	private div: HTMLDivElement;
+	private boneFollowersParent: HTMLDivElement;
+	private canvas: HTMLCanvasElement;
+	private fps: HTMLSpanElement;
+	private fpsAppended = false;
+
+	private intersectionObserver?: IntersectionObserver;
+	private resizeObserver?: ResizeObserver;
+	private input?: Input;
+
+	private overflowLeftSize = 0;
+	private overflowTopSize = 0;
+
+	private lastCanvasBaseWidth = 0;
+	private lastCanvasBaseHeight = 0;
+
+	private disposed = false;
+	private loaded = false;
+
+	/**
+	 * appendedToBody is assegned in the connectedCallback.
+	 * When true, the overlay will have the size of the element container in contrast to the default behaviour where the
+	 * overlay has always the size of the screen.
+	 * This is necessary when the overlay is inserted into a container that scroll in a different way with respect to the page.
+	 * Otherwise the following problems might occur:
+	 * 1) For containers appendedToBody, the widget will be slightly slower to scroll than the html behind. The effect is more evident for lower refresh rate display.
+	 * 2) For containers appendedToBody, the widget will overflow the container bounds until the widget html element container is visible
+	 * 3) For fixed containers, the widget will scroll in a jerky way
+	 *
+	 * In order to fix this behaviour, it is necessary to insert a dedicated `spine-overlay` webcomponent as a direct child of the container.
+	 * Moreover, it is necessary to perform the following actions:
+	 * 1) The scrollable container must have a `transform` css attribute. If it hasn't this attribute the `spine-overlay` will add it for you.
+	 * If your scrollable container has already this css attribute, or if you prefer to add it by yourself (example: `transform: translateZ(0);`), set the `no-auto-parent-transform` to the `spine-overlay`.
+	 * 2) The `spine-overlay` must have an `overlay-id` attribute. Choose the value you prefer.
+	 * 3) Each `spine-skeleton` must have an `overlay-id` attribute. The same as the hosting `spine-overlay`.
+	   * Connected to `scrollable` attribute.
+	 */
+	private appendedToBody = true;
+
+	readonly time = new TimeKeeper();
+
+	constructor () {
+		super();
+		this.root = this.attachShadow({ mode: "open" });
+
+		this.div = document.createElement("div");
+		this.div.style.position = "absolute";
+		this.div.style.top = "0";
+		this.div.style.left = "0";
+		this.div.style.setProperty("pointer-events", "none");
+		this.div.style.overflow = "hidden"
+		// this.div.style.backgroundColor = "rgba(0, 255, 0, 0.1)";
+
+		this.root.appendChild(this.div);
+
+		this.canvas = document.createElement("canvas");
+		this.boneFollowersParent = document.createElement("div");
+
+		this.div.appendChild(this.canvas);
+		this.canvas.style.position = "absolute";
+		this.canvas.style.top = "0";
+		this.canvas.style.left = "0";
+
+		this.div.appendChild(this.boneFollowersParent);
+		this.boneFollowersParent.style.position = "absolute";
+		this.boneFollowersParent.style.top = "0";
+		this.boneFollowersParent.style.left = "0";
+		this.boneFollowersParent.style.whiteSpace = "nowrap";
+		this.boneFollowersParent.style.setProperty("pointer-events", "none");
+		this.boneFollowersParent.style.transform = `translate(0px,0px)`;
+
+		this.canvas.style.setProperty("pointer-events", "none");
+		this.canvas.style.transform = `translate(0px,0px)`;
+		// this.canvas.style.setProperty("will-change", "transform"); // performance seems to be even worse with this uncommented
+
+		this.fps = document.createElement("span");
+		this.fps.style.position = "fixed";
+		this.fps.style.top = "0";
+		this.fps.style.left = "0";
+
+		const context = new ManagedWebGLRenderingContext(this.canvas, { alpha: true });
+		this.renderer = new SceneRenderer(this.canvas, context);
+
+		this.assetManager = new AssetManager(context);
+	}
+	[Symbol.dispose](): void {
+		throw new Error("Method not implemented.")
+	}
+
+	connectedCallback (): void {
+		this.appendedToBody = this.parentElement !== document.body;
+
+		let overlayId = this.getAttribute('overlay-id');
+		if (!overlayId) {
+			overlayId = SpineWebComponentOverlay.OVERLAY_ID;
+			this.setAttribute('overlay-id', overlayId);
+		}
+		const existingOverlay = SpineWebComponentOverlay.OVERLAY_LIST.get(overlayId);
+		if (existingOverlay && existingOverlay !== this) {
+			throw new Error(`"SpineWebComponentOverlay - You cannot have two spine-overlay with the same overlay-id: ${overlayId}"`);
+		}
+		SpineWebComponentOverlay.OVERLAY_LIST.set(overlayId, this);
+		// window.addEventListener("scroll", this.scrolledCallback);
+
+		if (document.readyState !== "complete") {
+			window.addEventListener("load", this.loadedCallback);
+		} else {
+			this.loadedCallback();
+		}
+
+		window.screen.orientation.addEventListener('change', this.orientationChangedCallback);
+
+		this.intersectionObserver = new IntersectionObserver((widgets) => {
+			for (const elem of widgets) {
+				const { target, intersectionRatio } = elem;
+				let { isIntersecting } = elem;
+				for (const widget of this.widgets) {
+					if (widget.getHostElement() != target) continue;
+
+					// old browsers do not have isIntersecting
+					if (isIntersecting === undefined) {
+						isIntersecting = intersectionRatio > 0;
+					}
+
+					widget.onScreen = isIntersecting;
+					if (isIntersecting) {
+						widget.onScreenFunction(widget);
+					}
+				}
+			}
+		}, { rootMargin: "30px 20px 30px 20px" });
+
+		// resize observer is supported by all major browsers today chrome started to support it in version 64 (early 2018)
+		// we cannot use window.resize event since it does not fire when body resizes, but not the window
+		// Alternatively, we can store the body size, check the current body size in the loop (like the translateCanvas), and
+		// if they differs call the resizeCallback. I already tested it, and it works. ResizeObserver should be more efficient.
+		if (this.appendedToBody) {
+			// if the element is scrollable, the user does not disable translate tweak, and the parent did not have already a transform, add the tweak
+			if (this.appendedToBody && !this.noAutoParentTransform && getComputedStyle(this.parentElement!).transform === "none") {
+				this.parentElement!.style.transform = `translateZ(0)`;
+			}
+			this.resizeObserver = new ResizeObserver(this.resizedCallback);
+			this.resizeObserver.observe(this.parentElement!);
+		} else {
+			window.addEventListener("resize", this.resizedCallback)
+		}
+
+		for (const widget of this.widgets) {
+			this.intersectionObserver?.observe(widget.getHostElement());
+		}
+		this.input = this.setupDragUtility();
+
+		this.startRenderingLoop();
+	}
+
+	private hasCssTweakOff () {
+		return this.noAutoParentTransform && getComputedStyle(this.parentElement!).transform === "none";
+	}
+
+	private running = false;
+	disconnectedCallback (): void {
+		const id = this.getAttribute('overlay-id');
+		if (id) SpineWebComponentOverlay.OVERLAY_LIST.delete(id);
+		// window.removeEventListener("scroll", this.scrolledCallback);
+		window.removeEventListener("load", this.loadedCallback);
+		window.removeEventListener("resize", this.resizedCallback);
+		window.screen.orientation.removeEventListener('change', this.orientationChangedCallback);
+		this.intersectionObserver?.disconnect();
+		this.resizeObserver?.disconnect();
+		this.input?.dispose();
+	}
+
+
+	static attributesDescription: Record<string, { propertyName: keyof OverlayAttributes, type: AttributeTypes, defaultValue?: any }> = {
+		"overlay-id": { propertyName: "overlayId", type: "string" },
+		"no-auto-parent-transform": { propertyName: "noAutoParentTransform", type: "boolean" },
+		"overflow-top": { propertyName: "overflowTop", type: "number" },
+		"overflow-bottom": { propertyName: "overflowBottom", type: "number" },
+		"overflow-left": { propertyName: "overflowLeft", type: "number" },
+		"overflow-right": { propertyName: "overflowRight", type: "number" },
+	}
+
+	static get observedAttributes (): string[] {
+		return Object.keys(SpineWebComponentOverlay.attributesDescription);
+	}
+
+	attributeChangedCallback (name: string, oldValue: string | null, newValue: string | null): void {
+		const { type, propertyName, defaultValue } = SpineWebComponentOverlay.attributesDescription[name];
+		const val = castValue(type, newValue, defaultValue);
+		(this as any)[propertyName] = val;
+		return;
+	}
+
+	private resizedCallback = () => {
+		this.updateCanvasSize();
+	}
+
+	private orientationChangedCallback = () => {
+		this.updateCanvasSize();
+		// after an orientation change the scrolling changes, but the scroll event does not fire
+		this.scrolledCallback();
+	}
+
+	// right now, we scroll the canvas each frame before rendering loop, that makes scrolling on mobile waaay more smoother
+	// this is way scroll handler do nothing
+	private scrolledCallback = () => {
+		// this.translateCanvas();
+	}
+
+	private loadedCallback = () => {
+		this.updateCanvasSize();
+		this.scrolledCallback();
+		if (!this.loaded) {
+			this.loaded = true;
+			this.parentElement!.appendChild(this);
+		}
+	}
+
+	/**
+	 * Remove the overlay from the DOM, dispose all the contained widgets, and dispose the renderer.
+	 */
+	dispose (): void {
+		for (const widget of [...this.widgets]) widget.dispose();
+
+		this.remove();
+		this.widgets.length = 0;
+		this.renderer.dispose();
+		this.disposed = true;
+		this.assetManager.dispose();
+	}
+
+	/**
+	 * Add the widget to the overlay.
+	 * If the widget is after the overlay in the DOM, the overlay is appended after the widget.
+	 * @param widget The widget to add to the overlay
+	 */
+	addWidget (widget: SpineWebComponentSkeleton) {
+		this.widgets.push(widget);
+		this.intersectionObserver?.observe(widget.getHostElement());
+		if (this.loaded) {
+			const comparison = this.compareDocumentPosition(widget);
+			// DOCUMENT_POSITION_DISCONNECTED is needed when a widget is inside the overlay (due to followBone)
+			if ((comparison & Node.DOCUMENT_POSITION_FOLLOWING) && !(comparison & Node.DOCUMENT_POSITION_DISCONNECTED)) {
+				this.parentElement!.appendChild(this);
+			}
+		}
+	}
+
+	/**
+	 * Remove the widget from the overlay.
+	 * @param widget The widget to remove from the overlay
+	 */
+	removeWidget (widget: SpineWebComponentSkeleton) {
+		const index = this.widgets.findIndex(w => w === widget);
+		if (index === -1) return false;
+
+		this.widgets.splice(index);
+		this.intersectionObserver?.unobserve(widget.getHostElement());
+		return true;
+	}
+
+	addSlotFollowerElement (element: HTMLElement) {
+		this.boneFollowersParent.appendChild(element);
+		this.resizedCallback();
+	}
+
+	private tempFollowBoneVector = new Vector3();
+	private startRenderingLoop () {
+		if (this.running) return;
+
+		const updateWidgets = () => {
+			const delta = this.time.delta;
+			for (const { skeleton, state, update, onScreen, offScreenUpdateBehaviour, beforeUpdateWorldTransforms, afterUpdateWorldTransforms } of this.widgets) {
+				if (!skeleton || !state) continue;
+				if (!onScreen && offScreenUpdateBehaviour === "pause") continue;
+				if (update) update(delta, skeleton, state)
+				else {
+					// delta = 0
+					state.update(delta);
+					skeleton.update(delta);
+
+					if (onScreen || (!onScreen && offScreenUpdateBehaviour === "pose")) {
+						state.apply(skeleton);
+						beforeUpdateWorldTransforms(delta, skeleton, state);
+						skeleton.updateWorldTransform(Physics.update);
+						afterUpdateWorldTransforms(delta, skeleton, state);
+					}
+				}
+			}
+
+			// fps top-left span
+			if (SpineWebComponentSkeleton.SHOW_FPS) {
+				if (!this.fpsAppended) {
+					this.div.appendChild(this.fps);
+					this.fpsAppended = true;
+				}
+				this.fps.innerText = this.time.framesPerSecond.toFixed(2) + " fps";
+			} else {
+				if (this.fpsAppended) {
+					this.div.removeChild(this.fps);
+					this.fpsAppended = false;
+				}
+			}
+		};
+
+		const clear = (r: number, g: number, b: number, a: number) => {
+			this.renderer.context.gl.clearColor(r, g, b, a);
+			this.renderer.context.gl.clear(this.renderer.context.gl.COLOR_BUFFER_BIT);
+		}
+
+		const startScissor = (divBounds: Rectangle) => {
+			this.renderer.end();
+			this.renderer.begin();
+			this.renderer.context.gl.enable(this.renderer.context.gl.SCISSOR_TEST);
+			this.renderer.context.gl.scissor(
+				this.screenToWorldLength(divBounds.x),
+				this.canvas.height - this.screenToWorldLength(divBounds.y + divBounds.height),
+				this.screenToWorldLength(divBounds.width),
+				this.screenToWorldLength(divBounds.height)
+			);
+		}
+
+		const endScissor = () => {
+			this.renderer.end();
+			this.renderer.context.gl.disable(this.renderer.context.gl.SCISSOR_TEST);
+			this.renderer.begin();
+		}
+
+		const renderWidgets = () => {
+			clear(0, 0, 0, 0);
+			let renderer = this.renderer;
+			renderer.begin();
+
+			let ref: DOMRect;
+			let offsetLeftForOevrlay = 0;
+			let offsetTopForOverlay = 0;
+			if (this.appendedToBody) {
+				ref = this.parentElement!.getBoundingClientRect();
+				const computedStyle = getComputedStyle(this.parentElement!);
+				offsetLeftForOevrlay = ref.left + parseFloat(computedStyle.borderLeftWidth);
+				offsetTopForOverlay = ref.top + parseFloat(computedStyle.borderTopWidth);
+			}
+
+			const tempVector = new Vector3();
+			for (const widget of this.widgets) {
+				const { skeleton, pma, bounds, mode, debug, offsetX, offsetY, xAxis, yAxis, dragX, dragY, fit, noSpinner, onScreen, loading, clip, isDraggable } = widget;
+
+				if (widget.isOffScreenAndWasMoved()) continue;
+				const elementRef = widget.getHostElement();
+				const divBounds = elementRef.getBoundingClientRect();
+				// need to use left and top, because x and y are not available on older browser
+				divBounds.x = divBounds.left + this.overflowLeftSize;
+				divBounds.y = divBounds.top + this.overflowTopSize;
+
+				if (this.appendedToBody) {
+					divBounds.x -= offsetLeftForOevrlay;
+					divBounds.y -= offsetTopForOverlay;
+				}
+
+				const { padLeft, padRight, padTop, padBottom } = widget
+				const paddingShiftHorizontal = (padLeft - padRight) / 2;
+				const paddingShiftVertical = (padTop - padBottom) / 2;
+
+				// get the desired point into the the div (center by default) in world coordinate
+				const divX = divBounds.x + divBounds.width * ((xAxis + .5) + paddingShiftHorizontal);
+				const divY = divBounds.y + divBounds.height * ((-yAxis + .5) + paddingShiftVertical) - 1;
+				this.screenToWorld(tempVector, divX, divY);
+				let divOriginX = tempVector.x;
+				let divOriginY = tempVector.y;
+
+				const paddingShrinkWidth = 1 - (padLeft + padRight);
+				const paddingShrinkHeight = 1 - (padTop + padBottom);
+				const divWidthWorld = this.screenToWorldLength(divBounds.width * paddingShrinkWidth);
+				const divHeightWorld = this.screenToWorldLength(divBounds.height * paddingShrinkHeight);
+
+				if (clip) startScissor(divBounds);
+
+				if (loading) {
+					if (noSpinner) {
+						if (!widget.loadingScreen) widget.loadingScreen = new LoadingScreen(renderer);
+						widget.loadingScreen!.drawInCoordinates(divOriginX, divOriginY);
+					}
+					if (clip) endScissor();
+					continue;
+				}
+
+				if (skeleton) {
+					if (mode === "inside") {
+						let { x: ax, y: ay, width: aw, height: ah } = bounds;
+						if (aw <= 0 || ah <= 0) continue;
+
+						// scale ratio
+						const scaleWidth = divWidthWorld / aw;
+						const scaleHeight = divHeightWorld / ah;
+
+						// default value is used for fit = none
+						let ratioW = skeleton.scaleX;
+						let ratioH = skeleton.scaleY;
+
+						if (fit === "fill") { // Fill the target box by distorting the source's aspect ratio.
+							ratioW = scaleWidth;
+							ratioH = scaleHeight;
+						} else if (fit === "width") {
+							ratioW = scaleWidth;
+							ratioH = scaleWidth;
+						} else if (fit === "height") {
+							ratioW = scaleHeight;
+							ratioH = scaleHeight;
+						} else if (fit === "contain") {
+							// if scaled height is bigger than div height, use height ratio instead
+							if (ah * scaleWidth > divHeightWorld) {
+								ratioW = scaleHeight;
+								ratioH = scaleHeight;
+							} else {
+								ratioW = scaleWidth;
+								ratioH = scaleWidth;
+							}
+						} else if (fit === "cover") {
+							if (ah * scaleWidth < divHeightWorld) {
+								ratioW = scaleHeight;
+								ratioH = scaleHeight;
+							} else {
+								ratioW = scaleWidth;
+								ratioH = scaleWidth;
+							}
+						} else if (fit === "scaleDown") {
+							if (aw > divWidthWorld || ah > divHeightWorld) {
+								if (ah * scaleWidth > divHeightWorld) {
+									ratioW = scaleHeight;
+									ratioH = scaleHeight;
+								} else {
+									ratioW = scaleWidth;
+									ratioH = scaleWidth;
+								}
+							}
+						}
+
+						// get the center of the bounds
+						const boundsX = (ax + aw / 2) * ratioW;
+						const boundsY = (ay + ah / 2) * ratioH;
+
+						// get vertices offset: calculate the distance between div center and bounds center
+						divOriginX = divOriginX - boundsX;
+						divOriginY = divOriginY - boundsY;
+
+						if (fit !== "none") {
+							// scale the skeleton
+							skeleton.scaleX = ratioW;
+							skeleton.scaleY = ratioH;
+							skeleton.updateWorldTransform(Physics.update);
+						}
+					}
+
+					const worldOffsetX = divOriginX + offsetX + dragX;
+					const worldOffsetY = divOriginY + offsetY + dragY;
+
+					widget.worldX = worldOffsetX;
+					widget.worldY = worldOffsetY;
+
+					renderer.drawSkeleton(skeleton, pma, -1, -1, (vertices, size, vertexSize) => {
+						for (let i = 0; i < size; i += vertexSize) {
+							vertices[i] = vertices[i] + worldOffsetX;
+							vertices[i + 1] = vertices[i + 1] + worldOffsetY;
+						}
+					});
+
+					// drawing debug stuff
+					if (debug) {
+						// if (true) {
+						let { x: ax, y: ay, width: aw, height: ah } = bounds;
+
+						// show bounds and its center
+						if (isDraggable) {
+							renderer.rect(true,
+								ax * skeleton.scaleX + worldOffsetX,
+								ay * skeleton.scaleY + worldOffsetY,
+								aw * skeleton.scaleX,
+								ah * skeleton.scaleY,
+								transparentRed);
+						}
+
+						renderer.rect(false,
+							ax * skeleton.scaleX + worldOffsetX,
+							ay * skeleton.scaleY + worldOffsetY,
+							aw * skeleton.scaleX,
+							ah * skeleton.scaleY,
+							blue);
+						const bbCenterX = (ax + aw / 2) * skeleton.scaleX + worldOffsetX;
+						const bbCenterY = (ay + ah / 2) * skeleton.scaleY + worldOffsetY;
+						renderer.circle(true, bbCenterX, bbCenterY, 10, blue);
+
+						// show skeleton root
+						const root = skeleton.getRootBone()!;
+						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);
+
+						// show line from origin to bounds center
+						renderer.line(originX, originY, bbCenterX, bbCenterY, green);
+					}
+
+					if (clip) endScissor();
+				}
+			}
+
+			renderer.end();
+		}
+
+		const updateBoneFollowers = () => {
+			for (const widget of this.widgets) {
+				if (widget.isOffScreenAndWasMoved() || !widget.skeleton) continue;
+
+				for (const boneFollower of widget.boneFollowerList) {
+					const { slot, bone, element, followAttachmentAttach, followRotation, followOpacity, followScale } = boneFollower;
+					const { worldX, worldY } = widget;
+					this.worldToScreen(this.tempFollowBoneVector, bone.worldX + worldX, bone.worldY + worldY);
+
+					if (Number.isNaN(this.tempFollowBoneVector.x)) continue;
+
+					let x = this.tempFollowBoneVector.x - this.overflowLeftSize;
+					let y = this.tempFollowBoneVector.y - this.overflowTopSize;
+
+					if (!this.appendedToBody) {
+						x += window.scrollX;
+						y += window.scrollY;
+					}
+
+					element.style.transform = `translate(calc(-50% + ${x.toFixed(2)}px),calc(-50% + ${y.toFixed(2)}px))`
+						+ (followRotation ? ` rotate(${-bone.getWorldRotationX()}deg)` : "")
+						+ (followScale ? ` scale(${bone.getWorldScaleX()}, ${bone.getWorldScaleY()})` : "")
+						;
+
+					element.style.display = ""
+
+					if (followAttachmentAttach && !slot.attachment) {
+						element.style.opacity = "0";
+					} else if (followOpacity) {
+						element.style.opacity = `${slot.color.a}`;
+					}
+
+				}
+			}
+		}
+
+		const loop = () => {
+			if (this.disposed || !this.isConnected) {
+				this.running = false;
+				return;
+			};
+			requestAnimationFrame(loop);
+			if (!this.loaded) return;
+			this.time.update();
+			this.translateCanvas();
+			updateWidgets();
+			renderWidgets();
+			updateBoneFollowers();
+		}
+
+		requestAnimationFrame(loop);
+		this.running = true;
+
+		const red = new Color(1, 0, 0, 1);
+		const green = new Color(0, 1, 0, 1);
+		const blue = new Color(0, 0, 1, 1);
+		const transparentWhite = new Color(1, 1, 1, .3);
+		const transparentRed = new Color(1, 0, 0, .3);
+	}
+
+	public cursorCanvasX = 1;
+	public cursorCanvasY = 1;
+	public cursorWorldX = 1;
+	public cursorWorldY = 1;
+
+	private tempVector = new Vector3();
+	private updateCursor (input: Point) {
+		this.cursorCanvasX = input.x - window.scrollX;
+		this.cursorCanvasY = input.y - window.scrollY;
+
+		if (this.appendedToBody) {
+			const ref = this.parentElement!.getBoundingClientRect();
+			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 updateWidgetCursor (widget: SpineWebComponentSkeleton): boolean {
+		if (widget.worldX === Infinity) return false;
+
+		widget.cursorWorldX = this.cursorWorldX - widget.worldX;
+		widget.cursorWorldY = this.cursorWorldY - widget.worldY;
+
+		return true;
+	}
+
+	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 inputPointTemp: Point = new Vector2();
+
+		const getInput = (ev?: MouseEvent | TouchEvent): Point => {
+			const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0];
+			inputPointTemp.x = originalEvent.pageX + this.overflowLeftSize;
+			inputPointTemp.y = originalEvent.pageY + this.overflowTopSize;
+			return inputPointTemp;
+		}
+
+		let lastX = 0;
+		let lastY = 0;
+		inputManager.addListener({
+			// moved is used to pass cursor position wrt to canvas and widget position and currently is EXPERIMENTAL
+			moved: (x, y, ev) => {
+				const input = getInput(ev);
+				this.updateCursor(input);
+
+				for (const widget of this.widgets) {
+					if (!this.updateWidgetCursor(widget) || !widget.onScreen) continue;
+
+					widget.cursorEventUpdate("move", ev);
+				}
+			},
+			down: (x, y, ev) => {
+				const input = getInput(ev);
+
+				this.updateCursor(input);
+
+				for (const widget of this.widgets) {
+					if (!this.updateWidgetCursor(widget) || widget.isOffScreenAndWasMoved()) continue;
+
+					widget.cursorEventUpdate("down", ev);
+
+					if ((widget.isInteractive && widget.cursorInsideBounds) || (!widget.isInteractive && widget.isCursorInsideBounds())) {
+						if (!widget.isDraggable) continue;
+
+						widget.dragging = true;
+						ev?.preventDefault();
+					}
+
+				}
+				lastX = input.x;
+				lastY = input.y;
+			},
+			dragged: (x, y, ev) => {
+				const input = getInput(ev);
+
+				let dragX = input.x - lastX;
+				let dragY = input.y - lastY;
+
+				this.updateCursor(input);
+
+				for (const widget of this.widgets) {
+					if (!this.updateWidgetCursor(widget) || widget.isOffScreenAndWasMoved()) continue;
+
+					widget.cursorEventUpdate("drag", ev);
+
+					if (!widget.dragging) continue;
+
+					const skeleton = widget.skeleton!;
+					widget.dragX += this.screenToWorldLength(dragX);
+					widget.dragY -= this.screenToWorldLength(dragY);
+					skeleton.physicsTranslate(dragX, -dragY);
+					ev?.preventDefault();
+					ev?.stopPropagation();
+				}
+				lastX = input.x;
+				lastY = input.y;
+			},
+			up: (x, y, ev) => {
+				for (const widget of this.widgets) {
+					widget.dragging = false;
+
+					if (widget.cursorInsideBounds) {
+						widget.cursorEventUpdate("up", ev);
+					}
+				}
+			}
+		});
+
+		return inputManager;
+	}
+
+	/*
+	* Resize/scroll utilities
+	*/
+
+	private updateCanvasSize () {
+		const { width, height } = this.getViewportSize();
+
+		// if the target width/height changes, resize the canvas.
+		if (this.lastCanvasBaseWidth !== width || this.lastCanvasBaseHeight !== height) {
+			this.lastCanvasBaseWidth = width;
+			this.lastCanvasBaseHeight = height;
+			this.overflowLeftSize = this.overflowLeft * width;
+			this.overflowTopSize = this.overflowTop * height;
+
+			const totalWidth = width * (1 + (this.overflowLeft + this.overflowRight));
+			const totalHeight = height * (1 + (this.overflowTop + this.overflowBottom));
+
+			this.canvas.style.width = totalWidth + "px";
+			this.canvas.style.height = totalHeight + "px";
+			this.resize(totalWidth, totalHeight);
+		}
+
+		// temporarely remove the div to get the page size without considering the div
+		// this is necessary otherwise if the bigger element in the page is remove and the div
+		// was the second bigger element, now it would be the div to determine the page size
+		// this.div?.remove(); is it better width/height to zero?
+		// this.div!.style.width = 0 + "px";
+		// this.div!.style.height = 0 + "px";
+		this.div!.style.display = "none";
+		if (!this.appendedToBody) {
+			const { width, height } = this.getPageSize();
+			this.div!.style.width = width + "px";
+			this.div!.style.height = height + "px";
+		} else {
+			if (this.hasCssTweakOff()) {
+				// this case lags if scrolls or position fixed
+				// users should never use tweak off, unless the parent container has already a transform
+				this.div!.style.width = this.parentElement!.clientWidth + "px";
+				this.div!.style.height = this.parentElement!.clientHeight + "px";
+				this.canvas.style.transform = `translate(${-this.overflowLeftSize}px,${-this.overflowTopSize}px)`;
+			} else {
+				this.div!.style.width = this.parentElement!.scrollWidth + "px";
+				this.div!.style.height = this.parentElement!.scrollHeight + "px";
+			}
+		}
+		this.div!.style.display = "";
+		// this.root.appendChild(this.div!);
+	}
+
+	private resize (width: number, height: number) {
+		let canvas = this.canvas;
+		canvas.width = Math.round(this.screenToWorldLength(width));
+		canvas.height = Math.round(this.screenToWorldLength(height));
+		this.renderer.context.gl.viewport(0, 0, canvas.width, canvas.height);
+		this.renderer.camera.setViewport(canvas.width, canvas.height);
+		this.renderer.camera.update();
+	}
+
+	// we need the bounding client rect otherwise decimals won't be returned
+	// this means that during zoom it might occurs that the div would be resized
+	// rounded 1px more making a scrollbar appear
+	private getPageSize () {
+		return document.body.getBoundingClientRect();
+	}
+
+	private lastViewportWidth = 0;
+	private lastViewportHeight = 0;
+	private lastDPR = 0;
+	private static readonly WIDTH_INCREMENT = 1.15;
+	private static readonly HEIGHT_INCREMENT = 1.2;
+	private static readonly MAX_CANVAS_WIDTH = 7000;
+	private static readonly MAX_CANVAS_HEIGHT = 7000;
+
+	// determine the target viewport width and height.
+	// The target width/height won't change if the viewport shrink to avoid useless re render (especially re render bursts on mobile)
+	private getViewportSize (): { width: number, height: number } {
+		if (this.appendedToBody) {
+			return {
+				width: this.parentElement!.clientWidth,
+				height: this.parentElement!.clientHeight,
+			}
+		}
+
+		let width = window.innerWidth;
+		let height = window.innerHeight;
+
+		const dpr = this.getDevicePixelRatio();
+		if (dpr !== this.lastDPR) {
+			this.lastDPR = dpr;
+			this.lastViewportWidth = this.lastViewportWidth === 0 ? width : width * SpineWebComponentOverlay.WIDTH_INCREMENT;
+			this.lastViewportHeight = height * SpineWebComponentOverlay.HEIGHT_INCREMENT;
+
+			this.updateWidgetScales();
+		} else {
+			if (width > this.lastViewportWidth) this.lastViewportWidth = width * SpineWebComponentOverlay.WIDTH_INCREMENT;
+			if (height > this.lastViewportHeight) this.lastViewportHeight = height * SpineWebComponentOverlay.HEIGHT_INCREMENT;
+		}
+
+		// if the resulting canvas width/height is too high, scale the DPI
+		if (this.lastViewportHeight * (1 + this.overflowTop + this.overflowBottom) * dpr > SpineWebComponentOverlay.MAX_CANVAS_HEIGHT ||
+			this.lastViewportWidth * (1 + this.overflowLeft + this.overflowRight) * dpr > SpineWebComponentOverlay.MAX_CANVAS_WIDTH) {
+			this.dprScale += .5;
+			return this.getViewportSize();
+		}
+
+		return {
+			width: this.lastViewportWidth,
+			height: this.lastViewportHeight,
+		}
+	}
+
+	/**
+	 * @internal
+	 */
+	public getDevicePixelRatio () {
+		return window.devicePixelRatio / this.dprScale;
+	}
+	private dprScale = 1;
+
+	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;
+
+			const skeleton = widget.skeleton;
+			if (!skeleton) continue;
+
+			// I'm not sure about this. With mode origin and fit none:
+			// case 1) If I comment this scale code, the skeleton is never scaled and will be always at the same size and won't change size while zooming
+			// case 2) Otherwise, the skeleton is loaded always at the same size, but changes size while zooming
+			const scale = this.getDevicePixelRatio();
+			skeleton.scaleX = skeleton.scaleX / widget.dprScale * scale;
+			skeleton.scaleY = skeleton.scaleY / widget.dprScale * scale;
+			widget.dprScale = scale;
+		}
+	}
+
+	private translateCanvas () {
+		let scrollPositionX = -this.overflowLeftSize;
+		let scrollPositionY = -this.overflowTopSize;
+
+		if (!this.appendedToBody) {
+			scrollPositionX += window.scrollX;
+			scrollPositionY += window.scrollY;
+		} else {
+
+			// Ideally this should be the only scrollable case (no-auto-parent-transform not enabled or at least an ancestor has transform)
+			// I'd like to get rid of the code below
+			if (!this.hasCssTweakOff()) {
+				scrollPositionX += this.parentElement!.scrollLeft;
+				scrollPositionY += this.parentElement!.scrollTop;
+			} else {
+				const { left, top } = this.parentElement!.getBoundingClientRect();
+				scrollPositionX += left + window.scrollX;
+				scrollPositionY += top + window.scrollY;
+
+				let offsetParent = this.offsetParent;
+				do {
+					if (offsetParent === document.body) break;
+
+					const htmlOffsetParentElement = offsetParent as HTMLElement;
+					if (htmlOffsetParentElement.style.position === "fixed" || htmlOffsetParentElement.style.position === "sticky" || htmlOffsetParentElement.style.position === "absolute") {
+						const parentRect = htmlOffsetParentElement.getBoundingClientRect();
+						this.div.style.transform = `translate(${left - parentRect.left}px,${top - parentRect.top}px)`;
+						return;
+					}
+
+					offsetParent = htmlOffsetParentElement.offsetParent;
+				} while (offsetParent);
+
+				this.div.style.transform = `translate(${scrollPositionX + this.overflowLeftSize}px,${scrollPositionY + this.overflowTopSize}px)`;
+				return;
+			}
+
+		}
+
+		this.canvas.style.transform = `translate(${scrollPositionX}px,${scrollPositionY}px)`;
+	}
+
+	/*
+	* Other utilities
+	*/
+	public screenToWorld (vec: Vector3, x: number, y: number) {
+		vec.set(x, y, 0);
+		// pay attention that clientWidth/Height rounds the size - if we don't like it, we should use getBoundingClientRect as in getPagSize
+		this.renderer.camera.screenToWorld(vec, this.canvas.clientWidth, this.canvas.clientHeight);
+	}
+	public worldToScreen (vec: Vector3, x: number, y: number) {
+		vec.set(x, -y, 0);
+		// pay attention that clientWidth/Height rounds the size - if we don't like it, we should use getBoundingClientRect as in getPagSize
+		// this.renderer.camera.worldToScreen(vec, this.canvas.clientWidth, this.canvas.clientHeight);
+		this.renderer.camera.worldToScreen(vec, this.worldToScreenLength(this.renderer.camera.viewportWidth), this.worldToScreenLength(this.renderer.camera.viewportHeight));
+	}
+	public screenToWorldLength (length: number) {
+		return length * this.getDevicePixelRatio();
+	}
+	public worldToScreenLength (length: number) {
+		return length / this.getDevicePixelRatio();
+	}
+}
+
+customElements.define("spine-overlay", SpineWebComponentOverlay);

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 22 - 1115
spine-ts/spine-widget/src/SpineWebComponentSkeleton.ts


+ 3 - 2
spine-ts/spine-widget/src/index.ts

@@ -1,3 +1,4 @@
-export * from './SpineWebComponentWidget.js';
+export * from './SpineWebComponentSkeleton.js';
+export * from './SpineWebComponentOverlay.js';
 export * from "@esotericsoftware/spine-core";
-export * from "@esotericsoftware/spine-webgl";
+export * from "@esotericsoftware/spine-webgl";

+ 189 - 0
spine-ts/spine-widget/src/wcUtils.ts

@@ -0,0 +1,189 @@
+/******************************************************************************
+ * Spine Runtimes License Agreement
+ * Last updated July 28, 2023. Replaces all prior versions.
+ *
+ * Copyright (c) 2013-2023, Esoteric Software LLC
+ *
+ * Integration of the Spine Runtimes into software or otherwise creating
+ * derivative works of the Spine Runtimes is permitted under the terms and
+ * conditions of Section 2 of the Spine Editor License Agreement:
+ * http://esotericsoftware.com/spine-editor-license
+ *
+ * Otherwise, it is permitted to integrate the Spine Runtimes into software or
+ * otherwise create derivative works of the Spine Runtimes (collectively,
+ * "Products"), provided that each user of the Products must obtain their own
+ * Spine Editor license and redistribution of the Products in any form must
+ * include this license and copyright notice.
+ *
+ * THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
+ * BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THE
+ * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *****************************************************************************/
+
+import { AnimationsInfo, FitType, ModeType, OffScreenUpdateBehaviourType } from "./SpineWebComponentSkeleton";
+
+const animatonTypeRegExp = /\[([^\]]+)\]/g;
+export type AttributeTypes = "string" | "number" | "boolean" | "array-number" | "array-string" | "object" | "fitType" | "modeType" | "offScreenUpdateBehaviourType" | "animationsInfo";
+
+export function castValue (type: AttributeTypes, value: string | null, defaultValue?: any) {
+	switch (type) {
+		case "string":
+			return castString(value, defaultValue);
+		case "number":
+			return castNumber(value, defaultValue);
+		case "boolean":
+			return castBoolean(value, defaultValue);
+		case "array-number":
+			return castArrayNumber(value, defaultValue);
+		case "array-string":
+			return castArrayString(value, defaultValue);
+		case "object":
+			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":
+			return castToAnimationsInfo(value) || defaultValue;
+		default:
+			break;
+	}
+}
+
+function castBoolean (value: string | null, defaultValue = "") {
+	return value === "true" || value === "" ? true : false;
+}
+
+function castString (value: string | null, defaultValue = "") {
+	return value === null ? defaultValue : value;
+}
+
+function castNumber (value: string | null, defaultValue = 0) {
+	if (value === null) return defaultValue;
+
+	const parsed = parseFloat(value);
+	if (Number.isNaN(parsed)) return defaultValue;
+	return parsed;
+}
+
+function castArrayNumber (value: string | null, defaultValue = undefined) {
+	if (value === null) return defaultValue;
+	return value.split(",").reduce((acc, pageIndex) => {
+		const index = parseInt(pageIndex);
+		if (!isNaN(index)) acc.push(index);
+		return acc;
+	}, [] as Array<number>);
+}
+
+function castArrayString (value: string | null, defaultValue = undefined) {
+	if (value === null) return defaultValue;
+	return value.split(",");
+}
+
+function castObject (value: string | null, defaultValue = undefined) {
+	if (value === null) return null;
+	return JSON.parse(value);
+}
+
+
+function castToAnimationsInfo (value: string | null): AnimationsInfo | undefined {
+	if (value === null) {
+		return undefined;
+	}
+
+	const matches = value.match(animatonTypeRegExp);
+	if (!matches) return undefined;
+
+	return matches.reduce((obj, group) => {
+		const [trackIndexStringOrLoopDefinition, animationNameOrTrackIndexStringCycle, loop, delayString, mixDurationString] = group.slice(1, -1).split(',').map(v => v.trim());
+
+		if (trackIndexStringOrLoopDefinition === "loop") {
+			if (!Number.isInteger(Number(animationNameOrTrackIndexStringCycle))) {
+				throw new Error(`Track index of cycle in ${group} must be a positive integer number, instead it is ${animationNameOrTrackIndexStringCycle}. Original value: ${value}`);
+			}
+			const animationInfoObject = obj[animationNameOrTrackIndexStringCycle] ||= { animations: [] };
+			animationInfoObject.cycle = true;
+			return obj;
+		}
+
+		const trackIndex = Number(trackIndexStringOrLoopDefinition);
+		if (!Number.isInteger(trackIndex)) {
+			throw new Error(`Track index in ${group} must be a positive integer number, instead it is ${trackIndexStringOrLoopDefinition}. Original value: ${value}`);
+		}
+
+		let delay;
+		if (delayString !== undefined) {
+			delay = parseFloat(delayString);
+			if (isNaN(delay)) {
+				throw new Error(`Delay in ${group} must be a positive number, instead it is ${delayString}. Original value: ${value}`);
+			}
+		}
+
+		let mixDuration;
+		if (mixDurationString !== undefined) {
+			mixDuration = parseFloat(mixDurationString);
+			if (isNaN(mixDuration)) {
+				throw new Error(`mixDuration in ${group} must be a positive number, instead it is ${mixDurationString}. Original value: ${value}`);
+			}
+		}
+
+		const animationInfoObject = obj[trackIndexStringOrLoopDefinition] ||= { animations: [] };
+		animationInfoObject.animations.push({
+			animationName: animationNameOrTrackIndexStringCycle,
+			loop: loop.trim().toLowerCase() === "true",
+			delay,
+			mixDuration,
+		});
+		return obj;
+	}, {} as AnimationsInfo);
+}
+
+function isFitType (value: string | null): value is FitType {
+	return (
+		value === "fill" ||
+		value === "width" ||
+		value === "height" ||
+		value === "contain" ||
+		value === "cover" ||
+		value === "none" ||
+		value === "scaleDown"
+	);
+}
+
+function isOffScreenUpdateBehaviourType (value: string | null): value is OffScreenUpdateBehaviourType {
+	return (
+		value === "pause" ||
+		value === "update" ||
+		value === "pose"
+	);
+}
+
+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);
+}
+
+export interface Point {
+	x: number,
+	y: number,
+}
+
+export interface Rectangle extends Point {
+	width: number,
+	height: number,
+}

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.