跳转至内容

BYTEPATH #11 - Passives

Introduction

In this article we'll go over the implementation of all passives in the game. There are a total of about 120 different things we will implement and those are enough to be turned into a very big skill tree (the tree I made has about 900 nodes, for instance).

这一篇我们会把游戏里所有被动效果的实现方式都过一遍。总共大概要做出 120 种左右的东西,而这些内容已经足够拼成一棵非常庞大的技能树了(比如我自己做的那棵,大约就有 900 个节点)。

This article will be filled with exercises tagged as content, and the way that will work is that I'll show you how to do something, and then give you a bunch of exercises to do that same thing but applying it to other stats. For instance, I will show you how to implement an HP multiplier, which is a stat that will multiply the HP of the player by a certain percentage, and then the exercises will ask for you to implement Ammo and Boost multipliers. In reality things will get a bit more complicated than that but this is the basic idea.

这篇里会有大量带着 content 标记的练习题。基本套路是:我先演示一种东西该怎么做,然后让你照着同样思路,把它扩展到别的属性上。比如我会先示范如何实现一个 HP multiplier,也就是按百分比提高玩家 HP 的属性;接着练习里就会让你自己去做 Ammo 和 Boost multiplier。实际内容当然会比这个稍微复杂一点,但大致思路就是这样。

After we're done with the implementation of everything in this article we'll have pretty much have most of the game's content implemented and then it's a matter of finishing up small details, like building the huge skill tree out of the passives we implemented. 😄

等这一篇里的内容都实现完,整款游戏里绝大多数内容基本也就齐了。之后剩下的主要就是补一些收尾细节,比如把这些被动真正组织成那棵庞大的技能树。😄

Types of Stats

Before we start with the implementation of everything we need to first decide what kinds of passives our game will have. I already decided what I wanted to do so I'm going to just follow that, but you're free to deviate from this and come up with your own ideas.

在真正开始写实现之前,得先想清楚游戏里的被动大致会分成哪些类型。我自己已经把方向定好了,所以接下来会按我的方案来做;不过你完全可以偏离这条路,按自己的想法重新设计。

The game will have three main types of passive values: resources, stat multipliers and chances.

这款游戏里的被动值,主要分成三大类:resources、stat multipliers 和 chances。

  • Resources are HP, Boost and Ammo. These are values that are described by a max_value variable as well as a current_value variable. In the case of HP we have the maximum HP the player has, and then we also have the current amount.
  • Resources 指的是 HP、Boost 和 Ammo。这些值通常都由一对变量来描述:一个是 max_value,一个是 current_value。比如 HP,就既有玩家的最大 HP,也有当前 HP。
  • Stat multipliers are multipliers that are applied to various values around the game. As the player goes around the tree picking up nodes, he'll be picking up stuff like "10% Increased Movement Speed", and so after he does that and starts a new match, we'll take all the nodes the player picked, pack them into those multiplier values, and then apply them in the game. So if the player picked nodes that amounted to 50% increased movement speed, then the movement speed multiplier will be applied to the max_v variable, so some mvspd_multiplier variable will be 1.5 and our maximum velocity will be multiplied by 1.5 (which is a 50% increase).
  • Stat multipliers 是施加到游戏各种数值上的倍率。玩家在技能树里一路点节点时,会拿到类似“10% Increased Movement Speed”这样的效果。等他开新一局时,我们就会把所有已选节点汇总成这些 multiplier,再在游戏里统一应用。比如玩家总共点出了 50% 的移动速度加成,那移动速度 multiplier 就会作用到 max_v 上,于是某个 mvspd_multiplier 变量会变成 1.5,而最大速度也就会乘上 1.5,也就是提升 50%。
  • Chances are exactly that, chances for some event to happen. The player will also be able to pick up added chance for certain events to happen in different circumstances. For instance, "5% Added Chance to Barrage on Cycle", which means that whenever a cycle ends (the 5 second period we implemented), there's a 5% chance for the player to launch a barrage of projectiles. If the player picks up tons of those nodes then the chance gets higher and a barrage happens more frequently.
  • Chances 顾名思义,就是某件事发生的概率。玩家也可以通过技能树去提高某些事件在特定条件下发生的概率。比如“5% Added Chance to Barrage on Cycle”,意思就是每当一次 cycle 结束时,也就是我们之前实现过的那个 5 秒周期结束时,玩家就有 5% 几率打出一串 barrage。如果这类节点点得很多,这个概率就会越来越高,barrage 触发也会越来越频繁。

The game will have an additional type of node and an additional mechanic: notable nodes and temporary buffs.

除此之外,游戏里还会有一种额外的节点类型,以及一种额外机制:notable nodes 和 temporary buffs。

  • Notable nodes are nodes that change the logic of the game in some way (although not always). For instance, there's a node that replaces your HP for Energy Shield. And with ES you take double damage, your ES recharges after you don't take damage for a while, and you have halved invulnerability time. Nodes like these are not as numerous as others but they can be very powerful and combined in fun ways.
  • Notable nodes 指的是那些会从根本上改变游戏逻辑的节点,虽然也不一定每个都这么重。比如有个节点会把你的 HP 替换成 Energy Shield。启用 ES 后,你会承受双倍伤害、在一段时间不受伤后开始回复 ES,而且无敌时间还会减半。像这种节点数量不会特别多,但通常都很强,而且彼此之间能玩出很多有意思的组合。
  • Temporary buffs are temporary boosts to your stats. Sometimes you'll get a temporary buff that, say, increases your attack speed by 50% for 4 seconds.
  • Temporary buffs 就是临时性的属性增益。比如你有时会拿到一个持续 4 秒、让攻击速度提高 50% 的短时 buff。

Knowing all this we can get started. To recap, the current resource stats we have in our codebase should look like this:

明确了这些分类之后,就可以正式开工了。先回顾一下,目前代码里的 resource 属性大概应该是这样:

lua
function Player:new(...)
    ...
  
    -- Boost
    self.max_boost = 100
    self.boost = self.max_boost
    ...

    -- HP
    self.max_hp = 100
    self.hp = self.max_hp

    -- Ammo
    self.max_ammo = 100
    self.ammo = self.max_ammo
  
    ...
end

The movement code values should look like this:

而移动相关的数值应该是这样:

lua
function Player:new(...)
    ...
  	
    -- Movement
    self.r = -math.pi/2
    self.rv = 1.66*math.pi
    self.v = 0
    self.base_max_v = 100
    self.max_v = self.base_max_v
    self.a = 100
	
    ...
end

And the cycle values should look like this (I renamed all previous references to the word "tick" to be "cycle" now for consistency):

而 cycle 相关的数值应该长这样(为了统一说法,我现在把之前所有叫 “tick” 的地方都改叫 “cycle” 了):

lua
function Player:new(...)
    ...
  
    -- Cycle
    self.cycle_timer = 0
    self.cycle_cooldown = 5

    ...
end

HP multiplier

So let's start with the HP multiplier. In a basic way all we have to do is define a variable named hp_multiplier that starts as the value 1, and then we apply the increases from the tree to this variable and multiply it by max_hp at some point. So let's do the first thing:

那就先从 HP multiplier 开始。最基础的做法其实很简单:先定义一个叫 hp_multiplier 的变量,初始值为 1;然后把技能树里拿到的加成累积到这个变量上;最后在某个时机拿它去乘 max_hp。先做第一步:

lua
function Player:new(...)
    ...
  	
    -- Multipliers
    self.hp_multiplier = 1
end

Now the second thing is that we have to assume we're getting increases to HP from the tree. To do this we have to assume how these increases will be passed in and how they'll be defined. Here I have to cheat a little (since I already wrote the game once) and say that the tree nodes will be defined in the following format:

接下来第二步,是假设技能树那边真的会把 HP 加成传进来。要做到这一点,我们得先约定树节点的数据格式。这里我得稍微“作弊”一下,因为这游戏我本来就做过一遍,所以我直接告诉你:技能树节点会按下面这种格式定义:

lua
tree[2] = {'HP', {'6% Increased HP', 'hp_multiplier', 0.06}}

This means that node #2 is named HP, has as its description 6% Increased HP, and affects the variable hp_multiplier by 0.06 (6%). There is a function named treeToPlayer which takes all 900~ of those node definitions and then applies them to the player object. It's important to note that the variable name used in the node definition has to be the same name as the one defined in the player object, otherwise things won't work out. This is a very thinly linked and error-prone method of doing it I think, but as I said in the previous article it's the kind of thing you can get away with because you're coding by yourself.

这表示:第 2 号节点叫 HP,它的描述文字是 6% Increased HP,而它会对 hp_multiplier 这个变量施加 0.06,也就是 6% 的增量。后面会有一个叫 treeToPlayer 的函数,它会把这 900 来条节点定义全部读一遍,然后把对应效果应用到玩家对象上。这里有个关键点:节点定义里写的变量名,必须和 Player 对象里真正存在的变量名完全一致,不然整套逻辑就接不上。我自己也觉得这种做法耦合得很松、也挺容易出错,但正如上一篇说过的,一个人自己写项目时,这种方式其实是完全用得下去的。

Now the final question is: when do we multiply hp_multiplier by max_hp? The natural option here is to just do it on the constructor, since that's when a new player is created, and a new player is created whenever a new Stage room is created, which is also when a new match starts. However, we'll do this at the very end of the constructor, after all resources, multipliers and chances have been defined:

现在最后一个问题是:到底什么时候拿 hp_multiplier 去乘 max_hp?最顺手的地方,当然就是构造函数里。因为玩家对象是在创建新一局的时候才会被新建,而新玩家的诞生也就意味着一局开始。不过我们会把这一步放在构造函数的最后,也就是等 resources、multipliers 和 chances 全部都定义完之后:

lua
function Player:new(...)
    ...
  
    -- treeToPlayer(self)
    self:setStats()
end

And so in the setStats function we can do this:

这样的话,在 setStats 里就可以这么写:

lua
function Player:setStats()
    self.max_hp = self.max_hp*self.hp_multiplier
    self.hp = self.max_hp
end

And so if you set hp_multiplier to 1.5 for instance and run the game, you'll notice that now the player will have 150 HP instead of its default 100.

比如你把 hp_multiplier 改成 1.5,再运行游戏,就会发现玩家现在拥有的是 150 HP,而不是默认的 100。

Note that we also have to assume the existence of the treeToPlayer function here and pass the player object to that function. Eventually when we write the skill tree code and implement that function, what it will do is set the values of all multipliers based on the bonuses from the tree, and then after those values are set we can call setStats to use those to change the stats of the player.

另外,这里也默认 treeToPlayer 这个函数会存在,并且接收玩家对象。等后面真正写技能树、把这个函数实现出来之后,它做的事情就是:先把树里的所有加成汇总到玩家的各个 multiplier 上,再在这些值都准备好之后调用 setStats,把它们真正作用到玩家属性里。

123. (CONTENT) Implement the ammo_multiplier variable.

123. (CONTENT) 实现 ammo_multiplier 变量。

124. (CONTENT) Implement the boost_multiplier variable.

124. (CONTENT) 实现 boost_multiplier 变量。

Flat HP

Now for a flat stat. Flat stats are direct increases to some stat instead of a percentage based one. The way we'll do it for HP is by defining a flat_hp variable which will get added to max_hp (before being multiplied by the multiplier):

接下来做一个 flat stat。所谓 flat stat,就是不按百分比,而是直接往某个属性上加固定值。对 HP 来说,我们会定义一个 flat_hp 变量,并在乘上 multiplier 之前,先把它加到 max_hp 上:

lua
function Player:new(...)
    ...
  	
    -- Flats
    self.flat_hp = 0
end
lua
function Player:setStats()
    self.max_hp = (self.max_hp + self.flat_hp)*self.hp_multiplier
    self.hp = self.max_hp
end

Like before, whenever we define a node in the tree we want to link it to the relevant variable, so, for instance, a node that adds flat HP could look like this:

和前面一样,只要在树里定义一个节点,我们就得把它和对应变量连起来。比如一个增加固定 HP 的节点可以写成:

