前言
作为使用 javascript 作为基础语言的游戏,screeps 自然支持使用 typescript(简称 ts)进行编程。本文就将介绍一下在游戏中使用 ts 的优缺点以及如何使用 ts 进行游戏。如果你有 ts 的相关基础的话对于理解本文会非常有帮助。
TypeScript 三分钟简介
首先简单普及一下 typescript:ts 是基于 js 的拓展型语言,它和 js 最大的区别就是 加入了静态类型检查 (像 java 那样)。除此之外 ts 的语法和 js 几乎完全一致。
在你完成代码编写后,可以使用 ts 编译器将.ts
文件编译成原生的.js
文件。是的,我们不能直接向 screeps 服务器提交你的 ts 代码。这个阶段 ts 编译器只做了如下两件事:
- 根据
.ts
文件中的静态类型检查代码是否有问题,检查通过后 移除所有的静态类型代码。 - 将移除了静态类型的代码转化成目标版本(如
es5
)的原生js
代码。
可以看到,ts 并不会让你的代码变的更高级,也不会让你的代码运行的更快。它做的只是增加了一个类型检查阶段。让大多数 “运行时错误” 转化成 “编译时错误”,从而提高代码在运行时的稳定性。
简单介绍就到这里,你可以访问 ts 官方文档 来获得更多内容。
在 Screeps 中使用 TS 的优缺点
这一小节我们来了解一下在游戏里使用 ts 会带来什么影响,在动手将你的项目迁移至 ts 之前,你需要好好考虑下面的内容,然后再做出选择。
优点
- 让代码运行的更稳定:这个是 ts 的主要作用,有一些隐晦的问题(如空值或者类型转换问题)不太容易发现,但是会引起大问题。相信每个人都经历过一觉起来 creep 死完了的情况。而 ts 的静态类型检查可以轻松的发现这些问题并告诉你:嗨,这代码有问题,如果你不解决就别想用它。
- 更强的自动补全和代码提示:实际上,我们在之前教程里使用的代码补全功能就是来自于 ts 的声明文件,但是由于我们写的还是 js 代码,所以编辑器的类型补全并不完整。你应该遇到过把一段代码抽象出来之后类型补全就消失了的问题。通过引入 ts 本体,我们将彻底告别没有类型补全的日子。
- 更加清晰的代码逻辑:通过添加类型,可以让你快速的理解某个变量究竟是代表着什么意思,从而提高代码的可读性。
- 更低的重构成本:重构几乎是每个玩家都会遇到的事情,而由于很多代码之间的依赖关系,你要重构的代码越底层,那么要修改的代码就越多,在使用 js 时,你需要费很大功夫把可疑的代码读一遍然后手动修改,哪怕你读的再仔细,都有可能遗漏一些比较隐晦的问题从而降低代码稳定性。而使用 ts 之后,ts 编译器会自动识别出你重构后的类型接口变更,进而发现因代码修改造成的问题。这样我们只需要修改编译器给出的报错即可轻松完成重构。
缺点
- 更高的学习成本:是的,学习新的 ts 语法!没人觉得这是个有趣的事情吧。当然如果你曾经学习过 java 之类带有静态类型的语言,ts 的语法对你来说不成问题。
- 陷入编写类型声明的泥潭:实际上,编写 ts 代码的大多数时间不是在写游戏代码,而是编写描述这些代码的类型声明。并不是引入 ts 之后就可以白嫖上面的好处,你需要写很多的类型声明,你的类型声明写的越完整(复杂),ts 的代码检查 / 提示能力就越强。并且 ts 严格的检查规则也会让刚接触的同学无从下手。你可能会遇到无论怎么修改代码,ts 的报错都一直在的绝望情境。
更简单的类型检查
其实除了使用 ts 之外,还有一种成本极低的方式来实现对代码的检查,只需要安装一些依赖和对 VSCode 进行一些配置,就可以通过代码中的注释完成类型检查。虽然效果没有 ts 好,但是相对与需要进行侵入式修改的 ts 来说,这种方式的使用成本更低。如果你有兴趣的话,可以通过下面文章来深入了解:
引入 TypeScript
ok!现在让我们开始升级我们的项目!请确保你已经完成了 上篇教程 中的内容,本篇内容会在其基础上进行升级。
和 rollup 类似,ts 也需要一个配置文件来了解我们的构建需求:在项目根目录中创建文件 tsconfig.json
,并填入如下内容:
{
"compilerOptions": {
"target": "es2017",
"moduleResolution": "Node",
"outDir": "dist/",
"baseUrl": "./",
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": [
"node_modules"
],
"include": [
"src/**/*.ts"
]
}
来简单介绍一下,这个文件里包含如下三大块:
- compilerOptions:这个对象里包含了我们的 ts 编译需求,绝大多数配置项都在这个对象里。
-
exclude:将哪些目录排除出 ts 的类型检查,因为
node_modules
是我们的项目依赖目录,所以我们没必要检查这些内容。 - include:启用类型检查的目录,我们的 ts 代码都要包含进来,不然会出现各种奇奇怪怪找不到类型的错误。
新建完后你可能会发现你的 tsconfig 红了,还给了你一个下面这样的报错,不用担心,这是因为我们还没有创建任何 ts 文件,稍后我们改造了 src 目录后重启 VSCode 即可解决这个问题。
配置文件创建好了之后我们就可以安装 ts 的编译器了,得益于我们的项目已经使用了 rollup 进行代码构建,我们可以非常轻松的完成这个步骤,首先在终端中执行如下命令来安装依赖:
npm install --save-dev typescript rollup-plugin-typescript2
其中 typescript
就是实际的 ts 编译器,而 rollup-plugin-typescript2
则是一个插件,用于在 rollup 构建流程中集成 ts 编译。安装完成后我们来升级构建流程,打开 rollup.config.js
,总共需要修改三个地方:
1、在开头引入安装的插件:
// ...
import screeps from 'rollup-plugin-screeps'
import copy from 'rollup-plugin-copy'
import typescript from 'rollup-plugin-typescript2' // <== 新增这一行
2、将打包入口文件改为 main.ts
:
export default {
input: 'src/main.ts', // <== 把这里的 main.js 改为 main.ts
output: {
file: 'dist/main.js', // <== 这里不用修改,因为我们的输出还是 js 文件
// ...
},
// ...
};
3、在构建流程中引入 ts 编译:
export default {
input: 'src/main.ts',
// ...
plugins: [
// ...
// 模块化依赖
commonjs(),
// 编译 ts
typescript({ tsconfig: "./tsconfig.json" }), // <== 新增这一行,注意先后顺序不要搞错了
// 执行上传或者复制
pluginDeploy
]
};
至此,我们的配置已经结束了,其实配置还是挺简单的,复杂的是接下来的工作:把整个项目中的 js 文件都改造为 ts 文件。
将 js 项目改造为 ts 项目
首先,找到我们的入口文件 src/main.js
,将其更名为 main.ts
。
说起来你可以不信,我们的项目改造已经可以告一段落了,现在在终端执行 npm run build
就可以看到我们的构建已经可以跑起来了:
是的,项目改造是可以慢慢来的,ts 项目中允许存在 js 文件,我们可以慢慢的将整个项目迁移到完全 ts。不过我还是推荐找个空闲时间一次性把所有的文件都改造为 ts 文件。在正式开始改造之前,我们先来了解一下 ts 中最重要的概念:类型:
认识 ts 中的类型
在 js 中,每个变量都有类型,例如如下代码,在 vscode 中我们可以通过把鼠标悬停在指定变量上的方式查看他的类型:
这个冒号后面的绿色名字就是这个对象的类型,它的意思是变量 room 的类型为 Room,而明确了类型之后,vscode 就可以通过我们之前安装的声明文件来进行代码补全。然而,随着我们代码的开发和文件拆分,越来越多变量类型将变得模糊起来,例如如下代码:
·./src/role.harvester.js
const harvester = {
run: function (creep) {
// harvester 执行的逻辑
}
}
export default harvester;
其中 harvester.run
函数接受一个参数 creep
,虽然从名字上我们知道他应该是个 creep,但是实际上 这个参数的类型完全取决与调用它的代码,哪怕我们给他传入一个 room 对象,代码依旧可以执行,不过由于让 room 对象执行了 creep 的逻辑,这段代码将会有很大概率报错。而我们的项目复杂之后,一个变量的实际类型将越来越难以确定。
我们可以通过鼠标悬停的方式查看当前这个 creep 参数的类型:
any
也是一个类型,它代表这个变量有可能是任何类型。而这就是导致 run 函数里的 creep 变量没有类型补全的罪魁祸首:vscode 不知道这个变量的确切类型,就无法得知这个变量包含什么属性。而我们引入 ts 以及接下来要对项目进行的改造,就是要让每个变量都有确切的类型,而不是模糊的 any。
ok,接下来我们把这个文件的后缀改造为 .ts
(./src/role.harvester.ts
),改完后缀名我们再次悬停之后可以发现代码补全仍然没有出现。所以接下来我们要通过添加 ts 特有的类型声明代码来显式声明 creep 参数的类型:
通过在 creep 参数后添加 : Creep
,这样就代表这个参数的类型必须是 Creep
,添加之后我们就可以发现类型补全列表中已经出现了 creep 应该存在的属性。
类型推导列表中的紫色小方块代表这是一个方法,蓝色长方体代表这是一个属性,而方法名被划掉代表这个方法处于依旧可用,但是马上要被废弃的不推荐状态。
并且如果我们调用这个方法时错误的传入了不是 Creep 类型的变量,ts 编译器就会立刻报错并打断代码构建:
这也就是我们引入 ts 的意义所在:通过显式指定变量类型,让编译器自动进行类型推导,并在推导出现矛盾时报错,让我们可以在代码上线之前解决问题。
接下来,我们就来介绍一下在项目改造中可能会遇到的常见问题:
问题1:类型上不存在属性 xxx
如下所示,这个问题常见于访问 memory 上的属性,例如 creep.memory
、room.memory
、spawn.memory
以及 Memory
:
出现这个问题的原因是:这些属性都是我们自定义的,ts 编译器并不知道它们的存在,为了安全起见(不允许访问未定义的属性 ),ts 将认为这段代码是有问题的。解决方法如下:
新建 src/index.d.ts
文件,并填入如下内容:
interface CreepMemory {
/**
* 该 creep 的角色
*/
role: string
}
再回来就可以发现报错消失了,并且还显示了我们的注释信息:
这里的 CreepMemory
是一个预设的类型,代表了 creep 的内存格式。你可以通过按住 ctrl 并点击代码的形式查看对应类型的声明位置。
可以看到这是一个非常长的文件,位于 node_modules/@types/screeps
中,这个不就是我们在一开始安装的 @types/screeps
依赖么?没错,这个几千行的文件描述了 screeps 出现的所有 api、属性、以及常量。你完全可以把它当作一个本地的交互式 screeps 文档来查阅。
这个文件中出现的代码和你之后要写的类型声明的语法完全一样,当你不知道什么写的时候就可以来这个文件里学习一下。
说的有点远,我们书接上文,ts 会默认根据变量的类型来这个文件里进行查找,而我们在 index.d.ts
里定义的 interface CreepMemory
其实在这个文件里有一个同名的定义。ts 编译器在找到我们的声明后会和已经出现过的同名类型进行合并,所以我们就可以借助 @types/screeps
中已经存在的声明开发我们的项目。除此之外,我们在后面的开发中还会遇到如下几个类型,这里简单介绍一下:
类型名 | 介绍 |
---|---|
Creep |
游戏中 creep 的类型 |
Room |
游戏中房间的类型 |
Memory |
游戏中的全局内存,除此之外还有 RoomMemory 、CreepMemory 等 |
Game |
游戏主对象类型 |
Structure |
游戏中所有建筑的基础类型,在此基础上派生出了具体的建筑类型,如:StructureSpawn 、StructureExtension ... 所有建筑都是以 Structure 开头的 |
Source |
房间中我们采集的能量矿类型 |
Mineral |
房间中的元素矿类型 |
问题2:找不到定义在游戏对象上的方法
如果你了解过 js 的原型拓展的话,那么你可能在游戏对象例如 creep 或者 room 上已经写了不少自己的拓展方法了,但是切换到 ts 之后你会发现这些方法都没法使用了,如下:
这个问题的原因和上面一样,也是因为我们自定义的方法 ts 编译器并不知道,所以认为这是不安全操作,解决的方法也很简单,直接在对应的类型上定义即可:
interface Creep {
/**
* 打印 hello world
*/
sayHello(): void
}
但是这种操作是有缺陷的,因为这个函数的定义(上面这一段 )和实现(函数拓展的实际代码)并没有强相关。如果我们有这个定义,但是函数没有实现或者没有挂载到原型上的话,ts 编译器并不会报错,但是在实际执行时这个调用就会报错说该函数未定义。所以,如果拓展的入参或者返回值有变化的话,我们就需要手动修改声明文件里的函数定义,这样才不会让 ts 的类型推导出现问题。
实际上,我们进行的原型拓展是一种比较 hack 的操作,ts 并不推荐,所以才会出现这种让人不舒服的写法。更优雅的写法是我们新建一个类,并用这个类来承载我们的自定义属性,如下:
/**
* 自定义的 creep
*/
class MyCreep {
readonly creep: Creep
/**
* 接受一个 creep 进行实例化
*/
constructor(creep: Creep) {
this.creep = creep
}
/**
* 打印 hello world
*/
public sayHello(): void {
console.log('hello world', this.creep.name)
}
}
// 实例化自定义 creep
const creep = new MyCreep(Game.creeps.creepA)
// 可以正常使用
creep.sayHello()
不过要想使用这种方式,你可能需要更加完善的基础代码建设来解决如下问题:
- 更啰嗦的写法:在每次访问前都要 import MyCreep 并进行实例化
- 更高的性能消耗:如果每次新建 creep 变量时都要实例化的话,必然会导致重复的实例化消耗,所以我们需要有一套逻辑来缓存 creep 以提供更加简单的访问方式并解决重复实例化的性能浪费。
-
警惕过期变量:游戏对象例如
Game.creeps
是会 在每个 tick 开始时全部销毁并进行重建,由此来保证变量里的数据都是最新的,这个操作是游戏自动进行的,而我们实例化的 myCreep 并不会被销毁重建,也就是说,如果你开始对这些自定义 creep 进行缓存的话,那你的缓存就可能会存在过期数据,所以还需要另外一套逻辑来解决变量的过期问题。
所以说,对于那些对游戏运行机制还不太熟悉的新手同学来说,我依旧推荐你使用原型拓展并手动维护拓展的声明,因为你花费在升级新框架的时间会多的多,并且由于对机制的不熟悉,还有可能会导致很多奇奇怪怪的问题(没错,绝大多数新手遇到的无法解决的问题都是自己的“框架”导致的,而不是游戏存在 bug )。
不过如果你想借机尝试一下也不是不可以,或许这就是你变强的契机呢。不过在此之前请做好代码备份,给自己留下反悔的机会。
ok,讲完了常见问题,我们来随便聊一下 ts,希望下面的内容会对你的开发有所启发:
ts 的类型推导
我们从刚才就在讲,ts 会自动根据已有的类型声明进行推导,如果你的代码是类型自洽的,那么 ts 就会认为检查通过。但是这并不意味着你要给每个变量指定类型(这是新手最容易进入的误区之一 ),例如如下代码:
const creep: Creep = Game.creeps.creepA
如果我们把上述代码中的 :Creep
去掉,你会发现 creep 的类型依旧为 Creep
:
为什么呢?因为在我们下载的 @type/screeps
中已经声明了:Game.creeps
里的所有子属性的类型都为 Creep,所以就算我们不给 creep 变量指定类型,ts 也会自动的将其推导为 Creep 类型。这也是推荐的写法:尽可能的让变量的类型都由 ts 编译器自行推导得出,如果你发现新建的变量类型为 any,那么你应该去检查是不是之前的类型声明不够完善导致赋值语句的右值类型为 any:
// 🔴 不好的写法
// getCreep 方法的返回值可能为 any,需要手动指定
const getCreep = function () { /** 代码实现 */ }
const creep: Creep = getCreep()
// 🟢 好的写法
// 显式定义 getCreep 方法的返回值,让函数调用结果更明确
const getCreep = function (): Creep { /** 代码实现 */ }
const creep = getCreep()
解耦类型声明
我们刚才在写自定义的类型声明时都是写在 index.d.ts
中的,但是当我们的类型声明越来越多时,这个文件就会变得非常的庞大。那么该如何进行解耦呢?首先要明确一下 ts 的类型声明查找规则:
- 如果一个 ts 文件中没有任何
export / import
语句的话,这个文件中类型声明将被放在全局作用域(GlobalThis
)里,如果有的话,那么类型声明的作用域将被限制在当前文件里。 - ts 会根据
tsconfig.json
中include
和exclude
的定义找到所有范围内的类型声明,并根据其作用域进行声明合并。
可以发现,类型声明的作用域和它的文件夹层级是没有关系的,所以我们可以 在每个模块(文件夹)下都新建一个 types.ts
文件,然后把这个模块包含的声明都写在里边,注意不要包含任何导出导出语句。
或者,你也可以 直接在代码文件里进行类型声明并导出,并在需要这个类型的文件中像引入普通变量一样进行导入,如下:
module.ts:定义类型和函数
export interface MyStorage {
memory: Memory
}
export const setMyStorage = function(storage: MyStorage) {
// ...
}
main.ts:导入并使用类型
import { setMyStorage, MyStorage } from './module';
const storage: MyStorage = {
memory: Memory
}
setMyStorage(storage)
由于这种方式有着更严格的作用域限制,所以更推荐用这种方式进行开发。但是要注意如果一个文件里有导入导出的话,这个文件中 CreepMemory
、Room
之类的声明就不会合并到 GlobalThis
上的 @types/screeps
里,从而导致在使用时依旧找不到我们声明的属性,你需要进行如下修改才可以:
// 导入了一个模块
import RoomWorkTaskController from './module/roomWorkTask'
// 🔴 错误的写法
// Room 接口的作用域在本文件内,其他文件无法访问该定义
interface Room {
/** 工作任务模块 */
work: RoomWorkTaskController
}
// 🟢 正确的写法
// 包裹一层 global,表明这个接口位于全局作用域
declare global {
interface Room {
/** 工作任务模块 */
work: RoomWorkTaskController
}
}
ts 的严格模式
在本文开头的配置里我们并没有启用严格模式,所以在有 js 文件存在时也不会报错。当我们想要更加强大的类型检查时(当然也更加的严格),就可以通过在 tsconfig.json
的 compilerOptions
里添加 "strict": true
来启动严格模式。
当启用了严格模式后,最主要的区别就是代码里将不允许出现隐式 any 类型,你可以由此发现代码里可能存在的隐患,并且严格模式还会启用其他限制,直接百度 ts 严格模式即可,这里不再赘述。
需要注意的是,ts 严格模式是一把双刃剑,在让你代码更加稳定的情况下也会限制你使用一些奇淫巧计,并且需要你投入更多的精力在类型完善上,这对于新手来说不一定是好事,所以我更推荐你根据自身水平和项目情况选择是否开启严格模式。
路径别名
当我们的文件夹层级比较深了之后,可能会遇到如下问题:
// 获取 src/utils.ts 中的函数
import { utilsFun } form '../../../../utils'
你可能需要很多 ../ 来访问外层的模块,这会降低代码可读性,并且在代码层级发生变化时也会导致出现问题。为此,ts 内建了路径别名来解决这个问题,还记得我们开头配置的 tsconfig.json
么?你可以在其中找到 compilerOptions.paths
这个对象。他就是路径别名:
{
"compilerOptions": {
...
"paths": {
"@/*": ["./src/*"]
}
},
...
}
很好理解,上面这个配置的含义就是 @/
路径就代表了 ./src/
路径,所以我们就可以把开头那个导出改写成如下形式:
import { utilsFun } form '@/utils'
这样是不是就舒服多了,除此之外你也可以根据自己需要自行定义路径别名。
结尾
至此,我们已经将 ts 引入了项目,并了解了一些 ts 的常见概念与问题。不过作为 js 的主要方言,ts 的内容还是非常复杂的,本文也只是对 ts 和 screeps 的结合做了一些介绍,如果你之前没有接触过 ts 的话,还是更推荐将其 官方教程 大致读一遍,随着对 ts 学习的不断深入,相信你可以对自己的项目有着更加强大的掌控力。
想要查看更多教程?欢迎访问 《Screeps 中文教程》!或者访问 《Screeps 搭建开发环境 - 导言》 来继续升级你的项目!