为了进阶Canvas,前进!冲啊

887 阅读9分钟

前言

前阵子,我偶然发现,有人用TypeScript + Canvas写了个坦克大战,相信小时候玩过坦克大战的伙伴最熟悉不过了。

为了在使用Canvas不局限于只是截图做海报,所以说我准备用Canvas写个小游戏,我也想写个坦克大战,但是我不会😂,于是搞了个另一种小时候的回忆:贪吃蛇

先看最后效果图:

除了上面的准备写贪吃蛇canvas是文字以外,其余部分全是canvas标签内的

效果有了,图有了,那我们认识一下过程中用到的CanvasRenderingContext2D 属性与方法

CanvasRenderingContext2D 属性与方法

.fillStyle 是Canvas 2D API 使用内部方式描述颜色和样式的属性。默认值是 #000 (黑色)

.fillRect()  是Canvas 2D API 绘制填充矩形的方法。当前渲染上下文中的fillStyle 属性决定了对这个矩形对的填充样式

.save()  是 Canvas 2D API 通过将当前状态放入栈中,保存 canvas 全部状态的方法。

.translate()  方法对当前网格添加平移变换的方法。可以理解为重新设置原点

.lineWidth 是 Canvas 2D API 设置线段厚度的属性(即线段的宽度),这宽度设置我以外的发现一个问题,譬如我们在(35,35)的画了一条宽度为10的线条,最后会如下图:

image.png

5和5之间就是35的位置,这条线条的最上面的位置实际上的30,最下面的位置实际上是40,一开始不清楚的话,就会出现了这种的错误:

image.png

由上面图中可以看出宽度不够,圆角也不见了。

.strokeStyle 是 Canvas 2D API 描述画笔(绘制图形)颜色或者样式的属性。默认值是 #000 (black)。

.stroke()  是 Canvas 2D API 使用非零环绕规则,根据当前的画线样式,绘制当前或已经存在的路径的方法。用于闭合画的线条

.restore()  是 Canvas 2D API 通过在绘图状态栈中弹出顶端的状态,将 canvas 恢复到最近的保存状态的方法。 如果没有保存状态,此方法不做任何改变。

.beginPath()  是 Canvas 2D API 通过清空子路径列表开始一个新路径的方法。 当你想创建一个新的路径时,调用此方法。

.arc()  是 Canvas 2D API 绘制圆弧路径的方法。 圆弧路径的圆心在  (x, y)  位置,半径为 r ,根据anticlockwise (默认为顺时针)指定的方向从 startAngle 开始绘制,到 endAngle 结束。

.lineTo()  是 Canvas 2D API 使用直线连接子路径的终点到x,y坐标的方法(并不会真正地绘制)。如果不闭合起来是看不见这条线的

.closePath()  是 Canvas 2D API 将笔点返回到当前子路径起始点的方法。它尝试从当前点到起始点绘制一条直线。 如果图形已经是封闭的或者只有一个点,那么此方法不会做任何操作。

.font 是 Canvas 2D API 描述绘制文字时,当前字体样式的属性。 使用和 CSS font 规范相同的字符串值。

.fillText()  是 Canvas 2D API 在  (x, y) 位置填充文本的方法。如果选项的第四个参数提供了最大宽度,文本会进行缩放以适应最大宽度。

.measureText()  方法返回一个关于被测量文本TextMetrics 对象包含的信息(例如它的宽度)。

.clearRect() 是Canvas 2D API的方法,这个方法通过把像素设置为透明以达到擦除一个矩形区域的目的。

.rect()  是 Canvas 2D API 创建矩形路径的方法,矩形的起点位置是  (x, y) ,尺寸为 width 和 height。矩形的4个点通过直线连接,子路径做为闭合的标记,所以你可以填充或者描边矩形。本次不使用这个。

上面就是本次所需用到的API了,如果需要了解更多了可以去看MDN对CanvasRenderingContext2D 属性与方法的解释

还需要记住一张图:

Canvas_grid_translate.png

这张图表示的是canvas的位置图,记住canvas的原点在左上角的位置,记住,记住,记住,重要的事情说三遍

