Bunyip a game engine in Go GitHub

Example examples/inputs

Inputs

Inputs

This program draws the whole of input on one screen. A keyboard lights up as keys are held, a line of text reports the mouse position, buttons, wheel, relative motion and modifiers, another reports what has been typed including an in-progress IME composition, and four panels show the sticks, triggers and buttons of any connected gamepads.

It is the program to read when a key does not do what a game expects. Key codes name physical positions rather than letters: input.KeyA is the key where A sits on a US QWERTY keyboard whatever the layout prints on it, which is what a game wants for WASD movement and what in.Chars() does not give. Typed text comes from Chars, which is the layout's own output and the only correct source for a text field. Read the input guide for action maps and rebinding, which this example deliberately leaves out to show the raw state.

The window controls used here, the title, the size floor, the cursor shape, cursor capture and fullscreen, are on engine.Context rather than in the input package; the window guide covers them.

Run it:

go run ./examples/inputs -seconds 3 -shot out.png

The flags are -seconds N and -shot file.png. There is no -headless flag; set BUNYIP_HEADLESS=1 to run it without a window. C captures the cursor, Escape releases it and then quits, F toggles fullscreen, and Ctrl+V or Cmd+V pastes.

Package and state

The game holds the font, the last forty typed runes, the accumulated wheel travel, and a smoothed mouse delta. Nothing here is input state: the engine keeps that, and a frame reads it from ctx.Input.

// Command inputs visualises everything the input package reports: keys
// held, mouse buttons, wheel, position, relative motion while the cursor
// is captured (C toggles, Escape releases), fullscreen (F), typed text
// with IME composition, and up to four gamepads with sticks, triggers
// and buttons.
package main

import (
	"flag"
	"fmt"
	"os"
	"strings"

	"golang.org/x/image/font/gofont/goregular"

	"github.com/matjam/bunyip/engine"
	"github.com/matjam/bunyip/gfx"
	"github.com/matjam/bunyip/input"
)

type game struct {
	seconds float64
	shot    string

	font     *gfx.Font
	typed    []rune
	scroll   float32
	deltaX   float32
	deltaY   float32
	shotDone bool
}

The keyboard layout

The drawing needs a picture of a keyboard, which the input package does not provide because a key code is a position rather than a layout. These two tables are this program's own: the rows to draw, and the names to print on the keys whose name is not derived from the code.

var keyRows = [][]input.Key{
	{input.KeyEscape, input.KeyF1, input.KeyF2, input.KeyF3, input.KeyF4, input.KeyF5, input.KeyF6, input.KeyF7, input.KeyF8, input.KeyF9, input.KeyF10, input.KeyF11, input.KeyF12},
	{input.KeyGrave, input.Key1, input.Key2, input.Key3, input.Key4, input.Key5, input.Key6, input.Key7, input.Key8, input.Key9, input.Key0, input.KeyMinus, input.KeyEqual, input.KeyBackspace},
	{input.KeyTab, input.KeyQ, input.KeyW, input.KeyE, input.KeyR, input.KeyT, input.KeyY, input.KeyU, input.KeyI, input.KeyO, input.KeyP, input.KeyLeftBracket, input.KeyRightBracket, input.KeyBackslash},
	{input.KeyCapsLock, input.KeyA, input.KeyS, input.KeyD, input.KeyF, input.KeyG, input.KeyH, input.KeyJ, input.KeyK, input.KeyL, input.KeySemicolon, input.KeyApostrophe, input.KeyEnter},
	{input.KeyLeftShift, input.KeyZ, input.KeyX, input.KeyC, input.KeyV, input.KeyB, input.KeyN, input.KeyM, input.KeyComma, input.KeyPeriod, input.KeySlash, input.KeyRightShift},
	{input.KeyLeftControl, input.KeyLeftAlt, input.KeyLeftSuper, input.KeySpace, input.KeyRightSuper, input.KeyRightAlt, input.KeyLeft, input.KeyUp, input.KeyDown, input.KeyRight},
}

var keyNames = map[input.Key]string{
	input.KeyEscape: "Esc", input.KeyGrave: "`", input.KeyMinus: "-", input.KeyEqual: "=", input.KeyBackspace: "Bksp", input.KeyTab: "Tab",
	input.KeyLeftBracket: "[", input.KeyRightBracket: "]", input.KeyBackslash: "\\", input.KeyCapsLock: "Caps", input.KeySemicolon: ";",
	input.KeyApostrophe: "'", input.KeyEnter: "Enter", input.KeyLeftShift: "Shift", input.KeyRightShift: "Shift", input.KeyComma: ",",
	input.KeyPeriod: ".", input.KeySlash: "/", input.KeyLeftControl: "Ctrl", input.KeyLeftAlt: "Alt", input.KeyLeftSuper: "Cmd",
	input.KeySpace: "Space", input.KeyRightSuper: "Cmd", input.KeyRightAlt: "Alt", input.KeyLeft: "<", input.KeyUp: "^", input.KeyDown: "v", input.KeyRight: ">",
}

keyName covers the rest by arithmetic. The letter, digit and function key codes are contiguous and in order, so the name of a key in one of those ranges is its offset from the first.

