STALKER-X is a camera module for LÖVE. It provides basic functionalities that a camera should have and is inspired by hump.camera and FlxCamera. The goal is to provide enough functions that building something like in this video becomes as easy as possible.
Place the Camera.lua
file inside your project and require it:
Camera = require 'Camera'
function love.load()
camera = Camera()
end
function love.update(dt)
camera:update(dt)
end
function love.draw()
camera:attach()
-- Draw your game here
camera:detach()
camera:draw() -- Call this here if you're using camera:fade, camera:flash or debug drawing the deadzone
end
You can create multiple camera objects if needed, even though most of the time you can get away with just a single global one.
The main feature of this library is the ability to follow a target. We can do that in a basic way like this:
function love.update(dt)
camera:update(dt)
camera:follow(player.x, player.y)
end
And that would look like this:
We can change how sticky or how ahead of the target the camera is by changing its lerp and lead variables. Lerp is value that goes from 0 to 1. Closer to 0 means less sticky following, while closer to 1 means stickier following:
function love.load()
camera = Camera()
camera:setFollowLerp(0.2)
end
And that would look like this:
Lead is a value that goes from 0 to infinity. Closer to 0 means no look-ahead, while higher values will move the camera in the direction of the target's movement more. In practice good lead values will range from 2 to 10.
function love.load()
camera = Camera()
camera:setFollowLerp(0.2)
camera:setFollowLead(10)
end
Different deadzones define different areas in which the camera will or will not follow the target. This can be useful to create all sorts of different behaviors like the some of the ones outlined in this article. All the examples below use a lerp value of 0.2 and a lead value of 0.
function love.load()
camera = Camera()
camera:setFollowStyle('LOCKON')
end
function love.load()
camera = Camera()
camera:setFollowStyle('PLATFORMER')
end
function love.load()
camera = Camera()
camera:setFollowStyle('TOPDOWN')
end
function love.load()
camera = Camera()
camera:setFollowStyle('TOPDOWN_TIGHT')
end
In this one the camera will move whenever the target reaches the edges of the screen in a screen by screen basis.
Because of this we need to define the width and height of our screen, which can be seen below as the 3rd and 4rd arguments
to the Camera
call. In most cases where the external screen size matches the internal screen size this will be done
automatically, but in some cases you might need to define it yourself.
For instance, if you're doing a pixel art game which has an internal resolution of 360x270
, in which you draw the entire
game to a canvas and then draw the canvas scaled by a factor in the end to fit the final screen size, you'd want the camera's
internal width/height to be the base 360x270
, and not the final 1440x1080
in case of 4x scale factor.
function love.load()
camera = Camera(200, 150, 400, 300)
camera:setFollowStyle('SCREEN_BY_SCREEN')
end
Without a deadzone the target will just be followed directly and without lerping or leading being applied.
If the lerp value is 1 and the lead value is 0 (the default values for both of those) then the camera will act
just like in the NO_DEADZONE
mode, even though the default mode is LOCKON
.
function love.load()
camera = Camera()
camera:setFollowStyle('NO_DEADZONE')
end
Custom deadzones can be set with the :setDeadzone(x, y, w, h)
call. Deadzones are set in camera coordinates,
with the top-left being 0, 0
and the bottom-right being camera.w, camera.h
. So the following call:
function love.load()
local w, h = 400, 300
camera = Camera(w/2, h/2, w, h)
camera:setDeadzone(40, h/2 - 40, w - 80, 80)
end
Will result in this:
function love.keypressed(key)
if key == 's' then
camera:shake(8, 1, 60)
end
end
In this example the camera will shake with intensity 8 and for the duration of 1 second with a frequency of 60Hz.
The camera implementation is based on this tutorial
which provides a nice additional frequency
parameter. Higher frequency means jerkier motion, and lower frequency means
smoother motion.
Note that if you have a target locked and you have NO_DEADZONE
or a lerp of 1 set, then a screen shake won't happen
since the camera will be locked tightly to the target.
This is a good effect for when the player gets hit, lightning strikes, or similar events.
function love.draw()
camera:attach()
-- ...
camera:detach()
camera:draw() -- Must call this to use camera:flash!
end
function love.keypressed(key)
if key == 'f' then
camera:flash(0.05, {0, 0, 0, 1})
end
end
The example above will fill the screen with the black color for 0.05 seconds, which looks like this:
This is a good effect for transitions between levels.
function love.draw()
camera:attach()
-- ...
camera:detach()
camera:draw() -- Must call this to use camera:fade!
end
function love.keypressed(key)
if key == 'f' then
camera:fade(1, {0, 0, 0, 1})
end
if key == 'g' then
camera:fade(1, {0, 0, 0, 0})
end
end
In the example above, when f
is pressed the screen will be gradually filled over 1 second with the black color
and then it will remain covered. If g
is pressed after that then the screen will gradually go back to normal over 1 second.
The default color that covers the screen initially is {0, 0, 0, 0}
.
All the gifs above were created with what I call a pixel art setup. In that everything is drawn to a canvas at a base resolution
and then that canvas is scaled to the final screen using the nearest
filter mode. This is how a chunky pixel look can be achieved and it's generally how pixel art is scaled in games. The advantages of this method is that you only have to care
about a single resolution and then everything else takes care of itself. The way this setup looks like in LÖVE code could go
something like this:
function love.load()
love.graphics.setDefaultFilter('nearest', 'nearest') -- scale everything with nearest neighbor
canvas = love.graphics.newCanvas(400, 300)
end
function love.draw()
love.graphics.setCanvas(canvas)
love.graphics.clear()
-- draw the game here
love.graphics.setCanvas()
-- Draw the 400x300 canvas scaled by 2 to a 800x600 screen
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode('alpha', 'premultiplied')
love.graphics.draw(canvas, 0, 0, 0, 2, 2)
love.graphics.setBlendMode('alpha')
end
All the gifs above followed this code. It's a base resolution of 400x300
being drawn at a scale of 2 to a 800x600
screen.
Now, this relates to the camera in that to make the camera work with this setup we need to tell it what's the base resolution
we're using. In this case it's 400x300
and so we can create the camera object specifying these values:
function love.load()
camera = Camera(200, 150, 400, 300)
...
end
The third and fourth arguments of the Camera
call are for the internal width and height of the camera, and in this case
they should match the base resolution. If those arguments are omitted then it will default to whatever value is returned
by the love.graphics.getWidth
and love.graphics.getHeight
calls. In a pixel setup like this omitting those values is
problematic because then the camera would assume an internal resolution of 800x600
which would make everything not work properly.
If you're using a variable timestep you might notice a jerky motion when the camera tries to follow a target tightly. This can be fixed by decreasing the lerp value, or more cleanly by using a fixed timestep setup. The code below is based on the "Free the Physics" section of this article.
-- LÖVE 0.10.2 fixed timestep loop, Lua version
function love.run()
if love.math then love.math.setRandomSeed(os.time()) end
if love.load then love.load(arg) end
if love.timer then love.timer.step() end
local dt = 0
local fixed_dt = 1/60
local accumulator = 0
while true do
if love.event then
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == 'quit' then
if not love.quit or not love.quit() then
return a
end
end
love.handlers[name](a, b, c, d, e, f)
end
end
if love.timer then
love.timer.step()
dt = love.timer.getDelta()
end
accumulator = accumulator + dt
while accumulator >= fixed_dt do
if love.update then love.update(fixed_dt) end
accumulator = accumulator - fixed_dt
end
if love.graphics and love.graphics.isActive() then
love.graphics.clear(love.graphics.getBackgroundColor())
love.graphics.origin()
if love.draw then love.draw() end
love.graphics.present()
end
if love.timer then love.timer.sleep(0.0001) end
end
end
-- LÖVE 0.10.2 fixed timestep loop, MoonScript version
love.run = () ->
if love.math then love.math.setRandomSeed(os.time())
if love.load then love.load(arg)
if love.timer then love.timer.step()
dt = 0
fixed_dt = 1/60
accumulator = 0
while true
if love.event
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit"
if not love.quit or not love.quit()
return a
love.handlers[name](a, b, c, d, e, f)
if love.timer
love.timer.step()
dt = love.timer.getDelta()
accumulator += dt
while accumulator >= fixed_dt do
if love.update then love.update(fixed_dt)
accumulator -= fixed_dt
if love.graphics and love.graphics.isActive()
love.graphics.clear(love.graphics.getBackgroundColor())
love.graphics.origin()
if love.draw then love.draw()
love.graphics.present()
if love.timer then love.timer.sleep(0.0001)
Creates a new Camera.
camera = Camera()
Arguments:
x=w/2
(number)
- The camera's x position. Defaults tow/2
y=h/2
(number)
- The camera's y position. Defaults toh/2
w=love.graphics.getWidth()
(number)
- The camera's width. Defaults tolove.graphics.getWidth()
h=love.graphics.getHeight()
(number)
- The camera's height. Defaults tolove.graphics.getHeight()
scale=1
(number)
- The camera's scale. Defaults to1
rotation=0
(number)
- The camera's rotation. Defaults to0
Returns:
Camera
(table)
- the Camera object
Updates the camera.
camera:update(dt)
Arguments:
dt
(number)
- The time step delta
Draws the camera, drawing the deadzone if draw_deadzone
is true
and also drawing the flash
and fade
effects.
camera:draw()
Attaches the camera, making all following draw operations be affected by the camera's translation, scale and rotation transformations.
camera:attach()
-- draw the game here
camera:detach()
Detaches the camera, returning the transformation stack back to normal.
camera:attach()
-- draw the game here
camera:detach()
The camera's position. This is the center of the camera and not its top-left position. This can be changed directly although
if you're using the follow
function then changing this directly might result in bugs.
camera.x, camera.y = 0, 0
The camera's scale/zoom.
camera.scale = 2
The camera's rotation.
camera.rotation = math.pi/8
The same as hump.camera:worldCoords. This takes in a position in camera coordinates and translates it to world coordinates. An example of this is taking the position of the mouse and seeing where it is in the world.
mx, my = camera:toWorldCoords(love.mouse.getPosition())
Arguments:
x
(number)
- The x position in camera coordinatesy
(number)
- The y position in camera coordinates
Returns:
x
(number)
- The x position in world coordinatesy
(number)
- The y position in world coordinates
The same as hump.camera:cameraCoords. This takes in a position in world coordinates and translates it to camera coordinates. An example of this is taking the position of the player and
player_x, player_y = camera:toCameraCoords(player.x, player.y)
love.graphics.line(player_x, player_y, love.mouse.getPosition())
Arguments:
x
(number)
- The x position in world coordinatesy
(number)
- The y position in world coordinates
Returns:
x
(number)
- The x position in camera coordinatesy
(number)
- The y position in camera coordinates
Gets the position of the mouse in world coordinates. This position can also be accessed directly through .mx, .my
.
mx, my = camera:getMousePosition()
mx, my = camera.mx, camera.my
Returns:
x
(number)
- The x position of the mouse in world coordinatesy
(number)
- The y position of the mouse in world coordinates
Shakes the screen with intensity for a certain duration.
camera:shake(8, 1, 60, 'X')
Arguments:
intensity
(number)
- The intensity of the shake in pixels. This will be decreased along the duration of the shake.duration=1
(number)
- The duration of the shake in seconds. Defaults to1
frequency=60
(number)
- The frequency of the shake. Higher = jerkier, lower = smoother. Defaults to60
axes='XY'
(string)
- The axes of the shake. Can be'X'
for horizontal,'Y'
for vertical or'XY'
for both. Defaults to'XY'
Fills the screen up with a color for a certain duration.
camera:flash(0.05, {0, 0, 0, 1})
Arguments:
duration
(number)
- The duration of the flash in secondscolor={0, 0, 0, 1}
(table[number])
- The color of the flash. Defaults to{0, 0, 0, 1}
Slowly fills up the screen with a color along the duration.
camera:fade(1, {0, 0, 0, 1}, function() print(1) end)
Arguments:
duration
(number)
- The duration of the fade in secondscolor
(table[number])
- The target color of the fadeaction
function
- An optional action that is run when the fade ends
Follow the target according to the follow style and lerp, lead values.
camera:follow(player.x, player.y)
Arguments:
x
(number)
- The x position of the target in world coordinatesy
(number)
- The y position of the target in world coordinates
Sets the follow style to be used by camera:follow
. Possible values are 'LOCKON'
, 'PLATFORMER'
, 'TOPDOWN'
, 'TOPDOWN_TIGHT'
, 'SCREEN_BY_SCREEN'
and 'NO_DEADZONE'
. This can also be changed directly through .follow_style
.
camera:setFollowStyle('LOCKON')
camera.follow_style = 'LOCKON'
Arguments:
follow_style
(string)
- The follow style to be used
Sets the deadzone directly. The follow style must be set to nil
for this to work.
camera:setDeadzone(0, 0, w, h)
Arguments:
x
(number)
- The top-left x position of the deadzone in camera coordinatesy
(number)
- The top-left y position of the deadzone in camera coordinatesw
(number)
- The width of the deadzoneh
(number)
- The height of the deadzone
Draws the deadzone if set to true. camera:draw()
must be called outside the camera:attach/detach
block for it to work.
camera.draw_deadzone = true
Sets the lerp value. This can be accessed directly through .follow_lerp_x
and .follow_lerp_y
.
camera:setFollowLerp(0.2)
camera.follow_lerp_x = 0.2
camera.follow_lerp_y = 0.2
Arguments:
x
(number)
- The x lerp valuey=x
(number)
- The y lerp value. Defaults to thex
value
Sets the lead value. This can be accessed directly through .follow_lead_x
and .follow_lead_y
.
camera:setFollowLead(10)
camera.follow_lead_x = 10
camera.follow_lead_y = 10
Arguments:
x
(number)
- The x lead valuey=x
(number)
The y lead value. Defaults to thex
value
Sets the boundaries of the camera in world coordinates. The camera won't be able to move past those points.
camera:setBounds(0, 0, 800, 600)
Arguments:
x
(number)
- The top-left x position of the boundaryy
(number)
- The top-left y position of the boundaryw
(number)
- The width of the rectangle that defines the boundaryh
(number)
- The height of the rectangle that defines the boundary
You can do whatever you want with this. See the license at the top of the main file.