第 1 期 · · Voyager #037
机制札记 #1:关卡出怪是怎样生成的
同样 3 点预算,为什么有时来一只路障,有时却来三只普通?从第 1 关的小例子出发,逐步了解 0.17q 的点数池、手动波表、选行与刷新规则,再用种子模拟验证计算。
同一关重开几次,有时来的是路障,有时却是一群普通僵尸。这些差别是怎样产生的?游戏又是在什么时候决定它们从哪一行出现的?
要回答这些问题,需要先了解一波僵尸是怎样排出来的。我们从第 1 关的一个小例子讲起,再逐步加入手动出怪、选行和刷新时机。没有接触过关卡文件的读者也可以从头阅读;已经熟悉点数池的读者,则可以留意后面的特殊情况。文中讨论的是 0.17q 的常规出怪系统,规则依据关卡文件与程序逻辑整理。Boss、控制器和预置实体产生的僵尸,还要分别看它们自己的机制。

先看一个三点预算的例子
假设游戏要给这一波安排僵尸,手里有 3 点可用。可选的只有普通僵尸和路障僵尸:普通需要 1 点,路障需要 3 点。两种僵尸都已解锁,抽取权重也相同。为了方便,下文把这份可用点数称为“预算”,把生成一只所需的点数称为“价格”。
先不要急着计算。这一波最后可能是什么样子?
- 一只路障僵尸。
- 三只普通僵尸。
- 上面两种情况都有可能。
如果认为是第三种,就已经抓住了这个例子的关键。不过,我们还需要弄清楚:既然权重相同,为什么数量会差这么多?
我们分两种情况来看。第一次抽到路障,3 点便一次用完,只能得到这一只。第一次抽到普通,则还剩 2 点;此时已经买不起路障,接下来的两次便只能继续抽普通,最后得到三只。

这正是第 1 关 MEDIUM 难度第 4 波会遇到的情况。两种结果并不是谁多花了点数,而是同样的点数被用在了不同的僵尸上。
权重相同,不等于数量相同。 权重影响一次抽取的结果,价格则会影响后面还有谁能参与抽取。接下来我们把这条规则展开。
点数池怎样把预算变成僵尸
每抽一次,都要重新看剩余点数
上例中的可选清单,就是点数池,在关卡文件中写作 ZombiePoints.ZombieList。如果打开这份清单,会看到每个候选条目可以设置下面三个参数:
Points:抽到一次要扣多少点,也就是上文所说的价格,默认 1。Weight:本次抽取时使用的相对权重,默认 100。Waves:从第几波开始允许普通抽取,波次从 1 起计;默认 0,表示第一波便可参与。
游戏准备抽取时,先看哪些条目已经解锁,再看剩余预算买得起哪些。只有通过这两项检查的候选,才按各自的权重参与本次抽取。抽中一只以后,扣去它的价格,再用剩下的点数重新检查一遍。
需要注意,“抽到过”并不会让一个条目退出点数池。只要条件仍然满足,它就可以再被抽到。直到预算用完,或者已经没有可抽的候选,本波的普通抽取才结束。
回到开头的例子,路障与普通只有在第一次抽取时才是等权重竞争。第一只普通出现后,路障便因价格太高而退出后续抽取。因此,不能把每次抽取都看成“普通、路障各占一半”。这里说的退出也只针对当前剩余预算,并不是从整关的点数池中删掉了路障。
每波有多少预算
前面直接给了 3 点预算,但游戏是怎样算出这 3 点的呢?普通波的默认基础预算为 B = i / 3 + 1,其中 i 是从 0 开始的波次编号。也就是说,第 1 波代入 0,第 4 波代入 3;除法产生的小数暂时保留。
基础预算还要乘难度倍率 M,最后才转为整数点数。以第 1 关的 MEDIUM 第 4 波为例:
- 先算基础预算:
3 / 3 + 1 = 2。 - 再乘这一难度的倍率:
2 × 1.5 = 3。 - 结果已经是整数,因此这一波有 3 点可用。
五档难度默认使用 1、1.5、2、2.5、3 倍。作者可以用 Multiplier 写一个共同倍率,也可以写五项数组,逐档设置。如果 ZombiePoints.Points 是长度恰好等于总波数 Waves 的数组,就改用数组各项作为每波的基础预算,之后仍然要乘难度倍率。
这里的 Points 容易混淆:写在池条目中,是一只僵尸的价格;写成 ZombiePoints 下的逐波数组,才是各波的基础预算。阅读文件时,先看它写在哪一层。
点数只是游戏用来安排出怪的预算,与阳光、荣光和僵尸 HP 分开计算。路障价格为 3,并不表示它的耐久是普通僵尸的 3 倍。
最后补充整数转换的细节:程序会先加上很小的 0.000001,再截去小数部分;大于这个微量、却仍不足 1 点的正预算按 1 点处理。一般的手算按上述方法即可。若结果恰好落在临界值附近,还需要考虑单精度计算的影响。
作者指定的僵尸放在哪里
点数池可以解释随机出现的僵尸,却还不能解释另一种常见安排:作者希望某一波一定有一只路障,而且指定它从第 3 行来。这样的记录可以直接写进顶层 ZombieList,不必等点数池恰好抽中。
于是,我们遇到了两份同名列表。顶层 ZombieList 存放逐波的手动记录,ZombiePoints.ZombieList 存放随机追加的候选。一关可以同时使用两者。下面先把随机追加放在一边,只看手动列表怎样分波。
怎样分波
假设依次写下“普通、普通、分波标记、路障、结束标记”,这份列表会得到几波?前两只普通属于第 1 波,越过分波标记后,路障便属于第 2 波。
关卡文件中的分波标记叫 NULLZOMBIE,结束标记叫 ENDNULLZOMBIE。游戏按顺序读取列表,一条普通记录配置一只僵尸;遇到前者,进入下一波;遇到后者,停止读取。
我们还可以把两个分波标记连在一起。此时它们之间没有任何普通记录,就会留下一个没有手动僵尸的波。下图把这两种写法放在一起:

