[ts] Added CameraController, drag & drop example

This commit is contained in:
badlogic 2021-09-18 01:58:14 +02:00
parent 7f5e7c3bfc
commit cda5c0f052
8 changed files with 417 additions and 88 deletions

View File

@ -10,6 +10,13 @@
"name": "Examples",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
},
{
"type": "pwa-chrome",
"request": "launch",
"name": "drag-and-drop",
"url": "http://localhost:8080/spine-webgl/example/drag-and-drop.html",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@ -27,6 +27,7 @@
<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/example/drag-and-drop.html">Drag &amp; drop</a></li>
<li><a href="/spine-webgl/example/dress-up.html">Dress-up</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>

View File

@ -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> = {};
dataUriToString (dataUri: string) {
dataUriToString(dataUri: string) {
if (!dataUri.startsWith("data:")) {
throw new Error("Not a data URI.");
}
@ -241,17 +241,17 @@ export class Downloader {
}
}
base64ToArrayBuffer (base64: string) {
base64ToUint8Array(base64: string) {
var binary_string = window.atob(base64);
var len = binary_string.length;
var bytes = new Uint8Array(len);
for (var i = 0; i < len; i++) {
bytes[i] = binary_string.charCodeAt(i);
}
return bytes.buffer;
return bytes;
}
dataUriToUint8Array (dataUri: string) {
dataUriToUint8Array(dataUri: string) {
if (!dataUri.startsWith("data:")) {
throw new Error("Not a data URI.");
}
@ -259,10 +259,10 @@ export class Downloader {
let base64Idx = dataUri.indexOf("base64,");
if (base64Idx == -1) throw new Error("Not a binary data URI.");
base64Idx += "base64,".length;
return this.base64ToArrayBuffer(dataUri.substr(base64Idx));
return this.base64ToUint8Array(dataUri.substr(base64Idx));
}
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.start(url, success, error)) return;
if (this.rawDataUris[url]) {
try {
@ -284,13 +284,13 @@ 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.start(url, success, error)) return;
if (this.rawDataUris[url]) {
try {
@ -317,7 +317,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;
@ -327,7 +327,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];

View File

@ -0,0 +1,19 @@
<html>
<script src="../dist/iife/spine-webgl.js"></script>
<style>
* {
margin: 0;
padding: 0;
}
</style>
<body>
<canvas id="canvas" style="position: absolute; width: 100%; height: 100%;"></canvas>
<div style="position: absolute; top: 1em; left: 1em; z-index: 1; color: #ccc;">
<label style="margin-right: 0.5em;">Animations</label>
<select id="animations"></select>
</div>
<script src="drag-and-drop.js"></script>
</body>
</html>

View File

@ -0,0 +1,220 @@
class App {
constructor() {
this.skeleton = null;
this.animationState = null;
this.canvas = null;
}
loadAssets(canvas) {
this.canvas = canvas;
// Load assets of Spineboy.
canvas.assetManager.loadBinary("assets/spineboy-pro.skel");
canvas.assetManager.loadTextureAtlas("assets/spineboy-pma.atlas");
}
initialize(canvas) {
// Load the Spineboy skeleton
this.loadSkeleton("assets/spineboy-pro.skel", "assets/spineboy-pma.atlas", "run");
// Setup listener for animation selection box
let animationSelectBox = document.body.querySelector("#animations");
animationSelectBox.onchange = () => {
this.animationState.setAnimation(0, animationSelectBox.value, true);
}
// Setup the drag and drop listener
new FileDragAndDrop(canvas.htmlCanvas, (files) => this.onDrop(files))
// Setup a camera controller for paning and zooming
new spine.CameraController(canvas.htmlCanvas, canvas.renderer.camera);
}
onDrop(files) {
let atlasFile;
let skeletonFile;
let pngs = [];
let assetManager = this.canvas.assetManager;
// We use data URIs to load the dropped files. Some file types
// are binary, so we have to encode them to base64 for loading
// through AssetManager.
let bufferToBase64 = (buffer) => {
var binary = '';
var bytes = new Uint8Array(buffer);
var len = bytes.byteLength;
for (var i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
for (var file of files) {
if (file.name.endsWith(".atlas") || file.name.endsWith(".atlas.txt")) {
atlasFile = file;
assetManager.setRawDataURI(file.name, "data:text/plain;," + file.contentText);
} else if (file.name.endsWith(".skel")) {
skeletonFile = file;
assetManager.setRawDataURI(file.name, "data:application/octet-stream;base64," + bufferToBase64(file.contentBinary));
assetManager.loadBinary(file.name);
} else if (file.name.endsWith(".json")) {
skeletonFile = file;
assetManager.setRawDataURI(file.name, "data:text/plain;," + file.contentText);
assetManager.loadJson(file.name);
} else if (file.name.endsWith(".png")) {
pngs.push(file);
assetManager.setRawDataURI(file.name, "data:image/png;base64," + bufferToBase64(file.contentBinary));
}
}
if (!atlasFile) {
alert("Please provide a .atlas or .atlas.txt atlas file.");
return;
}
if (pngs.length == 0) {
alert("Please provide the atlas page .png file(s).");
}
if (!skeletonFile) {
alert("Please provide a .skel or .json skeleton file.");
return;
}
assetManager.loadTextureAtlas(atlasFile.name);
let waitForLoad = () => {
if (this.canvas.assetManager.isLoadingComplete()) {
this.loadSkeleton(skeletonFile.name, atlasFile.name);
} else {
requestAnimationFrame(waitForLoad);
}
}
waitForLoad();
}
loadSkeleton(skeletonFile, atlasFile, animationName) {
// Load the skeleton and setup the animation state
let assetManager = this.canvas.assetManager;
var atlas = assetManager.require(atlasFile);
var atlasLoader = new spine.AtlasAttachmentLoader(atlas);
var skeletonData;
var skeletonBinaryOrJson = skeletonFile.endsWith(".skel") ?
new spine.SkeletonBinary(atlasLoader) :
new spine.SkeletonJson(atlasLoader);
skeletonBinaryOrJson.scale = 1;
skeletonData = skeletonBinaryOrJson.readSkeletonData(assetManager.require(skeletonFile));
this.skeleton = new spine.Skeleton(skeletonData);
var animationStateData = new spine.AnimationStateData(skeletonData);
this.animationState = new spine.AnimationState(animationStateData);
// Fill the animation selection box.
let animationSelectBox = document.body.querySelector("#animations");
animationSelectBox.innerHTML = "";
for (var animation of this.skeleton.data.animations) {
if (!animationName) animationName = animation.name;
let option = document.createElement("option");
option.value = option.innerText = animation.name;
option.selected = animation.name == animationName;
animationSelectBox.appendChild(option);
}
if (animationName) this.animationState.setAnimation(0, animationName, true);
// Center the skeleton in the viewport
this.centerSkeleton();
}
centerSkeleton() {
// Calculate the bounds of the skeleton
this.animationState.update(0);
this.animationState.apply(this.skeleton);
this.skeleton.updateWorldTransform();
let offset = new spine.Vector2(), size = new spine.Vector2();
this.skeleton.getBounds(offset, size);
// Make sure the canvas is sized properly and position and zoom
// the camera so the skeleton is centered in the viewport.
let renderer = this.canvas.renderer;
renderer.resize(spine.ResizeMode.Expand);
let camera = this.canvas.renderer.camera;
camera.position.x = offset.x + size.x / 2;
camera.position.y = offset.y + size.y / 2;
camera.zoom = size.x > size.y ? size.x / this.canvas.htmlCanvas.width * 3 : size.y / this.canvas.htmlCanvas.height * 3;
camera.update();
}
update(canvas, delta) {
this.animationState.update(delta);
this.animationState.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.line(-10000, 0, 10000, 0, spine.Color.RED);
renderer.line(0, -10000, 0, 10000, spine.Color.GREEN);
renderer.drawSkeleton(this.skeleton, true);
renderer.end();
}
}
new spine.SpineCanvas(document.getElementById("canvas"), {
app: new App()
});
class FileDragAndDrop {
constructor(element, callback) {
this.callback = callback;
element.ondrop = (ev) => this.onDrop(ev);
element.ondragover = (ev) => ev.preventDefault();
}
async onDrop(event) {
event.preventDefault();
event.stopPropagation();
const items = Object.keys(event.dataTransfer.items);
let files = [];
await Promise.all(items.map(async (key) => {
var file = event.dataTransfer.items[key].getAsFile();
if (file.kind == "string") return;
let contentBinary = await file.arrayBuffer();
let contentText = await file.text();
files.push({ name: file.name, contentBinary: contentBinary, contentText: contentText });
}));
this.callback(files);
}
}
// Shim for older browsers for File/Blob.arrayBuffer() and .text()
(function () {
function arrayBuffer() {
return new Promise(function () {
let fr = new FileReader();
fr.onload = () => {
resolve(fr.result);
};
fr.readAsArrayBuffer();
})
}
function text() {
return new Promise(function () {
let fr = new FileReader();
fr.onload = () => {
resolve(fr.result);
};
fr.readAsText(this);
})
}
if ('File' in self) {
File.prototype.arrayBuffer = File.prototype.arrayBuffer || arrayBuffer;
File.prototype.text = File.prototype.text || text;
}
Blob.prototype.arrayBuffer = Blob.prototype.arrayBuffer || arrayBuffer;
Blob.prototype.text = Blob.prototype.text || text;
})();

View File

@ -0,0 +1,89 @@
/******************************************************************************
* 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 { Input, Vector3 } from "src";
import { OrthoCamera } from "./Camera";
export class CameraController {
constructor(public canvas: HTMLElement, public camera: OrthoCamera) {
let cameraX = 0, cameraY = 0, cameraZoom = 0;
let mouseX = 0, mouseY = 0;
let lastX = 0, lastY = 0;
new Input(canvas).addListener({
down: (x: number, y: number) => {
cameraX = camera.position.x;
cameraY = camera.position.y;
mouseX = lastX = x;
mouseY = lastY = y;
},
dragged: (x: number, y: number) => {
let deltaX = x - mouseX;
let deltaY = y - mouseY;
let originWorld = camera.screenToWorld(new Vector3(0, 0), canvas.clientWidth, canvas.clientHeight);
let deltaWorld = camera.screenToWorld(new Vector3(deltaX, deltaY), canvas.clientWidth, canvas.clientHeight).sub(originWorld);
camera.position.set(cameraX - deltaWorld.x, cameraY - deltaWorld.y, 0);
camera.update();
lastX = x;
lastY = y;
},
zoom: (zoom: number) => {
let zoomAmount = zoom / 200 * camera.zoom;
let newZoom = camera.zoom + zoomAmount;
if (newZoom > 0) {
let x = 0, y = 0;
if (zoom < 0) {
x = lastX; y = lastY;
} else {
let viewCenter = new Vector3(canvas.clientWidth / 2 + 15, canvas.clientHeight / 2);
let mouseToCenterX = lastX - viewCenter.x;
let mouseToCenterY = canvas.clientHeight - 1 - lastY - viewCenter.y;
x = viewCenter.x - mouseToCenterX;
y = canvas.clientHeight - 1 - viewCenter.y + mouseToCenterY;
}
let oldDistance = camera.screenToWorld(new Vector3(x, y), canvas.clientWidth, canvas.clientHeight);
camera.zoom = newZoom;
camera.update();
let newDistance = camera.screenToWorld(new Vector3(x, y), canvas.clientWidth, canvas.clientHeight);
camera.position.add(oldDistance.sub(newDistance));
camera.update();
}
console.log(`${camera.zoom}, ${zoomAmount}, ${zoom}`);
},
up: (x: number, y: number) => {
lastX = x;
lastY = y;
},
moved: (x: number, y: number) => {
lastX = x;
lastY = y;
},
});
}
}

View File

@ -41,12 +41,12 @@ export class Input {
return new Touch(0, 0, 0);
});
constructor (element: HTMLElement) {
constructor(element: HTMLElement) {
this.element = element;
this.setupCallbacks(element);
}
private setupCallbacks (element: HTMLElement) {
private setupCallbacks(element: HTMLElement) {
let mouseDown = (ev: UIEvent) => {
if (ev instanceof MouseEvent) {
let rect = element.getBoundingClientRect();
@ -104,9 +104,21 @@ export class Input {
}
}
let mouseWheel = (e: WheelEvent) => {
e.preventDefault();
let deltaY = e.deltaY;
if (e.deltaMode == WheelEvent.DOM_DELTA_LINE) deltaY *= 8;
if (e.deltaMode == WheelEvent.DOM_DELTA_PAGE) deltaY *= 24;
let listeners = this.listeners;
for (let i = 0; i < listeners.length; i++)
if (listeners[i].zoom) listeners[i].zoom(e.deltaY);
};
element.addEventListener("mousedown", mouseDown, true);
element.addEventListener("mousemove", mouseMove, true);
element.addEventListener("mouseup", mouseUp, true);
element.addEventListener("wheel", mouseWheel, true);
element.addEventListener("touchstart", (ev: TouchEvent) => {
if (!this.currTouch) {
var touches = ev.changedTouches;
@ -133,56 +145,7 @@ export class Input {
}
ev.preventDefault();
}, false);
element.addEventListener("touchend", (ev: TouchEvent) => {
if (this.currTouch) {
var touches = ev.changedTouches;
for (var i = 0; i < touches.length; i++) {
var touch = touches[i];
if (this.currTouch.identifier === touch.identifier) {
let rect = element.getBoundingClientRect();
let x = this.currTouch.x = touch.clientX - rect.left;
let y = this.currTouch.y = touch.clientY - rect.top;
this.touchesPool.free(this.currTouch);
let listeners = this.listeners;
for (let i = 0; i < listeners.length; i++) {
if (listeners[i].up) listeners[i].up(x, y);
}
this.lastX = x;
this.lastY = y;
this.buttonDown = false;
this.currTouch = null;
break;
}
}
}
ev.preventDefault();
}, false);
element.addEventListener("touchcancel", (ev: TouchEvent) => {
if (this.currTouch) {
var touches = ev.changedTouches;
for (var i = 0; i < touches.length; i++) {
var touch = touches[i];
if (this.currTouch.identifier === touch.identifier) {
let rect = element.getBoundingClientRect();
let x = this.currTouch.x = touch.clientX - rect.left;
let y = this.currTouch.y = touch.clientY - rect.top;
this.touchesPool.free(this.currTouch);
let listeners = this.listeners;
for (let i = 0; i < listeners.length; i++) {
if (listeners[i].up) listeners[i].up(x, y);
}
this.lastX = x;
this.lastY = y;
this.buttonDown = false;
this.currTouch = null;
break;
}
}
}
ev.preventDefault();
}, false);
element.addEventListener("touchmove", (ev: TouchEvent) => {
if (this.currTouch) {
var touches = ev.changedTouches;
@ -206,13 +169,41 @@ export class Input {
}
ev.preventDefault();
}, false);
let touchEnd = (ev: TouchEvent) => {
if (this.currTouch) {
var touches = ev.changedTouches;
for (var i = 0; i < touches.length; i++) {
var touch = touches[i];
if (this.currTouch.identifier === touch.identifier) {
let rect = element.getBoundingClientRect();
let x = this.currTouch.x = touch.clientX - rect.left;
let y = this.currTouch.y = touch.clientY - rect.top;
this.touchesPool.free(this.currTouch);
let listeners = this.listeners;
for (let i = 0; i < listeners.length; i++) {
if (listeners[i].up) listeners[i].up(x, y);
}
this.lastX = x;
this.lastY = y;
this.buttonDown = false;
this.currTouch = null;
break;
}
}
}
ev.preventDefault();
};
element.addEventListener("touchend", touchEnd, false);
element.addEventListener("touchcancel", touchEnd);
}
addListener (listener: InputListener) {
addListener(listener: InputListener) {
this.listeners.push(listener);
}
removeListener (listener: InputListener) {
removeListener(listener: InputListener) {
let idx = this.listeners.indexOf(listener);
if (idx > -1) {
this.listeners.splice(idx, 1);
@ -221,13 +212,14 @@ export class Input {
}
export class Touch {
constructor (public identifier: number, public x: number, public y: number) {
constructor(public identifier: number, public x: number, public y: number) {
}
}
export interface InputListener {
down (x: number, y: number): void;
up (x: number, y: number): void;
moved (x: number, y: number): void;
dragged (x: number, y: number): void;
down?(x: number, y: number): void;
up?(x: number, y: number): void;
moved?(x: number, y: number): void;
dragged?(x: number, y: number): void;
zoom?(zoom: number): void;
}

View File

@ -1,5 +1,6 @@
export * from "./AssetManager";
export * from "./Camera";
export * from "./CameraController";
export * from "./GLTexture";
export * from "./Input";
export * from "./LoadingScreen";