原生js贪吃蛇游戏

315 阅读15分钟

一、前言

作者第一次写掘金文章,有很多不足之点,请多多包涵。

这是一个贪吃蛇游戏。用到的技术栈有:html、css、js,没有使用其他框架或者插件。

二、实现的功能

  1. 按钮三种情况:

    点击开始按钮,实现游戏开始,贪吃蛇开始移动,若键盘的方向键没被触发,则蛇默认向右移动。

    点击暂停按钮,实现游戏暂停,贪吃蛇静止状态。

    点击重新按钮,实现游戏重新开始。

  2. 蛇的情况:

    若蛇头碰到墙,则游戏结束并提示用户,用户点确定键后重新渲染页面

    若蛇头碰到蛇身,则游戏结束并提示用户,用户点确定键后重新渲染页面

    若蛇头碰到食物,积分将会加1,蛇长度会加1,食物会消失并重新再渲染一个到页面

三、运行效果

image.png

四、思路及代码

tip: 完整代码可直接运行,在最后面!莫急

1. html的编写

<body>
  <div id="btn">
    <button class="start">开始</button>
    <button class="stop">暂停</button>
    <button class="reset">重新</button>
  </div>

  <p class="fraction">积分: <span class="integral">0</span></p>

  <!-- 网格 -->
  <div id="grid"></div>
</body>

2. css的样式

新建index.css文件,并用link导入index.html的head标签内。

具体样式:body和子元素的居中,按钮的外观

body {
  width: 800px;
  display: flex;
  flex-direction: column;
  margin: 60px auto;
  align-items: center;
}
/* 按钮样式 */
button {
  width: 150px;
  display: inline-block;
  padding: 15px 25px;
  font-size: 24px;
  cursor: pointer;
  text-align: center;
  text-decoration: none;
  outline: none;
  color: #fff;
  background-color: #4CAF50;
  border: none;
  border-radius: 15px;
  box-shadow: 0 9px #999;
  margin: 0 5px 30px 0;
}

button:hover {
  background-color: #3e8e41
}

button:active {
  background-color: #3e8e41;
  box-shadow: 0 5px #666;
  transform: translateY(4px);
}

/* 积分 */
.fraction {
  font-size: 30px;
}

3. 初始化地图

首先创建一个map.js,表示地图,并放入到index.html的body尾部。

思路:设置地图(网格)的行列数,以及封装一个渲染网格到页面的函数init。

函数:根据行列数去渲染多个div。再分别设置每行和每列的类名,css则编写类名对应的样式,然后将列渲染到行,行则渲染到grid标签内。

此外:①列在创建时还添加id名,是为了方便后期修改数据。②网格四周为墙,因此在css对四周的格子添加border边框即可。

// 设置行列数
let rows = 15, cols = 15
let gird = document.getElementById('grid')

// 说明:初始化网格
function init() {
  // 创建行
  for(let i = 0; i < rows; i++) {
    let row = document.createElement('div')
    row.className = 'row'
    // 创建列
    for(let j = 0; j < cols; j++) {
      let col = document.createElement('div')
      
      col.className = 'col'
      // 后期修改数据需访问id
      col.id = 'col-' + i + '-' + j
      row.appendChild(col)
    }
  
      // 渲染到页面
    gird.appendChild(row)
    
  }
}

/* 行 */
.row {
  display: flex;
}

.col {
  width: 30px;
  height: 30px;
  border: 1px solid #3c3c3c;
}

/* 外围:墙 */
.row:first-child {
  border-top: 5px solid #000;
}

.row:last-child {
  border-bottom: 5px solid #000;
}

.col:first-child {
  border-left: 5px solid #000;
}

.col:last-child {
  border-right: 5px solid #000;
}

由于是初始化的函数,没被调用则不会执行。则需再创index.js;放在所有js标签的结尾,负责管理所有js代码。

// 初始化网格
init()

网格渲染完毕~

4. 创建食物

首先创建一个food.js,表示食物,并放到map.js下。

