I believe you all know the game.

It’s a lot of fun, whether it’s Super Mario Bros, Contra, King of Glory or Onmyoji.

Of course, this article does not involve so cow force of the game, here is simple to do a small game.

Let’s give it a name. Let’s call it ball-and-ball warfare.

Ahem, easy to understand

play

Anyone who enters the game enters a name and then can connect to the game and control a small ball.

You can manipulate the ball to do things like move, fire a bullet.

Kill other players to earn points and rank on the leaderboard.

There is a common name for these games, called IO games, and there are a lot of them on this site: iogams.space /

The game is available on github: github.com/lionet1224/…

Online experience: http://120.77.44.111:3000/

Demonstrate the GIF:

The preparatory work

To make this game first, we need the following skills:

  • The front end
    • Socket.io
    • Webpack
  • The back-end
    • Node
    • Socket.io
    • express
    • .

And you need to have some knowledge of the following technologies:

  • Canvas
  • object-oriented
  • ES6
  • Node
  • Promise

I wanted to use DENo and TS, but since I’m half-baked with both technologies, I won’t show it.

The game architecture

The back-end service needs to do:

  • Store the generated game object and send it to the front end.
  • Receive the front end of the player operations, to the game object for data processing

The front end needs to do:

  • Receive the data sent by the back end and render it.
  • Send the player’s actions to the server

This is also a typical state synchronization approach to game development.

Backend service setup development

Because the front end is data-driven through the back end, we developed the back end first.

Set up an Express service

First we need to download Express by typing the following command in the root directory:

// Create a package.json file
> npm init
// Install it and place it into a dependency in the package.json file
> npm install express socket.io --save
// Install and place the package.json development dependency
> npm install cross-env nodemon --save-dev
Copy the code

Here we can also use CNPM for the installation

Then create folders and files in the root directory.

We can get the above document.

Explain what they are:

  • publicStore some resources
  • srcDevelop code
    • clientThe front-end code
    • serversThe back-end code
      • coreThe core code
      • objectsPlayers, items, etc
    • sharedThe front and back ends share constants

Writing basic code

Then we write the code to start the service in server.js.

// server.js
// Introduce various modules
const express = require('express')
const socketio = require('socket.io');
const app = express();

const Socket = require('./core/socket');
const Game = require('./core/game');

// Start the service
const port = process.env.PORT || 3000;
const server = app.listen(3000.() = > {
  console.log('Server Listening on port: ' + port)
})

// Example game class
const game = new Game;

// Listen to the socket service
const io = socketio(server)
// Pass the game and I/O to the created socket class for unified management
const socket = new Socket(game, io);

// Listen for a callback to connect into the game
io.on('connect'.item= > {
  socket.listen(item)
})
Copy the code

The above code also introduces two other files: core/game and core/socket.

These two files in the code, I roughly write a little.

// core/game.js
class Game{
  constructor(){
    // Save the socket information of the player
    this.sockets = {}
    // Save the player's gameobject information
    this.players = {};
    / / the bullet
    this.bullets = [];
    // Last execution time
    this.lastUpdateTime = Date.now();
    // Whether to send data to the front end, where data will be sent every two frames
    this.shouldSendUpdate = false;
    // Update the game
    setInterval(this.update.bind(this), 1000 / 60);
  }

  update(){}// Players join the game
  joinGame(){}// Player disconnects the game
  disconnect(){}}module.exports = Game;
Copy the code
// core/socket.js
const Constants = require('.. /.. /shared/constants')

class Socket{
  constructor(game, io){
    this.game = game;
    this.io = io;
  }

  listen(){
    // The player successfully connected to the socket service
    console.log(`Player connected! Socket Id: ${socket.id}`)}}module.exports = Socket
Copy the code

Constant files are introduced into core/socket, so let’s see how I define them.

// shared/constants.js
module.exports = Object.freeze({
  // Player data
  PLAYER: {
    // Maximum life
    MAX_HP: 100./ / speed
    SPEED: 500./ / size
    RADUIS: 50.// Fire frequency, 0.1 seconds per shot
    FIRE: 1.
  },

  / / the bullet
  BULLET: {
    // Bullet speed
    SPEED: 1500.// Bullet size
    RADUIS: 20
  },

  / / props
  PROP: {
    // Generate time
    CREATE_TIME: 10./ / size
    RADUIS: 30
  },

  // Map size
  MAP_SIZE: 5000.// socket Specifies the name of the function that sends messages
  MSG_TYPES: {
    JOIN_GAME: 1.UPDATE: 2.INPUT: 3}})Copy the code

The object.freeze () method can freeze an Object. A frozen object can no longer be modified; If an object is frozen, no new properties can be added to the object, no existing properties can be deleted, no enumerability, configurability, or writability of the existing properties of the object can be modified, and the value of the existing properties cannot be modified. In addition, the prototype of an object cannot be modified after the object is frozen. Freeze () returns the same object as the argument passed. – MDN

With the code in the four files above, we have a back-end service structure with basic functionality.

Let’s start it up.

Create start command

Write the launch command in package.json.

// package.json
{
    // ...
    "scripts": {
      "dev": "cross-env NODE_ENV=development nodemon src/servers/server.js"."start": "cross-env NODE_ENV=production nodemon src/servers/server.js"
    }
    / /..
}
Copy the code

Cross env and nodemon are used in both dev and start commands.

  • cross-envSet the environment variable, and you can see there’s another one after thatNODE_ENV=development/productionTo determine whether it is a development mode.
  • nodemonThis simply means listening for file changes and resetting Node services.

Start the service and take a look

Run the following command to enable development mode.

> npm run dev
Copy the code

You can see that we have successfully started the service, listening on port 3000.

