Canvas 貪喫蛇大作戰

閱讀需知

這個遊戲是給小白練手的,大神請饒命
只有一條蛇,別說我騙你
目的是爲了練習 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;
};
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章