GameObject
class, we define the depth
attribute, which is initially 50 for all entities. Then, if we want to define the constructor of each class, we can set the depth
attribute for each object class ourselves. The idea is that objects with greater depth should be drawn from above, and smaller depth - at the bottom. That is, for example, if we want all effects to be drawn on top of everything else, then we can simply assign them to the depth
attribute, for example, the value 75. function TickEffect:new(area, x, y, opts) TickEffect.super.new(self, area, x, y, opts) self.depth = 75 ... end
game_objects
list by the depth
attribute of each object: function Area:draw() table.sort(self.game_objects, function(a, b) return a.depth < b.depth end) for _, game_object in ipairs(self.game_objects) do game_object:draw() end end
table.sort
to sort entities by their depth
attribute. Entities with a smaller depth will move to the front of the table, that is, they will be drawn first (under the rest), and entities with greater depth will move to the end of the table and will be the last ones (on top of everything). If you try to set different depth values for different types of objects, you will see that it works.game_objects
table, game_objects
may occur. Flickering occurs because if objects have the same depth, then in one frame one object may be on top of another, but in the next frame, go down below it. The likelihood of this is small, but it can happen and we should prevent it. function Area:draw() table.sort(self.game_objects, function(a, b) if a.depth == b.depth then return a.creation_time < b.creation_time else return a.depth < b.depth end end) for _, game_object in ipairs(self.game_objects) do game_object:draw() end end
y
. Entities with a larger value of y (that is, closer to the bottom of the screen) should be rendered last, and entities with a smaller value of y should be drawn first. What will the sort function look like in this case? function Player:new(...) ... self.max_boost = 100 self.boost = self.max_boost end function Player:update(dt) ... self.boost = math.min(self.boost + 10*dt, self.max_boost) ... end
boost
is set to max_boost
, that is, 100, and then we add to boost
by 10 per second until the value max_boost
. We can also implement rule 2 by simply subtracting 50 units per second when the player accelerates: function Player:update(dt) ... if input:down('up') then self.boosting = true self.max_v = 1.5*self.base_max_v self.boost = self.boost - 50*dt end if input:down('down') then self.boosting = true self.max_v = 0.5*self.base_max_v self.boost = self.boost - 50*dt end ... end
self.boost -= 50*dt
. Now, to check rule 4, we need to make sure that when boost
reaches 0, the cooling will start for 2 seconds. This is a bit more complicated, because more moving parts are used here. The code looks like this: function Player:new(...) ... self.can_boost = true self.boost_timer = 0 self.boost_cooldown = 2 end
can_boost
will be used to report when acceleration can be performed. By default, it is true, because the player must be able to accelerate when starting the game. It is set to false when boost
reaches 0, and then true in boost_cooldown
seconds. The variable boost_timer
will track how much time has passed after the boost
reached 0, and when this variable exceeds boost_cooldown
, can_boost
will be set to true. function Player:update(dt) ... self.boost = math.min(self.boost + 10*dt, self.max_boost) self.boost_timer = self.boost_timer + dt if self.boost_timer > self.boost_cooldown then self.can_boost = true end self.max_v = self.base_max_v self.boosting = false if input:down('up') and self.boost > 1 and self.can_boost then self.boosting = true self.max_v = 1.5*self.base_max_v self.boost = self.boost - 50*dt if self.boost <= 1 then self.boosting = false self.can_boost = false self.boost_timer = 0 end end if input:down('down') and self.boost > 1 and self.can_boost then self.boosting = true self.max_v = 0.5*self.base_max_v self.boost = self.boost - 50*dt if self.boost <= 1 then self.boosting = false self.can_boost = false self.boost_timer = 0 end end self.trail_color = skill_point_color if self.boosting then self.trail_color = boost_color end end
input:down
, we also check that boost
above 1 (rule 5) and that can_boost
is true (rule 5). When boost
reaches 0, we set the variables boosting
and can_boost
false, and then reset boost_timer
to 0. Since boost_timer
added to boost_timer
, every two seconds it will set can_boost
to true and we can accelerate again (rule 4). function Player:new(...) ... self.max_hp = 100 self.hp = self.max_hp self.max_ammo = 100 self.ammo = self.max_ammo end
Ammo
class and begin with the definitions: function Ammo:new(...) ... self.w, self.h = 8, 8 self.collider = self.area.world:newRectangleCollider(self.x, self.y, self.w, self.h) self.collider:setObject(self) self.collider:setFixedRotation(false) self.r = random(0, 2*math.pi) self.v = random(10, 20) self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) self.collider:applyAngularImpulse(random(-24, 24)) end function Ammo:draw() love.graphics.setColor(ammo_color) pushRotate(self.x, self.y, self.collider:getAngle()) draft:rhombus(self.x, self.y, self.w, self.h, 'line') love.graphics.pop() love.graphics.setColor(default_color) end
setLinearVelocity
and applyAngularImpulse
. In addition, this object is drawn using the draft
library. This is a small library that allows you to draw all sorts of shapes more conveniently than you would have done it yourself. In our case, we can simply draw the resource like any rectangle, but I decided to do it this way. I will assume that you have already installed the library yourself and have read the documentation, having learned about its capabilities. In addition, we will take into account the rotation of the physical object, using the getAngle
result in pushRotate
. function Stage:new() ... input:bind('p', function() self.area:addGameObject('Ammo', random(0, gw), random(0, gh)) end) end
(collision classes)
. For a start, we can define three classes of collisions for already existing objects: the player, shells, and resources. function Stage:new() ... self.area = Area(self) self.area:addPhysicsWorld() self.area.world:addCollisionClass('Player') self.area.world:addCollisionClass('Projectile') self.area.world:addCollisionClass('Collectable') ... end
setCollisionClass
(repeat this code in other files): function Player:new(...) ... self.collider:setCollisionClass('Player') ... end
Collectable
collision class so that it ignores the Player
: self.area.world:addCollisionClass('Collectable', {ignores = {'Player'}})
addCollisionClass
: function Stage:new() ... self.area.world:addCollisionClass('Player') self.area.world:addCollisionClass('Projectile', {ignores = {'Projectile'}}) self.area.world:addCollisionClass('Collectable', {ignores = {'Collectable', 'Projectile'}}) ... end
enter
: function Player:update(dt) ... if self.collider:enter('Collectable') then print(1) end end
Ammo
class is the slow motion of an object towards a player. The easiest way to do this is to add the behavior of Seek Behavior . My version of seek behavior is based on the book Programming Game AI by Example , in which there is a very good selection of general control behaviors. I will not explain the behavior in detail, because, frankly, I don’t remember how it works, so if you are interested, sort it out yourself: D function Ammo:update(dt) ... local target = current_room.player if target then local projectile_heading = Vector(self.collider:getLinearVelocity()):normalized() local angle = math.atan2(target.y - self.y, target.x - self.x) local to_target_heading = Vector(math.cos(angle), math.sin(angle)):normalized() local final_heading = (projectile_heading + 0.1*to_target_heading):normalized() self.collider:setLinearVelocity(self.v*final_heading.x, self.v*final_heading.y) else self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) end end
target
, if it exists, otherwise the resource will move in the initially specified direction. target
contains a link to the player, which is set in the Stage
as follows: function Stage:new() ... self.player = self.area:addGameObject('Player', gw/2, gh/2) end
ProjectileDeathEffect
object: a small white flash occurs, and then the actual color of the effect appears. The only difference here is that instead of drawing a square, we will draw a rhombus, that is, the same shape that we used to draw the actual ammunition resource. I will name this new object AmmoEffect
. We will not consider it in detail, because it is similar to ProjectileDeathEffect
. However, we call it as follows: function Ammo:die() self.dead = true self.area:addGameObject('AmmoEffect', self.x, self.y, {color = ammo_color, w = self.w, h = self.h}) for i = 1, love.math.random(4, 8) do self.area:addGameObject('ExplodeParticle', self.x, self.y, {s = 3, color = ammo_color}) end end
AmmoEffect
object and then from 4 to 8 ExplodeParticle
objects, which we have already used in the effect of death Player. The die
function of the Ammo object will be called when it collides with the Player: function Player:update(dt) ... if self.collider:enter('Collectable') then local collision_data = self.collider:getEnterCollisionData('Collectable') local object = collision_data.collider:getObject() if object:is(Ammo) then object:die() end end end
getEnterCollisionData
to retrieve collision data generated by the last enter collision event for the specified label. Then we use getObject
to access the object attached to the collider participating in the collision event, which can be any object in the Collectable collision class. In our case, we only have an Ammo object, but if we had others, then it would be here that the code would distinguish them. And this is exactly what we do - to check if the object obtained from getObject
is an Ammo
class, we use the is
function from the classic library. If this is actually an object of the Ammo class, then we call its function die
. All this should look like this:addAmmo
function, which simply adds a specific value to the ammo
variable and checks that it does not exceed max_ammo
: function Player:addAmmo(amount) self.ammo = math.min(self.ammo + amount, self.max_ammo) end
object:die()
in the newly added code.Boost
class will be approximately the same as that of the Ammo
class. It looks like this: function Boost:new(...) ... local direction = table.random({-1, 1}) self.x = gw/2 + direction*(gw/2 + 48) self.y = random(48, gh - 48) self.w, self.h = 12, 12 self.collider = self.area.world:newRectangleCollider(self.x, self.y, self.w, self.h) self.collider:setObject(self) self.collider:setCollisionClass('Collectable') self.collider:setFixedRotation(false) self.v = -direction*random(20, 40) self.collider:setLinearVelocity(self.v, 0) self.collider:applyAngularImpulse(random(-24, 24)) end function Boost:update(dt) ... self.collider:setLinearVelocity(self.v, 0) end
table.random
defined utils.lua
as follows: function table.random(t) return t[love.math.random(1, #t)] end
-48
or gw+48
, that is, an object is created outside the screen, but close enough to its edge.-direction
, because if the object is on the right, thendirection
equals 1; we want to move it to the left, so the speed must be negative (and vice versa for the opposite side). The component's velocity along the x axis is always assigned an attribute value v
, and the y component is assigned a value of 0. We want the object to move along a horizontal line, so we set the velocity along y to 0. function Boost:draw() love.graphics.setColor(boost_color) pushRotate(self.x, self.y, self.collider:getAngle()) draft:rhombus(self.x, self.y, 1.5*self.w, 1.5*self.h, 'line') draft:rhombus(self.x, self.y, 0.5*self.w, 0.5*self.h, 'fill') love.graphics.pop() love.graphics.setColor(default_color) end
AmmoEffect
(however, it is a bit more complicated), and the second is used for text +BOOST
. We'll start with one that looks like AmmoEffect and name it BoostEffect
.AmmoEffect
only difference lies in the execution time of each phase: from 0.1 to 0.2 in the first phase and from 0.15 to 0.35 in the second: function BoostEffect:new(...) ... self.current_color = default_color self.timer:after(0.2, function() self.current_color = self.color self.timer:after(0.35, function() self.dead = true end) end) end
visible
, if true, the effect will be drawn, and if false, it will not. By changing the value of this variable, we will achieve the desired effect: function BoostEffect:new(...) ... self.visible = true self.timer:after(0.2, function() self.timer:every(0.05, function() self.visible = not self.visible end, 6) self.timer:after(0.35, function() self.visible = true end) end) end
every
at intervals of 0.05 seconds, and after completion we finally make the effect visible. The effect “dies” after 0.55 seconds (because we set the dead
value to true after 0.55 when setting the current color), so making it visible at the end is not very important. Now we can draw it as follows: function BoostEffect:draw() if not self.visible then return end love.graphics.setColor(self.current_color) draft:rhombus(self.x, self.y, 1.34*self.w, 1.34*self.h, 'fill') draft:rhombus(self.x, self.y, 2*self.w, 2*self.h, 'line') love.graphics.setColor(default_color) end
function BoostEffect:new(...) ... self.sx, self.sy = 1, 1 self.timer:tween(0.35, self, {sx = 2, sy = 2}, 'in-out-cubic') end
function BoostEffect:draw() ... draft:rhombus(self.x, self.y, self.sx*2*self.w, self.sy*2*self.h, 'line') ... end
sx
and sy
will be increased to 2 for 0.35 seconds, that is, circuit, too, for the 0.35 second double. In the end, the result will look like this (I assume that you have already linked the function of die
this object to the collision event with the Player, as we did with the ammunition resource):BoostEffect
. Flicker uses the same logic as BoostEffect, that is, we have already considered it.characters
, process this table, and then draw each character from the table on the screen with all modifications and effects.InfoText
. We will call it like this: function Boost:die() ... self.area:addGameObject('InfoText', self.x, self.y, {text = '+BOOST', color = boost_color}) end
text
. Then the definition of the base class will look like this: function InfoText:new(...) ... self.depth = 80 self.characters = {} for i = 1, #self.text do table.insert(self.characters, self.text:utf8sub(i, i)) end end
utf8
. In general, it is a good idea to manipulate strings using a library that supports all types of characters, and as we will see soon, this is especially important for our object.O
in line +BOOST
means drawing at position initial_x_position + widthOf('+B')
. In our case, the problem with getting the width +B
is that it depends on the font used, since we will use the function Font:getWidth
, but have not yet set the font. However, we can easily solve this problem!resources/fonts
and then upload it. I will leave the code needed to load it as an exercise for you, because it is somewhat similar to the code used to load class definitions from a folder objects
(Exercise 14). By the end of this download process, we will have a global table fonts
that will contain all the downloaded fonts in the formatfontname_fontsize
. In this example we will use m5x7_16
: function InfoText:new(...) ... self.font = fonts.m5x7_16 ... end
function InfoText:draw() love.graphics.setFont(self.font) for i = 1, #self.characters do local width = 0 if i > 1 then for j = 1, i-1 do width = width + self.font:getWidth(self.characters[j]) end end love.graphics.setColor(self.color) love.graphics.print(self.characters[i], self.x + width, self.y, 0, 1, 1, 0, self.font:getHeight()/2) end love.graphics.setColor(default_color) end
love.graphics.setFont
to specify the font that we want to use in the following drawing operations. Then we have to go through each of the characters, and then draw them. But first we need to calculate its position in x, which is the sum of the width of all characters before it. The internal loop that accumulates a variable width
does just that. It starts from 1 (the beginning of the line) to i-1 (the character before the current one) and adds the width of each character to the total width
, that is, to the sum of all of them. Then we use love.graphics.print
to draw each individual character in its corresponding position. We also shift each character half the height of the font (so that the characters are centered relative to the position given by us y). function InfoText:new(...) ... self.visible = true self.timer:after(0.70, function() self.timer:every(0.05, function() self.visible = not self.visible end, 6) self.timer:after(0.35, function() self.visible = true end) end) self.timer:after(1.10, function() self.dead = true end) end
after
for 0.7 seconds, which we defined above. We will do this - every 0.035 seconds we will run a procedure that has a chance to change a character to another random character. It looks like this: self.timer:after(0.70, function() ... self.timer:every(0.035, function() for i, character in ipairs(self.characters) do if love.math.random(1, 20) <= 1 then -- change character else -- leave character as it is end end end) end)
random_characters
, which is a string containing all the characters that a character can change into. When a character needs to change, we randomly select a character from this string: self.timer:after(0.70, function() ... self.timer:every(0.035, function() local random_characters = '0123456789!@#$%¨&*()-=+[]^~/;?><.,|abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWYXZ' for i, character in ipairs(self.characters) do if love.math.random(1, 20) <= 1 then local r = love.math.random(1, #random_characters) self.characters[i] = random_characters:utf8sub(r, r) else self.characters[i] = character end end end) end)
background_colors
and foreground_colors
. Each table is the same size as the table characters
, and will simply contain the background and primary colors for each character. If no symbol is specified for a symbol in this table, it will by default use the primary color ( boost_color
) and a transparent background. function InfoText:new(...) ... self.background_colors = {} self.foreground_colors = {} end function InfoText:draw() ... for i = 1, #self.characters do ... if self.background_colors[i] then love.graphics.setColor(self.background_colors[i]) love.graphics.rectangle('fill', self.x + width, self.y - self.font:getHeight()/2, self.font:getWidth(self.characters[i]), self.font:getHeight()) end love.graphics.setColor(self.foreground_colors[i] or self.color or default_color) love.graphics.print(self.characters[i], self.x + width, self.y, 0, 1, 1, 0, self.font:getHeight()/2) end end
background_colors[i]
(background color for the current character), then for the background color we simply draw a rectangle in the appropriate position and the size of the current character. We change the main color by simply setting the setColor
color of the current character using the color. If foreground_colors[i]
not defined, then by default it is equal self.color
, which for this object is always equal boost_color
, since we are the ones that we transmit when we call it from the Boost object. But if self.color
not defined, then it defaults to white ( default_color
). By itself, this code fragment does nothing, because we have not defined the values inside the tables background_colors
and foreground_colors
. self.timer:after(0.70, function() ... self.timer:every(0.035, function() for i, character in ipairs(self.characters) do ... if love.math.random(1, 10) <= 1 then -- change background color else -- set background color to transparent end if love.math.random(1, 10) <= 2 then -- change foreground color else -- set foreground color to boost_color end end end) end)
table.random
. In addition, beyond this, we will define six more colors, which will be negatives of the six original ones. That is, if we have the original color 232, 48, 192
, then its negative can be defined as 255-232, 255-48, 255-192
. function InfoText:new(...) ... local default_colors = {default_color, hp_color, ammo_color, boost_color, skill_point_color} local negative_colors = { {255-default_color[1], 255-default_color[2], 255-default_color[3]}, {255-hp_color[1], 255-hp_color[2], 255-hp_color[3]}, {255-ammo_color[1], 255-ammo_color[2], 255-ammo_color[3]}, {255-boost_color[1], 255-boost_color[2], 255-boost_color[3]}, {255-skill_point_color[1], 255-skill_point_color[2], 255-skill_point_color[3]} } self.all_colors = fn.append(default_colors, negative_colors) ... end
append
to combine them. Then now we can do something of a type table.random(self.all_colors)
and get one random color out of ten defined in this table. That is, we can do the following: self.timer:after(0.70, function() ... self.timer:every(0.035, function() for i, character in ipairs(self.characters) do ... if love.math.random(1, 10) <= 1 then self.background_colors[i] = table.random(self.all_colors) else self.background_colors[i] = nil end if love.math.random(1, 10) <= 2 then self.foreground_colors[i] = table.random(self.all_colors) else self.background_colors[i] = nil end end end) end)
addAmmo
so that it supports the addition of negative values and does not allow the attribute ammo
to fall below 0. Do the same for the functions addBoost
and addHP
(adding HP resource will be the task of another exercise).addResource
and removeResource
?InfoText
change the probability of changing the symbol by 20%, the probability of changing the primary color by 5%, and the probability of changing the background color by 30%.default_colors
, negative_colors
And all_colors
in InfoText
non-locally and globally.InfoText
so that it is created between -self.w
and self.w
over the x component, between -self.h
and self.h
over the y component. Attributes w
and h
relate to the Boost object that creates the InfoText. function Area:getAllGameObjectsThat(filter) local out = {} for _, game_object in pairs(self.game_objects) do if filter(game_object) then table.insert(out, game_object) end end return out end
function InfoText:new(...) ... local all_info_texts = self.area:getAllGameObjectsThat(function(o) if o:is(InfoText) and o.id ~= self.id then return true end end) end
function Projectile:draw() love.graphics.setColor(default_color) pushRotate(self.x, self.y, Vector(self.collider:getLinearVelocity()):angle()) love.graphics.setLineWidth(self.s - self.s/4) love.graphics.line(self.x - 2*self.s, self.y, self.x, self.y) love.graphics.line(self.x, self.y, self.x + 2*self.s, self.y) love.graphics.setLineWidth(1) love.graphics.pop() end
pushRotate
we use the speed of the projectile, so we can rotate it in accordance with the angle at which it moves. Then inside we use love.graphics.setLineWidth
and set a value roughly proportional to the attribute s
, but slightly smaller. This means that shells with s
a large overall will be thicker. Then we draw the projectile with love.graphics.line
. It is also important that we draw one line from -2*self.s
to the center, and then one more from the center to 2*self.s
. We do this because each attack will have its own color, and we will change the color of one of these lines, but not the second. That is, for example, if we do this: function Projectile:draw() love.graphics.setColor(default_color) pushRotate(self.x, self.y, Vector(self.collider:getLinearVelocity()):angle()) love.graphics.setLineWidth(self.s - self.s/4) love.graphics.line(self.x - 2*self.s, self.y, self.x, self.y) love.graphics.setColor(hp_color) -- change half the projectile line to another color love.graphics.line(self.x, self.y, self.x + 2*self.s, self.y) love.graphics.setLineWidth(1) love.graphics.pop() end
globals.lua
and for now it will look like this: attacks = { ['Neutral'] = {cooldown = 0.24, ammo = 0, abbreviation = 'N', color = default_color}, }
Neutral
. It will use the attack parameters that we already have in the game. Now we can define a function setAttack
that will replace one attack with another and use this global attack table: function Player:setAttack(attack) self.attack = attack self.shoot_cooldown = attacks[attack].cooldown self.ammo = self.max_ammo end
function Player:new(...) ... self:setAttack('Neutral') ... end
attack
that will contain the name of the current attack. This attribute will be used in the function shoot
to check the current active attack and determine how to create projectiles.shoot_cooldown
. We have not created this attribute yet, but it will be similar to the attributes boost_timer
and boost_cooldown
. It will be used to control how often an action takes place, in our case an attack. We will delete this line: function Player:new(...) ... self.timer:every(0.24, function() self:shoot() end) ... end
function Player:new(...) ... self.shoot_timer = 0 self.shoot_cooldown = 0.24 ... end function Player:update(dt) ... self.shoot_timer = self.shoot_timer + dt if self.shoot_timer > self.shoot_cooldown then self.shoot_timer = 0 self:shoot() end ... end
shoot
so that it starts to take into account the existence of various attacks: 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}) if self.attack == 'Neutral' then 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 end
if self.attack == 'Neutral'
current attack with a conventional design . This function will gradually grow into a large chain of conditions, because we have to check all 16 attacks.Double
. She looks like this:ammo_color
(I obtained these values by trial and error): attacks = { ... ['Double'] = {cooldown = 0.32, ammo = 2, abbreviation = '2', color = ammo_color}, }
shoot
: function Player:shoot() ... elseif self.attack == 'Double' then self.ammo = self.ammo - attacks[self.attack].ammo self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r + math.pi/12), self.y + 1.5*d*math.sin(self.r + math.pi/12), {r = self.r + math.pi/12, attack = self.attack}) self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r - math.pi/12), self.y + 1.5*d*math.sin(self.r - math.pi/12), {r = self.r - math.pi/12, attack = self.attack}) end end
attack
. We will do this for each type of projectile, because it will help us determine what type of attack the projectile belongs to. This is useful for setting the appropriate color, as well as for changing behavior if necessary. The Projectile object now looks like this: function Projectile:new(...) ... self.color = attacks[self.attack].color ... end function Projectile:draw() pushRotate(self.x, self.y, Vector(self.collider:getLinearVelocity()):angle()) love.graphics.setLineWidth(self.s - self.s/4) love.graphics.setColor(self.color) love.graphics.line(self.x - 2*self.s, self.y, self.x, self.y) love.graphics.setColor(default_color) love.graphics.line(self.x, self.y, self.x + 2*self.s, self.y) love.graphics.setLineWidth(1) love.graphics.pop() end
color
color defined for this attack in the global table attacks
. And in the draw function, we draw one part of the line with this color, which is an attribute color
, and the other, which is default_color
. For most types of shells, the scheme will be the same. function Player:shoot() ... elseif self.attack == 'Double' then self.ammo = self.ammo - attacks[self.attack].ammo ... end end
ammo
drops to 0, we change the current attack to Neutral
: function Player:shoot() ... if self.ammo <= 0 then self:setAttack('Neutral') self.ammo = self.max_ammo end end
shoot
, because we do not want the player to shoot after the amount of ammunition drops to 0.Triple
. Its definition in the attack table looks like this: attacks['Triple'] = {cooldown = 0.32, ammo = 3, abbreviation = '3', color = boost_color}
Double
, but there is still an additional projectile, created in the middle (at the same angle as the attack projectile Neutral
). Create this attack in the same way as Double.Rapid
. Its definition in the attack table looks like this: attacks['Rapid'] = {cooldown = 0.12, ammo = 1, abbreviation = 'R', color = default_color}
Spread
. Its definition in the attack table: attacks['Spread'] = {cooldown = 0.16, ammo = 1, abbreviation = 'RS', color = default_color}
all_colors
(or through the frame, depending on how you feel better).Back
. Its definition in the attack table looks like this: attacks['Back'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Ba', color = skill_point_color}
Side
. Its definition in the attack table: attacks['Side'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Si', color = boost_color}
Attack
. As Boost
with SkillPoint
, the Attack resource is created in the left or right edge of the screen, and then very slowly moves inward. When a player interacts with an Attack resource, his attack with the function setAttack
changes to an attack that is contained in the resource.abbreviation
in the table attacks
. Here is what they look like:Rock
. It looks like this:Boost
, but with a few differences: function Rock:new(area, x, y, opts) Rock.super.new(self, area, x, y, opts) local direction = table.random({-1, 1}) self.x = gw/2 + direction*(gw/2 + 48) self.y = random(16, gh - 16) self.w, self.h = 8, 8 self.collider = self.area.world:newPolygonCollider(createIrregularPolygon(8)) self.collider:setPosition(self.x, self.y) self.collider:setObject(self) self.collider:setCollisionClass('Enemy') self.collider:setFixedRotation(false) self.v = -direction*random(20, 40) self.collider:setLinearVelocity(self.v, 0) self.collider:applyAngularImpulse(random(-100, 100)) end
createIrregularPolygon
to be defined in utils.lua
. This function should return a list of vertices that make up an irregular rectangle. By an irregular rectangle, I mean one that looks like a circle, but each vertex of which may be slightly closer or farther from the center, and in which the angles between each of the vertices may also be slightly random.size
and point_amount
. The first will relate to the radius of the circle, and the second to the number of points that make up the polygon (polygon): function createIrregularPolygon(size, point_amount) local point_amount = point_amount or 8 end
point_amount
not defined, then the default is 8.point_amount
, in each iteration of which we will determine the next vertex based on the interval of angles. For example, to determine the position of the second point, we can say that its angle will be in the interval 2*angle_interval
, where angle_interval
is the value 2*math.pi/point_amount
. That is, in this case it will be approximately equal to 90 degrees. It is more logical to write this code, so: function createIrregularPolygon(size, point_amount) local point_amount = point_amount or 8 local points = {} for i = 1, point_amount do local angle_interval = 2*math.pi/point_amount local distance = size + random(-size/4, size/4) local angle = (i-1)*angle_interval + random(-angle_interval/4, angle_interval/4) table.insert(points, distance*math.cos(angle)) table.insert(points, distance*math.sin(angle)) end return points end
angle_interval
, as explained above, but also define distance
as being somewhere within the radius of a circle, but with a random offset from -size/4
to +size/4
. This means that each vertex will not be exactly on the circle of the circle, but somewhere nearby. We also randomly randomize a range of angles to create the same effect. Finally, we add the x and y components to the list of returned points. Notice that the polygon is created in local space (assuming that the center is at 0, 0), that is, to place the object in the right place, we will then have to use it setPosition
.Enemy
. Like all other classes of collisions, this one must also be defined before use: function Stage:new() ... self.area.world:addCollisionClass('Enemy') ... end
love.graphics.polygon
: function Rock:draw() love.graphics.setColor(hp_color) local points = {self.collider:getWorldPoints(self.collider.shapes.main:getPoints())} love.graphics.polygon('line', points) love.graphics.setColor(default_color) end
PolygonShape:getPoints
. The points are returned in local coordinates, and we need global ones, so we have to use Body:getWorldPoints
to convert local coordinates to global ones. After that we can draw a polygon and it will behave as we expect. Note that since we get points directly from the collider, and the collider is a rotating polygon, we do not need to use pushRotate
to rotate the object, as it did with the Boost object, because the resulting points already take into account the rotation of objects.hp
to the Rock class with an initial value of 100hit
. :damage
, , 100damage
hp
, hp
0 , Rock «»hp
0 , hit_flash
true, 0,2 — false. hit_flash
true, default_color
, hp_color
.EnemyDeathEffect
. This effect is created when the enemy dies and behaves exactly like an object ProjectileDeathEffect
, only it is larger and corresponds to the size of the Rock object. This object must be created when the attribute hp
of the Rock object becomes 0 or lower.hit
with the amount of damage the projectile hits (by default, the projectiles will have an attribute damage
initially equal to 100). When hit, the projectile must also call its own function die
.hit
. This function should do the following:damage
, and in the case when it is not defined, the default value is 10invincible
to true.ExplodeParticle
addHP
(or removeHP
, if you decide to add it) should receive the attribute damage
and use it to reduce the HP of the Player object. Inside the function addHP
(or removeHP
), a method must be implemented to handle the situation when hp
it becomes equal to or less than 0 and the player dies.invincible
true, — false. , 0,2 6, , 0,5 0,25. , invisible
true false 0,04 , invincible
true. invisible
true, Player .hit
should be called when a player collides with Enemy. In the event of a conflict with the enemy, the player must be dealt damage 30.EnemyProjectile
.Projectile
. Both of these objects will have a lot of common code. We can abstract into a general object of shells that have a common behavior, but in fact this is not necessary, because there will be only two types of shells in the game. After copying we must make the following changes: function EnemyProjectile:new(...) ... self.collider:setCollisionClass('EnemyProjectile') end
function Stage:new() ... self.area.world:addCollisionClass('EnemyProjectile', {ignores = {'EnemyProjectile', 'Projectile', 'Enemy'}}) end
function EnemyProjectile:new(...) ... self.damage = 10 end
function EnemyProjectile:update(dt) ... if self.collider:enter('Player') then local collision_data = self.collider:getEnterCollisionData('Player') ... end
function EnemyProjectile:draw() love.graphics.setColor(hp_color) ... love.graphics.setColor(default_color) end
function Shooter:new(...) ... self.w, self.h = 12, 6 self.collider = self.area.world:newPolygonCollider( {self.w, 0, -self.w/2, self.h, -self.w, 0, -self.w/2, -self.h}) end
function Shooter:new(...) ... self.collider:setFixedRotation(false) self.collider:setAngle(direction == 1 and 0 or math.pi) self.collider:setFixedRotation(true) end
direction == 1 and math.pi or 0
is the implementation of the ternary operator in Lua. In other languages, it may look like (direction == 1) ? math.pi : 0
. I think the exercises in parts 2 and 4 allowed you to examine them in detail. In essence, the following happens here: ifdirection
equal to 1 (the enemy appears to the right and is directed to the left), then the first conventional design is true, that is, we get true and math.pi or 0
. Because of the order of execution and
and or
, the first will be true and math.pi
, that is, as a result we will have math.pi or 0
, which returns math.pi, because when both elements are true, then the or
first one returns. On the other hand, if it direction
is -1, then the first conditional is spars to false and we succeed false and math.pi or 0
, that is false or 0
, which leads us to 0, because when the first element is false, it or
returns the second. function Player:new(...) ... self.timer:every(random(3, 5), function() -- spawn PreAttackEffect object with duration of 1 second self.timer:after(1, function() -- spawn EnemyProjectile end) end) end
PreAttackEffect
for one second.TargetParticle
. These particles will move to the point that we define as the target, and then die after some time, or when they reach the goal. function TargetParticle:new(area, x, y, opts) TargetParticle.super.new(self, area, x, y, opts) self.r = opts.r or random(2, 3) self.timer:tween(opts.d or random(0.1, 0.3), self, {r = 0, x = self.target_x, y = self.target_y}, 'out-cubic', function() self.dead = true end) end function TargetParticle:draw() love.graphics.setColor(self.color) draft:rhombus(self.x, self.y, 2*self.r, 2*self.r, 'fill') love.graphics.setColor(default_color) end
d
(or a random value from 0.1 to 0.3 seconds), the transition is performed by the function tween k target_x, target_y
, and when the particle reaches this position, it dies. The particle is also drawn as a rhombus (as in one of the effects created earlier), but it can also be drawn as a circle or square, because it is rather small and becomes even smaller with time.PreAttackEffect
as follows: function PreAttackEffect:new(...) ... self.timer:every(0.02, function() self.area:addGameObject('TargetParticle', self.x + random(-20, 20), self.y + random(-20, 20), {target_x = self.x, target_y = self.y, color = self.color}) end) end
target_x, target_y
value of the position of the effect itself (that is, on the nose of the ship).Shooter
we create PreAttackEffect like this: function Shooter:new(...) ... self.timer:every(random(3, 5), function() self.area:addGameObject('PreAttackEffect', self.x + 1.4*self.w*math.cos(self.collider:getAngle()), self.y + 1.4*self.w*math.sin(self.collider:getAngle()), {shooter = self, color = hp_color, duration = 1}) self.timer:after(1, function() end) end) end
duration
that controls the lifetime of the PreAttackEffect object. Here we can do the following: function PreAttackEffect:new(...) ... self.timer:after(self.duration - self.duration/4, function() self.dead = true end) end
duration
because this is the object that I myself call the “controller object”. For example, it has nothing in the draw function, that is, we will never see it in the game. We see only the objects TargetParticle
that he orders to create. These objects have a random lifetime, from 0.1 to 0.3 seconds, that is, if we want the last particles to end immediately when the shell is fired, then this object will die in 0.1-0.3 seconds later than its duration in 1 second. I decided to make it equal to 0.75 (duration - duration / 4), but you can instead use another number, closer to 0.9 seconds.shooter
pointing to the Shooter object that created the PreAttackEffect object, so we can simply update the position of the PreAttackEffect based on the position of this parent object shooter
: function PreAttackEffect:update(dt) ... if self.shooter and not self.shooter.dead then self.x = self.shooter.x + 1.4*self.shooter.w*math.cos(self.shooter.collider:getAngle()) self.y = self.shooter.y + 1.4*self.shooter.w*math.sin(self.shooter.collider:getAngle()) end end
not self.shooter.dead
. It may happen that when we refer to objects inside each other in a similar way, then when one object dies, another one will still keep a reference to it. For example, a PreAttackEffect object lives 0.75 seconds, but between its creation and disappearance, the Shooter object that created it can be killed by a player. If this happens, there may be a problem.collider
Shooter object attribute , which is destroyed when the Shooter object dies. And if this object is destroyed, we can do nothing with it, because it no longer exists. So when we try to executegetAngle
then the game will fall out. We can work out a general system that solves such a problem, but in fact I don’t think it is necessary. For now, it is enough for us to simply be attentive to when we refer to objects in this way, so as not to try to gain access to objects that may already be dead. function Shooter:new(...) ... self.timer:every(random(3, 5), function() ... self.timer:after(1, function() self.area:addGameObject('EnemyProjectile', self.x + 1.4*self.w*math.cos(self.collider:getAngle()), self.y + 1.4*self.w*math.sin(self.collider:getAngle()), {r = math.atan2(current_room.player.y - self.y, current_room.player.x - self.x), v = random(80, 100), s = 3.5}) end) end) end
r
attribute) pointing in the direction of the player. In general, when we want to get the angle from source
to target
, we need to do the following: angle = math.atan2(target.y - source.y, target.x - source.x)
die
both objects is called and both are destroyed.direction
in the class Shooter confuse us? If so, how should it be renamed? If not, why not?Source: https://habr.com/ru/post/349718/
All Articles