万事俱备,东风来了,我们开始吧

创建项目

npm init vite@latest ts-canvas

选择vue:

image.png

然后回车,再选择vue+ts

image.png

这样就表示创建完成了:

image.png

如果不熟悉Vue3的一些新特性的,可以看这篇文章Vue3 新特性大收获

template部分:

//template部分:
<template>
  <div>准备写贪吃蛇 canvas</div>
  <canvas ref="taCanvas"></canvas>
</template>

就这么简单😎。

获取到Canvas DOM

首先我们需要写个draw方法来获取到Canvas

import { onBeforeUnmount, onMounted, ref } from "vue";

const taCanvas = ref<HTMLCanvasElement | null>(null);

function draw() {
  const canvas = taCanvas.value as HTMLCanvasElement;
  if (canvas.getContext("2d")) {
    //判断浏览器是否支持canvas标签
    canvas.width = 370;
    canvas.height = 430;
    var context = canvas.getContext("2d") as CanvasRenderingContext2D; //获取画布context的上下文环境
  } else {
    alert("您的浏览器不支持canvas,请换个浏览器试试");
  }
}

onMounted(() => {
  draw();
});

最后得到的context就是我们所需要的

画圆角矩形

从新最后的效果图中,我们可以看出里面有两个矩形,一个比较特别还是个圆角矩形。矩形好办啊,使用 rect 方法创建一条路径,然后可以使用 stroke() 方法。矩形就完成了:

image.png

但这个效果并不能得到想要的效果。

最后还是要通过arc画圆弧lineTo画线,来完成。 因为有两个都是矩形的,只不过有一个是圆角的,所以封装一个方法来供两次使用。

image.png 上图是根据MDN中对CanvasRenderingContext2D.arc()解释画的图,(默认为顺时针)指定的方向从 起点 开始绘制,到 终点 结束。

/**该方法用来绘制圆角矩形
 *@param ctx:canvas的上下文环境
 *@param x:左上角x轴坐标
 *@param y:左上角y轴坐标
 *@param width:矩形的宽度
 *@param height:矩形的高度
 *@param radius:圆的半径
 *@param lineWidth:线条粗细
 *@param strokeColor:线条颜色
 **/
function strokeRoundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number, lineWidth?: number, strokeColor?: string) {
  //圆的直径必然要小于矩形的宽高
  if (2 * radius > width || 2 * radius > height) {
    return false;
  }

  ctx.save();
  ctx.translate(x, y);
  // //绘制圆角矩形的各个边
  drawRoundRectPath(ctx, width, height, radius);
  ctx.lineWidth = lineWidth || 10; //若是给定了值就用给定的值否则给予默认值10
  ctx.strokeStyle = strokeColor || "#000";
  ctx.stroke();
  ctx.restore();
}
//绘制边框
function drawRoundRectPath(ctx: CanvasRenderingContext2D, width: number, height: number, radius: number) {
  ctx.beginPath();
  //从右下角顺时针绘制,弧度从0到1/2PI
  ctx.arc(width - radius, height - radius, radius, 0, Math.PI / 2);

  //矩形下边线
  ctx.lineTo(radius, height);

  //左下角圆弧,弧度从1/2PI到PI
  ctx.arc(radius, height - radius, radius, Math.PI / 2, Math.PI);

  //矩形左边线
  ctx.lineTo(0, radius);

  //左上角圆弧,弧度从PI到3/2PI
  ctx.arc(radius, radius, radius, Math.PI, (Math.PI * 3) / 2);

  //上边线
  ctx.lineTo(width - radius, 0);

  //右上角圆弧
  ctx.arc(width - radius, radius, radius, (Math.PI * 3) / 2, Math.PI * 2);

  //右边线
  ctx.lineTo(width, height - radius);
  ctx.closePath();
}

为了方便我们后面初始化画布,所以要写一个初始化的方法在draw调用进行初始化

