欢迎上车。今天我们将开始一场激动人心的冒险,在这里我们将开发属于我们自己的贪食蛇游戏。通过将其分解为一个个简短的步骤来学习如何解决问题。在这段旅程结束时,你会学到一些新东西,并且有信心能独立探索更多。

开始吧

首先新建一个文件“snake.html”,它将包含全部代码。

因为这是一个 HTML 文件,所以要做的第一件事就是申明 <!DOCTYPE>。请在 snake.html 文件中输入以下内容:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>

  <body>
    Welcome to Snake!
  </body>
</html>

不错,接下来在你最喜欢的浏览器中打开 snake.html。你应该能够看到 Welcome to Snake!。

创建画布

为了完成游戏,我们需要用到 <canvas> 标签,并借助 JavaScript 绘制图像。

用以下代码来替换 snake.html 里的欢迎语。

<canvas id="gameCanvas" width="300" height="300"><canvas>

id 用于标识画布且始终应该被指定。稍后我们会用它来访问画布。width 和 height 分别是画布的宽和高,同样也需要被指定。在本例中,画布尺寸为 300 px * 300 px。

snake.html 文件此时应该是这样的:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <canvas id="gameCanvas" width="300" height="300"></canvas>
  </body>
</html>

如果你刷新页面,你会发现页面一片空白。这是因为默认情况下,画布是空的并且没有背景。让我们来调整下。

为画布添加背景色和边框

为了看到画布,我们可以写一段 JavaScript 代码给它加一个边框。为此,我们需要在 </canvas> 后添加 <script></script> 标签,并在其中编写 JavaScript 代码。

如果你把 <script> 标签放在了 <canvas> 标签前面,那么你的 JavaScript 代码将不会生效,因为此时 HTML 还未加载完。

现在我们来写一些 JavaScript 代码,写在闭合的 <script></script> 标签之间。代码如下:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <script>
      /** 常量 **/
      const CANVAS_BORDER_COLOUR = 'black';
      const CANVAS_BACKGROUND_COLOUR = "white";

      // 获取 canvas 元素
      var gameCanvas = document.getElementById("gameCanvas");
      // 返回一个二维绘图上下文
      var ctx = gameCanvas.getContext("2d");
      // 选择画布的背景颜色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      // 选择画布的边框颜色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 绘制一个“实心的”长方形来覆盖整个画布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 绘制画布的“边框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
    </script>
  </body>
</html>

首先,使用前面指定的 id(gameCanvas)获取 canvas 元素。然后获取画布的“2d”上下文,这意味着我们将在 2D 空间绘制图像。

最后,我们画了一个 300 x 300 的白色矩形,边框为黑色。这个矩形从左上角(0,0)开始覆盖了整个画布。

如果你在浏览器中重新加载 snake.html,你会看到一个带黑色边框的白块!干得漂亮,现在我们已经有了一个画布,可以用来创建我们的贪食蛇游戏了!

用坐标来表示贪食蛇

为了让游戏能玩,我们需要知道蛇在画布中的位置。为此,我们用一系列坐标来表示蛇。因此,要在画布中间(150,150)画一条横向的蛇,我们可以这样写:

let snake = [
  {x: 150, y: 150},
  {x: 140, y: 150},
  {x: 130, y: 150},
  {x: 120, y: 150},
  {x: 110, y: 150},
];

注意蛇所有部位的 y 坐标都是 150。每一部位的 x 坐标比(左边)前一个部位多 10 px。数组中的第一对坐标 {x: 150, y: 150} 表示蛇头,位于蛇的最右边。

别急,在下一步我们画蛇的时候,会对此有更清晰的认识。

开始画蛇

为了画蛇,我们可以写一个函数为蛇身上的每一个部位画一个矩形。

function drawSnakePart(snakePart) {
  ctx.fillStyle = 'lightgreen';
  ctx.strokestyle = 'darkgreen';
  ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
  ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
}

接下来,我们用另一个函数在画布上把蛇展示出来。

function drawSnake() {
  snake.forEach(drawSnakePart);
}

