前些天逛知乎看到了篇文章,用 TypeScript 类型运算实现一个中国象棋程序,边看边喊牛逼,原来还可以这么玩?想我之前学了TypeScript
后,也就是做一些TypeScript
类型体操姿势合集,后面项目里也很少用到忘得也差不多了,借此机会重新温习一下相关内容。
扫雷的主要功能就是点击格子、标记格子、检查游戏结果:
点击<游戏, 横坐标, 纵坐标>
标记<游戏, 横坐标, 纵坐标>
检查游戏<游戏>
扫雷游戏里,每个格子只会有三种类型:地雷,空白,数字(表示周围8个格子中的炸弹数),另外每个格子可以进行旗子标记,也可以点击显示其原来的内容,因此我们这样定义:
type 空白 = '空白'
type 数字 = '数字'
type 炸弹 = '炸弹'
type 格子类型 = 空白 | 数字 | 炸弹
type 格子内容 = {
类型: 格子类型,
周围炸弹数: number,
被点击: boolean,
被标记: boolean
}
type 构造格子内容<
类型 extends 格子类型,
炸弹数 extends number = 0,
被点击 extends boolean = false,
被标记 extends boolean = false
> = {
类型: 类型,
被点击: 被点击,
周围炸弹数: 炸弹数,
被标记: 被标记
}
由于扫雷游戏的界面都是标准的N*M个格子组成,所以很自然而然地我们就会想到用一个二维数组来表示结构:
type 横坐标 = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
type 纵坐标 = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
type 一行格子 = [
格子内容,
格子内容,
格子内容,
格子内容,
格子内容,
格子内容,
格子内容,
格子内容,
格子内容
]
type 游戏 = [
一行格子,
一行格子,
一行格子,
一行格子,
一行格子,
一行格子,
一行格子,
一行格子,
一行格子
]
这里我们定义一个9 * 9的扫雷游戏,一个游戏有九行,每行就9个格子。
我们就可以这样定义一格扫雷游戏了:
type 一局游戏 = [
[
构造格子内容<空白>,
构造格子内容<空白>,
构造格子内容<数字, 1>,
...
],
...
]
同时二维数组也方便我们直接使用下标获取对应某个格子:
type 某个格子 = 一局游戏[0][4]
接下来是核心的部分了,我们需要点击格子,标记格子,即更改格子内容
中的被点击
和被标记
这两个属性,但是我们没法直接去修改定义好的类型值,因此我们可以换个思路,直接新建一个格子替换掉旧格子就行(参见之前的文章提到的方法)。
我们游戏结构是游戏
-一行格子
-格子
,所以我们要更新某个格子需要先找到对应要更新格子所在的行
,更新对应位置的格子
,然后再更新该行。
那么如何替换一个数组里面的某一项呢?这里我们可以使用递归的思路,如果是替换第0项,那只要把新内容和原类型数组剩余的项合并起来就行了,如果是替换第3项,那就把原类型数组的前两项和新内容,以及第3项后面的内容合并起来即可。
具体实现即如下:
type 替换某一行的某个格子<
某一行,
新的格子,
替换横坐标 extends 横坐标,
暂存替换格子前面所有格子的数组 extends any[] = []
> = 某一行 extends [infer 头, ...infer 剩余的]
? 替换横坐标 extends 0
? [...暂存替换格子前面所有格子的数组, 新的格子, ...剩余的]
: 替换某一行的某个格子<剩余的, 新的格子, 减一<替换横坐标>, [...暂存替换格子前面所有格子的数组, 头]>
: []
这里每次递归都是拿出某行内的第一个格子放到暂存的数组里面,当替换坐标减到0时,表示此时传入的行数组第一个格子即为需要替换的格子,此时将之前暂存的和新格子以及剩余的数组合并,即为替换后的结果。
这里用到了一个
减一
的类型,这里简单说明一下,虽然Typescript
没有加减的操作,但可以利用数组下标查值的方法来实现加一,减一。
type 加法表 = [
1,
2,
3,
4,
5,
6,
7,
8,
9,
never
]
type 减法表 = [
never,
0,
1,
2,
3,
4,
5,
6,
7,
8,
9
]
type 查表<
表格,
数字,
值 = never
> = 数字 extends keyof 表格 ? 表格[数字] : 值
type 加一<数字> = 查表<加法表, 数字>
type 减一<数字> = 查表<减法表, 数字>
type 五 = 减一<6>
同理替换某行也许类似思路:
type 替换某一行的某个格子<
某一行,
新的格子,
替换横坐标 extends 横坐标,
暂存前面的数组 extends any[] = []
> = 某一行 extends [infer 头, ...infer 剩余的]
? 替换横坐标 extends 0
? [...暂存前面的数组, 新的格子, ...剩余的]
: 替换某一行的某个格子<剩余的, 新的格子, 减一<替换横坐标>, [...暂存前面的数组, 头]>
: []
合并上述两个方法,即可实现出替换游戏中某个格子的方法:
type 替换游戏中的某个格子<
某局游戏 extends 游戏,
新格子 extends 格子内容,
替换横坐标 extends 横坐标,
替换纵坐标 extends 纵坐标
> = 替换游戏的某行<
某局游戏,
替换某一行的某个格子<
某局游戏[替换纵坐标], 新格子, 替换横坐标
>,
替换纵坐标
>
有了替换游戏中的某个格子
这格类型后,我们实现标记,点击格子就很容易了,本质上就是通过坐标找到原来的格子,使用其原来的属性加上变化后的被标记或被点击属性来构造出新的格子,然后进行替换即可:
type 点击<
某局游戏 extends 游戏,
横 extends 横坐标,
纵 extends 纵坐标
> = 某局游戏[纵][横] extends infer 旧格子
? 旧格子 extends 格子内容
? 旧格子['被点击'] extends false
? 旧格子['被标记'] extends false
? 替换游戏中的某个格子<
某局游戏,
构造格子内容<
旧格子['类型'],
旧格子['周围炸弹数'],
true,
旧格子['被标记']
>,
横,
纵
>
: 某局游戏
: 某局游戏
: never
: never
type 标记<
某局游戏 extends 游戏,
横 extends 横坐标,
纵 extends 纵坐标
> = 某局游戏[纵][横] extends infer 旧格子
? 旧格子 extends 格子内容
? 旧格子['被点击'] extends false
? 替换游戏中的某个格子<
某局游戏,
构造格子内容<
旧格子['类型'],
旧格子['周围炸弹数'],
旧格子['被点击'],
旧格子['被标记'] extends true ? false : true
>,
横,
纵
>
: 某局游戏
: never
: never
这里需要注意的一点是,只有未被标记和被点击的格子,才需要替换,所以点击里面有这两个判断:旧格子['被点击'] extends false
, 旧格子['被标记'] extends false
;只有没被点击的格子才能进行标记,而且标记可以重复取反。
于是我们已经可以开始进行游戏了:
type g2 = 点击<某局游戏, 4, 2>
type r2 = 渲染游戏<g2>
type c2 = 检查游戏<g2>
基本上核心功能已经实现了,为了让玩家有更好的体验,我们还需要做一个渲染类型来渲染该局游戏现在的状态。
首先我们需要定义好各种状态下显示字符:
type 旗子格子图 = '🚩'
type 炸弹格子图 = '💣'
type 空白格子图 = '🟦'
type 未点击格子图 = '🟪'
type 数字格子图数组 = ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣']
其次我们先考虑单个格子该如何渲染:
- 未被点击,
- 未被标记,显示🟪
- 被标记,显示🚩
- 被点击
- 是空白格子,显示🟦
- 是炸弹,显示💣
- 是数字,显示对应周围炸弹数对应的图片1️⃣2️⃣3️⃣4️⃣5️⃣6️⃣7️⃣8️⃣
翻译成TypeScript
:
type 渲染格子<某个格子 extends 格子内容> = 某个格子['被点击'] extends false
? 某个格子['被标记'] extends true
? 旗子格子图
: 未点击格子图
: 某个格子['类型'] extends 空白
? 空白格子图
: 某个格子['类型'] extends 炸弹
? 炸弹
: 数字格子图数组[某个格子['周围炸弹数']]
接下来渲染某一行格子:
type 渲染一行格子<
某一行格子,
渲染结果 extends string = ''
> = 某一行格子 extends [infer 第一个格子, ...infer 剩余格子]
? 第一个格子 extends 格子内容
? 渲染一行格子<剩余格子, `${渲染结果}${渲染结果 extends '' ? '' : ' '}${渲染格子<第一个格子>}`>
: ''
: ''
由于在TypeScript
里,我们没办法控制IDE的换行,故这里需要把结果渲染成对象结果(这里参考该文提供的方法)
type 渲染游戏<
某局游戏 extends 游戏
> = {
[key in 纵坐标]: 渲染一行格子<某局游戏[key]>
}
PS: 这里有时候渲染出来,
key
并没有按照0-8从小到大的顺序排列,不懂还如何处理。
一下我们可以到到渲染出来的游戏结果:
最后我们需要实现一个功能,来检验当前游戏是否结束,即只需判断是否有💣类型的格子被点击或者是所有非炸弹格子都被点击,来判断游戏是否成功或失败。
即游戏只会有三种状态:
- 游戏失败
- 游戏成功
- 游戏仍可继续
我们先定义好这三个类型:
type 游戏成功 = '✔️'
type 游戏失败 = '❌'
type 游戏仍可继续 = '🧹'
这里我们的策略是一个个格子检查过去,如果检查到某个格子:
- 被点击
- 非炸弹类型,则可以检查下一格子,如果之前没有标记继续, 则标记成功
- 是炸弹类型,直接返回失败
- 没被点击
- 非炸弹类型,则检查下一个格子,标记为继续(之前有标记成功的话,直接替换掉)
- 是炸弹类型,则检查下一个格子
所有格子检查结束后:
- 有碰到失败的,就是游戏失败
- 最后标记为继续的,则表示游戏仍可继续
- 最后标记为成功的,则表示成功
理清好逻辑后,我们很快就得到如下代码:
type 检查格子<
某个格子 extends 格子内容
> = 某个格子['被点击'] extends true
? 某个格子['类型'] extends 炸弹
? 游戏失败
: 游戏成功
: 某个格子['类型'] extends 炸弹
? 游戏成功
: 游戏仍可继续
type 检查某一行<
某一行,
结果 extends 游戏结果 = 游戏成功
> = 某一行 extends [infer 第一个格子, ...infer 剩余的]
? 第一个格子 extends 格子内容
? 检查格子<第一个格子> extends infer 第一个结果
? 第一个结果 extends 游戏失败
? 游戏失败
: 第一个结果 extends 游戏仍可继续
? 检查某一行<剩余的, 游戏仍可继续>
: 结果 extends 游戏仍可继续
? 检查某一行<剩余的, 游戏仍可继续>
: 检查某一行<剩余的, 游戏成功>
: never
: never
:结果
type 检查游戏<
某局游戏,
结果 extends 游戏结果 = 游戏成功
> = 某局游戏 extends [infer 第一行, ...infer 剩余行]
? 第一行 extends 一行格子
? 检查某一行<第一行> extends infer 第一个结果
? 第一个结果 extends 游戏失败
? 游戏失败
: 第一个结果 extends 游戏仍可继续
? 检查游戏<剩余行, 游戏仍可继续>
: 结果 extends 游戏仍可继续
? 检查游戏<剩余行, 游戏仍可继续>
: 检查游戏<剩余行, 游戏成功>
: never
: never
: 结果
以上,一个有最基础扫雷功能的游戏就完成了
本文仅在于功能实现,一些实现方法有点问题,例如检查游戏
的方法应该有更好的办法(暂时没想到)。
在线预览地址, 鼠标放在渲染游戏
返回的类型,就可以看到游戏内容了。
另外还有一些功能需要实现,暂时没想清楚:
- 点击空白格子,扩散周围其他空白格子
- 通过传入炸弹坐标,即可构建游戏
- ...