BYTEPATH #6 - Player Basics
Introduction
In this section we'll focus on adding more functionality to the Player class. First we'll focus on the player's attack and the Projectile object. After that we'll focus on two of the main stats that the player will have: Boost and Cycle/Tick. And finally we'll start on the first piece of content that will be added to the game, which is different Player ships. From this section onward we'll also only focus on gameplay related stuff, while the previous 5 were mostly setup for everything.
这一节我们会继续给 Player 类加功能。先处理玩家的攻击方式和 Projectile 对象,然后再来看玩家的两个核心属性:Boost 和 Cycle/Tick。最后,我们会开始制作游戏里的第一批正式内容,也就是不同类型的玩家飞船。从这一节开始,重心也会完全转到玩法本身;前面 5 篇主要还是在做各种基础搭建。
Player Attack
The way the player will attack in this game is that each n seconds an attack will be triggered and executed automatically. In the end there will be 16 types of attacks, but pretty much all of them have to do with shooting projectiles in the direction that the player is facing. For instance, this one shoots homing projectiles:
这个游戏里,玩家的攻击方式是每隔 n 秒自动触发并执行一次。最终一共会有 16 种攻击类型,不过它们大多数本质上都是朝玩家当前朝向发射某种弹体。比如下面这个会发射追踪子弹:

While this one shoots projectiles at a faster rate but at somewhat random angles:
而下面这个则是以更快的频率发射子弹,不过角度会带一点随机性:

Attacks and projectiles will have all sorts of different properties and be affected by different things, but the core of it is always the same.
不同攻击和不同弹体会带有各种各样的属性,也会受到不同机制的影响,但它们的核心思路始终差不多。
To achieve this we first need to make it so that the player attacks every n seconds. n is a number that will vary based on the attack, but the default one will be 0.24. Using the timer library that was explained in a previous section we can do this easily:
想实现这一点,第一步就是让玩家每隔 n 秒自动攻击一次。这个 n 会随着不同攻击而变化,不过默认值先定成 0.24。前面已经讲过 timer 库,所以这件事做起来很简单:
function Player:new()
...
self.timer:every(0.24, function()
self:shoot()
end)
endWith this we'll be calling a function called shoot every 0.24 seconds and inside that function we'll place the code that will actually create the projectile object.
这样一来,我们就会每隔 0.24 秒调用一次 shoot 函数,而真正创建 projectile 对象的逻辑就写在这个函数里。
So, now we can define what will happen in the shoot function. At first, for every shot fired we'll have a small effect to signify that a shot was fired. A good rule of thumb I have is that whenever an entity is created or deleted from the game, an accompanying effect should appear, as it masks the fact that an entity just appeared/disappeared out of nowhere on the screen and generally makes things feel better.
接下来就可以定义 shoot 里到底发生什么了。一开始,每次开火时我们都会做一个很小的视觉效果,告诉玩家“刚刚发射了一枪”。我自己有个挺常用的经验法则:只要游戏里有东西突然生成或者消失,最好都配一个对应效果。这样能掩盖“对象凭空出现/消失”的生硬感,整体手感通常也会更好。
To create this new effect first we need to create a new game object called ShootEffect (you should know how to do this by now). This effect will simply be a square that lasts for a very small amount of time around the position where the projectile will be created from. The easiest way to get that going is something like this:
为了做这个效果,先新建一个叫 ShootEffect 的游戏对象(做到这里你应该已经知道这类对象怎么建了)。它的表现很简单,就是在 projectile 即将生成的位置附近短暂出现一个小方块。最直接的写法大概像这样:
function Player:shoot()
self.area:addGameObject('ShootEffect', self.x + 1.2*self.w*math.cos(self.r),
self.y + 1.2*self.w*math.sin(self.r))
endfunction ShootEffect:new(...)
...
self.w = 8
self.timer:tween(0.1, self, {w = 0}, 'in-out-cubic', function() self.dead = true end)
end
function ShootEffect:draw()
love.graphics.setColor(default_color)
love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
endAnd that looks like this:
效果大概像这样:

The effect code is rather straight forward. It's just a square of width 8 that lasts for 0.1 seconds, and this width is tweened down to 0 along that duration. One problem with the way things are now is that the effect's position is static and doesn't follow the player. It seems like a small detail because the duration of the effect is small, but try changing that to 0.5 seconds or something longer and you'll see what I mean.
这个效果本身很直白:一个宽度为 8 的方块,持续 0.1 秒,并在这段时间里把宽度 tween 到 0。现在这段实现有个问题,就是这个效果的位置是静止的,不会跟着玩家走。因为持续时间很短,所以这个问题暂时看起来不明显;但你如果把持续时间改成 0.5 秒之类的更长值,就能马上看出不对劲。
One way to fix this is to pass the Player object as a reference to the ShootEffect object, and so in this way the ShootEffect object can have its position synced to the Player object:
一个解决办法,就是把 Player 对象本身作为引用传给 ShootEffect。这样 ShootEffect 就能把自己的位置同步到 Player 身上:
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})
endfunction ShootEffect:update(dt)
ShootEffect.super.update(self, dt)
if self.player then
self.x = self.player.x + self.d*math.cos(self.player.r)
self.y = self.player.y + self.d*math.sin(self.player.r)
end
end
function ShootEffect:draw()
pushRotate(self.x, self.y, self.player.r + math.pi/4)
love.graphics.setColor(default_color)
love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
love.graphics.pop()
endThe player attribute of the ShootEffect object is set to self in the player's shoot function via the opts table. This means that a reference to the Player object can be accessed via self.player in the ShootEffect object. Generally this is the way we'll pass references of objects from one another, because usually objects get created from within another objects function, which means that passing self achieves what we want. Additionally, we set the d attribute to the distance the effect should appear at from the center of the Player object. This is also done through the opts table.
在玩家的 shoot 函数里,我们通过 opts 把 player 属性设成了 self。这就意味着,在 ShootEffect 里可以通过 self.player 拿到 Player 对象的引用。通常来说,我们之后也会用这种方式在对象之间传引用,因为大多数对象本来就是在另一个对象的方法内部创建出来的,这时直接传 self 就能达到目的。另外,我们还通过 opts 传进去了 d,表示这个效果应该离 Player 中心多远。
Then in ShootEffect's update function we set its position to the player's. It's important to always check if the reference that will be accessed is actually set (if self.player then) because if it isn't than an error will happen. And often times, as we build more, it will be the case that entities will die while being referenced somewhere else and we'll try to access some of its values, but because it died, those values aren't set anymore and then an error is thrown. It's important to keep this in mind when referencing entities within each other like this.
接着在 ShootEffect 的 update 里,我们把它的位置同步到玩家身上。这里很重要的一点是,只要你要访问某个引用,就最好先确认它真的存在,也就是写上 if self.player then。否则一旦这个引用没设好,就会直接报错。后面内容越多,这类问题就越常见:某个实体已经死了,但别的地方还持有对它的引用,于是你去访问它的属性时,就会因为那些值已经不存在而抛错。只要对象之间开始相互引用,这件事就必须时刻记着。
Finally, the last detail is that I make it so that the square is synced with the player's angle, and then I also rotate that by 45 degrees to make it look cooler. The function used to achieve that was pushRotate and it looks like this:
最后还有一个细节:我让这个方块跟随玩家当前角度一起旋转,并额外再转 45 度,让它看起来更有意思一点。这里用到的函数叫 pushRotate,内容如下:
function pushRotate(x, y, r)
love.graphics.push()
love.graphics.translate(x, y)
love.graphics.rotate(r or 0)
love.graphics.translate(-x, -y)
endThis is a simple function that pushes a transformation to the transformation stack. Essentially it will rotate everything by r around point x, y until we call love.graphics.pop. So in this example we have a square and we rotate around its center by the player's angle plus 45 degrees (pi/4 radians). For completion's sake, the other version of this function which also contains scaling looks like this:
这是一个很简单的工具函数,它会把一个变换压进变换栈里。实际效果就是:在调用 love.graphics.pop 之前,后续绘制内容都会围绕 x, y 这个点旋转 r 角度。所以在这个例子里,我们就是让方块围绕自己的中心,按“玩家当前角度 + 45 度(pi/4 弧度)”来旋转。顺便补全一下,带缩放版本的函数长这样:
function pushRotateScale(x, y, r, sx, sy)
love.graphics.push()
love.graphics.translate(x, y)
love.graphics.rotate(r or 0)
love.graphics.scale(sx or 1, sy or sx or 1)
love.graphics.translate(-x, -y)
endThese functions are pretty useful and will be used throughout the game so make sure you play around with them and understand them well!
这两个函数都很实用,后面整款游戏里会反复用到,所以最好自己多试几次,把它们吃透。
Player Attack Exercises
80. Right now, we simply use an initial timer call in the player's constructor telling the shoot function to be called every 0.24 seconds. Assume an attribute self.attack_speed exists in the Player which changes to a random value between 1 and 2 every 5 seconds:
80. 现在我们只是在 Player 构造函数里用一个初始 timer,让 shoot 每 0.24 秒被调用一次。假设 Player 身上还有一个 self.attack_speed 属性,并且它会每隔 5 秒随机变成 1 到 2 之间的某个值:
function Player:new(...)
...
self.attack_speed = 1
self.timer:every(5, function() self.attack_speed = random(1, 2) end)
self.timer:every(0.24, function() self:shoot() end)How would you change the player object so that instead of shooting every 0.24 seconds, it shoots every 0.24/self.attack_speed seconds? Note that simply changing the value in the every call that calls the shoot function will not work.
你会怎么改 Player,使它不再固定每 0.24 秒攻击一次,而是改成每 0.24/self.attack_speed 秒攻击一次?注意,光是把调用 shoot 的那个 every 里的数值改掉,并不能解决问题。
81. In the last article we went over garbage collection and how forgotten references can be dangerous and cause leaks. In this article I explained how we will reference objects within one another using the Player and ShootEffect objects as examples. In this instance where the ShootEffect is a short-lived object that contains a reference to the Player inside it, do we need to care about dereferencing the Player reference so that it can be collected eventually or is it not necessary? In a more general way, when do we need to care about dereferencing objects that reference each other like this?
81. 上一篇我们讲过垃圾回收,也说过遗漏的引用可能会导致泄漏。这一篇里我又用 Player 和 ShootEffect 举例,说明对象之间会怎样相互引用。在这个例子里,ShootEffect 是一个寿命很短、内部持有 Player 引用的对象。那我们需不需要特意去解除这个 Player 引用,保证它将来能被回收?还是说这里没这个必要?再一般一点看,像这种对象之间互相引用的情况,到底在什么时候必须认真处理解除引用的问题?
82. Using pushRotate, rotate the player around its center by 180 degrees. It should look like this:
82. 使用 pushRotate,让玩家围绕自身中心旋转 180 度。效果应该像这样:

83. Using pushRotate, rotate the line that points in the player's moving direction around its center by 90 degrees. It should look like this:
83. 使用 pushRotate,把那条表示玩家移动方向的线绕它自己的中心旋转 90 度。效果应该像这样:

84. Using pushRotate, rotate the line that points in the player's moving direction around the player's center by 90 degrees. It should look like this:
84. 使用 pushRotate,把那条表示玩家移动方向的线绕玩家中心旋转 90 度。效果应该像这样:

85. Using pushRotate, rotate the ShootEffect object around the player's center by 90 degrees (on top of already rotating it by the player's direction). It should look like this:
85. 使用 pushRotate,让 ShootEffect 在已经跟随玩家朝向旋转的基础上,再额外绕玩家中心旋转 90 度。效果应该像这样:

Player Projectile
Now that we have the shooting effect done we can move on to the actual projectile. The projectile will have a movement mechanism that is very similar to the player's in that it's a physics object that has an angle and then we'll set its velocity according to that angle. So to start with, the call inside the shoot function:
现在开火效果已经有了,就可以继续做真正的 projectile。它的移动机制和玩家本身很像:同样是一个带角度的物理对象,然后根据这个角度去设置速度。首先,在 shoot 函数里这样调用:
function Player:shoot()
...
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})
endAnd this should have nothing unexpected. We use the same d variable that was defined earlier to set the Projectile's initial position, and then pass the player's angle as the r attribute. Note that unlike the ShootEffect object, the Projectile won't need anything more than the angle of the player when it was created, and so we don't need to pass the player in as a reference.
这里没什么特别意外的地方。我们还是用前面定义好的 d 来决定 Projectile 的初始出生位置,再把玩家当前角度作为 r 传进去。注意,和 ShootEffect 不同,Projectile 只需要知道“创建那一刻玩家的朝向”就够了,所以没必要把整个 Player 当引用传进去。
Now for the Projectile's constructor. The Projectile object will also have a circle collider (like the Player), a velocity and a direction its moving along:
接着来看 Projectile 的构造函数。它同样会有一个圆形碰撞体、一个速度,以及一个移动方向:
function Projectile:new(area, x, y, opts)
Projectile.super.new(self, area, x, y, opts)
self.s = opts.s or 2.5
self.v = opts.v or 200
self.collider = self.area.world:newCircleCollider(self.x, self.y, self.s)
self.collider:setObject(self)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
endThe s attribute represents the radius of the collider, it isn't r because that one already is used for the movement angle. In general I'll use variables w, h, r or s to represent object sizes. The first two when the object is a rectangle, and the other two when it's a circle. In cases where the r variable is already being used for a direction (like in this one), then s will be used for the radius. Those attributes are also mostly for visual purposes, since most of the time those objects already have the collider doing all collision related work.
这里的 s 表示碰撞体半径,不能用 r,因为 r 已经拿去表示移动角度了。一般来说,我会用 w、h、r 或 s 来表示对象尺寸:前两个对应矩形,后两个对应圆形。如果 r 已经被拿去表示方向,像这里这样,那半径就改用 s。这些属性大多数时候也是为了绘制和表现服务,因为真正的碰撞工作通常已经由 collider 本身在处理了。
Another thing we do here, which I think I already explained in another article, is the opts.attribute or default_value construct. Because of the way or works in Lua, we can use this construct as a fast way of saying this:
这里还有一个写法,我印象里前面也提过,就是 opts.attribute or default_value 这种结构。由于 Lua 里 or 的行为方式,这其实可以当作下面这段的简写:
if opts.attribute then
self.attribute = opts.attribute
else
self.attribute = default_value
endWe're checking to see if the attribute exists, and then setting some variable to that attribute, and if it doesn't then we set it to a default value. In the case of self.s, it will be set to opts.s if it was defined, otherwise it will be set to 2.5. The same applies to self.v. Finally, we set the projectile's velocity by using setLinearVelocity with the initial velocity of the projectile and the angle passed in from the Player. This uses the same idea that the Player uses for movement so that should be already understood.
它做的事情就是:先看这个属性有没有传进来,如果有,就用传入值;如果没有,就退回到默认值。比如 self.s,如果 opts.s 定义了,那它就取 opts.s;否则就用 2.5。self.v 也是一样。最后,我们再通过 setLinearVelocity,把 projectile 的初始速度和从 Player 那边传来的角度结合起来,设置它的线速度。这套思路和 Player 的移动是同一套,所以这里应该已经不陌生了。
If we now update and draw the projectile like:
如果接着这样去更新和绘制 projectile:
function Projectile:update(dt)
Projectile.super.update(self, dt)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
end
function Projectile:draw()
love.graphics.setColor(default_color)
love.graphics.circle('line', self.x, self.y, self.s)
endAnd that should look like this:
效果应该会像这样:

Player Projectile Exercises
86. From the player's shoot function, change the size/radius of the created projectiles to 5 and their velocity to 150.
86. 在玩家的 shoot 函数里,把生成出来的 projectiles 半径改成 5,速度改成 150。
87. Change the shoot function to spawn 3 projectiles instead of 1, while 2 of those projectiles are spawned with angles pointing to the player's angle +-30 degrees. It should look like this:
87. 修改 shoot,让它不再只生成 1 个 projectile,而是一次生成 3 个,其中两侧那两个 projectile 的角度分别是玩家朝向的正负 30 度。效果应该像这样:

88. Change the shoot function to spawn 3 projectiles instead of 1, with the spawning position of each side projectile being offset from the center one by 8 pixels. It should look like this:
88. 继续修改 shoot,同样一次生成 3 个 projectile,不过这次让左右两侧的 projectile 在出生位置上相对中间那个各偏移 8 像素。效果应该像这样:

89. Change the initial projectile speed to 100 and make it accelerate up to 400 over 0.5 seconds after its creation.
89. 把 projectile 的初始速度改成 100,并在创建后的 0.5 秒内让它加速到 400。
Player & Projectile Death
Now that the Player can move around and attack in a basic way, we can start worrying about some additional rules of the game. One of those rules is that if the Player hits the edge of the play area, he will die. The same should be the case for Projectiles, since right now they are being spawned but they never really die, and at some point there will be so many of them alive that the game will slow down considerably.
现在 Player 已经能基本移动和攻击了,我们可以开始补一些玩法规则。其中一条就是:如果 Player 撞到游戏区域边缘,就要死亡。Projectile 也应该遵循同样的规则,因为现在它们只会不断生成,却从来不会死,时间一长,场上活着的 projectile 会多到让游戏明显变慢。
So let's start with the Projectile object:
那就先从 Projectile 开始:
function Projectile:update(dt)
...
if self.x < 0 then self:die() end
if self.y < 0 then self:die() end
if self.x > gw then self:die() end
if self.y > gh then self:die() end
endWe know that the center of the play area is located at gw/2, gh/2, which means that the top-left corner is at 0, 0 and the bottom-right corner is at gw, gh. And so all we have to do is add a few conditionals to the update function of a projectile checking to see if its position is beyond any of those edges, and if it is, we call the die function.
我们知道游戏区域中心在 gw/2, gh/2,那也就意味着左上角是 0, 0,右下角是 gw, gh。所以要做的事非常直接:在 projectile 的 update 里加几个判断,检查它的位置是否已经越过这些边界,只要越界,就调用 die。
The same logic applies for the Player object:
Player 这边也是同样的逻辑:
function Player:update(dt)
...
if self.x < 0 then self:die() end
if self.y < 0 then self:die() end
if self.x > gw then self:die() end
if self.y > gh then self:die() end
endNow for the die function. This function is very simple and essentially what it will do it set the dead attribute to true for the entity and then spawn some visual effects. For the projectile the effect spawned will be called ProjectileDeathEffect, and like the ShootEffect, it'll be a square that lasts for a small amount of time and then disappears, although with a few differences. The main difference is that ProjectileDeathEffect will flash for a while before turning to its normal color and then disappearing. This gives a subtle but nice popping effect that looks good in my opinion. So the constructor could look like this:
接下来是 die 函数。这个函数本身很简单,核心无非是把对象的 dead 设成 true,然后顺手生成一些死亡效果。对 projectile 来说,这个效果会叫 ProjectileDeathEffect。和 ShootEffect 一样,它也是一个持续很短时间的小方块,但细节上有些不同。最主要的区别是:ProjectileDeathEffect 会先闪一下,再变回正常颜色,然后才消失。我觉得这种微妙的“啪”一下的感觉很好看。构造函数可以这样写:
function ProjectileDeathEffect:new(area, x, y, opts)
ProjectileDeathEffect.super.new(self, area, x, y, opts)
self.first = true
self.timer:after(0.1, function()
self.first = false
self.second = true
self.timer:after(0.15, function()
self.second = false
self.dead = true
end)
end)
endWe defined two attributes, first and second, which will denote in which stage the effect is in. If in the first stage, then its color will be white, while in the second its color will be what its color should be. After the second stage is done then the effect will die, which is done by setting dead to true. This all happens in a span of 0.25 seconds (0.1 + 0.15) so it's a very short lived and quick effect. Now for how the effect should be drawn, which is very similar to how ShootEffect was drawn:
这里定义了两个属性,first 和 second,用来标记当前处于效果的哪个阶段。第一阶段时,它会显示成白色;第二阶段时,则显示成它本来的颜色。第二阶段结束之后,就把 dead 设成 true,效果也就结束了。整个过程总共只有 0.25 秒,也就是 0.1 + 0.15,所以这是个非常短促的效果。至于绘制方式,和 ShootEffect 很像:
function ProjectileDeathEffect:draw()
if self.first then love.graphics.setColor(default_color)
elseif self.second then love.graphics.setColor(self.color) end
love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
endHere we simply set the color according to the stage, as I explained, and then we draw a rectangle of that color. To create this effect, we do it from the die function in the Projectile object:
这里就是按阶段去切换颜色,然后画出对应颜色的矩形。这个效果的创建位置,则放在 Projectile 对象自己的 die 函数里:
function Projectile:die()
self.dead = true
self.area:addGameObject('ProjectileDeathEffect', self.x, self.y,
{color = hp_color, w = 3*self.s})
endOne of the things I failed to mention before is that the game will have a finite amount of colors. I'm not an artist and I don't wanna spend much time thinking about colors, so I just picked a few of them that go well together and used them everywhere. Those colors are defined in globals.lua and look like this:
前面还有一件事我忘了提:这款游戏会只使用一小套固定颜色。我不是美术,也不想在配色上花太多时间,所以就挑了几种搭在一起还不错的颜色,然后全局反复使用。这些颜色定义在 globals.lua 里,大概是这样:
default_color = {222, 222, 222}
background_color = {16, 16, 16}
ammo_color = {123, 200, 164}
boost_color = {76, 195, 217}
hp_color = {241, 103, 69}
skill_point_color = {255, 198, 93}For the projectile death effect I'm using hp_color (red) to show what the effect looks like, but the proper way to do this in the future will be to use the color of the projectile object. Different attack types will have different colors and so the death effect will similarly have different colors based on the attack. In any case, the way the effect looks like is this:
这里为了演示 projectile 的死亡效果,我先直接用了 hp_color,也就是红色。但以后更合理的做法,应该是直接使用 projectile 本身的颜色。因为不同攻击类型会有不同配色,所以死亡效果也应该跟着攻击类型一起变色。总之,现在它看起来是这样:

Now for the Player death effect. The first thing we can do is mirror the Projectile die function and set dead to true when the Player reaches the edges of the screen. After that is done we can do some visual effects for it. The main visual effect for the Player death will be a bunch of particles that appear called ExplodeParticle, kinda like an explosion but not really. In general the particles will be lines that move towards a random angle from their initial position and slowly decrease in length. A way to get this working would be something like this:
接下来做 Player 的死亡效果。第一步同样是照着 Projectile 的 die 来,把 Player 在碰到屏幕边缘时也设成 dead = true。之后就可以给它加一些视觉表现。玩家死亡的主要效果会是一堆叫 ExplodeParticle 的粒子,看起来有点像爆炸,但也不是那种传统爆炸。总体来说,这些粒子会以线段的形式,朝随机角度飞出去,并逐渐缩短。实现方式大概可以这样:
function ExplodeParticle:new(area, x, y, opts)
ExplodeParticle.super.new(self, area, x, y, opts)
self.color = opts.color or default_color
self.r = random(0, 2*math.pi)
self.s = opts.s or random(2, 3)
self.v = opts.v or random(75, 150)
self.line_width = 2
self.timer:tween(opts.d or random(0.3, 0.5), self, {s = 0, v = 0, line_width = 0},
'linear', function() self.dead = true end)
endHere we define a few attributes, most of them are self explanatory. The additional thing we do is that over a span of between 0.3 and 0.5 seconds, we tween the size, velocity and line width of the particle to 0, and after that tween is done, the particle dies. The movement code for particle is similar to the Projectile, as well as the Player, so I'm going to skip it. It simply follows the angle using its velocity.
这里定义的几个属性基本都很直观。额外做的一件事是:在 0.3 到 0.5 秒之间,把粒子的尺寸、速度和线宽 tween 到 0,等 tween 结束后,它就死亡。至于粒子的移动逻辑,和 Projectile 以及 Player 的移动思路是一样的,所以这里就不再展开了,本质上就是按角度和速度往前走。
And finally the particle is drawn as a line:
最后,粒子的绘制方式是一条线:
function ExplodeParticle:draw()
pushRotate(self.x, self.y, self.r)
love.graphics.setLineWidth(self.line_width)
love.graphics.setColor(self.color)
love.graphics.line(self.x - self.s, self.y, self.x + self.s, self.y)
love.graphics.setColor(255, 255, 255)
love.graphics.setLineWidth(1)
love.graphics.pop()
endAs a general rule, whenever you have to draw something that is going to be rotated (in this case by the angle of direction of the particle), draw is as if it were at angle 0 (pointing to the right). So, in this case, we have to draw the line from left to right, with the center being the position of rotation. So s is actually half the size of the line, instead of its full size. We also use love.graphics.setLineWidth so that the line is thicker at the start and then becomes skinnier as time goes on.
这里有个通用原则:只要你要画的东西之后还会被旋转,那就先把它当成角度为 0,也就是朝右的状态去画。这里也是一样,我们先把线画成从左到右,并且让旋转中心落在中点上。所以 s 实际上表示的是线段的一半长度,而不是整条线的总长度。与此同时,我们也用了 love.graphics.setLineWidth,让线段一开始比较粗,然后随着时间推移慢慢变细。
The way these particles are created is rather simple. Just create a random number of them on the die function:
这些粒子的生成方式也很简单,直接在 die 函数里随机生成若干个:
function Player:die()
self.dead = true
for i = 1, love.math.random(8, 12) do
self.area:addGameObject('ExplodeParticle', self.x, self.y)
end
endOne last thing you can do is to bind a key to trigger the Player's die function, since the effect won't be able to be seen properly at the edge of the screen:
最后还可以做一件事:绑定一个按键,手动触发 Player 的 die,因为如果只在屏幕边缘触发死亡,有时候不太容易把效果看清楚:
function Player:new(...)
...
input:bind('f4', function() self:die() end)
endAnd all that looks like this:
这样一来,效果看起来会是这样:

This doesn't look very dramatic though. One way of really making something seem dramatic is by slowing time down a little. This is something a lot of people don't notice, but if you pay attention lots of games slow time down slightly whenever you get hit or whenever you die. A good example is Downwell, this video shows its gameplay and I marked the time when a hit happens so you can pay attention and see it for yourself.
不过这个效果还不算特别“惨烈”。想让某个瞬间更有冲击力,一个很好用的方法就是稍微把时间放慢一点。很多人平时不太会注意到这一点,但如果你留心观察,会发现很多游戏在玩家受击或死亡时,都会有一点点时间减速。一个很好的例子就是 Downwell,你可以看这个视频,我已经把受击发生的时间点标出来了,你可以自己注意一下。
Doing this ourselves is rather easy. First we can define a global variable called slow_amount in love.load and set it to 1 initially. This variable will be used to multiply the delta that we send to all our update functions. So whenever we want to slow time down by 50%, we set slow_amount to 0.5, for instance. Doing this multiplication can look like this:
这件事自己做起来也不难。首先可以在 love.load 里定义一个全局变量 slow_amount,初始值设成 1。之后我们会用它去乘传给所有 update 函数的 delta。比如想让时间减慢 50%,就把 slow_amount 设成 0.5。对应代码可以像这样:
function love.update(dt)
timer:update(dt*slow_amount)
camera:update(dt*slow_amount)
if current_room then current_room:update(dt*slow_amount) end
endAnd then we need to define a function that will trigger this work. Generally we want the time slow to go back to normal after a small amount of time. So it makes sense that this function should have a duration attached to it, on top of how much the slow should be:
接着还要定义一个专门触发减速的函数。一般来说,我们会希望减速效果在一小段时间后恢复正常,所以这个函数除了要知道减速到什么程度,还应该知道持续多久:
function slow(amount, duration)
slow_amount = amount
timer:tween('slow', duration, _G, {slow_amount = 1}, 'in-out-cubic')
endAnd so calling slow(0.5, 1) means that the game will be slowed to 50% speed initially and then over 1 second it will go back to full speed. One important thing to note here that the 'slow' string is used in the tween function. As explained in an earlier article, this means that when the slow function is called when the tween of another slow function call is still operating, that other tween will be cancelled and the new tween will continue from there, preventing two tweens from operating on the same variable at the same time.
所以,调用 slow(0.5, 1) 就表示:游戏会先立刻减速到 50%,然后在 1 秒内缓慢恢复到正常速度。这里有个重要细节,就是 tween 里用了 'slow' 这个字符串。前面讲过,这意味着如果上一次 slow 触发出来的 tween 还没结束,又有新的 slow 被调用,那旧的 tween 会先被取消,然后新的 tween 接着往下执行。这样就能避免两个 tween 同时操作同一个变量。
If we call slow(0.15, 1) when the player dies it looks like this:
如果在玩家死亡时调用 slow(0.15, 1),效果会像这样:

Another thing we can do is add a screen shake to this. The camera module already has a :shake function to it, and so we can add the following:
除此之外,我们还可以再叠加一个屏幕震动。camera 模块本来就已经有 :shake,所以直接加上下面这句就行:
function Player:die()
...
camera:shake(6, 60, 0.4)
...
endAnd finally, another thing we can do is make the screen flash for a few frames. This is something else that lots of games do that you don't really notice, but it helps sell an effect really well. This is a rather simple effect: whenever we call flash(n), the screen will flash with the background color for n frames. One way we can do this is by defining a flash_frames global variable in love.load that starts as nil. Whenever flash_frames is nil it means that the effect isn't active, and whenever it's not nil it means it's active. The flash function looks like this:
最后还能再加一个东西,就是让屏幕闪几帧。这也是很多游戏会做、但玩家未必会明确意识到的细节,不过它对强化打击感很有效。这个效果本身很简单:每次调用 flash(n),屏幕就会用背景色闪 n 帧。做法之一,是在 love.load 里定义一个全局变量 flash_frames,初始值设成 nil。当 flash_frames 是 nil 时,说明效果没激活;只要它不是 nil,就表示闪屏还在进行。flash 函数可以写成这样:
function flash(frames)
flash_frames = frames
endAnd then we can set this up in the love.draw function:
然后在 love.draw 里配上下面这段:
function love.draw()
if current_room then current_room:draw() end
if flash_frames then
flash_frames = flash_frames - 1
if flash_frames == -1 then flash_frames = nil end
end
if flash_frames then
love.graphics.setColor(background_color)
love.graphics.rectangle('fill', 0, 0, sx*gw, sy*gh)
love.graphics.setColor(255, 255, 255)
end
endFirst, we decrease flash_frames by 1 every frame, and then if it reaches -1 we set it to nil because the effect is over. And then whenever the effect is not over, we simply draw a big rectangle covering the whole screen that is colored as background_color. Adding this to the die function like this:
这里首先会让 flash_frames 每帧减 1;当它减到 -1 时,就把它重新设回 nil,表示效果结束。然后,只要这个效果还没结束,我们就绘制一个覆盖整个屏幕的大矩形,颜色就是 background_color。把它再加进 die 函数里,像这样:
function Player:die()
self.dead = true
flash(4)
camera:shake(6, 60, 0.4)
slow(0.15, 1)
for i = 1, love.math.random(8, 12) do
self.area:addGameObject('ExplodeParticle', self.x, self.y)
end
endGets us this:
就会得到这样的效果:

Very subtle and barely noticeable, but it's small details like these that make things feel more impactful and nicer.
这个效果非常细微,甚至不仔细看都未必发现得了,但恰恰就是这种小细节,会让整体冲击感和手感都更到位。
Player/Projectile Death Exercises
90. Without using the first and second attribute and only using a new current_color attribute, what is another way of achieving the changing colors of the ProjectileDeathEffect object?
90. 不使用 first 和 second 这两个属性,而只新增一个 current_color 属性的话,还有什么办法能实现 ProjectileDeathEffect 的变色效果?
91. Change the flash function to accept a duration in seconds instead of frames. Which one is better or is it just a matter of preference? Could the timer module use frames instead of seconds for its durations?
91. 把 flash 函数改成接收“秒数”而不是“帧数”作为持续时间。哪种做法更好,还是说这只是偏好问题?timer 模块的持续时间设计能不能也改成按帧而不是按秒?
Player Tick
Now we'll move on to another crucial part of the Player which is its cycle mechanism. The way the game works is that in the passive skill tree there will be a bunch of skills you can buy that will have a chance to be triggered on each cycle. And a cycle is just a counter that is triggered every n seconds. We need to set this up in a basic way. And to do that we'll just make it so that the tick function is called every 5 seconds:
接下来要处理 Player 的另一个关键机制,也就是 cycle。这个游戏里,技能树中的很多被动技能都会有“每次 cycle 时有几率触发”的设定。而所谓 cycle,本质上就是每隔 n 秒触发一次的计数事件。现在我们先把这个机制最基本的版本搭起来,做法就是每 5 秒调用一次 tick:
function Player:new(...)
...
self.timer:every(5, function() self:tick() end)
endIn the tick function, for now the only thing we'll do is add a little visual effect called TickEffect any time a tick happens. This effect is similar to the refresh effect in Downwell (see Downwell video I mentioned earlier in this article), in that it's a big rectangle over the Player that goes up a little. It looks like this:
现在在 tick 函数里,我们暂时只做一件事:每次 tick 发生时,生成一个叫 TickEffect 的小视觉效果。它有点像 Downwell 里的刷新效果,也就是我前面提过的那个视频里的表现:一个覆盖玩家的大矩形,稍微向上掠过去。效果像这样:

The first thing to notice is that it's a big rectangle that covers the player and gets smaller over time. But also that, like the ShootEffect, it follows the player. Which means that we know we'll need to pass the Player object as a reference to the TickEffect object:
先注意两个特点:它是一个覆盖玩家的大矩形,并且会随着时间逐渐缩小;同时它也和 ShootEffect 一样,会跟着玩家一起移动。这就意味着,我们同样需要把 Player 对象作为引用传给 TickEffect:
function Player:tick()
self.area:addGameObject('TickEffect', self.x, self.y, {parent = self})
endfunction TickEffect:update(dt)
...
if self.parent then self.x, self.y = self.parent.x, self.parent.y end
endAnother thing we can see is that it's a rectangle that gets smaller over time, but only in height. An easy way to do that is like this:
另外还能看出来,这个矩形只是在高度方向上逐渐变小,宽度并没有变化。最简单的写法可以这样:
function TickEffect:new(area, x, y, opts)
TickEffect.super.new(self, area, x, y, opts)
self.w, self.h = 48, 32
self.timer:tween(0.13, self, {h = 0}, 'in-out-cubic', function() self.dead = true end)
endIf you try this though, you'll see that the rectangle isn't going up like it should and it's just getting smaller around the middle of the player. One day to fix this is by introducing an y_offset attribute that gets bigger over time and that is subtracted from the y position of the TickEffect object:
不过你如果直接这样试,会发现矩形并没有像预期那样向上移动,而只是围绕玩家中间不断缩小。修正办法之一,是引入一个会随时间增大的 y_offset,然后把它从 TickEffect 的 y 坐标里减掉:
function TickEffect:new(...)
...
self.y_offset = 0
self.timer:tween(0.13, self, {h = 0, y_offset = 32}, 'in-out-cubic',
function() self.dead = true end)
end
function TickEffect:update(dt)
...
if self.parent then self.x, self.y = self.parent.x, self.parent.y - self.y_offset end
endAnd in this way we can get the desired effect. For now this is all that the tick function will do. Later as we add stats and passives it will have more stuff attached to it.
这样就能得到我们想要的效果了。现阶段 tick 函数先只负责这件事。等后面把更多属性和被动加进来之后,它身上还会继续挂更多逻辑。
Player Boost
Another important piece of gameplay is the boost. Whenever the user presses up, the player should start moving faster. And whenever the user presses down, the player should start moving slower. This boost mechanic is a core part of the gameplay and like the tick, we'll focus on the basics of it now and later add more to it.
另一个很重要的玩法机制是 boost。玩家按住上键时,飞船应该加速;按住下键时,则应该减速。boost 是这套玩法里的核心之一,和 tick 一样,我们现在先把最基础的版本做好,后面再往上继续叠功能。
First, lets get the button pressing to work. One of the attributes we have in the player is max_v. This sets the maximum velocity with which the player can move. What we want to do whenever up/down is pressed is change this value so that it becomes higher/lower. The problem with doing this is that after the button is done being pressed we need to go back to the normal value. And so we need another variable to hold the base value and one to hold the current value.
第一步先把按键逻辑做通。Player 身上有一个属性叫 max_v,它表示玩家允许达到的最大速度。我们希望按住上/下键时,临时把这个值调高或调低。问题在于,按键松开之后,它又必须恢复成正常值。所以这里就需要两套变量:一套保存基础值,一套保存当前值。
Whenever there's a stat (like velocity) that needs to be changed in game by modifiers, this (needing a base value and a current one) is a very common pattern. Later on as we add more stats and passives into the game we'll go into this with more detail. But for now we'll add an attribute called base_max_v, which will contain the initial/base value of the maximum velocity, and the normal max_v attribute will hold the current maximum velocity, affected by all sorts of modifiers (like the boost).
只要有某个属性,比如速度,会在游戏过程中被各种 modifier 改来改去,那“基础值 + 当前值”这套模式就会非常常见。后面当我们加入更多属性和被动时,还会更详细地展开这一点。现在先简单处理:新增一个 base_max_v,专门保存最大速度的原始值;而原本的 max_v 则改成用来表示当前生效的最大速度,它会受到 boost 之类各种修正的影响。
function Player:new(...)
...
self.base_max_v = 100
self.max_v = self.base_max_v
end
function Player:update(dt)
...
self.max_v = self.base_max_v
if input:down('up') then self.max_v = 1.5*self.base_max_v end
if input:down('down') then self.max_v = 0.5*self.base_max_v end
endWith this, every frame we're setting max_v to base_max_v and then we're checking to see if the up or down buttons are pressed and changing max_v appropriately. It's important to notice that this means that the call to setLinearVelocity that uses max_v has to happen after this, otherwise it will all fall apart horribly!
这样之后,每一帧我们都会先把 max_v 重置成 base_max_v,再看上键或下键有没有被按住,并相应地调整它。这里有个很关键的顺序问题:凡是依赖 max_v 的 setLinearVelocity 调用,都必须写在这段逻辑后面。不然整套速度控制就会乱掉。
Now that we have the basic boost functionality working, we can add some visuals. The way we'll do this is by adding trails to the player object. This is what they'll look like:
基础 boost 做通之后,就可以给它加一点视觉效果。这里我们会给玩家加一条拖尾,看起来大概像这样:

The creation of trails in general follow a pattern. And the way I do it is to create a new object every frame or so and then tween that object down over a certain duration. As the frames pass and you create object after object, they'll all be drawn near each other and the ones that were created earlier will start getting smaller while the ones just created will still be bigger, and the fact that they're all created from the bottom part of the player and the player is moving around, means that we'll get the desired trail effect.
做拖尾通常会遵循一种固定模式。我的做法是:差不多每一帧都新建一个对象,然后让这个对象在一小段时间里逐渐缩小。随着帧数推进,这些对象会一个接一个地被画出来,彼此靠得很近;先创建出来的已经开始变小,刚创建出来的还比较大。再加上它们都是从玩家后方生成,而玩家本身又在持续移动,于是就自然形成了我们想要的拖尾效果。
To do this we can create a new object called TrailParticle, which will essentially just be a circle with a certain radius that gets tweened down along some duration:
为了实现这个效果,我们可以新建一个 TrailParticle 对象。它本质上就是一个带半径的圆,并在一段时间里把半径 tween 到 0:
function TrailParticle:new(area, x, y, opts)
TrailParticle.super.new(self, area, x, y, opts)
self.r = opts.r or random(4, 6)
self.timer:tween(opts.d or random(0.3, 0.5), self, {r = 0}, 'linear',
function() self.dead = true end)
endDifferent tween modes like 'in-out-cubic' instead of 'linear', for instance, will make the trail have a different shape. I used the linear one because it looks the best to me, but your preference might vary. The draw function for this is just drawing a circle with the appropriate color and with the radius using the r attribute.
如果把 tween 模式从 'linear' 换成比如 'in-out-cubic',拖尾的形状和衰减感觉也会跟着变化。我自己觉得 linear 看起来最好,但这完全可能因人而异。至于它的 draw,就是按当前 r 半径和对应颜色去画一个圆。
On the Player object's end, we can create new TrailParticles like this:
在 Player 这一侧,可以这样持续生成 TrailParticle:
function Player:new(...)
...
self.trail_color = skill_point_color
self.timer:every(0.01, function()
self.area:addGameObject('TrailParticle',
self.x - self.w*math.cos(self.r), self.y - self.h*math.sin(self.r),
{parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color})
end)And so every 0.01 seconds (this is every frame, essentially), we spawn a new TrailParticle object behind the player, with a random radius between 2 and 4, random duration between 0.15 and 0.25 seconds, and color being skill_point_color, which is yellow.
这样一来,每隔 0.01 秒,基本也就是每一帧,我们都会在玩家后方生成一个新的 TrailParticle。它的半径在 2 到 4 之间随机,持续时间在 0.15 到 0.25 秒之间随机,颜色则用 skill_point_color,也就是黄色。
One additional thing we can do is changing the color of the particles to blue whenever up or down is being pressed. To do this we must add some logic to the boost code, namely, we need to be able to tell when a boost is happening, and to do this we'll add a boosting attribute. Via this attribute we'll be able to know when a boost is happening and then change the color being referenced in trail_color accordingly:
还可以再补一个小细节:只要按住上或下,拖尾颜色就切成蓝色。为了做到这一点,我们得在 boost 逻辑里多加一点状态管理,也就是新增一个 boosting 属性。这样一来,只要知道当前是不是处于 boost 状态,就能相应地切换 trail_color:
function Player:update(dt)
...
self.max_v = self.base_max_v
self.boosting = false
if input:down('up') then
self.boosting = true
self.max_v = 1.5*self.base_max_v
end
if input:down('down') then
self.boosting = true
self.max_v = 0.5*self.base_max_v
end
self.trail_color = skill_point_color
if self.boosting then self.trail_color = boost_color end
endAnd so with this we get what we wanted by changing trail_color to boost_color (blue) whenever the player is being boosted.
这样就能达到预期效果了:只要玩家处于 boost 状态,trail_color 就会切成 boost_color,也就是蓝色。
Player Ship Visuals
Now for the last thing this article will cover: ships! The game will have various different ship types that the player can be, each with different stats, passives and visuals. Right now we'll focus only the visual part and we'll add 1 ship, and as an exercise you'll have to add 7 more.
最后来看这篇的收尾内容:飞船。游戏里会有多种不同的玩家飞船,它们各自拥有不同的属性、被动和外观。现在我们先只做视觉部分,先实现 1 种飞船,另外 7 种会留给你当练习。
One thing that I should mention now that will hold true for the entire tutorial is something regarding content. Whenever there's content to be added to the game, like various ships, or various passives, or various options in a menu, or building the skill tree visually, etc, you'll have to do most of that work yourself. In the tutorial I'll cover how to do it once, but when that's covered and it's only a matter of manually and mindlessly adding more of the same, it will be left as an exercise.
这里有件和整套教程都相关的事情需要先说清楚,那就是“内容制作”这件事。只要某项工作属于往游戏里加内容,比如增加不同飞船、不同被动、菜单里的不同选项,或者手动去搭技能树等等,那么大部分重复劳动都需要你自己完成。教程里我会把一套做法讲明白,但一旦思路讲过了,后面那些只是机械性重复添加同类内容的部分,就都会作为练习留给你。
This is both because covering literally everything with all details would take a very long time and make the tutorial super big, and also because you need to learn if you actually like doing the manual work of adding content into the game. A big part of game development is just adding content and not doing anything "new", and depending on who you are personality wise you may not like the fact that there's a bunch of work to do that's just dumb work that might be not that interesting. In those cases you need to learn this sooner rather than later, because it's better to then focus on making games that don't require a lot of manual work to be done, for instance. This game is totally not that though. The skill tree will have about 800 nodes and all those have to be set manually (and you will have to do that yourself if you want to have a tree that big), so it's a good way to learn if you enjoy this type of work or not.
这么安排,一方面是因为如果事无巨细全都讲透,教程会变得极其漫长;另一方面也是因为,你得尽早搞清楚自己到底喜不喜欢做这种手动堆内容的工作。游戏开发里很大一部分时间,干的其实都不是“全新发明”,而是不断往里填内容。按人的性格不同,你未必会喜欢这种工作量大、又不算多刺激的重复劳动。如果你不喜欢,最好早点意识到这一点,那以后就更适合去做那些不需要大量手工内容堆砌的游戏。这个项目显然不是那一类。光技能树就会有大约 800 个节点,而且如果你真想做那么大,那些节点都得你自己手动填,所以它其实很适合拿来测试:你到底喜不喜欢这种类型的工作。
In any case, let's get started on one ship. This is what it looks like:
总之,我们先从一艘飞船开始。它长这样:

As you can see, it has 3 parts to it, one main body and two wings. The way we'll draw this is as a collection of simple polygons, and so we have to define 3 different polygons. We'll define the polygon's positions as if it is turned to the right (0 angle, as I explained previously). It will be something like this:
可以看到,它由 3 部分组成:一个主体,加上上下两片机翼。这里我们会把它当成一组简单多边形来绘制,所以要定义 3 个不同的 polygon。定义这些 polygon 顶点时,我们统一假设飞船是朝右的,也就是角度为 0,这一点前面已经讲过。结构会像这样:
function Player:new(...)
...
self.ship = 'Fighter'
self.polygons = {}
if self.ship == 'Fighter' then
self.polygons[1] = {
...
}
self.polygons[2] = {
...
}
self.polygons[3] = {
...
}
end
endAnd so inside each polygon table, we'll define the vertices of the polygon. To draw these polygons we'll have to do some work. First, we need to rotate the polygons around the player's center:
也就是说,每个 polygon 表里装的都是这个多边形的顶点。真正绘制这些 polygon 时,还要多做几步。第一步,是让它们围绕玩家中心一起旋转:
function Player:draw()
pushRotate(self.x, self.y, self.r)
love.graphics.setColor(default_color)
-- draw polygons here
love.graphics.pop()
endAfter this, we need to go over each polygon:
接着遍历每个 polygon:
function Player:draw()
pushRotate(self.x, self.y, self.r)
love.graphics.setColor(default_color)
for _, polygon in ipairs(self.polygons) do
-- draw each polygon here
end
love.graphics.pop()
endAnd then we draw each polygon:
最后把每个 polygon 画出来:
function Player:draw()
pushRotate(self.x, self.y, self.r)
love.graphics.setColor(default_color)
for _, polygon in ipairs(self.polygons) do
local points = fn.map(polygon, function(k, v)
if k % 2 == 1 then
return self.x + v + random(-1, 1)
else
return self.y + v + random(-1, 1)
end
end)
love.graphics.polygon('line', points)
end
love.graphics.pop()
endThe first thing we do is getting all the points ordered properly. Each polygon will be defined in local terms, meaning, a distance from the center of that is assumed to be 0, 0. This means that each polygon does now not know at which position it is in the game world yet.
这里首先做的是把所有点转换成正确的世界坐标顺序。每个 polygon 的定义都是局部坐标,也就是说,它们默认把自身中心当成 0, 0。因此在定义阶段,这些 polygon 其实还不知道自己在游戏世界里的真实位置。
The fn.map function goes over each element in a table and applies a function to it. In this case the function is checking to see if the index of the element is odd or even, and if its odd then it means it's for the x component, and if its even then it means it's for the y component. And so in each of those cases we simply add the x or y position of the player to the vertex, as a well as a random number between -1 and 1, so that the ship looks a bit wobbly and cooler. Then, finally, love.graphics.polygon is called to draw all those points.
fn.map 会遍历表中的每个元素,并对它应用一个函数。这里那段函数会判断当前元素索引是奇数还是偶数:奇数表示这个值对应 x 分量,偶数则表示 y 分量。然后我们就分别把玩家当前的 x 或 y 坐标加上去,同时再加一个 -1 到 1 之间的随机数,让飞船线条带一点抖动,看起来更活一点。最后,再调用 love.graphics.polygon 把这些点连成多边形。
Now, here's what the definition of each polygon looks like:
下面就是这几个 polygon 的具体定义:
self.polygons[1] = {
self.w, 0, -- 1
self.w/2, -self.w/2, -- 2
-self.w/2, -self.w/2, -- 3
-self.w, 0, -- 4
-self.w/2, self.w/2, -- 5
self.w/2, self.w/2, -- 6
}
self.polygons[2] = {
self.w/2, -self.w/2, -- 7
0, -self.w, -- 8
-self.w - self.w/2, -self.w, -- 9
-3*self.w/4, -self.w/4, -- 10
-self.w/2, -self.w/2, -- 11
}
self.polygons[3] = {
self.w/2, self.w/2, -- 12
-self.w/2, self.w/2, -- 13
-3*self.w/4, self.w/4, -- 14
-self.w - self.w/2, self.w, -- 15
0, self.w, -- 16
}The first one is the main body, the second is the top wing and the third is the bottom wing. All vertices are defined in an anti-clockwise manner, and the first point of a line is always the x component, while the second is the y component. Here I'll show a drawing that maps each vertex to the numbers outlined to the side of each point pair above:
第一个是机身主体,第二个是上机翼,第三个是下机翼。所有顶点都是按逆时针顺序定义的,而且每一对值里,第一个永远是 x,第二个永远是 y。下面这张图把顶点和上面注释里的编号一一对应起来了:

And as you can see, the first point is way to the right and vertically aligned with the center, so its self.w, 0. The next is a bit to the left and above the first, so its self.w/2, -self.w/2, and so on.
你可以看到,第一个点在最右边,并且和中心处于同一高度,所以写成 self.w, 0。下一个点则往左一点、同时比它更靠上,所以就是 self.w/2, -self.w/2,后面也都按这个思路类推。
Finally, another thing we can do after adding this is making the trails match the ship. For this one, as you can see in the gif I linked before, it has two trails coming out of the back instead of just one:
最后,飞船外观加完之后,还可以顺手把拖尾也跟飞船本身对齐。就像前面 gif 里看到的那样,这种 Fighter 不是一条尾焰,而是后方左右各拖出一条:
function Player:new(...)
...
self.timer:every(0.01, function()
if self.ship == 'Fighter' then
self.area:addGameObject('TrailParticle',
self.x - 0.9*self.w*math.cos(self.r) + 0.2*self.w*math.cos(self.r - math.pi/2),
self.y - 0.9*self.w*math.sin(self.r) + 0.2*self.w*math.sin(self.r - math.pi/2),
{parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color})
self.area:addGameObject('TrailParticle',
self.x - 0.9*self.w*math.cos(self.r) + 0.2*self.w*math.cos(self.r + math.pi/2),
self.y - 0.9*self.w*math.sin(self.r) + 0.2*self.w*math.sin(self.r + math.pi/2),
{parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color})
end
end)
endAnd here we use the technique of going from point to point based on an angle to get to our target. The target points we want are behind the player (0.9*self.w behind), but each offset by a small amount (0.2*self.w) along the opposite axis to the player's direction.
这里我们依然用的是“根据角度和距离一步步推位置”的那套方法。目标点都在玩家身后,也就是后退 0.9*self.w 的位置,但同时又要沿着玩家朝向的垂直轴,左右各偏一点,也就是 0.2*self.w,这样就能把两条尾焰分别放到飞船后侧两边。
And all this looks like this:
全部加起来后,效果就是这样:

Ship Visuals Exercises
As a small note, the (CONTENT) tag will mark exercises that are the content of the game itself. Exercises marked like this will have no answers and you're supposed to do them 100% yourself! From now on, more and more of the exercises will be like this, since we're starting to get into the game itself and a huge part of it is just manually adding content to it.
顺带说明一下,带有 (CONTENT) 标记的练习,表示它们属于游戏内容本身。这类题目不会给答案,你需要完全自己做。从现在开始,这种练习会越来越多,因为我们已经正式进入游戏内容阶段,而这部分很大比例的工作,本来就是手动把内容一点点加进去。
92. (CONTENT) Add 7 more ship types. To add a new ship type, simply add another conditional elseif self.ship == 'ShipName' then to both the polygon definition and the trail definition. Here's what the ships I made look like (but obviously feel free to be 100% creative and do your own designs):
92. (CONTENT) 再额外添加 7 种飞船类型。要新增一种飞船,只需要在 polygon 定义和 trail 定义这两处都再补一个 elseif self.ship == 'ShipName' then 分支即可。下面是我自己做的飞船造型(当然你完全可以不参考,直接 100% 按自己的想法设计):