In the service, we carry the socket service, how to test whether it is valid?

So let’s simply build the front end.

Webpack builds front-end files

When we were developing the front end, it was silky to use modularity, and there was also packaging compression for the production environment, which could be used with Webpack.

We have two different packaging environments, one for production and one for development, so we need two webPack configuration files.

Of course, it would be silly to write two, but we will deconstruct the repetitive content.

We create three files webpack.mon.js, webpack.dev.js, and webpack.prod.js in the root directory.

The lazy install module command for this step:

npm install @babel/core @babel/preset-env babel-loader css-loader html-webpack-plugin mini-css-extract-plugin optimize-css-assets-webpack-plugin terser-webpack-plugin webpack webpack-dev-middleware webpack-merge webpack-cli --save-dev

// webpack.common.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: {
    game: './src/client/index.js',},// Output the package file to the dist folder
  output: {
    filename: '[name].[contenthash].js'.path: path.resolve(__dirname, 'dist'),},module: {
    rules: [
      // Parse js with Babel
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader".options: {
            presets: ['@babel/preset-env'],}}},// Extract the CSS from js
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',],},],},plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',}).// Put the processed JS and CSS into HTML
    new HtmlWebpackPlugin({
      filename: 'index.html'.template: 'src/client/html/index.html',})]};Copy the code

The above code is ready to handle CSS and JS files, so we’ll assign it to development and Production, where production will compress JS and CSS as well as HTML.

// webpack.dev.js
const { merge } = require('webpack-merge')
const common = require('./webpack.common')

module.exports = merge(common, {
  mode: 'development'
})
Copy the code
// webpack.prod.js
const { merge } = require('webpack-merge')
const common = require('./webpack.common')
// Compress the js plug-in
const TerserJSPlugin = require('terser-webpack-plugin')
// Compress the CSS plug-in
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')

module.exports = merge(common, {
  mode: 'production'.optimization: {
    minimizer: [new TerserJSPlugin({}), new OptimizeCssAssetsPlugin({})]
  }
})
Copy the code

You’ve defined three different WebPack files, so how do you use them?

First of all, we need to make sure that the code is automatically packaged as soon as the changes are made, so the code is as follows:

// src/servers/server.js
const webpack = require('webpack')
const webpackDevMiddleware = require('webpack-dev-middleware')

const webpackConfig = require('.. /.. /webpack.dev')
// Front-end static file
const app = express();
app.use(express.static('public'))

if(process.env.NODE_ENV === 'development') {// This is the development mode
  // The webpack-dev-middleware middleware is used for code changes to package files using the webpack.dev configuration
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // The online environment only needs to show the packaged folder
  app.use(express.static('dist'))}Copy the code

Add the corresponding command to package.json.

{
/ /...
  "scripts": {
    "build": "webpack --config webpack.prod.js"."start": "npm run build && cross-env NODE_ENV=production nodemon src/servers/server.js"
  },
/ /...
}
Copy the code

Next, let’s try dev and start.

You can see that using the NPM run dev command not only starts the service but also packages the front-end file.

Try NPM run start again.

You can also see that the file is packaged before the service is started.

Let’s take a look at the packaged file.

Test whether the Socket is valid

Let me install the front end socket.io first.

> npm install socket.io-client --save
Copy the code

Then write the entry file for the front-end file:

// src/client/index.js
import { connect } from './networking'

Promise.all([
  connect()
]).then(() = > {

}).catch(console.error)
Copy the code

You can see that I introduced another file, Networking. Let’s take a look at it:

// src/client/networking
import io from 'socket.io-client'

// If HTTPS is used, WSS protocol is required
const socketProtocal = (window.location.protocol.includes('https')?'wss' : 'ws');
// Connect without reconnecting, so you can make a disconnect function
const socket = io(`${socketProtocal}: / /The ${window.location.host}`, { reconnection: false })

const connectPromise = new Promise(resolve= > {
  socket.on('connect'.() = > {
    console.log('Connected to server! '); resolve(); })})export const connect = onGameOver= > {
  connectPromise.then(() = > {
    socket.on('disconnect'.() = > {
      console.log('Disconnected from server.'); })})}Copy the code

The above code is the connection socket, which will automatically get the address and connect, and pass the Promise to index.js, so that the entry file will know when the connection is successful.

Let’s take a look at the front page now.

You can clearly see that the front and back end of the connection is successful.

Creating game Objects

Let’s now define the game object in the game.

First, there will be four different game objects:

  • PlayerPlayer characters
  • PropThe props
  • BulletThe bullet

Let’s do it one by one.

First, they both belong to objects, so I define a superclass Item for each of them:

// src/servers/objects/item.js
class Item{
  constructor(data = {}){
    // id
    this.id = data.id;
    / / position
    this.x = data.x;
    this.y = data.y;
    / / size
    this.w = data.w;
    this.h = data.h;
  }

  // Here is the running state of the object for each frame
  update(dt){}// Format the data for sending to the front end
  serializeForUpdate(){
    return {
      id: this.id,
      x: this.x,
      y: this.y,
      w: this.w,
      h: this.h
    }
  }
}

module.exports = Item;
Copy the code

The above class is the class from which all game objects inherit, and it defines the basic properties of every element in the game world.

Next comes the definition of player, Prop and Bullet.

// src/servers/objects/player.js
const Item = require('./item')
const Constants = require('.. /.. /shared/constants')

/** * Player object class */
class Player extends Item{
  constructor(data){
    super(data);

    this.username = data.username;
    this.hp = Constants.PLAYER.MAX_HP;
    this.speed = Constants.PLAYER.SPEED;
    // Beat the score
    this.score = 0;
    // Owned buffs
    this.buffs = [];
  }

