Jump to content

Where Input Has A Name


reepblue

213 views

 Share

I've spent the last few months pressing buttons, clicking joysticks and shaking my computer mouse to solve the solution input for Cyclone. Back when it shipped in June, I've created a system that allowed users to assign keys to actions, in which the game would detect as input. My player code never knew what button was pressed; it just knew what action was caused. This is very similar to how Steam Input works.

There were a few flaws with my original system. Some of which didn't surface until I shipped.

  • I stored the Keycode as int32 values. Unless someone had a chart, they couldn't easily bind their keys.
  • Things got really confusing when it came to international keyboards. Not all keycodes are universal.
  • My system only supported buttons. Actions for axis controls were not a thing and were hard coded.
  • The system relied on the Leadwerks API which would cause making a utility app kind of tricky.
  • Mouse aim wasn't generally liked.

I wanted a library that did nothing but input. But I got dead libraries or libraries that needed dependencies to work. I wanted Steam Input, but for the Keyboard and Mouse. I knew I couldn't let this sit based on the amount of feedback I was getting because from this. Since nobody else thought it was necessary to make one, it looked like I had to make an input system in-which input has a name.

Before we get into input, I first had to re-arrange how my repo file structure was set up. I had Cyclone set up as a generic Leadwerks project, but this wasn't going to work if I wanted to create shared libraries and utilities.  Before I did anything stupid with Cyclone, I made a new repo to figure out how everything should be laid out. I decided to follow a file structure much like Valve has their Source engine and use premake to generate the files. Shell scripts are used to generate projects via WSL. This allowed me to compile for Windows AND Linux on the same machine and I did casual build tests on macOS. I never want to write an input system ever again.

 

Before I could do any form of action detection, I needed to create a "Driver" sort of speak and have the operating system pump events into it. This interface class allows classes to be derived from it and work no matter what driver is created.

    class INPUTSYTEM_API IInputDriver
    {
    public:
        IInputDriver() {};
        virtual ~IInputDriver() {};

        virtual void EnablePumpEvents(const bool bState) = 0;
        virtual void PumpButtonDown(const button_t btn, const int controller = 0) = 0;
        virtual void PumpButtonUp(const button_t btn, const int controller = 0) = 0;
        virtual void PumpButtonCodeDown(const ButtonCode& btncode, const int controller = 0) = 0;
        virtual void PumpButtonCodeUp(const ButtonCode& btncode, const int controller = 0) = 0;
        virtual void PumpMouseWheelDir(const int dir) = 0;

        virtual void PumpAxis(const AxisCode& axiscode, const float x, const float y, const int controller = 0) = 0;
        virtual void PumpPointer(const PointerCode& pointer, const int x, const int y, const int controller = 0) = 0;
        virtual void PumpRumble(const float left, const float right, uint64_t duration_ms, const int controller = 0) = 0;

        virtual const bool ButtonDown(const ButtonCode& btncode, const int controller = 0) = 0;
        virtual const bool ButtonHit(const ButtonCode& btncode, const int controller = 0) = 0;
        virtual const bool ButtonReleased(const ButtonCode& btncode, const int controller = 0) = 0;
        virtual const bool ButtonAnyDown() = 0;
        virtual const bool ButtonAnyHit() = 0;
        virtual AxisVector GetAxis(const AxisCode& axiscode, const int controller = 0) = 0;
        virtual AxisVector GetButtonAxis(const ButtonCode& up, const ButtonCode& down, const ButtonCode& left, const ButtonCode& right) = 0;
        virtual void UpdateController(const int controller) = 0;
        virtual void SuspendControllerInput(const bool bState) = 0;

        virtual void Flush() = 0;
        virtual void FlushButtons() = 0;
        virtual void FlushAxis() = 0;

        virtual ButtonCode GetLastButtonPressed() = 0;
        virtual ButtonCode StringToButtonCode(const std::string& btnstring) = 0;
        virtual const char* ButtonCodeToString(const ButtonCode& btncode) = 0;

        virtual void SetCursorPosition(const int x, const int y) = 0;
        virtual void CenterCursorPosition() = 0;
        virtual ScreenPosition GetCursorPosition() = 0;
        virtual void MouseVisibility(const bool bState, const bool bRecenter) = 0;

        virtual void SetPointerPosition(const PointerCode& pointer, const int x, const int y) = 0;
        virtual ScreenPosition GetPointerPosition(const PointerCode& pointer) = 0;
        virtual void CenterPointerPosition(const PointerCode& pointer) = 0;

        virtual void SetActiveDevice(InputDevice device) = 0;
        virtual InputDevice GetActiveDevice() = 0;
        virtual bool DeviceChanged() = 0;
        
        virtual void Cleanup() = 0;
    };

