OogyControllerManager

🎮 ⌨️ Typescript Gamepad and Keyboard handler consolidated into a tiny, easy-to-use npm package https://insanj.github.io/OogyControllerManager/

View the Project on GitHub insanj/OogyControllerManager

OogyControllerManager

oogy-controller-manager

🎮 ⌨️ Typescript Gamepad and Keyboard handler consolidated into a tiny, easy-to-use npm package

Go to npm

About

Built and used in production for Oogy: Can You Help, an indie deckbuilding adventure on Steam (PC and Mac).

The goal of this project is to provide a useful abstraction over both the Web Gamepad API and window-level KeyboardEvents.

Why? In this case, as a video game, we want to handle keyboard and controller input with consistency—particularly in special cases such as pause menu input, “on controller disconnected”, and Steam Overlay handling.

Installation

In Browser, use:

<script type="module">
  import { OogyControllerManager } from "./OogyControllerManager.js";
  const controllerManager = new OogyControllerManager.OogyControllerManager({ // ...
  // code goes here! see example/ for an example using index.html
</script>

In Node environment, use:

npm install -s oogy-controller-manager

And using:

import { OogyControllerManager } from "oogy-controller-manager";
const controllerManager = new OogyControllerManager.OogyControllerManager({ // ...
// see example/ for express/nodejs index.js usage

See example/ for demonstrations of both browser and Node implementations.

NOTE: the Typescript src/ is bundled with the package, in case the transpiled .js is not suitable for all use-cases.

Building

Clone the repository, and run:

npm install
npm start

This will generate the latest production-ready .js file using Typescript, located at dist/OogyControllerManager.js.

See package.json for details. Configure tsconfig.json to build with sourcemaps.

Usage

Getting Started

/**
 * Represents an "event listener", primarily used to remove that listener
 when moving to a different screen. Has an automatically generated UUID,
 as well as a `deactivated` property, so may be helpful in order to detect
 if a current listener exists in a more complex context.
*/
currentControllerListener: OogyControllerListener;

controllerManager: OogyControllerManager;

constructor() {
  this.controllerManager = new OogyControllerManager({
    onControllerDisconnected: () => {
      this.handleUserPausedGame();
      // if the controller is disconnected, simulate pause event
    }
  });

  this.controllerManager.start(); // start receiving events

  this.controllerManager.addRightClickListener({
    // do the same with right clicking as pressing pause button, globally
    onRightClick: () => {
      this.handleUserPausedGameToggled();
      // not same as `handleUserPausedGame`, as disconnect ALWAYS pauses,
      // this can UNPAUSE as well
    }
  });

  // click event firing can be disabled using the manager-level property:
  // (this is what I use when the Steam Overlay is activated as well)
  // this.controllerManager.shouldStopAcceptingClicks = true;
}

Basic Usage

startUserInteraction(): void {

  // in this example, we are on a hypothetical screen with several "cards".
  // as we move on the keyboard or gamepad, we want to highlight the next card,
  // until eventually, we press A to select a card

  this.cardIndex = 0;
  this.cards = [{}, {}, {}]; // example UI elements, most likely,
  // that we are navigating with keyboard or controller

  this.currentControllerListener = this.controllerManager.addListener({
    onControllerInput: (input) => {
      // here is where you may want to interrupt if you did not get a chance
      // to either (1) remove or (2) deactivate the listener beforehand,
      // or as a safeguard in case the screen is inactive but listener
      // was not removed (yet) for some reason.
      // example: if (this.suppressUserInteraction === true) {
      //            return;
      //          }
      // note: this is not for pausing; see blocking listeners
      // for handling pause (`handleUserPausedGame`)

      switch (input) {
        default:
          return;
        case OogyControllerInput.start:
          this.handleUserPausedGameToggled();
          // here is our 3rd path to pausing the game so far,
          // and why this library can be helpful instead of doing this by hand
          break;
        case OogyControllerInput.a:
          this.handleUserSelectedCard(this.cardIndex);
          // SELECT the currently highlighted card so we can trigger any action, etc
          break;
        case OogyControllerInput.right:
          this.highlightedCardIndex = this.highlightedCardIndex + 1;
          // go to the next index -> move one to the right
          if (this.highlightedCardIndex > this.cards.length - 1) {
            this.highlightedCardIndex = 0; // wrap to start if already at end of list
          }
          break;
        // .....
      }
    }
  });
}

Disconnecting

stopUserInteraction(): void {
  this.controllerManager.removeListener(
    this.currentControllerListener
  ); // this is all we need to do
  // (each listener has an automagically generated UUID)
}

Blocking Listeners

startNavigationBarBlockingListener(): void {

  // in this example, we are entering a context where we need a "blocking"
  // listener, that can intercept and get all keyboard/controller events
  // without disrupting any existing listeners (such as, when PAUSE is active)

  this.controllerManager.addBlockingListener({

    // this is a special kind of listener that does not require a UUID,
    // as the API only supports 1 blocking listener at a time
    // (which is also its whole purpose, anything more complicated should
    //  remove listeners or use the `deactivated` property...)
    interceptControllerInputAndBlock: (input) => {

      if (
        input !== OogyControllerInput.guide &&
        input !== OogyControllerInput.start
      ) {

        if (this.userPausedGame === true) {
          // start is "active" so block anything else from receiving this
          // input until start pressed again
          this.handleInputWhileGamePaused(input);
          return true;
        }

        return false; // not paused, so return `false` to NOT intercept
      }

      this.handleUserPausedGameToggled();
      return true;
      // return `true` to intercept, this is the guide or pause button
    }

  });
}

stopNavigationBarBlockingListener(): void {
  this.controllerManager.removeBlockingListener();
  // no uuid, so this removes the only blocking listener active
}

Keyboard-Exclusive Listeners

const keyboardBlockingListener = this.controllerManager.addListener({
  // when showing a virtual keyboard or directly processing
  // keyboard input, we want to completely intercept keyboard events

  interceptKeyboardInput: (keyboardEvent) => {
    let keyCode = keyboardEvent.key.toUpperCase();
    if (keyCode === "CAPSLOCK") {
      /// ...
    } else if (keyCode === "ENTER" || keyCode === "ESCAPE") {
      this.handleEnterToggled();
      // intercepting keyboard events has the side effect
      // (in current API) of also disregarding inputs that we may
      // have been using for navigation, such as ENTER and ESCAPE
      return;
    }
  },

  // we can still accept controller input while doing this
  onControllerInput: (input) => {
    // ...
  },
});

Authors

Julian (@insanj) Weiss
julian@oogycanyouhelp.com
github.com/insanj
(c) 2022

License

MIT License

Copyright (c) 2022 Julian Weiss

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.