Screeps 使用 rollup 打包你的代码

screeps 系列教程

前言

其实严格意义上来讲,上一篇教程 中所搭建的并不是一个工程化的编程项目,他只是提供了一个更好看一点的“代码编辑窗口”。而本篇教程将站在更专业的角度,解决你在游戏中会遇到的一些痛点:

优缺点对比

优点

  • 不需要再担心代码丢失:由于上一篇教程中我们依旧是在游戏的代码目录中进行的开发,而当你网络不好时有可能会导致你的代码被直接吞掉(是真的所有代码都消失了 ),而使用 rollup 打包后,无论游戏代码再怎么被吞,也不会影响到我们的源代码。
  • 支持多文件夹:screeps 有个非常严重的问题就是不支持文件夹,这对于喜欢解耦的同学来说简直是一场灾难,而 rollup 可以完美的解决这个问题。
  • 引入庞大的 npm 生态:npm 是 node 的第三方库管理器,我们可以通过它安装成千上万已经上传的第三方包。

缺点

  • 没有缺点,请继续往下看

什么是 rollup?

你可能注意到了我们多次提到了 rollup,这是个啥东西呢?简单来说,这是一个构建工具,构建嘛,你给他一堆东西(源代码),它按照你的想法做一些事情,最后产出一个文件(成果代码),很简单对不对。

现在我们知道这么多就足够了,如果你想了解更多的话,可以查看 rollup 中文文档。接下来,我们就从头开始,一步步的搭建我们的 screeps 游戏开发环境。请确保你电脑上安装有 node 哦。

步骤1:项目配置及 rollup 安装

我们先来做一些最基本的准备工作。首先新建一个文件夹,命名为 my-screeps-bot 或其他你喜欢的名字。然后用 VScode 打开这个文件夹,ctrl + ~ 打开终端,在其中输入 npm init -y 进行项目初始化,再输入 npm install -D @types/screeps @types/lodash@3.10.1 安装自动补全。等命令执行完毕后就可以看到如下目录:

初始化完成的游戏目录

接下来我们在其中新建一个 src 目录,这个目录就是源代码存放目录,我们所有的 screeps 游戏代码都将写在这里,然后在其中新建 main.js 并填入如下代码,这个就是 screeps 的游戏入口函数:

// 游戏入口函数
export const loop = function () {
    console.log('hello world')
}

你可能会发现,不对啊,这和游戏里的入口写法不一样啊。是的,确切来说,我们现在使用的 import / export 是游戏默认使用的 module.exports 语法的升级版本es6),这个语法目前游戏还不支持,所以你没办法直接使用,不过不用担心,稍后我们的 rollup 会自动把这个语法编译成游戏可以理解的样子。

ok,测试代码准备好了,接下来我们来安装 rollup,在终端中执行如下命令即可:

npm install -D rollup

然后在 package.json 中的 scripts 里添加一个 build 字段:

"scripts": {
  "build": "rollup -cw",
  "test": "echo \"Error: no test specified\" && exit 1"
},

现在 rollup 已经安装完成了,但是还无法运行,因为我们还没有告诉 rollup 它要做什么工作,在根目录中新建 rollup.config.js 并填入如下内容: 注意是根目录,不要添加到 src 里了

// 告诉 rollup 他要打包什么
export default {
    // 源代码的入口是哪个文件
    input: 'src/main.js',
    // 构建产物配置
    output: {
        // 输出到哪个文件
        file: 'dist/main.js',
        format: 'cjs',
        sourcemap: true
    }
};

rollup 会默认在根目录下寻找这个名字并作为自己的配置文件。好了现在万事具备,我们在终端中执行:npm run build 即可开始构建:

rollup 构建成功

当看到如图所示的内容后,我们就已经成功执行了第一次编译,你可以在 dist/main.js 目录中看到我们的编译成果:

'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

// 游戏入口函数
const loop = function () {
    console.log('hello world');
};

exports.loop = loop;
//# sourceMappingURL=main.js.map

可以看到,刚才的 export 语法已经被转换成了一些奇奇怪怪的代码,虽然你有可能看不懂,不过没有关系,游戏能看懂就行了。

并且还有一个好消息,虽然你还没有做什么额外的配置,但是现在你的项目已经可以支持创建文件夹了,你可以通过如下内容进行测试:

src/modules/utils.js

/**
 * 打印 hello world
 */
export const sayHello = function () {
    console.log('hello world')
}

src/main.js

// 引入外部依赖
import { sayHello } from './modules/utils'

export const loop = function () {
    sayHello()
}

并且当你把鼠标悬停到函数的调用代码上时,可以发现我们写在函数上的注释被显示出来了!

