Search code examples
javascriptpuppeteergamepad-api

Simulate gamepad with Puppeteer?


I'd like to write some Puppeteer-based tests to test some logic that makes use of the Gamepad API, but I can't find any documentation on the Puppeteer docs that explain how to simulate a gamepad and how to send button presses to the browser.

What's the proper way to do it?


Solution

  • There isn't really a "proper" way to do it.

    What I've done is made a somewhat clean way you should be able to use. It essentially involves creating your own game controller management code to use in puppeteer, and injecting the controller state into a page using the puppeteer evaluate API call.

    Essentially we hijack the global scope navigator.getGamepads function and inject our own implementation.

    We start out by modelling a game controller in code. The most basic part of a controller is a button. So lets do that.

    class Button {
      constructor() {
        this.value = 0.0
        this.pressed = false
      }
      press() {
        this.value = 1.0
        this.pressed = true
      }
      unpress() {
        this.value = 0.0
        this.pressed = false
      }
      toObject() {
        return {
          value: this.value,
          pressed: this.pressed,
        }
      }
    }
    

    Next, according to the W3C Gamepad Specification a gamepad has two analog sticks, so we'll model those.

    class AnalogStick {
      constructor() {
        this.button = new Button()
        this.xAxis = 0.0
        this.yAxis = 0.0
      }
    
      setXAxis(value) {
        this.xAxis = value
      }
    
      setYAxis(value) {
        this.yAxis = value
      }
    }
    

    Finally, let's create a class to represent a game pad. This class will have a helper function that translates the controllers internal state to match the W3C Gamepad Interface signature.

    class GamePad {
      constructor(index = 0) {
        this.id = 'Standard Gamepad'
        this.displayId = null // this is used for VR
        this.connected = true
        this.index = index
        this.mapping = 'standard'
    
        this.dPad = {
          up: new Button(),
          right: new Button(),
          down: new Button(),
          left: new Button(),
        }
    
        this.select = new Button()
        this.home = new Button()
        this.start = new Button()
    
        this.actions = {
          top: new Button(),
          right: new Button(),
          bottom: new Button(),
          left: new Button(),
        }
    
        this.leftStick = new AnalogStick()
        this.rightStick = new AnalogStick()
    
        this.lButton = new Button()
        this.lTrigger = new Button()
    
        this.rButton = new Button()
        this.rTrigger = new Button()
      }
    
      getState() {
        return {
          axes: [
            this.leftStick.xAxis,
            this.leftStick.yAxis,
            this.rightStick.xAxis,
            this.rightStick.yAxis,
          ],
          buttons: [
            this.actions.bottom.toObject(),
            this.actions.right.toObject(),
            this.actions.left.toObject(),
            this.actions.top.toObject(),
    
            this.lButton.toObject(),
            this.rButton.toObject(),
    
            this.lTrigger.toObject(),
            this.rTrigger.toObject(),
    
            this.select.toObject(),
            this.start.toObject(),
    
            this.leftStick.button.toObject(),
            this.rightStick.button.toObject(),
    
            this.dPad.up.toObject(),
            this.dPad.down.toObject(),
            this.dPad.left.toObject(),
            this.dPad.right.toObject(),
    
            this.home.toObject(),
          ],
          connected: this.connected,
          displayId: this.displayId,
          id: this.id,
          index: this.index,
          mapping: this.mapping,
        }
      }
    }
    

    Now we have a convenient way of representing a gamecontroller and its state in code, we can hijack navigator.getGamepads and replace it with our own function that returns the state of our virtual controllers.

    Now we'll define a couple of helper functions. One that sets the gamepads state that navigator.getGamepads will return.

    const setGamepadsState = async (page, gamepads) => {
      const result = await page.evaluate((controllers) => {
        navigator.getGamepads = () => controllers
      }, gamepads)
      return result
    }
    

    Now that we've done that, we need a way to trigger the gamepadconnected event. We can do that using the puppeteer page.emit function call.

    const connectGamepad = async (page, gamepad) => {
      const connectedEvent = {
        gamepad,
      }
      page.emit('gamepadconnected', connectedEvent)
    }
    

    We now have all the building blocks to simulate a controller using puppeteer! Example usage is below:

    ;(async () => {
      const controller1 = new GamePad(0)
      const controller2 = new GamePad(1)
      const browser = await puppeteer.launch()
      const page = await browser.newPage()
    
      await page.goTo('https://www.yourgamepadpage.com')
    
      // Set the current gamepad state for both controllers in the puppeteer page.
      // We need to call this each time we change a controllers state
      await setGamepadsState(page, [controller1.getState(), controller2.getState()])
    
      // fires a 'gamepadconnected' event in the page for controller1
      await connectGamepad(page, controller1.getState())
      // fires a 'gamepadconnected' event in the page for controller2
      await connectGamepad(page, controller2.getState())
    
      // toggles the state of the bottom action button to pressed on controller1, 'X' on a playstation pad or 'A' on an xbox pad
      controller1.actions.bottom.press()
      await setGamepadsState(page, [controller1.getState(), controller2.getState()]) // passes controller1's current state into puppeteer's 'page.evaluate'
    
      // do a check here in your puppeteer based test!
      console.log('this should be whatever test code you need!')
    
      controller1.actions.bottom.unpress() // untoggles the state of the bottom action button on controller1
    
      // now lets simulate an analog stick axis shift, e.g. left analog stick on the horizontal axis all the way to the left.
      controller1.leftStick.setXAxis(-1.0)
      await setGamepadsState(page, [controller1.getState(), controller2.getState()]) // and now we pass it to the page context!
    
      await browser.close()
    })()
    

    Hopefully this should point you in the right direction. If you have any questions feel free to follow up here :)