Skip to content

A Godot-inspired action input handling system for Ebitengine

License

Notifications You must be signed in to change notification settings

quasilyte/ebitengine-input

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

86 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ebitengine input library

Build Status PkgGoDev

Overview

A Godot-inspired action input handling system for Ebitengine.

Key features:

  • Actions paradigm instead of the raw input events
  • Configurable keymaps
  • Bind more than one key to a single action
  • Bind keys with modifiers to a single action (like ctrl+c)
  • Simplified multi-input handling (like multiple gamepads)
  • Implements keybind scanning (see remap example)
  • Simplified keymap loading from a file (see configfile example)
  • Implements simulated/virtual input events (see simulateinput example)
  • No extra dependencies (apart from the Ebitengine of course)
  • Solves some issues related to gamepads in browsers
  • Wheel/scroll as action events
  • Motion-style events, like "gamepad stick just moved" (see smooth_movement example)
  • Can be used without extra deps or with gmath integration

This library may require some extra docs, code comments and examples. You can significantly help me by providing those. Pointing out what is currently missing is helpful too!

Some games that were built with this library:

Installation

go get github.com/quasilyte/ebitengine-input

A runnable example is available:

git clone https://github.com/quasilyte/ebitengine-input.git
cd ebitengine-input
go run ./_examples/basic/main.go

Quick Start

package main

import (
	"image"
	"log"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/ebitenutil"
	input "github.com/quasilyte/ebitengine-input"
)

const (
	ActionMoveLeft input.Action = iota
	ActionMoveRight
)

func main() {
	ebiten.SetWindowSize(640, 480)
	if err := ebiten.RunGame(newExampleGame()); err != nil {
		log.Fatal(err)
	}
}

type exampleGame struct {
	p           *player
	inputSystem input.System
}

func newExampleGame() *exampleGame {
	g := &exampleGame{}
	g.inputSystem.Init(input.SystemConfig{
		DevicesEnabled: input.AnyDevice,
	})
	keymap := input.Keymap{
		ActionMoveLeft:  {input.KeyGamepadLeft, input.KeyLeft, input.KeyA},
		ActionMoveRight: {input.KeyGamepadRight, input.KeyRight, input.KeyD},
	}
	g.p = &player{
		input: g.inputSystem.NewHandler(0, keymap),
		pos:   image.Point{X: 96, Y: 96},
	}
	return g
}

func (g *exampleGame) Layout(outsideWidth, outsideHeight int) (int, int) {
	return 640, 480
}

func (g *exampleGame) Draw(screen *ebiten.Image) {
	g.p.Draw(screen)
}

func (g *exampleGame) Update() error {
	g.inputSystem.Update()
	g.p.Update()
	return nil
}

type player struct {
	input *input.Handler
	pos   image.Point
}

func (p *player) Update() {
	if p.input.ActionIsPressed(ActionMoveLeft) {
		p.pos.X -= 4
	}
	if p.input.ActionIsPressed(ActionMoveRight) {
		p.pos.X += 4
	}
}

func (p *player) Draw(screen *ebiten.Image) {
	ebitenutil.DebugPrintAt(screen, "player", p.pos.X, p.pos.Y)
}

Introduction

Let's assume that we have a simple game where you can move a character left or right.

You might end up checking the specific key events in your code like this:

if ebiten.IsKeyPressed(ebiten.KeyLeft) {
    // Move left
}

But there are a few issues here:

  1. This approach doesn't allow a key rebinding for the user
  2. There is no clean way to add a gamepad support without making things messy
  3. And even if you add a gamepad support, how would you handle multiple gamepads?

All of these issues can be solved by our little library. First, we need to declare our abstract actions as enum-like constants:

const (
	ActionUnknown input.Action = iota
	ActionMoveLeft
	ActionMoveRight
)

Then we change the keypress handling code to this:

if h.ActionIsPressed(ActionMoveLeft) {
    // Move left
}

Now, what is h? It's an input.Handler.

The input handler is bound to some keymap and device ID (only useful for the multi-devices setup with multiple gamepads being connected to the computer).

Having a keymap solves the first issue. The keymap associates an input.Action with a list of input.Key. This means that the second issue is resolved too. The third issue is covered by the bound device ID.

So how do we create an input handler? We use a constructor provided by the input.System.

// The ID argument is important for devices like gamepads.
// The input handlers can have the same keymaps.
player1input := inputSystem.NewHandler(0, keymap)
player2input := inputSystem.NewHandler(1, keymap)

The input system is an object that you integrate into your game Update() loop.

func (g *myGame) Update() {
    g.inputSystem.Update() // called every Update()

    // ...rest of the function
}

You usually put this object into the game state. It could be either a global state (which I don't recommend) or a part of the state-like object that you pass through your game explicitely.

type myGame struct {
    inputSystem input.System

    // ...rest of the fields
}

You'll need to call the input.System.Init() once before calling its Update() method. This Init() can be called before Ebitengine game is executed.

func newMyGame() *myGame {
    g := &myGame{}
    g.inputSystem.Init(input.SystemConfig{
		DevicesEnabled: input.AnyDevice,
	})
    // ... rest of the game object initialization
    return g
}

The keymaps are quite straightforward. We're hardcoding the keymap here, but it could be read from the config file.

keymap := input.Keymap{
    ActionMoveLeft:  {input.KeyGamepadLeft, input.KeyLeft, input.KeyA},
    ActionMoveRight: {input.KeyGamepadRight, input.KeyRight, input.KeyD},
}

With the keymap above, when we check for the ActionMoveLeft, it doesn't matter if it was activated by a gamepad left button on a D-pad or by a keyboard left/A key.

Another benefit of this system is that we can get a list of relevant key events that can activate a given action. This is useful when you want to prompt player to press some button.

// If gamepad is connected, show only gamepad-related keys.
// Otherwise show only keyboard-related keys.
inputDeviceMask := input.KeyboardInput
if h.GamepadConnected() {
    inputDeviceMask = input.GamepadInput
}
keyNames := h.ActionKeyNames(ActionMoveLeft, inputDeviceMask)

Since the pattern above is quite common, there is a shorthand for that:

keyNames := h.ActionKeyNames(ActionMoveLeft, h.DefaultInputMask())

If the gamepad is connected, the keyNames will be ["gamepad_left"]. Otherwise it will contain two entries for our example: ["left", "a"].

To build a combined key like ctrl+c, use KeyWithModifier function:

// trigger an action when c is pressed while ctrl is down
input.KeyWithModifier(input.KeyC, input.ModControl)

See an example for a complete source code.

Enabling gmath

This library can be used with and without gmath dependency.

By default, it uses its own Vec implementation which is really just an {X, Y float64} wrapper without any methods. It's expected that you convert that simple type into your app's native vector2D type.

But if you happen to use gmath package, you don't have to do this conversion as ebitengine-input can make Vec an alias to gmath.Vec type.

To enable this alias declaration, you need to specify the gmath build tag like so:

go run --tags=gmath ./mygame

You may also want to configure your IDE/editor to understand that input.Vec is an alias to gmath.Vec. The best way to do that is to declare the default build tags via something like GOFLAGS.

For VSCode and VSCodium you can do the following:

"go.toolsEnvVars": {
	"GOFLAGS": "-tags=gmath",
}

Thread Safety Notice

This library never does any synchronization on its own. It's implied that you don't do a concurrent access to the input devices.

Therefore, keep in mind:

  • Emitting a simulated input event from several goroutines is a data race
  • Using any Handler APIs while System.Update is in process is a data race