51 KiB
Literate Flicky Clone
- Flicky
- Set up
- Dependencies
- Classy objects
- (2D) Vector Math
- Helper functions
- Swept AABB
- Broad-Phasing collisions with solid tiles
- Game states
- Debugging
- Map class
- Tile class
- Entity class
- Initialization
- Game logic
- Updating each frame
- Drawing to the screen
- Ideas
- Devlog
- Plan
- Install dependencies (inspect, debugger) through
luarocks
- Create object base "class" with copy metamethods functionality, explain literarily
- Refactor
Object:new
,Object:init
, introduceObject:extend
- Convert acceleration to vector in
Entity:move
- Fix gravity always changing position in y axis on
Entity:move
- Add
grounded
attribute to player/entity love.load()
- Bugfix drawing offset by 1 in x and y
love.update(dt)
love.draw()
- Level wrap-around
- Bugfix level wrap-around
- Update "literary" parts
- Player slide
- changing direction while in mid air should have lots of friction
- Refactor player, enemies, items code into entities
- Add a couple enemy entities with stupid AI
- Title screen art mock
- Gameplay screen art mock
- Fix Tile class
ipairs
- Collision with platforms above when jumping
- Camera fixed on player position
- Implement game states
- Explain
game
object components - Refine player movement
- Bugfix scaling and/or drawing
- Maybe create a game class, player class, etc.
- Explain 320x200 resolution
- Fix aspect ratio
- Bugfix jump code
- Set up some kind of debug mode (toggle?)
- Define enemy spawner logic (a few different options)
- Map player position correctly when drawing
- Create lower-resolution internal game model
- Consider dropping
game.world
and not using STI's box2d support - Draw debug helpers
- Draw platforms from
game.platforms
table - slide animation when direction button is released
- Generic
Object:copy
method - Decouple
GameState
objects, map (maybe), and graphics - Fix collision with solid tile when moving horizontally
- Implement game pause as
Play
substate - replace hardcoded player initial position (180, 100) with marker/point from Tiled map
- Reimplement
draw_debug_infos
- Fix jump
- Try implementing wrap-around with polar coordinates and cosine
- Install dependencies (inspect, debugger) through
- Old jam code
Flicky
I love Flicky, it's such a tight fun arcade game. I'm going to attempt to clone it using LÖVE and literate programming to note down my ideas and plans, and also explain the code a bit.
It's unlikely this will ever be published, but I'm hoping to make more games in the future this way, and improve until I'm confident enough to put them up on the interwebs.
Set up
I'll start off with a small conf.lua
file, specifying the LÖVE version, the game title, and that it should run in full screen by default:
function love.conf(t)
t.version = "11.4"
t.window.fullscreen = true
t.title = "Literate Flicky Clone"
end
Dependencies
I won't be using any dependencies, instead coding stuff on my own and using LÖVE's libraries sparingly. This is mainly because I like learning what makes games tick, but also I typically find LÖVE-related libraries hard to use and unnecessarily complicated, as well as poorly documented.
That being said, I do find inspect to be quite useful (I believe it should be a Lua built-in.)
local inspect = require("inspect/inspect")
Classy objects
Lua doesn't have classes, but it does have objects with prototype capabilities through their metatable property, which allow implementing something close to inheritance. Let's create a base Object
"class" which allows inheritance.
Object
will have a new
method which will create new instances. This is achieved by first setting Object
as its own __index
metamethod and then setting Object
as the metatable of a new, empty Lua table. This has the effect of fields not present in the new table to be looked up in Object
(mainly for looking up methods). All of that is encapsulated in an Object:new
function:
local inspect = require("inspect/inspect")
local Object = {}
Object.__index = Object
function Object:new(...)
local object = setmetatable({__index=self}, self)
object.__class = self
object:init(...)
return object
end
new
calls an init
method ala Python to allow subclasses to initialize themselves taking arbitrary arguments. Object
's initializer simply keep a copy of the arguments being passed in.
function Object:init(...)
self.__args = {...}
end
Now, inheritance will be a simple interface, an extend
function that takes care of creating a new
"class" (a Lua table) and copying all metamethods in the parent's metatable (except __index
and
any metamethod it already has) to it:
function Object:extend(SubClass)
SubClass = SubClass or {}
for k, v in pairs(self) do
if k:match("^__") then
SubClass[k] = v
end
end
SubClass.__index = SubClass
SubClass.__super = self
return setmetatable(SubClass, self)
end
Also, we'll set a handy __super
reference to the parent class, so subclasses can call its methods when extending them, enabling polymorphism.
I figured out enough of this on my own, but later found this written a lot better (and took lots of idead from) in different places:
Finally, return this for module imports:
return Object
(2D) Vector Math
This will be a good project to (re)learn vector math and its application to physics stuff on videogames.
Let's start with the vector class extending Object
:
local inspect = require("inspect/inspect")
local Object = require("object")
local Vector = Object:extend()
Vector
's initializer will take an x
and y
value and store them as attributes, and also compute
its angle from them.
function Vector:init(x, y)
Vector.__super.init(self, x, y)
self.x = x
self.y = y
self.angle = math.atan2(self.x, self.y)
end
Next, we'll define mathematical operations by extending Lua's metamethods, to get nice stuff like vector1 + vector2
, number*vector
, #vector
to get its length, etc.
function Vector:__add(v)
return self.__class:new(self.x + v.x, self.y + v.y)
end
function Vector:__sub(v)
return self.__class:new(self.x - v.x, self.y - v.y)
end
function Vector:__mul(obj)
if getmetatable(obj) == Vector then
-- dot product
return self.x*obj.x + self.y*obj.y
else
-- scalar product
return self.__class:new(self.x*obj, self.y*obj)
end
end
function Vector:__eq(v)
return self.x == v.x and self.y == v.y
end
function Vector:__lt(v)
return self.x < v.x and self.y < v.y
end
function Vector:__le(v)
return self.x <= v.x and self.y <= v.y
end
function Vector:__gt(v)
return self.x > v.x and self.y > v.y
end
function Vector:__ge(v)
return self.x >= v.x and self.y >= v.y
end
function Vector:__unm()
return self.__class:new(-self.x, -self.y)
end
function Vector:__len() -- magnitude
return math.sqrt(self.x^2 + self.y^2)
end
function Vector:normalize()
return self.__class:new(self.x/#self, self.y/#self)
end
-- Method to get the clockwise normal of the vector
function Vector:normal_cw()
return self.__class:new(self.y, -self.x)
end
-- Method to get the counterclockwise normal of the vector
function Vector:normal_ccw()
return self.__class:new(-self.y, self.x)
end
return Vector
local Vector = require("vector")
Helper functions
Let's create a module to contain helper functions:
local helpers = {}
For whatever reason Lua doesn't have a sign function 🤷, let's define our own:
function helpers.sign(n)
if n == 0 then
return 0
end
return math.abs(n)/n
end
Same with rounding numbers, need our own function for that:
function helpers.round(num)
return num >= 0 and math.floor(num + 0.5) or math.ceil(num - 0.5)
end
Let's define a bound
function to keep a value within bounds
function helpers.bound(value, min, max)
if value and value < min then
return min
elseif value and value > max then
return max
else
return value
end
end
I think a functional map
function can be helpful when loading the map and similar stuff
function helpers.map(tbl, fun)
local result = {}
for i, v in ipairs(tbl) do
result[i] = fun(v)
end
return result
end
But map
would feel too lonely being the only functional function around, let's also add filter
:
function helpers.filter(tbl, fun)
local result = {}
for i, v in ipairs(tbl) do
if fun(v) then
table.insert(result, v)
end
end
return result
end
To achieve Flicky's screen wrap-around trick (or part of it at least), we need a normalize
function that given a number n
, it returns a new n
that's within a range offset the original n
, so if the screen's width is 100, and n
is 110
, normalizing it would result in 10
, if that makes sense:
function helpers.normalize(n, min, max)
local range = max - min
return ((n - min) % range + range) % range + min
end
We'll need a function to tell if two rectangles overlap for basic collision detection, with rectangles being Lua tables with these field names:
- x
- horizontal pixel position
- y
- vertical pixel position
- w
- width in pixels
- h
- height in pixels
function helpers.rectangles_overlap(rect1, rect2)
local x_overlaps = rect1.x < rect2.x + rect2.w and rect1.x + rect1.w > rect2.x
local y_overlaps = rect1.y < rect2.y + rect2.h and rect1.y + rect1.h > rect2.y
return {x_overlaps and y_overlaps, x_overlaps, y_overlaps}
end
Finally return the helpers object containing all those functions, and load it from main:
return helpers
local helpers = require("helpers")
Swept AABB
This is what I ended up learning with more difficulty than I expected. I could probably get away with something simpler for this game, but I want to learn as much as I can.
In any case, the idea here is to detect two rectangles colliding and avoid the "tunneling" problem where rectangles moving at a high enough velocity move past each other rectangles that should have collided.
I read a bunch of articles, but I'm now following one from gamedev.net, let's try and translate to Lua:
function swept_aabb(box1, box2, dt)
local x_inv_entry, y_inv_entry, x_inv_exit, y_inv_exit
if box1.dx > 0 then
x_inv_entry = box2.x - (box1.x + box1.w)
x_inv_exit = (box2.x + box2.w) - box1.x
else
x_inv_entry = (box2.x + box2.w) - box1.x
x_inv_exit = box2.x - (box1.x + box1.w)
end
if (box1.dy > 0) then
y_inv_entry = box2.y - (box1.y + box1.h)
y_inv_exit = (box2.y + box2.h) - box1.y
else
y_inv_entry = (box2.y + box2.h) - box1.y
y_inv_exit = box2.y - (box1.y + box1.h)
end
local x_entry_time, y_entry_time, x_exit_time, y_exit_time
if (box1.dx == 0) then
x_entry_time = -math.huge
x_exit_time = math.huge
else
x_entry_time = x_inv_entry / box1.dx
x_exit_time = x_inv_exit / box1.dx
end
if (box1.dy == 0) then
y_entry_time = -math.huge
y_exit_time = math.huge
else
y_entry_time = y_inv_entry / box1.dy
y_exit_time = y_inv_exit / box1.dy
end
local entry_time = math.max(y_entry_time, x_entry_time)
local exit_time = math.min(y_exit_time, x_exit_time)
local normalx, normaly
if (entry_time > exit_time or x_entry_time < 0 and y_entry_time < 0 or x_entry_time > 1 or y_entry_time > 1) then
normalx = 0
normaly = 0
entry_time = 1
else
if x_entry_time > y_entry_time then
if x_inv_entry < 0 then
normalx = 1
normaly = 0
else
normalx = -1
normaly = 0
end
else
if y_inv_entry < 0 then
normalx = 0
normaly = 1
else
normalx = 0
normaly = -1
end
end
end
print("SWEPT AABB TIME:", entry_time )
return {normal={x=normalx, y=normaly}, dt=entry_time}
end
Broad-Phasing collisions with solid tiles
This basically comes down to finding the (solid) tiles within the rectangle determined by the initial and final positions of an entity (the player for now).
First, let's write a function to get that displacement rectangle:
function get_displacement_rect(entity, next_pos)
local displacement_rect = {
x=entity.x,
y=entity.y,
w=entity.w,
h=entity.h,
}
if entity.dx > 0 then
displacement_rect.x = entity.x
displacement_rect.w = next_pos.x + entity.w - entity.x
elseif entity.dx < 0 then
displacement_rect.x = next_pos.x
displacement_rect.w = entity.x + entity.w - next_pos.x
end
if entity.dy > 0 then
displacement_rect.y = entity.y
displacement_rect.h = next_pos.y + entity.h - entity.y
elseif entity.dy < 0 then
displacement_rect.y = next_pos.y
displacement_rect.h = entity.y + entity.h - next_pos.y
end
return displacement_rect
end
Next, we'll need a function that returns a list of rectangles representing tiles found in the displacement rectangle:
function distance(point_a, point_b)
return math.sqrt(math.pow(point_a.x - point_b.x, 2) + math.pow(point_a.y - point_b.y, 2))
end
function find_closest_tile_in_rectangle(entity, rect, game)
local closest
local center = {x=entity.x + entity.w/2, y=entity.y + entity.h/2}
local min_distance = math.huge
local top_left_tile = game:pixel_to_tile(rect)
local bottom_right_tile = game:pixel_to_tile({
x=rect.x + rect.w,
y=rect.y + rect.h,
})
for tx=top_left_tile.x, bottom_right_tile.x do
for ty=top_left_tile.y, bottom_right_tile.y do
local value = game:tile_value({x=tx, y=ty})
if value ~= 0 then
local tile_center = {x=game:x_to_pixel(tx) + game.map.tilewidth/2, y=game:y_to_pixel(ty) + game.map.tileheight/2}
local current_distance = distance(center, tile_center)
if current_distance < min_distance then
closest = {x=game:x_to_pixel(tx), y=game:y_to_pixel(ty), w=game.map.tilewidth, h=game.map.tileheight, value=value, tx=tx, ty=ty}
min_distance = current_distance
end
end
end
end
return closest
end
Game states
I'm thinking there should be at least 4 game states:
- Title screen
- Play
- Game Over
- Ending
Let's start with the base GameState
class, it should keep the shared logic of initialization, updating the input state, as well as drawing to the screen:
local Object = require("object")
local Entity = require("entity")
local Map = require("map")
local helpers = require("helpers")
local GameState = Object:extend()
function GameState:init()
GameState.__super.init(self)
self.input = {
start=false,
right=false,
left=false,
up=false,
down=false,
jump=false,
}
self.ended = false
self.next = nil
end
function GameState:updateInput(dt)
self.input.start = love.keyboard.isDown("return")
self.input.right = love.keyboard.isDown("d", "right")
self.input.left = love.keyboard.isDown("a", "left")
self.input.up = love.keyboard.isDown("w", "up")
self.input.down = love.keyboard.isDown("down", "s")
self.input.jump = love.keyboard.isDown("space")
end
function GameState:update(dt)
self:updateInput(dt)
end
function GameState:draw()
end
The first state (for now, we may later add a splash screen or similar) the game will be in is the title screen, from which the player can start the game or go to other states like options, cheats, etc. This boils down to extending the GameState:update
method to check the start button is pressed, in which case it'll create the next state to switch to, Play
:
local Title = GameState:extend()
function Title:update(dt)
Title.__super.update(self, dt)
if self.input.start then
print("PRESSED START ENDING THIS STATE")
self.ended = true
self.next = Play:new()
end
end
function Title:draw()
Title.__super.draw(self)
local font = love.graphics.getFont()
local height = font:getHeight()
local width = font:getWidth("MICHI")
love.graphics.setColor(1, 1, 1)
love.graphics.print("MICHI", 320/2, 24, 0, 2, 2, width/2, height/2)
width = font:getWidth("DRAGON")
love.graphics.print("DRAGON", 320/2, 64, 0, 2, 2, width/2, height/2)
width = font:getWidth("START")
love.graphics.print("START", 320/2, 128, 0, 1, 1, width/2, height/2)
end
So, let's define that Play
state. Basically this takes the place of the old game
object I started with in main.lua
. It'll be the biggest state and probably will be split out into sub-states of its own, but for now it'll be a single level which will eventually lead to the game over and game finished states (once deaths and level progression are implemented). Right now it will load a single level in the form of an instance of the new Map
class, update the player Entity
state, and draw the map and player to the screen.
local Play = GameState:extend()
function Play:init()
Play.__super.init(self)
self.map = Map:new("assets/map.lua")
self.current_frame = 0
self.physics = {
gravity=self.map.tileheight*9.8, -- somewhat exagerated gravity (3x)
maxdx=self.map.tilewidth*8, -- max horizontal speed
maxdy=self.map.tileheight*24, -- max vertical speed
jump=self.map.tileheight*512, -- instant jump impulse
}
self.physics.horizontal_acc = self.physics.maxdx*2 -- horizontal acceleration
self.physics.friction = self.physics.maxdx*1.5 -- horizontal friction
self.player_entity = Entity:new(180, 100, 4, 8, self.physics.gravity, 2*8, self.physics.horizontal_acc, self.physics.friction, self.map)
end
function Play:updatePlayer(dt)
self.player_entity = self.player_entity:move(self.input, dt, self.current_frame)
end
function Play:updateCamera(dt)
end
function Play:updateItems(dt)
end
function Play:updateEnemies(dt)
end
function Play:updateChirps(dt)
end
function Play:update(dt)
Play.__super.update(self, dt)
self.current_frame = self.current_frame + 1
self:updatePlayer(dt)
self:updateCamera(dt)
self:updateItems(dt)
self:updateEnemies(dt)
self:updateChirps(dt)
end
function Play:draw()
love.graphics.setColor(0.2, 0.2, 0.2)
self.map:draw()
love.graphics.setColor(0, 0.8, 0.1)
self.player_entity:draw()
end
<<playState>>
<<titleState>>
return {
Play=Play,
Title=Title,
}
local states = require("states")
Debugging
Let's add a function to draw some debug stuff, starting with just the player's position. I'll later set it up as a toggle with a key or similar.
function game:draw_debug_infos()
love.graphics.setColor(0.1, 0.1, 0.1)
love.graphics.setFont(self.debug_font)
love.graphics.print("pos:("..math.floor(self.player_entity.pos.x)..", "..math.floor(self.player_entity.pos.y)..") g="..tostring(self.player_entity.grounded), 2, 2)
love.graphics.print(
"vel:("..math.floor(self.player_entity.vel.x)..", "..math.floor(self.player_entity.vel.y)..")", 96, 2)
love.graphics.print("input:(L="..tostring(self.input.left)..",R="..tostring(self.input.right)..",U="..tostring(self.input.up)..",D="..tostring(self.input.down)..",J="..tostring(self.input.jump)..")", 2, 12)
end
Map class
Go through each tile in the map and query its value to draw solid tiles (value > 0) in color (this will later be replaced by drawing the actual tile graphics form the tilesheet.)
Tile values deserve a quick explanation: Tiled provides a single-dimension array representing each tile on the map. Each element in the array is simply a number representing the tile's index (to be mapped to a spritesheet), with 0 meaning "no tile here".
local Object = require("object")
local Tile = require("tile")
local helpers = require("helpers")
local inspect = require("inspect/inspect")
local Map = Object:extend()
function Map:init(filename)
Map.__super.init(self)
local map = dofile(filename) -- love.filesystem.load(filename)
local platform_layers = helpers.filter(map.layers, function (layer) return layer.name == "platforms" end)
self.width = map.width
self.height = map.height
self.tilewidth = map.tilewidth
self.tileheight = map.tileheight
self.data = platform_layers[1].data
self.max_pixel = {x=map.width*map.tilewidth, y=map.height*map.tileheight}
end
function Map:itertiles()
local function iterator(map, index)
index = index + 1
local value = map.data[index]
if value ~= nil then
local tx = ((index - 1) % map.width) + 1
local ty = math.floor((index - 1) / map.width) + 1
local tile = Tile:new(tx, ty, map.tilewidth, map.tileheight, value)
return index, tile
end
end
return iterator, self, 0
end
function Map:draw()
for i, tile in self:itertiles() do
love.graphics.setColor(0.9, 0.8, 0.7)
if tile.solid then
love.graphics.setColor(0.2, 0.2, 0.2)
end
love.graphics.rectangle("fill", tile.pixel_rect.x, tile.pixel_rect.y, tile.w, tile.h)
love.graphics.setColor(0.9, 0.1, 0.3)
love.graphics.rectangle("line", tile.pixel_rect.x, tile.pixel_rect.y, tile.w, tile.h)
end
end
function Map:get_tile_from_pixel(pixel)
local tile_x = math.floor(pixel.x/self.tilewidth) + 1
local tile_y = math.floor(pixel.y/self.tileheight) + 1
local value = self.data[tile_x + (tile_y - 1)*self.width]
return Tile:new(tile_x, tile_y, self.tilewidth, self.tileheight, value)
end
return Map
Tile class
local inspect = require("inspect/inspect")
local Vector = require("vector")
local helpers = require("helpers")
local default_w = 8
local default_h = 8
local Tile = Vector:extend()
function Tile:init(x, y, w, h, value)
Tile.__super.init(self, x, y)
self.w = w
self.h = h
self.pixel_rect = {
x=(x - 1)*self.w,
y=(y - 1)*self.h,
w=self.w,
h=self.h,
}
self.value = value
self.solid = not (value == 0 or value == nil)
end
function Tile:from_pixel(pixel, w, h)
w = w or default_w
h = h or default_h
return Tile:new(math.floor(pixel.x/w) + 1, math.floor(pixel.y/h) + 1, w, h)
end
return Tile
local Tile = require("tile")
Entity class
If my mind's bandwidth allows, I may also learn some ECS, so let's start with an entity class that has position and velocity (which should later become components?).
In any case, an entity will have these attributes:
-
pos
- the x and y coordinates of the entity's center
-
vel
- the entity's velocity vector
-
acc
- the entity's acceleration vector
-
rect
- the entity's bounding rect, with
x
andy
for the top left corner, andw
andh
for its width and height, respectively
Also, I want to create an API to allow controlling any entity's actions, namely:
- walk
- jump
- attack
- draw
All of that while having gravity always affect an entity. I'm thinking of starting off with walking and jumping, for which an entity object would provide move
and jump
methods respectively. The move
method in particular would take a movement
object, which specifies in which direction it's moving (left or right) or jumping, which is basically the game's input
object.
local Object = require("object")
local Vector = require("vector")
local Tile = require("tile")
local helpers = require("helpers")
local inspect = require("inspect/inspect")
local Entity = Object:extend()
function Entity:init(x, y, w, h, gravity, max_jump_height, horizontal_acc, friction, map)
Entity.__super.init(self, x, y, w, h)
self.gravity = gravity
self.pos = Vector:new(x, y)
self.vel = Vector:new(0, 0)
self.maxvel = Vector:new(8*8, 8*24)
self.width = w
self.height = h
self.grounded = false
self.horizontal_acc = horizontal_acc
self.max_jump_height = max_jump_height
self.friction = friction
self.map = map
end
function Entity:get_rect()
return {x=self.pos.x - self.width/2, y=self.pos.y - self.height/2, w=self.width, h=self.height}
end
function Entity:draw()
local rect = self:get_rect()
love.graphics.setColor(0, 0, 0.8)
love.graphics.rectangle("fill", helpers.round(rect.x), helpers.round(rect.y), rect.w, rect.h)
love.graphics.setColor(1, 0, 0)
love.graphics.circle("fill", self.pos.x, self.pos.y, 1)
end
Each frame, we'll want to update an entity's state. This means updating its velocity and position based on its previous state, and then adjusting that next state if there are collisions that would prevent actually moving there.
The first step is to create a copy of the current state so we work on that rather than the actual entity, thus, let's define a copy
method:
function Entity:copy()
local new = Entity:new(self.pos.x, self.pos.y, self.width, self.height, self.gravity, self.max_jump_height, self.horizontal_acc, self.friction, self.map)
new.vel.x = self.vel.x
new.vel.y = self.vel.y
new.grounded = self.grounded
return new
end
Next, let's define the move
method, which takes a movement object (basically, the player's input),
and the time delta since the last frame, and will create a copy, update its velocity and position,
check for collisions, and return a new entity with the updated state.
First, let's do the horizontal movement (walking or flying?)
-- TODO: check if not grounded and allow air control
function Entity:move_horizontally(movement, dt)
local x_accel = 0
local next_state = self:copy()
if movement.right or movement.left then
if movement.right then
x_accel = self.horizontal_acc
else
x_accel = -self.horizontal_acc
end
else
if self.vel.x < 0 then
x_accel = self.friction
elseif self.vel.x > 0 then
x_accel = -self.friction
end
end
next_state.vel.x = helpers.bound(self.vel.x + x_accel*dt, -self.maxvel.x, self.maxvel.x)
next_state.pos.x = helpers.normalize(self.pos.x + next_state.vel.x*dt, self.width/2, self.map.max_pixel.x + self.width*1.5)
if self.vel.x < 0 and next_state.vel.x > 0 or self.vel.x > 0 and next_state.vel.x < 0 then
next_state.vel.x = 0
end
return next_state
end
Next, let's handle jumps with a method that assumes the player is allowed to jump, i.e. is grounded.
function Entity:jump(dt)
local next_state = self:copy()
next_state.grounded = false
next_state.vel.y = -math.sqrt(2*self.max_jump_height*self.gravity)
next_state.pos.y = helpers.bound(next_state.pos.y + next_state.vel.y*dt, 0, self.map.max_pixel.y - self.height/2)
return next_state
end
When the entity isn't grounded and isn't jumping, it's falling:
function Entity:fall(dt)
local next_state = self:copy()
next_state.grounded = false
next_state.vel.y = self.vel.y + self.gravity*dt
next_state.pos.y = helpers.bound(next_state.pos.y + next_state.vel.y*dt, 0, self.map.max_pixel.y - self.height/2)
return next_state
end
Lastly, we also need a way to ground the entity, setting the grounded
flag to true
, and its vertical velocity to 0.
function Entity:ground()
local next_state = self:copy()
next_state.grounded = true
next_state.vel.y = 0
return next_state
end
We'll need to check for collisions against solid tiles and adjust the entities position:
function Entity:adjust_for_collisions(old_state, dt, frame)
local next_state = self:copy()
local top_left_tile = self.map:get_tile_from_pixel(next_state.pos)
local bottom_left_tile = self.map:get_tile_from_pixel(next_state.pos + Vector:new(0, self.map.tileheight))
local bottom_right_tile = self.map:get_tile_from_pixel(next_state.pos + Vector:new(self.map.tilewidth, self.map.tileheight))
local top_right_tile = self.map:get_tile_from_pixel(next_state.pos + Vector:new(self.map.tilewidth, 0))
local next_rect = next_state:get_rect()
if old_state.grounded and self.vel.y >= 0 then
if
self.vel.x < 0 and not bottom_left_tile.solid
or self.vel.x > 0 and not bottom_right_tile.solid
then
next_state = next_state:fall(dt)
end
end
if not self.grounded then
if self.vel.y < 0 and (
top_left_tile.solid and helpers.rectangles_overlap(next_rect, top_left_tile.pixel_rect)[2]
or
top_right_tile.solid and helpers.rectangles_overlap(next_rect, top_right_tile.pixel_rect)[2]
) then
next_state.pos.y = top_right_tile.y + top_right_tile.h - self.height/2
next_state.vel.y = 0
print(frame, "CEILING BUMP!")
elseif self.vel.y >= 0 and (
bottom_left_tile.solid and helpers.rectangles_overlap(next_rect, bottom_left_tile.pixel_rect)[3]
or
bottom_right_tile.solid and helpers.rectangles_overlap(next_rect, bottom_right_tile.pixel_rect)[3]
) then
next_state = next_state:ground()
print(frame, "GROUNDED BECAUSE OF LANDING")
next_state.pos.y = bottom_right_tile.pixel_rect.y - self.height/2
end
end
if old_state.vel.x < 0 then
if top_left_tile.solid and helpers.rectangles_overlap(next_rect, top_left_tile.pixel_rect)[2] then
print(frame, "DETECTED COLLISION WHEN MOVING LEFT TOWARDS", top_left_tile.pixel_rect.x + top_left_tile.pixel_rect.w, "NEXT X:", next_state.pos.x, "CURRENT VEL.X", old_state.vel.x)
next_state.vel.x = 0
next_state.pos.x = math.ceil(top_left_tile.pixel_rect.x + top_left_tile.w + next_state.width/2) -1
print(frame, "ADJUSTED X", next_state.pos.x)
end
elseif old_state.vel.x > 0 then
if top_right_tile.solid and helpers.rectangles_overlap(next_rect, top_right_tile.pixel_rect)[2] then
next_state.vel.x = 0
next_state.pos.x = top_right_tile.pixel_rect.x - self.width*1.5
end
end
if next_state.grounded and next_state.vel.y ~= 0 then
print(frame, "GROUNDED WITH VERTICAL VELOCITY", next_state.vel.y, "THIS SHOULD NOT HAPPEN")
next_state.vel.y = 0
end
return next_state
end
function Entity:move(movement, dt, frame)
local wasright = self.vel.x > 0
local wasleft = self.vel.x < 0
local justjumped = false
local accel = Vector:new(0, self.gravity)
local next_state = self:move_horizontally(movement, dt)
if self.grounded then
if movement.jump then
next_state = next_state:jump(dt)
end
else
next_state = next_state:fall(dt)
end
if not (next_state.pos == self.pos) then
if distance(self.pos, next_state.pos) > 8 then
print(frame, "ENTITY MOVING TOO FAST!", inspect(self.pos), inspect(next_state.pos))
-- swept AABB
else
next_state = next_state:adjust_for_collisions(self, dt, frame)
end
end
return next_state
end
return Entity
local Entity = require("entity")
Initialization
Next, we define love.load()
, which should take care of:
- loading graphics, setting the scale
- setting up animations
- loading the level and mapping it into data in the
game
object. -
initialize the player and other objects
- this will be moved to a level initialization function later on
I want a retro look, so pixels should look sharp, I could use a library like maid64, but I'll just keep things simple by:
- setting the nearest neighbor interpolation as the default scaling filter
- calculate the scaling factors for width and height, which will be used when drawing stuff
local screen_width = 320 --self.map.width*self.map.tilewidth
local screen_height = 200 -- self.map.height*self.map.tileheight
love.graphics.setDefaultFilter("nearest", "nearest")
local desktop_width, desktop_height = love.window.getDesktopDimensions()
scale_width = desktop_width/screen_width
scale_height = desktop_height/screen_height
Next, all we need to do is create the first game state players will see, which is the title screen. All the logic for switching to other states is self-contained:
current_state = states.Title:new()
Putting it all together in LÖVE's load
callback:
local scale_width, scale_height, current_state
function love.load()
<<initGraphics>>
<<initGameState>>
end
Game logic
Flicky's gameplay seems basic at first, but there's quite a bit of stuff going on:
- controls
- move left and right, jump
- objective
-
collect "Chirps" and take them to the exit
- Chirps will follow Flicky around until then
- enemies
-
cats move around the level (there's other types of enemies in later levels)
- if they touch Flicky, a life is lost
- if they touch a Chirp, it stops following Flicky
- enemies respawn from fixed points
- enemies can be taken down if Flicky kicks throwable items like flower pots at them
- ?
- levels wrap around, giving the illusion of a bigger, infinite level
This means the game logic should keep track of a few things:
-
Flicky's state
- position
- probably other physics stuff too, like velocity, etc.
- lives
- whether Flicky is carrying an item or not (maybe just an item attribute that can be
nil
)
- Camera's position, based on Flick's position
-
Chirps' position, and whether they're following Flicky
- I'm thinking Chirps could be small state machines
-
Level-specific stuff
- Initial enemies position
- Enemy spawner positions
- How many enemies are active
- Maximum allowed enemies
- Platforms
-
Game state stuff
- Collected chirps count
- Timer
At the most basic (i.e. the whole game being a single level) that results in a few main functions love.update(dt)
will call:
Updating each frame
At the highest level, updating each frame is pretty simple:
- update the current game state
- check if the current state has ended
- if it has, switch to its next state
function love.update(dt)
current_state:update(dt)
if current_state.ended then
current_state = current_state.next
end
end
Drawing to the screen
First up, let's reset the color every time we draw so it doesn't tint everything with the color that was used last:
love.graphics.setColor(1, 1, 1)
Go through each tile in the map and query its value to draw solid tiles (value > 0) in color (this will later be replaced by drawing the actual tile graphics form the tilesheet.) We'll also do scaling using the calculated scale factors from the game object: game:draw_debug_infos()
#+end_src
function love.draw()
<<resetColor>>
love.graphics.push()
love.graphics.scale(scale_width, scale_height)
current_state:draw()
love.graphics.pop()
end
Ideas
I'm thinking once I have the core of the game working I can try introducing some throwing mechanics like Yoshi's Story. Either throw the chicks/eggs themselves, or have some special pickup that can be thrown at enemies.
Devlog
[2024-08-17 sáb]
Not a huge amount of work this week, but feeling good about it, noteworthy stuff so far:
- broke up entity movement and collision detection into separate methods
- jumping feels nice, and got the code layed out so I can set whatever max height I want
- collision with tiles still not perfect, but we're getting there
Next week I want to work on:
- game states (at least get a menu/title screen)
- add other entities to the game with stupid AI
- stretch: some pixel art
[2024-09-02 lun]
I was able to work on game states but got detoured with making a Map
class that would allow
iterating over instances of it returning each tile that makes up the map, and got into a Lua
iterators rabbit hole which ended up finding out __ipairs
is no longer supported (for quite a
while), so I had to just make a plain iterator method (that led me to think a lot about my
choosing Lua for this.) In any case, I'm glad I was able to stub out the title screen along with the
game states architecture.
The game is currently not working, but this is what I did this week:
GameState
class- Move gameplay code into
Play
GameState
subclass - Implement
Title
GameState
subclass, working with input to switch toPlay
state - Clean up a bunch of old code
Map
class to encapsulate tile logic, with iterator yieldingTile
instances- Got working title idea: "Michi Dragon"
- Started stubbing out title screen art
This week I'd like to:
- get game working again (fix map/tiles code)
- work on simple title screen art
- update literary parts for all the code I added last week
Plan
TODO
Install dependencies (inspect, debugger) through luarocks
DONE Create object base "class" with copy metamethods functionality, explain literarily
DONE
Refactor Object:new
, Object:init
, introduce Object:extend
TODO
Convert acceleration to vector in Entity:move
DONE
Fix gravity always changing position in y axis on Entity:move
bug
SCHEDULED: <2024-08-08 jue 14:00-15:00> DEADLINE: <2024-08-09 Fri>
CLOCK: [2024-08-07 mié 11:42]–[2024-08-07 mié 12:11] => 0:29
DONE
Add grounded
attribute to player/entity
DONE
love.load()
DONE [A] Bugfix drawing offset by 1 in x and y
DONE
love.update(dt)
DONE
love.draw()
Explain game.map:box2d_draw
call for debugging purposes.
DONE Level wrap-around
DONE Bugfix level wrap-around
Can't move past right side of the level, but going to the left works (?)
TODO Update "literary" parts
DEADLINE: <2024-09-07 sáb>
TODO Player slide
Tweak friction values
TODO changing direction while in mid air should have lots of friction
DONE Refactor player, enemies, items code into entities
TODO Add a couple enemy entities with stupid AI
DEADLINE: <2024-09-08 Sun>
maybe they just move left or right constantly, jump if they "see" a gap or blocking tile ahead.
TODO Title screen art mock
DEADLINE: <2024-09-07 sáb>
CLOCK: [2024-08-20 mar 11:39]–[2024-08-20 mar 12:06] => 0:27
fonts I like:
- Essays
- dhurjati (maybe for a shmup)
- lmmono10-italic
- lmromanusl10-regular
- lmu10
qzcmitexgyrechorus-med
TODO Gameplay screen art mock
DEADLINE: <2024-10-02 mié>
DONE
Fix Tile class ipairs
SCHEDULED: <2024-08-29 jue 10:30-11:30> DEADLINE: <2024-08-30 Fri>
CLOCK: [2024-08-29 jue 10:42]–[2024-08-29 jue 11:14] => 0:32
Turns out Lua no longer supports the __ipairs
metamethod (hasn't for years), bummer.
DONE Collision with platforms above when jumping
TODO Camera fixed on player position
Bonus points for a small offset left and right before moving camera position.
DONE Implement game states
DEADLINE: <2024-08-30 Fri>
CLOCK: [2024-08-21 mié 07:26]–[2024-08-21 mié 07:57] => 0:31
- Title/Menu
- Gameplay
- Game Over
- Finished game
TODO
Explain game
object components
TODO Refine player movement
TODO Bugfix scaling and/or drawing
DONE Maybe create a game class, player class, etc.
TODO Explain 320x200 resolution
TODO Fix aspect ratio
Using 16:10, should add "black bars" on the sides (or on top and bottom)
But how?
DONE Bugfix jump code
Jumping is somewhat inconsistent, if you hold jump, final jump height varies quite a bit.
Got this one fixed by setting a fixed Y velocity when jump button is hit, rather than calculating a new acceleration with dt
.
(Should explain this in the literate part)
TODO Set up some kind of debug mode (toggle?)
TODO Define enemy spawner logic (a few different options)
DONE Map player position correctly when drawing
TODO Create lower-resolution internal game model
DONE
Consider dropping game.world
and not using STI's box2d support
I'm following Code inComplete's Tiny Platformer article, coding the physics from scratch somewhat. Maybe start off like that and later try to refactor into LOVE's physics?
Draw debug helpers
- draw player's "tile"
DONE
Draw platforms from game.platforms
table
TODO slide animation when direction button is released
TODO
Generic Object:copy
method
TODO
Decouple GameState
objects, map (maybe), and graphics
TODO Fix collision with solid tile when moving horizontally
DEADLINE: <2024-09-12 Thu>
CLOCK: [2024-08-13 mar 12:01]–[2024-08-13 mar 12:13] => 0:12
TODO
Implement game pause as Play
substate
DEADLINE: <2024-09-16 Mon>
TODO replace hardcoded player initial position (180, 100) with marker/point from Tiled map
TODO
Reimplement draw_debug_infos
DEADLINE: <2024-09-12 Thu>
Need something more global, maybe that takes a game state instance or something.
DONE Fix jump
SCHEDULED: <2024-08-13 mar 11:15-12:00>
TODO Try implementing wrap-around with polar coordinates and cosine
DEADLINE: <2024-10-02 mié>
-- Constants
local radius = 100 -- Radius of the "circle" that represents the screen width
local max_velocity = 4 * math.pi -- Max angular velocity (2 full circle per second)
local acceleration = 0.2 -- Angular acceleration
-- Initial state
local theta = 0 -- Initial angle (in radians)
local angular_velocity = 0 -- Initial angular velocity
local delta_time = 1/30 -- Time step (e.g., 1/30th of a second)
-- Function to update the entity's position
function update_position()
-- Apply angular acceleration
angular_velocity = angular_velocity + acceleration * delta_time
-- Cap the angular velocity at max_velocity
if angular_velocity > max_velocity then
angular_velocity = max_velocity
end
-- Update the angle (theta)
theta = theta + angular_velocity * delta_time
-- Ensure theta stays within [0, 2 * pi] to simulate wrap-around
if theta >= 2 * math.pi then
theta = theta - 2 * math.pi
elseif theta < 0 then
theta = theta + 2 * math.pi
end
-- Calculate the horizontal position using the cosine of the angle
local x_position = radius * math.cos(theta)
return x_position
end
-- Example usage
for i = 1, 60 do
local x = update_position()
print(string.format("Time: %.2f, X Position: %.2f", i * delta_time, x))
end
Old jam code
I initially tried making this game for Week Sauce jam but abandoned it, below is where I ended up at, keeping it here as reference (quite likely to remove it):
local anim8 = require("anim8")
local sti = require("sti")
local inspect = require("inspect")
local dbg = require("debugger")
love.graphics.setDefaultFilter("nearest", "nearest")
local game = {
screen_width=320,
screen_height=200,
player={speed=5120, jumping=false},
timeToSpawn=8,
entities={
chicks={},
spawners={},
enemies={},
},
spawnerIndex=1,
maxEnemies=1,
}
-- TODO: Track platform enemies are on, so they can jump only when grounded, and detect when a platform's edge is coming
function beginContact(a, b, contact)
for i, chick in ipairs(game.entities.chicks) do
if not chick.rescued and (a == chick.fixture or b == chick.fixture) then
if a == game.player.fixture or b == game.player.fixture then
chick.rescued = true
print("Player rescued chick!")
return true
end
end
end
for i, enemy in ipairs(game.entities.enemies) do
if a == enemy.fixture or b == enemy.fixture then
if a == game.player.fixture or b == game.player.fixture then
-- player dies!
end
-- NOTE: first body is platforms
platforms = game.world:getBodies()[1]
for i, platFixture in ipairs(platforms:getFixtures()) do
if a == platFixture or b == platFixture then
enemy.platform = platFixture
dbg()
print("ENEMY IS ON PLATFORM "..inspect(platFixture))
end
end
end
end
if a == game.player.fixture or b == game.player.fixture then
game.player.jumping = false
end
end
function endContact(a, b, contact)
end
function love.load()
local desktop_width, desktop_height = love.window.getDesktopDimensions()
game.scaleW = desktop_width / game.screen_width
game.scaleH = desktop_height / game.screen_height
love.physics.setMeter(8)
game.world = love.physics.newWorld(0, 9.81*128)
game.world:setCallbacks(beginContact, endContact)
game.map = sti("map.lua", {"box2d"})
game.map:box2d_init(game.world)
game.sprites = {
player=love.graphics.newImage("assets/chicken.png"),
chick=love.graphics.newImage("assets/chick.png"),
}
local g = anim8.newGrid(32, 32, game.sprites.player:getWidth(), game.sprites.player:getHeight())
game.player.moveRightAnim = anim8.newAnimation(g('1-3', 2), 0.1)
game.player.moveLeftAnim = anim8.newAnimation(g('1-3', 4), 0.1)
game.player.currentAnim = game.player.moveRightAnim
-- TODO: move to player code
game.player.body = love.physics.newBody(game.world, 160, 64, "dynamic")
game.player.body:setFixedRotation(true)
game.player.shape = love.physics.newRectangleShape(8, 12)
game.player.fixture = love.physics.newFixture(game.player.body, game.player.shape)
game.player.fixture:setUserData("PLAYER")
game.player.anchor = game.player.body
game.map.layers.entities.visible = false
g = anim8.newGrid(24, 24, game.sprites.chick:getWidth(), game.sprites.chick:getHeight())
for i, mapEntity in ipairs(game.map.layers.entities.objects) do
if mapEntity.type == "chick" then
chick = {rescued=false}
chick.idleAnim = anim8.newAnimation(g('1-3', 2), 0.2)
chick.moveRightAnim = anim8.newAnimation(g('4-7', 3), 0.1)
chick.moveLeftAnim = anim8.newAnimation(g('4-7', 1), 0.1)
chick.currentAnim = chick.idleAnim
chick.body = love.physics.newBody(
game.world, mapEntity.x, mapEntity.y, "static"
)
chick.shape = love.physics.newRectangleShape(
mapEntity.width, mapEntity.height
)
chick.fixture = love.physics.newFixture(chick.body, chick.shape)
chick.fixture:setUserData("CHICK"..i)
chick.fixture:setSensor(true)
table.insert(game.entities.chicks, chick)
end
if mapEntity.type == "spawner" then
table.insert(game.entities.spawners, mapEntity)
end
end
end
function normalize(n, min, max)
local range = max - min
n = ((n - min) % range + range) % range + min
return n
end
function spawnEnemy()
spawner = game.entities.spawners[game.spawnerIndex]
game.spawnerIndex = game.spawnerIndex + 1
if game.spawnerIndex > #game.entities.spawners then
game.spawnerIndex = 1
end
enemy = {
jumpTimer=2,
body=love.physics.newBody(game.world, spawner.x, spawner.y - 8, "dynamic"),
platform=nil,
}
enemy.shape = love.physics.newRectangleShape(8, 8)
enemy.fixture = love.physics.newFixture(enemy.body, enemy.shape)
-- enemy.fixture:setSensor(true)
table.insert(game.entities.enemies, enemy)
end
function love.update(dt)
local velX, velY = game.player.body:getLinearVelocity()
local playerX, playerY = game.player.body:getPosition()
if #game.entities.enemies < game.maxEnemies then
if game.timeToSpawn <= 0 then
spawnEnemy()
game.timeToSpawn = 8
else
game.timeToSpawn = game.timeToSpawn - dt
end
end
for i, enemy in ipairs(game.entities.enemies) do
enemy.body:setLinearVelocity(64, 0)
-- if enemy.jumpTimer <= 0 then
-- enemy.body:applyLinearImpulse(0, -1024)
-- enemy.jumpTimer = 2
-- else
-- enemy.jumpTimer = enemy.jumpTimer - dt
-- end
local enemyX, enemyY = enemy.body:getPosition()
-- NOTE: wrap around level
enemy.body:setPosition(normalize(enemyX, 0, game.screen_width), enemyY)
end
for i, chick in ipairs(game.entities.chicks) do
if chick.rescued then
if chick.body:getType() ~= "dynamic" then
chick.body:setType("dynamic")
chick.anchor = game.player.anchor
game.player.anchor = chick.body
end
chick.body:setLinearVelocity(0, 0)
-- NOTE: wrap around level
-- local x, y = chick.anchor:getPosition()
local x = chick.body:getX()
local y = chick.body:getY()
-- local dx = normalize(playerX - x, -playerX, game.screen_width - playerX)
local dx = playerX - x
local dy = playerY - y*1.04
local distance = math.sqrt(dx^2 + dy^2)
if math.abs(dx) > 12 then
if dx > 0 then
chick.currentAnim = chick.moveRightAnim
elseif dx < 0 then
chick.currentAnim = chick.moveLeftAnim
end
chick.body:setX(normalize(x + (dx/distance)*128*dt, 0, game.screen_width))
end
if math.abs(dy) > 1 then
chick.body:setY(y + (dy/distance)*96*dt)
end
chick.currentAnim:update(dt)
end
end
if love.keyboard.isDown("d", "right") then
velX = game.player.speed*dt
game.player.currentAnim = game.player.moveRightAnim
end
if love.keyboard.isDown("a", "left") then
velX = -game.player.speed*dt
game.player.currentAnim = game.player.moveLeftAnim
end
game.player.body:setLinearVelocity(velX, velY)
if velX ~= 0 then
game.player.currentAnim:update(dt)
end
if love.keyboard.isDown("space") then
if not game.player.jumping then
game.player.body:applyLinearImpulse(0, -512)
game.player.jumping = true
end
end
game.world:update(dt)
playerX, playerY = game.player.body:getPosition()
-- NOTE: wrap around level
game.player.body:setPosition(normalize(playerX, 0, game.screen_width), playerY)
end
function love.draw()
love.graphics.setColor(1, 1, 1)
local cameraX = math.floor(game.screen_width/2 - game.player.body:getX())
game.map:draw(cameraX, 0, game.scaleW, game.scaleH)
game.map:box2d_draw(cameraX, 0, game.scaleW, game.scaleH)
game.map:draw(cameraX - game.screen_width, 0, game.scaleW, game.scaleH)
game.map:box2d_draw(cameraX - game.screen_width, 0, game.scaleW, game.scaleH)
game.map:draw(cameraX + game.screen_width, 0, game.scaleW, game.scaleH)
game.map:box2d_draw(cameraX + game.screen_width, 0, game.scaleW, game.scaleH)
love.graphics.push()
love.graphics.scale(game.scaleW, game.scaleH)
for i, spawner in ipairs(game.entities.spawners) do
love.graphics.setColor(0.1, 0.9, 0.2)
love.graphics.rectangle("fill", cameraX + spawner.x , spawner.y-8, 8, 8)
love.graphics.rectangle("fill", cameraX - game.screen_width + spawner.x, spawner.y-8, 8, 8)
love.graphics.rectangle("fill", cameraX + game.screen_width + spawner.x, spawner.y-8, 8, 8)
end
for i, chick in ipairs(game.entities.chicks) do
local x, y = chick.body:getPosition()
love.graphics.setColor(1, 1, 1)
chick.currentAnim:draw(game.sprites.chick, x + cameraX - 12, y - 18)
chick.currentAnim:draw(game.sprites.chick, cameraX - game.screen_width + x - 12, y - 18)
chick.currentAnim:draw(game.sprites.chick, cameraX + game.screen_width + x - 12, y - 18)
love.graphics.setColor(0.8, 0.9, 0.1)
love.graphics.rectangle("line", x + cameraX, y, 8, 8)
love.graphics.rectangle("line", cameraX - game.screen_width + x, y, 8, 8)
love.graphics.rectangle("line", cameraX + game.screen_width + x, y, 8, 8)
end
for i, enemy in ipairs(game.entities.enemies) do
local x, y = enemy.body:getPosition()
love.graphics.setColor(0.9, 0.2, 0.01)
love.graphics.rectangle("fill", x + cameraX, y, 8, 8)
love.graphics.rectangle("fill", cameraX - game.screen_width + x, y, 8, 8)
love.graphics.rectangle("fill", cameraX + game.screen_width + x, y, 8, 8)
end
love.graphics.translate(cameraX, 0)
love.graphics.setColor(1, 1, 1)
game.player.currentAnim:draw(game.sprites.player, game.player.body:getX() - 16, game.player.body:getY() - 24)
love.graphics.setColor(0.8, 0.7, 0)
love.graphics.polygon("line", game.player.body:getWorldPoints(game.player.shape:getPoints()))
love.graphics.pop()
end