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

举报
阿童木 发表于 2021/11/26 19:20:47 2021/11/26
【摘要】 📢 大家好,我是小丞同学,这篇文章将带你制作一个贪吃蛇小游戏📢 非常感谢你的阅读,不对的地方欢迎指正 🙏📢 愿你生活明朗,万物可爱 前言最近在学习中,再次遇到了贪吃蛇的案例,之前刚学 JavaScript 的时候就有遇到过,趁着这段时间有一点点时间,就跟着做了一下,这篇文章将手把手带你实现一个贪吃蛇的小游戏,难度不会很大,嘻嘻可以从这个案例中学到以下几点:面向对象编程、this 指向...

📢 大家好,我是小丞同学,这篇文章将带你制作一个贪吃蛇小游戏

📢 非常感谢你的阅读,不对的地方欢迎指正 🙏

📢 愿你生活明朗,万物可爱

前言

最近在学习中,再次遇到了贪吃蛇的案例,之前刚学 JavaScript 的时候就有遇到过,趁着这段时间有一点点时间,就跟着做了一下,这篇文章将手把手带你实现一个贪吃蛇的小游戏,难度不会很大,嘻嘻

可以从这个案例中学到以下几点:

面向对象编程、this 指向问题、webpack 简单的配置、

一、实现效果预览

贪吃蛇

需要实现的功能有以下:

  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
<!---->
<div id="snake">
    <!-- 蛇身 -->
    <div></div>
</div>

// 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", "<div></div>")
}

小科普

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 指向的问题,我们需要手动调整指向当前的类

在处理到这一步时,我们的蛇头已经能够移动了

snake-2

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 的位置,当蛇头反向时,它的值就会变成第二节身体的位置

image-20210920113407364

画个图好理解一点,圆圈表示蛇头即将到达的位置,右边的方块是蛇头

因此我们添加这段代码,当满足掉头条件时,我们继续让它前进

set Y(value) {
    // 有没有第二个身体
    if (this.bodies[1] && (this.bodies[1] as HTMLElement).offsetTop === value) {
        // 如果掉头,应该继续前进
        if (value > this.Y) {
            value = 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 还有很多未探索的地方,继续努力吧,也欢迎大家提出自己的意见,或者提一点点的建议,让我们一起成长吧!

非常感谢您的阅读,欢迎提出你的意见,有什么问题欢迎指出,谢谢!🎈

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。