function initCanvas(context: CanvasRenderingContext2D) {
  context.fillStyle = "#b7d4a8";
  context.fillRect(10, 10, 350, 410);
  //绘制一个圆角矩形
  strokeRoundRect(context, 5, 5, 360, 420, 10);
  //绘制贪吃蛇游动的范围矩形
  strokeRoundRect(context, 33, 33, 302, 302, 0, 2);
}

这里可能会有疑惑,明明设置的圆角是10,为什么XY的位置会是5,不明白的可以掉个头复习一下上面说的lineWidth

draw()方法更新为:

function draw() {
  const canvas = taCanvas.value as HTMLCanvasElement;
  if (canvas.getContext("2d")) {
    //判断浏览器是否支持canvas标签
    canvas.width = 370;
    canvas.height = 430;
    var context = canvas.getContext("2d") as CanvasRenderingContext2D; //获取画布context的上下文环境
    initCanvas(context);
  } else {
    alert("您的浏览器不支持canvas,请换个浏览器试试");
  }
}

最后得到的效果图:

image.png

绘制蛇

首先上面画出绘制贪吃蛇游动的范围矩形,范围其实就是300*300。那我们蛇开始的位置就是这个范围的左上角。那这个位置等于多少呢?

image.png 我们线的宽度是2。 所以我们的蛇开始的起点应该是[35,35]:

image.png

这里设置每个方块的大小是8,因为留着一些缝隙,可以很好的看出来,不然会跟边界链接在一起,不好区分

蛇肯定不只是一个方块的位置,可以设置一个二维数组存放蛇的位置列表。代码如下:

//绘制蛇
let snakeList = ref([[35, 35]]);

function drawSnake(ctx: CanvasRenderingContext2D) {
  for (let i = 0; i < snakeList.value.length; i++) {
    ctx.save();
    ctx.translate(snakeList.value[i][0], snakeList.value[i][1]);
    ctx.fillStyle = "#000";
    ctx.fillRect(0, 0, 8, 8);
    ctx.restore();
  }
}

随机食物

我们的食物的位置必须是随机,因为我们就没有见过贪吃蛇中的食物是一直固定在一个位置上给我们吃的。 那我们就要写一个在canvas随机画一个食物。

首先上面我们画出了绘制贪吃蛇游动的范围矩形,说明我们的食物肯定也是在这个范围的,这个范围其实就是300*300

let footX = ref(35);
let footY = ref(35);
//绘制食物
function drawFoot(ctx: CanvasRenderingContext2D) {
  let top = Math.round(Math.random() * 29) * 10 + 35;
  let left = Math.round(Math.random() * 29) * 10 + 35;

  ctx.save();
  //左上角坐标
  // ctx.translate(39, 35);
  //右上角
  // ctx.translate(290+39, 35);
  //右下角
  // ctx.translate(290+39, 35+290-2);
  //左下角
  // ctx.translate(39, 35+290-2);
  if (footX.value === snakeList.value[0][0] && footY.value === snakeList.value[0][1]) {
    ctx.translate(left, top);
    footX.value = left;
    footY.value = top;
  } else {
    ctx.translate(footX.value, footY.value);
  }

  ctx.fillStyle = "#000";
  // ctx.rotate((45 * Math.PI) / 180);
  ctx.fillRect(0, 0, 8, 8);
  // console.log("x轴:", left - 35, "Y轴:", top - 35);
  ctx.restore();
}

上面就是关于食物的所有代码了

footX、footY分别表示存食物在canvas上的位置,因为我们每次在重新绘制canvas时,只要没有吃到食物,食物的位置都是不变的。

本来是想通过rotate()将正方形选择45度,食物会是一个菱形的样子,但是失败了,就出现了上面部分代码注释的,有兴趣的伙伴可以试一下。

绘制分数

分数我们可以根据蛇身的长度来计算,蛇头不算分数:

//绘制分数
function drawScore(ctx: CanvasRenderingContext2D) {
  ctx.save();
  ctx.font = "bold 20px serif";
  ctx.fillStyle = "#000";

  // console.log(ctx.measureText("SCORE:0"))
  ctx.fillText("SCORE:" + (snakeList.value.length - 1), 33, 383.5);
  ctx.restore();
}

