前言

还记得刚上大学的时候,第一节编程课,老师说:“不要让我抓到你们玩游戏,否则玩什么就让你们写什么”。当时什么也不会,也不敢玩。

如果现在能回到那节课,我就可以肆无忌惮的玩我的 贪吃蛇 了,被她抓到直接把源码地址给她一丢,岂不快哉。

言归正传,那么本文将带你实现一个网页版贪吃蛇小游戏,技术栈选用当前热门的 Vite + Vue3 + Ts

👉👉 在线试玩 👉👉 源码地址

建议结合源码阅读本文,效果更佳哦 ~

游戏截图

未标题-1.png

在这里插入图片描述

11.gif

目录结构

├── src 
     ├── assets      // 存放静态资源
     ├── components  // vue组件
     │    ├── Cell.vue         // 每一个小方块
     │    ├── Controller.vue   // 游戏控制器 
     │    ├── KeyBoard.vue     // 移动端软键盘 
     │    └── Map.vue          // 地图组件
     ├── game       // 游戏核心逻辑
     │    ├── GameControl.ts  // 控制器类
     │    ├── Food.ts         // 食物类
     │    ├── Snake.ts        // 蛇类
     │    ├── hit.ts          // 碰撞的逻辑
     │    ├── render.ts       // 渲染视图的逻辑
     │    ├── map.ts          // 跟地图相关的逻辑 
     │    └── index.ts        // 主流程
     ├── types      // TS类型
     ├── utils      // 工具函数
     ├── main.ts    // 主入口文件
     └── App.vue    // vue根组件

         ......

实现过程

注:实现过程只截取关键代码进行讲解,建议对照源码进行阅读,更容易理解。

游戏画面渲染

/src/game/map.ts

// 获取屏幕尺寸
const clientWidth = document.documentElement.clientWidth - 20;
const clientHeight = document.documentElement.clientHeight - 40;

// 行数
export const gameRow = clientWidth > 700 ? Math.floor(clientHeight / 54) : Math.floor(clientHeight / 34);

// 列数
export const gameCol = clientWidth > 700 ? Math.floor(clientWidth / 54) : Math.floor(clientWidth / 34);

// 初始化地图  现在所有的位置type都是0
export function initMap(map: Map) {
  for (let i = 0; i < gameRow; i++) {
    const arr: Array<number> = [];
    for (let j = 0; j < gameCol; j++) {
      arr.push(0);
    }
    map.push(arr);
  }
  return map;
}

如何计算格子的数量?

我这里通过获取设备屏幕的宽高,来进行一个设备的判断,屏幕大的格子就大一点(50px),屏幕小的格子就小一点(30px)。这里我的宽高都减去了一点,目的是让画面有区域感,看起来更美观。

然后通过计算屏幕的宽高除以每个格子的大小,获取到地图的 行数列数 ,因为每个格子直接有 2pxmargin,所以是 5434

如何产生地图?

然后,我们根据上一步计算出来的 行数列数 ,通过 二维数组 来进行地图的渲染。二维数组的元素值决定着每一个小格子的颜色。因为是初始化,我们就先默认全部设为 0,再把元素的值传递给子组件 Cell.vue

/src/components/Map.vue

<template>
  <div class="game-box">
    <!---->
    <div class="row"
         v-for='row in gameRow'
         :key='row'>
      <!---->
      <div class="col"
           v-for='col in gameCol'
           :key='col'>
        <!-- 小格子 -->
        <Cell :type='map[row-1][col-1]'></Cell>
      </div>
    </div>
  </div>
</template>

如何区分元素?

/src/components/Cell.vue

<template>
  <div class='cell-box'
       :class='classes'>
  </div>
</template>

<script lang='ts' setup>
import { computed, defineProps } from 'vue';
const props = defineProps(['type']);
// 小格子的颜色
const classes = computed(() => {
  return {
    head: props.type === 2,
    body: props.type === 1,
    food: props.type === -1,
  };
});
</script>

想一下整个游戏地图上都会出现哪些元素呢,蛇头(2),蛇身(1)和食物(-1)。所以我们根据不同的元素值赋予不同的 class,就可以让不同的元素在地图上展示不同的样式了。

控制器类的设计