  update(dt){}serializeForUpdate(){
    return {
      ...(super.serializeForUpdate()),
      username: this.username,
      hp: this.hp,
      buffs: this.buffs.map(item= > item.type)
    }
  }
}

module.exports = Player;
Copy the code

Then there is the definition of items and bullets.

// src/servers/objects/prop.js
const Item = require('./item')

/**
 * 道具类
 */
class Prop extends Item{
  constructor(){
    super();
  }
}

module.exports = Prop;
Copy the code
// src/servers/objects/bullet.js
const Item = require('./item')

/** * bullets */
class Bullet extends Item{
  constructor(){
    super();
  }
}

module.exports = Bullet
Copy the code

These are simple definitions that will be added as you develop.

Adding event sending

The above code is already defined, but it still needs to be used, so let’s develop ways to use it here.

After a Player enters a name to join the game, a Player game object needs to be generated.

// src/servers/core/socket.js
class Socket{
  // ...
  listen(socket){
    console.log(`Player connected! Socket Id: ${socket.id}`);

    // Join the game
    socket.on(Constants.MSG_TYPES.JOIN_GAME, this.game.joinGame.bind(this.game, socket));
    // Disconnect the game
    socket.on('disconnect'.this.game.disconnect.bind(this.game, socket));
  }
  // ...
}
Copy the code

Then add the relevant logic in game.js.

// src/servers/core/game.js
const Player = require('.. /objects/player')
const Constants = require('.. /.. /shared/constants')

class Game{
  // ...

  update(){
    const now = Date.now();
    // The current time is subtracted from the last execution time to get the interval time
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Update the player character
    Object.keys(this.players).map(playerID= > {
      const player = this.players[playerID];
      player.update(dt);
    })

    if(this.shouldSendUpdate){
      // Send data
      Object.keys(this.sockets).map(playerID= > {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
            Constants.MSG_TYPES.UPDATE,
            // Handle the object data in the game and send it to the front end
            this.createUpdate(player)
        )
      })

      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true; }}createUpdate(player){
    // Other players
    const otherPlayer = Object.values(this.players).filter(
        p= >p ! == player );return {
      t: Date.now(),
      / / their
      me: player.serializeForUpdate(),
      others: otherPlayer,
      / / the bullet
      bullets: this.bullets.map(bullet= > bullet.serializeForUpdate())
    }
  }

  // Players join the game
  joinGame(socket, username){
    this.sockets[socket.id] = socket;

    // Player location is randomly generated
    const x = (Math.random() * . 5 + 25.) * Constants.MAP_SIZE;
    const y = (Math.random() * . 5 + 25.) * Constants.MAP_SIZE;
    this.players[socket.id] = new Player({
      id: socket.id,
      username,
      x, y,
      w: Constants.PLAYER.WIDTH,
      h: Constants.PLAYER.HEIGHT
    })
  }

  disconnect(socket){
    delete this.sockets[socket.id];
    delete this.players[socket.id]; }}module.exports = Game;
Copy the code

Here we developed the Player joining and quitting, the Player object data update, and the game data sending.

Now that the back-end services have the ability to provide content to the front end, let’s start developing the front end interface.

Front-end interface development

This allows us to develop a back-end service with basic functionality.

Let’s develop the functionality of the front end.

Receiving data sent by the back-end

Let’s see what the data from the back end looks like.

Write the receiving method on the front end first.

// src/client/networking.js
import { processGameUpdate } from "./state";

export const connect = onGameOver= > {
  connectPromise.then(() = > {
    // Update the game
    socket.on(Constants.MSG_TYPES.UPDATE, processGameUpdate);

    socket.on('disconnect'.() = > {
      console.log('Disconnected from server.'); })})}export const play = username= > {
  socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
}
Copy the code
// src/client/state.js
export function processGameUpdate(update){
    console.log(update);
}
Copy the code
// src/client/index.js
import { connect, play } from './networking'

Promise.all([
  connect()
]).then(() = > {
  play('test');
}).catch(console.error)
Copy the code

The above code can let us enter the page to join the game directly, go to the page to see the effect.

Writing the game interface

Let’s edit the HTML code first.

// src/client/html/index.html
<! DOCTYPEhtml>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="Width = device - width, initial - scale = 1.0">
  <title>The ball ball fight</title>
</head>
<body>
  <canvas id="cnv"></canvas>
  <div id="home">
    <h1>The ball ball fight</h1>
    <p class="text-secondary">A simple shooting game</p>
    <hr>

    <div class="content">
      <div class="key">
        <p>
          <code>W</code>Move up</p>
        <p>
          <code>S</code>Move down the</p>
        <p>
          <code>A</code>Move to the left</p>
        <p>
          <code>D</code>To the right</p>
        <p>
          <code>The left mouse button</code>bullets</p>
      </div>
      <div class="play hidden">
        <input type="text" id="username-input" placeholder="Name">
        <button id="play-button">Start the game</button>
      </div>
      <div class="connect">
        <p>Connect to the server...</p>
      </div>
    </div>
  </div>
</body>
</html>
Copy the code

Then import the CSS in index.js.

// src/client/index.js
import './css/bootstrap-reboot.css'
import './css/main.css'
Copy the code

Create a file in SRC /client/ CSS, where bootstrap-reboot is a file that resets the base style of bootstrap. This file can be downloaded on the network, but it is too long to post.

Write the corresponding styles in main.css.

// src/client/css/main.css
html.body {
  margin: 0;
  padding: 0;
  overflow: hidden;
  width: 100%;
  height: 100vh;
  background: linear-gradient(to right bottom, rgb(154.207.223), rgb(100.216.89));
}

.hidden{
  display: none ! important;
}

#cnv{
  width: 100%;
  height: 100%;
}