也就是说,分波标记本身不是一只僵尸,空的手动波也不一定是游戏中的空波。另外,相同类型、相同行连续写了两次,仍然是两只,不能因为内容相同便合成一只。本站波表中的 x2 就是在保留这个数量。
同一份列表如何适应不同难度
假设作者想让刚才那只路障只在 HARD 及以上出现,就可以给它加上 LowDif: 30。这里的 30 来自游戏用于比较难度的数值:EASY = 10、MEDIUM = 20、HARD = 30、IMPOSSIBLE = 40、UNBALANCED = 50。
判断方法很直接:当前难度值大于或等于 LowDif,就保留这条记录;否则跳过。不写 LowDif 时按 0 处理,因此五档都能通过这项检查。
如果把门槛从 30 改成 44,IMPOSSIBLE 还能出现这只僵尸吗?虽然 44 离 40 更近,但它并不会被归到 40 那一档。由于 40 < 44,只有数值为 50 的 UNBALANCED 能通过。

LowDif 比较的是数值,不是就近选择一档难度。 关卡介绍中的 Dif 评分是另一项数据,不参与这里的比较。
怎样重复一段波表
除了逐波向后读,手动列表还可以指定一段重复使用的记录。EndlessWave 用来设置循环起点,也从 0 起计。以第 3 关为例,它的手动列表有 10 波,EndlessWave: 7 指向第 8 波。
读完第 10 波后,就回到第 8 波这段记录。本站按 Waves: 15 展开时,第 11~15 波依次复用第 8、9、10、8、9 波。注意,回去的是手动记录的读取位置,页面上显示的波次仍然向后增加。