/src/game/GameControl.ts

export class GameControl {
  // 蛇
  snake: Snake;
  // 食物
  private _food: Food;
  // 地图
  private _map: Map;
  // 游戏状态
  private _isLive: IsLive;
  constructor(map: Map, isLive: IsLive) {
    this._map = map;
    this.snake = new Snake();
    this._food = new Food();
    this._isLive = isLive;
  }
  // 开始游戏
  start() {
    // 绑定键盘按键按下的事件
    document.addEventListener('keydown', this.keydownHandler.bind(this));
    // 添加到帧循环列表
    addTicker(this.handlerTicker.bind(this));
    // 标记游戏状态为开始
    this._isLive.value = 2;
  }
  // 创建一个键盘按下的响应函数
  keydownHandler(event: KeyboardEvent) {
    this.snake.direction = event.key;
  }
  // 渲染map
  private _timeInterval = 200;
  // 是否移动蛇
  private _isMove = intervalTimer(this._timeInterval);
  // 定义帧循环函数
  handlerTicker(n: number) {
    if (this._isMove(n)) {
      try {
        this.snake.move(this.snake.direction, this._food);
      } catch (error: any) {
        // 标记游戏状态为结束
        this._isLive.value = 3;
        // 停止循环
        stopTicker();
      }
    }
    render(this._map, this.snake, this._food);
  }
  // 重新开始游戏
  replay() {
    reset(this._map);
    this.snake.direction = 'Right';
    this.snake = new Snake();
    this._food = new Food();
    this._isLive.value = 2;
    addTicker(this.handlerTicker.bind(this));
  }
}

开始游戏

开始游戏的时候我们要做三件事情,首先绑定键盘事件,然后添加帧循环让游戏动起来,最后把游戏状态置为游戏中。

如何添加/停止帧循环?

不有了解帧循环的可以参考我下面这篇文章。

👉👉 一个神奇的前端动画 API requestAnimationFrame

/src/utils/ticker.ts

let startTime = Date.now();
type Ticker = Function;
let tickers: Array<Ticker> = [];
const handleFrame = () => {
  tickers.forEach((ticker) => {
    ticker(Date.now() - startTime);
  });
  startTime = Date.now();
  requestAnimationFrame(handleFrame);
};
requestAnimationFrame(handleFrame);
//添加帧循环
export function addTicker(ticker: Ticker) {
  tickers.push(ticker);
}
//停止帧循环
export function stopTicker() {
  tickers = [];
}
// 时间累加器
export function intervalTimer(interval: number) {
  let t = 0;
  return (n: number) => {
    t += n;
    if (t >= interval) {
      t = 0;
      return true;
    }
    return false;
  };
}

重新开始游戏

重新开始游戏的时候我们同样要做三件事情,重置地图,添加帧循环,把游戏状态置为游戏中。

蛇类的设计

/src/game/Snake.ts

