Vue3 + Typescript implements a web version of greedy snake by yourself

Keywords: Front-end TypeScript Vue.js

preface

I still remember when I was just in college, in the first programming class, the teacher said, "don't let me catch you playing games, otherwise you will write what you play.". I didn't know anything and didn't dare to play.

If I can go back to that class now, I can play with my greedy snake unscrupulously. If she catches me, she will throw the source code address directly to her. Isn't it fast.

To get back to business, this article will take you to implement a web version of Snake game. The technology stack selects the current popular Vite + Vue3 + Ts.

👉👉 Online trial 👉👉 Source address

It is recommended to read this article in combination with the source code for better results~

Game screenshot

[external chain picture transfer failed. The source station may have anti-theft chain mechanism. It is recommended to save the picture and upload it directly (img-wqpvnq7j-1635420801146)( https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f6f9d0ceb196419e91b327b4b7d3beda ~tplv-k3u1fbpfcp-watermark.image?)]

directory structure

├── src 
     ├── assets      // Storing static resources
     ├── components  // vue component
     │    ├── Cell.vue         // Every little square
     │    ├── Controller.vue   // Game controller 
     │    ├── KeyBoard.vue     // Mobile terminal soft keyboard 
     │    └── Map.vue          // Map components
     ├── game       // Game core logic
     │    ├── GameControl.ts  // Controller class
     │    ├── Food.ts         // Food category
     │    ├── Snake.ts        // Snakes
     │    ├── hit.ts          // Collision logic
     │    ├── render.ts       // Render view logic
     │    ├── map.ts          // Map related logic 
     │    └── index.ts        // Mainstream process
     ├── types      // TS type
     ├── utils      // Tool function
     ├── main.ts    // Main entry file
     └── App.vue    // vue root component

         ......

Implementation process

Note: the implementation process only intercepts the key code for explanation. It is recommended to read the source code for easier understanding.

Game screen rendering

/src/game/map.ts

// Get screen size
const clientWidth = document.documentElement.clientWidth - 20;
const clientHeight = document.documentElement.clientHeight - 40;

// Number of rows
export const gameRow = clientWidth > 700 ? Math.floor(clientHeight / 54) : Math.floor(clientHeight / 34);

// Number of columns
export const gameCol = clientWidth > 700 ? Math.floor(clientWidth / 54) : Math.floor(clientWidth / 34);

// Initialize the map. Now all location type s are 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;
}

How to calculate the number of grids?

Here, I judge a device by obtaining the width and height of the device screen. The grid with a large screen is a little larger (50px) and the grid with a small screen is a little smaller (30px). Here I have subtracted a little from the width and height in order to make the picture have a sense of area and look more beautiful.

Then, divide the width and height of the screen by the size of each grid to obtain the number of rows and columns of the map. Because each grid directly has a 2px margin, it is 54 and 34.

How to generate maps?

Then, we render the map through a two-dimensional array according to the number of rows and columns calculated in the previous step. The element value of the two-dimensional array determines the color of each small grid. Because it is initialization, we first set all to 0 by default, and then pass the value of the element to the sub component Cell.vue.

/src/components/Map.vue

<template>
  <div class="game-box">
    <!-- that 's ok -->
    <div class="row"
         v-for='row in gameRow'
         :key='row'>
      <!-- column -->
      <div class="col"
           v-for='col in gameCol'
           :key='col'>
        <!-- Small lattice -->
        <Cell :type='map[row-1][col-1]'></Cell>
      </div>
    </div>
  </div>
</template>

How to distinguish elements?

/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']);
// Color of small grid
const classes = computed(() => {
  return {
    head: props.type === 2,
    body: props.type === 1,
    food: props.type === -1,
  };
});
</script>

Think about what elements will appear on the whole game map, snake head (2), snake body (1) and food (- 1). Therefore, we assign different class es according to different element values, so that different elements can display different styles on the map.

Design of controller class

/src/game/GameControl.ts

