aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorjakob.stendahl <jakob.stendahl@infomedia.dk>2023-01-15 22:35:42 +0100
committerJakob Stendahl <jakob.stendahl@outlook.com>2023-01-15 22:35:42 +0100
commit3df1a8049dc693fb1a8835d2aafdd57b74aac407 (patch)
tree82b34456f34224a92f36591be908c69a4fddb3eb /src
parent216e1259c32c4775768da915b6fea9b8adc5c35f (diff)
downloadmicrobit-gamepad-3df1a8049dc693fb1a8835d2aafdd57b74aac407.tar.gz
microbit-gamepad-3df1a8049dc693fb1a8835d2aafdd57b74aac407.zip
Initial commit
Diffstat (limited to 'src')
-rw-r--r--src/img/icon/any/pwa-192x192.pngbin0 -> 13351 bytes
-rw-r--r--src/img/icon/any/pwa-512x512.pngbin0 -> 62946 bytes
-rw-r--r--src/img/icon/maskable/maskable_icon_x1.pngbin0 -> 160793 bytes
-rw-r--r--src/img/icon/maskable/maskable_icon_x128.pngbin0 -> 4812 bytes
-rw-r--r--src/img/icon/maskable/maskable_icon_x144.pngbin0 -> 5480 bytes
-rw-r--r--src/img/icon/maskable/maskable_icon_x152.pngbin0 -> 5847 bytes
-rw-r--r--src/img/icon/maskable/maskable_icon_x384.pngbin0 -> 25551 bytes
-rw-r--r--src/img/icon/maskable/maskable_icon_x512.pngbin0 -> 40783 bytes
-rw-r--r--src/img/icon/maskable/maskable_icon_x72.pngbin0 -> 2068 bytes
-rw-r--r--src/img/icon/maskable/maskable_icon_x96.pngbin0 -> 3054 bytes
-rw-r--r--src/index.html91
-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
-rw-r--r--src/manifest.webmanifest65
-rw-r--r--src/scss/gamepad.scss12
-rw-r--r--src/scss/landscape-warning.scss39
-rw-r--r--src/scss/main.scss97
-rw-r--r--src/scss/settings.scss136
-rw-r--r--src/scss/statusline.scss130
-rw-r--r--src/scss/styles.scss20
-rw-r--r--src/service-worker.js55
23 files changed, 1752 insertions, 0 deletions
diff --git a/src/img/icon/any/pwa-192x192.png b/src/img/icon/any/pwa-192x192.png
new file mode 100644
index 0000000..f54e70e
--- /dev/null
+++ b/src/img/icon/any/pwa-192x192.png
Binary files differ
diff --git a/src/img/icon/any/pwa-512x512.png b/src/img/icon/any/pwa-512x512.png
new file mode 100644
index 0000000..a0bd1eb
--- /dev/null
+++ b/src/img/icon/any/pwa-512x512.png
Binary files differ
diff --git a/src/img/icon/maskable/maskable_icon_x1.png b/src/img/icon/maskable/maskable_icon_x1.png
new file mode 100644
index 0000000..f81222c
--- /dev/null
+++ b/src/img/icon/maskable/maskable_icon_x1.png
Binary files differ
diff --git a/src/img/icon/maskable/maskable_icon_x128.png b/src/img/icon/maskable/maskable_icon_x128.png
new file mode 100644
index 0000000..543833a
--- /dev/null
+++ b/src/img/icon/maskable/maskable_icon_x128.png
Binary files differ
diff --git a/src/img/icon/maskable/maskable_icon_x144.png b/src/img/icon/maskable/maskable_icon_x144.png
new file mode 100644
index 0000000..de6573d
--- /dev/null
+++ b/src/img/icon/maskable/maskable_icon_x144.png
Binary files differ
diff --git a/src/img/icon/maskable/maskable_icon_x152.png b/src/img/icon/maskable/maskable_icon_x152.png
new file mode 100644
index 0000000..b119eb2
--- /dev/null
+++ b/src/img/icon/maskable/maskable_icon_x152.png
Binary files differ
diff --git a/src/img/icon/maskable/maskable_icon_x384.png b/src/img/icon/maskable/maskable_icon_x384.png
new file mode 100644
index 0000000..0535cab
--- /dev/null
+++ b/src/img/icon/maskable/maskable_icon_x384.png
Binary files differ
diff --git a/src/img/icon/maskable/maskable_icon_x512.png b/src/img/icon/maskable/maskable_icon_x512.png
new file mode 100644
index 0000000..ffec187
--- /dev/null
+++ b/src/img/icon/maskable/maskable_icon_x512.png
Binary files differ
diff --git a/src/img/icon/maskable/maskable_icon_x72.png b/src/img/icon/maskable/maskable_icon_x72.png
new file mode 100644
index 0000000..912eadd
--- /dev/null
+++ b/src/img/icon/maskable/maskable_icon_x72.png
Binary files differ
diff --git a/src/img/icon/maskable/maskable_icon_x96.png b/src/img/icon/maskable/maskable_icon_x96.png
new file mode 100644
index 0000000..63241b8
--- /dev/null
+++ b/src/img/icon/maskable/maskable_icon_x96.png
Binary files differ
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..525c5de
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html lang="en" dir="ltr">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="viewport-fit=cover,width=device-width, initial-scale=1, shrink-to-fit=no,initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+ <meta name="HandheldFriendly" content="true" />
+
+ <title>MICRO:BIT gamepad</title>
+ <link rel="canonical" rel="https://jakobst1n.github.io/microbit-gamepad/">
+
+ <link rel="stylesheet" type="text/css" href="./scss/styles.scss">
+ <link rel="manifest" href="manifest.webmanifest" crossorigin="use-credentials">
+
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Kufam&display=swap" rel="stylesheet">
+ <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
+ </head>
+ <body class="">
+ <div class="app-info">
+ <h1>MICRO:BIT gamepad</h1>
+ <span class="version">{{ VERSION }}</span>
+ </div>
+
+ <button title="Show settings dialog" class="settings-button" id="btn_show_settings">
+ <i class="fas fa-cog" aria-hidden="true"></i>
+ </button>
+
+ <div class="settings-dialog">
+ <div class="header">
+ <h1>Settings</h1>
+ <button title="Close settings dialog" class="close-settings" id="btn_hide_settings">
+ <i class="fas fa-times" aria-hidden="true"></i>
+ </button>
+ </div>
+ <div class="content">
+ <form>
+ <label class="form-control" for="layout">
+ Gamepad layout
+ <select name="layout" id="layout">
+ <option value="1">DPad + Buttons</option>
+ <option value="2">DPad + DPad</option>
+ <option value="9">Joystick + Joystick (not micro:bit compatible)</option>
+ </select>
+ </label>
+ <label class="form-control form-cb" for="show-gamepad-alt-text">
+ <input type="checkbox" name="show-gamepad-alt-text" id="show-gamepad-alt-text">
+ Show gamepad keybindings
+ </label>
+ <label class="form-control form-cb" for="enable-haptic">
+ <input type="checkbox" name="enable-haptic" id="enable-haptic">
+ Enable haptic feedback
+ </label>
+ <label class="form-control form-cb" for="show-touches">
+ <input type="checkbox" name="show-touches" id="show-touches">
+ Show touches
+ </label>
+ <label class="form-control form-cb" for="enable-debug">
+ <input type="checkbox" name="enable-debug" id="enable-debug">
+ Show debug data
+ </label>
+ </form>
+ </div>
+ </div>
+
+ <div class="landscape-warning">
+ <div class="landscape-warning-content">
+ <i class="fas fa-sync-alt"></i>
+ <h1>Please use landscape mode</h1>
+ <button id="btn_ignore_landscape_warning">Ignore</button>
+ </div>
+ </div>
+
+ <button class="button-center button-center-top" title="Search for bluetooth device" id="btn_connect">CONNECT</button>
+ <button class="button-center button-center-top" title="Disconnect from connected bluetooth device" id="btn_disconnect">DISCONNECT</button>
+
+ <pre class="gamepad-touches" style="margin-left:10px;margin-top:50px;font-size: 8px;"></pre>
+ <div class="gamepad-wrapper" style="position:absolute;top:0;left:0;"></div>
+
+ <div class="button-states"><pre></pre></div>
+
+ <div class="statusline">
+ <span class="statusline-item connection">
+ <i class="fab fa-bluetooth-b" aria-hidden="true"></i>
+ </span>
+ <span class="notification-area"></span>
+ </div>
+
+ <script type="text/javascript" src="./js/main.js"></script>
+ </body>
+</html>
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);
+}
diff --git a/src/manifest.webmanifest b/src/manifest.webmanifest
new file mode 100644
index 0000000..478c874
--- /dev/null
+++ b/src/manifest.webmanifest
@@ -0,0 +1,65 @@
+{
+ "background_color": "#454545",
+ "theme_color": "#5ac775",
+ "name": "MICROBIT gamepad",
+ "short_name": "MICROBIT gamepad",
+ "display": "fullscreen",
+ "start_url": "/microbit-gamepad/",
+ "orientation": "landscape",
+ "icons": [
+ {
+ "src": "img/icon/any/pwa-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "img/icon/any/pwa-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "img/icon/maskable/maskable_icon_x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "img/icon/maskable/maskable_icon_x384.png",
+ "sizes": "384x384",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "img/icon/maskable/maskable_icon_x152.png",
+ "sizes": "152x152",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "img/icon/maskable/maskable_icon_x144.png",
+ "sizes": "144x144",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "img/icon/maskable/maskable_icon_x128.png",
+ "sizes": "128x128",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "img/icon/maskable/maskable_icon_x96.png",
+ "sizes": "96x96",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "img/icon/maskable/maskable_icon_x72.png",
+ "sizes": "72x72",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/src/scss/gamepad.scss b/src/scss/gamepad.scss
new file mode 100644
index 0000000..997fee8
--- /dev/null
+++ b/src/scss/gamepad.scss
@@ -0,0 +1,12 @@
+#gamepad-wrapper {
+ width: 100%;
+ height: 100%;
+}
+
+canvas {
+ z-index: -999;
+ position:fixed;
+
+ width: 100%;
+ height: 100%;
+}
diff --git a/src/scss/landscape-warning.scss b/src/scss/landscape-warning.scss
new file mode 100644
index 0000000..443102a
--- /dev/null
+++ b/src/scss/landscape-warning.scss
@@ -0,0 +1,39 @@
+.landscape-warning {
+ display: none;
+ position: absolute;
+ z-index: 9999999;
+ width: 100%;
+ height: 100%;
+ padding-top: 50%;
+ background-color: $background-base;
+ text-align: center;
+
+ &-content {
+ position: relative;
+ top: -50px;
+ }
+
+ i { font-size: 5em; }
+
+ h1 {
+ font-size: 1em;
+ margin: 10px;
+ }
+
+ button {
+ border: none;
+ background-color: $background-z1;
+ color: $foreground-z1;
+ padding: 10px;
+ width: 140px;
+ height: 40px;
+ }
+
+ .ignore-landscape-warning & { display: none !important; }
+}
+
+@media screen and (orientation:portrait) {
+ .landscape-warning {
+ display: block;
+ }
+}
diff --git a/src/scss/main.scss b/src/scss/main.scss
new file mode 100644
index 0000000..08d7a44
--- /dev/null
+++ b/src/scss/main.scss
@@ -0,0 +1,97 @@
+html, body {
+ margin: 0;
+ padding: 0;
+ /* width: 100%;
+ height: 100%; */
+ font-family: monospace;
+ overflow: hidden;
+ font-family: 'Kufam', 'Courier new', 'monospace';
+}
+
+body {
+ // margin: 10px;
+ background-color: $background-base;
+ color: $foreground-base;
+}
+
+.app-info {
+ position: absolute;
+ top: 15px;
+ left: 10px;
+ left: max(env(safe-area-inset-left, 10px), 10px);
+
+ h1 {
+ font-size: 14px;
+ margin: 0;
+ color: $foreground-base;
+ }
+
+ .version {
+ display: block;
+ font-size: 9px;
+ }
+}
+
+button:hover {
+ filter: brightness(80%);
+}
+button:active {
+ filter: brightness(70%);
+}
+
+.button-center {
+ position: absolute;
+ display: none;
+ margin-top: 5px;
+ border: none;
+ background-color: $background-z1;
+ color: $foreground-z1;
+ padding: 10px;
+ width: 140px;
+ height: 40px;
+ left: calc(50% - 70px);
+ font-weight: 400;
+ font-size: 15px;
+
+ .armed & { display: block; }
+ &-top { top: 10px; }
+ &-bottom { bottom: 50px; }
+}
+
+#btn_connect { display: block; }
+.connected #btn_connect { display: none; }
+.connected #btn_disconnect { display: block; }
+.connected #btn_arm { display: block; }
+#btn_connect, #btn_disconnect {
+ text-shadow: 0 0 5px #c3c3c3, 0 0 10px #636363;
+}
+
+.settings-button {
+ position: absolute;
+ background: $background-base;
+ color: $foreground-z1;
+ border: none;
+ top: 15px;
+ font-size: 1.3em;
+ right: max(env(safe-area-inset-left, 10px), 10px);
+}
+.settings-button:hover {
+ color: $foreground-base;
+ filter: brightness(100%);
+}
+
+.button-states {
+ display: none;
+ position: absolute;
+ top: 60px;
+ left: calc(50% - 80px - 10px);
+ width: 160px;
+ text-align: center;
+ background-color: #272727;
+ padding: 0 10px;
+ border-radius: 10px;
+ font-size: 9px;
+
+ .connected & { display: block; }
+ .debug & { display: block; }
+}
diff --git a/src/scss/settings.scss b/src/scss/settings.scss
new file mode 100644
index 0000000..0fa052d
--- /dev/null
+++ b/src/scss/settings.scss
@@ -0,0 +1,136 @@
+/* Only show animations when user has not set a preference not to show motion */
+@media (prefers-reduced-motion: no-preference) {
+ .settings-dialog {
+ transition: all 0.5s ease;
+
+ input[type="checkbox"]::before {
+ transition: 120ms transform ease-in-out;
+ }
+ }
+}
+
+.settings-dialog {
+ position: absolute;
+ background-color: $background-z2;
+ color: $foreground-z2;
+ top: 100%;
+ left: 0;
+ right: 0;
+ bottom: 40px;
+ z-index: 999;
+ box-sizing: border-box;
+ z-index: 1;
+ height: calc(100% - 40px);
+ flex-direction: column;
+
+ .header, .content { padding: 10px; }
+
+ &.shown {
+ display: flex;
+ top: 0;
+ }
+
+ button {
+ background-color: $background-z1;
+ color: $foreground-z1;
+ border: none;
+ width: fit-content;
+ height: fit-content;
+ font-size: 1.3em;
+ }
+
+ .header {
+ background-color: $background-z1;
+ display: flex;
+ box-sizing: border-box;
+ align-items: center;
+
+ button {
+ margin-left: auto;
+ }
+ }
+
+ select {
+ background: $background-z1;
+ color: $foreground-z1;
+ border: none;
+ padding: 5px;
+ display: block;
+
+ }
+
+ h1 {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+
+ .content {
+ height: 100%;
+ overflow: auto;
+ }
+
+ form {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ flex-wrap: wrap;
+ }
+
+ .form-control {
+ margin-bottom: 5px;
+ margin-top: 5px;
+ }
+
+ .form-cb {
+ line-height: 1.1;
+ display: grid;
+ grid-template-columns: 1em auto;
+ gap: 0.5em;
+ }
+
+ input[type="checkbox"] {
+ appearance: none;
+ background: $background-z1;
+ color: $foreground-z1;
+ margin: 0;
+
+ font: inherit;
+ color: currentColor;
+ width: 1.15em;
+ height: 1.15em;
+ border: none;
+ border-radius: 0.15em;
+ transform: translateY(-0.075em);
+
+ display: grid;
+ place-content: center;
+ }
+
+ input[type="checkbox"]::before {
+ content: "";
+ width: 0.65em;
+ height: 0.65em;
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
+ transform: scale(0);
+ transform-origin: bottom left;
+ box-shadow: inset 1em 1em $foreground-z1;
+ /* Windows High Contrast Mode */
+ background-color: CanvasText;
+ }
+
+ input[type="checkbox"]:checked::before {
+ transform: scale(1);
+ }
+
+ input[type="checkbox"]:focus {
+ outline: max(2px, 0.15em) solid currentColor;
+ outline-offset: max(2px, 0.15em);
+ }
+
+ input[type="checkbox"]:disabled {
+ --form-control-color: var(--form-control-disabled);
+
+ color: var(--form-control-disabled);
+ cursor: not-allowed;
+ }
+}
diff --git a/src/scss/statusline.scss b/src/scss/statusline.scss
new file mode 100644
index 0000000..cf5f8f2
--- /dev/null
+++ b/src/scss/statusline.scss
@@ -0,0 +1,130 @@
+.statusline {
+ position: absolute;
+ display: flex;
+ bottom: 0;
+ left: 0;
+ height: 40px;
+ /* padding-left: 5px; */
+ width: calc(100%);
+ font-size: 12px;
+ margin: 0;
+ align-items: center;
+ z-index: 2;
+
+ background-color: $background-z1;
+ i {
+ font-size: 14px;
+ margin-right: 5px;
+ }
+
+ &-item {
+ display: none;
+ align-items: flex-start;
+ margin-left: 5px;
+ margin-right: 5px;
+ font-size: 13px;
+
+ color: $foreground-z1;
+
+ .connected & { display: flex; }
+ }
+
+ &-item:first-child {
+ margin-left: 10px;
+ margin-left: calc(env(safe-area-inset-left) + 10px);
+ }
+
+ &-item:last-child {
+ margin-right: 10px;
+ margin-right: calc(env(safe-area-inset-right) + 10px);
+ }
+}
+
+.connection {
+ display: flex;
+ color: $color-danger;
+
+ &:after {
+ display: block;
+ content: "DISCONNECTED";
+ }
+
+ .connected & { color: $color-success; }
+ .connected &:after {
+ display: block;
+ content: "CONNECTED";
+ }
+}
+
+.ping {
+ margin-left: auto;
+
+ i {
+ -webkit-animation: ping-fade-out 2s forwards; /* Safari 4+ */
+ -moz-animation: ping-fade-out 2s forwards; /* Fx 2+ */
+ -o-animation: ping-fade-out 2s forwards; /* Opera 12+ */
+ animation: ping-fade-out 2s forwards; /* IE 10+, Fx 29+ */
+ }
+
+ @-webkit-keyframes ping-fade-out {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+ }
+}
+
+.notification {
+ background-color: $background-z2;
+ color: $foreground-z2;
+ overflow: hidden;
+ display: flex;
+ align-items: baseline;
+
+ &-area {
+ background-color: $background-z2;
+ height: 100%;
+ width: 0;
+ overflow: hidden;
+ margin-left: 5px;
+ margin-left: auto;
+ transition: all 1s ease;
+ }
+
+ &-area.show {
+ width: 100%;
+ }
+
+ &-content {
+ margin-left: 5px;
+ margin-right: 5px;
+ width: 100%;
+ overflow: hidden;
+ }
+
+ i {
+ margin-left: 5px;
+ margin-right: 5px;
+ }
+
+ i.alert { color: $color-danger; }
+ i.info { color: $color-info; }
+ i.success { color: $color-success; }
+ i.warning { color: $color-warning; }
+
+ p {
+ width: max-content;
+ -webkit-animation: scroll-text 10s linear forwards; /* Safari 4+ */
+ -moz-animation: scroll-text 10s linear forwards; /* Fx 2+ */
+ -o-animation: scroll-text 10s linear forwards; /* Opera 12+ */
+ animation: scroll-text 10s linear forwards; /* IE 10+, Fx 29+ */
+ }
+
+ @-webkit-keyframes scroll-text {
+ 0% { margin-left: 100%; }
+ 100% { margin-left: -100%; }
+ }
+
+ @-webkit-keyframes fade-in {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+ }
+}
diff --git a/src/scss/styles.scss b/src/scss/styles.scss
new file mode 100644
index 0000000..60b46b2
--- /dev/null
+++ b/src/scss/styles.scss
@@ -0,0 +1,20 @@
+@import '@fortawesome/fontawesome-free/css/all.css';
+
+$background-base: #454545;
+$foreground-base: #9c9c9c;
+$background-z1: #333333;
+$foreground-z1: #b5b5b5;
+$background-z2: #252525;
+$foreground-z2: #b5b5b5;
+
+$color-danger: #c32222;
+$color-info: #0374a8;
+$color-success: #008000;
+$color-warning: #d99b0b;
+
+@import "main.scss";
+@import "statusline.scss";
+@import "settings.scss";
+@import "gamepad.scss";
+@import "landscape-warning.scss";
+
diff --git a/src/service-worker.js b/src/service-worker.js
new file mode 100644
index 0000000..96accb3
--- /dev/null
+++ b/src/service-worker.js
@@ -0,0 +1,55 @@
+var APP_PREFIX = 'hoverbitcontroller' // Identifier for this app (this needs to be consistent across every cache update)
+var VERSION = '{{ VERSION }}' // Version of the off-line cache (change this value everytime you want to update cache)
+var CACHE_NAME = APP_PREFIX + VERSION
+var URLS = ["{{ CACHE_FILES }}"] // This will be replaced by the deploy-script
+
+// Respond with cached resources
+self.addEventListener('fetch', function (e) {
+ console.log('fetch request : ' + e.request.url)
+ e.respondWith(
+ caches.match(e.request).then(function (request) {
+ if (request) { // if cache is available, respond with cache
+ console.log('responding with cache : ' + e.request.url)
+ return request
+ } else { // if there are no cache, try fetching request
+ console.log('file is not cached, fetching : ' + e.request.url)
+ return fetch(e.request)
+ }
+
+ // You can omit if/else for console.log & put one line below like this too.
+ // return request || fetch(e.request)
+ })
+ )
+})
+
+// Cache resources
+self.addEventListener('install', function (e) {
+ e.waitUntil(
+ caches.open(CACHE_NAME).then(function (cache) {
+ console.log('installing cache : ' + CACHE_NAME)
+ return cache.addAll(URLS)
+ })
+ )
+})
+
+// Delete outdated caches
+self.addEventListener('activate', function (e) {
+ e.waitUntil(
+ caches.keys().then(function (keyList) {
+ // `keyList` contains all cache names under your username.github.io
+ // filter out ones that has this app prefix to create white list
+ var cacheWhitelist = keyList.filter(function (key) {
+ return key.indexOf(APP_PREFIX)
+ })
+ // add current cache name to white list
+ cacheWhitelist.push(CACHE_NAME)
+
+ return Promise.all(keyList.map(function (key, i) {
+ if (cacheWhitelist.indexOf(key) === -1) {
+ console.log('deleting cache : ' + keyList[i] )
+ return caches.delete(keyList[i])
+ }
+ }))
+ })
+ )
+})