BYTEPATH #7 - Player Stats and Attacks
Introduction
In this tutorial we'll focus on getting more basics of gameplay down on the Player side of things. First we'll add the most fundamental stats: ammo, boost, HP and skill points. These stats will be used throughout the entire game and they're the main resources the player will use to do everything he can do. After that we'll focus on the creation of Resource objects, which are objects that the player can gather that contain the stats just mentioned. And finally after that we'll add the attack system as well as a few different attacks to the Player.
这一篇会继续补齐 Player 这边更完整的玩法基础。首先我们会加入几项最根本的属性:ammo、boost、HP 和 skill points。这些属性会贯穿整款游戏,也是玩家进行各种操作时最核心的资源。之后我们会实现 Resource 对象,也就是玩家可以拾取、并为这些属性提供补充的物件。最后,再把攻击系统和几种不同攻击方式加到 Player 身上。
Draw Order
Before we go over to the main parts of this article, setting the game's draw order is something important that I forgot to mention in the previous article so we'll go over it now.
在进入正文前,先补一个我上一篇忘记提的重要问题:游戏里的绘制顺序,也就是 draw order。
The draw order decides which objects will be drawn on top and which will be drawn behind which. For instance, right now we have a bunch of effects that are drawn when something happens. If the effects are drawn behind other objects like the Player then they either won't be visible or they will look wrong. Because of this we need to make sure that they are always drawn on top of everything and for that we need to define some sort of drawing order between objects.
绘制顺序决定了哪些对象会被画在前面,哪些又会被压在后面。比如现在我们已经有一些在事件发生时才出现的视觉效果。如果这些效果被画在 Player 之类的对象后面,那它们要么根本看不见,要么看起来就会很怪。所以我们得保证它们总是出现在足够靠前的位置,而这就要求我们给不同对象定义一套明确的绘制先后关系。
The way we'll set this up is somewhat straight forward. In the GameObject class we'll define a depth attribute which will start at 50 for all entities. Then, in the definition of each class' constructors, we will be able to define the depth attribute ourselves for that class of objects if we want to. The idea is that objects with a higher depth will be drawn in front, while objects with a lower depth will be drawn behind. So, for instance, if we want to make sure that all effects are drawn in front of everything else, we can just set their depth attribute to something like 75.
这个机制做起来并不复杂。我们会先在 GameObject 类里定义一个 depth 属性,所有对象默认从 50 开始。之后在各个类自己的构造函数里,如果有需要,就可以覆写这个 depth。规则很简单:depth 越大,绘制时越靠前;depth 越小,就越靠后。比如如果想确保所有效果都压在其他东西上面,那就直接把它们的 depth 设成 75 之类的值。
function TickEffect:new(area, x, y, opts)
TickEffect.super.new(self, area, x, y, opts)
self.depth = 75
...
endNow, the way that this works internally is that every frame we'll be sorting the game_objects list according to the depth attribute of each object:
而在内部实现上,我们会在每一帧按照每个对象的 depth 值,对 game_objects 列表进行排序:
function Area:draw()
table.sort(self.game_objects, function(a, b)
return a.depth < b.depth
end)
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
endHere, before drawing we simply use table.sort to sort the entities based on their depth attribute. Entities that have lower depth will be sorted to the front of the table and so will be drawn first (behind everything), and entities that have a higher depth will be sorted to the back of the table and so they will be drawn last (in front of everything). If you try setting different depth values to different types of objects you should see that this works out well.
这里的思路就是在真正绘制之前,先用 table.sort 按 depth 排序。depth 较小的对象会排到前面,因此会更早被绘制,也就相当于在后景;depth 较大的对象会排到后面,因而更晚被绘制,也就会显示在前景。你可以自己给不同类型对象设一些不同的 depth 值试试,效果会很直观。
One small problem that this approach has though is that some objects will have the same depth, and when this happens, depending on how the game_objects table is being sorted over time, flickering can occur. Flickering occurs because if objects have the same depth, in one frame one object might be sorted to be in front of another, but in another frame it might be sorted to be behind another. It's unlikely to happen but it can happen and we should take precautions against that.
不过这种做法有个小问题:有些对象的 depth 可能会一样。而一旦相同,game_objects 在不同帧里的排序结果就可能来回变化,于是就会出现闪烁。所谓闪烁,就是这一帧 A 在 B 前面,下一帧又变成 B 在 A 前面。虽然不一定经常发生,但确实有可能,所以最好提前处理掉。
One way to solve it is to define another sorting parameter in case objects have the same depth. In this case the other parameter I chose was the object's time of creation:
一种解决办法,是在 depth 相同的情况下,再增加一个备用排序条件。我这里选的是对象的创建时间:
function Area:draw()
table.sort(self.game_objects, function(a, b)
if a.depth == b.depth then return a.creation_time < b.creation_time
else return a.depth < b.depth end
end)
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
endSo if the depths are equal then objects that were created earlier will be drawn earlier, and objects that were created later will be drawn later. This is a reasonable solution and if you test it out you'll see that it also works!
这样的话,只要 depth 一样,先创建出来的对象就先画,后创建出来的就后画。这是个很合理的兜底方案,实际试一试你也会发现它确实有效。
Draw Order Exercises
93. Order objects so that ones with higher depth are drawn behind and ones with lower depth are drawn in front of others. In case the objects have the same depth, they should be ordered by their creation time. Objects that were created earlier should be drawn last, and objects that were created later should be drawn first.
93. 把排序规则改成:depth 更高的对象反而画在后面,depth 更低的对象画在前面。如果两个对象的 depth 一样,则按创建时间排序:创建更早的对象后画,创建更晚的对象先画。
94. In a top-downish 2.5D game like in the gif below, one of the things you have to do to make sure that the entities are drawn in the appropriate order is sort them by their y position. Entities that have a higher y position (meaning that they are closer to the bottom of the screen) should be drawn last, while entities that have a lower y position should be drawn first. What would the sorting function look like in that case?
94. 在下面 gif 这种偏俯视角的 2.5D 游戏里,为了保证实体的前后遮挡关系正确,常见做法之一是按它们的 y 坐标来排序。y 值更大的对象,也就是更靠近屏幕底部的对象,应当更晚绘制;y 值更小的对象则应更早绘制。在这种情况下,排序函数应该怎么写?