绘制等级

等级其实跟分数的计算方式差不多的,但是我们要除以一个阶级分,就譬如我10分才能升一级这样,这个10就是阶级分,每个阶级蛇移动的速度都会依次提高。

let level = 1;
//绘制等级
function drawLevel(ctx: CanvasRenderingContext2D) {
  level = Math.floor((snakeList.value.length - 1) / 10) + 1;
  ctx.save();
  ctx.font = "bold 20px serif";
  ctx.fillStyle = "#000";

  // console.log(ctx.measureText("level:0"));
  ctx.fillText("level:" + level, 337 - ctx.measureText("level:" + level).width, 383.5);
  ctx.restore();
}

让蛇动起来

经历了绘制蛇、随机食物、绘制分数、绘制等级。initCanvas()变成了这样:

function initCanvas(context: CanvasRenderingContext2D) {
  context.fillStyle = "#b7d4a8";
  context.fillRect(10, 10, 350, 410);
  //绘制一个圆角矩形
  strokeRoundRect(context, 5, 5, 360, 420, 10);
  //绘制贪吃蛇游动的范围矩形
  strokeRoundRect(context, 33, 33, 302, 302, 0, 2);

  //绘制分数文本
  drawScore(context);

  //绘制等级文本
  drawLevel(context);

  //绘制食物
  drawFoot(context);

  //绘制蛇
  drawSnake(context);
}

蛇有了,食物有了,让是不是该让蛇run起来了。

动起来可以通过监听键盘箭头事件,来调整行走方向,因为我们箭头事件就一次监听就可以了,不需要重复监听,所以代码如下:

function draw() {
  const canvas = taCanvas.value as HTMLCanvasElement;
  if (canvas.getContext("2d")) {
    //判断浏览器是否支持canvas标签
    canvas.width = 370;
    canvas.height = 430;
    var context = canvas.getContext("2d") as CanvasRenderingContext2D; //获取画布context的上下文环境
    initCanvas(context);

    //监听键盘箭头事件
    document.addEventListener("keydown", (event) => keydownHandler(event, context));

    //蛇动起来
    run(context);
  } else {
    alert("您的浏览器不支持canvas,请换个浏览器试试");
  }
}

//页面销毁时移除监听事件
onBeforeUnmount(() => {
  document.removeEventListener("keydown", (event) => keydownHandler(event));
});

let direction = ref("");
function keydownHandler(event: KeyboardEvent, context?: CanvasRenderingContext2D) {
  // console.log(event.key);
  direction.value = event.key;
}

再先来看一张移动图:

image.png

上图的意思是蛇头往前移一个位置时,后面的蛇身依次使用前面一个的位置。在数组中就是[i]的位置就是使用[i-1]的位置。因为这里蛇头就是数组开始的第一项,那么我们的第二个蛇身用的就是上一个蛇头的位置,第三个蛇身用的就是第二个蛇身的位置。

蛇在移动的过程中 肯定是不能让它超出一开始绘制的游动的范围的,如果超出了,那这个蛇肯定是许仙那条。

image.png

由于我们的小方块是8还有预留的2间隙,算起来一个是10,所以上边界和左边界是:35,右边界和下边界是:290 + 35,超出就撞墙

再来就是判断是否吃到了食物,就很简单,利用蛇头的位置和食物的位置是否全等即可

最后一个,就是不能吃到自己,也很简单,把蛇身的位置与蛇头一一比对,就可以知道是否吃到蛇身

再最后一个,蛇不能掉头,掉头的话,还是让他按之前的位置继续前进,这个就要去判断新的位置是否与第一个蛇身的位置是否相等即可。

无论是蛇撞墙还是迟到自己,都让蛇死亡,游戏结束

run()代码如下:

let isLive = ref(true);
function run(ctx: CanvasRenderingContext2D) {
  let X = snakeList.value[0][0];
  let Y = snakeList.value[0][1];
  switch (direction.value) {
    case "ArrowUp":
    case "Up":
      // 向上移动 top 减少
      Y -= 10;
      break;
    case "ArrowDown":
    case "Down":
      // 向下移动 top 增加
      Y += 10;
      break;
    case "ArrowLeft":
    case "Left":
      // 向左移动 left 减少
      X -= 10;
      break;
    case "ArrowRight":
    case "Right":
      // 向右移动 left 增加
      X += 10;
      break;
    default:
      X += 10;
      break;
  }

  // console.log(snakeList.value[0][0] ==X, snakeList.value[0][1] == Y);
  // console.log("蛇头", snakeList.value[0][0], snakeList.value[0][1]);
  // console.log("偏移到的位置", X, Y);
  if (snakeList.value[1] && snakeList.value[1][0] === X && snakeList.value[1][1] === Y) {
    // console.log("第一个蛇身", snakeList.value[1][0], snakeList.value[1][1]);
    //命中直接取反
    switch (direction.value) {
      case "ArrowUp":
      case "Up":
        // 向上移动 top 减少
        Y += 20;
        break;
      case "ArrowDown":
      case "Down":
        // 向下移动 top 增加
        Y -= 20;
        break;
      case "ArrowLeft":
      case "Left":
        // 向左移动 left 减少
        X += 20;
        break;
      case "ArrowRight":
      case "Right":
        // 向右移动 left 增加
        X -= 20;
        break;
      default:
        X -= 20;
        break;
    }
  }
  checkEat(X, Y);
  changeListXY();

  snakeList.value[0][0] = X;
  snakeList.value[0][1] = Y;
  if (snakeList.value[0][0] > 290 + 35 || snakeList.value[0][0] < 35 || snakeList.value[0][1] > 290 + 35 || snakeList.value[0][1] < 35) {
    console.log("我撞墙了");
    alert("我撞墙了" + " GAME OVER!");
    isLive.value = false;
    return;
  }
  if (isEatBody()) {
    alert("吃到自己了" + " GAME OVER!");
    isLive.value = false;
    return;
  }

  // console.log(snakeList.value);

  ctx.clearRect(0, 0, 370, 430);
  initCanvas(ctx);
  isLive.value &&
    setTimeout(() => {
      run(ctx);
    }, 300 - (level - 1) * 30);
}

function checkEat(X: number, Y: number) {
  if (X === footX.value && Y === footY.value) {
    snakeList.value.push([X, Y]);
    // console.log(snakeList.value);
    // debugger;
  }
}

function isEatBody() {
  for (let i = 1; i < snakeList.value.length; i++) {
    if (snakeList.value[0][0] === snakeList.value[i][0] && snakeList.value[0][1] === snakeList.value[i][1]) {
      console.log("吃到自己了", i);
      return true;
    }
  }

  return false;
}

全部代码

<template>
  <div>准备写贪吃蛇 canvas</div>
  <canvas ref="taCanvas"></canvas>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";

const taCanvas = ref<HTMLCanvasElement | null>(null);
function draw() {
  const canvas = taCanvas.value as HTMLCanvasElement;
  if (canvas.getContext("2d")) {
    //判断浏览器是否支持canvas标签
    canvas.width = 370;
    canvas.height = 430;
    var context = canvas.getContext("2d") as CanvasRenderingContext2D; //获取画布context的上下文环境
    initCanvas(context);

    //监听键盘箭头事件
    document.addEventListener("keydown", (event) => keydownHandler(event, context));

    //蛇动起来
    run(context);
  } else {
    alert("您的浏览器不支持canvas,请换个浏览器试试");
  }
}
function initCanvas(context: CanvasRenderingContext2D) {
  context.fillStyle = "#b7d4a8";
  context.fillRect(10, 10, 350, 410);
  //绘制一个圆角矩形
  strokeRoundRect(context, 5, 5, 360, 420, 10);
  //绘制贪吃蛇游动的范围矩形
  strokeRoundRect(context, 33, 33, 302, 302, 0, 2);

  //绘制分数文本
  drawScore(context);

  //绘制等级文本
  drawLevel(context);

  //绘制食物
  drawFoot(context);

  //绘制蛇
  drawSnake(context);
}
/**该方法用来绘制圆角矩形
 *@param ctx:canvas的上下文环境
 *@param x:左上角x轴坐标
 *@param y:左上角y轴坐标
 *@param width:矩形的宽度
 *@param height:矩形的高度
 *@param radius:圆的半径
 *@param lineWidth:线条粗细
 *@param strokeColor:线条颜色
 **/
function strokeRoundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number, lineWidth?: number, strokeColor?: string) {
  //圆的直径必然要小于矩形的宽高
  if (2 * radius > width || 2 * radius > height) {
    return false;
  }

  ctx.save();
  ctx.translate(x, y);
  // //绘制圆角矩形的各个边
  drawRoundRectPath(ctx, width, height, radius);
  ctx.lineWidth = lineWidth || 10; //若是给定了值就用给定的值否则给予默认值10
  ctx.strokeStyle = strokeColor || "#000";
  ctx.stroke();
  ctx.restore();
}
//绘制边框
function drawRoundRectPath(ctx: CanvasRenderingContext2D, width: number, height: number, radius: number) {
  ctx.beginPath();
  //从右下角顺时针绘制,弧度从0到1/2PI
  ctx.arc(width - radius, height - radius, radius, 0, Math.PI / 2);

  //矩形下边线
  ctx.lineTo(radius, height);

  //左下角圆弧,弧度从1/2PI到PI
  ctx.arc(radius, height - radius, radius, Math.PI / 2, Math.PI);

  //矩形左边线
  ctx.lineTo(0, radius);

  //左上角圆弧,弧度从PI到3/2PI
  ctx.arc(radius, radius, radius, Math.PI, (Math.PI * 3) / 2);

  //上边线
  ctx.lineTo(width - radius, 0);

  //右上角圆弧
  ctx.arc(width - radius, radius, radius, (Math.PI * 3) / 2, Math.PI * 2);

  //右边线
  ctx.lineTo(width, height - radius);
  ctx.closePath();
}
//绘制分数
function drawScore(ctx: CanvasRenderingContext2D) {
  ctx.save();
  ctx.font = "bold 20px serif";
  ctx.fillStyle = "#000";

  // console.log(ctx.measureText("SCORE:0"))
  ctx.fillText("SCORE:" + (snakeList.value.length - 1), 33, 383.5);
  ctx.restore();
}

let level = 1;
//绘制等级
function drawLevel(ctx: CanvasRenderingContext2D) {
  level = Math.floor((snakeList.value.length - 1) / 10) + 1;
  ctx.save();
  ctx.font = "bold 20px serif";
  ctx.fillStyle = "#000";

  // console.log(ctx.measureText("level:0"));
  ctx.fillText("level:" + level, 337 - ctx.measureText("level:" + level).width, 383.5);
  ctx.restore();
}

let footX = ref(35);
let footY = ref(35);
//绘制食物
function drawFoot(ctx: CanvasRenderingContext2D) {
  let top = Math.round(Math.random() * 29) * 10 + 35;
  let left = Math.round(Math.random() * 29) * 10 + 35;

  ctx.save();
  //左上角坐标
  // ctx.translate(39, 35);
  //右上角
  // ctx.translate(290+39, 35);
  //右下角
  // ctx.translate(290+39, 35+290-2);
  //左下角
  // ctx.translate(39, 35+290-2);
  if (footX.value === snakeList.value[0][0] && footY.value === snakeList.value[0][1]) {
    ctx.translate(left, top);
    footX.value = left;
    footY.value = top;
  } else {
    ctx.translate(footX.value, footY.value);
  }

  ctx.fillStyle = "#000";
  // ctx.rotate((45 * Math.PI) / 180);
  ctx.fillRect(0, 0, 8, 8);
  // console.log("x轴:", left - 35, "Y轴:", top - 35);
  ctx.restore();
}
//绘制蛇
let snakeList = ref([[35, 35]]);

function drawSnake(ctx: CanvasRenderingContext2D) {
  for (let i = 0; i < snakeList.value.length; i++) {
    ctx.save();
    ctx.translate(snakeList.value[i][0], snakeList.value[i][1]);
    ctx.fillStyle = "#000";
    // ctx.rotate((45 * Math.PI) / 180);
    ctx.fillRect(0, 0, 8, 8);
    ctx.restore();
  }
}

