📢 大家好,我是小丞同学,这篇文章将带你制作一个贪吃蛇小游戏
📢 非常感谢你的阅读,不对的地方欢迎指正 🙏
📢 愿你生活明朗,万物可爱
前言
最近在学习中,再次遇到了贪吃蛇的案例,之前刚学 JavaScript 的时候就有遇到过,趁着这段时间有一点点时间,就跟着做了一下,这篇文章将手把手带你实现一个贪吃蛇的小游戏,难度不会很大,嘻嘻
可以从这个案例中学到以下几点:
面向对象编程、this 指向问题、webpack 简单的配置、
一、实现效果预览
需要实现的功能有以下:
- 页面布局
- 随机生成食物
- 分数统计(吃食物数量)
- 等级提升(加速)
- 蛇成长
- 事件监测
- 撞身检测
- 撞壁检测
- 结束判断
二、代码实现
1. 页面布局
做一个简单的布局,这里主要采用的是 less 和 flex 布局结合
比较有意思的几点
在布局时,采用了全局变量 bg-color 来定义全局颜色,为代码增加了更多的可扩展性
@bg-color: #b7d4a8;
全局采用了 CSS3 中的盒模型 border-box ,避免了由于边框以及边距对盒原大小造成的影响
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
在绘制蛇身时,需要通过在容器内添加 div 标签的方式来设置,蛇的长度,因此在布局时,需要对容器内的 div 标签单独设置样式
<!-- 蛇 -->
<div id="snake">
<!-- 蛇身 -->
<div></div>
</div>
#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. 随机生成食物
我们先梳理一下,食物需要先什么属性或者方法吧
- 每个食物要有一个位置,我们通过
X 和 Y 属性定位 - 同时我们需要一个能够随机生成食物位置的方法
class Food {
element: HTMLElement;
constructor() {
this.element = document.getElementById("food")!
}
get X() {
return this.element.offsetLeft
}
get Y() {
return this.element.offsetTop
}
change() {
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 取值函数来取值,我们就可以像使用普通变量一样来获取 X 和 Y 值
由于每次食物被吃了之后,我们都需要生成一个新的食物,其实我们也只是让食物换一个位置而已,始终都是同一个 food 节点,这里我们采用的是 random 来生成一个 0-29 的随机数,然后取10倍,这样就能将位置选择为随机的 10 的倍数,同时在地图范围之内
在这里我们还有很多可以改进的地方,例如我门采用了 29 纯数字,这不利于我们对地图的更改,当地图发生改变时,我们需要修改源码才能改善代码,这不大好,我们可以用一个变量来保存噢
3. 分数统计
在写好 Food 类之后,我们再来写个简单的 ScorePanel 类,用来设置底部的计分和等级
- 我们需要有一个分数记录,一个等级记录,以及修改它们的方法
- 为了提高可扩展性,我们需要两个变量来控制限制的最大等级,以及达到多少分升级
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 中,我们尽量设置好,以确保我们的变量不会被我们误用导致错误
我们再来定义 getter 和 setter 方法,用来获取蛇头的位置,以及设置蛇头的位置
为什么要是蛇头呢?
我们需要通过蛇头的移动方向来驱动这个蛇身的移动,因为每个蛇身块都是跟随着上一块蛇身的
get X() {
return this.head.offsetLeft
}
get Y() {
return this.head.offsetTop
}
(set 中有很多判断,太长了,影响篇幅)
设置好 set 和 get 方法后,我们需要写一个能够使蛇成长的方法,所谓的成长不过就是让 snake 节点中添加多一个 div 元素
addBody() {
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) {
case "ArrowUp":
Y -= 10
break
case "ArrowDown":
Y += 10
break
case "ArrowLeft":
X -= 10
break
case "ArrowRight":
X += 10
break
}
}
我们更改了 X 、Y 值后,我们需要将它重新赋值给 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 指向的问题,我们需要手动调整指向当前的类
在处理到这一步时,我们的蛇头已经能够移动了
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 X 和 set 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')
this.isLive = false
}
同时结束蛇的生命
9. 掉头检测
由于我们的蛇不能掉头,因此我们需要判断以下用户想反向走时,对这个事件进行处理
我们继续在设置值的函数中添加代码
首先只有一个身体的时候,我们是不需要考虑的,因此我们先要判断是否有第二个蛇身的存在,同时最关键的一点是,这个蛇身的位置是不是和我们即将要行走的 value 值相等
什么意思呢?
在蛇移动的时候,第二节蛇身的位置应该是第一节的位置,蛇头的位置是value 的位置,当蛇头反向时,它的值就会变成第二节身体的位置
画个图好理解一点,圆圈表示蛇头即将到达的位置,右边的方块是蛇头
因此我们添加这段代码,当满足掉头条件时,我们继续让它前进
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 还有很多未探索的地方,继续努力吧,也欢迎大家提出自己的意见,或者提一点点的建议,让我们一起成长吧!
非常感谢您的阅读,欢迎提出你的意见,有什么问题欢迎指出,谢谢!🎈
|