📜 ⬆️ ⬇️

Creating a game on Lua and LĂ–VE - 5

image

Table of contents



13. Skill Tree

14. Console

15. Final
')

Part 12: Other Passive Skills


Volley


We will start with the implementation of the remaining attacks. The first is the Blast attack, which looks like this:

Gif

Several shells are shot at different speeds, like a shotgun, which then quickly disappear. All colors are taken from the table of negative_colors and each shell deals less damage than usual. This is how the attack table will look like:

 attacks['Blast'] = {cooldown = 0.64, ammo = 6, abbreviation = 'W', color = default_color} 

And here is the process of creating shells:

 function Player:shoot() ... elseif self.attack == 'Blast' then self.ammo = self.ammo - attacks[self.attack].ammo for i = 1, 12 do local random_angle = random(-math.pi/6, math.pi/6) self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r + random_angle), self.y + 1.5*d*math.sin(self.r + random_angle), table.merge({r = self.r + random_angle, attack = self.attack, v = random(500, 600)}, mods)) end camera:shake(4, 60, 0.4) end ... end 

Here we simply create 12 rounds with a random angle in the range from -30 to +30 degrees from the direction in which the player moves. We also randomize the speed in the range of 500 and 600 (usually its value is 200), that is, the projectile will be about three times faster than usual.

However, this will not give us the desired behavior, because we want the shells to disappear quickly enough. This can be implemented as follows:

 function Projectile:new(...) ... if self.attack == 'Blast' then self.damage = 75 self.color = table.random(negative_colors) self.timer:tween(random(0.4, 0.6), self, {v = 0}, 'linear', function() self:die() end) end ... end 

Here are three actions. First, we set a value less than 100 for damage. This means that in order to kill an ordinary enemy with 100 HP, we need not one, but two projectiles. This is logical, because during this attack, 12 shells are fired simultaneously. Secondly, we set the color of the projectile by randomly selecting it from the negative_colors table. This is the place where the code is convenient for us to do this. Finally, we report that after a random interval of time from 0.4 to 0.6 seconds, this projectile should be destroyed, which will give us the desired effect. In addition, we do not just destroy the projectile, but reduce its speed to 0, because it looks a little better.

All this creates the behavior we need and it seems that we have already finished. However, after adding a heap of passive skills in the previous part of the article, we need to be careful and make sure that everything added after will normally be combined with these passive skills. For example, the last in the previous part, we added the effect of a shell shield. The problem with the Blast attack is that it is completely incompatible with the effect of a shield projectile, because Blast projectiles die in 0.4-0.6 seconds, which makes them very bad projectiles for a shield.

One way to solve this problem is to separate the interfering passive skills (in our case, the shield) and apply our own logic to each situation. In a situation where the shield value for the projectile is true, then the projectile, independently of the rest, must exist for 6 seconds. And in all other situations the duration specified by the attack will be preserved. Here is what it will look like:

 function Projectile:new(...) ... if self.attack == 'Blast' then self.damage = 75 self.color = table.random(negative_colors) if not self.shield then self.timer:tween(random(0.4, 0.6), self, {v = 0}, 'linear', function() self:die() end) end end if self.shield then ... self.timer:after(6, function() self:die() end) end ... end 

This solution seems like a hack, and you can easily imagine that it will gradually become more complex with the addition of new passive skills, and we will have to add more and more conditions. But based on my experience, this method is the simplest and least error prone than all the others. You can try to solve this problem in a different, more general way, and this will usually have unintended consequences. Perhaps there is a better general solution to this problem, which I personally did not think of, but if I did not find it, then the next best solution would be the simplest one, namely the set of conditional structures that determine what can and cannot be done. Be that as it may, now every new added attack that changes the duration of the projectile's life, we will precede the condition if not self.shield .

172. (CONTENT) Implement the passive skill projectile_duration_multiplier . Remember to use it for all duration-related behaviors of the Projectile class.

Rotation


The next realizable attack will be Spin. It looks like this:

Gif

These shells constantly change the angle to a constant value. We can do this by adding the variable rv , which will designate the rate of change of the angle, and then add in each frame this value to r :

 function Projectile:new(...) ... self.rv = table.random({random(-2*math.pi, -math.pi), random(math.pi, 2*math.pi)}) end function Projectile:update(dt) ... if self.attack == 'Spin' then self.r = self.r + self.rv*dt end ... end 

