Stage
, Console
and SkillTree
.1920x1080
. For this game, I will choose 480x270
, because this is 1920x1080
divided by 4. To change the size of the game to this value, we need to use the conf.lua
file, which, as I explained in the previous part, is a configuration file that defines the parameters of the LÖVE project by default, including the resolution of the window in which the game is launched.gw
and gh
, corresponding to the width and height of the base resolution, and variables sx
and sy
, corresponding to the scale applied to this base resolution. The conf.lua
file should be in the same folder as the main.lua
file, and still look like this: gw = 480 gh = 270 sx = 1 sy = 1 function love.conf(t) t.identity = nil -- () t.version = "0.10.2" -- LÖVE, () t.console = false -- (boolean, Windows) t.window.title = "BYTEPATH" -- () t.window.icon = nil -- , () t.window.width = gw -- () t.window.height = gh -- () t.window.borderless = false -- (boolean) t.window.resizable = true -- (boolean) t.window.minwidth = 1 -- () t.window.minheight = 1 -- () t.window.fullscreen = false -- (boolean) t.window.fullscreentype = "exclusive" -- () t.window.vsync = true -- (boolean) t.window.fsaa = 0 -- () t.window.display = 1 -- , () t.window.highdpi = false -- dpi Retina (boolean) t.window.srgb = false -- - sRGB (boolean) t.window.x = nil -- x () t.window.y = nil -- y () t.modules.audio = true -- (boolean) t.modules.event = true -- (boolean) t.modules.graphics = true -- (boolean) t.modules.image = true -- (boolean) t.modules.joystick = true -- (boolean) t.modules.keyboard = true -- (boolean) t.modules.math = true -- (boolean) t.modules.mouse = true -- (boolean) t.modules.physics = true -- (boolean) t.modules.sound = true -- (boolean) t.modules.system = true -- (boolean) t.modules.timer = true -- (boolean), 0 delta time love.update 0 t.modules.window = true -- (boolean) t.modules.thread = true -- (boolean) end
gw/2, gh/2
) now, like this:love.window.setMode
, for example, with a width of 3*gw
and a height of 3*gh
, you get something like this:gw/2
and gh/2
no longer the center of the screen when it is tripled. We want to be able to draw a small circle at a base resolution of 480x270
, so that when the screen is enlarged to the size of a regular monitor, the circle also scales proportionally (and pixelated), and its position also proportionally remains the same. The simplest way to solve this problem is to use Canvas
, which is also called in other engines the frame buffer (framebuffer) or target render (render target). First, we will create a canvas with the base resolution in the constructor of the Stage
class: function Stage:new() self.area = Area(self) self.main_canvas = love.graphics.newCanvas(gw, gh) end
480x270
, on which you can draw: function Stage:draw() love.graphics.setCanvas(self.main_canvas) love.graphics.clear() love.graphics.circle('line', gw/2, gh/2, 50) self.area:draw() love.graphics.setCanvas() end
love.graphics.setCanvas
, which redirects all the drawing operations to the current given canvas. Then we call love.graphics.clear
, which clears the contents of this canvas in the current frame, because it was also drawn in the previous frame, and in each frame we want to draw everything from scratch. Then, drawing everything we need, we reuse setCanvas
, but this time without passing anything, so that our target canvas is no longer current and the redirection of drawing operations is no longer performed. function Stage:draw() love.graphics.setCanvas(self.main_canvas) love.graphics.clear() love.graphics.circle('line', gw/2, gh/2, 50) self.area:draw() love.graphics.setCanvas() love.graphics.setColor(255, 255, 255, 255) love.graphics.setBlendMode('alpha', 'premultiplied') love.graphics.draw(self.main_canvas, 0, 0, 0, sx, sy) love.graphics.setBlendMode('alpha') end
love.graphics.draw
to draw the canvas on the screen, and then also wrap it with calls to love.graphics.setBlendMode
, which according to the Canvas page of the LÖVE wiki is used to prevent blending. If you run the program now, you will see a drawn circle.sx
and sy
. While these variables have the value 1, but if you change their values, for example, by 3, the following will occur:480x270
canvas is now in the middle of the 1440x810
canvas. Since the screen itself has a size of 480x270
, we cannot see the entire Canvas, because it is larger than the screen. To fix this, we can create the resize
function in main.lua
, which, when it main.lua
sx
, sy
, changes the size of the screen itself: function resize(s) love.window.setMode(s*gw, s*gh) sx, sy = s, s end
resize(3)
in love.load
, the following should happen:'linear'
. Since we want the game to have a pixelated appearance, we must change the value to 'nearest'
. Calling love.graphics.setDefaultFilter
with the argument 'nearest'
at the beginning of love.load
should fix the problem. Another aspect is that we need to assign the value 'rough'
to LineStyle . Since it defaults to 'smooth'
, LÖVE primitives will be rendered with aliasing, and this is not suitable for creating a pixel style. If you do all this and run the code again, the screen should look like this:x, y
position should be equal to gw/2, gh/2
, and regardless of the final resolution, the object will always be in the center of the screen. This greatly simplifies the process: it means that we only need to worry once about how the game looks and how the objects are distributed on the screen.1920x1080
. The basic resolution of our game is perfectly scaled to him. But the second most popular resolution is 1366x768
. 480x270
does not scale to it. What options can you offer to work with non-standard permissions when switching the game to full screen?camera.lua
file in the hump library folder (and overwrite the existing version of camera.lua
), and then add the require of the camera module to main.lua
. Place the Shake.lua
file in the objects
folder. function random(min, max) local min, max = min or 0, max or 1 return (min > max and (love.math.random()*(min - max) + max)) or (love.math.random()*(max - min) + min) end
Shake.lua
file. After defining this function in utils.lua
try doing something like this: function love.load() ... camera = Camera() input:bind('f3', function() camera:shake(4, 60, 1) end) ... end function love.update(dt) ... camera:update(dt) ... end
Stage
class: function love.load() ... camera = Camera() input:bind('f3', function() camera:shake(4, 60, 1) end) ... end function love.update(dt) ... camera:update(dt) ... end
f3
screen will start shaking:dt
argument. And it will look like this: function Stage:update(dt) camera.smoother = Camera.smooth.damped(5) camera:lockPosition(dt, gw/2, gh/2) self.area:update(dt) end
damped
with a value of 5
. I derived these parameters through trial and error, but in general this allows the camera to focus on the target point in a smooth and pleasant way. I put this code inside the Stage room because we are now working with the Stage room, and in this room the camera should always be centered in the middle of the screen and never move (except for screen shaking moments). As a result, we get the following:objects
folder called Player.lua
, which will look like this: Player = GameObject:extend() function Player:new(area, x, y, opts) Player.super.new(self, area, x, y, opts) end function Player:update(dt) Player.super.update(self, dt) end function Player:draw() end
GameObject
and have the same structure of the constructor, the update and draw functions. Now we can create an instance of this Player object in the Stage room as follows: function Stage:new() ... self.area:addGameObject('Player', gw/2, gh/2) end
Area
drawn, we can simply draw a circle in its position: function Player:draw() love.graphics.circle('line', self.x, self.y, 25) end
addGameObject
returns the created object, so we can store a link to the player inside the self.player
Stage, and if necessary, include the death event of the Player object with an self.player
key: function Stage:new() ... self.player = self.area:addGameObject('Player', gw/2, gh/2) input:bind('f3', function() self.player.dead = true end) end
f3
key, the Player object should die, that is, the circle should stop drawing. This happens as a result of how we set up the Area
object code in the previous section. It is also important to note that if we decide to store the links returned by addGameObject
in this way, then if we do not specify a variable in which the reference to nil
is stored, this object will never be deleted. In addition, it is important not to forget to assign nil
values to the links (in our case, the string self.player = nil
), if you want the object to actually be deleted from memory (besides the fact that its attribute is assigned a dead
true value).windfield
and add its require to the file main.lua
. According to her documentation, there are two basic concepts in her - World
andCollider
. World is the physical world in which the simulation takes place, and the Collider is a physical object simulated inside this world. That is, our game will need a semblance of the physical world, and the player will be a collider inside this world.Area
by adding a call addPhysicsWorld
: function Area:addPhysicsWorld() self.world = Physics.newWorld(0, 0, true) end
.world
area containing the physical world. We also need to update this world (and, if necessary, draw it for debugging purposes), if it exists: function Area:update(dt) if self.world then self.world:update(dt) end for i = #self.game_objects, 1, -1 do ... end end function Area:draw() if self.world then self.world:draw() end for _, game_object in ipairs(self.game_objects) do game_object:draw() end end
addPhysicsWorld
rather than just adding it to the Area constructor because we don't want all regions to have physical worlds. For example, the Console room will also use an object to manage its entities, but there is no need to attach the physical world to this Area. Therefore, by calling one function, we make it optional. We can create an instance of the physical world in the Area of the Stage room as follows: function Stage:new() self.area = Area(self) self.area:addPhysicsWorld() ... end
function Player:new(area, x, y, opts) Player.super.new(self, area, x, y, opts) self.x, self.y = x, y self.w, self.h = 12, 12 self.collider = self.area.world:newCircleCollider(self.x, self.y, self.w) self.collider:setObject(self) end
GameObject
have the same constructor, in which they receive a reference to the object Area
to which they belong.w
and h
define its width and height as 12. Next, we add a newCircleCollider
with a radius equal to the width. It is not very logical to create a collider as a circle, if we define width and height, but this will be useful in the future, because when we add different types of ships, all ships will have a visual width and height, but physically the collider will always be around, so that all ships have the same chances and have predictable behavior for the player.setObject
that binds the Player object to the newly created Collider. This is useful because when two colliders collide, we can get information from the point of view of colliders, not objects. For example, if a Player encounters Projectile, we will have two colliders representing Player and Projectile, but we may not have the objects themselves. setObject
(andgetObject
) allows us to set and retrieve the object to which Collider belongs. function Player:draw() love.graphics.circle('line', self.x, self.y, self.w) end
.newWorld
and what happens if you set it to false? Are there any advantages to setting the value true / false? What kind? function Player:new(area, x, y, opts) Player.super.new(self, area, x, y, opts) ... self.r = -math.pi/2 self.rv = 1.66*math.pi self.v = 0 self.max_v = 100 self.a = 100 end
r
as the angle at which the player moves. At first it matters -math.pi/2
, that is, it points up. The angles in LÖVE are shown clockwise, that is math.pi/2
, it is down and -math.pi/2
up is (and 0 is to the right). A variable rv
is the rate at which the angle changes when the player presses "left" or "right." Then we have v
, denoting the speed of the player, and max_v
denoting the maximum speed of the player. The last attribute is the a
player acceleration. All values are obtained by trial and error. function Player:update(dt) Player.super.update(self, dt) if input:down('left') then self.r = self.r - self.rv*dt end if input:down('right') then self.r = self.r + self.rv*dt end self.v = math.min(self.v + self.a*dt, self.max_v) self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) end
main.lua
(since we use the global Input object for everything): function love.load() ... input:bind('left', 'left') input:bind('right', 'right') ... end
r
corresponding to the player’s angle changes by 1.66*math.pi
radian in the corresponding direction. It is also important to note here that this value is multiplied by dt
, that is, this value is controlled on a per-second basis. That is, the rate of change of the angle is measured in 1.66*math.pi
radians per second. This is the result of how the game loop works, which we analyzed in the first part of the tutorial.v
. It is a little more complicated, but if you did it in other languages, it should be familiar to you. The original calculation has the form self.v = self.v + self.a*dt
, that is, we simply increase the speed by the magnitude of the acceleration. In this case, we increase it by 100 per second. But we also defined the attributemax_v
which should limit the maximum allowed speed. If we do not limit it, it self.v = self.v + self.a*dt
will increase v
infinitely, and our player will turn into Sonic. And we do not need this! One way to prevent this is to: function Player:update(dt) ... self.v = self.v + self.a*dt if self.v >= self.max_v then self.v = self.max_v end ... end
v
it becomes larger max_v
, we limit it to this value, and not exceed it. Another brief way to write this is to use a function math.min
that returns the minimum value among all the arguments passed to it. In our case, we pass the result self.v + self.a*dt
and self.max_v
, that is, if the result of the addition is greater max_v
, it math.min
will return max_v
, since it is less than the sum. This is a very common and useful pattern in Lua (and in other programming languages too).setLinearVelocity
set the speed of the Collider using x and y equal to the attributev
multiplied by the appropriate value depending on the angle of the object. In general, when we want to move something in some direction, and we have an angle for this, we should use it cos
to move along the x axis and sin
to move along the y axis. This is also a very common pattern in the development of 2D games. I will not explain this by suggesting that you’ve got it sorted out at school (if it’sn’t, search Google for the basics of trigonometry).GameObject
, and it is quite simple. Since we use the physics engine, we have two representations in some variables, for example, speed and position. We get the position and speed of the player using the attributes x, y
and v
, and the position and speed of the Collider - usinggetPosition
and getLinearVelocity
. It will be logical to synchronize these two views, and one of the ways to achieve this automatically is to change the parent class of all game objects: function GameObject:update(dt) if self.timer then self.timer:update(dt) end if self.collider then self.x, self.y = self.collider:getPosition() end end
collider
,x
it y
will be set to the position of this collider. And when the position of the collider changes, the representation of this position in the object itself will also change accordingly.world:draw()
drawn by the Collider. In fact, we want to draw not only colliders, so it would be logical to comment out this line and draw the Player object directly: function Player:draw() love.graphics.circle('line', self.x, self.y, self.w) end
function Player:draw() love.graphics.circle('line', self.x, self.y, self.w) love.graphics.line(self.x, self.y, self.x + 2*self.w*math.cos(self.r), self.y + 2*self.w*math.sin(self.r)) end
B
that is in distance
units of a position A
, such that the position B
is at a certain angle angle
relative to the position A
, the pattern will be something like this: bx = ax + distance*math.cos(angle)
and by = ay + distance*math.sin(angle)
. This is very often used in the development of 2D games (at least, I think so) and an intuitive understanding of this pattern will be useful for you. math.pi/2 math.pi/4 3*math.pi/4 -5*math.pi/6 0 11*math.pi/12 -math.pi/6 -math.pi/2 + math.pi/4 3*math.pi/4 + math.pi/3 math.pi
a
? What will the player’s update function look like if it didn’t exist? Are there any advantages to its existence?(x, y)
point B
from the position A
, if the angle used is equal -math.pi/4
, and the distance is 100
.(x, y)
point C
from the position B
, if the angle used is equal math.pi/4
, and the distance is equal 50
. Positions A
and B
, as well as the distance and angle between them remain the same as in the previous exercise.A
to a certain point C
and is it permissible to use only a set of intermediate points that can be reached through angles and distances?getAngle
. Why not synchronize it through the attribute r
?a
, then when performing the assignmenta = nil
the garbage collector will understand that the table to which they referred to is no longer referenced, so it can be removed from memory in the future garbage collection cycle. The problem occurs when one object is referred to several times and you forget to remove links from all these points.addGameObject
, the object is added to the list .game_objects
. This is considered a single link pointing to this object. However, the object itself is also returned in this function. Therefore, earlier we did something like self.player = self.area:addGameObject('Player', ...)
, that is, in addition to storing the object reference in the list inside the Area object, we also store the reference to it in a variable self.player
. That is, when we sayself.player.dead
and the Player object is removed from the list of game objects in the Area object, it still cannot be deleted from memory, because it still points to it self.player
. That is, in this case, in order to really remove the Player object from memory, we need to set both dead
true and then execute self.player = nil
.setObject
to which we pass an object so that Collider stores a link to it. If the object dies, will it be removed from memory? No, because Collider still holds a link to it. The same problem, only in other conditions. One way to solve the problem is to explicitly delete objects using the function created for them destroy
, which will deal with the removal of links. function GameObject:destroy() self.timer:destroy() if self.collider then self.collider:destroy() end self.collider = nil end
destroy
. This function calls the functions destroy
of the EnhancedTimer object, as well as the function of the collider (Collider) . These functions perform the dereferencing of items that the user probably wants to remove from memory. For example, in Collider:destroy
one of the actions is a call self:setObject(nil)
: since we want to destroy this object, we do not need Collider to keep a link to it anymore. function Area:update(dt) if self.world then self.world:update(dt) end for i = #self.game_objects, 1, -1 do local game_object = self.game_objects[i] game_object:update(dt) if game_object.dead then game_object:destroy() table.remove(self.game_objects, i) end end end
dead
object attribute is true, then in addition to deleting game objects from the list, we also call its destroy function, which gets rid of references to it. We can expand this concept and realize that the physical world itself has World: destroy , and we can use it when we destroy the Area: object function Area:destroy() for i = #self.game_objects, 1, -1 do local game_object = self.game_objects[i] game_object:destroy() table.remove(self.game_objects, i) end self.game_objects = {} if self.world then self.world:destroy() self.world = nil end end
function Stage:destroy() self.area:destroy() self.area = nil end
gotoRoom
: function gotoRoom(room_type, ...) if current_room and current_room.destroy then current_room:destroy() end current_room = _G[room_type](...) end
current_room
existing variable is present and if it contains an attribute destroy
(in fact, we ask if it contains a real room); if so, we call the destroy function. And then we make the transition to the target room. NewGameObject = GameObject:extend() function NewGameObject:new(area, x, y, opts) NewGameObject.super.new(self, area, x, y, opts) end function NewGameObject:update(dt) NewGameObject.super.update(self, dt) end function NewGameObject:draw() end function NewGameObject:destroy() NewGameObject.super.destroy(self) end
function count_all(f) local seen = {} local count_table count_table = function(t) if seen[t] then return end f(t) seen[t] = true for k,v in pairs(t) do if type(v) == "table" then count_table(v) elseif type(v) == "userdata" then f(v) end end end count_table(_G) end function type_count() local counts = {} local enumerate = function (o) local t = type_name(o) counts[t] = (counts[t] or 0) + 1 end count_all(enumerate) return counts end global_type_table = nil function type_name(o) if global_type_table == nil then global_type_table = {} for k,v in pairs(_G) do global_type_table[v] = k end global_type_table[0] = "table" end return global_type_table[getmetatable(o) or 0] or "Unknown" end
main.lua
, and then add the love.load
following inside : function love.load() ... input:bind('f1', function() print("Before collection: " .. collectgarbage("count")/1024) collectgarbage() print("After collection: " .. collectgarbage("count")/1024) print("Object count: ") local counts = type_count() for k, v in pairs(counts) do print(k, v) end print("-------------------------------------") end) ... end
f1
, it shows the amount of memory before and after the garbage collection cycle, and also displays the types of objects in memory. This is useful because now we can, for example, create a new Stage room with objects inside, delete it, and then make sure that the memory remains the same (or almost the same, hehe) as before creating the Stage. If it remains the same, there are no memory leaks, and if not, then we have problems and we need to look for their sources.f2
to creating and activating a new Stage room with a call gotoRoom
.f3
to the destruction of the current room.f1
. Thereafter ponazhimayte key several times f2
, and f3
to create and destroy new rooms. Now again check the amount of used memory by repeatedly pressing f1
. Is the amount of memory left as it was before, or has it become more? function Stage:new() ... for i = 1, 100 do self.area:addGameObject('Player', gw/2 + random(-4, 4), gh/2 + random(-4, 4)) end end
n
second the attack is triggered and starts automatically. In the end, we will have 16 types of attacks, but most of them will be associated with firing projectiles in the direction of the player’s gaze. For example, this is an attack of suggestive missiles:n
second. n
Is a number that varies depending on the attack, but by default it matters 0.24
. This can be easily implemented using the timer library, which we described in the previous sections: function Player:new() ... self.timer:every(0.24, function() self:shoot() end) end
shoot
every 0.24 seconds, and inside this function we will locate the code that will create the object of the projectile.ShootEffect
(now you should already know how to do it). This effect should be a simple square, remaining on the screen for a short period of time next to the position in which the projectile was created. The easiest way to achieve this is to use something like this: function Player:shoot() self.area:addGameObject('ShootEffect', self.x + 1.2*self.w*math.cos(self.r), self.y + 1.2*self.w*math.sin(self.r)) end
function ShootEffect:new(...) ... self.w = 8 self.timer:tween(0.1, self, {w = 0}, 'in-out-cubic', function() self.dead = true end) end function ShootEffect:draw() love.graphics.setColor(default_color) love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w) end
tween
. So far we have one problem: the position of the effect is static and does not follow the player. This seems like a minor detail, because the duration of the effect is small, but try changing it to 0.5 seconds or more, and you will see what I mean. function Player:shoot() local d = 1.2*self.w self.area:addGameObject('ShootEffect', self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), {player = self, d = d}) end
function ShootEffect:update(dt) ShootEffect.super.update(self, dt) if self.player then self.x = self.player.x + self.d*math.cos(self.player.r) self.y = self.player.y + self.d*math.sin(self.player.r) end end function ShootEffect:draw() pushRotate(self.x, self.y, self.player.r + math.pi/4) love.graphics.setColor(default_color) love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w) love.graphics.pop() end
player
ShootEffect object through the table opts
in the function of the shoot player is assigned a value self
. This means that access to the link to the Player object can be obtained through self.player
the ShootEffect object. In general, we transfer object references to each other in this way, because usually objects are created from functions of other objects, that is, by transferring self
, we get what we need. In addition, we assign the attribute the d
value of the distance at which the effect should appear from the center of the Player object. This is also implemented using the table opts
.if self.player then
), because if it is not, then an error will occur. Also very often in the process of creating the game, there will be cases when entities will be referenced from somewhere else, and we will try to get access to their values, but since they have already died, these values will not be set, and we will get an error. It is important not to forget about this, referring to the entities within each other in this way.pushRotate
that looks like this: function pushRotate(x, y, r) love.graphics.push() love.graphics.translate(x, y) love.graphics.rotate(r or 0) love.graphics.translate(-x, -y) end
r
around a point x, y
until we call love.graphics.pop
. That is, in this example we have a square and we rotate it around its center at the player's angle plus 45 degrees (pi / 4 radians). For the sake of completeness, you can show another version of this function, also containing scaling: function pushRotateScale(x, y, r, sx, sy) love.graphics.push() love.graphics.translate(x, y) love.graphics.rotate(r or 0) love.graphics.scale(sx or 1, sy or sx or 1) love.graphics.translate(-x, -y) end
self.attack_speed
that changes every 5 seconds to a random value in the interval from 1 to 2: function Player:new(...) ... self.attack_speed = 1 self.timer:every(5, function() self.attack_speed = random(1, 2) end) self.timer:every(0.24, function() self:shoot() end)
0.24/self.attack_speed
second? Note that a simple change in a call every
that calls the shoot function will not work.pushRotate
turn the player around its center by 180 degrees. It should look like this:pushRotate
rotate the line indicating the direction of the player’s movement around its center by 90 degrees. It should look like this:pushRotate
rotate the line indicating the player’s direction around the player’s center by 90 degrees. It should look like this:pushRotate
rotate the ShootEffect object around the center of the player by 90 degrees (beyond that it already rotates relative to the player’s direction). It should look like this: function Player:shoot() ... self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r), self.y + 1.5*d*math.sin(self.r), {r = self.r}) end
d
that was previously defined, and then pass the angle of the player as an attribute r
. Note that unlike the ShootEffect object, the Projectile object doesn’t require anything other than the player’s corner when creating it, so we don’t need to pass the player as a link. function Projectile:new(area, x, y, opts) Projectile.super.new(self, area, x, y, opts) self.s = opts.s or 2.5 self.v = opts.v or 200 self.collider = self.area.world:newCircleCollider(self.x, self.y, self.s) self.collider:setObject(self) self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) end
s
is the radius of the collider, it is not designated as r
, because we already use this variable for the angle of motion. In general, I would object to specify the size of the variables to use w
, h
, r
or s
. The first two are when the object is a rectangle, and the last two are when it is a circle. In cases where the variable is r
already used for the direction (as in this case), the radius will be used s
. These attributes are mainly used for visualization, because most often these objects already have a collider that does all the work associated with collisions.opts.attribute or default_value
. Thanks to howor
working in Lua, we can use this design as a quick way to transfer the following: if opts.attribute then self.attribute = opts.attribute else self.attribute = default_value end
self.s
it will be assigned a value opts.s
if it is defined, otherwise it is assigned a value 2.5
. The same applies to self.v
. Finally, we specify the velocity of the projectile using setLinearVelocity
, indicating the initial velocity of the projectile and the angle transmitted from the Player. It uses the same approach as when moving the Player, so you should already understand this. function Projectile:update(dt) Projectile.super.update(self, dt) self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) end function Projectile:draw() love.graphics.setColor(default_color) love.graphics.circle('line', self.x, self.y, self.s) end
function Projectile:update(dt) ... if self.x < 0 then self:die() end if self.y < 0 then self:die() end if self.x > gw then self:die() end if self.y > gh then self:die() end end
gw/2, gh/2
, that is, the upper left corner is in 0, 0
, and the lower right is in gw, gh
. And we need only add a few conditional constructions to the update function of the projectile, checking its position, and if it is outside the bounds, then we must call the function die
. function Player:update(dt) ... if self.x < 0 then self:die() end if self.y < 0 then self:die() end if self.x > gw then self:die() end if self.y > gh then self:die() end end
die
. It is very simple and, in essence, the only thing it does is to set the dead
entity attribute to true, and then create visual effects. For a projectile, the effect created will be called ProjectileDeathEffect
; as in the case of ShootEffect, it will be a square, remaining on the screen for a short period of time, and then disappearing, but with some differences. The main difference is that ProjectileDeathEffect will flicker for a while, then switch to its normal color and fade. This creates a light, but interesting cotton effect. So the constructor will look like this: function ProjectileDeathEffect:new(area, x, y, opts) ProjectileDeathEffect.super.new(self, area, x, y, opts) self.first = true self.timer:after(0.1, function() self.first = false self.second = true self.timer:after(0.15, function() self.second = false self.dead = true end) end) end
first
and second
that will indicate what stage the effect is at. If it is in the first stage, then it will have a white color, and in the second it will take its real color. After the second stage is completed, the effect “dies”, which is done by assigning the dead
value true. All this happens within 0.25 seconds (0.1 + 0.15), that is, it is a very short-lived and fast effect. The effect will be rendered in a manner very similar to the ShootEffect drawing method: function ProjectileDeathEffect:draw() if self.first then love.graphics.setColor(default_color) elseif self.second then love.graphics.setColor(self.color) end love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w) end
die
in the Projectile object: function Projectile:die() self.dead = true self.area:addGameObject('ProjectileDeathEffect', self.x, self.y, {color = hp_color, w = 3*self.s}) end
globals.lua
and look like this: default_color = {222, 222, 222} background_color = {16, 16, 16} ammo_color = {123, 200, 164} boost_color = {76, 195, 217} hp_color = {241, 103, 69} skill_point_color = {255, 198, 93}
hp_color
(red) to show how the effect looks, but in the future it will be right to use the color of the projectile object. Different types of attacks will have different colors, so the effect of death will also have different colors depending on the attack. Now the effect is as follows:die
of the Projectile object and set the attribute dead
to true when the player reaches the edges of the screen. By doing this, you can add visual effects to death. The main special effect at the death of the player will be a particle beam, called ExplodeParticle
, a bit like an explosion. In general, the particles will be lines moving at random angles from the initial position and slowly decreasing in length. You can implement it in this way: function ExplodeParticle:new(area, x, y, opts) ExplodeParticle.super.new(self, area, x, y, opts) self.color = opts.color or default_color self.r = random(0, 2*math.pi) self.s = opts.s or random(2, 3) self.v = opts.v or random(75, 150) self.line_width = 2 self.timer:tween(opts.d or random(0.3, 0.5), self, {s = 0, v = 0, line_width = 0}, 'linear', function() self.dead = true end) end
function ExplodeParticle:draw() pushRotate(self.x, self.y, self.r) love.graphics.setLineWidth(self.line_width) love.graphics.setColor(self.color) love.graphics.line(self.x - self.s, self.y, self.x + self.s, self.y) love.graphics.setColor(255, 255, 255) love.graphics.setLineWidth(1) love.graphics.pop() end
s
- it is actually half the size of the line, not the full size. We also use the love.graphics.setLineWidth
line first to be oily and thinner over time.die
: function Player:die() self.dead = true for i = 1, love.math.random(8, 12) do self.area:addGameObject('ExplodeParticle', self.x, self.y) end end
die
Player function , because we cannot see the effect on the border of the screen: function Player:new(...) ... input:bind('f4', function() self:die() end) end
slow_amount
in love.load
and assign it an initial value of 1. We will use this variable to multiply the delta passed in all update functions. So when we need to slow down time by 50%, we will assignslow_amount
a value of 0.5. Performing this multiplication looks like this: function love.update(dt) timer:update(dt*slow_amount) camera:update(dt*slow_amount) if current_room then current_room:update(dt*slow_amount) end end
function slow(amount, duration) slow_amount = amount timer:tween('slow', duration, _G, {slow_amount = 1}, 'in-out-cubic') end
slow(0.5, 1)
will mean that the game will first slow down to 50% speed, and then return to full speed after 1 second. It is important to note here that the string is used in the tween function 'slow'
. As explained in the previous sections, this means that when the slow function is called, when the tween of the other slow function is still active, this previous tween will be canceled and the new tween will continue, which will prevent the two tween functions from performing one variable at the same time.slow(0.15, 1)
the player at the time of death, we get the following::shake
, so we can add the following: function Player:die() ... camera:shake(6, 60, 0.4) ... end
flash(n)
, the screen will flicker with the background color for n frames. One way to implement this possibility is to define a global variable flash_frames
in love.load
, which is initially nil. When flash_frames
equal to nil, this means that the effect is inactive, and when it is not equal to nil, then it is active. The blink function looks like this: function flash(frames) flash_frames = frames end
love.draw
: function love.draw() if current_room then current_room:draw() end if flash_frames then flash_frames = flash_frames - 1 if flash_frames == -1 then flash_frames = nil end end if flash_frames then love.graphics.setColor(background_color) love.graphics.rectangle('fill', 0, 0, sx*gw, sy*gh) love.graphics.setColor(255, 255, 255) end end
flash_frames
by 1, and then, when it reaches, -1
we assign it nil, because the effect is complete. And when the effect is not complete, we simply draw a large rectangle with a color background_color
that covers the whole screen. Adding this to a function die
looks like this: function Player:die() self.dead = true flash(4) camera:shake(6, 60, 0.4) slow(0.15, 1) for i = 1, love.math.random(8, 12) do self.area:addGameObject('ExplodeParticle', self.x, self.y) end end
first
and second
, and using only a new attribute current_color
?flash
so that it receives the duration in seconds, not frames. Which one is better, or is it just a matter of taste? Can a timer be used to measure the duration of frames instead of seconds?tick
called every 5 seconds: function Player:new(...) ... self.timer:every(5, function() self:tick() end) end
TickEffect
that is triggered by each measure. This effect is similar to the effect of refresh in Downwell (see video about Downwell above), it is a large rectangle that briefly overlaps with the player. It looks like this: function Player:tick() self.area:addGameObject('TickEffect', self.x, self.y, {parent = self}) end
function TickEffect:update(dt) ... if self.parent then self.x, self.y = self.parent.x, self.parent.y end end
function TickEffect:new(area, x, y, opts) TickEffect.super.new(self, area, x, y, opts) self.w, self.h = 48, 32 self.timer:tween(0.13, self, {h = 0}, 'in-out-cubic', function() self.dead = true end) end
y_offset
that increases with time and is subtracted from the position y
of the TickEffect object: function TickEffect:new(...) ... self.y_offset = 0 self.timer:tween(0.13, self, {h = 0, y_offset = 32}, 'in-out-cubic', function() self.dead = true end) end function TickEffect:update(dt) ... if self.parent then self.x, self.y = self.parent.x, self.parent.y - self.y_offset end end
max_v
that sets the maximum speed at which a player can move. We want to make it so that when you press "up" / "down" this value changes and becomes more / less. The problem here is that after the key is released, we need to return to the normal value. Therefore, we need another variable that stores the base value and another one that contains the current value.base_max_v
containing the initial / base value of the maximum speed, and the usual attribute max_v
will contain the current maximum speed to which all possible modifiers are applied (for example, acceleration). function Player:new(...) ... self.base_max_v = 100 self.max_v = self.base_max_v end function Player:update(dt) ... self.max_v = self.base_max_v if input:down('up') then self.max_v = 1.5*self.base_max_v end if input:down('down') then self.max_v = 0.5*self.base_max_v end end
max_v
value in each frame base_max_v
, and then check whether the up / down keys are pressed and change them accordingly max_v
. It is important to note that this means that the call setLinearVelocity
using max_v
it should occur after that, otherwise everything will fall apart!TrailParticle
, which will simply be around a certain radius, for a certain time, reduced by the function tween: function TrailParticle:new(area, x, y, opts) TrailParticle.super.new(self, area, x, y, opts) self.r = opts.r or random(4, 6) self.timer:tween(opts.d or random(0.3, 0.5), self, {r = 0}, 'linear', function() self.dead = true end) end
'in-out-cubic'
instead 'linear'
, will give a different shape to the trail. I used linear because it seems to me the most beautiful, but you can choose another one. The draw function simply draws a circle of the corresponding color and radius using an attribute r
. function Player:new(...) ... self.trail_color = skill_point_color self.timer:every(0.01, function() self.area:addGameObject('TrailParticle', self.x - self.w*math.cos(self.r), self.y - self.h*math.sin(self.r), {parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color}) end)
skill_point_color
(yellow).boosting
. With this attribute, we can find out when acceleration occurs and change the color to which it refers accordingly trail_color
: function Player:update(dt) ... self.max_v = self.base_max_v self.boosting = false if input:down('up') then self.boosting = true self.max_v = 1.5*self.base_max_v end if input:down('down') then self.boosting = true self.max_v = 0.5*self.base_max_v end self.trail_color = skill_point_color if self.boosting then self.trail_color = boost_color end end
trail_color
to boost_color
(blue). function Player:new(...) ... self.ship = 'Fighter' self.polygons = {} if self.ship == 'Fighter' then self.polygons[1] = { ... } self.polygons[2] = { ... } self.polygons[3] = { ... } end end
function Player:draw() pushRotate(self.x, self.y, self.r) love.graphics.setColor(default_color) -- love.graphics.pop() end
function Player:draw() pushRotate(self.x, self.y, self.r) love.graphics.setColor(default_color) for _, polygon in ipairs(self.polygons) do -- end love.graphics.pop() end
function Player:draw() pushRotate(self.x, self.y, self.r) love.graphics.setColor(default_color) for _, polygon in ipairs(self.polygons) do local points = fn.map(polygon, function(k, v) if k % 2 == 1 then return self.x + v + random(-1, 1) else return self.y + v + random(-1, 1) end end) love.graphics.polygon('line', points) end love.graphics.pop() end
0, 0
. This means that each polygon does not yet know in which position of the world it is.fn.map
bypasses each element in the table and applies a function to it. In this case, the function will check the index for parity. If it is odd, it denotes the component x, and if it is even, then the component y. That is, in each of these cases, we simply add the player’s x or y position to the top, as well as a random number in the range from -1 to 1, so that the ship looks a bit more fuzzy and interesting. Then, finally, it is called love.graphics.polygon
to draw all these points. self.polygons[1] = { self.w, 0, -- 1 self.w/2, -self.w/2, -- 2 -self.w/2, -self.w/2, -- 3 -self.w, 0, -- 4 -self.w/2, self.w/2, -- 5 self.w/2, self.w/2, -- 6 } self.polygons[2] = { self.w/2, -self.w/2, -- 7 0, -self.w, -- 8 -self.w - self.w/2, -self.w, -- 9 -3*self.w/4, -self.w/4, -- 10 -self.w/2, -self.w/2, -- 11 } self.polygons[3] = { self.w/2, self.w/2, -- 12 -self.w/2, self.w/2, -- 13 -3*self.w/4, self.w/4, -- 14 -self.w - self.w/2, self.w, -- 15 0, self.w, -- 16 }
self.w, 0
. The next one is to the left and above the first one, that is, its coordinates self.w/2, -self.w/2
, and so on. function Player:new(...) ... self.timer:every(0.01, function() if self.ship == 'Fighter' then self.area:addGameObject('TrailParticle', self.x - 0.9*self.w*math.cos(self.r) + 0.2*self.w*math.cos(self.r - math.pi/2), self.y - 0.9*self.w*math.sin(self.r) + 0.2*self.w*math.sin(self.r - math.pi/2), {parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color}) self.area:addGameObject('TrailParticle', self.x - 0.9*self.w*math.cos(self.r) + 0.2*self.w*math.cos(self.r + math.pi/2), self.y - 0.9*self.w*math.sin(self.r) + 0.2*self.w*math.sin(self.r + math.pi/2), {parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color}) end end) end
0.9*self.w
), but each is shifted a short distance ( 0.2*self.w
) along an axis opposite to the player’s movement.elseif self.ship == 'ShipName' then
both to the definition of polygons and to the definition of a trace. Here are the ships created by me (but you, of course, can create and invent your own design):Source: https://habr.com/ru/post/349440/
All Articles