天天看點

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

大家好,我是漫步,今天分享一個Nodejs實戰長文,希望對你有所幫助。

……

相信大家都是知道遊戲的吧。

這玩意還是很有意思的,無論是超級瑪麗,還是魂鬥羅,亦或者是王者榮耀以及陰陽師。

當然,這篇文章不涉及到那麼牛逼的遊戲,這裡就簡單的做一個小遊戲吧。

先給它取個名字,就叫“球球作戰”吧。

咳咳,簡單易懂嘛

玩法

任何人進入遊戲輸入名字然後就可以連接配接進入遊戲,控制一個小球。

你可以操作這個小球進行一些動作,比如:移動,發射子彈。

通過殺死其他玩家來擷取積分,并在排行榜上進行排名。

其實這類遊戲有一個統一的名稱,叫做IO類遊戲,在這個網站中有大量的這類遊戲:https://iogames.space/
這個遊戲的github位址:https://github.com/lionet1224/node-game
線上體驗: http://120.77.44.111:3000/

示範GIF:

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

準備工作

首先制作這個遊戲,我們需要的技術為:

  • 前端
    • Socket.io
    • Webpack
  • 後端
    • Node
    • Socket.io
    • express
    • ...

并且你需要對以下技術有一定了解:

  • Canvas
  • 面向對象
  • ES6
  • Node
  • Promise
其實本來想使用

deno

ts

來開發的,但是因為我對這兩項技術都是半生不熟的階段,是以就不拿出來獻醜了。

遊戲架構

後端服務需要做的是:

  • 存儲生成的遊戲對象,并且将其發送給前端。
  • 接收前端的玩家操作,給遊戲對象進行資料處理

前端需要做的是:

  • 接收後端發送的資料并将其渲染出來。
  • 将玩家的操作發送給伺服器

這也是典型的狀态同步方式開發遊戲。

後端服務搭建開發

因為前端是通過後端的資料驅動的,是以我們就先開發後端。

搭建起一個Express服務

首先我們需要下載下傳

express

,在根目錄下輸入以下指令:

// 建立一個package.json檔案
> npm init
// 安裝并且将其置入package.json檔案中的依賴中
> npm install express socket.io --save
// 安裝并置入package.json的開發依賴中
> npm install cross-env nodemon --save-dev

           
這裡我們也可以使用cnpm進行安裝

然後在根目錄中瘋狂建檔案夾以及檔案。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

我們就可以得出以上的檔案啦。

解釋一下分别是什麼東西:

  • public

     存儲一些資源
  • src

     開發代碼
    • core

       核心代碼
    • objects

       玩家、道具等對象
    • client

       前端代碼
    • servers

       後端代碼
    • shared

       前後端共用常量

編寫基本代碼

然後我們在

server.js

中編寫啟動服務的相關代碼。

// server.js
// 引入各種子產品
const express = require('express')
const socketio = require('socket.io');
const app = express();

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

// 啟動服務
const port = process.env.PORT || 3000;
const server = app.listen(3000, () => {
  console.log('Server Listening on port: ' + port)
})

// 執行個體遊戲類
const game = new Game;

// 監聽socket服務
const io = socketio(server)
// 将遊戲以及io傳入建立的socket類來統一管理
const socket = new Socket(game, io);

// 監聽連接配接進入遊戲的回調
io.on('connect', item => {
  socket.listen(item)
})

           

上面的代碼還引入了兩個其他檔案

core/game

core/socket

這兩個檔案中的代碼,我大緻的編寫了一下。

// core/game.js
class Game{
  constructor(){
    // 儲存玩家的socket資訊
    this.sockets = {}
    // 儲存玩家的遊戲對象資訊
    this.players = {};
    // 子彈
    this.bullets = [];
    // 最後一次執行時間
    this.lastUpdateTime = Date.now();
    // 是否發送給前端資料,這裡将每兩幀發送一次資料
    this.shouldSendUpdate = false;
    // 遊戲更新
    setInterval(this.update.bind(this), 1000 / 60);
  }

  update(){

  }

