BYTEPATH #8 - Enemies
Introduction
In this article we'll go over the creation of a few enemies as well as the EnemyProjectile class, which is a projectile that some enemies can shoot to hurt the player. This article will be a bit shorter than others since we won't focus on creating all enemies now, only the basic behavior that will be shared among most of them.
这一篇我们会来做几种敌人,以及 EnemyProjectile 类,也就是某些敌人会发射、用来伤害玩家的那种弹丸。和前几篇比起来,这一篇会稍微短一点,因为我们暂时不会把所有敌人一口气都做完,而是先把大多数敌人会共用的那部分基础行为搭起来。
Enemies
Enemies in this game will work in a similar way to how the resources we created in the last article worked, in the sense that they will be spawned at either left or right of the screen at a random y position and then they will slowly move inward. The code used to make that work will be exactly the same as the one used in each resource we implemented.
这个游戏里的敌人,出生方式和上一篇里做出来的各种资源很像:它们会在屏幕左边或右边的某个随机 y 位置生成,然后缓慢朝场内移动。用来实现这件事的代码,也和我们给资源对象写的那套几乎一模一样。
We'll get started with the first enemy which is called Rock. It looks like this:
我们先从第一个敌人开始,它叫 Rock,效果长这样:

The constructor code for this object will be very similar to the Boost one, but with a few small differences:
这个对象的构造函数和 Boost 很像,不过会有几个小差别:
function Rock:new(area, x, y, opts)
Rock.super.new(self, area, x, y, opts)
local direction = table.random({-1, 1})
self.x = gw/2 + direction*(gw/2 + 48)
self.y = random(16, gh - 16)
self.w, self.h = 8, 8
self.collider = self.area.world:newPolygonCollider(createIrregularPolygon(8))
self.collider:setPosition(self.x, self.y)
self.collider:setObject(self)
self.collider:setCollisionClass('Enemy')
self.collider:setFixedRotation(false)
self.v = -direction*random(20, 40)
self.collider:setLinearVelocity(self.v, 0)
self.collider:applyAngularImpulse(random(-100, 100))
endHere instead of the object using a RectangleCollider, it will use a PolygonCollider. We create the vertices of this polygon with the function createIrregularPolygon which will be defined in utils.lua. This function should return a list of vertices that make up an irregular polygon. What I mean by an irregular polygon is one that is kinda like a circle but where each vertex might be a bit closer or further away from the center, and where the angles between each vertex may be a little random as well.
这里和资源对象最明显的区别是:它不再用 RectangleCollider,而是改用 PolygonCollider。这个多边形的顶点会通过 createIrregularPolygon 这个函数来生成,函数放在 utils.lua 里。它需要返回一组顶点,拼出一个“不规则多边形”。所谓不规则,就是它整体看上去有点像圆,但每个顶点到圆心的距离会有一点随机偏差,顶点之间的夹角也会稍微带一点随机性。
To start with the definition of that function we can say that it will receive two arguments: size and point_amount. The first will refer to the radius of the circle and the second will refer to the number of points that will compose the polygon:
先来定义这个函数。它可以接收两个参数:size 和 point_amount。前者表示这个形状大致的半径,后者表示构成多边形的点数:
function createIrregularPolygon(size, point_amount)
local point_amount = point_amount or 8
endHere we also say that if point_amount isn't defined it will default to 8.
这里顺便规定一下:如果没有传 point_amount,那它默认就是 8。
The next thing we can do is start defining all the points. This can be done by going from 1 to point_amount and in each iteration defining the next vertex based on an angle interval. So, for instance, to define the position of the second point we can say that its angle will be somewhere around 2*angle_interval, where angle_interval is the value 2*math.pi/point_amount. So in this case it would be around 90 degrees. This probably makes more sense in code, so:
接下来就可以开始真正生成所有顶点了。做法是从 1 遍历到 point_amount,每轮根据一个角度间隔去确定下一个顶点的位置。比如说,要确定第二个点,就可以让它的角度大致落在 2*angle_interval 附近,其中 angle_interval 就是 2*math.pi/point_amount。换算一下,大概就是围绕着 90 度附近取值。代码看起来会更直观一些:
function createIrregularPolygon(size, point_amount)
local point_amount = point_amount or 8
local points = {}
for i = 1, point_amount do
local angle_interval = 2*math.pi/point_amount
local distance = size + random(-size/4, size/4)
local angle = (i-1)*angle_interval + random(-angle_interval/4, angle_interval/4)
table.insert(points, distance*math.cos(angle))
table.insert(points, distance*math.sin(angle))
end
return points
endAnd so here we define angle_interval like previously explained, but we also define distance as being around the radius of the circle, but with a random offset between -size/4 and +size/4. This means that each vertex won't be exactly on the edge of the circle but somewhere around it. We also randomize the angle interval a bit to create the same effect. Finally, we add both x and y components to a list of points which is then returned. Note that the polygon is created in local space (assuming that the center is 0, 0), which means that we have to then use setPosition to place the object in its proper place.
这里我们按照前面说的方式先算出 angle_interval,同时还定义了一个 distance,它会在原本半径附近上下浮动,范围是 -size/4 到 +size/4。也就是说,每个顶点不会规规矩矩地落在圆周上,而是会围绕圆周略微内缩或外扩。角度本身也做了轻微随机,目的也是一样。最后,把每个顶点的 x、y 分量依次塞进 points 列表并返回。这里还得注意一点:这些顶点是按局部坐标生成的,也就是默认圆心在 (0, 0)。所以之后还需要调用 setPosition 把整个对象摆到正确的位置上。
Another difference in the constructor of this object is the use of the Enemy collision class. Like all other collision classes, this one should be defined before it can be used:
这个对象构造函数里的另一个区别,是它用到了 Enemy 这个碰撞类。和别的碰撞类一样,它也得先定义好,才能拿来用:
function Stage:new()
...
self.area.world:addCollisionClass('Enemy')
...
endIn general, new collision classes should be added for object types that will have different collision behaviors between each other. For instance, enemies will physically ignore the player but will not physically ignore projectiles. Because no other object type in the game follows this behavior, it means we need to add a new collision class to do it. If the Projectile collision class only ignored the player instead of also ignoring other projectiles, then enemies would be able to have their collision class be Projectile as well.
一般来说,只有当某种对象需要一套不同于其他对象的碰撞规则时,才值得给它单独加一个新的碰撞类。比如敌人会在物理上忽略玩家,但不会忽略弹丸。游戏里其他对象并没有完全照着这套规则来,所以我们才需要单独定义一个 Enemy 碰撞类。反过来说,如果 Projectile 这个碰撞类只是忽略玩家,而不是连别的弹丸也一起忽略,那么敌人甚至可以直接复用 Projectile 这个类。
The last thing about the Rock object is how its drawn. Since it's just a polygon we can simply draw its points using love.graphics.polygon:
关于 Rock 的最后一件事,是它该怎么画出来。因为它本质上就是一个多边形,所以直接用 love.graphics.polygon 把顶点画出来就行:
function Rock:draw()
love.graphics.setColor(hp_color)
local points = {self.collider:getWorldPoints(self.collider.shapes.main:getPoints())}
love.graphics.polygon('line', points)
love.graphics.setColor(default_color)
endWe get its points first by using PolygonShape:getPoints. These points are returned in local coordinates, but we want global ones, so we have to use Body:getWorldPoints to convert from local to global coordinates. Once that's done we can just draw the polygon and it will behave like expected. Note that because we're getting points from the collider directly and the collider is a polygon that is rotating around, we don't need to use pushRotate to rotate the object like we did for the Boost object since the points we're getting are already accounting for the objects rotation.
首先通过 PolygonShape:getPoints 拿到多边形的点。这些点返回时还是局部坐标,但我们真正需要的是世界坐标,所以还得再用 Body:getWorldPoints 把它们转换过去。做完这一步之后,就可以直接把这个多边形画出来了,效果也会符合预期。这里还要注意:因为我们拿到的点本来就直接来自碰撞体,而这个碰撞体自己也在旋转,所以不需要像画 Boost 那样额外用 pushRotate 去给对象旋转,顶点本身已经把旋转算进去了。
If you do all this it should look like this:
把这些都接好之后,效果应该像这样:

Enemies Exercises
110. Perform the following tasks:
110. 完成下面这些任务:
- Add an attribute named
hpto the Rock class that initially is set to 100 - 给
Rock类加一个名为hp的属性,初始值设为 100 - Add a function named
hitto the Rock class. This function should do the following: - 给
Rock类加一个名为hit的函数,它要具备以下行为: - It should receives a
damageargument, and in case it isn't then it should default to 100 - 它接收一个
damage参数;如果没有传这个参数,则默认按 100 处理 damagewill be subtracted fromhpand ifhphits 0 or lower then the Rock object will diedamage会从hp中扣除;如果hp小于等于 0,则Rock对象死亡- If
hpdoesn't hit 0 or lower then an attribute namedhit_flashwill be set to true and then set to false 0.2 seconds later. In the draw function of the Rock object, wheneverhit_flashis set to true the color of the object will be set todefault_colorinstead ofhp_color. - 如果
hp还没降到 0 或以下,就把一个叫hit_flash的属性设成true,并在 0.2 秒后再改回false。同时,在Rock的绘制函数里,只要hit_flash为true,对象颜色就应当从hp_color临时切到default_color。
111. Create a new class named EnemyDeathEffect. This effect gets created whenever an enemy dies and it behaves exactly like the ProjectileDeathEffect object, except that it is bigger according to the size of the Rock object. This effect should be created whenever the Rock object's hp attribute hits 0 or lower.
111. 创建一个新类 EnemyDeathEffect。它会在敌人死亡时生成,行为和 ProjectileDeathEffect 完全一样,只不过尺寸要根据 Rock 的大小变大一些。当 Rock 的 hp 变成 0 或以下时,就应该生成这个效果。
112. Implement the collision event between an object of the Projectile collision class and an object of the Enemy collision class. In this case, implement it in the Projectile's class update function. Whenever the projectile hits an object of the Enemy class, it should call the enemy's hit function with the amount of damage this projectile deals (by default, projectiles will have an attribute named damage that is initially set to 100). Whenever a hit happens the projectile should also call its own die function.
112. 实现 Projectile 碰撞类与 Enemy 碰撞类之间的碰撞事件。这里要求你把逻辑写在 Projectile 类的 update 函数里。只要弹丸撞到 Enemy 类对象,就应该调用对方的 hit 函数,并把当前弹丸的伤害值传进去(默认情况下,弹丸会有一个名为 damage 的属性,初始值是 100)。同时,每次命中发生时,弹丸自己也要调用自己的 die 函数。
113. Add a function named hit to the Player class. This function should do the following things:
113. 给 Player 类添加一个 hit 函数,它需要完成以下事情:
- It should receive a
damageargument, and in case it isn't defined it should default to 10 - 它接收一个
damage参数,如果没传,则默认按 10 处理 - This function should not do anything whenever the
invincibleattribute is true - 当
invincible属性为true时,这个函数不应该产生任何效果 - Between 4 and 8
ExplodeParticleobjects should be spawned - 生成 4 到 8 个
ExplodeParticle对象 - The
addHP(orremoveHPfunction if you decided to add this one) should take thedamageattribute and use it to remove HP from the Player. Inside theaddHP(orremoveHP) function there should be a way to deal with the case wherehphits or goes below 0 and the player dies. addHP(或者如果你自己实现了removeHP,也可以在那里)应该根据damage去减少玩家 HP。在addHP(或removeHP)函数内部,还得能处理hp变成 0 或以下时玩家死亡的情况。
Additionally, the following conditional operations should hold:
另外,还需要满足下面这些条件分支:
- If the damage received is equal to or above 30, then the
invincibleattribute should be set to true and 2 seconds later it should be set to false. On top of that, the camera should shake with intensity 6 for 0.2 seconds, the screen should flash for 3 frames, and the game should be slowed to 0.25 for 0.5 seconds. Finally, aninvisibleattribute should alternate between true and false every 0.04 for the duration thatinvincibleis set to true, and additionally the Player's draw function shouldn't draw anything wheneverinvisibleis set to true. - 如果这次受到的伤害大于等于 30,那么应当把
invincible设为true,并在 2 秒后重新设回false。除此之外,相机要以强度 6 震动 0.2 秒;屏幕要闪白 3 帧;游戏时间要减速到 0.25,并持续 0.5 秒。最后,还要加一个invisible属性:在invincible持续的这段时间里,它每隔 0.04 秒就在true和false之间来回切换;同时当invisible为true时,Player的draw函数不应该绘制任何内容。 - If the damage received is below 30, then the camera should shake with intensity 6 for 0.1 seconds, the screen should flash for 2 frames, and the game should be slowed to 0.75 for 0.25 seconds.
- 如果受到的伤害小于 30,那么相机要以强度 6 震动 0.1 秒;屏幕闪 2 帧;游戏减速到 0.75,并持续 0.25 秒。
This hit function should be called whenever the Player collides with an Enemy. The player should be hit for 30 damage on enemy collision.
这个 hit 函数应该在 Player 与 Enemy 碰撞时被调用。默认情况下,玩家撞到敌人时要承受 30 点伤害。
After finishing these 4 exercises you should have completed everything needed for the interactions between Player, Projectile and Rock enemy to work like they should in the game. These interactions will hold true and be similar for other enemies as well. And it all should look like this:
把上面这 4 个练习做完之后,玩家、弹丸和 Rock 敌人之间应有的交互就基本齐了。后续其他敌人的交互模式也会大致沿用这一套。整体效果看起来应该像这样:

EnemyProjectile
So, now we can focus on another part of making enemies which is creating enemies that can shoot projectiles. A few enemies will be able to do that and so we need to create an object, like the Projectile one, but that is used by enemies instead. For this we'll create the EnemyProjectile object.
接下来我们来处理敌人系统里的另一部分:会发射弹丸的敌人。有些敌人具备这种能力,所以我们需要一个和 Projectile 很像、但专门给敌人使用的对象。这个对象就叫 EnemyProjectile。
This object can be created at first by just copypasting the code for the Projectile one and changing it slightly. Both these objects will share a lot of the same code. We could somehow abstract them out into a general projectile-like object that has the common behavior, but that's really not necessary since these are the only two types of projectiles the game will have. After the copypasting is done the things we have to change are these:
最开始,你完全可以直接把 Projectile 的代码复制一份,再稍微改一改来得到它。因为这两个对象本来就会共享大量相同逻辑。你当然也可以再把它们抽象成一个更通用的“弹丸基类”,把公共行为放进去,但我觉得没必要,因为游戏里也就这两类弹丸。复制完之后,真正需要修改的地方主要是下面这些:
function EnemyProjectile:new(...)
...
self.collider:setCollisionClass('EnemyProjectile')
endThe collision class of an EnemyProjectile should also be EnemyProjectile. We want EnemyProjectiles objects to ignore other EnemyProjectiles, Projectiles and the Player. So we must add the collision class such that it fits that purpose:
EnemyProjectile 的碰撞类应该也叫 EnemyProjectile。我们希望敌人的弹丸忽略其他敌方弹丸、玩家自己的 Projectile,以及玩家本体。所以这个碰撞类的定义也要按这个目标去配:
function Stage:new()
...
self.area.world:addCollisionClass('EnemyProjectile',
{ignores = {'EnemyProjectile', 'Projectile', 'Enemy'}})
endThe other main thing we have to change is the damage. A normal projectile shot by the player deals 100 damage, but a projectile shot by an enemy should deal 10 damage:
另一个主要变化是伤害。玩家发射的普通弹丸默认造成 100 伤害,而敌人发射的弹丸应该只造成 10 伤害:
function EnemyProjectile:new(...)
...
self.damage = 10
endAnother thing is that we want projectiles shot by enemies to collide with the Player but not with other enemies. So we can take the collision code that the Projectile object used and just turn it around on the Player instead:
还有一点,我们希望敌方弹丸能撞到玩家,但不要去撞到别的敌人。所以可以把原本写在 Projectile 上的碰撞逻辑,反过来改成针对 Player:
function EnemyProjectile:update(dt)
...
if self.collider:enter('Player') then
local collision_data = self.collider:getEnterCollisionData('Player')
...
endFinally, we want this object to look completely red instead of half-red and half-white, so that the player can tell projectiles shot from an enemy to projectiles shot by himself:
最后,我们还希望这个对象的外观是彻底的红色,而不是像玩家弹丸那样一半红一半白。这样玩家一眼就能区分出:这是敌人打出来的,还是自己打出来的:
function EnemyProjectile:draw()
love.graphics.setColor(hp_color)
...
love.graphics.setColor(default_color)
endWith all these small changes we have successfully create the EnemyProjectile object. Now we need to create an enemy that will use it!
做完这些小改动之后,EnemyProjectile 就算基本成型了。接下来,我们就需要一个真正会使用它的敌人。
Shooter
This is what the Shooter enemy looks like:
Shooter 敌人长这样:

As you can see, there's a little effect and then after a projectile is fired. The projectile looks just like the player one, except it's all red.
你可以看到,它在发射前会先出现一个小小的蓄力效果,然后才打出弹丸。这个弹丸和玩家的很像,只不过通体都是红色的。
We can start making this enemy by copypasting the code from the Rock object. This enemy (and all enemies) will share the same property that they code from either left or right of then screen and then move inwards slowly, and since the Rock object already has that code taken care of we can start from there. Once that's done, we have to change a few things:
做这个敌人时,我们同样可以从 Rock 的代码直接复制起步。因为它和其他敌人一样,都有一个共同特性:从屏幕左右生成,然后缓慢朝场内移动。而 Rock 已经把这部分逻辑写好了,所以直接拿来改最省事。改动点主要有下面这些:
function Shooter:new(...)
...
self.w, self.h = 12, 6
self.collider = self.area.world:newPolygonCollider(
{self.w, 0, -self.w/2, self.h, -self.w, 0, -self.w/2, -self.h})
endThe width, height and vertices of the Shooter enemy are different from the Rock. With the rock we just created an irregular polygon, but here we need the enemy to have a well defined and pointy shape so that the player can instinctively tell where it will come from. The setup here for the vertices is similar to how we did it for designing ships, so if you want you can change the way this enemy looks and make it look cooler.
Shooter 的宽高和顶点形状都和 Rock 不一样。Rock 用的是一个不规则多边形,但这里我们需要它是一个轮廓清晰、带尖头的造型,好让玩家下意识地知道它的前方朝哪里。这里定义顶点的方式和我们之前设计飞船轮廓时很像,所以如果你愿意,也完全可以自己改成更酷的样子。
function Shooter:new(...)
...
self.collider:setFixedRotation(false)
self.collider:setAngle(direction == 1 and 0 or math.pi)
self.collider:setFixedRotation(true)
endThe other thing we need to change is that unlike the rock, setting the object's velocity is not enough. We must also set its angle so that physics collider points in the right direction. To do this we first need to disable its fixed rotation (otherwise setting the angle won't work), change the angle, and then set its fixed rotation back to true. The rotation is set to fixed again because we don't want the collider to spin around if something hits it, we want it to remain pointing to the direction it's moving towards.
另一个需要改的点是:和 Rock 不同,Shooter 仅仅设置速度还不够。我们还得把它本身的朝向也设好,让物理碰撞体真正朝着移动方向指过去。为此,要先临时关掉 fixed rotation(否则直接改角度不会生效),调整好角度,再把 fixed rotation 打开。之所以最后还要重新固定旋转,是因为我们不希望它被碰撞之后乱转,我们希望它始终保持朝向自己的前进方向。
The line direction == 1 and math.pi or 0 is basically how you do a ternary operator in Lua. In other languages this would look like (direction == 1) ? math.pi : 0. The exercises back in article 2 and 4 I think went over this in detail, but essentially what will happen is that if direction is 1 (coming from the right and pointing to the left) then the first conditional will parse to true, which leaves us with true and math.pi or 0. Because of precedence between and and or, true and math.pi will go first, which leaves us with math.pi or 0, which will return math.pi, since or returns the first element whenever both are true. On the other hand, if direction is -1, then the first conditional will parse to false and we'll have false and math.pi or 0, which will parse to false or 0, which will parse to 0, since or returns the second element whenever the first is false.
direction == 1 and math.pi or 0 这一行,本质上就是 Lua 里模拟三元运算符的常见写法。换成别的语言,大概相当于 (direction == 1) ? math.pi : 0。这个写法在第 2、4 篇的练习里应该都已经提过了。它的逻辑大致是:如果 direction 为 1,说明对象从右边来、朝左边飞,那么前半段条件为真,于是整个表达式会先变成 true and math.pi or 0。由于 and 和 or 的优先顺序,先算 true and math.pi,结果得到 math.pi,再变成 math.pi or 0,最终返回 math.pi。相反,如果 direction 是 -1,那么前半段条件是假,表达式变成 false and math.pi or 0,接着化简成 false or 0,最后返回 0。这正好符合我们想要的两个朝向。
With all this, we can spawn Shooter objects and they should look like this:
做完这些之后,Shooter 就已经可以正常生成了,效果大概像这样:

Now we need to create the pre-attack effect. Usually in most games whenever an enemy is about to attack something happens that tells the player that enemy is about to attack. Most of the time it's an animation, but it could also be an effect. In our case we'll use a simple "charging up" effect, where a bunch of particles are continually sucked into the point where the projectile will come from until the release happens.
接下来还要补上攻击前摇效果。通常在大多数游戏里,敌人准备攻击时都会先给玩家一个提示。很多时候这是一段动画,也可能只是某种视觉效果。这里我们准备用一个简单的“蓄力”效果:一堆粒子不断朝弹丸即将射出的那个点聚拢,直到真正发射。
At a high level this is how we'll do it:
从高层看,思路大概是这样:
function Player:new(...)
...
self.timer:every(random(3, 5), function()
-- spawn PreAttackEffect object with duration of 1 second
self.timer:after(1, function()
-- spawn EnemyProjectile
end)
end)
endSo this means that with an interval of between 3 and 5 seconds each Shooter enemy will shoot a new projectile. This will happen after the PreAttackEffect effect is up for 1 second.
这表示每个 Shooter 敌人都会以 3 到 5 秒之间的随机间隔发射一次弹丸。而真正开火之前,会先持续 1 秒钟的 PreAttackEffect。
The basic way effects like these that have to do with particles work is that, like with the trails, some type of particle will be spawned every frame or every other frame and that will make the effect work. In this case, we will spawn particles called TargetParticle. These particles will move towards a point we define as the target and then die after a duration or when they reach the point.
像这种依赖粒子的效果,基本思路和尾迹是一样的:每帧或者每隔一帧生成一些粒子,通过它们的连续叠加来形成视觉效果。这里我们会生成一种叫 TargetParticle 的粒子。它们会朝某个指定目标点移动,并在持续一段时间后、或者抵达目标点时死亡。
function TargetParticle:new(area, x, y, opts)
TargetParticle.super.new(self, area, x, y, opts)
self.r = opts.r or random(2, 3)
self.timer:tween(opts.d or random(0.1, 0.3), self,
{r = 0, x = self.target_x, y = self.target_y}, 'out-cubic', function() self.dead = true end)
end
function TargetParticle:draw()
love.graphics.setColor(self.color)
draft:rhombus(self.x, self.y, 2*self.r, 2*self.r, 'fill')
love.graphics.setColor(default_color)
endHere each particle will be tweened towards target_x, target_y over a d duration (or a random value between 0.1 and 0.3 seconds), and when that position is reached then the particle will die. The particle is also drawn as a rhombus (like one of the effects we made earlier), but it could be drawn as a circle or rectangle since it's small enough and gets smaller over the tween duration.
这里每个粒子都会在一个 d 时长内(默认随机在 0.1 到 0.3 秒之间),通过 tween 朝 target_x, target_y 移动。等 tween 结束,也就是它抵达目标点时,粒子就会死掉。绘制上,我们把它画成菱形,和之前做过的某些效果一样。不过因为它足够小,而且在 tween 过程中还会逐渐缩小,所以就算画成圆或者矩形,其实也都可以。
The way we create these objects in PreAttackEffect looks like this:
而在 PreAttackEffect 里,我们会这样生成这些粒子:
function PreAttackEffect:new(...)
...
self.timer:every(0.02, function()
self.area:addGameObject('TargetParticle',
self.x + random(-20, 20), self.y + random(-20, 20),
{target_x = self.x, target_y = self.y, color = self.color})
end)
endSo here we spawn one particle every 0.02 seconds (almost every frame) in a random location around its position, and then we set the target_x, target_y attributes to the position of the effect itself (which will be at the tip of the ship).
也就是说,我们每隔 0.02 秒,也就是几乎每一帧,都会在当前效果位置附近的随机区域生成一个新粒子,然后把它的 target_x、target_y 都设成这个效果对象自己的位置,也就是飞船尖端所在的位置。
In Shooter, we create PreAttackEffect like this:
在 Shooter 里,创建 PreAttackEffect 的代码会是这样:
function Shooter:new(...)
...
self.timer:every(random(3, 5), function()
self.area:addGameObject('PreAttackEffect',
self.x + 1.4*self.w*math.cos(self.collider:getAngle()),
self.y + 1.4*self.w*math.sin(self.collider:getAngle()),
{shooter = self, color = hp_color, duration = 1})
self.timer:after(1, function()
end)
end)
endThe initial position we set should be at the tip of the Shooter object, and so we can use the general math.cos and math.sin pattern we've been using so far to achieve that and account for both possible angles (0 and math.pi). We also pass a duration attribute, which controls how long the PreAttackEffect object will stay alive for. Back there we can do this:
这里设定的位置应该是 Shooter 的尖端,所以继续沿用我们一直在用的 math.cos / math.sin 模式,就能同时兼容它可能出现的两个朝向(0 和 math.pi)。另外,我们还传了一个 duration 属性,用来控制 PreAttackEffect 要持续多久。对应地,在 PreAttackEffect 里可以这么写:
function PreAttackEffect:new(...)
...
self.timer:after(self.duration - self.duration/4, function() self.dead = true end)
endThe reason we don't use duration by itself here is because this object is what I call in my head a controller object. For instance, it doesn't have anything in its draw function, so we never actually see it in the game. What we see are the TargetParticle objects that it commands to spawn. Those objects have a random duration of between 0.1 and 0.3 seconds each, which means if we want the last particles to end right as the projectile is being shot, then this object has to die between 0.1 and 0.3 seconds earlier than its 1 second duration. As a general case thing I decided to make this 0.75 (duration - duration/4), but it could be another number that is closer to 0.9 instead.
这里没有直接用 duration,是因为我脑子里把这个对象当成一种“控制器对象”。它自己其实没有任何绘制内容,所以游戏里玩家根本看不见它。玩家真正看到的,是它命令生成出来的那些 TargetParticle。而这些粒子的存活时间本身就在 0.1 到 0.3 秒之间波动,这就意味着:如果我们希望最后一批粒子恰好在弹丸发射时结束,那 PreAttackEffect 自己就得比 1 秒完整时长更早一点死掉。这里我粗暴地取成了 0.75 秒,也就是 duration - duration/4,当然你也可以改成更接近 0.9 的别的数字。
In any case, if you run everything now it should look like this:
总之,如果你现在把这些都跑起来,效果应该像这样:

And this works well enough. But if you pay attention you'll notice that the target position of the particles (the position of the PreAttackEffect object) is staying still instead of following the Shooter. We can fix this in the same way we fixed the ShootEffect object for the player. We already have the shooter attribute pointing to the Shooter object that created the PreAttackEffect object, so we can just update PreAttackEffect's position based on the position of this shooter parent object:
这样其实已经够用了。但如果你仔细看,就会发现粒子最终汇聚的目标点,也就是 PreAttackEffect 的位置,是固定不动的,而不是跟着 Shooter 一起移动。解决办法和之前修正玩家 ShootEffect 的方法一模一样。因为我们已经通过 shooter 属性把创建者 Shooter 的引用传进来了,所以只要在 update 里根据这个父对象来刷新 PreAttackEffect 的位置就行:
function PreAttackEffect:update(dt)
...
if self.shooter and not self.shooter.dead then
self.x = self.shooter.x + 1.4*self.shooter.w*math.cos(self.shooter.collider:getAngle())
self.y = self.shooter.y + 1.4*self.shooter.w*math.sin(self.shooter.collider:getAngle())
end
endAnd so here every frame we're updating this objects position to be at the tip of the Shooter object that created it. If you run this it would look like this:
这样一来,每一帧都会把这个效果对象的位置更新到创建它的 Shooter 尖端上。运行之后的效果会像这样:

One important thing about the update code about is the not self.shooter.dead part. One thing that can happen when we reference objects within each other like this is that one object dies while another still holds a reference to it. For instance, the PreAttackEffect object lasts 0.75 seconds, but between its creation and its demise, the Shooter object that created it can be killed by the player, and if that happens problems can occur.
这里有一个非常重要的小细节,就是 not self.shooter.dead 这一段。像这种对象之间互相引用的写法,很容易出现一种情况:其中一个对象已经死了,另一个对象却还留着它的引用。比如 PreAttackEffect 会持续 0.75 秒,但在这 0.75 秒里,创造它的那个 Shooter 有可能被玩家先一步打死。一旦发生这种事,就可能出问题。
In this case the problem is that we have to access the Shooter's collider attribute, which gets destroyed whenever the Shooter object dies. And if that object is destroyed we can't really do anything with it because it doesn't exist anymore, so when we try to getAngle it that will crash our game. We could work out a general system that solves this problem but I don't really think that's necessary. For now we should just be careful whenever we reference objects like this to make sure that we don't access objects that might be dead.
在这个例子里,问题就出在我们还要访问 Shooter 的 collider。而 Shooter 一旦死亡,这个碰撞体通常也会被销毁。东西都没了,你自然就不能再对它调用 getAngle,否则游戏就会直接崩。你当然可以再专门设计一套通用机制来处理这种“引用对象已死”的情况,但我觉得没有必要。现在最现实的做法,就是只要对象之间有这种引用关系,就时刻小心别去访问可能已经死掉的对象。
Now for the final part, which is the one where we create the EnemyProjectile object. For now we'll handle this relatively simply by just spawning it like we would spawn any other object, but with some specific attributes:
接下来就是最后一块,也就是实际发射 EnemyProjectile 的部分。这里先用最直接的方式处理:像生成别的对象那样把它生成出来,只不过附带一些特定属性:
function Shooter:new(...)
...
self.timer:every(random(3, 5), function()
...
self.timer:after(1, function()
self.area:addGameObject('EnemyProjectile',
self.x + 1.4*self.w*math.cos(self.collider:getAngle()),
self.y + 1.4*self.w*math.sin(self.collider:getAngle()),
{r = math.atan2(current_room.player.y - self.y, current_room.player.x - self.x),
v = random(80, 100), s = 3.5})
end)
end)
endHere we create the projectile at the same position that we created the PreAttackEffect, and then we set its velocity to a random value between 80 and 100, and then its size to be slightly larger than the default value. The most important part is that we set its angle (r attribute) to point towards the player. In general, whenever you want something to get the angle of something from source to target, you should do:
这里我们把弹丸生成在和 PreAttackEffect 一样的位置,再给它一个 80 到 100 之间的随机速度,并让它的尺寸稍微比默认值大一点。最关键的一点是:我们把它的角度,也就是 r 属性,设成指向玩家。更一般地说,只要你想求一个东西从 source 指向 target 的角度,公式基本就是:
angle = math.atan2(target.y - source.y, target.x - source.x)And that's what we're doing here. After the object is spawned it will point itself towards the player and move there. It should look like this:
这里我们做的正是这件事。对象一生成出来,就会朝向玩家并沿那个方向飞出去。效果应该像这样:

If you compare this to the initial gif on this section this looks a bit different. The projectiles there have a period where they slowly turn towards the player rather than coming out directly towards him. This uses the same piece of code as the homing passive that we will add eventually, so I'm gonna leave that for later.
如果你拿现在这个效果和本节最开头的 gif 比,就会发现还是有一点差别。最开始那个版本里,弹丸并不是一出膛就直接朝向玩家,而是会先慢慢转过去。那种行为和我们之后要做的 homing 被动用的是同一段技术,所以我打算留到后面再讲。
One thing that will happen for the EnemyProjectile object is that eventually it will be filled with lots of functionality so that it can serve the purposes of lots of different enemies. All this functionality, however, will first be implemented in the Projectile object because it will serve as a passive to the player. So, for instance, there's a passive that makes projectiles circle around the player. Once we implement that, we can copypaste that code to the EnemyProjectile object and then implement an enemy that makes use of that idea and has projectiles that circle it instead. A number of enemies will be created in this way so those will be left as an exercise for when we implement passives for the player.
EnemyProjectile 之后还会不断长出更多功能,因为它最终要服务很多不同类型的敌人。不过这些功能基本都会先在玩家自己的 Projectile 上实现,因为它们通常会先作为玩家的某种被动出现。比如说,之后会有一个被动,让弹丸围着玩家打转。等那部分完成之后,我们就可以把对应代码复制到 EnemyProjectile 里,再做一个利用这套机制的敌人,让它自己的弹丸围着它转。后面会有不少敌人都是这样来的,所以它们会留到玩家被动系统那部分作为练习去做。
For now, we'll stay with those two enemies (Rock and Shooter) and with the EnemyProjectile object as it is and move on to other things, but we'll come back to create more enemies in the future as we add more functionality to the game.
所以,现阶段我们就先停在这两个敌人,也就是 Rock 和 Shooter,以及当前这个版本的 EnemyProjectile。接下来会先去做别的系统,等游戏功能继续扩展之后,我们还会回来补更多敌人。
EnemyProjectile/Shooter Exercises
114. Implement a collision event between Projectile and EnemyProjectile. In the EnemyProjectile class, make it so that whenever it hits an object of the Projectile class, both object's die function will be called and they will both be destroyed.
114. 实现 Projectile 和 EnemyProjectile 之间的碰撞事件。在 EnemyProjectile 类里处理这件事:只要它撞到 Projectile 类对象,就让双方都调用自己的 die 函数,并一起被销毁。
115. Is the way the direction attribute is named confusing in the Shooter class? If so, what could it be named instead? If not, how is it not?
115. Shooter 类里 direction 这个属性的命名会不会让人困惑?如果会,那你觉得它更适合叫什么名字?如果不会,请说明为什么你觉得它并不混乱。