如何开发一个可自由扩展的经典贪吃蛇

最近疫情反复,公司提倡在家办公,虽然住的离公司比较近,但是也没办法去公司薅羊毛。公司餐厅这段时间不提供餐饮、零食、饮料,无奈只能呆在家里吃外卖。上周刚完成一个大需求的上线,剩下的工作都是一些内部优化的“主动型”任务,无聊之际想写一些代码。。。突然感觉有点可怜,实在没有什么爱好了,可能年龄到了一定的坎,对于谈情说爱游戏人生,实在提不起什么兴趣,实在不该是年轻人的状态吧。

其实我对代码也没什么兴趣了,可能只是相对而言吧。画UI是没什么意思了,大部分情况下,能还原就行,谁还在乎你的 HTML 语义化和 CSS 优雅程度呢?可能唯一还会在乎的只有你的 JS 了吧。毕竟,只要你不跑路,你的山还是你的山,舔也得舔完啊。这么一想,或许我还是有一点追求的。

两段废话应该凑了一些字数,那下面就正式进入正题

目标

抽象游戏逻辑,把渲染和玩法扩展交给其他开发者,毕竟核心逻辑是不变的,UI是千变万化的。作为这个系列的第一篇文章,就以经单贪吃蛇为切入点,一步步完善它。

贪吃蛇是什么

我们先要搞清楚经典贪吃蛇是什么

images.jpeg

想起来没有,对,就是诺基亚最强的时候,哈哈哈。我记得我第一个诺基亚手机,600多块钱,初中九键时代,单手口袋发短信。

游戏开发非常适合 OOP,所以我们要搞清楚有哪些 O

一句话描述

这个游戏(Game)就是在一片草地(Map)上有一条蛇(Snake),玩家(Player)控制蛇的移动方向(Direction),通过“吃”各种随机出现的”东西“(Obstacle),呈现出不同的效果。

  • 蛇撞到边界会死
  • 蛇撞到障碍物(Obstacle),情况不一定,所以这里可以发挥的地方很多。
  • 蛇撞到自己的身体会死

对象分析

对象出现的顺序,不代表思路的顺序,前置某些对象,是为了大家看的时候顺畅一些。

通过分析每一个对象的特点,我们才能够知道它到底是什么。再分析第一个对象之前,我这边前置一个很容易理解的对象 Point。在游戏开发中,物体的位置其实是一个很通用的属性,所以这边我们先实现了一下。

class Point {
  public x = 0;
  public y = 0;

  public static create(x: number, y: number) {
    const point = new Point();
    point.x = x;
    point.y = y;
    return point;
  }

  public constructor() {}
}
复制代码

移动的方向,正常情况是上下左右四个方向,我这里提供了八个方向

class Direction extends Point {
  public static readonly UP = Direction.create(0, -1);
  public static readonly DOWN = Direction.create(0, 1);
  public static readonly LEFT = Direction.create(-1, 0);
  public static readonly RIGHT = Direction.create(1, 0);
  public static readonly LEFT_UP = Direction.create(-1, -1);
  public static readonly RIGHT_UP = Direction.create(1, -1);
  public static readonly LEFT_DOWN = Direction.create(-1, 1);
  public static readonly RIGHT_DOWN = Direction.create(1, 1);

  public static create(x: number, y: number) {
    const direction = new Direction();
    direction.x = x;
    direction.y = y;
    return direction;
  }
}
复制代码

地图 Map

地图是一个矩形,自然有宽度和高度。地图默认有边界,宽度为:0-width,高度为 0-height。有边界的情况下,需要提供一个碰撞检测方法,入参为:检测点(蛇头的位置)

class GameMap {
  constructor(public readonly width: number, public readonly height: number) {}

  public isCollision(point: Point) {
    const isCollision =
      point.x < 0 ||
      point.y < 0 ||
      point.x >= this.width ||
      point.y >= this.height;
    if (isCollision) {
      console.error("撞墙了");
    }
    return isCollision;
  }
}
复制代码