You may notice that there is button_t and ButtonCode. The type: button_t is the unit32_t type from the OS and the ButtonCode is button_t reassigned to my own enum structure. This way, my end values remain consistent between OS and drivers. For example, WinAPI pumps Virtual Keyboard flag values while Coco/X11 will pump its own flag definitions. If you wanted to use SDL, all you need to do is make an SDL driver for it and the code on top of it will not care.

Also note that there are no separate functions to detect button and key presses. There are only Buttons, Axis, and Pointer values when it comes to my input library. I was also going to omit the cursor commands, but it's part of the desktop environment and really can't be ignored. The Set/GetCursorPosition is there for redundancy really. 

The pump functions are made public to allow external pumping of events. For Windows, you can "hijack" the Window polling with a custom function. Here's an example of how I did it in Leadwerks and the same thing can be done in Ultra Engine for Windows exclusive games.

LRESULT CALLBACK MyWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam)
{
	InputSystem::ProcInputWin32(hwnd, message, wparam, lparam);
	return Leadwerks::WndProc(hwnd, message, wparam, lparam);
}

int main(int argc, const char* argv[])
{
	auto window = Leadwerks::Window::Create("Game", 0, 0, 1280, 720, Leadwerks::Window::Titlebar | Leadwerks::Window::Center);

	if (window == NULL)
		return 1;

	// Init the input system.
	InputSystem::Init(window->hwnd);

#ifdef _WIN32
	// Swap the callback with ours
	WNDPROC OldProc = reinterpret_cast<WNDPROC>(SetWindowLongPtr(
		hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(MyWndProc)));
#endif
....

That's all it takes to start my input system. Just pass the window handle to the Init function and the library will load the rest. I only have a WinAPI driver right now, but the idea is that of you were building on Linux or macOS, the driver will be created. I still need to work out how polling and key events are processed on those platforms.

We can get a button press as easily as this:

auto b = InputSystem::GetDriver()->ButtonHit(InputSystem::BUTTON_KEY_ESCAPE);

Josh provided me with insight on using the raw mouse value to return a Vector valve so you can easily obtain it by:

auto a = InputSystem::GetDriver()->GetAxis(InputSystem::AXIS_MOUSE);

You can even do neat things like this:

if (InputSystem::GetDriver()->ButtonAnyDown())
{
	auto btn = InputSystem::GetDriver()->GetLastButtonPressed();
	std::cout << InputSystem::GetDriver()->ButtonCodeToString(btn) << " is held down!" << std::endl;
}

 

Great, we now have an input API like everyone else. Although it's richer and more flexible than what Leadwerks provides, we're not done. We need another layer on top of the driver in which our game will actually use.

Meet the Action Controller!

    class INPUTSYTEM_API IActionController
    {
    public:
        int32_t controller_port = 0;

        IActionController() {};
        virtual ~IActionController() {};
        virtual const int32_t GetControllerID() = 0;

        virtual void SetActionSet(const char* actionsetid) = 0;
        virtual const char* GetActionSet() = 0;

        virtual bool Down(const char* actionid, const char* actionsetid = "") = 0;
        virtual bool Hit(const char* actionid, const char* actionsetid = "") = 0;
        virtual bool Released(const char* actionid, const char* actionsetid = "") = 0;
        virtual AxisVector Axis(const char* actionid, const char* actionsetid = "") = 0;
        virtual void Rumble(const float leftmotor, const float rightmotor, uint64_t duration_ms = 10) = 0;
        virtual void FlushAllInput(const bool bCenterPointDevice = false) = 0;

        virtual const bool NoActionSets() = 0;
        virtual const Action GetAction(const char* actionid, const char* actionsetid = "") = 0;
        
        virtual const int ButtonCount(const char* actionid, const char* actionsetid = "") = 0;
        virtual const int AxisCount(const char* actionid, const char* actionsetid = "") = 0;
        virtual const ButtonCode GetButton(const char* actionid, const int index = 0, const char* actionsetid = "") = 0;
        virtual const AxisCode GetAxis(const char* actionid, const int index = 0, const char* actionsetid = "") = 0;
        virtual const ButtonAxis GetButtonAxis(const char* actionid, const char* actionsetid = "") = 0;
        virtual const PointerCode GetPointDevice() = 0;

        virtual const bool IsKBM() = 0;
        virtual const float GetSetting(const char* setting, const float defaultsetting = 0) = 0;

        virtual void SetPointDevice(const PointerCode& pointdevice) = 0;
        virtual void SetPointDevicePosition(const int x, const int y) = 0;
        virtual ScreenPosition GetPointDevicePosition() = 0;
        virtual void CenterPointDevice() = 0;
        virtual void TogglePointerVisibility(const bool bShow) = 0;
        virtual const bool GetPointerVisibility() = 0;

        // Writting of data
        virtual void SetSetting(const char* setting, const float fValue) = 0;
        
        virtual void RegisterActionSet(const char* actionsetid) = 0;
        virtual void BindAction(const char* actionsetid, const char* actionid, const ButtonCode& buttoncode) = 0;
        virtual void BindAction(const char* actionsetid, const char* actionid, const AxisCode& axiscode) = 0;
        virtual void BindAction(const char* actionsetid, const char* actionid, const ButtonAxis& buttonaxis) = 0;
        virtual void ClearAction(const char* actionsetid, const char* actionid) = 0;

        virtual IInputDriver* GetDriver() = 0;

        friend class IInputDriver;
    };

 

Did notice that we pass strings instead of button codes? So instead of checking if the Space bar is pressed, we check if the Space action is pressed.

// Bad, don't do this.
const bool b = InputSystem::GetDriver()->ButtonHit(InputSystem::BUTTON_KEY_SPACE)
if (b) pPlayer->Jump();

// Do this!
auto controller = InputSystem::GetDriver()->GetController();
if (controller->Hit("Jump")) pPlayer->Jump();

The keys can be ether assigned in code or loaded form a JSON file. All actions are converted to lower case to prevent confusion between "Jump" and "jump". Actions can have multiple buttons binded to it which is good for also storing controller buttons. The controller also supports having 4 buttons act as an axis which should be used for movement.

It's easy to create an action controller. 

auto action_controller = InputSystem::CreateActionController("actioncontroller.json");

Don't have a script yet? You can directly bind buttons and axis values after its creation and save the results to get started.

    auto action_controller = InputSystem::CreateActionController("");

    const char* action_set = "TestActionSet";
    action_controller->RegisterActionSet(action_set);
    action_controller->BindAction(action_set, "Camera", InputSystem::AXIS_MOUSE);
    action_controller->BindAction(action_set, "Camera", InputSystem::AXIS_GAMEPAD_RSTICK);
    action_controller->BindAction(action_set, "Jump", InputSystem::BUTTON_KEY_SPACE);
    action_controller->BindAction(action_set, "Jump", InputSystem::BUTTON_KEY_C);
    action_controller->BindAction(action_set, "Jump", InputSystem::BUTTON_GAMEPAD_A);
    InputSystem::ButtonAxis move_axis =
    {
        InputSystem::BUTTON_KEY_W,
        InputSystem::BUTTON_KEY_S,
        InputSystem::BUTTON_KEY_A,
        InputSystem::BUTTON_KEY_D
    };
    action_controller->BindAction(action_set, "Move", move_axis);
    action_controller->BindAction(action_set, "Move", InputSystem::AXIS_GAMEPAD_LSTICK);
    
    InputSystem::SaveControllerProfile(action_controller, "inputtest.json");

 

This is all well and good, but the real payout will be to have an app that can easily bind raw input to actions. For this, I had to use Ultra App Kit as I needed something compatible with my Win32 libraries. Otherwise, I would have used the full engine.

Like I said, it's pretty straight forward to hook this up with the Ultra API.

#ifdef _WIN32
LRESULT CALLBACK MyWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam)
{
    InputSystem::ProcInputWin32(hwnd, message, wparam, lparam);
    return UltraEngine::Window::WndProc(hwnd, message, wparam, lparam);
}
#endif