.text-secondary{
  color: # 666;
}

code{
  color: white;
  background: rgb(236.72.72);
  padding: 2px 10px;
  border-radius: 5px;
}

hr {
  border: 0;
  border-top: 1px solid rgba(0.0.0.0.1);
  margin: 1rem 0;
  width: 100%;
}

button {
  font-size: 18px;
  outline: none;
  border: none;
  color: black;
  background-color: transparent;
  padding: 5px 20px;
  border-radius: 3px;
  transition: background-color 0.2 s ease;
}

button:hover {
  background-color: rgb(141.218.134);
  color: white;
}

button:focus {
  outline: none;
}

#home p{
  margin-bottom: 5px;
}

#home{
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translateY(-50%) translateX(-50%);
  padding: 20px 30px;
  background-color: white;
  display: flex;
  flex-direction: column;
  align-items: center;
  border-radius: 5px;
  text-align: center;
}

#home input {
  font-size: 18px;
  outline: none;
  border: none;
  border-bottom: 1px solid #dedede;
  margin-bottom: 5px;
  padding: 3px;
  text-align: center;
}

#home input:focus{
  border-bottom: 1px solid #8d8d8d;
}

#home .content{
  display: flex;
  justify-content: space-between;
  align-items: center;
}

#home .content .play{
  width: 200px;
  margin-left: 50px;
}

#home .content .connect{
  margin-left: 50px;
}
Copy the code

Finally, we can get the image below.

Write the logic for the start of the game

Let’s start by creating a util.js to hold some utility functions.

// src/client/util.js
export function $(elem){
  return document.querySelector(elem)
}
Copy the code

Then write the corresponding logical code in index.js.

// src/client/index.js
import { connect, play } from './networking'
import{$}from './util'

Promise.all([
  connect()
]).then(() = > {
  // Hide the connection server display input box and keys
  $('.connect').classList.add('hidden'The $()'.play').classList.remove('hidden')
  // And focus the input box by default
  $('#home input').focus();

  // The game start button listens for click events
  $('#play-button').onclick = () = > {
    // Check whether the input box is null
    let val = $('#home input').value;
    if(val.replace(/\s*/g.' ') = = =' ') {
      alert('Name cannot be empty')
      return;
    }
    // Start the game, hide the start screen
    $('#home').classList.add('hidden')
    play(val)
  }
}).catch(console.error)
Copy the code

The above code can start the game normally, but the game starts, no screen.

So, let’s now develop the code to render the screen.

Load resources

We all know that the canvas needs all the images to load, otherwise it will have nothing, so let’s write a code to load all the images first.

Image files are stored in public/assets

// src/client/asset.js
// Resources to be loaded
const ASSET_NAMES = [
  'ball.svg'.'aim.svg'
]

// Save the downloaded image file for canvas use
const assets = {};
// Each image is loaded with a promise. Promise.all will end when all images are loaded successfully
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset))

function downloadAsset(assetName){
  return new Promise(resolve= > {
    const asset = new Image();
    asset.onload = () = > {
      console.log(`Downloaded ${assetName}`)
      assets[assetName] = asset;
      resolve();
    }
    asset.src = `/assets/${assetName}`})}export const downloadAssets = () = > downloadPromise;
export const getAsset = assetName= > assets[assetName]
Copy the code

Next, introduce asset.js in index.js.

// src/client/index.js
import { downloadAssets } from './asset'

Promise.all([
  connect(),
  downloadAssets()
]).then(() = > {
  // ...
}).catch(console.error)
Copy the code

At this point, we should see this output in the page.

Images can be downloaded from IconFont or the Online experience network or Github.

Drawing game objects

Let’s create a new render. Js file and write the corresponding drawing code in it.

// src/client/render.js
import { MAP_SIZE, PLAYER } from '.. /shared/constants'
import { getAsset } from './asset'
import { getCurrentState } from './state'
import{$}from './util'

const cnv = $('#cnv')
const ctx = cnv.getContext('2d')

function setCanvasSize(){
  cnv.width = window.innerWidth;
  cnv.height = window.innerHeight;
}

// The canvas width and height will be set once by default, and once when the screen is scaled
setCanvasSize();
window.addEventListener('resize', setCanvasSize)

// Draw the function
function render(){
  const { me, others, bullets } = getCurrentState();
  if(! me){return; }}// This will start the timer of the render function and export it, which we use in index.js
let renderInterval = null;
export function startRendering(){
  renderInterval = setInterval(render, 1000 / 60);
}

export function stopRendering(){
  ctx.clearRect(0.0, cnv.width, cnv.height)
  clearInterval(renderInterval);
}
Copy the code

You can see above that we introduced the getCurrentState function in state.js, which will get the data object returned by the latest server.

// src/client/state.js
const gameUpdates = [];

export function processGameUpdate(update){
  gameUpdates.push(update)
} 

export function getCurrentState(){
  return gameUpdates[gameUpdates.length - 1]}Copy the code

Draw the background

Since the map in the game is too large to fit on a single screen, the player needs a reference to move around. Here, a gradient circle is used as a reference.

// src/client/render.js
function render(){
  // ...
  // Draw the background circle
  renderBackground(me.x, me.y);

  // Draw a boundary
  ctx.strokeStyle = 'black'
  ctx.lineWidth = 1;
  // The upper left corner of the default boundary is in the center of the screen. Subtract x/y from the character to calculate the offset relative to the character
  ctx.strokeRect(cnv.width / 2 - me.x, cnv.height / 2 - me.y, MAP_SIZE, MAP_SIZE)
}

