TypeScript|刚学会 TypeScript, 顺手做个贪吃蛇小游戏

TypeScript|刚学会 TypeScript, 顺手做个贪吃蛇小游戏
文章图片

大家好,我是小丞同学,这篇文章将带你制作一个贪吃蛇小游戏
非常感谢你的阅读,不对的地方欢迎指正
愿你生活明朗,万物可爱
前言 最近在学习中,再次遇到了贪吃蛇的案例,之前刚学 JavaScript 的时候就有遇到过,趁着这段时间有一点点时间,就跟着做了一下,这篇文章将手把手带你实现一个贪吃蛇的小游戏,难度不会很大,嘻嘻
可以从这个案例中学到以下几点:
面向对象编程、this 指向问题、webpack 简单的配置、
一、实现效果预览 TypeScript|刚学会 TypeScript, 顺手做个贪吃蛇小游戏
文章图片

需要实现的功能有以下:
  1. 页面布局
  2. 随机生成食物
  3. 分数统计(吃食物数量)
  4. 等级提升(加速)
  5. 蛇成长
  6. 事件监测
  7. 撞身检测
  8. 撞壁检测
  9. 结束判断
二、代码实现 1. 页面布局
做一个简单的布局,这里主要采用的是 lessflex 布局结合
比较有意思的几点
在布局时,采用了全局变量 bg-color 来定义全局颜色,为代码增加了更多的可扩展性
@bg-color: #b7d4a8;

全局采用了 CSS3 中的盒模型 border-box ,避免了由于边框以及边距对盒原大小造成的影响
* {margin: 0; padding: 0; box-sizing: border-box; }

在绘制蛇身时,需要通过在容器内添加 div 标签的方式来设置,蛇的长度,因此在布局时,需要对容器内的 div 标签单独设置样式
// index.html
// index.less #snake {& > div {width: 10px; height: 10px; background-color: black; // 设置间距 border: 1px solid @bg-color; // 开启定位 position: absolute; } }

对于食物的样式,采用的是 flex 加一个小小的旋转
#food {position: absolute; width: 10px; height: 10px; left: 40px; top: 100px; display: flex; flex-flow: row wrap; justify-content: space-between; align-content: space-between; & > div {width: 4px; height: 4px; background-color: black; transform: rotate(45deg); } }

对每个 div 设置旋转一定的角度,好看一点点
这里需要注意的是:由于我们的蛇身以及食物都是需要移动的,我们需要将它们设置为绝定定位方式,并注意父盒子开启相对定位
2. 随机生成食物
我们先梳理一下,食物需要先什么属性或者方法吧
  1. 每个食物要有一个位置,我们通过 XY 属性定位
  2. 同时我们需要一个能够随机生成食物位置的方法
// 定义食物类 Food class Food {// 定义食物元素 element: HTMLElement; constructor() {// 获取页面中的 food 元素给 element this.element = document.getElementById("food")! } // 获取食物 x 轴坐标的方法 get X() {return this.element.offsetLeft } get Y() {return this.element.offsetTop } // 修改食物位置的方法 change() {// 一格大小就是10 let top = Math.round(Math.random() * 29) * 10 let left = Math.round(Math.random() * 29) * 10 this.element.style.left = left + 'px' this.element.style.top = top + 'px' } }

在这里我们创建了一个 Food 类,用来定义食物的位置
首先声明了一个 element 属性,指定为 HTMLElement,在constructor 中需要获取到我们的 food 元素赋值给 element 属性
这里由于 ts 的语法检查机制比较严格,我们需要在获取节点的最后加上一个 ! ,表示信任此处的元素获取
这里 TS 其实是做了预判,它担心我们获取不到这个节点而出错,习惯就好,加个 !
在获取食物坐标的方法中,我们采用了 getter 取值函数来取值,我们就可以像使用普通变量一样来获取 XY
由于每次食物被吃了之后,我们都需要生成一个新的食物,其实我们也只是让食物换一个位置而已,始终都是同一个 food 节点,这里我们采用的是 random 来生成一个 0-29 的随机数,然后取10倍,这样就能将位置选择为随机的 10 的倍数,同时在地图范围之内
在这里我们还有很多可以改进的地方,例如我门采用了 29 纯数字,这不利于我们对地图的更改,当地图发生改变时,我们需要修改源码才能改善代码,这不大好,我们可以用一个变量来保存噢
3. 分数统计
在写好 Food 类之后,我们再来写个简单的 ScorePanel 类,用来设置底部的计分和等级
  1. 我们需要有一个分数记录,一个等级记录,以及修改它们的方法
  2. 为了提高可扩展性,我们需要两个变量来控制限制的最大等级,以及达到多少分升级
