commit 58fe857de1d8f2c3a62ad825e615bc62d4f09bb5 Author: Gonzalo Delgado Date: Sat Aug 17 09:13:37 2024 -0300 First commit, lots of work since a while back. diff --git a/conf.lua b/conf.lua new file mode 100644 index 0000000..04f45e5 --- /dev/null +++ b/conf.lua @@ -0,0 +1,5 @@ +function love.conf(t) + t.version = "11.4" + t.window.fullscreen = true + t.title = "Literate Flicky Clone" +end diff --git a/flickyclone.org b/flickyclone.org new file mode 100644 index 0000000..4eb2e80 --- /dev/null +++ b/flickyclone.org @@ -0,0 +1,1575 @@ +:PROPERTIES: +:header-args: :tangle main.lua :comments no +:END: +#+TITLE: Literate Flicky Clone +#+AUTHOR: Gonzalo Delgado + +* 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 [[https://love2d.org/wiki/Config_Files][~conf.lua~ file]], specifying the LÖVE version, the game title, and that it should run in full screen by default: +#+begin_src lua :tangle conf.lua + function love.conf(t) + t.version = "11.4" + t.window.fullscreen = true + t.title = "Literate Flicky Clone" + end +#+end_src + + +* 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.) + +#+begin_src lua + local inspect = require("inspect/inspect") +#+end_src + +* 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: +#+begin_src lua :tangle object.lua + local inspect = require("inspect/inspect") + local Object = {} + Object.__index = Object + + function Object:new(...) + local object = setmetatable({__index=self}, self) + object:init(...) + object.__class = self + return object + end +#+end_src +~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. +#+begin_src lua :tangle object.lua + function Object:init(...) + self.__args = {...} + end +#+end_src + +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: +#+begin_src lua :tangle object.lua + function Object:extend() + local SubClass = {} + 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 +#+end_src +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: +- [[https://github.com/eevee/klinklang/blob/master/object.lua][Eevee's klingklang engine]] +- [[https://github.com/egordorichev/CurseOfTheArrow/blob/5b5c65ee2b6353324cb262422a9d2d7506911f5f/src/engine.lua#L49][Curse of the Arrow]] +- [[https://hump.readthedocs.io/en/latest/class.html][Hump]] +- [[https://github.com/rxi/classic][Classic]] + + +Finally, return this for module imports: +#+begin_src lua :tangle object.lua + return Object +#+end_src + +* (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~: +#+begin_src lua :tangle vector.lua + local inspect = require("inspect/inspect") + local Object = require("object") + + local Vector = Object:extend() +#+end_src + +~Vector~'s initializer will take an ~x~ and ~y~ value and store them as attributes, and also compute +its angle from them. +#+begin_src lua :tangle vector.lua + 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 +#+end_src + +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. + +#+begin_src lua :tangle vector.lua + 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 + +#+end_src + +#+begin_src lua :tangle vector.lua + 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 +#+end_src +#+begin_src lua + local Vector = require("vector") +#+end_src + +* Helper functions + +Let's create a module to contain helper functions: +#+begin_src lua :tangle helpers.lua + local helpers = {} +#+end_src + +For whatever reason Lua doesn't have a sign function 🤷, let's define our own: +#+begin_src lua :tangle helpers.lua + function helpers.sign(n) + if n == 0 then + return 0 + end + return math.abs(n)/n + end +#+end_src + +Same with rounding numbers, need our own function for that: +#+begin_src lua :tangle helpers.lua + function helpers.round(num) + return num >= 0 and math.floor(num + 0.5) or math.ceil(num - 0.5) + end +#+end_src + +Let's define a ~bound~ function to keep a value within bounds +#+begin_src lua :tangle helpers.lua + 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 +#+end_src + +I think a functional ~map~ function can be helpful when loading the map and similar stuff +#+begin_src lua :tangle helpers.lua + function helpers.map(tbl, fun) + local result = {} + for i, v in ipairs(tbl) do + result[i] = fun(v) + end + return result + end +#+end_src +But ~map~ would feel too lonely being the only functional function around, let's also add ~filter~: +#+begin_src lua :tangle helpers.lua + 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 +#+end_src + +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: +#+begin_src lua :tangle helpers.lua + function helpers.normalize(n, min, max) + local range = max - min + return ((n - min) % range + range) % range + min + end +#+end_src + +Finally return the helpers object containing all those functions, and load it from main: +#+begin_src lua :tangle helpers.lua + return helpers +#+end_src + +#+begin_src lua + local helpers = require("helpers") +#+end_src + +* 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 [[https://gamedev.net/articles/programming/general-and-gameplay-programming/swept-aabb-collision-detection-and-response-r3084/][one from gamedev.net]], let's try and translate to +Lua: +#+begin_src 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 +#+end_src + +* 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: +#+begin_src lua + 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 +#+end_src + +Next, we'll need a function that returns a list of rectangles representing tiles found in the displacement rectangle: +#+begin_src lua + 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 +#+end_src + +* Game object + +We'll have a game object that will hold all the state of the game and provide some functions to modify it. Let's start with an empty object. + +#+begin_src lua + local game = { + current_frame = 0, + input = { + right=false, + left=false, + up=false, + down=false, + jump=false, + } + } +#+end_src + +* Input + +The game object will keep the state of the input. I'm adding this right now mainly for debugging purposes, but it'll be good to keep that in its own place. +#+begin_src lua + function game:updateInput(dt) + 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 +#+end_src + +* Map & Base game data + +Let's add a function to the game object to load a Tiled map from a .lua file. +In the tiled map, the platforms are non-empty tiles. Since the layer format I chose is CSV, I get a 1-dimensional array (a table in Lua) with numbers representing each tile on the map, with 0 being an empty tile, and any other number representing the graphic for that tile from the tilesheet. + +Also, the map data contains tile sizes and the map size from which we define the (virtual) screen size, as well as some physics values. + +#+begin_src lua + function game:loadWorldMap(filename) + self.map = dofile(filename) + local platform_layers = helpers.filter(self.map.layers, function (layer) return layer.name == "platforms" end) + self.platforms = platform_layers[1].data + 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 + end +#+end_src + +The map has the tile sizes, so it makes sense that these utility functions are defined as methods of the game object (who will also be their main user): +#+begin_src lua + function game:pixel_to_tile(pixel) + return { + x=math.floor(pixel.x/self.map.tilewidth) + 1, + y=math.floor(pixel.y/self.map.tileheight) + 1, + } + end + + function game:x_to_pixel(tile_x) + return (tile_x - 1)*self.map.tilewidth + end + + function game:y_to_pixel(tile_y) + return (tile_y - 1)*self.map.tileheight + end + + function game:tile_to_pixel(tile) + local pixel = { + x=self:x_to_pixel(tile.x), + y=self:y_to_pixel(tile.y), + } + return pixel + end + + function game:tile_to_rect(tile) + local pixel = self:tile_to_pixel(tile) + return { + x=pixel.x, + y=pixel.y, + w=self.map.tilewidth, + h=self.map.tileheight, + } + end +#+end_src + +We'll also 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 +#+begin_src lua + function 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 +#+end_src + +~tile_value~ (defined below) deserves 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". + +Since the array is one-dimensional, we need to map a tile with coordinates to this array to be able to obtain its value, hence: +#+begin_src lua + function game:tile_value(tile) + return self.platforms[tile.x + (tile.y - 1)*self.map.width] + end +#+end_src + +* Graphics + +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 + + +Our game object will provide a method for taking care of that: +#+begin_src lua + function game:initializeGraphics() + local screen_width = self.map.width*self.map.tilewidth + local screen_height = self.map.height*self.map.tileheight + love.graphics.setDefaultFilter("nearest", "nearest") + local desktop_width, desktop_height = love.window.getDesktopDimensions() + self.scale_width = desktop_width/screen_width + self.scale_height = desktop_height/screen_height + self.screen_width = screen_width + self.screen_height = screen_height + self.debug_font = love.graphics.newFont(8, "normal", 2) + end +#+end_src + +The game's ~scale_width~ and ~scale_height~ attributes will be used whenever anything needs to be drawn. + +* 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. +#+begin_src lua + 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 +#+end_src + +* Tile class + +#+begin_src lua :tangle tile.lua + local inspect = require("inspect/inspect") + local Vector = require("vector") + local helpers = require("helpers") + + local map = dofile("map.lua") + local tile_layers = helpers.filter(map.layers, function (layer) return layer.name == "platforms" end) + local tile_data = tile_layers[1].data + + local Tile = Vector:extend() + Tile.max_pixel = {x=map.width*map.tilewidth, y=map.height*map.tileheight} + + function Tile:init(x, y) + Tile.__super.init(self, x, y) + local value = tile_data[x + (y - 1)*map.width] + self.w = map.tilewidth + self.h = map.tileheight + self.pixel_rect = { + x=(x - 1)*map.tilewidth, + y=(y - 1)*map.tileheight, + w=map.tileheight, + h=map.tilewidth, + } + self.value = value + self.solid = value ~= 0 + end + + function Tile:from_pixel(pixel) + return Tile:new(math.floor(pixel.x/map.tilewidth) + 1, math.floor(pixel.y/map.tileheight) + 1) + end + + function Tile:get_pixel_rect() + local rect = self.pixel_rect + rect.w = map.tilewidth + rect.h = map.tileheight + return rect + end + + return Tile +#+end_src + +#+begin_src lua + local Tile = require("tile") +#+end_src + +* 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~ and ~y~ for the top left corner, and ~w~ and ~h~ + 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. +#+begin_src lua :tangle entity.lua + 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) + 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 + 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 +#+end_src + +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: +#+begin_src lua :tangle entity.lua + 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) + new.vel.x = self.vel.x + new.vel.y = self.vel.y + new.grounded = self.grounded + return new + end +#+end_src + +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?) +#+begin_src lua :tangle entity.lua + -- 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, Tile.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 +#+end_src + +Next, let's handle jumps with a method that assumes the player is allowed to jump, i.e. is grounded. +#+begin_src lua :tangle entity.lua + 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, Tile.max_pixel.y - self.height/2) + return next_state + end +#+end_src +When the entity isn't grounded and isn't jumping, it's falling: +#+begin_src lua :tangle entity.lua + 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, Tile.max_pixel.y - self.height/2) + return next_state + end +#+end_src +Lastly, we also need a way to /ground/ the entity, setting the ~grounded~ flag to ~true~, and its vertical velocity to 0. +#+begin_src lua :tangle entity.lua + function Entity:ground() + local next_state = self:copy() + next_state.grounded = true + next_state.vel.y = 0 + return next_state + end +#+end_src + +We'll need to check for collisions against solid tiles and adjust the entities position: +#+begin_src lua :tangle entity.lua + function Entity:adjust_for_collisions(old_state, dt, frame) + local next_state = self:copy() + local top_left_tile = Tile:from_pixel(next_state.pos) + local bottom_left_tile = top_left_tile + Tile:new(0, 1) + local bottom_right_tile = top_left_tile + Tile:new(1, 1) + local top_right_tile = top_left_tile + Tile:new(1, 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 rectangles_overlap(next_rect, top_left_tile.pixel_rect)[2] + or + top_right_tile.solid and 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 rectangles_overlap(next_rect, bottom_left_tile.pixel_rect)[3] + or + bottom_right_tile.solid and 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 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 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 +#+end_src +#+begin_src lua :tangle entity.lua + + 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 +#+end_src +#+begin_src lua + local Entity = require("entity") +#+end_src + +* Player + +Let's use the entity class to set up a player object that tracks its position and velocity. +#+begin_src lua + function game:initPlayer(x, y) + self.player_entity = Entity:new(x, y, 4, 8, self.physics.gravity, 2*8, self.physics.horizontal_acc, self.physics.friction) + end +#+end_src + +* 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 + + +First, we'll load the map which also contains physics and screen data we'll need later on, using the method we defined earlier: +#+name: loadMap +#+begin_src lua :tangle no + game:loadWorldMap("map.lua") +#+end_src + +To initialize the graphics we simply call the method we defined for the game object: +#+name: initializeGraphics +#+begin_src lua :tangle no + game:initializeGraphics() +#+end_src + +Let's place the player somewhere in the level: +#+name: initPlayer +#+begin_src lua :tangle no + game:initPlayer(180, 100) +#+end_src + +Putting it all together in LÖVE's ~load~ callback: +#+begin_src lua :noweb yes + function love.load() + <> + <> + <> + end +#+end_src + +* 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: + +#+begin_src lua + function love.update(dt) + game.current_frame = game.current_frame + 1 + game:updateInput(dt) + game:updatePlayer(dt) + game:updateCamera(dt) + game:updateItems(dt) + game:updateEnemies(dt) + game:updateChirps(dt) + end +#+end_src + + +~updatePlayer~ should take care of a few basic things: +- moving the player left or right when the A/left arrow or S/right arrow keys are pressed +- applying momentum and friction to the player +- making the player jump when the jump button/key is hit +- applying gravity to the player +- checking collisions + + +First, let's check the current physics and input state: +#+name: playerStatusCheck +#+begin_src lua :tangle no + local wasleft = self.player_entity.vel.x < 0 + local wasright = self.player_entity.vel.x > 0 + local wasjumping = self.player_entity.vel.y < 0 + local wasfalling = self.player_entity.vel.y > 0 +#+end_src + +Based on those values, we'll want to calculate the next position of the player, but not update it +just yet: +#+name: calculateNextPlayerPos +#+begin_src lua :tangle no + local ddx = 0 + local ddy = self.physics.gravity + local next_p = self.player_entity:copy() + local player_rect = self.player_entity:get_rect() + if self.input.right then + ddx = self.physics.accel + elseif wasright then + ddx = -self.physics.friction + end + + -- if self.input.up then + -- next_dy = -48 + -- end + -- if self.input.down then + -- next_dy = 48 + -- end + + if self.input.left then + ddx = -self.physics.accel + elseif wasleft then + ddx = self.physics.friction + end + if self.player_entity.grounded then + if self.input.jump and not wasjumping then + next_p.vel.y = -self.physics.jump + self.player_entity.grounded = false + end + else + next_p.vel.y = helpers.bound(self.player_entity.vel.y + ddy*dt, -self.physics.maxdy, self.physics.maxdy) + end + next_p.vel.x = helpers.bound(self.player_entity.vel.x + ddx*dt, -self.physics.maxdx, self.physics.maxdx) + next_p.pos.x = helpers.normalize( + self.player_entity.pos.x + next_p.vel.x*dt, + -player_rect.w/2, + self:x_to_pixel(game.map.width) + player_rect.w*1.5 + ) + next_p.pos.y = helpers.bound( + self.player_entity.pos.y + next_p.vel.y*dt, + 0, + self:y_to_pixel(game.map.height) - self.player_entity.height/2 + ) +#+end_src + +Having the next position, we need to check it for collisions and adjust before updating the player's actual position. + +#+name: detectCollisionsAndRespond +#+begin_src lua :tangle no + + if next_p.pos.x ~= self.player_entity.pos.x or next_p.pos.y ~= self.player_entity.pos.y then + if distance(self.player_entity.pos, next_p.pos) > self.map.tilewidth then + -- TODO: sweep AABB?? + print("PLAYER MOVING TOO FAST!", inspect(self.player_entity.pos), inspect(next_p.pos)) + else + local top_left_tile = self:pixel_to_tile(next_p:get_rect()) + local bottom_left_tile = {x=top_left_tile.x, y=top_left_tile.y + 1} + local bottom_right_tile = {x=top_left_tile.x + 1, y=top_left_tile.y + 1} + local top_right_tile = {x=top_left_tile.x + 1, y=top_left_tile.y} + local next_rect = next_p:get_rect() + + if not wasjumping and self.player_entity.grounded then + if wasleft and self:tile_value(bottom_left_tile) == 0 or wasright and self:tile_value(bottom_right_tile) == 0 then + self.player_entity.grounded = false + end + end + + if wasjumping and ( + self:tile_value(top_left_tile) ~= 0 + and rectangles_overlap(next_rect, self:tile_to_rect(top_left_tile))[2] + or self:tile_value(top_right_tile) ~= 0 + and rectangles_overlap(next_rect, self:tile_to_rect(top_right_tile))[2] + ) then + next_p.pos.y = self:y_to_pixel(bottom_left_tile.y) + self.player_entity.height/2 + next_p.vel.y = 0 + elseif self:tile_value(bottom_left_tile) ~= 0 and rectangles_overlap(next_rect, self:tile_to_rect(bottom_left_tile))[2] + or self:tile_value(bottom_right_tile) ~= 0 and rectangles_overlap(next_rect, self:tile_to_rect(bottom_right_tile))[2] then + next_p.pos.y = self:y_to_pixel(bottom_right_tile.y) - self.player_entity.height/2 + next_p.vel.y = 0 + self.player_entity.grounded = true + end + + if wasleft and ( + self:tile_value(top_left_tile) ~= 0 and not wasjumping or self:tile_value(bottom_left_tile) ~= 0 and not self.player_entity.grounded + ) then + next_p.pos.x = self:x_to_pixel(top_right_tile.x) + self.player_entity.width/2 + next_p.vel.x = 0 + end + if wasright and ( + self:tile_value(top_right_tile) ~= 0 and rectangles_overlap(next_rect, self:tile_to_rect(top_right_tile))[1] and not wasjumping or self:tile_value(bottom_right_tile) ~= 0 and not self.player_entity.grounded and rectangles_overlap(next_rect, self:tile_to_rect(bottom_right_tile))[1] + ) then + next_p.pos.x = self:x_to_pixel(bottom_right_tile.x) - self.player_entity.width/2 + next_p.vel.x = 0 + end + end + end + + +#+end_src + +Finally, we can update the player's position as well as its velocity and acceleration +#+name: updatePlayerPhysics +#+begin_src lua :tangle no + self.player_entity.vel.x = next_p.vel.x + self.player_entity.vel.y = next_p.vel.y + self.player_entity.pos.x = next_p.pos.x + self.player_entity.pos.y = next_p.pos.y +#+end_src +The following code helps stop the player at some point when being affected by friction, rather than jittering because the calculated velocity isn't exactly 0. +#+name: handleFriction +#+begin_src lua :tangle no + if wasleft and self.player_entity.vel.x > 0 or wasright and self.player_entity.vel.x < 0 then + self.player_entity.vel.x = 0 + end +#+end_src + +#+begin_src lua :noweb yes + function game:updatePlayer(dt) + -- local accel = {x=0, y=self.physics.gravity} + -- if self.input.right then + -- accel.x = self.physics.accel + -- elseif wasright then + -- accel.x = -self.physics.friction + -- end + -- if self.input.left then + -- accel.x = -self.physics.accel + -- elseif wasleft then + -- accel.x = self.physics.friction + -- end + -- if self.input.jump and self.player_entity.grounded then + -- accel.y = -self.physics.jump + -- end + -- self.player_entity = self.player_entity:update(accel, dt) + self.player_entity = self.player_entity:move(self.input, dt, self.current_frame) + end +#+end_src + +Let's stub out the rest functions for now +#+begin_src lua + function game:updateCamera(dt) + end + function game:updateItems(dt) + end + function game:updateEnemies(dt) + end + function game:updateChirps(dt) + end +#+end_src + +* 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: +#+name: resetColor +#+begin_src lua :tangle no + love.graphics.setColor(1, 1, 1) +#+end_src +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: +#+name: drawMap +#+begin_src lua :tangle no + love.graphics.setColor(0.2, 0.2, 0.2) + for ty = 1, game.map.height do + for tx = 1, game.map.width do + love.graphics.setColor(0.9, 0.8, 0.7) + local tile = {x=tx, y=ty} + local pixel = game:tile_to_pixel(tile) + local value = game:tile_value(tile) + if value > 0 then + love.graphics.setColor(0.2, 0.2, 0.2) + end + local tile_rect = { + x=pixel.x, + y=pixel.y, + w=game.map.tilewidth, + h=game.map.tileheight, + } + love.graphics.rectangle("fill", pixel.x, pixel.y, game.map.tilewidth, game.map.tileheight) + love.graphics.setColor(0.9, 0.1, 0.3) + love.graphics.rectangle("line", pixel.x, pixel.y, game.map.tilewidth, game.map.tileheight) + end + end + game:draw_debug_infos() +#+end_src + +Let's draw the player as a rectangle for now: +#+name: drawPlayer +#+begin_src lua :tangle no + love.graphics.setColor(0, 0.8, 0.1) + game.player_entity:draw() +#+end_src +#+begin_src lua :noweb yes + function love.draw() + <> + love.graphics.push() + love.graphics.scale(game.scale_width, game.scale_height) + <> + <> + love.graphics.pop() + end +#+end_src + +* 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 + +* 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 vie> +:LOGBOOK: +CLOCK: [2024-08-07 mié 11:42]--[2024-08-07 mié 12:11] => 0:29 +:END: + +** 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 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-08-24 sáb> + +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-08-24 sáb> + +** TODO Gameplay screen art mock +DEADLINE: <2024-08-24 sáb> + +** 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. + +** TODO Implement game states +DEADLINE: <2024-08-24 sáb> + +- [ ] 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 Fix collision with solid tile when moving horizontally +DEADLINE: <2024-08-31 sáb> +:LOGBOOK: +CLOCK: [2024-08-13 mar 12:01]--[2024-08-13 mar 12:13] => 0:12 +:END: + +** DONE Fix jump +SCHEDULED: <2024-08-13 mar 11:15-12:00> + +** TODO Try implementing wrap-around with polar coordinates and cosine +DEADLINE: <2024-08-31 sáb> +#+begin_src lua :tangle angularwrap.lua + -- 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 +#+end_src + +* 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): +#+begin_src lua :tangle no + 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 +#+end_src diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..dfb931d --- /dev/null +++ b/main.lua @@ -0,0 +1,300 @@ +local inspect = require("inspect/inspect") + +local Vector = require("vector") + +local helpers = require("helpers") + +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 + +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 + +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 + +local game = { + current_frame = 0, + input = { + right=false, + left=false, + up=false, + down=false, + jump=false, + } +} + +function game:updateInput(dt) + 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 game:loadWorldMap(filename) + self.map = dofile(filename) + local platform_layers = helpers.filter(self.map.layers, function (layer) return layer.name == "platforms" end) + self.platforms = platform_layers[1].data + 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 +end + +function game:pixel_to_tile(pixel) + return { + x=math.floor(pixel.x/self.map.tilewidth) + 1, + y=math.floor(pixel.y/self.map.tileheight) + 1, + } +end + +function game:x_to_pixel(tile_x) + return (tile_x - 1)*self.map.tilewidth +end + +function game:y_to_pixel(tile_y) + return (tile_y - 1)*self.map.tileheight +end + +function game:tile_to_pixel(tile) + local pixel = { + x=self:x_to_pixel(tile.x), + y=self:y_to_pixel(tile.y), + } + return pixel +end + +function game:tile_to_rect(tile) + local pixel = self:tile_to_pixel(tile) + return { + x=pixel.x, + y=pixel.y, + w=self.map.tilewidth, + h=self.map.tileheight, + } +end + +function 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 + +function game:tile_value(tile) + return self.platforms[tile.x + (tile.y - 1)*self.map.width] +end + +function game:initializeGraphics() + local screen_width = self.map.width*self.map.tilewidth + local screen_height = self.map.height*self.map.tileheight + love.graphics.setDefaultFilter("nearest", "nearest") + local desktop_width, desktop_height = love.window.getDesktopDimensions() + self.scale_width = desktop_width/screen_width + self.scale_height = desktop_height/screen_height + self.screen_width = screen_width + self.screen_height = screen_height + self.debug_font = love.graphics.newFont(8, "normal", 2) +end + +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 + +local Tile = require("tile") + +local Entity = require("entity") + +function game:initPlayer(x, y) + self.player_entity = Entity:new(x, y, 4, 8, self.physics.gravity, 2*8, self.physics.horizontal_acc, self.physics.friction) +end + +function love.load() + game:loadWorldMap("map.lua") + game:initializeGraphics() + game:initPlayer(180, 100) +end + +function love.update(dt) + game.current_frame = game.current_frame + 1 + game:updateInput(dt) + game:updatePlayer(dt) + game:updateCamera(dt) + game:updateItems(dt) + game:updateEnemies(dt) + game:updateChirps(dt) +end + +function game:updatePlayer(dt) + -- local accel = {x=0, y=self.physics.gravity} + -- if self.input.right then + -- accel.x = self.physics.accel + -- elseif wasright then + -- accel.x = -self.physics.friction + -- end + -- if self.input.left then + -- accel.x = -self.physics.accel + -- elseif wasleft then + -- accel.x = self.physics.friction + -- end + -- if self.input.jump and self.player_entity.grounded then + -- accel.y = -self.physics.jump + -- end + -- self.player_entity = self.player_entity:update(accel, dt) + self.player_entity = self.player_entity:move(self.input, dt, self.current_frame) +end + +function game:updateCamera(dt) +end +function game:updateItems(dt) +end +function game:updateEnemies(dt) +end +function game:updateChirps(dt) +end + +function love.draw() + love.graphics.setColor(1, 1, 1) + love.graphics.push() + love.graphics.scale(game.scale_width, game.scale_height) + love.graphics.setColor(0.2, 0.2, 0.2) + for ty = 1, game.map.height do + for tx = 1, game.map.width do + love.graphics.setColor(0.9, 0.8, 0.7) + local tile = {x=tx, y=ty} + local pixel = game:tile_to_pixel(tile) + local value = game:tile_value(tile) + if value > 0 then + love.graphics.setColor(0.2, 0.2, 0.2) + end + local tile_rect = { + x=pixel.x, + y=pixel.y, + w=game.map.tilewidth, + h=game.map.tileheight, + } + love.graphics.rectangle("fill", pixel.x, pixel.y, game.map.tilewidth, game.map.tileheight) + love.graphics.setColor(0.9, 0.1, 0.3) + love.graphics.rectangle("line", pixel.x, pixel.y, game.map.tilewidth, game.map.tileheight) + end + end + game:draw_debug_infos() + love.graphics.setColor(0, 0.8, 0.1) + game.player_entity:draw() + love.graphics.pop() +end