export class Snake {
  bodies: SnakeBodies;
  head: SnakeHead;
  // 创建一个属性来存储蛇的移动方向(也就是按键的方向)
  direction: string;
  constructor() {
    this.direction = 'Right';
    this.head = {
      x: 1,
      y: 0,
      status: 2,
    };
    this.bodies = [
      {
        x: 0,
        y: 0,
        status: 1,
      },
    ];
  }
  // 定义一个方法,用来检查蛇是否吃到食物
  checkEat(food: Food) {
    if (this.head.x === food.x && this.head.y === food.y) {
      // 分数增加
      // this.scorePanel.addScore();
      // 食物的位置要进行重置
      food.change(this);
      // 蛇要增加一节
      this.bodies.unshift({
        x: food.x,
        y: food.y,
        status: 1,
      });
    }
  }
  // 控制蛇移动
  move(food: Food) {
    // 判断是否游戏结束
    if (hitFence(this.head, this.direction) || hitSelf(this.head, this.bodies)) {
      throw new Error('游戏结束');
    }
    const headX = this.head.x;
    const headY = this.head.y;
    const bodyX = this.bodies[this.bodies.length - 1].x;
    const bodyY = this.bodies[this.bodies.length - 1].y;
    switch (this.direction) {
      case 'ArrowUp':
      case 'Up':
        // 向上移动 需要检测按键是否相反方向
        if (headY - 1 === bodyY && headX === bodyX) {
          moveDown(this.head, this.bodies);
          this.direction = 'Down';
          return;
        }
        moveUp(this.head, this.bodies);
        break;
      case 'ArrowDown':
      case 'Down':
        // 向下移动 需要检测按键是否相反方向
        if (headY + 1 === bodyY && headX === bodyX) {
          moveUp(this.head, this.bodies);
          this.direction = 'Up';
          return;
        }
        moveDown(this.head, this.bodies);
        break;
      case 'ArrowLeft':
      case 'Left':
        // 向左移动 需要检测按键是否相反方向
        if (headY === bodyY && headX - 1 === bodyX) {
          moveRight(this.head, this.bodies);
          this.direction = 'Right';
          return;
        }
        moveLeft(this.head, this.bodies);
        break;
      case 'ArrowRight':
      case 'Right':
        // 向右移动 需要检测按键是否相反方向
        if (headY === bodyY && headX + 1 === bodyX) {
          moveLeft(this.head, this.bodies);
          this.direction = 'Left';
          return;
        }
        moveRight(this.head, this.bodies);
        break;
      default:
        break;
    }
    // 检查蛇是否吃到食物
    this.checkEat(food);
  }
  // 移动端修改移动方向
  changeDirection(direction: string) {
    if (direction === 'Left' && this.direction !== 'Left' && this.direction !== 'Right') {
      this.direction = 'Left';
      return;
    }
    if (direction === 'Right' && this.direction !== 'Left' && this.direction !== 'Right') {
      this.direction = 'Right';
      return;
    }
    if (direction === 'Up' && this.direction !== 'Up' && this.direction !== 'Down') {
      this.direction = 'Up';
      return;
    }
    if (direction === 'Down' && this.direction !== 'Up' && this.direction !== 'Down') {
      this.direction = 'Down';
      return;
    }
  }
}

蛇如何移动?

这个地方是困扰我最长时间的,但是只要想通了就不是很难。我们需要根据方向去修改蛇头的坐标,然后我们把蛇头的坐标放进蛇身体的数组的最后一个元素,然后再删掉蛇身体的数组的第一个元素。因为蛇移动永远都是下一节的蛇身走到上一节蛇身的位置,这样视图上看起来就像是蛇在移动了。

/src/game/Snake.ts

// 向上移动
function moveUp(head: SnakeHead, bodies: SnakeBodies) {
  head.y--;
  bodies.push({
    x: head.x,
    y: head.y + 1,
    status: 1,
  });
  bodies.shift();
}
// 向下移动
function moveDown(head: SnakeHead, bodies: SnakeBodies) {
  head.y++;
  bodies.push({
    x: head.x,
    y: head.y - 1,
    status: 1,
  });
  bodies.shift();
}
// 向右移动
function moveRight(head: SnakeHead, bodies: SnakeBodies) {
  head.x++;
  bodies.push({
    x: head.x - 1,
    y: head.y,
    status: 1,
  });
  bodies.shift();
}
// 向左移动
function moveLeft(head: SnakeHead, bodies: SnakeBodies) {
  head.x--;
  bodies.push({
    x: head.x + 1,
    y: head.y,
    status: 1,
  });
  bodies.shift();
}

然后我们要把新的蛇的位置信息渲染到视图上。

/src/game/render.ts

// 每一次渲染都需要将map重置,然后再进行新数据的渲染
export function render(map: Map, snake: Snake, food: Food) {
  // 重置map
  reset(map);
  // 渲染蛇头
  _renderSnakeHead(map, snake.head);
  // 渲染蛇身
  _renderSnakeBody(map, snake.bodies);
  // 渲染食物
  _renderFood(map, food);
}
// 重置map 将二维数组所有元素重置为0
export function reset(map: Map) {
  for (let i = 0; i < map.length; i++) {
    for (let j = 0; j < map[0].length; j++) {
      if (map[i][j] !== 0) {
        map[i][j] = 0;
      }
    }
  }
}
// 渲染蛇身 -1 食物 1 蛇身体 2 蛇头
function _renderSnakeBody(map: Map, bodies: SnakeBodies) {
  for (let i = 0; i < bodies.length; i++) {
    const row = bodies[i].y;
    const col = bodies[i].x;
    map[row][col] = 1;
  }
}
// 渲染蛇头 -1 食物 1 蛇身体 2 蛇头
function _renderSnakeHead(map: Map, head: SnakeHead) {
  const row = head.y;
  const col = head.x;
  map[row][col] = 2;
}
// 渲染食物 -1 食物 1 蛇身体 2 蛇头
function _renderFood(map: Map, food: Food) {
  const row = food.y;
  const col = food.x;
  map[row][col] = -1;
}

