閱讀需知
這個遊戲是給小白練手的,大神請饒命
只有一條蛇,別說我騙你
目的是爲了練習 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;
};