这里的 15 只是页面采用的展开范围,不能据此认定循环关在游戏里一定于第 15 波结束。实际何时结束,还要看运行时的结束条件。
再加上三条特殊规则
到这里,我们已经知道手动记录怎样分波,也知道点数池怎样花掉预算。再补上几条规则,就能理解一波僵尸的大致构造顺序了:游戏先放入手动记录,再处理自动旗帜队伍、末波保底和手动记录的点数扣除,最后才用剩余预算进行普通抽取。
这一过程是在进入关卡时逐波完成的。也就是说,点数池的种类、数量和初始化选行,在开局构造波表时就已产生。 如果某波恰好来了难以应付的僵尸,不能由此推断游戏刚刚看过你的阵容,临时抽了一种来克制它。后面介绍的灵魂选行会读取植物状态,但那是另一件事。
大波的预算与旗帜队伍
大波通常比普通波热闹一些。默认预算公式会在每个第 10 波和最后一波作大波处理:先把基础预算乘 2.5,再计算附加普通僵尸的数量 N。N 的算法是把 i × M / 3 截去小数部分,然后将结果限制在 2~8。
把一只旗帜僵尸也算进去,队伍总数便是 C = N + 1。如果放大后的基础预算至少为 1.5 × C,就先扣去 C,然后再乘难度倍率、转成整数。我们可以把这一步理解为给旗帜队伍预留点数。
不过,“预留了点数”与“真的生成队伍”需要分开看。要生成一只旗帜和 N 只普通,还必须开启 ZombiePoints.Flag: true,而它默认关闭。预算中的预留分支本身不检查 Flag,所以不能看到扣点就认定一定有自动旗帜队伍。
若设置了有效的逐波 Points 数组,则以数组值覆盖基础预算。还有一种容易混淆的情况:作者在手动列表里单独写了一只旗帜。这只旗帜属于手动记录,并不会顺便打开自动队伍。最后回算第 1 关时,我们会把大波的数字完整代入一遍。
手动记录也可以占用预算
假设本波有 5 点预算,作者另外手动安排了一只路障。如果没有其他特殊处理,点数池仍然可以拿这 5 点继续抽。手动写了一只,并不自动意味着随机部分少来一只。
若希望手动安排也计入开支,可以开启默认关闭的 SubtractMode。例如这条路障记录的扣点价格为 3,那么扣除后便只剩 2 点供普通抽取。
扣多少点也有查找顺序:手动记录自己写了 Points,先用这个值;没有写,就找池中对应类型的价格;仍然找不到,才按 0 处理。只有本波满足难度条件的手动记录参与这项扣除。由此可见,打开扣点模式以后,随机追加的余量取决于手动记录的价格,并不是简单减去手动僵尸的只数。
末波可能补进从未抽到的候选
再考虑一种特殊情况:池中一直放着路障,权重也不是 0,但前面恰好一次都没抽到。到了最后一波,游戏会不会让它继续缺席?默认开启的 Protected 就是为这种情况准备的。
它会检查各个池条目的历史,将权重非零、此前池中生成次数为 0 的条目补入一次,并扣去价格。为便于说明,假设检查时还剩 2 点,唯一需要保底的是价格为 3 的路障,而且所有行都可用。虽然 2 点买不起它,保底仍会补入,预算也随之变为 2 - 3 = -1。

