I am participating in the nuggets Community game creative submission Contest. For details, please see: Game Creative Submission Contest

preface

This article will achieve a web version of snake small game, technology stack selection of the current popular Vite + Vue3 + Ts.

👉👉 online demo 👉👉 source code address

It is recommended to read this article with the source code, the effect is better oh ~

screenshots

The directory structure

├ ─ ─ the SRC ├ ─ ─ assets// Store static resources├ ─ ─ the components/ / the vue components│ ├ ─ ─ Cell. Vue// Each little square│ ├ ─ ─ Controller. Vue// Game controller│ ├ ─ ─ the rid_device_info_keyboard. Vue// Mobile soft keyboard│ └ ─ ─Map.vue          // Map component├ ─ ─ game// Core game logic│ ├ ─ ─ GameControl. Ts// Controller class│ ├ ─ ─ Food. Ts/ / class food│ ├ ─ ─ Snake. Ts/ / snakes│ ├ ─ ─ hit. Ts// Collision logic│ ├ ─ ─ render. Ts// Render the view logic│ ├ ─ ─ map. Ts// Map related logic│ └ ─ ─ index. Ts/ / main process├ ─ ─ types/ / TS type├ ─ ─ utils// Utility functions├ ─ ─ the main ts// Main entry file└ ─ ─ App. Vue// Vue root component.Copy the code

The implementation process

Note: the implementation process only intercept the key code to explain, it is recommended to read the source code, easier to understand.

Game rendering

/src/game/map.ts

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

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

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

// Initialize the map now that all positions are of 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;
}
Copy the code

How do I count the number of cells?

Here, I judge a device by obtaining the width and height of the device’s screen. The larger screen is larger (50px) and the smaller screen is smaller (30px). I subtracted a little bit of width and height here to give the picture a sense of area and make it look nicer.

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

How do you create maps?

Then, according to the number of rows and columns calculated in the previous step, we render the map through a two-dimensional array. The element value of the two-dimensional array determines the color of each cell. Since this is initialization, we default to all zeros and pass the element’s value to the child component cell.vue.

/src/components/Map.vue

<template>
  <div class="game-box">
    <! Line -- -- -- >
    <div class="row"
         v-for='row in gameRow'
         :key='row'>
      <! - column -- -- >
      <div class="col"
           v-for='col in gameCol'
           :key='col'>
        <! -- Small box -->
        <Cell :type='map[row-1][col-1]'></Cell>
      </div>
    </div>
  </div>
</template>
Copy the code

How do you 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']);
// The color of the small box
const classes = computed(() = > {
  return {
    head: props.type === 2.body: props.type === 1.food: props.type === -1}; });</script>
Copy the code

Think about what elements will appear on the entire game map, snake head (2), snake body (1) and food (-1). So we can assign different classes to different elements based on their values, so that different elements can display different styles on the map.

Controller class design

/src/game/GameControl.ts

export class GameControl {
  / / the snake
  snake: Snake;
  / / food
  private _food: Food;
  / / map
  private _map: Map;
  // Game state
  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 keypress events
    document.addEventListener('keydown'.this.keydownHandler.bind(this));
    // Add to the frame loop list
    addTicker(this.handlerTicker.bind(this));
    // mark the game state as start
    this._isLive.value = 2;
  }
  // Create a keypress response function
  keydownHandler(event: KeyboardEvent) {
    this.snake.direction = event.key;
  }
  / / render the map
  private _timeInterval = 200;
  // Whether to move the snake
  private _isMove = intervalTimer(this._timeInterval);
  // Define the frame loop function
  handlerTicker(n: number) {
    if (this._isMove(n)) {
      try {
        this.snake.move(this.snake.direction, this._food);
      } catch (error: any) {
        // Mark the game state as over
        this._isLive.value = 3;
        // Stop the loop
        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)); }}Copy the code

Start the game

To start the game we need to do three things, first bind keyboard events, then add a frame loop to get the game moving, and finally put the game state into play.

How do I add/stop frame loops?

Do not understand the frame loop can refer to my following article.

👉👉 a fantastic 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 a frame loop
export function addTicker(ticker: Ticker) {
  tickers.push(ticker);
}
// Stop the frame loop
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;
  };
}
Copy the code

Restart the game

To restart the game, we also need to do three things: reset the map, add a frame loop, and put the game state into the game.

Snake design