函数介绍

是的,这都得益于我们使用的 export / import 语法,让 vscode 可以寻找到对应的函数并将其头部注释显示出来,你可以在 这里 这里找到关于 export / import 更详细的用法。

函数的头部注释必须使用多行注释,并且支持 markdown 语法,你也可以使用 jsdoc 风格的注释让 vscode 更智能的提示你的函数。

如果你控制台没有关闭的话,你就会发现每当你保存代码的时候,rollup 都会自动运行构建,然后在 dist 目录中生成最新的代码。真好,让我们高呼自动化永远滴神!现在让我们去看一下构建成果:

没有什么变化

什么!为什么没有看到我的新代码!不用担心,打开 main.js 就可以看到:

'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

/**
 * 封装 hello world 逻辑并导出
 */
const sayHello = function () {
    console.log('hello world');
};

// 引入外部依赖

const loop = function () {
    sayHello();
};

exports.loop = loop;
//# sourceMappingURL=main.js.map

这就是 rollup 的本职工作:通过分析你的依赖代码,将一个复杂嵌套、相互调用的项目打包成单独一个文件,并且还会剔除掉那些没有使用的代码。也就是说,我们最终上传到 screeps 运行的代码只会有这一个文件,这不就另辟蹊径的解决了 screeps 不能使用文件夹的问题了嘛?

ok,代码已经构建好了,但是现在成果代码还在本地电脑上,接下来我们就需要把代码传递给 screeps 服务器,这样才能让 screeps 运行我们写的代码。

步骤2:上传代码到 screeps

rollup 本身支持使用插件进行拓展,我们接下来就使用插件进行代码上传,上传代码到游戏服务器一共有两种方法:

  • 直接上传至服务器:将打包好的代码直接上传到 screeps 服务器,只要启用了 HTTP 访问接口的游戏服务器都可以用(比如官服和一些大型私服 ),但是如果你网络不太好的话,很容易出现上传失败的问题。使用插件 rollup-plugin-screeps
  • 复制到游戏客户端目录:将打包好的代码自动复制到 screeps 的代码存放目录中,借助 screeps 客户端将代码进行上传,所以只有游戏客户端启动时这种方式才有效果。这种一般都是用来访问本地的小型测试服务器,使用插件 rollup-plugin-copy

接下来我们开始进行配置,首先安装我们需要的插件,打开终端指定下面命令:

npm install rollup-plugin-clear rollup-plugin-screeps rollup-plugin-copy -D

先在项目根目录下新建文件 .secret.json 并填入如下内容:

{
    "main": {
        "token": "你的 screeps token 填在这里",
        "protocol": "https",
        "hostname": "screeps.com",
        "port": 443,
        "path": "/",
        "branch": "default"
    },
    "local": {
        "copyPath": "你要上传到的游戏路径,例如 C:\\Users\\DELL\\AppData\\Local\\Screeps\\scripts\\screeps.com\\default"
    }
}

注意需要填写里边的 main.token 字段和 local.copyPath 字段(如果你不想用这种方式的话可以直接不填 ),token 可以从 这里 获取。copyPath 可以通过游戏客户端控制台左下角的 Open local folder 按钮找到。

创建这个文件的目标是因为其中包含了我们的隐私信息,所以需要单独拿出来,如果你想把代码上传到 github 上的话一定要把这个文件加入 .gitignore

接下来我们改造一下 rollup.config.js,下面代码直接覆盖进去即可,注意我们还使用了 rollup-plugin-clear 插件在每次编译前先清空目标代码文件夹:

import clear from 'rollup-plugin-clear'
import screeps from 'rollup-plugin-screeps'
import copy from 'rollup-plugin-copy'

let config
// 根据指定的目标获取对应的配置项
if (!process.env.DEST) console.log("未指定目标, 代码将被编译但不会上传")
else if (!(config = require("./.secret.json")[process.env.DEST])) {
    throw new Error("无效目标,请检查 secret.json 中是否包含对应配置")
}

// 根据指定的配置决定是上传还是复制到文件夹
const pluginDeploy = config && config.copyPath ?
    // 复制到指定路径
    copy({
        targets: [
            {
                src: 'dist/main.js',
                dest: config.copyPath
            },
            {
                src: 'dist/main.js.map',
                dest: config.copyPath,
                rename: name => name + '.map.js',
                transform: contents => `module.exports = ${contents.toString()};`
            }
        ],
        hook: 'writeBundle',
        verbose: true
    }) :
    // 更新 .map 到 .map.js 并上传
    screeps({ config, dryRun: !config })

