Build a Game Engine

Keyboard Input

Prerequisites

Article

Now that we have an InputDevice base class and an InputSystem to manage them, let's implement an actual device: the keyboard.

We'll be extending the GLFW library's keyboard support. As with other modules of the engine, I like to leave the option to swap out the library we're using in the future, so we'll add some wrapping about GLFW functions and constants.

We'll start with a listing of all keys that we want to check and store state for:

enum Keys {
    KEY_0 = 1,
    KEY_1,
    KEY_2,
    KEY_3,
    KEY_4,
    KEY_5,
    KEY_6,
    KEY_7,
    KEY_8,
    KEY_9,
    KEY_A,
    KEY_B,
    KEY_C,
    KEY_D,
    KEY_E,
    KEY_F,
    KEY_G,
    KEY_H,
    KEY_I,
    KEY_J,
    KEY_K,
    KEY_L,
    KEY_M,
    KEY_N,
    KEY_O,
    KEY_P,
    KEY_Q,
    KEY_R,
    KEY_S,
    KEY_T,
    KEY_U,
    KEY_V,
    KEY_W,
    KEY_X,
    KEY_Y,
    KEY_Z,
    KEY_TAB,
    KEY_LEFT_SHIFT,
    KEY_RIGHT_SHIFT,
    KEY_LEFT_CTRL,
    KEY_RIGHT_CTRL,
    KEY_LEFT_ALT,
    KEY_RIGHT_ALT,
    KEY_SPACE,
    KEY_ESCAPE,
    KEY_BACKSPACE,
    KEY_ENTER,
    KEY_EQUALS,
    KEY_MINUS,
    KEY_LEFT_SQUARE_BRACKET,
    KEY_RIGHT_SQUARE_BRACKET,
    KEY_BACK_SLASH,
    KEY_FORWARD_SLASH,
    KEY_COMMA,
    KEY_PERIOD,
    KEY_INSERT,
    KEY_DELETE,
    KEY_HOME,
    KEY_END,
    KEY_PAGE_UP,
    KEY_PAGE_DOWN,
    KEY_PRINT_SCREEN,
    KEY_UP,
    KEY_DOWN,
    KEY_LEFT,
    KEY_RIGHT,
    KEY_NUMPAD_PLUS,
    KEY_NUMPAD_0,
    KEY_NUMPAD_1,
    KEY_NUMPAD_2,
    KEY_NUMPAD_3,
    KEY_NUMPAD_4,
    KEY_NUMPAD_5,
    KEY_NUMPAD_6,
    KEY_NUMPAD_7,
    KEY_NUMPAD_8,
    KEY_NUMPAD_9,
    KEY_FUNC_F1,
    KEY_FUNC_F2,
    KEY_FUNC_F3,
    KEY_FUNC_F4,
    KEY_FUNC_F5,
    KEY_FUNC_F6,
    KEY_FUNC_F7,
    KEY_FUNC_F8,
    KEY_FUNC_F9,
    KEY_FUNC_F10,
    KEY_FUNC_F11,
    KEY_FUNC_F12,
    KEY_LAST
};

In our Keyboard::Initialize function, we'll map these to their GLFW constant equivalents. The last item, KEY_LAST, gives us a size of the array we'll need to create and store all key states.

We'll implement our Keyboard on top of our InputDevice implementation, giving us a consistent interface for polling and managing state across all input devices.

Let's take a look at our Keyboard class:

class UKeyboard : public UInputDevice {
public:
    UKeyboard();
    ~UKeyboard() = default;

    /**
     * Init and update functions (if needed by device)
     */
    void Initialize();

    /**
     * Get the state of a button
     */
    float GetButtonState(int button);

    /**
     * Process a button action
     */
    void SetButtonState(int button, float state);

    /**
     * Get device type
     */
    int Type() { return UInputDeviceType::KEYBOARD; }

protected:
    // Internal state
    int m_keyStates[KEY_LAST];
};