Basic Stats
Now we'll start with stat building. The first stat we'll focus on is the boost one. The way it works now is that whenever the player presses up or down the ship will take a different speed based on the key pressed. The way it should work is that on top of this basic functionality, it should also be a resource that depletes with use and regenerates over time when not being used. The specific numbers and rules that will be used are these:
接下来正式开始做属性系统。第一个要处理的是 boost。它目前的逻辑只是:玩家按上或按下时,飞船速度会变快或变慢。真正完整的版本则应该是在这个基础上,把 boost 也做成一种会随着使用而消耗、在未使用时缓慢恢复的资源。具体规则如下:
- The player will have 100 boost initially
- 玩家初始拥有 100 boost
- Whenever the player is boosting 50 boost will be removed per second
- 只要玩家处于 boost 状态,每秒消耗 50 boost
- At all times 10 boost is generated per second
- 无论何时,boost 都会以每秒 10 点的速度恢复
- Whenever boost reaches 0 a 2 second cooldown is applied before it can be used again
- 当 boost 降到 0 时,会进入 2 秒冷却,冷却结束前不能再次使用
- Boosts can only happen when the cooldown is off and when the boost resource is above 0
- 只有当冷却结束、并且 boost 资源大于 0 时,才允许进行 boost
These sound a little complicated but they're not. The first three are just number specifications, and the last two are to prevent boosts from never ending. When the resource reaches 0 it will regenerate to 1 pretty consistently and this can lead to a scenario where you can essentially use the boost forever, so a cooldown has to be added to prevent this from happening.
这些规则看着多,其实并不复杂。前 3 条只是数值设定,后 2 条则是为了避免 boost 变成某种理论上可以无限续航的状态。因为资源一旦归零,马上又会缓慢回到 1,这可能导致玩家在某些情况下几乎能一直“卡着边缘”持续 boost,所以必须加个冷却来封住这个漏洞。
Now to add this as code:
下面把它们写成代码:
function Player:new(...)
...
self.max_boost = 100
self.boost = self.max_boost
end
function Player:update(dt)
...
self.boost = math.min(self.boost + 10*dt, self.max_boost)
...
endSo with this we take care of rules 1 and 3. We start boost with max_boost, which is 100, and then we add 10 per second to boost while making sure that it doesn't go over max_boost. We can easily also get rule 2 done by simply decreasing 50 boost per second whenever the player is boosting:
这样就先实现了规则 1 和 3。boost 初始值等于 max_boost,也就是 100;之后再以每秒 10 点的速度恢复,同时确保它不会超过 max_boost。规则 2 也很好补,只要在玩家 boost 时每秒扣掉 50 就行:
function Player:update(dt)
...
if input:down('up') then
self.boosting = true
self.max_v = 1.5*self.base_max_v
self.boost = self.boost - 50*dt
end
if input:down('down') then
self.boosting = true
self.max_v = 0.5*self.base_max_v
self.boost = self.boost - 50*dt
end
...
endPart of this code was already here from before, so the only lines we really added were the self.boost -= 50*dt ones. Now to check rule 4 we need to make sure that whenever boost reaches 0 a 2 second cooldown is started before the player can boost again. This is a bit more complicated because if involves more moving parts, but it looks like this:
这段里大部分逻辑之前就已经有了,所以真正新增的核心只是那两句 self.boost = self.boost - 50*dt。接下来要处理规则 4,也就是当 boost 归零时,必须进入 2 秒冷却后才能再次使用。这个部分稍微复杂一点,因为需要多几个状态变量一起配合:
function Player:new(...)
...
self.can_boost = true
self.boost_timer = 0
self.boost_cooldown = 2
endAt first we'll introduce 3 variables. can_boost will be used to tell when a boost can happen. By default it's set to true because the player should be able to boost at the start. It will be set to false once boost reaches 0 and then it will be set to true again boost_cooldown seconds after. The boost_timer variable will take care of tracking how much time it has been since boost reached 0, and if this variable goes above boost_cooldown then we will set can_boost to true.
这里先引入 3 个变量。can_boost 用来表示当前是否允许 boost。默认值是 true,因为玩家开局就应该能 boost。等 boost 降到 0 后,它会被改成 false;等过了 boost_cooldown 秒之后,再重新设回 true。而 boost_timer 则负责记录从 boost 归零开始已经过去了多久,一旦它超过 boost_cooldown,我们就把 can_boost 重新打开。
function Player:update(dt)
...
self.boost = math.min(self.boost + 10*dt, self.max_boost)
self.boost_timer = self.boost_timer + dt
if self.boost_timer > self.boost_cooldown then self.can_boost = true end
self.max_v = self.base_max_v
self.boosting = false
if input:down('up') and self.boost > 1 and self.can_boost then
self.boosting = true
self.max_v = 1.5*self.base_max_v
self.boost = self.boost - 50*dt
if self.boost <= 1 then
self.boosting = false
self.can_boost = false
self.boost_timer = 0
end
end
if input:down('down') and self.boost > 1 and self.can_boost then
self.boosting = true
self.max_v = 0.5*self.base_max_v
self.boost = self.boost - 50*dt
if self.boost <= 1 then
self.boosting = false
self.can_boost = false
self.boost_timer = 0
end
end
self.trail_color = skill_point_color
if self.boosting then self.trail_color = boost_color end
endThis looks complicated but it just follows from what we wanted to achieve. Instead of just checking to see if a key is being pressed with input:down, we additionally also make sure that boost is above 1 (rule 5) and that can_boost is true (rule 5). Whenever boost reaches 0 we set boosting and can_boost to false, and then we reset boost_timer to 0. Since boost_timer is being added dt every frame, after 2 seconds it will set can_boost to true and we'll be able to boost again (rule 4).
这段代码看起来是比前面复杂不少,但其实就是把我们刚才的规则逐条翻译成程序。现在我们不只是判断某个键有没有被按下,还额外要求 boost 必须大于 1(规则 5),并且 can_boost 必须为真(规则 5)。一旦 boost 用到底,就把 boosting 和 can_boost 都关掉,同时把 boost_timer 重置为 0。之后因为 boost_timer 每帧都会加上 dt,所以 2 秒一到,它就会重新让 can_boost 变成 true,玩家也就又能 boost 了(规则 4)。
The code above is also the way the boost mechanism should look like now in its complete state. One thing to note is that you might think that this looks ugly or unorganized or any number of combination of bad things. But this is just what a lot of code that takes care of certain aspects of gameplay looks like. It's multiple rules that are being followed and they sort of have to be followed all in the same place. It's important to get used to code like this, in my opinion.
上面这段,也就是 boost 机制此时的完整形态。这里还有一点值得提一下:你可能会觉得这段代码看起来不够优雅、不够整洁,或者总之有点“乱”。但很多负责玩法规则的代码,本来就长这样。它们往往就是好几条规则同时在一个地方被落实,而这些规则本身也确实得放在一起处理。就我看来,尽快习惯这种代码风格,是很有必要的。
In any case, out of the basic stats, boost was the only one that had some more involved logic to it. There are two more important stats: ammo and HP, but both are way simpler. Ammo will just get depleted whenever the player attacks and regained whenever a resource is collected in gameplay, and HP will get depleted whenever the player is hit and also regained whenever a resource is collected in gameplay. For now, we can just add them as basic stats like we did for the boost:
总之,在这些基础属性里,boost 是唯一一个逻辑稍微复杂一点的。接下来还有两个重要属性:ammo 和 HP,不过它们都简单得多。ammo 会在玩家攻击时减少,拾取对应资源时恢复;HP 会在玩家受击时减少,拾取对应资源时恢复。现在先像 boost 那样,给它们补上最基础的定义就可以了:
function Player:new(...)
...
self.max_hp = 100
self.hp = self.max_hp
self.max_ammo = 100
self.ammo = self.max_ammo
endResources
What I call resources are small objects that affect one of the main basic stats that we just went over. The game will have a total of 5 of these types of objects and they'll work like this:
这里说的 resources,就是那些会影响我们刚才讲过的基础属性的小型可拾取对象。游戏里一共会有 5 种,它们的功能分别如下:
- Ammo resource restores 5 ammo to the player and is spawned on enemy death
- Ammo resource 会为玩家恢复 5 点 ammo,并在敌人死亡时掉落
- Boost resource restores 25 boost to the player and is spawned randomly by the Director
- Boost resource 会为玩家恢复 25 点 boost,并由 Director 随机生成
- HP resource restores 25 HP to the player and is spawned randomly by the Director
- HP resource 会为玩家恢复 25 点 HP,并由 Director 随机生成
- SkillPoint resource adds 1 skill point to the player and is spawned randomly by the Director
- SkillPoint resource 会为玩家增加 1 点技能点,并由 Director 随机生成
- Attack resource changes the current player attack and is spawned randomly by the Director
- Attack resource 会改变玩家当前攻击方式,并由 Director 随机生成
The Director is a piece of code that handles the spawning of enemies as well as resources. I called it that because other games (like L4D) call it that and it just seems to fit. Because we're not going to work on that piece of code yet, for now we'll bind the creation of each resource to a key just to test that it works.
Director 是一段专门负责生成敌人和资源的代码。我之所以叫它 Director,是因为别的游戏,比如 L4D,也这么叫,感觉挺贴切的。不过现在我们还不会马上去实现这一块,所以先用按键绑定的方式手动生成各种 resource,拿来测试功能。
Ammo Resource
So let's get started on the ammo. The final result should be like this:
那就先从 ammo 开始。最终效果应该像这样:

The little green rectangles are the ammo resource. When the player hits one of them with its body the resource is destroyed and the player gets 5 ammo. We can create a new class named Ammo and start with some definitions:
画面里那些绿色小方块就是 ammo resource。玩家用自己的机体碰到它们时,这个 resource 会被销毁,同时玩家获得 5 点 ammo。我们可以先新建一个 Ammo 类,并做一些基础定义:
function Ammo:new(...)
...
self.w, self.h = 8, 8
self.collider = self.area.world:newRectangleCollider(self.x, self.y, self.w, self.h)
self.collider:setObject(self)
self.collider:setFixedRotation(false)
self.r = random(0, 2*math.pi)
self.v = random(10, 20)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
self.collider:applyAngularImpulse(random(-24, 24))
end
function Ammo:draw()
love.graphics.setColor(ammo_color)
pushRotate(self.x, self.y, self.collider:getAngle())
draft:rhombus(self.x, self.y, self.w, self.h, 'line')
love.graphics.pop()
love.graphics.setColor(default_color)
endAmmo resources will be physics rectangles that start with some random and small velocity and rotation, set initially by setLinearVelocity and applyAngularImpulse. This object is also drawn using the draft library. This is a small library that lets you draw all sorts of shapes more easily than if you had to do it yourself. For this case you can just draw the resource as a rectangle if you want, but I'll choose to go with this. I'll also assume that you can already install the library yourself and read the documentation to figure out what it can and can't do. Additionally, we're also taking into account the rotation of the physics object by using the result from getAngle in pushRotate.
Ammo resource 会是一个物理矩形对象,初始时带一点随机的小速度和旋转,这些分别由 setLinearVelocity 和 applyAngularImpulse 设定。绘制时我们还用到了 draft 这个库,它能让你更轻松地画出各种几何图形。当然,这里你如果愿意,直接把 resource 画成普通矩形也完全可以,不过我这里会用这个库。默认我也不再展开安装方法,做到这里你应该已经能自己装库、读文档、判断它能做什么了。另外,因为这个物理对象本身会旋转,我们也通过 getAngle 配合 pushRotate 把它的旋转角度考虑进去了。
To test this all out, we can bind the creation of one of these objects to a key like this:
为了测试这套东西,可以先把它的生成绑定到一个按键上:
function Stage:new()
...
input:bind('p', function()
self.area:addGameObject('Ammo', random(0, gw), random(0, gh))
end)
endAnd if you run the game now and press p a bunch of times you should see these objects spawning and moving/rotating around.
现在运行游戏后,多按几次 p,你应该就能看到这些对象不断生成,并在场上移动和旋转。
The next thing we should do is create the collision interaction between player and resource. This interaction will hold true for all resources and will be mostly the same always. The first thing we want to do is make sure that we can capture an event when the player physics object collides with the ammo physics object. The easiest way to do this is through the use of collision classes. To start with we can define 3 collision classes for objects that are already exist: the Player, the projectiles and the resources.
接下来要做的,就是让玩家和 resource 之间真正发生碰撞交互。这套交互逻辑对所有 resource 基本都适用,之后大多只是换个具体效果。第一步,是确保玩家的物理对象撞到 ammo 的物理对象时,我们能捕捉到这个事件。最简单的办法,就是使用 collision classes。先给已经存在的对象定义 3 个碰撞类别:Player、projectiles 和 resources。
function Stage:new()
...
self.area = Area(self)
self.area:addPhysicsWorld()
self.area.world:addCollisionClass('Player')
self.area.world:addCollisionClass('Projectile')
self.area.world:addCollisionClass('Collectable')
...
endAnd then in each one of those files (Player, Projectile and Ammo) we can set the collider's collision class using setCollisionClass (repeat the code below for the other files):
然后在对应文件里,也就是 Player、Projectile 和 Ammo 中,用 setCollisionClass 给各自的 collider 指定碰撞类别(其他文件同理照着写):
function Player:new(...)
...
self.collider:setCollisionClass('Player')
...
endBy itself this doesn't change anything, but it gives us a base to work with and to capture collision events between physics objects. For instance, if we change the Collectable collision class to ignore the Player like this:
光这样做本身不会立刻改变什么,但它给了我们一个可以控制物理对象交互规则、并捕捉碰撞事件的基础。比如,如果我们把 Collectable 设成忽略 Player:
self.area.world:addCollisionClass('Collectable', {ignores = {'Player'}})Then if you run the game again you'll notice that the player is now physically ignoring the ammo resource objects. This isn't what we want to do in the end but it serves as a nice example of what we can do with collision classes. The rules we actually want these 3 collision classes to follow are the following:
那你重新运行后就会发现,玩家现在会在物理层面直接穿过 ammo resource。这当然不是我们最终想要的结果,不过它很好地说明了 collision classes 能做到什么。真正需要的规则其实是下面这些:
- Projectile will ignore Projectile
- Projectile 忽略 Projectile
- Collectable will ignore Collectable
- Collectable 忽略 Collectable
- Collectable will ignore Projectile
- Collectable 忽略 Projectile
- Player will generate collision events with Collectable
- Player 和 Collectable 之间需要产生碰撞事件
Rules 1, 2 and 3 can be satisfied by making small changes to the addCollisionClass calls:
规则 1、2、3 只需要稍微改一下 addCollisionClass 的定义:
function Stage:new()
...
self.area.world:addCollisionClass('Player')
self.area.world:addCollisionClass('Projectile', {ignores = {'Projectile'}})
self.area.world:addCollisionClass('Collectable', {ignores = {'Collectable', 'Projectile'}})
...
endIt's important to note that the order of the declaration of collision classes matters. For instance, if we swapped the order of the Projectile and Collectable declarations a bug would happen because the Collectable collision class makes reference to the Projectile collision class, but since the Projectile collision class isn't yet defined it bugs out.
这里有个很重要的细节:collision class 的声明顺序是有影响的。比如,如果你把 Projectile 和 Collectable 这两个声明顺序对调,就会出 bug。因为 Collectable 的定义里引用了 Projectile 这个 collision class,可如果那时候 Projectile 还没定义,自然就会出问题。
The fourth rule can be satisfied by using the enter call:
第 4 条规则,则可以通过 enter 来实现:
function Player:update(dt)
...
if self.collider:enter('Collectable') then
print(1)
end
endAnd if you run this you'll see that 1 will be printed to the console every time the player collides with an ammo resource.
运行之后,每次玩家撞上 ammo resource,控制台都会打印出 1。
Another piece of behavior we need to add to the Ammo class is that it needs to move towards the player slightly. An easy way to do this is to add the Seek Behavior to it. My version of the seek behavior was based on the book Programming Game AI by Example which has a very nice section on steering behaviors in general. I'm not going to explain it in detail because I honestly don't remember how it works, so I'll just assume if you're curious about it you'll figure it out 😄
Ammo 还需要再加一个行为:它会稍微朝玩家靠过去。一个简单做法,是给它加上 Seek Behavior。我自己这版 seek 行为,是参考 Programming Game AI by Example 这本书来的,书里关于 steering behaviors 的那一部分写得挺不错。至于原理我这里就不细讲了,因为老实说我自己也早就忘得差不多了,所以如果你好奇,就自己顺着往下研究吧 😄
function Ammo:update(dt)
...
local target = current_room.player
if target then
local projectile_heading = Vector(self.collider:getLinearVelocity()):normalized()
local angle = math.atan2(target.y - self.y, 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)
else self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) end
endSo here the ammo resource will head towards the target if it exists, otherwise it will just move towards the direction it was initially set to move towards. target contains a reference to the player, which was set in Stage like this:
这里的意思就是:如果 target 存在,ammo resource 就会稍微朝它靠拢;如果不存在,那它就继续沿着自己最初被赋予的方向前进。这里的 target 指向玩家引用,而这个引用是在 Stage 里这样保存的:
function Stage:new()
...
self.player = self.area:addGameObject('Player', gw/2, gh/2)
endFinally, the only thing left to do is what happens when an ammo resource is collected. From the gif above you can see that a little effect plays (like the one for when a projectile dies), along with some particles, and then the player also gets +5 ammo.
最后,剩下要处理的就是 ammo resource 被拾取后会发生什么。从上面的 gif 里你可以看到,它会播放一个小效果,类似 projectile 死亡时那个闪一下的效果,同时还会带一些粒子,最后玩家还会获得 +5 ammo。
Let's start with the effect. This effect follows the exact same logic as the ProjectileDeathEffect object, in that there's a little white flash and then the actual color of the effect comes on. The only difference here is that instead of drawing a square we will be drawing a rhombus, which is the same shape that we used to draw the ammo resource itself. I'll call this new object AmmoEffect and I won't really go over it in detail since it's the same as ProjectileDeathEffect. The way we call it though is like this:
先来看这个效果本身。它和 ProjectileDeathEffect 的逻辑是完全一样的:先白闪一下,再切回真正的颜色。唯一的区别是,这次画出来的不再是方块,而是菱形,也就是 ammo resource 自己那种形状。这个对象我会叫它 AmmoEffect。因为它和 ProjectileDeathEffect 实在太像了,所以我就不再细讲实现细节,直接看调用方式:
function Ammo:die()
self.dead = true
self.area:addGameObject('AmmoEffect', self.x, self.y,
{color = ammo_color, w = self.w, h = self.h})
for i = 1, love.math.random(4, 8) do
self.area:addGameObject('ExplodeParticle', self.x, self.y, {s = 3, color = ammo_color})
end
endHere we are creating one AmmoEffect object and then between 4 and 8 ExplodeParticle objects, which we already used before for the Player's death effect. The die function on an Ammo object will get called whenever it collides with the Player:
这里会生成一个 AmmoEffect,再随机生成 4 到 8 个 ExplodeParticle。后者我们之前已经在 Player 死亡时用过了。Ammo 的 die 函数会在它和 Player 发生碰撞时被调用:
function Player:update(dt)
...
if self.collider:enter('Collectable') then
local collision_data = self.collider:getEnterCollisionData('Collectable')
local object = collision_data.collider:getObject()
if object:is(Ammo) then
object:die()
end
end
endHere first we use getEnterCollisionData to get the collision data generated by the last enter collision event for the specified tag. After this we use getObject to get access to the object attached to the collider involved in this collision event, which could be any object of the Collectable collision class. In this case we only have the Ammo object to worry about, but if we had others here's where we'd place the code to separate between them. And that's what we do, to really check that the object we get from getObject is the of the Ammo class we use classic's is function. If it is really is an object of the Ammo class then we call its die function. All that should look like this:
这里首先用 getEnterCollisionData 拿到最近一次指定标签碰撞产生的 collision data。然后通过 getObject,取出这次碰撞里那个 collider 绑定的对象。这个对象理论上可能是任何属于 Collectable 类的东西。当前我们只有 Ammo 一种 collectable 要处理,但以后如果还有别的,就是在这里做分流判断。为了确认 getObject 拿到的对象确实是 Ammo 类实例,我们用了 classic 提供的 is 函数。只有真的是 Ammo,才调用它的 die。全部做完后,效果会像这样:

One final thing we forgot to do is actually add +5 ammo to the player whenever an ammo resource is gathered. For this we'll define an addAmmo function which simply adds a certain amount to the ammo variable and checks that it doesn't go over max_ammo:
最后还有件事别忘了:玩家拾取 ammo resource 时,确实要获得 +5 ammo。为此可以写一个 addAmmo 函数,用来增加一定数量的 ammo,同时确保它不会超过 max_ammo:
function Player:addAmmo(amount)
self.ammo = math.min(self.ammo + amount, self.max_ammo)
endAnd then we can just call this after object:die() in the collision code we just added.
然后在刚才那段碰撞代码里,紧跟在 object:die() 后面调用它就行。
Boost Resource
Now for the boost. The final result should look like this:
接着来看 boost resource。最终效果应该像这样:

As you can see, the idea is almost the same as the ammo resource, except that the boost resource's movement is a little different, it looks a little different and the visual effect that happens when one is gathered is different as well.
可以看出来,它和 ammo resource 的整体思路几乎一样,只不过 boost resource 的移动方式不一样、外观不一样,拾取时触发的视觉效果也不一样。
So let's start with the basics. For every resource other than the ammo one, they'll be spawned either on the left or right of the screen and they'll move really slowly in a straight line to the other side. The same applies to the enemies. This gives the player enough time to move towards the resource to pick it up if he wants to.
那就先从基础部分开始。除了 ammo 之外,其他 resource 都会从屏幕左边或右边生成,然后以很慢的速度沿直线横穿到另一侧。敌人之后也会采用同样的处理方式。这样玩家就有足够时间决定:要不要主动靠过去把资源捡起来。
The basic starting setup of the Boost class is about the same as the Ammo one and looks like this:
Boost 类的基础写法和 Ammo 差不多,大概如下:
function Boost:new(...)
...
local direction = table.random({-1, 1})
self.x = gw/2 + direction*(gw/2 + 48)
self.y = random(48, gh - 48)
self.w, self.h = 12, 12
self.collider = self.area.world:newRectangleCollider(self.x, self.y, self.w, self.h)
self.collider:setObject(self)
self.collider:setCollisionClass('Collectable')
self.collider:setFixedRotation(false)
self.v = -direction*random(20, 40)
self.collider:setLinearVelocity(self.v, 0)
self.collider:applyAngularImpulse(random(-24, 24))
end
function Boost:update(dt)
...
self.collider:setLinearVelocity(self.v, 0)
endThere are a few differences though. The 3 first lines in the constructor are picking the initial position of this object. The table.random function is defined in utils.lua as follows:
不过这里也有几个关键区别。构造函数开头那 3 行,是在确定这个对象的初始出生位置。用到的 table.random 在 utils.lua 里定义如下:
function table.random(t)
return t[love.math.random(1, #t)]
endAnd as you can see what it does is just pick a random element from a table. In this case, we're picking either -1 or 1 to signify the direction in which the object will be spawned. If -1 is picked then the object will be spawned to the left of the screen, and if 1 is picked then it will be spawned to the right. More precisely, the exact positions chosen for its chosen position will be either -48 or gw+48, so slightly offscreen but close enough to the edge.
可以看到,它做的事情就是从表里随机抽一个元素。这里我们在 -1 和 1 之间随机,表示对象会出生在左侧还是右侧。如果取到 -1,对象就从屏幕左边生成;如果取到 1,就从右边生成。更准确地说,它的 x 坐标最终会是 -48 或 gw+48,也就是略微在屏幕外,但离边缘很近。
After this we define the object mostly like the Ammo one, with a few differences again only when it comes to its velocity. If this object was spawned to the right then we want it to move left, and if it was spawned to the left then we want it to move right. So its velocity is set to a random value between 20 and 40, but also multiplied by -direction, since if the object was to the right, direction was 1, and if we want to move it to the left then the velocity has to be negative (and the opposite for the other side). The object's velocity is always set to the v attribute on the x component and set to 0 on the y component. We want the object to remain moving in a horizontal line no matter what so setting its y velocity to 0 will achieve that.
之后对象本体的定义大部分和 Ammo 差不多,不过在速度设置上又有一点不同。如果它从右边生成,我们就希望它往左移动;如果从左边生成,就希望它往右移动。所以它的速度会是 20 到 40 之间的随机值,再乘上 -direction。因为如果对象在右边,direction 就是 1,而要让它往左走,速度就必须是负数;左边的情况则反过来。整个对象的速度始终只在 x 轴上使用 v,y 轴速度固定为 0。因为我们希望它无论如何都只沿水平线移动,这样设就能保证这一点。
The final main difference is in the way its drawn:
最后一个主要区别,是它的绘制方式:
function Boost:draw()
love.graphics.setColor(boost_color)
pushRotate(self.x, self.y, self.collider:getAngle())
draft:rhombus(self.x, self.y, 1.5*self.w, 1.5*self.h, 'line')
draft:rhombus(self.x, self.y, 0.5*self.w, 0.5*self.h, 'fill')
love.graphics.pop()
love.graphics.setColor(default_color)
endHere instead of just drawing a single rhombus we draw one inner and one outer to be used as sort of an outline. You can obviously draw all these objects in whatever way you want, but this is what I personally decided to do.
这里不再只画一个菱形,而是画一个外轮廓,再加一个内部小菱形,形成类似描边的感觉。当然,这些资源对象你完全可以按自己喜欢的方式去画,我这里只是用了自己比较顺眼的一种表现。
Now for the effects. There are two effects being used here: one that is similar to AmmoEffect (although a bit more involved) and the one that is used for the +BOOST text. We'll start with the one that's similar to AmmoEffect and call it BoostEffect.
接下来做效果。这里一共会用到两个效果:一个类似 AmmoEffect,不过稍微复杂一点;另一个则是那个会显示 +BOOST 的文字效果。先从前者开始,它叫 BoostEffect。
This effect has two parts to it, the center with its white flash and the blinking effect before it disappears. The center works in the same way as the AmmoEffect, the only difference is that the timing of each phase is different, from 0.1 to 0.2 in the first phase and from 0.15 to 0.35 in the second:
这个效果包含两个部分:中间那一层白闪,以及消失前的闪烁。中间那层的逻辑和 AmmoEffect 一样,唯一的差别只是每个阶段的持续时间不同:第一阶段从 0.1 改成 0.2,第二阶段从 0.15 改成 0.35:
function BoostEffect:new(...)
...
self.current_color = default_color
self.timer:after(0.2, function()
self.current_color = self.color
self.timer:after(0.35, function()
self.dead = true
end)
end)
endThe other part of the effect is the blinking before it dies. This can be achieved by creating a variable named visible, which when set to true will draw the effect and when set to false will not draw the effect. By changing this variable from false to true really fast we can achieve the desired effect:
效果的另一部分,是它在消失前会闪烁。这可以通过一个叫 visible 的变量来实现:为 true 时绘制,为 false 时不绘制。只要快速地在真假之间切换,就能得到想要的闪烁感:
function BoostEffect:new(...)
...
self.visible = true
self.timer:after(0.2, function()
self.timer:every(0.05, function() self.visible = not self.visible end, 6)
self.timer:after(0.35, function() self.visible = true end)
end)
endHere we use the every call to switch between visible and not visible six times, each with a 0.05 seconds delay in between, and after that's done we set it to be visible in the end. The effect will die after 0.55 seconds anyway (since we set dead to true after 0.55 seconds when setting the current color) so setting it to be visible in the end isn't super important to do. In any case, then we can draw it like this:
这里用 every 每隔 0.05 秒切换一次 visible,总共切 6 次,结束后再把它设回可见。反正整个效果本来 0.55 秒后就会死亡,所以最后这一步是不是强制设回可见其实没那么关键。不过完整起见,还是这样写。绘制代码则可以是:
function BoostEffect:draw()
if not self.visible then return end
love.graphics.setColor(self.current_color)
draft:rhombus(self.x, self.y, 1.34*self.w, 1.34*self.h, 'fill')
draft:rhombus(self.x, self.y, 2*self.w, 2*self.h, 'line')
love.graphics.setColor(default_color)
endWe're simply drawing both the inner and outer rhombus at different sizes. The exact numbers (1.34, 2) were reached through pretty much trial and error based on what looked best.
这里就是按不同尺寸分别画出内外两个菱形。具体数值比如 1.34 和 2,基本都是我靠试出来的,选了一个看起来最顺眼的版本。
The final thing we need to do for this effect is to make the outer rhombus outline expand over the life of the object. We can do that like this:
这个效果最后还差一件事:让外层菱形描边在对象生命周期里逐渐放大。写法可以是:
function BoostEffect:new(...)
...
self.sx, self.sy = 1, 1
self.timer:tween(0.35, self, {sx = 2, sy = 2}, 'in-out-cubic')
endAnd then update the draw function like this:
然后把绘制更新成这样:
function BoostEffect:draw()
...
draft:rhombus(self.x, self.y, self.sx*2*self.w, self.sy*2*self.h, 'line')
...
endWith this, the sx and sy variables will grow to 2 over 0.35 seconds, which means that the outline rhombus will also grow to double its previous value over those 0.35 seconds. In the end the result looks like this (I'm assuming you already linked this object's die function to the collision event with the Player, like we did for the ammo resource):
这样一来,sx 和 sy 会在 0.35 秒内从 1 变到 2,于是外层描边菱形也会在这段时间里放大到原来的两倍。最终效果像这样(这里默认你已经像 Ammo 那样,把这个对象的 die 绑定到玩家碰撞事件上了):

Now for the other part of the effect, the crazy looking text. This text effect will be used throughout the game pretty much everywhere so let's make sure we get it right. Here's what the effect looks like again:
接下来做效果的另一部分,也就是那个看起来很疯的文字效果。这种文字效果后面在游戏里会到处复用,所以这里最好一次把它做对。先再看一眼它的样子:

First let's break this effect down into its multiple parts. The first thing to notice is that it's simply a string being drawn to the screen initially, but then near its end it starts blinking like the BoostEffect object. That blinking part turns out to use exactly the same logic as the BoostEffect so we have that covered already.
先把这个效果拆开看。第一眼能注意到的是,一开始它其实只是普通地把一串字符串画在屏幕上;但快结束的时候,它会像 BoostEffect 一样开始闪烁。至于闪烁这部分,逻辑和 BoostEffect 完全一样,所以这块我们已经有现成方案了。
What also happens though is that the letters of the string start changing randomly to other letters, and each character's background also changes colors randomly. This suggests that this effect is processing characters individually internally rather than operating on a single string, which probably means we'll have to do something like hold all characters in a characters table, operate on this table, and then draw each character on that table with all its modifications and effects to the screen.
但另外还发生了一件事:字符串里的字母会随机变成别的字符,而且每个字符的背景色也会随机变化。这说明这个效果在内部并不是把整串文字当成一个整体来处理,而是按字符逐个处理。所以大概率我们得把所有字符拆进一张 characters 表里,再对这张表做操作,最后逐个字符带着各自的效果画到屏幕上。
So to start with this in mind we can define the basics of the InfoText class. The way we're gonna call it is like this:
按照这个思路,先把 InfoText 类的基础骨架搭起来。调用方式会像这样:
function Boost:die()
...
self.area:addGameObject('InfoText', self.x, self.y, {text = '+BOOST', color = boost_color})
endAnd so the text attribute will contain our string. Then the basic definition of the class can look like this:
也就是说,text 属性里装的就是我们要显示的字符串。类的基础定义则可以写成:
function InfoText:new(...)
...
self.depth = 80
self.characters = {}
for i = 1, #self.text do table.insert(self.characters, self.text:utf8sub(i, i)) end
endWith this we simply define that this object will have depth of 80 (higher than all other objects, so will be drawn in front of everything) and then we separate the initial string into characters in a table. We use an utf8 library to do this. In general it's a good idea to manipulate strings with a library that supports all sorts of characters, and for this object it's really important that we do this as we'll see soon.
这里做了两件事:首先把这个对象的 depth 设成 80,比其他对象都高,因此会被画在最前面;然后把原始字符串拆成字符,放进一张表里。拆分时用了一个 utf8 库。一般来说,只要你要认真处理字符串,最好都用支持各种字符集的库;而对这个对象来说,这一点尤其重要,后面你会看到原因。
In any case, the drawing of these characters should also be done on an individual basis because as we figured out earlier, each character has its own background that can change randomly, so it's probably the case that we'll want to draw each character individually.
既然字符是逐个处理的,那绘制自然也得逐个来。毕竟我们前面已经推断过,每个字符都有自己会乱变的背景色,所以基本可以确定,最后也得按字符一颗颗画。
The logic used to draw characters individually is basically to go over the table of characters and draw each character at the x position that is the sum of all characters before it. So, for instance, drawing the first O in +BOOST means drawing it at something like initial_x_position + widthOf('+B'). The problem with getting the width of +B in this case is that it depends on the font being used, since we'll use the Font:getWidth function, and right now we haven't set any font. We can solve that easily though!
按字符绘制的逻辑,本质上就是遍历 characters 表,让每个字符的 x 坐标等于“前面所有字符宽度之和”。比如在 +BOOST 里,要画第一个 O,它的 x 大概就应该是 initial_x_position + widthOf('+B')。问题在于,这里的 +B 宽度取决于你正在使用的字体,因为我们会调用 Font:getWidth,而当前我们还没给这个对象设定字体。不过这也很好解决。
For this effect the font used will be m5x7 by Daniel Linssen. We can put this font in the folder resources/fonts and then load it. The code needed for loading it will be left as an exercise, since it's somewhat similar to the code used to load class definitions in the objects folder (exercise 14). By the end of this loading process we should have a global table called fonts that has all loaded fonts in the format fontname_fontsize. In this instance we'll use m5x7_16:
这里要用的字体是 Daniel Linssen 的 m5x7。把它放进 resources/fonts 目录,然后加载进来即可。至于加载代码,就留给你自己做练习了,因为它和前面自动加载 objects 文件夹里类定义的思路有点类似(练习 14)。最终,我们应该拿到一个全局表 fonts,里面所有已加载字体都按 fontname_fontsize 这样的格式保存。这里我们用的是 m5x7_16:
function InfoText:new(...)
...
self.font = fonts.m5x7_16
...
endAnd this is what the drawing code looks like:
对应的绘制代码如下:
function InfoText:draw()
love.graphics.setFont(self.font)
for i = 1, #self.characters do
local width = 0
if i > 1 then
for j = 1, i-1 do
width = width + self.font:getWidth(self.characters[j])
end
end
love.graphics.setColor(self.color)
love.graphics.print(self.characters[i], self.x + width, self.y,
0, 1, 1, 0, self.font:getHeight()/2)
end
love.graphics.setColor(default_color)
endFirst we use love.graphics.setFont to set the font we want to use for the next drawing operations. After this we go over each character and then draw it. But first we need to figure out its x position, which is the sum of the width of the characters before it. The inner loop that accumulates on the variable width is doing just that. It goes from 1 (the start of the string) to i-1 (the character before the current one) and adds the width of each character to a total final width that is the sum of all of them. After that we use love.graphics.print to draw each individual character at its appropriate position. We also offset each character up by half the height of the font (so that the characters are centered around the y position we defined).
这里先用 love.graphics.setFont 设定接下来要使用的字体。然后逐个遍历字符并绘制。但在画之前,得先算出当前字符的 x 坐标,它等于前面所有字符宽度的总和。内部那个累加 width 的循环,做的就是这件事:从字符串起点一直累加到当前字符前一个位置,把每个字符的宽度都加起来。之后再通过 love.graphics.print 把当前字符画到正确位置上。同时我们还把字符沿 y 方向往上偏移了半个字体高度,这样整个文本会围绕我们设定的 y 位置居中。
If we test all this out now it looks like this:
如果现在就运行测试,效果会像这样:

Which looks about right!
看起来已经差不多对了。
Now we can move on to making the text blink a little before disappearing. This uses the same logic as the BoostEffect object so we can just kinda copy it:
接下来就可以给它加上消失前的闪烁。这部分和 BoostEffect 完全是同一套逻辑,所以几乎可以直接照搬:
function InfoText:new(...)
...
self.visible = true
self.timer:after(0.70, function()
self.timer:every(0.05, function() self.visible = not self.visible end, 6)
self.timer:after(0.35, function() self.visible = true end)
end)
self.timer:after(1.10, function() self.dead = true end)
endAnd if you run this you should see that the text stays normal for a while, starts blinking and then disappears.
运行后你会看到,文字会先正常停留一会儿,然后开始闪烁,最后消失。
Now the hard part, which is making each character change randomly as well as its foreground and background colors. This changing starts at about the same that the character starts blinking, so we'll place this piece of code inside the 0.7 seconds after call we just defined above. The way we'll do this is that every 0.035 seconds, we'll run a procedure that will have a chance to change a character to another random character. That looks like this:
接下来才是更麻烦的部分:让每个字符随机变化,同时它们的前景色和背景色也随机跳变。这个变化大致和文字开始闪烁的时机一致,所以我们会把代码放进刚才那个 0.7 秒的 after 里面。做法是:每隔 0.035 秒,执行一次过程,让每个字符都有一定概率变成另一个随机字符。代码大概像这样:
self.timer:after(0.70, function()
...
self.timer:every(0.035, function()
for i, character in ipairs(self.characters) do
if love.math.random(1, 20) <= 1 then
-- change character
else
-- leave character as it is
end
end
end)
end)And so each 0.035 seconds, for each character there's a 5% probability that it will be changed to something else. We can complete this by adding a variable named random_characters which is a string that contains all characters a character might change to, and then when a character change is necessary we pick one at random from this string:
也就是说,每隔 0.035 秒,每个字符都有 5% 概率被改成别的东西。要把这块补完整,可以加一个叫 random_characters 的变量,让它装着所有可能被替换成的字符。等需要换字时,就从这串字符里随机抽一个出来:
self.timer:after(0.70, function()
...
self.timer:every(0.035, function()
local random_characters = '0123456789!@#$%¨&*()-=+[]^~/;?><.,|abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWYXZ'
for i, character in ipairs(self.characters) do
if love.math.random(1, 20) <= 1 then
local r = love.math.random(1, #random_characters)
self.characters[i] = random_characters:utf8sub(r, r)
else
self.characters[i] = character
end
end
end)
end)And if you run that now it should look like this:
现在运行的话,效果应该会像这样:

We can use the same logic we used here to change the character's colors as well as their background colors. For that we'll define two tables, background_colors and foreground_colors. Each table will be the same size of the characters table and will simply hold the background and foreground colors for each character. If a certain character doesn't have any colors set in these tables then it just defaults to the default color for the foreground (boost_color ) and to a transparent background.
同样的逻辑,也可以拿来处理字符本身的颜色和背景色。为此我们先定义两张表:background_colors 和 foreground_colors。它们和 characters 表长度一致,分别保存每个字符当前的背景色和前景色。如果某个字符在这两张表里没有对应颜色,那前景色就默认用 boost_color,背景则保持透明。
function InfoText:new(...)
...
self.background_colors = {}
self.foreground_colors = {}
end
function InfoText:draw()
...
for i = 1, #self.characters do
...
if self.background_colors[i] then
love.graphics.setColor(self.background_colors[i])
love.graphics.rectangle('fill', self.x + width, self.y - self.font:getHeight()/2,
self.font:getWidth(self.characters[i]), self.font:getHeight())
end
love.graphics.setColor(self.foreground_colors[i] or self.color or default_color)
love.graphics.print(self.characters[i], self.x + width, self.y,
0, 1, 1, 0, self.font:getHeight()/2)
end
endFor the background colors we simply draw a rectangle at the appropriate position and with the size of the current character if background_colors[i] (the background color for the current character) is defined. As for the foreground color, we simply set the color to draw the current character with using setColor. If foreground_colors[i] isn't defined then it defauls to self.color, which for this object should always be boost_color since that's what we're passing in when we call it from the Boost object. But if self.color isn't defined either then it defaults to white (default_color). By itself this piece of code won't really do anything, because we haven't defined any of the values inside the background_colors or the foreground_colors tables.
背景色的处理方式很直接:只要 background_colors[i] 有值,就在当前字符对应位置画一个和字符尺寸匹配的矩形。至于前景色,则是在画字符前用 setColor 切换颜色。如果 foreground_colors[i] 没定义,就退回到 self.color,而对这个对象来说,self.color 基本总会是 boost_color,因为从 Boost 对象里调用它时传进来的就是这个值。如果连 self.color 都没有,那最后才退回到白色,也就是 default_color。当然,现在这段代码本身还不会真正产生效果,因为我们还没往 background_colors 和 foreground_colors 里填任何内容。
To do that we can use the same logic we used to change characters randomly:
要填这些内容,还是可以用和“随机换字符”同样的套路:
self.timer:after(0.70, function()
...
self.timer:every(0.035, function()
for i, character in ipairs(self.characters) do
...
if love.math.random(1, 10) <= 1 then
-- change background color
else
-- set background color to transparent
end
if love.math.random(1, 10) <= 2 then
-- change foreground color
else
-- set foreground color to boost_color
end
end
end)
end)The code that changes colors around will have to pick between a list of colors. We defined a group of 6 colors globally and we could just put those all into a list and then use table.random to pick one at random. What we'll do is that but also define 6 more colors on top of it which will be the negatives of the 6 original ones. So say you have 232, 48, 192 as the original color, we can define its negative as 255-232, 255-48, 255-192.
颜色切换逻辑需要从一组候选色里随机挑颜色。前面我们已经全局定义过一组颜色,所以完全可以直接把它们塞进一张表里,再用 table.random 随机抽取。除此之外,我们还会额外再补一组“反色”。比如原色是 232, 48, 192,那它的反色就可以写成 255-232, 255-48, 255-192。
function InfoText:new(...)
...
local default_colors = {default_color, hp_color, ammo_color, boost_color, skill_point_color}
local negative_colors = {
{255-default_color[1], 255-default_color[2], 255-default_color[3]},
{255-hp_color[1], 255-hp_color[2], 255-hp_color[3]},
{255-ammo_color[1], 255-ammo_color[2], 255-ammo_color[3]},
{255-boost_color[1], 255-boost_color[2], 255-boost_color[3]},
{255-skill_point_color[1], 255-skill_point_color[2], 255-skill_point_color[3]}
}
self.all_colors = fn.append(default_colors, negative_colors)
...
endSo here we define two tables that contain the appropriate values for each color and then we use the append function to join them together. So now we can say something like table.random(self.all_colors) to get a random color out of the 10 defined in those tables, which means that we can do this:
这里我们定义了两张表,一张装正常颜色,一张装对应的反色。然后通过 append 把它们拼接起来。这样之后就可以直接写 table.random(self.all_colors),从这 10 种颜色里随机抽一个出来。于是完整逻辑就能写成:
self.timer:after(0.70, function()
...
self.timer:every(0.035, function()
for i, character in ipairs(self.characters) do
...
if love.math.random(1, 10) <= 1 then
self.background_colors[i] = table.random(self.all_colors)
else
self.background_colors[i] = nil
end
if love.math.random(1, 10) <= 2 then
self.foreground_colors[i] = table.random(self.all_colors)
else
self.background_colors[i] = nil
end
end
end)
end)And if we run the game now it should look like this:
现在再运行游戏,效果应该会像这样:

And that's it. We'll improve it even more later on (and on the exercises) but it's enough for now. Lastly, the final thing we should do is make sure that whenever we collect a boost resource we actually add +25 boost to the player. This works exactly the same way as it did for the ammo resource so I'm going to skip it.
到这里,这个效果就算基本做完了。后面我们还会继续完善它,练习题里也会继续改,但现在已经够用了。最后别忘了,玩家拾取 boost resource 时,还得真的给玩家加上 +25 boost。这部分和 ammo resource 的处理完全一样,所以这里就不重复展开了。
Resources Exercises
95. Make it so that the Projectile collision class will ignore the Player collision class.
95. 让 Projectile 这个 collision class 忽略 Player collision class。
96. Change the addAmmo function so that it supports the addition of negative values and doesn't let the ammo attribute go below 0. Do the same for the addBoost and addHP functions (adding the HP resource is an exercise defined below).
96. 修改 addAmmo 函数,使它既支持传入正值,也支持传入负值,同时确保 ammo 不会降到 0 以下。再用同样思路实现 addBoost 和 addHP(HP resource 的添加在下面的练习里)。
97. Following the previous exercise, is it better to handle positive and negative values on the same function or to separate between addResource and removeResource functions instead?
97. 延续上一题的思路,把正值和负值都放进同一个函数里处理更好,还是拆成 addResource 和 removeResource 两个函数更好?
98. In the InfoText object, change the probability of a character being changed to 20%, the probability of a foreground color being changed to 5%, and the probability of a background color being changed to 30%.
98. 在 InfoText 对象里,把“字符被替换”的概率改成 20%,“前景色变化”的概率改成 5%,“背景色变化”的概率改成 30%。
99. Define the default_colors, negative_colors and all_colors tables globally instead of locally in InfoText.
99. 不要再把 default_colors、negative_colors 和 all_colors 定义在 InfoText 局部作用域里,而是改成全局定义。
100. Randomize the position of the InfoText object so that it is spawned between -self.w and self.w in its x component and between -self.h and self.h in its y component. The w and h attributes refer to the Boost object that is spawning the InfoText.
100. 随机化 InfoText 的生成位置,让它在 x 方向上出生在 -self.w 到 self.w 之间,在 y 方向上出生在 -self.h 到 self.h 之间。这里的 w 和 h 指的是生成这个 InfoText 的 Boost 对象的宽高。
101. Assume the following function:
101. 假设有下面这样一个函数:
function Area:getAllGameObjectsThat(filter)
local out = {}
for _, game_object in pairs(self.game_objects) do
if filter(game_object) then
table.insert(out, game_object)
end
end
return out
endWhich returns all game objects inside an Area that pass a filter function. And then assume that it's called like this inside InfoText's constructor:
它会返回一个 Area 中所有通过筛选函数的游戏对象。再假设它在 InfoText 构造函数里这样被调用:
function InfoText:new(...)
...
local all_info_texts = self.area:getAllGameObjectsThat(function(o)
if o:is(InfoText) and o.id ~= self.id then
return true
end
end)
endWhich returns all existing and alive InfoText objects that are not this one. Now make it so that this InfoText object doesn't visually collide with any other InfoText object, meaning, it doesn't occupy the same space on the screen as another such that its text would become unreadable. You may do this in whatever you think is best as long as it achieves the goal.
这会返回当前场上所有还活着、并且不是自己本身的 InfoText 对象。现在请你让这个新的 InfoText 不会和其他 InfoText 在视觉上重叠,也就是说,它不能和另一条文字挤在同一块屏幕区域里,导致内容变得看不清。至于具体怎么实现,你可以自由选择方法,只要最后达成这个目标即可。
102. (CONTENT) Add the HP resource with all of its functionality and visual effects. It uses the exact same logic as the Boost resource, but instead adds +25 to HP instead. The resource and effects look like this:
102. (CONTENT) 加入 HP resource,包括它完整的功能和视觉效果。它的整体逻辑和 Boost resource 完全一样,只不过效果变成给玩家 +25 HP。资源和效果长这样:

103. (CONTENT) Add the SP resource with all of its functionality and visual effects. It uses the exact same logic as the Boost resource, but instead adds +1 to SP instead. The SP resource should also be defined as a global variable for now instead of an internal one to the Player object. The resource and effects look like this:
103. (CONTENT) 加入 SP resource,包括它完整的功能和视觉效果。它的整体逻辑也和 Boost resource 一样,只不过变成给玩家 +1 SP。另外,SP 目前先不要作为 Player 的内部属性,而是定义成一个全局变量。资源和效果长这样:

Attacks
Alright, so now for attacks. Before anything else the first thing we're gonna do is change the way projectiles are drawn. Right now they're being drawn as circles but we want them as lines. This can be achieved with something like this:
好,接下来进入攻击系统。开始之前,我们先把 projectile 的绘制方式改掉。现在它还是圆形,但我们想把它画成线段。可以这样实现:
function Projectile:draw()
love.graphics.setColor(default_color)
pushRotate(self.x, self.y, Vector(self.collider:getLinearVelocity()):angle())
love.graphics.setLineWidth(self.s - self.s/4)
love.graphics.line(self.x - 2*self.s, self.y, self.x, self.y)
love.graphics.line(self.x, self.y, self.x + 2*self.s, self.y)
love.graphics.setLineWidth(1)
love.graphics.pop()
endIn the pushRotate function we use the projectile's velocity so that we can rotate it towards the angle its moving at. Then inside we use love.graphics.setLineWidth and set it to a value somewhat proportional to the s attribute but slightly smaller. This means that projectiles with bigger s will be thicker in general. Then we draw the projectile using love.graphics.line and importantly, we draw one line from -2*self.s to the center and then another from the center to 2*self.s. We do this because each attack will have different colors, and what we'll do is change the color of one those lines but not change the color of another. So, for instance, if we do this:
在 pushRotate 里,我们使用 projectile 当前速度的方向,让它始终朝着移动方向旋转。然后再通过 love.graphics.setLineWidth 把线宽设成和 s 大致成比例、但稍微小一点的值。这样,s 越大的 projectile,看起来整体也会更粗。接着用 love.graphics.line 来画子弹。这里有个很重要的细节:我们不是一次画一整条线,而是先从 -2*self.s 画到中心,再从中心画到 2*self.s。之所以分两段,是因为不同攻击会有不同颜色,而我们之后会让其中一段换色,另一段保持不变。比如像这样:
function Projectile:draw()
love.graphics.setColor(default_color)
pushRotate(self.x, self.y, Vector(self.collider:getLinearVelocity()):angle())
love.graphics.setLineWidth(self.s - self.s/4)
love.graphics.line(self.x - 2*self.s, self.y, self.x, self.y)
love.graphics.setColor(hp_color) -- change half the projectile line to another color
love.graphics.line(self.x, self.y, self.x + 2*self.s, self.y)
love.graphics.setLineWidth(1)
love.graphics.pop()
endIt will look like this:
看起来就会像这样:

In this way we can make each attack have its own color which helps with letting the player better understand what's going on on the screen.
这样我们就能让每种攻击拥有自己的配色,帮助玩家更清楚地分辨屏幕上到底发生了什么。
The game will end up having 16 attacks but we'll cover only a few of them now. The way the attack system will work is very simple and these are the rules:
最终游戏里会有 16 种攻击,不过现在我们只先实现其中一部分。攻击系统本身的规则很简单:
- Attacks (except the Neutral one) consume ammo with every shot;
- 除了 Neutral 之外,所有攻击每次发射都会消耗 ammo;
- When ammo hits 0 the current attack is changed to Neutral;
- 当 ammo 降到 0 时,当前攻击会自动切回 Neutral;
- New attacks can be obtained through resources that are spawned randomly;
- 新攻击通过随机刷出的 resource 获取;
- When a new attack is obtained, the current attack is removed and ammo is fully regenerated;
- 每次获得新攻击时,旧攻击会被替换掉,同时 ammo 会回满;
- Each attack consumes a different amount of ammo and has different properties.
- 每种攻击消耗的 ammo 数量不同,属性也不同。
The first we're gonna do is define a table that will hold information on each attack, such as their cooldown, ammo consumption and color. We'll define this in globals.lua and for now it will look like this:
首先,我们先定义一张表,用来保存每种攻击的基础信息,比如冷却、耗弹量和颜色。这张表会写在 globals.lua 里,当前先从最基础的版本开始:
attacks = {
['Neutral'] = {cooldown = 0.24, ammo = 0, abbreviation = 'N', color = default_color},
}The normal attack that we already have defined is called Neutral and it simply has the stats that the attack we had in the game had so far. Now what we can do is define a function called setAttack which will change from one attack to another and use this global table of attacks:
也就是说,我们之前已经实现的那种普通攻击,现在正式命名为 Neutral,并把它当前的数值填进这张表。接下来可以写一个 setAttack 函数,用它来切换攻击类型,同时顺便读取全局 attacks 表里的配置:
function Player:setAttack(attack)
self.attack = attack
self.shoot_cooldown = attacks[attack].cooldown
self.ammo = self.max_ammo
endAnd then we can call it like this:
然后在构造函数里这样调用:
function Player:new(...)
...
self:setAttack('Neutral')
...
endHere we simply change an attribute called attack which will contain the name of the current attack. This attribute will be used in the shoot function to check which attack is currently active and how we should proceed with projectile creation.
这里其实就是设置一个 attack 属性,让它保存当前攻击名。之后在 shoot 函数里,我们就会根据这个属性来判断当前到底是哪种攻击,以及应该怎样生成 projectile。
We also change an attribute named shoot_cooldown. This is an attribute that we haven't created yet, but similar to how the boost_timer and boost_cooldown attributes work, they will be used to control how often something can happen, in this case how often an attack happen. We will remove this line:
与此同时,我们还会修改一个叫 shoot_cooldown 的属性。这个属性我们现在还没正式定义,不过它和 boost_timer、boost_cooldown 那套逻辑很像,作用都是控制某件事隔多久才能发生一次。这里只不过它控制的是攻击频率。所以,我们需要把之前这句删掉:
function Player:new(...)
...
self.timer:every(0.24, function() self:shoot() end)
...
endAnd instead do the timing of attacks manually like this:
改成手动管理攻击计时,像这样:
function Player:new(...)
...
self.shoot_timer = 0
self.shoot_cooldown = 0.24
...
end
function Player:update(dt)
...
self.shoot_timer = self.shoot_timer + dt
if self.shoot_timer > self.shoot_cooldown then
self.shoot_timer = 0
self:shoot()
end
...
endFinally, at the end of the setAttack function we also regenerate the ammo resource. With this we take care of rule 4. The next thing we can do is change the shoot function a little to start taking into account the fact that different attacks exist:
同时,setAttack 结尾那句让 ammo 回满的逻辑,也顺便帮我们实现了规则 4。接下来可以稍微改一下 shoot,让它开始意识到“现在攻击类型已经不只一种了”:
function Player:shoot()
local d = 1.2*self.w
self.area:addGameObject('ShootEffect',
self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), {player = self, d = d})
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})
end
endBefore launching the projectile we check the current attack with the if self.attack == 'Neutral' conditional. This function will grow based on a big conditional chain like this where we'll be checking for all 16 attacks that we add.
也就是说,在真正发射 projectile 之前,我们会先通过 if self.attack == 'Neutral' 这样的判断,确认当前是哪种攻击。以后这段 shoot 会不断长大,最终变成一条大分支链,把 16 种攻击全部都覆盖进去。
So let's get started with adding one actual attack to see what it's like. The attack we'll add will be called Double and it looks like this:
那我们就先真正加一种攻击,看看完整流程是什么样。第一种要加的攻击叫 Double,效果如下:

And as you can see it shoots 2 projectiles at an angle instead of one. To get started with this first we'll add the attack's description to the global attacks table. This attack will have a cooldown of 0.32, cost 2 ammo, and its color will be ammo_color (these values were reached through trial and error):
可以看到,它不再只发 1 发子弹,而是会以夹角形式打出 2 发。实现的第一步,还是先把它的配置填进全局 attacks 表里。这个攻击的冷却是 0.32,耗弹 2,颜色是 ammo_color。这些数值也基本是我一点点试出来的:
attacks = {
...
['Double'] = {cooldown = 0.32, ammo = 2, abbreviation = '2', color = ammo_color},
}Now we can add it to the shoot function as well:
然后在 shoot 函数里补上它的分支:
function Player:shoot()
...
elseif self.attack == 'Double' then
self.ammo = self.ammo - attacks[self.attack].ammo
self.area:addGameObject('Projectile',
self.x + 1.5*d*math.cos(self.r + math.pi/12),
self.y + 1.5*d*math.sin(self.r + math.pi/12),
{r = self.r + math.pi/12, attack = self.attack})
self.area:addGameObject('Projectile',
self.x + 1.5*d*math.cos(self.r - math.pi/12),
self.y + 1.5*d*math.sin(self.r - math.pi/12),
{r = self.r - math.pi/12, attack = self.attack})
end
endHere we create two projectiles instead of one, each pointing with an angle offset of math.pi/12 radians, or 15 degrees. We also make it so that the projectile receives the attack attribute as the name of the attack. For each projectile type we'll do this as it will help us identify which attack this projectile belongs to. That is helpful for setting its appropriate color as well as changing its behavior when necessary. The Projectile object now looks like this:
这里我们不再生成一个 projectile,而是生成两个。它们分别以 math.pi/12 弧度,也就是 15 度,朝左右偏开。与此同时,我们还把当前攻击名通过 attack 属性传给 projectile。之后每种 projectile 都会这么做,因为这样它就能知道自己属于哪一种攻击。这对设置正确颜色,或者在需要时修改它的行为,都很有帮助。于是 Projectile 现在会变成这样:
function Projectile:new(...)
...
self.color = attacks[self.attack].color
...
end
function Projectile:draw()
pushRotate(self.x, self.y, Vector(self.collider:getLinearVelocity()):angle())
love.graphics.setLineWidth(self.s - self.s/4)
love.graphics.setColor(self.color)
love.graphics.line(self.x - 2*self.s, self.y, self.x, self.y)
love.graphics.setColor(default_color)
love.graphics.line(self.x, self.y, self.x + 2*self.s, self.y)
love.graphics.setLineWidth(1)
love.graphics.pop()
endIn the constructor we set color to the color defined in the global attacks table for this attack. And then in the draw function we draw one part of the line with its color being the color attribute, and another being default_color. For most projectile types this drawing setup will hold.
在构造函数里,我们把 color 设成全局 attacks 表中当前攻击对应的颜色。接着在 draw 里,把线段的一半画成 self.color,另一半画成 default_color。对于大多数 projectile 类型,这种绘制方式都会一直沿用。
The last thing we forgot to do is to make it so that this attack obeys rule 1, meaning that we forgot to add code to make it consume the amount of ammo it should consume. This is a pretty simple fix:
最后还有件事别忘了:我们得让这个攻击真正遵守规则 1,也就是每次发射都消耗它该消耗的 ammo。这个修正很简单:
function Player:shoot()
...
elseif self.attack == 'Double' then
self.ammo = self.ammo - attacks[self.attack].ammo
...
end
endWith this rule 1 (for the Double attack) will be followed. We can also add the code that will make rule 2 come true, which is that when ammo hits 0, we change the current attack to the Neutral one:
这样一来,至少 Double 攻击就已经遵守规则 1 了。然后我们还可以把规则 2 也加进来,也就是当 ammo 见底时,自动把当前攻击切回 Neutral:
function Player:shoot()
...
if self.ammo <= 0 then
self:setAttack('Neutral')
self.ammo = self.max_ammo
end
endThis must come at the end of the shoot function since we don't want the player to be able to shoot one extra time after his ammo resource hits 0.
这段必须放在 shoot 函数末尾,因为我们不想让玩家在 ammo 已经用光后,还凭空多打出额外一枪。
If you do all this and try running it it should look like this:
把这些都做完并运行后,效果应该像这样:

Attacks Exercises
104. (CONTENT) Implement the Triple attack. Its definition on the attacks table looks like this:
104. (CONTENT) 实现 Triple 攻击。它在 attacks 表中的定义如下:
attacks['Triple'] = {cooldown = 0.32, ammo = 3, abbreviation = '3', color = boost_color}And the attack itself looks like this:
攻击效果长这样:

The angles on the projectile are exactly the same as Double, except that there's one extra projectile also being spawned along the middle (at the same angle that the Neutral projectile is spawned). Create this attack following the same steps that were used for the Double attack.
它的 projectile 角度和 Double 完全一样,只不过中间会再额外多生成一发,也就是沿着 Neutral 那种正中间方向发射。请按和 Double 攻击相同的步骤把它实现出来。
105. (CONTENT) Implement the Rapid attack. Its definition on the attacks table looks like this:
105. (CONTENT) 实现 Rapid 攻击。它在 attacks 表中的定义如下:
attacks['Rapid'] = {cooldown = 0.12, ammo = 1, abbreviation = 'R', color = default_color}And the attack itself looks like this:
攻击效果长这样:

106. (CONTENT) Implement the Spread attack. Its definition on the attacks table looks like this:
106. (CONTENT) 实现 Spread 攻击。它在 attacks 表中的定义如下:
attacks['Spread'] = {cooldown = 0.16, ammo = 1, abbreviation = 'RS', color = default_color}And the attack itself looks like this:
攻击效果长这样:

The angles used for the shots are a random value between -math.pi/8 and +math.pi/8. This attack's projectile color also works a bit differently. Instead of having one color only, the color changes randomly to one inside the all_colors list every frame (or every other frame depending on what you think is best).
它发射时使用的角度,是 -math.pi/8 到 +math.pi/8 之间的随机值。另外,这种攻击的 projectile 颜色逻辑也稍有不同。它不会固定使用一种颜色,而是每帧,或者隔帧,随机切换成 all_colors 列表中的某一种颜色,具体节奏你可以自己决定哪种更合适。
107. (CONTENT) Implement the Back attack. Its definition on the attacks table looks like this:
107. (CONTENT) 实现 Back 攻击。它在 attacks 表中的定义如下:
attacks['Back'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Ba', color = skill_point_color}And the attack itself looks like this:
攻击效果长这样:

108. (CONTENT) Implement the Side attack. Its definition on the attacks table looks like this:
108. (CONTENT) 实现 Side 攻击。它在 attacks 表中的定义如下:
attacks['Side'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Si', color = boost_color}And the attack itself looks like this:
攻击效果长这样:

109. (CONTENT) Implement the Attack resource. Like the Boost and SkillPoint resources, the Attack resource is spawned from either the left or right of the screen at a random y position, and then moves inward very slowly. When the player comes into contact with an Attack resource, his attack is changed to the attack that the resource contains using the setAttack function.
109. (CONTENT) 实现 Attack resource。和 Boost、SkillPoint resource 一样,它会从屏幕左右任意一侧、一个随机 y 坐标处生成,然后缓慢向屏幕内部移动。当玩家碰到一个 Attack resource 时,就使用 setAttack 函数,把玩家当前攻击切换成这个 resource 所携带的攻击类型。
Attack resources look a bit different from the Boost or SkillPoint resources, but the idea behind it and its effects are pretty much the same. The colors used for each different attack are the same as the ones used for its projectiles and the identifying name used is the one that we called abbreviation in the attacks table. Here's what they look like:
Attack resource 的外观会和 Boost、SkillPoint resource 略有不同,但它背后的思路以及相关效果,基本还是同一套。每种攻击所使用的颜色,应当和它的 projectile 颜色保持一致,而显示出来的简称,则使用 attacks 表里那个叫 abbreviation 的字段。效果看起来像这样:

Don't forget to create InfoText objects whenever a new attack is gathered by the player!
别忘了,玩家每次拿到新的攻击时,也都要同步生成对应的 InfoText 效果。