export class GameControl {
  // snake
  snake: Snake;
  // food
  private _food: Food;
  // Map
  private _map: Map;
  // Game status
  private _isLive: IsLive;
  constructor(map: Map, isLive: IsLive) {
    this._map = map;
    this.snake = new Snake();
    this._food = new Food();
    this._isLive = isLive;
  }
  // Start the game
  start() {
    // Bind keyboard key press event
    document.addEventListener('keydown', this.keydownHandler.bind(this));
    // Add to frame cycle list
    addTicker(this.handlerTicker.bind(this));
    // Mark game status as start
    this._isLive.value = 2;
  }
  // Create a keyboard press response function
  keydownHandler(event: KeyboardEvent) {
    this.snake.direction = event.key;
  }
  // Render map
  private _timeInterval = 200;
  // Move snake
  private _isMove = intervalTimer(this._timeInterval);
  // Define frame loop function
  handlerTicker(n: number) {
    if (this._isMove(n)) {
      try {
        this.snake.move(this.snake.direction, this._food);
      } catch (error: any) {
        // Mark game status as end
        this._isLive.value = 3;
        // Stop cycle
        stopTicker();
      }
    }
    render(this._map, this.snake, this._food);
  }
  // Restart the game
  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));
  }
}

Start the game

When we start the game, we have to do three things: first bind the keyboard event, then add a frame loop to make the game move, and finally set the game state to in the game.

How do I add / stop a frame loop?

If you don't know about frame cycle, please refer to my following article.

👉👉 A magical front-end animation 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);
//Add frame loop
export function addTicker(ticker: Ticker) {
  tickers.push(ticker);
}
//Stop frame cycle
export function stopTicker() {
  tickers = [];
}
// Time accumulator
export function intervalTimer(interval: number) {
  let t = 0;
  return (n: number) => {
    t += n;
    if (t >= interval) {
      t = 0;
      return true;
    }
    return false;
  };
}

Restart the game

When we restart the game, we also have to do three things: reset the map, add a frame loop, and set the game state to in the game.

Snake design

/src/game/Snake.ts

export class Snake {
  bodies: SnakeBodies;
  head: SnakeHead;
  // Create an attribute to store the moving direction of the snake (that is, the direction of the key)
  direction: string;
  constructor() {
    this.direction = 'Right';
    this.head = {
      x: 1,
      y: 0,
      status: 2,
    };
    this.bodies = [
      {
        x: 0,
        y: 0,
        status: 1,
      },
    ];
  }
  // Define a method to check whether the snake eats food
  checkEat(food: Food) {
    if (this.head.x === food.x && this.head.y === food.y) {
      // Score increase
      // this.scorePanel.addScore();
      // The position of the food should be reset
      food.change(this);
      // The snake needs to add a section
      this.bodies.unshift({
        x: food.x,
        y: food.y,
        status: 1,
      });
    }
  }
  // Control Snake Movement
  move(food: Food) {
    // Determine whether the game is over
    if (hitFence(this.head, this.direction) || hitSelf(this.head, this.bodies)) {
      throw new Error('game over');
    }
    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':
        // To move up, you need to check whether the key is in the opposite direction
        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':
        // To move down, you need to check whether the key is in the opposite direction
        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':
        // To move to the left, you need to check whether the key is in the opposite direction
        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':
        // To move to the right, you need to check whether the key is in the opposite direction
        if (headY === bodyY && headX + 1 === bodyX) {
          moveLeft(this.head, this.bodies);
          this.direction = 'Left';
          return;
        }
        moveRight(this.head, this.bodies);
        break;
      default:
        break;
    }
    // Check whether the snake eats food
    this.checkEat(food);
  }
  // The moving end modifies the moving direction
  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;
    }
  }
}

How do snakes move?

This place has bothered me for the longest time, but as long as I figure it out, it's not very difficult. We need to modify the coordinates of the snake head according to the direction, then we put the coordinates of the snake head into the last element of the array of snake bodies, and then delete the first element of the array of snake bodies. Because the snake moves from the next section to the previous section, it looks like the snake is moving in the view.

/src/game/Snake.ts

// Move up
function moveUp(head: SnakeHead, bodies: SnakeBodies) {
  head.y--;
  bodies.push({
    x: head.x,
    y: head.y + 1,
    status: 1,
  });
  bodies.shift();
}
// Move down
function moveDown(head: SnakeHead, bodies: SnakeBodies) {
  head.y++;
  bodies.push({
    x: head.x,
    y: head.y - 1,
    status: 1,
  });
  bodies.shift();
}
// Move right
function moveRight(head: SnakeHead, bodies: SnakeBodies) {
  head.x++;
  bodies.push({
    x: head.x - 1,
    y: head.y,
    status: 1,
  });
  bodies.shift();
}
// Move left
function moveLeft(head: SnakeHead, bodies: SnakeBodies) {
  head.x--;
  bodies.push({
    x: head.x + 1,
    y: head.y,
    status: 1,
  });
  bodies.shift();
}

Then we will render the location information of the new snake to the view.

