像素版贪吃蛇是小时候最为经典的游戏之一了,作为一款超级容易上手的游戏,其麻雀虽小,五脏俱全;作为最早的必死结局游戏之一,在有限的地图上,游戏者操纵着贪吃蛇游走在地图上,捕获食物并保证蛇头不撞边界和自身,这操作刚开始很容易,但随着得分的增加,蛇身越来越长,移动速度越来越快,尽管再小心翼翼,却难逃一死,听着这似乎是个令人悲伤的游戏?
笔者是个前端儿,从事前端行业也有好一阵子,之前在网络上也看过别人写的贪吃蛇;但别人的终究是别人的,不如自己写一遍来的爽。
职业病发作,多说无益,开始行动……
工欲善其事必先利其器!贪吃蛇再小,逻辑再单一,在写之前还是要做一番分析的。
一个完整的游戏必然由许多小单元构成,贪吃蛇也不例外。
地图
、蛇
、食物
是构成这个游戏的三大基本要素。
可是,光有这些可不行,丁是丁卯是卯,玩不到一块去。所以,还需要一些其他的东西,可这些其他东西是什么呢?
认真思考一番后,感觉答案似乎很简单,那就是动起来
!
是的,只有动起来
,贪吃蛇才能移动,才能吃到食物,才能碰到边界或者蛇身。
至此,整个游戏的大体轮廓算是理清楚了,下面就是代码逻辑的实现了。
地图
地图其实是个二维坐标系统,x
和y
的值决定了地图的大小,x
为横轴,y
为纵轴。
坐标系原点在左上角,整个地图从代码逻辑上讲就是由一个个(x,y)
的点构成:
蛇
蛇只是地图上连续的几个点,比如(0,0),(1,0),(2,0)
,其中(2,0)
是蛇头的位置。
食物
食物跟蛇类似,是地图上与蛇不重复的一个点,比如(8,8)
。
小结
地图
、蛇
、食物
有了,它们综合起来大概便是下面这个样子:
分析到这种程度,贪吃蛇这个小游戏基本上是把地图锄了一遍,把蛇捋了又捋,可以下手开始敲代码了!!!
一般来讲,这种动态的东西用<canvas>
标签做的比较多,但杀鸡焉用牛刀(其实是我暂时还不会),用DOM
就够了。
为了省去繁琐的操作DOM
的写法,我决定使用VUE
框架,使用其数据驱动DOM
的特性来省略大量操作DOM
的代码。另外,es6
的语法在前端浏览器的支持度是越来越高,熟悉es6
语法变得越来越重要,所以此处打算用class
来构建贪吃蛇。
样式
现在的前端行业UI框架盛行,尤其是经常做管理系统的前端,工作中似乎都不用特意去写CSS
样式了,日常工作偏js逻辑,但作为一枚立志成为高级货的前端而言,掌握原生的样式还是很重要的。
重要的事情说三遍:
样式很重要!
样式很重要!
样式很重要!
蛇和食物如何存在
上面分析过,蛇和食物可以用坐标来标识,类似如下代码:
let body = [{x:0,y:0},{x:1,y:0},{x:2,y:0},];
let food = {x:8,y:8};
那如何在地图上体现出来呢?
答案是样式。
之前说过,地图是一个个坐标点格子,只要把蛇坐标的格子样式做的与其他地方不一样便能看的出蛇的效果,食物也是如此。
另外,食物的位置应该是随机的,而且不能与蛇的位置重合,所以需要写一个生成食物的方法。
综合以上几点,初始代码如下:
<template>
<div class="crawl">
<div class="main">
<!-- 游戏主体 -->
<div class="map">
<div v-for="(rows,y) in crawl.map" :key="y" class="row-map">
<div
v-for="(unit,x) in rows"
:key="x"
:class="['unit-map',crawl.body.find(item => item.x === x && item.y === y) ? 'body-map' : (crawl.food.x === x && crawl.food.y === y) ? 'food-map' : '']"
></div>
</div>
</div>
<!-- 侧边状态栏 -->
<div class="aside-panel">
<h6>贪吃蛇</h6>
<p class="instructions">操作方式:按↑、↓、←、→控制蛇前进的方向。</p>
<div class="score">
<p>分数</p>
<p>0</p>
</div>
<button class="game-btn">开始游戏</button>
</div>
</div>
<!-- 游戏信息提示 -->
<div class="info"></div>
</div>
</template>
<script>
export default {
data() {
let crawl = class {
body = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }];
constructor(x, y) {
this.x = x;
this.y = y;
this.createMap();
this.createFood(); // 实例化时执行此函数只是为了看到食物的位置,实际逻辑里食物不在此生成。
}
createMap() {
// 生成地图
this.map = [[]];
for (let i = 0; i < this.y; i++) {
this.map[i] = [];
for (let j = 0; j < this.x; j++) {
this.map[i][j] = 0;
}
}
}
createFood() {
// 生成食物
let x = parseInt(Math.random() * this.x);
let y = parseInt(Math.random() * this.y);
if (this.body.find(item => item.x === x && item.y === y)) {
return this.createFood();
} else {
return this.food = { x, y };
}
}
};
return {
crawl: new crawl(20, 30) // new一个地图大小为20、30的贪吃蛇实例。
};
}
};
</script>
<style scoped>
.crawl {
display: inline-block;
position: relative;
}
.main {
display: flex;
flex-wrap: nowrap;
align-items: stretch;
border: 5px solid #4e4444;
background-color: #9fae87bf;
}
.map {
display: inline-block;
line-height: 0;
}
.row-map {
display: block;
}
/*
** 小时候的游戏机都是使用的印刷屏,仔细看屏幕会看出一个个浅底色的格子
** .unit-map模拟的就是类似的效果
*/
.unit-map {
width: 12px;
height: 12px;
border: 2px solid #a2b1870f;
box-sizing: border-box;
display: inline-block;
}
.body-map {
border: 2px solid #a2b187;
background: #020202;
}
.food-map {
border: 2px solid #a2b187;
background: #020202;
border-radius: 50%;
}
.aside-panel {
width: 120px;
display: inline-block;
border-left: 2px solid #4e4444;
}
.aside-panel h6 {
line-height: 40px;
}
.aside-panel .instructions {
margin: 20px 5px;
}
.aside-panel .game-btn {
border: none;
background: none;
cursor: pointer;
outline: none;
font-size: 20px;
position: absolute;
bottom: 20px;
right: 25px;
width: 80px;
}
</style>
以下便是效果:
动起来
到上面那一步只是实现了整个游戏的静态效果,接下来便是整个游戏的难点及精髓。
笔者:皮皮蛇,起来嗨!
贪吃蛇:你不能叫我动我就动,我也是一条有尊严讲道理有逻辑的蛇。
笔者:……
算了,二次元的蛇总不能抓起来吊打一顿让它乖乖听话吧,还是得用脑子想想如何让贪吃蛇乖乖动起来
。
let body = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }];
body = [{ x: 1, y: 0 }, { x: 2, y: 0 },{ x: 3, y: 0 }];
body = [{ x: 2, y: 0 }, { x: 3, y: 0 }, { x: 4, y: 0 }];
上面这个初始化蛇且重新赋值的过程是不是成功的让蛇往右走了两步!!!
这便是让蛇动起来的基本原理。可玩游戏总不能让使用者像it人士一样敲代码吧,还是需要靠老少皆宜的操作来控制代码的运行。
绞尽乳汁,哦,不对,是绞尽脑汁思考一番后,感觉要让蛇有规律可控的动起来
还需要拆分为速度
、方向
和碰撞
这三个东西。
速度
这个很好理解,就是跑的多快,这里可以用window
下的setInterval
方法来实现,定时执行函数里控制body
的变化,定时器执行间隔越短,那么速度越快,这样在可视化上就模拟出了蛇的移动。
方向
这个也很简单,就是往哪里跑,设定用户使用W
、S
、A
、D
和↑
、↓
、←
、→
来控制蛇的运动方向。
不过,这里要注意一点,那就是贪吃蛇不带倒车功能,也就是说它在往左走的时候,你不能一步到位叫它往右走,它在往上走的时候,你不能一步到位叫它往下走。
碰撞
这个得好好说一下,什么是碰撞?
碰撞分为三种:
- 食物碰撞
这是游戏中最让人开心的碰撞,因为这个的发生意味着你操纵的贪吃蛇吃到了食物,蛇长+1,这也是增加分数的唯一手段。 - 边界碰撞
此游戏设定的地图是有界的,贪吃蛇不能跳出这个范围;所以,游戏过程中需要检测贪吃蛇在下一刻是否在行进方向上超出了地图边界,如果超出,则判断死亡。 - 自身碰撞
作为二维生物的贪吃蛇,身体是不能叠加的,所以,当下一刻,贪吃蛇的行进位置和蛇身的某一部分要叠加了,则意味着贪吃蛇撞自己死亡了!能自己撞自己死亡的蛇,刺不刺激,惊不惊喜,意不意外?
我们要通过代码判断这三种碰撞情况以便决定游戏是继续还是结束。
接下来便继续按照这些思路去完善贪吃蛇……
最后代码如下:
<template>
<div class="crawl">
<div class="main">
<!-- 游戏主体 -->
<div class="map">
<div v-for="(rows,y) in crawl.map" :key="y" class="row-map">
<div
v-for="(unit,x) in rows"
:key="x"
:class="['unit-map',crawl.body.find(item => item.x === x && item.y === y) ? 'body-map' : (crawl.food && crawl.food.x === x && crawl.food.y === y) ? 'food-map' : '']"
></div>
</div>
</div>
<!-- 侧边状态栏 -->
<div class="aside-panel">
<h6>贪吃蛇</h6>
<p class="instructions">操作方式:按↑、↓、←、→控制蛇前进的方向,按空格键暂停游戏。</p>
<div class="score">
<p>分数</p>
<p>{{crawl.scores}}</p>
</div>
<button class="game-btn" @click='crawl.start()'>开始游戏</button>
</div>
</div>
<!-- 游戏信息提示 -->
<div class="info" v-show='crawl.message' @click='crawl.maskHandler'>
<span>{{crawl.message}}</span>
</div>
</div>
</template>
<script>
export default {
data() {
let crawl = class {
body = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }];
direction = 'right';
isSuspend = false; // 是否暂停状态
scores = 0;
directionReduce = 0;
message;
constructor(x = 20, y = 30) {
this.x = x;
this.y = y;
this.maskHandler = this.maskHandler.bind(this);
this.createMap();
this.initEvent();
}
createMap() {
// 生成地图
this.map = [[]];
for (let i = 0; i < this.y; i++) {
this.map[i] = [];
for (let j = 0; j < this.x; j++) {
this.map[i][j] = 0;
}
}
}
createFood() {
// 生成食物
let x = parseInt(Math.random() * this.x);
let y = parseInt(Math.random() * this.y);
if (this.body.find(item => item.x === x && item.y === y)) {
return this.createFood();
} else {
return this.food = { x, y };
}
}
move(direction) {
let oldHead = this.body[this.body.length - 1];
let head = {};
switch(direction){
case 'up':
head.x = oldHead.x;
head.y = oldHead.y - 1;
break;
case 'left':
head.x = oldHead.x - 1;
head.y = oldHead.y;
break;
case 'down':
head.x = oldHead.x;
head.y = oldHead.y + 1;
break;
case 'right':
head.x = oldHead.x + 1;
head.y = oldHead.y;
break;
default:
break;
}
if(JSON.stringify(head) !== '{}'){
if (head.x === this.food.x && head.y === this.food.y) { // 遇到食物
this.body.push(head);
this.createFood();
this.scores += 10;
window.clearInterval(window.timer);
this.interval();
} else if(head.x + 1 > this.x || head.y + 1 > this.y || head.x < 0 || head.y < 0){ // 下一步要逃出边界
window.clearInterval(window.timer);
this.message = '游戏结束';
}else if(this.body.find(item => item.x === head.x && item.y === head.y) && (head.x !== this.body[0].x && head.y !== this.body[0].y)){ // 蛇撞自己了
window.clearInterval(window.timer);
this.message = '游戏结束';
}else{
this.body.push(head);
this.body.shift();
}
}
}
start() {
this.reset();
this.createFood();
this.interval();
}
reset() {
window.clearInterval(window.timer);
this.body = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }];
this.food = {x: undefined,y: undefined};
this.isSuspend = false;
this.direction = 'right';
this.message = '';
}
initEvent() {
window.addEventListener('keyup', e => {
let status = {37:'left',38:'up',39:'right',40:'down',65:'left',68:'right',83:'down',87:'up',}[e.keyCode] || '';
if(status && this.directionReduce < 1) { // keydown第一次被触发
this.isKeyup = false;
this.directionReduce ++;
if(['left','right'].includes(status)){ // 掉头
if(['up','down'].includes(this.direction)){
this.direction = status;
}
}
if(['up','down'].includes(status)){ // 掉头
if(['left','right'].includes(this.direction)){
this.direction = status;
}
}
}else{
e.preventDefault();
if(e.keyCode === 32){
this.isSuspend = !this.isSuspend;
window.clearInterval(window.timer);
this.message = '游戏暂停';
if(!this.isSuspend){
this.interval();
this.message = '';
}
}
}
})
}
interval(){
let duration = 400 - (this.body.length - 3)*10;
window.timer = window.setInterval(() => {
if(this.directionReduce > 0) this.directionReduce --;
this.move(this.direction);
},duration < 20 ? 20 : duration);
}
maskHandler(){
this.message === '游戏结束' && (this.message = '');
}
};
return {
crawl: new crawl() // new一个地图大小为默认的贪吃蛇实例。
};
}
};
</script>
<style scoped>
.crawl {
display: inline-block;
position: relative;
}
.main {
display: flex;
flex-wrap: nowrap;
align-items: stretch;
border: 5px solid #4e4444;
background-color: #9fae87bf;
}
.map {
display: inline-block;
line-height: 0;
}
.row-map {
display: block;
}
/*
** 小时候的游戏机都是使用的印刷屏,仔细看屏幕会看出一个个浅底色的格子
** .unit-map模拟的就是类似的效果
*/
.unit-map {
width: 12px;
height: 12px;
border: 2px solid #a2b1870f;
box-sizing: border-box;
display: inline-block;
}
.body-map {
border: 2px solid #a2b187;
background: #020202;
}
.food-map {
border: 2px solid #a2b187;
background: #020202;
border-radius: 50%;
}
.aside-panel {
width: 120px;
display: inline-block;
border-left: 2px solid #4e4444;
}
.aside-panel h6 {
line-height: 40px;
}
.aside-panel .instructions {
margin: 20px 5px;
}
.aside-panel .game-btn {
border: none;
background: none;
cursor: pointer;
outline: none;
font-size: 20px;
position: absolute;
bottom: 20px;
right: 25px;
width: 80px;
}
.info{
position:absolute;
width:100%;
height:100%;
top: 0;
left: 0;
background:#0000002b;
display: inline-flex;
justify-content: center;
align-items: center;
}
.info span{
display: inline-block;
width: 80px;
height: 30px;
line-height: 30px;
border:2px dotted #413939;
font-weight: bold;
}
</style>
游戏效果是下面这样的:
难点
贪吃蛇虽然是个小游戏,但还是有一些棘手的情况需要处理,比如说游戏者操作过快的问题,又比如说键盘默认事件的处理(这个东西说出来就简单的很,没注意到是很坑人)。
最后,因为写这玩意耗费的时间挺长,所以自己也比较珍惜自己写的东西,你们如果觉得好可以转载,但请注明出处,希望不要复制粘贴一下就成自己的了。