跳转至内容

BYTEPATH #9 - Director and Gameplay Loop

Introduction

In this article we'll finish up the basic implementation of the entire game with a minimal amount of content. We'll go over the Director, which is the code that will handle spawning of enemies and resources. Then we'll go over restarting the game once the player dies. And after that we'll take care of a basic score system as well as some basic UI so that the player can tell what his stats are.

这一篇里,我们会在只加入最少量内容的前提下,把整款游戏最基础的玩法框架补完整。首先会讲 Director,也就是负责生成敌人和资源的那部分代码。接着会处理玩家死亡后如何重开游戏。再之后,我们还会补上一个最基础的分数系统,以及一些简单 UI,让玩家能看清自己当前的状态。

Director

The Director is the piece of code that will control the creation of enemies, attacks and resources in the game. The goal of the game is to survive as long as possible and get as high a score as possible, and the challenge comes from the ever increasing number and difficulty of enemies that are spawned. This difficulty will be controlled entirely by the code that we will start writing now.

Director 是游戏里负责控制敌人、攻击资源和普通资源生成节奏的那段代码。这个游戏的目标很简单,就是尽可能活得久、尽可能拿高分,而挑战性主要来自敌人数量和敌人强度会不断提高。至于难度该怎样递增,就完全由我们接下来要写的这套代码来掌控。

The rules of that the director will follow are somewhat simple:

Director 需要遵循的规则其实不复杂:

  1. Every 22 seconds difficulty will go up;

  2. 每过 22 秒,难度提升一级;

  3. In the duration of each difficulty enemies will be spawned based on a point system:

  4. 在每个难度阶段内部,敌人的生成会遵循一套点数系统:

    • Each difficulty (or round) has a certain amount of points available to be used;
    • 每个难度阶段(也可以叫 round)都会有一笔可用点数;
    • Enemies cost a fixed amount of points (harder enemies cost more);
    • 每种敌人都要消耗固定点数,越难的敌人消耗越多;
    • Higher difficulties have a higher amount of points available;
    • 难度越高,可用点数越多;
    • Enemies are chosen to be spawned along the round's duration randomly until it runs out of points.
    • 在这个 round 的持续时间里,会随机挑选敌人进行生成,直到点数被花完。
  5. Every 16 seconds a resource (HP, SP or Boost) will be spawned;

  6. 每过 16 秒,会生成一个资源(HP、SP 或 Boost);

  7. Every 30 seconds an attack will be spawned.

  8. 每过 30 秒,会生成一个攻击资源。

We'll start by creating the Director object, which is just a normal object (not one that inherits from GameObject to be used in an Area) where we'll place our code:

我们先来创建 Director 对象。它只是一个普通对象,不会继承 GameObject,也不会被塞进 Area 里。它只是单纯用来承载这部分控制逻辑:

lua
Director = Object:extend()

function Director:new(stage)
    self.stage = stage
end

function Director:update(dt)
  
end

We can create this and then instantiate it in the Stage room like this:

接着,在 Stage Room 里把它实例化出来:

lua
function Stage:new()
    ...
    self.director = Director(self)
end

function Stage:update(dt)
    self.director:update(dt)
    ...
end

We want the Director object to have a reference to the Stage room because we'll need it to spawn enemies and resources, and the only way to do that is through stage.area. The director will also have timing needs so it will need to be updated accordingly.

我们让 Director 持有 Stage 的引用,是因为它之后要生成敌人和资源,而这些操作最终都得落到 stage.area 上。同时,Director 自己也有很多基于时间的逻辑,所以自然也得在 update 里持续更新。

To start with rule 1, we can just define a simple difficulty attribute and a few extra ones to handle the timing of when that attribute goes up. This timing code will be just like the one we did for the Player's boost or cycle mechanisms.

先来看规则 1。它最直接的实现方式,就是定义一个 difficulty 属性,再加几个辅助变量,专门控制它什么时候递增。这部分计时逻辑和我们之前做玩家 boost 或 cycle 时的写法是一个套路。

lua
function Director:new(...)
    ...

    self.difficulty = 1
    self.round_duration = 22
    self.round_timer = 0
end

function Director:update(dt)
    self.round_timer = self.round_timer + dt
    if self.round_timer > self.round_duration then
        self.round_timer = 0
        self.difficulty = self.difficulty + 1
        self:setEnemySpawnsForThisRound()
    end
end

And so difficulty goes up every 22 seconds, according to how we described rule 1. Additionally, here we also call a function called setEnemySpawnsForThisRound, which is essentially where rule 2 will take place.

这样一来,difficulty 就会每 22 秒提升一次,和规则 1 一致。除此之外,每次难度提升时,我们还会调用一个叫 setEnemySpawnsForThisRound 的函数,而规则 2 的主要逻辑就会放在那里。

The first part of rule 2 is that every difficulty has a certain amount of points to spend. The first thing we need to figure out here is how many difficulties we want the game to have and if we want to define all these points manually or through some formula. I decided to do the later and say that the game essentially is infinite and gets harder and harder until the player won't be able to handle it anymore. So for the this purpose I decided that the game would have 1024 difficulties since it's a big enough number that it's very unlikely anyone will hit it.

规则 2 的第一部分,是每个难度阶段都有一笔可支配的点数。这里首先要想清楚两件事:游戏到底要有多少个难度层级?这些点数是手动一一写死,还是通过公式生成?我最后选的是后者,也就是让游戏本质上趋近于“无限难度”,直到玩家再也扛不住为止。为了实现这个目的,我就干脆定义了 1024 个难度层级。这个数字已经大得离谱,几乎不可能真有人打到头。

