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:

alt alt

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:

  1. Add/remove any number of targets.
  2. Keep the camera’s position centered at the midpoint of the targets.
  3. 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.

Note

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):

alt alt

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)

Like video?