We choose between intervals from -2 * math.pi to -math.pi OR between intervals from math.pi to 2 * math.pi because we do not want absolute values ​​to be less than math.pi or greater than 2 * math.pi . Low absolute values ​​mean that the circle performed by the projectile becomes larger, and large absolute values ​​mean that the circle becomes smaller. We want to limit the size of the circle with the values ​​we need to make it look right. It should also be understood that the difference between negative and positive values ​​lies in the direction in which the circle rotates.

In addition, we can add spins to the shells for a long life, because we do not want them to exist forever:

 function Projectile:new(...) ... if self.attack == 'Spin' then self.timer:after(random(2.4, 3.2), function() self:die() end) end end 

This is how the shoot function will look like:

 function Player:shoot() ... elseif self.attack == 'Spin' then self.ammo = self.ammo - attacks[self.attack].ammo 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 end 

And here is the attack table:

 attacks['Spin'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Sp', color = hp_color} 

This way we get the behavior we need. However, we need one more thing - the trail of the projectile. Unlike the self-guided projectile, which uses the same trail used for the player’s ships, this projectile trail will follow the shape and color of the projectile, but will also slowly become invisible until it completely disappears. We can do this in the same way as we did for another object of the track, but taking into account these differences:

 ProjectileTrail = GameObject:extend() function ProjectileTrail:new(area, x, y, opts) ProjectileTrail.super.new(self, area, x, y, opts) self.alpha = 128 self.timer:tween(random(0.1, 0.3), self, {alpha = 0}, 'in-out-cubic', function() self.dead = true end) end function ProjectileTrail:update(dt) ProjectileTrail.super.update(self, dt) end function ProjectileTrail:draw() pushRotate(self.x, self.y, self.r) local r, g, b = unpack(self.color) love.graphics.setColor(r, g, b, self.alpha) love.graphics.setLineWidth(2) love.graphics.line(self.x - 2*self.s, self.y, self.x + 2*self.s, self.y) love.graphics.setLineWidth(1) love.graphics.setColor(255, 255, 255, 255) love.graphics.pop() end function ProjectileTrail:destroy() ProjectileTrail.super.destroy(self) end 

And it looks quite standard, the only noticeable aspect is that we have the alpha variable, which we change through tween to 0, so that the projectile slowly disappears after a random period of time from 0.1 to 0.3 seconds, and then we draw the trace in the same way how to draw a shell. It is important that we use the variables r , s and color parent projectile, that is, when creating it, we need to transfer them all:

 function Projectile:new(...) ... if self.attack == 'Spin' then self.rv = table.random({random(-2*math.pi, -math.pi), random(math.pi, 2*math.pi)}) self.timer:after(random(2.4, 3.2), function() self:die() end) self.timer:every(0.05, function() self.area:addGameObject('ProjectileTrail', self.x, self.y, {r = Vector(self.collider:getLinearVelocity()):angle(), color = self.color, s = self.s}) end) end ... end 

In this way we will achieve the results we need.

173. (CONTENT) Implement the Flame attack. Here's what the attack table should look like:

 attacks['Flame'] = {cooldown = 0.048, ammo = 0.4, abbreviation = 'F', color = skill_point_color} 

And here is the attack itself:

Gif

Projectiles must remain alive for a random interval of time from 0.6 to 1 second and resemble Blast projectiles, and their speed must be changed during this time using tween to 0. These projectiles also use the ProjectileTrail object in the same way as Spin projectiles do. Each of the Flame shells also deals reduced damage by 50 units.

Bouncing shells


Bounce projectiles should bounce off the walls, not collapse them. By default, the Bounce projectile can bounce off the walls 4 times before being destroyed on another blow to the wall. We can set this using the opts table in the shoot function:

 function Player:shoot() ... elseif self.attack == 'Bounce' then self.ammo = self.ammo - attacks[self.attack].ammo 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, bounce = 4}, mods)) end end 

Thus, the bounce variable will contain the number of bounces left by the projectile. We can use it by decreasing by 1 with each blow to the wall:

 function Projectile:update(dt) ... -- Collision if self.bounce and self.bounce > 0 then if self.x < 0 then self.r = math.pi - self.r self.bounce = self.bounce - 1 end if self.y < 0 then self.r = 2*math.pi - self.r self.bounce = self.bounce - 1 end if self.x > gw then self.r = math.pi - self.r self.bounce = self.bounce - 1 end if self.y > gh then self.r = 2*math.pi - self.r self.bounce = self.bounce - 1 end else if self.x < 0 then self:die() end if self.y < 0 then self:die() end if self.x > gw then self:die() end if self.y > gh then self:die() end end ... end 