此时 snake.html 文件应该是这样的。

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <script>
      /** 常量 **/
      const CANVAS_BORDER_COLOUR = 'black';
      const CANVAS_BACKGROUND_COLOUR = "white";
      const SNAKE_COLOUR = 'lightgreen';
      const SNAKE_BORDER_COLOUR = 'darkgreen';

      let snake = [
        {x: 150, y: 150},
        {x: 140, y: 150},
        {x: 130, y: 150},
        {x: 120, y: 150},
        {x: 110, y: 150}
      ]

      // 获取 canvas 元素
      var gameCanvas = document.getElementById("gameCanvas");
      // 返回一个二维绘制上下文
      var ctx = gameCanvas.getContext("2d");
      //  选择画布的背景颜色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      //  选择画布的边框颜色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 绘制一个“实心的”长方形来覆盖整个画布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 绘制画布的“边框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
      drawSnake();

      /**
       * 在画布上画蛇
       */
      function drawSnake() {
        // 循环遍历蛇的每一部分,并将其绘制到画布上
        snake.forEach(drawSnakePart)
      }
      /**
       * 在画布上画蛇的一个部分
       * @param { object } snakePart —— 需要绘制的部位的所在坐标
       */
      function drawSnakePart(snakePart) {
        // 设置蛇身体的背景颜色
        ctx.fillStyle = SNAKE_COLOUR;
        // 设置蛇身的边框色
        ctx.strokestyle = SNAKE_BORDER_COLOUR;
        // 在蛇身坐标所在的位置,绘制“实心”的矩形以表示蛇    
        ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
        // 绘制蛇身的边框
        ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
      }
    </script>
  </body>
</html>

刷新页面,你会发现画布中间有一条绿色的蛇。

让贪食蛇横向移动

接下来我们想要让蛇能动起来。

嗯,为了让蛇能够一步一步(10 px)移动到最右边,我们可以把蛇的每个部位的 x 坐标,每次增加 10 px(dx = +10 px)。同理,为了让蛇移动到最左边,每次把蛇的每个部位的 x 坐标减少 10 px(dx = -10)。

dx 是蛇的横向移动速度。

蛇向右移动了 10 px 后,坐标如下图所示:

[图片上传失败...(image-21b4ae-1546593847219)]

用 advanceSnake 函数来更新蛇的状态。

function advanceSnake() {
  const head = {x: snake[0].x + dx, y: snake[0].y};
  snake.unshift(head);
  snake.pop();
}

首先,我们为蛇画了个新头。然后使用 unshift 方法将新头放在蛇的第一个部位,然后使用 pop 方法移除蛇的最后一部分。这样以后,如上图所示,所有蛇身上其他部位都移动到了对应位置。

让贪食蛇纵向移动

为了让蛇能够上下移动,我们不能直接将蛇的所有 y 坐标调整 10 px。那会让整条蛇上下移动。

相反,我们可以调整蛇头的 y 坐标。减少 10 px 蛇会向下移动,增加 10 px 蛇会向上移动。这样就能让蛇正确地移动了。

幸运的是,因为我们编写的 advanceSnake 函数很棒,所以这很容易做到。在 advanceSnake 函数中,更新 head 让 y 坐标随着 dy 变化。

const head = {x: snake[0].x + dx, y: snake[0].y + dy};

为了验证 advanceSnake 函数是否正确,我们可以临时性地在 drawSnake 函数前调用它。

// 向右走一步
advanceSnake()
// 水平速度改为 0
dx = 0;
// 垂直速度改为 10
dy = -10;
// 向上走一步
advanceSnake();
// 在画布上画蛇
drawSnake();

现在 snake.html 代码如下:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <script>
      /** 常量 **/
      const CANVAS_BORDER_COLOUR = 'black';
      const CANVAS_BACKGROUND_COLOUR = "white";
      const SNAKE_COLOUR = 'lightgreen';
      const SNAKE_BORDER_COLOUR = 'darkgreen';

      let snake = [
        {x: 150, y: 150},
        {x: 140, y: 150},
        {x: 130, y: 150},
        {x: 120, y: 150},
        {x: 110, y: 150}
      ]

      // 横向移动速度
      let dx = 10;
      // 纵向移动速度
      let dy = 0;

      // 获取 canvas 元素
      var gameCanvas = document.getElementById("gameCanvas");
      // 返回一个二维绘制上下文
      var ctx = gameCanvas.getContext("2d");
      //  选择画布的背景颜色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      //  选择画布的边框颜色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 绘制一个“实心的”长方形来覆盖整个画布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 绘制画布的“边框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);

      // 向右走一步
      advanceSnake()
      // 水平速度改为 0
      dx = 0;
      // 垂直速度改为 10
      dy = -10;
      // 向上走一步
      advanceSnake();
      // 在画布上画蛇
      drawSnake();

      /**
        * 根据蛇的水平移动速度改变蛇的 x 坐标,
        * 根据蛇的垂直移动速度改变蛇的 y 坐标
        */
      function advanceSnake() {
        const head = {x: snake[0].x + dx, y: snake[0].y + dy};
        snake.unshift(head);
        snake.pop();
      }

      /**
       * 在画布上画蛇
       */
      function drawSnake() {
        // 循环遍历蛇的每一部分,并将其绘制到画布上
        snake.forEach(drawSnakePart)
      }
      /**
       * 在画布上画蛇的一个部分
       * @param { object } snakePart —— 需要绘制的部位的所在坐标
       */
      function drawSnakePart(snakePart) {
        // 设置蛇身体的背景颜色
        ctx.fillStyle = SNAKE_COLOUR;
        // 设置蛇身的边框色
        ctx.strokestyle = SNAKE_BORDER_COLOUR;
        // 在蛇身坐标所在的位置,绘制“实心”的矩形以表示蛇    
        ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
        // 绘制蛇身的边框
        ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
      }
    </script>
  </body>