蛇 Snake

  • 蛇头:引领前进的方向
  • 蛇身:关节构成
  • 长度:关节的总数
  • 成长:加一个关节
  • 缩小:减一个关节
  • 移动:前进一个单位

这里引出一个新的概念,关节(SnakeNode),那我们先实现一下:

class SnakeNode extends Point {
  public static create(x: number, y: number) {
    const node = new SnakeNode();
    node.x = x;
    node.y = y;
    return node;
  }
}
复制代码

抛个问题,这里为什么我们不直接使用 Point ,而要搞出一个 SnakeNode ?

有了关节,那我们就可以创造一条蛇了

/**
 * 贪吃蛇
 */
export class Snake {
  /**
   * 蛇身节点数组
   */
  public body!: SnakeNode[];

  /**
   * 蛇头,其实就是第一个关节
   */
  public get head() {
    return this.body[0];
  }

  /**
   * 蛇身长度
   */
  public get size() {
    return this.body.length;
  }

  /**
   * 初始化,默认在地图左上角
   * @param bodyLength 初始蛇身长度
   */
  public init(bodyLength: number) {
    this.body = [];
    for (let i = 0; i < bodyLength; i++) {
      this.prepend(SnakeNode.create(i, 0));
    }
  }
  
  /**
   * 蛇头下一个位置
   * @param direction
   * @returns 
   */
  public getNextPosition(direction: Direction) {
    const { head } = this;
    const nextPosition = new Point();
    nextPosition.x = head.x + direction.x;
    nextPosition.y = head.y + direction.y;
    return nextPosition;
  }

  /**
   * 蛇身生长
   * @param x
   * @param y
   */
  public grow(x: number, y: number) {
    this.append(SnakeNode.create(x, y));
  }

  /**
   * 蛇身减小
   */
  public reduce() {
    this.body.pop();
  }

  /**
   * 尾部增加
   * @param node
   */
  private append(node: SnakeNode) {
    this.body.push(node);
  }

  /**
   * 头部增加
   * @param node
   */
  private prepend(node: SnakeNode) {
    this.body.unshift(node);
  }

  /**
   * 判断是否会发生身体碰撞
   * @param nextPosition
   * @returns
   */
  public isCollision(nextPosition: Point) {
    for (let i = 0; i < this.body.length; i++) {
      const point = this.body[i];
      if (point.x === nextPosition.x && point.y === nextPosition.y) {
        console.error("撞自己了");
        return true;
      }
    }
    return false;
  }

  /**
   * 移动到下一个位置
   * @param nextPosition
   */
  public move(nextPosition: Point) {
    // 加入头
    this.prepend(SnakeNode.create(nextPosition.x, nextPosition.y));
    // 去掉尾
    this.body.pop();
  }
}

复制代码

到这里为止,游戏的核心元素:蛇、地图都已经准许就绪,障碍物(食物)我认为是可选对象,我们放到最后讲解。我们来组织一下游戏逻辑

游戏 Game

目前为止,我们支持的游戏配置如下:

type GameConfig = {
  /** 地图配置 */
  map: {
    /** 地图宽度 */
    width: number;
    /** 地图高度 */
    height: number;
  };
  /** 贪吃蛇配置 */
  snake: {
    /** 蛇身初始长度 */
    bodyLength: number;
  };
};
复制代码

游戏就是需要在每一帧里面更新状态,反馈结果,至于更新的频率,我们大可交给开发者自己去控制。

/**
 * 游戏类
 */
class Game {

  /** 贪吃蛇 */
  public snake: Snake;
  /** 地图 */
  public map: GameMap;
  /** 运动方向 */
  public direction!: Direction;

  /** 配置 */
  private config: GameConfig;

  public constructor(config: GameConfig) {
    this.config = config;
    this.snake = new Snake();
    this.map = new GameMap(this.config.map.width, this.config.map.height);
  }

  /**
   * 改变方向
   * @param direction
   */
  public setDirection(direction: Direction) {
    this.direction = direction;
  }

  /**
   * 游戏初始化
   */
  public init() {
    // 贪吃蛇初始化
    this.snake.init(this.config.snake.bodyLength);
    // 默认方向
    this.direction = Direction.RIGHT;
  }

