From 5a10e534e09af4a2dcb1b3af6534aca4e33957dd Mon Sep 17 00:00:00 2001 From: RodrigoDornelles Date: Sun, 5 Nov 2023 21:41:00 -0300 Subject: [PATCH] feat: fist version project (#1) * chore: project skeleton * chore: add tests * feat: animate draw * chore: rename mouses to fingers * feat: start support mobile * fix: mouse interfering with touch events * feat: support multi touch * feat: support gamepad axis * feat: emulate keyboard * chore: prepare to publish in npm --- .github/workflows/check.yml | 28 ++++ .github/workflows/npm.yml | 37 +++++ .npmignore | 5 + README.md | 3 +- examples/index.html | 4 +- keycodes.json | 266 ++++++++++++++++++++++++++++++++++++ package.json | 8 +- src/construtors.ts | 27 ++++ src/draw.ts | 50 +++++++ src/emu.ts | 50 +++++++ src/handlers.ts | 48 +++++++ src/interface.ts | 33 +++++ src/main.ts | 68 ++++++--- src/util.ts | 38 ++++++ tests/keys.test.ts | 10 ++ 15 files changed, 650 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/npm.yml create mode 100644 .npmignore create mode 100644 keycodes.json create mode 100644 src/construtors.ts create mode 100644 src/draw.ts create mode 100644 src/emu.ts create mode 100644 src/handlers.ts create mode 100644 src/interface.ts create mode 100644 src/util.ts create mode 100644 tests/keys.test.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..9f5c1a6 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,28 @@ +name: Check + +on: + push: + branches: [] + pull_request: + branches: [] + workflow_dispatch: + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Restore node modules cache + uses: actions/cache@v3 + with: + path: ./node_modules/ + key: npm-${{ hashFiles('package.json') }} + - + name: Install dependencies + run: npm install + - + name: Check + run: npm run test diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml new file mode 100644 index 0000000..e6fd1b5 --- /dev/null +++ b/.github/workflows/npm.yml @@ -0,0 +1,37 @@ +name: npm + +on: + push: + tags: + - "*.*.*" + +jobs: + package: + runs-on: ubuntu-latest + container: emscripten/emsdk + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Restore node modules cache + uses: actions/cache@v3 + with: + path: ./node_modules/ + key: npm-${{ hashFiles('package.json') }} + - + name: Install dependencies + run: npm install + - + name: Build project + run: npm run build + + - uses: actions/setup-node@v2 + with: + node-version: '16.x' + registry-url: 'https://registry.npmjs.org' + + - name: Publish project + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..873baec --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +./.github/ +./tests/ +./examples/ +./dist/index.html +LICENSE diff --git a/README.md b/README.md index 6efa1c0..75f1094 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,13 @@ - + diff --git a/examples/index.html b/examples/index.html index b8b4d26..5513938 100644 --- a/examples/index.html +++ b/examples/index.html @@ -5,9 +5,9 @@ Demo - + - + diff --git a/keycodes.json b/keycodes.json new file mode 100644 index 0000000..f3e76c8 --- /dev/null +++ b/keycodes.json @@ -0,0 +1,266 @@ +{ + "Backspace": { + "key": "Backspace", + "keyCode": 8 + }, + "Tab": { + "key": "Tab", + "keyCode": 9 + }, + "Enter": { + "key": "Enter", + "keyCode": 13 + }, + "ShiftLeft": { + "key": "Shift", + "keyCode": 16 + }, + "ControlLeft": { + "key": "Control", + "keyCode": 17 + }, + "AltLeft": { + "key": "Alt", + "keyCode": 18 + }, + "CapsLock": { + "key": "CapsLock", + "keyCode": 20 + }, + "Escape": { + "key": "Escape", + "keyCode": 27 + }, + "Space": { + "key": " ", + "keyCode": 32 + }, + "PageUp": { + "key": "PageUp", + "keyCode": 33 + }, + "PageDown": { + "key": "PageDown", + "keyCode": 34 + }, + "End": { + "key": "End", + "keyCode": 35 + }, + "Home": { + "key": "Home", + "keyCode": 36 + }, + "ArrowLeft": { + "key": "ArrowLeft", + "keyCode": 37 + }, + "ArrowUp": { + "key": "ArrowUp", + "keyCode": 38 + }, + "ArrowRight": { + "key": "ArrowRight", + "keyCode": 39 + }, + "ArrowDown": { + "key": "ArrowDown", + "keyCode": 40 + }, + "Delete": { + "key": "Delete", + "keyCode": 46 + }, + "Digit0": { + "key": "0", + "keyCode": 48 + }, + "Digit1": { + "key": "1", + "keyCode": 49 + }, + "Digit2": { + "key": "2", + "keyCode": 50 + }, + "Digit3": { + "key": "3", + "keyCode": 51 + }, + "Digit4": { + "key": "4", + "keyCode": 52 + }, + "Digit5": { + "key": "5", + "keyCode": 53 + }, + "Digit6": { + "key": "6", + "keyCode": 54 + }, + "Digit7": { + "key": "7", + "keyCode": 55 + }, + "Digit8": { + "key": "8", + "keyCode": 56 + }, + "Digit9": { + "key": "9", + "keyCode": 57 + }, + "KeyA": { + "key": "a", + "keyCode": 65 + }, + "KeyB": { + "key": "b", + "keyCode": 66 + }, + "KeyC": { + "key": "c", + "keyCode": 67 + }, + "KeyD": { + "key": "d", + "keyCode": 68 + }, + "KeyE": { + "key": "e", + "keyCode": 69 + }, + "KeyF": { + "key": "f", + "keyCode": 70 + }, + "KeyG": { + "key": "g", + "keyCode": 71 + }, + "KeyH": { + "key": "h", + "keyCode": 72 + }, + "KeyI": { + "key": "i", + "keyCode": 73 + }, + "KeyJ": { + "key": "j", + "keyCode": 74 + }, + "KeyK": { + "key": "k", + "keyCode": 75 + }, + "KeyL": { + "key": "l", + "keyCode": 76 + }, + "KeyM": { + "key": "m", + "keyCode": 77 + }, + "KeyN": { + "key": "n", + "keyCode": 78 + }, + "KeyO": { + "key": "o", + "keyCode": 79 + }, + "KeyP": { + "key": "p", + "keyCode": 80 + }, + "KeyQ": { + "key": "q", + "keyCode": 81 + }, + "KeyR": { + "key": "r", + "keyCode": 82 + }, + "KeyS": { + "key": "s", + "keyCode": 83 + }, + "KeyT": { + "key": "t", + "keyCode": 84 + }, + "KeyU": { + "key": "u", + "keyCode": 85 + }, + "KeyV": { + "key": "v", + "keyCode": 86 + }, + "KeyW": { + "key": "w", + "keyCode": 87 + }, + "KeyX": { + "key": "x", + "keyCode": 88 + }, + "KeyY": { + "key": "y", + "keyCode": 89 + }, + "KeyZ": { + "key": "z", + "keyCode": 90 + }, + "F1": { + "key": "F1", + "keyCode": 112 + }, + "F2": { + "key": "F2", + "keyCode": 113 + }, + "F3": { + "key": "F3", + "keyCode": 114 + }, + "F4": { + "key": "F4", + "keyCode": 115 + }, + "F5": { + "key": "F5", + "keyCode": 116 + }, + "F6": { + "key": "F6", + "keyCode": 117 + }, + "F7": { + "key": "F7", + "keyCode": 118 + }, + "F8": { + "key": "F8", + "keyCode": 119 + }, + "F9": { + "key": "F9", + "keyCode": 120 + }, + "F10": { + "key": "F10", + "keyCode": 121 + }, + "F11": { + "key": "F11", + "keyCode": 122 + }, + "F12": { + "key": "F12", + "keyCode": 123 + } +} diff --git a/package.json b/package.json index b9210a5..7f31f12 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,20 @@ { "name": "gamepadzilla", - "version": "0.0.1", + "version": "1.0.0", "author": "RodrigoDornelles", "license": "AGPL-3.0-or-later", "keywords": [ "gamepadzilla", "joystick", "gamepad", - "gpz" + "gpz", + "bun" ], "description": "gpz is a plug and play virtual and physical gamepad for html5 mobile games. (no script need)", "scripts": { + "test": "bun test .", "start": "bun run build --watch", - "build": "bun build --minify --outdir dist --target browser --asset-naming '[name].[ext]' --entry-naming '[name].[ext]' src/main.ts", + "build": "bun build --minify --outdir dist --target browser --entry-naming 'gamepadzilla.js' src/main.ts", "build:example": "npm run build && cp examples/* dist" }, "devDependencies": { diff --git a/src/construtors.ts b/src/construtors.ts new file mode 100644 index 0000000..6b1bcbb --- /dev/null +++ b/src/construtors.ts @@ -0,0 +1,27 @@ +import {ObjectGpz, ClassGpz} from './interface' +import { emu } from './emu' +import { draw } from './draw' +import { getKeyCodes } from './util' + +function construtors(): Array { + const objects = [] as Array + Object.keys(ClassGpz).forEach((gpztype) => { + Array.from(document.querySelectorAll(ClassGpz[gpztype])) + .filter(el => el instanceof HTMLCanvasElement) + .forEach(el => { + objects.push({ + fingers: [], + emu: emu[gpztype], + draw: draw[gpztype], + type: ClassGpz[gpztype], + canvas: el as HTMLCanvasElement, + ctx2d: (el as HTMLCanvasElement).getContext('2d') as CanvasRenderingContext2D, + fakekeys: getKeyCodes(el.dataset.gpzBind), + }) + }) + }) + + return objects +} + +export {construtors} diff --git a/src/draw.ts b/src/draw.ts new file mode 100644 index 0000000..cf85bea --- /dev/null +++ b/src/draw.ts @@ -0,0 +1,50 @@ +import { ObjectGpz, Vector2d } from "./interface"; +import { nestFinger } from "./util"; + +function drawCircle(ctx: CanvasRenderingContext2D, fill: string, x: number, y: number, r: number) { + ctx.beginPath() + ctx.arc(x, y, r, 0, 2 * Math.PI) + ctx.fillStyle = fill + ctx.fill() + ctx.fillStyle = 'black'; + ctx.stroke(); + ctx.closePath(); +} + +const draw = { + Btn(self: ObjectGpz) { + const centerX: number = self.canvas.width / 2; + const centerY: number = self.canvas.height / 2; + const radius: number = 25; + let color: string = '#88888880' + + self.fingers.forEach(finger => { + const distance = Math.sqrt((finger.x - centerX) ** 2 + (finger.y - centerY) ** 2) + if (distance < radius) { + color = 'red' + } + }) + + self.ctx2d.clearRect(0, 0, self.canvas.width, self.canvas.height) + drawCircle(self.ctx2d, color, centerX, centerY, radius) + }, + Joy(self: ObjectGpz) { + const center: Vector2d = {x: self.canvas.width / 2, y: self.canvas.height / 2} + const radius: number = 50 + const radius2: number = radius/3 + const percentage: number = 1.5 + const stick = nestFinger(center, self.fingers) + + if ((stick.dis / percentage) > (radius - radius2)) { + const angle = Math.atan2(stick.pos.y - center.y, stick.pos.x - center.x) + stick.pos.x = center.x + (radius - radius2) * percentage * Math.cos(angle) + stick.pos.y = center.y + (radius - radius2) * percentage * Math.sin(angle) + } + + self.ctx2d.clearRect(0, 0, self.canvas.width, self.canvas.height) + drawCircle(self.ctx2d, '#aaaaaa80', center.x, center.y, radius) + drawCircle(self.ctx2d, '#88888880', stick.pos.x, stick.pos.y, radius2) + } +} + +export { draw } diff --git a/src/emu.ts b/src/emu.ts new file mode 100644 index 0000000..0985e20 --- /dev/null +++ b/src/emu.ts @@ -0,0 +1,50 @@ +import { ObjectGpz, Vector2d } from "./interface" +import { nestFinger } from "./util" + + +const emu = { + Joy(self: ObjectGpz) { + const center: Vector2d = {x: self.canvas.width / 2, y: self.canvas.height / 2} + const stick = nestFinger(center, self.fingers) + const dirX = stick.pos.x - center.x + const dirY = stick.pos.y - center.y + + if (stick.dis > 10) { + if (dirX < 0) { + window.dispatchEvent(new KeyboardEvent('keydown', self.fakekeys[1])) + } + else if (dirX > 0) { + window.dispatchEvent(new KeyboardEvent('keydown', self.fakekeys[3])) + } else { + window.dispatchEvent(new KeyboardEvent('keyup', self.fakekeys[1])) + window.dispatchEvent(new KeyboardEvent('keyup', self.fakekeys[3])) + } + if (dirY < 0) { + window.dispatchEvent(new KeyboardEvent('keydown', self.fakekeys[0])) + } + else if (dirY > 0) { + window.dispatchEvent(new KeyboardEvent('keydown', self.fakekeys[2])) + } + else { + window.dispatchEvent(new KeyboardEvent('keyup', self.fakekeys[2])) + window.dispatchEvent(new KeyboardEvent('keyup', self.fakekeys[0])) + } + } else { + self.fakekeys.forEach(fakekey => { + window.dispatchEvent(new KeyboardEvent('keyup', fakekey)) + }) + } + }, + Btn(self: ObjectGpz) { + const center: Vector2d = {x: self.canvas.width / 2, y: self.canvas.height / 2} + const btn = nestFinger(center, self.fingers) + if (btn.dis > (50/3)) { + window.dispatchEvent(new KeyboardEvent('keydown', self.fakekeys[0])) + } + else { + window.dispatchEvent(new KeyboardEvent('keyup', self.fakekeys[0])) + } + } +} + +export { emu } diff --git a/src/handlers.ts b/src/handlers.ts new file mode 100644 index 0000000..395f354 --- /dev/null +++ b/src/handlers.ts @@ -0,0 +1,48 @@ +import { Vector2d } from "./interface"; + +function handleMouse(event: MouseEvent, element: HTMLElement): Array { + const rect = element.getBoundingClientRect() + const x = event.clientX - rect.left + const y = event.clientY - rect.top + return [{x, y}] +} + +function handleTouch(event: TouchEvent, element: HTMLElement): Array { + const fingers: Array = [] + const rect = element.getBoundingClientRect() + + Array.from(event.touches).forEach(touch => { + const x = touch.clientX - rect.left; + const y = touch.clientY - rect.top; + fingers.push({x, y}) + }) + + return fingers +} + +function handleGamepadAxis(element: HTMLCanvasElement): Array { + if (!('getGamepads' in navigator)) { + return [] + } + const gamepads = navigator.getGamepads().filter(g => g !== null) as Array + const rect = element.getBoundingClientRect() + const width: number = rect.right - rect.left + const height: number = rect.bottom - rect.top + const mapValue = (value: number) => (value + 1) / 2 + const fingers: Array = [] + + gamepads.forEach((gamepad) => { + const lastOddAxisIndex = gamepad.axes.length - 1; + for (let pivot = 0; pivot < lastOddAxisIndex; pivot += 2) { + if (gamepad.axes[pivot] !== 0 || gamepad.axes[pivot + 1] !== 0) { + const x = mapValue(gamepad.axes[pivot]) * width + const y = mapValue(gamepad.axes[pivot + 1]) * height + fingers.push({x, y}) + } + } + }) + + return fingers +} + +export {handleMouse, handleTouch, handleGamepadAxis} diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 0000000..451f560 --- /dev/null +++ b/src/interface.ts @@ -0,0 +1,33 @@ +enum GamepadFSM { + Offline = 0, + Online = 1, + Cleanup = 2 +} + +interface Vector2d { + x: number, + y: number +} + +enum ClassGpz { + Joy = '.gpz-joy', + Btn = '.gpz-btn', +} + +interface Keycode { + key: string, + code: string, + keyCode: number +} + +interface ObjectGpz { + type: ClassGpz, + emu(self: ObjectGpz): void, + draw(self: ObjectGpz): void, + canvas: HTMLCanvasElement, + ctx2d: CanvasRenderingContext2D, + fingers: Array + fakekeys: Array +} + +export {ClassGpz, ObjectGpz, Vector2d, GamepadFSM, Keycode} diff --git a/src/main.ts b/src/main.ts index f0072d7..a0dde4f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,23 +1,55 @@ +import { construtors } from "./construtors"; +import { handleTouch, handleMouse, handleGamepadAxis } from "./handlers"; +import { ClassGpz, GamepadFSM } from "./interface"; + document.addEventListener('DOMContentLoaded', () => { - const elementsWithClass = document.querySelectorAll('.gpz-joy') - const canvasElements = Array.from(elementsWithClass).filter((element) => element instanceof HTMLCanvasElement) as Array - const contexts2D = canvasElements.map((canvasElement) => { - return canvasElement.getContext('2d'); - }) as Array + const touchEvents = ['touchstart', 'touchmove', 'touchend', 'touchcancel'] + const objects = construtors() + let gamepadState: GamepadFSM = GamepadFSM.Offline + + + objects.forEach(obj => { + function eventClean() { + obj.fingers = [] + obj.draw(obj) + obj.emu(obj) + } + function eventMouse(event: MouseEvent) { + obj.fingers = handleMouse(event, obj.canvas) + obj.draw(obj) + obj.emu(obj) + } + function eventTouch(event: TouchEvent) { + obj.canvas.removeEventListener('mouseleave', eventClean) + obj.canvas.removeEventListener('mousemove', eventMouse) + obj.fingers = handleTouch(event as TouchEvent, obj.canvas) + obj.draw(obj) + obj.emu(obj) + } + function eventGamepad() { + const axis = handleGamepadAxis(obj.canvas) + if (axis.length > 0) { + gamepadState = GamepadFSM.Online + obj.fingers = axis + obj.draw(obj) + obj.emu(obj) + } + if (axis.length === 0 && gamepadState == GamepadFSM.Online) { + gamepadState = GamepadFSM.Cleanup + obj.fingers = [] + obj.draw(obj) + obj.emu(obj) + } + } - contexts2D.forEach(ctx => { - const centerX: number = ctx.canvas.width / 2; - const centerY: number = ctx.canvas.height / 2; - const radius: number = 50; - const fillColor: string = "yellow"; - const strokeColor: string = "black"; + if (obj.type == ClassGpz.Joy) { + setInterval(eventGamepad, 1000 / 60) + } - ctx.beginPath(); - ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); - ctx.fillStyle = fillColor; - ctx.fill(); - ctx.fillStyle = strokeColor; - ctx.stroke(); - ctx.closePath(); + obj.canvas.addEventListener('mouseleave', eventClean) + obj.canvas.addEventListener('mousemove', eventMouse) + touchEvents.forEach(eventName => obj.canvas.addEventListener(eventName, (event) => eventTouch(event as TouchEvent))) + + obj.draw(obj) }) }) diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..8c9e63e --- /dev/null +++ b/src/util.ts @@ -0,0 +1,38 @@ +import { Keycode, Vector2d } from "./interface" +import keycodesjson from "../keycodes.json" + +function nestFinger(center: Vector2d, fingers: Array): {dis: number, pos: Vector2d} { + if (fingers.length == 0) { + return {dis: 0, pos: center} + } + + let shortestDistance : number = Number.MAX_SAFE_INTEGER + let closestFinger: Vector2d = fingers[0] + + fingers.forEach(finger => { + const distance = Math.sqrt((finger.x - center.x) ** 2 + (finger.y - center.y) ** 2) + if (distance < shortestDistance) { + shortestDistance = distance + closestFinger = finger + } + }) + + return {dis: shortestDistance, pos: closestFinger} +} + +function getKeyCodes(txt: string): Array { + const tokens = txt.trim().split(' ').filter(token => token in keycodesjson) + const keycodes: Array = [] + + tokens.forEach(token => { + keycodes.push({ + code: token, + key: keycodesjson[token].key, + keyCode: keycodesjson[token].keyCode, + }) + }) + + return keycodes +} + +export {nestFinger, getKeyCodes} \ No newline at end of file diff --git a/tests/keys.test.ts b/tests/keys.test.ts new file mode 100644 index 0000000..963c421 --- /dev/null +++ b/tests/keys.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import { ClassGpz } from "../src/interface"; +import { draw } from "../src/draw" + +test("draw keys", () => { + const expected = Object.keys(ClassGpz).sort() + const compare = Object.keys(draw).sort() + expect(compare).toEqual(expected); +}) +