Game Dev Artisan Logo
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.

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.

controller mapping

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.

Want to support our work?Support us via Ko-fi