Multitarget Camera
Problem
You need a dynamic camera that moves and zooms to keep multiple objects on screen at the same time.
An example might be in a 2 player game, keeping both players on-screen as they move farther and closer together, like so:
Solution
In a single-player game, you’re probably used to attaching the camera to the player, so that it automatically follows them. We can’t really do this here because we have 2 (or more) players or other game objects that we want to keep on the screen at all times.
We need our camera to do 3 things:
- Add/remove any number of targets.
- Keep the camera’s position centered at the midpoint of the targets.
- Adjust the camera’s zoom to keep all targets on screen.
Create a new scene with a Camera2D
and attach a script. We’ll add this camera to our game once we’re done.
Let’s break down how the script works.
You can see the full script at the end of the article.
Here’s how the script starts:
extends Camera2D
export var move_speed = 0.5 # camera position lerp speed
export var zoom_speed = 0.25 # camera zoom lerp speed
export var min_zoom = 1.5 # camera won't zoom closer than this
export var max_zoom = 5 # camera won't zoom farther than this
export var margin = Vector2(400, 200) # include some buffer area around targets
var targets = [] # Array of targets to be tracked.
onready var screen_size = get_viewport_rect().size
These settings will let you adjust the camera’s behavior. We’ll lerp()
all camera changes, so making the move/zoom speeds low will introduce some delay in the camera “catching up” to sudden changes.
Maximum and minimum zoom values will also depend on the size of objects in your game and how close or far you want to get.
The margin
property is going to add some extra space around the targets so they’re not right on the edge of the viewable area.
Lastly, we have our array of targets and we get the viewport size so that we can properly calculate the scale.
func add_target(t):
if not t in targets:
targets.append(t)
func remove_target(t):
if t in targets:
targets.erase(t)
For adding and removing targets, we have two helper functions. You can use these during gameplay to change what targets are being tracked (“Player 3 has entered the game!”). Note that we don’t want to have the same target tracked twice, so we reject it if it’s already there.
Most of the functionality happens in _process()
. First, moving the camera:
func _process(delta):
if !targets:
return
# Keep the camera centered between the targets
var p = Vector2.ZERO
for target in targets:
p += target.position
p /= targets.size()
position = lerp(position, p, move_speed)
Here, we loop through the targets’ positions and find the common center. Using lerp()
we make sure it moves there smoothly.
Next, we’ll handle the zoom:
# Find the zoom that will contain all targets
var r = Rect2(position, Vector2.ONE)
for target in targets:
r = r.expand(target.position)
r = r.grow_individual(margin.x, margin.y, margin.x, margin.y)
var d = max(r.size.x, r.size.y)
var z
if r.size.x > r.size.y * screen_size.aspect():
z = clamp(r.size.x / screen_size.x, min_zoom, max_zoom)
else:
z = clamp(r.size.y / screen_size.y, min_zoom, max_zoom)
zoom = lerp(zoom, Vector2.ONE * z, zoom_speed)
The key functionality here comes from Rect2
. We want to find a rectangle that encloses all the targets, which we can get with the expand()
method. We then grow the rect by the margin
.
Here you can see the rectangle being drawn (press “Tab” in the demo project to enable this drawing):
Then, depending whether the rectangle is wider or taller (relative to the screen’s aspect ratio), we find the scale and clamp it in the max/min range we’ve defined.
Full script
extends Camera2D
export var move_speed = 0.5 # camera position lerp speed
export var zoom_speed = 0.25 # camera zoom lerp speed
export var min_zoom = 1.5 # camera won't zoom closer than this
export var max_zoom = 5 # camera won't zoom farther than this
export var margin = Vector2(400, 200) # include some buffer area around targets
var targets = [] # Array of targets to be tracked.
onready var screen_size = get_viewport_rect().size
func _process(delta):
if !targets:
return
# Keep the camera centered between the targets
var p = Vector2.ZERO
for target in targets:
p += target.position
p /= targets.size()
position = lerp(position, p, move_speed)
# Find the zoom that will contain all targets
var r = Rect2(position, Vector2.ONE)
for target in targets:
r = r.expand(target.position)
r = r.grow_individual(margin.x, margin.y, margin.x, margin.y)
var d = max(r.size.x, r.size.y)
var z
if r.size.x > r.size.y * screen_size.aspect():
z = clamp(r.size.x / screen_size.x, min_zoom, max_zoom)
else:
z = clamp(r.size.y / screen_size.y, min_zoom, max_zoom)
zoom = lerp(zoom, Vector2.ONE * z, zoom_speed)
func add_target(t):
if not t in targets:
targets.append(t)
func remove_target(t):
if t in targets:
targets.erase(t)