class ScorePanel {// 记录分数和等级 score = 0; level = 1; // 分数和等级的元素 scoreEle: HTMLElement levelEle: HTMLElement // 设置一个变量的限制等级 maxLevel: number // 设置一个变量 表示多少分时升级 upScore: number constructor(maxLevel: number = 10, upScore: 10) {this.scoreEle = document.getElementById("score")! this.levelEle = document.getElementById("level")! this.maxLevel = maxLevel this.upScore = upScore } // 设置一个加分方法 addScore() {this.scoreEle.innerHTML = ++this.score + ''; (this.score % this.upScore === 0) && this.levelUp() } // 提升等级的方法 levelUp() {this.level < this.maxLevel && (this.levelEle.innerHTML = ++this.level + '') } }

我们创建了一个 ScorePanel
在这个类中,我们预先设定了很多的变量,在 TS 中我们需要设置它们的使用类型
在这里我们设置了加分的方法
addScore() {this.scoreEle.innerHTML = ++this.score + ''; (this.score % this.upScore === 0) && this.levelUp() }

当我们调用这个函数时,就可以实现分数的增加,然后我们需要对当前的分数进行判断,当分数达到我们设置的升级分数时,我们调用类中的 levelUp 方法,让当前的等级提升
4. 蛇的成长
在定义完了基本的周边功能后,我们需要正式的对蛇开始进攻了
我们先创建一个 snake 类,用来设置蛇自身的特性,比如,位置、长度
首先我们需要设置一些变量,用来存储我们的节点
// 蛇头 head: HTMLElement // 蛇的身体 bodies: HTMLCollection // 获取蛇容器 element: HTMLElement constructor() {this.element = document.getElementById("snake")! this.head = document.querySelector("#snake > div") as HTMLElement this.bodies = this.element.getElementsByTagName("div") }

TS 中,我们尽量设置好,以确保我们的变量不会被我们误用导致错误
我们再来定义 gettersetter 方法,用来获取蛇头的位置,以及设置蛇头的位置
为什么要是蛇头呢?
我们需要通过蛇头的移动方向来驱动这个蛇身的移动,因为每个蛇身块都是跟随着上一块蛇身的
// 获取蛇的坐标 get X() {return this.head.offsetLeft } get Y() {return this.head.offsetTop }

set 中有很多判断,太长了,影响篇幅)
设置好 setget 方法后,我们需要写一个能够使蛇成长的方法,所谓的成长不过就是让 snake 节点中添加多一个 div 元素
// 蛇加身体的方法 addBody() {// 向 element 中添加一个 div this.element.insertAdjacentHTML("beforeend", "") }

小科普
insertAdjacentHTML() 方法将指定的文本解析为 Element 元素,并将结果节点插入到DOM树中的指定位置。它不会重新解析它正在使用的元素,因此它不会破坏元素内的现有元素。这避免了额外的序列化步骤,使其比直接使用 innerHTML 操作更快。
指定位置有以下几个
  • 'beforebegin':元素自身的前面。
  • 'afterbegin':插入元素内部的第一个子节点之前。
  • 'beforeend':插入元素内部的最后一个子节点之后。
  • 'afterend':元素自身的后面。
