Screeps 抽象角色系统

screeps 系列教程

简介

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

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

什么是发布成本?

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

减少发布成本

如何降低发布成本?

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

你或许在游戏的过程中已经或多或少的做过了类似的事情,例如将状态的更新逻辑封装成一个函数,或是将常用的 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 生命周期:

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 的工作。

具体的流程如下:

代码流程

实现运行流程

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

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) {
            this.memory.ready = creepLogic.prepare(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 是如何找到自己要执行的代码的:

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 的行为逻辑。而不用关系其他角色细节,即下图所示:

和其他模块进行对接

这里为了简单起见,我们手动创建了 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 - 中文系列教程

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,042评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,996评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,674评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,340评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,404评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,749评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,902评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,662评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,110评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,577评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,258评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,848评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,726评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,952评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,271评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,452评论 2 348

推荐阅读更多精彩内容

  • feisky云计算、虚拟化与Linux技术笔记posts - 1014, comments - 298, trac...
    不排版阅读 3,827评论 0 5
  • 前言 本文来讨论一下 Screeps 中最重要的 角色系统 的设计方案以及一些基本原则。注意,本文中讨论的内容可能...
    HoPGoldy阅读 13,382评论 3 12
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,090评论 1 32
  • 生活中我是一个能不做的事情就不做,觉得必要做的事情才去做,所以我是一个特别怕事的人,那么对于像我这样的懒人有什么办...
    百凌爱读书阅读 916评论 0 1
  • 7:00起床 7:00-8:00洗漱、早餐。 8:00-8:30半小时专业阅读。 8:30-12:00上午的正式工...
    魏金宝阅读 163评论 0 0