思路:设置食物的初始化位置,以对象格式存储行列及数值,即 let food = {x: 2, y: 2}, 接着封装一个食物随机的位置的函数randomFood。

randomFood函数:用随机数去定义行列数的随机位置范围,再次赋给食物的行和列,最后将食物渲染到页面。(可封装为一个函数renderFood)其中:还需设置食物位置不能出现蛇本身,具体后文会补充。

renderFood函数:根据当前食物的行列值,去找该值对应的div的id(这就是上文为什么要给div设置id的原因),找到后给其赋类名为food, 同时不能丢失原来的网格的类名col。最后到css给该类名设置背景颜色即是食物。

// 食物初始值
let food = {x: 2, y: 2}

// 说明:随机食物的变化
// 食物初始值
let food = {x: 2, y: 2}

// 说明:随机食物的变化
function randomFood() {
  // 只能出现在网格内的随机位置
  let x = Math.floor(Math.random() * rows)
  let y = Math.floor(Math.random() * cols)

  // 再次更新位置
  food = {x: x, y: y}
  
  // 渲染食物到页面
  renderFood()
}

// 说明:渲染食物到页面
function renderFood() {
  // 根据当前的行列值,找到该值对应的div的id
  let div = document.getElementById('col-' + food.x + '-' + food.y)
  // 它即为食物,同时不能丢失原来的列名
  div.className = 'col food'
}
.food {
  background-color: #dd2323; // 红色
}

效果则需在index.js内的其他代码下,调用该函数

// 随机食物的位置
randomFood()

食物渲染完毕~

5. 创建蛇

首先创建一个snake.js,表示蛇,并放到food.js下。

思路:设置蛇的初始位置,以数组内为对象格式存储行列及数值,即 let snake = [{x: 8, y: 6}, {x: 8, y: 5}, {x: 8, y: 4}], 接着封装一个渲染蛇到页面的函数renderSnake。

renderSnake函数:根据当前蛇长度,去找该值对应的div的id,找到后给其赋类名为snake, 同时不能丢失原来的网格的类名col。最后到css给该类名设置背景颜色即是蛇。

// 蛇的初始位置
let snake = [{x: 8, y: 6}, {x: 8, y: 5}, {x: 8, y: 4}];

// 说明:渲染食物到页面
function renderSnake() {
  for(let i = 0; i < snake.length; i++) {
    // 根据当前的行列值,找到该值对应的div的id
    let div = document.getElementById('col-' + snake[i].x + '-' + snake[i].y)
    // 找到后即赋类名为蛇,同时不能丢失原来的网格类名
    div.className = 'col snake'
  }
}
.snake {
  background-color: #3c3c;
}

效果则需在index.js内的其他代码下,调用该函数

// 蛇的位置
renderSnake()

蛇渲染完毕~

接下来是关于游戏开始出现的各种情况啦!!!

6. 根据蛇头的方向去走动

返回到snake.js文件,继续封装函数。

思路:先定义蛇头方向为右,再去封装一个根据蛇头方向去走动的函数updateSnake。

updateSnake函数:先获取蛇数组的蛇头坐标,存进一个对象,根据蛇头方向去改变对象的x或y坐标,再将新的蛇头加到原数组前面,以及移除蛇尾。

说直接点:蛇根据方向去走动,走前一步即把该步更换为蛇头,蛇尾则删除一节。

// 蛇头位置
let direction = 'right'

function updateSnake() {
  // 获取蛇头的坐标
  let head = { x: snake[0].x , y: snake[0].y}
  // 根据蛇头的方向去改变坐标位置
  switch(direction) {
    case "up":
        head.x--; // 向上移动一格
        break;
    case "down":// 向下移动一格
        head.x++;
        break;
    case "left":// 向左移动一格
        head.y--;
        break;
    case "right":// 向右移动一格
        head.y++;
        break;
  }

  // 把新蛇头添加原数组前面
  snake.unshift(head)

  // 移除蛇尾
  snake.pop()
}

测试:先用定时器执行蛇,是否按蛇头方向走动。