</html>

刷新页面,蛇动了!

clipboard.png

自己是一个五年的前端工程师

这里推荐一下我的前端学习=交流=q==u=n=:731771211,里面都是学习前端的,从最基础的HTML+CSS+JS【炫酷特效,游戏,插件封装,设计模式】到移动端HTML5的项目实战的学习资料都有整理,送给每一位前端小伙伴,欢迎加入。

点击:加入

重构代码

在继续下一步之前,让我们重构一下现有代码,将绘制画布的代码封装在一个函数中。这有助于完成下一步。

代码重构是重构现有计算机代码的过程,同时不改变其外部行为。
function clearCanvas() {
  ctx.fillStyle = "white";
  ctx.strokeStyle = "black";
  ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
  ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
}

让贪食蛇自动移动

好的,既然我们已经成功地重构了代码,那么接下来让贪食蛇能够自动移动吧。

之前为了测试 advanceSnake 函数是否正确,我们调用了它两次。一次为了让蛇向右移动,一次为了让它向上移动。

因此如果想要让蛇右移五次,那么我们需要连续调用 advanceSnake() 函数五次。

clearCanvas();
advanceSnake();
advanceSnake();
advanceSnake();
advanceSnake();
advanceSnake();
drawSnake();

但是,如上所示连续五次调用 advanceSnake 函数会让蛇一下子向前跳 50 px。

相反,我们想让蛇看起来像是一步一步地前进。

为此,我们用 setTimeout 在每次调用之间添加一个短暂的延时。我们还需要确保每次调用 advanceSnake 函数时都调用 drawSnake 函数。如果我们不这样做,我们将无法看到蛇移动的中间步骤。

setTimeout(function onTick() {
  clearCanvas();
  advanceSnake();
  drawSnake();
}, 100);
setTimeout(function onTick() {
  clearCanvas();
  advanceSnake();
  drawSnake();
}, 100);
...
drawSnake();

注意我们同样在每个 setTimeout 中调用了 clearCanvas() 函数。这是为了移除蛇先前所有的位置,以免留下痕迹。

尽管如此,上述代码仍然有问题。没有任何代码告诉程序它必须等待一个 setTimeout 完成后才能移动到下一个setTimeout。也就是说蛇仍然会在一个短暂延迟后向前跳 50 px。

要解决这个问题,我们必须将代码包装在函数中,每次只调用一个函数。

stepOne();

function stepOne() {
  setTimeout(function onTick() {
    clearCanvas();
    advanceSnake();
    drawSnake();
   // 调用第二个函数
   stepTwo();
  }, 100)
}
function stepTwo() {
  setTimeout(function onTick() {
    clearCanvas();
    advanceSnake();
    drawSnake();
    // 调用第三个函数
    stepThree();
  }, 100)
}
...

如何让贪食蛇一直前进?我们可以改为创建一个 main 函数并递归调用它,而不是创建无数个相互调用的函数。

function main() {
  setTimeout(function onTick() {
    clearCanvas();
    advanceSnake();
    drawSnake();
    // 再次调用 main 函数
    main();
  }, 100)
}

贪食蛇现在会一直向右移动。虽然一旦它到达画布的尽头,将继续其无尽的旅程并进入未知的地方。我们将在适当的时候解决这个问题,耐心点年轻的朋友们

image

调整蛇的移动方向

我们的下一个任务是在按下任意方向键时更改蛇的移动方向。在 drawSnakePart 函数之后添加以下代码。

