aboutsummaryrefslogtreecommitdiff
path: root/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'src/js')
-rw-r--r--src/js/gamepad.js664
-rw-r--r--src/js/main.js199
-rw-r--r--src/js/notification.js102
-rw-r--r--src/js/uBit.js142
4 files changed, 1107 insertions, 0 deletions
diff --git a/src/js/gamepad.js b/src/js/gamepad.js
new file mode 100644
index 0000000..5ab4130
--- /dev/null
+++ b/src/js/gamepad.js
@@ -0,0 +1,664 @@
+class CanvasStage {
+ canvas;
+ #dpi = window.devicePixelRatio;
+ #width;
+ #height;
+ #ctx;
+ #elements = [];
+ touches = {};
+ showTouches = false;
+
+ constructor(id, node) {
+ this.canvas = document.createElement("canvas");
+ this.canvas.setAttribute("id", id);
+ node.appendChild(this.canvas);
+
+ addEventListener("resize", () => this.resize());
+ this.resize();
+
+ console.debug("Created canvas", this.canvas);
+
+ setInterval(() => {
+ this.drawElements();
+ }, 10);
+ }
+
+ /* Resizes the canvas to be the correct size for the current screen */
+ resize() {
+ this.#ctx = this.canvas.getContext("2d");
+ this.#height = +getComputedStyle(this.canvas).getPropertyValue("height").slice(0, -2);
+ this.#width = +getComputedStyle(this.canvas).getPropertyValue("width").slice(0, -2);
+ this.canvas.setAttribute('height', this.#height * this.#dpi);
+ this.canvas.setAttribute('width', this.#width * this.#dpi);
+ }
+
+ /* Translate a screen x coordinate to a canvas x coordinate */
+ screenToCanvasX(x) { return x * this.#dpi; }
+
+ /* Translate a screen y coordinate to a canvas y coordinate */
+ screenToCanvasY(y) { return y * this.#dpi; }
+
+ /* Get target at position, i.e. the element that intersects said position */
+ getTarget(x, y) {
+ x *= this.#dpi;
+ y *= this.#dpi;
+ for (let i = 0; i < this.#elements.length; i++) {
+ if ((this.#elements[i] instanceof TouchElement)
+ && (this.#elements[i].collides(this.#ctx, x, y))) {
+ return this.#elements[i];
+ }
+ }
+ }
+
+ /* Redraws all elements of the stage on the screen. */
+ drawElements() {
+ this.#ctx.clearRect(0, 0, this.#width * this.#dpi, this.#height * this.#dpi);
+ for (let i = 0; i < this.#elements.length; i++) {
+ this.#elements[i].draw(this.#ctx);
+ }
+ if (this.showTouches) {
+ this.drawTouches();
+ }
+ }
+
+ /* Draws all touches on the screen, used to debug */
+ drawTouches(e) {
+ const colors = ["200, 0, 0", "0, 200, 0", "0, 0, 200", "200, 200, 0", "200, 200, 200"]
+ for (const [identifier, touch] of Object.entries(this.touches)) {
+ this.#ctx.beginPath();
+ this.#ctx.arc(touch.x * this.#dpi, touch.y * this.#dpi, 20 * this.#dpi, 0, 2*Math.PI, true);
+ this.#ctx.fillStyle = `rgba(${colors[identifier]}, 0.2)`;
+ this.#ctx.fill();
+
+ this.#ctx.lineWidth = 2.0;
+ this.#ctx.strokeStyle = `rgba(${colors[identifier]}, 0.8)`;
+ this.#ctx.stroke();
+ }
+ }
+
+ /* Add a element to the stage */
+ addElement(element) {
+ this.#elements.push(element);
+ element.init();
+ }
+
+ /* Remove a element from the stage by id */
+ removeElementById(id) {
+ for (let i = 0; i < this.#elements.length; i++) {
+ if (id === this.#elements[i].id) {
+ this.#elements.splice(i, 1);
+ return;
+ }
+ }
+ }
+
+ /* Wipe all elements from the stage */
+ removeAllElements() {
+ this.#elements.splice(0, this.#elements.length);
+ }
+
+}
+
+class Element {
+ gamepad;
+ id;
+ x;
+ y;
+ alignX;
+ alignY;
+ path;
+ isInside;
+ isActive;
+ type = "Element";
+
+ constructor(opts, gamepad) {
+ let _opts = Object.assign({
+ id: null,
+ x: 0,
+ y: 0,
+ alignX: null,
+ alignY: null
+ }, opts);
+ this.id = _opts.id;
+ this.x = _opts.x;
+ this.y = _opts.y;
+ this.alignX = _opts.alignX;
+ this.alignY = _opts.alignY;
+ this.gamepad = gamepad;
+ }
+
+ /* Used for initializing the element onto the stage */
+ init() {}
+
+ /* Get the x-axis scaling factor (currently unused, only the y scaling factor is in use) */
+ getScaleX(ctx) {
+ return ctx.canvas.width / 100;
+ }
+
+ /* Get the y-axis scaling factor */
+ getScaleY(ctx) {
+ return ctx.canvas.height / 100;
+ }
+
+ /* Get the canvas x position of this element, adjusted from the virtual canvas coordinates */
+ getX(ctx) {
+ let x = this.x * this.getScaleY(ctx);
+ if (this.alignX === "center") {
+ x = (ctx.canvas.width / 2) + x;
+ }
+ if (this.alignX === "right") {
+ x = ctx.canvas.width - x;
+ }
+ return x;
+ }
+
+ /* Get the canvas y position of this element, adjusted from the virtual canvas coordinates */
+ getY(ctx) {
+ let y = this.y * this.getScaleY(ctx);
+ if (this.alignY === "center") {
+ y = (ctx.canvas.height / 2) + y;
+ }
+ if (this.alignY === "bottom") {
+ y = ctx.canvas.height - y;
+ }
+ return y;
+ }
+
+ /* Used to draw the element onto a canvas context */
+ draw(ctx) {}
+
+ /* Used to check wether the coordinates is inside this element */
+ collides(ctx, x, y) {
+ this.isInside = ctx.isPointInPath(this.path, x, y);
+ return this.isInside;
+ }
+
+}
+
+export class Square extends Element {
+ draw(ctx) {
+ this.path = new Path2D();
+ let w = this.getScaleY(ctx) * 20;
+ this.path.rect(this.getX(ctx) - (w/2), this.getY(ctx) - (w/2), w, w);
+ ctx.fillStyle = `rgba(100, 100, 100, 0.8)`;
+ ctx.fill(this.path);
+ }
+}
+
+class TouchElement extends Element {
+ type = "TouchElement";
+ touchCount = 0;
+
+ setActive(e, doCallbacks = true) {
+ if (["end", "cancel"].includes(e.type)) { this.touchCount--; }
+ let eState = e.type == "start";
+ if ((eState !== this.isActive) && (this.touchCount == 0)) {
+ this.isActive = eState;
+ if (doCallbacks) {
+ this.gamepad.handleTouchEventCallbacks(this.createTouchEventObject(
+ this.isActive ? "touchstart" : "touchend"
+ ));
+ }
+ }
+ if (e.type == "start") { this.touchCount++; }
+ }
+
+ createTouchEventObject(action) {
+ return {
+ id: this.id,
+ action: action,
+ type: this.type
+ }
+ }
+
+}
+
+export class GamepadButton extends TouchElement {
+ shape;
+ altText;
+ altTextAlign;
+ type = "GamepadButton";
+
+ constructor(opts) {
+ let _opts = Object.assign({
+ keyboardButton: null,
+ altText: null,
+ altTextAlign: "left",
+ shape: "round"
+ }, opts);
+ super(opts);
+ this.keyboardButton = _opts.keyboardButton;
+ this.shape = _opts.shape;
+ this.altText = _opts.altText;
+ this.altTextAlign = _opts.altTextAlign;
+ }
+
+ init() {
+ if (this.keyboardButton !== null) {
+ this.gamepad.registerKeybinding(this.keyboardButton, this);
+ }
+ }
+
+ draw(ctx) {
+ this.path = new Path2D();
+ if (this.shape === "round") {
+ this.path.arc(this.getX(ctx), this.getY(ctx), this.getScaleY(ctx) * 10, 0, 4*Math.PI, true);
+ } else if (this.shape === "square") {
+ let w = this.getScaleY(ctx) * 20;
+ this.path.rect(this.getX(ctx) - (w/2), this.getY(ctx) - (w/2), w, w);
+ }
+ if (this.isActive) {
+ ctx.fillStyle = `rgba(80, 80, 80, 1)`;
+ } else {
+ ctx.fillStyle = `rgba(100, 100, 100, 0.8)`;
+ }
+ ctx.fill(this.path);
+
+ let s = `${Math.floor((this.getScaleY(ctx)*8).toString())}px 'Press Start 2P'`;
+ ctx.font = s;
+ ctx.textBaseline = "middle";
+ ctx.textAlign = "center";
+ ctx.fillStyle = `rgba(255, 255, 255, 1)`;
+ ctx.fillText(this.id, this.getX(ctx), this.getY(ctx));
+
+ if ((this.altText !== null) && (this.gamepad.showAltText)) {
+ ctx.beginPath();
+ ctx.font = `${Math.floor((this.getScaleY(ctx)*3).toString())}px 'Press Start 2P'`;
+ ctx.textBaseline = "middle";
+ ctx.textAlign = "center";
+ ctx.fillStyle = `rgba(150, 150, 150, 1)`;
+ let ax = this.getX(ctx);
+ let ay = this.getY(ctx);
+ switch (this.altTextAlign) {
+ case "left":
+ ax -= (this.getScaleY(ctx) * 13);
+ break;
+ case "right":
+ ax += (this.getScaleY(ctx) * 13);
+ break;
+ case "top":
+ ay -= (this.getScaleY(ctx) * 13);
+ break;
+ case "bottom":
+ ay += (this.getScaleY(ctx) * 13);
+ break;
+ }
+ ctx.fillText(this.altText, ax, ay);
+ }
+ }
+
+}
+
+export class GamepadJoystick extends TouchElement {
+ type = "GamepadJoystick";
+ mouseX = 0;
+ mouseY = 0;
+ cR = 0;
+ cX = 0;
+ cY = 0;
+
+ #lockX;
+ #lockY;
+
+ #pressedKeys = {};
+
+ constructor(opts) {
+ let _opts = Object.assign({
+ lockX: false,
+ lockY: false,
+ autoCenter: true,
+ bindUp: null,
+ bindLeft: null,
+ bindRight: null,
+ bindDown: null
+ }, opts);
+ super(opts);
+ this.#lockX = _opts.lockX;
+ this.#lockY = _opts.lockY;
+ this.bindUp = _opts.bindUp;
+ this.bindLeft = _opts.bindLeft;
+ this.bindRight = _opts.bindRight;
+ this.bindDown = _opts.bindDown;
+ }
+
+ init() {
+ if (this.bindUp !== null) {
+ this.gamepad.registerKeybinding(this.bindUp, this);
+ }
+ if (this.bindLeft !== null) {
+ this.gamepad.registerKeybinding(this.bindLeft, this);
+ }
+ if (this.bindRight !== null) {
+ this.gamepad.registerKeybinding(this.bindRight, this);
+ }
+ if (this.bindDown !== null) {
+ this.gamepad.registerKeybinding(this.bindDown, this);
+ }
+ }
+
+ isKeyPressed(key) {
+ return ((key !== null)
+ && (this.#pressedKeys.hasOwnProperty(key))
+ && (this.#pressedKeys[key] > 0));
+ }
+
+ createTouchEventObject(action) {
+ return {
+ id: this.id,
+ action: action,
+ type: this.type,
+ x: Math.round((this.mouseX / this.cR) * 100),
+ y: Math.round((this.mouseY / this.cR) * 100)
+ }
+ }
+
+ setActive(e) {
+ super.setActive(e, false);
+ if (e.hasOwnProperty("key")) {
+ if (!this.#pressedKeys.hasOwnProperty(e.key)) {
+ this.#pressedKeys[e.key] = 0;
+ }
+ if (["start"].includes(e.type)) {
+ this.#pressedKeys[e.key]++;
+ }
+ if (["end", "cancel"].includes(e.type)) {
+ this.#pressedKeys[e.key]--;
+ }
+ }
+
+ let max = this.cR
+ if (!this.#lockX) {
+ if (e.hasOwnProperty("x")) {
+ this.mouseX = this.cX - this.gamepad.stage.screenToCanvasX(e.x);
+ this.mouseX = Math.min(Math.abs(this.mouseX), max) * Math.sign(this.mouseX);
+ this.mouseX *= -1;
+ }
+ if (this.isKeyPressed(this.bindLeft)) { this.mouseX = -max; }
+ if (this.isKeyPressed(this.bindRight)) { this.mouseX = max; }
+ if (this.isKeyPressed(this.bindLeft) && this.isKeyPressed(this.bindRight)) { this.mouseX = 0; }
+ if (!this.isActive) { this.mouseX = 0; }
+ }
+ if (!this.#lockY) {
+ if (e.hasOwnProperty("y")) {
+ this.mouseY = this.cY - this.gamepad.stage.screenToCanvasY(e.y);
+ this.mouseY = Math.min(Math.abs(this.mouseY), max) * Math.sign(this.mouseY);
+ }
+ if (this.isKeyPressed(this.bindUp)) { this.mouseY = max; }
+ if (this.isKeyPressed(this.bindDown)) { this.mouseY = -max; }
+ if (this.isKeyPressed(this.bindUp) && this.isKeyPressed(this.bindDown)) { this.mouseY = 0; }
+ if (!this.isActive) { this.mouseY = 0; }
+ }
+
+ let action = "touchmove";
+ if (this.isActive && (this.touchCount == 1) && (e.type === "start")) {
+ action = "touchstart";
+ }
+ if (!this.isActive) {
+ action = "touchend";
+ }
+ this.gamepad.handleTouchEventCallbacks(this.createTouchEventObject(action));
+ }
+
+ draw(ctx) {
+ this.cX = this.getX(ctx);
+ this.cY = this.getY(ctx);
+ this.cR = this.getScaleY(ctx) * 25;
+
+ this.path = new Path2D();
+ this.path.arc(this.cX, this.cY, this.cR, 0, 4*Math.PI, true);
+ if (this.isActive) {
+ ctx.fillStyle = `rgba(85, 85, 85, 0.8)`;
+ } else {
+ ctx.fillStyle = `rgba(100, 100, 100, 0.8)`;
+ }
+ ctx.fill(this.path);
+
+ ctx.beginPath();
+ ctx.arc(this.cX + this.mouseX, this.cY - this.mouseY, this.getScaleY(ctx) * 15, 0, 4*Math.PI, true);
+ ctx.fillStyle = `rgba(130, 130, 130, 1)`;
+ ctx.fill();
+
+ }
+
+}
+
+export class Gamepad {
+ stage;
+ #width;
+ #height;
+
+ #touches = {};
+ #keybindings = {};
+ #keystates = {};
+ #touchEventCallbacks = [];
+
+ showDebug = false;
+ showAltText = true;
+ enableVibration = true;
+
+ constructor() {
+ this.stage = new CanvasStage("GamePad", document.querySelector(".gamepad-wrapper"));
+ this.addEventListeners();
+ }
+
+ addEventListeners() {
+ let ev = ["keydown", "keyup"];
+ for(var e in ev) {
+ document.addEventListener(ev[e], (e) => this.handleKeyEvent(e), false);
+ }
+ ev = ["touchstart", "touchend", "touchcancel", "touchmove"];
+ for(var e in ev) {
+ this.stage.canvas.addEventListener(ev[e], (e) => this.handleTouchEvent(e), false);
+ }
+ ev = ["mousedown", "mouseup", "mousemove"];
+ for(var e in ev) {
+ this.stage.canvas.addEventListener(ev[e], (e) => this.handleMouseEvent(e), false);
+ }
+ }
+
+ /* Used by stage elements to register themselves with some keybinding */
+ registerKeybinding(binding, element) {
+ this.#keybindings[binding] = element;
+ }
+
+ /* Event handler for keyboard events */
+ handleKeyEvent(e) {
+ const typedict = {"keydown": "start", "keyup": "end"}
+ if (!this.#keystates.hasOwnProperty(e.keyCode)) {
+ this.#keystates[e.keyCode] = {pressed: false};
+ }
+ if (this.#keybindings.hasOwnProperty(e.key)) {
+ let id = `Key ${e.key}`
+ let target = this.#keybindings[e.key];
+ let gtEvent = {
+ touchId: id,
+ key: e.key,
+ type: typedict[e.type]
+ };
+ switch (e.type) {
+ case "keydown":
+ if (this.#keystates[e.keyCode].pressed) { return; }
+ this.#keystates[e.keyCode].pressed = true;
+
+ this.#touches[id] = {};
+ this.#touches[id].target = target;
+ if (this.#touches[id].hasOwnProperty("target")
+ && this.#touches[id].target != null) {
+ this.#touches[id].target.setActive(gtEvent);
+ }
+ break;
+ case "keyup":
+ if (!this.#keystates[e.keyCode].pressed) { return; }
+ this.#keystates[e.keyCode].pressed = false;
+
+ if (this.#touches[id].hasOwnProperty("target")
+ && this.#touches[id].target != null) {
+ this.#touches[id].target.setActive(gtEvent);
+ }
+ delete this.#touches[id];
+ break;
+ }
+ }
+ this.stage.touches = this.#touches;
+ this.debugTouches();
+ }
+
+ /* Event handler for mouse events, will just translate the event to a more common form
+ * before further processing. */
+ handleMouseEvent(e) {
+ const typedict = {"mousedown": "start", "mouseup": "end", "mousemove": "move"}
+ this.processGamepadTouchEvent({
+ x: e.clientX,
+ y: e.clientY,
+ touchId: "mouse",
+ type: typedict[e.type]
+ });
+ }
+
+ /* Event handler for touch events, will just translate the event to a more common form
+ * before further processing. */
+ handleTouchEvent(e) {
+ e.preventDefault();
+ const typedict = {"touchstart": "start", "touchend": "end", "touchcancel": "end", "touchmove": "move"}
+ for (let i = 0; i < e.changedTouches.length; i++) {
+ let touch = e.changedTouches[i];
+ this.processGamepadTouchEvent({
+ x: touch.clientX,
+ y: touch.clientY,
+ touchId: touch.identifier,
+ type: typedict[e.type]
+ });
+ }
+ }
+
+ /* Event handler for processing standarized touch/mouse events. */
+ processGamepadTouchEvent(gtEvent) {
+ let target = this.stage.getTarget(gtEvent.x, gtEvent.y)
+ switch (gtEvent.type) {
+ case "start":
+ this.#touches[gtEvent.touchId] = {};
+ this.#touches[gtEvent.touchId].target = target;
+ case "move":
+ if (this.#touches.hasOwnProperty(gtEvent.touchId)) {
+ this.#touches[gtEvent.touchId].x = gtEvent.x;
+ this.#touches[gtEvent.touchId].y = gtEvent.y;
+
+ if (this.#touches[gtEvent.touchId].hasOwnProperty("target")
+ && this.#touches[gtEvent.touchId].target != null) {
+ this.#touches[gtEvent.touchId].target.setActive(gtEvent);
+ }
+ }
+ break;
+
+ case "end":
+ case "cancel":
+ if (this.#touches[gtEvent.touchId].hasOwnProperty("target")
+ && this.#touches[gtEvent.touchId].target != null) {
+ this.#touches[gtEvent.touchId].target.setActive(gtEvent);
+ }
+ delete this.#touches[gtEvent.touchId];
+ break;
+
+ default:
+ console.log("Unknown touch event", gtEvent.type);
+ }
+ this.stage.touches = this.#touches;
+ this.debugTouches();
+ }
+
+ /* Update the debug text with all current touches */
+ debugTouches() {
+ let s = "";
+ if (this.showDebug) {
+ for (const [i, t] of Object.entries(this.#touches)) {
+ s += `[${i}] `
+ if (t.hasOwnProperty("x")) {
+ s += `x: ${Math.round(t.x, 2)}, y: ${Math.round(t.y)},`
+ }
+ s += `target: ${t.target ? t.target.id : null}\n`;
+ }
+ }
+ document.querySelector(".gamepad-touches").innerHTML = s;
+ }
+
+ /* Used by elements to process callbacks on actions to outside the gamepad */
+ handleTouchEventCallbacks(e) {
+ if (this.enableVibration && ["touchstart", "touchend"].includes(e.action)) {
+ try {
+ window.navigator.vibrate(5);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ for (let i = 0; i < this.#touchEventCallbacks.length; i++) {
+ this.#touchEventCallbacks[i](e);
+ }
+ }
+
+ /* Register a method as a callback for gamepad touch events */
+ onTouchEvent(callback) {
+ this.#touchEventCallbacks.push(callback);
+ }
+
+ /* Add a list of elements to the gamepad stage */
+ addElements(elements) {
+ for (let i = 0; i < elements.length; i++) {
+ elements[i].gamepad = this;
+ this.stage.addElement(elements[i]);
+ }
+ }
+
+ /* Remove a list of elements from the gamepad stage by id */
+ removeElementsById(elementIds) {
+ for (let i = 0; i < elementIds.length; i++) {
+ this.stage.removeElementById(elementIds[i]);
+ }
+ }
+
+ /* Remove all elements from the gamepad stage */
+ removeAllElements() {
+ this.stage.removeAllElements();
+ }
+
+ /* Initialize gamepad with a predefined layout */
+ setGamepadLayout(variant) {
+ console.debug(`Setting the gamepad layout to ${variant}, deleting all current elements.`);
+ this.removeAllElements();
+ switch (variant) {
+ case "1":
+ this.addElements([
+ new Square({id: "filler1", x: 40, y: 0, alignX: "left", alignY: "center"}),
+ new GamepadButton({id: "C", x: 20, y: 0, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowLeft", altText: "◀", altTextAlign: "right"}),
+ new GamepadButton({id: "D", x: 60, y: 0, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowRight",altText: "▶", altTextAlign: "left"}),
+ new GamepadButton({id: "A", x: 40, y: -20, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowUp", altText: "▲", altTextAlign: "bottom"}),
+ new GamepadButton({id: "B", x: 40, y: 20, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowDown", altText: "▼", altTextAlign: "top"}),
+ new GamepadButton({id: "3", x: 20, y: 0, alignX: "right", alignY: "center", shape: "round", keyboardButton: "3", altText: "3", altTextAlign: "left"}),
+ new GamepadButton({id: "4", x: 60, y: 0, alignX: "right", alignY: "center", shape: "round", keyboardButton: "4", altText: "4", altTextAlign: "right"}),
+ new GamepadButton({id: "1", x: 40, y: -20, alignX: "right", alignY: "center", shape: "round", keyboardButton: "1", altText: "1", altTextAlign: "bottom"}),
+ new GamepadButton({id: "2", x: 40, y: 20, alignX: "right", alignY: "center", shape: "round", keyboardButton: "2", altText: "2", altTextAlign: "top"}),
+ ])
+ break;
+ case "2":
+ this.addElements([
+ new Square({id: "filler2", x: 40, y: 0, alignX: "right", alignY: "center"}),
+ new Square({id: "filler1", x: 40, y: 0, alignX: "left", alignY: "center"}),
+ new GamepadButton({id: "C", x: 20, y: 0, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowLeft", altText: "◀", altTextAlign: "right"}),
+ new GamepadButton({id: "D", x: 60, y: 0, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowRight",altText: "▶", altTextAlign: "left"}),
+ new GamepadButton({id: "A", x: 40, y: -20, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowUp", altText: "▲", altTextAlign: "bottom"}),
+ new GamepadButton({id: "B", x: 40, y: 20, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowDown", altText: "▼", altTextAlign: "top"}),
+ new GamepadButton({id: "3", x: 20, y: 0, alignX: "right", alignY: "center", shape: "square", keyboardButton: "3"}),
+ new GamepadButton({id: "4", x: 60, y: 0, alignX: "right", alignY: "center", shape: "square", keyboardButton: "4"}),
+ new GamepadButton({id: "1", x: 40, y: -20, alignX: "right", alignY: "center", shape: "square", keyboardButton: "1"}),
+ new GamepadButton({id: "2", x: 40, y: 20, alignX: "right", alignY: "center", shape: "square", keyboardButton: "2"}),
+ ])
+ break;
+ case "9":
+ this.addElements([
+ new GamepadJoystick({id: "left", x: 40, y: 0, alignX: "left", alignY: "center", lockX: true, bindUp: "ArrowUp", bindDown: "ArrowDown"}),
+ new GamepadJoystick({id: "right", x: 40, y: 0, alignX: "right", alignY: "center", lockY: true, bindLeft: "ArrowLeft", bindRight: "ArrowRight"})
+ ]);
+ break;
+ }
+ }
+
+}
diff --git a/src/js/main.js b/src/js/main.js
new file mode 100644
index 0000000..801a9eb
--- /dev/null
+++ b/src/js/main.js
@@ -0,0 +1,199 @@
+import { uBitBLE, MESEvents } from "./uBit";
+import { notif_alert, notif_warn, notif_info, notif_success } from './notification';
+import { Gamepad } from './gamepad';
+
+/* Attempt to install service worker */
+let sw = "service-worker.js";
+if (navigator.serviceWorker) {
+ navigator.serviceWorker.register(
+ sw, {scope: '/hoverbit-ble/'}
+ ).then(registration => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing;
+ if (installingWorker == null) { return; }
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === "installed") {
+ if (navigator.serviceWorker.controller) {
+ notif_info("New content is available, relaunch the app to install it.");
+ } else {
+ notif_success("Content is cached for offline use.");
+ }
+ }
+ };
+ };
+ registration.update();
+ }).catch(error => {
+ notif_warn("Could not install service worker...");
+ console.error("Error during service worker registration:", error);
+ });
+}
+
+/* Allow the ignore-landscape-warning button to work */
+document.getElementById("btn_ignore_landscape_warning").addEventListener("click", () => {
+ document.body.classList.add("ignore-landscape-warning");
+});
+
+/* Show a warning if bluetooth is unavailable in the browser. */
+if (!navigator.bluetooth) {
+ //alert("Bluetooth not enabled in your browser, this won't work...");
+ console.error("You do not have a bluetooth enabled browser, you need to have a bluetooth enabled browser...");
+ notif_alert("Your browser does not seem to support bluetooth, try using Google Chrome or Microsoft Edge.");
+}
+
+/* Define and initialize things */
+let gamepad = new Gamepad();
+window.gamepad = gamepad;
+let ubit = new uBitBLE();
+window.ubit = ubit;
+
+/* Setup storage and picker for the gamepad layout */
+document.querySelector(".settings-dialog #layout").addEventListener("change", (v) => {
+ gamepad.setGamepadLayout(v.target.value);
+ localStorage.setItem("gamepadLayout", v.target.value);
+ document.querySelector(".button-states pre").innerHTML = "No buttons pressed yet";
+});
+if (localStorage.getItem("gamepadLayout") === null) { localStorage.setItem("gamepadLayout", "1"); }
+gamepad.setGamepadLayout(localStorage.getItem("gamepadLayout"));
+document.querySelector(".button-states pre").innerHTML = "No buttons pressed yet";
+document.querySelector(".settings-dialog #layout").value = localStorage.getItem("gamepadLayout");
+
+/* Setup storage for toggling touches */
+document.querySelector(".settings-dialog #show-touches").addEventListener("change", (v) => {
+ gamepad.stage.showTouches = v.target.checked;
+ localStorage.setItem("showTouches", v.target.checked);
+});
+if (localStorage.getItem("showTouches") === null) { localStorage.setItem("showTouches", false); }
+gamepad.stage.showTouches = localStorage.getItem("showTouches") == "true";
+document.querySelector(".settings-dialog #show-touches").checked = localStorage.getItem("showTouches") == "true";
+
+/* Setup storage for toggling alt text */
+document.querySelector(".settings-dialog #show-gamepad-alt-text").addEventListener("change", (v) => {
+ gamepad.showAltText = v.target.checked;
+ localStorage.setItem("showAltText", v.target.checked);
+});
+if (localStorage.getItem("showAltText") === null) { localStorage.setItem("showAltText", false); }
+gamepad.showAltText = localStorage.getItem("showAltText") == "true";
+document.querySelector(".settings-dialog #show-gamepad-alt-text").checked = localStorage.getItem("showAltText") == "true";
+
+/* Setup storage for toggling vibration/haptic feedback */
+document.querySelector(".settings-dialog #enable-haptic").addEventListener("change", (v) => {
+ gamepad.enableVibration = v.target.checked;
+ localStorage.setItem("enableHaptic", v.target.checked);
+});
+if (localStorage.getItem("enableHaptic") === null) { localStorage.setItem("enableHaptic", true); }
+gamepad.enableVibration = localStorage.getItem("enableHaptic") == "true";
+document.querySelector(".settings-dialog #enable-haptic").checked = localStorage.getItem("enableHaptic") == "true";
+
+/* Setup storage for toggling debug mode */
+document.querySelector(".settings-dialog #enable-debug").addEventListener("change", (v) => {
+ gamepad.showDebug = v.target.checked;
+ if (v.target.checked) {
+ document.body.classList.add("debug");
+ } else {
+ document.body.classList.remove("debug");
+ }
+ localStorage.setItem("enableDebug", v.target.checked);
+});
+if (localStorage.getItem("enableDebug") === null) { localStorage.setItem("enableDebug", false); }
+gamepad.showDebug = localStorage.getItem("enableDebug") == "true";
+if (localStorage.getItem("enableDebug") === "true") {
+ document.body.classList.add("debug");
+} else {
+ document.body.classList.remove("debug");
+}
+document.querySelector(".settings-dialog #enable-debug").checked = localStorage.getItem("enableDebug") == "true";
+
+/* Setup buttons for opening/closing settings panel */
+document.querySelector("#btn_show_settings").addEventListener("click", () => {
+ document.querySelector(".settings-dialog").classList.add("shown");
+});
+document.querySelector("#btn_hide_settings").addEventListener("click", () => {
+ document.querySelector(".settings-dialog").classList.remove("shown");
+});
+
+/* Setup actions for bluetooth connect/disconnect buttons */
+document.querySelector("#btn_disconnect").addEventListener("click", () => {
+ ubit.disconnect();
+});
+document.getElementById("btn_connect").addEventListener("click", () => {
+ if (!navigator.bluetooth) {
+ notif_alert("You need a bluetooth enabled browser for this app to work, try chrome.");
+ }
+ ubit.searchDevice();
+});
+
+/* Handle gamepad events */
+let gamepadState = {};
+gamepad.onTouchEvent(e => {
+ /* This is just for the debug data */
+ if (["touchstart", "touchmove"].includes(e.action)) {
+ gamepadState[e.id] = {state: true, ...e};
+ }
+ if (["touchend"].includes(e.action)) {
+ gamepadState[e.id] = {state: false, ...e};
+ }
+ let debugString = "";
+ for (const [key, value] of Object.entries(gamepadState)) {
+ debugString += `${key}: ${value.state ? 'Pressed' : 'Not pressed'}`;
+ if (value.hasOwnProperty("x")) {
+ debugString += ` (x: ${value.x}, y: ${value.y})`;
+ }
+ debugString += `\n`;
+ }
+ document.querySelector(".button-states pre").innerHTML = debugString;
+});
+
+gamepad.onTouchEvent(e => {
+ const event_type = MESEvents.MES_DPAD_CONTROLLER_ID;
+ let event_value = null;
+ if (e.action == "touchstart") {
+ if (e.id == "A") {
+ event_value = MESEvents.MES_DPAD_BUTTON_A_DOWN;
+ } else if (e.id == "B") {
+ event_value = MESEvents.MES_DPAD_BUTTON_B_DOWN;
+ } else if (e.id == "C") {
+ event_value = MESEvents.MES_DPAD_BUTTON_C_DOWN;
+ } else if (e.id == "D") {
+ event_value = MESEvents.MES_DPAD_BUTTON_D_DOWN;
+ } else if (e.id == "1") {
+ event_value = MESEvents.MES_DPAD_BUTTON_1_DOWN;
+ } else if (e.id == "2") {
+ event_value = MESEvents.MES_DPAD_BUTTON_2_DOWN;
+ } else if (e.id == "3") {
+ event_value = MESEvents.MES_DPAD_BUTTON_3_DOWN;
+ } else if (e.id == "4") {
+ event_value = MESEvents.MES_DPAD_BUTTON_4_DOWN;
+ }
+ } else if (e.action == "touchend") {
+ if (e.id == "A") {
+ event_value = MESEvents.MES_DPAD_BUTTON_A_UP;
+ } else if (e.id == "B") {
+ event_value = MESEvents.MES_DPAD_BUTTON_B_UP;
+ } else if (e.id == "C") {
+ event_value = MESEvents.MES_DPAD_BUTTON_C_UP;
+ } else if (e.id == "D") {
+ event_value = MESEvents.MES_DPAD_BUTTON_D_UP;
+ } else if (e.id == "1") {
+ event_value = MESEvents.MES_DPAD_BUTTON_1_UP;
+ } else if (e.id == "2") {
+ event_value = MESEvents.MES_DPAD_BUTTON_2_UP;
+ } else if (e.id == "3") {
+ event_value = MESEvents.MES_DPAD_BUTTON_3_UP;
+ } else if (e.id == "4") {
+ event_value = MESEvents.MES_DPAD_BUTTON_4_UP;
+ }
+ }
+ if ((ubit.isConnected()) && (event_value != null)) {
+ ubit.eventService.sendEvent(event_type, event_value);
+ }
+});
+
+/* Setup handlers for ubit (bluetooth) events */
+ubit.onConnect(() => {
+ document.body.classList.add("connected");
+});
+
+ubit.onDisconnect(() => {
+ document.body.classList.remove("connected");
+});
+
diff --git a/src/js/notification.js b/src/js/notification.js
new file mode 100644
index 0000000..07163a5
--- /dev/null
+++ b/src/js/notification.js
@@ -0,0 +1,102 @@
+let waiting_timer = undefined;
+let notif_queue = [];
+
+function notif(notif_c) {
+ let notification_area = document.querySelector(".statusline .notification-area");
+
+ if ((notification_area.querySelector(".notification") === null) && (waiting_timer === undefined)) {
+ // This is just so no notifications will be played and disappears while the full screen landscape warning is in the way.
+ if( (screen.availHeight > screen.availWidth) && (!document.body.classList.contains("ignore-landscape-warning"))){
+ waiting_timer = setInterval(() => {
+ if( (screen.availHeight < screen.availWidth) || (document.body.classList.contains("ignore-landscape-warning"))){
+ clearInterval(waiting_timer);
+ waiting_timer = undefined;
+ notif(notif_queue.pop());
+ }
+ }, 1000);
+ notif_queue.push(notif_c);
+ return;
+ }
+
+ let notif_elem = document.createElement("div");
+ notif_elem.className = "notification";
+ notif_elem.appendChild(notif_c[0]);
+ notif_elem.appendChild(notif_c[1]);
+
+ notification_area.appendChild(notif_elem);
+
+ notification_area.classList.add("show");
+ setTimeout(() => {
+ notification_area.classList.remove("show");
+ notif_elem.querySelector("p").style.opacity = "0";
+ setTimeout(() => {
+ notification_area.removeChild(notif_elem);
+ if (notif_queue.length > 0) {
+ notif(notif_queue.pop());
+ }
+ }, 1000);
+ }, 10000);
+ } else {
+ notif_queue.push(notif_c);
+ }
+}
+
+export function notif_alert(alert_str) {
+ let div = document.createElement("div");
+ div.className = "notification-content";
+
+ let text = document.createElement("p");
+ text.innerHTML = alert_str;
+ div.appendChild(text);
+
+ let icon = document.createElement("i");
+ icon.className = "alert fas fa-exclamation-triangle";
+ div.appendChild(icon);
+
+ notif([icon, div]);
+}
+
+export function notif_warn(alert_str) {
+ let div = document.createElement("div");
+ div.className = "notification-content";
+
+ let text = document.createElement("p");
+ text.innerHTML = alert_str;
+ div.appendChild(text);
+
+ let icon = document.createElement("i");
+ icon.className = "warning fas fa-exclamation-triangle";
+ div.appendChild(icon);
+
+ notif([icon, div]);
+}
+
+export function notif_info(info_str) {
+ let div = document.createElement("div");
+ div.className = "notification-content";
+
+ let text = document.createElement("p");
+ text.innerHTML = info_str;
+ div.appendChild(text);
+
+ let icon = document.createElement("i");
+ icon.className = "info fas fa-info-circle";
+ div.appendChild(icon);
+
+ notif([icon, div]);
+}
+
+export function notif_success(success_str) {
+ let div = document.createElement("div");
+ div.className = "notification-content";
+
+ let text = document.createElement("p");
+ text.innerHTML = success_str;
+ div.appendChild(text);
+
+ let icon = document.createElement("i");
+ icon.className = "success fas fa-check-circle";
+ div.appendChild(icon);
+
+ notif([icon, div]);
+}
diff --git a/src/js/uBit.js b/src/js/uBit.js
new file mode 100644
index 0000000..0275ff9
--- /dev/null
+++ b/src/js/uBit.js
@@ -0,0 +1,142 @@
+/*
+ * This code is written with a lot of help from these resources:
+ * https://github.com/antefact/microBit.js/blob/master/src/microBit.js
+ * https://gist.github.com/kotobuki/7c67f8b9361e08930da1a5cfcfb0653f
+ * https://lancaster-university.github.io/microbit-docs/resources/bluetooth/bluetooth_profile.html
+ */
+const UART_SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
+/* Used for reading UART data from micro bit */
+const UART_TX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
+/* Used for writing UART data to micro bit */
+const UART_RX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
+/* The event service characteristic (which extends the uBit message bus over bluetooth) */
+const EVENT_SERVICE_CHARACTERISTIC_UUID = "e95d93af-251d-470a-a062-fa1922dfa9a8";
+/* This should be read once connected, as the ubit will advertise which events it wants to subscribe to */
+const UBIT_REQUIREMENT_CHARACTERISTIC_UUID = "e95db84c-251d-470a-a062-fa1922dfa9a8";
+/* The characteristic where we should write the events we wish to be informed of from the microbit */
+const CLIENTREQUIREMENTS_CHARACTERISTIC_UUID = "e95d23c4-251d-470a-a062-fa1922dfa9a8"
+/* The characteristic used for reading EventService messages */
+const UBITEVENT_CHARACTERISTIC_UUID = "e95d9775-251d-470a-a062-fa1922dfa9a8";
+/* The characteristic used for writing EventService messages */
+const CLIENTEVENT_CHARACTERISTIC_UUID = "e95d5404-251d-470a-a062-fa1922dfa9a8";
+
+/* This table is retrieved from this site:
+ * https://github.com/lancaster-university/microbit-dal/blob/master/inc/bluetooth/MESEvents.h */
+export const MESEvents = {
+ MES_DPAD_CONTROLLER_ID: 1104,
+ MES_DPAD_BUTTON_A_DOWN: 1,
+ MES_DPAD_BUTTON_A_UP: 2,
+ MES_DPAD_BUTTON_B_DOWN: 3,
+ MES_DPAD_BUTTON_B_UP: 4,
+ MES_DPAD_BUTTON_C_DOWN: 5,
+ MES_DPAD_BUTTON_C_UP: 6,
+ MES_DPAD_BUTTON_D_DOWN: 7,
+ MES_DPAD_BUTTON_D_UP: 8,
+ MES_DPAD_BUTTON_1_DOWN: 9,
+ MES_DPAD_BUTTON_1_UP: 10,
+ MES_DPAD_BUTTON_2_DOWN: 11,
+ MES_DPAD_BUTTON_2_UP: 12,
+ MES_DPAD_BUTTON_3_DOWN: 13,
+ MES_DPAD_BUTTON_3_UP: 14,
+ MES_DPAD_BUTTON_4_DOWN: 15,
+ MES_DPAD_BUTTON_4_UP: 16
+}
+
+class BluetoothService {
+ SERVICE_UUID = null;
+}
+
+class EventService extends BluetoothService {
+ static SERVICE_UUID = EVENT_SERVICE_CHARACTERISTIC_UUID;
+ service;
+
+ constructor(service, ubitEvent) {
+ super();
+ this.service = service;
+ this.ubitEvent = ubitEvent;
+ console.log("EventService initialized.");
+ }
+
+ sendEvent(event_type, event_value) {
+ this.ubitEvent.writeValue(
+ new Uint16Array([event_type, event_value])
+ );
+ }
+
+ static async getService(gattServer) {
+ console.debug("Getting EventService");
+ let service = await gattServer.getPrimaryService(EventService.SERVICE_UUID);
+ console.debug("Getting UBitevent characteristic");
+ let ubitEventCharacteristic = await service.getCharacteristic(CLIENTEVENT_CHARACTERISTIC_UUID);
+ return new EventService(service, ubitEventCharacteristic);
+ }
+}
+
+export class uBitBLE {
+ eventService;
+ device;
+
+ constructor() {
+ this.onConnectCallback = function() {};
+ this.onDisconnectCallback = function() {};
+ }
+
+ onConnect(callbackFunction) {
+ this.onConnectCallback = callbackFunction;
+ }
+
+ onDisconnect(callbackFunction) {
+ this.onDisconnectCallback = callbackFunction;
+ }
+
+ isConnected() {
+ if (this.device) {
+ return this.device.gatt.connected;
+ } else {
+ return false;
+ }
+ }
+
+ disconnect() {
+ if (this.isConnected()) {
+ this.device.gatt.disconnect();
+ }
+ }
+
+ async searchDevice() {
+ this.device = await navigator.bluetooth.requestDevice({
+ filters: [{namePrefix: "BBC micro:bit"}],
+ optionalServices: [EVENT_SERVICE_CHARACTERISTIC_UUID]
+ });
+ this.device.addEventListener('gattserverdisconnected', this.onDisconnectCallback);
+ console.log("Connected to new device", this.device.name, this.device.id);
+
+ console.debug("Connection to GATT server...");
+ const server = await this.device.gatt.connect()
+
+ this.onConnectCallback();
+ console.debug("Getting services...");
+
+ const eventService = await EventService.getService(server);
+ this.eventService = eventService;
+ }
+
+}
+
+function getSupportedProperties(characteristic) {
+ let supportedProperties = [];
+ for (const p in characteristic.properties) {
+ if (characteristic.properties[p] === true) {
+ supportedProperties.push(p.toUpperCase());
+ }
+ }
+ return '[' + supportedProperties.join(', ') + ']';
+}
+
+function eventByteArrayToString(event) {
+ let receivedData = [];
+ for (var i = 0; i < event.target.value.byteLength; i++) {
+ receivedData[i] = event.target.value.getUint8(i);
+ }
+ return String.fromCharCode.apply(null, receivedData);
+}