Canvas 贪吃蛇大作战

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/FlowGuanEr/article/details/102466686

阅读需知

这个游戏是给小白练手的,大神请饶命
只有一条蛇,别说我骗你
目的是为了练习 JS + Canvas 的逻辑
代码虽然不难,但由于本人能力有限,所以计算过程还是有一丢烧脑,所以 … 谁抄咬谁,转载啥的,注明出处

效果

在这里插入图片描述
在这里插入图片描述

蛇的小眼睛看到了吗,很 Q 有没有

代码如下

html:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
  <style>
    * {
      margin: 0;
      padding: 0;
    }
    .btn {
      width: 250px;
      height: 100px;
      line-height: 100px;
      text-align: center;
      position: fixed;
      font-size: 30px;
      color: #fff;
      left: calc((100vw - 200px) / 2);
      top: calc((100vh - 200px) / 2);
      cursor: pointer;
      background: #ff2255;
      opacity: .8;
      border-radius: 8px;
      box-shadow: 0 0 5px #ff0022;
    }
  </style>
</head>
<body>
<div class="btn">开始游戏</div>
<canvas id="cv"></canvas>
<script src="snake.js"></script>
</body>
</html>
JS
const btn = document.getElementsByClassName('btn')[0]; // 开始按钮
const cv = document.getElementById('cv'); // 画布
const ctx = cv.getContext('2d'); // canvas 的绘图对象

const PI = Math.PI;

// 背景网格的宽度
const net = 20;

// 遥杆外圈半径
const dr = 80;
// 摇杆内部半径
const dcr = 50;

// 初始化蛇的数量
const initNum = 5;
// 构成舍身的圆的半径
const snakeR = 20;
// 蛇沿任意方向(snakeDeg)移动的速度
const snakeV = 2;
// 构成蛇身上的圆之间的距离,并不代表像素值
const snakeDis = 15;

// 食物的半径
const foodR = 3;
// 初始化食物的数量
const initFoodNum = 200;

// 两眼睛之间角度: eyesDeg * 2
const eyesDeg = PI / 3;
// 眼睛的半径
const eyesR = 4;

// 眼睛的颜色
let ec = getColor();

// 蛇已经吃到的食物的数量
let snakeHasFoodNum = 0;
// 蛇行走的角度的初始值,游戏运行过程中需要用摇杆控制,是一个 [-PI / 2, PI / 3 * 2] 之间的值
let snakeDeg = getDeg();
// 所有蛇身上的圆的数组
let snakeArr = [];
// 蛇头走过的所有点的数组
let pointArr = [];
// 所有食物的数组
let foodArr = [];
// 画布的宽高,用于计算
let cw, ch;
// 获取设备屏幕的宽高,设置成画布的宽高
cv.width = cw = window.innerWidth;
cv.height = ch = window.innerHeight;

// 摇杆范围的中心坐标
const controlX = cw - 50 - dr, controlY = ch - 50 - dr;
// 摇杆的中心点坐标
let centerX = controlX, centerY = controlY;
// 摇杆范围的背景和摇杆的背景
const controlBg = `rgba(80, 80, 80, .2)`, centerBg = `rgba(0, 0, 0, .2)`;

// 游戏运行过程中唯一的计时器
let timer;

// 蛇类(指的是蛇身上的圆类)
class Snake {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.bg = getColor();
  }
  draw() {
    drawCircle(this.x, this.y, this.bg, snakeR);
  }
}

// 鼠标移动过的点类
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

// 食物类
class Food {
  constructor() {
    this.x = rn(foodR, cw - foodR);
    this.y = rn(foodR, ch - foodR);
    this.bg = getColor();
  }
  draw() {
    drawCircle(this.x, this.y, this.bg, foodR);
  }
}

// 随机数函数,返回一个指定范围之内的整数
function rn(x, y) {
  return Math.round(Math.random() * (y - x) + x);
}

// 随机颜色函数,返回一个指定范围之内的随机 rgb 颜色
function getColor() {
  return `rgb(${rn(80, 255)},${rn(80, 255)},${rn(80, 255)})`;
}