Here, in addition to reducing the number of remaining rebounds, we also change the direction of the projectile, taking into account the wall that it hit. Perhaps there is a more general way to do this, but I could only come up with a solution that takes into account the collision with each wall separately, after which the necessary calculations are performed to correctly reflect / reflect the angle of the projectile. Note that when bounce is 0, the first conditional construction is skipped and we go to the usual path, which leads to the destruction of the projectile.

It is also important to arrange all this collision code before calling setLinearVelocity , otherwise bounces will not work, because we will turn the projectile with a delay of one frame, but simply reversing its angle will not cause it to go back. For the sake of security, besides turning the angle of the projectile, we can also use setPosition to force it to be positioned, but I do not think this is necessary.

The colors of the bouncing projectile will be random, as is the case with the Spread projectile, except that they are taken from the default_colors table. This means that we need to take care of them in the Projectile:draw function Projectile:draw separately:

 function Projectile:draw() ... if self.attack == 'Bounce' then love.graphics.setColor(table.random(default_colors)) end ... end 

The attack table looks like this:

 attacks['Bounce'] = {cooldown = 0.32, ammo = 4, abbreviation = 'Bn', color = default_color} 

And all this should look like this:

Gif

174. (CONTENT) Implement the 2Split attack. Here is what it looks like:

Gif

It looks exactly like the Homing shell, only using the color ammo_color .

When the projectile hits the enemy, it is divided into two (two projectiles are created) at angles of + -45 degrees from the direction of the original projectile. If the projectile hits the wall, then two projectiles are created or with a reflection angle from the wall (that is, if the projectile hits the upper wall, then two projectiles are created aimed at math.pi / 4 and 3 * math.pi / 4) or the opposite angle reflections of the projectile, you can choose yourself. Here is the table of this attack:

 attacks['2Split'] = {cooldown = 0.32, ammo = 3, abbreviation = '2S', color = ammo_color} 

175. (CONTENT) Implement the 4Split attack. Here is what it looks like:

Gif

It behaves in the same way as the attack of 2Split, only creates not 2, but 4 projectiles. The shells are sent at all angles of 45 degrees from the center, that is, math.pi / 4, 3 * math.pi / 4, -math.pi / 4 and -3 * math.pi / 4. This is how the attack table looks like:

 attacks['4Split'] = {cooldown = 0.4, ammo = 4, abbreviation = '4S', color = boost_color} 

Lightning


Here's what the Lightning attack looks like:

Gif

When a player reaches a certain distance to the enemy, a lightning bolt is created, causing damage to the enemy. Most of the work here is to create a lightning bolt, so we’ll look at it first. We implement it by creating a LightningLine object, which will be a visual representation of the lightning charge:

 LightningLine = GameObject:extend() function LightningLine:new(area, x, y, opts) LightningLine.super.new(self, area, x, y, opts) ... self:generate() end function LightningLine:update(dt) LightningLine.super.update(self, dt) end -- Generates lines and populates the self.lines table with them function LightningLine:generate() end function LightningLine:draw() end function LightningLine:destroy() LightningLine.super.destroy(self) end 

I will focus on the rendering function and leave the creation of lightning lines for you! This tutorial describes the generation method in great detail, so I will not repeat it here. We assume that all the lines that make up the lightning bolt are in the self.lines table, and that each line is a table containing the keys x1, y1, x2, y2 . With this in mind, we can in the simplest way draw a lightning bolt like this:

 function LightningLine:draw() for i, line in ipairs(self.lines) do love.graphics.line(line.x1, line.y1, line.x2, line.y2) end end 

However, it looks too easy. Therefore, we need to first draw these lines with the color boost_color and with a line thickness of 2.5, and then on top of them we will draw the same lines again, but with the color default_color and line thickness 1.5. This will make the lightning bolt a little thicker and more like a zipper.

 function LightningLine:draw() for i, line in ipairs(self.lines) do local r, g, b = unpack(boost_color) love.graphics.setColor(r, g, b, self.alpha) love.graphics.setLineWidth(2.5) love.graphics.line(line.x1, line.y1, line.x2, line.y2) local r, g, b = unpack(default_color) love.graphics.setColor(r, g, b, self.alpha) love.graphics.setLineWidth(1.5) love.graphics.line(line.x1, line.y1, line.x2, line.y2) end love.graphics.setLineWidth(1) love.graphics.setColor(255, 255, 255, 255) end 