function renderBackground(x, y){
  // Assume that the background circle is at the top left corner of the screen, then cnv.width/height / 2 will position the circle at the center of the screen
  // MAP_SIZE / 2-x /y the distance between the center of the map and the player. This distance is the correct location of the center of the background circle
  const backgroundX = MAP_SIZE / 2 - x + cnv.width / 2;
  const backgroundY = MAP_SIZE / 2 - y + cnv.height / 2;
  const bgGradient = ctx.createRadialGradient(
    backgroundX,
    backgroundY,
    MAP_SIZE / 10,
    backgroundX,
    backgroundY,
    MAP_SIZE / 2
  )
  bgGradient.addColorStop(0.'rgb(100, 216, 89)')
  bgGradient.addColorStop(1.'rgb(154, 207, 223)')
  ctx.fillStyle = bgGradient;
  ctx.fillRect(0.0, cnv.width, cnv.height)
}
Copy the code

The above code will look like the following.

Our player’s location is set as a random number on the server, so it’s a random location every time you enter the game.

Draw the player

The next step is to draw the player, again writing the corresponding code in render.

// src/client/render.js
function render(){
  // ...
  // Draw all players
  // The first parameter is the comparison position data, and the second parameter is the player rendering data
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

function renderPlayer(me, player){
  const { x, y } = player;
  // By default, render the player in the center of the screen, then set the position to it, then calculate the relative position of the player, and then the correct position on the screen
  const canvasX = cnv.width / 2 + x - me.x;
  const canvasY = cnv.height / 2 + y - me.y;

  ctx.save();
  ctx.translate(canvasX, canvasY);
  ctx.drawImage(
    getAsset('ball.svg'),
    -PLAYER.RADUIS,
    -PLAYER.RADUIS,
    PLAYER.RADUIS * 2,
    PLAYER.RADUIS * 2
  )
  ctx.restore();

  // Draw the blood bar background
  ctx.fillStyle = 'white'
  ctx.fillRect(
    canvasX - PLAYER.RADUIS,
    canvasY - PLAYER.RADUIS - 8,
    PLAYER.RADUIS * 2.4
  )

  // Draw a blood stripe
  ctx.fillStyle = 'red'
  ctx.fillRect(
    canvasX - PLAYER.RADUIS,
    canvasY - PLAYER.RADUIS - 8,
    PLAYER.RADUIS * 2 * (player.hp / PLAYER.MAX_HP),
    4
  )

  // Draw the player name
  ctx.fillStyle = 'white'
  ctx.textAlign = 'center';
  ctx.font = "20px 'Microsoft Yahei '"
  ctx.fillText(player.username, canvasX, canvasY - PLAYER.RADUIS - 16)}Copy the code

This will draw the player correctly.

In the above two images, I have two players entering the game with two pages open, and you can see that they are centered on themselves, and the other players are drawn relative to them.

Gameplay development

Add mobile interaction

Now that we’ve drawn the player, we can start moving it.

We create an input.js to write the corresponding input interaction code.

// src/client/input.js
// Send the message to the backend
import { emitControl } from "./networking";

function onKeydown(ev){
  let code = ev.keyCode;
  switch(code){
    case 65:
      emitControl({
        action: 'move-left'.data: false
      })
      break;
    case 68:
      emitControl({
        action: 'move-right'.data: true
      })
      break;
    case 87:
      emitControl({
        action: 'move-top'.data: false
      })
      break;
    case 83:
      emitControl({
        action: 'move-bottom'.data: true
      })
      break; }}function onKeyup(ev){
  let code = ev.keyCode;
  switch(code){
    case 65:
      emitControl({
        action: 'move-left'.data: 0
      })
      break;
    case 68:
      emitControl({
        action: 'move-right'.data: 0
      })
      break;
    case 87:
      emitControl({
        action: 'move-top'.data: 0
      })
      break;
    case 83:
      emitControl({
        action: 'move-bottom'.data: 0
      })
      break; }}export function startCapturingInput(){
  window.addEventListener('keydown', onKeydown);
  window.addEventListener('keyup', onKeyup);
}

export function stopCapturingInput(){
  window.removeEventListener('keydown', onKeydown);
  window.removeEventListener('keyup', onKeyup);
}
Copy the code
// src/client/networking.js
// ...

// Send the message to the backend
export const emitControl = data= > {
  socket.emit(Constants.MSG_TYPES.INPUT, data);
}
Copy the code

The above code is very simple, by determining W/S/A/D four keys to send information to the back end.

The back end handles the passing to the player object and then makes the player move during game updates.

// src/servers/core/game.js
class Game{
  // ...
  update(){
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Every time the game updates, tell the player the object you want to update
    Object.keys(this.players).map(playerID= > {
      const player = this.players[playerID]
      player.update(dt)
    })
  }

  handleInput(socket, item){
    const player = this.players[socket.id];
    if(player){
      let data = item.action.split(The '-');
      let type = data[0];
      let value = data[1];
      switch(type){
        case 'move':
          // This is to prevent the front end from sending 1000/-1000 numbers, which would cause the player to move very fast
          player.move[value] = typeof item.data === 'boolean'
                                ? item.data ? 1 : -1
                                : 0
          break; }}}}Copy the code

Then add the corresponding mobile code in player.js.

// src/servers/objects/player.js
class Player extends Item{
  constructor(data){
    super(data)

    this.move = {
      left: 0.right: 0.top: 0.bottom: 0
    };
    // ...
  }

  update(dt){
    // Here dt is the time per game update, times the speed of the dt moving 60 frames per second
    this.x += (this.move.left + this.move.right) * this.speed * dt;
    this.y += (this.move.top + this.move.bottom) * this.speed * dt;
  }

  // ...
}

module.exports = Player;
Copy the code

With the above code, we have implemented the logic of the player’s movement. Let’s take a look at the effect.

As you can see, we can fly out of the map by adding the corresponding restriction code in player.js.

// src/servers/objects/player.js
class Player extends Item{
  // ...
  
  update(dt){
    this.x += (this.move.left + this.move.right) * this.speed * dt;
    this.y += (this.move.top + this.move.bottom) * this.speed * dt;

    // The maximum size of the map cannot be larger than the maximum size of the map
    // At the beginning of the map, 0 cannot be less than 0 when compared to its own position
    this.x = Math.max(0.Math.min(Constants.MAP_SIZE, this.x))
    this.y = Math.max(0.Math.min(Constants.MAP_SIZE, this.y))
  }

  // ...
}

module.exports = Player;
Copy the code

Increased bullets fired

Now that our characters can move, we need bullets to fight each other, so let’s work on them.

Let’s start by adding the code on the front end that sends the intent to shoot.

// src/client/input.js
// Here we use atan2 to get the mouse Angle relative to the center of the screen
function getMouseDir(ev){
  const dir = Math.atan2(ev.clientX - window.innerWidth / 2, ev.clientY - window.innerHeight / 2);
  return dir;
}

// Each time the mouse moves, send the direction to the back end to save
function onMousemove(ev){
  if(ev.button === 0){
    emitControl({
      action: 'dir'.data: getMouseDir(ev)
    })
  }
}

/ / fire
function onMousedown(ev){
  if(ev.button === 0){
    emitControl({
      action: 'bullet'.data: true}}})/ / a ceasefire
function onMouseup(ev){
  if(ev.button === 0){
    emitControl({
      action: 'bullet'.data: false}}})export function startCapturingInput(){
  window.addEventListener('mousedown', onMousedown)
  window.addEventListener('mousemove', onMousemove)
  window.addEventListener('mouseup', onMouseup)
}

export function stopCapturingInput(){
  window.removeEventListener('mousedown', onMousedown)
  window.addEventListener('mousemove', onMousemove)
  window.removeEventListener('mouseup', onMouseup)
}
Copy the code

Then write the corresponding code in the back end.

// src/servers/core/game.js
class Game{
  // ...
  
  update(){
    // ...
    // If the bullet flies off the map or reaches the character, filter it out
    this.bullets = this.bullets.filter(item= >! item.isOver)// Update for each bullet
    this.bullets.map(bullet= > {
      bullet.update(dt);
    })

    Object.keys(this.players).map(playerID= > {
      const player = this.players[playerID]
      // Add firing bullets to character objects
      const bullet = player.update(dt)
      if(bullet){
        this.bullets.push(bullet); }})}handleInput(socket, item){
    const player = this.players[socket.id];
    if(player){
      let data = item.action.split(The '-');
      let type = data[0];
      let value = data[1];
      switch(type){
        case 'move':
          player.move[value] = typeof item.data === 'boolean'
                                ? item.data ? 1 : -1
                                : 0
          break;
        // Update the mouse position
        case 'dir':
          player.fireMouseDir = item.data;
          break;
        // Fire/cease fire
        case 'bullet':
          player.fire = item.data;
          break; }}}}module.exports = Game;
Copy the code

Now that the logic for the bullet is written in game.js, all you need to do is return a bullet object in player.js.

// src/servers/objects/player.js
const Bullet = require('./bullet');

class Player extends Item{
  constructor(data){
    super(data)
    
    // ...
    
    / / fire
    this.fire = false;
    this.fireMouseDir = 0;
    this.fireTime = 0;
  }

  update(dt){
    // ...
    
    // Reduced firing delay per frame
    this.fireTime -= dt;
    // Determine whether to fire
    if(this.fire ! =false) {// Return a bullet object if there is no delay
      if(this.fireTime <= 0) {// Will delay the reset
        this.fireTime = Constants.PLAYER.FIRE;
        // Create a bullet object and pass its id to it. When you collide with it, the bullet you shoot will not hit you
        return new Bullet(this.id, this.x, this.y, this.fireMouseDir); }}}// ...
}

module.exports = Player;
Copy the code

The corresponding bullet. Js file should also be completed.

// src/servers/objects/bullet.js
const shortid = require('shortid')
const Constants = require('.. /.. /shared/constants');
const Item = require('./item')

class Bullet extends Item{
  constructor(parentID, x, y, dir){
    super({
      id: shortid(),
      x, y,
      w: Constants.BULLET.RADUIS,
      h: Constants.BULLET.RADUIS,
    });

    this.rotate = 0;
    this.dir = dir;
    this.parentID = parentID;
    this.isOver = false;
  }

  update(dt){
    // Use trigonometric functions to calculate the corresponding x/y value for the mouse position
    this.x += dt * Constants.BULLET.SPEED * Math.sin(this.dir);
    this.y += dt * Constants.BULLET.SPEED * Math.cos(this.dir);
    
    // This is for the bullet to have a rotation function, one rotation per second
    this.rotate += dt * 360;

    // Set isOver to true when you leave the map, and it will filter in game.js
    if(this.x < 0 || this.x > Constants.MAP_SIZE
      || this.y < 0 || this.y > Constants.MAP_SIZE){
        this.isOver = true; }}serializeForUpdate(){
    return {
      ...(super.serializeForUpdate()),
      rotate: this.rotate
    }
  }
}

module.exports = Bullet;
Copy the code

A shortid library is introduced here to create a random number

Use NPM install shortid –save to install

At this point, we can fire normally, but we can’t see the bullet yet.

That’s because the corresponding drawing code is not written.

// src/client/render.js
function render(){
  // ...
  
  bullets.map(renderBullet.bind(null, me))

  // ...
}

function renderBullet(me, bullet){
  const { x, y, rotate } = bullet;
  ctx.save();
  // Offset to the bullet relative to the character
  ctx.translate(cnv.width / 2 + x - me.x, cnv.height / 2 + y - me.y)
  / / rotation
  ctx.rotate(Math.PI / 180 * rotate)
  // Draw the bullet
  ctx.drawImage(
    getAsset('bullet.svg'),
    -BULLET.RADUIS,
    -BULLET.RADUIS,
    BULLET.RADUIS * 2,
    BULLET.RADUIS * 2
  )
  ctx.restore();
}
Copy the code

At this point, we will complete the function of firing the bullet.

Let’s see what happens.

Collision detection

Now that the player’s logic for moving and sending bullets is complete, it’s time to develop collision detection, the most important aspect of the battle.

We add it directly in game.js.

// src/servers/core/game.js
class Game{
  // ..
  
  update(){
    // ...

    // Pass the player and bullet in for collision detection
    this.collisions(Object.values(this.players), this.bullets);

    Object.keys(this.sockets).map(playerID= > {
      const socket = this.sockets[playerID]
      const player = this.players[playerID]
      // If the player's health is below or equal to zero, tell him the game is over and remove him from the game
      if(player.hp <= 0){
        socket.emit(Constants.MSG_TYPES.GAME_OVER)
        this.disconnect(socket); }})// ...
  }

  collisions(players, bullets){
    for(let i = 0; i < bullets.length; i++){
      for(let j = 0; j < players.length; j++){
        let bullet = bullets[i];
        let player = players[j];

        // The bullet fired by oneself cannot reach oneself
        // distanceTo is a way of using the Pythagorean theorem to determine the distance between the object and the player. If the distance is less than the radius of the bullet, the player will collide
        if(bullet.parentID ! == player.id && player.distanceTo(bullet) <= Constants.PLAYER.RADUIS + Constants.BULLET.RADUIS ){// Bullet destruction
          bullet.isOver = true;
          // Player takes blood
          player.takeBulletDamage();
          // Here the judge gives points to the player whose final strike kills him
          if(player.hp <= 0) {this.players[bullet.parentID].score++;
          }
          break; }}}}// ...
}

module.exports = Game;
Copy the code

Next, add game over logic to the front end.

// src/client/index.js
// ...
import { startRendering, stopRendering } from './render'
import { startCapturingInput, stopCapturingInput } from './input'

Promise.all([
  connect(gameOver),
  downloadAssets()
]).then(() = > {
  // ...
}).catch(console.error)

function gameOver(){
  // Stop rendering
  stopRendering();
  // Stop listening
  stopCapturingInput();
  // The start screen is displayed
  $('#home').classList.remove('hidden');
  alert('You're fucked, re-enter the game. ');
}
Copy the code

At this point we can normally play the game.

Let’s see what happens.

Leaderboard function

Now that we’ve done the basics of the normal game, we need a ranking to make the player experience (aha, ha, ha).

Let’s show the leaderboard at the front.

Let’s start by adding data back to the leaderboard on the back end.

// src/servers/core/game.js
class Game{
  // ...

  createUpdate(player){
    // ...

    return {
      // ...
      leaderboard: this.getLeaderboard()
    }
  }

  getLeaderboard(){
    return Object.values(this.players)
      .sort((a, b) = > b.score - a.score)
      .slice(0.10)
      .map(item= > ({ username: item.username, score: item.score }))
  }
}

module.exports = Game;
Copy the code

Then write the style of the leaderboard in the front end.

// src/client/html/index.html
// ..
<body>
  <canvas id="cnv"></canvas>

  <div class="ranking hidden">
    <table>
      <thead>
        <tr>
          <th>ranking</th>
          <th>The name</th>
          <th>integral</th>
        </tr>
      </thead>
      <tbody>
      </tbody>
    </table>
  </div>
  
  // ...
</body>
</html>
Copy the code
// src/client/css/main.css
// ...

.ranking{
  position: fixed;
  width: 300px;
  background: # 333;
  top: 0;
  left: 0;
  color: white;
  padding: 10px;
}

.ranking table{
  border: 0;
  border-collapse: 0;
  width: 100%;
}
Copy the code

Write another function to render the data in render. Js.

// src/client/render.js
// ...

export function updateRanking(data){
  let str = ' ';

  data.map((item, i) = > {
    str += `
      <tr>
        <td>${i + 1}</td>
        <td>${item.username}</td>
        <td>${item.score}</td>
      <tr>
    `$(})'.ranking table tbody').innerHTML = str;
}
Copy the code

Finally, use this function in state.js.

// src/client/state.js
import { updateRanking } from "./render";

const gameUpdates = [];

export function processGameUpdate(update){
  gameUpdates.push(update)

  updateRanking(update.leaderboard) 
}

// ...
Copy the code

Now there is no problem with rendering the leaderboard. Now go to index.js and manage the display and hide of the leaderboard.

// src/client/index.js
// ...

Promise.all([
  connect(gameOver),
  downloadAssets()
]).then(() = > {
  // ...

  $('#play-button').onclick = () = > {
    // ...

    $('.ranking').classList.remove('hidden')

    // ...
  }
}).catch(console.error)

function gameOver(){
  // ...
  $('.ranking').classList.add('hidden')
  // ...
}
Copy the code

At this point, the function of the leaderboard is complete.

Props to develop

Of course, the game is still very poor now, let’s add a few props to increase the game.

Let’s complete prop.js first.

// src/servers/objects/prop.js
const Constants = require('.. /.. /shared/constants')
const Item = require('./item')

class Prop extends Item{
  constructor(type){
    // Random location
    const x = (Math.random() * . 5 + 25.) * Constants.MAP_SIZE;
    const y = (Math.random() * . 5 + 25.) * Constants.MAP_SIZE;
    super({
      x, y,
      w: Constants.PROP.RADUIS,
      h: Constants.PROP.RADUIS
    });

    this.isOver = false;
    // What type of buff
    this.type = type;
    // Lasts 10 seconds
    this.time = 10;
  }

  // How this item affects the player
  add(player){
    switch(this.type){
      case 'speed':
        player.speed += 500;
        break; }}// Removing this item removes the impact on the player
  remove(player){
    switch(this.type){
      case 'speed':
        player.speed -= 500;
        break; }}// Update each frame
  update(dt){
    this.time -= dt;
  }

  serializeForUpdate(){
    return {
      ...(super.serializeForUpdate()),
      type: this.type,
      time: this.time
    }
  }
}

module.exports = Prop;
Copy the code

Then we add the logic to add items regularly in game.js.

// src/servers/core/game.js
const Constants = require(".. /.. /shared/constants");
const Player = require(".. /objects/player");
const Prop = require(".. /objects/prop");

class Game{
  constructor(){
    // ...
    // Add an array of items to save
    this.props = [];
    
    // ...
    // Add item timing
    this.createPropTime = 0;
    setInterval(this.update.bind(this), 1000 / 60);
  }

  update(){
    // ...
    
    // This is added when the timing is 0
    this.createPropTime -= dt;
    // Filter out items that have already crashed
    this.props = this.props.filter(item= >! item.isOver)// Do not add more than 10 items
    if(this.createPropTime <= 0 && this.props.length < 10) {this.createPropTime = Constants.PROP.CREATE_TIME;
      this.props.push(new Prop('speed'))}// ...

    this.collisionsBullet(Object.values(this.players), this.bullets);
    this.collisionsProp(Object.values(this.players), this.props)

    // ...
  }

  // Collision detection between player and item
  collisionsProp(players, props){
    for(let i = 0; i < props.length; i++){
      for(let j = 0; j < players.length; j++){
        let prop = props[i];
        let player = players[j];

        if(player.distanceTo(prop) <= Constants.PLAYER.RADUIS + Constants.PROP.RADUIS){
          // After the collision, the prop disappears
          prop.isOver = true;
          // The player adds the effect of this item
          player.pushBuff(prop);
          break; }}}}// Collisions are different from collisions
  collisionsBullet(players, bullets){
    // ...
  }

  createUpdate(player){
    // ...
    
    return {
      // ...
      props: this.props.map(prop= > prop.serializeForUpdate())
    }
  }
}

module.exports = Game;
Copy the code

Here we can optimize the collision detection and transform it into a collision function that can be used in any scenario, but here we just copied it into two for convenience.

Next, add the corresponding function in player.js.

// src/servers/objects/player.js
const Item = require('./item')
const Constants = require('.. /.. /shared/constants');
const Bullet = require('./bullet');

class Player extends Item{
  // ...

  update(dt){
    // ...
  
    // Determine if the buff is invalid
    this.buffs = this.buffs.filter(item= > {
      if(item.time > 0) {return item;
      } else {
        item.remove(this); }})// Buff duration is reduced per frame
    this.buffs.map(buff= > buff.update(dt));

    // ...
  }

  / / add
  pushBuff(prop){
    this.buffs.push(prop);
    prop.add(this);
  }
  
  // ...

  serializeForUpdate(){
    return {
      // ...
      buffs: this.buffs.map(item= > item.serializeForUpdate())
    }
  }
}

module.exports = Player;
Copy the code

Now that you’ve done what you need to do on the back end, add the drawing aspect to the front end.

// src/client/render.js
// ...

function render(){
  const { me, others, bullets, props } = getCurrentState();
  if(! me){return;
  }
  
  // ...
  
  // Draw props
  props.map(renderProp.bind(null, me))
  
  // ...
}

// ...

// Draw props
function renderProp(me, prop){
  const { x, y, type } = prop;
  ctx.save();
  ctx.drawImage(
    getAsset(`${type}.svg`),
    cnv.width / 2 + x - me.x,
    cnv.height / 2 + y - me.y,
    PROP.RADUIS * 2,
    PROP.RADUIS * 2
  )
  ctx.restore();
}

function renderPlayer(me, player){
  // ...
  
  // Display items that the player has received
  player.buffs.map((buff, i) = > {
    ctx.drawImage(
      getAsset(`${buff.type}.svg`),
      canvasX - PLAYER.RADUIS + i * 22,
      canvasY + PLAYER.RADUIS + 16.20.20)})}Copy the code

At this point, the accelerator is complete.

If you need to add more items, you can do so in prop.js and change speed to the random item type when generating items in game.js.

The finished effect.

Disconnection display

We can write an interface specifically to display the disconnection prompt.

// src/client/html/index.html
// ...
<body>
  // ...
  
  <div class="disconnect hidden">
    <p>The server is disconnected</p>
  </div>
</body>
Copy the code
// src/client/css/main.css
.disconnect{
  position: fixed;
  width: 100%;
  height: 100vh;
  left: 0;
  top: 0;
  z-index: 100;
  background: white;
  display: flex;
  justify-content: center;
  align-items: center;
  color: # 444;
  font-size: 40px;
}
Copy the code

This interface is displayed when you go to networking.js and disconnect the connection.

// src/client/networking.js
// ...

export const connect = onGameOver= > {
  connectPromise.then(() = > {
    // ...
    socket.on('disconnect'.() = >{$('.disconnect').classList.remove('hidden')
      console.log('Disconnected from server.')})})}// ...
Copy the code

At this point, we open the game, then close the game service, and the game will display this interface.

The end of the

This is the end of this article.

Thank you for watching, if you think it is good, you can point to support (hey hey).