export default {
    input: 'src/main.js',
    output: {
        file: 'dist/main.js',
        format: 'cjs',
        sourcemap: true
    },
    plugins: [
        // 清除上次编译成果
        clear({ targets: ["dist"] }),
        // 执行上传或者复制
        pluginDeploy
    ]
};

接下来打开 package.json 新增构建命令:

"scripts": {
  "push": "rollup -cw --environment DEST:main",
  "local": "rollup -cw --environment DEST:local",
  ...
},

ok,现在已经一切准备就绪了,接下来我们测试一下。首先执行 npm run push 来直接提交我们的代码到游戏服务器,如果 rollup 还在运行的话,可以先用 ctrl + c 停止:

!!!备份注意,执行该条命令后,线上代码将会被直接覆盖,请在执行前妥善保存!!!

代码提交完成

由于插件是静默提交的,所以当看到控制台开始重新 waiting for changes 时就说明代码已经提交完成了,这时候打开游戏客户端就可以看到我们的代码已经被上传到了服务器并且正常执行了(在网页端上同样可以看到):

代码上传成功

如果你一直看不到代码上传成功,并且过一会之后发现终端里报了错误,这说明你的网络还是不够稳定,你可以直接保存一下来重新触发上传,或者尝试通过其他手段改善你的网络。

接下来我们来试一下第二种方式,打开游戏 steam 客户端后在 vscode 终端执行 npm run local,等待 rollup 编译完成后即可看到上传成功:

代码已复制到指定目录

然后即可在游戏客户端中看到我们上传的代码,如果没有看到的话,请检查你复制到的目标文件夹是否是你当前代码分支对应的文件夹。

使用 SourceMap 校正异常信息

到这里我们的项目配置基本已经告一段落了并且可以使用了... 等等,不太对,最后所有的代码都被打包到了一个文件里,那如果有代码报错的话,它提示的报错位置岂不是和我源代码的不一致?例如我在 src/modules/utils.js 里抛一个错:

/**
 * 显示 hello world
 */
export const sayHello = function () {
    console.log('hello world')
    throw new Error('我是 sayHello 里的报错')
}

然后上传到游戏服务器之后就会发现,这报错信息是在 main.js 里而不是实际的 src/modules/utils.js 啊,难道每次遇到报错我还要打开编译后的代码对比一下才能确定报错的实际位置?

这个报错追踪栈的位置不是我们想要的

是的,你的感觉很敏锐,这虽然不会影响代码的正常运行,但是会让我们在查找 bug 时更加麻烦,那么怎么解决这个问题呢?回想一下,我们的编译产物除了 main.js 之外是不是还有一个东西:

这个 .map 文件是什么呢?它的全称叫做 SourceMap,是一张对照表,能够描述代码编译前后的对应关系,而我们借助一些小工具的帮助,就可以让报错信息直接显示对应的源代码的位置!

我们要借助的小工具名字就叫做 source-map,是一个 npm 的第三方包,执行如下命令即可安装:

npm install source-map@0.6.1

然后新建文件 modules/errorMapper.js 并填入如下内容:

/**
 * 校正异常的堆栈信息
 * 
 * 由于 rollup 会打包所有代码到一个文件,所以异常的调用栈定位和源码的位置是不同的
 * 本模块就是用来将异常的调用栈映射至源代码位置
 * 
 * @see https://github.com/screepers/screeps-typescript-starter/blob/master/src/utils/ErrorMapper.ts
 */

import { SourceMapConsumer } from 'source-map'

// 缓存 SourceMap
let consumer = null

// 第一次报错时创建 sourceMap
const getConsumer = function () {
    if (consumer == null) consumer = new SourceMapConsumer(require("main.js.map"))
    return consumer
}

// 缓存映射关系以提高性能
const cache = {}

/**
 * 使用源映射生成堆栈跟踪,并生成原始标志位
 * 警告 - global 重置之后的首次调用会产生很高的 cpu 消耗 (> 30 CPU)
 * 之后的每次调用会产生较低的 cpu 消耗 (~ 0.1 CPU / 次)
 *
 * @param {Error | string} error 错误或原始追踪栈
 * @returns {string} 映射之后的源代码追踪栈
 */
