Screeps 抽象角色系统

screeps 系列教程

简介

在设计自己的角色系统的时候,很多人都会被角色越来越多的问题所困扰,本文不讨论如何去削减角色的数量,而是从“发布成本”的角度出发,来介绍如何解决这个问题,并提高角色系统的可维护性。

本文将会使用到以下概念,如果对其不太了解,可以先阅读后方的拓展链接:

  • 原型拓展 - Screeps 浅谈游戏中的原型拓展

什么是发布成本?

在正式开始前,我们先来简单了解一下什么是发布成本,发布成本可以简单的理解成 创建一个新角色时要新增的代码量。发布成本越高,我们就越抗拒在自己的系统里加入新的角色。那么反过来,假如我们创建一个新角色只需要寥寥十几行甚至几行代码就可以完成,非常简单的就可以完成新角色的加入,那么不就从根本上 放弃治疗 解决问题了么?

Screeps 抽象角色系统_第1张图片
减少发布成本

如何降低发布成本?

降低发布成本的核心思想就是 将不同角色中的可复用代码抽象出来,形成一个新的“平台”,而把不可复用的逻辑代码整合成统一的配置项。这样,在发布新角色时我们只需复制配置项模板,然后填写其中的可变逻辑即可。

你或许在游戏的过程中已经或多或少的做过了类似的事情,例如将状态的更新逻辑封装成一个函数,或是将常用的 creep 方法封装起来。同样的,本文的主要内容就是如何高效的将不可变的逻辑抽象出来,避免大家少走弯路。

《设计模式》 —— GoF

考虑你的设计中哪些地方可能变化,这种方式与关注会导致重新设计的原因相反。它不是考虑什么时候会迫使你的设计改变,而是考虑你怎样才能够 在不重新设计的情况下进行改变。这里的关键在于封装发生变化的概念,这是许多设计模式的主题。

了解 creep 的通用运行模式

在抽象可复用的代码之前,我们要先了解 creep 的运行模式,通俗点说就是每个 creep 都要执行的代码逻辑。通过对基本的采集者harvester、升级者upgrader、建筑者builder进行观察,我们不难看出:

creep 运行时通常会在两个状态之间进行循环。

例如,我们对 Screeps 基本角色系统 一文中提到的角色进行拆分:

英文名 角色名 状态A 切换条件 状态B
havester 采矿者 开采能量 carry 是否到达上限 存入指定的结构
upgrader 升级者 取出能量 carry 是否到达上限 升级房间控制器
builder 建造者 取出能量 carry 是否到达上限 建造结构
carrier 运输者 取出能量 carry 是否到达上限 存入指定的结构
repairer 维修者 取出能量 carry 是否到达上限 修复受损的结构
defender 防御者 驻守指定区域 房间内是否有入侵者 攻击入侵者

但是这并不是全部,例如在开拓者 (在新殖民房间中建造 spawn) 需要先抵达执行房间,然后再执行建造者的逻辑。又比如士兵,可能需要在作战前先获取强化。所以说:

在某些角色中,需要执行一个额外的准备阶段。

所以,我们可以整理出如下 creep 生命周期:

Screeps 抽象角色系统_第2张图片
creep 生命周期

进而,我们就可以得到如下 creep 生命周期阶段的基本结构,这一段只是用作说明,不需要加进你的代码。使用了 typescript 中的接口来描述,如果你没有用过 typescript 的话,可以参考其中的注释进行理解

/**
 * creep 生命周期阶段
 */
interface CreepLifeCycle {
    /**
     * [可选] 准备阶段,接受 creep 并执行对应的准备逻辑
     * 根据其返回值判断是否准备完成,在准备完成前是不会执行下面的 target 和 source 阶段的
     */
    prepare?: (creep: Creep) => boolean
    /**
     * [必须] 工作阶段,接受 creep 并执行对应的工作逻辑(例如建造建筑,升级控制器)
     * 在返回 true 时代表所需资源不足,将在紫萼个 tick 开始执行 source 阶段
     */
    target: (creep: Creep) => boolean
    /**
     * [可选] 资源获取阶段,接受 creep 并执行对应的资源获取逻辑(例如获取能量,采集矿物)
     * 在返回 true 时代表能量获取完成,将在下个 tick 开始执行 target 阶段
     */
    source?: (creep: Creep) => boolean
}