The way the amount of points each difficulty has will be define through a simple formula that I arrived at through trial and error seeing what felt best. Again, this kind of stuff is more on the design side of things so I don't want to spend much time on my reasoning, but you should try your own ideas here if you feel like you can do something better.

每个难度该给多少点数,我最后是通过一个比较简单的公式来定的。这个公式本身主要是靠反复试出来的,怎么手感最好就怎么来。这类内容已经比较偏游戏设计了,所以我不打算把自己的推导过程讲太细。如果你觉得你能设计出更好的曲线,完全可以自己改。

The way I decided to do is was through this formula:

我最后采用的是下面这套规则:

  • Difficulty 1 has 16 points;
  • Difficulty 1 拥有 16 点;
  • From difficulty 2 onwards the following formula is followed on a 4 step basis:
  • 从 difficulty 2 开始,按 4 个一组的节奏套用下面这个公式:
    • Difficulty i has difficulty i-1 points + 8
    • Difficulty i 的点数 = difficulty i-1 的点数 + 8
    • Difficulty i+1 has difficulty i points
    • Difficulty i+1 的点数 = difficulty i 的点数
    • Difficulty i+2 has difficulty (i+1)/1.5
    • Difficulty i+2 的点数 = difficulty i+1 的点数除以 1.5
    • Difficulty i+3 has difficulty (i+2)*2
    • Difficulty i+3 的点数 = difficulty i+2 的点数乘以 2

In code that looks like this:

写成代码就是这样:

lua
function Director:new(...)
    ...
  
    self.difficulty_to_points = {}
    self.difficulty_to_points[1] = 16
    for i = 2, 1024, 4 do
        self.difficulty_to_points[i] = self.difficulty_to_points[i-1] + 8
        self.difficulty_to_points[i+1] = self.difficulty_to_points[i]
        self.difficulty_to_points[i+2] = math.floor(self.difficulty_to_points[i+1]/1.5)
        self.difficulty_to_points[i+3] = math.floor(self.difficulty_to_points[i+2]*2)
    end
end

And so, for instance, for the first 14 difficulties the amount of points they will have looks like this:

比如说,前 14 个难度对应的点数大概会是这样:

lua
Difficulty - Points
1 - 16
2 - 24
3 - 24
4 - 16
5 - 32
6 - 40
7 - 40
8 - 26
9 - 56
10 - 64
11 - 64
12 - 42
13 - 84

And so what happens is that at first there's a certain level of points that lasts for about 3 rounds, then it goes down for 1 round, and then it spikes a lot on the next round that becomes the new plateau that lasts for ~3 rounds and then this repeats forever. This creates a nice "normalization -> relaxation -> intensification" loop that feels alright to play around.

这样一来,点数曲线会呈现出一种节奏:先在一个大致水平上维持三轮左右,然后稍微回落一轮,接着下一轮再猛地抬高,形成一个新的平台,再重复这个过程。玩起来会有一种“稳定一阵 -> 缓一下 -> 再加压”的循环节奏,我觉得手感还不错。

The way points increase also follows a pretty harsh and fast rule, such that at difficulty 40 for instance a round will be composed of around 400 points. Since enemies spend a fixed amount of points and each round must spend all points its given, the game quickly becomes overwhelming and so at some point players won't be able to win anymore, but that's fine since it's how we're designing the game and it's a game about getting the highest score possible essentially given these circumstances.

而且这套点数增长本身是比较凶的。比如到 difficulty 40 左右时,一个 round 大概就已经会有 400 点上下。由于每种敌人都要消耗固定点数,而每个 round 又必须把点数花完,所以游戏很快就会变得压迫感十足,最终玩家肯定会被淹没。不过这正是我们想要的设计结果:它本来就是一款让你在注定越来越扛不住的环境里尽量刷高分的游戏。

Now that we have this sorted we can try to go for the second part of rule 2, which is the definition of how much each enemy should cost. For now we only have two enemies implemented so this is rather trivial, but we'll come back to fill this out more in another article after we've implemented more enemies. What it can look like now is this though:

点数规模定下来之后,就可以继续处理规则 2 的第二部分,也就是“每种敌人到底值多少点”。目前游戏里只做了两种敌人,所以这部分非常简单。不过等以后敌人种类变多了,我们还会回来继续补。现在先这么写就够了:

lua
function Director:new(...)
    ...
    self.enemy_to_points = {
        ['Rock'] = 1,
        ['Shooter'] = 2,
    }
end

This is a simple table where given an enemy name, we'll get the amount of points it costs to spawn it.

这就是一张很简单的映射表:给你一个敌人名字,就能查到生成它要花多少点。

The last part of rule 2 has to do with the implementation of the setEnemySpawnsForThisRound function. But before we get to that I have to introduce a very important construct we'll use throughout the game whenever chances and probabilities are involved.

规则 2 的最后一部分,就落在 setEnemySpawnsForThisRound 这个函数上了。不过在正式写它之前,我得先介绍一个在这个项目里会反复出现的重要小工具。以后凡是涉及概率、几率的地方,都会用到它。

ChanceList

Let's say you want X to happen 25% of the time, Y to happen 25% of the time and Z to happen 50% of the time. The normal way you'd do this is just use a function like love.math.random, have it generate a value between 1 and 100 and then see where this number lands. If it lands below 25 we say that X event will happen, if it lands between 25 and 50 we say that Y event will happen, and if it lands above 50 then Z event will happen.