// 返回一个随机的角度
function getDeg() {
  return (Math.random() * PI).toFixed(2) - 0;
}

// 画圆函数,在 (x, y) 的位置,画一个半径为 r 的 圆,填充颜色为 bg
function drawCircle(x, y, bg, r) {
  ctx.beginPath();
  ctx.fillStyle = bg;
  ctx.arc(x, y, r, 0, 2 * PI);
  ctx.fill();
}

function draw(arr) {
  for(let item of arr) {
    item.draw();
  }
}

// 绘制摇杆控制范围和摇杆
function drawControl() {
  drawCircle(controlX, controlY, controlBg, dr);
  drawCircle(centerX, centerY, centerBg, dcr);
}

// 网格背景的绘制
function drawNet() {
  // 计算水平线和竖直线的绘制次数
  const xc = cw / net, yc = ch / net;
  // 按照较大的那个循环绘制网格
  const c = xc > yc ? xc : yc;

  ctx.beginPath();
  ctx.lineWidth = .1;
  ctx.fillStyle = '#aaa';
  for(let i = 0; i < c; i++) {
    // 竖直线
    if (i < xc) {
      ctx.moveTo(i * net, 0);
      ctx.lineTo(i * net, ch);
    }
    // 水平线
    if (i < yc) {
      ctx.moveTo(0, i * net);
      ctx.lineTo(cw, i * net);
    }
  }
  ctx.stroke();
}

// 初始化蛇身
function initBody() {
  // 蛇头的位置(在中心)
  let initX = cw / 2, initY = ch / 2;
  // 实例化一个蛇头对象
  const sh = new Snake(initX, initY);
  snakeArr.push(sh);

  // 蛇身
  for(let i = 1; i < initNum; i++) {
    // 实例化蛇身对象,蛇身都不需要位置,因为每个蛇身走的点都是蛇头走过的点
    let sb = new Snake();
    snakeArr.push(sb);
  }
}

// 蛇眼睛的绘制
function snakeEyes() {
  // 蛇头
  let sh = snakeArr[0];
  /* 两眼睛的坐标
   * - 以 snakeDeg 为 0 度角,蛇头的坐标为 (0, 0) 点绘制眼睛
   * - 以蛇头的半径为斜边,用 eyesDeg 的正弦余弦值计算眼睛的坐标
   * - 两个眼睛的 y 坐标是沿 x 正半轴对称的
   * */
  let
    x = Math.cos(eyesDeg) * snakeR,
    y1 = Math.sin(eyesDeg) * snakeR,
    y2 = -y1;

  ctx.save();
  ctx.translate(sh.x, sh.y);
  ctx.rotate(snakeDeg);
  drawCircle(x, y1, ec, eyesR);
  drawCircle(x, y2, ec, eyesR);
  ctx.restore();

}

// 绘制蛇
function drawSnake() {
  for(let i = snakeArr.length - 1; i >= 0; i--) {
    snakeArr[i].draw();
  }
  snakeEyes();
}