lua
tree[15] = {'Flat HP', {'+10 Max HP', 'flat_hp', 10}}

125. (CONTENT) Implement the flat_ammo variable.

125. (CONTENT) 实现 flat_ammo 变量。

126. (CONTENT) Implement the flat_boost variable.

126. (CONTENT) 实现 flat_boost 变量。

127. (CONTENT) Implement the ammo_gain variable, which adds to the amount of ammo gained when the player picks one up. Change the calculations in the addAmmo function accordingly.

127. (CONTENT) 实现 ammo_gain 变量。它会增加玩家每次拾取 ammo 时得到的数量。并据此修改 addAmmo 函数里的计算方式。

Homing Projectile

The next passive we'll implement is "Chance to Launch Homing Projectile on Ammo Pickup", but for now we'll focus on the homing projectile part. One of the attacks the player will have is a homing projectile so we'll just implement that as well now.

接下来要实现的被动是 “Chance to Launch Homing Projectile on Ammo Pickup”,不过现在我们先只做其中的 homing projectile 这一半。反正玩家本来也会有一种 homing 攻击,所以这里干脆顺手先把它实现掉。

A projectile will have its homing function activated whenever the attack attribute is set to 'Homing'. The code that actually does the homing will be the same as the code we used for the Ammo resource:

只要一个 projectile 的 attack 属性被设成 'Homing',它的追踪逻辑就会启用。真正负责追踪的代码,和我们之前写给 Ammo resource 的那套 seek 逻辑是一样的:

lua
function Projectile:update(dt)
    ...
    self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))

    -- Homing
    if self.attack == 'Homing' then
    	-- Move towards target
        if self.target then
            local projectile_heading = Vector(self.collider:getLinearVelocity()):normalized()
            local angle = math.atan2(self.target.y - self.y, self.target.x - self.x)
            local to_target_heading = Vector(math.cos(angle), math.sin(angle)):normalized()
            local final_heading = (projectile_heading + 0.1*to_target_heading):normalized()
            self.collider:setLinearVelocity(self.v*final_heading.x, self.v*final_heading.y)
        end
    end
end

The only thing we have to do differently is defining the target variable. For the Ammo object the target variable points to the player object, but in the case of a projectile it should point to a nearby enemy. To get a nearby enemy we can use the getAllGameObjectsThat function that is defined in the Area class, and use a filter that will only select objects that are enemies and that are close enough. To do this we must first define what objects are enemies and what objects aren't enemies, and the easiest way to do that is to just have a global table called enemies which will contain a list of strings with the name of the enemy classes. So in globals.lua we can add the following definition:

这里唯一不同的是 target 变量怎么取。Ammo 的 target 指向的是玩家,但 projectile 的 target 应该指向附近的某个敌人。要拿到附近敌人,可以借用 Area 类里的 getAllGameObjectsThat 函数,再配一个筛选器,只选出“既是敌人、又离得够近”的对象。为此,首先得先约定哪些对象算敌人,最简单的做法就是维护一张全局表 enemies,里面放敌人类名对应的字符串。比如在 globals.lua 里这样写:

lua
enemies = {'Rock', 'Shooter'}

And as we add more enemies into the game we also add their string to this table accordingly. Now that we know which object types are enemies we can easily select them:

之后每往游戏里加一种新敌人,就顺手把它的名字追加进这张表。既然敌人类型已经有了统一列表,那么筛选它们就很简单了:

lua
local targets = self.area:getAllGameObjectsThat(function(e)
    for _, enemy in ipairs(enemies) do
    	if e:is(_G[enemy]) then
            return true
        end
    end
end)

We use the _G[enemy] line to access the class definition of the current string we're looping over. So _G['Rock'] will return the table that contains the class definition of the Rock class. We went over this in multiple articles so it should be clear by now why this works.

这里用 _G[enemy] 去取得当前字符串对应的类定义。也就是说,_G['Rock'] 会返回 Rock 这个类定义所在的表。前面几篇里我们已经反复讲过这种做法,所以现在它为什么成立,应该已经很清楚了。

Now for the other condition we want to select only enemies that are within a certain radius of this projectile. Through trial and error I came to a radius of about 400 units, which is not small enough that the projectile will never have a proper target, but not big enough that the projectile will try to hit offscreen enemies too much:

接下来还要加上另一个条件:我们只想选出离当前 projectile 一定半径范围内的敌人。这个半径我最后试出来大概 400 左右比较合适,既不会小到经常找不到目标,也不会大到 projectile 总想去追画面外的敌人:

lua
local targets = self.area:getAllGameObjectsThat(function(e)
    for _, enemy in ipairs(enemies) do
    	if e:is(_G[enemy]) and (distance(e.x, e.y, self.x, self.y) < 400) then
            return true
        end
    end
end)

distance is a function we can define in utils.lua which returns the distance between two positions:

distance 是我们可以定义在 utils.lua 里的一个工具函数,用来返回两点之间的距离:

lua
function distance(x1, y1, x2, y2)
    return math.sqrt((x1 - x2)*(x1 - x2) + (y1 - y2)*(y1 - y2))
end

And so after this we should have our enemies in the targets list. After that all we want to do is get a random one of them and point that as the target that the projectile will move towards:

这样做完后,targets 里就会装着所有候选敌人。接下来只需要从里面随便抽一个出来,设成 projectile 的 target 即可:

lua
self.target = table.remove(targets, love.math.random(1, #targets))

And all that should look like this:

把这些拼起来,完整逻辑会像这样:

lua
function Projectile:update(dt)
    ...

    self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))

    -- Homing
    if self.attack == 'Homing' then
        -- Acquire new target
        if not self.target then
            local targets = self.area:getAllGameObjectsThat(function(e)
                for _, enemy in ipairs(enemies) do
                    if e:is(_G[enemy]) and (distance(e.x, e.y, self.x, self.y) < 400) then
                        return true
                    end
                end
            end)
            self.target = table.remove(targets, love.math.random(1, #targets))
        end
        if self.target and self.target.dead then self.target = nil end

        -- Move towards target
        if self.target then
            local projectile_heading = Vector(self.collider:getLinearVelocity()):normalized()
            local angle = math.atan2(self.target.y - self.y, self.target.x - self.x)
            local to_target_heading = Vector(math.cos(angle), math.sin(angle)):normalized()
            local final_heading = (projectile_heading + 0.1*to_target_heading):normalized()
            self.collider:setLinearVelocity(self.v*final_heading.x, self.v*final_heading.y)
        end
    end
end

There's an additional line at the end of the block where we acquire a new target, where we set self.target to nil in case the target has been killed. This makes it so that whenever the target for this projectile stops existing, self.target will be set to nil and a new target will be acquired, since the condition not self.target will be met and then the whole process will repeat itself. It's also important to mention that once a target has been acquired we don't do any more calculations, so there's no big need to worry about the performance of getAllGameObjectsThat, which is a function that naively loops over all objects currently alive in the game.

这里还有一行很关键,就是在 target 已经死亡的情况下,把 self.target 重新设回 nil。这样只要这个 projectile 当前锁定的敌人不存在了,下一次就会再次满足 not self.target,然后自动重新找一个目标。顺便也要提一句:一旦目标锁定成功,后面就不会继续重复做那套全场扫描,所以其实没必要太担心 getAllGameObjectsThat 的性能。它虽然是个很朴素、会遍历当前所有对象的函数,但这里的调用频率并不高。

One extra thing we have to do is change how the projectile object behaves whenever it's not homing or whenever there's no target. Intuitively using setLinearVelocity first to set the projectile's velocity once, and then using it again inside the if self.attack == 'Homing' loop would make sense, since the velocity would only be changed if the projectile is in fact homing and if a target exists. But for some reason doing that results in all sorts of problems, so we have to make sure we only call setLinearVelocity once, and that implies something like this:

还有一件额外要处理的事:当 projectile 不是 homing,或者暂时没有 target 时,它的运动逻辑要怎么写。直觉上看,先用一次 setLinearVelocity 设普通速度,再在 if self.attack == 'Homing' 里按需覆盖一次,好像挺合理。但不知为什么,这么写会引发一堆奇怪问题。所以最稳妥的办法,是保证每帧只调用一次 setLinearVelocity,写法就得改成这样:

lua
-- Homing
if self.attack == 'Homing' then
    ...
-- Normal movement
else self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) end

This is a bit more confusing than the previous setup but it works. And if we test all this and create a projectile with the attack attribute set to 'Homing' it should look like this:

这种写法确实比之前更拧巴一点,但它能正常工作。如果你测试一下,并生成一个 attack = 'Homing' 的 projectile,效果应该像这样:

128. (CONTENT) Implement the Homing attack. Its definition on the attacks table looks like this:

128. (CONTENT) 实现 Homing 攻击。它在 attacks 表中的定义如下:

lua
attacks['Homing'] = {cooldown = 0.56, ammo = 4, abbreviation = 'H', color = skill_point_color}

And the attack itself looks like this:

攻击效果长这样:

Note that the projectile for this attack (as well as others that are to come) is slightly different. It's a rhombus half colored as white and half colored as the color of the attack (in this case skill_point_color), and it also has a trail that's the same as the player's.

注意,这个攻击的 projectile,以及后面还会出现的几个 projectile,外观会和普通的略有不同。它是一个菱形,一半是白色,一半是攻击本身的颜色(这里是 skill_point_color),同时还会带一条和玩家飞船拖尾类似的 trail。

Chance to Launch Homing Projectile on Ammo Pickup

Now we can move on to what we wanted to implement, which is this chance-type passive. This one is has a chance to be triggered whenever we pick the Ammo resource up. We'll hold this chance in the launch_homing_projectile_on_ammo_pickup_chance variable and then whenever an Ammo resource is picked up, we'll call a function that will handle rolling the chances for this event to happen.

现在可以回到我们真正想做的东西了,也就是这个概率型被动:拾取 Ammo resource 时,有几率发射 Homing Projectile。我们会把这个概率保存在 launch_homing_projectile_on_ammo_pickup_chance 变量里。然后每次拾取 Ammo 时,再专门调用一个函数,让它负责掷这次概率。

But before we can do that we need to specify how we'll handle these chances. As I introduced in another article, here we'll also use the chanceList concept. If an event has 5% probability of happening, then we want to make sure that it will actually follow that 5% somewhat reasonably, and so it just makes sense to use chanceLists.

不过在此之前,得先先统一一下这些“概率”到底怎么处理。和前面别的地方一样,这里我们也会继续使用 chanceList 的概念。因为如果某件事标着 5% 概率,我们就希望它长期看来能比较像 5%,而不是纯随机到特别飘,所以继续用 chanceList 会更合理。

The way we'll do it is that after we call the setStats function on the Player's constructor, we'll also call a function called generateChances which will create all the chanceLists we'll use throughout the game. Since there will be lots and lots of different events that will need to be rolled we'll put all chanceLists into a table called chances, and organize things that so whenever we need to roll for a chance of something happening, we can do something like:

做法是这样的:在 Player 构造函数里调用完 setStats 之后,我们还会再调用一个叫 generateChances 的函数,用来生成游戏里会用到的所有 chanceList。因为后面需要掷概率的事件会很多,所以我们会把这些 chanceList 全部放进一张叫 chances 的表里。这样一来,只要需要掷某个事件的概率,我们就可以这么写:

lua
if self.chances.launch_homing_projectile_on_ammo_pickup_chance:next() then
    -- launch homing projectile
end

We could set up the chances table manually, so that every time we add a new _chance type variable that will hold the chances for some event to happen, we also add and generate its chanceList in the generateChances function. But we can be a bit clever here and decide that every variable that deals with chances will end with _chance, and then we can use that to our advantage:

当然,你也完全可以手动维护这张 chances 表:每新增一个 _chance 类型变量,就在 generateChances 里手动把对应 chanceList 建出来。不过这里我们可以稍微聪明一点,干脆约定“凡是处理概率的变量,名字都以 _chance 结尾”,然后顺着这个约定自动生成:

lua
function Player:generateChances()
    self.chances = {}
    for k, v in pairs(self) do
        if k:find('_chance') and type(v) == 'number' then
      
        end
    end
end

Here we're going through all key/value pairs inside the player object and returning true whenever we find an attribute that contains in its name the _chance substring, as well as being a number. If both those things are true then based on our own decision this is a variable that is dealing with chances of some event happening. So now all we have to do is then create the chanceList and add it to the chances table:

这里我们遍历 Player 对象里的所有键值对。只要某个属性名里包含 _chance,并且它本身又是个数字,那按照我们的约定,它就应该是某种“事件发生概率”。既然如此,接下来只需要为它创建对应的 chanceList,并塞进 chances 表里就行:

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

And so this will create a chanceList of 100 values, with v of them being true, and 100-v of them being false. So if the only chance-type variable we had defined in our player object was the launch_homing_projectile_on_ammo_pickup_chance one, and this had the value 5 attached to it (meaning 5% probability of this event happening), then the chanceList would have 5 true values and 95 false ones, which gets us what we wanted.

这样生成出来的 chanceList 长度是 100,其中有 vtrue,以及 100-vfalse。所以如果玩家身上唯一的 chance 属性是 launch_homing_projectile_on_ammo_pickup_chance,而它的值是 5,也就是 5%,那最终就会得到一个包含 5 个 true 和 95 个 false 的列表,这正是我们想要的效果。

And so if we call generateChances on the player's constructor:

因此,只要在玩家构造函数里调用 generateChances

lua
function Player:new(...)
    ...
  
    -- treeToPlayer(self)
    self:setStats()
    self:generateChances()
end

Then everything should work fine. We can now define the launch_homing_projectile_on_ammo_pickup_chance variable:

整套东西就能正常跑起来了。现在我们也可以正式定义 launch_homing_projectile_on_ammo_pickup_chance

lua
function Player:new(...)
    ...
  	
    -- Chances
    self.launch_homing_projectile_on_ammo_pickup_chance = 0
end

And if you wanna test that the roll system works, you can set that to a value like 50 and then call :next() a few times to see what happens.

如果你想测试这套掷概率机制有没有生效,可以先把它临时改成 50,再多调用几次 :next() 看看结果。

The implementation of the actual launching will happen through the onAmmoPickup function, which will be called whenever Ammo is picked up:

真正发射 homing projectile 的逻辑,会写在 onAmmoPickup 函数里。这个函数会在每次拾取 Ammo 时被调用:

lua
function Player:update(dt)
    ...
    if self.collider:enter('Collectable') then
        ...
    
        if object:is(Ammo) then
            object:die()
            self:addAmmo(5)
            self:onAmmoPickup()
      	...
    end
end

And that function then would look like this:

onAmmoPickup 本身可以写成这样:

lua
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

And then all that would end up looking like this:

最后整体效果就会像这样:

129. (CONTENT) Implement the regain_hp_on_ammo_pickup_chance passive. The amount of HP regained is 25 and should be added with the addHP function, which adds the given amount of HP to the hp value, making sure that it doesn't go above max_hp. Additionally, an InfoText object should be created with the text 'HP Regain!' in hp_color.

129. (CONTENT) 实现 regain_hp_on_ammo_pickup_chance 被动。回复量为 25 HP,并应通过 addHP 函数来增加,也就是把指定 HP 加到 hp 上,同时确保不超过 max_hp。此外,还需要生成一个 InfoText,文本为 'HP Regain!',颜色使用 hp_color

130. (CONTENT) Implement the regain_hp_on_sp_pickup_chance passive. he amount of HP regained is 25 and should be added with the addHP function. An InfoText object should be created with the text 'HP Regain!' in hp_color. Additionally, an onSPPickup function should be added to the Player class and in it all this work should be done (like we did with the onAmmoPickup function).

130. (CONTENT) 实现 regain_hp_on_sp_pickup_chance 被动。回复量同样为 25 HP,并通过 addHP 函数处理。同时还需要生成一个 InfoText,文本为 'HP Regain!',颜色为 hp_color。另外,给 Player 类新增一个 onSPPickup 函数,并把这一整套逻辑都放进去,做法和 onAmmoPickup 一样。

Haste Area

The next passives we want to implement are "Chance to Spawn Haste Area on HP Pickup" and "Chance to Spawn Haste Area on SP Pickup". We already know how to do the "on Resource Pickup" part, so now we'll focus on the "Haste Area". A haste area is simply a circle that boosts the player's attack speed whenever he is inside it. This boost in attack speed will be applied as a multiplier, so it makes sense for us to implement the attack speed multiplier first.

接下来要做的被动是 “Chance to Spawn Haste Area on HP Pickup” 和 “Chance to Spawn Haste Area on SP Pickup”。“on Resource Pickup” 这一半我们已经很熟了,所以现在重点放在 “Haste Area” 本身。所谓 haste area,其实就是一个圆形区域,只要玩家待在里面,就会获得攻速加成。既然这个加成会体现在 multiplier 上,那我们自然应该先把 attack speed multiplier 这件事做好。

ASPD multiplier

We can define an ASPD multiplier simply as the aspd_multiplier variable and then multiply this variable by our shooting cooldown:

ASPD multiplier 最基础的做法,就是定义一个 aspd_multiplier 变量,然后把它作用到射击冷却上:

lua
function Player:new(...)
    ...
  	
    -- Multipliers
    self.aspd_multiplier = 1
end
lua
function Player:update(dt)
    ...
  
    -- Shoot
    self.shoot_timer = self.shoot_timer + dt
    if self.shoot_timer > self.shoot_cooldown*self.aspd_multiplier then
        self.shoot_timer = 0
        self:shoot()
    end
end

The main difference that this one multiplier in particular will have is that lower values are better than higher values. In general, if a multiplier value is 0.5 then it's cutting whatever stat it's being applied to by half. So for HP, movement speed and pretty much everything else this is a bad thing. However, for attack speed lower values are better, and this can be simply explained by the code above. Since we're applying the multiplier to the shoot_cooldown variable, lower values means that this cooldown will be lower, which means that the player will shoot faster. We'll use this knowledge next when creating the HasteArea object.

这个 multiplier 和别的 multiplier 最大的区别在于:这里是数值越低越好。一般来说,一个 multiplier 如果是 0.5,意味着它把对应属性砍半;对 HP、移动速度之类来说,这通常都是坏事。但对 attack speed 来说反而不是。原因从上面的代码里就能看出来:因为这个 multiplier 是乘在 shoot_cooldown 上的,所以值越低,冷却越短,玩家射得也就越快。等会实现 HasteArea 时,就会用到这个结论。

Haste Area

And now that we have the ASPD multiplier we can get back to this. What we want to do here is to create a circular area that will decrease aspd_multiplier by some amount as long as the player is inside it. To achieve this we'll create a new object named HasteArea which will handle the logic of seeing if the player is inside it or not and setting the appropriate values in case he is. The basic structure of the object looks like this:

现在既然有了 ASPD multiplier,就可以回过头实现 Haste Area 了。我们的目标是:做一个圆形区域,只要玩家站在里面,就把 aspd_multiplier 按某个幅度往下压。为了实现这一点,我们会新建一个 HasteArea 对象,由它来负责判断玩家是不是在区域内,并在需要时修改对应数值。对象的基本结构可以是这样:

lua
function HasteArea:new(...)
    ...
  
    self.r = random(64, 96)
    self.timer:after(4, function()
        self.timer:tween(0.25, self, {r = 0}, 'in-out-cubic', function() self.dead = true end)
    end)
end

function HasteArea:update(dt)
    ...
end

function HasteArea:draw()
    love.graphics.setColor(ammo_color)
    love.graphics.circle('line', self.x, self.y, self.r + random(-2, 2))
    love.graphics.setColor(default_color)
end

For the logic behind applying the actual effect we have to keep track of when the player enters/leaves the area and then modify the aspd_multiplier value once that happens. The way to do this looks something like this:

真正的效果逻辑,需要知道玩家什么时候进入、什么时候离开这个区域,然后据此修改 aspd_multiplier。可以像下面这样写:

lua
function HasteArea:update(dt)
    ...
  	
    local player = current_room.player
    if not player then return end
    local d = distance(self.x, self.y, player.x, player.y)
    if d < self.r and not player.inside_haste_area then -- Enter event
        player:enterHasteArea()
    elseif d >= self.r and player.inside_haste_area then -- Leave event
    	player:exitHasteArea()
    end
end

We use a variable called inside_haste_area to keep track of whether the player is inside the area or not. This variable is set to true inside enterHasteArea and set to false inside exitHasteArea, meaning that those functions will only be called once when those events happen from the HasteArea object. In the Player class, both functions simply will apply the modifications necessary:

这里用一个叫 inside_haste_area 的变量来记录玩家当前是不是在区域里。它会在 enterHasteArea 中被设成 true,在 exitHasteArea 中被设成 false。这样一来,HasteArea 对象每次只会在“进入”和“离开”事件发生时,分别调用一次对应函数。到了 Player 类里,这两个函数就只负责真正施加数值变化:

lua
function Player:enterHasteArea()
    self.inside_haste_area = true
    self.pre_haste_aspd_multiplier = self.aspd_multiplier
    self.aspd_multiplier = self.aspd_multiplier/2
end

function Player:exitHasteArea()
    self.inside_haste_area = false
    self.aspd_multiplier = self.pre_haste_aspd_multiplier
    self.pre_haste_aspd_multiplier = nil
end

And so in this way whenever the player enters the area his attack speed will be doubled, and whenever he exits the area it will go back to normal. One big point that's easy to miss here is that it's tempting to put all this logic inside the HasteArea object instead of linking it back to the player via the inside_haste_area variable. The reason why we can't do this is because if we do, then problems will occur whenever the player enters/leaves multiple areas. As it is right now, the fact that the inside_haste_area variable exists means that we will only apply the buff once, even if the player is standing on top of 3 overlapping HasteArea objects.

这样,玩家进入区域时攻速就会翻倍,离开区域时又恢复正常。这里有个很容易忽略的点:你会很想把整套逻辑都塞进 HasteArea 对象内部,而不是通过 inside_haste_area 这个变量回连到 Player 身上。但如果真这么做,一旦玩家同时进出多个区域,就会出问题。现在这种写法之所以安全,正是因为有 inside_haste_area 这个状态存在,它保证了即便玩家同时站在 3 个重叠的 HasteArea 里,buff 也只会被应用一次。

131. (CONTENT) Implement the spawn_haste_area_on_hp_pickup_chance passive. An InfoText object should be created with the text 'Haste Area!'. Additionally, an onHPPickup function should be added to the Player class.

131. (CONTENT) 实现 spawn_haste_area_on_hp_pickup_chance 被动。触发时需要生成一个 InfoText,文本为 'Haste Area!'。另外,还需要在 Player 类里新增一个 onHPPickup 函数。

132. (CONTENT) Implement the spawn_haste_area_on_sp_pickup_chancepassive. An InfoText object should be created with the text 'Haste Area!'.

132. (CONTENT) 实现 spawn_haste_area_on_sp_pickup_chance 被动。触发时同样需要生成一个文本为 'Haste Area!'InfoText

Chance to Spawn SP on Cycle

The next one we'll go for is spawn_sp_on_cycle_chance. For this one we kinda already know how to do it in its entirety. The "onCycle" part behaves quite similarly to "onResourcePickup", the only difference is that we'll call the onCycle function whenever a new cycle occurs instead of whenever a resource is picked. And the "spawn SP" part is simply creating a new SP resource, which we also already know how to do.

下一个要做的是 spawn_sp_on_cycle_chance。这个被动从头到尾其实我们已经差不多知道怎么写了。“onCycle” 这一半和 “onResourcePickup” 很像,只不过现在触发时机从“捡到资源”变成了“发生一次新的 cycle”。而 “spawn SP” 则更简单,无非就是生成一个新的 SP resource,而这件事我们也已经会了。

So for the first part, we need to go into the cycle function and call onCycle:

所以第一步,就是在 cycle 函数里把 onCycle 调起来:

lua
function Player:cycle()
    ...
    self:onCycle()
end

Then we add the spawn_sp_on_cycle_chance variable to the Player:

然后把 spawn_sp_on_cycle_chance 变量加到 Player 身上:

lua
function Player:new(...)
    ...
  	
    -- Chances
    self.spawn_sp_on_cycle_chance = 0
end

And with that we also automatically add a new chanceList representing the chances of this variable. And because of that we can add the functionality needed to the onCycle function:

这样一来,这个变量也会被自动纳入 chanceList 生成流程里。于是接下来只需要在 onCycle 里补上真正触发的逻辑:

lua
function Player:onCycle()
    if self.chances.spawn_sp_on_cycle_chance:next() then
        self.area:addGameObject('SkillPoint')
        self.area:addGameObject('InfoText', self.x, self.y, 
      	{text = 'SP Spawn!', color = skill_point_color})
    end
end

And this should work out as expected:

效果应该就会像预期那样运作:

Chance to Barrage on Kill

The next one is barrage_on_kill_chance. The only thing we don't really know how to do here is the "Barrage" part. Triggering events on kill is similar to the previous one, except instead of whenever a cycle happens, we'll call the player's onKill function whenever an enemy dies.

接下来是 barrage_on_kill_chance。这里唯一还没具体实现过的,其实就是 “Barrage” 本身。至于 “on kill” 的触发方式,和上一种差不多,只不过这次不是在 cycle 发生时,而是在敌人死亡时调用玩家的 onKill 函数。

So first we add the barrage_on_kill_chance variable to the Player:

首先,先把 barrage_on_kill_chance 变量加到 Player 上:

lua
function Player:new(...)
    ...
  	
    -- Chances
    self.barrage_on_kill_chance = 0
end

Then we create the onKill function and call it whenever an enemy dies. There are two approaches to calling onKill whenever an enemy dies. The first is to just call it from every enemy's die or hit function. The problem with this is that as we add new enemies we'll need to add this same code calling onKill to all of them. The other option is to call onKill whenever a Projectile object collides with an enemy. The problem with this is that some projectiles can collide with enemies but not kill them (because the enemies have more HP or the projectile deals less damage), and so we need to figure out a way to tell if the enemy is actually dead or not. It turns out that figuring that out is pretty easy, so that's what I'm gonna go with:

然后写 onKill,并在敌人真正死亡时触发它。关于这件事,大致有两种思路。第一种,是直接在每个敌人的 diehit 函数里手动调用 onKill。问题是这样一来,每加一种新敌人,你都得重复往它们身上补同样的调用代码。第二种,是在 Projectile 撞上敌人时去判断。问题在于:撞上敌人并不意味着一定击杀成功,因为有些敌人血更厚,或者 projectile 本身伤害更低。所以我们还得有办法判断“这一下到底有没有把敌人打死”。不过好在这个判断并不难,所以我会选这个方案:

lua
function Projectile:update(dt)
    ...
  	
    if self.collider:enter('Enemy') then
        ...

        if object then
            object:hit(self.damage)
            self:die()
            if object.hp <= 0 then current_room.player:onKill() end
        end
    end
end

So all we have to do is after we call the enemy's hit function is to simply check if the enemy's HP is 0 or not. If it is it means he's dead and so we can call onKill.

也就是说,在调用完敌人的 hit 函数后,只要再检查一次它的 HP 有没有降到 0 以下即可。只要是,那就说明它已经死了,这时候就可以安心调用 onKill

Now for the barrage itself. The way we'll code is that by default, 8 projectiles will be shot within 0.05 seconds of each other, with an angle of between -math.pi/8 and +math.pi/8 of the angle the player is pointing towards. The barrage projectiles will also have the attack that the player has. So if the player has homing projectiles, then all barrage projectiles will also be homing. All that translates to this:

接下来才是 barrage 本身的逻辑。默认情况下,它会在极短时间内发出 8 枚 projectile,每枚之间相隔 0.05 秒,角度则在玩家当前朝向的 -math.pi/8+math.pi/8 之间随机。并且这些 barrage projectile 会继承玩家当前使用的攻击类型。也就是说,如果玩家现在拿的是 homing 攻击,那 barrage 打出来的子弹也全都会是 homing。写出来就是这样:

lua
function Player:onKill()
    if self.chances.barrage_on_kill_chance:next() then
        for i = 1, 8 do
            self.timer:after((i-1)*0.05, function()
                local random_angle = random(-math.pi/8, math.pi/8)
                local d = 2.2*self.w
                self.area:addGameObject('Projectile', 
            	self.x + d*math.cos(self.r + random_angle), 
            	self.y + d*math.sin(self.r + random_angle), 
            	{r = self.r + random_angle, attack = self.attack})
            end)
        end
        self.area:addGameObject('InfoText', self.x, self.y, {text = 'Barrage!!!'})
    end
end

Most of this should be pretty straightforward. The only notable thing is that we use after inside a for loop to separate the creation of projectiles by 0.05 seconds between each other. Other than that we simply create the projectile with the given constraints. All that should look like this:

这里大部分内容都比较直观。唯一比较值得提一句的是:我们在 for 循环里套了 after,借此把每枚 projectile 的生成时间错开 0.05 秒。除此之外,基本就是按给定规则创建 projectile。最终效果应该像这样:

For the next exercises (and every one that comes after them), don't forget to create InfoText objects with the appropriate colors so that the player can tell when something happened.

接下来这些练习,以及后面所有类似练习里,都别忘了生成颜色合适的 InfoText,这样玩家才能明确知道刚才触发了什么效果。

133. (CONTENT) Implement the spawn_hp_on_cycle_chance passive.

133. (CONTENT) 实现 spawn_hp_on_cycle_chance 被动。

134. (CONTENT) Implement the regain_hp_on_cycle_chance passive. The amount of HP regained is 25.

134. (CONTENT) 实现 regain_hp_on_cycle_chance 被动。回复量为 25 HP。

135. (CONTENT) Implement the regain_full_ammo_on_cycle_chance passive.

135. (CONTENT) 实现 regain_full_ammo_on_cycle_chance 被动。

136. (CONTENT) Implement the change_attack_on_cycle_chance passive. The new attack is chosen at random.

136. (CONTENT) 实现 change_attack_on_cycle_chance 被动。新攻击类型随机选择。

137. (CONTENT) Implement the spawn_haste_area_on_cycle_chance passive.

137. (CONTENT) 实现 spawn_haste_area_on_cycle_chance 被动。

138. (CONTENT) Implement the barrage_on_cycle_chance passive.

138. (CONTENT) 实现 barrage_on_cycle_chance 被动。

139. (CONTENT) Implement the launch_homing_projectile_on_cycle_chance passive.

139. (CONTENT) 实现 launch_homing_projectile_on_cycle_chance 被动。

140. (CONTENT) Implement the regain_ammo_on_kill_chance passive. The amount of ammo regained is 20.

140. (CONTENT) 实现 regain_ammo_on_kill_chance 被动。回复量为 20 ammo。

141. (CONTENT) Implement the launch_homing_projectile_on_kill_chance passive.

141. (CONTENT) 实现 launch_homing_projectile_on_kill_chance 被动。

142. (CONTENT) Implement the regain_boost_on_kill_chance passive. The amount of boost regained is 40.

142. (CONTENT) 实现 regain_boost_on_kill_chance 被动。回复量为 40 boost。

143. (CONTENT) Implement the spawn_boost_on_kill_chance passive.

143. (CONTENT) 实现 spawn_boost_on_kill_chance 被动。

Gain ASPD Boost on Kill

We already implemented an "ASPD Boost"-like passive before with the HasteArea object. Now we want to implement another where we have a chance to get an attack speed boost whenever we kill an enemy. However, if we try to implement this in the same way that we implement the previous ASPD boost we would soon encounter problems. To recap, this is how we implement the boost in HasteArea:

我们之前已经通过 HasteArea 实现过一种类似“ASPD Boost”的效果了。现在想做的是另一种:当玩家击杀敌人时,有一定概率获得攻速提升。不过,如果你直接照搬之前 HasteArea 那套实现,很快就会遇到问题。先回顾一下 HasteArea 当时的做法:

lua
function HasteArea:update(dt)
    HasteArea.super.update(self, dt)

    local player = current_room.player
    if not player then return end
    local d = distance(self.x, self.y, player.x, player.y)
    if d < self.r and not player.inside_haste_area then player:enterHasteArea()
    elseif d >= self.r and player.inside_haste_area then player:exitHasteArea() end
end

And then enterHasteArea and exitHasteArea look like this:

enterHasteAreaexitHasteArea 则是这样:

lua
function Player:enterHasteArea()
    self.inside_haste_area = true
    self.pre_haste_aspd_multiplier = self.aspd_multiplier
    self.aspd_multiplier = self.aspd_multiplier/2
end

function Player:exitHasteArea()
    self.inside_haste_area = false
    self.aspd_multiplier = self.pre_haste_aspd_multiplier
    self.pre_haste_aspd_multiplier = nil
end

If we tried to implement the aspd_boost_on_kill_chance passive in a similar way it would look something like this:

如果照着这个套路去写 aspd_boost_on_kill_chance,大概就会变成这样:

lua
function Player:onKill()
    ...
    if self.chances.aspd_boost_on_kill_chance:next() then
        self.pre_boost_aspd_multiplier = self.aspd_multiplier
    	self.aspd_multiplier = self.aspd_multiplier/2
    	self.timer:after(4, function()
      	    self.aspd_multiplier = self.pre_boost_aspd_multiplier
            self.pre_boost_aspd_multiplier = nil
      	end)
    end
end

Here we simply do what we did for the HasteArea boost. We store the current attack speed multiplier, halve it, and then after a set duration (in this case 4 seconds), we restore it back to its original value. The problem with doing things this way happens whenever we want to stack these boosts together.

这里做的事情和 HasteArea 时一模一样:先存下当前攻速 multiplier,把它折半,然后 4 秒后再恢复原值。问题在于,一旦这些增益可能叠加,这种写法马上就会出毛病。

Consider the situation where the player has entered a HasteArea and then gets an ASPD boost on kill. The problem here is that if the player exits the HasteArea before the 4 seconds for the boost duration are over then his aspd_multiplier variable will be restored to pre-ASPD boost levels, meaning that leaving the area will erase all other existing attack speed boosts.

比如,玩家先站进了一个 HasteArea,随后又触发了一个 on-kill 的 ASPD boost。此时如果玩家在这 4 秒结束前先走出了 HasteArea,那么 aspd_multiplier 会被恢复到进入 on-kill boost 之前的状态。换句话说,离开区域这件事会把别的攻速增益也一并抹掉。

And then also consider the situation where the player has an ASPD boost active and then enters a HasteArea. Whenever the boost duration ends the HasteArea effect will also be erased, since the pre_boost_aspd_multiplier will restore aspd_multiplier to a value that doesn't take into account the attack speed boost from the HasteArea. But even more worryingly, whenever the player exits the HasteArea he will now have permanently increased attack speed, since the save attack speed when he entered it was the one that was boosted from the ASPD boost.

反过来,再考虑另一种情况:玩家已经有一个 on-kill 的 ASPD boost 在生效,然后又走进了 HasteArea。等这个 boost 计时结束时,pre_boost_aspd_multiplier 会把 aspd_multiplier 恢复到一个不包含 HasteArea 增益的旧值,于是 HasteArea 效果也会被一并擦掉。更糟的是,当玩家之后离开 HasteArea 时,他还有可能永久保留一部分攻速加成,因为进入区域时存下来的“原值”其实已经是被 boost 过后的值了。

So the main way we can fix this is by introducing a few variables:

要解决这个问题,核心思路就是引入更多层次分明的变量:

lua
function Player:new(...)
    ...
  	
    self.base_aspd_multiplier = 1
    self.aspd_multiplier = 1
    self.additional_aspd_multiplier = {}
end

Instead of only having the aspd_multiplier variable, now we'll have base_aspd_multiplier as well as additional_aspd_multiplier. aspd_multiplier will hold the current multiplier affected by all boosts. base_aspd_multiplier will hold the initial multiplier affected only by percentage increases. So if we have 50% increased attack speed from the tree, it will be applied on the constructor (in setStats) to base_aspd_multiplier. Then additional_aspd_multiplier will contain the added values of all boosts. So if we're inside a HasteArea, we would add the appropriate value to this table and then multiply its sum by the base every frame. So our update function for instance would look like this:

这样一来,我们不再只有一个 aspd_multiplier,而是额外分出 base_aspd_multiplieradditional_aspd_multiplieraspd_multiplier 用来表示当前最终生效的数值,包含所有增益;base_aspd_multiplier 则表示只吃了树上永久加成后的基础值。比如技能树总共给了 50% attack speed,那这部分只会在构造时通过 setStats 作用到 base_aspd_multiplier。而 additional_aspd_multiplier 则是个表,专门装所有临时增益。比如玩家站在 HasteArea 里,我们就往这张表里加一个对应值,然后每帧把它们汇总后再和基础值一起算。大致上,update 会变成这样:

lua
function Player:update(dt)
    ...
  	
    self.additional_aspd_multiplier = {}
    if self.inside_haste_area then table.insert(self.additional_aspd_multiplier, -0.5) end
    if self.aspd_boosting then table.insert(self.additional_aspd_multiplier, -0.5) end
    local aspd_sum = 0
    for _, aspd in ipairs(self.additional_aspd_multiplier) do
        aspd_sum = aspd_sum + aspd
    end
    self.aspd_multiplier = self.base_aspd_multiplier/(1 - aspd_sum)
end

In this way, every frame we'd be recalculating the aspd_multiplier variable based on the base as well as the boosts. There are a few multipliers that will make use of functionality very similar to this, so I'll just create a general object for this, since repeating it every time and with different variable names would be tiresome.

这样的话,每一帧都会根据基础值和所有当前生效的增益,重新计算一次 aspd_multiplier。后面还会有别的 multiplier 用到非常类似的逻辑,所以我不想每次都重复写一遍差不多的东西。更好的做法,是干脆把这套机制抽成一个通用对象。

The Stat object looks like this:

这个通用对象就叫 Stat,定义如下:

lua
Stat = Object:extend()

function Stat:new(base)
    self.base = base

    self.additive = 0
    self.additives = {}
    self.value = self.base*(1 + self.additive)
end

function Stat:update(dt)
    for _, additive in ipairs(self.additives) do self.additive = self.additive + additive end

    if self.additive >= 0 then
        self.value = self.base*(1 + self.additive)
    else
        self.value = self.base/(1 - self.additive)
    end

    self.additive = 0
    self.additives = {}
end

function Stat:increase(percentage)
    table.insert(self.additives, percentage*0.01)
end

function Stat:decrease(percentage)
    table.insert(self.additives, -percentage*0.01)
end

And the way we'd use it for our attack speed problem is like this:

而把它用在我们当前的攻速问题上,大概会变成:

lua
function Player:new(...)
    ...
  	
    self.aspd_multiplier = Stat(1)
end

function Player:update(dt)
    ...
  
    if self.inside_haste_area then self.aspd_multiplier:decrease(100) end
    if self.aspd_boosting then self.aspd_multiplier:decrease(100) end
    self.aspd_multiplier:update(dt)
  
    ...
end

We would be able to access the attack speed multiplier at any point after aspd_multiplier:update is called by saying aspd_multiplier.value, and it would return us the correct result based on the base as well as the all possible boosts applied. Because of this we need to change how the aspd_multiplier variable is used:

只要在调用完 aspd_multiplier:update() 之后,我们就可以通过 aspd_multiplier.value 读取最终攻速倍率,而它会自动考虑基础值和所有当前生效的增益。因此,相应地,原本使用 aspd_multiplier 的地方也得跟着改:

lua
function Player:update(dt)
    ...
  
    -- Shoot
    self.shoot_timer = self.shoot_timer + dt
    if self.shoot_timer > self.shoot_cooldown*self.aspd_multiplier.value then
        self.shoot_timer = 0
        self:shoot()
    end
end

Here we just change self.shoot_cooldown*self.aspd_multiplier to self.shoot_cooldown*self.aspd_multiplier.value, since things wouldn't work out otherwise. Additionally, we can also change something else here. The way our aspd_multiplier variable works now is contrary to how every other variable in the game works. When we say that we get increased 10% HP, we know that hp_multiplier is 1.1, but when we say that we get increased 10% ASPD, aspd_multiplier is 0.9 instead. We can change this very and make aspd_multiplier behave the same way as other variables by dividing instead of multiplying it to shoot_cooldown:

这里无非就是把原本的 self.shoot_cooldown*self.aspd_multiplier 改成 self.shoot_cooldown*self.aspd_multiplier.value,不然逻辑就接不上了。另外,其实这里还可以继续顺手改一件事。现在这套 aspd_multiplier 的行为方式,和游戏里别的 multiplier 恰好反着来。比如“增加 10% HP”时,hp_multiplier 会变成 1.1;可“增加 10% ASPD”时,aspd_multiplier 却会变成 0.9。为了让它和别的变量表现一致,我们可以不再“乘上它”,而改成“用它去除冷却”:

lua
if self.shoot_timer > self.shoot_cooldown/self.aspd_multiplier.value then

In this way, if we get a 100% increase in ASPD, its value will be 2 and we will be halving the cooldown between shots, which is what we want. Additionally we need to change the way we apply our boosts and instead of calling decrease on them we will call increase:

这样一来,如果我们获得 100% ASPD 增益,它的值就会变成 2,而射击冷却则会被除以 2,也就是正好减半,这才更符合直觉。与此同时,临时增益的写法也得跟着调整:原本调用 decrease 的地方,现在要改成调用 increase

lua
function Player:update(dt)
    ...
  
    if self.inside_haste_area then self.aspd_multiplier:increase(100) end
    if self.aspd_boosting then self.aspd_multiplier:increase(100) end
    self.aspd_multiplier:update(dt)
end

Another thing to keep in mind is that because aspd_multiplier is a Stat object and not just a number, whenever we implement the tree and import its values to the Player object we'll need to treat them differently. So the treeToPlayer function that I mentioned earlier will have to take this into account as well.

还要记住一点:既然 aspd_multiplier 现在已经是个 Stat 对象,而不是普通数字,那么以后技能树把值导入 Player 时,也得专门为这种情况写特殊处理。也就是说,前面提过的 treeToPlayer 函数,后面也要把这一点考虑进去。

In any case, in this way we can easily implement "Gain ASPD Boost on Kill" correctly:

总之,有了这套 Stat 机制之后,“Gain ASPD Boost on Kill” 就能被正确实现了:

lua
function Player:new(...)
    ...
  
    -- Chances
    self.gain_aspd_boost_on_kill_chance = 0
end
lua
function Player:onKill()
    ...
  	
    if self.chances.gain_aspd_boost_on_kill_chance:next() then
        self.aspd_boosting = true
        self.timer:after(4, function() self.aspd_boosting = false end)
        self.area:addGameObject('InfoText', self.x, self.y, 
      	{text = 'ASPD Boost!', color = ammo_color})
    end
end

We can also delete the enterHasteArea and exitHasteArea functions, as well as changing how the HasteArea object works slightly:

与此同时,我们还可以顺手把 enterHasteAreaexitHasteArea 这两个函数删掉,并把 HasteArea 的逻辑也简化一下:

lua
function HasteArea:update(dt)
    HasteArea.super.update(self, dt)

    local player = current_room.player
    if not player then return end
    local d = distance(self.x, self.y, player.x, player.y)
    if d < self.r then player.inside_haste_area = true
    elseif d >= self.r then player.inside_haste_area = false end
end

Instead of any complicated logic like we had before, we simply set the Player's inside_haste_area attribute to true or false based on if the player is inside the area or not, and then because of the way we implemented the Stat object, the application of the attack speed boost that comes from a HasteArea will be done automatically.

现在不用再搞之前那套“进入时保存、离开时恢复”的复杂逻辑了。我们只需要简单地根据玩家是否在区域内,去更新 inside_haste_area 为真或假。而由于 Stat 对象已经负责了增益叠加和结算,所以 HasteArea 带来的攻速提升也会自动正确应用。

144. (CONTENT) Implement the mvspd_boost_on_cycle_chance passive. A "MVSPD Boost" gives the player 50% increased movement speed for 4 seconds. Also implement the mvspd_multiplier variable and multiply it in the appropriate location.

144. (CONTENT) 实现 mvspd_boost_on_cycle_chance 被动。“MVSPD Boost” 会让玩家在 4 秒内获得 50% 的移动速度提升。同时实现 mvspd_multiplier 变量,并在合适的位置把它乘进去。

145. (CONTENT) Implement the pspd_boost_on_cycle_chance passive. A "PSPD Boost" gives projectiles created by the player 100% increased movement speed for 4 seconds. Also implement the pspd_multiplier variable and multiply it in the appropriate location.

145. (CONTENT) 实现 pspd_boost_on_cycle_chance 被动。“PSPD Boost” 会让玩家发射出的 projectile 在 4 秒内获得 100% 的移动速度提升。同时实现 pspd_multiplier 变量,并在正确的位置把它作用进去。

146. (CONTENT) Implement the pspd_inhibit_on_cycle_chance passive. A "PSPD Inhibit" gives projectiles created by the player 50% decreased movement speed for 4 seconds.

146. (CONTENT) 实现 pspd_inhibit_on_cycle_chance 被动。“PSPD Inhibit” 会让玩家发射出的 projectile 在 4 秒内获得 50% 的移动速度降低。

While Boosting

These next passives we'll implement are the last ones of the "On Event Chance" type. So far all the ones we've focused on are chances of something happening on some event (on kill, on cycle, on resource pickup, ...) and these ones won't be different, since they will be chances for something to happen while boosting.

接下来这批被动,是最后一组 “On Event Chance” 类型的被动。到目前为止,我们一直在做的都是“在某种事件发生时,有几率触发某件事”的被动,比如 on kill、on cycle、on resource pickup 等等。这一组也不例外,只不过事件从那些切换成了“玩家正在 boosting”。

The first one we'll do is launch_homing_projectile_while_boosting_chance. The way this will work is that there will be a normal chance for the homing projectile to be launched, and this chance will be rolled on an interval of 0.2 seconds whenever we're boosting. This means that if we boost for 1 second, we'll roll this chance 5 times.

第一个要做的是 launch_homing_projectile_while_boosting_chance。它的工作方式是:只要玩家处于 boosting 状态,就每隔 0.2 秒掷一次概率,决定要不要发出一枚 homing projectile。也就是说,如果玩家连续 boost 了 1 秒,那这个概率就会被掷 5 次。

A good way of doing this is by defining two new functions: onBoostStart and onBoostEnd and then doing whatever it is we want to do to active the passive when the boost start, and then deactivate it when it ends. To add those two functions we need to change the boost code a little:

一个很自然的做法,是额外定义两个函数:onBoostStartonBoostEnd。boost 开始时,在 onBoostStart 里启用相关被动;boost 结束时,在 onBoostEnd 里把它们停掉。要做到这一点,我们先得稍微改一下 boost 逻辑:

lua
function Player:update(dt)
    ...
  
    -- Boost
    ...
    if self.boost_timer > self.boost_cooldown then self.can_boost = true end
    if input:pressed('up') and self.boost > 1 and self.can_boost then self:onBoostStart() end
    if input:released('up') then self:onBoostEnd() end
    if input:down('up') and self.boost > 1 and self.can_boost then 
        ...
        if self.boost <= 1 then
            self.boosting = false
            self.can_boost = false
            self.boost_timer = 0
            self:onBoostEnd()
        end
    end
    if input:pressed('down') and self.boost > 1 and self.can_boost then self:onBoostStart() end
    if input:released('down') then self:onBoostEnd() end
    if input:down('down') and self.boost > 1 and self.can_boost then 
        ...
        if self.boost <= 1 then
            self.boosting = false
            self.can_boost = false
            self.boost_timer = 0
            self:onBoostEnd()
        end
    end
    ...
end

Here we add input:pressed and input:released, which return true only whenever those events happen, and with that we can be sure that onBoostStart and onBoostEnd will only be called once when those events happen. We also add onBoostEnd to inside the input:down conditional in case the player doesn't release the button but the amount of boost available to him ends and therefore the boost ends as well.

这里新增了 input:pressedinput:released,它们只会在对应事件真正发生的那一帧返回 true。这样就能保证 onBoostStartonBoostEnd 只在开始和结束那一刻各调用一次。另外,我们还在 input:down 的逻辑里也补了 onBoostEnd,因为有一种情况是:玩家没松键,但 boost 资源自己先耗尽了。这时 boost 也应该算结束,自然也得触发一次 onBoostEnd

Now for the launch_homing_projectile_while_boosting_chance part:

接下来再看 launch_homing_projectile_while_boosting_chance 本身:

lua
function Player:new(...)
    ...
  
    -- Chances
    self.launch_homing_projectile_while_boosting_chance = 0
end

function Player:onBoostStart()
    self.timer:every('launch_homing_projectile_while_boosting_chance', 0.2, function()
        if self.chances.launch_homing_projectile_while_boosting_chance:next() then
            local d = 1.2*self.w
            self.area:addGameObject('Projectile', 
          	self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), 
                {r = self.r, attack = 'Homing'})
            self.area:addGameObject('InfoText', self.x, self.y, {text = 'Homing Projectile!'})
        end
    end)
