Recently, the epidemic repeated, the company advocated working at home, although living close to the company, but also can not go to the company to collect wool. The company restaurant does not provide food, snacks, drinks during this period, but can only stay at home to eat take-out. I just finished a big requirement last week, the rest of the work is some internal optimization “active” tasks, I want to write some code when I am bored… Suddenly feel a little pitiful, really do not have what hobby, the possibility age reached a certain level, for love game life, really can not raise what interest, really should not be the state of young people.

I’m not really interested in code anymore, probably relatively speaking. It’s no fun to draw UI. In most cases, you can restore it. Who cares about your HTML semantics and CSS elegance? Maybe 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 the licking has to be done. Come to think of it, maybe I’m a little bit after all.

Two paragraphs of nonsense should gather up some words, then officially enter the subject

The target

Abstract the game logic and hand over rendering and gameplay extensions to other developers. After all, the core logic is immutable and the UI is ever-changing. As the first article of this series, it takes the snake as the breakthrough point and improves it step by step.

What is a gluttonous snake

Let’s start by figuring out what the classic snake is

Remember that? Yeah, when Nokia was at its best. I remember my first Nokia mobile phone, more than 600 yuan, junior high school nine button era, one hand pocket text message.

Game development lends itself well to OOP, so let’s figure out what O’s are

One-sentence description

In this Game, there is a Snake on a Map of grass. Players control the movement of the Snake and show different effects by eating various random objects.

  • Snakes will die if they strike the boundary
  • When snakes bump into obstacles, the situation is not always clear, so there are many areas to play here.
  • A snake will die if it hits itself

Object analysis

The order of the object, does not represent the order of the idea, the front of some objects, is to see some smooth.

By analyzing the characteristics of each object, we can know what it is. Before I look at the first object, I start with an object Point that’s easy to understand. In game development, the position of an object is a very general property, so we implemented it here.

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(){}}Copy the code

The directions of movement, normally up, down, left, right, four directions, I’ve provided eight directions here

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;
    returndirection; }}Copy the code

Map a Map

A map is a rectangle, naturally with width and height. The map has a border by default. The width is 0-width and the height is 0-height. If there is a boundary, you need to provide a collision detection method with the following parameters: detection point (position of snake head)

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("Hit the wall.");
    }
    returnisCollision; }}Copy the code

Snake Snake

  • Snakehead: Lead the way
  • Snake body: jointed
  • Length: Total number of joints
  • Growth: Add a joint
  • Shrink: Remove one joint
  • Move: Advance one unit

SnakeNode: SnakeNode: SnakeNode

class SnakeNode extends Point {
  public static create(x: number, y: number) {
    const node = new SnakeNode();
    node.x = x;
    node.y = y;
    returnnode; }}Copy the code

Question: why don’t we just use Point here instead of making a SnakeNode?

With joints, we can create a snake

/** ** snake */
export class Snake {
  /** * snake node array */
  publicbody! : SnakeNode[];/** ** the first joint */
  public get head() {
    return this.body[0];
  }

  /** * Snake length */
  public get size() {
    return this.body.length;
  }

  /** * initialization, default at top left corner of map *@param BodyLength Initial snake length */
  public init(bodyLength: number) {
    this.body = [];
    for (let i = 0; i < bodyLength; i++) {
      this.prepend(SnakeNode.create(i, 0)); }}/** ** the next position *@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;
  }

  /** * The snake grows *@param x
   * @param y* /
  public grow(x: number, y: number) {
    this.append(SnakeNode.create(x, y));
  }

  /** * Snake body reduced */
  public reduce() {
    this.body.pop();
  }

  /** * add * to the end@param node* /
  private append(node: SnakeNode) {
    this.body.push(node);
  }

  /** * add * to the header@param node* /
  private prepend(node: SnakeNode) {
    this.body.unshift(node);
  }

  /** * Determine whether a body collision will occur *@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("Hit myself.");
        return true; }}return false;
  }

  /** ** moves to the next position *@param nextPosition* /
  public move(nextPosition: Point) {
    / / in head
    this.prepend(SnakeNode.create(nextPosition.x, nextPosition.y));
    / / remove the tail
    this.body.pop(); }}Copy the code

At this point, the core elements of the game: snakes, maps are allowed in place, and obstacles (food) are, I think, optional, which we’ll leave to the end. Let’s organize the game logic

The Game is Game

Here are the configurations we’ve supported so far:

type GameConfig = {
  /** Map configuration */
  map: {
    /** Map width */
    width: number;
    /** Map height */
    height: number;
  };
  /** Snake configuration */
  snake: {
    /** Initial snake length */
    bodyLength: number;
  };
};
Copy the code

