版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
阅读需知
这个游戏是给小白练手的,大神请饶命
只有一条蛇,别说我骗你
目的是为了练习 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;
};