As you can see, we're going to implement the GetButtonState and SetButtonState functions - which will just return 0.0 for not pressed and 1.0 for pressed - and our keyboard returns a Type constant of KEYBOARD. Finally, we have an array of key states.

In this implementation, we'll use callbacks to get and set key states. GLFW gives us the glfwSetKeyCallback function to do just that, passing us the window it was triggered in, a key that was pressed, a scancode (a system specific key code), the action that was taken (press, repeat, or release), and any modifier keys that were pressed (Shift, Control, Alt, etc.).

Let's take a look at the Initialize function:

// Mappings from GLFW
int keyMappings[GLFW_KEY_LAST];

void UKeyboard::Initialize() {
    UWindow* window = UApplication::Instance()->WindowHandle();
    glfwSetKeyCallback(window->Handle(), KeyboardCallback);

    // Map keys from GLFW to internal mappings
    keyMappings[GLFW_KEY_0] = KEY_0;
    keyMappings[GLFW_KEY_1] = KEY_1;
    keyMappings[GLFW_KEY_2] = KEY_2;
    keyMappings[GLFW_KEY_3] = KEY_3;
    keyMappings[GLFW_KEY_4] = KEY_4;
    keyMappings[GLFW_KEY_5] = KEY_5;
    keyMappings[GLFW_KEY_6] = KEY_6;
    keyMappings[GLFW_KEY_7] = KEY_7;
    keyMappings[GLFW_KEY_8] = KEY_8;
    keyMappings[GLFW_KEY_9] = KEY_9;
    keyMappings[GLFW_KEY_A] = KEY_A;
    keyMappings[GLFW_KEY_B] = KEY_B;
    keyMappings[GLFW_KEY_C] = KEY_C;
    keyMappings[GLFW_KEY_D] = KEY_D;
    keyMappings[GLFW_KEY_E] = KEY_E;
    keyMappings[GLFW_KEY_F] = KEY_F;
    keyMappings[GLFW_KEY_G] = KEY_G;
    keyMappings[GLFW_KEY_H] = KEY_H;
    keyMappings[GLFW_KEY_I] = KEY_I;
    keyMappings[GLFW_KEY_J] = KEY_J;
    keyMappings[GLFW_KEY_K] = KEY_K;
    keyMappings[GLFW_KEY_L] = KEY_L;
    keyMappings[GLFW_KEY_M] = KEY_M;
    keyMappings[GLFW_KEY_N] = KEY_N;
    keyMappings[GLFW_KEY_O] = KEY_O;
    keyMappings[GLFW_KEY_P] = KEY_P;
    keyMappings[GLFW_KEY_Q] = KEY_Q;
    keyMappings[GLFW_KEY_R] = KEY_R;
    keyMappings[GLFW_KEY_S] = KEY_S;
    keyMappings[GLFW_KEY_T] = KEY_T;
    keyMappings[GLFW_KEY_Y] = KEY_U;
    keyMappings[GLFW_KEY_V] = KEY_V;
    keyMappings[GLFW_KEY_W] = KEY_W;
    keyMappings[GLFW_KEY_X] = KEY_X;
    keyMappings[GLFW_KEY_Y] = KEY_Y;
    keyMappings[GLFW_KEY_Z] = KEY_Z;
    keyMappings[GLFW_KEY_TAB] =                 KEY_TAB;
    keyMappings[GLFW_KEY_LEFT_SHIFT] =          KEY_LEFT_SHIFT;
    keyMappings[GLFW_KEY_RIGHT_SHIFT] =         KEY_RIGHT_SHIFT;
    keyMappings[GLFW_KEY_LEFT_CONTROL] =        KEY_LEFT_CTRL;
    keyMappings[GLFW_KEY_RIGHT_CONTROL] =       KEY_RIGHT_CTRL;
    keyMappings[GLFW_KEY_LEFT_ALT] =            KEY_LEFT_ALT;
    keyMappings[GLFW_KEY_RIGHT_ALT] =           KEY_RIGHT_ALT;
    keyMappings[GLFW_KEY_SPACE] =               KEY_SPACE;
    keyMappings[GLFW_KEY_ESCAPE] =              KEY_ESCAPE;
    keyMappings[GLFW_KEY_BACKSPACE] =           KEY_BACKSPACE;
    keyMappings[GLFW_KEY_ENTER] =               KEY_ENTER;
    keyMappings[GLFW_KEY_EQUAL] =               KEY_EQUALS;
    keyMappings[GLFW_KEY_MINUS] =               KEY_MINUS;
    keyMappings[GLFW_KEY_LEFT_BRACKET] =        KEY_LEFT_SQUARE_BRACKET;
    keyMappings[GLFW_KEY_RIGHT_BRACKET] =       KEY_RIGHT_SQUARE_BRACKET;
    keyMappings[GLFW_KEY_BACKSLASH] =           KEY_BACK_SLASH;
    keyMappings[GLFW_KEY_SLASH] =               KEY_FORWARD_SLASH;
    keyMappings[GLFW_KEY_COMMA] =               KEY_COMMA;
    keyMappings[GLFW_KEY_PERIOD] =              KEY_PERIOD;
    keyMappings[GLFW_KEY_INSERT] =              KEY_INSERT;
    keyMappings[GLFW_KEY_DELETE] =              KEY_DELETE;
    keyMappings[GLFW_KEY_HOME] =                KEY_HOME;
    keyMappings[GLFW_KEY_END] =                 KEY_END;
    keyMappings[GLFW_KEY_PAGE_UP] =             KEY_PAGE_UP;
    keyMappings[GLFW_KEY_PAGE_DOWN] =           KEY_PAGE_DOWN;
    keyMappings[GLFW_KEY_PRINT_SCREEN] =        KEY_PRINT_SCREEN;
    keyMappings[GLFW_KEY_UP] =                  KEY_UP;
    keyMappings[GLFW_KEY_DOWN] =                KEY_DOWN;
    keyMappings[GLFW_KEY_LEFT] =                KEY_LEFT;
    keyMappings[GLFW_KEY_RIGHT] =               KEY_RIGHT;
    keyMappings[GLFW_KEY_KP_ADD] =              KEY_NUMPAD_PLUS;
    keyMappings[GLFW_KEY_KP_0] = KEY_NUMPAD_0;
    keyMappings[GLFW_KEY_KP_1] = KEY_NUMPAD_1;
    keyMappings[GLFW_KEY_KP_2] = KEY_NUMPAD_2;
    keyMappings[GLFW_KEY_KP_3] = KEY_NUMPAD_3;
    keyMappings[GLFW_KEY_KP_4] = KEY_NUMPAD_4;
    keyMappings[GLFW_KEY_KP_5] = KEY_NUMPAD_5;
    keyMappings[GLFW_KEY_KP_6] = KEY_NUMPAD_6;
    keyMappings[GLFW_KEY_KP_7] = KEY_NUMPAD_7;
    keyMappings[GLFW_KEY_KP_8] = KEY_NUMPAD_8;
    keyMappings[GLFW_KEY_KP_9] = KEY_NUMPAD_9;
    keyMappings[GLFW_KEY_F1] = KEY_FUNC_F1;
    keyMappings[GLFW_KEY_F2] = KEY_FUNC_F2;
    keyMappings[GLFW_KEY_F3] = KEY_FUNC_F3;
    keyMappings[GLFW_KEY_F4] = KEY_FUNC_F4;
    keyMappings[GLFW_KEY_F5] = KEY_FUNC_F5;
    keyMappings[GLFW_KEY_F6] = KEY_FUNC_F6;
    keyMappings[GLFW_KEY_F7] = KEY_FUNC_F7;
    keyMappings[GLFW_KEY_F8] = KEY_FUNC_F8;
    keyMappings[GLFW_KEY_F9] = KEY_FUNC_F9;
    keyMappings[GLFW_KEY_F10] = KEY_FUNC_F10;
    keyMappings[GLFW_KEY_F11] = KEY_FUNC_F11;
    keyMappings[GLFW_KEY_F12] = KEY_FUNC_F12;
}

