前言
闲来无聊想写一个贪吃蛇试试自己的js水平,那么如题所示要怎么只用一个div实现贪吃蛇呢。
首先canvas肯定是可以实现的,但是那就没意思啦。
其次动态生成div也不是不行,但是这样还是有多个div,而且频繁操作如此多的dom对性能影响很大。
这里通过一个神奇的属性实现效果 ------ box-shadow。
box-shadow
box-shadow相信大家都很熟悉,阴影,其有5个可选参数,具体含义如下:
/* x 偏移量 | y 偏移量 | 阴影模糊半径 | 阴影扩散半径 | 阴影颜色 */
box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2);
值得注意的是其可以一次性写多个阴影,只需要通过逗号分隔即可:
/* x 偏移量 | y 偏移量 | 阴影模糊半径 | 阴影扩散半径 | 阴影颜色 */
box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2), 2px 2px 2px 1px rgba(0, 0, 0, 0.2);
重点来了,如果我们将阴影模糊半径和阴影扩散半径设置为0,那么会产生一个神奇的效果,形成一个个像素块
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.box {
width: 30px;
height: 30px;
background: linear-gradient(to right, red, blue);
box-shadow: 30px 30px 0 red,90px 30px 0 blue;
}
</style>
</head>
<body>
<div class="box"></div>
</body>
</html>
利用这个特性,我们只要改变div的box-shadow属性,就可以渲染出n个像素块,我们只需要关心box-shaow的值是什么。
贪吃蛇
项目地址: https://github.com/pengzhijian/tan-chi-she/tree/main 只有一个html文件,不想看的可以直接去文章底部复制完整代码。
详细教程如下:
1.html和css代码
html只要新增一个div标签即可:
<body>
<div class="game-box"></div>
</body>
css主要设置了div的宽高,以及left、width、color属性,box-shaow的初始值只是为了方便查看效果,
body设置了宽高和边框,body就是贪吃蛇的场地:
* {
margin: 0;
padding: 0;
}
.game-box {
position: absolute;
left: -20px;
width: 20px;
height: 20px;
color: rgba(red, green, blue, 1);
box-shadow: 20px 20px 0 red,20px 80px 0 red;
}
body {
position: relative;
margin-left: 20px;
border: 1px solid black;
width: 95vw;
height: 95vh;
}
2. js代码
1. 初始化
这里我通过一个Snake类去实现贪吃蛇,构造函数的参数有三个分别是 speed(蛇的速度),long(蛇初始长度),width(单个方块的宽度),此外添加了一个snake属性,作为dom节点,一个snakeArr属性去存储蛇各个节点信息:
class Snake {
/***
* speed:蛇速度
* long:蛇初始长度
* width:单个方块宽度
* ***/
constructor(speed, long, width) {
this.speed = speed;
this.long = long;
this.width = width;
// 添加一个snake属性,作为dom节点:
this.snake = document.querySelector('.game-box');
}
// 蛇dom
snake = null
// 蛇节点存放
snakeArr = []
}
然后根据传参width去设置div的宽高及初始的left,此处默认蛇从左上角出现,
场地的大小默认是width的n倍,且根据窗口的大小动态调整,具体实现如下:
this.snake.style.width = width + 'px';
this.snake.style.height = width + 'px';
this.snake.style.left = -width + 'px';
// 自动设置场地大小
document.body.style.width = Math.floor(document.body.clientWidth / this.width) * this.width + 'px';
document.body.style.height = Math.floor(document.body.clientHeight / this.width) * this.width + 'px';
最后根据传参long设置初始的蛇节点,蛇节点有4个属性x(节点的x坐标)、y(节点的y坐标)、color(节点的颜色)、direction(节点的运动方向):
for (let i = 0; i < this.long; i++) {
if (i == this.long - 1) {
this.snakeArr.push({
x: i,
y: 0,
color: 'orange',
direction: 'right'
})
} else {
this.snakeArr.push({
x: i,
y: 0,
color: 'red',
direction: 'right'
})
}
}
2. 更新视图元素
渲染视图只需要把每个蛇节点坐标和颜色赋值给 boxShadow即可,注意的是最后一个元素不加逗号:
// 更新视图元素
updateSnake() {
let boxShadow = this.snakeArr.reduce((pre, cur, index) => {
if (index == this.snakeArr.length - 1) {
return pre + `${cur.x * this.width}px ${cur.y * this.width}px 0 ${cur.color}`
}
return pre + `${cur.x * this.width}px ${cur.y * this.width}px 0 ${cur.color},`
}, '')
this.snake.style.boxShadow = boxShadow;
}
尝试运行效果如下:
<script>
class Snake {
/***
* speed:蛇速度
* long:蛇初始长度
* width:单个方块宽度
* ***/
constructor(speed, long, width) {
this.speed = speed;
this.long = long;
this.width = width;
// 添加一个snake属性,作为dom节点:
this.snake = document.querySelector('.game-box');
this.snake.style.width = width + 'px';
this.snake.style.height = width + 'px';
this.snake.style.left = -width + 'px';
// 自动设置场地大小
document.body.style.width = Math.floor(document.body.clientWidth / this.width) * this.width + 'px';
document.body.style.height = Math.floor(document.body.clientHeight / this.width) * this.width + 'px';
for (let i = 0; i < this.long; i++) {
if (i == this.long - 1) {
this.snakeArr.push({
x: i,
y: 0,
color: 'orange',
direction: 'right'
})
} else {
this.snakeArr.push({
x: i,
y: 0,
color: 'red',
direction: 'right'
})
}
}
this.updateSnake();
}
// 蛇dom
snake = null
// 蛇节点存放
snakeArr = []
// 更新视图元素
updateSnake() {
let boxShadow = this.snakeArr.reduce((pre, cur, index) => {
if (index == this.snakeArr.length - 1) {
return pre + `${cur.x * this.width}px ${cur.y * this.width}px 0 ${cur.color}`
}
return pre + `${cur.x * this.width}px ${cur.y * this.width}px 0 ${cur.color},`
}, '')
this.snake.style.boxShadow = boxShadow;
}
}
let snake = new Snake(1, 5, 50);
</script>
3. 运动逻辑
蛇的运动整体逻辑也很简单,只要判断前进的方向,然后将蛇最后一个节点的位置移动到第一个节点的位置即可,俗称头插法,值得注意的有还需要改变之前头部的颜色和方向,蛇的速度通过setInterval的触发时间实现,具体实现。
重点:运行的方向不能和之前的方向相反,要做一个判断。
// 蛇头的方向
direction = 'right'
beforeDirection = 'right'
// 运行状态
moveTimer = null
// 蛇运动控制
move() {
this.moveTimer = setInterval(() => {
// 将最后一个删掉,插入第一个,变化颜色和位置即可
let startBody = this.snakeArr.shift();
let lastBody = this.snakeArr.at(-1);
startBody.color = 'orange';
lastBody.color = 'red';
if (this.beforeDirection == 'right' && this.direction == 'left' || this.beforeDirection == 'up' && this.direction == 'down' || this.beforeDirection == 'left' && this.direction == 'right' || this.beforeDirection == 'down' && this.direction == 'up') {
this.direction = this.beforeDirection;
}
if (this.direction === 'right') {
startBody.x = lastBody.x + 1;
startBody.y = lastBody.y;
} else if (this.direction === 'left') {
startBody.x = lastBody.x - 1;
startBody.y = lastBody.y;
} else if (this.direction === 'down') {
startBody.x = lastBody.x;
startBody.y = lastBody.y + 1;
} else if (this.direction === 'up') {
startBody.x = lastBody.x;
startBody.y = lastBody.y - 1;
}
startBody.direction = this.direction;
this.beforeDirection = this.direction;
this.snakeArr.push(startBody);
this.updateSnake();
}, 500 / this.speed)
}
在构造函数加上 this.move()后,效果如下:
4. 游戏控制器(上下左右移动)
核心思路:按下按键添加方向,松开按键删除方向,以最后按下的按键为主,允许中途切换方向。
这里我通过一个数组存储按下的按键,然后遍历数组有相同的设置方向即可,具体实现如下:
gameTimer = null
// 游戏控制器,上下左右控制
gameControl() {
let keyCodes = []
// 模式2为 wasd控制,模式1为 上下左右控制
let model = 1;
document.addEventListener('keydown', function (event) {
keyCodes.includes(event.keyCode) ? '' : keyCodes.push(event.keyCode)
console.log('down_key', keyCodes)
})
document.addEventListener('keyup', function (event) {
keyCodes.splice(keyCodes.indexOf(event.keyCode), 1)
})
this.gameTimer = setInterval(() => {
if (model === 1 && keyCodes.length > 0) {
keyCodes.forEach(item => {
switch (item) {
case 37:
this.direction = 'left';
break;
case 38:
this.direction = 'up';
break;
case 39:
this.direction = 'right';
break;
case 40:
this.direction = 'down';
break;
}
})
}
if (model === 2 && keyCodes.length > 0) {
keyCodes.forEach(item => {
switch (item) {
case 65:
this.direction = 'left';
break;
case 87:
this.direction = 'up';
break;
case 68:
this.direction = 'right';
break;
case 83:
this.direction = 'down';
break;
}
})
}
}, 20)
}
在构造函数加上 this.gameControl()后,效果如下:
5. 死亡检测
死亡情况有两种:
- 头和身子合并,判断死亡。
- 头碰到墙壁,判断死亡。
// 死亡检测
deadControl() {
// 1.当头和身子合并,判断死亡
let head = this.snakeArr.at(-1)
let isDead = false
isDead = this.snakeArr.some((item, index) => {
if (head.x === item.x && head.y === item.y && index != this.snakeArr.length - 1) {
item.color = 'blue';
this.updateSnake();
return true
}
return false;
})
// 2.当碰到墙壁时,判断死亡
let sceneWidthCount = Math.floor(document.body.clientWidth / this.width);
let sceneHightCount = Math.floor(document.body.clientHeight / this.width);
// y要大于等于,x要小于1,实测得出结论
if (head.x < 1 || head.y < 0 || head.x > sceneWidthCount || head.y >= sceneHightCount) {
isDead = true;
}
return isDead;
}
在move函数的updateSnake()之前添加死亡检测代码:
// 死亡检测
if (this.deadControl()) {
console.log('dead!!!!!!')
alert('你挂了!!!!!!');
clearInterval(this.moveTimer);
clearInterval(this.controlTimer);
clearInterval(this.gameTimer);
this.moveTimer = null;
this.controlTimer = null;
this.gameTimer = null;
return;
}
this.updateSnake();
效果如下:
6. 生成食物
需求:每次屏幕只有一个食物,被吃完了继续生成一个食物。
生成食物的重点是要在非蛇身的地方随机生成食物,此处实现思路为将游戏场景抽象为 1----n 的方格,根据每个方格的数字去算出x和y,比如 5 * 4 的场景,那么最后一个方格的数字就是20。
foodArr = []
// 被吃完了生成一个食物
foodControl() {
if (this.foodArr.length < 1) {
let sceneWidthCount = Math.floor(document.body.clientWidth / this.width);
let sceneHightCount = Math.floor(document.body.clientHeight / this.width);
// 场景总共的格子数
let countSum = sceneWidthCount * sceneHightCount;
// 排除有食物和身子的格子
let countArr = [];
for (let i = 0; i < countSum; i++) {
countArr.push(i + 1);
for (let j = 0; j < this.snakeArr.length; j++) {
if (this.snakeArr[j].x + sceneWidthCount * this.snakeArr[j].y == i + 1) {
countArr.splice(countArr.indexOf(i + 1), 1)
break;
}
}
}
let randomNum = Math.floor(Math.random() * countArr.length);
let newX = parseInt(countArr[randomNum] % sceneWidthCount);
let newY = countArr[randomNum] % sceneWidthCount == 0 ? Math.floor((countArr[randomNum]) / sceneWidthCount) - 1 : Math.floor((countArr[randomNum]) / sceneWidthCount);
this.foodArr.push({
x: newX == 0 ? 9 : newX,
y: newY,
color: 'pink'
})
}
}
在每次刷新视图时将食物数组加进去一起刷新:
// 更新视图元素
updateSnake() {
this.foodControl();
let allArr = this.foodArr.concat(this.snakeArr)
let boxShadow = allArr.reduce((pre, cur, index) => {
if (index == allArr.length - 1) {
return pre + `${cur.x * this.width}px ${cur.y * this.width}px 0 ${cur.color}`
}
return pre + `${cur.x * this.width}px ${cur.y * this.width}px 0 ${cur.color},`
}, '')
this.snake.style.boxShadow = boxShadow;
}
7. 吃食物判定
当头部位置和食物位置重合时判定,吃完了在尾巴处添加一个新的节点,新节点的位置需要根据尾巴节点的位置判断。(尾插法?)
// 吃食物判定
eatControl() {
// 头部和食物的位置判断,食物目前是每次只有一个,吃完了再生成,吃了在尾部插入一个新的身体,根据尾部节点的位置判断
if (this.snakeArr.at(-1).x == this.foodArr[0].x && this.snakeArr.at(-1).y == this.foodArr[0].y) {
let newX = '';
let newY = '';
if (this.snakeArr[0].direction == 'left') {
newX = this.snakeArr[0].x + 1;
newY = this.snakeArr[0].y;
} else if (this.snakeArr[0].direction == 'right') {
newX = this.snakeArr[0].x - 1;
newY = this.snakeArr[0].y;
} else if (this.snakeArr[0].direction == 'top') {
newX = this.snakeArr[0].x;
newY = this.snakeArr[0].y + 1;
} else if (this.snakeArr[0].direction == 'down') {
newX = this.snakeArr[0].x;
newY = this.snakeArr[0].y - 1;
}
this.snakeArr.unshift({
x: newX,
y: newY,
color: 'red'
});
this.foodArr.splice(0, 1);
}
}
在每次运动刷新前添加吃食物判定(move函数中):
// 食物检测
this.eatControl();
8. 胜利检测
当蛇节点数量等于场景格子数时判断胜利:
if (this.snakeArr.length == Math.floor(document.body.clientWidth / this.width) * Math.floor(document.body.clientHeight / this.width)) {
console.log('win!!!!!!!')
alert('游戏通关!!!!!!!!')
clearInterval(this.moveTimer)
clearInterval(this.controlTimer)
this.moveTimer = null;
this.controlTimer = null;
return;
}
完整html代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>贪吃蛇</title>
<style>
* {
margin: 0;
padding: 0;
}
.game-box {
position: absolute;
left: -20px;
width: 20px;
height: 20px;
color: rgba(red, green, blue, 1);
box-shadow: 20px 20px 0 red,20px 80px 0 red;
}
body {
position: relative;
margin-left: 20px;
border: 1px solid black;
width: 95vw;
height: 95vh;
}
</style>
</head>
<body>
<div class="game-box"></div>
<script>
class Snake {
/***
* speed:蛇速度
* long:蛇初始长度
* width:单个方块宽度
* ***/
constructor(speed, long, width) {
this.speed = speed;
this.long = long;
this.width = width;
// 添加一个snake属性,作为dom节点:
this.snake = document.querySelector('.game-box');
this.snake.style.width = width + 'px';
this.snake.style.height = width + 'px';
this.snake.style.left = -width + 'px';
// 自动设置场地大小
document.body.style.width = Math.floor(document.body.clientWidth / this.width) * this.width + 'px';
document.body.style.height = Math.floor(document.body.clientHeight / this.width) * this.width + 'px';
for (let i = 0; i < this.long; i++) {
if (i == this.long - 1) {
this.snakeArr.push({
x: i,
y: 0,
color: 'orange',
direction: 'right'
})
} else {
this.snakeArr.push({
x: i,
y: 0,
color: 'red',
direction: 'right'
})
}
}
this.updateSnake()
this.move()
this.gameControl()
}
// 蛇头的方向
direction = 'right'
beforeDirection = 'right'
// 蛇节点存放
snakeArr = []
// 蛇dom
snake = null
// 运行状态
moveTimer = null
controlTime = null
gameTimer = null
// 食物存储
foodArr = []
// 更新视图元素
updateSnake() {
this.foodControl();
let allArr = this.foodArr.concat(this.snakeArr)
let boxShadow = allArr.reduce((pre, cur, index) => {
if (index == allArr.length - 1) {
return pre + `${cur.x * this.width}px ${cur.y * this.width}px 0 ${cur.color}`
}
return pre + `${cur.x * this.width}px ${cur.y * this.width}px 0 ${cur.color},`
}, '')
this.snake.style.boxShadow = boxShadow;
}
// 蛇运动控制
move() {
this.moveTimer = setInterval(() => {
// 将最后一个删掉,插入第一个,变化颜色和位置即可
let startBody = this.snakeArr.shift();
let lastBody = this.snakeArr.at(-1)
startBody.color = 'orange';
lastBody.color = 'red';
// console.log(11111112222222222, this.direction)
if (this.beforeDirection == 'right' && this.direction == 'left' || this.beforeDirection == 'up' && this.direction == 'down' || this.beforeDirection == 'left' && this.direction == 'right' || this.beforeDirection == 'down' && this.direction == 'up') {
this.direction = this.beforeDirection;
}
if (this.direction === 'right') {
startBody.x = lastBody.x + 1;
startBody.y = lastBody.y;
} else if (this.direction === 'left') {
startBody.x = lastBody.x - 1;
startBody.y = lastBody.y;
} else if (this.direction === 'down') {
startBody.x = lastBody.x;
startBody.y = lastBody.y + 1;
} else if (this.direction === 'up') {
startBody.x = lastBody.x;
startBody.y = lastBody.y - 1;
}
startBody.direction = this.direction;
this.beforeDirection = this.direction;
this.snakeArr.push(startBody);
if (this.snakeArr.length == Math.floor(document.body.clientWidth / this.width) * Math.floor(document.body.clientHeight / this.width)) {
console.log('win!!!!!!!')
alert('游戏通关!!!!!!!!')
clearInterval(this.moveTimer)
clearInterval(this.controlTimer)
this.moveTimer = null;
this.controlTimer = null;
return;
}
// 死亡检测
if (this.deadControl()) {
console.log('dead!!!!!!')
alert('你挂了!!!!!!');
clearInterval(this.moveTimer);
clearInterval(this.controlTimer);
clearInterval(this.gameTimer);
this.moveTimer = null;
this.controlTimer = null;
this.gameTimer = null;
return;
}
// 食物检测
this.eatControl();
this.updateSnake();
}, 500 / this.speed)
}
// 游戏控制器,上下左右控制
gameControl() {
let keyCodes = []
let model = 1;
document.addEventListener('keydown',function (event) {
keyCodes.includes(event.keyCode) ? '' : keyCodes.push(event.keyCode)
console.log('down_key', keyCodes)
})
document.addEventListener('keyup',function (event) {
keyCodes.splice(keyCodes.indexOf(event.keyCode), 1)
})
this.gameTimer = setInterval(() => {
if (model === 1 && keyCodes.length > 0) {
keyCodes.forEach(item => {
switch (item) {
case 37:
this.direction = 'left';
break;
case 38:
this.direction = 'up';
break;
case 39:
this.direction = 'right';
break;
case 40:
this.direction = 'down';
break;
}
})
}
if (model === 2 && keyCodes.length > 0) {
keyCodes.forEach(item => {
switch (item) {
case 65:
this.direction = 'left';
break;
case 87:
this.direction = 'up';
break;
case 68:
this.direction = 'right';
break;
case 83:
this.direction = 'down';
break;
}
})
}
}, 20)
}
// 死亡检测
deadControl() {
// 1.当头和身子合并,判断死亡
let head = this.snakeArr.at(-1)
let isDead = false
isDead = this.snakeArr.some((item, index) => {
if (head.x === item.x && head.y === item.y && index != this.snakeArr.length - 1) {
item.color = 'blue';
this.updateSnake();
return true
}
return false;
})
// console.log(111111111111111, isDead)
// 2.当碰到墙壁时,判断死亡
let sceneWidthCount = Math.floor(document.body.clientWidth / this.width);
let sceneHightCount = Math.floor(document.body.clientHeight / this.width);
// console.log(77777777777, head)
// y要大于等于,x要小于1,实测得出结论
if (head.x < 1 || head.y < 0 || head.x > sceneWidthCount || head.y >= sceneHightCount) {
// console.log(2222222222, isDead)
isDead = true;
}
return isDead;
}
// 被吃完了生成一个食物
foodControl() {
if (this.foodArr.length < 1) {
let sceneWidthCount = Math.floor(document.body.clientWidth / this.width);
let sceneHightCount = Math.floor(document.body.clientHeight / this.width);
// 场景总共的格子数
let countSum = sceneWidthCount * sceneHightCount;
// 排除有食物和身子的格子
let countArr = [];
for (let i = 0; i < countSum; i ++) {
countArr.push(i + 1);
// console.log(66666666, JSON.stringify(countArr))
for (let j = 0; j < this.snakeArr.length; j ++) {
if (this.snakeArr[j].x + sceneWidthCount * this.snakeArr[j].y == i + 1) {
countArr.splice(countArr.indexOf(i + 1), 1)
// console.log(9999999999, this.snakeArr[j], i, JSON.stringify(countArr))
break;
}
}
}
let randomNum = Math.floor(Math.random() * countArr.length);
let newX = parseInt(countArr[randomNum] % sceneWidthCount);
let newY = countArr[randomNum] % sceneWidthCount == 0 ? Math.floor((countArr[randomNum]) / sceneWidthCount) - 1 : Math.floor((countArr[randomNum]) / sceneWidthCount);
this.foodArr.push({
x: newX == 0 ? 9 : newX,
y: newY,
color: 'yellow'
})
// console.log(88888777777777,randomNum,countArr[randomNum], this.foodArr)
}
}
// 吃食物判定
eatControl() {
// 头部和食物的位置判断,食物目前是每次只有一个,吃完了再生成,吃了在尾部插入一个新的身体,根据尾部节点的位置判断
if (this.snakeArr.at(-1).x == this.foodArr[0].x && this.snakeArr.at(-1).y == this.foodArr[0].y) {
let newX = '';
let newY = '';
if (this.snakeArr[0].direction == 'left') {
newX = this.snakeArr[0].x + 1;
newY = this.snakeArr[0].y;
} else if (this.snakeArr[0].direction == 'right') {
newX = this.snakeArr[0].x - 1;
newY = this.snakeArr[0].y;
} else if (this.snakeArr[0].direction == 'top') {
newX = this.snakeArr[0].x;
newY = this.snakeArr[0].y + 1;
} else if (this.snakeArr[0].direction == 'down') {
newX = this.snakeArr[0].x;
newY = this.snakeArr[0].y - 1;
}
this.snakeArr.unshift({
x: newX,
y: newY,
color: 'red'
});
this.foodArr.splice(0, 1);
// console.log(333333333333, JSON.stringify(this.snakeArr), this.foodArr)
}
}
}
let snake = new Snake(1, 5, 50);
</script>
</body>
</html>