In addition, I use the alpha attribute here, which is initially equal to 255 and is reduced by tween to 0 over the lifetime of the line, that is, approximately 0.15 seconds.

We now turn to the creation of this object LightningLine. We want this attack to work like this: when a player gets close enough to the enemy within his immediate visibility, the attack is triggered and we do damage to the enemy. So let's start by getting all the enemies close to the player. We can do it in the same way as in the case of the homing projectile, which picked up the target in a certain radius. However, we want the radius not to be centered on the player, because the player should not be able to inflict damage on the enemies behind him, so we will shift the center of this circle forward a little, in the direction of the player’s movement, after which we will perform actions.

 function Player:shoot() ... elseif self.attack == 'Lightning' then local x1, y1 = self.x + d*math.cos(self.r), self.y + d*math.sin(self.r) local cx, cy = x1 + 24*math.cos(self.r), y1 + 24*math.sin(self.r) ... end 

Here we define x1, y1 , that is, the position from which we generally fire projectiles (at the very nose of the ship), and then we also determine cx, cy , that is, the center of the radius, which we will use to search for the nearest enemy. We shift the circle by 24 units, which is quite a lot so that he cannot choose the enemies behind the player.

The next thing we can do is just copy the code we used in the Projectile object, when we wanted the self-guided shells to find their goals, but change it to fit our needs, replacing the position of the circle with our cx, cy center of the circle:

 function Player:shoot() ... elseif self.attack == 'Lightning' then ... -- Find closest enemy local nearby_enemies = self.area:getAllGameObjectsThat(function(e) for _, enemy in ipairs(enemies) do if e:is(_G[enemy]) and (distance(ex, ey, cx, cy) < 64) then return true end end end) ... end 

After that we will get a list of enemies within a radius of 64 units of a circle, located 24 units ahead of the player. Here we can either choose the enemy at random, or take the nearest one. We will focus on the last option, that is, for this we need to sort the table based on the distance from each of the enemies to the circle:

 function Player:shoot() ... elseif self.attack == 'Lightning' then ... table.sort(nearby_enemies, function(a, b) return distance(ax, ay, cx, cy) < distance(bx, by, cx, cy) end) local closest_enemy = nearby_enemies[1] ... end 

For this purpose, you can use table.sort . Then we just need to take the first element of the sorted table and attack it:

 function Player:shoot() ... elseif self.attack == 'Lightning' then ... -- Attack closest enemy if closest_enemy then self.ammo = self.ammo - attacks[self.attack].ammo closest_enemy:hit() local x2, y2 = closest_enemy.x, closest_enemy.y self.area:addGameObject('LightningLine', 0, 0, {x1 = x1, y1 = y1, x2 = x2, y2 = y2}) for i = 1, love.math.random(4, 8) do self.area:addGameObject('ExplodeParticle', x1, y1, {color = table.random({default_color, boost_color})}) end for i = 1, love.math.random(4, 8) do self.area:addGameObject('ExplodeParticle', x2, y2, {color = table.random({default_color, boost_color})}) end end end end 

First we need to make sure that closest_enemy does not equal nil, because if this is the case, then we should not do anything. Most of the time it will be equal to nil, since there are no enemies nearby. If this is not the case, then we reduce the ammunition, as we did for all other attacks, and then call the hit function for the enemy that is being damaged. After that, we create a LightningLine object with variables x1, y1, x2, y2 , representing the position directly in front of the ship from which the charge will be released, as well as the center of the enemy. Finally, we create a bunch of ExplodeParticle particles to make the attack more interesting.

The last thing we need for an attack to work is its table:

 attacks['Lightning'] = {cooldown = 0.2, ammo = 8, abbreviation = 'Li', color = default_color} 

And all this should look like this:

Gif

176. (CONTENT) Implement the Explode attack. Here is what it looks like:

Gif

An explosion is created, destroying all enemies within a certain radius. The projectile itself looks like homing, except that hp_color slightly larger. The attack table looks like this:

 attacks['Explode'] = {cooldown = 0.6, ammo = 4, abbreviation = 'E', color = hp_color} 

177. (CONTENT) Implement an attack Laser . Here is what it looks like:

Gif

It creates a huge line, destroying all the enemies that cross it. It can be programmed either as a line or as a rotated rectangle for collision detection. If you decide to use the line, it is better to use 3 or 5 lines that are slightly separated from each other, otherwise the player will sometimes miss the enemies, which seems unfair.

