diff options
author | jakob.stendahl <jakob.stendahl@infomedia.dk> | 2023-01-15 22:35:42 +0100 |
---|---|---|
committer | Jakob Stendahl <jakob.stendahl@outlook.com> | 2023-01-15 22:35:42 +0100 |
commit | 3df1a8049dc693fb1a8835d2aafdd57b74aac407 (patch) | |
tree | 82b34456f34224a92f36591be908c69a4fddb3eb /src | |
parent | 216e1259c32c4775768da915b6fea9b8adc5c35f (diff) | |
download | microbit-gamepad-3df1a8049dc693fb1a8835d2aafdd57b74aac407.tar.gz microbit-gamepad-3df1a8049dc693fb1a8835d2aafdd57b74aac407.zip |
Initial commit
Diffstat (limited to 'src')
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 Binary files differnew file mode 100644 index 0000000..f54e70e --- /dev/null +++ b/src/img/icon/any/pwa-192x192.png diff --git a/src/img/icon/any/pwa-512x512.png b/src/img/icon/any/pwa-512x512.png Binary files differnew file mode 100644 index 0000000..a0bd1eb --- /dev/null +++ b/src/img/icon/any/pwa-512x512.png diff --git a/src/img/icon/maskable/maskable_icon_x1.png b/src/img/icon/maskable/maskable_icon_x1.png Binary files differnew file mode 100644 index 0000000..f81222c --- /dev/null +++ b/src/img/icon/maskable/maskable_icon_x1.png diff --git a/src/img/icon/maskable/maskable_icon_x128.png b/src/img/icon/maskable/maskable_icon_x128.png Binary files differnew file mode 100644 index 0000000..543833a --- /dev/null +++ b/src/img/icon/maskable/maskable_icon_x128.png diff --git a/src/img/icon/maskable/maskable_icon_x144.png b/src/img/icon/maskable/maskable_icon_x144.png Binary files differnew file mode 100644 index 0000000..de6573d --- /dev/null +++ b/src/img/icon/maskable/maskable_icon_x144.png diff --git a/src/img/icon/maskable/maskable_icon_x152.png b/src/img/icon/maskable/maskable_icon_x152.png Binary files differnew file mode 100644 index 0000000..b119eb2 --- /dev/null +++ b/src/img/icon/maskable/maskable_icon_x152.png diff --git a/src/img/icon/maskable/maskable_icon_x384.png b/src/img/icon/maskable/maskable_icon_x384.png Binary files differnew file mode 100644 index 0000000..0535cab --- /dev/null +++ b/src/img/icon/maskable/maskable_icon_x384.png diff --git a/src/img/icon/maskable/maskable_icon_x512.png b/src/img/icon/maskable/maskable_icon_x512.png Binary files differnew file mode 100644 index 0000000..ffec187 --- /dev/null +++ b/src/img/icon/maskable/maskable_icon_x512.png diff --git a/src/img/icon/maskable/maskable_icon_x72.png b/src/img/icon/maskable/maskable_icon_x72.png Binary files differnew file mode 100644 index 0000000..912eadd --- /dev/null +++ b/src/img/icon/maskable/maskable_icon_x72.png diff --git a/src/img/icon/maskable/maskable_icon_x96.png b/src/img/icon/maskable/maskable_icon_x96.png Binary files differnew file mode 100644 index 0000000..63241b8 --- /dev/null +++ b/src/img/icon/maskable/maskable_icon_x96.png 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]) + } + })) + }) + ) +}) |