function changeDirection(event) {
  const LEFT_KEY = 37;
  const RIGHT_KEY = 39;
  const UP_KEY = 38;
  const DOWN_KEY = 40;
  const keyPressed = event.keyCode;
  const goingUp = dy === -10;
  const goingDown = dy === 10;
  const goingRight = dx === 10;
  const goingLeft = dx === -10;
  if (keyPressed === LEFT_KEY && !goingRight) {
    dx = -10;
    dy = 0;
  }
  if (keyPressed === UP_KEY && !goingDown) {
    dx = 0;
    dy = -10;
  }
  if (keyPressed === RIGHT_KEY && !goingLeft) {
    dx = 10;
    dy = 0;
  }
  if (keyPressed === DOWN_KEY && !goingDown) {
    dx = 0;
    dy = 10;
  }
}

这没有什么特别的。我们检查按下的键是否与其中一个方向键匹配。如果匹配到,我们如上所述改变垂直和水平速度。

请注意,我们还要检查蛇是否往新预期方向的相反方向上移动。这是为了防止我们的蛇掉头,例如当蛇向左移动时按下右键。

要将 changeDirection 加到游戏代码中,可以在 document 上使用 addEventListener 来“监听”是否有键被按下。然后我们随着 keydown 事件调用 changeDirection 方法。接着在 main 函数之后添加以下代码。

document.addEventListener("keydown", changeDirection)

你现在应该可以使用四个方向键更改蛇的方向。

接下来让我们看看如何生成食物并让我们的蛇长大。

为蛇生成食物

为了生成蛇食,我们必须生成一组随机坐标。我们可以使用辅助函数 randomTen 来生成两个数字。一个用于 x 坐标,一个用于 y 坐标。

我们还必须确保食物不与蛇的位置重叠。如果重叠了,我们必须生成一个新的食物位置。

function randomTen(min, max) {
  return Math.round((Math.random() * (max-min) + min) / 10) * 10;
}
function createFood() {
  foodX = randomTen(0, gameCanvas.width - 10);
  foodY = randomTen(0, gameCanvas.height - 10);
  snake.forEach(function isFoodOnSnake(part) {
    const foodIsOnSnake = part.x == foodX && part.y == foodY
    if (foodIsOnSnake)
      createFood();
  });
}

然后我们需要写一个在画布上绘制食物的函数。

function drawFood() {
 ctx.fillStyle = 'red';
 ctx.strokestyle = 'darkred';
 ctx.fillRect(foodX, foodY, 10, 10);
 ctx.strokeRect(foodX, foodY, 10, 10);
}

最后我们可以在调用 main 之前调用 createFood。别忘了还要更新 main 函数以调用 drawFood 函数。

function main() {
  setTimeout(function onTick() {
    clearCanvas();
    drawFood()
    advanceSnake();
    drawSnake();
    main();
  }, 100)
}

让蛇长大

让蛇长大很简单。更新 advanceSnake 函数,检查蛇头是否碰到了食物。如果碰到了,我们可以跳过移除蛇的最后部分这一操作,同时创建一个新的食物位置。

function advanceSnake() {
  const head = {x: snake[0].x + dx, y: snake[0].y};
  snake.unshift(head);
  const didEatFood = snake[0].x === foodX && snake[0].y === foodY;
  if (didEatFood) {
    createFood();
  } else {
    snake.pop();
  }
}

记录游戏分数

为了让游戏更有乐趣,我们还可以添加一个分数,当蛇吃食物时分数增加。

在声明了 snake 后,创建一个新的变量 score,并将其设为 0。

let score = 0;

接下来在画布前添加一个 id 为“score”的新 div,用于显示分数。

<div id="score">0</div>
<canvas id="gameCanvas" width="300" height="300"></canvas>

最后更新 advanceSnake, 当蛇吃食物时,增加并显示分数。

function advanceSnake() {
  ...
  if (didEatFood) {
    score += 10;
    document.getElementById('score').innerHTML = score;
    createFood();
  } else {
    ...
  }
}

胜利就在眼前了

结束游戏

还剩下最后一部分,那就是结束游戏。为此创建一个函数 didGameEnd,当游戏结束时返回 true,否则返回 false。

function didGameEnd() {
  for (let i = 4; i < snake.length; i++) {
    const didCollide = snake[i].x === snake[0].x &&
      snake[i].y === snake[0].y
    if (didCollide) return true
  }
  const hitLeftWall = snake[0].x < 0;
  const hitRightWall = snake[0].x > gameCanvas.width - 10;
  const hitToptWall = snake[0].y < 0;
  const hitBottomWall = snake[0].y > gameCanvas.height - 10;
  return hitLeftWall || 
         hitRightWall || 
         hitToptWall ||
         hitBottomWall
} 

