经典小游戏复刻系列之贪吃蛇(DOM版)

像素版贪吃蛇是小时候最为经典的游戏之一了,作为一款超级容易上手的游戏,其麻雀虽小,五脏俱全;作为最早的必死结局游戏之一,在有限的地图上,游戏者操纵着贪吃蛇游走在地图上,捕获食物并保证蛇头不撞边界和自身,这操作刚开始很容易,但随着得分的增加,蛇身越来越长,移动速度越来越快,尽管再小心翼翼,却难逃一死,听着这似乎是个令人悲伤的游戏?

笔者是个前端儿,从事前端行业也有好一阵子,之前在网络上也看过别人写的贪吃蛇;但别人的终究是别人的,不如自己写一遍来的爽。

职业病发作,多说无益,开始行动……

工欲善其事必先利其器!贪吃蛇再小,逻辑再单一,在写之前还是要做一番分析的。

一个完整的游戏必然由许多小单元构成,贪吃蛇也不例外。

地图食物是构成这个游戏的三大基本要素。

可是,光有这些可不行,丁是丁卯是卯,玩不到一块去。所以,还需要一些其他的东西,可这些其他东西是什么呢?

认真思考一番后,感觉答案似乎很简单,那就是动起来

是的,只有动起来,贪吃蛇才能移动,才能吃到食物,才能碰到边界或者蛇身。

至此,整个游戏的大体轮廓算是理清楚了,下面就是代码逻辑的实现了。

地图

地图其实是个二维坐标系统,xy的值决定了地图的大小,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的变化,定时器执行间隔越短,那么速度越快,这样在可视化上就模拟出了蛇的移动。

方向

这个也很简单,就是往哪里跑,设定用户使用WSAD来控制蛇的运动方向。

不过,这里要注意一点,那就是贪吃蛇不带倒车功能,也就是说它在往左走的时候,你不能一步到位叫它往右走,它在往上走的时候,你不能一步到位叫它往下走。

碰撞

这个得好好说一下,什么是碰撞?

碰撞分为三种:

  1. 食物碰撞

    这是游戏中最让人开心的碰撞,因为这个的发生意味着你操纵的贪吃蛇吃到了食物,蛇长+1,这也是增加分数的唯一手段。
  2. 边界碰撞

    此游戏设定的地图是有界的,贪吃蛇不能跳出这个范围;所以,游戏过程中需要检测贪吃蛇在下一刻是否在行进方向上超出了地图边界,如果超出,则判断死亡。
  3. 自身碰撞

    作为二维生物的贪吃蛇,身体是不能叠加的,所以,当下一刻,贪吃蛇的行进位置和蛇身的某一部分要叠加了,则意味着贪吃蛇撞自己死亡了!能自己撞自己死亡的蛇,刺不刺激,惊不惊喜,意不意外?

我们要通过代码判断这三种碰撞情况以便决定游戏是继续还是结束。

接下来便继续按照这些思路去完善贪吃蛇……
最后代码如下:

<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>

游戏效果是下面这样的:

游戏暂停
游戏结束

难点

贪吃蛇虽然是个小游戏,但还是有一些棘手的情况需要处理,比如说游戏者操作过快的问题,又比如说键盘默认事件的处理(这个东西说出来就简单的很,没注意到是很坑人)。

最后,因为写这玩意耗费的时间挺长,所以自己也比较珍惜自己写的东西,你们如果觉得好可以转载,但请注明出处,希望不要复制粘贴一下就成自己的了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容