  /**
   * 每一帧更新
   * @returns {boolean} false: 游戏结束/true: 游戏继续
   */
  public update() {
    const nextPosition = this.snake.getNextPosition(this.direction);
    // 是否发生地图碰撞
    if (this.map.isCollision(nextPosition)) {
      return false;
    }
    // 是否发生蛇身碰撞
    if (this.snake.isCollision(nextPosition)) {
      return false;
    }
    // 移动
    this.snake.move(nextPosition);
    return true;
  }
}
复制代码

在 update 函数中,我们根据移动方向,计算出蛇头下一位置。

  • 判断是否发生地图碰撞,如果是,返回false
  • 判断蛇身是否发生碰撞,如果是,返回false
  • 否则说明蛇可以安全移动到下一位置

引入渲染

游戏逻辑完成了,其实不 care 渲染方式是什么,你可以使用 Vue/React/Webgl 等等任何你喜欢的方式。因为工作中我是使用 React的,所以这里我使用 Vue3 来举例如何渲染。

import { Game } from '@game/greedySnake'
import { onMounted } from "vue";

const game = new Game({
  map: {
    width: 30,
    height: 30,
  },
  snake: {
    bodyLength: 3,
  },
});

game.init();

onMounted(() => {
    setInterval(() => {
        game.update()
    }, 300)
})
复制代码

实例化一个游戏,然后初始化,在 Dom 挂载之后,用 300 ms的频率去启动游戏的更新,当然可以更快,完全由你决定。

地图渲染

我们没有图片,那最简单的地图就是一个二维网格,每个网格大小为 20x20 的正方形。或者你用一张图片代替也不是不可以。

<script setup lang="ts">
const map = new Array(game.config.width * game.config.height)
</script>
<template>
    <div
      :style="{
        display: 'flex',
        flexWrap: 'wrap',
        width: `${width * 20}px`,
        height: `${height * 20}px`,
      }"
    >
      <div
        :key="index"
        v-for="(_, index) in "
        style="width: 20px; border: 1px solid #ccc; box-sizing: border-box"
      ></div>
    </div>
</template>
复制代码

蛇身渲染

但我们缺少蛇身数据,那就让 Game 提供一下吧。

class Game {
  /**
   * 蛇身
   */
  public get body() {
    return this.snake.body.map((b) => Point.create(b.x, b.y));
  }
}
复制代码

这里我们用 getter 拿到蛇身的坐标信息,对于渲染而言足够了。太多的信息暴露不是好事情,构造新的数据也是以防外部去修改它本身。

<template>
  <div
    :key="index"
    v-for="(item, index) in game.body"
    :style="{
      position: 'absolute',
      width: '20px',
      height: '20px',
      left: `${item.x * 20}px`,
      top: `${item.y * 20}px`,
      backgroundColor: index === 0 ? 'green' : 'blue',
    }"
  ></div>
</template>
复制代码

但意外出现了,蛇并不会动,因为用于渲染的 game.body 并不是一个响应式对象,我们需要处理一下。

<script setup lang="ts">
import { reactive } from "vue";
const data = reactive({
    body: []
})
onMounted(() => {
    setInterval(() => {
        game.update()
        data.body = game.body
    }, 300)
})
</script>
复制代码

我们用 data.body 去渲染就行了,同理障碍物渲染也是一样的,这里就不加代码演示了。

更多渲染

纯2D游戏,用3D渲染出来,会不会感觉很不一样?摄像机跟随蛇头的位置,在天空盒内自由翱翔,会不会有一种第一人称骑着龙在天空飞翔的感觉,但我们没有模型,只能使用小方块来代替,如果有人感兴趣的话,可以使用 Three.js实现一下。

当然任何支持UI的地方,我们都可以去做。

障碍物

所有在地图上随机出现的“东西”,我把它定义为障碍物。

抽象类

障碍物如何被生产(放到地图某个位置),如何被消费(被蛇头撞到后,产生什么作用),因为需要位置属性,所以继承了 Point,抽象之后如下:

/**
 * 障碍物抽象基类
 */
abstract class Obstacle extends Point {
  /**
   * 障碍物类型
   */
  public abstract type: string;
  /**
   * 障碍物生成,一般是设置障碍物的位置
   */
  public abstract produce(): void;
  /**
   * 障碍物消费,一般是障碍物和蛇头发生碰撞之后,要做的事情;
   * 返回值决定了是否继续游戏,true表示继续,false表示结束游戏
   */
  public abstract consume(): boolean;

  public constructor(protected game: Game) {
    super();
  }
}
复制代码

所有障碍物都必须继承基类,并实现相应的抽象方法和属性。

障碍物管理器

考虑到地图上可能同时存在多个障碍物,我们实现一个管理器,用于管理障碍物的添加、碰撞、生产、消费等。

/**
 * 障碍物管理器
 */
export class ObstacleManager {
  public obstacles: Obstacle[] = [];

  /**
   * 添加障碍物
   * @param obstacle
   */
  public add(obstacle: Obstacle) {
    this.obstacles.push(obstacle);
  }

  /**
   * 发生碰撞的障碍物
   * @param point
   * @returns
   */
  public isCollision(point: Point) {
    return this.obstacles.filter((o) => o.x === point.x && o.y === point.y);
  }
  
  public init() {
    this.obstacles.forEach((o) => o.produce());
  }
}
复制代码

障碍物(食物)

在经典贪吃蛇里面,食物是最基础的障碍物了,蛇吃了食物才会变长,越来越长,但不会越来越粗!

/**
 * 食物
 */
export class Food extends Obstacle {
  public type = "food";

  public produce() {
    const point = this.getRandomPoint();
    this.x = point.x;
    this.y = point.y;
  }

  public consume() {
    // 吃掉食物,蛇身长度加1
    this.game.snake.grow(this.x, this.y);
    // 计数加1
    this.game.count++;
    return true;
  }
}
复制代码

其实大部分障碍物的 produce 实现都是一样的,无非就是重新随机生成一个位置。这里“随机”还是有一定限制:

  • 在地图内
  • 不能在蛇身上
  • 不能和其他障碍物重合

所以 getRandomPoint 算法实现需要递归,会不会在游戏后面产生 BadCase 我也不确定。至于效率方面,如果有更好的算法,请评论告诉我一下呀。

function getRandomPoint(maxX: number, maxY: number, points: Point[]) {
  let x = 0,
    y = 0;
  random();
  function random() {
    x = Math.floor(Math.random() * maxX);
    y = Math.floor(Math.random() * maxY);

    for (let i = 0; i < points.length; i++) {
      if (x === points[i].x && y === points[i].y) {
        random();
        break;
      }
    }
  }
  return Point.create(x, y);
}
复制代码

障碍物(炸弹)

这里只是为了举个例子来说明,障碍物的行为被抽象之后,那游戏的玩法就很多了,可以很多种类的障碍物参与到游戏当中,而不需要改任何游戏逻辑。

/**
 * 炸弹
 */
export class Bomb extends Obstacle {
  public type = "bomb";

  public produce() {
    const point = this.getRandomPoint();
    this.x = point.x;
    this.y = point.y;
  }

  public consume() {
    // 吃掉食物,蛇身长度减1
    this.game.snake.reduce();
    // 计数减1
    this.game.count-=1;
    return true;
  }
}
复制代码

障碍物的局限

因为这里障碍物消费后的结果是个 Boolean 类型,也就导致外部无法形成更多的玩法控制。如果可以的话,可以将消费后的结果封装城一个 ConsumeResult 类,那外部可以根据不同的 ConsumeResult 做出不同的反应了。

游戏逻辑完善

上面完成的游戏逻辑还记得吗,上面我们只完成了基本的边界碰撞、自身碰撞和移动,并没有引入障碍物,这里我们完善一下。

/**
 * 游戏类
 */
class Game {
  /** 障碍物管理器 */
  private obstacleManager = new ObstacleManager();
  