首先,我们检查蛇的头部是否碰到蛇身上其他部分,如果碰到了那么返回 true。

请注意,我们从索引值 4 开始循环。这有两个原因:一个是如果索引为 0,didCollide 则会立即判断为 true,导致游戏结束。另一个是,蛇的前三个部分不可能相互接触。

接下来我们检查蛇是否在画布上撞墙了,如果是,那么返回 true,否则返回 false 。

现在,如果 didEndGame 返回 true,我们可以在主函数中提前返回,从而结束游戏。

function main() {
  if (didGameEnd()) return;
  ...
}

snake.html 现在应该是这样的:

<!DOCTYPE html>
<html>
  <head>
   <title>Snake Game</title>
  </head>
  <body>
    <div id="score">0</div>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <script>
      /** 常量 **/
      const CANVAS_BORDER_COLOUR = 'black';
      const CANVAS_BACKGROUND_COLOUR = "white";
      const SNAKE_COLOUR = 'lightgreen';
      const SNAKE_BORDER_COLOUR = 'darkgreen';
      const FOOD_COLOUR = 'red';
      const FOOD_BORDER_COLOUR = 'darkred';
      let snake = [
        {x: 150, y: 150},
        {x: 140, y: 150},
        {x: 130, y: 150},
        {x: 120, y: 150},
        {x: 110, y: 150}
      ]
      // 玩家的分数
      let score = 0;
      // 横向移动速度
      let dx = 10;
      // 纵向移动速度
      let dy = 0;
      // 获取 canvas 元素
      var gameCanvas = document.getElementById("gameCanvas");
      // 返回一个二维绘制上下文
      var ctx = gameCanvas.getContext("2d");
      //  选择画布的背景颜色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      //  选择画布的边框颜色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 绘制一个“实心的”长方形来覆盖整个画布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 绘制画布的“边框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 开始游戏
      main();
      // 生成第一个食物位置
      createFood();
      // 按下任意一个键,都会调用 changeDirection
      document.addEventListener("keydown", changeDirection);
      function main() {
        if (didGameEnd()) return;
        setTimeout(function onTick() {
          clearCanvas();
          drawFood();
          advanceSnake();
          drawSnake();
          // Call main again
          main();
        }, 100)
      }
      /**
      * 设置画布的背景色为 CANVAS_BACKGROUND_COLOUR 
      * 并绘制画布的边框
      */
      function clearCanvas() {
        //  选择画布的背景颜色
        ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
        //  选择画布的边框颜色
        ctx.strokestyle = CANVAS_BORDER_COLOUR;
        // 绘制一个“实心的”长方形来覆盖整个画布
        ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
        // 绘制画布的“边框”
        ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
      }
      /**
       * 当蛇撞墙或者蛇头碰到蛇身的时候返回 true
       */
      function didGameEnd() {
        for (let i = 4; i < snake.length; i++) {
          const didCollide = snake[i].x === snake[0].x && snake[i].y === snake[0].y
          if (didCollide) return true
        }
        const hitLeftWall = snake[0].x < 0;
        const hitRightWall = snake[0].x > gameCanvas.width - 10;
        const hitToptWall = snake[0].y < 0;
        const hitBottomWall = snake[0].y > gameCanvas.height - 10;
        return hitLeftWall || hitRightWall || hitToptWall || hitBottomWall
      }
      /**
       * 在画布上画食物
       */
      function drawFood() {
        ctx.fillStyle = FOOD_COLOUR;
        ctx.strokestyle = FOOD_BORDER_COLOUR;
        ctx.fillRect(foodX, foodY, 10, 10);
        ctx.strokeRect(foodX, foodY, 10, 10);
      }
      /**
       * 根据蛇的水平移动速度改变蛇的 x 坐标,
       * 根据蛇的垂直移动速度改变蛇的 y 坐标
       */
      function advanceSnake() {
        // 绘制新的头部
        const head = {x: snake[0].x + dx, y: snake[0].y + dy};
        // 将新的头部放到蛇身体的第一个部位
        snake.unshift(head);
        const didEatFood = snake[0].x === foodX && snake[0].y === foodY;
        if (didEatFood) {
          // 增加分数
          score += 10;
          // 在屏幕上显示分数
          document.getElementById('score').innerHTML = score;
          // 生成新的食物
          createFood();
        } else {
          // 移除蛇的最后一个部分
          snake.pop();
        }
      }
      /**
        * 给定一个最大值和最小值,生成一个 10 的倍数的随机数     
        * @param { number } min —— 随机数的下限
        * @param { number } max —— 随机数的上限
       */
      function randomTen(min, max) {
        return Math.round((Math.random() * (max-min) + min) / 10) * 10;
      }
     /**     
      * 随机生成食物坐标
      */
      function createFood() {
        // 随机生成食物的 x 坐标
        foodX = randomTen(0, gameCanvas.width - 10);
        // 随机生成食物的 y 坐标
        foodY = randomTen(0, gameCanvas.height - 10);
        // 如果新生成的食物与蛇当前位置重叠,重新为食物生成一个位置
        snake.forEach(function isOnSnake(part) {
          if (part.x == foodX && part.y == foodY) createFood();
        });
      }
      /**
       * 在画布上画蛇
       */
      function drawSnake() {
        // 循环遍历蛇的每一部分,并将其绘制到画布上
        snake.forEach(drawSnakePart)
      }
      /**
       * 在画布上画蛇的一个部分
       * @param { object } snakePart  —— 需要绘制的部位的所在坐标
       */
      function drawSnakePart(snakePart) {
        // 设置蛇身体的背景颜色
        ctx.fillStyle = SNAKE_COLOUR;
        // 设置蛇身的边框色
        ctx.strokestyle = SNAKE_BORDER_COLOUR;
        // 在蛇身坐标所在的位置,绘制“实心”的矩形以表示蛇      
        ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
        // 绘制蛇身的边框
        ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
      }
      /**     
      * 根据按下的键,改变蛇的水平移动速度和垂直移动速度
      * 为了避免蛇反转,蛇的移动方向不能直接变成相反的方向,
      * 比如说,当前方向是“向右”,那么下一个方向不能是“向左”
      * @param { object } event —— 键盘事件
      */
      function changeDirection(event) {
        const LEFT_KEY = 37;
        const RIGHT_KEY = 39;
        const UP_KEY = 38;
        const DOWN_KEY = 40;
        const keyPressed = event.keyCode;
        const goingUp = dy === -10;
        const goingDown = dy === 10;
        const goingRight = dx === 10;
        const goingLeft = dx === -10;
        if (keyPressed === LEFT_KEY && !goingRight) {
          dx = -10;
          dy = 0;
        }
        if (keyPressed === UP_KEY && !goingDown) {
          dx = 0;
          dy = -10;
        }
        if (keyPressed === RIGHT_KEY && !goingLeft) {
          dx = 10;
          dy = 0;
        }
        if (keyPressed === DOWN_KEY && !goingUp) {
          dx = 0;
          dy = 10;
        }
      }
    </script>
  </body>
