diff --git a/README.md b/README.md index 4be74b3..0627cb4 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ Example [video](https://youtu.be/viv-B_A-A2o) of recording and running a **macro For more videos, check out this [playlist](https://www.youtube.com/playlist?list=PLfC95bU1D4gpJEM3SYfzaI2e5vD0q7v0z). # Status -One keyboard layout or gaming controller layout is supported to map input to the control sticks and the buttons on a Nintendo Switch controller. +One keyboard layout, gaming controller layout, using you mouse, or touchscreen is supported to map input to the control sticks and the buttons on a Nintendo Switch controller. I've mainly tested this with Animal Crossing and Mixer - FTL low latency streaming. -This is very much a work in progress right now but you can indeed play your Switch remotely using a keyboard/controller. +This is very much a work in progress right now but you can indeed play your Switch remotely using a keyboard/controller/mouse/touchscreen. # Macros You can record and run **macros**! @@ -47,10 +47,9 @@ The host (person setting this up) needs: The client (your friend) needs: * A web browser to open the client and send commands * You can use the already [hosted client][client] but you may have to enable mixed content for that site in your browser's settings if the server your friend is hosting does not have SSL (a link that starts with https). -* A keyboard or gaming controller +* A keyboard or **gaming controller** is recommended or just use your mouse/touchscreen for simple stuff # Plans -* Make controller buttons clickable/tappable. * Support custom key bindings. * Improve macro support: exporting/importing. * Default layout options for common controllers. diff --git a/website-client/.dockerignore b/website-client/.dockerignore new file mode 100644 index 0000000..3e2e84b --- /dev/null +++ b/website-client/.dockerignore @@ -0,0 +1,2 @@ +build/ +node_modules/ diff --git a/website-client/Dockerfile b/website-client/Dockerfile index 6442eb5..59a4ec9 100644 --- a/website-client/Dockerfile +++ b/website-client/Dockerfile @@ -11,7 +11,6 @@ COPY public/ ./public/ COPY tsconfig.json ./ COPY src/ ./src/ - RUN yarn build CMD yarn start-prod diff --git a/website-client/README.md b/website-client/README.md index 8d60a68..a414464 100644 --- a/website-client/README.md +++ b/website-client/README.md @@ -32,7 +32,7 @@ docker run --rm -d -p 5000:5000 --name switch-remoteplay-client switch-remotepla #### Updating the Public Image You will need permission to push a new image to Docker Hub. -``` +```bash docker login docker tag switch-remoteplay-client juharris/switch-remoteplay-client:latest docker push juharris/switch-remoteplay-client:latest diff --git a/website-client/package.json b/website-client/package.json index 172d1ab..653afc4 100644 --- a/website-client/package.json +++ b/website-client/package.json @@ -1,6 +1,6 @@ { "name": "switch-rp-client", - "version": "0.5.0", + "version": "0.6.0", "private": true, "dependencies": { "@material-ui/core": "^4.10.0", @@ -25,9 +25,9 @@ }, "scripts": { "start": "react-scripts start", - "start-prod": "serve --single build", + "start-prod": "serve --single build", "build": "react-scripts build", - "test": "react-scripts test", + "test": "react-scripts test --env='./src/test/custom-test-env.js'", "eject": "react-scripts eject", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint-fix": "eslint . --fix --ext .js,.jsx,.ts,.tsx" @@ -51,6 +51,7 @@ "@types/react-router-dom": "^5.1.5", "@typescript-eslint/eslint-plugin": "^3.0.0", "@typescript-eslint/parser": "^3.0.0", - "eslint-plugin-react": "^7.20.0" + "eslint-plugin-react": "^7.20.0", + "fake-indexeddb": "^3.1.2" } } diff --git a/website-client/src/components/Button/Button.tsx b/website-client/src/components/Button/Button.tsx index 2b97d9a..e1120c6 100644 --- a/website-client/src/components/Button/Button.tsx +++ b/website-client/src/components/Button/Button.tsx @@ -1,14 +1,19 @@ import React from 'react' +import ControllerButton from '../Controller/ControllerButton' import classes from './Button.module.css' const Button: React.FunctionComponent = (props: any) => { let classList = classes.Button - if (props.button.pressed) classList += " " + classes.Pressed - return ( -
-

{props.button.symbol}

-
- ) + if (props.button.pressed) { + classList += " " + classes.Pressed + } + return +

{props.button.symbol}

