Table of Contents
Introduction
In this post we’re going to continue our series on Godot Fundamentals. We’ll be adding controller support to move our tank and navigate our UI. We will also be adding input scheme options within our game menu, controller vibrations when firing, and replacing our mouse cursor with a weapon crosshair when our input scheme changes to the gamepad.
Video
Input Mapping
Thanks to Godot's Input Map within the Project Settings, we'll be able to jump right into mapping our Controller's input to our existing actions. However, for our weapon's rotation, we'll want to create some new actions and mappings to give us similar behavior to our weapon following our mouse cursor.
To get started let's add a new Event to our actions "turn_left", "turn_right", "move_forward", "move_backward", and "weapon_fire".
We can simply select the action we want to add the Event to, and click the + icon to open the Event Configuration prompt. By default this will be listening for events from our keyboard, mouse, and any joypad device.
For turning I'll use the Left Joystick's X Axis by pressing left on the joystick for our "turn_left" action, then right on the joystick for our "turn_right" action. I'll use the Y Axis for the "move_forward" and "move_backward" actions. For the "weapon_fire" action I'll use the Right Trigger.
When entering these inputs, you'll notice by default it's using the Device: All Devices, you can limit this to a specific input device, and is recommended if using multiple controllers. For now we'll allow the default behavior.
For our Weapon
's aiming, we'll want to create 4 new actions that will handle the aiming accross the different axis: "weapon_aim_up", "weapon_aim_down", "weapon_aim_left", "weapon_aim_right". We'll use the Right Joystick's X and Y Axis for the events.
Next we'll flip the toggle at the top right to Show Built-in Actions to allow us to map new Events to the existing UI actions that we are using.
For "ui_accept" we'll use the Bottom Action and Start buttons to allow us to select prompts from out menu.
For "ui_cancel" we'll use the Back button to open our menu (though this context may change if we modify our menu in the future).
The various "ui_*" directions for navigation may already be set for the Joystick and D-Pad directions but if not, set those respectively.
Game Input Schemes
To track our current Input Scheme for our game, we'll create an enumerator and static variable to contain the current scheme in our Game
's script -> scenes/game.gd
:
# game.gd
...
enum INPUT_SCHEMES { KEYBOARD_AND_MOUSE, GAMEPAD, TOUCH_SCREEN }
static var INPUT_SCHEME: INPUT_SCHEMES = INPUT_SCHEMES.KEYBOARD_AND_MOUSE
...
We've created an input scheme for KEYBOARD_AND_MOUSE, GAMEPAD, and TOUCH_SCREEN. By setting the INPUT_SCHEME
variable to static we're allowing our Game
to track and expose a simple way to modify our game's preferred control type. We can later read this value to modify behavior of code.
Input Type Menu Option
Inside our GameMenu
's GridContainer
we'll want to add a child Label called InputLabel
and a child node of OptionButton
called InputTypeButton
.
For our InputTypeButton
we'll add elements to the items in the Inspector: Keyboard and Gamepad for now. Click Add Element for each option and update the Text field respectively. We'll set the selected field to default to 0 which is our Keyboard option.
We'll update our Control Focus items so our MusicSlider
moves to the InputTypeButton
next, and that moves to the MainMenuButton
next. We'll also need to set the respective previous focus items as well.
Now let's connect our InputTypeButton
's item_selected
signal up to a new function that will update our Game
's INPUT_SCHEME
variable when we change this menu item:
# game_menu.gd
func _on_input_type_button_item_selected(index):
if index != -1:
Game.INPUT_SCHEME = index
Tank Control
Now that we've mapped our controls and added the abilitty to track our INPUT_SCHEME
, we'll need to modify how we utilize these controls better within our Tank
's movement processing. Inside our _physics_process
function we'll extract each axis indepentently:
# tank.gd
...
func _physics_process(delta):
...
var drive_input := Input.get_axis("move_backward", "move_forward")
var turn_input := Input.get_axis("turn_left", "turn_right")
if turn_input != 0:
# Rotate direction based on our input vector and apply turn speed
direction = direction.rotated(turn_input * (PI / 2) * TURN_SPEED * delta)
if drive_input != 0:
...
velocity = lerp(velocity, (direction.normalized() * drive_input) * move_speed, SPEED * delta)
...
# Apply Weapon Rotation
update_weapon_rotation(delta)
...
Here we've extracted our axis for drive_input
and turn_input
variables that we then replace our rotation
and velocity
variables with and properly update those lines within our script.
Next we extracted our logic for weapon rotation into a new update_weapon_rotation(delta)
function:
# tank.gd
...
func update_weapon_rotation(delta) -> void:
if Game.INPUT_SCHEME == Game.INPUT_SCHEMES.KEYBOARD_AND_MOUSE:
var weapon_rotate_direction := Input.get_axis("rotate_weapon_left", "rotate_weapon_right")
var mouse_pos = get_local_mouse_position()
var new_transform = weapon.transform.looking_at(mouse_pos)
weapon.transform = weapon.transform.interpolate_with(new_transform, ROTATE_SPEED * delta)
elif Game.INPUT_SCHEME == Game.INPUT_SCHEMES.GAMEPAD:
var aim_direction := Input.get_vector("weapon_aim_left", "weapon_aim_right", "weapon_aim_up", "weapon_aim_down")
if aim_direction != Vector2.ZERO:
var angle = aim_direction.angle()
weapon.global_rotation = angle
Note how we've refactored our logic to check our Game
's INPUT_SCHEME
to ensure we either are rotating towards the mouse_pos
or the aim_direction
based on which input scheme we have selected.
Camera and Cursor
Now that we can modify how our weapon's rotation is behaving, we want to also ensure that our camera and cursor is also moving properly when we've switched to the GAMEPAD
scheme.
Let's first create a Cursor
Sprite2D
node as a child of our Tank
using our assets/sprites/cursor.png
for our texture. We'll set this Cursor
to default to hidden. Then bind a reference to it in our Tank
's script as well as create a new export variable crosshair_range
with a default similar to our MAX_DISTANCE
for our Camera
.
# tank.gd
...
@export var crosshair_range := 50.0
...
@onready var crosshair = $Crosshair
...
Next let's update the Crosshair
's position when we update our Weapon
's rotation.
# tank.gd
...
func update_weapon_rotation(delta) ->void:
...
weapon.global_rotation = angle
crosshair.global_position = global_position + (Vector2(cos(angle), sin(angle)) * crosshair_range)
crosshair.global_rotation = 0
Note that our Crosshair
's global_rotation
should always be 0 if we're showing the crosshair and updating it's position.
Let's update our Camera
's _process
function to better position on our cursor based on the GAMEPAD
scheme:
# camera.gd
func _process(delta):
...
MODES.TARGET_MOUSE_BLENDED:
if Game.INPUT_SCHEME == Game.INPUT_SCHEMES.KEYBOARD_AND_MOUSE:
var mouse_pos := get_local_mouse_position()
target_position = (target.position + mouse_pos)
target_position.x = clamp(target_position.x, -MAX_DISTANCE + target.position.x, MAX_DISTANCE + target.position.x)
target_position.y = clamp(target_position.y, -MAX_DISTANCE + target.position.y, MAX_DISTANCE + target.position.y)
elif Game.INPUT_SCHEME == Game.INPUT_SCHEMES.GAMEPAD:
if target.crosshair:
target_position = target.crosshair.global_position
else:
target_position = target.position
Event Bus
Now that we have some condition's where we need to reliably behave different once in a specific INPUT_SCHEME
we want to be notified when this scheme changes.
A great way to handle this is using the Event Bus pattern. An Event Bus is a Singleton that we'll add in our Project's Autoload to make it globally accessible accross our game and it's job is to centralize some common Signals that we may need to observe or emit from various parts of our Nodes.
Our INPUT_SCHEME is a great example of where we may want to use this pattern. From our UI
's GameMenu
we change the INPUT_SCHEME we want our game to use, but depending on that, we will want to tell our CursorManager
that it can revert the Mouse Cursor to default, and our new Crosshair
Sprite2D
within our Tank
to become visible.
First let's create a new folder in the root of our FileSystem we'll call globals
and inside here we'll create an event_bus.gd
script that extends Node and has our input_scheme_changed
Signal:
# event_bus.gd
extends Node
signal input_scheme_changed(scheme)
Now we'll register this script to Autoload and it'll create a Node on our SceneTree's root by default. Open Project Settings and goto the Autoload tab and set the Path to the res://globals/event_bus.gd
script and give it a Node Name of EventBus
and ensure it's Enabled.
Now inside our GameMenu
's script we'll emit the EventBus
's input_scheme_changed
signal once our button item is selected:
# game_menu.gd
func _on_input_type_button_item_selected(index):
if index != -1:
Game.INPUT_SCHEME = index
EventBus.input_scheme_changed.emit(index)
Input Scheme Changed Listeners
Now that we're emitting the signal whenever we change our input scheme, let's setup the various listeners to ensure we behave on this change.
Inside our CursorManager
we'll update our update_cusor
function to check the current state of the Game
's INPUT_SCHEME
so we revert our mouse cursor when we change to GAMEPAD
scheme:
# cursor_manager.gd
func update_cursor():
if Game.INPUT_SCHEME == Game.INPUT_SCHEMES.GAMEPAD:
Input.set_custom_mouse_cursor(null, Input.CURSOR_ARROW)
else:
...
Because we only update the cursor when the size of our viewport changes, we'll also connect up the EventBus
's input_scheme_changed
signal:
# cursor_manager.gd
func _ready():
...
EventBus.input_scheme_changed.connect(_on_input_scheme_changed)
func _on_input_scheme_changed(_scheme) -> void:
update_cursor()
Next back in our Tank
's script, we'll be connecting to the input_scheme_changed
signal and toggling the view of our crosshair and forcing our weapon's rotation to occur (with a modification to it's function definition):
# tank.gd
func _ready():
...
EventBus.input_scheme_changed.connect(_on_input_scheme_changed)
...
func update_weapon_rotation(delta, force_update_position = false) -> void:
...
if force_update_position || aim_direction != Vector2.ZERO:
...
...
func _on_input_scheme_changed(scheme) -> void:
if scheme == Game.INPUT_SCHEMES.GAMEPAD:
crosshair.show()
update_weapon_rotation(0, true)
else:
crosshair.hide()
Controller Vibration
Last for a bit of fun and as a quick example, let's set up our controller to vibrate when we fire our weapon. This will only work while we are in the INPUT_SCHEME
of GAMEPAD
. Open our Weapon
's script and at the end of the fire
function we'll modify it to include the following:
# weapon.gd
func fire():
...
if Game.INPUT_SCHEME == Game.INPUT_SCHEMES.GAMEPAD:
Input.start_joy_vibration(0, .1, 0, .5)
Here we're checking our INPUT_SCHEME
and starting vibration on our Input Contorller using the start_joy_vibration
function using the weak motor at a .1 magnitude for only .5 seconds. Read more on the vibration capabilities from Godot's Documentation .
Recap
There we have it, the beggings of Controller Support and multiple Input Scheme options. We've added a variety of controller mappings to our gamepads, including vibration support. We've also introduced ourselves to the Event Bus pattern for better signaling and observing of Events. Be sure to check out the project's GitHub project for the source code! If you have questions or ideas on how to leverage these concepts, be sure to jump into our growing Discord community and start a chat!
Happy Coding.