Table of Contents
Introduction
In this post we’re going to continue our series on Godot Fundamentals. We’ll be introducing a Camera with different modes that will follow our mouse or focus a target. We'll also setup a custom cursor icon. Our Tank's turret controls will also be updated to always point towards the direction of our mouse.
Video
Setup the Camera2D
To get started, we'll open our Game scene and add a Camera2D child. This camera will have additional functionality, so we'll create a script for this called scenes/ui/camera.gd
.
Our Camera2D will have a class_name of Camera and we will define our different modes using a MODES enum.
extends Camera2D
class_name Camera
enum MODES { TARGET, TARGET_MOUSE_BLENDED }
For our example we'll create a TARGET and TARGET_MOUSE_BLENDED modes.
We'll be exporting variables that define our target, the mode we want our camera to be in, as well as some values to smooth our movement, and keep our camera within range of our target.
@export var target: Node = null
@export var mode: MODES = MODES.TARGET
@export var MAX_DISTANCE: float = 50
@export var SMOOTH_SPEED: float = 1.0
We'll also want to define some internal variables to track our target_position as well as a fallback_target.
var target_position := Vector2.INF
var fallback_target: Node = null
When our Camera is ready, we'll define it's fallback_target to be equal to the export's target.
func _ready():
fallback_target = target
For each frame, we want to calculate if our camera is on the correct target, and update it's position accordingly. We'll create some logic around each case using a match statement against our mode variable. We can then take our camera's position and move it to our calculated target_position.
func _process(delta):
match(mode):
MODES.TARGET:
if target:
target_position = target.position
MODES.TARGET_MOUSE_BLENDED:
if target:
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)
if target_position != Vector2.INF:
position = lerp(position, target_position, SMOOTH_SPEED * delta)
In our TARGET_MOUSE_BLENDED mode we are adding our target position (Tank) and our mouse's position and clamping a position between it and the MAX_DISTANCE we have defined in our exports. If our target_position is defined, we'll lerp towards that position over our SMOOTH_SPEED * delta.
Next we'll create some functions to allow us to change our mode and target.
func change_mode(new_mode: MODES) -> void:
mode = new_mode
func change_target(new_target: Node) -> void:
if new_target:
if target and target.tree_exiting.is_connected(_clear_target):
target.tree_exiting.disconnect(_clear_target)
target = new_target
new_target.tree_exiting.connect(_clear_target)
func _clear_target() -> void:
target = fallback_target
change_mode(MODES.TARGET_MOUSE_BLENDED)
When we change our target, we check to see if we have a signal connected to it to be notified when the node exits the tree, in which case we want to clear that target from our camera, and switch back our fallback_target, returning to our default camera mode.
Back in our Game Scene, we'll be setting the Camera2D's exports as follows:
- target = our Tank scene
- mode = TARGET_MOUSE_MODE
- MAX_DISTANCE = 50
- SMOOTH_SPEED = 2
For the remaining settings we'll define our Limit to control how our camera stops when reaching the edge of our World Scene. We'll set Left and Top to 0, and Right to 3024 and Bottom to 1570. These numbers will vary based on the size of your world, but this is the size I've chosen based on my TileMap. Because we are using pixel art, we don't want position_smoothing_enabled. When it is enabled, it'll slowly bring our camera's position into place over a set number of pixels per frame. This can be great, but creates tearing in our art, which we want to avoid as much as we can control.
Camera Targeting
To demonstrate our TARGET mode of our camera, let's create a way retrieve the nearest crate to our tank, which we will then change our camera to target for a short period, then transition back to our tank.
To do this, we'll need to create a new static function in our World script to get the closest crate to a given position.
static func get_closest_crate_to(position: Vector2) -> Crate:
var closest: Crate = null
var closest_distance = INF
for child in _instance.tile_map.get_children():
if child is Crate:
var dist = position.distance_to(child.position)
if !closest || (dist < closest_distance):
closest = child
closest_distance = dist
return closest
Our World checks each child of our TileMap and if it's a crate we calculate it's distance to our position and we track and compare the closest child and return that.
To perform the camera's transition, inside our Game script, we'll create a new function that will start our game called start_game. This function will create a 2 second timer, we'll wait for it to finish, then we will find our nearest crate (Note we return if no crate is found), change our Camera's mode and target to that crate, once we've done this, we'll await another 3 second timer, then change our target back to our tank, and the mode back to our TARGET_MOUSE_BLENDED.
class_name Game
...
func start_game():
await get_tree().create_timer(2).timeout
var crate: Crate = World.get_closest_crate_to(tank.position)
if !crate:
return
tank.has_control = false
camera.change_mode(Camera.MODES.TARGET)
camera.change_target(crate)
await get_tree().create_timer(3).timeout
camera.change_target(tank)
camera.change_mode(Camera.MODES.TARGET_MOUSE_BLENDED)
tank.has_control = true
You may also notice that we set our tank's has_control boolean either true for false based on if we are targeting the crate or the tank. To do this we need to add a variable to our Tank script and add some logic to our _physics_process and _input functions to prevent handling player input while we don't have control of our tank.
class_name Tank
...
var has_control := true
...
func _physics_process(delta):
if !has_control:
return
...
...
func _input(event):
if !has_control:
return
...
An important note about our start_game function is that we must use call_deferred from within our Game's _ready function. This function will delay our call to start_game, which is required currently due to the way our TileMap set's up it's children based on our Scene Collections. Normally a node's children call their _ready functions BEFORE the parent is ready, but in the case of a TileMap's use of Scene Collections, the children are created and added to the scene AFTER the TileMap's _ready function is called. This is a noted and open issue that may change in the future, but for now a deferred call is viable.
UI Letterbox Effect
For our Camera mode TARGET, let's add a letterbox effect that we can transition in/out of on the UI layer to give us more emphasis on the target we are currently focused on.
Open our UI Scene and inside the Control node we'll add a new Control child and call it Letterbox. Set the Letterbox anchor preset to Full Rect. Inside this Control, we'll add 2 ColorRect children one called TopRect and the other BottomRect, setting both to Access as Unique Name. We'll set the anchor preset for the TopRect to Top Wide, which will pin it to the top, and span full width. For our BottomRect we'll set the anchor preset to Bottom Wide, which will pin to the bottom, and span full width. We can set both of them to the color black.
Next we'll add an AnimationPlayer as a child of our UI root node. This will have an animation called open_letterbox. Here add tracks for both our TopRect and BottomRect and keyframe the custom_minimum_size property to 0 at the beginning of our animation, then at the end, we want to keyframe our y value of this property to 64. We can adjust our easing value on our initial keyframe so it modifies the pace at which we transition from 0 to 64 in our transition. Ensure these values and timings are the same on each ColorRect property. I'll be using an easing value of 5 which gives its a steep ramp up towards the end of the animation.
Inside our UI script, let's add a reference to our AnimationPlayer and create a variable to track if our letterbox should be open or closed.
class_name UI
...
@onready var animation_player = $AnimationPlayer
var letterbox_open := false
static var _instance: UI = null
func _ready():
...
_instance = self if _instance == null else _instance
We'll also setup our UI instance to be statically defined and accessible in static method calls using our _instance variable. With this defined we can create static methods to open and close our letterbox using the animation_player reference to call our animation.
class_name UI
...
static func open_letterbox() -> void:
if !_instance.letterbox_open:
_instance.animation_player.play("open_letterbox")
_instance.letterbox_open = true
static func close_letterbox() -> void:
if _instance.letterbox_open:
_instance.animation_player.play_backwards("open_letterbox")
_instance.letterbox_open = false
Back in our Game script, when we start our game and change our target we will open our letterbox, then once we change back we will close our letterbox.
class_name Game
...
func start_game():
...
camera.change_target(crate)
UI.open_letterbox()
...
UI.close_letterbox()
camera.change_target(tank)
...
Turret Targeting Mouse Position
Now that our Camera modes are defined and tested, we want to also update our Tank's weapon to point toward our mouse's current position as well, this will feel more natural as we pan our camera around with our mouse cursor.
In our Tank script we'll update our _physics_process's Weapon Rotation section to no longer take control for the button presses, rather we want to track our mouse position and create a transform that takes in our current position and use the looking_at from our weapon's Transform property. This will then be interpolated over time of our ROTATE_SPEED * delta.
class_name Tank
...
func _physics_process(delta):
...
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)
Update Mouse Cursor Icon
Lastly let's update our Mouse Cursor Icon to one that we've created. The cursor is included in our Github Project for you to download, or you can create your own. We will be creating a cursor manager that handles updating our cursor for us, it will also handle scaling in the event we modify our window size, or need to scale for other reasons.
For our CursorManager, we'll create a child of type Node and call it CursorManager. We'll attach a script called scenes/ui/cursor_manager.gd
and give it export variables; base_window_size for the target window size of our game, and cursor_texture for the target cursor texture we want. In our _ready function we'll call our update_cursor function and set a signal connection to the same function for when our screen is resized.
extends Node
@export var base_window_size: Vector2 = Vector2.ZERO
@export var cursor_texture: Texture2D = null
func _ready():
update_cursor()
get_tree().get_root().size_changed.connect(update_cursor)
func update_cursor():
var current_window_size = get_viewport().size
var scale_multiple = min(floor(current_window_size.x / base_window_size.x), floor(current_window_size.y / base_window_size.y))
var image = cursor_texture.get_image()
var scaler = cursor_texture.get_size() * (scale_multiple + 1)
image.resize(scaler.x, scaler.y, Image.INTERPOLATE_NEAREST)
var texture = ImageTexture.create_from_image(image)
Input.set_custom_mouse_cursor(texture, Input.CURSOR_ARROW, scaler * .5)
Our update_cursor function get's our viewport size, and determines a potential scale multiplier to apply to our cursor image, then recreates our Image Texture from our base image, and updates accordingly.
It's important to note that if our Display settings are defined to use the Stretch mode viewport, then it will render graphics to the viewport at a set size, and our size_changed signal won't fire, therefore not scaling our cursor image, which means as the window is smaller, the scale of the cursor will be larger in comparison. To resolve this, you can change the stretch mode to be canvas_items, but keep in mind you may have other issues in regards to viewport scaling with pixel art type games, as each item is treated individually rather than the viewport as a whole.
Recap
Now we have a Camera in our Game that can swap between multiple modes, and defined to focus on specific targets that we define, so long as the target has a position, we can focus there. We have nice letterboxing included when focusing on specific targets. Our Camera can follow our mouse, along with our turret. We also have a custom cursor icon that can be used to immerse our users a bit more into the feel of our game.
Happy Coding.