Director
object, which will be a regular object (not one that inherits from a GameObject, but that used in the Area). In it we put our code: Director = Object:extend() function Director:new(stage) self.stage = stage end function Director:update(dt) end
function Stage:new() ... self.director = Director(self) end function Stage:update(dt) self.director:update(dt) ... end
stage.area
. The director will also need access to the time, so he needs the appropriate update.difficulty
and a few auxiliary ones to control the time for increasing this attribute. This temporary change code will be the same as that used in the acceleration or cycle mechanisms of the Player. function Director:new(...) ... self.difficulty = 1 self.round_duration = 22 self.round_timer = 0 end function Director:update(dt) self.round_timer = self.round_timer + dt if self.round_timer > self.round_duration then self.round_timer = 0 self.difficulty = self.difficulty + 1 self:setEnemySpawnsForThisRound() end end
difficulty
increases every 22 seconds in accordance with rule 1. We can also call the setEnemySpawnsForThisRound
function, which will execute rule 2. function Director:new(...) ... self.difficulty_to_points = {} self.difficulty_to_points[1] = 16 for i = 2, 1024, 4 do self.difficulty_to_points[i] = self.difficulty_to_points[i-1] + 8 self.difficulty_to_points[i+1] = self.difficulty_to_points[i] self.difficulty_to_points[i+2] = math.floor(self.difficulty_to_points[i+1]/1.5) self.difficulty_to_points[i+3] = math.floor(self.difficulty_to_points[i+2]*2) end end
-
1 - 16
2 - 24
3 - 24
4 - 16
5 - 32
6 - 40
7 - 40
8 - 26
9 - 56
10 - 64
11 - 64
12 - 42
13 - 84
function Director:new(...) ... self.enemy_to_points = { ['Rock'] = 1, ['Shooter'] = 2, } end
setEnemySpawnsForThisRound
function. But before we get to it, I want to introduce you to a very important construction related to chances and probabilities. We will use it throughout the game.love.math.random
- to make it generate a value from 1 to 100, and then check where the value is. If it is less than 25, then we say that event X occurred, if from 25 to 50, then event Y, and if greater than 50, then event Z.love.math.random
100 times, X will occur exactly 25 times. If we do it 10,000 times, then the probability will probably be close to 25%, but often we need to have more control over the situation. Therefore, the simple solution is to create what I call a “list of changes” ( chanceList
).next
function. This function will give us a random value from the list, say, 28. This means that event Y will occur. The difference is that when we call a function, we also remove the selected random value from the list. In essence, this means that 28 will never again fall out and the event Y now has a slightly lower probability than the other two events. The more often we call next
, the more empty the list becomes, and when it becomes completely empty, we simply recreate all 100 numbers again. events = chanceList({'X', 25}, {'Y', 25}, {'Z', 50}) for i = 1, 100 do print(events:next()) --> will print X 25 times, Y 25 times and Z 50 times end
events = chanceList({'X', 5}, {'Y', 5}, {'Z', 10}) for i = 1, 20 do print(events:next()) --> will print X 5 times, Y 5 times and Z 10 times end
events = chanceList({'X', 5}, {'Y', 5}, {'Z', 10}) for i = 1, 40 do print(events:next()) --> will print X 10 times, Y 10 times and Z 20 times end
utils.lua
function in chanceList
and use some of the Lua features that we covered in the second part of this tutorial.next
function. The easiest way to achieve this is to simply give this object a simple table that looks like this: function chanceList(...) return { next = function(self) end } end
...
which we will process in more detail later. Then we return a table that has a function next
. This function receives self
as the only argument, since we know that calling the function with :
transfers as its first argument itself. That is, inside the function next
self
refers to a table that is returned by chanceList
.next
function, we can define several attributes that this function will have. The first is the chance_list
itself, which will contain the values ​​returned by the next
function: function chanceList(...) return { chance_list = {}, next = function(self) end } end
next
function. In our example: events = chanceList({'X', 3}, {'Y', 3}, {'Z', 4})
chance_list
attribute will look something like this: .chance_list = {'X', 'X', 'X', 'Y', 'Y', 'Y', 'Z', 'Z', 'Z', 'Z'}
chance_definitions
, which will store all the values ​​and probabilities passed to the chanceList
function: function chanceList(...) return { chance_list = {}, chance_definitions = {...}, next = function(self) end } end
next
function. We need two behaviors from this function: it must return a random value according to the probabilities described in chance_definitions
, and also restore the internal chance_list
when it reaches zero elements. Assuming the list is filled with elements, we can implement the first behavior as follows: next = function(self) return table.remove(self.chance_list, love.math.random(1, #self.chance_list)) end
chance_list
table and return it. Due to the internal structure of the elements, all restrictions are satisfied.chance_list
table chance_list
. It turns out that we can use to build the list the same code that will be used to empty it. It will look like this: next = function(self) if #self.chance_list == 0 then for _, chance_definition in ipairs(self.chance_definitions) do for i = 1, chance_definition[2] do table.insert(self.chance_list, chance_definition[1]) end end end return table.remove(self.chance_list, love.math.random(1, #self.chance_list)) end
chance_list
zero. This will be true when you first call next
, and also when the list is empty after a lot of calls. If this is true, then we begin to bypass the chance_definitions
table, which contains tables that we call chance_definition
with the values ​​and probabilities of these values. That is, if we called the function chanceList
like this: events = chanceList({'X', 3}, {'Y', 3}, {'Z', 4})
chance_definitions
table looks like this: .chance_definitions = {{'X', 3}, {'Y', 3}, {'Z', 4}}
chance_definitions[1]
this list, chance_definitions[1]
refers to the value, and chance_definitions[2]
refers to the number of times the value is found in the chance_list
. Knowing this, to fill the list we simply insert chance_definition[1]
into chance_list
chance_definition[2]
times. And we do the same for all chance_definitions
tables. events = chanceList({'X', 2}, {'Y', 2}, {'Z', 4}) for i = 1, 16 do print(events:next()) end
setEnemySpawnsForThisRound
. The first thing we want to do is determine the likelihood of creating each enemy. Different levels of complexity will have different probabilities of creation, and we will need to set at least the first few difficulties manually. Then the subsequent difficulties will be set randomly, because they will have so many points that the player will be too overloaded anyway. function Director:new(...) ... self.enemy_spawn_chances = { [1] = chanceList({'Rock', 1}), [2] = chanceList({'Rock', 8}, {'Shooter', 4}), [3] = chanceList({'Rock', 8}, {'Shooter', 8}), [4] = chanceList({'Rock', 4}, {'Shooter', 8}), } end
function Director:new(...) ... for i = 5, 1024 do self.enemy_spawn_chances[i] = chanceList( {'Rock', love.math.random(2, 12)}, {'Shooter', love.math.random(2, 12)} ) end end
setEnemySpawnsForThisRound
function. The first thing we do is use the creation of enemies in the list according to the enemy_spawn_chances
table until we run out of points for the current difficulty level. It might look something like this: function Director:setEnemySpawnsForThisRound() local points = self.difficulty_to_points[self.difficulty] -- Find enemies local enemy_list = {} while points > 0 do local enemy = self.enemy_spawn_chances[self.difficulty]:next() points = points - self.enemy_to_points[enemy] table.insert(enemy_list, enemy) end end
enemy_list
will be filled with the rows Rock
and Shooter
according to the probabilities of the current complexity. We put this code inside a while loop, which stops execution when the number of remaining points reaches zero.enemy_list
table will be created. It might look something like this: function Director:setEnemySpawnsForThisRound() ... -- Find enemies spawn times local enemy_spawn_times = {} for i = 1, #enemy_list do enemy_spawn_times[i] = random(0, self.round_duration) end table.sort(enemy_spawn_times, function(a, b) return a < b end) end
enemy_list
assigned a random number between 0 and round_duration
, stored in the enemy_spawn_times
table. We will sort this table so that the values ​​are in order. That is, if our enemy_list
table looks like this: .enemy_list = {'Rock', 'Shooter', 'Rock'}
enemy_spawn_times
table will look like this: .enemy_spawn_times = {2.5, 8.4, 14.8}
timer:after
: function Director:setEnemySpawnsForThisRound() ... -- Set spawn enemy timer for i = 1, #enemy_spawn_times do self.timer:after(enemy_spawn_times[i], function() self.stage.area:addGameObject(enemy_list[i]) end) end end
enemy_spawn_times
and set the creation of enemies from enemy_list
in accordance with the numbers in the first table. The last thing to do is to call this function once when starting the game: function Director:new(...) ... self:setEnemySpawnsForThisRound() end
function Director:new(...) ... self.resource_spawn_chances = chanceList({'Boost', 28}, {'HP', 14}, {'SkillPoint', 58}) end
finish
function in the Stage class, which uses gotoRoom
to switch to another Stage room. This function looks like this: function Stage:finish() timer:after(1, function() gotoRoom('Stage') end) end
gotoRoom
will deal with the destruction of the previous instance of the Stage and the creation of a new one so that we do not have to destroy the objects manually. The only thing we need to take care of is to set the player
attribute in the Stage class to nil
in its destroy function, otherwise the Player object will not be deleted correctly.finish
function can be called from the Player object itself when the player dies: function Player:die() ... current_room:finish() end
current_room
is a global variable containing the current active room, and when the die
function is called for the player, the only active room will be the Stage, so everything will work as it should. If we run the code, it will work as we expected. If the player dies, then after 1 second a new room of the Stage starts and you can start the game again.score
attribute in the Stage class, which will track the points we collect. After the game is completed, this score will be saved somewhere, and we will be able to compare it with previous records. For now, we'll skip the part with the comparison of points and focus only on the basics. function Stage:new() ... self.score = 0 end
function Player:addAmmo(amount) self.ammo = math.min(self.ammo + amount, self.max_ammo) current_room.score = current_room.score + 50 end
addAmmo
), and then simply add here the code that changes the account. Just as we did for the function finish
, here we can access the Stage room through current_room
, because the Stage room is the only one that can be active in this case. function Stage:draw() love.graphics.setCanvas(self.main_canvas) love.graphics.clear() ... love.graphics.setFont(self.font) -- Score love.graphics.setColor(default_color) love.graphics.print(self.score, gw - 20, 10, 0, 1, 1, math.floor(self.font:getWidth(self.score)/2), self.font:getHeight()/2) love.graphics.setColor(255, 255, 255) love.graphics.setCanvas() ... end
depth
so that it is drawn on top of everything, or we can just draw over the Area in the canvas main_canvas
that the Stage room uses. I decided to choose the second method, but they both work.love.graphics.setFont
: function Stage:new() ... self.font = fonts.m5x7_16 end
function Stage:draw() ... love.graphics.setCanvas(self.main_canvas) love.graphics.clear() ... -- HP local r, g, b = unpack(hp_color) local hp, max_hp = self.player.hp, self.player.max_hp love.graphics.setColor(r, g, b) love.graphics.rectangle('fill', gw/2 - 52, gh - 16, 48*(hp/max_hp), 4) love.graphics.setColor(r - 32, g - 32, b - 32) love.graphics.rectangle('line', gw/2 - 52, gh - 16, 48, 4) love.graphics.setCanvas() end
gw/2 - 52, gh - 16
, and its width will be equal 48
. That is, both bars will be drawn relative to the center of the screen with a small gap of 8 pixels. From this we can also understand that the position of the bar on the right will be gw/2 + 4, gh - 16
.hp_color
, and its outline will be a rectangle with color hp_color - 32
. Since we cannot subtract from the table, we need to divide the table hp_color
into separate components and subtract from each.hp/max_hp
. For example, if it hp/max_hp
is 1, then HP is full. If 0.5, thenhp
has half the size max_hp
. If 0.25, then 1/4 of the size. And if we multiply this ratio by the width that the strip should have, then we get a beautiful visualization of the player’s HP fill. If we implement it, the game will look like this: function Stage:draw() ... love.graphics.setCanvas(self.main_canvas) love.graphics.clear() ... -- HP ... love.graphics.print('HP', gw/2 - 52 + 24, gh - 24, 0, 1, 1, math.floor(self.font:getWidth('HP')/2), math.floor(self.font:getHeight()/2)) love.graphics.setCanvas() end
gw/2 - 52 + 24
, that is, relative to the center of the strip, that is, we need to shift it to the width of this text typed in this font (and we do this with the help of the function getWidth
). function Stage:draw() ... love.graphics.setCanvas(self.main_canvas) love.graphics.clear() ... -- HP ... love.graphics.print(hp .. '/' .. max_hp, gw/2 - 52 + 24, gh - 6, 0, 1, 1, math.floor(self.font:getWidth(hp .. '/' .. max_hp)/2), math.floor(self.font:getHeight()/2)) love.graphics.setCanvas() end
gw/2 - 52, 16
.gw/2 + 4, 16
.gw/2 + 4, gh - 16
.— , , . , , ( ), .
, , , . , , .
all_colors
containing a list of all colors. These colors never change and never write to this table. At the same time, it is read from different objects, for example, when we need to get a random color.current_room
. Already her name itself implies some uncertainty, since the current room can be an object Stage
, or an object Console
, or an object SkillTree
, or any other type of Room object. I decided that for our game this would be an acceptable decrease in clarity.all_colors
interfere with us in the long run. As long as we monitor variables like current_room
and do not allow them to be too multiple or too confusing (for example, it current_room
changes only when a function is called gotoRoom
), we can control everything. local direction = table.random({-1, 1}) self.x = gw/2 + direction*(gw/2 + 48) self.y = random(16, gh - 16)
spawner_component = SpawnerComponent()
, and it will magically process for us all the logic of the spawn of objects. In our example, these are three lines, but the same logic applies to more complex behaviors. local direction = table.random({-1, 1}) self.x = gw/2 + direction*(gw/2 + 48) self.y = gh/2
function Rock:new(area, x, y, opts) ... 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 function Rock:update(dt) ... self.collider:setLinearVelocity(self.v, 0) end
max_value
, current_value
. HP , , .max_v
. mvspd_multiplier
1.5, 1.5 ( 50%). function Player:new(...) ... -- Boost self.max_boost = 100 self.boost = self.max_boost ... -- HP self.max_hp = 100 self.hp = self.max_hp -- Ammo self.max_ammo = 100 self.ammo = self.max_ammo ... end
function Player:new(...) ... -- Movement self.r = -math.pi/2 self.rv = 1.66*math.pi self.v = 0 self.base_max_v = 100 self.max_v = self.base_max_v self.a = 100 ... end
function Player:new(...) ... -- Cycle self.cycle_timer = 0 self.cycle_cooldown = 5 ... end
hp_multiplier
that initially has a value of 1, and then apply all the increases from the tree to this variable and at some point multiply it by max_hp
. Let's start with the first: function Player:new(...) ... -- Multipliers self.hp_multiplier = 1 end
tree[2] = {'HP', {'6% Increased HP', 'hp_multiplier', 0.06}}
HP
, has a description 6% Increased HP
and affects the variable hp_multiplier
at 0.06 (6%). There is a function treeToPlayer
that receives all 900 of these node definitions and applies them to the player object. It is also important to note that the variable name used in the definition of the node must be the same name that is defined in the player object, otherwise it will not work. This is a very subtly connected and error-prone way, but as I said in the previous part, you can put up with such things, because we write everything alone.hp_multiplier
bymax_hp
? The natural choice is to simply do it in the constructor, because it is in him that a new player is created, and a new player is created when creating a new Stage room, which also happens when a new game starts. However, we will do this at the very end of the constructor, after all the resources, factors, and probabilities are determined: function Player:new(...) ... -- treeToPlayer(self) self:setStats() end
setStats
we can do the following: function Player:setStats() self.max_hp = self.max_hp*self.hp_multiplier self.hp = self.max_hp end
hp_multiplier
value 1.5 and start the game, we note that the player will have 150 HP instead of 100.treeToPlayer
and transfer the player's object to this function. Later, when we write the skill tree code and implement this function, it will set the values ​​of all multipliers based on the bonuses from the tree, and after setting the values, we can call setStats
to use them to change player parameters.ammo_multiplier
.boost_multiplier
.flat_hp
that is added to max_hp
(before multiplying by a factor): function Player:new(...) ... -- Flats self.flat_hp = 0 end
function Player:setStats() self.max_hp = (self.max_hp + self.flat_hp)*self.hp_multiplier self.hp = self.max_hp end
tree[15] = {'Flat HP', {'+10 Max HP', 'flat_hp', 10}}
flat_ammo
.flat_boost
.ammo_gain
that is added to the amount of ammunition received when a player selects a resource. Change the calculations in the function accordingly addAmmo
.attack
value is assigned to the attribute 'Homing'
. The homing code will be the same as the code used for the Ammo resource: function Projectile:update(dt) ... self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) -- Homing if self.attack == 'Homing' then -- Move towards target if self.target then local projectile_heading = Vector(self.collider:getLinearVelocity()):normalized() local angle = math.atan2(self.target.y - self.y, self.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) end end end
target
. For an Ammo object, the variable target
points to the player's object, but in the case of a projectile, it will indicate the nearest enemy. To get the nearest enemy, we can use the function getAllGameObjectsThat
defined in the Area class and apply a filter that will select only objects that are enemies and are close enough. To do this, we must first determine which of the objects are enemies, and which objects are not. The easiest way to do this is to create a global table enemies
that will contain a list of rows with the names of the enemy classes. That is, globals.lua
we can add the following definition: enemies = {'Rock', 'Shooter'}
local targets = self.area:getAllGameObjectsThat(function(e) for _, enemy in ipairs(enemies) do if e:is(_G[enemy]) then return true end end end)
_G[enemy]
to access the class definition of the current row that we loop through. That is, _G['Rock']
returns a table containing the class definition Rock
. We looked at this in several parts of the tutorial, so you should already understand why this works. local targets = self.area:getAllGameObjectsThat(function(e) for _, enemy in ipairs(enemies) do if e:is(_G[enemy]) and (distance(ex, ey, self.x, self.y) < 400) then return true end end end)
distance
Is a function that we can define in utils.lua
. It returns the distance between two positions: function distance(x1, y1, x2, y2) return math.sqrt((x1 - x2)*(x1 - x2) + (y1 - y2)*(y1 - y2)) end
targets
. Then the only thing we need is to choose one of them at random and indicate it as the target
projectile is heading to: self.target = table.remove(targets, love.math.random(1, #targets))
function Projectile:update(dt) ... self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) -- Homing if self.attack == 'Homing' then -- Acquire new target if not self.target then local targets = self.area:getAllGameObjectsThat(function(e) for _, enemy in ipairs(enemies) do if e:is(_G[enemy]) and (distance(ex, ey, self.x, self.y) < 400) then return true end end end) self.target = table.remove(targets, love.math.random(1, #targets)) end if self.target and self.target.dead then self.target = nil end -- Move towards target if self.target then local projectile_heading = Vector(self.collider:getLinearVelocity()):normalized() local angle = math.atan2(self.target.y - self.y, self.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) end end end
self.target
value nil in the case when the target is killed. Due to this, when the target for the projectile ceases to exist, the self.target
value nil is assigned and a new target is obtained, because the condition will be satisfied not self.target
, after which the whole process is repeated. It is also important to mention that after receiving the goal, we do not do any more calculations, so there is no need to worry about the speed of the function getAllGameObjectsThat
, which naively bypasses in the loop all the living objects in the game.setLinearVelocity
to set the velocity of the projectile, and then reuse it inside the cycle if self.attack == 'Homing'
, since the speed will only change if the projectile is actually homing and if there is a target. But for some reason, this leads to all sorts of problems, so we have to call setLinearVelocity
only once, that is, write something like this: -- Homing if self.attack == 'Homing' then ... -- Normal movement else self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) end
attack
which is assigned a value 'Homing'
, then it should look like this:Homing
. Its definition in the attack table looks like this: attacks['Homing'] = {cooldown = 0.56, ammo = 4, abbreviation = 'H', color = skill_point_color}
skill_point_color
), which also has a track that has the color of the player.launch_homing_projectile_on_ammo_pickup_chance
, and when selecting a resource Ammo, we will call a function that “throws out on the dice” the probability of the event being executed.chanceList
. If an event has a probability of 5%, then we need to make sure that these 5% are observed fairly enough, so it is logical to use chanceList.setStats
in the Player constructor, we will also call a function generateChances
that will create all the chanceList lists that apply throughout the game. Since the game will have many different events for which you need to “roll a cube”, we will put all the chanceList lists in the table chances
and arrange everything so that when we need to “roll the cube” on the likelihood of doing something, we could do something like: if self.chances.launch_homing_projectile_on_ammo_pickup_chance:next() then -- launch homing projectile end
chances
manually, that is, each time we add a new type variable _chance
in which the probability of a certain event will be stored, we will also add and generate generateChances
its chanceList list in the function . But here we can do a little smarter and decide that every variable that deals with probabilities will end in _chance
, and use this to our advantage: function Player:generateChances() self.chances = {} for k, v in pairs(self) do if k:find('_chance') and type(v) == 'number' then end end end
_chance
, as well as being a number. If both of these conditions are true, then, based on our own decision, this is a variable related to the likelihood of an event. That is, we just need to create then the chanceList and add it to the table chances
: function Player:generateChances() self.chances = {} for k, v in pairs(self) do if k:find('_chance') and type(v) == 'number' then self.chances[k] = chanceList({true, math.ceil(v)}, {false, 100-math.ceil(v)}) end end end
v
of which will be true, and 100-v
- false. That is, if the only “probability” type variable defined in the player's object was launch_homing_projectile_on_ammo_pickup_chance
, and it has a value of 5 (meaning a 5% probability of making an event), then chanceList will have 5 true values ​​and 95 false values, which gives us the desired result.generateChances
for the player constructor: function Player:new(...) ... -- treeToPlayer(self) self:setStats() self:generateChances() end
launch_homing_projectile_on_ammo_pickup_chance
: function Player:new(...) ... -- Chances self.launch_homing_projectile_on_ammo_pickup_chance = 0 end
:next()
to see what happens.onAmmoPickup
that is called when selecting the Ammo resource: function Player:update(dt) ... if self.collider:enter('Collectable') then ... if object:is(Ammo) then object:die() self:addAmmo(5) self:onAmmoPickup() ... end end
function Player:onAmmoPickup() if self.chances.launch_homing_projectile_on_ammo_pickup_chance:next() then local d = 1.2*self.w self.area:addGameObject('Projectile', self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), {r = self.r, attack = 'Homing'}) self.area:addGameObject('InfoText', self.x, self.y, {text = 'Homing Projectile!'}) end end
regain_hp_on_ammo_pickup_chance
. The amount of HP recovered will be 25. It should be added using a function addHP
that adds the specified amount of HP to the value hp
, checking that it does not exceed max_hp
. In addition, an object must be created InfoText
with text 'HP Regain!'
and color hp_color
.regain_hp_on_sp_pickup_chance
. The amount of HP recovered will be 25. It should be added using a function addHP
that adds the specified amount of HP to the value hp
, checking that it does not exceed max_hp
. An object must also be created InfoText
with text 'HP Regain!'
and color hp_color
. In addition, you need to add a function to the Player class.onSPPickup
, and all the work must be done in it (in the same way as it was with the function onAmmoPickup
).aspd_multiplier
and then multiply this variable by the time of the “respite” between shooting: function Player:new(...) ... -- Multipliers self.aspd_multiplier = 1 end
function Player:update(dt) ... -- Shoot self.shoot_timer = self.shoot_timer + dt if self.shoot_timer > self.shoot_cooldown*self.aspd_multiplier then self.shoot_timer = 0 self:shoot() end end
shoot_cooldown
, smaller values ​​mean that the pause will be shorter, that is, the player will shoot faster. We use this knowledge when creating an object HasteArea
.aspd_multiplier
by a certain amount while the player is in it. To achieve this, we will create a new object HasteArea
that will control the logic to check whether the player is inside and set the appropriate values. The basic structure of this object is as follows: function HasteArea:new(...) ... self.r = random(64, 96) self.timer:after(4, function() self.timer:tween(0.25, self, {r = 0}, 'in-out-cubic', function() self.dead = true end) end) end function HasteArea:update(dt) ... end function HasteArea:draw() love.graphics.setColor(ammo_color) love.graphics.circle('line', self.x, self.y, self.r + random(-2, 2)) love.graphics.setColor(default_color) end
aspd_multiplier
when it happens. You can do it like this: function HasteArea:update(dt) ... local player = current_room.player if not player then return end local d = distance(self.x, self.y, player.x, player.y) if d < self.r and not player.inside_haste_area then -- Enter event player:enterHasteArea() elseif d >= self.r and player.inside_haste_area then -- Leave event player:exitHasteArea() end end
inside_haste_area
to track whether a player is in the area. This variable takes the value true inside enterHasteArea
and false inside exitHasteArea
, that is, these functions will be called only when these events occur in the object HasteArea
. In the Player class, both functions will simply apply the necessary modifications: function Player:enterHasteArea() self.inside_haste_area = true self.pre_haste_aspd_multiplier = self.aspd_multiplier self.aspd_multiplier = self.aspd_multiplier/2 end function Player:exitHasteArea() self.inside_haste_area = false self.aspd_multiplier = self.pre_haste_aspd_multiplier self.pre_haste_aspd_multiplier = nil end
HasteArea
, and not to link it with the player through a variable inside_haste_area
. We cannot do this because if we do, then problems will arise when the player simultaneously enters several areas at the same time or leaves them. In the current form, the existence of a variable inside_haste_area
means that we will apply the bonus only once, even if the player is on top of three intersecting HasteArea objects.spawn_haste_area_on_hp_pickup_chance
. The object InfoText
must be created with text.'Haste Area!'
.In addition, you need to add a function to the Player class onHPPickup
.spawn_haste_area_on_sp_pickup_chance
. The object InfoText
must be created with text.'Haste Area!'
.spawn_sp_on_cycle_chance
. We fully know how to implement it. The “in cycle” part behaves very much like “when selecting a resource”; the only difference is that we will call the function onCycle
when executing a new cycle, and not when selecting a resource. And the “creation of SP” part is just the creation of a new SP resource, the implementation of which is known to us.cycle
and call onCycle
: function Player:cycle() ... self:onCycle() end
spawn_sp_on_cycle_chance
: function Player:new(...) ... -- Chances self.spawn_sp_on_cycle_chance = 0 end
onCycle
: function Player:onCycle() if self.chances.spawn_sp_on_cycle_chance:next() then self.area:addGameObject('SkillPoint') self.area:addGameObject('InfoText', self.x, self.y, {text = 'SP Spawn!', color = skill_point_color}) end end
barrage_on_kill_chance
. The only thing that we do not know yet is the part with the “queue”. Triggering an event during a kill is similar to the previous one, except that instead of calling during the execution of a cycle, we will call the player’s function onKill
when the enemy dies.barrage_on_kill_chance
: function Player:new(...) ... -- Chances self.barrage_on_kill_chance = 0 end
onKill
and call it when the enemy dies. There are two approaches to challenge onKill
when a player dies. The first is to simply call a function from a function die
or hit
each enemy. The problem here is that when adding new enemies we will have to add the same calling code to all of them onKill
. The second option is to challenge onKill
if there is a collision with an enemy of the object Projectile. The problem here is that some projectiles can collide with enemies, but not kill them (because enemies have more HP or a projectile does less damage), so we need to find a way to check if the enemy is actually dead. It turns out that performing this check is quite simple, so I will choose this method: function Projectile:update(dt) ... if self.collider:enter('Enemy') then ... if object then object:hit(self.damage) self:die() if object.hp <= 0 then current_room.player:onKill() end end end end
hit
enemy is simply to check if the enemy's HP is zero. If it is equal, then it means that he is dead, and we can call onKill
. function Player:onKill() if self.chances.barrage_on_kill_chance:next() then for i = 1, 8 do self.timer:after((i-1)*0.05, function() local random_angle = random(-math.pi/8, math.pi/8) local d = 2.2*self.w self.area:addGameObject('Projectile', self.x + d*math.cos(self.r + random_angle), self.y + d*math.sin(self.r + random_angle), {r = self.r + random_angle, attack = self.attack}) end) end self.area:addGameObject('InfoText', self.x, self.y, {text = 'Barrage!!!'}) end end
after
to separate the creation of shells with a pause of 0.05 seconds. In all other respects, we simply create a projectile with given limitations. All this should look like this:InfoText
with the corresponding colors so that the player can understand what is happening.spawn_hp_on_cycle_chance
.regain_hp_on_cycle_chance
. The number of recovered HP should be equal to 25.regain_full_ammo_on_cycle_chance
.change_attack_on_cycle_chance
. New attack is chosen randomly.spawn_haste_area_on_cycle_chance
.barrage_on_cycle_chance
.launch_homing_projectile_on_cycle_chance
.regain_ammo_on_kill_chance
. The number of recovered ammunition should be equal to 20.launch_homing_projectile_on_kill_chance
.regain_boost_on_kill_chance
. The number of recovered acceleration should be equal to 40.spawn_boost_on_kill_chance
.HasteArea
. Now we want to implement another one in which we will have a chance to get an increase in the speed of attacks after killing an enemy. However, if we try to implement it in the same way as the previous acceleration of ASPD, we will soon encounter problems. To refresh your memories, I’ll give an example of how acceleration is implemented in HasteArea
: function HasteArea:update(dt) HasteArea.super.update(self, dt) local player = current_room.player if not player then return end local d = distance(self.x, self.y, player.x, player.y) if d < self.r and not player.inside_haste_area then player:enterHasteArea() elseif d >= self.r and player.inside_haste_area then player:exitHasteArea() end end
enterHasteArea
they exitHasteArea
look like this: function Player:enterHasteArea() self.inside_haste_area = true self.pre_haste_aspd_multiplier = self.aspd_multiplier self.aspd_multiplier = self.aspd_multiplier/2 end function Player:exitHasteArea() self.inside_haste_area = false self.aspd_multiplier = self.pre_haste_aspd_multiplier self.pre_haste_aspd_multiplier = nil end
aspd_boost_on_kill_chance
same way, it would look something like this: function Player:onKill() ... if self.chances.aspd_boost_on_kill_chance:next() then self.pre_boost_aspd_multiplier = self.aspd_multiplier self.aspd_multiplier = self.aspd_multiplier/2 self.timer:after(4, function() self.aspd_multiplier = self.pre_boost_aspd_multiplier self.pre_boost_aspd_multiplier = nil end) end end
aspd_multiplier
will be restored to the value before the ASPD accelerates, that is, when leaving the area, all other acceleration bonuses will speed up the attacks.pre_boost_aspd_multiplier
restore to a aspd_multiplier
value that does not take into account the increase in the HasteArea attack speed. But more importantly, when the player leaves HasteArea, he will have a constantly increased attack speed, because the stored attack speed at the entrance will be that increased by the acceleration of ASPD. function Player:new(...) ... self.base_aspd_multiplier = 1 self.aspd_multiplier = 1 self.additional_aspd_multiplier = {} end
aspd_multiplier
, we will have base_aspd_multiplier
and additional_aspd_multiplier
. The variable aspd_multiplier
will store the current factor taking into account all accelerations. base_aspd_multiplier
will contain the original multiplier, taking into account only the percentage increase. That is, if we get from the tree an increase in the attack speed by 50%, it will be applied in the constructor (c setStats
) to base_aspd_multiplier
. Then it additional_aspd_multiplier
will contain all the added values ​​of all accelerations. That is, when the player is in HasteArea, we will add the corresponding value to this table, and then in each frame multiply its amount by the base. Therefore, for example, the update function will be as follows: function Player:update(dt) ... self.additional_aspd_multiplier = {} if self.inside_haste_area then table.insert(self.additional_aspd_multiplier, -0.5) end if self.aspd_boosting then table.insert(self.additional_aspd_multiplier, -0.5) end local aspd_sum = 0 for _, aspd in ipairs(self.additional_aspd_multiplier) do aspd_sum = aspd_sum + aspd end self.aspd_multiplier = self.base_aspd_multiplier/(1 - aspd_sum) end
aspd_multiplier
in accordance with the value of the base, as well as all the accelerations. We will have a few more factors that function in a very similar way, so I will create a common object for this, since repeating the code with different variable names every time will be tedious.Stat
looks like this: Stat = Object:extend() function Stat:new(base) self.base = base self.additive = 0 self.additives = {} self.value = self.base*(1 + self.additive) end function Stat:update(dt) for _, additive in ipairs(self.additives) do self.additive = self.additive + additive end if self.additive >= 0 then self.value = self.base*(1 + self.additive) else self.value = self.base/(1 - self.additive) end self.additive = 0 self.additives = {} end function Stat:increase(percentage) table.insert(self.additives, percentage*0.01) end function Stat:decrease(percentage) table.insert(self.additives, -percentage*0.01) end
function Player:new(...) ... self.aspd_multiplier = Stat(1) end function Player:update(dt) ... if self.inside_haste_area then self.aspd_multiplier:decrease(100) end if self.aspd_boosting then self.aspd_multiplier:decrease(100) end self.aspd_multiplier:update(dt) ... end
aspd_multiplier:update
, referring to aspd_multiplier.value
, which will return the correct result in accordance with the base and all the possible accelerations. Therefore, we must change the way the variable is used aspd_multiplier
: function Player:update(dt) ... -- Shoot self.shoot_timer = self.shoot_timer + dt if self.shoot_timer > self.shoot_cooldown*self.aspd_multiplier.value then self.shoot_timer = 0 self:shoot() end end
self.shoot_cooldown*self.aspd_multiplier
on self.shoot_cooldown*self.aspd_multiplier.value
, because otherwise it will not work anything. In addition, we have to change something else here. The way the variable works is aspd_multiplier
so far contrary to how all the other variables of the game work. When we say that we have increased HP by 10%, we know that it hp_multiplier
is equal to 1.1, but when we say that we have increased by 10% ASPD, it aspd_multiplier
is actually equal to 0.9. We can change this and make it aspd_multiplier
behave in the same way as other variables, with the division instead of multiplying shoot_cooldown
: if self.shoot_timer > self.shoot_cooldown/self.aspd_multiplier.value then
decrease
we will call increase
: function Player:update(dt) ... if self.inside_haste_area then self.aspd_multiplier:increase(100) end if self.aspd_boosting then self.aspd_multiplier:increase(100) end self.aspd_multiplier:update(dt) end
aspd_multiplier
is an object Stat
, and not just a number, then when you implement the tree and import its values ​​into the Player object, we will have to process them in a different way. Therefore, in the above function, treeToPlayer
we will have to take this into account. function Player:new(...) ... -- Chances self.gain_aspd_boost_on_kill_chance = 0 end
function Player:onKill() ... if self.chances.gain_aspd_boost_on_kill_chance:next() then self.aspd_boosting = true self.timer:after(4, function() self.aspd_boosting = false end) self.area:addGameObject('InfoText', self.x, self.y, {text = 'ASPD Boost!', color = ammo_color}) end end
enterHasteArea
and exitHasteArea
, and also slightly change the operation of the HasteArea object: function HasteArea:update(dt) HasteArea.super.update(self, dt) local player = current_room.player if not player then return end local d = distance(self.x, self.y, player.x, player.y) if d < self.r then player.inside_haste_area = true elseif d >= self.r then player.inside_haste_area = false end end
inside_haste_area
Player object attribute to true or false depending on whether it is in the region, and then, due to the way we implemented the object Stat
, the application of the acceleration rate of attacks received from HasteArea will be performed automatically.mvspd_boost_on_cycle_chance
. “Increasing MVSPD” gives the player a 50 percent increase in movement speed for 4 seconds. Also implement the variable mvspd_multiplier
and multiply it in the appropriate place.pspd_boost_on_cycle_chance
. “Increasing PSPD” gives the shells created by the player a 100 percent increase in speed by 4 seconds. Also, implement the variablepspd_multiplier
and multiply it in the appropriate place.pspd_inhibit_on_cycle_chance
. “Reducing PSPD” gives the shells created by the player a 50 percent reduction in speed by 4 seconds.launch_homing_projectile_while_boosting_chance
. It will work like this: there is a normal probability of a shot by a homing projectile, and this probability will be checked in the interval of 0.2 seconds when performing acceleration (Boost). This means that if we accelerate within 1 second, “probability cubes” will be thrown 5 times.onBoostStart
andonBoostEnd
. They will activate the passive skill at the start of acceleration and deactivate it at the end of acceleration. To add these two functions, we need to change the acceleration code slightly: function Player:update(dt) ... -- Boost ... if self.boost_timer > self.boost_cooldown then self.can_boost = true end ... if input:pressed('up') and self.boost > 1 and self.can_boost then self:onBoostStart() end if input:released('up') then self:onBoostEnd() end if input:down('up') and self.boost > 1 and self.can_boost then ... if self.boost <= 1 then self.boosting = false self.can_boost = false self.boost_timer = 0 self:onBoostEnd() end end if input:pressed('down') and self.boost > 1 and self.can_boost then self:onBoostStart() end if input:released('down') then self:onBoostEnd() end if input:down('down') and self.boost > 1 and self.can_boost then ... if self.boost <= 1 then self.boosting = false self.can_boost = false self.boost_timer = 0 self:onBoostEnd() end end ... end
input:pressed
and input:released
that return true only when these events occur, and thanks to this we can be sure that onBoostStart
they onBoostEnd
will only be called when these events occur. We also add inside the conditional design input:down
onBoostEnd
in case the player does not release the button, but the amount of acceleration available to him ends, and therefore the acceleration ends.launch_homing_projectile_while_boosting_chance
: function Player:new(...) ... -- Chances self.launch_homing_projectile_while_boosting_chance = 0 end function Player:onBoostStart() self.timer:every('launch_homing_projectile_while_boosting_chance', 0.2, function() if self.chances.launch_homing_projectile_while_boosting_chance:next() then local d = 1.2*self.w self.area:addGameObject('Projectile', self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), {r = self.r, attack = 'Homing'}) self.area:addGameObject('InfoText', self.x, self.y, {text = 'Homing Projectile!'}) end end) end function Player:onBoostEnd() self.timer:cancel('launch_homing_projectile_while_boosting_chance') end
timer:every
to test the probability of firing a homing projectile every 0.2 seconds, and then, when the acceleration ends, we cancel this timer. Here is what it looks like when the probability of an event is 100%:cycle_speed_multiplier
. This variable, depending on its value, increases or decreases the speed of the cycle. That is, for example, if it cycle_speed_multiplier
is equal to 2, and the cycle duration defaults to 5 seconds, then the use of a variable will lead to a decrease in the cycle duration to 2.5 seconds.increased_cycle_speed_while_boosting
. This variable should be of type boolean and signal whether the speed of the cycle should increase when the player performs the acceleration. Acceleration should be an increase in cycle speed multiplier by 200%.invulnerability_while_boosting
. This variable is of type boolean and indicates whether the player should be invulnerable during acceleration. Use the existing attribute invincible
that is responsible for the invulnerability of the player.luck_multiplier
. Luck is one of the basic parameters of the game; it increases the likelihood of performing the desired events. Suppose we have a 10 percent chance of hitting a projectile with a kill. If it luck_multiplier
is equal to 2, then this probability becomes 20 percent.generateChances
, so we can simply implement it here: function Player:generateChances() self.chances = {} for k, v in pairs(self) do if k:find('_chance') and type(v) == 'number' then self.chances[k] = chanceList( {true, math.ceil(v*self.luck_multiplier)}, {false, 100-math.ceil(v*self.luck_multiplier)}) end end end
v
by luck_multiplier
and it should work exactly as it should. Due to this, we can realize the passive skill increased_luck_while_boosting
as follows: function Player:onBoostStart() ... if self.increased_luck_while_boosting then self.luck_boosting = true self.luck_multiplier = self.luck_multiplier*2 self:generateChances() end end function Player:onBoostEnd() ... if self.increased_luck_while_boosting and self.luck_boosting then self.luck_boosting = false self.luck_multiplier = self.luck_multiplier/2 self:generateChances() end end
HasteArea
. We can do it now because we will not have any other passive skills that give the player an increase in luck, that is, we don’t need to worry about a few bonuses that can override each other. If we had a few passive skills that give an increase in luck, we would have to make them an object Stat
, as was the case with aspd_multiplier
.generateChances
Otherwise, our increase in luck will not affect anything. Such a solution has a flaw - all lists are reset, so if a list accidentally selected a series of unsuccessful “throws” and then was reset, then again it can re-select a series of unsuccessful “throws” instead of using the chanceList property, in which over time the selection of less successful shots becomes less likely. But this is a very frivolous problem, which personally does not bother me very much.hp_spawn_chance_multiplier
which increases the likelihood that when the director creates a new resource, this resource will be HP. If you remember how the director works, then this implementation will be pretty simple: function Player:new(...) ... -- Multipliers self.hp_spawn_chance_multiplier = 1 end
function Director:new(...) ... self.resource_spawn_chances = chanceList({'Boost', 28}, {'HP', 14*current_room.player.hp_spawn_chance_multiplier}, {'SkillPoint', 58}) end
resource_spawn_chances
, so all we need is to use hp_spawn_chance_multiplier
to increase the likelihood that the HP resource will be created in accordance with the multiplier.spawn_sp_chance_multiplier
.spawn_boost_chance_multiplier
.drop_double_ammo_chance
. When an enemy dies, there must be a chance that he will create two Ammo objects instead of one.attack_twice_chance
. When a player attacks, there must be a probability of calling the function shoot
twice.spawn_double_hp_chance
. When a director creates an HP resource, there must be a chance that instead of one, he will create two HP objects.spawn_double_sp_chance
When a director creates a SkillPoint resource, there must be a chance that he will create two instead of one SkillPoint object.gain_double_sp_chance
. When a player selects a SkillPoint resource, there must be a chance that he will get two skill points instead of one.enemy_spawn_rate_multiplier
will control how quickly Director changes the difficulty levels. By default, this happens every 22 seconds, but if it enemy_spawn_rate_multiplier
is 2, then it will happen every 11 seconds. The implementation of this is also quite simple: function Player:new(...) ... -- Multipliers self.enemy_spawn_rate_multiplier = 1 end
function Director:update(dt) ... -- Difficulty self.round_timer = self.round_timer + dt if self.round_timer > self.round_duration/self.stage.player.enemy_spawn_rate_multiplier then ... end end
round_duration
by enemy_spawn_rate_multiplier
to get the desired duration of the round.resource_spawn_rate_multiplier
.attack_spawn_rate_multiplier
.turn_rate_multiplier
. This passive skill increases or decreases the turning speed of a player’s ship.boost_effectiveness_multiplier
. This passive skill increases or decreases acceleration effectiveness. This means that if a variable has a value of 2, then acceleration will work twice as fast or slower.projectile_size_multiplier
. This is a passive skill that increases or decreases the size of projectiles.boost_recharge_rate_multiplier
. This is a passive skill that increases or decreases the acceleration recharge rate.invulnerability_time_multiplier
. This is a passive skill that increases or decreases the player’s invulnerability time when damage is dealt.ammo_consumption_multiplier
. This passive skill increases or decreases the amount of ammunition consumed during all attacks.size_multiplier
. This passive skill increases or decreases the size of the player’s ship. It should be noted that the positions of all traces of all ships, as well as the positions of projectiles must be changed accordingly.stat_boost_duration_multiplier
. This passive skill increases or decreases the duration of the time bonuses given to the player.EnemyProjectile
, after which we will be able to create enemies using some of these skills. For example, there is a passive skill that causes projectiles to rotate around a ship, rather than fly straight. Later we will add an enemy around whom piles of shells will fly, and in both cases the same technology will be used.projectile_ninety_degree_change
. He will periodically change the angle of the projectile to 90 degrees. It will look like this:projectile_ninety_degree_change
boolean variable that will affect when true. Since we are going to apply this effect in the class Projectile
, we have two options on how to read the value from it projectile_ninety_degree_change
from the Player: either transfer it to the table opts
when creating a new projectile as a function shoot
, or read it directly from the Player, accessing throughcurrent_room.player
. I will take the second path, because it is simpler and there are no serious flaws in it, except that we will have to replace current_room.player
it with something else when we move part of this code to EnemyProjectile
. All this will look something like this: function Player:new(...) ... -- Booleans self.projectile_ninety_degree_change = false end
function Projectile:new(...) ... if current_room.player.projectile_ninety_degree_change then end end
function Projectile:new(...) ... if current_room.player.projectile_ninety_degree_change then self.timer:after(0.2, function() self.ninety_degree_direction = table.random({-1, 1}) self.r = self.r + self.ninety_degree_direction*math.pi/2 end) end end
timer:every
: function Projectile:new(...) ... if current_room.player.projectile_ninety_degree_change then self.timer:after(0.2, function() self.ninety_degree_direction = table.random({-1, 1}) self.r = self.r + self.ninety_degree_direction*math.pi/2 self.timer:every('ninety_degree_first', 0.25, function() self.r = self.r - self.ninety_degree_direction*math.pi/2 self.timer:after('ninety_degree_second', 0.1, function() self.r = self.r - self.ninety_degree_direction*math.pi/2 self.ninety_degree_direction = -1*self.ninety_degree_direction end) end) end) end end
every
we change the direction in which it should turn, otherwise it will not fluctuate between the up / down directions and will move up / down rather than in a straight line. By implementing this, we get the following:projectile_random_degree_change
that changes the angle of the projectile at random. Unlike turns at 90 degrees, shells in this case should not restore their original direction.angle_change_frequency_multiplier
. This skill increases or decreases the rate of change of the angles of the previous two passive skills. If angle_change_frequency_multiplier
, for example, is 2, then instead of changing the angles after 0.25 and 0.1 seconds, they will change after 0.125 and 0.05 seconds.timer:tween
, thus obtaining the effect of a wave projectile:timer:tween
: function Projectile:new(...) ... if current_room.player.wavy_projectiles then local direction = table.random({-1, 1}) self.timer:tween(0.25, self, {r = self.r + direction*math.pi/8}, 'linear', function() self.timer:tween(0.25, self, {r = self.r - direction*math.pi/4}, 'linear') end) self.timer:every(0.75, function() self.timer:tween(0.25, self, {r = self.r + direction*math.pi/4}, 'linear', function() self.timer:tween(0.5, self, {r = self.r - direction*math.pi/4}, 'linear') end) end) end end
timer:every
, in the code it does not begin to perform its functions until the end of the initial time, so we first perform one iteration of the loop manually, and then execute each loop. In the first iteration, we also use the initial value of math.pi / 8 instead of math.pi / 4, because we want the projectile to oscillate two times smaller than necessary, because it is initially in the middle position (as it was just shot Player), and not on one of the oscillation boundaries.projectile_waviness_multiplier
. This skill increases or decreases the target angle that the projectile should reach when performing tween. For example, if If projectile_waviness_multiplier
is 2, then the arc of its trajectory will be twice the normal. function Projectile:new(...) ... if current_room.player.fast_slow then local initial_v = self.v self.timer:tween('fast_slow_first', 0.2, self, {v = 2*initial_v}, 'in-out-cubic', function() self.timer:tween('fast_slow_second', 0.3, self, {v = initial_v/2}, 'linear') end) end if current_room.player.slow_fast then local initial_v = self.v self.timer:tween('slow_fast_first', 0.2, self, {v = initial_v/2}, 'in-out-cubic', function() self.timer:tween('slow_fast_second', 0.3, self, {v = 2*initial_v}, 'linear') end) end end
projectile_acceleration_multiplier
. This skill controls the amount of acceleration as the speed increases from the original value.projectile_deceleration_multiplier
. This skill controls the amount of inhibition when the speed decreases from the initial value. Ax = Bx + R*math.cos(time) Ay = By + R*math.sin(time)
time
is a variable whose value increases with time. Before proceeding with the implementation, let's prepare everything else. shield_projectile_chance
the variable will not be a boolean, but a variable of the “probability” type, that is, each time a new projectile is created, there will appear a probability that it will begin to rotate around the player. function Player:new(...) ... -- Chances self.shield_projectile_chance = 0 end function Player:shoot() ... local shield = self.chances.shield_projectile_chance:next() 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, attack = self.attack, shield = shield}) ... end
shield
for which the die rolls on whether this projectile should rotate around the player, after which we transfer it to the opts
challenge table addGameObject
. Here we need to repeat this step for each of the available attacks. Since we will have similar changes in the future, we can do something like this instead: function Player:shoot() ... local mods = { shield = self.chances.shield_projectile_chance:next() } 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), table.merge({r = self.r, attack = self.attack}, mods)) ... end
mods
. The function table.merge
is not yet defined, but based on how we use it here, you can guess what it does. function table.merge(t1, t2) local new_table = {} for k, v in pairs(t2) do new_table[k] = v end for k, v in pairs(t1) do new_table[k] = v end return new_table end
shield
. First, we want to define variables such as radius, rotational speed, and so on. For now, I define them as follows: function Projectile:new(...) ... if self.shield then self.orbit_distance = random(32, 64) self.orbit_speed = random(-6, 6) self.orbit_offset = random(0, 2*math.pi) end end
orbit_distance
denotes the radius around the player. orbit_speed
will be multiplied by time
, that is, at larger absolute values, the projectile will move faster, and at smaller values, it will move more slowly. Negative values ​​cause the projectile to move in a different direction, which adds a bit of randomness. orbit_offset
- this is the initial angular displacement that each projectile has. It also adds a bit of randomness and does not allow all shells to be created in approximately one position. And now, when we have determined all this, we can apply the parametric equation of a circle to the position of the projectile: function Projectile:update(dt) ... -- Shield if self.shield then local player = current_room.player self.collider:setPosition( player.x + self.orbit_distance*math.cos(self.orbit_speed*time + self.orbit_offset), player.y + self.orbit_distance*math.sin(self.orbit_speed*time + self.orbit_offset)) end ... end
setLinearVelocity
, otherwise nothing will work. We also need to remember to add a global variable time
and increment it in each frame by dt
. If we do everything right, it will look like this: function Projectile:new(...) ... self.previous_x, self.previous_y = self.collider:getPosition() end function Projectile:update(dt) ... -- Shield if self.shield then ... local x, y = self.collider:getPosition() local dx, dy = x - self.previous_x, y - self.previous_y self.r = Vector(dx, dy):angle() end ... -- At the very end of the update function self.previous_x, self.previous_y = self.collider:getPosition() end
r
in which the angle of the projectile will be stored, taken into account during rotation. Since we use setLinearVelocity
this angle, when the projectile is drawn in Projectile:draw
and used Vector(self.collider:getLinearVelocity()):angle())
to get direction, everything will be set according to how the variable is set r
. And all this will look like this: function Projectile:new(...) ... if self.shield then ... self.invisible = true self.timer:after(0.05, function() self.invisible = false end) end end function Projectile:draw() if self.invisible then return end ... end
function Projectile:new(...) ... if self.shield then ... self.timer:after(6, function() self:die() end) end end
Source: https://habr.com/ru/post/350316/
All Articles