Explorar el Código

[ts] Added SpineCanvas, a simpler way to render via spine-webgl

Rewrote mix-and-match example as well as barebones example to illustrate usage.
badlogic hace 4 años
padre
commit
91a8b6a100

+ 4 - 3
CHANGELOG.md

@@ -83,14 +83,14 @@
   * `Skin.GetAttachments()` has been replaced by `Skin.Attachments`, returning an `ICollection<SkinEntry>`. This makes access more consistent and intuitive. To fix any compile errors, replace any occurrances of `Skin.GetAttachments()` by `Skin.Attachments`.
   * Removed redundant `Spine.Unity.AttachmentTools.AttachmentCloneExtensions` extension methods `Attachment.GetCopy()` and `Attachment.GetLinkedMesh()`. To fix any compile errors, replace any occurrances with `Attachment.Copy()` and `Attachment.NewLinkedMesh()`.
   * Removed `Spine.Unity.AttachmentTools.AttachmentRegionExtensions` extension methods `Attachment.GetRegion()`. Use `Attachment.RendererObject as AtlasRegion` instead.
-  * Removed redundant `Spine.SkeletonExtensions` extension methods:  
+  * Removed redundant `Spine.SkeletonExtensions` extension methods:
     Replace:
     * `Skeleton.SetPropertyToSetupPose()`
     * `Skeleton.SetDrawOrderToSetupPose()`
     * `Skeleton.SetSlotAttachmentsToSetupPose()`
     * `Skeleton.SetSlotAttachmentToSetupPose()`
 
-    with `Skeleton.SetSlotsToSetupPose()`.  
+    with `Skeleton.SetSlotsToSetupPose()`.
     Replace:
      * `Slot.SetColorToSetupPose()`
      * `Slot.SetAttachmentToSetupPose()`
@@ -108,7 +108,7 @@
   * Reverted changes: `BoneFollower` property `followLocalScale` has intermediately been renamed to `followScale` but was renamed back to `followLocalScale`. Serialized values (scenes and prefabs) will automatically be upgraded, only code accessing `followScale` needs to be adapted.
   * Fixed Timeline not pausing (and resuming) clip playback on Director pause, this is now the default behaviour. If you require the old behaviour (e.g. to continue playing an idle animation during Director pause), there is now an additional parameter `Don't Pause with Director` provided that can be enabled for each Timeline clip.
   * Fixed Timeline `Spine AnimationState Clips` ignoring empty space on the Timeline after a clip's end. Timeline clips now also offer `Don't End with Clip` and `Clip End Mix Out Duration` parameters if you prefer the old behaviour of previous versions. By default when empty space follows the clip on the timeline, the empty animation is set on the track with a MixDuration of `Clip End Mix Out Duration`. Set `Don't End with Clip` to `true` to continue playing the clip's animation instead and mimic the old 3.8 behaviour. If you prefer pausing the animation instead of mixing out to the empty animation, set `Clip End Mix Out Duration` to a value less than 0, then the animation is paused instead.
-  
+
 * **Additions**
   * Additional **Fix Draw Order** parameter at SkeletonRenderer, defaults to `disabled` (previous behaviour).
     Applies only when 3+ submeshes are used (2+ materials with alternating order, e.g. "A B A").
@@ -216,6 +216,7 @@
 * `AssetManager` constructor now takes an option `Downloader` instance. Used to download assets only once and share them between `AssetManager` instances.
 * Added web worker support to `AssetManager`.
 * Added various default parameters to `AnimationState` methods for ease of use.
+* Added `SpineCanvas`, a simpler way to render a scene via spine-webgl. See `spine-ts/spine-webgl/examples/barebones.html` and `spine-ts/spine-webgl/examples/mix-and-match.html`.
 
 ### WebGL backend
 * **Breaking change:** removed `SharedAssetManager`. Use `AssetManager` with a shared `Downloader` instance instead.

+ 1 - 0
spine-ts/index.html

@@ -26,6 +26,7 @@
 		<ul>
 			<li><a href="/spine-webgl/example">Example</a></li>
 			<li><a href="/spine-webgl/example/barebones.html">Barebones</a></li>
+			<li><a href="/spine-webgl/example/mix-and-match.html">Mix &amp; match</a></li>
 			<li><a href="/spine-webgl/demos/additiveblending.html">Additive blending</a></li>
 			<li><a href="/spine-webgl/demos/clipping.html">Clipping</a></li>
 			<li><a href="/spine-webgl/demos/hoverboard.html">Hoverboard</a></li>

+ 30 - 2
spine-ts/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "@esotericsoftware/spine-ts",
-  "version": "4.0.2",
+  "version": "4.0.4",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "@esotericsoftware/spine-ts",
-      "version": "4.0.2",
+      "version": "4.0.4",
       "license": "LicenseRef-LICENSE",
       "workspaces": [
         "spine-core",
@@ -8251,6 +8251,11 @@
         "@esotericsoftware/spine-core": "4.0.2"
       }
     },