end

function Player:onBoostEnd()
    self.timer:cancel('launch_homing_projectile_while_boosting_chance')
end

Here whenever a boost starts we call timer:every to roll a chance for the homing projectile every 0.2 seconds, and then whenever a boost ends we cancel that timer. Here's what that looks like if the chance of this event happening was 100%:

这里的逻辑很直接:只要 boost 一开始,就用 timer:every 每 0.2 秒掷一次 Homing Projectile 的触发概率;而 boost 一结束,就把这个 timer 取消掉。如果把这个触发概率临时调成 100%,效果看起来会像这样:

147. (CONTENT) Implement the cycle_speed_multiplier variable. This variable makes the cycle speed faster or slower based on its value. So, for instance, if cycle_speed_multiplier is 2 and our default cycle duration is 5 seconds, then applying it would turn our cycle duration to 2.5 instead.

147. (CONTENT) 实现 cycle_speed_multiplier 变量。这个变量会按它的数值让 cycle 变快或变慢。比如默认 cycle 持续 5 秒,如果 cycle_speed_multiplier 是 2,那么应用后就应该变成 2.5 秒。

148. (CONTENT) Implement the increased_cycle_speed_while_boosting passive. This variable should be a boolean that signals if the cycle speed should be increased or not whenever the player is boosting. The boost should be an increase of 200% to cycle speed multiplier.