The game needs to update the status and feedback every frame, and the frequency of updates is at the developer’s discretion.

/** * game */
class Game {

  /** Snake */
  public snake: Snake;
  / * * * / map
  public map: GameMap;
  /** * direction */
  publicdirection! : 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);
  }

  /** * change direction *@param direction* /
  public setDirection(direction: Direction) {
    this.direction = direction;
  }

  /** * Game initialization */
  public init() {
    // Snake initialization
    this.snake.init(this.config.snake.bodyLength);
    // Default direction
    this.direction = Direction.RIGHT;
  }

  /** * Updates each frame *@returns {boolean} False: game ends /true: game continues */
  public update() {
    const nextPosition = this.snake.getNextPosition(this.direction);
    // Whether a map collision occurred
    if (this.map.isCollision(nextPosition)) {
      return false;
    }
    // Whether a snake collision occurred
    if (this.snake.isCollision(nextPosition)) {
      return false;
    }
    / / move
    this.snake.move(nextPosition);
    return true; }}Copy the code

In the update function, we calculate the next position of the snake head based on the direction of movement.

  • Determines whether a map collision occurred, if so, return false
  • Checks whether snakes collided, if so, return false
  • Otherwise, the snake can safely move to the next position

Introduction of rendering

The game logic is done, it doesn’t really matter what the render method is, you can use Vue/React/Webgl, whatever you like. Since I use React at work, I use Vue3 to illustrate how to render.

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)})Copy the code

Instantiate a game, initialize it, and after the Dom is mounted, start updates to the game at a rate of 300 ms, but faster, up to you.

Map rendering

We don’t have pictures, so the simplest map is a two-dimensional grid of 20×20 squares. Or you can use a picture instead.

<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>
Copy the code

The snake body rendering

But we don’t have snake body data, so let Game provide it.

class Game {
  /** ** snake */
  public get body() {
    return this.snake.body.map((b) = >Point.create(b.x, b.y)); }}Copy the code

Here we use the getter to get the coordinate information for the snake, which is enough for rendering. Too much exposure is not a good thing, and building new data is also a way to prevent outsiders from modifying it.

<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>
Copy the code

But by accident, the snake doesn’t move, because the game.body used for rendering isn’t a responsive object, and we need to deal with that.

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

We can render with data.body, and the same goes for obstacle rendering, so we won’t add any code here.

More rendering

Does a pure 2D game feel very different when rendered in 3D? The camera follows the position of the snake head and flies freely in the sky box. Will there be a feeling of flying in the sky while riding a dragon in the first person? But we don’t have a model, so we can only use small squares instead.

Of course, anything that supports UI, we can do it.

obstacles

All the random “things” that appear on the map, I define that as obstacles.

An abstract class

How obstacles are produced (put in a certain position on the map), how they are consumed (hit by the snake’s head, what effect it has), because the location attribute is needed, so we inherit Point, after abstraction as follows:

/** * Obstacle abstract base class */
abstract class Obstacle extends Point {
  /** * Obstacle type */
  public abstract type: string;
  /** * Obstacles are generated, usually by setting the location of obstacles */
  public abstract produce(): void;
  /** * Obstacle consumption is generally something to be done after the collision between the obstacle and the snake head; * The return value determines whether to continue the game, true to continue, false to end the game */
  public abstract consume(): boolean;

  public constructor(protected game: Game) {
    super();
  }
}
Copy the code

All obstacles must inherit from the base class and implement the corresponding abstract methods and properties.

Obstacle manager

Considering that there may be multiple obstacles on the map at the same time, we implement a manager to manage the addition, collision, production, consumption, and so on of obstacles.

/** * Obstacle manager */
export class ObstacleManager {
  public obstacles: Obstacle[] = [];

