diff options
Diffstat (limited to 'src/js/gamepad.js')
-rw-r--r-- | src/js/gamepad.js | 664 |
1 files changed, 664 insertions, 0 deletions
diff --git a/src/js/gamepad.js b/src/js/gamepad.js new file mode 100644 index 0000000..5ab4130 --- /dev/null +++ b/src/js/gamepad.js @@ -0,0 +1,664 @@ +class CanvasStage { + canvas; + #dpi = window.devicePixelRatio; + #width; + #height; + #ctx; + #elements = []; + touches = {}; + showTouches = false; + + constructor(id, node) { + this.canvas = document.createElement("canvas"); + this.canvas.setAttribute("id", id); + node.appendChild(this.canvas); + + addEventListener("resize", () => this.resize()); + this.resize(); + + console.debug("Created canvas", this.canvas); + + setInterval(() => { + this.drawElements(); + }, 10); + } + + /* Resizes the canvas to be the correct size for the current screen */ + resize() { + this.#ctx = this.canvas.getContext("2d"); + this.#height = +getComputedStyle(this.canvas).getPropertyValue("height").slice(0, -2); + this.#width = +getComputedStyle(this.canvas).getPropertyValue("width").slice(0, -2); + this.canvas.setAttribute('height', this.#height * this.#dpi); + this.canvas.setAttribute('width', this.#width * this.#dpi); + } + + /* Translate a screen x coordinate to a canvas x coordinate */ + screenToCanvasX(x) { return x * this.#dpi; } + + /* Translate a screen y coordinate to a canvas y coordinate */ + screenToCanvasY(y) { return y * this.#dpi; } + + /* Get target at position, i.e. the element that intersects said position */ + getTarget(x, y) { + x *= this.#dpi; + y *= this.#dpi; + for (let i = 0; i < this.#elements.length; i++) { + if ((this.#elements[i] instanceof TouchElement) + && (this.#elements[i].collides(this.#ctx, x, y))) { + return this.#elements[i]; + } + } + } + + /* Redraws all elements of the stage on the screen. */ + drawElements() { + this.#ctx.clearRect(0, 0, this.#width * this.#dpi, this.#height * this.#dpi); + for (let i = 0; i < this.#elements.length; i++) { + this.#elements[i].draw(this.#ctx); + } + if (this.showTouches) { + this.drawTouches(); + } + } + + /* Draws all touches on the screen, used to debug */ + drawTouches(e) { + const colors = ["200, 0, 0", "0, 200, 0", "0, 0, 200", "200, 200, 0", "200, 200, 200"] + for (const [identifier, touch] of Object.entries(this.touches)) { + this.#ctx.beginPath(); + this.#ctx.arc(touch.x * this.#dpi, touch.y * this.#dpi, 20 * this.#dpi, 0, 2*Math.PI, true); + this.#ctx.fillStyle = `rgba(${colors[identifier]}, 0.2)`; + this.#ctx.fill(); + + this.#ctx.lineWidth = 2.0; + this.#ctx.strokeStyle = `rgba(${colors[identifier]}, 0.8)`; + this.#ctx.stroke(); + } + } + + /* Add a element to the stage */ + addElement(element) { + this.#elements.push(element); + element.init(); + } + + /* Remove a element from the stage by id */ + removeElementById(id) { + for (let i = 0; i < this.#elements.length; i++) { + if (id === this.#elements[i].id) { + this.#elements.splice(i, 1); + return; + } + } + } + + /* Wipe all elements from the stage */ + removeAllElements() { + this.#elements.splice(0, this.#elements.length); + } + +} + +class Element { + gamepad; + id; + x; + y; + alignX; + alignY; + path; + isInside; + isActive; + type = "Element"; + + constructor(opts, gamepad) { + let _opts = Object.assign({ + id: null, + x: 0, + y: 0, + alignX: null, + alignY: null + }, opts); + this.id = _opts.id; + this.x = _opts.x; + this.y = _opts.y; + this.alignX = _opts.alignX; + this.alignY = _opts.alignY; + this.gamepad = gamepad; + } + + /* Used for initializing the element onto the stage */ + init() {} + + /* Get the x-axis scaling factor (currently unused, only the y scaling factor is in use) */ + getScaleX(ctx) { + return ctx.canvas.width / 100; + } + + /* Get the y-axis scaling factor */ + getScaleY(ctx) { + return ctx.canvas.height / 100; + } + + /* Get the canvas x position of this element, adjusted from the virtual canvas coordinates */ + getX(ctx) { + let x = this.x * this.getScaleY(ctx); + if (this.alignX === "center") { + x = (ctx.canvas.width / 2) + x; + } + if (this.alignX === "right") { + x = ctx.canvas.width - x; + } + return x; + } + + /* Get the canvas y position of this element, adjusted from the virtual canvas coordinates */ + getY(ctx) { + let y = this.y * this.getScaleY(ctx); + if (this.alignY === "center") { + y = (ctx.canvas.height / 2) + y; + } + if (this.alignY === "bottom") { + y = ctx.canvas.height - y; + } + return y; + } + + /* Used to draw the element onto a canvas context */ + draw(ctx) {} + + /* Used to check wether the coordinates is inside this element */ + collides(ctx, x, y) { + this.isInside = ctx.isPointInPath(this.path, x, y); + return this.isInside; + } + +} + +export class Square extends Element { + draw(ctx) { + this.path = new Path2D(); + let w = this.getScaleY(ctx) * 20; + this.path.rect(this.getX(ctx) - (w/2), this.getY(ctx) - (w/2), w, w); + ctx.fillStyle = `rgba(100, 100, 100, 0.8)`; + ctx.fill(this.path); + } +} + +class TouchElement extends Element { + type = "TouchElement"; + touchCount = 0; + + setActive(e, doCallbacks = true) { + if (["end", "cancel"].includes(e.type)) { this.touchCount--; } + let eState = e.type == "start"; + if ((eState !== this.isActive) && (this.touchCount == 0)) { + this.isActive = eState; + if (doCallbacks) { + this.gamepad.handleTouchEventCallbacks(this.createTouchEventObject( + this.isActive ? "touchstart" : "touchend" + )); + } + } + if (e.type == "start") { this.touchCount++; } + } + + createTouchEventObject(action) { + return { + id: this.id, + action: action, + type: this.type + } + } + +} + +export class GamepadButton extends TouchElement { + shape; + altText; + altTextAlign; + type = "GamepadButton"; + + constructor(opts) { + let _opts = Object.assign({ + keyboardButton: null, + altText: null, + altTextAlign: "left", + shape: "round" + }, opts); + super(opts); + this.keyboardButton = _opts.keyboardButton; + this.shape = _opts.shape; + this.altText = _opts.altText; + this.altTextAlign = _opts.altTextAlign; + } + + init() { + if (this.keyboardButton !== null) { + this.gamepad.registerKeybinding(this.keyboardButton, this); + } + } + + draw(ctx) { + this.path = new Path2D(); + if (this.shape === "round") { + this.path.arc(this.getX(ctx), this.getY(ctx), this.getScaleY(ctx) * 10, 0, 4*Math.PI, true); + } else if (this.shape === "square") { + let w = this.getScaleY(ctx) * 20; + this.path.rect(this.getX(ctx) - (w/2), this.getY(ctx) - (w/2), w, w); + } + if (this.isActive) { + ctx.fillStyle = `rgba(80, 80, 80, 1)`; + } else { + ctx.fillStyle = `rgba(100, 100, 100, 0.8)`; + } + ctx.fill(this.path); + + let s = `${Math.floor((this.getScaleY(ctx)*8).toString())}px 'Press Start 2P'`; + ctx.font = s; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + ctx.fillStyle = `rgba(255, 255, 255, 1)`; + ctx.fillText(this.id, this.getX(ctx), this.getY(ctx)); + + if ((this.altText !== null) && (this.gamepad.showAltText)) { + ctx.beginPath(); + ctx.font = `${Math.floor((this.getScaleY(ctx)*3).toString())}px 'Press Start 2P'`; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + ctx.fillStyle = `rgba(150, 150, 150, 1)`; + let ax = this.getX(ctx); + let ay = this.getY(ctx); + switch (this.altTextAlign) { + case "left": + ax -= (this.getScaleY(ctx) * 13); + break; + case "right": + ax += (this.getScaleY(ctx) * 13); + break; + case "top": + ay -= (this.getScaleY(ctx) * 13); + break; + case "bottom": + ay += (this.getScaleY(ctx) * 13); + break; + } + ctx.fillText(this.altText, ax, ay); + } + } + +} + +export class GamepadJoystick extends TouchElement { + type = "GamepadJoystick"; + mouseX = 0; + mouseY = 0; + cR = 0; + cX = 0; + cY = 0; + + #lockX; + #lockY; + + #pressedKeys = {}; + + constructor(opts) { + let _opts = Object.assign({ + lockX: false, + lockY: false, + autoCenter: true, + bindUp: null, + bindLeft: null, + bindRight: null, + bindDown: null + }, opts); + super(opts); + this.#lockX = _opts.lockX; + this.#lockY = _opts.lockY; + this.bindUp = _opts.bindUp; + this.bindLeft = _opts.bindLeft; + this.bindRight = _opts.bindRight; + this.bindDown = _opts.bindDown; + } + + init() { + if (this.bindUp !== null) { + this.gamepad.registerKeybinding(this.bindUp, this); + } + if (this.bindLeft !== null) { + this.gamepad.registerKeybinding(this.bindLeft, this); + } + if (this.bindRight !== null) { + this.gamepad.registerKeybinding(this.bindRight, this); + } + if (this.bindDown !== null) { + this.gamepad.registerKeybinding(this.bindDown, this); + } + } + + isKeyPressed(key) { + return ((key !== null) + && (this.#pressedKeys.hasOwnProperty(key)) + && (this.#pressedKeys[key] > 0)); + } + + createTouchEventObject(action) { + return { + id: this.id, + action: action, + type: this.type, + x: Math.round((this.mouseX / this.cR) * 100), + y: Math.round((this.mouseY / this.cR) * 100) + } + } + + setActive(e) { + super.setActive(e, false); + if (e.hasOwnProperty("key")) { + if (!this.#pressedKeys.hasOwnProperty(e.key)) { + this.#pressedKeys[e.key] = 0; + } + if (["start"].includes(e.type)) { + this.#pressedKeys[e.key]++; + } + if (["end", "cancel"].includes(e.type)) { + this.#pressedKeys[e.key]--; + } + } + + let max = this.cR + if (!this.#lockX) { + if (e.hasOwnProperty("x")) { + this.mouseX = this.cX - this.gamepad.stage.screenToCanvasX(e.x); + this.mouseX = Math.min(Math.abs(this.mouseX), max) * Math.sign(this.mouseX); + this.mouseX *= -1; + } + if (this.isKeyPressed(this.bindLeft)) { this.mouseX = -max; } + if (this.isKeyPressed(this.bindRight)) { this.mouseX = max; } + if (this.isKeyPressed(this.bindLeft) && this.isKeyPressed(this.bindRight)) { this.mouseX = 0; } + if (!this.isActive) { this.mouseX = 0; } + } + if (!this.#lockY) { + if (e.hasOwnProperty("y")) { + this.mouseY = this.cY - this.gamepad.stage.screenToCanvasY(e.y); + this.mouseY = Math.min(Math.abs(this.mouseY), max) * Math.sign(this.mouseY); + } + if (this.isKeyPressed(this.bindUp)) { this.mouseY = max; } + if (this.isKeyPressed(this.bindDown)) { this.mouseY = -max; } + if (this.isKeyPressed(this.bindUp) && this.isKeyPressed(this.bindDown)) { this.mouseY = 0; } + if (!this.isActive) { this.mouseY = 0; } + } + + let action = "touchmove"; + if (this.isActive && (this.touchCount == 1) && (e.type === "start")) { + action = "touchstart"; + } + if (!this.isActive) { + action = "touchend"; + } + this.gamepad.handleTouchEventCallbacks(this.createTouchEventObject(action)); + } + + draw(ctx) { + this.cX = this.getX(ctx); + this.cY = this.getY(ctx); + this.cR = this.getScaleY(ctx) * 25; + + this.path = new Path2D(); + this.path.arc(this.cX, this.cY, this.cR, 0, 4*Math.PI, true); + if (this.isActive) { + ctx.fillStyle = `rgba(85, 85, 85, 0.8)`; + } else { + ctx.fillStyle = `rgba(100, 100, 100, 0.8)`; + } + ctx.fill(this.path); + + ctx.beginPath(); + ctx.arc(this.cX + this.mouseX, this.cY - this.mouseY, this.getScaleY(ctx) * 15, 0, 4*Math.PI, true); + ctx.fillStyle = `rgba(130, 130, 130, 1)`; + ctx.fill(); + + } + +} + +export class Gamepad { + stage; + #width; + #height; + + #touches = {}; + #keybindings = {}; + #keystates = {}; + #touchEventCallbacks = []; + + showDebug = false; + showAltText = true; + enableVibration = true; + + constructor() { + this.stage = new CanvasStage("GamePad", document.querySelector(".gamepad-wrapper")); + this.addEventListeners(); + } + + addEventListeners() { + let ev = ["keydown", "keyup"]; + for(var e in ev) { + document.addEventListener(ev[e], (e) => this.handleKeyEvent(e), false); + } + ev = ["touchstart", "touchend", "touchcancel", "touchmove"]; + for(var e in ev) { + this.stage.canvas.addEventListener(ev[e], (e) => this.handleTouchEvent(e), false); + } + ev = ["mousedown", "mouseup", "mousemove"]; + for(var e in ev) { + this.stage.canvas.addEventListener(ev[e], (e) => this.handleMouseEvent(e), false); + } + } + + /* Used by stage elements to register themselves with some keybinding */ + registerKeybinding(binding, element) { + this.#keybindings[binding] = element; + } + + /* Event handler for keyboard events */ + handleKeyEvent(e) { + const typedict = {"keydown": "start", "keyup": "end"} + if (!this.#keystates.hasOwnProperty(e.keyCode)) { + this.#keystates[e.keyCode] = {pressed: false}; + } + if (this.#keybindings.hasOwnProperty(e.key)) { + let id = `Key ${e.key}` + let target = this.#keybindings[e.key]; + let gtEvent = { + touchId: id, + key: e.key, + type: typedict[e.type] + }; + switch (e.type) { + case "keydown": + if (this.#keystates[e.keyCode].pressed) { return; } + this.#keystates[e.keyCode].pressed = true; + + this.#touches[id] = {}; + this.#touches[id].target = target; + if (this.#touches[id].hasOwnProperty("target") + && this.#touches[id].target != null) { + this.#touches[id].target.setActive(gtEvent); + } + break; + case "keyup": + if (!this.#keystates[e.keyCode].pressed) { return; } + this.#keystates[e.keyCode].pressed = false; + + if (this.#touches[id].hasOwnProperty("target") + && this.#touches[id].target != null) { + this.#touches[id].target.setActive(gtEvent); + } + delete this.#touches[id]; + break; + } + } + this.stage.touches = this.#touches; + this.debugTouches(); + } + + /* Event handler for mouse events, will just translate the event to a more common form + * before further processing. */ + handleMouseEvent(e) { + const typedict = {"mousedown": "start", "mouseup": "end", "mousemove": "move"} + this.processGamepadTouchEvent({ + x: e.clientX, + y: e.clientY, + touchId: "mouse", + type: typedict[e.type] + }); + } + + /* Event handler for touch events, will just translate the event to a more common form + * before further processing. */ + handleTouchEvent(e) { + e.preventDefault(); + const typedict = {"touchstart": "start", "touchend": "end", "touchcancel": "end", "touchmove": "move"} + for (let i = 0; i < e.changedTouches.length; i++) { + let touch = e.changedTouches[i]; + this.processGamepadTouchEvent({ + x: touch.clientX, + y: touch.clientY, + touchId: touch.identifier, + type: typedict[e.type] + }); + } + } + + /* Event handler for processing standarized touch/mouse events. */ + processGamepadTouchEvent(gtEvent) { + let target = this.stage.getTarget(gtEvent.x, gtEvent.y) + switch (gtEvent.type) { + case "start": + this.#touches[gtEvent.touchId] = {}; + this.#touches[gtEvent.touchId].target = target; + case "move": + if (this.#touches.hasOwnProperty(gtEvent.touchId)) { + this.#touches[gtEvent.touchId].x = gtEvent.x; + this.#touches[gtEvent.touchId].y = gtEvent.y; + + if (this.#touches[gtEvent.touchId].hasOwnProperty("target") + && this.#touches[gtEvent.touchId].target != null) { + this.#touches[gtEvent.touchId].target.setActive(gtEvent); + } + } + break; + + case "end": + case "cancel": + if (this.#touches[gtEvent.touchId].hasOwnProperty("target") + && this.#touches[gtEvent.touchId].target != null) { + this.#touches[gtEvent.touchId].target.setActive(gtEvent); + } + delete this.#touches[gtEvent.touchId]; + break; + + default: + console.log("Unknown touch event", gtEvent.type); + } + this.stage.touches = this.#touches; + this.debugTouches(); + } + + /* Update the debug text with all current touches */ + debugTouches() { + let s = ""; + if (this.showDebug) { + for (const [i, t] of Object.entries(this.#touches)) { + s += `[${i}] ` + if (t.hasOwnProperty("x")) { + s += `x: ${Math.round(t.x, 2)}, y: ${Math.round(t.y)},` + } + s += `target: ${t.target ? t.target.id : null}\n`; + } + } + document.querySelector(".gamepad-touches").innerHTML = s; + } + + /* Used by elements to process callbacks on actions to outside the gamepad */ + handleTouchEventCallbacks(e) { + if (this.enableVibration && ["touchstart", "touchend"].includes(e.action)) { + try { + window.navigator.vibrate(5); + } catch (e) { + console.error(e); + } + } + for (let i = 0; i < this.#touchEventCallbacks.length; i++) { + this.#touchEventCallbacks[i](e); + } + } + + /* Register a method as a callback for gamepad touch events */ + onTouchEvent(callback) { + this.#touchEventCallbacks.push(callback); + } + + /* Add a list of elements to the gamepad stage */ + addElements(elements) { + for (let i = 0; i < elements.length; i++) { + elements[i].gamepad = this; + this.stage.addElement(elements[i]); + } + } + + /* Remove a list of elements from the gamepad stage by id */ + removeElementsById(elementIds) { + for (let i = 0; i < elementIds.length; i++) { + this.stage.removeElementById(elementIds[i]); + } + } + + /* Remove all elements from the gamepad stage */ + removeAllElements() { + this.stage.removeAllElements(); + } + + /* Initialize gamepad with a predefined layout */ + setGamepadLayout(variant) { + console.debug(`Setting the gamepad layout to ${variant}, deleting all current elements.`); + this.removeAllElements(); + switch (variant) { + case "1": + this.addElements([ + new Square({id: "filler1", x: 40, y: 0, alignX: "left", alignY: "center"}), + new GamepadButton({id: "C", x: 20, y: 0, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowLeft", altText: "◀", altTextAlign: "right"}), + new GamepadButton({id: "D", x: 60, y: 0, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowRight",altText: "▶", altTextAlign: "left"}), + new GamepadButton({id: "A", x: 40, y: -20, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowUp", altText: "▲", altTextAlign: "bottom"}), + new GamepadButton({id: "B", x: 40, y: 20, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowDown", altText: "▼", altTextAlign: "top"}), + new GamepadButton({id: "3", x: 20, y: 0, alignX: "right", alignY: "center", shape: "round", keyboardButton: "3", altText: "3", altTextAlign: "left"}), + new GamepadButton({id: "4", x: 60, y: 0, alignX: "right", alignY: "center", shape: "round", keyboardButton: "4", altText: "4", altTextAlign: "right"}), + new GamepadButton({id: "1", x: 40, y: -20, alignX: "right", alignY: "center", shape: "round", keyboardButton: "1", altText: "1", altTextAlign: "bottom"}), + new GamepadButton({id: "2", x: 40, y: 20, alignX: "right", alignY: "center", shape: "round", keyboardButton: "2", altText: "2", altTextAlign: "top"}), + ]) + break; + case "2": + this.addElements([ + new Square({id: "filler2", x: 40, y: 0, alignX: "right", alignY: "center"}), + new Square({id: "filler1", x: 40, y: 0, alignX: "left", alignY: "center"}), + new GamepadButton({id: "C", x: 20, y: 0, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowLeft", altText: "◀", altTextAlign: "right"}), + new GamepadButton({id: "D", x: 60, y: 0, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowRight",altText: "▶", altTextAlign: "left"}), + new GamepadButton({id: "A", x: 40, y: -20, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowUp", altText: "▲", altTextAlign: "bottom"}), + new GamepadButton({id: "B", x: 40, y: 20, alignX: "left", alignY: "center", shape: "square", keyboardButton: "ArrowDown", altText: "▼", altTextAlign: "top"}), + new GamepadButton({id: "3", x: 20, y: 0, alignX: "right", alignY: "center", shape: "square", keyboardButton: "3"}), + new GamepadButton({id: "4", x: 60, y: 0, alignX: "right", alignY: "center", shape: "square", keyboardButton: "4"}), + new GamepadButton({id: "1", x: 40, y: -20, alignX: "right", alignY: "center", shape: "square", keyboardButton: "1"}), + new GamepadButton({id: "2", x: 40, y: 20, alignX: "right", alignY: "center", shape: "square", keyboardButton: "2"}), + ]) + break; + case "9": + this.addElements([ + new GamepadJoystick({id: "left", x: 40, y: 0, alignX: "left", alignY: "center", lockX: true, bindUp: "ArrowUp", bindDown: "ArrowDown"}), + new GamepadJoystick({id: "right", x: 40, y: 0, alignX: "right", alignY: "center", lockY: true, bindLeft: "ArrowLeft", bindRight: "ArrowRight"}) + ]); + break; + } + } + +} |