148. (CONTENT) 实现 increased_cycle_speed_while_boosting 被动。这个变量应当是一个布尔值,用来表示玩家 boosting 时,cycle speed 是否需要提升。提升幅度应为 cycle speed multiplier 增加 200%。

149. (CONTENT) Implement the invulnerability_while_boosting passive. This variable should be a boolean that signals if the player should be invulnerable whenever he is boosting. Make use of the invincible attribute which already exists and serves the purpose of making the player invincible.

149. (CONTENT) 实现 invulnerability_while_boosting 被动。这个变量应当是一个布尔值,用来表示玩家在 boosting 时是否应当处于无敌状态。请直接利用现有的 invincible 属性,它本来就是用来控制玩家无敌与否的。

Increased Luck While Boosting

The final "While Boosting" type of passive we'll implement is "Increased Luck While Boosting". Before we can implement it though we need to implement the luck_multiplier stat. Luck is one of the main stats of the game and it works by increasing the chances of favorable events to happen. So, let's say you have 10% chance to launch a homing projectile on kill. If luck_multiplier is 2, then this chance becomes 20% instead.

“While Boosting” 这一类里,最后一个要实现的被动是 “Increased Luck While Boosting”。但在做它之前,我们得先把 luck_multiplier 这个属性本身实现出来。Luck 是游戏里的核心属性之一,它的作用就是提高各种有利事件发生的概率。举个例子,如果你原本有 10% 概率在击杀时发射一枚 homing projectile,那么当 luck_multiplier 为 2 时,这个概率就会变成 20%。