setInterval(function(){
  renderSnake() // 更新蛇的位置
  updateSnake() // 根据蛇头的方向去走动
},1000)

测试后会发现有小bug,蛇会一直变长,是因为蛇在更新前没有清掉原来的蛇,因此会一直叠加。解决办法:先注释该定时器,再看第 7

7. 清空原来的蛇

返回到snake.js文件,继续封装函数。

思路:根据蛇的长度,去获得对应的div的id,将其格子去掉snake的类名,最后封装为一个函数clearSnake。

// 说明:蛇走动时,清掉原来的蛇
function clearSnake() {
  for (let i = 0; i < snake.length; i++) {
    // 获取原来的蛇的位置
    let div = document.getElementById('col-' + snake[i].x + '-' + snake[i].y)
    // 清掉该蛇,相当于不写类名snake,同时不能丢失网格的类名col
    div.className = 'col'
  }
}

测试:继续用定时器执行蛇,成功运行。

setInterval(function(){
  clearSnake()
  updateSnake() // 根据蛇头的方向去走动
  renderSnake() // 更新蛇的位置
},1000)

等蛇头走到墙,会发现控制台报错,是因为还没设置游戏机制:蛇头撞到墙,游戏到此结束。

8.蛇头撞到墙

起因:蛇头撞到墙,说明已超出网格的范围,没有网格(div)给蛇占位了,此时蛇要更新位置会找不到该元素,返回null,而null被赋类名,因此会报错。或者根据定时器得出报错信息: Cannot set properties of null (setting 'className')看是第几行有问题。

思路:在snake.js的更新蛇位置的函数内,做一个判断,若蛇获得的位置是null,即是撞墙了,那么就返回false出去,不是的话就继续赋类名。而index.js则对该函数做一层判断,将它的返回值存在一个变量,若变量为flase,表示撞墙,则游戏结束。(若是测试的话则假设清除定时器和提示用户)

// 判断是否撞墙了
    if(div !== null) {
      // 找到后即赋类名为蛇,同时不能丢失原来的网格类名
      div.className = 'col snake'
    }else {
      return false
    }

测试:继续用定时器执行蛇。

