I will start by making a camera system for our RTS game. Inspired by real-time strategy games like Stronghold and Age of Empires, our camera will offer players a top-down perspective with a slight tilt. Users will have the ability to move the camera around the map, zoom in and out for detailed views, and rotate the perspective.
Breakdown
I will be creating a node to control the elevation (height) of the camera and camera node will be made its child. Then based on the user inputs (such as mouse motion, button pressing or mouse buttons), camera will move, pan, rotate or zoom. Constraints such as max elevation, minimum elevation, speed etc will be defined.
Getting Started with our Camera System
Start by creating a folder named “RTSController”. I am calling it RTS Controller as it is what the user will actually be seeing through & performing actions. In this folder, make a scene with the same name, and Node3D
as its root node.
Make another Node3D
as its child and set its name to be Elevation
. Then move this Elevation
node upward. Then add a Camera3D
node as its child. Make the camera look downward by controlling its rotation
property. Finally, attach a script to it.
Making the Camera Functional
We will make it functional using the script code. For each action, we have a separate function to handle it. We will call that function every frame (by calling it within _process
), and we will update the camera system state via user input.
Define all variables
We need to take different parameters about our camera, such as zoom limits, rotation speed, movement speed, elevation angles limits, screen margins (to move camera if mouse crosses those margins), and whether to allow zooming, panning or rotation.
We also have to keep reference of camera, elevation node & current state of our system (is_rotating
, is_panning
, etc):
extends Node3D
# Parameters for Camera Control
@export_range(0, 1000) var movement_speed: float = 64
@export_range(0, 1000) var rotation_speed: float = 5
@export_range(0, 1000, 0.1) var zoom_speed: float = 50
@export_range(0, 1000) var min_zoom: float = 32
@export_range(0, 1000) var max_zoom: float = 256
@export_range(0, 90) var min_elevation_angle: float = 10
@export_range(0, 90) var max_elevation_angle: float = 90
@export var edge_margin: float = 50
@export var allow_rotation: bool = true
@export var allow_zoom: bool = true
@export var allow_pan: bool = true
# Camera Nodes
@onready var camera = $Elevation/Camera3D
@onready var elevation_node = $Elevation
# Runtime State
var is_rotating: bool = false
var is_panning: bool = false
var last_mouse_position: Vector2
var zoom_level: float = 64
Define Camera System Functionality
We need to define functions for each action our RTS camera system should perform. These include panning (smoothly moving the camera against cursor), zooming, rotating, and moving the camera based on mouse or keyboard arrow keys input.
# Movement
func handle_keyboard_movement(delta: float) -> void:
var direction = Vector3.ZERO
if Input.is_action_pressed("ui_up"):
direction.z -= 1
if Input.is_action_pressed("ui_down"):
direction.z += 1
if Input.is_action_pressed("ui_left"):
direction.x -= 1
if Input.is_action_pressed("ui_right"):
direction.x += 1
if direction != Vector3.ZERO:
direction = direction.normalized()
global_translate(direction * movement_speed * delta)
func handle_edge_movement(delta: float) -> void:
var viewport = get_viewport()
var mouse_pos = viewport.get_mouse_position()
var screen_rect = viewport.get_visible_rect()
var direction = Vector3.ZERO
if mouse_pos.x < edge_margin:
direction.x -= 1
elif mouse_pos.x > screen_rect.size.x - edge_margin:
direction.x += 1
if mouse_pos.y < edge_margin:
direction.z -= 1
elif mouse_pos.y > screen_rect.size.y - edge_margin:
direction.z += 1
if direction != Vector3.ZERO:
direction = direction.normalized()
global_translate(direction * movement_speed * delta)
# Rotation
func handle_rotation(delta: float) -> void:
if is_rotating:
var mouse_displacement = get_viewport().get_mouse_position() - last_mouse_position
last_mouse_position = get_viewport().get_mouse_position()
# Horizontal rotation
rotation.y -= deg_to_rad(mouse_displacement.x * rotation_speed * delta)
# Elevation
var elevation_angle = rad_to_deg(elevation_node.rotation.x)
elevation_angle = clamp(
elevation_angle - mouse_displacement.y * rotation_speed * delta,
-max_elevation_angle,
-min_elevation_angle
)
elevation_node.rotation.x = deg_to_rad(elevation_angle)
# Zoom
func handle_zoom(delta: float) -> void:
zoom_level = clamp(zoom_level, min_zoom, max_zoom)
camera.position.y = lerp(camera.position.y, zoom_level, 0.1)
# Panning
func handle_panning(delta: float) -> void:
if is_panning:
var current_mouse_pos = get_viewport().get_mouse_position()
var displacement = current_mouse_pos - last_mouse_position
last_mouse_position = current_mouse_pos
global_translate(Vector3(-displacement.x, 0, -displacement.y) * 0.1)
Using These Functions
The above functions should be called in _process
to do their work. In _process
, notice that when panning occurs, other movements are disabled. This is because movements or rotations should not occur while panning, as it is desirable.
func _process(delta: float) -> void:
if not is_panning:
handle_edge_movement(delta)
handle_keyboard_movement(delta)
if allow_rotation:
handle_rotation(delta)
if allow_zoom:
handle_zoom(delta)
else:
if allow_pan:
handle_panning(delta)
Updating the Camera States
The above functions perform their work only if some conditions are true (i.e., in certain states). For example when a user moves the mouse, or presses some keys. So we need to modify our camera state variables (such as is_panning
, is_rotating
) based on user input. So when this variable (or any other variable) becomes true, the camera will act accordingly:
# handling inputs
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("camera_rotate"):
is_rotating = true
last_mouse_position = get_viewport().get_mouse_position()
elif event.is_action_released("camera_rotate"):
is_rotating = false
if event.is_action_pressed("camera_pan"):
is_panning = true
last_mouse_position = get_viewport().get_mouse_position()
elif event.is_action_released("camera_pan"):
is_panning = false
if event.is_action_pressed("zoom_in"):
zoom_level -= zoom_speed
elif event.is_action_pressed("zoom_out"):
zoom_level += zoom_speed
Full RTS Game Camera Script
extends Node3D
# Parameters for Camera Control
@export_range(0, 1000) var movement_speed: float = 64
@export_range(0, 1000) var rotation_speed: float = 5
@export_range(0, 1000, 0.1) var zoom_speed: float = 50
@export_range(0, 1000) var min_zoom: float = 32
@export_range(0, 1000) var max_zoom: float = 256
@export_range(0, 90) var min_elevation_angle: float = 10
@export_range(0, 90) var max_elevation_angle: float = 90
@export var edge_margin: float = 50
@export var allow_rotation: bool = true
@export var allow_zoom: bool = true
@export var allow_pan: bool = true
# Camera Nodes
@onready var camera = $Elevation/Camera3D
@onready var elevation_node = $Elevation
# Runtime State
var is_rotating: bool = false
var is_panning: bool = false
var last_mouse_position: Vector2
var zoom_level: float = 64
func _ready() -> void:
# Initialize zoom level
zoom_level = camera.position.y
func _process(delta: float) -> void:
if not is_panning:
handle_edge_movement(delta)
handle_keyboard_movement(delta)
if allow_rotation:
handle_rotation(delta)
if allow_zoom:
handle_zoom(delta)
else:
if allow_pan:
handle_panning(delta)
# handling inputs
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("camera_rotate"):
is_rotating = true
last_mouse_position = get_viewport().get_mouse_position()
elif event.is_action_released("camera_rotate"):
is_rotating = false
if event.is_action_pressed("camera_pan"):
is_panning = true
last_mouse_position = get_viewport().get_mouse_position()
elif event.is_action_released("camera_pan"):
is_panning = false
if event.is_action_pressed("zoom_in"):
zoom_level -= zoom_speed
elif event.is_action_pressed("zoom_out"):
zoom_level += zoom_speed
# Movement
func handle_keyboard_movement(delta: float) -> void:
var direction = Vector3.ZERO
if Input.is_action_pressed("ui_up"):
direction.z -= 1
if Input.is_action_pressed("ui_down"):
direction.z += 1
if Input.is_action_pressed("ui_left"):
direction.x -= 1
if Input.is_action_pressed("ui_right"):
direction.x += 1
if direction != Vector3.ZERO:
direction = direction.normalized()
global_translate(direction * movement_speed * delta)
func handle_edge_movement(delta: float) -> void:
var viewport = get_viewport()
var mouse_pos = viewport.get_mouse_position()
var screen_rect = viewport.get_visible_rect()
var direction = Vector3.ZERO
if mouse_pos.x < edge_margin:
direction.x -= 1
elif mouse_pos.x > screen_rect.size.x - edge_margin:
direction.x += 1
if mouse_pos.y < edge_margin:
direction.z -= 1
elif mouse_pos.y > screen_rect.size.y - edge_margin:
direction.z += 1
if direction != Vector3.ZERO:
direction = direction.normalized()
global_translate(direction * movement_speed * delta)
# Rotation
func handle_rotation(delta: float) -> void:
if is_rotating:
var mouse_displacement = get_viewport().get_mouse_position() - last_mouse_position
last_mouse_position = get_viewport().get_mouse_position()
# Horizontal rotation
rotation.y -= deg_to_rad(mouse_displacement.x * rotation_speed * delta)
# Elevation
var elevation_angle = rad_to_deg(elevation_node.rotation.x)
elevation_angle = clamp(
elevation_angle - mouse_displacement.y * rotation_speed * delta,
-max_elevation_angle,
-min_elevation_angle
)
elevation_node.rotation.x = deg_to_rad(elevation_angle)
# Zoom
func handle_zoom(delta: float) -> void:
zoom_level = clamp(zoom_level, min_zoom, max_zoom)
camera.position.y = lerp(camera.position.y, zoom_level, 0.1)
# Panning
func handle_panning(delta: float) -> void:
if is_panning:
var current_mouse_pos = get_viewport().get_mouse_position()
var displacement = current_mouse_pos - last_mouse_position
last_mouse_position = current_mouse_pos
global_translate(Vector3(-displacement.x, 0, -displacement.y) * 0.1)