</html>

现在贪食蛇游戏已经可以玩了,不过,我们还是来看看最后一个问题。我保证,这绝对是最后一个。

潜藏的 bugs

如果你玩了足够多次游戏,可能会注意到游戏有时会意外结束。这是一个很好的例子,展示了 bug 会潜入我们的程序并制造麻烦。

发现 bug 的时候,解决它的最好方法是首先找到可靠的方法来重现它。也就是说,找到导致意外行为的精确步骤。然后,需要了解它们导致意外行为的原因,最后寻求解决方案。

重现 bug

在我们的例子中,重现 bug 的步骤如下:

  • [ ] 蛇正向左移动
  • [ ] 玩家按下向下键
  • [ ] 玩家立即按下向右键(在 100 ms 内)
  • [ ] 游戏结束

image

分析导致 bug 的原因

让我们一步步分解 bug 产生的过程。

蛇正在向左移动

  • [ ] 水平速度,dx 等于 -10
  • [ ] 调用 main 函数
  • [ ] 调用 advanceSnake 函数,蛇向左移动 10 px。

玩家按下向下键

调用 changeDirection
keyPressed === DOWN_KEY && !goingUp 的值为 true
dx 更改为 0
dy 更改为 +10

玩家立即按下向右键(在 100 ms 内)

  • [ ] 调用 changeDirection
  • [ ] keyPressed === RIGHT_KEY && !goingLeft 的值为 true
  • [ ] dx 更改为 +10
  • [ ] dy 更改为 0

游戏结束

  • [ ] main 函数延时 100 ms 后被调用
  • [ ] 调用 advanceSnake,蛇向右移动 10 px。
  • [ ] const didCollide = snake[i].x === snake[0].x && snake[i].y ===

      snake[0].y 的值为true
  • [ ] didGameEnd 返回 true
  • [ ] main 函数提前返回
  • [ ] 游戏结束

解决 bug

在分析了发生的事情之后,我们了解到游戏结束是因为蛇掉头了。