/* 蛇身每个元素的位置
*  - 蛇沿任意方向移动的速度是 2 (snakeV)
*  - pointArr 中是蛇头走过的坐标列表
*  - 游戏开始之后会从 pointArr 这个数组的头部开始添加根据 snakeDeg 和 snakeV 计算出的坐标点
*  - 计时器每执行一次,构成蛇的所有圆的坐标都要根据 pointArr 重新绘制
*  - snakeDis 代表蛇身上圆与圆之间像个的 pointArr 中的坐标个数
*  - 如果 pointArr 为空的话,就只绘制蛇头
*  - 假如 pointArr 和 snakeArr 分别如下
*
*    pointArr: [p1, p2 , ... , p15, p16, ... , p30, p31, ... , pn]
*    snakeArr: [a, b, c, d, e]
*
*    这个循环的执行规律应该是:
*    i   snakeArr[i]    t    pointArr[t]
*    0       a          0        p1
*    1       b          15       p15
*    2       c          30       p30
*    3       d          45       p45
*    4       e          60       p60
*
*    所以虽然蛇头移动过的点非常紧密,但是蛇身上的圆和圆之间还是有一定距离
*  - 如果计时器执行了一次,pointArr 中就会添加进去一个新的蛇头移动的点
*    pointArr 就会是这样:
*    [p0, p1, p2 , ... , p15, p16, ... , p30, p31, ... , pn]
*
*    此时 changePosition 会调用一次,之后会重新绘制蛇,那么循环的执行规律是:
*    i   snakeArr[i]    t    pointArr[t]
*    0       a          0        p0
*    1       b          15       p14
*    2       c          30       p29
*    3       d          45       p44
*    4       e          60       p59
*
*    如果计时器再执行一次:
*    pointArr: [p, p0, p1, p2 , ... , p15, p16, ... , p30, p31, ... , pn]
*    循环执行规律:
*    i   snakeArr[i]    t    pointArr[t]
*    0       a          0        p
*    1       b          15       p13
*    2       c          30       p28
*    3       d          45       p43
*    4       e          60       p58
*
*    以此规律执行,蛇身上的每个点要走的路径都是蛇头要走的路径
* */
function changePosition() {
  let t = 0;
  for(let i = 0; i < snakeArr.length; i++) {
    if(!pointArr[t]) break;
    snakeArr[i].x = pointArr[t].x;
    snakeArr[i].y = pointArr[t].y;

    t += snakeDis;
  }
}

/* 对于 pointArr 的优化:
*  - 根据 changePosition 的逻辑,需要将蛇头走过的所有路径保留下来
*  - 但是计时器运行速度非常快,随着运行游戏的进行,pointArr 中的元素会越来越多
*    假如蛇身总共有 100 个 圆,需要的点总共 100 * snakeDis(50) = 500 个
*    但是实际上如果游戏 运行 10s,计时器执行 10000 / 10 = 1000 个点,20s 就是 2000 个
*    大量存储冗余因此而生
* */
function pointSplice() {
  // 蛇需要的点的个数
  let needPointNum = snakeArr.length * snakeDis;
  // pointArr 中元素的个数
  let pointNum = pointArr.length;
  // 如果 pointArr 中元素的个数超过需要的点的个数
  if(pointNum > needPointNum) {
    // 就 pointArr 中不用的点都删除
    pointArr.splice(needPointNum, pointNum - needPointNum);
  }
}

/*
*  蛇头随着计时器的调用移动:
*  - snakeDeg 是通过摇杆控制计算出的角度
*  - 计时器每执行一次,蛇沿任意方向移动的距离为 snakeV
*  - 根据 snakeDeg 的正弦和余弦值,计算出蛇沿任意方向移动 snakeV 时,蛇头的 x 和 y 坐标的边框量
*  - 用蛇头原来的位置加上偏移量,就是该方向新的位置
*  - 将这个位置添加到 pointArr 中的头部
* */
function snakeMove() {
  // 蛇头 x 和 y 方向的变化量
  const vx = snakeV * Math.cos(snakeDeg),
        vy = snakeV * Math.sin(snakeDeg);

  const oldHead = pointArr[0] ? pointArr[0] : snakeArr[0];
  const nx = oldHead.x + vx,
        ny = oldHead.y + vy;
  pointArr.unshift(new Point(nx, ny));
}

/*
 * 碰壁死亡时,在蛇原本的范围内随机下蛇身上所有圆的位置
 * */
function snakeDebris() {
  for(var i = 1; i < snakeArr.length; i++) {
    let x = snakeArr[i].x, y = snakeArr[i].y;
    let _x = x + rn(-30, 30),
        _y = y + rn(-30, 30);
    snakeArr[i].x = _x < snakeR ? snakeR : _x > cw - snakeR ? cw - snakeR : _x;
    snakeArr[i].y = _y < snakeR ? snakeR : _y > ch - snakeR ? ch - snakeR : _y;
  }
}

// 初始化食物
function initFood() {
  for(let i = 0; i < initFoodNum; i++) {
    let food = new Food();
    foodArr.push(food);
  }
}

