为了不让自己下线时出现 creep 都凉了的情况,你的代码里或多或少都有一个用于控制他们数量的模块。在教程中,官方给出了一个简单有效的方法:检查每个角色的数量,当数量低于指定数量时就进行生成。而本文主要讲述的内容,就是介绍数量控制系统的设计思路。如果有不同的意见和看法欢迎评论区留言探讨~
系统组成
首先,让我们先来了解下一个完整的数量控制系统的组成部分:
- 数量检查逻辑:这一部分负责发现需要进行生成的 creep,并把要生成的 creep 的信息(如名称、内存、身体部分)提交给 spawn。
- spawn 生成逻辑:这一部分负责接收数量检查逻辑传过来的任务,并用其生成 creep。
- 期望数量配置:这部分将给数量检查逻辑提供指定 creep 期望的数量,可以手动维护,也可以通过分析当前殖民地状态自动修改。
这三个模块共同构成了一个数量控制系统,缺一不可。接下来,我将对这三部分进行分别介绍:
数量检查:集中式还是分布式?
在教程的有这么一行代码:
var harvesters = _.filter(Game.creeps, creep => creep.memory.role == 'harvester');
这行代码统计了采集者harvester
的数量,并在后面进行对比。这就是一个典型的集中式数量检查,简单明了。集中式的优点就是非常可靠,通过实时的监控,每一个角色的数量永远都是已知的。无论因为什么原因导致的 creep 死亡,数量控制系统都可以立刻得知,并重新安排生成。
集中式数量检查的缺点
但是这种可靠性的代价就是代码复杂度的提高和 cpu 的消耗。在开发数量控制系统时有一个重要的前提:每个房间的运营 creep 数量都是不同的。也就是说,我们要针对每个房间运行一次数量检查逻辑,从而保证房间的正常运营。
而除此之外,有一些例如外矿采集者和对外作战的 creep 的数量是不属于某个具体的房间的,所以我们还需要针对这些 creep 做一次数量检查。
越来越多的额外逻辑会让你的代码不再简洁,慢慢的变成一座屎山,从而摧毁你的游戏体验。
分布式设计 - creep 自检查
那么与之对应,分布式的设计思想就是把数量检查的逻辑分散到每个 creep 中:每个 creep 会定期检查自己是否健康,如果不健康的话就通知 spawn 进行生成。从而“自发的”维持指定的数量。
Creep.prototype.work = function() {
// ...
// 如果 creep 还没有发送重生信息的话,执行健康检查,保证只发送一次生成任务
// 健康检查不通过则向 spawnList 发送自己的生成任务
if (!this.memory.hasSendRebirth) {
const health = this.isHealthy()
if (!health) {
// 向指定 spawn 推送生成任务
// ...
this.memory.hasSendRebirth = true
}
}
}
// creep 监控状态检查
Creep.prototype.isHealthy = function() {
if (this.ticksToLive <= 10) return false
else return true
}
分布式数量检查的优点
分布式数量检查的优点就在于结构简单,因为每个 creep 都只关注自身,所以不会产生多房间的数量需要统计多次的问题。
并且,分布式检查还有一个其他模式难以实现的优点,可以自由控制发送重生任务的时机,你可以由此实现 creep 的无缝替换,例如计算从出生点到工作岗位的距离,然后算出到达岗位的时间,从而提前生成接班的 creep,避免空闲。
分布式检查分布式数量检查的缺点
这种设计思路最大的缺点是 不够可靠,为什么呢?假如有入侵者杀死了 creep ,或者有核弹直接杀死了所有的 creep 呢?如果还没有发送任务就已经死了,那么这个角色的数量就会 -1 并且不会回到正常的状态,因为可以发送生成任务的 creep 已经不在了。
所以说,优点也是缺点,问题的根本在于 很难决定什么时候应该发送生成任务。如果你使用这种方式来管理 creep 的数量,那你就要额外设计一个定时检查任务,来确保没有 creep 提前倒下。
最直接的方法 - 死亡检查
那么有没有一种简单不烧脑!也不烧 cpu 的方法来解决这个问题呢!答案是有的!并且这个设计方式甚至在教程中已经给出来了。他就是通过检查死去 creep 的记忆:
for(const name in Memory.creeps) {
if(!Game.creeps[name]) {
// 不再删除了!
// delete Memory.creeps[name]
// 向 spawn 发送生成任务
// ...
}
}
当我们发现有 creep 死亡时,就可以根据其 memory 中的角色直接将其加入生成队列,非常的简单无脑。
creep 的同名问题
如果新生成的 creep 和原来的保持同名,那么它将 直接继承原来的记忆。这在某种情况下是个坏处,例如这个 creep 需要在生成之后执行一个准备阶段,但是它内存中的一个字段表示它已经完成了准备 (继承了上个 creep 的记忆)。从而导致 creep 错误的跳过了准备阶段。
虽然解决了 creep 的持续生成问题,但是这种设计理念并不会检查配置项的变动(刚才讲的 creep 自检查也不会 ),也就是说新增的角色并不会自动生成。不过这种问题就属于小问题了,定时检查配置项的变动,并在变动时将新增的 creep 加入生成。这并不是什么难事不是么?
Spawn 生成逻辑
结束了数量检查,spawn 生成逻辑就要简单很多,可能细心的你已经发现了,上文中所有提到新生成 creep 的时候都是用的 "向 Spawn 推送生成任务" 而非 "调用 Spawn 进行生成"。
为什么要这么说呢?首先强调一个结论:无论在什么情况下,都推荐 使用任务队列进行 creep 生成。这样写可以将 spawn 的生成逻辑和其他逻辑解耦,方便维护。并且这种写法足够简单,也足够清晰。
OK,来介绍一下流程:首先,其他想要进行 creep 生成的模块不允许直接访问Spawn.spawnCreep
方法,而是调用 加入生成队列 的函数将 creep 生成任务追加到队列的末尾。同时,每个 tick 里 spawn 只需要检查任务队列就可以 了,如果有的话就从队列中弹出第一个任务进行生成。下面是流程图:
并且,游戏也非常贴心的在每个 spawn 上都分配了一块内存可以使用,我们的任务队列就保存在这里。
接下来我们来实现一下,这里我们在Spawn
原型上拓展三个方法,一个是 spawn 的工作:检查任务队列,一个用来 让其他模块添加生成任务,还有一个 封装 creep 生成的主要实现:
// 检查任务队列
Spawn.prototype.work = function() {
// 代码...
}
// 将生成任务推入队列
Spawn.prototype.addTask = function(taskName) {
// 代码...
}
// creep 生成主要实现
Spawn.prototype.mainSpawn = function(taskName) {
// 代码...
}
首先是第一个拓展,work
方法:首先进行检查,在无法生成时直接跳过来节省 cpu。然后尝试进行生成,如果生成成功的话就将完成生成的任务从队列中移除。
// 检查任务队列
Spawn.prototype.work = function() {
// 自己已经在生成了 / 内存里没有生成队列 / 生产队列为空 就啥都不干
if (this.spawning || !this.memory.spawnList || this.memory.spawnList.length == 0) return
// 进行生成
const spawnSuccess = this.mainSpawn(this.memory.spawnList[0])
// 生成成功后移除任务
if (spawnSuccess) this.memory.spawnList.shift()
}
然后是添加生成任务的方法,addTask
:这个方法将任务的名称追加到任务队列的末尾,然后返回它的排队位置:
// 将生成任务推入队列
Spawn.prototype.addTask = function(taskName) {
// 任务加入队列
this.memory.spawnList.push(taskName)
return this.memory.spawnList.length
}
这样,其他模块想要生成 creep 就只需要调用这个方法,然后传入期望要生成 creep 的任务名称即可,极大的减轻了和其他模块的耦合度。
最后一个拓展方法mainSpawn
,为了让 spawn 的work()
逻辑保持清晰,我更倾向于将其封装成一个独立的方法。这个方法包含了 creep 生成的核心实现,例如 通过任务名称taskName
获取任务的具体配置项、内存初始化、指定身体部件等。因为 creep 生成实现的方法每个人的区别都比较大,所以这里不进行过多介绍,具体生成实现请结合自己的代码进行修改。唯一一点需要注意的就是 在生成成功是返回true
,work
方法将根据这个返回值决定是不是要移除已经尝试过生成的任务。
拓展:为什么要使用生成队列?
假如我们有一个角色数量配置列表,在进行生成检查时,代码会 按照这个列表的顺序依次检查,那么无论什么情况下,在列表上方的角色都会被优先生成。如果配置列表下方有一个重要角色A,那么它上面的所有角色、无论重要不重要,都会先生成完,才会轮到A。在很多情况下,这会导致导致房间运营因为某个重要角色的缺失从而陷入瘫痪。
如果你采用的是 creep 自检查的话,为了避免重复生成,一个 creep 一生只会发送一次重生任务,假如 spawn 正在生成没办法立刻响应这个重生任务,同时自身也没有任务队列保存这个任务,那么这个 creep 的任务就会被遗弃掉,从而导致问题的产生。
接下来,我们来讲一下最容易被忽视的一点:期望数量配置。
期望数量配置
在角色数量逐渐增多的时候,很多人都会选择进行第一次重构 —— 将所有角色数量及其配置放置在一个列表中来方便维护,如下:
const creepConfigs = [
{
role: 'harvester',
bodys: [ WORK, CARRY, MOVE ],
number: 1
}, {
role: 'upgrader',
bodys: [ WORK, CARRY, MOVE ],
number: 1
},
// 更多角色 ...
]
这是很重要的一部分,可以极大的减轻你代码的复杂程度,并在某些时刻节省 cpu 消耗,例如我们在上一小节中介绍的 Spawn 生成逻辑 中,并没有直接将 creep 的详细生成信息一股脑的塞进生成队列中,而是将 任务名称 放进队列,这里的任务名称就可以是上面列表中的role
字段。然后在mainSpawn
中通过这个任务名称来取出对应的配置项。
这么做有什么好处呢,首先,保存在内存中的数据每 tick 都要经过JSON.stringify
的序列化,所以 内存中的数据越简单,所消耗的 cpu 也就越少。其次,这么做可以提高系统的响应效率,无论我们对其配置项进行什么修改,spawn 通过任务名称取到的永远是最新的配置。如果 spawn 队列里保存了整个任务的话,就有可能导致保存了旧版本的角色配置,从而生成出一个不符合预期的 creep 来。
动态调控期望
当我们将配置项抽象成一个独立的列表后,就可以采取一些更”智能“的方式来管理它了。例如,我们可以额外设计一个模块,这个模块会监测房间的状态,从而调整某个角色的数量或者身体部件:
自动调控数量借此,我们就可以完成房间的自动化运维,从而在不影响代码可读性的前提下节省我们人力维护成本。
不过需要注意的是,如果你觉得自己对于游戏的了解还不够深入的话,那么我是 不推荐在自己的代码里加入这个自动数量维护的,因为你可以能会花不少的时间来设计这个模块,但最后因为经验不足导致需要大量重构,从而影响游戏体验。至少在房间 8 级之前,你可以先手工维护来积累经验。
设计小 tips
文章的最后,我们简单的提几点在设计上述系统时应该注意的几点要素。
-
支持多房间
首先,这个系统要支持多房间,因为我们日常管理肯定是以房间为单位的。每个房间的数量配置都是不一样的。 -
独立的配置文件
其次,代码应该和配置文件解耦,将配置项单独放在一个文件中,因为随着基地的不断发展,我们的creep
的数量、身体的组成部分都是会频繁变动的,没有人会希望在修改这些配置时还要改一堆的代码。 -
多
Spawn
兼容
在教程中,只使用了一个出生点,但是在实际发展的时候,我们每个房间都可以拥有多个Spawn
的,所以在开发时我们也要考虑到这一点。例如,你可以不用 Spawn 自带的内存空间,而是把孵化队列存放到房间内存中,这样就可以多个 Spawn 共同处理孵化任务。
总结
本文我们将 creep 的数量控制系统划分为三个模块,数量检查、spawn 生成 以及 期望数量配置。并简单介绍了这三个模块的设计思路以及彼此之间的关系。当然,本文所讲的并不一定是最好的设计模式,如果你有更好的想法的话,欢迎评论或者私信交流 ~
想要查看更多教程?欢迎访问 《Screeps 中文教程》!
网友评论