Game Controller Support

Adds support for Xbox and Nintendo style game controllers. The gamepad
controls are documented on the README and in the game's Settings window.

The buttons are not customizable yet, except that the player can choose
between two button styles:

* X Style (default): "A" button is on the bottom and "B" on the right.
* N Style: swaps the A/B and the X/Y buttons to use a Nintendo-style
  layout instead of an Xbox-style.
This commit is contained in:
Noah 2022-02-19 18:25:36 -08:00
parent 626fd53a84
commit 4de0126b19
13 changed files with 596 additions and 7 deletions

View File

@ -97,6 +97,43 @@ Ctrl-Y
Redo
```
# Gamepad Controls
The game supports Xbox and Nintendo style game controllers. The button
bindings are not yet customizable, except to choose between the
"X Style" or "N Style" for A/B and X/Y button mappings.
Gamepad controls very depending on two modes the game can be in:
## Mouse Mode
The Gamepad emulates a mouse cursor in this mode.
* The left analog stick moves a cursor around the screen.
* The right analog stick scrolls the level (title screen and editor)
* A or X button simulates a Left-click
* B or Y button simulates a Right-click
* L1 (left shoulder) emulates a Middle-click
* L2 (left trigger) closes the top-most window in the editor mode
(like the Backspace key).
## Gameplay Mode
When playing a level, the controls are as follows:
* The left analog stick and the D-Pad will move the player character.
* A or X button to "Use" objects such as Warp Doors.
* B or Y button to "Jump"
* R1 (right shoulder) toggles between Mouse Mode and Gameplay Mode.
You can use the R1 button to access Mouse Mode to interact with the
menus or click on the "Edit Level" button.
Note: characters with antigravity (such as the Bird) can move in all
four directions but characters with gravity only move left and right
and have the dedicated "Jump" button. This differs from regular
keyboard controls where the Up arrow is to Jump.
# Built-In Doodads
A brief introduction to the built-in doodads available so far:

BIN
assets/sprites/pointer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

View File

@ -16,6 +16,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/branding"
"git.kirsle.net/apps/doodle/pkg/chatbot"
"git.kirsle.net/apps/doodle/pkg/gamepad"
"git.kirsle.net/apps/doodle/pkg/license"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/shmem"
@ -25,6 +26,7 @@ import (
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/sdl"
"github.com/urfave/cli/v2"
sdl2 "github.com/veandco/go-sdl2/sdl"
_ "image/png"
)
@ -70,6 +72,9 @@ func main() {
usercfg.Current.CrosshairColor = balance.DefaultCrosshairColor
}
// Set GameController style.
gamepad.SetStyle(gamepad.Style(usercfg.Current.ControllerStyle))
app.Version = fmt.Sprintf("%s build %s%s. Built on %s",
branding.Version,
Build,
@ -149,6 +154,9 @@ func main() {
balance.Height,
)
// Activate game controller event support.
sdl2.GameControllerEventState(1)
// Load the SDL fonts in from bindata storage.
if fonts, err := assets.AssetDir("assets/fonts"); err == nil {
for _, file := range fonts {

View File

@ -92,6 +92,10 @@ var (
// Invulnerability time in seconds at respawn from checkpoint, in case
// enemies are spawn camping.
RespawnGodModeTimer = 3 * time.Second
// GameController thresholds.
GameControllerMouseMoveMax float64 = 20 // Max pixels per tick to simulate mouse movement.
GameControllerScrollMin float64 = 0.3 // Minimum threshold for a right-stick scroll event.
)
// Edit Mode Values

View File

@ -13,6 +13,7 @@ var (
GoldCoin = "assets/sprites/gold.png"
SilverCoin = "assets/sprites/silver.png"
LockIcon = "assets/sprites/padlock.png"
CursorIcon = "assets/sprites/pointer.png"
// Title Screen Font
TitleScreenFont = render.Text{

View File

@ -10,6 +10,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/branding"
"git.kirsle.net/apps/doodle/pkg/enum"
"git.kirsle.net/apps/doodle/pkg/gamepad"
"git.kirsle.net/apps/doodle/pkg/keybind"
"git.kirsle.net/apps/doodle/pkg/levelpack"
"git.kirsle.net/apps/doodle/pkg/log"
@ -131,7 +132,6 @@ func (d *Doodle) Run() error {
// Poll for events.
ev, err := d.Engine.Poll()
shmem.Cursor = render.NewPoint(ev.CursorX, ev.CursorY)
if err != nil {
log.Error("event poll error: %s", err)
d.running = false
@ -139,6 +139,13 @@ func (d *Doodle) Run() error {
}
d.event = ev
// Let the gamepad controller check for events, if it's in MouseMode
// it will fake the mouse cursor.
gamepad.Loop(ev)
// Globally store the cursor position.
shmem.Cursor = render.NewPoint(ev.CursorX, ev.CursorY)
// Command line shell.
if d.shell.Open {
} else if keybind.ShellKey(ev) {
@ -196,6 +203,9 @@ func (d *Doodle) Run() error {
// Draw the debug overlay over all scenes.
d.DrawDebugOverlay()
// Let the gamepad controller draw in case of MouseMode to show the cursor.
gamepad.Draw(d.Engine)
// Render the pixels to the screen.
err = d.Engine.Present()
if err != nil {
@ -246,6 +256,7 @@ func (d *Doodle) MakeSettingsWindow(supervisor *ui.Supervisor) *ui.Window {
CrosshairColor: &usercfg.Current.CrosshairColor,
HideTouchHints: &usercfg.Current.HideTouchHints,
DisableAutosave: &usercfg.Current.DisableAutosave,
ControllerStyle: &usercfg.Current.ControllerStyle,
}
return windows.MakeSettingsWindow(d.width, d.height, cfg)
}

29
pkg/gamepad/enum.go Normal file
View File

@ -0,0 +1,29 @@
package gamepad
// Enums and constants.
// Mode of controller behavior (Player One)
type Mode int
// Style of controller (Player One)
type Style int
// Controller mode options.
const (
// MouseMode: the joystick moves a mouse cursor around and
// the face buttons emulate mouse click events.
MouseMode Mode = iota
// GameplayMode: to control the player character during Play Mode.
GameplayMode
// EditorMode: to support the Level Editor.
EditorMode
)
// Controller style options.
const (
XStyle Style = iota // Xbox 360 layout (A button on bottom)
NStyle // Nintendo style (A button on right)
CustomStyle // Custom style (TODO)
)

394
pkg/gamepad/gamepad.go Normal file
View File

@ -0,0 +1,394 @@
/*
Package gamepad provides game controller logic for the game.
Controls
The gamepad controls are currently hard-coded for Xbox 360 style controllers, and the
controller mappings vary depending on the "mode" of control.
N Style and X Style
If the gamepad control is set to "NStyle" then the A/B and X/Y buttons will be swapped
to match the labels of a Nintendo style controller. Since the game has relatively few
inputs needed now, this module maps them to a PrimaryButton and a SecondaryButton,
defined as:
PrimaryButton: A or X button
SecondaryButton: B or Y button
Mouse Mode
- Left stick moves the mouse cursor (a cursor sprite is drawn on screen)
- Right stick scrolls the level (title screen or level editor)
- PrimaryButton emulates a left click.
- SecondaryButton emulates a right click.
- Left Shoulder emulates a middle click.
- Left Trigger (L2) closes the top-most window in the Editor (Backspace key)
- Right Shoulder toggles between Mouse Mode and other scene-specific mode.
Gameplay Mode
- Left stick moves the player character (left/right only).
- D-Pad also moves the player character (left/right only).
- PrimaryButton is to "Use"
- SecondaryButton is to "Jump"
- If the player has antigravity, up/down controls on left stick or D-Pad work too.
- Right Shoulder toggles between GameplayMode and MouseMode.
*/
package gamepad
import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/apps/doodle/pkg/sprites"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
)
// Global state variables for gamepad support.
var (
// PlayScene tells us whether antigravity is on, so directional controls work all directions.
PlayModeAntigravity bool
SceneName string // Set by doodle.Goto() so we know what scene name we're on.
playerOne *int // controller index for Player 1 (main).
p1style Style
p1mode Mode
// Mouse cursor
cursorVisible bool
cursorSprite *ui.Image
cursorLast render.Point // detect if mouse cursor took over from gamepad
// MouseMode button state history to emulate mouse-ups.
leftClickLast bool
rightClickLast bool
middleClickLast bool
leftTriggerLast bool
// MouseMode right stick last position, for arrow key (level scroll) emulation.
rightStickLast event.Vector
// Gameplay mode last left-stick position.
leftStickLast event.Vector
dpLeftLast bool // D-Pad last positions.
dpRightLast bool
dpDownLast bool
dpUpLast bool
// Right Shoulder last state (mode switch)
rightShoulderLast bool
)
// SetControllerIndex sets which gamepad will be "Player One"
func SetControllerIndex(index int) {
playerOne = &index
}
// UnsetController detaches the Player One controller.
func UnsetController() {
playerOne = nil
}
// SetStyle sets the controller button style.
func SetStyle(s Style) {
p1style = s
}
// SetMode sets the controller mode.
func SetMode(m Mode) {
p1mode = m
}
// PrimaryButton returns whether the A or X button is pressed.
func PrimaryButton(ctrl event.GameController) bool {
if p1style == NStyle {
return ctrl.ButtonB() || ctrl.ButtonY()
}
return ctrl.ButtonA() || ctrl.ButtonX()
}
// SecondaryButton returns whether the B or Y button is pressed.
func SecondaryButton(ctrl event.GameController) bool {
if p1style == NStyle {
return ctrl.ButtonA() || ctrl.ButtonX()
}
return ctrl.ButtonB() || ctrl.ButtonY()
}
// Loop hooks the render events on each game tick.
func Loop(ev *event.State) {
// If we don't have a controller registered, watch out for one until we do.
if playerOne == nil {
if len(ev.Controllers) > 0 {
for idx, ctrl := range ev.Controllers {
SetControllerIndex(idx)
log.Info("gamepad: using controller #%d (%s) as Player 1", idx, ctrl)
}
} else {
return
}
}
// Get our SDL2 controller.
ctrl, ok := ev.GetController(*playerOne)
if !ok {
log.Error("gamepad: controller #%d has gone away! Detaching as Player 1", playerOne)
playerOne = nil
return
}
// Right Shoulder = toggle controller mode, handle this first.
if ctrl.ButtonR1() {
if !rightShoulderLast {
if SceneName == "Play" {
// Toggle between GameplayMode and MouseMode.
if p1mode == GameplayMode {
p1mode = MouseMode
} else {
p1mode = GameplayMode
}
// Reset all button states.
ev.Left = false
ev.Right = false
ev.Up = false
ev.Down = false
ev.Enter = false
ev.Space = false
}
rightShoulderLast = true
return
}
} else if rightShoulderLast {
rightShoulderLast = false
}
// If we are in Play Mode, translate gamepad events into key events.
if p1mode == GameplayMode {
// D-Pad controls to move the player character.
if ctrl.ButtonLeft() {
ev.Left = true
dpLeftLast = true
} else if dpLeftLast {
ev.Left = false
dpLeftLast = false
}
if ctrl.ButtonRight() {
ev.Right = true
dpRightLast = true
} else if dpRightLast {
ev.Right = false
dpRightLast = false
}
// Antigravity on? Up/Down arrow emulation.
if PlayModeAntigravity {
if ctrl.ButtonUp() {
ev.Up = true
dpUpLast = true
} else if dpUpLast {
ev.Up = false
dpUpLast = false
}
if ctrl.ButtonDown() {
ev.Down = true
dpDownLast = true
} else if dpDownLast {
ev.Down = false
dpDownLast = false
}
}
// "Use" button.
if PrimaryButton(ctrl) {
ev.Space = true
ev.Enter = true // to click thru modals
leftClickLast = true
} else if leftClickLast {
ev.Space = false
ev.Enter = false
leftClickLast = false
}
// Jump button.
if SecondaryButton(ctrl) {
ev.Up = true
rightClickLast = true
} else if rightClickLast {
ev.Up = false
rightClickLast = false
}
// Left control stick to move the player character.
// TODO: analog movements.
leftStick := ctrl.LeftStick()
if leftStick.X != 0 {
if leftStick.X < -balance.GameControllerScrollMin {
ev.Left = true
ev.Right = false
} else if leftStick.X > balance.GameControllerScrollMin {
ev.Right = true
ev.Left = false
} else {
ev.Right = false
ev.Left = false
}
} else if leftStickLast.X != 0 {
ev.Left = false
ev.Right = false
}
// Antigravity on?
if PlayModeAntigravity {
if leftStick.Y != 0 {
if leftStick.Y < -balance.GameControllerScrollMin {
ev.Up = true
ev.Down = false
} else if leftStick.Y > balance.GameControllerScrollMin {
ev.Down = true
ev.Up = false
} else {
ev.Down = false
ev.Up = false
}
} else if leftStickLast.Y != 0 {
ev.Up = false
ev.Down = false
}
}
leftStickLast = leftStick
}
// If we are emulating a mouse, handle that now.
if p1mode == MouseMode {
// Move the cursor.
leftStick := ctrl.LeftStick()
if leftStick.X != 0 || leftStick.Y != 0 {
cursorVisible = true
} else if cursorVisible {
// If the mouse cursor has moved behind our back (e.g., real mouse moved), turn off
// the MouseMode cursor sprite.
if cursorLast.X != ev.CursorX || cursorLast.Y != ev.CursorY {
cursorVisible = false
}
}
ev.CursorX += int(leftStick.X * balance.GameControllerMouseMoveMax)
ev.CursorY += int(leftStick.Y * balance.GameControllerMouseMoveMax)
// Constrain the cursor inside window boundaries.
w, h := shmem.CurrentRenderEngine.WindowSize()
if ev.CursorX < 0 {
ev.CursorX = 0
} else if ev.CursorX > w {
ev.CursorX = w
}
if ev.CursorY < 0 {
ev.CursorY = 0
} else if ev.CursorY > h {
ev.CursorY = h
}
// Store last cursor point so we can detect mouse movement outside the gamepad.
cursorLast = render.NewPoint(ev.CursorX, ev.CursorY)
// Are we clicking?
if PrimaryButton(ctrl) {
ev.Button1 = true // left-click
leftClickLast = true
} else if leftClickLast {
ev.Button1 = false
leftClickLast = false
}
// Right-click
if SecondaryButton(ctrl) {
ev.Button3 = true // right-click
rightClickLast = true
} else if rightClickLast {
ev.Button3 = false
rightClickLast = false
}
// Middle-click
if ctrl.ButtonL1() {
ev.Button2 = true // middle click
middleClickLast = true
} else if middleClickLast {
ev.Button2 = false
middleClickLast = false
}
// Left Trigger = Backspace (close active window)
if ctrl.ButtonL2() {
if !leftTriggerLast {
ev.SetKeyDown(`\b`, true)
}
leftTriggerLast = true
} else if leftTriggerLast {
ev.SetKeyDown(`\b`, false)
leftTriggerLast = false
}
// Arrow Key emulation on the right control stick, e.g. for Level Editor.
rightStick := ctrl.RightStick()
if rightStick.X != 0 {
if rightStick.X < -balance.GameControllerScrollMin {
ev.Left = true
ev.Right = false
} else if rightStick.X > balance.GameControllerScrollMin {
ev.Right = true
ev.Left = false
} else {
ev.Right = false
ev.Left = false
}
} else if rightStickLast.X != 0 {
ev.Left = false
ev.Right = false
}
if rightStick.Y != 0 {
if rightStick.Y < -balance.GameControllerScrollMin {
ev.Up = true
ev.Down = false
} else if rightStick.Y > balance.GameControllerScrollMin {
ev.Down = true
ev.Up = false
} else {
ev.Down = false
ev.Up = false
}
} else if rightStickLast.Y != 0 {
ev.Up = false
ev.Down = false
}
rightStickLast = rightStick
}
}
// Draw the cursor on screen if the game controller is emulating a mouse.
func Draw(e render.Engine) {
if p1mode != MouseMode || !cursorVisible {
return
}
if cursorSprite == nil {
img, err := sprites.LoadImage(e, balance.CursorIcon)
if err != nil {
log.Error("gamepad: couldn't load cursor sprite (%s): %s", balance.CursorIcon, err)
return
}
cursorSprite = img
}
cursorSprite.Present(e, shmem.Cursor)
}

View File

@ -7,6 +7,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/collision"
"git.kirsle.net/apps/doodle/pkg/doodads"
"git.kirsle.net/apps/doodle/pkg/gamepad"
"git.kirsle.net/apps/doodle/pkg/keybind"
"git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/levelpack"
@ -282,6 +283,9 @@ func (s *PlayScene) setupAsync(d *Doodle) error {
// runtime, + the bitmap generation is pretty wicked fast anyway.
loadscreen.PreloadAllChunkBitmaps(s.Level.Chunker)
// Gamepad: put into GameplayMode.
gamepad.SetMode(gamepad.GameplayMode)
s.startTime = time.Now()
s.perfectRun = true
s.running = true
@ -416,6 +420,7 @@ func (s *PlayScene) installPlayerDoodad(filename string, spawn render.Point, cen
// EditLevel toggles out of Play Mode to edit the level.
func (s *PlayScene) EditLevel() {
log.Info("Edit Mode, Go!")
gamepad.SetMode(gamepad.MouseMode)
s.d.Goto(&EditorScene{
Filename: s.Filename,
Level: s.Level,
@ -518,6 +523,7 @@ func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) {
OnRestartLevel: s.RestartLevel,
OnRetryCheckpoint: s.RetryCheckpoint,
OnExitToMenu: func() {
gamepad.SetMode(gamepad.MouseMode)
s.d.Goto(&MainScene{})
},
}
@ -589,6 +595,9 @@ func (s *PlayScene) Loop(d *Doodle, ev *event.State) error {
return nil
}
// Inform the gamepad controller whether we have antigravity controls.
gamepad.PlayModeAntigravity = s.antigravity || !s.Player.HasGravity()
// Update debug overlay values.
*s.debWorldIndex = s.drawing.WorldIndexAt(render.NewPoint(ev.CursorX, ev.CursorY)).String()
*s.debPosition = s.Player.Position().String() + " vel " + s.Player.Velocity().String()
@ -614,6 +623,7 @@ func (s *PlayScene) Loop(d *Doodle, ev *event.State) error {
// Switching to Edit Mode?
if s.CanEdit && keybind.GotoEdit(ev) {
gamepad.SetMode(gamepad.MouseMode)
s.EditLevel()
return nil
}

View File

@ -1,6 +1,7 @@
package doodle
import (
"git.kirsle.net/apps/doodle/pkg/gamepad"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/go/render/event"
)
@ -23,6 +24,9 @@ type Scene interface {
// Goto a scene. First it unloads the current scene.
func (d *Doodle) Goto(scene Scene) error {
// Inform the gamepad controller what scene.
gamepad.SceneName = scene.Name()
// Clear any debug labels.
customDebugLabels = []debugLabel{}

View File

@ -59,9 +59,10 @@ type Field struct {
Frame *ui.Frame
// Variable bindings, the type may infer to be:
BoolVariable *bool // Checkbox
TextVariable *string // Textbox
Options []Option // Selectbox
BoolVariable *bool // Checkbox
TextVariable *string // Textbox
Options []Option // Selectbox
SelectValue interface{} // Selectbox default choice
// Tooltip to add to a form control.
// Checkbox only for now.
@ -208,8 +209,9 @@ func (form Form) Create(into *ui.Frame, fields []Field) {
Font: row.Font,
})
frame.Pack(btn, ui.Pack{
Side: ui.W,
FillX: true,
Side: ui.W,
FillX: true,
Expand: true,
})
if row.Options != nil {
@ -218,7 +220,11 @@ func (form Form) Create(into *ui.Frame, fields []Field) {
}
}
btn.Handle(ui.Click, func(ed ui.EventData) error {
if row.SelectValue != nil {
btn.SetValue(row.SelectValue)
}
btn.Handle(ui.Change, func(ed ui.EventData) error {
if selection, ok := btn.GetValue(); ok {
if row.OnSelect != nil {
row.OnSelect(selection.Value)
@ -227,6 +233,7 @@ func (form Form) Create(into *ui.Frame, fields []Field) {
return nil
})
btn.Supervise(form.Supervisor)
form.Supervisor.Add(btn)
}
}

View File

@ -37,6 +37,7 @@ type Settings struct {
CrosshairColor render.Color
HideTouchHints bool `json:",omitempty"`
DisableAutosave bool `json:",omitempty"`
ControllerStyle int
// Secret boolprops from balance/boolprops.go
ShowHiddenDoodads bool `json:",omitempty"`

View File

@ -5,9 +5,11 @@ import (
"strings"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/gamepad"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/native"
"git.kirsle.net/apps/doodle/pkg/shmem"
magicform "git.kirsle.net/apps/doodle/pkg/uix/magic-form"
"git.kirsle.net/apps/doodle/pkg/usercfg"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/go/render"
@ -30,6 +32,7 @@ type Settings struct {
CrosshairColor *render.Color
HideTouchHints *bool
DisableAutosave *bool
ControllerStyle *int
// Configuration options.
SceneName string // name of scene which called this window
@ -81,6 +84,7 @@ func NewSettingsWindow(cfg Settings) *ui.Window {
// Make the tabs
cfg.makeOptionsTab(tabFrame, Width, Height)
cfg.makeControlsTab(tabFrame, Width, Height)
cfg.makeControllerTab(tabFrame, Width, Height)
cfg.makeExperimentalTab(tabFrame, Width, Height)
tabFrame.Supervise(cfg.Supervisor)
@ -721,3 +725,82 @@ func (c Settings) makeExperimentalTab(tabFrame *ui.TabFrame, Width, Height int)
return tab
}
// Settings Window "Controller" Tab
func (c Settings) makeControllerTab(tabFrame *ui.TabFrame, Width, Height int) *ui.Frame {
tab := tabFrame.AddTab("Gamepad", ui.NewLabel(ui.Label{
Text: "Gamepad",
Font: balance.TabFont,
}))
tab.Resize(render.NewRect(Width-4, Height-tab.Size().H-46))
// Render the form.
form := magicform.Form{
Supervisor: c.Supervisor,
Engine: c.Engine,
Vertical: true,
LabelWidth: 150,
}
form.Create(tab, []magicform.Field{
{
Label: "About",
Font: balance.LabelFont,
},
{
Label: "Play Sketchy Maze with an Xbox or Nintendo controller!\n\n" +
"Full customization options aren't here yet, but you can\n" +
"choose between the 'X Style' or 'N Style' profile below.\n" +
"'N Style' will swap the A/B and X/Y buttons.",
Font: balance.UIFont,
},
{
Label: "Button Style:",
Font: balance.LabelFont,
Type: magicform.Selectbox,
Options: []magicform.Option{
{
Label: "X Style (default)",
Value: int(gamepad.XStyle),
},
{
Label: "N Style",
Value: int(gamepad.NStyle),
},
},
SelectValue: *c.ControllerStyle,
OnSelect: func(v interface{}) {
style, _ := v.(int)
log.Error("style: %d", style)
gamepad.SetStyle(gamepad.Style(style))
saveGameSettings()
},
},
{
Label: "\nThe gamepad controls vary between two modes:",
Font: balance.UIFont,
},
{
Label: "Mouse Mode (outside of gameplay)",
Font: balance.LabelFont,
},
{
Label: "The left analog stick moves a mouse cursor around.\n" +
"The right analog stick scrolls the level around.\n" +
"A or X: Left-click B or Y: Right-click\n" +
"L1: Middle-click L2: Close window",
Font: balance.UIFont,
},
{
Label: "Gameplay Mode",
Font: balance.LabelFont,
},
{
Label: "Left stick or D-Pad to move the player around.\n" +
"A or X: 'Use' B or Y: 'Jump'\n" +
"R1: Toggle between Mouse and Gameplay controls.",
Font: balance.UIFont,
},
})
return tab
}