  // 玩家加入遊戲
  joinGame(){

  }

  // 玩家斷開遊戲
  disconnect(){

  }
}

module.exports = Game;

           
// core/socket.js
const Constants = require('../../shared/constants')

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

  listen(){
    // 玩家成功連接配接socket服務
    console.log(`Player connected! Socket Id: ${socket.id}`)
  }
}

module.exports = Socket

           

core/socket

中引入了常量檔案,我們來看看我在其中是怎麼定義的。

// shared/constants.js
module.exports = Object.freeze({
  // 玩家的資料
  PLAYER: {
    // 最大生命
    MAX_HP: 100,
    // 速度
    SPEED: 500,
    // 大小
    RADUIS: 50,
    // 開火頻率, 0.1秒一發
    FIRE: .1
  },

  // 子彈
  BULLET: {
    // 子彈速度
    SPEED: 1500,
    // 子彈大小
    RADUIS: 20
  },

  // 道具
  PROP: {
    // 生成時間
    CREATE_TIME: 10,
    // 大小
    RADUIS: 30
  },

  // 地圖大小
  MAP_SIZE: 5000,

  // socket發送消息的函數名
  MSG_TYPES: {
    JOIN_GAME: 1,
    UPDATE: 2,
    INPUT: 3
  }
})

           
Object.freeze() 方法可以當機一個對象。一個被當機的對象再也不能被修改;當機了一個對象則不能向這個對象添加新的屬性,不能删除已有屬性,不能修改該對象已有屬性的可枚舉性、可配置性、可寫性,以及不能修改已有屬性的值。此外,當機一個對象後該對象的原型也不能被修改。freeze() 傳回和傳入的參數相同的對象。- MDN

通過上面的四個檔案的代碼,我們已經擁有了一個具備基本功能的後端服務結構了。

接下來就來将它啟動起來吧。

建立啟動指令

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"
    }
    //..
}

           

這裡的兩個指令

dev

start

都使用到了

cross-env

nodemon

,這裡解釋一下:

  • cross-env

     設定環境變量,這裡可以看到這個後面還有一個

    NODE_ENV=development/production

    ,判斷是否是開發模式。
  • nodemon

     這個的話說白了就是監聽檔案變化并重置Node服務。

啟動服務看一下吧

執行以下指令開啟開發模式。

> npm run dev

           
Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

可以看到我們成功的啟動服務了,監聽到了

3000

端口。

在服務中,我們搭載了

socket

服務,那要怎麼測試是否有效呢?

是以我們現在簡單的搭建一下前端吧。

Webpack搭建前端檔案

我們在開發前端的時候,用到子產品化的話會開發更加絲滑一些,并且還有生産環境的打包壓縮,這些都可以使用到

Webpack

我們的打包有兩種不同的環境,一種是生産環境,一種是開發環境,是以我們需要兩個

webpack

的配置檔案。

當然傻傻的直接寫兩個就有點憨憨了,我們将其中重複的内容給解構出來。

我們在根目錄下建立

webpack.common.js

webpack.dev.js

webpack.prod.js

三個檔案。

此步驟的懶人安裝子產品指令:

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',
  },
  // 将打封包件輸出到dist檔案夾
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      // 使用babel解析js
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      // 将js中的css抽出來
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
    // 将處理後的js以及css置入html中
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'src/client/html/index.html',
    }),
  ],
};

           

上面的代碼已經可以處理

css

以及

js

檔案了,接下來我們将它配置設定給

development

production

中,其中

production

将會壓縮

js

css

以及

html

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

module.exports = merge(common, {
  mode: 'development'
})

           
// webpack.prod.js
const { merge } = require('webpack-merge')
const common = require('./webpack.common')
// 壓縮js的插件
const TerserJSPlugin = require('terser-webpack-plugin')
// 壓縮css的插件
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')

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

           

上面已經定義好了三個不同的

webpack

檔案,那麼該怎麼樣使用它們呢?

首先開發模式,我們需要做到修改了代碼就自動打包代碼,那麼代碼如下:

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