The effect of the attack itself is different from everyone else, but there should be no problems with it. One huge white line in the middle, whose thickness changes over time with the help of tween, and two red lines on the sides, which are initially close to the white lines, but then expand and disappear when the effect is complete. The shooting effect is an enlarged version of the original ShootEffect object and also has a red color. The attack table looks like this:

 attacks['Laser'] = {cooldown = 0.8, ammo = 6, abbreviation = 'La', color = hp_color} 

178. (CONTENT) Implement the passive skill additional_lightning_bolt . If it is true, then the player can attack with two charges of lightning at the same time. From a programming point of view, this means that instead of searching for one nearest enemy, we will look for two and attack both if they exist. You can also try to separate each attack in a small interval, for example 0.5 seconds, because it looks better.

179. (CONTENT) Implement the passive skill increased_lightning_angle . This skill increases the angle at which a lightning attack can be triggered, that is, it will also attack enemies on the sides, and sometimes behind the player. From a programming point of view, this means that when the increased_lightning_angle is true, then we do not offset the lightning circle by 24 units and use the player’s center in our calculations.

180. (CONTENT) Implement the area_multiplier passive skill. This skill increases the range of all attacks and effects associated with squares.The most recent examples include the Lightning lightning attack circle, as well as the Explode attack area. But this will also apply to explosions in general, as well as to everything related to areas (when a circle is used to obtain information or to apply effects).

181. (CONTENT) Implement a passive skill laser_width_multiplier. This skill increases or decreases the thickness of the Laser attack.

182. (CONTENT) Implement a passive skill additional_bounce_projectiles. This skill increases or decreases the number of bounce projectile bounces. By default, bounce attack projectiles can bounce 4 times. If additional_bounce_projectilesequal to 4, then the bounce attack projectiles can bounce 8 times.

183. (CONTENT) Implement a passive skillfixed_spin_attack_direction. This type of boolean skill makes it so that all Spin attack projectiles rotate in a constant direction, that is, they all rotate either just to the left or just to the right.

184. (CONTENT) Implement a passive skill split_projectiles_split_chance. This is a projectile, adding to the already divided attack 2Split or 4Split shells the probability to split again. For example, if this probability becomes equal to 100, then separated shells will be recursively separated constantly (however, we will not allow this in the skill tree).

185. (CONTENT) Implement passive skills [attack]_spawn_chance_multiplierwhere[attack]- this is the name of each attack. These skills increase the likelihood of creating a certain attack. Now, when we create the Attack resource, the attack is chosen randomly. However, now we want them to be selected from the chanceList, which initially has equal probabilities for all attacks, but is modified using passive skills [attack]_spawn_chance_multiplier.

186. (CONTENT) Implement passive skills start_with_[attack], where [attack]is the name of each attack. These passive skills make the player start the game with an appropriate attack. For example, if start_with_bouncetrue, the player will begin each round with a bounce attack. If true have multiple passive skills start_with_[attack], then one of them is randomly selected.

Additional homing projectiles


Passive skill additional_homing_projectilesadds extra projectiles to passive Homing Projectile Launch skills. Usually launched self-guided shells look like this:

 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 

additional_homing_projectiles- This is the number that tells us how many extra shells to use. For this to work, we can do something like this:

 function Player:onAmmoPickup() if self.chances.launch_homing_projectile_on_ammo_pickup_chance:next() then local d = 1.2*self.w for i = 1, 1+self.additional_homing_projectiles do self.area:addGameObject('Projectile', self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), {r = self.r, attack = 'Homing'}) end self.area:addGameObject('InfoText', self.x, self.y, {text = 'Homing Projectile!'}) end end 

And then we will only have to apply it to each instance in which the passive skill of launch_homing_projectileany type appears .

187. (CONTENT) Implement a passive skill additional_barrage_projectiles.

188. (CONTENT) Implement a passive skill barrage_nova. This is a boolean variable, which, when set, causes the projectiles of the queue to be released in a circle, and not in the direction of the player’s movement. Here is what it looks like:

Gif

Projectile mine


Mina projectile is a projectile that remains in its place of creation and explodes over time. Here is what it looks like:

Gif

As you can see, it simply rotates like a spin attack projectile, but much faster. To implement this, we say that if the attribute mineis true for the projectile, then it will behave like Spin shells, but with an increased rotation speed.

 function Projectile:new(...) ... if self.mine then self.rv = table.random({random(-12*math.pi, -10*math.pi), random(10*math.pi, 12*math.pi)}) self.timer:after(random(8, 12), function() -- Explosion end) end ... end function Projectile:update(dt) ... -- Spin or Mine if self.attack == 'Spin' or self.mine then self.r = self.r + self.rv*dt end ... end 