+
} export default Button diff --git a/website-client/src/components/Controller/Controller.module.css b/website-client/src/components/Controller/Controller.module.css index 900b41a..aba3e7c 100644 --- a/website-client/src/components/Controller/Controller.module.css +++ b/website-client/src/components/Controller/Controller.module.css @@ -1,22 +1,4 @@ /* TODO Use lower-case for the names. */ - -.Left { - display: flex; - align-items: center; -} - -.Middle { - /* Some of the settings were used before the video was in between the controls. */ - /* display: flex; */ - /* flex-direction: column; */ - /* flex-grow: 1; */ - min-width: 23rem; -} - -.Right { - -} - .LargeButton { background-color: #111; margin: 1rem; diff --git a/website-client/src/components/Controller/Controller.tsx b/website-client/src/components/Controller/Controller.tsx index 94a43fb..4efa4f8 100644 --- a/website-client/src/components/Controller/Controller.tsx +++ b/website-client/src/components/Controller/Controller.tsx @@ -1,46 +1,63 @@ import { createStyles, withStyles } from '@material-ui/core' +import Grid from '@material-ui/core/Grid' import React from 'react' +import { SendCommand } from '../../key-binding/KeyBinding' import Diamond from '../Diamond/Diamond' import VideoStream from '../VideoStream' import cssClasses from './Controller.module.css' +import ControllerButton from './ControllerButton' import { ControllerState } from './ControllerState' import Joystick from './Joystick/Joystick' const styles = () => createStyles({ controller: { - display: 'flex', - flexDirection: 'row', justifyContent: 'center', }, + + middle: { + // Some of the settings were used before the video was in between the controls. + // display: 'flex', + // flexDirection: 'column', + // flexGrow: 1, + // minWidth: '23rem', + }, }) -class Controller extends React.Component { - render(): React.ReactNode { - const { classes } = this.props - const controllerState: ControllerState | undefined = this.props.controllerState +class Controller extends React.Component<{ + controllerState: ControllerState, + sendCommand: SendCommand, + videoStreamProps: any, + classes: any, +}> { + render(): React.ReactNode { + const { classes, sendCommand } = this.props + const controllerState: ControllerState = this.props.controllerState return ( -
-
+ +
-

L

-
+
-

ZL

-
-
+ { > {/* Slightly wider than a typical minus. */}

-
+
- -

-
-
-
+ + + -
-
+ +
-

R

-
+
-

+

-
-
+

ZR

-
+
- -

-
-
-
+ + + ) } } diff --git a/website-client/src/components/Controller/ControllerButton.tsx b/website-client/src/components/Controller/ControllerButton.tsx new file mode 100644 index 0000000..0953ed5 --- /dev/null +++ b/website-client/src/components/Controller/ControllerButton.tsx @@ -0,0 +1,52 @@ +import { createStyles, withStyles } from '@material-ui/core' +import React from 'react' +import { SendCommand } from '../../key-binding/KeyBinding' +import { ControllerState } from './ControllerState' + +const styles = () => createStyles({ +}) + +class ControllerButton extends React.Component<{ + name: string, + sendCommand: SendCommand, + controllerState: ControllerState, + classes: any, + className: string, +}> { + constructor(props: any) { + super(props) + + this.onSelect = this.onSelect.bind(this) + this.onUnselect = this.onUnselect.bind(this) + } + + private onSelect(e: React.MouseEvent | React.TouchEvent) { + const { name, sendCommand, controllerState } = this.props + const singleCommand = `${name} d` + const updatedGivenState = true + sendCommand(singleCommand, controllerState, updatedGivenState) + e.preventDefault() + + } + private onUnselect(e: React.MouseEvent | React.TouchEvent) { + const { name, sendCommand, controllerState } = this.props + const singleCommand = `${name} u` + const updatedGivenState = true + sendCommand(singleCommand, controllerState, updatedGivenState) + e.preventDefault() + } + + render(): React.ReactNode { + return
+ {this.props.children} +
+ } +} + +export default withStyles(styles)(ControllerButton) \ No newline at end of file diff --git a/website-client/src/components/Controller/ControllerState.ts b/website-client/src/components/Controller/ControllerState.ts index 156e39d..ae9aa56 100644 --- a/website-client/src/components/Controller/ControllerState.ts +++ b/website-client/src/components/Controller/ControllerState.ts @@ -1,5 +1,10 @@ class ButtonState { public isPressed = false + constructor(other?: ButtonState) { + if (other !== undefined) { + this.isPressed = other.isPressed + } + } } class StickState extends ButtonState { @@ -8,6 +13,14 @@ class StickState extends ButtonState { /** -1 is up, +1 is down */ public verticalValue = 0 + + constructor(other?: StickState) { + super(other) + if (other !== undefined) { + this.horizontalValue = other.horizontalValue + this.verticalValue = other.verticalValue + } + } } // Member names in here match actions. @@ -37,6 +50,36 @@ class ControllerState { leftStick = new StickState() rightStick = new StickState() + + constructor(other?: ControllerState) { + if (other !== undefined) { + // Deep copy + this.arrowLeft = new ButtonState(other.arrowLeft) + this.arrowRight = new ButtonState(other.arrowRight) + this.arrowUp = new ButtonState(other.arrowUp) + this.arrowDown = new ButtonState(other.arrowDown) + + this.a = new ButtonState(other.a) + this.b = new ButtonState(other.b) + this.x = new ButtonState(other.x) + this.y = new ButtonState(other.y) + + this.l = new ButtonState(other.l) + this.zl = new ButtonState(other.zl) + + this.r = new ButtonState(other.r) + this.zr = new ButtonState(other.zr) + + this.minus = new ButtonState(other.minus) + this.plus = new ButtonState(other.plus) + + this.capture = new ButtonState(other.capture) + this.home = new ButtonState(other.home) + + this.leftStick = new StickState(other.leftStick) + this.rightStick = new StickState(other.rightStick) + } + } } export { ControllerState, ButtonState } diff --git a/website-client/src/components/Controller/Joystick/Joystick.module.css b/website-client/src/components/Controller/Joystick/Joystick.module.css deleted file mode 100644 index 36fdce4..0000000 --- a/website-client/src/components/Controller/Joystick/Joystick.module.css +++ /dev/null @@ -1,21 +0,0 @@ -.joystickHolder { - height: 6rem; - width: 6rem; - background-color: #111; - border-radius: 50%; - margin: 1rem auto; -} - -.joystick { - height: 5rem; - width: 5rem; - background-color: #222; - border-radius: 50%; - margin: 1rem auto; - position: relative; - top: 0.5rem; -} - -.pressed { - background-color: #999; -} \ No newline at end of file diff --git a/website-client/src/components/Controller/Joystick/Joystick.tsx b/website-client/src/components/Controller/Joystick/Joystick.tsx index acd2056..3327098 100644 --- a/website-client/src/components/Controller/Joystick/Joystick.tsx +++ b/website-client/src/components/Controller/Joystick/Joystick.tsx @@ -1,24 +1,191 @@ +import { createStyles, withStyles } from '@material-ui/core' import React from 'react' -import classes from './Joystick.module.css' +import { SendCommand } from '../../../key-binding/KeyBinding' +import { ControllerState } from '../ControllerState' -const Joystick: React.FunctionComponent = (props: any) => { - let joystickHolderClassList = classes.joystickHolder - let joystickClassList = classes.joystick - const movedThreshold = 0.15 - if (props.pressed) { - joystickHolderClassList += " " + classes.pressed +const styles = () => createStyles({ + joystickHolder: { + height: '6rem', + width: '6rem', + backgroundColor: '#111', + borderRadius: '50%', + margin: '1rem auto', + }, + joystick: { + height: '5rem', + width: '5rem', + backgroundColor: '#222', + borderRadius: '50%', + margin: '1rem auto', + position: 'relative', + top: '0.5rem', + }, + pressed: { + backgroundColor: '#999', } - if (Math.abs(props.x) > movedThreshold || Math.abs(props.y) > movedThreshold) { - joystickClassList += " " + classes.pressed +}) + +class Joystick extends React.Component<{ + name: string, + sendCommand: SendCommand, + controllerState: ControllerState, + x: number, y: number, + pressed: boolean, + classes: any, +}> { + // We can probably use the controllerState to track these but it might not be reliable + // if another class creates a new state. + /** Indicates if the stick has been moved since it has been selected. */ + moved = false + /** Indicates if the stick has been pressed inwards. */ + pressed = false + pressCheck?: any + + prevX = 0 + prevY = 0 + prevH = 0 + prevV = 0 + + constructor(props: any) { + super(props) + + this.onDrag = this.onDrag.bind(this) + this.onSelect = this.onSelect.bind(this) + this.onUnselect = this.onUnselect.bind(this) + this.preventScroll = this.preventScroll.bind(this) + } + + private getStickState() { + return this.props.name === 'l' ? this.props.controllerState.leftStick : this.props.controllerState.rightStick } - const styles = { - transform: `translate(${props.x * 15}px, ${props.y * 15}px)`, + private preventScroll(e: any) { + e.preventDefault() + } + + private onSelect(e: React.TouchEvent | React.DragEvent | React.MouseEvent) { + let x, y + this.moved = false + this.pressed = false + if (e.type.includes('drag')) { + const event = e as React.DragEvent + x = event.clientX + y = event.clientY + } else if (e.type === 'mousedown') { + const event = e as React.MouseEvent + x = event.screenX + y = event.screenY + + document.addEventListener('mousemove', this.onDrag) + document.addEventListener('mouseup', this.onUnselect) + } else { + const event = e as React.TouchEvent + + const touch = event.targetTouches[0] + x = touch.clientX + y = touch.clientY + } + this.prevX = x + this.prevY = y + + document.addEventListener('touchmove', this.preventScroll, { passive: false }) + this.pressCheck = setTimeout(() => { + if (!this.moved) { + // The stick has not moved yet so assume that it is being pressed inwards. + this.pressed = true + const stickState = this.getStickState() + stickState.isPressed = true + const stickName = this.props.name === 'l' ? 'l_stick' : 'r_stick' + this.props.sendCommand(`${stickName} d`, this.props.controllerState) + } + }, 400) + e.preventDefault() } - return
-
+ + private onDrag(e: React.TouchEvent | React.DragEvent | MouseEvent) { + let x, y + if (e.type.includes('drag')) { + const event = e as React.DragEvent + x = event.clientX + y = event.clientY + } else if (e.type === 'mousemove') { + const event = e as MouseEvent + x = event.screenX + y = event.screenY + } else { + const event = e as React.TouchEvent + + const touch = event.targetTouches[0] + x = touch.clientX + y = touch.clientY + } + const scale = 16 + const threshold = 0.01 + const h = Math.min(Math.max((x - this.prevX) / scale, -1), 1) + const v = Math.min(Math.max((y - this.prevY) / scale, -1), 1) + if (Math.abs(h - this.prevH) > threshold || Math.abs(v - this.prevV) > threshold) { + this.moved = true + const stickState = this.getStickState() + stickState.horizontalValue = h + stickState.verticalValue = v + this.props.sendCommand(`s ${this.props.name} hv ${h.toFixed(3)} ${v.toFixed(3)}`, + this.props.controllerState) + } + this.prevH = h + this.prevV = v + } + + private onUnselect(e: React.TouchEvent | React.DragEvent | MouseEvent) { + clearTimeout(this.pressCheck) + document.removeEventListener('touchmove', this.preventScroll) + document.removeEventListener('mousemove', this.onDrag) + const stickState = this.getStickState() + stickState.horizontalValue = 0 + stickState.verticalValue = 0 + stickState.isPressed = false + const commands = [] + if (this.pressed) { + const stickName = this.props.name === 'l' ? 'l_stick' : 'r_stick' + commands.push(`${stickName} u`) + } + if (this.moved) { + commands.push(`s ${this.props.name} center`) + } + if (commands.length > 0) { + this.props.sendCommand(commands.join('&'), this.props.controllerState) + } + + e.preventDefault() + } + + render(): React.ReactNode { + const { classes, x, y } = this.props + let joystickHolderClassList = classes.joystickHolder + let joystickClassList = classes.joystick + const movedThreshold = 0.15 + if (this.props.pressed) { + joystickHolderClassList += " " + classes.pressed + } + if (Math.abs(x) > movedThreshold || Math.abs(y) > movedThreshold) { + joystickClassList += " " + classes.pressed + } + + const styles = { + transform: `translate(${x * 15}px, ${y * 15}px)`, + } + + return
+
+
-
+ } } -export default Joystick \ No newline at end of file +export default withStyles(styles)(Joystick) \ No newline at end of file diff --git a/website-client/src/components/Controller/__tests__/parse-command.test.ts b/website-client/src/components/Controller/__tests__/parse-command.test.ts index d446342..6a03ad0 100644 --- a/website-client/src/components/Controller/__tests__/parse-command.test.ts +++ b/website-client/src/components/Controller/__tests__/parse-command.test.ts @@ -1,7 +1,7 @@ import { ControllerState } from '../ControllerState' -import { parseCommand } from '../parse-command' +import { updateState, parseCommand } from '../parse-command' -describe('parseCommand', () => { +describe('parse-command', () => { it('tap button', () => { let c1, c2 for (const buttonName of [ @@ -49,6 +49,29 @@ describe('parseCommand', () => { expect(parseCommand(`left`)).toStrictEqual([c1, c2]) }) + it('tap with other command', () => { + const s1 = new ControllerState() + s1.a.isPressed = true + s1.b.isPressed = true + const s2 = new ControllerState() + s2.b.isPressed = true + expect(parseCommand(`a&b d`)).toStrictEqual([s1, s2]) + }) + + it('tap with other commands and stick', () => { + const s1 = new ControllerState() + s1.a.isPressed = true + s1.y.isPressed = true + s1.b.isPressed = true + s1.x.isPressed = true + s1.leftStick.horizontalValue = 0.6 + const s2 = new ControllerState() + s2.b.isPressed = true + s2.x.isPressed = true + s2.leftStick.horizontalValue = 0.6 + expect(parseCommand(`a&y&b d&x d&s l h 0.6`)).toStrictEqual([s1, s2]) + }) + it('push button', () => { let c = new ControllerState() for (const buttonName of [ @@ -61,9 +84,14 @@ describe('parseCommand', () => { ]) { (c as any)[buttonName].isPressed = true expect(parseCommand(`${buttonName} d`)).toStrictEqual([c]) + const updatedState = new ControllerState() + updateState(`${buttonName} d`, updatedState) + expect(updatedState).toStrictEqual(c) c = new ControllerState() expect(parseCommand(`${buttonName} u`)).toStrictEqual([c]) + updateState(`${buttonName} u`, updatedState) + expect(updatedState).toStrictEqual(c) } c = new ControllerState() @@ -217,4 +245,45 @@ describe('parseCommand', () => { c.a.isPressed = c.b.isPressed = true expect(parseCommand('a d&b d')).toStrictEqual([c]) }) + + it('updateState', () => { + const c = new ControllerState() + const expected = new ControllerState() + updateState('a d', c) + expected.a.isPressed = true + expect(c).toStrictEqual(expected) + }) + + it('updateState tap', () => { + const c = new ControllerState() + const expected = new ControllerState() + updateState('x', c) + // Tapping is not really supported but it should not update the state. + expect(c).toStrictEqual(expected) + }) + + it('updateState stick d', () => { + const c = new ControllerState() + const expected = new ControllerState() + updateState('l_stick d', c) + expected.leftStick.isPressed = true + expect(c).toStrictEqual(expected) + }) + + it('updateState stick move', () => { + const c = new ControllerState() + const expected = new ControllerState() + updateState('s r hv 0.4 -0.5', c) + expected.rightStick.horizontalValue = 0.4 + expected.rightStick.verticalValue = -0.5 + expect(c).toStrictEqual(expected) + }) + + it('updateState with &', () => { + const c = new ControllerState() + const expected = new ControllerState() + updateState('a d&b d', c) + expected.a.isPressed = expected.b.isPressed = true + expect(c).toStrictEqual(expected) + }) }) diff --git a/website-client/src/components/Controller/parse-command.ts b/website-client/src/components/Controller/parse-command.ts index 71a4473..429c5e6 100644 --- a/website-client/src/components/Controller/parse-command.ts +++ b/website-client/src/components/Controller/parse-command.ts @@ -18,6 +18,99 @@ const buttonNameToStateMember: { [buttonName: string]: string } = { right: 'arrowRight', } +/** + * Updates `controllerState`. + * @param command A single command that can be represented by one state such as just pressing a button down or just letting a button back up but not tapping a button (e.g. 'x' which involves pushing down then back up). Can use '&' to combine commands. + * @param controllerState The current state. + * @param fullCommand The full command that `singleCommand` came from. + */ +function updateState(command: string, controllerState: ControllerState, fullCommand?: string): void { + if (fullCommand === undefined) { + fullCommand = command + } + for (let singleCommand of command.split('&')) { + singleCommand = singleCommand.trim() + const commandParts = singleCommand.split(/\s+/) + if (commandParts.length < 2) { + // A button might be tapped. Not really supported but it should not update the state. + console.warn("updateState: Ignoring unrecognized part of command (when updating the state for the UI): \"%s\" from \"%s\"", singleCommand, fullCommand) + } else { + const button = commandParts[0] + if (button === 's') { + const stick = commandParts[1] + let stickState = undefined + if (stick === 'l') { + stickState = controllerState.leftStick + } else if (stick === 'r') { + stickState = controllerState.rightStick + } + if (stickState) { + if (commandParts.length === 3) { + const direction = commandParts[2] + switch (direction) { + case 'up': + stickState.verticalValue = -1 + break + case 'down': + stickState.verticalValue = 1 + break + case 'left': + stickState.horizontalValue = -1 + break + case 'right': + stickState.horizontalValue = 1 + break + case 'center': + stickState.horizontalValue = stickState.verticalValue = 0 + break + default: + console.warn("updateState: Ignoring unrecognized direction (when updating the state for the UI) in: \"%s\" from \"%s\"", singleCommand, fullCommand) + } + } else if (commandParts.length === 4) { + const direction = commandParts[2] + const amount = commandParts[3] + let stickAmount + switch (amount) { + case 'min': + stickAmount = direction === 'h' ? -1 : +1 + break + case 'max': + stickAmount = direction === 'h' ? +1 : -1 + break + case 'center': + stickAmount = 0 + break + default: + stickAmount = parseFloat(amount) + } + if (direction === 'h') { + stickState.horizontalValue = stickAmount + } else if (direction === 'v') { + stickState.verticalValue = stickAmount + } else { + console.warn("updateState: Ignoring unrecognized direction (when updating the state for the UI) in: \"%s\" from \"%s\"", singleCommand, fullCommand) + } + } else if (commandParts.length === 5 && commandParts[2] === 'hv') { + const horizontalAmount = commandParts[3] + const verticalAmount = commandParts[4] + stickState.horizontalValue = parseFloat(horizontalAmount) + stickState.verticalValue = parseFloat(verticalAmount) + } else { + console.warn("updateState: Ignoring unrecognized stick command (when updating the state for the UI) in: \"%s\" from \"%s\"", singleCommand, fullCommand) + } + } else { + console.warn("updateState: Ignoring unrecognized stick (when updating the state for the UI) in: \"%s\" from \"%s\"", singleCommand, fullCommand) + } + } else if (buttonNames.has(button)) { + const isPressed = commandParts[1] === 'd'; + (controllerState as any)[buttonNameToStateMember[button] || button].isPressed = isPressed + } else { + console.warn("updateState: Ignoring unrecognized part of command (when updating the state for the UI): \"%s\" from \"%s\"", singleCommand, fullCommand) + } + } + } +} + /** * This should mainly be used for macros. * @@ -28,99 +121,25 @@ function parseCommand(command: string): ControllerState[] { const result = [] const controllerState = new ControllerState() - let hasTap = false + const tappedButtons = [] for (let singleCommand of command.split('&')) { singleCommand = singleCommand.trim() if (buttonNames.has(singleCommand)) { const member: string = buttonNameToStateMember[singleCommand] || singleCommand; (controllerState as any)[member].isPressed = true - hasTap = true + tappedButtons.push(singleCommand) } else { - const commandParts = singleCommand.split(/\s+/) - if (commandParts.length < 2) { - console.warn("Ignoring unrecognized part of command: \"%s\" from \"%s\"", singleCommand, command) - } else { - const button = commandParts[0] - if (button === 's') { - const stick = commandParts[1] - let stickState = undefined - if (stick === 'l') { - stickState = controllerState.leftStick - } else if (stick === 'r') { - stickState = controllerState.rightStick - } - if (stickState) { - if (commandParts.length === 3) { - const direction = commandParts[2] - switch (direction) { - case 'up': - stickState.verticalValue = -1 - break - case 'down': - stickState.verticalValue = 1 - break - case 'left': - stickState.horizontalValue = -1 - break - case 'right': - stickState.horizontalValue = 1 - break - case 'center': - stickState.horizontalValue = stickState.verticalValue = 0 - break - default: - console.warn("Ignoring unrecognized direction in: \"%s\" from \"%s\"", singleCommand, command) - } - } else if (commandParts.length === 4) { - const direction = commandParts[2] - const amount = commandParts[3] - let stickAmount - switch (amount) { - case 'min': - stickAmount = direction === 'h' ? -1 : +1 - break - case 'max': - stickAmount = direction === 'h' ? +1 : -1 - break - case 'center': - stickAmount = 0 - break - default: - stickAmount = parseFloat(amount) - } - if (direction === 'h') { - stickState.horizontalValue = stickAmount - } else if (direction === 'v') { - stickState.verticalValue = stickAmount - } else { - console.warn("Ignoring unrecognized direction in: \"%s\" from \"%s\"", singleCommand, command) - } - } else if (commandParts.length === 5 && commandParts[2] === 'hv') { - const horizontalAmount = commandParts[3] - const verticalAmount = commandParts[4] - stickState.horizontalValue = parseFloat(horizontalAmount) - stickState.verticalValue = parseFloat(verticalAmount) - } else { - console.warn("Ignoring unrecognized stick command in: \"%s\" from \"%s\"", singleCommand, command) - } - } else { - console.warn("Ignoring unrecognized stick in: \"%s\" from \"%s\"", singleCommand, command) - } - } else if (buttonNames.has(button)) { - const isPressed = commandParts[1] === 'd' - if (isPressed) { - (controllerState as any)[buttonNameToStateMember[button] || button].isPressed = true - } - } else { - console.warn("Ignoring unrecognized part of command: \"%s\" from \"%s\"", singleCommand, command) - } - } + updateState(singleCommand, controllerState, command) } - } result.push(controllerState) - if (hasTap) { - result.push(new ControllerState()) + if (tappedButtons.length > 0) { + // Undo the tapped buttons. + const nextState = new ControllerState(controllerState) + for (const tappedButton of tappedButtons) { + updateState(`${tappedButton} u`, nextState) + } + result.push(nextState) } return result @@ -128,5 +147,5 @@ function parseCommand(command: string): ControllerState[] { -export { parseCommand } +export { updateState, parseCommand } diff --git a/website-client/src/components/Macros/Macros.tsx b/website-client/src/components/Macros/Macros.tsx index 656ae32..116b47b 100644 --- a/website-client/src/components/Macros/Macros.tsx +++ b/website-client/src/components/Macros/Macros.tsx @@ -124,7 +124,7 @@ class Macros extends React.Component<{ console.error(event) } - request.onupgradeneeded = function(event: Event) { + request.onupgradeneeded = (event: Event) => { const db: IDBDatabase = (event?.target as any).result db.createObjectStore('macro', { keyPath: 'id', autoIncrement: true, }) } @@ -303,6 +303,8 @@ class Macros extends React.Component<{ async playMacro(macro: string[]) { this.openToast("Playing macro", 'info') + // TODO Keep a controller state and update it. + // Can't just call `updateState` since a macro could have a putton tap that `updateState` does not handle. for (const command of macro) { const m = /wait (\d+)/.exec(command) if (m) { diff --git a/website-client/src/components/PlayGame.tsx b/website-client/src/components/PlayGame.tsx index 8cf1820..1dd2d49 100644 --- a/website-client/src/components/PlayGame.tsx +++ b/website-client/src/components/PlayGame.tsx @@ -13,7 +13,7 @@ import GamepadBinding from '../key-binding/GamepadBinding' import KeyboardBinding from '../key-binding/KeyboardBinding' import Controller from './Controller/Controller' import { ControllerState } from './Controller/ControllerState' -import { parseCommand } from './Controller/parse-command' +import { updateState, parseCommand } from './Controller/parse-command' import MacroRecorder from './Macros/MacroRecorder' import Macros from './Macros/Macros' @@ -65,7 +65,8 @@ class PlayGame extends React.Component { this.toggleSendMode = this.toggleSendMode.bind(this) this.updateConnectionStatus = this.updateConnectionStatus.bind(this) - const inputMethod = new KeyboardBinding(this.sendCommand) + const controllerState = new ControllerState() + const inputMethod = new KeyboardBinding(this.sendCommand, controllerState) const inputMethodOptions = [ inputMethod, ] @@ -86,6 +87,8 @@ class PlayGame extends React.Component { inputMethod, inputMethodOptions, + + controllerState, } } @@ -123,7 +126,7 @@ class PlayGame extends React.Component { e.gamepad.index, e.gamepad.id, e.gamepad.buttons.length, e.gamepad.axes.length) this.state.inputMethod.stop() - const inputMethod = new GamepadBinding(this.sendCommand, e.gamepad) + const inputMethod = new GamepadBinding(this.sendCommand, this.state.controllerState, e.gamepad) const inputMethodOptions = this.state.inputMethodOptions.concat([inputMethod]) this.setState({ inputMethod, @@ -214,7 +217,14 @@ class PlayGame extends React.Component { }) } - private sendCommand(command: string, controllerState?: ControllerState) { + /** + * @param command The command the execute. + * @param controllerState The current state of the controller. If `undefined`, then the states for the `command` will be automatically determined but the UI might not look right if this method is called concurrently. + * @param updateGivenState If `true`, then the passed in state should be updated. Defaults to `false`. + */ + // Matches the SendCommand interace. + private sendCommand(command: string, controllerState?: ControllerState, + updateGivenState = false) { if (command && this.state.socket && this.state.isInSendMode) { this.state.socket.emit('p', command) } @@ -222,6 +232,9 @@ class PlayGame extends React.Component { // Controller and key bindings should send the state since it should be easy for them to compute it. // Running commands from a macro might not send the state. if (controllerState !== undefined) { + if (updateGivenState) { + updateState(command, controllerState) + } this.setState({ controllerState, }) @@ -344,6 +357,7 @@ class PlayGame extends React.Component {
} diff --git a/website-client/src/key-binding/GamepadBinding.ts b/website-client/src/key-binding/GamepadBinding.ts index 757484f..3cb584d 100644 --- a/website-client/src/key-binding/GamepadBinding.ts +++ b/website-client/src/key-binding/GamepadBinding.ts @@ -1,5 +1,6 @@ -import { KeyBinding, SendCommand } from "./KeyBinding" +import { ControllerState } from "../components/Controller/ControllerState" import actions from "./actions" +import { KeyBinding, SendCommand } from "./KeyBinding" /** * Bindings for a single Gamepad. @@ -38,8 +39,10 @@ export default class GamepadBinding extends KeyBinding { gamepad: Gamepad animationRequest?: number - constructor(sendCommand: SendCommand, gamepad: Gamepad) { - super(sendCommand) + constructor(sendCommand: SendCommand, + controllerState: ControllerState, + gamepad: Gamepad) { + super(sendCommand, controllerState) this.gamepad = gamepad this.loop = this.loop.bind(this) diff --git a/website-client/src/key-binding/KeyBinding.ts b/website-client/src/key-binding/KeyBinding.ts index e4580c8..3c752bd 100644 --- a/website-client/src/key-binding/KeyBinding.ts +++ b/website-client/src/key-binding/KeyBinding.ts @@ -1,15 +1,19 @@ import { ControllerState } from '../components/Controller/ControllerState' +/** + * @param command The command the execute. + * @param controllerState The current state of the controller. If `undefined`, then the states for the `command` will be automatically determined but the UI might not look right if this method is called concurrently. + * @param updateGivenState If `true`, then the passed in state should be updated. Defaults to `false`. + */ export interface SendCommand { - (command: string, controllerState?: ControllerState): void + (command: string, controllerState?: ControllerState, + updateGivenState?: boolean): void } export abstract class KeyBinding { - sendCommand: SendCommand - controllerState = new ControllerState() - - constructor(sendCommand: SendCommand) { - this.sendCommand = sendCommand + constructor( + public sendCommand: SendCommand, + public controllerState: ControllerState) { } abstract getName(): string diff --git a/website-client/src/key-binding/KeyboardBinding.ts b/website-client/src/key-binding/KeyboardBinding.ts index 666d6e4..40ee1ed 100644 --- a/website-client/src/key-binding/KeyboardBinding.ts +++ b/website-client/src/key-binding/KeyboardBinding.ts @@ -1,5 +1,6 @@ +import { ControllerState } from '../components/Controller/ControllerState' import actions from './actions' -import { KeyBinding } from './KeyBinding' +import { KeyBinding, SendCommand } from './KeyBinding' /** * Keyboard bindings. No mouse control. @@ -52,6 +53,14 @@ export default class KeyboardBinding extends KeyBinding { ArrowRight: actions.rightStickFullRight, } + constructor( + sendCommand: SendCommand, + controllerState: ControllerState) { + super(sendCommand, controllerState) + this.handleKeyDown = this.handleKeyDown.bind(this) + this.handleKeyUp = this.handleKeyUp.bind(this) + } + getName(): string { return "Keyboard" } @@ -84,12 +93,22 @@ export default class KeyboardBinding extends KeyBinding { document.removeEventListener('keyup', this.handleKeyUp) } - handleKeyDown: (e: KeyboardEvent) => void = (e: KeyboardEvent) => { - this.handleKey(e, e.code, 'down') + private handleKeyDown(e: KeyboardEvent): void { + if (!e.repeat) { + // Only run the comment once per press. + this.handleKey(e, e.code, 'down') + } else { + e.preventDefault() + } } - handleKeyUp: (e: KeyboardEvent) => void = (e: KeyboardEvent) => { - this.handleKey(e, e.code, 'up') + private handleKeyUp(e: KeyboardEvent): void { + if (!e.repeat) { + // Only run the comment once per press. + this.handleKey(e, e.code, 'up') + } else { + e.preventDefault() + } } diff --git a/website-client/src/test/custom-test-env.js b/website-client/src/test/custom-test-env.js new file mode 100644 index 0000000..52b1e7c --- /dev/null +++ b/website-client/src/test/custom-test-env.js @@ -0,0 +1,18 @@ +const Environment = require('jest-environment-jsdom') // eslint-disable-line @typescript-eslint/no-var-requires + +// Modified https://github.com/microsoft/0xDeCA10B/blob/master/demo/client/test/custom-test-env.js + +/** + * A custom environment to set up globals for tests. + */ +module.exports = class CustomTestEnvironment extends Environment { + async setup() { + await super.setup() + if (typeof this.global.indexedDB === 'undefined') { + this.global.indexedDB = require('fake-indexeddb') + } + if (typeof this.global.IDBKeyRange === 'undefined') { + this.global.IDBKeyRange = require("fake-indexeddb/lib/FDBKeyRange") + } + } +} diff --git a/website-client/yarn.lock b/website-client/yarn.lock index b39f97e..1bd9db2 100644 --- a/website-client/yarn.lock +++ b/website-client/yarn.lock @@ -2613,6 +2613,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base64-arraybuffer-es6@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.6.0.tgz#036f79f57588dca0018de7792ddf149299382007" + integrity sha512-57nLqKj4ShsDwFJWJsM4sZx6u60WbCge35rWRSevUwqxDtRwwxiKAO800zD2upPv4CfdWjQp//wSLar35nDKvA== + base64-arraybuffer@0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8" @@ -3521,7 +3526,7 @@ core-js-pure@^3.0.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== -core-js@^2.4.0: +core-js@^2.4.0, core-js@^2.5.3: version "2.6.11" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -4868,6 +4873,14 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +fake-indexeddb@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-3.1.2.tgz#8073a12ed3b254f7afc064f3cc2629f0110a5303" + integrity sha512-W60eRBrE8r9o/EePyyUc63sr2I/MI9p3zVwLlC1WI1xdmQVqqM6+wec9KDWDz2EZyvJKhrDvy3cGC6hK8L1pfg== + dependencies: + realistic-structured-clone "^2.0.1" + setimmediate "^1.0.5" + fast-deep-equal@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" @@ -9498,6 +9511,16 @@ readdirp@~3.4.0: dependencies: picomatch "^2.2.1" +realistic-structured-clone@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-2.0.2.tgz#2f8ec225b1f9af20efc79ac96a09043704414959" + integrity sha512-5IEvyfuMJ4tjQOuKKTFNvd+H9GSbE87IcendSBannE28PTrbolgaVg5DdEApRKhtze794iXqVUFKV60GLCNKEg== + dependencies: + core-js "^2.5.3" + domexception "^1.0.1" + typeson "^5.8.2" + typeson-registry "^1.0.0-alpha.20" + realpath-native@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" @@ -10076,7 +10099,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= @@ -11024,6 +11047,20 @@ typescript@~3.7.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae" integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw== +typeson-registry@^1.0.0-alpha.20: + version "1.0.0-alpha.37" + resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.37.tgz#23e6bffc264b4dfc80603e2a8545bd4750102d42" + integrity sha512-xXkriUyWzsBCNmMLyLXXFkc2UQbK7nDB8ItS3LJWlKMJvfzZfRBaZFPxH6cfjJYD4mQbv1mAxjk/9mRfyWe88g== + dependencies: + base64-arraybuffer-es6 "0.6.0" + typeson "5.18.2" + whatwg-url "7.1.0" + +typeson@5.18.2, typeson@^5.8.2: + version "5.18.2" + resolved "https://registry.yarnpkg.com/typeson/-/typeson-5.18.2.tgz#0d217fc0e11184a66aa7ca0076d9aa7707eb7bc2" + integrity sha512-Vetd+OGX05P4qHyHiSLdHZ5Z5GuQDrHHwSdjkqho9NSCYVSLSfRMjklD/unpHH8tXBR9Z/R05rwJSuMpMFrdsw== + unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" @@ -11447,19 +11484,19 @@ whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0: resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== -whatwg-url@^6.4.1: - version "6.5.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8" - integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ== +whatwg-url@7.1.0, whatwg-url@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" + integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== dependencies: lodash.sortby "^4.7.0" tr46 "^1.0.1" webidl-conversions "^4.0.2" -whatwg-url@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" - integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== +whatwg-url@^6.4.1: + version "6.5.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8" + integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ== dependencies: lodash.sortby "^4.7.0" tr46 "^1.0.1"