std::shared_ptr<UltraEngine::Window> BuildWindow(const int w, const int h)
{
    //Get displays
    auto displays = GetDisplays();

    //Create window
    auto mainwindow = CreateWindow("Action Mapper", 0, 0, w, h, displays[0], WINDOW_CENTER | WINDOW_TITLEBAR | WINDOW_HIDDEN);
    mainwindow->SetMinSize(w, h);

#ifdef _WIN32
    // Get device context
    HWND hwnd = mainwindow->GetHandle();
    HDC hdc = GetDC(hwnd);

    // Load the icon for window titlebar and taskbar
    HICON icon = LoadIconA(GetModuleHandle(NULL), (LPCSTR)101);
    SendMessage(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(icon));

    // Swap the callback with ours
    WNDPROC OldProc = reinterpret_cast<WNDPROC>(SetWindowLongPtr(
        hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(MyWndProc)));
#endif

    InputSystem::Init();
    InputSystem::GetDriver()->EnablePumpEvents(false);
    InputSystem::GetDriver()->SuspendControllerInput(true);

    return mainwindow;
}

I first initialize the driver, then tell it to not pump any OS events into it. This is because I wanted the events to pump to my driver only when requested by my bind button class. When that is pressed, the button gets disabled until InputSystem::GetDriver()->ButtonAnyHit() returns true and saves the last pressed key. I didn't need to pass the window handle as that's only needed to toggle the visibility of the cursor. 