5. 控制蛇的移动
现在我们的蛇已经能够添加身体了,但是我们没有添加控制蛇移动的方法,没有办法来展示这个效果
我们继续来看看如何使得蛇能够移动?
我们采用键盘的方向键来控制蛇的移动方向,前面也有提到整个蛇的移动是通过蛇头的驱动的,因此我们先实现控制蛇头的移动
首先我们需要创建一个 GameControl 类,作为这个游戏的控制器,用来控制蛇的移动
首先我们需要有一个键盘响应事件,用来获取用户的键盘事件,同时我们需要对按键进行判断,是否是能够控制蛇移动的四个键
因此我们可以编写两个函数 keydownHandle 键盘事件响应函数 、run 函数主控制器,判断用户按下的是什么键执行对应变化
我们可以将这两个函数封装到 init 函数中,作为初始化函数一并启动
init() {// 绑定键盘事件 document.addEventListener("keydown", this.keydownHandle.bind(this)) this.run() }

在这个函数里,由于我们需要采用 TS 的检查机制,我们可以将事件回调分离成一个函数,但是由于这里的回调调用对象是 document ,我们需要手动更改 this 的指向
我们在 keydownHandle 中处理键盘事件,通过一个 direaction 变量来记录当前的按键
// 存储蛇的移动方向 direction: string = ''// 键盘响应函数 keydownHandle(event: KeyboardEvent) {// 检查是否合法 this.direction = event.key }

根据 direction 来判断 蛇移动的方向
// 创建蛇移动的方法 run() {let X = this.snake.X let Y = this.snake.Y // 根据按键方向修改值 switch (this.direction) {// 向上 top减少 case "ArrowUp": Y -= 10 break // 向下 top 增加 case "ArrowDown": Y += 10 break // 向左 left 减少 case "ArrowLeft": X -= 10 break // 向右 left 增加 case "ArrowRight": X += 10 break } }

我们更改了 XY 值后,我们需要将它重新赋值给 snake 中的对应值,由于我们设置了 setter 函数,我们可以直接赋值
this.snake.X = X; this.snake.Y = Y;

我们通过对四个方向键的 switch 判断,我们使得我们能够控制蛇的移动,但是现在这样还不足以达到不断移动的效果,我们需要实现按下一个方向键后,就不停的向一个方向移动,因此我们可以在 run 中开启一个定时器,使得它能够递归的调用 run
// 递归调用 this.isLive && setTimeout(this.run.bind(this), 300 - (this.scorePanel.level - 1) * 30)

由于我们的蛇有死亡机制,我们需要预先判断以下,这里也存在着 this 指向的问题,我们需要手动调整指向当前的类
在处理到这一步时,我们的蛇头已经能够移动了
TypeScript|刚学会 TypeScript, 顺手做个贪吃蛇小游戏
文章图片

6. 检查吃到食物
现在我们的蛇头已经能够移动了,我们可以去触碰食物以及任何地方了,我们现在需要检查是否吃到食物,吃到食物会怎么样,执行什么函数
// 检查是否吃到食物 checkEat(X: number, Y: number) {if (X === this.food.X && Y === this.food.Y) {// 食物位置改变 this.food.change() // 加分 this.scorePanel.addScore() // 蛇加一 this.snake.addBody() } }

在检查是否吃到食物的函数中,我们需要两个参数,也就是蛇头的位置,用来判断是否和食物重叠,如果重叠则改变食物的位置,得分,并且身体加一
7. 控制蛇身移动
现在我们的蛇已经能够吃食物了,但是我们会发现吃完食物后,它的身体不会和它一起走,而是定位到了左上角,因此我们需要处理蛇身移动的问题
由于涉及到 snake 本身的特性,因此我们回到 snake 类中编写
// 添加一个蛇身体移动的方法 moveBody() {//位置在前一个蛇块的位置 for (let i = this.bodies.length - 1; i > 0; i--) {let X = (this.bodies[i - 1] as HTMLElement).offsetLeft; let Y = (this.bodies[i - 1] as HTMLElement).offsetTop; (this.bodies[i] as HTMLElement).style.left = X + 'px'; (this.bodies[i] as HTMLElement).style.top = Y + 'px'; } }

我们通过循环,从蛇的最后一个蛇块开始遍历,让它的位置变成前一个蛇块的位置
这样就能一个接着一个移动了,不理解的可以想一想噢~
在这段代码中,遇到了很多类型断言的问题,由于 TS 检查机制中不确定数组元素中有没有 offset 类方法,因此会给我们报错提示
8. 撞墙检测
当我们的蛇头撞到墙时,我们需要结束游戏,因此我们需要添加一点判断,同时由于蛇只能往一个方向走,因此我们需要优化以下代码,不需要每次都调用 set Xset Y ,当新值和旧值相同时,我们可以直接返回
set Y(value) {// 如果新值和旧值相同,则直接返回不再修改 if(this.Y === value){return; } if (value < 0 || value > 290) {throw new Error('蛇撞墙了') } // 移动身体 this.moveBody(); this.head.style.top = value + 'px'; }

当撞墙时,我们抛出一个错误,然后可以在 GameControl 中采用 try...catch 来捕获这个错误,做出指示
try { this.snake.X = X; this.snake.Y = Y; } catch (e: any) {alert(e.message + 'GAME OVER') // isLive 设置为 false this.isLive = false }

同时结束蛇的生命
9. 掉头检测
由于我们的蛇不能掉头,因此我们需要判断以下用户想反向走时,对这个事件进行处理
我们继续在设置值的函数中添加代码
首先只有一个身体的时候,我们是不需要考虑的,因此我们先要判断是否有第二个蛇身的存在,同时最关键的一点是,这个蛇身的位置是不是和我们即将要行走的 value 值相等
什么意思呢?
在蛇移动的时候,第二节蛇身的位置应该是第一节的位置,蛇头的位置是value 的位置,当蛇头反向时,它的值就会变成第二节身体的位置
TypeScript|刚学会 TypeScript, 顺手做个贪吃蛇小游戏
文章图片

画个图好理解一点,圆圈表示蛇头即将到达的位置,右边的方块是蛇头
因此我们添加这段代码,当满足掉头条件时,我们继续让它前进
set Y(value) {// 有没有第二个身体 if (this.bodies[1] && (this.bodies[1] as HTMLElement).offsetTop === value) {// 如果掉头,应该继续前进 if (value > this.Y) {value = https://www.it610.com/article/this.Y - 10 } else {value = this.Y + 10 } } }

10. 撞身检测
当蛇吃到自己时,需要结束游戏,因此我们需要检测是否吃到自己的身体
我们需要遍历以下蛇身的所有位置,与蛇头的位置进行比较,如果有和蛇头相同的位置,则说明蛇头吃到蛇身了
checkHeadBody() {// 获取所有的身体,检查是否重叠 for (let i = 1; i < this.bodies.length; i++) {let bd = this.bodies[i] as HTMLElement if (this.X === bd.offsetLeft && this.Y === bd.offsetTop) {throw new Error('撞到自己了') } } }

由于这里我们需要多次类型断言,就提取出来单独断言了
三、总结 整个贪吃蛇游戏的框架就这么多了,在写这篇文章的时候,可以有一些代码篇幅过长,对代码有一点的缩减,可能会影响到阅读或者理解,请见谅
从这个案例中,简单的对 TypeScript 有了一定的认知,但仍然有很多的知识没有被涉及到,感觉这个案例不大行,还需要再练习一下。总的来说,Typescript 相对于 javascipt 来说有很多的限制,这些限制让潜在的未知 bug 都显示了出来,有助于代码的维护同时能够让开发者减少后期找 bug 的苦恼
自己对于 typescript 还有很多未探索的地方,继续努力吧,也欢迎大家提出自己的意见,或者提一点点的建议,让我们一起成长吧!
【TypeScript|刚学会 TypeScript, 顺手做个贪吃蛇小游戏】非常感谢您的阅读,欢迎提出你的意见,有什么问题欢迎指出,谢谢!

    推荐阅读