The way to implement this turns out to be very very simple. All "chance" type passives go through the generateChances function, so we can just implement this there:

这件事的实现其实非常简单。因为所有 “chance” 类型的被动,最终都会经过 generateChances,所以直接在那个函数里统一处理就行:

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

And here we simply multiply v by our luck_multiplier and it should work as expected. With this we can go on to implement the increased_luck_while_boosting passive like this:

这里做的事情就是把原本的 v 先乘上 luck_multiplier,然后再去生成 chanceList。这样就能得到符合预期的结果。基于这个前提,increased_luck_while_boosting 就可以这样实现:

lua
function Player:onBoostStart()
    ...
    if self.increased_luck_while_boosting then 
    	self.luck_boosting = true
    	self.luck_multiplier = self.luck_multiplier*2
    	self:generateChances()
    end
end

function Player:onBoostEnd()
    ...
    if self.increased_luck_while_boosting and self.luck_boosting then
    	self.luck_boosting = false
    	self.luck_multiplier = self.luck_multiplier/2
    	self:generateChances()
    end
end

Here we implement it like we initially did for the HasteArea object. The reason we can do this now is because there will not be any other passives that will give the Player a luck boost, which means that we don't have to worry about multiple boosts possibly overriding each other. If we had multiple passives giving boosts to luck, then we'd need to make it a Stat object like we did for the aspd_multiplier.

这里的实现方式,基本又回到了我们最初给 HasteArea 用的那种“开始时改、结束时再恢复”的写法。之所以这里可以这么干,是因为 Luck 不会像攻速那样,同时被多种临时增益反复叠加。既然不会有多个不同被动一起给 Luck 加 buff,那我们就不用担心它们互相覆盖。如果真有多种 Luck 增益,那这里也一样要像 aspd_multiplier 那样做成 Stat 对象。

Also importantly, whenever we change our luck multiplier we also call generateChances again, otherwise our luck boost will not really affect anything. There's a downside to this which is that all lists get reset, and so if some list randomly selected a bunch of unlucky rolls and then it gets reset here, it could select a bunch of unlucky rolls again instead of following the chanceList property where it would be less likely to select more unlucky rolls as time goes on. But this is a very minor problem that I personally don't really worry about.

另外还有个很重要的点:每次 luck_multiplier 发生变化时,都必须重新调用一次 generateChances,否则这个 Luck 提升根本不会影响到实际概率。这样做的代价是,所有 chanceList 都会被重置。于是如果某个列表本来已经连续掷出了很多倒霉结果,这里一重置,它又有可能重新来一轮倒霉,而不是沿着 chanceList 原本那种“越不出,后面越容易出”的性质继续往下走。不过这点影响很小,我自己一般不会太在意。

HP Spawn Chance Multiplier

Now we'll go over hp_spawn_chance_multiplier, which increases the chance that whenever the Director spawns a new resource, that resource will be an HP one. This is a fairly straightforward implementation if we remember how the Director works:

接下来讲 hp_spawn_chance_multiplier。它的作用是:当 Director 生成新 resource 时,提高其中刷出 HP resource 的概率。只要还记得 Director 的工作方式,这个实现就非常直接:

lua
function Player:new(...)
    ...
  	
    -- Multipliers
    self.hp_spawn_chance_multiplier = 1
end
lua
function Director:new(...)
    ...
  
    self.resource_spawn_chances = chanceList({'Boost', 28}, 
    {'HP', 14*current_room.player.hp_spawn_chance_multiplier}, {'SkillPoint', 58})
end

On article 9 we went over the creation of the chances for each resource to be spawned. The resource_spawn_chances chanceList holds those chances, and so all we have to do is make sure that we use hp_spawn_chance_multiplier to increase the chances that the HP resource will be spawned according to the multiplier.

在第 9 篇里,我们已经讲过资源刷出概率是怎么生成的。resource_spawn_chances 这张 chanceList 里装的就是这些概率。所以现在要做的,只是确保在生成这张列表时,把 hp_spawn_chance_multiplier 正确地乘到 HP resource 的权重上。

It's also important here to initialize the Director after the Player in the Stage room, since the Director depends on variables the Player has while the Player doesn't depend on the Director at all.

这里还有个顺序问题也要注意:在 Stage 房间里,Director 必须在 Player 之后初始化。因为 Director 会依赖 Player 身上的某些变量,而 Player 反过来并不依赖 Director。

150. (CONTENT) Implement the spawn_sp_chance_multiplier passive.

150. (CONTENT) 实现 spawn_sp_chance_multiplier 被动。

151. (CONTENT) Implement the spawn_boost_chance_multiplier passive.

151. (CONTENT) 实现 spawn_boost_chance_multiplier 被动。

Given everything we've implemented so far, these next exercises can be seen as challenges. I haven't gone over most aspects of their implementation, but they're pretty simple compared to everything we've done so far so they should be straightforward.

结合目前为止已经实现的这些东西,接下来的练习基本都可以当作挑战题来看。它们的细节我不会再一一展开,不过和我们前面做过的相比,难度其实不高,所以整体上应该都还算顺手。

152. (CONTENT) Implement the drop_double_ammo_chance passive. Whenever an enemy dies there will be a chance that it will create two Ammo objects instead of one.

152. (CONTENT) 实现 drop_double_ammo_chance 被动。敌人死亡时,有概率掉落两个 Ammo 对象,而不是一个。

153. (CONTENT) Implement the attack_twice_chance passive. Whenever the player attacks there will be a chance to call the shoot function twice.

153. (CONTENT) 实现 attack_twice_chance 被动。玩家攻击时,有概率把 shoot 函数调用两次。

154. (CONTENT) Implement the spawn_double_hp_chance passive. Whenever an HP resource is spawned by the Director there will be a chance that it will create two HP objects instead of one.

154. (CONTENT) 实现 spawn_double_hp_chance 被动。Director 生成 HP resource 时,有概率一次生成两个 HP 对象,而不是一个。

155. (CONTENT) Implement the spawn_double_sp_chance passive. Whenever a SkillPoint resource is spawned by the Director there will be a chance that it will create two SkillPoint objects instead of one.

155. (CONTENT) 实现 spawn_double_sp_chance 被动。Director 生成 SkillPoint resource 时,有概率一次生成两个 SkillPoint 对象,而不是一个。

156. (CONTENT) Implement the gain_double_sp_chance passive. Whenever the player collects a SkillPoint resource there will be a chance that he will gain two skill points instead of one.

156. (CONTENT) 实现 gain_double_sp_chance 被动。玩家拾取 SkillPoint resource 时,有概率获得 2 点技能点,而不是 1 点。

Enemy Spawn Rate

The enemy_spawn_rate_multiplier will control how fast the Director changes difficulties. By default this happens every 22 seconds, but if enemy_spawn_rate_multiplier is 2 then this will happen every 11 seconds instead. This is another rather straightforward implementation:

enemy_spawn_rate_multiplier 用来控制 Director 提升难度的速度。默认情况下,这件事每 22 秒发生一次;如果 enemy_spawn_rate_multiplier 是 2,那它就会改成每 11 秒发生一次。这个实现同样非常直接:

lua
function Player:new(...)
    ...
  	
    -- Multipliers
    self.enemy_spawn_rate_multiplier = 1
end
lua
function Director:update(dt)
    ...
  	
    -- Difficulty
    self.round_timer = self.round_timer + dt
    if self.round_timer > self.round_duration/self.stage.player.enemy_spawn_rate_multiplier then
        ...
    end
end

So here we just divide round_duration by enemy_spawn_rate_multiplier to get the target round duration.

这里其实就是把 round_duration 除以 enemy_spawn_rate_multiplier,从而得到新的目标轮次时长。

