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}
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
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
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.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
if not self.shield
.projectile_duration_multiplier
. Remember to use it for all duration-related behaviors of the Projectile class.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
function Projectile:new(...) ... if self.attack == 'Spin' then self.timer:after(random(2.4, 3.2), function() self:die() end) end end
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
attacks['Spin'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Sp', color = hp_color}
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
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
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}
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
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
bounce
is 0, the first conditional construction is skipped and we go to the usual path, which leads to the destruction of the projectile.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.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
attacks['Bounce'] = {cooldown = 0.32, ammo = 4, abbreviation = 'Bn', color = default_color}
ammo_color
. attacks['2Split'] = {cooldown = 0.32, ammo = 3, abbreviation = '2S', color = ammo_color}
attacks['4Split'] = {cooldown = 0.4, ammo = 4, abbreviation = '4S', color = boost_color}
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
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
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
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. 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
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.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
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
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
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. attacks['Lightning'] = {cooldown = 0.2, ammo = 8, abbreviation = 'Li', color = default_color}
Explode
attack. Here is what it looks like:hp_color
slightly larger. The attack table looks like this: attacks['Explode'] = {cooldown = 0.6, ammo = 4, abbreviation = 'E', color = hp_color}
Laser
. Here is what it looks like: attacks['Laser'] = {cooldown = 0.8, ammo = 6, abbreviation = 'La', color = hp_color}
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.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.laser_width_multiplier
. This skill increases or decreases the thickness of the Laser attack.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_projectiles
equal to 4, then the bounce attack projectiles can bounce 8 times.fixed_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.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).[attack]_spawn_chance_multiplier
where[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
.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_bounce
true, 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
adds 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
launch_homing_projectile
any type appears .additional_barrage_projectiles
.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:mine
is 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
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.drop_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()
.projectiles_explode_on_expiration
that 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.self_explode_on_cycle_chance
. This skill adds the player the chance to create explosions around each cycle. It will look like this: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
is true, the player's HP turns into an energy shield (from this moment called ES). ES work differs from HP's work as follows: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
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.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
setStats
is 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_shield
reflects 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 setStats
it 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
.energy_shield
is true, it looked like this:energy_shield_recharge_amount_multiplier
that increases or decreases the number of recovered per second ES.energy_shield_recharge_cooldown_multiplier
that increases or decreases the pause time after causing the player damage, after which the ES begins to recharge.added_chance_to_all_on_kill_events
is 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_chance
up 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.generateChances
chanceList 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_events
to the appropriate place in the process of generating the chanceList.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
_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.v
we 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
local ammo_increases = self.max_ammo - 100 local ammo_to_aspd = 30 aspd_multiplier:increase((ammo_to_aspd/100)*ammo_increases)
ammo_to_aspd
has 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%. function Player:new(...) ... -- Conversions self.ammo_to_aspd = 0 end
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
change_attack_periodically
that changes a player's attack every 10 seconds. New attack is chosen randomly.gain_sp_on_death
that gives the player after death 20 SP.convert_hp_to_sp_if_hp_full
which gives the player 3 SP every time he picks up a HP resource, and his HP is already at its maximum.mvspd_to_aspd
that 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_aspd
is equal to 30 (that is, the conversion rate is 30%), then his ASPD should be increased by 9%.mvspd_to_hp
that adds a decrease in movement speed to the player's HP. For example, if the MVSPD is reduced by 30%, and mvspd_to_hp
equal to 30 (that is, the conversion coefficient is equal to 30%), then it should add 21 HP.mvspd_to_pspd
that adds an increase in movement speed to the speed of projectiles. It works exactly the same as mvspd_to_aspd
.no_boost
that disables the acceleration of the player Boost (max_boost = 0).half_ammo
that halves a player’s ammunition.half_hp
that halves a player's HP.deals_damage_while_invulnerable
that allows the player to inflict damage on enemies when they are invulnerable (for example, when the attribute invincible
is true after it hits ).refill_ammo_if_hp_full
that fully restores a player’s ammunition if he selects a HP resource at full scale HP.refill_boost_if_hp_full
that fully restores the player's Boost when he selects a HP resource at HP full scale.only_spawn_boost
that makes so that the only resources created are Boost.only_spawn_attack
that 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).no_ammo_drop
, in which the enemies never drop ammunition.infinite_ammo
in which no player’s attacks consume ammunition.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.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.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.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.Crusader
:Rogue
:Bit Hunter
:Sentinel
:Striker
:Nuclear
:Cycler
:Wisp
:Source: https://habr.com/ru/post/352462/
All Articles