注意,这里每个阶段的值都是函数Function,我们对应阶段的实际代码逻辑就包含在这些函数里,这样对于底层架构来说,只需要根据 creep 当前的状态调用不同的函数即可,不需要关心 creep 的具体工作逻辑是怎样的。这在软件设计上被称为 关注点分离。

设计 creep 运行流程

我们已经了解了 creep 的运行模式,现在来重新设计一下代码流程,使其可以兼容我们的新设计。

首先,我们要将角色的逻辑整合在一起做成一个函数,这个函数接受必要的参数,并返回要执行的工作逻辑,而返回的工作逻辑对象的结构就是上文中的 CreepLifeCycle。为什么要这么设计呢?必要的参数又是什么呢?

要解答这个问题,我们先来思考一下在设计角色逻辑时面临的最大问题是什么。是的,如何获取自己的操作目标。这里的操作目标指的是 creep 在工作时要面对的东西,例如 harvester 要采集的 source。你是不是纠结了很久如何让 creep 采集不同的 source?

在之前的代码中,我们的工作逻辑里耦合了太多由 if-else 组成的目标获取代码,例如根据某个内存字段获取到不同的 source 对象。这实际上违反了 单一职责原则。所以我们现在将这些目标获取代码拿到外边,然后通过函数参数的形式传递给 creep 的工作逻辑,工作逻辑不用关心这些目标是怎么来的,直接无脑执行即可,这样就保证了工作函数的纯洁性。

这个参数根据角色的不同也是不一样的,例如 harvester 会接受一个 source Id 作为他要采集的能量来源,而 defender 会接受一个房间名作为他要防御的目标房间。

接下来我们会在内存中创建一个对象来保存这些配置,并开放一套 api 来对这个配置对象进行管理。最后我们会在Creep原型上添加一个 work 方法,将我们上一小节中的生命周期逻辑存放到其中。这样只需要遍历 Game.creeps 并调用creep.work()即可完成每个 creep 的工作。

具体的流程如下:

Screeps 抽象角色系统_第3张图片
代码流程

实现运行流程

上一小节是不是看的有些晕,没有关系,接下来接下来我们会把所有的代码实现出来并一一讲解。为了方便理解,这里会按照上面的流程图 从下往上 进行实现。本节内容推荐先在 训练场 中进行实验。

1> 在配置项中定义运行逻辑

首先我们实现最后一步:在配置项中定义运行逻辑,按照上面的CreepLifeCycle实现最简单的upgrader升级者,新建role.upgrader.js并填入如下内容:

/**
 * 升级者配置生成器
 * source: 从指定矿中挖矿
 * target: 将其转移到指定的 roomController 中
 * 
 * @param sourceId 要挖的矿 id
 */
module.exports = sourceId => ({
    // 采集能量矿
    source: creep => {
        const source = Game.getObjectById(sourceId)
        if (creep.harvest(source) == ERR_NOT_IN_RANGE) creep.moveTo(source)

        // 自己身上的能量装满了,返回 true(切换至 target 阶段)
        return creep.store.getFreeCapacity() <= 0
    },
    // 升级控制器
    target: creep => {
        const controller = creep.room.controller
        if (creep.upgradeController(controller) == ERR_NOT_IN_RANGE) creep.moveTo(controller)

        // 自己身上的能量没有了,返回 true(切换至 source 阶段)
        return creep.store[RESOURCE_ENERGY] <= 0
    }
})

可以看到我们用非常少的代码就实现了升级者的逻辑。当然这里并不能直接运行,稍后我们会继续进行完善。

这里先简单介绍一下这段代码,可以看到最外层我们用module.exports和箭头函数导出了一个函数,这个函数 接收一个能量矿的 id ,并返回升级者的工作逻辑,这里返回的工作逻辑对象就是上文中的 CreepLifeCycle。稍后我们会使用这个函数快捷的生成一个升级者。而由于升级者不需要准备阶段,所以我们省略了prepare阶段的实现。

值得注意的是 source 和 target 方法的返回值,最终的框架会根据其返回值决定是否要切换至另一个阶段。