+    "spine-canvas/node_modules/@esotericsoftware/spine-core": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@esotericsoftware/spine-core/-/spine-core-4.0.2.tgz",
+      "integrity": "sha512-tkHGEt0NyC/UuHPJtPSmD2YK5D3Z8Yp0OqKhKeH298aRJTIYfsmlgpupgraUqxaacEv9IatIC79pLATtlEsz7A=="
+    },
     "spine-core": {
       "name": "@esotericsoftware/spine-core",
       "version": "4.0.4",
@@ -8270,6 +8275,14 @@
       "resolved": "https://registry.npmjs.org/@esotericsoftware/spine-core/-/spine-core-4.0.2.tgz",
       "integrity": "sha512-tkHGEt0NyC/UuHPJtPSmD2YK5D3Z8Yp0OqKhKeH298aRJTIYfsmlgpupgraUqxaacEv9IatIC79pLATtlEsz7A=="
     },
+    "spine-player/node_modules/@esotericsoftware/spine-webgl": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@esotericsoftware/spine-webgl/-/spine-webgl-4.0.2.tgz",
+      "integrity": "sha512-hSiMZ62g73td5qS5whRb6XtHlTCPYSsVCKHPmrTG4PSxIj0VYWh4ByOZvTcA9mTXZuV8kAZhdqNVc2AXmAe++A==",
+      "dependencies": {
+        "@esotericsoftware/spine-core": "4.0.2"
+      }
+    },
     "spine-threejs": {
       "name": "@esotericsoftware/spine-threejs",
       "version": "4.0.4",
@@ -8330,6 +8343,13 @@
       "version": "file:spine-canvas",
       "requires": {
         "@esotericsoftware/spine-core": "4.0.2"
+      },
+      "dependencies": {
+        "@esotericsoftware/spine-core": {
+          "version": "4.0.2",
+          "resolved": "https://registry.npmjs.org/@esotericsoftware/spine-core/-/spine-core-4.0.2.tgz",
+          "integrity": "sha512-tkHGEt0NyC/UuHPJtPSmD2YK5D3Z8Yp0OqKhKeH298aRJTIYfsmlgpupgraUqxaacEv9IatIC79pLATtlEsz7A=="
+        }
       }
     },
     "@esotericsoftware/spine-core": {
@@ -8346,6 +8366,14 @@
           "version": "4.0.2",
           "resolved": "https://registry.npmjs.org/@esotericsoftware/spine-core/-/spine-core-4.0.2.tgz",
           "integrity": "sha512-tkHGEt0NyC/UuHPJtPSmD2YK5D3Z8Yp0OqKhKeH298aRJTIYfsmlgpupgraUqxaacEv9IatIC79pLATtlEsz7A=="
+        },
+        "@esotericsoftware/spine-webgl": {
+          "version": "4.0.2",
+          "resolved": "https://registry.npmjs.org/@esotericsoftware/spine-webgl/-/spine-webgl-4.0.2.tgz",
+          "integrity": "sha512-hSiMZ62g73td5qS5whRb6XtHlTCPYSsVCKHPmrTG4PSxIj0VYWh4ByOZvTcA9mTXZuV8kAZhdqNVc2AXmAe++A==",
+          "requires": {
+            "@esotericsoftware/spine-core": "4.0.2"
+          }
         }
       }
     },

+ 26 - 26
spine-ts/spine-core/src/AssetManagerBase.ts