const webpackConfig = require('../../webpack.dev')
// 前端靜态檔案
const app = express();
app.use(express.static('public'))

if(process.env.NODE_ENV === 'development'){
  // 這裡是開發模式
  // 這裡使用了webpack-dev-middleware的中間件,作用就是代碼改動就使用webpack.dev的配置進行打封包件
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // 上線環境就隻需要展示打包後的檔案夾
  app.use(express.static('dist'))
}

           

接下來就在

package.json

中添加相對應的指令吧。

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

           

接下來,我們試試

dev

start

的效果吧。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

可以看到使用

npm run dev

指令後不僅啟動了服務還打包了前端檔案。

再試試

npm run start

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

也可以看到先打包好了檔案再啟動了服務。

我們來看看打包後的檔案。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

測試Socket是否有效

先讓我裝一下前端的

socket.io

> npm install socket.io-client --save

           

然後編寫一下前端檔案的入口檔案:

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

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

}).catch(console.error)

           

可以看到上面代碼我引入了另一個檔案

networking

,我們來看一下:

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

// 這裡判斷是否是https,如果是https就需要使用wss協定
const socketProtocal = (window.location.protocol.includes('https') ? 'wss' : 'ws');
// 這裡就進行連接配接并且不重新連接配接,這樣可以制作一個斷開連接配接的功能
const socket = io(`${socketProtocal}://${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.');
    })
  })
}

           

上面的代碼就是連接配接

socket

,将會自動擷取位址然後進行連接配接,通過

Promise

傳給

index.js

,這樣入口檔案就可以知道什麼時候連接配接成功了。

我們現在就去前端頁面中看一下吧。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發
Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

可以很清楚的看到,前後端都有連接配接成功的相關提示。

建立遊戲對象

我們現在來定義一下遊戲中的遊戲對象吧。

首先遊戲中将會有四種不同的遊戲對象:

  • Player

     玩家人物
  • Prop

     道具
  • Bullet

     子彈

我們來一一将其實作吧。

首先他們都屬于物體,是以我給他們都定義一個父類

Item

:

// src/servers/objects/item.js
class Item{
  constructor(data = {}){
    // id
    this.id = data.id;
    // 位置
    this.x = data.x;
    this.y = data.y;
    // 大小
    this.w = data.w;
    this.h = data.h;
  }

  // 這裡是物體每幀的運作狀态
  update(dt){
  
  }

  // 格式化資料以友善發送資料給前端
  serializeForUpdate(){
    return {
      id: this.id,
      x: this.x,
      y: this.y,
      w: this.w,
      h: this.h
    }
  }
}

module.exports = Item;

           

上面這個類是所有遊戲對象都要繼承的類,它定義了遊戲世界裡每一個元素的基本屬性。

接下來就是

player

Prop

Bullet

的定義了。

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

/**
 * 玩家對象類
 */
class Player extends Item{
  constructor(data){
    super(data);

    this.username = data.username;
    this.hp = Constants.PLAYER.MAX_HP;
    this.speed = Constants.PLAYER.SPEED;
    // 擊敗分值
    this.score = 0;
    // 擁有的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;

           

然後是道具以及子彈的定義。

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

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

module.exports = Prop;

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

/**
 * 子彈類
 */
class Bullet extends Item{
  constructor(){
    super();
  }
}

module.exports = Bullet

           

上面都是簡單的定義,随着開發會逐漸添加内容。

添加事件發送

上面的代碼雖然已經定義好了,但是還需要使用它,是以在這裡我們來開發使用它們的方法。

在玩家輸入名稱加入遊戲後,需要生成一個

Player

的遊戲對象。

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

    // 加入遊戲
    socket.on(Constants.MSG_TYPES.JOIN_GAME, this.game.joinGame.bind(this.game, socket));
    // 斷開遊戲
    socket.on('disconnect', this.game.disconnect.bind(this.game, socket));
  }
  // ...
}

           

然後在

game.js

中添加相關邏輯。

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

class Game{
  // ...

  update(){
    const now = Date.now();
    // 現在的時間減去上次執行完畢的時間得到中間間隔的時間
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // 更新玩家人物
    Object.keys(this.players).map(playerID => {
      const player = this.players[playerID];
      player.update(dt);
    })

    if(this.shouldSendUpdate){
      // 發送資料
      Object.keys(this.sockets).map(playerID => {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
            Constants.MSG_TYPES.UPDATE,
            // 處理遊戲中的對象資料發送給前端
            this.createUpdate(player)
        )
      })

      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  createUpdate(player){
    // 其他玩家
    const otherPlayer = Object.values(this.players).filter(
        p => p !== player
    );

    return {
      t: Date.now(),
      // 自己
      me: player.serializeForUpdate(),
      others: otherPlayer,
      // 子彈
      bullets: this.bullets.map(bullet => bullet.serializeForUpdate())
    }
  }

  // 玩家加入遊戲
  joinGame(socket, username){
    this.sockets[socket.id] = socket;

    // 玩家位置随機生成
    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;

           

這裡我們開發了玩家的加入以及退出,還有

Player

對象的資料更新,以及遊戲的資料發送。

現在後端服務已經有能力提供内容給前端了,接下來我們開始開發前端的界面吧。

前端界面開發

上面的内容讓我們開發了一個擁有基本功能的後端服務。

接下來來開發前端的相關功能吧。

接收後端發送的資料

我們來看看後端發過來的資料是什麼樣的吧。

先在前端編寫接收的方法。

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

export const connect = onGameOver => {
  connectPromise.then(()=> {
    // 遊戲更新
    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);
}

           
// src/client/state.js
export function processGameUpdate(update){
    console.log(update);
}

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

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

           

上面的代碼就可以讓我們進入頁面就直接加入遊戲了,去頁面看看效果吧。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發
Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

編寫遊戲界面

我們先将

html

代碼編輯一下。

// src/client/html/index.html
<!DOCTYPE html>
<html >
<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>球球作戰</title>
</head>
<body>
  <canvas id="cnv"></canvas>
  <div id="home">
    <h1>球球作戰</h1>
    <p class="text-secondary">一個簡簡單單的射擊遊戲</p>
    <hr>

    <div class="content">
      <div class="key">
        <p>
          <code>W</code> 向上移動
        </p>
        <p>
          <code>S</code> 向下移動
        </p>
        <p>
          <code>A</code> 向左移動
        </p>
        <p>
          <code>D</code> 向右移動
        </p>
        <p>
          <code>滑鼠左鍵</code> 發射子彈
        </p>
      </div>
      <div class="play hidden">
        <input type="text" id="username-input" placeholder="名稱">
        <button id="play-button">開始遊戲</button>
      </div>
      <div class="connect">
        <p>連接配接伺服器中...</p>
      </div>
    </div>
  </div>
</body>
</html>

           

然後在

index.js

中導入

css

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

           

src/client/css

中建立對應的檔案,其中

bootstrap-reboot

bootstrap

的重置基礎樣式的檔案,這個可以在網絡上下載下傳,因為太長,本文就不貼出來了。

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.2s 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;
}

           

最後我們就可以得到下面這張圖的效果了。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

編寫遊戲開始的邏輯

我們先建立一個

util.js

來存放一些工具函數。

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

           

然後在

index.js

中編寫對應的邏輯代碼。

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

Promise.all([
  connect()
]).then(() => {
  // 隐藏連接配接伺服器顯示輸入框及按鍵
  $('.connect').classList.add('hidden')
  $('.play').classList.remove('hidden')
  // 并且預設聚焦輸入框
  $('#home input').focus();

  // 遊戲開始按鈕監聽點選事件
  $('#play-button').onclick = () => {
    // 判斷輸入框的值是否為空
    let val = $('#home input').value;
    if(val.replace(/\s*/g, '') === '') {
      alert('名稱不能為空')
      return;
    }
    // 遊戲開始,隐藏開始界面
    $('#home').classList.add('hidden')
    play(val)
  }
}).catch(console.error)

           

上面的代碼已經可以正常的開始遊戲了,但是遊戲開始了,沒有畫面。

是以,我們現在來開發一下渲染畫面的代碼。

加載資源

我們都知道

canvas

繪制圖檔需要圖檔加載完畢,不然的話會啥也沒有,是以我們先編寫一個加載所有圖檔的代碼。

圖檔檔案存儲在

public/assets

// src/client/asset.js
// 需要加載的資源
const ASSET_NAMES = [
  'ball.svg',
  'aim.svg'
]

// 将下載下傳好的圖檔檔案儲存起來供canvas使用
const assets = {};
// 每一張圖檔都是通過promise進行加載的,所有圖檔加載成功後,Promise.all就會結束
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]

           

接下來在

index.js

中引入

asset.js

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

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

           

這個時候,我們在頁面中就可以看到這樣的輸出了。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

圖檔可以去

iconfont

或是

線上體驗的network

或是

github

中下載下傳。

繪制遊戲對象

我們建立一個

render.js

檔案,在其中編寫對應的繪制代碼。

// 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;
}

// 這裡将預設設定一次canvas寬高,當螢幕縮放的時候也會設定一次
setCanvasSize();
window.addEventListener('resize', setCanvasSize)

// 繪制函數
function render(){
  const { me, others, bullets } = getCurrentState();
  if(!me){
    return;
  }
}

// 這裡将啟動渲染函數的定時器,将其導出,我們在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);
}

           

可以看到上面我們引入

state.js

中的

getCurrentState

函數,這個函數将擷取最新伺服器傳回的資料對象。

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

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

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

           

繪制背景

因為遊戲中的地圖是一個大地圖,一個螢幕是裝不下的,是以玩家移動需要一個參照物,這裡使用一個漸變的圓來做參照物。

// src/client/render.js
function render(){
  // ...
  // 繪制背景圓
  renderBackground(me.x, me.y);

  // 繪制一個邊界
  ctx.strokeStyle = 'black'
  ctx.lineWidth = 1;
  // 預設邊界左上角在螢幕中心,減去人物的x/y算出相對于人物的偏移
  ctx.strokeRect(cnv.width / 2 - me.x, cnv.height / 2 - me.y, MAP_SIZE, MAP_SIZE)
}

function renderBackground(x, y){
  // 假設背景圓的位置在螢幕左上角,那麼cnv.width/height / 2就會将這個圓定位在螢幕中心
  // MAP_SIZE / 2 - x/y 地圖中心與玩家的距離,這段距離就是背景圓圓心正确的位置
  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)
}

           

上面的代碼實作的效果就是下圖。

我們玩家的位置在伺服器中設定的是随機數字,是以每次進入遊戲都是随機的位置。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

繪制玩家

接下來就是繪制玩家了,依舊是在

render.js

中編寫對應的代碼。

// src/client/render.js
function render(){
  // ...
  // 繪制所有的玩家
  // 第一個參數是對照位置的資料,第二個參數是玩家渲染的資料
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

function renderPlayer(me, player){
  const { x, y } = player;
  // 預設将玩家渲染在螢幕中心,然後将位置設定上去,再計算相對于自己的相對位置,就是正确在螢幕的位置了
  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();

  // 繪制血條背景
  ctx.fillStyle = 'white'
  ctx.fillRect(
    canvasX - PLAYER.RADUIS,
    canvasY - PLAYER.RADUIS - 8,
    PLAYER.RADUIS * 2,
    4
  )

  // 繪制血條
  ctx.fillStyle = 'red'
  ctx.fillRect(
    canvasX - PLAYER.RADUIS,
    canvasY - PLAYER.RADUIS - 8,
    PLAYER.RADUIS * 2 * (player.hp / PLAYER.MAX_HP),
    4
  )

  // 繪制玩家的名稱
  ctx.fillStyle = 'white'
  ctx.textAlign = 'center';
  ctx.font = "20px '微軟雅黑'"
  ctx.fillText(player.username, canvasX, canvasY - PLAYER.RADUIS - 16)
}

           

這樣就可以将玩家正确的繪制出來了。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

上面兩張圖,是我打開兩個頁面進入遊戲的兩名玩家,可以看出它們分别以自己為中心,其他的玩家相對于它進行了繪制。

遊戲玩法開發

添加移動互動

既然玩家我們繪制出來了,那麼就可以讓它開始移動起來了。

我們建立一個

input.js

來編寫對應的輸入互動代碼。

// src/client/input.js
// 發送資訊給後端
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);
}

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

// 發送資訊給後端
export const emitControl = data => {
  socket.emit(Constants.MSG_TYPES.INPUT, data);
}

           

上面的代碼很簡單,通過判斷

W

/

S

/

A

/

D

四個按鍵發送資訊給後端。

後端進行處理傳遞給玩家對象,然後在遊戲更新中使玩家移動。

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

    // 每次遊戲更新告訴玩家對象,你要更新了
    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('-');
      let type = data[0];
      let value = data[1];
      switch(type){
        case 'move':
          // 這裡是為了防止前端發送1000/-1000這種數字,會導緻玩家移動飛快
          player.move[value] = typeof item.data === 'boolean'
                                ? item.data ? 1 : -1
                                : 0
          break;
      }
    }
  }
}

           

然後在

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){
    // 這裡的dt是每次遊戲更新的時間,乘于dt将會60幀也就是一秒移動speed的值
    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;

           

通過上面的代碼,我們就實作了玩家移動的邏輯了,下面我們看看效果。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

5.gif

可以看出,我們可以飛出地圖之外,我們在

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;

    // 在地圖最大尺寸和自身位置比較時,不能大于地圖最大尺寸
    // 在地圖開始0位置和自身位置比較時,不能小于0
    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;

           

增加發送子彈

既然我們的人物已經可以移動了,那麼玩家間對抗的工具“子彈”那肯定是不能少的,現在我們就來開發吧。

我們先在前端添加發送開槍意圖的代碼。

// src/client/input.js
// 這裡使用atan2擷取滑鼠相對螢幕中心的角度
function getMouseDir(ev){
  const dir = Math.atan2(ev.clientX - window.innerWidth / 2, ev.clientY - window.innerHeight / 2);
  return dir;
}

// 每次滑鼠移動,發送方向給後端儲存
function onMousemove(ev){
  if(ev.button === 0){
    emitControl({
      action: 'dir',
      data: getMouseDir(ev)
    })
  }
}

// 開火
function onMousedown(ev){
  if(ev.button === 0){
    emitControl({
      action: 'bullet',
      data: true
    })
  }
}

// 停火
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)
}

           

然後在後端中編寫對應的代碼。

// src/servers/core/game.js
class Game{
  // ...
  
  update(){
    // ...
    // 如果子彈飛出地圖或是已經達到人物身上,就過濾掉
    this.bullets = this.bullets.filter(item => !item.isOver)
    // 為每一個子彈更新
    this.bullets.map(bullet => {
      bullet.update(dt);
    })

    Object.keys(this.players).map(playerID => {
      const player = this.players[playerID]
      // 在人物對象中添加發射子彈
      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('-');
      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;
        // 更新滑鼠位置
        case 'dir':
          player.fireMouseDir = item.data;
          break;
        // 開火/停火
        case 'bullet':
          player.fire = item.data;
          break;
      }
    }
  }
}

module.exports = Game;

           

game.js

中已經編寫好了子彈的邏輯了,現在隻需要在

player.js

中傳回一個

bullet

對象就可以成功發射了。

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

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

  update(dt){
    // ...
    
    // 每幀都減少開火延遲
    this.fireTime -= dt;
    // 判斷是否開火
    if(this.fire != false){
      // 如果沒有延遲了就傳回一個bullet對象
      if(this.fireTime <= 0){
        // 将延遲重新設定
        this.fireTime = Constants.PLAYER.FIRE;
        // 建立一個bullet對象,将自身的id傳遞過去,後面做碰撞的時候,就自己發射的子彈就不會打到自己
        return new Bullet(this.id, this.x, this.y, this.fireMouseDir);
      }
    }
  }
  
  // ...
}

module.exports = Player;

           

對應的

bullet.js

檔案也要補全一下。

// 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){
    // 使用三角函數将滑鼠位置計算出對應的x/y值
    this.x += dt * Constants.BULLET.SPEED * Math.sin(this.dir);
    this.y += dt * Constants.BULLET.SPEED * Math.cos(this.dir);
    
    // 這裡是為了讓子彈有一個旋轉功能,一秒轉一圈
    this.rotate += dt * 360;

    // 離開地圖就将isOver設定為true,在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;

           
這裡引入了一個

shortid

庫,是建立一個随機數的作用

使用

npm install shortid \--save

安裝

這個時候,我們就可以正常發射子彈,但是還不能看見子彈。

那是因為沒有寫對應的繪制代碼。

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

  // ...
}

function renderBullet(me, bullet){
  const { x, y, rotate } = bullet;
  ctx.save();
  // 偏移到子彈相對人物的位置
  ctx.translate(cnv.width / 2 + x - me.x, cnv.height / 2 + y - me.y)
  // 旋轉
  ctx.rotate(Math.PI / 180 * rotate)
  // 繪制子彈
  ctx.drawImage(
    getAsset('bullet.svg'),
    -BULLET.RADUIS,
    -BULLET.RADUIS,
    BULLET.RADUIS * 2,
    BULLET.RADUIS * 2
  )
  ctx.restore();
}

           

這個時候,我們就将發射子彈的功能完成了。

來看看效果吧。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

6.gif

碰撞檢測

既然完成了玩家的移動及發送子彈邏輯,現在就可以開發對戰最重要的碰撞檢測了。

我們直接在

game.js

中添加。

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

    // 将玩家及子彈傳入進行碰撞檢測
    this.collisions(Object.values(this.players), this.bullets);

    Object.keys(this.sockets).map(playerID => {
      const socket = this.sockets[playerID]
      const player = this.players[playerID]
      // 如果玩家的血量低于等于0就告訴他遊戲結束,并将其移除遊戲
      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];

        // 自己發射的子彈不能達到自己身上
        // distanceTo是一個使用勾股定理判斷物體與自己的距離,如果距離小于玩家與子彈的半徑就是碰撞了
        if(bullet.parentID !== player.id
          && player.distanceTo(bullet) <= Constants.PLAYER.RADUIS + Constants.BULLET.RADUIS
          ){
          // 子彈毀滅
          bullet.isOver = true;
          // 玩家扣血
          player.takeBulletDamage();
          // 這裡判斷給最後一擊使其死亡的玩家加分
          if(player.hp <= 0){
            this.players[bullet.parentID].score++;
          }
          break;
        }
      }
    }
  }

  // ...
}

module.exports = Game;

           

接下來在前端中添加遊戲結束的邏輯。

// 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(){
  // 停止渲染
  stopRendering();
  // 停止監聽
  stopCapturingInput();
  // 将開始界面顯示出來
  $('#home').classList.remove('hidden');
  alert('你GG了,重新進入遊戲吧。');
}

           

這個時候我們就可以正常的進行遊戲了。

來看看效果。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

8.gif

排行榜功能

既然我們已經完成了正常的遊戲基本操作,那麼現在需要一個排行來讓玩家有遊戲體驗(啊哈哈哈)。

我們先在前端把排行榜顯示出來。

我們先在後端添加傳回排行榜的資料。

// 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;

           

然後在前端中編寫一下排行榜的樣式。

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

  <div class="ranking hidden">
    <table>
      <thead>
        <tr>
          <th>排名</th>
          <th>姓名</th>
          <th>積分</th>
        </tr>
      </thead>
      <tbody>
      </tbody>
    </table>
  </div>
  
  // ...
</body>
</html>

           
// 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%;
}

           

再編寫一個渲染資料的函數在

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;
}

           

最後在

state.js

中使用這個函數。

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

const gameUpdates = [];

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

  updateRanking(update.leaderboard) 
}

// ...

           

現在渲染排行榜是沒有問題了,現在到

index.js

中管理一下排行榜的顯示隐藏。

// 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')
  // ...
}

           

寫到這裡,排行榜的功能就完成了。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

道具開發

當然遊戲現在這樣遊戲性還是很差的,我們來加幾個道具增加一點遊戲性吧。

先将

prop.js

完善吧。

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

class Prop extends Item{
  constructor(type){
    // 随機位置
    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;
    // 什麼類型的buff
    this.type = type;
    // 持續10秒
    this.time = 10;
  }

  // 這個道具對玩家的影響
  add(player){
    switch(this.type){
      case 'speed':
        player.speed += 500;
        break;
    }
  }
  
  // 移除這個道具時将對玩家的影響消除
  remove(player){
    switch(this.type){
      case 'speed':
        player.speed -= 500;
        break;
    }
  }

  // 每幀更新
  update(dt){
    this.time -= dt;
  }

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

module.exports = Prop;

           

然後我們在

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(){
    // ...
    // 增加一個儲存道具的數組
    this.props = [];
    
    // ...
    // 添加道具的計時
    this.createPropTime = 0;
    setInterval(this.update.bind(this), 1000 / 60);
  }

  update(){
    // ...
    
    // 這個定時為0時添加
    this.createPropTime -= dt;
    // 過濾掉已經碰撞後的道具
    this.props = this.props.filter(item => !item.isOver)
    // 道具大于10個時不添加
    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)

    // ...
  }

  // 玩家與道具的碰撞檢測
  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){
          // 碰撞後,道具消失
          prop.isOver = true;
          // 玩家添加這個道具的效果
          player.pushBuff(prop);
          break;
        }
      }
    }
  }

  // 這裡是之前的collisions,為了和碰撞道具區分
  collisionsBullet(players, bullets){
    // ...
  }

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

module.exports = Game;

           
這裡可以将碰撞檢測進行優化,将其改造成任何場景都可以使用的碰撞函數,這裡是為了友善就直接複制成兩個。

接下來在

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){
    // ...
  
    // 判斷buff是否失效
    this.buffs = this.buffs.filter(item => {
      if(item.time > 0){
        return item;
      } else {
        item.remove(this);
      }
    })
    // buff的持續時間每幀都減少
    this.buffs.map(buff => buff.update(dt));

    // ...
  }

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

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

module.exports = Player;

           

後端需要做的功能已經完成了,現在到前端中添加繪制方面的代碼。

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

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

// ...

// 繪制道具
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){
  // ...
  
  // 顯示玩家已經領取到的道具
  player.buffs.map((buff, i) => {
    ctx.drawImage(
      getAsset(`${buff.type}.svg`),
      canvasX - PLAYER.RADUIS + i * 22,
      canvasY + PLAYER.RADUIS + 16,
      20, 20
    )
  })
}

           

這個時候,加速道具就完成啦。

如果你需要添加更多道具,可以在

prop.js

中進行添加,并且在

game.js

中生成道具的時候把

speed

改為随機道具的

type

完成後的效果。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

斷開連接配接顯示

我們可以寫一個界面專門來顯示斷開連接配接的提示。

// src/client/html/index.html
// ...
<body>
  // ...
  
  <div class="disconnect hidden">
    <p>與伺服器斷開連接配接了</p>
  </div>
</body>

           
// 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;
}

           

再到

networking.js

中斷開連接配接時顯示這個界面。

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

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

// ...

           

這個時候,我們打開遊戲,然後關閉遊戲服務,遊戲就會顯示這個界面了。

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

image.png

轉自:我系小西幾呀

連結:https://juejin.cn/post/6960096410305822751

推薦閱讀

53道常見NodeJS基礎面試題(附答案)

關注下方「前端開發部落格」,回複 “加群”

加入我們一起學習,天天進步

Node 開發一個多人對戰的射擊遊戲玩法準備工作遊戲架構後端服務搭建開發前端界面開發遊戲玩法開發

如果覺得這篇文章還不錯,來個【分享、點贊、在看】三連吧,讓更多的人也看到~

繼續閱讀