这是因为当玩家按下向下键时,dx 被设置为 0。因此 keyPressed === RIGHT_KEY && !goingLeft 值为 true,同时 dx 更改为 10。

重要的是要注意,在 100 ms 时间内,方向改变了。如果超过 100 ms,那么蛇首先会向下移而不会掉头。

为了解决这个 bug,必须确保只有在 main 和 advanceSnake 被调用之后,才可以改变蛇的方向。可以创建一个变量 changingDirection。当调用 changeDirection 时设置 changingDirection 为 true,并在调用 advanceSnake 时设置为 false。

在 changeDirection 函数中,如果 changingDirection 为 true,就提前返回。

function changeDirection(event) {
  const LEFT_KEY = 37;
  const RIGHT_KEY = 39;
  const UP_KEY = 38;
  const DOWN_KEY = 40;
  if (changingDirection) return;
  changingDirection = true;
  ...
}
function main() {
  setTimeout(function onTick() {
    changingDirection = false;

    ...
  }, 100)
}

以下是 snake.html 的最终版本

注意我还在 <style></style> 标签之间添加了一些样式。这是为了让画布和分数显示在屏幕中间。
<!DOCTYPE html>
<html>
  <head>
    <title>Snake Game</title>
    <link href="https://fonts.googleapis.com/css?family=Antic+Slab" rel="stylesheet">

  </head>

  <body>

    <div id="score">0</div>
    <canvas id="gameCanvas" width="300" height="300"></canvas>

    <style>
      #gameCanvas {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
      }
      #score {
        text-align: center;
        font-size: 140px;
        font-family: 'Antic Slab', serif;
      }
    </style>
  </body>

  <script>
    const GAME_SPEED = 100;
    const CANVAS_BORDER_COLOUR = 'black';
    const CANVAS_BACKGROUND_COLOUR = "white";
    const SNAKE_COLOUR = 'lightgreen';
    const SNAKE_BORDER_COLOUR = 'darkgreen';
    const FOOD_COLOUR = 'red';
    const FOOD_BORDER_COLOUR = 'darkred';
    let snake = [
      {x: 150, y: 150},
      {x: 140, y: 150},
      {x: 130, y: 150},
      {x: 120, y: 150},
      {x: 110, y: 150}
    ]
    // 玩家的分数
    let score = 0;
    // changingDirection 为 true 时表示蛇正在改变方向
    let changingDirection = false;
    // 食物的 x 坐标
    let foodX;
    // 食物的 y 坐标
    let foodY;
    // 横向移动速度
    let dx = 10;
    // 纵向移动速度
    let dy = 0;
    // 获取 canvas 元素
    const gameCanvas = document.getElementById("gameCanvas");
    // 返回一个二维绘制上下文
    const ctx = gameCanvas.getContext("2d");
    // 开始游戏
    main();
    // 生成第一个食物位置
    createFood();
    // 按下任意一个键,都会调用 changeDirection
    document.addEventListener("keydown", changeDirection);
    /**
     * 游戏的主函数
     * 递归调用以推进游戏  
     */
    function main() {
      // 判定游戏结束时提前返回从而终止游戏
      if (didGameEnd()) return;
      setTimeout(function onTick() {
        changingDirection = false;
        clearCanvas();
        drawFood();
        advanceSnake();
        drawSnake();
        // 再次调用 main
        main();
      }, GAME_SPEED)
    }
    /**
     * 设置画布的背景色为 CANVAS_BACKGROUND_COLOUR 
     * 并绘制画布的边框
     */
    function clearCanvas() {
      //  选择画布的背景颜色
      ctx.fillStyle = CANVAS_BACKGROUND_COLOUR;
      //  选择画布的边框颜色
      ctx.strokestyle = CANVAS_BORDER_COLOUR;
      // 绘制一个“实心的”长方形来覆盖整个画布
      ctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
      // 绘制画布的“边框”
      ctx.strokeRect(0, 0, gameCanvas.width, gameCanvas.height);
    }
    /**
     * 在画布上画食物
     */
    function drawFood() {
      ctx.fillStyle = FOOD_COLOUR;
      ctx.strokestyle = FOOD_BORDER_COLOUR;
      ctx.fillRect(foodX, foodY, 10, 10);
      ctx.strokeRect(foodX, foodY, 10, 10);
    }
    /**
     * 根据蛇的水平移动速度改变蛇的 x 坐标,
     * 根据蛇的垂直移动速度改变蛇的 y 坐标
     */
    function advanceSnake() {
      // 绘制新的头部
      const head = {x: snake[0].x + dx, y: snake[0].y + dy};
      // 将新的头部放到蛇身体的第一个部位
      snake.unshift(head);
      const didEatFood = snake[0].x === foodX && snake[0].y === foodY;
      if (didEatFood) {
        // 增加分数
        score += 10;
        // 在屏幕上显示分数
        document.getElementById('score').innerHTML = score;
        // 生成新的食物
        createFood();
      } else {
        // 移除蛇的最后一个部分
        snake.pop();
      }
    }
    /**
     * 当蛇撞墙或者蛇头碰到蛇身的时候返回 true
     */
    function didGameEnd() {
      for (let i = 4; i < snake.length; i++) {
        if (snake[i].x === snake[0].x && snake[i].y === snake[0].y) return true
      }
      const hitLeftWall = snake[0].x < 0;
      const hitRightWall = snake[0].x > gameCanvas.width - 10;
      const hitToptWall = snake[0].y < 0;
      const hitBottomWall = snake[0].y > gameCanvas.height - 10;
      return hitLeftWall || hitRightWall || hitToptWall || hitBottomWall
    }
    /**
     * 给定一个最大值和最小值,生成一个 10 的倍数的随机数     
     * @param { number } min —— 随机数的下限
     * @param { number } max —— 随机数的上限
     */
    function randomTen(min, max) {
      return Math.round((Math.random() * (max-min) + min) / 10) * 10;
    }
    /**     
     * 随机生成食物坐标
     */
    function createFood() {
      // 随机生成食物的 x 坐标
      foodX = randomTen(0, gameCanvas.width - 10);
      // 随机生成食物的 y 坐标
      foodY = randomTen(0, gameCanvas.height - 10);
      // 如果新生成的食物与蛇当前位置重叠,重新为食物生成一个位置
      snake.forEach(function isFoodOnSnake(part) {
        const foodIsoNsnake = part.x == foodX && part.y == foodY;
        if (foodIsoNsnake) createFood();
      });
    }
    /**
     * 在画布上画蛇
     */
    function drawSnake() {
      // 循环遍历蛇的每一部分,并将其绘制到画布上
      snake.forEach(drawSnakePart)
    }
    /**
     * 在画布上画蛇的一个部分
     * @param { object } snakePart —— 需要绘制的部位的所在坐标
     */
    function drawSnakePart(snakePart) {
      // 设置蛇身体的背景颜色
      ctx.fillStyle = SNAKE_COLOUR;
      // 设置蛇身的边框色
      ctx.strokestyle = SNAKE_BORDER_COLOUR;
      // 在蛇身坐标所在的位置,绘制“实心”的矩形以表示蛇      
      ctx.fillRect(snakePart.x, snakePart.y, 10, 10);
      // 绘制蛇身的边框
      ctx.strokeRect(snakePart.x, snakePart.y, 10, 10);
    }
    /**     
     * 根据按下的键,改变蛇的水平移动速度和垂直移动速度
     * 为了避免蛇反转,蛇的移动方向不能直接变成相反的方向,
     * 比如说,当前方向是“向右”,那么下一个方向不能是“向左”
     * @param { object } event —— 键盘事件
     */
    function changeDirection(event) {
      const LEFT_KEY = 37;
      const RIGHT_KEY = 39;
      const UP_KEY = 38;
      const DOWN_KEY = 40;
      /**
       * 避免贪食蛇掉头
       * 举个例子:
       * 蛇正在向右移动。玩家按下向下键然后迅速地按下向左键。
       * 此时蛇不会先向下移动一步,而会马上改变方向
       */
      if (changingDirection) return;
      changingDirection = true;

      const keyPressed = event.keyCode;
      const goingUp = dy === -10;
      const goingDown = dy === 10;
      const goingRight = dx === 10;
      const goingLeft = dx === -10;
      if (keyPressed === LEFT_KEY && !goingRight) {
        dx = -10;
        dy = 0;
      }

      if (keyPressed === UP_KEY && !goingDown) {
        dx = 0;
        dy = -10;
      }

      if (keyPressed === RIGHT_KEY && !goingLeft) {
        dx = 10;
        dy = 0;
      }

      if (keyPressed === DOWN_KEY && !goingUp) {
        dx = 0;
        dy = 10;
      }
    }
  </script>
</html>

结尾

编程并不是一件很难学习,虽然最初未必敢于尝试,但这毫无疑问是非常有价值的。
前端开发进阶点击:加入


程序员阿宇
3.2k 声望791 粉丝

前端学习交流群:784783012 欢迎新手,进阶者