/*
 * 根据摇杆控制蛇的角度
 * - 蛇移动的角度是摇杆的圆心和摇杆背景圆心连线的角度
 * - 根据摇杆中两个圆心的坐标结合反正弦值(反余弦值也行)计算出角度
 * - 将这个角度设置成 snakeDeg
 * - 由于数学的伟大和奥妙,刚开始算出来的角度不对
 * - 经过不是特别周密的计算,发现象限不一样,角度计算不一样,所以根据象限计算角度
 * - 感兴趣请拿出纸和笔,画个圆,以圆心为 (0, 0) 点,画个象限,自己感受
 * - 写不动了
 * - 如有更好的方法,欢迎指教
 * */
function controlDirection(x, y) {
  const a = x - controlX, b = y - controlY;
  const dis = Math.sqrt(a * a + b * b);

  centerX = dis > dr ? controlX : x;
  centerY = dis > dr ? controlY : y;

  if(dis > dr) {
    cv.onmousemove = null;
  }

  let sindeg = Math.abs(b / dis);

  // 第一象限
  if(a > 0 && b > 0) snakeDeg = Math.asin(sindeg);
  // 二
  if(a < 0 && b > 0) snakeDeg = PI - Math.asin(sindeg);
  // 三
  if(a < 0 && b < 0) snakeDeg = PI + Math.asin(sindeg);
  // 四
  if(a > 0 && b < 0) snakeDeg = -Math.asin(sindeg);
}

// 蛇头和食物的碰撞检测
function hit() {
  const sh = snakeArr[0];
  for(let i = 0; i < foodArr.length; i++) {
    let a = sh.x - foodArr[i].x, b = sh.y - foodArr[i].y;
    let dis = Math.sqrt(a * a + b * b);
    if(dis <= snakeR + foodR) {
      foodArr.splice(i, 1);
      i--;
      
      snakeHasFoodNum++;
      if(snakeHasFoodNum === 10) {
        snakeArr.push(new Snake());
        snakeHasFoodNum = 0;
      }
    }
  }
}

// 碰壁死亡
function dieJudge() {
  let sh = snakeArr[0];
  if(sh.x < snakeR || sh.x > cw - snakeR || sh.y < snakeR || sh.y > ch - snakeR) {
    gameOver();
    return true;
  }
}

function gameOver() {
 btn.style.display = 'block';
 clearInterval(timer);
 snakeHasFoodNum = 0;
 snakeDebris();
}

function initAll() {
  initFood();
  draw(foodArr);
  drawControl();
  drawNet();
}
initAll();

// 按钮又是开始按钮,又是重新开始按钮,所以包含了重新开始功能
btn.onclick = function() {
  btn.style.display = 'none';
  pointArr = [];
  snakeArr = [];
  foodArr = [];
  initBody();
  initFood();
  ec = getColor();
  snakeDeg = getDeg();

  // 在计时器中要使用到不同频率,用这个值控制
  let hz = 0;
  timer = setInterval(function() {
    hz++;
    
    ctx.clearRect(0, 0, cw, ch);
    drawNet();
    draw(foodArr);
    dieJudge();
    drawSnake();
    drawControl();
    changePosition();
    hit();
    pointSplice();

    // 向要让蛇移动的慢点,可以把 1 改大点
    if(hz % 1 === 0) {
      snakeMove();
    }

    // 生成食物的频率,每 .5 秒生成一个
    if(hz % 50 === 0) {
      foodArr.push(new Food());
    }
  }, 10);
};

cv.onmousedown = function() {
  cv.onmousemove = function(ev) {
    let e = ev || event;

    let x = e.offsetX, y = e.offsetY;

    // 控制方向
    controlDirection(x, y);
  };
};
cv.onmouseup = function() {
  centerX = controlX;
  centerY = controlY;
  cv.onmousemove = null;
};

document.onselectstart = function() {
  return false;
};

猜你喜欢

转载自blog.csdn.net/FlowGuanEr/article/details/102466686