const sourceMappedStackTrace = function (error) {
    const stack = error instanceof Error ? error.stack : error
    // 有缓存直接用
    if (cache.hasOwnProperty(stack)) return cache[stack]

    const re = /^\s+at\s+(.+?\s+)?\(?([0-z._\-\\\/]+):(\d+):(\d+)\)?$/gm
    let match
    let outStack = error.toString()
    console.log("ErrorMapper -> sourceMappedStackTrace -> outStack", outStack)

    while ((match = re.exec(stack))) {
        // 解析完成
        if (match[2] !== "main") break
        
        // 获取追踪定位
        const pos = getConsumer().originalPositionFor({
            column: parseInt(match[4], 10),
            line: parseInt(match[3], 10)
        })

        // 无法定位
        if (!pos.line) break
        
        // 解析追踪栈
        if (pos.name) outStack += `\n    at ${pos.name} (${pos.source}:${pos.line}:${pos.column})`
        else {
            // 源文件没找到对应文件名,采用原始追踪名
            if (match[1]) outStack += `\n    at ${match[1]} (${pos.source}:${pos.line}:${pos.column})`
            // 源文件没找到对应文件名并且原始追踪栈里也没有,直接省略
            else outStack += `\n    at ${pos.source}:${pos.line}:${pos.column}`
        }
    }

    cache[stack] = outStack
    return outStack
}

/**
 * 错误追踪包装器
 * 用于把报错信息通过 source-map 解析成源代码的错误位置
 * 和原本 wrapLoop 的区别是,wrapLoop 会返回一个新函数,而这个会直接执行
 * 
 * @param next 玩家代码
 */
export const errorMapper = function (next) {
    return () => {
        try {
            // 执行玩家代码
            next()
        }
        catch (e) {
            if (e instanceof Error) {
                // 渲染报错调用栈,沙盒模式用不了这个
                const errorMessage = Game.rooms.sim ?
                    `沙盒模式无法使用 source-map - 显示原始追踪栈<br>${_.escape(e.stack)}` :
                    `${_.escape(sourceMappedStackTrace(e))}`
                
                console.log(`<text style="color:#ef9a9a">${errorMessage}</text>`)
            }
            // 处理不了,直接抛出
            else throw e
        }
    }
}

这段代码的主要作用就是读取同目录下的 main.js.map.js 文件,并用其校正我们的异常追踪栈。接下来我们在 main.js 中引用它(代码直接覆盖即可 ):

import { errorMapper } from './modules/errorMapper'
import { sayHello } from './modules/utils'

export const loop = errorMapper(() => {
    sayHello()
})

这里使用我们刚才定义的错误捕获器包裹整个入口函数,这样其中的代码报错都会被捕获然后进行校正。ok,然后我们把代码提交到游戏:

[下午8:46:34][shard2]Error: Unknown module 'source-map'
    at Object.requireFn (<runtime>:46500:23)
    at main:5:17
    at main:118:3
    at Object.exports.evalCode (<runtime>:15845:76)
    at Object.requireFn (<runtime>:46518:28)
    at Object.exports.run (<runtime>:46461:60)

运行之后发现,代码报错了!我们找不到 source-map 这个模块!这是什么原因导致的呢?

如果我们打开我们上传后的代码,就可以发现编译后的代码使用了下面这行代码来引入 source-map

var sourceMap = require('source-map');

但是我们并没有把这个模块的代码上传到 screeps,自然就出现了找不到模块的问题。所以,接下来我们要 把 source-map 也打包进我们的最终代码。首先我们来安装所需的 rollup 插件:

npm install -D @rollup/plugin-node-resolve @rollup/plugin-commonjs

然后在 rollup.config.js 中引入并调用这两个模块:

// 在代码头部引入包
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'

// ...

// 在 plugins 中调用插件
export default {
    // ...
    plugins: [
        // 清除上次编译成果
        clear({ targets: ["dist"] }),
        // 打包依赖
        resolve(),
        // 模块化依赖
        commonjs(),
        // 执行上传或者复制
        pluginDeploy
    ]
};

现在重新构建并把代码上传到 screeps,这次我们就可以看到刚才的测试报错指向了正确的位置:

报错不再指向 main.js

结束

ok,这次我们真的完成了本阶段的所有配置,下面是项目的文件目录,之后我们在 src 文件中进行游戏代码的开发即可:

本篇教程介绍了如何使用 rollup 完成 screeps 项目的构建,并解决了代码被清、不支持多文件夹以及异常追踪栈不准确的问题。注意最后,我们引入了其他人开发的 source-map 模块,如果你有兴趣的话,也可以尝试把自己的代码封装成模块并发布到 npm,这样其他的玩家就可以按照相同的方式非常简单的引入并使用你的模块。

接下来,你可以开始把精力投入到 screeps 的游玩中,也可以参照 Screeps 使用 TypeScript 进行静态类型检查 来升级你的项目。想要了解更多 Screeps 的中文教程?欢迎访问 Screeps - 中文系列教程

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

推荐阅读更多精彩内容