BYTEPATH #12 - More Passives
Blast
We'll start by implementing all attacks left. The first one is the Blast attack and it looks like this:
先把剩下的所有攻击都补出来。第一个是 Blast 攻击,效果如下:

Multiples projectiles are fired like a shotgun with varying velocities and then they quickly disappear. All the colors are the ones negative_colors table and each projectile deals less damage than normal. This is what the attack table looks like:
它会像霰弹枪一样一次射出多枚速度各不相同的子弹,然后这些子弹会很快消失。所有颜色都取自 negative_colors 表,而且每发子弹造成的伤害都比普通攻击低。攻击表如下:
attacks['Blast'] = {cooldown = 0.64, ammo = 6, abbreviation = 'W', color = default_color}And this is what the projectile creation process looks like:
而生成子弹的过程是这样的:
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
...
endSo here we just create 12 projectiles with a random angle between -30 and +30 degrees from the direction the player is moving towards. We also randomize the velocity between 500 and 600 (its normal value is 200), meaning the projectile is about 3 times as fast as normal.
这里的做法很直接:一次创建 12 发子弹,它们的角度会在玩家当前朝向的 -30 度到 +30 度之间随机分布。同时,子弹速度也会随机落在 500 到 600 之间,而普通值是 200,也就是说它差不多有平时 3 倍快。
Doing this alone though won't get the behavior we want, since we also want for the projectiles to disappear rather quickly. The way to do this is like this:
不过光做到这里还不够,因为我们还希望这些子弹能很快消失。可以这样处理:
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
...
endThree things are happening here. The first is that we're setting the damage to a value lower than 100. This means that to kill the normal enemy which has 100 HP we'll need two projectiles instead of one. This makes sense given that this attack shoots 12 of them at once. The second thing we're doing is setting the color of this projectile to a random one from the negative_colors table. This is also something we wanted to do and this is the proper place to do it. Finally, we're also saying that after a random amount between 0.4 and 0.6 seconds this projectile will die, which will give us the effect we wanted. We're also slowing the projectile's velocity down to 0 instead of just killing it, since that looks a little better.
这里一共做了三件事。第一,把伤害设成低于 100 的数值。这样一来,要打死默认 100 HP 的普通敌人,就需要两发而不是一发,这和它一次喷出 12 发子弹的设定是吻合的。第二,把子弹颜色设成 negative_colors 表里的随机颜色,这正是我们想要的效果,而且放在这里处理也最合适。最后,我们规定这枚子弹会在 0.4 到 0.6 秒后的某个随机时刻死亡,这样就能得到想要的短命效果。顺便我们不是直接秒杀它,而是先把速度缓动到 0,因为看起来会更自然一些。
And all this gets us all the behavior we wanted and on the surface this looks done. However, now that we've added tons of passives in the previous article we need to be careful and make sure that everything we add afterwards plays well with those passives. For instance, the last thing we did in the previous article was add the shield projectile effect. The problem with the Blast attack is that it doesn't play well with the shield projectile effect at all, since Blast projectiles will die after 0.4-0.6 seconds and this makes for a very poor shield projectile.
做到这里,表面上看 Blast 已经算完成了,该有的行为基本都有了。但上一篇我们已经往游戏里塞了大量被动,所以现在每加一个新东西,都得注意它能不能和旧被动和平共处。比如上一篇最后实现的是 shield projectile 效果,而 Blast 最大的问题就是它和这个效果完全不搭,因为 Blast 子弹在 0.4 到 0.6 秒后就会死掉,用来当 shield projectile 会非常难看。
One of the ways we can fix this is by singling out the offending passive (in this case the shield one) and applying different logic for each situation. In the situation where shield is true for a projectile, then the projectile will last 6 seconds no matter what. And in all other situations then the duration set by whatever attack will take hold. This would look like this:
一个解决办法,就是把这个会冲突的被动单独拎出来处理。这里冲突的是 shield,所以我们针对不同情况套不同逻辑:如果某个子弹的 shield 为 true,那它无论如何都持续 6 秒;其他情况下,才按各自攻击设定的寿命走。代码像这样:
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
...
endThis seems like a hacky solution, and you can easily imagine that it would get more complicated if more and more passives are added and we have to add more and more conditions delineating what happens if this or if not that. But from my experience doing this is by far the easiest and less error prone way of doing things. The alternative is trying to solve this problem in some kind of general way and generally those have unintended consequences. Maybe there's a better general solution for this problem specifically that I can't think of, but given that it hasn't occurred to me yet then the next best thing is to do the simplest thing, which is just a bunch of conditionals saying what should or should not happen. In any case, now for every attack we add that changes a projectile's duration, we'll have to preface it with if not self.shield.
这看起来确实有点土,也很容易想象:随着被动越来越多,这种“如果是这个就怎样、不是那个又怎样”的条件分支只会越来越多。但按我的经验,这反而是最省事、也最不容易出错的做法。另一条路是想办法抽出一个很通用的统一解法,可这种方案往往副作用更多。也许这个问题其实有更漂亮的通解,只是我现在没想到;既然没想到,那最好的办法就是先用最简单的办法,也就是老老实实写条件判断。总之,从现在开始,凡是会修改子弹持续时间的新攻击,都得先套一层 if not self.shield。
172. (CONTENT) Implement the projectile_duration_multiplier passive. Remember to use it on any duration-based behavior on the Projectile class.
172. (CONTENT) 实现 projectile_duration_multiplier 被动。记得把它应用到 Projectile 类中所有和持续时间有关的行为上。
Spin
The next attack we'll implement is the Spin one. It looks like this:
接下来实现的是 Spin 攻击,效果如下:

These projectiles just have their angle constantly changed by a fixed amount instead of going straight. The way we can achieve this is by adding a rv variable, which represents the angle's rate of change, and then every frame add this amount to r:
这种子弹不会笔直往前飞,而是会持续按固定幅度改变自己的角度。做法是加一个 rv 变量,表示角度变化速度,然后每一帧都把这个值累加到 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
...
endThe reason we pick between -2*math.pi and -math.pi OR between math.pi and 2*math.pi is because we don't want any of the values that are lower than math.pi or higher than 2*math.pi in absolute terms. Low absolute values means that the circle the projectile makes is bigger, while higher absolute values means that the circle is smaller. We want a nice limit on the circle's size both ways so this makes sense. It should also be clear that the difference between negative or positive values is in the direction the circle spin towards.
之所以在 -2*math.pi 到 -math.pi,或者 math.pi 到 2*math.pi 之间取值,是因为我们不希望角速度的绝对值低于 math.pi,也不希望高于 2*math.pi。绝对值太小,子弹绕出来的圈会太大;绝对值太大,圈又会太小。把它限制在这段区间里,轨迹大小会比较顺眼。至于正负号的区别,很直观,就是决定它朝哪个方向旋转。
We can also add a duration to Spin projectiles since we don't want them to stay around forever:
另外我们也可以给 Spin 子弹加上寿命,免得它们永远留在场上:
function Projectile:new(...)
...
if self.attack == 'Spin' then
self.timer:after(random(2.4, 3.2), function() self:die() end)
end
endThis is what the shoot function would look like:
shoot 函数则会是这样:
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
endAnd this is what the attack table looks like:
攻击表如下:
attacks['Spin'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Sp', color = hp_color}And all this should get us the behavior we want. However, there's an additional thing to do which is the trail on the projectile. Unlike the Homing projectile which uses a trail like the one we used for the Player ships, this one follows the shape and color of the projectile but also slowly becomes invisible until it disappears completely. We can implement this in the same way we did for the other trail object, but taking into account those differences:
这样一来,Spin 的核心行为就出来了。不过还有个额外细节要补,就是它的拖尾。和 Homing 子弹那种沿用玩家飞船拖尾的做法不同,这里的拖尾要继承子弹本身的形状和颜色,而且会逐渐变透明,直到完全消失。实现方式和之前那个 trail 对象差不多,只是要把这些差异考虑进去:
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)
endAnd so this looks pretty standard, the only notable thing being that we have an alpha variable which we tween to 0 so that the projectile slowly disappears over a random duration of between 0.1 and 0.3 seconds, and then we draw the trail just like we would draw a projectile. Importantly, we use the r, s and color variables from the parent projectile, which means that when creating it we need to pass all that down:
这段代码整体上很常规,唯一比较值得注意的是多了个 alpha 变量,我们会把它缓动到 0,让拖尾在 0.1 到 0.3 秒的随机时长里慢慢淡出。绘制时也基本就是按子弹本体的方式来画。关键点在于:这里用到了父子弹的 r、s 和 color,所以在创建拖尾时必须把这些值一并传下来:
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
...
endAnd this should get us the results we want.
这样应该就能得到想要的效果了。
173. (CONTENT) Implement the Flame attack. This is what the attack table looks like:
173. (CONTENT) 实现 Flame 攻击。攻击表如下:
attacks['Flame'] = {cooldown = 0.048, ammo = 0.4, abbreviation = 'F', color = skill_point_color}And this is what the attack looks like:
攻击效果如下:

The projectiles should remain alive for a random duration between 0.6 and 1 second, and like the Blast projectiles, their velocity should be tweened to 0 over that duration. The projectiles also make use of the ProjectileTrail object in the way that the Spin projectiles do. Flame projectiles also deal decreased damage at 50 each.
这些子弹应该存活 0.6 到 1 秒之间的随机时长,并且和 Blast 子弹一样,在这段时间里把速度缓动到 0。它们也要像 Spin 子弹那样使用 ProjectileTrail 对象。另外,Flame 子弹的伤害要降低到每发 50。
Bounce
Bounce projectiles will bounce off of walls instead of being destroyed by them. By default a Bounce projectile can bounce 4 times before being destroyed whenever it hits a wall again. The way we can define this is by setting it through the opts table in the shoot function:
Bounce 子弹撞到墙后不会直接销毁,而是会反弹。默认情况下,一枚 Bounce 子弹最多可以反弹 4 次,第 5 次再撞墙时才会被销毁。这个设定可以通过 shoot 函数里的 opts 表传进去:
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
endAnd so the bounce variable will contain the number of bounces left for this projectile. We can use this so that whenever we hit a wall we decrease this by 1:
这样一来,bounce 变量里存的就是这枚子弹剩余可反弹次数。于是每次碰墙时,把它减 1 就行:
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
...
endHere on top of decreasing the number of bounces left, we also change the projectile's direction according to which wall it hit. Perhaps there's a general and better way to do this, but I could only think of this solution, which involves taking into account each wall separately and then doing the math necessary to reflect/mirror the projectile's angle properly. Note that when bounce is 0, the first conditional will fail and then we'll go down to the normal path that we had before, which ends up killing the projectile.
这里除了减少剩余反弹次数之外,还要根据它撞上的墙面来修改子弹方向。也许这件事有更通用、更优雅的写法,但我目前能想到的就是这种办法:把每面墙分开判断,然后各自做正确的反射角计算。注意,当 bounce 变成 0 之后,前面的条件就会失败,程序就会走回原本的逻辑分支,最后直接把子弹销毁。
It's also important to place all this collision code before we call setLinearVelocity, otherwise our bounces won't work at all since we'll turn the projectile with a one frame delay and then simply reversing its angle won't make it come back. To be safe we could also use setPosition to forcefully set the position of the projectile to the border of the screen on top of turning its angle, but I didn't find that to be necessary.
还有一点很重要:这段碰撞代码必须放在调用 setLinearVelocity 之前,不然反弹根本不会正常工作。因为那样的话,子弹会延后一帧才转向,而光把角度反过来已经来不及让它弹回来了。稳妥一点的话,也可以在修改角度的同时再用 setPosition 把子弹强行贴回屏幕边缘,不过我自己试下来觉得没这个必要。
Moving on, the colors of a bounce projectile are random like the Spread one, except going through the default_colors table. This means we need to take care of it separately in the Projectile:draw function:
接下来,Bounce 子弹的颜色和 Spread 一样也是随机的,只不过随机来源换成了 default_colors 表。因此我们需要在 Projectile:draw 里单独处理它:
function Projectile:draw()
...
if self.attack == 'Bounce' then love.graphics.setColor(table.random(default_colors)) end
...
endAnd the attack table looks like this:
攻击表如下:
attacks['Bounce'] = {cooldown = 0.32, ammo = 4, abbreviation = 'Bn', color = default_color}And all that should look like this:
最终效果应该像这样:

174. (CONTENT) Implement the 2Split attack. This is what it looks like:
174. (CONTENT) 实现 2Split 攻击。效果如下:

It looks exactly like the Homing projectile, except that it uses ammo_color instead.
它看起来和 Homing 子弹完全一样,只不过颜色改成了 ammo_color。
Whenever it hits an enemy the projectile will split into two (two new projectiles are created) at +-45 degrees from the direction the projectile was moving towards. If the projectile hits a wall then 2 projectiles will be created either against the reflected angle of the wall (so if it hits the up wall 2 projectiles will be created pointing to math.pi/4 and 3*math.pi/4) or against the reflected angle of the projectile, this is entirely your choice. This is what the attacks table looks like:
每当它打中敌人时,子弹都会分裂成两发新子弹,方向是在原飞行方向的正负 45 度。如果子弹撞上墙,那么也要生成 2 发新子弹,至于新子弹是根据墙面反射角来算,还是根据原子弹自身反射后的方向来算,都由你决定。比如撞到上边界时,可以让两发子弹分别朝 math.pi/4 和 3*math.pi/4 飞。攻击表如下:
attacks['2Split'] = {cooldown = 0.32, ammo = 3, abbreviation = '2S', color = ammo_color}175. (CONTENT) Implement the 4Split attack. This is what it looks like:
175. (CONTENT) 实现 4Split 攻击。效果如下:

It behaves exactly like the 2Split attack, except that it creates 4 projectiles instead of 2. The projectiles point at all 45 degrees angles from the center, meaning angles math.pi/4, 3*math.pi/4, -math.pi/4 and -3*math.pi/4. This is what the attack table looks like:
它的行为和 2Split 完全一致,只不过不是分成 2 发,而是分成 4 发。四发子弹会朝中心点四个 45 度方向飞出去,也就是 math.pi/4、3*math.pi/4、-math.pi/4 和 -3*math.pi/4。攻击表如下:
attacks['4Split'] = {cooldown = 0.4, ammo = 4, abbreviation = '4S', color = boost_color}Lightning
This is what the Lightning attack looks like:
Lightning 攻击效果如下:

Whenever the player reaches a certain distance from an enemy, a lightning bolt will be created, dealing damage to the enemy. Most of the work here lies in creating the lightning bolt, so we'll focus on that first. We'll do it by creating an object called LightningLine, which will be the visual representation of our lightning bolt:
当玩家进入敌人附近一定范围内时,就会生成一道闪电并对敌人造成伤害。这里的大部分工作量都在于把闪电这条线做出来,所以我们先处理这个。做法是创建一个叫 LightningLine 的对象,把它当成闪电的视觉表现:
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)
endI'll focus on the draw function and leave the entire generation of the lightning lines for you! This tutorial explains the generation method really really well and it would be redundant for me to repeat all that here. I'll assume that you have all the lines that compose the lightning bolt in a table called self.lines, and that each line is a table which contains the keys x1, y1, x2, y2. With that in mind we can draw the lightning bolt in a basic way like this:
我这里主要讲 draw 函数,至于闪电线段本身怎么生成,就留给你来做了。这篇教程 对生成方法讲得非常清楚,我在这里重复一遍意义不大。这里先假设组成闪电的所有线段都已经放进了 self.lines 表里,并且每条线都是一个带有 x1, y1, x2, y2 键的表。在这个前提下,最基础的绘制方法如下:
function LightningLine:draw()
for i, line in ipairs(self.lines) do
love.graphics.line(line.x1, line.y1, line.x2, line.y2)
end
endHowever, this looks too simple. So what we'll do is draw all those lines first with boost_color and with line width set to 2.5, and then on top of that we'll draw the same lines again but with default_color and line width set to 1.5. This should make the lightning bolt a bit thicker and also look somewhat more like lightning.
不过这样画出来太单薄了。所以我们可以先用 boost_color、线宽 2.5 把所有线段画一遍,再在上面用 default_color、线宽 1.5 再画一遍同样的线。这样闪电会显得更粗,也更像闪电一些。
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)
endI also use an alpha attribute here that starts at 255 and is tweened down to 0 over the duration of the line, which is about 0.15 seconds.
这里我还用了一个 alpha 属性,它初始为 255,并会在这道闪电存在的时间内缓动到 0,而持续时间大概是 0.15 秒。
Now for when to actually create this LightningLine object. The way we want this attack to work is that whenever the player gets close enough to an enemy within his immediate line of sight, the attack will be triggered and we will damage that enemy. So first let's gets all enemies close to the player. We can do this in the same way we did for the homing projectile, which had to pick a target within a certain radius. The radius we want, however, can't be centered on the player because we don't want the player to be able to hit enemies behind him, so we'll offset the center of this circle a little forward in the direction the player is moving towards and then go from there.
接下来要考虑的就是:到底什么时候生成这个 LightningLine。我们希望这招的逻辑是,只要玩家前方视野内有敌人靠得足够近,就触发闪电并对那个敌人造成伤害。所以第一步,要先找出离玩家足够近的敌人。这个过程可以借用 Homing 子弹选目标时的思路,也就是在某个半径内找目标。不过这里的圆心不能放在玩家正中间,因为我们不想让玩家打到身后的敌人。所以要把这个圆沿着玩家当前朝向稍微往前推一点,再基于这个圆去找敌人。
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)
...
endHere we're defining x1, y1, which is the position we generally shoot projectiles from (right in front of the tip of the ship), and then we're also defining cx, cy, which is the center of the radius we'll use to find a nearby enemy. We offset this circle by 24 units, which is a big enough number to prevent us from picking enemies that are behind the player.
这里定义的 x1, y1,就是平时子弹发射出来的位置,也就是飞船尖端前方一点的位置。然后 cx, cy 则是我们接下来要用来检索附近敌人的那个圆的圆心。这个圆往前偏移了 24 个单位,基本足够避免把玩家身后的敌人也算进去。
The next thing we can do is just copypaste the code we used in the Projectile object when we wanted homing projectiles to find their target, but change it to fit our needs here by changing the circle position for our own cx, cy circle center:
接下来,我们可以把 Projectile 对象里 Homing 子弹找目标的那段代码直接拿过来,再稍微改一改,把圆心换成我们这里的 cx, cy:
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(e.x, e.y, cx, cy) < 64) then
return true
end
end
end)
...
endAfter this we'll have a list of enemies within a 64 units radius of a circle 24 units in front of the player. What we can do here is either pick an enemy at random or find the closest one. I'll go with the latter and so to do that we need to sort the table based on each enemy's distance to the circle:
这样做完之后,我们就能拿到一个敌人列表,里面全是位于玩家前方 24 单位处那个圆心、半径 64 范围内的敌人。接下来你可以随机挑一个,也可以找最近的一个。我这里选后者,所以要按每个敌人到圆心的距离来排序:
function Player:shoot()
...
elseif self.attack == 'Lightning' then
...
table.sort(nearby_enemies, function(a, b)
return distance(a.x, a.y, cx, cy) < distance(b.x, b.y, cx, cy)
end)
local closest_enemy = nearby_enemies[1]
...
endtable.sort can be used here to achieve that goal. Then it's a matter of taking the first element of the sorted table and attacking it:
这里用 table.sort 就能完成排序。排完之后,拿第一个元素出来打就是了:
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
endFirst we make sure that closest_enemy isn't nil, because if it is then we shouldn't do anything, and most of the time it will be nil since no enemies will be around. If it isn't, then we remove ammo as we did for all other attacks, and call the hit function on that enemy object so that it takes damage. After that we spawn the LightningLine object with the x1, y1, x2, y2 variables representing the position right in front of the ship from where the bolt will come from and the center of the enemy. Finally, we spawn a bunch of ExplodeParticles to add something to the attack.
第一步先确认 closest_enemy 不是 nil,因为如果它是 nil,就说明周围根本没有合适目标,那自然什么都不该发生。大多数时候其实都会是 nil。如果它不是 nil,那就和其他攻击一样先扣掉 ammo,再调用敌人的 hit 函数让它受伤。然后用 x1, y1, x2, y2 生成 LightningLine 对象,其中前两个值表示闪电从飞船尖端前方哪个位置发出,后两个值表示敌人的中心位置。最后再生成一堆 ExplodeParticle,给这个攻击补点视觉效果。
One last thing we need to set before this can all work is the attacks table:
在这一切生效之前,还有最后一件事要做,就是补上攻击表:
attacks['Lightning'] = {cooldown = 0.2, ammo = 8, abbreviation = 'Li', color = default_color}And all that should look like this:
最终效果应该像这样:

176. (CONTENT) Implement the Explode attack. This is what it looks like:
176. (CONTENT) 实现 Explode 攻击。效果如下:

An explosion is created and it destroys all enemies in a certain radius around it. The projectile itself looks like the homing projectile except with hp_color and a bit bigger. The attack table looks like this:
它会制造一次爆炸,并清掉周围一定半径内的所有敌人。子弹本体看起来和 Homing 子弹差不多,只是颜色改成 hp_color,体积也稍微更大一点。攻击表如下:
attacks['Explode'] = {cooldown = 0.6, ammo = 4, abbreviation = 'E', color = hp_color}177. (CONTENT) Implement the Laser attack. This is what it looks like:
177. (CONTENT) 实现 Laser 攻击。效果如下:

A huge line is created and it destroys all enemies that cross it. This can be programmed literally as a line or as a rotated rectangle for collision detection purposes. If you choose to go with a line then it's better to use 3 or 5 lines instead that are a bit separated from each other, otherwise the player can sometimes miss enemies in a way that doesn't feel fair.
它会生成一条巨大的线,并消灭所有碰到这条线的敌人。碰撞检测上,你可以真的把它当作一条线来做,也可以把它看成一个旋转过的矩形。如果你打算按“线”来处理,最好别只用 1 条,而是用 3 条或者 5 条稍微错开的线,不然有时候玩家明明感觉该打中了,却会擦着敌人过去,体验不太公平。
The effect of the attack itself is different from everything else but shouldn't be too much trouble. One huge white line in the middle that tweens its width down over time, and two red lines to the sides that start closer to the white lines but then spread out and disappear as the effect ends. The shooting effect is a bigger version of the original ShootEffect object and its also colored red. The attack table looks like this:
这招本身的视觉效果和其他攻击都不太一样,不过做起来也不至于太麻烦。中间是一条很粗的白线,会随着时间推移把宽度缓动变小;两侧再各有一条红线,一开始贴得比较近,之后会逐渐向外散开,并在效果结束时一起消失。开火特效则是原版 ShootEffect 的放大版,并且也要换成红色。攻击表如下:
attacks['Laser'] = {cooldown = 0.8, ammo = 6, abbreviation = 'La', color = hp_color}178. (CONTENT) Implement the additional_lightning_bolt passive. If this is set to true then the player will be able to attack with two lightning bolts at once. Programming wise this means that instead of looking for the only the closest enemy, we will look for two closest enemies and attack both if they exist. You may also want to separate each attack by a small duration like 0.5 seconds between each, since this makes it feel better.
178. (CONTENT) 实现 additional_lightning_bolt 被动。如果它为 true,玩家就可以一次打出两道闪电。程序上也就是把“只找最近的一个敌人”改成“找最近的两个敌人”,如果两个都存在就都攻击到。你也可以让两次闪电之间间隔一个很短的时间,比如 0.5 秒,这样手感会更好。
179. (CONTENT) Implement the increased_lightning_angle passive. This passive increases the angle with which the lightning attack can be triggered, meaning that it will also hit enemies to the sides and sometimes behind the player. In programming terms this means that if increased_lightning_angle is true, then we won't offset the lightning circle by 24 units like we did and we will use the center of the player as the center position for our calculations instead.
179. (CONTENT) 实现 increased_lightning_angle 被动。这个被动会扩大 Lightning 攻击可触发的角度,也就是说它不仅能打到前面的敌人,还能打到两侧,甚至偶尔打到身后的敌人。程序上就是说:如果 increased_lightning_angle 为 true,那我们就不再像之前那样把闪电检测圆往前偏移 24 个单位,而是直接把玩家中心当作计算圆心。
180. (CONTENT) Implement the area_multiplier passive. This is a passive that increases the area of all area based attacks and effects. The most recent examples would be the lightning circle of the Lightning attack as well as the explosion area of the Explode attack. But it would also apply to explosions in general and anything that is area based (where a circle is used to get information or apply effects).
180. (CONTENT) 实现 area_multiplier 被动。它会扩大所有基于范围的攻击和效果。刚刚提到的例子包括 Lightning 的检测圆、Explode 的爆炸范围;但它也应该作用于更广义上的所有区域型效果,也就是凡是通过圆形范围来获取信息或施加效果的地方,都要吃到这个倍率。
181. (CONTENT) Implement the laser_width_multiplier passive. This is a passive that increases or decreases the width of the Laser attack.
181. (CONTENT) 实现 laser_width_multiplier 被动。它会提高或降低 Laser 攻击的宽度。
182. (CONTENT) Implement the additional_bounce_projectiles passive. This is a passive that increases the amount of bounces a Bounce projectile has. By default, Bounce attack projectiles can bounce 4 times. If additional_bounce_projectiles is 4, then Bounce attack projectiles should be able to bounce a total of 8 times.
182. (CONTENT) 实现 additional_bounce_projectiles 被动。它会增加 Bounce 子弹可反弹的次数。默认情况下 Bounce 攻击的子弹可以反弹 4 次;如果 additional_bounce_projectiles 为 4,那么它总共就应该能反弹 8 次。
183. (CONTENT) Implement the fixed_spin_attack_direction passive. This is a boolean passive that makes it so that all Spin attack projectiles spin in a fixed direction, meaning, all of them will either spin only left or only right.
183. (CONTENT) 实现 fixed_spin_attack_direction 被动。这是一个布尔型被动,它会让所有 Spin 子弹都沿固定方向旋转,也就是要么全部只往左转,要么全部只往右转。
184. (CONTENT) Implement the split_projectiles_split_chance passive. This is a projectile that adds a chance for projectiles that were split from a 2Split or 4Split attack to also be able to split. For instance, if this chance ever became 100, then split projectiles would split recursively indefinitely (however we'll never allow this to happen on the tree).
184. (CONTENT) 实现 split_projectiles_split_chance 被动。它会让由 2Split 或 4Split 分裂出来的子弹,也有一定几率继续再次分裂。比如如果这个概率真的到了 100,那么分裂子弹就会无限递归地继续分裂下去。当然,我们不会让技能树里真的出现这么离谱的数值。
185. (CONTENT) Implement the [attack]_spawn_chance_multiplier passives, where [attack] is the name of each attack. These passives will increase the chance that a particular attack will be spawned. Right now whenever we spawn an Attack resource, the attack is picked randomly. However, now we want them to be picked from a chanceList that initially has equal chances for all attacks, but will get changed by the [attack]_spawn_chance_multiplier passives.
185. (CONTENT) 实现 [attack]_spawn_chance_multiplier 这一组被动,其中 [attack] 是各个攻击名称。它们会提高某种特定攻击生成出来的概率。现在游戏在生成 Attack 资源时,是完全随机挑一种攻击;而现在我们希望改成从一个 chanceList 里抽取,初始时所有攻击权重相同,但会被这些 [attack]_spawn_chance_multiplier 被动改写。
186. (CONTENT) Implement the start_with_[attack] passives, where [attack] is the name of each attack. These passives will make it so that the player starts with that attack. So for instance, if start_with_bounce is true, then the player will start every round with the Bounce attack. If multiple start_with_[attack] passives are true, then one must be picked at random.
186. (CONTENT) 实现 start_with_[attack] 这一组被动,其中 [attack] 是各个攻击名称。它们会让玩家在每一局开始时就自带对应攻击。比如如果 start_with_bounce 为 true,那么玩家每回合开局都会拥有 Bounce 攻击。如果有多个 start_with_[attack] 同时为 true,那就从里面随机选一个。
Additional Homing Projectiles
The additional_homing_projectiles passive will add additional projectiles to "Launch Homing Projectile"-type passives. In general the way we're launching homing projectiles looks like this:
additional_homing_projectiles 被动会给所有“Launch Homing Projectile”这一类被动额外增加发射数量。通常我们发射 homing 子弹的代码大概是这样:
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
endadditional_homing_projectiles is a number that will tell us how many extra projectiles should be used. So to make this work we can just do something like this:
additional_homing_projectiles 本身是个数字,用来表示要额外多发射多少枚子弹。所以要支持它,只要像下面这样改就行:
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
endAnd then all we have left to do is apply this to every instance where a launch_homing_projectile passive of some kind appears.
剩下要做的,也就是把这套逻辑套到所有 launch_homing_projectile 类型的被动触发点上。
187. (CONTENT) Implement the additional_barrage_projectiles passive.
187. (CONTENT) 实现 additional_barrage_projectiles 被动。
188. (CONTENT) Implement the barrage_nova passive. This is a boolean that when set to true will make it so that barrage projectiles fire in a circle rather than in the general direction the player is looking towards. This is what it looks like:
188. (CONTENT) 实现 barrage_nova 被动。这是一个布尔值;当它为 true 时,barrage 子弹不再朝玩家大致面向的方向发射,而是改成朝四周成圆形散射。效果如下:

Mine Projectile
A mine projectile is a projectile that stays around the location it was created at and eventually explodes. This is what it looks like:
Mine projectile 是一种会停留在生成地点附近、过一段时间后再爆炸的子弹。效果如下:

As you can see, it just rotates like a Spin attack projectile but at a much faster rate. To implement this all we'll do is say that whenever the mine attribute is true for a projectile, it will behave like Spin projectiles do but with an increased spin velocity.
可以看到,它本质上就是像 Spin 子弹那样旋转,只不过转得快得多。要实现这个效果,只要规定:凡是子弹的 mine 属性为 true,它就按 Spin 子弹的逻辑来走,但使用更高的旋转速度。
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
...
endHere instead of confining our spin velocities to between absolute math.pi and 2*math.pi, we do it for absolute 10*math.pi and 12*math.pi. This results in the projectile spinning much faster and covering a smaller area, which is perfect for the kind of behavior we want.
这里不再像普通 Spin 那样把角速度限制在绝对值 math.pi 到 2*math.pi 之间,而是换成绝对值 10*math.pi 到 12*math.pi。这样会让子弹转得非常快,而且绕行范围更小,正好符合地雷型子弹想要的感觉。
Additionally, after a random duration of between 8 and 12 seconds the projectile will explode. This explosion should be handled in the same way that explosions were handled for the Explode projectile. In my case I created an Explosion object, but there are many different ways of doing it and as long as it works it's OK. I'll leave that as an exercise since the Explode attack was also an exercise.
另外,子弹会在 8 到 12 秒后的某个随机时刻爆炸。这个爆炸的处理方式,应该和 Explode 子弹的爆炸一致。我自己是单独做了一个 Explosion 对象,不过实现方式不止一种,只要效果对就行。因为 Explode 攻击本身也是练习题,所以这里我也继续把它留给你自己做。
189. (CONTENT) Implement the drop_mines_chance passive, which adds a chance for the player to drop a mine projectile behind him every 0.5 seconds. Programming wise this works via a timer that will run every 0.5 seconds and on each of those runs we'll roll our drop_mines_chance:next() function.
189. (CONTENT) 实现 drop_mines_chance 被动。它会让玩家每隔 0.5 秒有一定几率在自己身后丢下一枚 mine projectile。程序上可以通过一个每 0.5 秒触发一次的计时器来做,每次触发时调用 drop_mines_chance:next() 掷一次概率。
190. (CONTENT) Implement the projectiles_explode_on_expiration passive, which makes it so that whenever projectiles die because their duration ended, they will also explode. This should only apply to when their duration ends. If a projectile comes into contact with an enemy or a wall it shouldn't explode as a result of this passive being set to true.
190. (CONTENT) 实现 projectiles_explode_on_expiration 被动。它会让子弹在因为寿命结束而死亡时顺带爆炸。注意,这个效果只在“持续时间耗尽”时触发;如果子弹是撞到敌人或者墙才死亡,就不应该因为这个被动而爆炸。
191. (CONTENT) Implement the self_explode_on_cycle_chance passive. This passive adds a chance for the player to create explosions around himself on each cycle. This is what it looks like:
191. (CONTENT) 实现 self_explode_on_cycle_chance 被动。它会让玩家在每个 cycle 触发时,有一定几率在自己周围制造爆炸。效果如下:

The explosions used are the same ones as for the Explode attack. The number, placement and size of the explosions created will be left for you to decide based on what you feel is best.
这里用到的爆炸效果和 Explode 攻击是同一套。至于爆炸数量、摆放位置以及尺寸怎么定,就交给你自己按手感来决定。
192. (CONTENT) Implement the projectiles_explosions passive. If this is set to true then all explosions that happen from a projectile created by the player will create multiple projectiles instead in similar fashion to how the barrage_nova passive works. The number of projectiles created initially is 5 and this number is affected by the additional_barrage_projectiles passive.
192. (CONTENT) 实现 projectiles_explosions 被动。如果它为 true,那么所有由玩家发射出的子弹所引发的爆炸,都会额外生成多枚子弹,逻辑上类似 barrage_nova。初始生成数量是 5 发,之后还要受到 additional_barrage_projectiles 被动的影响。
Energy Shield
When the energy_shield passive is set to true, the player's HP will become energy shield instead (called ES from now). ES works differently than HP in the following ways:
当 energy_shield 被动为 true 时,玩家的 HP 会改成 energy shield,后面简称 ES。ES 和普通 HP 的区别主要有这几条:
- The player will take double damage
- 玩家会承受双倍伤害
- The player's ES will recharge after a certain duration without taking damage
- 玩家在一段时间不受伤后,ES 会自动回复
- The player will have halved invulnerability time
- 玩家的无敌时间会减半
We can implement all of this mostly in the hit 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
...
endWe're defining that our cooldown for when ES starts recharging after a hit is 2 seconds, and that the recharge rate is 4 ES per second (1 per 0.25 seconds in the every call). We're also placing this conditional at the top of the hit function and doubling the damage variable, which will be used further down to actually deal damage to the player.
这里我们设定:玩家受伤后,ES 要等 2 秒才开始回复;回复速度则是每秒 4 点 ES,也就是 every 里每 0.25 秒回 1 点。与此同时,这段判断被放在 hit 函数开头,并且先把 damage 变量翻倍,后面真正扣血时就会按双倍伤害来算。
The only thing left now is making sure that we halve the invulnerability time. We can either do this on the hit function or on the setStats function. I'll go with the latter since we haven't messed with that function in a while.
现在还剩下最后一件事,就是确保无敌时间减半。这个改动可以写在 hit 函数里,也可以写进 setStats。我这里选后者,顺手也把 setStats 再利用一下。
function Player:setStats()
...
if self.energy_shield then
self.invulnerability_time_multiplier = self.invulnerability_time_multiplier/2
end
endSince setStats is called at the end of the constructor and after the treeToPlayer function is called (meaning that it's called after we've loaded in all passives from the tree), we can be sure that energy_shield being true or not is representative of the passives the player picked up in the tree, and we can also be sure that we're only halving the invulnerability timer after all increases/decreases to this multiplier have been applied from the tree. This last assurance isn't really necessary for this passive, since the order here doesn't really matter, but for other passives it might and that would be a case where applying changes in setStats makes sense. Generally if a chance to a stat comes from a boolean and it's a change that is permanent throughout the game, then placing it in setStats makes the most sense.
因为 setStats 是在构造函数末尾调用的,而且是在 treeToPlayer 之后执行,也就是说它运行时,技能树里的所有被动都已经加载进来了。所以这时候 energy_shield 是不是真的为 true,就准确反映了玩家在技能树里是否点出了这个效果。同时也能保证:无敌时间减半这件事,是在树上所有对该倍率的增减都应用完之后才做的。对这个被动本身来说,顺序其实影响不大,但换成别的被动就可能有影响了,所以这类逻辑放在 setStats 里通常更稳。一般来说,如果某个属性变化是由布尔值触发,而且它会在整局游戏里持续生效,那写在 setStats 里通常最合适。
193. (CONTENT) Change the HP UI so that whenever energy_shield is set to true is looks like this instead:
193. (CONTENT) 修改 HP UI,让它在 energy_shield 为 true 时改成下面这种样式:

194. (CONTENT) Implement the energy_shield_recharge_amount_multiplier passive, which increases or decreases the amount of ES recharged per second.
194. (CONTENT) 实现 energy_shield_recharge_amount_multiplier 被动,它会提高或降低每秒回复的 ES 数值。
195. (CONTENT) Implement the energy_shield_recharge_cooldown_multiplier passive, which increases or decreases the cooldown duration before ES starts recharging after a hit.
195. (CONTENT) 实现 energy_shield_recharge_cooldown_multiplier 被动,它会提高或降低 ES 在受伤后开始回复之前的冷却时长。
Added Chance to All 'On Kill' Events
If added_chance_to_all_on_kill_events is 5, for instance, then all 'On Kill' passives will have their chances increased by 5%. This means that if originally the player got passives that accumulated his launch_homing_projectile_on_kill_chance to 8, then instead of having 8% final probability he will have 13% instead. This is a pretty OP passive but implementation wise it's also interesting to look at.
比如说,如果 added_chance_to_all_on_kill_events 的值是 5,那么所有带有 “On Kill” 的被动,其触发概率都会统一额外加上 5%。这意味着,假设玩家原本通过若干被动把 launch_homing_projectile_on_kill_chance 堆到了 8,那么最终概率就不再是 8%,而会变成 13%。这是个相当超模的被动,但从实现角度看倒是挺有意思。
We can implement this by changing the way the generateChances function generates its chanceLists. Since that function goes through all passives that end with _chance, it stands to reason that we can also parse out all passives that have _on_kill on them, which means that once we do that our only job is to add added_chance_to_all_on_kill_events to the appropriate place in the chanceList generation.
这个功能可以通过修改 generateChances 生成 chanceList 的方式来实现。既然这个函数本来就会遍历所有以 _chance 结尾的被动,那么我们自然也可以顺手再筛出所有名字里带 _on_kill 的被动。一旦这一步做完,剩下的工作就只是把 added_chance_to_all_on_kill_events 加到正确的位置上。
So first let separate normal passives from ones that have on_kill on them:
所以第一步,是把普通概率被动和那些带 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
endWe use the same method as we did to find passives with _chance in them, except changing that for _on_kill. Additionally, we also need to make sure that this passive has an above 0% probability of generating its event. We don't want our new passive to add its chance to all 'On Kill' events even when the player invested no points whatsoever in that event, so we only do it for events where the player already has some chance invested in it.
这里的思路和之前找 _chance 被动完全一样,只不过额外再检查一次 _on_kill。另外还要确保,这个事件本身原本就至少有大于 0 的触发概率。我们不希望玩家完全没在某个 “On Kill” 效果上投入点数时,这个新被动还凭空给它加概率。所以只有当玩家本来已经在这个事件上有投入时,我们才给它额外加成。
Now what we can do is simply create the chanceList, but instead of using v by itself we'll use v+added_chance_to_all_on_kill_events:
接下来就很简单了:照常生成 chanceList,只不过不再直接使用 v,而是改用 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
endIncreases in Ammo Added as ASPD
This one is a conversion of part of one stat to another. In this case we're taking all increases to the Ammo resource and adding them as extra attack speed. The precise way we'll do it is following this formula:
这个被动属于“把一种属性的一部分转换成另一种属性”的类型。这里要做的是:把 Ammo 资源上的所有提升,转成额外的攻击速度。具体公式如下:
local ammo_increases = self.max_ammo - 100
local ammo_to_aspd = 30
aspd_multiplier:increase((ammo_to_aspd/100)*ammo_increases)This means that if we have, let's say, 130 max ammo, and our ammo_to_aspd conversion is 30%, then we'll end up increasing our attack speed by 0.3*30 = 9%. If we have 250 max ammo and the same conversion percentage, then we'll end up with 1.5*30 = 45%.
意思就是,如果玩家现在有 130 点最大 ammo,而 ammo_to_aspd 转换比例是 30%,那么最终攻击速度会提升 0.3 * 30 = 9%。如果玩家有 250 点最大 ammo,转换比例还是 30%,那最终得到的就是 1.5 * 30 = 45%。
To get this working we can define the attribute first:
要让它生效,先定义这个属性:
function Player:new(...)
...
-- Conversions
self.ammo_to_aspd = 0
endAnd then we can apply the conversion to the aspd_multiplier variable. Because this variable is a Stat, we need to do it in the update function. If this variable was a normal variable we'd do it in the setStats function instead.
然后把这个转换结果应用到 aspd_multiplier 上。由于这个变量是一个 Stat 对象,所以得在 update 函数里处理;如果它只是普通变量,那就应该写在 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)
...
endAnd this should work out how we expect it to.
这样应该就能按预期工作了。
Final Passives
There are only about 20 more passives left to implement and most of them are somewhat trivial and so will be left as exercises. They don't really have any close relation to most of the passives we went through so far, so even though they may be trivial you can think of them as challenges to see if you really have a grasp on the codebase and on what's actually going on.
剩下大概还有 20 个左右的被动要实现,其中大多数都不算复杂,所以我就把它们留成练习。这些东西和前面讲过的大部分被动没有太强的直接联系,所以即便实现起来不难,也可以把它们当成一次检验,看看你是不是真的已经吃透了这套代码,以及整个系统到底是怎么运作的。
196. (CONTENT) Implement the change_attack_periodically passive, which changes the player's attack every 10 seconds. The new attack is chosen randomly.
196. (CONTENT) 实现 change_attack_periodically 被动。它会让玩家每隔 10 秒自动切换一次攻击方式,新攻击随机选择。
197. (CONTENT) Implement the gain_sp_on_death passive, which gives the player 20SP whenever he dies.
197. (CONTENT) 实现 gain_sp_on_death 被动。它会让玩家每次死亡时获得 20SP。
198. (CONTENT) Implement the convert_hp_to_sp_if_hp_full passive, which gives the player 3SP whenever he gathers an HP resource and his HP is already full.
198. (CONTENT) 实现 convert_hp_to_sp_if_hp_full 被动。当玩家拾取 HP 资源且当前 HP 已满时,会改为获得 3SP。
199. (CONTENT) Implement the mvspd_to_aspd passive, which adds the increases in movement speed to attack speed. The increases should be added using the same formula used for ammo_to_aspd, meaning that if the player has 30% increases to MVSPD and mvspd_to_aspd is 30 (meaning 30% conversion), then his ASPD should be increased by 9%.
199. (CONTENT) 实现 mvspd_to_aspd 被动。它会把移动速度的提升按比例转成攻击速度。计算方式和 ammo_to_aspd 相同,也就是说,如果玩家有 30% 的 MVSPD 提升,而 mvspd_to_aspd 为 30,也就是 30% 转换率,那么他的 ASPD 应该提升 9%。
200. (CONTENT) Implement the mvspd_to_hp passive, which adds the decreases in movement speed to the player's HP. As an example, if the player has 30% decreases to MVSPD and mvspd_to_hp is 30 (meaning 30% conversion), then the added HP should be 21.
200. (CONTENT) 实现 mvspd_to_hp 被动。它会把移动速度的降低部分按比例转成玩家的 HP。比如,如果玩家的 MVSPD 降低了 30%,而 mvspd_to_hp 为 30,也就是 30% 转换率,那么最终增加的 HP 应该是 21。
201. (CONTENT) Implement the mvspd_to_pspd passive, which adds the increases in movement speed to a projectile's speed. This one works in the same way as the mvspd_to_aspd one.
201. (CONTENT) 实现 mvspd_to_pspd 被动。它会把移动速度的提升转换成子弹速度。实现方式和 mvspd_to_aspd 一样。
202. (CONTENT) Implement the no_boost passive, which makes the player have no boost (max_boost = 0).
202. (CONTENT) 实现 no_boost 被动,让玩家彻底失去 boost,也就是 max_boost = 0。
203. (CONTENT) Implement the half_ammo passive, which makes the player have halved ammo.
203. (CONTENT) 实现 half_ammo 被动,让玩家的 ammo 减半。
204. (CONTENT) Implement the half_hp passive, which makes the player have halved HP.
204. (CONTENT) 实现 half_hp 被动,让玩家的 HP 减半。
205. (CONTENT) Implement the deals_damage_while_invulnerable passive, which makes the player deal damage on contact with enemies whenever he is invulnerable (when the invincible attribute is set to true when the player gets hit, for example).
205. (CONTENT) 实现 deals_damage_while_invulnerable 被动。它会让玩家在无敌状态下与敌人接触时造成伤害,比如玩家被击中后 invincible 属性为 true 的那段时间里。
206. (CONTENT) Implement the refill_ammo_if_hp_full passive, which refills the player's ammo completely if the player picks up an HP resource and his HP is already full.
206. (CONTENT) 实现 refill_ammo_if_hp_full 被动。当玩家拾取 HP 资源且 HP 已满时,ammo 会被直接补满。
207. (CONTENT) Implement the refill_boost_if_hp_full passive, which refills the player's boost completely if the player picks up an HP resource and his HP is already full.
207. (CONTENT) 实现 refill_boost_if_hp_full 被动。当玩家拾取 HP 资源且 HP 已满时,boost 会被直接补满。
208. (CONTENT) Implement the only_spawn_boost passive, which makes it so that the only resources that can be spawned are Boost ones.
208. (CONTENT) 实现 only_spawn_boost 被动,让场上只会生成 Boost 资源。
209. (CONTENT) Implement the only_spawn_attack passive, which makes it so that no resources are spawned on their set cooldown, but only Attacks are. This means that attacks are spawned both on the resource cooldown as well as on their own attack cooldown (so every 16 seconds as well as every 30 seconds).
209. (CONTENT) 实现 only_spawn_attack 被动。它会让普通资源不再按各自冷却生成,只有 Attack 会生成。这意味着 Attack 不仅会按自己的攻击冷却生成,也会按资源刷新冷却一起生成,也就是比如每 16 秒和每 30 秒都会刷一次。
210. (CONTENT) Implement the no_ammo_drop passive, which makes it so that enemies never drop ammo.
210. (CONTENT) 实现 no_ammo_drop 被动,让敌人永远不掉落 ammo。
211. (CONTENT) Implement the infinite_ammo passive, which makes it so that no attack the player uses consumes ammo.
211. (CONTENT) 实现 infinite_ammo 被动,让玩家使用任何攻击时都不再消耗 ammo。
And with that we've gone over all passives. In total we went over around 150 different passives, which will be extended to about 900 or so nodes in the skill tree, since many of those passives are just stat upgrades and stat upgrades can be spread over the tree of concentrated in one place.
这样一来,所有被动就都过完了。前前后后我们一共讲了大约 150 种不同的被动,而在技能树里它们会进一步扩展成大约 900 个节点,因为很多被动本质上只是属性加成,而这种加成既可以分散铺满整棵树,也可以集中堆在某几个区域。
But before we get to tree (which we'll go over in the next article), now that we have pretty much all this implemented we can build on top of it and implement all enemies and all player ships as well. You are free to deviate from the examples I'll give on each of those exercises and create your own enemies/ships.
不过在进入技能树之前,也就是下一篇要讲的内容,既然现在这些底层东西基本都已经实现得差不多了,我们也可以顺势往上搭,把所有敌人和玩家飞船类型也一并做出来。下面这些练习里我会给出自己的例子,但你完全可以不照搬,自己设计敌人和飞船。
Enemies
212. (CONTENT) Implement the BigRock enemy. This enemy behaves just like the Rock one, except it's bigger and splits into 4 Rock objects when killed. It has 300 HP by default.
212. (CONTENT) 实现 BigRock 敌人。它的行为和 Rock 基本一致,只不过体型更大,而且被击杀时会分裂成 4 个 Rock。默认 HP 为 300。

213. (CONTENT) Implement the Waver enemy. This enemy behaves like a wavy projectile and occasionally shoots projectiles out of its front and back (like the Back attack). It has 70 HP by default.
213. (CONTENT) 实现 Waver 敌人。它会像一枚波浪形运动的子弹那样移动,并且会偶尔从前后两个方向发射子弹,类似 Back 攻击。默认 HP 为 70。

214. (CONTENT) Implement the Seeker enemy. This enemy behaves like the Ammo object and moves slowly towards the player. At a fixed interval this enemy will also release mines that behave in the same way as mine projectiles. It has 200 HP by default.
214. (CONTENT) 实现 Seeker 敌人。它的行为像 Ammo 对象一样,会缓慢朝玩家移动。同时它还会按固定间隔释放地雷,这些地雷的行为和 mine projectile 相同。默认 HP 为 200。

215. (CONTENT) Implement the Orbitter enemy. This enemy behaves like the Rock or BigRock enemies, but has a field of projectiles surrounding him. Those projectiles behave just like shield projectiles we implemented in the last article. If the Orbitter dies before his projectiles, the leftover projectiles will start homing towards the player for a short duration. It has 450 HP by default.
215. (CONTENT) 实现 Orbitter 敌人。它的本体行为类似 Rock 或 BigRock,但身边会环绕一圈子弹。这些子弹的行为和上一篇实现的 shield projectile 一样。如果 Orbitter 本体先死,而这些子弹还活着,那么剩余子弹会在短时间内开始朝玩家追踪。默认 HP 为 450。

Ships
We already went over visuals for all the ships in article 5 or 6 I think and if I remember correctly those were exercises as well. So I'll assume you have those already made and hidden somewhere and that they have names and all that. The next exercises will assume the ones I created, but since it's mostly usage of passives we implemented in the last and this article, you can create your own ships based on what you feel is best. These are just the ones I personally came up with.
关于飞船的视觉表现,我们大概在第 5 或第 6 篇已经讲过了,如果我没记错,那部分本身也是练习。所以这里我默认你已经把这些飞船做出来并放在某处了,名字之类的也都准备好了。接下来的练习会以我自己做的那几艘船为参考,但因为本质上主要还是在组合上一篇和这一篇实现过的被动,所以你完全可以按自己的想法设计飞船。下面这些只是我个人给出的方案。
216. (CONTENT) Implement the Crusader ship:
216. (CONTENT) 实现 Crusader 飞船:

Its stats are as follows:
它的属性如下:
- Boost = 80
- 推进值 = 80
- Boost Effectiveness Multiplier = 2
- 推进效果倍率 = 2
- Movement Speed Multiplier = 0.6
- 移动速度倍率 = 0.6
- Turn Rate Multiplier = 0.5
- 转向速度倍率 = 0.5
- Attack Speed Multiplier = 0.66
- 攻击速度倍率 = 0.66
- Projectile Speed Multiplier = 1.5
- 子弹速度倍率 = 1.5
- HP = 150
- 生命值 = 150
- Size Multiplier = 1.5
- 体型倍率 = 1.5
217. (CONTENT) Implement the Rogue ship:
217. (CONTENT) 实现 Rogue 飞船:

Its stats are as follows:
它的属性如下:
- Boost = 120
- 推进值 = 120
- Boost Recharge Multiplier = 1.5
- 推进回复倍率 = 1.5
- Movement Speed Multiplier = 1.3
- 移动速度倍率 = 1.3
- Ammo = 120
- 弹药值 = 120
- Attack Speed Multiplier = 1.25
- 攻击速度倍率 = 1.25
- HP = 80
- 生命值 = 80
- Invulnerability Multiplier = 0.5
- 无敌时间倍率 = 0.5
- Size Multiplier = 0.9
- 体型倍率 = 0.9
218. (CONTENT) Implement the Bit Hunter ship:
218. (CONTENT) 实现 Bit Hunter 飞船:

Its stats are as follows:
它的属性如下:
- Movement Speed Multiplier = 0.9
- 移动速度倍率 = 0.9
- Turn Rate Multiplier = 0.9
- 转向速度倍率 = 0.9
- Ammo = 80
- 弹药值 = 80
- Attack Speed Multiplier = 0.8
- 攻击速度倍率 = 0.8
- Projectile Speed Multiplier = 0.9
- 子弹速度倍率 = 0.9
- Invulnerability Multiplier = 1.5
- 无敌时间倍率 = 1.5
- Size Multiplier = 1.1
- 体型倍率 = 1.1
- Luck Multiplier = 1.5
- 幸运倍率 = 1.5
- Resource Spawn Rate Multiplier = 1.5
- 资源生成倍率 = 1.5
- Enemy Spawn Rate Multiplier = 1.5
- 敌人生成倍率 = 1.5
- Cycle Speed Multiplier = 1.25
- Cycle 速度倍率 = 1.25
219. (CONTENT) Implement the Sentinel ship:
219. (CONTENT) 实现 Sentinel 飞船:

Its stats are as follows:
它的属性如下:
- Energy Shield = true
- 启用能量护盾 = true
220. (CONTENT) Implement the Striker ship:
220. (CONTENT) 实现 Striker 飞船:

Its stats are as follows:
它的属性如下:
- Ammo = 120
- 弹药值 = 120
- Attack Speed Multiplier = 2
- 攻击速度倍率 = 2
- Projectile Speed Multiplier = 1.25
- 子弹速度倍率 = 1.25
- HP = 50
- 生命值 = 50
- Additional Barrage Projectiles = 8
- 额外 Barrage 子弹数 = 8
- Barrage on Kill Chance = 10%
- 击杀时触发 Barrage 概率 = 10%
- Barrage on Cycle Chance = 10%
- Cycle 时触发 Barrage 概率 = 10%
- Barrage Nova = true
- 启用 Barrage Nova = true
221. (CONTENT) Implement the Nuclear ship:
221. (CONTENT) 实现 Nuclear 飞船:

Its stats are as follows:
它的属性如下:
- Boost = 80
- 推进值 = 80
- Turn Rate Multiplier = 0.8
- 转向速度倍率 = 0.8
- Ammo = 80
- 弹药值 = 80
- Attack Speed Multiplier = 0.85
- 攻击速度倍率 = 0.85
- HP = 80
- 生命值 = 80
- Invulnerability Multiplier = 2
- 无敌时间倍率 = 2
- Luck Multiplier = 1.5
- 幸运倍率 = 1.5
- Resource Spawn Rate Multiplier = 1.5
- 资源生成倍率 = 1.5
- Enemy Spawn Rate Multiplier = 1.5
- 敌人生成倍率 = 1.5
- Cycle Speed Multiplier = 1.5
- Cycle 速度倍率 = 1.5
- Self Explode on Cycle Chance = 10%
- Cycle 时自爆概率 = 10%
222. (CONTENT) Implement the Cycler ship:
222. (CONTENT) 实现 Cycler 飞船:

Its stats are as follows:
它的属性如下:
- Cycle Speed Multiplier = 2
- Cycle 速度倍率 = 2
223. (CONTENT) Implement the Wisp ship:
223. (CONTENT) 实现 Wisp 飞船:

Its stats are as follows:
它的属性如下:
- Boost = 50
- 推进值 = 50
- Movement Speed Multiplier = 0.5
- 移动速度倍率 = 0.5
- Turn Rate Multiplier = 0.5
- 转向速度倍率 = 0.5
- Attack Speed Multiplier = 0.66
- 攻击速度倍率 = 0.66
- Projectile Speed Multiplier = 0.5
- 子弹速度倍率 = 0.5
- HP = 50
- 生命值 = 50
- Size Multiplier = 0.75
- 体型倍率 = 0.75
- Resource Spawn Rate Multiplier = 1.5
- 资源生成倍率 = 1.5
- Enemy Spawn Rate Multiplier = 1.5
- 敌人生成倍率 = 1.5
- Shield Projectile Chance = 100%
- 生成护盾子弹概率 = 100%
- Projectile Duration Multiplier = 1.5
- 子弹持续时间倍率 = 1.5
END
And now we've finished implementing all content in the game. These last two articles were filled with exercises that are mostly about manually adding all this content. To some people that might be extremely boring, so it's a good gauge of if you like implementing this kind of content or not. A lot of game development is just straight up stuff like this, so if you really really don't like it it's better to learn about it sooner rather than later.
到这里,游戏里的所有内容就都实现完了。最后这两篇基本全是练习,核心就是手动把这一大堆内容一点点塞进游戏里。对有些人来说,这会无聊得要命;但它也正好能帮你判断,你到底喜不喜欢做这种类型的内容实现。游戏开发里有很大一部分工作,本来就是这种扎扎实实堆内容的活,所以如果你真的非常讨厌,早点意识到这件事反而更好。
The next article will focus on the skill tree, which is how we're going to present all these passives to the player. We'll focus on building everything necessary to make the skill tree work, but the building of the tree itself (like placing and linking nodes together) will be entirely up to you. This is another one of those things where we're just manually adding content to the game and not doing anything too complicated.
下一篇会开始讲技能树,也就是我们打算用来把这些被动呈现给玩家的系统。重点会放在把技能树运行起来所需要的一切都搭好,至于技能树本身怎么构建,比如节点怎么摆、怎么连线,就完全交给你自己决定了。这同样属于那种“工作量不少,但技术复杂度没那么高”的内容填充型任务。