func keyName(k input.Key) string {
	if n, ok := keyNames[k]; ok {
		return n
	}
	switch {
	case k >= input.KeyA && k <= input.KeyZ:
		return string(rune('A' + k - input.KeyA))
	case k >= input.Key0 && k <= input.Key9:
		return string(rune('0' + k - input.Key0))
	case k >= input.KeyF1 && k <= input.KeyF12:
		return fmt.Sprintf("F%d", k-input.KeyF1+1)
	}
	return "?"
}

Init: the window controls

Init creates the font and then sets three window properties. SetTitle changes the title after the window is open. SetSizeLimits takes a minimum width and height and a maximum; zero means no limit, so this window cannot be dragged smaller than 480 by 320 and has no upper bound. SetCursor picks one of the system cursor shapes.

func (g *game) Init(ctx *engine.Context) error {
	var err error
	g.font, err = ctx.Gfx.NewFont(goregular.TTF, 14, gfx.FontOptions{})
	// The window controls: a title, a size floor and a crosshair pointer.
	ctx.SetTitle("Bunyip inputs (paste with Ctrl+V or Cmd+V)")
	ctx.SetSizeLimits(480, 320, 0, 0)
	ctx.SetCursor(engine.CursorCrosshair)
	return err
}

Shutdown destroys the font. Every GPU resource a game creates is destroyed on the goroutine that created it, which is this one.

func (g *game) Shutdown(ctx *engine.Context) { g.font.Destroy() }

Update: reading the state

ctx.Input is the state for this update. The methods split into edges and levels: KeyPressed is true on the update a key goes down, KeyDown is true for as long as it is held. Escape does two things depending on the state, releasing a captured cursor first and quitting only when the cursor is free, which is the behaviour a player expects from a captured pointer.

in.Chars() returns the runes typed since the last update, already through the platform's own layout and dead-key handling. They are appended to a buffer the program trims to forty runes. Backspace is handled from the key code, because a backspace is not a character. in.Mods() is a bit set, so a paste is a V press with either control or the platform's super key held, and ctx.Clipboard reads the system clipboard.

in.Scroll() returns this update's wheel travel, which the program accumulates, and in.MouseDelta() the pointer's relative motion, which is the only useful pointer reading while the cursor is captured and the absolute position is frozen. The delta is smoothed with a decay towards zero so a still mouse settles rather than flickering.

func (g *game) Update(ctx *engine.Context) error {
	in := ctx.Input
	if in.KeyPressed(input.KeyEscape) {
		if ctx.CursorCaptured() {
			ctx.SetCursorCaptured(false)
		} else {
			ctx.Quit()
		}
	}
	if g.seconds > 0 && ctx.Time >= g.seconds {
		ctx.Quit()
	}
	if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
		ctx.Screenshot(g.shot)
		g.shotDone = true
	}
	if in.KeyPressed(input.KeyC) {
		ctx.SetCursorCaptured(!ctx.CursorCaptured())
	}
	if in.KeyPressed(input.KeyF) {
		ctx.SetFullscreen(!ctx.Fullscreen())
	}
	g.typed = append(g.typed, in.Chars()...)
	if len(g.typed) > 40 {
		g.typed = g.typed[len(g.typed)-40:]
	}
	if in.KeyPressed(input.KeyBackspace) && len(g.typed) > 0 {
		g.typed = g.typed[:len(g.typed)-1]
	}
	if in.KeyPressed(input.KeyV) && in.Mods()&(input.ModControl|input.ModSuper) != 0 {
		if s, err := ctx.Clipboard(); err == nil {
			g.typed = append(g.typed, []rune(s)...)
		}
	}
	_, dy := in.Scroll()
	g.scroll += dy
	dx, ddy := in.MouseDelta()
	g.deltaX, g.deltaY = g.deltaX*0.9+dx, g.deltaY*0.9+ddy
	return nil
}

Draw: the keyboard

Draw runs once per frame, which may be more or less often than Update. Input edges are latched for the whole frame, so an interface built here sees every press even when the frame ran no update; levels like KeyDown are simply the current state.

Each key is a filled rectangle with its name drawn on it, tinted while the key is down. Positions are view units from the top left with Y increasing downwards. gfx.RGB takes sRGB bytes and returns the linear colour the renderer works in.