large.actionmapper.jpg.8312f52a3a6d4848e

This ended up working really well, and I'm happy how it came out. But you know what would be really customer friendly? What if this had a native Linux build? Ultra App Kit has Linux64 libraries, and my entire build environment uses premake with WSL so building one was no issue. The issue is that I didn't look into X11 and I don't wanna hold the next update much longer.

Using my input library, I just pump the events from the Ultra API into my WinAPI driver and most keys worked. I had to fix Control, Alt, and Shift, but it was pretty easy. This only gets used with the Linux build. 

#ifdef __linux__
#define VK_LSHIFT           0xA0
#define VK_RSHIFT           0xA1
#define VK_LCONTROL         0xA2
#define VK_RCONTROL         0xA3
#define VK_LMENU            0xA4
#define VK_RMENU            0xA5
#endif

namespace UltraInputWrapper
{
    // We need to convert some keys to translate
    // engine values to Windows VK values.
    const int Convert(const int in)
    {
        int key = in;
        if (in == UltraEngine::KEY_CONTROL) return VK_LCONTROL;
        //if (in == UltraEngine::KEY_CONTROL) return VK_RCONTROL;
        if (in == UltraEngine::KEY_SHIFT) return VK_LSHIFT;
        //if (in == UltraEngine::KEY_SHIFT) return VK_RSHIFT;
        if (in == UltraEngine::KEY_ALT) return VK_LMENU;
        //if (in == UltraEngine::KEY_ALT) return VK_RMENU;
        return key;
    }

    // For non-Windows, push engine values.
    // Since UltraEngine uses the same values as WinAPI, it should be fine..
    void PollIntoDriver(const Event& e)
    {
        switch (e.id)
        {
        case EVENT_KEYDOWN:
            InputSystem::GetDriver()->PumpButtonDown(Convert(e.data));
            break;

        case EVENT_KEYUP:
            InputSystem::GetDriver()->PumpButtonUp(Convert(e.data));
            break;

        case EVENT_MOUSEDOWN:
            InputSystem::GetDriver()->PumpButtonDown(Convert(e.data));
            break;

        case EVENT_MOUSEUP:
            InputSystem::GetDriver()->PumpButtonUp(Convert(e.data));
            break;

        default:
            break;
        }
    }
}

 

The final step was to gut the old input system out of Cyclone and replace it with my action controller. I also had to make it co-exist with Steam Input which I found out nulls out my XInput calls. I wrote a new singleton class that checks the state of both my action controller and Steam Input. Then it was making sure the right glyphs showed up per action.

One last thing I want to share is a little bit of how my GameController class works. Since this follows the same ideology as Steam Input, it all works nicely.

// Button Example
const bool GameController::OnJump()
{
	if (actioncontroller == NULL) return false;
	return actioncontroller->Hit("Jump") || SteamInputController::Hit(SteamInputController::eControllerDigitalAction_Jump);
}

// Axis Example
Leadwerks::Vec2 GameController::GetMovementAxis()
{
	Leadwerks::Vec2 ret = Leadwerks::Vec2(0);

	if (actioncontroller != NULL)
	{
		// Shared movement between KB and Controller.
		InputSystem::AxisVector moveAxis = actioncontroller->Axis("Move");
		ret = Leadwerks::Vec2(moveAxis.x + SteamInputController::Axis(SteamInputController::eControllerAnalogAction_MoveControls).x, moveAxis.y + SteamInputController::Axis(SteamInputController::eControllerAnalogAction_MoveControls).y);
	}

	ret.x = Leadwerks::Math::Clamp(ret.x, -1.0f, 1.0f);
	ret.y = Leadwerks::Math::Clamp(ret.y, -1.0f, 1.0f);
	return ret;
}

 

This was an absolute time vampire, but I'm glad it's done minus the Coco/X11 drivers. Cyclone will be updated soon with this and hopefully this fixes international bindings. I couldn't find any information on the subject, and my virtual Turkish keyboard works properly. I'm just hoping the storing of the VK values is enough.

I can easily build this for Windows, Mac and Linux to work with Leadwerks or the upcoming Ultra Engine. This is how we should be programming our input for our games. Stop using window->KeyHit(). I plan on releasing the binaries so everyone can have better input code. 

 

  • Like 5
 Share

1 Comment


Recommended Comments

Guest
Add a comment...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

×
×
  • Create New...