大家好,我是小丞同学,这篇文章将带你制作一个贪吃蛇小游戏
非常感谢你的阅读,不对的地方欢迎指正
愿你生活明朗,万物可爱
最近在学习中,再次遇到了贪吃蛇的案例,之前刚学 JavaScript
的时候就有遇到过,趁着这段时间有一点点时间,就跟着做了一下,这篇文章将手把手带你实现一个贪吃蛇的小游戏,难度不会很大,嘻嘻
可以从这个案例中学到以下几点:
面向对象编程、this
指向问题、webpack
简单的配置、
需要实现的功能有以下:
做一个简单的布局,这里主要采用的是 less
和 flex
布局结合
比较有意思的几点
在布局时,采用了全局变量 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 设置旋转一定的角度,好看一点点
这里需要注意的是:由于我们的蛇身以及食物都是需要移动的,我们需要将它们设置为绝定定位方式,并注意父盒子开启相对定位
我们先梳理一下,食物需要先什么属性或者方法吧
X
和 Y
属性定位// 定义食物类 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
取值函数来取值,我们就可以像使用普通变量一样来获取 X
和 Y
值
由于每次食物被吃了之后,我们都需要生成一个新的食物,其实我们也只是让食物换一个位置而已,始终都是同一个 food
节点,这里我们采用的是 random
来生成一个 0-29
的随机数,然后取10倍,这样就能将位置选择为随机的 10
的倍数,同时在地图范围之内
在这里我们还有很多可以改进的地方,例如我门采用了
29
纯数字,这不利于我们对地图的更改,当地图发生改变时,我们需要修改源码才能改善代码,这不大好,我们可以用一个变量来保存噢
在写好 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
方法,让当前的等级提升
在定义完了基本的周边功能后,我们需要正式的对蛇开始进攻了
我们先创建一个 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() {
// 向 element 中添加一个 div
this.element.insertAdjacentHTML("beforeend", "")
}
小科普
insertAdjacentHTML()
方法将指定的文本解析为Element
元素,并将结果节点插入到DOM树中的指定位置。它不会重新解析它正在使用的元素,因此它不会破坏元素内的现有元素。这避免了额外的序列化步骤,使其比直接使用innerHTML
操作更快。指定位置有以下几个
'beforebegin'
:元素自身的前面。'afterbegin'
:插入元素内部的第一个子节点之前。'beforeend'
:插入元素内部的最后一个子节点之后。'afterend'
:元素自身的后面。
现在我们的蛇已经能够添加身体了,但是我们没有添加控制蛇移动的方法,没有办法来展示这个效果
我们继续来看看如何使得蛇能够移动?
我们采用键盘的方向键来控制蛇的移动方向,前面也有提到整个蛇的移动是通过蛇头的驱动的,因此我们先实现控制蛇头的移动
首先我们需要创建一个 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
}
}
我们更改了 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
指向的问题,我们需要手动调整指向当前的类
在处理到这一步时,我们的蛇头已经能够移动了
现在我们的蛇头已经能够移动了,我们可以去触碰食物以及任何地方了,我们现在需要检查是否吃到食物,吃到食物会怎么样,执行什么函数
// 检查是否吃到食物
checkEat(X: number, Y: number) {
if (X === this.food.X && Y === this.food.Y) {
// 食物位置改变
this.food.change()
// 加分
this.scorePanel.addScore()
// 蛇加一
this.snake.addBody()
}
}
在检查是否吃到食物的函数中,我们需要两个参数,也就是蛇头的位置,用来判断是否和食物重叠,如果重叠则改变食物的位置,得分,并且身体加一
现在我们的蛇已经能够吃食物了,但是我们会发现吃完食物后,它的身体不会和它一起走,而是定位到了左上角,因此我们需要处理蛇身移动的问题
由于涉及到 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
类方法,因此会给我们报错提示
当我们的蛇头撞到墙时,我们需要结束游戏,因此我们需要添加一点判断,同时由于蛇只能往一个方向走,因此我们需要优化以下代码,不需要每次都调用 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')
// isLive 设置为 false
this.isLive = false
}
同时结束蛇的生命
由于我们的蛇不能掉头,因此我们需要判断以下用户想反向走时,对这个事件进行处理
我们继续在设置值的函数中添加代码
首先只有一个身体的时候,我们是不需要考虑的,因此我们先要判断是否有第二个蛇身的存在,同时最关键的一点是,这个蛇身的位置是不是和我们即将要行走的 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
}
}
}
当蛇吃到自己时,需要结束游戏,因此我们需要检测是否吃到自己的身体
我们需要遍历以下蛇身的所有位置,与蛇头的位置进行比较,如果有和蛇头相同的位置,则说明蛇头吃到蛇身了
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
还有很多未探索的地方,继续努力吧,也欢迎大家提出自己的意见,或者提一点点的建议,让我们一起成长吧!
非常感谢您的阅读,欢迎提出你的意见,有什么问题欢迎指出,谢谢!