Hi all!
This is the first post of a series in which I'll explain how we can implement parallax scrolling in Love2d. The code that I'll share is similar to the one I'm using for Breaking Fast, our next game to come.
The implementation builds on the tutorial that you can find here. Actually, I advise you to take a look at this tutorial first, because it covers the fundamental principles behind the implementation. Then, we will perform some extensions, so come back here.
The implementation starts with the idea of creating a data structure that represents the camera. So now, we will focus on a camera.lua file that implements the camera data and functionalities.
camera.lua
local camera = {}
camera.__index = camera
return camera
What data must a camera have? Well, the first and most important one is the (x,y) coordinates that represent its position. It could also hold data regarding scaling in both directions, and its orientation. If we want the camera to support parallax (and we certainly do), it must also contain a list of layers, where each layer will contain its own objects to be drawn. With this in mind, we can create the camera constructor, as follows:
camera.lua
local function construct()
local self = setmetatable({x = 0, y = 0, scaleX = 1, scaleY = 1, rotation = 0, layers={}}, camera)
return self
end
setmetatable(camera, {__call = construct})
As you can see, we are creating a camera with an empty list of layers. Also, with the setmetatable function, we are instructing that if, from some client code, we use the table camera as a function, the method to which __call points is to be invoked. This means that if we want to create two different cameras from client code, we only need to do the following:
some client code.lua
local Camera = require "camera"
myCamera1 = Camera()
myCamera2 = Camera()
Love2d uses three functions to transform the current coordinate system:
love.graphics.rotate( rotation )
love.graphics.scale( x_scale, y_scale )
love.graphics.translate ( dx, dy )
Let's focus on the last one. According to the documentation: When this function is called with two numbers, dx, and dy, all the following drawing operations take effect as if their x and y coordinates were x+dx and y+dy.
So, assume we have a square in the coordinate (1, 0), as shown on the left part of Figure 1. After performing love.graphics.translate( -1, 0 ), we would have the square in the coordinate (1 + (-1), 0 + 0) = (0, 0), because even when we are actually moving the coordinate system, this is equivalent to moving the square in the opposite direction, as depicted on the right side of Figure 1.
Figure 1
This is the fundamental mechanism that we can use in order to implement traditional scrolling, where the dx and dy values correspond to the position of the camera with negative sign. The same applies for the scaling and the rotation. We can encapsulate all this in two functions as follows:
camera.lua
function camera:set()
love.graphics.push()
love.graphics.rotate(-self.rotation)
love.graphics.scale(1 / self.scaleX, 1 / self.scaleY)
love.graphics.translate(-self.x, -self.y)
end
function camera:unset()
love.graphics.pop()
end
The push() function saves the current transformation on top of a stack, whereas pop() sets the current transformation to the one on top of the stack. The strategy to draw is therefore something similar to this:
camera.lua
function camera:draw()
self:set()
-- draw stuff
self:unset()
end
Obviously, prior to calling self:set(), we could modify properties of the camera, in such a way that each draw call behaves different in terms of scaling, rotation or movement/position.
Let's focus now on the parallax effect. For such effect, we have to turn our attention to the layers. What data must a layer have? First, the layer must be able to draw itself, so it must have a reference to a function that will draw the objects in such a layer. Given that we want that each layer can move at different rates (in order to achieve the parallax effect), each layer must hold a rate or scale value. Finally, it would be interesting that we could decide a relative order among layers, in such a way that we can specify the order in which different layers are drawn. The function that builds a new layer is shown next:
camera.lua
function camera:newLayer(order, scale, func)
local newLayer = {draw = func, scale = scale, order = order}
table.insert(self.layers, newLayer)
table.sort(self.layers, function(a,b) return a.order < b.order end)
return newLayer
end
Note that after inserting the new layer into the table of layers, we order them according to the order value. Now, we can complete the draw function as follows:
camera.lua
function camera:draw()
local bx, by = self.x, self.y
for _, v in ipairs(self.layers) do
self.x = bx * v.scale
self.y = by * v.scale
self:set()
v.draw()
self:unset()
end
self.x, self.y = bx, by
end
The draw function iterates over all the layers defined for the camera, it then applies a layer scale to the current position of the camera, which makes each layer to be drawn in a possibly different position, depending on the value of this scale. Finally, the draw function of the layer is called. In order to preserve the original position of the camera, we use the temporary variables bx and by.
Note that given that we are using the product of the layer scale and the current position, stationary objects in layers with scale = 0 will have no movement. Therefore, any static background of the game will belong to a layer with such a scale. A layer with scale = 1 will contain objects that move at the same rate as the camera (but in the opposite direction). If we want to track a character controlled by the player, a good strategy is including it in a layer with scale = 1 and moving it at the same rate as the camera, as we will see later. Anything that moves at a faster or slower rates than the camera will belong to layers with scales greater or lower than 1, respectively. The following post in the series will provide further insight on these statements, but for now, let's see now how some client code can use all of this. Let's assume that we have four layers, that we want to draw in the following order:
- Static background layer.
- Background layer that moves slowly.
- Player layer.
- Foreground layer with objects moving much faster than the rate at which the player moves.
some client code.lua
myCamera1 = Camera()
myCamera1:newLayer(-10, 0, function()
love.graphics.setColor(255, 255, 255)
love.graphics.draw(staticBackground)
end)
myCamera1:newLayer(-5, 0.3, function()
love.graphics.setColor(255, 255, 255)
love.graphics.draw(slowBackground)
end)
myCamera1:newLayer(0, 1.0, function()
love.graphics.setColor(255, 255, 255)
love.graphics.draw(player)
end)
myCamera1:newLayer(10, 1.5, function()
love.graphics.setColor(255, 255, 255)
love.graphics.draw(foreground)
end)
Of course this is not the end of the story. Client code is responsible for updating and drawing the contents of the camera. Fortunately, this is easy, as it is shown next:
some client code.lua
function love.draw()
myCamera1:draw()
end
function love.update(dt)
myCamera1:update(dt, player1.posX)
end
love.draw and love.update(dt) are two framework callbacks provided by Love2d and which developers can override in order to customize their behaviours. The former will essentially call the draw function that we implemented in the camera.lua module, whereas the update function will in turn call an update function in the camera module, which we haven't discussed yet. In this example, we assume that we want the camera to follow the player when it is in the center of the screen. Achieving this requires adding the following lines to the camera.lua file:
camera.lua
local intendedWidth = 1920
function camera:setPosition(x, y)
self.x = x or self.x
self.y = y or self.y
end
function camera:update(dt, posX)
if posX > intendedWidth / 2 then
self:setPosition(posX - intendedWidth / 2)
else
self:setPosition(0)
end
end
Assuming a resolution (intendedWidth) of 1920 pixels in the horizontal dimension, we want to reposition the camera as soon as the player moves past half of this resolution (center of the screen). From that moment onwards, the camera is repositioned each frame to center the player on the screen. As the player belongs to a layer with scale = 1, the player will remain always in the same position in relation to the camera. This again will be further discussed and mathematically proved in the following post of the series.
And this is for now! I hope you found this tutorial useful. In the next installments of the tutorial, I intend to discuss and provide further insight on three aspects: reasoning about layers scales and their relation with the positions of objects, detecting collisions among objects in different layers and representing objects in different cameras, for example, for local multiplayer games.
See you!