  /**
   * 添加障碍物
   * @param obstacle
   */
  public addObstacle(obstacle: Obstacle) {
    this.obstacleManager.add(obstacle);
  }
  
  /**
   * 游戏初始化
   */
  public init() {
    // 计分清零
    this.count = 0;
    // 贪吃蛇初始化
    this.snake.init(this.config.snake.bodyLength);
    // 默认方向
    this.direction = Direction.RIGHT;
    
    // 如果开发者没有添加任何障碍物,那么我们添加一种默认的基础食物
    if (this.obstacles.length === 0) {
      this.addObstacle(new Food(this));
    }
    // 障碍物初始化
    this.obstacleManager.init();
  }

  /**
   * 每一帧更新
   * @returns {boolean} false: 游戏结束/true: 游戏继续
   */
  public update() {
    const nextPosition = this.snake.getNextPosition(this.direction);
    // 是否发生地图碰撞
    if (this.map.isCollision(nextPosition)) {
      return false;
    }
    // 是否发生蛇身碰撞
    if (this.snake.isCollision(nextPosition)) {
      return false;
    }
    // 是否发生障碍物碰撞,这里拿到所有发生碰撞的障碍物
    const obstacles = this.obstacleManager.isCollision(nextPosition);
    if (obstacles.map((o) => o.consume()).filter((v) => !v).length > 0) {
      return false;
    }
    // 发生碰撞的障碍物需要重置,即再次被生产
    obstacles.forEach((o) => o.produce());
    // 移动
    this.snake.move(nextPosition);
    return true;
  }
}
复制代码

开发者可以根据 update 的返回值,决定游戏是否结束。这里的障碍物碰撞检测,原则上应该同一时刻只会存在一个,因为我们在障碍物生产的时候,剔除了重合的位置,也就是同一个位置不可能存在1个以上的障碍物。

控制

写到最后才发现了漏了这一趴,没有复杂的操作,就是控制移动的方向,不过推荐一个好用的库 hotkeys-js

import hotkeys from "hotkeys-js";

hotkeys("w", function () {
  game.setDirection(Direction.UP);
});
hotkeys("s", function () {
  game.setDirection(Direction.DOWN);
});
hotkeys("a", function () {
  game.setDirection(Direction.LEFT);
});
hotkeys("d", function () {
  game.setDirection(Direction.RIGHT);
});
复制代码

当然如果愿意的话,其他四个方向也可以加上。在操作过程中,你会发现,如果蛇在向右移动的过程中,你突然让它向左移动,会怎么样?没错,蛇头撞到蛇身了,游戏结束。这是不合理的,我们应该不允许这么操作。

/**
* 改变方向
* @param direction
*/
public setDirection(direction: Direction) {
  if (
    this.direction.x + direction.x === 0 &&
    this.direction.y + direction.y === 0
  ) {
    // 不允许前后操作方向相反
    return;
  }
  this.direction = direction;
}
复制代码

所以只要判断一下前后操作的方向是不是相反就可以了,判断条件很简单:各个方向相加是不是为 0 就行了。如果是,就不响应玩家的操作。

总结

通过对游戏逻辑的抽象,开发者不用关心具体的游戏实现是怎样的,只要用一个时钟去驱动游戏,根据游戏数据去改变视图即可。并且通过障碍物抽象基类,开发者可以自由扩展游戏的玩法,而不用去改动核心逻辑。但世界上没有完全的自由,所谓的自由不过是在某种规矩下的自由,比如无论怎么扩展,这始终是一个经典贪吃蛇,蛇只能一步步移动,你不能让它飞。

思考

如果我们要实现多人对战,那么就一个 Game 实例中就存在多个 Snake 实例,那么就需要一个 SnakeManager 来负责 snake 的管理。

  • 在障碍物生成的时候剔除所有更多蛇身位置
  • 在 update 中检测每条蛇的蛇头与其他任意蛇身的碰撞
  • 计分系统应该挂载在 Snake 身上

附上链接: game-greedy-snake,欢迎对小游戏感兴趣的朋友一起策划,将在下个版本完善。

猜你喜欢

转载自juejin.im/post/7080382781070999566