假设你希望事件 X 有 25% 概率发生,Y 也有 25%,而 Z 有 50%。最常见的写法,当然就是直接用 love.math.random 生成一个 1 到 100 之间的随机数,然后看它落在哪个区间里。如果小于 25,就判定 X 发生;在 25 到 50 之间,就判定 Y;大于 50,则判定 Z。

The big problem with doing things this way though is that we can't ensure that if we run love.math.random 100 times, X will happen actually 25 times, for instance. If we run it 10000 times maybe it will approach that 25% probability, but often times we want to have way more control over the situation than that. So a simple solution is to create what I call a chanceList.

但这种做法有个很大的问题:你没法保证在正好调用 100 次 love.math.random 时,X 就一定刚好发生 25 次。如果你跑 10000 次,它也许会慢慢逼近那个比例,可很多时候我们希望对结果有更强的控制力。所以这里我会用一个我自己叫做 chanceList 的结构来解决这个问题。

The way chanceLists work is that you generate a list with values between 1 and 100. Then whenever you want to get a random value on this list you call a function called next. This function will give you a random number in it, let's say it gives you 28. This means that Y event happened. The difference is that once we call that function, we will also remove the random number chosen from the list. This essentially means that 28 can never happen again and that event Y now has a slightly lower chance of happening than the other 2 events. As we call next more and more, the list will get more and more empty and then when it gets completely empty we just regenerate the 100 numbers again.

chanceList 的思路是这样的:先生成一张列表,里面放好总共 100 个代表概率权重的值。然后每次你想随机取一个结果时,就调用一个叫 next 的函数。假设这次它抽到了 28,那么就说明这次应该触发 Y。和普通随机最大的区别在于:一旦这个值被抽出来,它会立刻从列表里移除。也就是说,28 以后不会再被抽到,Y 的概率也会相对另外两个事件轻微下降。随着你不断调用 next,列表会越来越空;等到彻底空掉之后,再重新生成这 100 个值。

In this way, we can ensure that event X will happen exactly 25 times, that event Y will happen exactly 25 times, and that event Z will happen exactly 50 times. We can also make it so that instead of it generating 100 numbers, it will generate 20 instead. And so in that case event X would happen 5 times, Y would happen 5 times, and Z would happen 10 times.

这样一来,我们就能保证在一轮完整消耗完之后,X 正好会发生 25 次,Y 也正好 25 次,而 Z 正好 50 次。你甚至还可以不必固定成 100 个元素,而是只生成 20 个。那样的话,X 和 Y 就会各发生 5 次,Z 发生 10 次,比例依然是对的。

The way the interface for this idea works is rather simple looks like this:

这个结构的用法会非常简单,大概像这样:

lua
events = chanceList({'X', 25}, {'Y', 25}, {'Z', 50})
for i = 1, 100 do
    print(events:next()) --> will print X 25 times, Y 25 times and Z 50 times
end
lua
events = chanceList({'X', 5}, {'Y', 5}, {'Z', 10})
for i = 1, 20 do
    print(events:next()) --> will print X 5 times, Y 5 times and Z 10 times
end
lua
events = chanceList({'X', 5}, {'Y', 5}, {'Z', 10})
for i = 1, 40 do
    print(events:next()) --> will print X 10 times, Y 10 times and Z 20 times
end

We will create the chanceList function in utils.lua and we will make use of some of Lua's features in this that we covered in tutorial 2. Make sure you're up to date on that!

我们会把 chanceList 这个函数写在 utils.lua 里,而在实现过程中也会用到一些我们在第 2 篇已经讲过的 Lua 特性。所以如果那部分你还不太熟,最好先补一下。

The first thing we have to realize is that this function will return some kind of object that we should be able to call the next function on. The easiest way to achieve that is to just make that object a simple table that looks like this:

首先要意识到一点:这个函数最终要返回某种“对象”,而我们希望能在它身上调用 next。最简单的办法,就是直接返回一张带函数的普通 table,大概长这样:

lua
function chanceList(...)
    return {
        next = function(self)

        end
    }
end

Here we are receiving all the potential definitions for values and chances as ... and we'll handle those in more details soon. Then we're returning a table that has a function called next in it. This function receives self as its only argument, since as we know, calling a function using : passes itself as the first argument. So essentially, inside the next function, self refers to the table that chanceList is returning.

这里,... 接收的是一组“值 + 权重”的定义,具体怎么处理它们我们马上会讲。现在先关注返回值:我们返回了一张表,表里带着一个叫 next 的函数。因为之后会用 : 调它,所以 self 会自动作为第一个参数传进去。换句话说,在 next 函数内部,self 指的就是 chanceList 返回出来的那张表。

Before defining what's inside the next function, we can define a few attributes that this table will have. The first is the actual chance_list one, which will contain the values that should be returned by next:

在真正实现 next 之前,我们可以先给这张表定义几个属性。第一个就是实际承载抽签内容的 chance_list

lua
function chanceList(...)
    return {
    	chance_list = {},
        next = function(self)

        end
    }
end

This table starts empty and will be filled in the next function. In this example, for instance:

这张表一开始是空的,之后会在 next 里被填充。比如像下面这种定义:

lua
events = chanceList({'X', 3}, {'Y', 3}, {'Z', 4})

The chance_list attribute would look something like this:

对应生成出来的 chance_list 大概会长这样:

lua
.chance_list = {'X', 'X', 'X', 'Y', 'Y', 'Y', 'Z', 'Z', 'Z', 'Z'}

The other attribute we'll need is one called chance_definitions, which will hold all the values and chances passed in to the chanceList function:

另一个需要的属性叫 chance_definitions,它用来保存传进 chanceList 的那些“值 + 概率定义”本体:

lua
function chanceList(...)
    return {
    	chance_list = {},
    	chance_definitions = {...},
        next = function(self)

        end
    }
end

And that's all we'll need. Now we can move on to the next function. The two behaviors we want out of that function is that it returns us a random value according to the chances described in chance_definitions, and also that it regenerates the internal chance_list whenever it reaches 0 elements. Assuming that the list is filled with elements we can take care of the former behavior like this:

这两个属性基本就够了。接下来就可以正式写 next。它需要完成两件事:第一,按 chance_definitions 里规定的权重随机返回一个值;第二,当内部 chance_list 被抽空时,自动重新生成。先假设 chance_list 已经装满,我们可以先把“随机抽一个值并返回”这部分写成这样:

lua
next = function(self)
    return table.remove(self.chance_list, love.math.random(1, #self.chance_list))
end

We simply pick a random element inside the chance_list table and then return it. Because of the way elements are laid out inside, all the constraints we had about how this should work are being followed.

这里做的事情非常直接:从 chance_list 里随机挑一个元素,然后把它移除并返回。因为 chance_list 里的元素本来就是按权重铺好的,所以这种抽法天然就满足了我们想要的比例控制。

Now for the most important part, how we'll actually build the chance_list table. It turns out that we can use the same piece of code to build this list initially as well as whenever it gets emptied after repeated uses. The way this looks is like this:

接下来才是最关键的一部分:怎么真正把 chance_list 填出来。好在,无论是第一次初始化,还是之后被抽空后重新补满,这两件事其实可以共用同一段代码:

lua
next = function(self)
    if #self.chance_list == 0 then
        for _, chance_definition in ipairs(self.chance_definitions) do
      	    for i = 1, chance_definition[2] do 
                table.insert(self.chance_list, chance_definition[1]) 
      	    end
    	end
    end
    return table.remove(self.chance_list, love.math.random(1, #self.chance_list))
end

And so what we're doing here is first figuring out if the size of chance_list is 0. This will be true whenever we call next for the first time as well as whenever the list gets emptied after we called it multiple times. If it is true, then we start going over the chance_definitions table, which contains tables that we call chance_definition with the values and chances for that value. So if we called the chanceList function like this:

这里的逻辑是:先检查 chance_list 的长度是不是 0。第一次调用 next 时,这肯定成立;而以后每次列表被抽空之后,它也会再次成立。只要条件满足,我们就遍历 chance_definitions。其中每一项都是一张小表,包含一个值和对应的权重。比如下面这个调用:

lua
events = chanceList({'X', 3}, {'Y', 3}, {'Z', 4})

The chance_definitions table looks like this:

它内部的 chance_definitions 就会是这样:

lua
.chance_definitions = {{'X', 3}, {'Y', 3}, {'Z', 4}}

And so whenever we go over this list, chance_definitions[1] refers to the value and chance_definitions[2] refers to the number of times that value appears in chance_list. Knowing that, to fill up the list we simply insert chance_definition[1] into chance_list chance_definition[2] times. And we do this for all tables in chance_definitions as well.

所以当我们遍历这个列表时,chance_definition[1] 表示具体值,而 chance_definition[2] 表示这个值在 chance_list 里要出现多少次。于是我们只需要把 chance_definition[1]chance_list 里插入 chance_definition[2] 次,并对每一项都做一遍,就能把整张列表铺满。

And so if we try this out now we can see that it works out:

照这样写完之后,试一下就能看到它确实按预期工作:

lua
events = chanceList({'X', 2}, {'Y', 2}, {'Z', 4})
for i = 1, 16 do
    print(events:next())
end

Director

Now back to the Director, we wanted to implement the last part of rule 2 which deals with the implementation of setEnemySpawnsForThisRound. The first thing we wanna do for this is to define the spawn chances of each enemy. Different difficulties will have different spawn chances and we'll want to define at least the first few difficulties manually. And then the following difficulties will be defined somewhat randomly since they'll have so many points that the player will get overwhelmed either way.

回到 Director。我们刚才留下来的,是规则 2 的最后一部分,也就是 setEnemySpawnsForThisRound 的实现。先要做的事情,是定义每种敌人的生成概率。不同难度下,敌人的出现权重会不一样。前面几档难度我会手动精细地写死,而后面那些难度,因为点数已经高到玩家无论如何都会被压爆,所以就让它们比较随意地随机生成也没什么问题。

So this is what the first few difficulties could look like:

比如前几个难度可以先写成这样:

lua
function Director:new(...)
    ...
    self.enemy_spawn_chances = {
        [1] = chanceList({'Rock', 1}),
        [2] = chanceList({'Rock', 8}, {'Shooter', 4}),
        [3] = chanceList({'Rock', 8}, {'Shooter', 8}),
        [4] = chanceList({'Rock', 4}, {'Shooter', 8}),
    }
end

These are not the final numbers but just an example. So in the first difficulty only rocks would be spawned, then in the second one shooters would also be spawned but at a lower amount than rocks, then in the third both would be spawned about the same, and finally in the fourth more shooters would be spawned than rocks.

这些数字并不是最终定稿,只是一个示例。按这个例子来说,difficulty 1 只会生成 Rock;difficulty 2 开始加入 Shooter,但数量比 Rock 少;difficulty 3 时两者差不多;到了 difficulty 4,Shooter 的权重就反过来比 Rock 更高了。

For difficulties past 5 until 1024 we can just assign somewhat random probabilities to each enemy like this:

而对于 difficulty 5 到 1024 之间的那些阶段,我们就可以直接给每种敌人一个比较随意的随机权重:

lua
function Director:new(...)
    ...
    for i = 5, 1024 do
        self.enemy_spawn_chances[i] = chanceList(
      	    {'Rock', love.math.random(2, 12)}, 
      	    {'Shooter', love.math.random(2, 12)}
    	)
    end
end

When we implement more enemies we will do the first 16 difficulties manually and after difficulty 17 we'll do it somewhat randomly. In general, a player with a completely filled skill tree won't be able to go past difficulty 16 that often so it's a good place to stop.

等以后敌人种类变多之后,我会把前 16 个 difficulty 都手动设计好,而从 difficulty 17 开始,就让它们随机一些。原因也很简单:一般来说,就算玩家技能树已经点满,也不太可能频繁打到 16 以后,所以把精调停在这里已经挺合适了。

Now for the setEnemySpawnsForThisRound function. The first thing we'll do is use create enemies in a list, according to the enemy_spawn_chances table, until we run out of points for this difficulty. This can look something like this:

接下来终于可以写 setEnemySpawnsForThisRound 了。第一步,就是根据 enemy_spawn_chances,不断把敌人名字塞进一个列表里,直到这一轮的点数被花完。代码可以像这样:

lua
function Director:setEnemySpawnsForThisRound()
    local points = self.difficulty_to_points[self.difficulty]

    -- Find enemies
    local enemy_list = {}
    while points > 0 do
        local enemy = self.enemy_spawn_chances[self.difficulty]:next()
        points = points - self.enemy_to_points[enemy]
        table.insert(enemy_list, enemy)
    end
end

And so with this, the local enemy_list table will be filled with Rock and Shooter strings according to the probabilities of the current difficulty. We put this inside a while loop that stops whenever the number of points left reaches 0.

这样一来,本地变量 enemy_list 里就会按当前难度设定好的权重,填满一串 RockShooter 字符串。我们把它放在一个 while 循环里,只要点数还没被花光,就继续往里面塞。

After this, we need to decide when in the 22 second duration of this round each one of those enemies inside the enemy_list table will be spawned. That could look something like this:

做完这一步之后,还要决定:在当前这个 22 秒的 round 里,这些敌人具体在什么时候出生。可以像下面这样处理:

lua
function Director:setEnemySpawnsForThisRound()
    ...
  
    -- Find enemies spawn times
    local enemy_spawn_times = {}
    for i = 1, #enemy_list do 
    	enemy_spawn_times[i] = random(0, self.round_duration) 
    end
    table.sort(enemy_spawn_times, function(a, b) return a < b end)
end

Here we make it so that each enemy in enemy_list has a random number of between 0 and round_duration assigned to it and stored in the enemy_spawn_times table. We further sort this table so that the values are laid out in order. So if our enemy_list table looks like this:

这里我们给 enemy_list 里的每个敌人都分配一个 0 到 round_duration 之间的随机时间,并把这些时间放进 enemy_spawn_times。然后再把这张表排序,好让它们从早到晚排列好。比如如果 enemy_list 长这样:

lua
.enemy_list = {'Rock', 'Shooter', 'Rock'}

Our enemy_spawn_times table would look like this:

那对应的 enemy_spawn_times 可能会变成这样:

lua
.enemy_spawn_times = {2.5, 8.4, 14.8}

Which means that a Rock would be spawned 2.5 seconds in, a Shooter would be spawned 8.4 seconds in, and another Rock would be spawned 14.8 seconds in since the start of the round.

也就是说,从这轮开始算起,2.5 秒时生成一个 Rock,8.4 秒时生成一个 Shooter,14.8 秒时再生成一个 Rock

Finally, now we have to actually set enemies to be spawned using the timer:after call:

最后,我们只需要把这些生成时间真正变成定时生成事件,也就是调用 timer:after

lua
function Director:setEnemySpawnsForThisRound()
    ...

    -- Set spawn enemy timer
    for i = 1, #enemy_spawn_times do
        self.timer:after(enemy_spawn_times[i], function()
            self.stage.area:addGameObject(enemy_list[i])
        end)
    end
end

And this should be pretty straightforward. We go over the enemy_spawn_times list and set enemies from the enemy_list to be spawned according to the numbers in the former. The last thing to do is to call this function once for when the game starts:

这部分就没什么特别的了。我们遍历 enemy_spawn_times,并根据这些时间,把 enemy_list 里对应的敌人安排好。最后还要记得在游戏一开始时手动调用一次这个函数:

lua
function Director:new(...)
    ...
    self:setEnemySpawnsForThisRound()
end

If we don't do this then enemies will only start spawning after 22 seconds. We can also add an Attack resource spawn at the start so that the player has the chance to swap his attack from the get go as well, but that's not mandatory. In any case, if you run everything now it should work like we intended!

如果不这么做,敌人就得等整整 22 秒之后才会第一次出现。你当然也可以顺手在开局时额外生成一个攻击资源,好让玩家从一开始就有机会换攻击,不过这不是必须的。总之,到这里为止跑起来的话,整体行为应该已经和我们的设计一致了。

This is where we'll stop with the Director for now but we'll come back to it in a future article after we have added more content to the game!

Director 暂时就先停在这里。等游戏里加进更多内容之后,我们还会在后面的文章里回来继续扩展它。

Director Exercises

116. (CONTENT) Implement rule 3. It should work just like rule 1, except that instead of the difficulty going up, either one of the 3 resources listed will be spawned. The chances for each resource to be spawned should follow this definition:

116. (CONTENT) 实现规则 3。它的写法和规则 1 很像,只不过这次不是让难度提升,而是生成三种资源中的某一种。资源的生成权重需要按下面这个定义来:

lua
function Director:new(...)
    ...
    self.resource_spawn_chances = chanceList({'Boost', 28}, {'HP', 14}, {'SkillPoint', 58})
end

117. (CONTENT) Implement rule 4. It should work just like rule 1, except that instead of the difficulty going up, a random attack is spawned.

117. (CONTENT) 实现规则 4。它同样和规则 1 的结构类似,只不过这次不是提升难度,而是随机生成一个攻击资源。

118. The while loop that takes care of finding enemies to spawn has one big problem: it can get stuck indefinitely in an infinite loop. Consider the situation where there's only one point left, for instance, and enemies that cost 1 point (like a Rock) can't be spawned anymore because that difficulty doesn't spawn Rocks. Find a general fix for this problem without changing the cost of enemies, the number of points in a difficulty, or without assuming that the probabilities of enemies being spawned will take care of it (making all difficulties always spawn low cost enemies like Rocks).

118. 那个负责挑选敌人的 while 循环有个大问题:它可能会无限卡死。比如说只剩下 1 点了,但这一难度又根本不会生成只花 1 点的敌人(比如 Rock),那它就可能永远转不出来。请在不修改敌人点数、不修改每个难度总点数、也不依赖“总给每个难度安排低消耗敌人”这种取巧前提的情况下,想一个通用修复方案。

Game Loop

Now for the game loop. What we'll do here is make sure that the player can play the game over and over by making it so that whenever the player dies it restarts another run from scratch. In the final game the loop will be a bit different, because after a playthrough you'll be thrown back into the Console room, but since we don't have the Console room ready now, we'll just restart a Stage one. This is also a good place to check for memory problems, since we'll be restarting the Stage room over and over after the game has been played thoroughly.

接下来讲游戏主循环层面的事情。这里我们要做的是,让玩家能够一局接一局地反复玩下去:只要玩家死亡,就从头再开一局。最终正式版里,这个循环会稍微不一样,因为玩家一局结束之后会被送回 Console Room;不过目前我们还没把 Console 做完,所以现在就先简单地重启一个新的 Stage。这同时也是个检查内存问题的好时机,因为我们会反复创建、销毁 Stage Room,如果哪里泄漏了,很容易在这里暴露出来。

Because of the way we structured things it turns out that doing this is incredibly simple. We'll do it by defining a finish function in the Stage class, which will take care of using gotoRoom to change to another Stage room. This function looks like this:

由于我们前面的结构搭得还算合理,这件事做起来会非常简单。办法就是在 Stage 类里定义一个 finish 函数,让它负责通过 gotoRoom 切到一个新的 Stage Room。代码如下:

lua
function Stage:finish()
    timer:after(1, function()
        gotoRoom('Stage')
    end)
end

gotoRoom will take care of destroying the previous Stage instance and creating the new one, so we don't have to worry about manually destroying objects here or there. The only one we have worry about is setting the player attribute in the Stage class to nil in its destroy function, otherwise the Player object won't be collected properly.

gotoRoom 本身就会帮我们销毁旧的 Stage 实例,再创建新的,所以这里不用再担心额外去手动清别的对象。唯一得留心的是:在 Stagedestroy 函数里,要把 player 这个属性设成 nil,否则 Player 对象可能没法被正确回收。

The finish function can be called whenever the player dies from the Player object itself:

这个 finish 函数可以直接在 Player 死亡时,从 Player 对象内部调用:

lua
function Player:die()
    ...
    current_room:finish()
end

We know that current_room is a global variable that holds the currently active room, and whenever the die function is called on a player the only room that could be active is a Stage, so this works out well. If you run all this you'll see that it works as expected. Once the player dies, after 1 second a new Stage room will start and you can play right away.

因为我们知道 current_room 总是指向当前激活的 Room,而玩家的 die 函数被调用时,当前活跃的房间只可能是 Stage,所以这里这么写完全没问题。跑起来之后你会看到,它确实按预期工作:玩家死掉 1 秒之后,新的 Stage 会重新开始,马上就能再玩一局。

Note that this was this simple because of how we structured our game with the idea of Rooms and Areas. If we had structured things differently it would have been considerably harder and this is (in my opinion) where a lot of people get lost when making games with LÖVE. Because you can structure things in whatever way you want, it's easy to do it in a way that doesn't make doing things like resetting gameplay simple. So it's important to understand the role that the way we architectured everything plays.

要注意的是,这件事之所以能做得这么轻松,恰恰是因为我们前面用了 Rooms 和 Areas 这套结构。如果一开始换了别的组织方式,重开一局很可能就会变得麻烦得多。我觉得很多人用 LÖVE 做游戏时之所以会卡住,很大一部分原因也在这里:因为 LÖVE 对结构几乎不设限,所以你很容易把东西组织成一种“重置玩法超级困难”的样子。所以,理解架构方式本身到底起了什么作用,非常重要。

Score

The main goal of the game is to have the highest score possible, so we need to create a score system. This one is also fairly simple compared to everything else we've been doing. All we need to do for now is create a score attribute in the Stage class that will keep track of how well we're doing on this run. Once the game ends that score will get saved somewhere else and then we'll be able to compare it against our highest scores ever. For now we'll skip the second part of comparing scores and just focus on getting the basics of it down.

因为这款游戏的核心目标就是冲高分,所以我们还得补上计分系统。和前面那些系统比起来,这部分其实也很简单。现在需要做的,无非是在 Stage 类里加一个 score 属性,用来记录当前这局的得分。等游戏结束后,这个分数会被存到别的地方,之后我们再拿它和历史最高分做比较。这里只先把基础版本做好,不去展开排行榜或最高分比较。

lua
function Stage:new()
    ...
    self.score = 0
end

And then we can increase the score whenever something that should increase it happens. Here are all the score rules for now:

然后,只要发生那些“理应加分”的事件,我们就顺手把分数加上去。当前版本的计分规则如下:

  1. Gathering an ammo resource adds 50 to score
  2. 收集一个 Ammo 资源,加 50 分
  3. Gathering a boost resource adds 150 to score
  4. 收集一个 Boost 资源,加 150 分
  5. Gathering a skill point resource adds 250 to score
  6. 收集一个 SkillPoint 资源,加 250 分
  7. Gathering an attack resource adds 500 to score
  8. 收集一个攻击资源,加 500 分
  9. Killing a Rock adds 100 to score
  10. 击杀一个 Rock,加 100 分
  11. Killing a Shooter adds 150 to score
  12. 击杀一个 Shooter,加 150 分

So, the way we'd go about doing rule 1 would be like this:

比如要实现规则 1,最直接的写法可以像这样:

lua
function Player:addAmmo(amount)
    self.ammo = math.min(self.ammo + amount, self.max_ammo)
    current_room.score = current_room.score + 50
end

We simply go to the most obvious place where the event happens (in this case in the addAmmo function), and then just add the code that changes the score there. Like we did for the finish function, we can access the Stage room through current_room here because the Stage room is the only one that could be active in this case.

思路很朴素:直接在事件最自然发生的地方加上计分逻辑。这里也就是 addAmmo 函数内部。和之前调用 finish 一样,这里也可以直接通过 current_room 访问 Stage,因为此时当前活跃的房间只可能是 Stage

Score Exercises

119. (CONTENT) Implement rules 2 through 6. They are very simple implementations and should be just like the one given as an example.

119. (CONTENT) 把规则 2 到 6 也实现掉。它们都非常直接,基本就是照着上面那个例子抄着写。

UI

Now for the UI. In the final game it looks like this:

接下来是 UI。最终游戏里的效果大概像这样:

There's the number of skill points you have to the top-left, your score to the top-right, and then the fundamental player stats on the top and bottom middle of the screen. Let's start with the score. All we want to do here is print a number to the top-right of the screen. This could look like this:

左上角是你拥有的技能点数量,右上角是当前得分,而屏幕上下居中位置则显示玩家几个最核心的属性。我们先从分数开始。现在要做的事情很简单,就是把一个数字打印到屏幕右上角。代码可以像这样:

lua
function Stage:draw()
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        ...
  		
        love.graphics.setFont(self.font)

        -- Score
        love.graphics.setColor(default_color)
        love.graphics.print(self.score, gw - 20, 10, 0, 1, 1,
    	math.floor(self.font:getWidth(self.score)/2), self.font:getHeight()/2)
        love.graphics.setColor(255, 255, 255)
    love.graphics.setCanvas()
  
    ...
end

We want to draw the UI above everything else and there are essentially two ways to do this. We can either create an object named UI or something and set its depth attribute so that it will be drawn on top of everything, or we can just draw everything directly on top of the Area on the main_canvas that the Stage room uses. I decided to go for the latter but either way works.

这里我们希望 UI 永远盖在最上层。实现这个目标其实有两条路:一种是专门做个 UI 对象,再把它的 depth 设得足够高,让它总是在最后绘制;另一种就是干脆直接在 Stagemain_canvas 上,在 Area 画完之后再把 UI 往上叠。我这里选的是后者,不过两种办法都能工作。

In the code above we're just using love.graphics.setFont to set this font:

上面那段代码里,我们先通过 love.graphics.setFont 切到了这套字体:

lua
function Stage:new()
    ...
    self.font = fonts.m5x7_16
end

And then after that we're drawing the score at a reasonable position on the top-right of the screen. We offset it by half the width of the text so that the score is centered on that position, rather than starting in it, otherwise when numbers get too high (>10000) the text will go offscreen.

然后把分数画到右上角一个合适的位置。这里特意用文本宽度的一半来做偏移,是为了让分数围绕那个点居中显示,而不是从那个点开始往右长。否则等数字一大,比如超过 10000 之后,文字就很容易被顶出屏幕外。

The skill point text follows a similarly simple setup so that will be left as an exercise.

技能点那一块的处理方式完全类似,所以我就留给你自己做了。


Now for the other main part of the UI, which are the center elements. We'll start with the HP one. We want to draw 3 things: the word of the stat (in this case "HP"), a bar showing how filled the stat is, and then numbers showing that same information but more precisely.

接下来处理 UI 里另外一大块,也就是上下中间那些状态栏。先从 HP 开始。我们需要画三样东西:属性名称本身(这里就是 "HP")、一条显示当前充满程度的条形槽,以及一组更精确的数字值。

First we'll start by drawing the bar:

先从血条本体开始画:

lua
function Stage:draw()
    ...
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        ...
  
        -- HP
        local r, g, b = unpack(hp_color)
        local hp, max_hp = self.player.hp, self.player.max_hp
        love.graphics.setColor(r, g, b)
        love.graphics.rectangle('fill', gw/2 - 52, gh - 16, 48*(hp/max_hp), 4)
        love.graphics.setColor(r - 32, g - 32, b - 32)
        love.graphics.rectangle('line', gw/2 - 52, gh - 16, 48, 4)
	love.graphics.setCanvas()
end

First, the position we'll draw this rectangle at is gw/2 - 52, gh - 16 and the width will be 48, which means that both bars will be drawn around the center of the screen with a small gap of around 8 pixels. From this we can also tell that the position of the bar to the right will be gw/2 + 4, gh - 16.

这里血条矩形的起点位置是 gw/2 - 52, gh - 16,宽度则是 48。也就是说,这两条状态条会围绕屏幕正中间左右展开,中间留出大约 8 像素的空隙。顺着这个布局你也能推出来,右边那条对应属性条的位置应该会是 gw/2 + 4, gh - 16

The way we draw this bar is that it will be a filled rectangle with hp_color as its color, and then an outline on that rectangle with hp_color - 32 as its color. Since we can't really subtract from a table, we have to separate the hp_color table into its separate components and subtract from each.

这条血条的绘制方式是:先画一块填充矩形,颜色用 hp_color;再在外面描一圈边框,边框颜色则是 hp_color 各通道减去 32 后的结果。因为你没法直接对一个 table 做减法,所以这里先把 hp_color 拆成 r, g, b 三个分量,再分别减。

The only bar that will be changed in any way is the one that is filled, and it will be changed according to the ratio of hp/max_hp. For instance, if hp/max_hp is 1, it means that the HP is full. If it's 0.5, then it means hp is half the size of max_hp. If it's 0.25, then it means it's 1/4 the size. And so if we multiply this ratio by the width the bar is supposed to have, we'll have a decent visual on how filled the player's HP is or isn't. If you do that it should look like this:

真正会变化的,其实只有里面那条填充条,而它的宽度由 hp/max_hp 这个比例控制。比如这个比值是 1,就表示血量全满;如果是 0.5,就表示当前血量只有上限的一半;如果是 0.25,那就是四分之一。所以只要把这个比例乘上血条总宽度,就能得到一个足够直观的可视化结果。做出来之后的效果大概像这样:

And you'll notice here that as the player gets his the bar responds accordingly.

你会看到,随着玩家受伤,血条也会同步做出对应变化。

Now similarly to how we drew the score number, we can the draw the HP text:

接下来,和画分数数字时一样,我们再把 HP 这个文字也加上去:

lua
function Stage:draw()
    ...
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        ...
  
        -- HP
        ...
        love.graphics.print('HP', gw/2 - 52 + 24, gh - 24, 0, 1, 1,
    	math.floor(self.font:getWidth('HP')/2), math.floor(self.font:getHeight()/2))
	love.graphics.setCanvas()
end

Again, similarly to how we did for the score, we want this text to be centered around gw/2 - 52 + 24, which is the center of the bar, and so we have to offset it by the width of this text while using this font (and we do that with the getWidth function).

这里的思路和分数完全一样:我们希望 "HP" 围绕 gw/2 - 52 + 24 这个点居中,而这个点正好就是血条的中心。所以同样得用文本宽度去做偏移,也就是靠 getWidth 来算。

Finally, we can also draw the HP numbers below the bar somewhat simply:

最后,再把更精确的血量数字也画到条形槽下方:

lua
function Stage:draw()
    ...
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        ...
  
        -- HP
        ...
        love.graphics.print(hp .. '/' .. max_hp, gw/2 - 52 + 24, gh - 6, 0, 1, 1,
    	math.floor(self.font:getWidth(hp .. '/' .. max_hp)/2),
    	math.floor(self.font:getHeight()/2))
	love.graphics.setCanvas()
end

And here the same principle applies. We want the text to be centered to we have to offset it by its width. Most of these positions were arrived at through trial and error so you can try different spacings if you want.

这里用的原则还是一样:想让文字居中,就得拿它自身的宽度来做偏移。至于这些坐标具体是多少,说白了大多都是靠试出来的。所以如果你觉得有别的间距更顺眼,完全可以自己调。

UI Exercises

120. (CONTENT) Implement the UI for the Ammo stat. The position of the bar is gw/2 - 52, 16.

120. (CONTENT) 实现 Ammo 属性的 UI。它的条形槽位置是 gw/2 - 52, 16

121. (CONTENT) Implement the UI for the Boost stat. The position of the bar is gw/2 + 4, 16.

121. (CONTENT) 实现 Boost 属性的 UI。它的条形槽位置是 gw/2 + 4, 16

122. (CONTENT) Implement the UI for the Cycle stat. The position of the bar is gw/2 + 4, gh - 16.

122. (CONTENT) 实现 Cycle 属性的 UI。它的条形槽位置是 gw/2 + 4, gh - 16

END

And with that we finished the first main part of the game. This is the basic skeleton of the entire game with a minimal amount of content. The second part (the next 5 or so articles) will focus entirely on adding content to the game. The structure of the articles will also start to become more like this article where I show how to do something once and then the exercises are just implementing that same idea for multiple other things.

到这里,游戏的第一大部分就算完成了。现在你手上已经有了整款游戏最基础的骨架,只不过内容量还很少。接下来的第二阶段,也就是后面大概五篇文章,会完全围绕“往游戏里填内容”来展开。而文章结构也会越来越像这一篇:我先演示一种东西怎么做,后面的练习则是让你按同样思路把一堆类似内容补出来。

The next article though will be a small intermission where I'll go over some thoughts on coding practices and where I'll try to justify some of the choices I've made on how to architecture things and how I chose to lay all this code out. You can skip it if you only care about making the game, since it's going to be a more opinionated article and not as directly related to the game itself as others.

不过下一篇会稍微插个队,先来一篇短暂的“中场休息”。我会在里面聊一些关于编程实践的想法,也会顺便解释一下:为什么我在架构和代码组织上会做出现在这些选择。如果你只在乎把游戏做出来,其实完全可以跳过那篇,因为它会比别的文章更主观一些,也没有那么直接地服务于玩法实现本身。