157. (CONTENT) Implement the resource_spawn_rate_multiplier passive.

157. (CONTENT) 实现 resource_spawn_rate_multiplier 被动。

158. (CONTENT) Implement the attack_spawn_rate_multiplier passive.

158. (CONTENT) 实现 attack_spawn_rate_multiplier 被动。

And here are some more exercises for some more passives. These are mostly multipliers that couldn't fit into any of the classes of passives talked about before but should be easy to implement.

接下来还有一批额外的被动练习。它们大多数都是一些不太好归到前面那些类别里的 multiplier,不过实现起来应该并不难。

159. (CONTENT) Implement the turn_rate_multiplier passive. This is a passive that increases or decreases the speed with which the Player's ship turns.

159. (CONTENT) 实现 turn_rate_multiplier 被动。它会提高或降低玩家飞船转向的速度。

160. (CONTENT) Implement the boost_effectiveness_multiplier passive. This is a passive that increases or decreases the effectiveness of boosts. This means that if this variable has the value of 2, a boost will go twice as fast or twice as slow as before.

160. (CONTENT) 实现 boost_effectiveness_multiplier 被动。它会提高或降低 boost 的强度。也就是说,如果这个变量是 2,那么一次 boost 的加速或减速效果,就会是原来的两倍。

161. (CONTENT) Implement the projectile_size_multiplier passive. This is a passive that increases or decreases the size of projectiles.

161. (CONTENT) 实现 projectile_size_multiplier 被动。它会增大或缩小 projectile 的尺寸。

162. (CONTENT) Implement the boost_recharge_rate_multiplier passive. This is a passive that increases or decreases how fast boost is recharged.

162. (CONTENT) 实现 boost_recharge_rate_multiplier 被动。它会提高或降低 boost 的回复速度。

163. (CONTENT) Implement the invulnerability_time_multiplier passive. This is a passive that increases or decreases the duration of the player's invulnerability after he's hit.

163. (CONTENT) 实现 invulnerability_time_multiplier 被动。它会提高或降低玩家受击后无敌时间的长短。

164. (CONTENT) Implement the ammo_consumption_multiplier passive. This is a passive that increases or decreases the amount of ammo consumed by all attacks.

164. (CONTENT) 实现 ammo_consumption_multiplier 被动。它会提高或降低所有攻击的 ammo 消耗量。

165. (CONTENT) Implement the size_multiplier passive. This is a passive that increases or decreases the size of the player's ship. Note that that the positions of the trails for all ships, as well as the position of projectiles as they're fired need to be changed accordingly.

165. (CONTENT) 实现 size_multiplier 被动。它会提高或降低玩家飞船的大小。注意,飞船尺寸变化后,所有飞船的拖尾位置,以及 projectile 发射时的出生位置,也都需要跟着一起调整。

166. (CONTENT) Implement the stat_boost_duration_multiplier passive. This is a passive that increases of decreases the duration of temporary buffs given to the player.

166. (CONTENT) 实现 stat_boost_duration_multiplier 被动。它会提高或降低玩家获得的各种临时 buff 的持续时间。

Projectile Passives

Now we'll focus on a few projectile passives. These passives will change how our projectiles behave in some fundamental way. These same ideas can also be implemented in the EnemyProjectile object and then we can create enemies that use some of this as well. For instance, there's a passive that makes your projectiles orbit around you instead of just going straight. Later on we'll add an enemy that has tons of projectiles orbiting it as well and the technology behind it is the same for both situations.

接下来我们来看几种 projectile 被动。这类被动会从根本上改变 projectile 的行为方式。它们对应的思路,同样也可以应用到 EnemyProjectile 上,因此以后你完全可以做出一类敌人,也拥有和玩家类似的 projectile 特性。比如有一种被动会让你的 projectile 围着你旋转,而不是直线飞行。之后我们就会做一个敌人,让它身边也飘着一圈 projectile,而它背后的实现原理其实和这里是同一套。

90 Degree Change

We'll call this passive projectile_ninety_degree_change and what it will do is that the angle of the projectile will be changed by 90 degrees periodically. The way this looks is like this:

先来看 projectile_ninety_degree_change。它的效果是让 projectile 的朝向每隔一段时间就偏转 90 度。效果如下:

Notice that the projectile roughly moves in the same direction it was moving towards as it was shot, but its angle changes rapidly by 90 degrees each time. This means that the angle change isn't entirely randomly decided and we have to put some thought into it.

可以看到,projectile 整体上还是在沿着它最初发射的大方向前进,只不过角度会不断以 90 度为单位快速折来折去。这说明它的转向并不是完全随机的,我们得稍微认真安排一下它的规律。

The basic way we can go about this is to say that projectile_ninety_degree_change will be a boolean and that the effect will apply whenever it is true. Because we're going to apply this effect in the Projectile class, we have two options in regards to how we'll read from it that the Player's projectile_ninety_degree_change variable is true or not: either pass that in in the opts table whenever we create a new projectile from the shoot function, or read that directly from the player by accessing it through current_room.player. I'll go with the second solution because it's easier and there are no real drawbacks to it, other than having to change current_room.player to something else whenever we move some of this code to EnemyProjectile. The way all this would look is something like this:

最基础的做法,是把 projectile_ninety_degree_change 设成一个布尔值,只要它为真,就给 projectile 加上这个效果。因为逻辑会写在 Projectile 类内部,所以要读取 Player 身上的这个变量,大概有两种办法:一种是在 shoot 创建 projectile 时通过 opts 传进去;另一种则是直接在 Projectile 内部通过 current_room.player 去读。我这里选第二种,因为更省事,而且几乎没什么坏处,唯一的代价也就是以后如果把这段逻辑迁移到 EnemyProjectile,需要把 current_room.player 改掉而已。整体写法大概如下:

lua
function Player:new(...)
    ...
  	
    -- Booleans
    self.projectile_ninety_degree_change = false
end
lua
function Projectile:new(...)
    ...

    if current_room.player.projectile_ninety_degree_change then

    end
end

Now what we have to do inside the conditional in the Projectile constructor is to change the projectile's angle each time by 90 degrees, but also respecting its original direction. What we can do is first change the angle by either 90 degrees or -90 degrees randomly. This would look like this:

接下来要做的,就是在 Projectile 构造函数这个条件分支里,让 projectile 每次以 90 度为单位偏转,但又要保留和最初方向的关系。一个简单办法,是先随机决定它第一次是往左 90 度,还是往右 90 度:

lua
function Projectile:new(...)
    ...

    if current_room.player.projectile_ninety_degree_change then
        self.timer:after(0.2, function()
      	    self.ninety_degree_direction = table.random({-1, 1})
            self.r = self.r + self.ninety_degree_direction*math.pi/2
      	end)
    end
end

Now what we need to do is figure out how to turn the projectile in the other direction, and then turn it back in the other, and then again, and so on. It turns out that since this is a periodic thing that will happen forever, we can use timer:every:

接下来要解决的,就是它之后如何反复左右来回折。既然这是一个会无限重复的周期性行为,那最自然的做法就是使用 timer:every

lua
function Projectile:new(...)
    ...
  	
    if current_room.player.projectile_ninety_degree_change then
        self.timer:after(0.2, function()
      	    self.ninety_degree_direction = table.random({-1, 1})
            self.r = self.r + self.ninety_degree_direction*math.pi/2
            self.timer:every('ninety_degree_first', 0.25, function()
                self.r = self.r - self.ninety_degree_direction*math.pi/2
                self.timer:after('ninety_degree_second', 0.1, function()
                    self.r = self.r - self.ninety_degree_direction*math.pi/2
                    self.ninety_degree_direction = -1*self.ninety_degree_direction
                end)
            end)
      	end)
    end
end

At first we turn the projectile in the opposite direction that we turned it initially, which means that now it's facing its original angle. Then, after only 0.1 seconds, we turn it again in that same direction so that it's facing the opposite direction to when it first turned. So, if it was fired facing right, what happened is: after 0.2 seconds it turned up, after 0.25 it turned right again, after 0.1 seconds it turned down, and then after 0.25 seconds it will repeat by turning right then up, then right then down, and so on.

这里的流程是这样的:第一次先往一个方向拐 90 度;之后先往反方向拐回来,使它重新朝向原始方向;再过 0.1 秒,又继续朝同一个方向再拐一次,于是它就会来到最初那次拐弯的反方向。比如它原本朝右发射,那可能会是:0.2 秒后先往上,0.25 秒后回正,接着 0.1 秒后往下,然后再过 0.25 秒又回正、再往上,如此循环。

Importantly, at the end of each every loop we change the direction it should turn towards, otherwise it wouldn't oscillate between up/down and would keep going up/down instead of straight. Doing all that looks like this:

其中一个很关键的点是:在每次 every 的末尾,我们都会把它下一次该拐的方向翻转一下。不这样做的话,它就不会在上下之间来回摆,而会一直朝某一边不断偏下去或偏上去。完整效果就是这样:

167. (CONTENT) Implement the projectile_random_degree_change passive, which changes the angle of the projectile randomly instead. Unlike the 90 degrees one, projectiles in this one don't need to retain their original direction.

167. (CONTENT) 实现 projectile_random_degree_change 被动。它会随机改变 projectile 的角度。和 90 度变化版不同,这一种不需要保留 projectile 原本的大方向。

168. (CONTENT) Implement the angle_change_frequency_multiplier passive. This is a passive that increases or decreases the speed with which angles change in the previous 2 passives. If angle_change_frequency_multiplier is 2, for instance, then instead of angles changing with 0.25 and 0.1 seconds, they will change with 0.125 and 0.05 seconds instead.

168. (CONTENT) 实现 angle_change_frequency_multiplier 被动。它会提高或降低前面那两种角度变化被动的变化频率。比如如果 angle_change_frequency_multiplier 为 2,那么原本 0.25 秒和 0.1 秒的切换节奏,就会变成 0.125 秒和 0.05 秒。

Wavy Projectiles

Instead of abruptly changing the angle of our projectile, we can do it softly using the timer:tween function, and in this way we can get a wavy projectile effect that looks like this:

如果不想让 projectile 的角度突然折过去,而是想让它平滑地摆动,那么就可以改用 timer:tween。这样就能做出这种 wavy projectile 的效果:

The idea is almost the same as the previous examples but using timer:tween instead:

思路和前面的例子基本一致,只不过现在把“瞬间拐弯”换成了 timer:tween 的平滑过渡:

lua
function Projectile:new(...)
    ...
  	
    if current_room.player.wavy_projectiles then
        local direction = table.random({-1, 1})
        self.timer:tween(0.25, self, {r = self.r + direction*math.pi/8}, 'linear', function()
            self.timer:tween(0.25, self, {r = self.r - direction*math.pi/4}, 'linear')
        end)
        self.timer:every(0.75, function()
            self.timer:tween(0.25, self, {r = self.r + direction*math.pi/4}, 'linear',  function()
                self.timer:tween(0.5, self, {r = self.r - direction*math.pi/4}, 'linear')
            end)
        end)
    end
end

Because of the way timer:every works, in that it doesn't start performing its functions until after the initial duration, we first do one iteration of the loop manually, and then after that the every loop takes over. In the first iteration we also use an initial value of math.pi/8 instead of math.pi/4 because we only want the projectile to tween half of what it usually does, since it starts in the middle position (as it was just shot from the Player) instead of on either edge of the oscillation.

这里要注意 timer:every 的一个特性:它不会一开始立刻执行,而是会等到第一个周期结束后才触发。所以我们得先手动做一次初始摆动,然后再交给 every 接管。与此同时,第一次摆动用的是 math.pi/8 而不是 math.pi/4,因为 projectile 刚发射出来时,本来就在摆动曲线的中间位置,而不是左边缘或右边缘,所以第一次只需要摆半程。

169. (CONTENT) Implement the projectile_waviness_multiplier passive. This is a passive that increases or decreases the target angle that the projectile should reach when tweening. If projectile_waviness_multiplier is 2, for instance, then the arc of its path will be twice as big as normal.