2> 创建 creep 管理 api

ok,接下来我们来创建一个全局模块,这个模块将负责 creep 的增删。新增文件 creepApi.js 并填入如下内容:

global.creepApi = {
    /**
     * 新增 creep 配置项
     * @param configName 配置项名称
     * @param role 该 creep 的角色
     * @param args creep 的工作参数
     */
    add(configName, role, ...args) {
        if (!Memory.creepConfigs) Memory.creepConfigs = {}
        Memory.creepConfigs[configName] = { role, args }
        
        return `${configName} 配置项已更新:[角色] ${role} [工作参数] ${args}`
    },
    /**
     * 移除指定 creep 配置项
     * @param configName 要移除的配置项名称
     */
    remove(configName) {
        delete Memory.creepConfigs[configName]
        return `${configName} 配置项已移除`
    },
    /**
     * 获取 creep 配置项
     * @param configName 要获取的配置项名称
     * @returns 对应的配置项,若不存在则返回 undefined
     */
    get(configName) {
        if (!Memory.creepConfigs) return undefined
        return Memory.creepConfigs[configName]
    }
}

这个模块一共暴露了三个方法,分别用于添加 creep 配置、移除配置以及获取配置,非常的简单。注意其中使用了 es6 的 解构操作符 ... 来让代码更加精简。

好了,现在我们已经有了配置工具,接下来我们将拓展 Creep 原型,让 creep 们可以从自己持有的配置中明白需要做什么。

4> 进行 Creep 拓展

首先新建mount.creep.js,并填入如下内容:

/**
 * 引入 creep 配置项
 * 其键为角色名(role),其值为对应角色的逻辑生成函数
 */
const roles = {
    upgrader: require('role.upgrader.js')
}

// 添加 work 方法
Creep.prototype.work = function() {

    // ------------------------ 第一步:获取 creep 执行逻辑 ------------------------

    // 获取对应配置项
    const creepConfig = creepApi.get(this.memory.configName)
    // 检查 creep 内存中的配置是否存在
    if (!creepConfig) {
        console.log(`creep ${this.name} 携带了一个无效的配置项 ${this.memory.configName}`)
        this.say('找不到配置!')
        return 
    }
    const creepLogic = roles[creepConfig.role](...creepConfig.args)

    // ------------------------ 第二步:执行 creep 准备阶段 ------------------------

    // 没准备的时候就执行准备阶段
    if (!this.memory.ready) {
        // 有准备阶段配置则执行
        if (creepLogic.prepare && creepLogic.isReady) {
            creepLogic.prepare(this)
            this.memory.ready = creepLogic.isReady(this)
        }
        // 没有就直接准备完成
        else this.memory.ready = true
        return
    }

    // ------------------------ 第三步:执行 creep 工作阶段 ------------------------

    let stateChange = true
    // 执行对应阶段
    // 阶段执行结果返回 true 就说明需要更换 working 状态
    if (this.memory.working) {
        if (creepLogic.target) stateChange = creepLogic.target(this)
    }
    else {
        if (creepLogic.source) stateChange = creepLogic.source(this)
    }

    // 状态变化了就切换工作阶段
    if (stateChange) this.memory.working = !this.memory.working
}

这一段代码比较长,我们来详细介绍一下,首先我们引入了 role.upgrader.js 并将其放在一个对象 roles 中,这个对象包含了我们所有的角色,后期我们新增了角色的话需要添加到这里。

然后我们通过修改 Creep 原型的方式为所有的 creep 都添加了 work 方法,这个方法中包含的内容就是我们在一开始提到的 “基础框架”。其中一共包含了三部分,上面已经通过注释形式标注了起来,分别是:

  • 获取工作逻辑:通过 creep 内存中保存的 configName 字段借助 creepApi 获取对应的配置项。

  • 执行准备阶段:检查 creep 内存中的 ready 字段,如果不为 true 的话则说明 creep 还没准备好,去执行准备阶段。在准备完成前不会执行下面的工作阶段。

  • 执行工作阶段:状态机,检查 creep 内存中的 working 字段,如果为 true 则执行 target 阶段,为 false 就执行 source 阶段,并根据这两个阶段的返回值决定要不要切换状态。

