Monkune/lib/STALKER-X/README.md
Jaidyn Ann 2f0e1614d0 Init
2021-01-07 01:29:06 -06:00

20 KiB

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.

Contents


Quick Start

Place the Camera.lua file inside your project and require it:

Camera = require 'Camera'

Creating a camera object

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.


Following a target

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:


Follow lerp and lead

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


Deadzones

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.

LOCKON

function love.load()
    camera = Camera()
    camera:setFollowStyle('LOCKON')
end

PLATFORMER

function love.load()
    camera = Camera()
    camera:setFollowStyle('PLATFORMER')
end

TOPDOWN

function love.load()
    camera = Camera()
    camera:setFollowStyle('TOPDOWN')
end

TOPDOWN_TIGHT

function love.load()
    camera = Camera()
    camera:setFollowStyle('TOPDOWN_TIGHT')
end

SCREEN_BY_SCREEN

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

NO_DEADZONE

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

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:


Shake

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.


Flash

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:


Fade

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}.


Tips

Pixel Camera

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.


Fixed Timestep

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)

DOCUMENTATION

Camera(x, y, w, h, scale, rotation)

Creates a new Camera.

camera = Camera()

Arguments:

  • x=w/2 (number) - The camera's x position. Defaults to w/2
  • y=h/2 (number) - The camera's y position. Defaults to h/2
  • w=love.graphics.getWidth() (number) - The camera's width. Defaults to love.graphics.getWidth()
  • h=love.graphics.getHeight() (number) - The camera's height. Defaults to love.graphics.getHeight()
  • scale=1 (number) - The camera's scale. Defaults to 1
  • rotation=0 (number) - The camera's rotation. Defaults to 0

Returns:

  • Camera (table) - the Camera object

:update(dt)

Updates the camera.

camera:update(dt)

Arguments:

  • dt (number) - The time step delta

:draw()

Draws the camera, drawing the deadzone if draw_deadzone is true and also drawing the flash and fade effects.

camera:draw()

:attach()

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

:detach()

Detaches the camera, returning the transformation stack back to normal.

camera:attach()
-- draw the game here
camera:detach()

.x, .y

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

.scale

The camera's scale/zoom.

camera.scale = 2

.rotation

The camera's rotation.

camera.rotation = math.pi/8

:toWorldCoords(x, y)

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 coordinates
  • y (number) - The y position in camera coordinates

Returns:

  • x (number) - The x position in world coordinates
  • y (number) - The y position in world coordinates

:toCameraCoords(x, y)

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 coordinates
  • y (number) - The y position in world coordinates

Returns:

  • x (number) - The x position in camera coordinates
  • y (number) - The y position in camera coordinates

:getMousePosition()

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 coordinates
  • y (number) - The y position of the mouse in world coordinates

:shake(intensity, duration, frequency, axes)

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 to 1
  • frequency=60 (number) - The frequency of the shake. Higher = jerkier, lower = smoother. Defaults to 60
  • axes='XY' (string) - The axes of the shake. Can be 'X' for horizontal, 'Y' for vertical or 'XY' for both. Defaults to 'XY'

:flash(duration, color)

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 seconds
  • color={0, 0, 0, 1} (table[number]) - The color of the flash. Defaults to {0, 0, 0, 1}

:fade(duration, color, action)

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 seconds
  • color (table[number]) - The target color of the fade
  • action function - An optional action that is run when the fade ends

:follow(x, y)

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 coordinates
  • y (number) - The y position of the target in world coordinates

:setFollowStyle(follow_style)

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

:setDeadzone(x, y, w, h)

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 coordinates
  • y (number) - The top-left y position of the deadzone in camera coordinates
  • w (number) - The width of the deadzone
  • h (number) - The height of the deadzone

.draw_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

:setFollowLerp(x, y)

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 value
  • y=x (number) - The y lerp value. Defaults to the x value

:setFollowLead(x, y)

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 value
  • y=x (number) The y lead value. Defaults to the x value

:setBounds(x, y, w, h)

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 boundary
  • y (number) - The top-left y position of the boundary
  • w (number) - The width of the rectangle that defines the boundary
  • h (number) - The height of the rectangle that defines the boundary


LICENSE

You can do whatever you want with this. See the license at the top of the main file.