How to develop a freely scalable classic snake

Recently, the epidemic situation has repeated, and the company advocates working from home. Although I live close to the company, there is no way to go to the company for wool. The company's restaurant does not provide meals, snacks, and beverages during this time, so I have no choice but to stay at home and eat takeaway. I just completed the launch of a large demand last week, and the rest of the work is some "active" tasks for internal optimization, and I want to write some code when I am bored. . . Suddenly I feel a little pitiful. I really don't have any hobbies. Maybe I have reached a certain age, and I really have no interest in falling in love and playing games in life. It really shouldn't be the state of a young man.

In fact, I have no interest in code, maybe only in relative terms. It’s no fun to draw UI. In most cases, it can be restored. Who cares about your HTML semantics and CSS elegance? Probably the only thing you care about is your JS. After all, as long as you don't run away, your mountain is still your mountain, and you have to finish licking it. With that in mind, maybe I still have something to pursue.

The two paragraphs of nonsense should make up some words, so let's officially enter the topic

Target

Abstract game logic, and hand over rendering and gameplay extension to other developers. After all, the core logic is unchanged, and the UI is ever-changing. As the first article in this series, we will take the Jingdan Snake as the starting point and improve it step by step.

What is a greedy snake

Let's first figure out what the classic snake is

images.jpeg

I don't think of it, yes, it was when Nokia was the strongest, hahaha. I remember my first Nokia mobile phone, more than 600 yuan, in the age of nine buttons in junior high school, and sending text messages with one hand pocket.

Game development is great for OOP, so we need to figure out what O

one sentence description

This game (Game) is that there is a snake (Snake) on a piece of grass (Map), the player (Player) controls the direction of the snake's movement (Direction), and presents by "eating" various random "things" (Obstacle) that appear. produce different effects.

  • The snake will die if it hits the border
  • The snake hits the obstacle (Obstacle), the situation is not necessarily, so there are many places to play here.
  • Snake dies when it hits itself

object analysis

The order in which objects appear does not represent the order of ideas. Some objects are prepended to make it easier for everyone to see.

By analyzing the characteristics of each object, we can know what it is. Before analyzing the first object, I prepend an easy-to-understand object Point. In game development, the position of an object is actually a very common attribute, so let's implement it here first.

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 做出不同的反应了。

游戏逻辑完善

Do you remember the game logic completed above? We only completed the basic boundary collision, self-collision and movement above, and did not introduce obstacles. Let's improve it here.

/**
 * 游戏类
 */
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;
  }
}
复制代码

The developer can decide whether the game is over according to the return value of update. In principle, there should only be one obstacle collision detection here at the same time, because when we produce obstacles, we eliminate the overlapping positions, that is, there cannot be more than one obstacle in the same position.

control

At the end of writing, I found out that I missed this one, there is no complicated operation, just control the direction of movement, but I recommend a useful library 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);
});
复制代码

Of course, the other four directions can also be added if desired. During the operation, you will find that when the snake is moving to the right, you suddenly let it move to the left, what will happen? That's right, the snake's head hit the snake's body, and the game is over. This is unreasonable and we should not allow it.

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

Therefore, it is enough to judge whether the directions of the front and rear operations are opposite. The judgment condition is very simple: whether the sum of each direction is 0 is enough. If so, do not respond to player actions.

Summarize

By abstracting game logic, developers don't need to care about the specific game implementation, just use a clock to drive the game and change the view according to the game data. And through the abstract base class of obstacles, developers can freely extend the gameplay of the game without changing the core logic. But there is no complete freedom in the world. The so-called freedom is only freedom under certain rules. For example, no matter how it is extended, it is always a classic snake. The snake can only move step by step, and you cannot let it fly.

think

If we want to implement multiplayer battle, then there are multiple Snake instances in one Game instance, then we need a SnakeManager to be responsible for snake management.

  • cull all more snake body positions when the obstacle is generated
  • Detect the collision of each snake's head with any other snake's body in the update
  • The scoring system should be mounted on Snake

Attached the link: game-greedy-snake , welcome friends who are interested in small games to plan together, which will be improved in the next version.

Guess you like

Origin juejin.im/post/7080382781070999566