如何检测蛇是否吃到食物?

这个就很简单了,只要判断蛇头的坐标和蛇身体是否一样就行了。当相同的时候我们往蛇身体的数组push 当前蛇头的位置,但是不删掉蛇尾的元素,视图上看起来就像是蛇增加了一节。

如何检测蛇的碰撞?

游戏结束有两种情况,一种是碰到边界,一种是碰到自己。碰到边界的判断就是蛇头的坐标是否超过了行数列数。碰到自己的判断就是蛇头的坐标是否和蛇身体的某一节重合。

/src/game/hit.ts

// 蛇头是否触碰到边界
export function hitFence(head: SnakeHead, direction: string) {
  // 1.获取蛇头的位置
  // 2.检测蛇头是不是超出了游戏的范围
  let isHitFence = false;
  switch (direction) {
    case 'ArrowUp':
    case 'Up':
      // 向上移动
      isHitFence = head.y - 1 < 0;
      break;
    case 'ArrowDown':
    case 'Down':
      // 向下移动   因为head.y是从0开始的 gameRow是从1开始的 所以gameRow要-1
      isHitFence = head.y + 1 > gameRow - 1;
      break;
    case 'ArrowLeft':
    case 'Left':
      // 向左移动
      isHitFence = head.x - 1 < 0;
      break;
    case 'ArrowRight':
    case 'Right':
      // 向右移动
      isHitFence = head.x + 1 > gameCol - 1;
      break;
    default:
      break;
  }
  return isHitFence;
}
// 蛇头是否触碰到自己
export function hitSelf(head: SnakeHead, bodies: SnakeBodies) {
  // 1.获取蛇头的坐标
  const x = head.x;
  const y = head.y;
  // 2.获取身体
  const snakeBodies = bodies;
  // 3.检测蛇头是不是撞到了自己,也就是蛇头的下一步移动会不会和身体数组的元素重复
  const isHitSelf = snakeBodies.some((body) => {
    return body.x === x && body.y === y;
  });
  return isHitSelf;
}

如何改变蛇的移动方向?

这个也很简单,修改对应的 direction 值就好了,但是要注意判断蛇是不可以回头的。

食物类的设计

如何随机生成食物?

通过生成随机数,产生一个随机的坐标,当新坐标与蛇重合时,调用自身再次生成即可。

/src/game/Food.ts

export class Food {
  // 食物的坐标
  x: number;
  y: number;
  status = -1;
  constructor() {
    this.x = randomIntegerInRange(0, gameCol - 1);
    this.y = randomIntegerInRange(0, gameRow - 1);
  }
  // 修改食物的位置
  change(snake: Snake) {
    // 生成一个随机的位置
    const newX = randomIntegerInRange(0, gameCol - 1);
    const newY = randomIntegerInRange(0, gameRow - 1);
    // 1.获取蛇头的坐标
    const x = snake.head.x;
    const y = snake.head.y;
    // 2.获取身体
    const bodies = snake.bodies;
    // 3.食物不可以和头部以及身体重合
    const isRepeatBody = bodies.some((body) => {
      return body.x === newX && body.y === newY;
    });
    const isRepeatHead = newX === x && newY === y;
    // 不满足条件重新随机
    if (isRepeatBody || isRepeatHead) {
      this.change(snake);
    } else {
      this.x = newX;
      this.y = newY;
    }
  }
}

结语

想要练习的小伙伴们可以照着我的思路走一下,有不懂的可以评论区问我,我看到了会第一时间回复的。

👉👉 在线试玩 👉👉 源码地址

喜欢的小伙伴不要忘了点赞和 star 哦 ~

Logo

前往低代码交流专区

更多推荐