func (g *game) Draw(ctx *engine.Context) error {
	gr := ctx.Gfx
	in := ctx.Input
	// Keyboard.
	for r, row := range keyRows {
		x := float32(20)
		for _, k := range row {
			name := keyName(k)
			w := float32(max(34, 10*len(name)+14))
			col := gfx.RGB(50, 52, 66)
			if in.KeyDown(k) {
				col = gfx.RGB(120, 190, 255)
			}
			y := 20 + float32(r)*40
			gr.FillRect(x, y, w, 34, col)
			gr.DrawText(g.font, name, x+7, y+9, gfx.RGB(235, 235, 240))
			x += w + 6
		}
	}

Draw: the pointer and the text

The modifier list is built from the same bit set the update read. The line closure draws a line of text and advances a cursor down the screen, which is the shortest way to lay out a few lines without a layout system.

in.Composition() is the text an input method is composing but has not committed. A game that draws its own text field draws it after the committed text, usually underlined, and expects it to be replaced when the composition finishes.

	mods := []string{}
	for _, m := range []struct {
		bit  input.Mods
		name string
	}{{input.ModShift, "shift"}, {input.ModControl, "ctrl"}, {input.ModAlt, "alt"}, {input.ModSuper, "super"}, {input.ModCapsLock, "caps"}} {
		if in.Mods()&m.bit != 0 {
			mods = append(mods, m.name)
		}
	}
	y := float32(270)
	line := func(s string) {
		gr.DrawText(g.font, s, 20, y, gfx.RGB(220, 220, 230))
		y += 22
	}
	mx, my := in.Mouse()
	line(fmt.Sprintf("Mouse %.0f, %.0f   buttons L%v M%v R%v   wheel %.1f   delta %.1f, %.1f   modifiers [%s]",
		mx, my, in.MouseDown(input.MouseLeft), in.MouseDown(input.MouseMiddle), in.MouseDown(input.MouseRight), g.scroll, g.deltaX, g.deltaY, strings.Join(mods, " ")))
	line(fmt.Sprintf("Cursor captured: %v (C toggles; Escape releases)   Fullscreen: %v (F toggles)", ctx.CursorCaptured(), ctx.Fullscreen()))
	typed := string(g.typed)
	if comp := in.Composition(); comp != "" {
		typed += "[" + comp + "]"
	}
	line("Typed: " + typed + "_")

Draw: the gamepads

input.MaxGamepads is how many pads the engine tracks, and in.Gamepad(i) returns the state of one whether or not anything is plugged in; Connected says which. Axes read from minus one to one, with the stick's Y positive upwards, so the drawing subtracts it from the centre to move the marker up the screen. Triggers read from zero to one, which is why the bar is 90*pad.Axis(axis) wide. Buttons are a fixed set running to input.GamepadButtonCount, laid out here eight to a row.

	// Gamepads.
	for i := range input.MaxGamepads {
		pad := in.Gamepad(i)
		px := 20 + float32(i)*230
		py := float32(380)
		name := "no controller"
		col := gfx.RGB(60, 60, 70)
		if pad.Connected {
			name, col = pad.Name, gfx.RGB(90, 160, 90)
		}
		gr.FillRect(px, py, 210, 180, gfx.RGB(38, 40, 50))
		gr.DrawText(g.font, fmt.Sprintf("Pad %d: %s", i+1, name), px+8, py+6, col)
		for s, axes := range [][2]input.GamepadAxis{{input.AxisLeftX, input.AxisLeftY}, {input.AxisRightX, input.AxisRightY}} {
			cx, cy := px+50+float32(s)*110, py+80
			gr.FillRect(cx-30, cy-30, 60, 60, gfx.RGB(25, 26, 34))
			gr.FillRect(cx+pad.Axis(axes[0])*26-4, cy-pad.Axis(axes[1])*26-4, 8, 8, gfx.RGB(255, 210, 90))
		}
		for t, axis := range []input.GamepadAxis{input.AxisLeftTrigger, input.AxisRightTrigger} {
			gr.FillRect(px+10+float32(t)*100, py+125, 90, 8, gfx.RGB(25, 26, 34))
			gr.FillRect(px+10+float32(t)*100, py+125, 90*pad.Axis(axis), 8, gfx.RGB(255, 210, 90))
		}
		for b := range input.GamepadButtonCount {
			bc := gfx.RGB(60, 62, 76)
			if pad.Down(b) {
				bc = gfx.RGB(120, 190, 255)
			}
			gr.FillRect(px+10+float32(int(b)%8)*24, py+142+float32(int(b)/8)*16, 20, 12, bc)
		}
	}
	return nil
}

main

engine.Run opens the window and runs the loop until the game quits. The examples leave Config.Validation off; to turn on the Vulkan validation layers when they are installed, set BUNYIP_VALIDATION=1 in the environment.

func main() {
	seconds := flag.Float64("seconds", 0, "exit after this many seconds")
	shot := flag.String("shot", "", "write a screenshot to this PNG")
	flag.Parse()
	err := engine.Run(engine.Config{Title: "Bunyip inputs", Width: 960, Height: 580, Resizable: true},
		&game{seconds: *seconds, shot: *shot})
	if err != nil {
		fmt.Fprintln(os.Stderr, "inputs:", err)
		os.Exit(1)
	}
}

What to try

  • Print in.Chars() next to the key codes in Draw with a non-US layout selected, and watch the physical positions stay put while the characters change.
  • Add a row to keyRows for the numeric keypad and give the codes names in keyNames.
  • Capture the cursor with C and read the delta line: MouseDelta in Update keeps reporting motion while the position stops changing.
  • Call ctx.SetCursor with a different shape in Init, or hide it, and see what the platform layer does with it.
  • Feed g.deltaX into a camera in Draw to see how a first-person view would use relative motion.

Source files

main.go

The whole directory on GitHub