let gameOver = false
let flag
let timer = setInterval(function(){
  clearSnake()
  updateSnake() // 根据蛇头的方向去走动
  flag = renderSnake() // 更新蛇的位置
  if(flag === false) {
    clearInterval(timer) 
    alert('你输了,游戏结束')
  }

在此会发现蛇没办法控制上下左右,一直向右走,因此需要借助方向键去控制蛇。看第9。

9.以方向键去控制蛇头的值

思路:index.js监听键盘事件,触发则调用snake.js的方向键改变蛇头方向的函数keydownHandler并传键值。 而snake.js封装一个函数,根据键值去修改蛇头的方向值。(方向值:38=>上、39=>右、40=>下、37=>左)

// 监听键盘事件
document.addEventListener('keydown', function(e) {
  keydownHandler(e.keyCode)
})
// 以方向键去控制蛇头的值
function keydownHandler(val) {
  switch(val) {
    case 38: // 上
    direction = "up";
    break;
    case 40: // 下
        direction = "down";
        break;
    case 37: // 左
        direction = "left";
        break;
    case 39: // 右
      direction = "right";
      break;
  }
}

此时看运行效果,蛇可以根据用户的方向键开始移动了,那下一步就是看蛇是否吃到食物了。

10.蛇吃到食物

若蛇移动的位置是食物的位置,表示吃到食物,那么蛇长度加1,原食物消失,重新渲染食物,积分要加1。

总思路:(在snake.js的updateSnake的函数里)根据蛇头的方向去走动时,在移除蛇尾时做一层判断,若蛇头的位置和食物的位置一致,则表示吃到食物了,不移除蛇尾,积分要做加1的操作,若位置不一致则移除蛇尾。

积分思路:新建integral.js文件,放到snake.js和DOM元素结束之间都可。内部定义变量初值为0,以及一个函数,每调用一次,变量就会加1,并渲染到DOM元素上。

let integral = document.querySelector('.integral')
let sum = 0
// 每调用一次,变量就会加1,并渲染到DOM元素上
function add() {
  sum++
  integral.innerText = sum
}
    // 判断是否吃到食物
  if (head.x == food.x && head.y == food.y) {
     // 积分要加1的操作
     add()
     // 吃到则重新渲染食物且不移除蛇尾
    randomFood()
  }
  else {
    snake.pop() // 移除蛇尾
  }

游戏内部功能基本完成,接下来是游戏过程了。

11. 游戏过程

先把index.js原来的定时器删除掉,再去创建一个process.js,表示进程,并放到snake.js下。

思路:index.js声明全局变量来判断游戏是否正在进行状态,接着封装一个函数到process内,若是进行时,则执行清空蛇原来的位置、更新蛇的位置、更新食物位置的变化。若执行完毕需判断蛇头的情况,更新蛇的位置的返回值若为false,则提示用户并做一步退出游戏操作的处理,反之用延时器1秒后继续以上操作。(这就是为什么要在更新蛇的位置的函数内接收一个false返回值的原因了)

游戏退出操作(函数):①游戏状态应该为true,表示游戏已经结束了。②提示用户失败的原因。③重新刷新页面,让用户再玩一次。

// 游戏是否结束
let gameOver = false
// 游戏循环
function gameLoop() {
  // 若进行时
  if(!gameOver) {
    // 清空蛇原来的位置
    clearSnake()
    // 更新蛇的位置
    updateSnake()
    // 渲染蛇的位置
    let flag = renderSnake()

    // 执行完毕后,判断蛇的位置的情况
    if(flag === false) {
      failure()
    }else {
      setTimeout(gameLoop, 600)
    }
  }
}

// 说明:游戏结束
function failure() {
  // 游戏状态
  gameOver = true
  // 提示用户已失败
  alert('游戏结束')
  // 重新刷新页面再玩一局
  location.reload()
}

由于函数没被调用,想要看效果则需在index.js调用

    gameLoop()

测试完毕后记得注释或删除该行!

12. 三大按钮

总思路:process内获取三大按钮的DOM元素和封装三个函数,而index.js根据点击不同的按钮会进入不同的函数。

let btn = document.getElementById('btn')
let start = btn.querySelector('.start')
let stop = btn.querySelector('.stop')
let reset = btn.querySelector('.reset')

// 说明:点击开始按钮执行的函数
function startHandler() {

}


// 说明:点击暂停按钮执行的函数
function stopHandler() {

}


// 说明:点击重新按钮执行的函数
function resetHandler() {

}

// 注意‘=’后面已经是一个函数了,无需加()
// 4-1.游戏开始
start.onclick = startHandler
// 4-2.游戏暂停
stop.onclick = stopHandler
// 4-3.游戏重新
reset.onclick = resetHandler

开始按钮的函数

思路:游戏状态为正在进行时->游戏过程(测试的函数)->设置开始按钮是禁用的->暂停按钮是可点击的。

// 说明:点击开始按钮执行的函数
function startHandler() {
  // 状态:正在进行时
  gameOver = false
  // 游戏过程
  gameLoop()
  // 开始按钮禁用
  start.disabled = true
  // 暂停按钮开启
  stop.disabled = false
}

可直接去页面运行效果

暂停按钮的函数

思路:游戏状态假设为结束状态->设置开始按钮可点击->暂停按钮是禁用的。

// 说明:点击暂停按钮执行的函数
function stopHandler() {
  // 游戏假设为结束状态
  gameOver = true
  // 开始按钮可点击
  start.disabled = false
  // 暂停按钮禁用
  stop.disabled = true
}

可直接去页面运行效果

重新按钮的函数

思路:调用js内置函数的刷新页面函数,相当于按f5。

// 说明:点击重新按钮执行的函数
function resetHandler() {
  // 刷新页面
  location.reload()
}

可直接去页面运行效果

13. 细节:食物不能出现在蛇身上

思路:渲染食物的函数,当食物获取随机位置的行列数后,进一步用while不断去调用是否在蛇身上的函数,重新获取行列数,直到找到一个不在蛇身体上的坐标为止。

是否在蛇身上的函数:接收外部行列参数,遍历蛇本身,根据蛇每一部分去判断是否和行列一致,是则表示食物在蛇身上,返回true,循环结束都发现不一致,表示不是在蛇身上,则返回false。

// 说明:判断食物的位置是否出现会出现在蛇的每个身上
function isSnakeBody(x, y) {
  for(let i = 0; i < snake.length; i++) {
    if(x === snake[i].x && y === snake[i].y) {
      return true // 说明是在蛇身上了
    }
  }
  return false // 说明不是在蛇身上了

}
    // randomFood()函数内,获取随机位置后的补充代码
   // 不能在蛇身体生成食物
  // 不断尝试生成新的坐标,直到找到一个不在蛇身体上的坐标为止
  while(isSnakeBody(x,y)) {
    x = Math.floor(Math.random() * rows)
    y = Math.floor(Math.random() * cols)
  }

14. 细节:蛇头不能碰到蛇身

思路:snake.js封装一个函数isSnakeHitSelf,判断蛇头是否会等于每一个蛇身,是的话返回true,不是则返回false。再到process.js的游戏循环函数,找出关于判断蛇位置的情况,再添加一个判断条件,调用isSnakeHitSelf,若返回true则表示碰到自己,那么需结束游戏。

// 说明:判断蛇头是否会等于每一个蛇身
function isSnakeHitSelf() {
  for(let i = 1; i < snake.length; i++) { // 蛇头的下一个开始算起
    if(snake[0].x === snake[i].x && snake[0].y === snake[i].y){
      return true
    }
  }
  // 蛇头不会碰到自己
  return false
}
// gameLoop函数内
 else if(isSnakeHitSelf()) { // 蛇头是否碰到蛇身
      failure()
}

15. 细节:暂停按钮的禁用

思路:游戏还没有开始,不让用户点击暂停按钮,在获取暂停按钮元素后,直接将它的状态设为禁用。

// ... 获取DOM元素
stop.disabled = true

16. 细节:提示用户失败原因

思路:在游戏正在进行时,利用一个字符串变量,在蛇撞墙或碰到自己时,给变量赋不同的文字,再传入游戏结束函数。游戏结束函数则修改弹出信息为形参值。

// gameLoop函数内的修改
// 提示词
let point = ''

 if(flag === false) { // 是否撞墙
      point = '蛇头撞到墙了,游戏结束'
      failure(point)
 }
else if(isSnakeHitSelf()) { // 蛇头是否碰到蛇身
  point('蛇头碰到蛇身,游戏结束')
  failure(point)
}

// failure函数内的修改
function failure(point) { // 接收参数
  alert(point) // 修改传入的值
}

17. 细节:两个刷新页面的代码

在process.js函数内,会发现有两个刷新页面的代码:location.reload(),是游戏结束和重新按钮的,因此在游戏结束后可直接调用重新按钮的函数。

// 说明:游戏结束
function failure(point) {
  // 重新刷新页面再玩一局
  resetHandler() 
}

18.拓展

本案例没有清除食物的函数,因为在蛇吃到食物后,调用食物更新位置,也就是食物原来的位置被覆盖掉,重新渲染新的位置。若有需要的可参考以下代码。

// 说明:清除食物位置标记
function clearFood() {
  // 根据当前蛇节的坐标获取对应的格子元素
  let cellElement = document.getElementById("col-" + food.x + "-" + food.y);
  cellElement.className = "col";
}

五、源代码

1.gitee官网直接搜:@mini25的demo合集的原生js的snake文件 2.点击链接跳转到我的仓库 原生js · mini25/demo合集 - 码云 - 开源中国 (gitee.com)

六、致谢

感谢读者能看到这里,如果你对文章中的某些内容感到困惑或不理解,可以在评论区提问,我会尽力为你解答。此外,你也可以分享自己的观点和看法,与其他读者进行交流和讨论。

七、总结

第一次写文章的经历是一次富有挑战性和收获的旅程!!!