169. (CONTENT) 实现 projectile_waviness_multiplier 被动。它会提高或降低 projectile 在 tween 时目标角度的幅度。比如如果 projectile_waviness_multiplier 为 2,那么它飞行轨迹的摆动弧度就会变成原来的两倍。

Acceleration and Deceleration

Now we'll go for a few passives that change the speed of the projectile. The first one is "Fast -> Slow" and the second is "Slow -> Fast", meaning, the projectile starts with either fast or slow velocity, and then transitions into either slow or fast velocity. This is what "Fast -> Slow" looks like:

接下来我们来看几种会改变 projectile 速度的被动。第一种叫 “Fast -> Slow”,第二种叫 “Slow -> Fast”。也就是 projectile 会先以较快或较慢的速度起步,然后再逐渐过渡成相反的速度状态。下面是 “Fast -> Slow” 的效果:

The way we'll implement this is pretty straightforward. For the "Fast -> Slow" one we'll tween the velocity to double its initial value quickly, and then after a while tween it down to half its initial value. And for the other we'll simply do the opposite.

实现方式非常直接。对于 “Fast -> Slow”,我们先很快地把速度 tween 到初始值的两倍,再过一会儿把它 tween 到初始值的一半;而 “Slow -> Fast” 则反过来做。

lua
function Projectile:new(...)
    ...
  	
    if current_room.player.fast_slow then
        local initial_v = self.v
        self.timer:tween('fast_slow_first', 0.2, self, {v = 2*initial_v}, 'in-out-cubic', function()
            self.timer:tween('fast_slow_second', 0.3, self, {v = initial_v/2}, 'linear')
        end)
    end

    if current_room.player.slow_fast then
        local initial_v = self.v
        self.timer:tween('slow_fast_first', 0.2, self, {v = initial_v/2}, 'in-out-cubic', function()
            self.timer:tween('slow_fast_second', 0.3, self, {v = 2*initial_v}, 'linear')
        end)
    end
end

170. (CONTENT) Implement the projectile_acceleration_multiplier passive. This is a passive that controls how fast or how slow a projectile accelerates whenever it changes to a higher velocity than its original value.

170. (CONTENT) 实现 projectile_acceleration_multiplier 被动。它会控制 projectile 在速度提升到高于初始值时,提速过程有多快或多慢。

171. (CONTENT) Implement the projectile_deceleration_multiplier passive. This is a passive that controls how fast or how slow a projectile decelerates whenever it changes to a lower velocity than its original value.

171. (CONTENT) 实现 projectile_deceleration_multiplier 被动。它会控制 projectile 在速度降低到低于初始值时,减速过程有多快或多慢。

Shield Projectiles

This one is a bit more involved than the others because it has more moving parts to it, but this is what the end result should look like. As you can see, the projectiles orbit around the player and also sort of inherit its movement direction. The way we can achieve this is by using a circle's parametric equation. In general, if we want A to orbit around B with some radius R then we can do something like this:

这一项会比前面的复杂一点,因为里面牵扯到的部分更多一些,不过最终效果应该长这样。你可以看到,projectile 会围绕玩家旋转,并且还会多少带上一点玩家移动方向的感觉。要实现这种效果,可以借助圆的参数方程。一般来说,如果我们想让 A 围绕 B、以半径 R 进行轨道运动,就可以写成:

lua
Ax = Bx + R*math.cos(time)
Ay = By + R*math.sin(time)

Where time is a variable that goes up as times passes. Before we get to implementing this let's set everything else up. shield_projectile_chance will be a chance-type variable instead of a boolean, meaning that every time a new projectile will be created there will be a chance it will orbit the player.

这里的 time 是一个会随着时间不断增长的变量。不过在真正把这套公式塞进代码前,我们先把别的部分搭好。shield_projectile_chance 会是一个概率型变量,而不是布尔值。也就是说,每次创建新的 projectile 时,都有一定几率让它变成围绕玩家旋转的 shield projectile。

lua
function Player:new(...)
    ...
  	
    -- Chances
    self.shield_projectile_chance = 0
end

function Player:shoot()
    ...
    local shield = self.chances.shield_projectile_chance:next()
  	
    if self.attack == 'Neutral' then
        self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r), 
        self.y + 1.5*d*math.sin(self.r), {r = self.r, attack = self.attack, shield = shield})
	...
end

Here we define the shield variable with the roll of if this projectile should be orbitting the player or not, and then we pass that in the opts table of the addGameObject call. Here we have to repeat this step for every attack type we have. Since we'll have to make future changes like this one, we can just do something like this instead now:

这里先通过一次掷概率,决定这个 projectile 到底要不要变成 orbiting shield projectile,然后再把结果通过 opts 传给 addGameObject。理论上,这件事之后要给所有攻击类型都补一遍。既然后面还会不断遇到类似需求,那不如现在就顺手把写法改得更通用一点:

lua
function Player:shoot()
    ...
  	
    local mods = {
        shield = self.chances.shield_projectile_chance:next()
    }

    if self.attack == 'Neutral' then
        self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r), 
        self.y + 1.5*d*math.sin(self.r), table.merge({r = self.r, attack = self.attack}, mods))
        ...
end

And so in this way, in the future we'll only have to add things to the mods table. The table.merge function hasn't been defined yet, but you can guess what it does based on how we're using it here.

这样一来,后面再有新的附加参数时,我们只需要往 mods 表里继续塞东西就行了。table.merge 这个函数现在还没定义,不过光看用法你大概也能猜出来它是干什么的。

lua
function table.merge(t1, t2)
    local new_table = {}
    for k, v in pairs(t2) do new_table[k] = v end
    for k, v in pairs(t1) do new_table[k] = v end
    return new_table
end

It simply joins two tables together with all their values into a new one and then returns it.

它做的事情就是把两张表的内容合并到一个新的表里,然后把这个新表返回出来。

Now we can start with the actual implementation of the shield functionality. At first we want to define a few variables, like the radius, the orbit speed and so on. For now I'll define them like this:

现在可以正式开始写 shield 功能本体了。首先需要定义几个变量,比如轨道半径、旋转速度之类的。暂时先这样设:

lua
function Projectile:new(...)
    ...
  	
    if self.shield then
        self.orbit_distance = random(32, 64)
        self.orbit_speed = random(-6, 6)
        self.orbit_offset = random(0, 2*math.pi)
    end
end

orbit_distance represents the radius around the player. orbit_speed will be multiplied by time, which means that higher absolute values will make it go faster, while lower ones will make it go slower. Negative values will make the projectile turn in the other direction which adds some randomness to it. orbit_offset is the initial angle offset that each projectile will have. This also adds some randomness to it and prevents all projectiles from being started at roughly the same position. And now that we have all these defined we can apply the circle's parametric equation to the projectile's position:

orbit_distance 表示 projectile 围绕玩家运动时的半径。orbit_speed 会乘到 time 上,因此绝对值越大,转得越快;绝对值越小,转得越慢。负值则会让 projectile 朝反方向旋转,这样也能多一点随机性。orbit_offset 则是每个 projectile 初始角度的偏移量,它的作用同样是增加随机性,避免所有 projectile 都从差不多的位置一起起转。现在这些值都有了,就可以把圆的参数方程真正用到 projectile 的位置上:

lua
function Projectile:update(dt)
    ...
  
    -- Shield
    if self.shield then
        local player = current_room.player
        self.collider:setPosition(
      	player.x + self.orbit_distance*math.cos(self.orbit_speed*time + self.orbit_offset),
      	player.y + self.orbit_distance*math.sin(self.orbit_speed*time + self.orbit_offset))
    end
  	
    ...
end

It's important to place this after any other calls we may make to setLinearVelocity otherwise things won't work out. We also shouldn't forget to add the global time variable and increase it by dt every frame. If we do that correctly then it should look like this:

这里一定要把这段放在所有 setLinearVelocity 调用之后,不然逻辑会打架,效果就不对了。另外也别忘了要有一个全局 time 变量,并且让它每帧用 dt 持续增加。把这些都处理好之后,效果大概会是这样:

And this gets the job done but it looks wrong. The main thing wrong with it is that the projectile's angles are not taking into account the rotation around the player. One way to fix this is to store the projectile's position last frame and then get the angle of the vector that makes up the subtraction of the current position by the previous position. Code is worth a thousand words so that looks like this:

虽然它已经“能转起来”了,但看着还是不太对。最大的问题在于:projectile 自身的朝向,并没有正确反映它绕玩家旋转的轨迹。一个修正办法是:记录 projectile 上一帧的位置,然后用“当前位置减去上一帧位置”得到一个位移向量,再从这个向量里算出角度。直接看代码更清楚:

lua
function Projectile:new(...)
    ...
  	
    self.previous_x, self.previous_y = self.collider:getPosition()
end

function Projectile:update(dt)
    ...
  	
    -- Shield
    if self.shield then
        ...
        local x, y = self.collider:getPosition()
        local dx, dy = x - self.previous_x, y - self.previous_y
        self.r = Vector(dx, dy):angle()
    end

    ...
  
    -- At the very end of the update function
    self.previous_x, self.previous_y = self.collider:getPosition()
end

And in this way we're setting the r variable to contain the angle of the projectile while taking into account its rotation. Because we're using setLinearVelocity and using that angle, it means that when we draw the projectile in Projectile:draw and use Vector(self.collider:getLinearVelocity()):angle()) to get our direction, everything will be set according what we set the r variable to. And so all that looks like this:

这样一来,r 里记录的就是 projectile 当前真正的运动角度,也就把绕玩家旋转这一部分算进去了。由于我们后面会继续依赖这个角度来调用 setLinearVelocity,而 Projectile:draw 又会通过 Vector(self.collider:getLinearVelocity()):angle() 去判断朝向,所以整套表现都会自动和这个新的 r 对齐。最终效果像这样:

And this looks about right. One small problem that you can see in the gif above is that as projectiles are fired, if they turn into shield projectiles they don't do it instantly. There's a 1-2 frame delay where they look like normal projectiles and then they disappear and appear orbiting the player. One way to fix this is to just hide all shield projectiles for 1-2 frames and then unhide them:

现在看起来就基本对了。不过上面的 gif 里还能看到一个小问题:当 projectile 刚刚生成并且要变成 shield projectile 时,它不会立刻切进轨道,而是会有 1 到 2 帧看起来像普通 projectile,随后突然消失,再在玩家周围冒出来。一个简单修正办法,是干脆让 shield projectile 前 1 到 2 帧先隐藏,然后再显示:

lua
function Projectile:new(...)
    ...
  	
    if self.shield then
        ...
    	self.invisible = true
    	self.timer:after(0.05, function() self.invisible = false end)
    end
end

function Projectile:draw()
    if self.invisible then return end
    ...
end

And finally, it would be pretty OP if shield projectiles could just stay there forever until they hit an enemy, so we need to add a projectile duration such that after that duration ends the projectile will be killed:

最后,shield projectile 如果能无限存在、直到命中敌人才消失,那就会强得有点离谱了。所以还得给它加一个持续时间,时间一到就主动销毁:

lua
function Projectile:new(...)
    ...
  	
    if self.shield then
    	...
    	self.timer:after(6, function() self:die() end)
    end
end

And in this way after 6 seconds of existence our shield projectiles will die.

这样一来,shield projectile 存活 6 秒之后就会自动死亡。

END

I'm going to end it here because the editor I'm using to write this is starting to choke on the size of this article. In the next article we'll continue with the implementation of more passives, as well as adding all player attacks, enemies, and passives related to them. The next article also marks the end of implementation of all content in the game, and the ones coming after that will focus on how to present that content to the player (SkillTree and Console rooms).

这一篇我先写到这里,因为我用来写教程的编辑器已经快被这篇的体量拖死了。下一篇里我们会继续实现更多被动,同时把剩下的玩家攻击、敌人,以及和它们相关的被动也补完。下一篇也意味着游戏里所有内容层面的实现将正式收尾;再往后几篇,就会转向“怎样把这些内容呈现给玩家”,也就是 SkillTree 和 Console 这些房间的实现。