/src/game/Snake.ts

export class Snake {
  bodies: SnakeBodies;
  head: SnakeHead;
  // Create a property to store the direction of the snake's movement (i.e. 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 has eaten the food
  checkEat(food: Food) {
    if (this.head.x === food.x && this.head.y === food.y) {
      // The score increases
      // this.scorePanel.addScore();
      // The food position should be reset
      food.change(this);
      // Add a section for the snake
      this.bodies.unshift({
        x: food.x,
        y: food.y,
        status: 1}); }}// Control snake movement
  move(food: Food) {
    // Determine if 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':
        // Moving up requires checking if 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':
        // Moving down requires checking if 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, 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, 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 if the snake has eaten
    this.checkEat(food);
  }
  // Change the movement direction on the mobile terminal
  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; }}}Copy the code

How does the snake move?

This is the place that has bothered me the longest, but it’s not so hard once I figure it out. 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 snake body array, and then delete the first element of the snake body array. Because when the snake moves, it’s always when the next snake moves to the same spot as the previous snake, so it looks like the snake is moving.

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

Then we will render the new snake’s position information to the view.

/src/game/render.ts

// Each render requires the map to be reset before new data is rendered
export function render(map: Map, snake: Snake, food: Food) {
  / / reset the map
  reset(map);
  // Render the snake head
  _renderSnakeHead(map, snake.head);
  // Render the snake
  _renderSnakeBody(map, snake.bodies);
  // Render the food
  _renderFood(map, food);
}
// Reset map resets all elements of the two-dimensional 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;
}
Copy the code

How do you detect whether a snake has eaten food?

This is very simple, as long as determine whether the coordinates of the snake head and snake body are the same. When we do the same, we push the current snake head into the array of the snake body, but don’t delete the tail element, and it looks like the snake has added a section to the view.

How do you detect snake collisions?

The end of the game has two situations, one is to encounter the boundary, one is to encounter themselves. The determination of the boundary is whether the coordinate of the snake’s head exceeds the number of rows and columns. The judgment of encountering oneself is whether the coordinates of the snake’s head coincide with some part of the snake’s body.

/src/game/hit.ts

// Whether the snake head touches the boundary
export function hitFence(head: SnakeHead, direction: string) {
  // 1. Obtain the position of the snake head
  // 2. Check whether the snake head is outside the scope of the game
  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 at 0 and gameRow starts at 1, so gameRow needs -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 to the right
      isHitFence = head.x + 1 > gameCol - 1;
      break;
    default:
      break;
  }
  return isHitFence;
}
// Whether the snake head touches itself
export function hitSelf(head: SnakeHead, bodies: SnakeBodies) {
  // 1. Obtain the coordinates of the snake head
  const x = head.x;
  const y = head.y;
  // 2. Get the body
  const snakeBodies = bodies;
  // 3. Check if the snake's head has hit itself, i.e., if the next move of the snake's head repeats the elements of the body array
  const isHitSelf = snakeBodies.some((body) = > {
    return body.x === x && body.y === y;
  });
  return isHitSelf;
}
Copy the code

How to change the direction of the snake’s movement?

This is also easy to change the direction of the corresponding value, but note that the snake can not turn back.

Food design

How do you randomly generate food?

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

/src/game/Food.ts

export class Food {
  // Food coordinates
  x: number;
  y: number;
  status = -1;
  constructor() {
    this.x = randomIntegerInRange(0, gameCol - 1);
    this.y = randomIntegerInRange(0, gameRow - 1);
  }
  // Change the location of food
  change(snake: Snake) {
    // Generate a random position
    const newX = randomIntegerInRange(0, gameCol - 1);
    const newY = randomIntegerInRange(0, gameRow - 1);
    // 1. Obtain the coordinates of the snake head
    const x = snake.head.x;
    const y = snake.head.y;
    // 2. Get the body
    const bodies = snake.bodies;
    // 3. Food should not overlap with the head or body
    const isRepeatBody = bodies.some((body) = > {
      return body.x === newX && body.y === newY;
    });
    const isRepeatHead = newX === x && newY === y;
    // re-randomize if the condition is not met
    if (isRepeatBody || isRepeatHead) {
      this.change(snake);
    } else {
      this.x = newX;
      this.y = newY; }}}Copy the code

conclusion

👉👉 online demo 👉👉 source code address

Don’t forget to hit “like” and “star”