欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

Screeps 设计数量控制系统

最编程 2024-08-06 13:05:01
...
screeps 系列教程

为了不让自己下线时出现 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 生成实现的方法每个人的区别都比较大,所以这里不进行过多介绍,具体生成实现请结合自己的代码进行修改。唯一一点需要注意的就是 在生成成功是返回truework方法将根据这个返回值决定是不是要移除已经尝试过生成的任务。

拓展:为什么要使用生成队列?

假如我们有一个角色数量配置列表,在进行生成检查时,代码会 按照这个列表的顺序依次检查,那么无论什么情况下,在列表上方的角色都会被优先生成。如果配置列表下方有一个重要角色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 中文教程》