Here, instead of limiting the rotation speeds in the interval of absolute values ​​from math.pi to 2 * math.pi, we take absolute values ​​from 10 * math.pi to 12 * math.pi. As a result, the projectile rotates much faster and covers a smaller area, which is ideally suited to this type of behavior.

In addition, after a random interval of 8 to 12 seconds, the projectile explodes. This explosion does not need to be processed in the same way as explosions processed for an Explode projectile. In my case, I created an object Explosion, but there are many ways to implement this action. I will leave it as an exercise, because the Explode attack was also an exercise.

189. (CONTENT) Implement a passive skilldrop_mines_chance, which adds to the player the likelihood of a projectile mine every 0.5 seconds. From the point of view of programming, this is implemented through a timer that starts every 0.5 seconds. In each of these launches, we roll function cubes drop_mines_chance:next().

190. (CONTENT) Implement a passive skill projectiles_explode_on_expirationthat makes it so that when destroying projectiles, because of the end of their lifespan, they also explode. This should apply only to the time of completion of their lives. If a projectile collides with an enemy or a wall, then it should not explode when this skill is true.

191. (CONTENT) Implement a passive skill self_explode_on_cycle_chance. This skill adds the player the chance to create explosions around each cycle. It will look like this:

Gif

Here the same explosions are used as in the Explode attack. The number, location and size of the created explosions can be chosen independently.

192. (CONTENT) Implement a passive skill projectiles_explosions. If it is true, then all explosions caused by the projectile created by the player will create multiple projectiles resembling the action of a passive skill barrage_nova. The number of shells created is initially equal to 5 and this number is influenced by the passive skill additional_barrage_projectiles.

Energy shield


When the passive skill energy_shieldis true, the player's HP turns into an energy shield (from this moment called ES). ES work differs from HP's work as follows:


We can implement all this mainly in the function hit:

 function Player:new(...) ... -- ES self.energy_shield_recharge_cooldown = 2 self.energy_shield_recharge_amount = 1 -- Booleans self.energy_shield = true ... end function Player:hit(damage) ... if self.energy_shield then damage = damage*2 self.timer:after('es_cooldown', self.energy_shield_recharge_cooldown, function() self.timer:every('es_amount', 0.25, function() self:addHP(self.energy_shield_recharge_amount) end) end) end ... end 

We declare that the pause before the start of ES reloading after hitting is 2 seconds, and that the reloading rate is 4 ES per second (1 per 0.25 seconds in a call every). We also have a conditional structure at the top of the hit function and double the damage variable, which will be used below to cause damage to the player.

The only thing left for us here is halving the time of invulnerability. We can do this either in the hit function, or in the function setStats. We will choose the second option, because we have not been involved in this function for a long time.

 function Player:setStats() ... if self.energy_shield then self.invulnerability_time_multiplier = self.invulnerability_time_multiplier/2 end end 

Since it setStatsis called at the end of the constructor and after the function is called treeToPlayer(that is, it is called after loading all passive skills from the tree), we can be sure that the value energy_shieldreflects all the skills selected by the player in the tree. In addition, we can be sure that we reduce the invulnerability timer after applying all the increases / decreases of this multiplier from the tree. This is actually not necessary for this passive skill, since order is not important here, but for other skills it may be important and in that case applying changes to setStatsit makes sense. Usually, if the probability of a parameter is obtained from the variable boolean and this change is constantly in the game, then it is more logical to put it in setStats.

193. (CONTENT) Change the HP parameter UI so that whenenergy_shield is true, it looked like this:

Gif

194. (CONTENT) Implement a passive skill energy_shield_recharge_amount_multiplierthat increases or decreases the number of recovered per second ES.

195. (CONTENT) Implement a passive skill energy_shield_recharge_cooldown_multiplierthat increases or decreases the pause time after causing the player damage, after which the ES begins to recharge.

Adding probabilities to all “kill” events


For example, if it added_chance_to_all_on_kill_eventsis equal to 5, then all the probabilities of all passive skills like “during a murder” increase by 5%. This means that if the player initially acquired skills that add launch_homing_projectile_on_kill_chanceup to 8, then the final probability instead of 8% will be 13%. This is too powerful a passive skill, but it is interesting to consider it from the point of view of implementation.