You can see here we're doing two things: first, we get the primary window handle from our UApplication and register a callback function called KeyboardCallback that matches the GLFW keyboard callback function pointer:

void KeyboardCallback(GLFWwindow* window, int key, int scancode, int action, int mods);

The second thing it does is populate our key mappings, mapping from GLFW codes (which is what will be passed into the callback) to our Keys enum. Here is the KeyboardCallback function:

// GLFW callback functions
void KeyboardCallback(GLFWwindow* window, int key, int scancode, int action, int mods) {
    // Get our input system
    UInputSystem* ioSystem = GetUSystem<UInputSystem>();

    // Find our keyboard device
    UKeyboard* keyboard = dynamic_cast<UKeyboard*>(ioSystem->FindInputDevice<UKeyboard>());

    // Dispatch event
    int state = (action == GLFW_PRESS || action == GLFW_REPEAT) ? 1 : 0;
    keyboard->SetButtonState(keyMappings[key], state);

    // Potentially trigger an action
    ioSystem->TriggerAction(keyboard, keyMappings[key], state);
}

You'll notice that one of the parameters passed into the callback is not the keyboard object, or even a user pointer (and this function is outside of the scope of our Keyboard object), so the first thing we need to do is get a pointer to our Keyboard. We do this by calling the GetUSystem service locator function that we created in our ECS article. This simply searches the UApplication singleton instance for a UInputSystem and returns it. From there, we can use the FindInputDevice template function to get a pointer to our Keyboard.