/src/game/render.ts

// Every time you render, you need to reset the map, and then render the new data
export function render(map: Map, snake: Snake, food: Food) {
  // Reset map
  reset(map);
  // Render snake head
  _renderSnakeHead(map, snake.head);
  // Render snake body
  _renderSnakeBody(map, snake.bodies);
  // Render food
  _renderFood(map, food);
}
// Reset map resets all elements of the 2D array to 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;
      }
    }
  }
}
// Render snake body - 1 food 1 snake body 2 snake head
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;
  }
}
// Render snake head - 1 food 1 snake body 2 snake head
function _renderSnakeHead(map: Map, head: SnakeHead) {
  const row = head.y;
  const col = head.x;
  map[row][col] = 2;
}
// Render food - 1 food 1 snake body 2 snake head
function _renderFood(map: Map, food: Food) {
  const row = food.y;
  const col = food.x;
  map[row][col] = -1;
}

How to detect whether snakes eat food?

This is very simple. Just judge whether the coordinates of the snake head are the same as the snake body. At the same time, we push the position of the current snake head into the array of snake bodies, but do not delete the element of snake tail. It looks like a snake has added a section on the view.

How to detect snake collision?

There are two situations at the end of the game, one is to encounter the boundary, the other is to encounter yourself. The judgment when encountering the boundary is whether the coordinates of the snake head exceed the number of rows and columns. When you encounter yourself, you judge whether the coordinates of the snake head coincide with a section of the snake's body.

/src/game/hit.ts

// Does the snake head touch the boundary
export function hitFence(head: SnakeHead, direction: string) {
  // 1. Get the position of snake head
  // 2. Is it beyond the scope of the game to detect snakeheads
  let isHitFence = false;
  switch (direction) {
    case 'ArrowUp':
    case 'Up':
      // Move up
      isHitFence = head.y - 1 < 0;
      break;
    case 'ArrowDown':
    case 'Down':
      // Move down because head.y starts from 0 and gameRow starts from 1, so gameRow should be - 1
      isHitFence = head.y + 1 > gameRow - 1;
      break;
    case 'ArrowLeft':
    case 'Left':
      // Move left
      isHitFence = head.x - 1 < 0;
      break;
    case 'ArrowRight':
    case 'Right':
      // Move right
      isHitFence = head.x + 1 > gameCol - 1;
      break;
    default:
      break;
  }
  return isHitFence;
}
// Does the snake head touch itself
export function hitSelf(head: SnakeHead, bodies: SnakeBodies) {
  // 1. Obtain the coordinates of snake head
  const x = head.x;
  const y = head.y;
  // 2. Get the body
  const snakeBodies = bodies;
  // 3. Check whether the snake head has hit itself, that is, whether the next movement of the snake head will repeat the elements of the body array
  const isHitSelf = snakeBodies.some((body) => {
    return body.x === x && body.y === y;
  });
  return isHitSelf;
}

How to change the direction of the snake?

This is also very simple. Just modify the corresponding direction value, but pay attention to judge that the snake can't turn back.

Food design

How to randomly generate food?

Generate a random coordinate by generating a random number. When the new coordinate coincides with the snake, call itself to generate it again.

/src/game/Food.ts

export class Food {
  // Coordinates of food
  x: number;
  y: number;
  status = -1;
  constructor() {
    this.x = randomIntegerInRange(0, gameCol - 1);
    this.y = randomIntegerInRange(0, gameRow - 1);
  }
  // Modify the location of food
  change(snake: Snake) {
    // Generate a random location
    const newX = randomIntegerInRange(0, gameCol - 1);
    const newY = randomIntegerInRange(0, gameRow - 1);
    // 1. Obtain the coordinates of snake head
    const x = snake.head.x;
    const y = snake.head.y;
    // 2. Get the body
    const bodies = snake.bodies;
    // 3. The food shall not coincide with the head and body
    const isRepeatBody = bodies.some((body) => {
      return body.x === newX && body.y === newY;
    });
    const isRepeatHead = newX === x && newY === y;
    // Re randomization if conditions are not met
    if (isRepeatBody || isRepeatHead) {
      this.change(snake);
    } else {
      this.x = newX;
      this.y = newY;
    }
  }
}

epilogue

If you want to practice, you can follow my ideas. If you don't understand, you can ask me in the comment area. I will reply as soon as I see it.

👉👉 Online trial 👉👉 Source address

Don't forget to praise and star~

Posted by andco on Thu, 28 Oct 2021 08:21:47 -0700