let direction = ref("");
function keydownHandler(event: KeyboardEvent, context?: CanvasRenderingContext2D) {
  // console.log(event.key);
  direction.value = event.key;
  // run(context)
}
let isLive = ref(true);
function run(ctx: CanvasRenderingContext2D) {
  let X = snakeList.value[0][0];
  let Y = snakeList.value[0][1];
  switch (direction.value) {
    case "ArrowUp":
    case "Up":
      // 向上移动 top 减少
      Y -= 10;
      break;
    case "ArrowDown":
    case "Down":
      // 向下移动 top 增加
      Y += 10;
      break;
    case "ArrowLeft":
    case "Left":
      // 向左移动 left 减少
      X -= 10;
      break;
    case "ArrowRight":
    case "Right":
      // 向右移动 left 增加
      X += 10;
      break;
    default:
      X += 10;
      break;
  }

  // console.log(snakeList.value[0][0] ==X, snakeList.value[0][1] == Y);
  // console.log("蛇头", snakeList.value[0][0], snakeList.value[0][1]);
  // console.log("偏移到的位置", X, Y);
  if (snakeList.value[1] && snakeList.value[1][0] === X && snakeList.value[1][1] === Y) {
    // console.log("第一个蛇身", snakeList.value[1][0], snakeList.value[1][1]);
    //命中直接取反
    switch (direction.value) {
      case "ArrowUp":
      case "Up":
        // 向上移动 top 减少
        Y += 20;
        break;
      case "ArrowDown":
      case "Down":
        // 向下移动 top 增加
        Y -= 20;
        break;
      case "ArrowLeft":
      case "Left":
        // 向左移动 left 减少
        X += 20;
        break;
      case "ArrowRight":
      case "Right":
        // 向右移动 left 增加
        X -= 20;
        break;
      default:
        X -= 20;
        break;
    }
  }
  checkEat(X, Y);
  changeListXY();

  snakeList.value[0][0] = X;
  snakeList.value[0][1] = Y;
  if (snakeList.value[0][0] > 290 + 35 || snakeList.value[0][0] < 35 || snakeList.value[0][1] > 290 + 35 || snakeList.value[0][1] < 35) {
    console.log("我撞墙了");
    alert("我撞墙了" + " GAME OVER!");
    isLive.value = false;
    return;
  }
  if (isEatBody()) {
    alert("吃到自己了" + " GAME OVER!");
    isLive.value = false;
    return;
  }

  // console.log(snakeList.value);

  ctx.clearRect(0, 0, 370, 430);
  initCanvas(ctx);
  isLive.value &&
    setTimeout(() => {
      run(ctx);
    }, 300 - (level - 1) * 30);
}

function changeListXY() {
  for (let i = snakeList.value.length - 1; i > 0; i--) {
    // console.log(i)
    // let X = ;
    // let Y = ;

    snakeList.value[i][0] = snakeList.value[i - 1][0];
    snakeList.value[i][1] = snakeList.value[i - 1][1];
    // console.log(X,Y,snakeList.value)
    // debugger
  }
}

function checkEat(X: number, Y: number) {
  if (X === footX.value && Y === footY.value) {
    snakeList.value.push([X, Y]);
    // console.log(snakeList.value);
    // debugger;
  }
}

function isEatBody() {
  for (let i = 1; i < snakeList.value.length; i++) {
    if (snakeList.value[0][0] === snakeList.value[i][0] && snakeList.value[0][1] === snakeList.value[i][1]) {
      console.log("吃到自己了", i);
      return true;
    }
  }

  return false;
}

onMounted(() => {
  draw();
});

onBeforeUnmount(() => {
  document.removeEventListener("keydown", (event) => keydownHandler(event));
});
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}

html,body,#app{
  margin: 0;
  padding: 0;
}
</style>

总结

这样一个用canvas画的一个贪吃蛇小游戏就完成了,终于不再是截图和做海报了。