  /** * Add obstacles *@param obstacle* /
  public add(obstacle: Obstacle) {
    this.obstacles.push(obstacle);
  }

  /** * The obstacle where the collision occurred *@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()); }}Copy the code

Obstacles (food)

In the classic snake, food is the most basic obstacle, the snake will eat food will become longer, longer and longer, but not thicker and thicker!

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

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

  public consume() {
    // For eating food, snake length increases by 1
    this.game.snake.grow(this.x, this.y);
    // Count increment 1
    this.game.count++;
    return true; }}Copy the code

In fact, the produce implementation of most obstacles is the same, which is nothing more than to randomly generate a position again. There are limits to randomness:

  • In the map
  • Not on a snake
  • It can’t overlap with any other obstacle

So the implementation of getRandomPoint algorithm requires recursion, and I’m not sure if there will be BadCase after the game. As for efficiency, if there is a better algorithm, please let me know in the comments.

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);
}
Copy the code

Obstacle (bomb)

This is just an example to illustrate that once the behavior of obstacles is abstracted, the gameplay of the game will be much more extensive, and many kinds of obstacles can be involved in the game without changing any game logic.

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

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

  public consume() {
    // If you eat the food, the snake length decreases by 1
    this.game.snake.reduce();
    // Count minus 1
    this.game.count-=1;
    return true; }}Copy the code

Limitation of obstacle

Because the result of consuming obstacles is a Boolean type, there is no way to form more gameplay control externally. If possible, you can encapsulate the result into a ConsumeResult class so that the outside can react differently to the ConsumeResult.

Game Logic refinement

Remember the game logic we did above, we only did the basic boundary collision, self collision, and movement, we didn’t introduce obstacles, here we improve.

/** * game */
class Game {
  /** Obstacle manager */
  private obstacleManager = new ObstacleManager();
  
  /** * Add obstacles *@param obstacle* /
  public addObstacle(obstacle: Obstacle) {
    this.obstacleManager.add(obstacle);
  }
  
  /** * Game initialization */
  public init() {
    // The score is cleared
    this.count = 0;
    // Snake initialization
    this.snake.init(this.config.snake.bodyLength);
    // Default direction
    this.direction = Direction.RIGHT;
    
    // If the developers don't add any obstacles, then we add a default base food
    if (this.obstacles.length === 0) {
      this.addObstacle(new Food(this));
    }
    // The obstacle is initialized
    this.obstacleManager.init();
  }

  /** * Updates each frame *@returns {boolean} False: game ends /true: game continues */
  public update() {
    const nextPosition = this.snake.getNextPosition(this.direction);
    // Whether a map collision occurred
    if (this.map.isCollision(nextPosition)) {
      return false;
    }
    // Whether a snake collision occurred
    if (this.snake.isCollision(nextPosition)) {
      return false;
    }
    // If there is an obstacle collision, get all the obstacles that occurred
    const obstacles = this.obstacleManager.isCollision(nextPosition);
    if (obstacles.map((o) = > o.consume()).filter((v) = >! v).length >0) {
      return false;
    }
    // The bump that occurred needs to be reset, i.e., produced again
    obstacles.forEach((o) = > o.produce());
    / / move
    this.snake.move(nextPosition);
    return true; }}Copy the code

The developer can decide if the game is over based on the return value of the update. In principle, there should only be one obstacle collision detection at the same time, because the overlapping position is eliminated when the obstacle is produced, that is, it is impossible to have more than one obstacle in the same position.

control

There is no complex operation, just control the direction of movement, but recommend a good 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);
});
Copy the code

Of course, the other four directions can be added if you wish. And as you do that, what happens if the snake, while it’s moving to the right, suddenly moves it to the left? That’s right. Snake’s head hit snake. Game over. This is not reasonable, we should not allow this operation.

/** * change direction *@param direction* /
public setDirection(direction: Direction) {
  if (
    this.direction.x + direction.x === 0 &&
    this.direction.y + direction.y === 0
  ) {
    // Reverse direction is not allowed
    return;
  }
  this.direction = direction;
}
Copy the code

So it’s just a matter of determining whether the directions are opposite, and the condition is simple: whether the directions add up to 0. If so, it does not respond to the player’s actions.

conclusion

By abstracting the logic of the game, developers don’t have to worry about how the actual game is implemented, just use a clock to drive the game and change the view based on the game data. By abstracting the base class with obstacles, developers are free to extend the gameplay without changing the core logic. But there is no complete freedom in the world, the so-called freedom is only freedom under certain rules, such as no matter how extended, this is always a classic greedy snake, the snake can only move step by step, you can’t let it fly.

thinking

If we want to achieve multiplayer, then there are multiple Snake instances in a Game instance, then you need a SnakeManager to be responsible for the management of Snake.

  • Remove all more snake positions when obstacles are generated
  • Detect collisions between each snake’s head and any other snake’s body in update
  • The scoring system should be mounted on Snake

Attached is a link: Game-greedy-Snake, which will be perfected in the next version.