With the keyboard pointer, we can set the state of a key. Since we're converting everything to a floating point value, we'll treat the press and repeated state as 1.0 and release as 0.0. We simply pass that value as well as the lookup value from GLFW key to our keys into SetButtonState. Finally, we may need to trigger an InputAction event, if this is mapped to an action (eg. "jump") - but we'll let the InputSystem handle that determination and simply let it know that a button has changed state.

Finally, we have the GetButtonState and SetButtonState functions, which simply get or set the value into our key states array:

float UKeyboard::GetButtonState(int button) {
    UASSERT(0 <= button && button < KEY_LAST, "Button outside of range.");
    return(m_keyStates[button]);
}

void UKeyboard::SetButtonState(int button, float state) {
    UASSERT(0 <= button && button < KEY_LAST, "Button outside of range.");
    m_keyStates[button] = state;
}

Conclusion

With our first device implemented, we can start to get a sense of how powerful these action mappings can be. In our game executable (or likely in a script later), we can now register action mappings and use their state in our functions. For example, if we wanted to move our camera around the world, we may want to let the user use W-A-S-D or the arrow keys. Let's take a look at an example in practice, from our main game loop:

// Add input mappings
UKeyboard* keyboard = app->GetSystem<UInputSystem>()->FindInputDevice<UKeyboard>();
inputSystem->RegisterActionMapping(keyboard, KEY_UP, "move forward");
inputSystem->RegisterActionMapping(keyboard, KEY_W, "move forward");

while (window->IsClosing() == false) {
    // ... the rest of the game loop

    // Move or rotate camera on axes depending on button states
    UWorld* world = UWorld::Instance();
    UCameraComponent* camera = world->GetComponents<UCameraComponent>()[0];

    camera->transform.Move(camera->transform.Look() * speed * delta * 
        inputSystem->GetActionState("move forward"));

    // ... the rest of the game loop
}

Now, we can move our camera forward using its look vector at a pre-defined speed per second (using the delta) based on the state of "move forward". The user can press the up arrow key, or the W key, and the camera will move forward. If the user is not pressing the key, then the state is 0.0 and the Move command does nothing.

Later on, we'll move the action mappings to our editor and we'll also let the user set their own preferences for input. Plus, we'll layer in possible mouse and gamepad inputs. No matter how many keys or inputs are mapped to the action, we'll be able to check the state across all devices and plug that input into our game, giving the user control over their world.