Node 开发一个多人对战的射击游戏(实战长文)
点击上方 程序员成长指北,关注公众号
回复1,加入高级 Node 进阶交流群
来源:我系小西几呀
https://juejin.cn/post/6960096410305822751
相信大家都是知道游戏的吧。
这玩意还是很有意思的,无论是超级玛丽,还是魂斗罗,亦或者是王者荣耀以及阴阳师。
当然,这篇文章不涉及到那么牛逼的游戏,这里就简单的做一个小游戏吧。
先给它取个名字,就叫“球球作战”吧。
咳咳,简单易懂嘛
玩法
任何人进入游戏输入名字然后就可以连接进入游戏,控制一个小球。
你可以操作这个小球进行一些动作,比如:移动,发射子弹。
通过杀死其他玩家来获取积分,并在排行榜上进行排名。
其实这类游戏有一个统一的名称,叫做IO类游戏,在这个网站中有大量的这类游戏: iogames.space/
这个游戏的github地址:github.com/lionet1224/…
在线体验: http://120.77.44.111:3000/
演示GIF:

准备工作
首先制作这个游戏,我们需要的技术为:
前端 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进行安装
然后在根目录中疯狂建文件夹以及文件。

我们就可以得出以上的文件啦。
解释一下分别是什么东西:
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

可以看到我们成功的启动服务了,监听到了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的效果吧。

可以看到使用npm run dev命令后不仅启动了服务还打包了前端文件。
再试试npm run start。

也可以看到先打包好了文件再启动了服务。
我们来看看打包后的文件。

测试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,这样入口文件就可以知道什么时候连接成功了。
我们现在就去前端页面中看一下吧。


可以很清楚的看到,前后端都有连接成功的相关提示。
创建游戏对象
我们现在来定义一下游戏中的游戏对象吧。
首先游戏中将会有四种不同的游戏对象:
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)
上面的代码就可以让我们进入页面就直接加入游戏了,去页面看看效果吧。


编写游戏界面
我们先将html代码编辑一下。
// src/client/html/index.html
<!DOCTYPE html>
<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>球球作战</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;
}
最后我们就可以得到下面这张图的效果了。

编写游戏开始的逻辑
我们先创建一个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)
这个时候,我们在页面中就可以看到这样的输出了。

图片可以去
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)
}
上面的代码实现的效果就是下图。
我们玩家的位置在服务器中设置的是随机数字,所以每次进入游戏都是随机的位置。

绘制玩家
接下来就是绘制玩家了,依旧是在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)
}
这样就可以将玩家正确的绘制出来了。


上面两张图,是我打开两个页面进入游戏的两名玩家,可以看出它们分别以自己为中心,其他的玩家相对于它进行了绘制。
游戏玩法开发
添加移动交互
既然玩家我们绘制出来了,那么就可以让它开始移动起来了。
我们创建一个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;
通过上面的代码,我们就实现了玩家移动的逻辑了,下面我们看看效果。

可以看出,我们可以飞出地图之外,我们在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();
}
这个时候,我们就将发射子弹的功能完成了。
来看看效果吧。

碰撞检测
既然完成了玩家的移动及发送子弹逻辑,现在就可以开发对战最重要的碰撞检测了。
我们直接在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了,重新进入游戏吧。');
}
这个时候我们就可以正常的进行游戏了。
来看看效果。

排行榜功能
既然我们已经完成了正常的游戏基本操作,那么现在需要一个排行来让玩家有游戏体验(啊哈哈哈)。
我们先在前端把排行榜显示出来。
我们先在后端添加返回排行榜的数据。
// 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')
  // ...
}
写到这里,排行榜的功能就完成了。

道具开发
当然游戏现在这样游戏性还是很差的,我们来加几个道具增加一点游戏性吧。
先将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。
完成后的效果。

断开连接显示
我们可以写一个界面专门来显示断开连接的提示。
// 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.')
    })
  })
}
// ...
这个时候,我们打开游戏,然后关闭游戏服务,游戏就会显示这个界面了。

结束
写到这里,本文就结束啦。
感谢各位的观看,如果觉得写的不错,可以点个赞支持一下(嘿嘿)。
最后


   “分享、点赞、在看” 支持一波 
 