We can implement it by changing the way the function generates generateChanceschanceList lists. Since this function bypasses all passive skills whose name ends with _chance, it is obvious that we can also parsit all passive skills containing in the name _on_kill. That is, after we do this, it will be enough for us to add added_chance_to_all_on_kill_eventsto the appropriate place in the process of generating the chanceList.

To begin, we will separate the usual passive skills from those that have in the title on_kill:

 function Player:generateChances() self.chances = {} for k, v in pairs(self) do if k:find('_chance') and type(v) == 'number' then if k:find('_on_kill') and v > 0 then else self.chances[k] = chanceList({true, math.ceil(v)}, {false, 100-math.ceil(v)}) end end end end 

We use the same method that we used to search for passive skills with _chance, just replace this line with _on_kill. In addition, we also need to verify that this passive skill has a probability of generating an event greater than 0%. We do not want the new passive skill to add its probability to all events like “when killing”, when the player did not spend points on this event, so we do this only for events in which the player has already invested some probability.

Now we can simply create a chanceList, but instead of using the most vwe will use v+added_chance_to_all_on_kill_events:

 function Player:generateChances() self.chances = {} for k, v in pairs(self) do if k:find('_chance') and type(v) == 'number' then if k:find('_on_kill') and v > 0 then self.chances[k] = chanceList( {true, math.ceil(v+self.added_chance_to_all_on_kill_events)}, {false, 100-math.ceil(v+self.added_chance_to_all_on_kill_events)}) else self.chances[k] = chanceList({true, math.ceil(v)}, {false, 100-math.ceil(v)}) end end end end 

Increased ASPD due to added ammunition


This skill is the conversion of a part of one parameter to another. In this case, we take all the increases of the Ammo resource and add them as an additional attack speed. We can do this with this formula:

 local ammo_increases = self.max_ammo - 100 local ammo_to_aspd = 30 aspd_multiplier:increase((ammo_to_aspd/100)*ammo_increases) 

It means that if, say, the maximum stock is 130 ammunition, and ammo_to_aspdhas a conversion ratio of 30%, then as a result we will increase the attack speed by 0.3 * 30 = 9%. If the maximum is 250 ammunition, then with the same conversion percentage we will get 1.5 * 30 = 45%.

To accomplish this, we first define an attribute:

 function Player:new(...) ... -- Conversions self.ammo_to_aspd = 0 end 

And then we can apply the transform to the variable aspd_multiplier. Since this variable is Stat, we need to do this in a function update. If this variable were ordinary, we would do it in a function setStats.

 function Player:update(dt) ... -- Conversions if self.ammo_to_aspd > 0 then self.aspd_multiplier:increase((self.ammo_to_aspd/100)*(self.max_ammo - 100)) end self.aspd_multiplier:update(dt) ... end 

And this should work exactly as we intended.

Recent passive skills


We need to realize only about 20 passive skills. Most of them are quite trivial, so I will leave them for the exercises. In fact, they have almost nothing to do with most of the skills that we have implemented before, so although they may be trivial, you can consider them as tasks with which you can check if you really understand what is happening and how in the code base.

196. (CONTENT) Implement a passive skill change_attack_periodicallythat changes a player's attack every 10 seconds. New attack is chosen randomly.

197. (CONTENT) Implement a passive skill gain_sp_on_deaththat gives the player after death 20 SP.

198. (CONTENT) Implement a passive skillconvert_hp_to_sp_if_hp_fullwhich gives the player 3 SP every time he picks up a HP resource, and his HP is already at its maximum.

199. (CONTENT) Implement a passive skill mvspd_to_aspdthat adds an increase in movement speed to attack speed. This increase should be added using the same formula that is used for ammo_to_aspd. That is, if a player has an increase in MVSPD by 30%, and mvspd_to_aspdis equal to 30 (that is, the conversion rate is 30%), then his ASPD should be increased by 9%.

200. (CONTENT) Implement a passive skill mvspd_to_hpthat adds a decrease in movement speed to the player's HP. For example, if the MVSPD is reduced by 30%, and mvspd_to_hpequal to 30 (that is, the conversion coefficient is equal to 30%), then it should add 21 HP.

201. (CONTENT)Implement a passive skill mvspd_to_pspdthat adds an increase in movement speed to the speed of projectiles. It works exactly the same as mvspd_to_aspd.

202. (CONTENT) Implement a passive skill no_boostthat disables the acceleration of the player Boost (max_boost = 0).

203. (CONTENT) Implement a passive skill half_ammothat halves a player’s ammunition.