@@ -40,36 +40,36 @@ export class AssetManagerBase implements Disposable {
 	private toLoad = 0;
 	private loaded = 0;
 
-	constructor (textureLoader: (image: HTMLImageElement | ImageBitmap) => Texture, pathPrefix: string = "", downloader: Downloader = null) {
+	constructor(textureLoader: (image: HTMLImageElement | ImageBitmap) => Texture, pathPrefix: string = "", downloader: Downloader = null) {
 		this.textureLoader = textureLoader;
 		this.pathPrefix = pathPrefix;
 		this.downloader = downloader || new Downloader();
 	}
 
-	private start (path: string): string {
+	private start(path: string): string {
 		this.toLoad++;
 		return this.pathPrefix + path;
 	}
 
-	private success (callback: (path: string, data: any) => void, path: string, asset: any) {
+	private success(callback: (path: string, data: any) => void, path: string, asset: any) {
 		this.toLoad--;
 		this.loaded++;
 		this.assets[path] = asset;
 		if (callback) callback(path, asset);
 	}
 
-	private error (callback: (path: string, message: string) => void, path: string, message: string) {
+	private error(callback: (path: string, message: string) => void, path: string, message: string) {
 		this.toLoad--;
 		this.loaded++;
 		this.errors[path] = message;
 		if (callback) callback(path, message);
 	}
 
-	setRawDataURI (path: string, data: string) {
+	setRawDataURI(path: string, data: string) {
 		this.downloader.rawDataUris[this.pathPrefix + path] = data;
 	}
 
-	loadBinary (path: string,
+	loadBinary(path: string,
 		success: (path: string, binary: Uint8Array) => void = null,
 		error: (path: string, message: string) => void = null) {
 		path = this.start(path);
@@ -81,7 +81,7 @@ export class AssetManagerBase implements Disposable {
 		});
 	}
 
-	loadText (path: string,
+	loadText(path: string,
 		success: (path: string, text: string) => void = null,
 		error: (path: string, message: string) => void = null) {
 		path = this.start(path);
@@ -93,7 +93,7 @@ export class AssetManagerBase implements Disposable {
 		});
 	}
 
-	loadJson (path: string,
+	loadJson(path: string,
 		success: (path: string, object: object) => void = null,
 		error: (path: string, message: string) => void = null) {
 		path = this.start(path);
@@ -105,7 +105,7 @@ export class AssetManagerBase implements Disposable {
 		});
 	}
 
-	loadTexture (path: string,
+	loadTexture(path: string,
 		success: (path: string, texture: Texture) => void = null,
 		error: (path: string, message: string) => void = null) {
 		path = this.start(path);
@@ -136,7 +136,7 @@ export class AssetManagerBase implements Disposable {
 		}
 	}
 
-	loadTextureAtlas (path: string,
+	loadTextureAtlas(path: string,
 		success: (path: string, atlas: TextureAtlas) => void = null,
 		error: (path: string, message: string) => void = null
 	) {
@@ -170,11 +170,11 @@ export class AssetManagerBase implements Disposable {
 		});
 	}
 
-	get (path: string) {
+	get(path: string) {
 		return this.assets[this.pathPrefix + path];
 	}
 
-	require (path: string) {
+	require(path: string) {
 		path = this.pathPrefix + path;
 		let asset = this.assets[path];
 		if (asset) return asset;
@@ -182,7 +182,7 @@ export class AssetManagerBase implements Disposable {
 		throw Error("Asset not found: " + path + (error ? "\n" + error : ""));
 	}
 
-	remove (path: string) {
+	remove(path: string) {
 		path = this.pathPrefix + path;
 		let asset = this.assets[path];
 		if ((<any>asset).dispose) (<any>asset).dispose();
@@ -190,7 +190,7 @@ export class AssetManagerBase implements Disposable {
 		return asset;
 	}
 
-	removeAll () {
+	removeAll() {
 		for (let key in this.assets) {
 			let asset = this.assets[key];
 			if ((<any>asset).dispose) (<any>asset).dispose();
@@ -198,27 +198,27 @@ export class AssetManagerBase implements Disposable {
 		this.assets = {};
 	}
 
-	isLoadingComplete (): boolean {
+	isLoadingComplete(): boolean {
 		return this.toLoad == 0;
 	}
 
-	getToLoad (): number {
+	getToLoad(): number {
 		return this.toLoad;
 	}
 
-	getLoaded (): number {
+	getLoaded(): number {
 		return this.loaded;
 	}
 
-	dispose () {
+	dispose() {
 		this.removeAll();
 	}
 
-	hasErrors () {
+	hasErrors() {
 		return Object.keys(this.errors).length > 0;
 	}
 
-	getErrors () {
+	getErrors() {
 		return this.errors;
 	}
 }
@@ -227,7 +227,7 @@ export class Downloader {
 	private callbacks: StringMap<Array<Function>> = {};
 	rawDataUris: StringMap<string> = {};
 
-	downloadText (url: string, success: (data: string) => void, error: (status: number, responseText: string) => void) {
+	downloadText(url: string, success: (data: string) => void, error: (status: number, responseText: string) => void) {
 		if (this.rawDataUris[url]) url = this.rawDataUris[url];
 		if (this.start(url, success, error)) return;
 		let request = new XMLHttpRequest();
@@ -241,20 +241,20 @@ export class Downloader {
 		request.send();
 	}
 
-	downloadJson (url: string, success: (data: object) => void, error: (status: number, responseText: string) => void) {
+	downloadJson(url: string, success: (data: object) => void, error: (status: number, responseText: string) => void) {
 		this.downloadText(url, (data: string): void => {
 			success(JSON.parse(data));
 		}, error);
 	}
 
-	downloadBinary (url: string, success: (data: Uint8Array) => void, error: (status: number, responseText: string) => void) {
+	downloadBinary(url: string, success: (data: Uint8Array) => void, error: (status: number, responseText: string) => void) {
 		if (this.rawDataUris[url]) url = this.rawDataUris[url];
 		if (this.start(url, success, error)) return;
 		let request = new XMLHttpRequest();
 		request.open("GET", url, true);
 		request.responseType = "arraybuffer";
 		let onerror = () => {
-			this.finish(url, request.status, request.responseText);
+			this.finish(url, request.status, request.response);
 		};
 		request.onload = () => {
 			if (request.status == 200)
@@ -266,7 +266,7 @@ export class Downloader {
 		request.send();
 	}
 
-	private start (url: string, success: any, error: any) {
+	private start(url: string, success: any, error: any) {
 		let callbacks = this.callbacks[url];
 		try {
 			if (callbacks) return true;
@@ -276,7 +276,7 @@ export class Downloader {
 		}
 	}
 
-	private finish (url: string, status: number, data: any) {
+	private finish(url: string, status: number, data: any) {
 		let callbacks = this.callbacks[url];
 		delete this.callbacks[url];
 		let args = status == 200 ? [data] : [status, data];

+ 50 - 142
spine-ts/spine-webgl/example/barebones.html

@@ -5,166 +5,74 @@
 		margin: 0;
 		padding: 0;
 	}
-
-	body,
-	html {
-		height: 100%
-	}
-
-	canvas {
-		position: absolute;
-		width: 100%;
-		height: 100%;
-	}
 </style>
 
 <body>
-	<canvas id="canvas"></canvas>
+	<canvas id="canvas" style="position: absolute; width: 100%; height: 100%;"></canvas>
 	<script>
-
-		var canvas;
-		var gl;
-		var shader;
-		var batcher;
-		var mvp = new spine.Matrix4();
-		var assetManager;
-		var skeletonRenderer;
-
-		var lastFrameTime;
-		var spineboy;
-
-		function init() {
-			// Setup canvas and WebGL context. We pass alpha: false to canvas.getContext() so we don't use premultiplied alpha when
-			// loading textures. That is handled separately by PolygonBatcher.
-			canvas = document.getElementById("canvas");
-			canvas.width = window.innerWidth;
-			canvas.height = window.innerHeight;
-			var config = { alpha: false };
-			gl = canvas.getContext("webgl", config) || canvas.getContext("experimental-webgl", config);
-			if (!gl) {
-				alert('WebGL is unavailable.');
-				return;
+		class App {
+			skeleton;
+			animationState;
+
+			loadAssets(canvas) {
+				// Load the skeleton file.
+				canvas.assetManager.loadBinary("assets/spineboy-pro.skel");
+				// Load the atlas and its pages.
+				canvas.assetManager.loadTextureAtlas("assets/spineboy-pma.atlas");
 			}
 
-			// Create a simple shader, mesh, model-view-projection matrix, SkeletonRenderer, and AssetManager.
-			shader = spine.Shader.newTwoColoredTextured(gl);
-			batcher = new spine.PolygonBatcher(gl);
-			mvp.ortho2d(0, 0, canvas.width - 1, canvas.height - 1);
-			skeletonRenderer = new spine.SkeletonRenderer(gl);
-			assetManager = new spine.AssetManager(gl);
+			initialize(canvas) {
+				let assetManager = canvas.assetManager;
 
-			// Tell AssetManager to load the resources for each skeleton, including the exported .skel file, the .atlas file and the .png
-			// file for the atlas. We then wait until all resources are loaded in the load() method.
-			assetManager.loadBinary("assets/spineboy-pro.skel");
-			assetManager.loadTextureAtlas("assets/spineboy-pma.atlas");
-			requestAnimationFrame(load);
-		}
-
-		function load() {
-			// Wait until the AssetManager has loaded all resources, then load the skeletons.
-			if (assetManager.isLoadingComplete()) {
-				spineboy = loadSpineboy("run", true);
-				lastFrameTime = Date.now() / 1000;
-				requestAnimationFrame(render); // Loading is done, call render every frame.
-			} else {
-				requestAnimationFrame(load);
-			}
-		}
+				// Create the texture atlas.
+				var atlas = assetManager.require("assets/spineboy-pma.atlas");
 
-		function loadSpineboy(initialAnimation, premultipliedAlpha) {
-			// Load the texture atlas from the AssetManager.
-			var atlas = assetManager.require("assets/spineboy-pma.atlas");
+				// Create a AtlasAttachmentLoader that resolves region, mesh, boundingbox and path attachments
+				var atlasLoader = new spine.AtlasAttachmentLoader(atlas);
 
-			// Create a AtlasAttachmentLoader that resolves region, mesh, boundingbox and path attachments
-			var atlasLoader = new spine.AtlasAttachmentLoader(atlas);
+				// Create a SkeletonBinary instance for parsing the .skel file.
+				var skeletonBinary = new spine.SkeletonBinary(atlasLoader);
 
-			// Create a SkeletonBinary instance for parsing the .skel file.
-			var skeletonBinary = new spine.SkeletonBinary(atlasLoader);
+				// Set the scale to apply during parsing, parse the file, and create a new skeleton.
+				skeletonBinary.scale = 1;
+				var skeletonData = skeletonBinary.readSkeletonData(assetManager.require("assets/spineboy-pro.skel"));
+				this.skeleton = new spine.Skeleton(skeletonData);
 
-			// Set the scale to apply during parsing, parse the file, and create a new skeleton.
-			skeletonBinary.scale = 1;
-			var skeletonData = skeletonBinary.readSkeletonData(assetManager.require("assets/spineboy-pro.skel"));
-			var skeleton = new spine.Skeleton(skeletonData);
-			var bounds = calculateSetupPoseBounds(skeleton);
-
-			// Create an AnimationState, and set the initial animation in looping mode.
-			var animationStateData = new spine.AnimationStateData(skeleton.data);
-			var animationState = new spine.AnimationState(animationStateData);
-			animationState.setAnimation(0, initialAnimation, true);
-
-			// Pack everything up and return to caller.
-			return { skeleton: skeleton, state: animationState, bounds: bounds, premultipliedAlpha: premultipliedAlpha };
-		}
-
-		function calculateSetupPoseBounds(skeleton) {
-			skeleton.setToSetupPose();
-			skeleton.updateWorldTransform();
-			var offset = new spine.Vector2();
-			var size = new spine.Vector2();
-			skeleton.getBounds(offset, size, []);
-			return { offset: offset, size: size };
-		}
-
-		function render() {
-			var now = Date.now() / 1000;
-			var delta = now - lastFrameTime;
-			lastFrameTime = now;
-
-			// Update the MVP matrix to adjust for canvas size changes
-			resize();
-
-			gl.clearColor(0.3, 0.3, 0.3, 1);
-			gl.clear(gl.COLOR_BUFFER_BIT);
-
-			// Apply the animation state based on the delta time.
-			var skeleton = spineboy.skeleton;
-			var state = spineboy.state;
-			var premultipliedAlpha = spineboy.premultipliedAlpha;
-			state.update(delta);
-			state.apply(skeleton);
-			skeleton.updateWorldTransform();
-
-			// Bind the shader and set the texture and model-view-projection matrix.
-			shader.bind();
-			shader.setUniformi(spine.Shader.SAMPLER, 0);
-			shader.setUniform4x4f(spine.Shader.MVP_MATRIX, mvp.values);
+				// Create an AnimationState, and set the "run" animation in looping mode.
+				var animationStateData = new spine.AnimationStateData(skeletonData);
+				this.animationState = new spine.AnimationState(animationStateData);
+				this.animationState.setAnimation(0, "run", true);
+			}
 
-			// Start the batch and tell the SkeletonRenderer to render the active skeleton.
-			batcher.begin(shader);
-			skeletonRenderer.premultipliedAlpha = premultipliedAlpha;
-			skeletonRenderer.draw(batcher, skeleton);
-			batcher.end();
+			update(canvas, delta) {
+				// Update the animation state using the delta time.
+				this.animationState.update(delta);
+				// Apply the animation state to the skeleton.
+				this.animationState.apply(this.skeleton);
+				// Let the skeleton update the transforms of its bones.
+				this.skeleton.updateWorldTransform();
+			}
 
-			shader.unbind();
+			render(canvas) {
+				let renderer = canvas.renderer;
+				// Resize the viewport to the full canvas.
+				renderer.resize(spine.ResizeMode.Expand);
 
-			requestAnimationFrame(render);
-		}
+				// Clear the canvas with a light gray color.
+				canvas.clear(0.2, 0.2, 0.2, 1);
 
-		function resize() {
-			var w = canvas.clientWidth;
-			var h = canvas.clientHeight;
-			if (canvas.width != w || canvas.height != h) {
-				canvas.width = w;
-				canvas.height = h;
+				// Begin rendering.
+				renderer.begin();
+				// Draw the skeleton
+				renderer.drawSkeleton(this.skeleton, true);
+				// Complete rendering.
+				renderer.end();
 			}
-
-			// Calculations to center the skeleton in the canvas.
-			var bounds = spineboy.bounds;
-			var centerX = bounds.offset.x + bounds.size.x / 2;
-			var centerY = bounds.offset.y + bounds.size.y / 2;
-			var scaleX = bounds.size.x / canvas.width;
-			var scaleY = bounds.size.y / canvas.height;
-			var scale = Math.max(scaleX, scaleY) * 1.2;
-			if (scale < 1) scale = 1;
-			var width = canvas.width * scale;
-			var height = canvas.height * scale;
-
-			mvp.ortho2d(centerX - width / 2, centerY - height / 2, width, height);
-			gl.viewport(0, 0, canvas.width, canvas.height);
 		}
 
-		init();
-
+		new spine.SpineCanvas(document.getElementById("canvas"), {
+			app: new App()
+		})
 	</script>
 </body>
 

+ 84 - 0
spine-ts/spine-webgl/example/mix-and-match.html

@@ -0,0 +1,84 @@
+<html>
+<script src="../dist/iife/spine-webgl.js"></script>
+<style>
+	html,
+	body {
+		margin: 0;
+		padding: 0;
+	}
+</style>
+
+<body>
+	<canvas id="canvas" style="position: absolute; width: 100%; height: 100%;"></canvas>
+</body>
+<script>
+	// Define the class running in the Spine canvas
+	class App {
+		skeleton;
+		state;
+
+		loadAssets(canvas) {
+			canvas.assetManager.AnimationState
+			canvas.assetManager.loadTextureAtlas("mix-and-match-pma.atlas");
+			canvas.assetManager.loadBinary("mix-and-match-pro.skel");
+		}
+
+		initialize(canvas) {
+			let assetManager = canvas.assetManager;
+
+			// Create the atlas
+			let atlas = canvas.assetManager.get("mix-and-match-pma.atlas");
+			let atlasLoader = new spine.AtlasAttachmentLoader(atlas);
+
+			// Create the skeleton
+			let skeletonBinary = new spine.SkeletonBinary(atlasLoader);
+			skeletonBinary.scale = 0.5;
+			let skeletonData = skeletonBinary.readSkeletonData(assetManager.get("mix-and-match-pro.skel"));
+			this.skeleton = new spine.Skeleton(skeletonData);
+
+			// Create the animation state
+			let stateData = new spine.AnimationStateData(skeletonData);
+			this.state = new spine.AnimationState(stateData);
+			this.state.setAnimation(0, "dance", true);
+
+			// Create a new skin, by mixing and matching other skins
+			// that fit together. Items making up the girl are individual
+			// skins. Using the skin API, a new skin is created which is
+			// a combination of all these individual item skins.
+			let mixAndMatchSkin = new spine.Skin("custom-girl");
+			mixAndMatchSkin.addSkin(skeletonData.findSkin("skin-base"));
+			mixAndMatchSkin.addSkin(skeletonData.findSkin("nose/short"));
+			mixAndMatchSkin.addSkin(skeletonData.findSkin("eyelids/girly"));
+			mixAndMatchSkin.addSkin(skeletonData.findSkin("eyes/violet"));
+			mixAndMatchSkin.addSkin(skeletonData.findSkin("hair/brown"));
+			mixAndMatchSkin.addSkin(skeletonData.findSkin("clothes/hoodie-orange"));
+			mixAndMatchSkin.addSkin(skeletonData.findSkin("legs/pants-jeans"));
+			mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/bag"));
+			mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/hat-red-yellow"));
+			this.skeleton.setSkin(mixAndMatchSkin);
+		}
+
+		update(canvas, delta) {
+			this.state.update(delta);
+			this.state.apply(this.skeleton);
+			this.skeleton.updateWorldTransform();
+		}
+
+		render(canvas) {
+			let renderer = canvas.renderer;
+			renderer.resize(spine.ResizeMode.Expand);
+			canvas.clear(0.2, 0.2, 0.2, 1);
+			renderer.begin();
+			renderer.drawSkeleton(this.skeleton, true);
+			renderer.end();
+		}
+	}
+
+	// Create the Spine canvas which runs the app
+	new spine.SpineCanvas(document.getElementById("canvas"), {
+		pathPrefix: "assets/",
+		app: new App()
+	});
+</script>
+
+</html>

+ 121 - 0
spine-ts/spine-webgl/src/SpineCanvas.ts

@@ -0,0 +1,121 @@
+/******************************************************************************
+ * Spine Runtimes License Agreement
+ * Last updated January 1, 2020. Replaces all prior versions.
+ *
+ * Copyright (c) 2013-2020, 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 { TimeKeeper, AssetManager, ManagedWebGLRenderingContext, SceneRenderer, Input, StringMap } from "./";
+
+/** An app running inside a {@link SpineCanvas}. The app life cycle
+ * is as follows:
+ *
+ * 1. `loadAssets()` is called. The app can queue assets for loading via {@link SpineCanvas#assetManager}.
+ * 2. `initialize()` is called when all assets are loaded. The app can setup anything it needs to enter the main application logic.
+ * 3. `update()` is called periodically at screen refresh rate. The app can update its state.
+ * 4. `render()` is called periodically at screen refresh rate. The app can render its state via {@link SpineCanvas#renderer} or directly via the WebGL context in {@link SpineCanvas.gl}`
+ */
+export interface SpineCanvasApp {
+    loadAssets?(canvas: SpineCanvas): void;
+    initialize?(canvas: SpineCanvas): void;
+    update?(canvas: SpineCanvas, delta: number): void;
+    render?(canvas: SpineCanvas): void;
+    error?(canvas: SpineCanvas, errors: StringMap<string>): void;
+}
+
+/** Configuration passed to the {@link SpineCanvas} constructor */
+export interface SpineCanvasConfig {
+    /* The {@link SpineCanvasApp} to be run in the canvas. */
+    app: SpineCanvasApp;
+    /* The path prefix to be used by the {@link AssetManager}. */
+    pathPrefix: string;
+}
+
+/** Manages the life-cycle and WebGL context of a {@link SpineCanvasApp}. The app loads
+ * assets and initializes itself, then updates and renders its state at the screen refresh rate. */
+export class SpineCanvas {
+    readonly context: ManagedWebGLRenderingContext;
+
+    /** Tracks the current time, delta, and other time related statistics. */
+    readonly time = new TimeKeeper();
+    /** The HTML canvas to render to. */
+    readonly htmlCanvas: HTMLCanvasElement;
+    /** The WebGL rendering context. */
+    readonly gl: WebGLRenderingContext;
+    /** The scene renderer for easy drawing of skeletons, shapes, and images. */
+    readonly renderer: SceneRenderer;
+    /** The asset manager to load assets with. */
+    readonly assetManager: AssetManager;
+    /** The input processor used to listen to mouse, touch, and keyboard events. */
+    readonly input: Input;
+
+    /** Constructs a new spine canvas, rendering to the provided HTML canvas. */
+    constructor(canvas: HTMLCanvasElement, config: SpineCanvasConfig) {
+        if (config.pathPrefix === undefined) config.pathPrefix = "";
+        if (config.app === undefined) config.app = {
+            loadAssets: () => { },
+            initialize: () => { },
+            update: () => { },
+            render: () => { },
+            error: () => { },
+        }
+
+        this.htmlCanvas = canvas;
+        this.context = new ManagedWebGLRenderingContext(canvas, { alpha: true });
+        this.renderer = new SceneRenderer(canvas, this.context);
+        this.gl = this.context.gl;
+        this.assetManager = new AssetManager(this.context, config.pathPrefix);
+        this.input = new Input(canvas);
+
+        config.app.loadAssets?.(this);
+
+        let loop = () => {
+            requestAnimationFrame(loop);
+            this.time.update();
+            config.app.update?.(this, this.time.delta);
+            config.app.render?.(this);
+        }
+
+        let waitForAssets = () => {
+            if (this.assetManager.isLoadingComplete()) {
+                if (this.assetManager.hasErrors()) {
+                    config.app.error?.(this, this.assetManager.getErrors());
+                } else {
+                    config.app.initialize?.(this);
+                    loop();
+                }
+                return;
+            }
+            requestAnimationFrame(waitForAssets);
+        }
+        requestAnimationFrame(waitForAssets);
+    }
+
+    /** Clears the canvas with the given color. The color values are given in the range [0,1]. */
+    clear(r: number, g: number, b: number, a: number) {
+        this.gl.clearColor(r, g, b, a);
+        this.gl.clear(this.gl.COLOR_BUFFER_BIT);
+    }
+}

+ 7 - 7
spine-ts/spine-webgl/src/WebGL.ts

@@ -34,7 +34,7 @@ export class ManagedWebGLRenderingContext {
 	public gl: WebGLRenderingContext;
 	private restorables = new Array<Restorable>();
 
-	constructor (canvasOrContext: HTMLCanvasElement | WebGLRenderingContext | EventTarget, contextConfig: any = { alpha: "true" }) {
+	constructor(canvasOrContext: HTMLCanvasElement | WebGLRenderingContext | EventTarget, contextConfig: any = { alpha: "true" }) {
 		if (!((canvasOrContext instanceof WebGLRenderingContext) || (typeof WebGL2RenderingContext !== 'undefined' && canvasOrContext instanceof WebGL2RenderingContext)))
 			this.setupCanvas(canvasOrContext, contextConfig);
 		else {
@@ -43,7 +43,7 @@ export class ManagedWebGLRenderingContext {
 		}
 	}
 
-	private setupCanvas (canvas: any, contextConfig: any) {
+	private setupCanvas(canvas: any, contextConfig: any) {
 		this.gl = <WebGLRenderingContext>(canvas.getContext("webgl2", contextConfig) || canvas.getContext("webgl", contextConfig));
 		this.canvas = canvas;
 		canvas.addEventListener("webglcontextlost", (e: any) => {
@@ -57,11 +57,11 @@ export class ManagedWebGLRenderingContext {
 		});
 	}
 
-	addRestorable (restorable: Restorable) {
+	addRestorable(restorable: Restorable) {
 		this.restorables.push(restorable);
 	}
 
-	removeRestorable (restorable: Restorable) {
+	removeRestorable(restorable: Restorable) {
 		let index = this.restorables.indexOf(restorable);
 		if (index > -1) this.restorables.splice(index, 1);
 	}
@@ -75,7 +75,7 @@ const ONE_MINUS_DST_ALPHA = 0x0305;
 const DST_COLOR = 0x0306;
 
 export class WebGLBlendModeConverter {
-	static getDestGLBlendMode (blendMode: BlendMode) {
+	static getDestGLBlendMode(blendMode: BlendMode) {
 		switch (blendMode) {
 			case BlendMode.Normal: return ONE_MINUS_SRC_ALPHA;
 			case BlendMode.Additive: return ONE;
@@ -85,7 +85,7 @@ export class WebGLBlendModeConverter {
 		}
 	}
 
-	static getSourceColorGLBlendMode (blendMode: BlendMode, premultipliedAlpha: boolean = false) {
+	static getSourceColorGLBlendMode(blendMode: BlendMode, premultipliedAlpha: boolean = false) {
 		switch (blendMode) {
 			case BlendMode.Normal: return premultipliedAlpha ? ONE : SRC_ALPHA;
 			case BlendMode.Additive: return premultipliedAlpha ? ONE : SRC_ALPHA;
@@ -95,7 +95,7 @@ export class WebGLBlendModeConverter {
 		}
 	}
 
-	static getSourceAlphaGLBlendMode (blendMode: BlendMode) {
+	static getSourceAlphaGLBlendMode(blendMode: BlendMode) {
 		switch (blendMode) {
 			case BlendMode.Normal: return ONE;
 			case BlendMode.Additive: return ONE;

+ 16 - 15
spine-ts/spine-webgl/src/index.ts

@@ -1,16 +1,17 @@
-export * from './AssetManager';
-export * from './Camera';
-export * from './GLTexture';
-export * from './Input';
-export * from './LoadingScreen';
-export * from './Matrix4';
-export * from './Mesh';
-export * from './PolygonBatcher';
-export * from './SceneRenderer';
-export * from './Shader';
-export * from './ShapeRenderer';
-export * from './SkeletonDebugRenderer';
-export * from './SkeletonRenderer';
-export * from './Vector3';
-export * from './WebGL';
+export * from "./AssetManager";
+export * from "./Camera";
+export * from "./GLTexture";
+export * from "./Input";
+export * from "./LoadingScreen";
+export * from "./Matrix4";
+export * from "./Mesh";
+export * from "./PolygonBatcher";
+export * from "./SceneRenderer";
+export * from "./Shader";
+export * from "./ShapeRenderer";
+export * from "./SkeletonDebugRenderer";
+export * from "./SkeletonRenderer";
+export * from "./SpineCanvas";
+export * from "./Vector3";
+export * from "./WebGL";
 export * from "@esotericsoftware/spine-core";

+ 0 - 132
spine-ts/spine-webgl/tests/test-mix-and-match.html

@@ -1,132 +0,0 @@
-<html>
-<script src="../dist/iife/spine-webgl.js"></script>
-<script src="https://code.jquery.com/jquery-3.1.0.min.js"></script>
-<style>
-	* {
-		margin: 0;
-		padding: 0;
-	}
-
-	body,
-	html {
-		height: 100%
-	}
-
-	canvas {
-		position: absolute;
-		width: 100%;
-		height: 100%;
-	}
-</style>
-
-<body>
-	<div id="label" style="position: absolute; top: 0; left: 0; color: #fff; z-index: 10"></div>
-	<canvas id="canvas" style="background: red;"></canvas>
-</body>
-<script>
-
-	var FILE = "mix-and-match-pro";
-	var ANIMATION = "dance";
-	var SCALE = 0.5;
-
-	var canvas, context, gl, renderer, input, assetManager;
-	var skeletons = [];
-	var timeKeeper;
-	var label = document.getElementById("label");
-	var updateMean = new spine.WindowedMean();
-	var renderMean = new spine.WindowedMean();
-
-	function init() {
-		canvas = document.getElementById("canvas");
-		canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight;
-		context = new spine.ManagedWebGLRenderingContext(canvas, { alpha: false });
-		gl = context.gl;
-
-		renderer = new spine.SceneRenderer(canvas, context);
-		assetManager = new spine.AssetManager(context, "../example/assets/");
-		input = new spine.Input(canvas);
-
-		assetManager.loadTextureAtlas(FILE.replace("-pro", "").replace("-ess", "") + "-pma.atlas");
-		assetManager.loadBinary(FILE + ".skel");
-
-		timeKeeper = new spine.TimeKeeper();
-		requestAnimationFrame(load);
-	}
-
-	var run = true;
-
-	function load() {
-		timeKeeper.update();
-		if (assetManager.isLoadingComplete()) {
-			var atlas = assetManager.get(FILE.replace("-pro", "").replace("-ess", "") + "-pma.atlas");
-			var atlasLoader = new spine.AtlasAttachmentLoader(atlas);
-			var skeletonBinary = new spine.SkeletonBinary(atlasLoader);
-			skeletonBinary.scale = SCALE;
-			var skeletonData = skeletonBinary.readSkeletonData(assetManager.get(FILE + ".skel"));
-
-			skeleton = new spine.Skeleton(skeletonData);
-			var stateData = new spine.AnimationStateData(skeleton.data);
-			state = new spine.AnimationState(stateData);
-			stateData.defaultMix = 0;
-
-			// Create a new skin, by mixing and matching other skins
-			// that fit together. Items making up the girl are individual
-			// skins. Using the skin API, a new skin is created which is
-			// a combination of all these individual item skins.
-			var mixAndMatchSkin = new spine.Skin("custom-girl");
-			mixAndMatchSkin.addSkin(skeletonData.findSkin("skin-base"));
-			mixAndMatchSkin.addSkin(skeletonData.findSkin("nose/short"));
-			mixAndMatchSkin.addSkin(skeletonData.findSkin("eyelids/girly"));
-			mixAndMatchSkin.addSkin(skeletonData.findSkin("eyes/violet"));
-			mixAndMatchSkin.addSkin(skeletonData.findSkin("hair/brown"));
-			mixAndMatchSkin.addSkin(skeletonData.findSkin("clothes/hoodie-orange"));
-			mixAndMatchSkin.addSkin(skeletonData.findSkin("legs/pants-jeans"));
-			mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/bag"));
-			mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/hat-red-yellow"));
-			skeleton.setSkin(mixAndMatchSkin);
-
-			state.setAnimation(0, ANIMATION, true);
-			skeletons.push({ skeleton: skeleton, state: state });
-
-			requestAnimationFrame(render);
-		} else {
-			requestAnimationFrame(load);
-		}
-	}
-
-	function render() {
-		var start = Date.now()
-		timeKeeper.update();
-		var delta = timeKeeper.delta;
-
-		for (var i = 0; i < skeletons.length; i++) {
-			var state = skeletons[i].state;
-			var skeleton = skeletons[i].skeleton;
-			state.update(delta);
-			state.apply(skeleton);
-			skeleton.updateWorldTransform();
-		}
-		updateMean.addValue(Date.now() - start);
-		start = Date.now();
-
-		gl.clearColor(0.2, 0.2, 0.2, 1);
-		gl.clear(gl.COLOR_BUFFER_BIT);
-
-		renderer.resize(spine.ResizeMode.Fit);
-		renderer.begin();
-		for (var i = 0; i < skeletons.length; i++) {
-			var skeleton = skeletons[i].skeleton;
-			renderer.drawSkeleton(skeleton, true);
-		}
-		renderer.end();
-
-		requestAnimationFrame(render)
-		renderMean.addValue(Date.now() - start);
-		label.innerHTML = ("Update time: " + Number(updateMean.getMean()).toFixed(2) + " ms\n" +
-			"Render time: " + Number(renderMean.getMean()).toFixed(2) + " ms\n");
-	}
-
-	init();
-</script>
-
-</html>