你可以通过下面这张图理解 creep 是如何找到自己要执行的代码的:

Screeps 抽象角色系统_第4张图片

5> 挂载拓展并调用 creep

ok,现在我们已经完成了全部的准备工作,接下来只需要把他们实装即可,在main.js里填写如下代码:

// 挂载 creep 管理模块
require('creepApi.js')
// 挂载 creep 拓展
require('mount.creep.js')

module.exports.loop = function() {
    // 遍历所有 creep 并执行上文中拓展的 work 方法
    Object.values(Game.creeps).forEach(creep => creep.work())
}

现在我们就可以来进行测试了,首先执行如下代码来孵化一个 creep:

// 注意修改其中的 spawn 名称
Game.spawns.Spawn1.spawnCreep([WORK, CARRY, MOVE], 'firstUpgrader', { memory: { configName: 'upgrader1' }})

有一点和官方教程不同的是,在 creep 内存中保存了 configName: upgrader1 而不是 role: upgrader,因为在这个架构里,不同的升级者的配置是不同的(例如 upgrader1 会去能量矿 A,而 upgrader2 会去能量矿 B ),所以我们要通过upgrader1来找到其对应的配置项。

在他孵化完成后你可以看到它在嚷嚷着找不到配置项,这是因为我们给他内存中设置的配置 upgrader1 并不存在,接下来我们在控制台执行如下代码来新建这个配置:

// 注意把第三个参数改成房间中存在的 source id
creepApi.add('upgrader1', 'upgrader', '5bbcaa7d9099fc012e631786')

现在我们就能看到 creep 已经开始执行他的升级任务了!

NICE

这行代码的意思就是新增配置项 upgrader1,指定角色为 upgrader,将采集对应 source 中的能量并升级 controller,是不是非常简单。你可以将上面的 source id 换成房间内的另一个 source,然后再执行一遍,然后就可以看到 creep 迅速的响应了我们的变更。

你也可以在控制台执行下面的代码来删除配置项,删除后 creep 将会重新变为一个无头苍蝇:

creepApi.remove('upgrader1')

也就是说,我们只需要使用 creepApi 对配置项进行控制,就可以灵活的指导 creep 的行为逻辑。而不用关系其他角色细节,即下图所示:

Screeps 抽象角色系统_第5张图片

和其他模块进行对接

这里为了简单起见,我们手动创建了 creep 的配置项,如果你还是个新手的话,推荐你先以这种形式手动调整房间的运营单位来积累经验,在你对游戏的了解有所深入之后,你可以尝试结合自己的 spawn 孵化模块和 creep 数量控制模块来动态的调用 creepApi 进行 creep 增删 以达到动态调整运营单位的目的,调用方式和上面控制台命令完全一致,这里不再过多深入。

写在最后

本文中提到的框架并不复杂,只有两个需要注意的点:

  • source 和 target 生命周期阶段会根据函数的返回值(是否为 true)决定下个 tick 是否要切换为另一个阶段。
  • creepApi 是指导 creep 工作的核心工具。通过其他模块调用 creepApi,可以完成各种各样的 creep 工作。

如果你对上面提到的代码还有不了解的地方,推荐把上面的 设计 creep 运行流程 小节多读几次。接下来提几点可以优化的地方,你可以酌情考虑升级:

  • 添加 isNeed 阶段:上面配置项只能满足那些会一直生成的 creep 发布,而元素矿采集单位和房间守卫这种有可能很长时间都不会孵化的单位该怎么办呢?通过添加额外的 isNeed 阶段,并在 spawn 孵化前进行检查,这样就可以决定是否要重新孵化某个单位。
  • 在配置项中添加 body 函数:creep 在不同时期的体型是会发生改变的,我们可以在配置中添加一个 body 函数,这个函数会在孵化时由 spawn 调用,并将函数的返回值作为要孵化 creep 的 body 体型,由此来提高角色的内聚性。

如果你不知道如何着手进行修改的话,可以参考我的 Screeps 项目 HoPGoldy/my-screeps-ai。以上就是本文的全部内容了,了解更多 Screeps 的中文教程?欢迎访问 Screeps - 中文系列教程!

你可能感兴趣的:(Screeps 抽象角色系统)