204. (CONTENT) Implement a passive skill half_hpthat halves a player's HP.

205. (CONTENT) Implement a passive skill deals_damage_while_invulnerablethat allows the player to inflict damage on enemies when they are invulnerable (for example, when the attribute invincibleis true after it hits ).

206. (CONTENT)Implement a passive skill refill_ammo_if_hp_fullthat fully restores a player’s ammunition if he selects a HP resource at full scale HP.

207. (CONTENT) Implement a passive skill refill_boost_if_hp_fullthat fully restores the player's Boost when he selects a HP resource at HP full scale.

208. (CONTENT) Implement a passive skill only_spawn_boostthat makes so that the only resources created are Boost.

209. (CONTENT) Implement a passive skill only_spawn_attackthat makes it so that for a given period of time does not create any resources other than attacks. This means that all attacks are created after a pause in resources, as well as an attack's own pause (that is, every 16 seconds, as well as every 30 seconds).

210. (CONTENT)Implement a passive skill no_ammo_drop, in which the enemies never drop ammunition.

211. (CONTENT) Implement a passive skill infinite_ammoin which no player’s attacks consume ammunition.

And on this we will end up with passive skills. In total, we considered about 150 different passive skills, which in the skill tree will stretch by about 900 knots, since many of these skills are simply parameter changes, and can be scattered throughout the tree, rather than concentrated in one place.

But before we proceed to the tree (which we will look at in the next part of the article), we can slightly expand the content and implement all the player’s ships and all enemies. You can not fully follow my examples, which I will give in the exercises, and create your own ships / enemies.

The enemies


212. (CONTENT) Implement the enemy BigRock. This enemy behaves in the same way as Rock, only he more and after death is divided into 4 objects Rock. By default, it has 300 HP.

Gif

213. (CONTENT) Realize the enemy Waver. This enemy behaves like a wave projectile and occasionally shoots projectiles from the front and rear (like the Back attack). By default, it has 70 HP.

Gif

214. (CONTENT) Realize the enemy Seeker. This enemy behaves like an Ammo object and moves slowly in the direction of the player. With constant time intervals, this enemy also lays mines just like mine shells. By default, it has 200 HP.

Gif

215. (CONTENT) Realize the enemy Orbitter. This enemy behaves like Rock or BigRock, but around it there is a shield of shells. These shells behave like shells-shields, which we implemented in the previous part of the article. If the Orbitter dies before the death of its projectiles, then the remaining projectiles homing at the player for a short period of time. By default, it has 450 HP.

Gif

Ships


We have already considered the graphics of all the ships in one of the previous parts of the tutorial, which was also implemented in the exercises. So we will assume that you have already done them, and also that they have names and everything else. In the following exercises we will use those created by me, but since we have implemented most of the used passive skills in the previous and current parts of the article, you can create your own ships to your taste. I will give an example of those who came up with himself.

216. (CONTENT) Implement the ship Crusader:

Gif

It has the following parameters:


217. (CONTENT) Implement the ship Rogue:

Gif

It has the following parameters:


218. (CONTENT) Implement the ship Bit Hunter:

Gif

It has the following parameters:


219. (CONTENT) Implement the ship Sentinel:

Gif

It has the following parameters:


220. (CONTENT) Implement the ship Striker:

Gif

It has the following parameters:


221. (CONTENT) Implement the ship Nuclear:

Gif

It has the following parameters:


222. (CONTENT) Implement the ship Cycler:

Gif

It has the following parameters:


223. (CONTENT) Implement the ship Wisp:

Gif

It has the following parameters:


END


And on this we finished the implementation of all the content of the game. In the last two parts of the article there were a lot of exercises, mainly related to adding content manually. To some it may seem very boring, so this work is a good indicator of whether you like the implementation of such content. Most of the development of the game is to simply add such elements, so if you don’t like it at all, then it’s better to know about it sooner than later.

In the next section, we will look at a skill tree, in which the player will display all these passive skills. We will focus on creating all the skills necessary for the work of the tree, but creating the tree itself (for example, placing and connecting nodes) will remain entirely your task. This is another one of those moments when we simply manually add content to the game and do not do anything particularly complicated.



If you like this series of tutorials, then you can stimulate me to write something similar in the future:


By purchasing a tutorial on itch.io, you will have access to the complete source code of the game, answers to the exercises from parts 1-9, the code broken into parts of the tutorial (the code will look like it should look at the end of each part) and the key games on Steam.

Source: https://habr.com/ru/post/352462/


All Articles