为什么买不起还能补进来?因为保底发生在普通抽取的解锁与预算检查之前,也在手动记录扣点之前。尚未解锁或预算不足,都不能用来排除一次保底。
还要特别留意“从池中生成过”这几个字。假如前面出现的路障来自手动列表,就不算池条目已经抽到;反过来,只要该条目此前已从池中生成过,末波便不会再为它保底一次。保底记的是池条目的历史,不是玩家在场上见过哪些僵尸。
从哪一行进入
固定行与随机行
前面一直在讨论来什么僵尸,现在再看它们从哪里进入。一条记录写着 Row: 2,会落在哪一行?答案是第 3 行,因为这里的行号从 0 开始。常见五行场地中,最上行编号为 0,最下行为 4。
如果没有填写记录的 Row,或者写成 Row: -1,游戏会等到这只僵尸出场时再选行。它先从当前可用、且不受行冷却限制的行中随机选一行;如果所有可用行都在冷却,则选择冷却值最低的一行。
固定行也不是无条件生效。指定行不合法、不可用或受冷却限制时,同样会回退到随机路径。因此,波表写了固定行,说明的是记录中的安排;最终出场仍然要通过运行时检查。
此外,关卡顶层还有一份 Row 数组,用来记录各行状态,其中状态值 2 表示不可用行。它和僵尸记录中的 Row: 2 含义不同:前者在说一行处于什么状态,后者在说一只僵尸指定去哪一行。
开局选行为什么比较均匀
如果连续观察点数池生成的僵尸,可能会觉得它们在各行分得比较开。这与初始化时使用的平滑选行器有关。点数池和自动旗帜队伍选行时,会记录每行距最近两次被选中已有多少次选择,再据此调整权重,让近期选得少的行更容易被选中。
可以把它理解为一本不断更新的选行记录。读到下一波时,这本记录不会清空;手动固定行也会更新它。因此,某一波的行分布还会受到前面记录的影响。
但平滑选行并不要求“每行轮到一次才能重来”。刚选过的行仍有可能再次被选中,五只僵尸也不一定恰好每行一只。另外,类型抽取和平滑选行共用同一条关卡局部随机数流,改动前面的候选或记录,也可能使后面的结果发生变化。
灵魂选行究竟看什么
Row: -2 是另一种情况。在 EASY、MEDIUM 下,它仍按普通随机规则选行;到了 HARD、IMPOSSIBLE、UNBALANCED,才会使用 Soul 评分。游戏在每波开始时计算一次植物布局快照,然后从可用行中选分数最低的一行;若最低分并列,就取最上面的一行。
我们用一个容易计算的阵容来理解它。假设五行都可用,第 1、3、5 行各有一棵满耐久坚果,第 2、4 行为空,场上没有其他植物。单棵坚果的贡献按 5 × 当前耐久 / 最大耐久 作整数计算,满耐久时就是 5 分。于是五行依次得到 5、0、5、0、5。
第 2、4 行同为最低分,该选哪一行呢?按照并列时取最上行的规则,会选择第 2 行。

现在稍微改变一下情形:第 2 行已经来了一只本波的僵尸,后面的灵魂记录会转而选择第 4 行吗?在这个例子中不会。评分用的是本波开始时的植物快照,新来的僵尸并没有改变这份评分。因此,多条灵魂记录可能连续选中同一行。
真正的评分还会考虑豌豆射手与豌豆坚果的邻行 Dd 基因支援,但并没有把所有植物的输出、控场、阳光与主动技能都算进去。所谓“灵魂弱行”,只是这份评分最低的一行,不一定就是你在实战中觉得最难防守的那一行。
清得越快,下一波就越早吗
知道了每波有哪些僵尸、从哪一行来,还剩最后一个问题:下一波什么时候来?这部分由 WaveManager 参与控制。InitTime 设置首次等待,Counter 设置波间计数器,Factor 则参与计算提前刷新的阈值;其中的 Flag 子配置控制进入大波前的节奏。
我们先看提前刷新是怎样发生的。每波生成后,游戏记下本波符合条件的僵尸的初始统计值,再乘 Factor 得到阈值。之后,计数器走过 200 以上、剩余值仍大于 100 时,如果本波剩余统计值降到了阈值或以下,就把剩余等待改为 100。
这里可以做一个只看数字的例子。假设初始统计值为 100,系数为 0.25,阈值便是 25;计数器从 2500 走过了 300,还剩 2200。此时若剩余统计值为 20,三项条件都满足,计数器便从 2200 改为 100。

如果只走过 150,即使统计值已经降到 20,也不能在这个时刻触发上述分支。这说明提前刷新要同时看清怪进度与计数器条件,不能只看场上还剩多少僵尸。
0.17q 的默认首次等待是 1800 个逻辑更新单位,普通波计数器为 2500~3100、系数为 0.25,大波计数器为 4000、系数为 0。关卡可以覆盖这些设置,例如第 48 关将普通波和大波的系数都设成了 0.5~0.65。这些单位不能直接当作毫秒或显示帧数。
还需要说明两点。其一,统计时会检查波次归属与实体状态,不能拿全场僵尸头数替代这里的统计值。其二,触发后只是把剩余等待缩短到 100,并不会当场生成下一波;大波提示与额外计数器也会影响实际出场时刻。本站波表目前没有模拟这段实时计时。
回到第 1 关,自己算一遍
前面的规则已经足够用来读一份简单关卡了。我们回到第 1 关:它共有 10 波,没有顶层手动列表,开启了自动旗帜队伍。普通、路障的价格分别为 1、3,权重同为 4000,路障从第 2 波解锁。
如果选择 MEDIUM,倍率为 1.5,那么路障最早能在哪一波参与普通抽取?先把前四波的预算依次算出来:
- 第 1 波:
1 × 1.5转成整数,得到 1 点。 - 第 2 波:
(1 / 3 + 1) × 1.5,得到 2 点。 - 第 3 波:算出 2.5,截去小数后仍是 2 点。
- 第 4 波:
(3 / 3 + 1) × 1.5 = 3,终于有 3 点可用。

答案是第 4 波。第 2 波虽然已经解锁,但 2 点还买不起价格为 3 的路障;第 3 波同样不够。解锁只解决“允许抽”的问题,预算还要解决“买得起”的问题。 两项都满足以后,是否真的抽中,仍然取决于抽取结果。
再来看第 10 波。此时 i = 9,基础预算是 9 / 3 + 1 = 4,经过大波放大后成为 10。附加普通数为 9 × 1.5 / 3 = 4.5 截成 4,连同旗帜共 5 只。
由于 10 ≥ 1.5 × 5,先从基础预算中扣掉 5,再乘 1.5,转成整数得到 7 点。这 7 点供后续点数池处理,自动队伍则另有 1 只旗帜和 4 只普通。计算到这里,记得把这两部分分开,才不会漏掉自动队伍,也不会把预留点数重复扣除。
现在可以打开第 1 关的 MEDIUM 种子示例,展开完整波表来核对。本站当前模型中,种子 123456 的第 4 波是一只位于第 3 行的路障;第 10 波除自动队伍外,池中还抽到 4 只普通与 1 只路障,正好花掉 7 点。
最后留一个小练习:改到 UNBALANCED,第 2 波能否抽到路障?这一关的倍率数组是 1、1.5、2、2.5、4,最高档覆盖了默认的 3。可以先代入计算,再与下面的结果比较。
用 4 倍计算,第 2 波有 5 点。此时路障既已解锁,预算也足够,因此能够参与抽取。它从第 4 波提前到第 2 波获得资格,并不是解锁波次变了,而是难度倍率让预算提前达到了价格要求。
用波表验证时,还要知道什么
阅读本站出怪解析时,可以先选择难度,再分清确定数量、候选类型和出场时选行。问号表示点数池数量还没有经过初始化模拟;“可选 1/2/3/4/5 行”列出的是同一条记录的可能落点,不能读成五行各来一只。
开启“模拟波表”后,输入的无符号 32 位整数会初始化本站重建的 MT19937。同一实现下,关卡、难度和种子相同,便能得到相同的模拟结果。这使我们可以反复检查一次抽取究竟花了多少点、后面又剩下哪些候选。不过,它与我是僵尸无尽的 12 位分享代码用途不同,本站也没有还原游戏上游怎样产生这份种子。因此,输入一个数得到的波表,不能直接认作某次实机对局的出怪。
重建中还有两处差异。原程序在首次平滑抽取前没有初始化行权重,本站将其规范为 0;原程序先更新手动固定行的历史,再作难度过滤,当前模拟则只记录所选难度的有效固定行。如果关卡混用了这些记录,逐行结果就可能不同。出场时的行冷却、随机选行、Soul 快照与波间计时,同样不在这份初始化模拟的范围内。
官方页面只分析基础 level{id}.json,类似 level34_1.json 的文件属于相关子关卡或变体,不能混进基础波表;创意工坊则解析当前选中的子关卡。若页面提示没有标准出怪表,意思是没有常规波表可以展开。控制器、Boss 或预置实体仍然可能产生僵尸,需要继续看相应关卡的其他安排。