点击上方 程序员成长指北,关注公众号

回复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

其实本来想使用denots来开发的,但是因为我对这两项技术都是半生不熟的阶段,所以就不拿出来献丑了。

游戏架构

后端服务需要做的是:

  • 存储生成的游戏对象,并且将其发送给前端。

  • 接收前端的玩家操作,给游戏对象进行数据处理

前端需要做的是:

  • 接收后端发送的数据并将其渲染出来。

  • 将玩家的操作发送给服务器

这也是典型的状态同步方式开发游戏。

后端服务搭建开发

因为前端是通过后端的数据驱动的,所以我们就先开发后端。

搭建起一个Express服务

首先我们需要下载express,在根目录下输入以下命令:

// 创建一个package.json文件
> npm init
// 安装并且将其置入package.json文件中的依赖中
> npm install express socket.io --save
// 安装并置入package.json的开发依赖中
> npm install cross-env nodemon --save-dev

这里我们也可以使用cnpm进行安装

然后在根目录中疯狂建文件夹以及文件。

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

这里的两个命令devstart都使用到了cross-envnodemon,这里解释一下:

  • cross-env 设置环境变量,这里可以看到这个后面还有一个NODE_ENV=development/production,判断是否是开发模式。

  • nodemon 这个的话说白了就是监听文件变化并重置Node服务。

启动服务看一下吧

执行以下命令开启开发模式。

> npm run dev

可以看到我们成功的启动服务了,监听到了3000端口。

在服务中,我们搭载了socket服务,那要怎么测试是否有效呢?

所以我们现在简单的搭建一下前端吧。

Webpack搭建前端文件

我们在开发前端的时候,用到模块化的话会开发更加丝滑一些,并且还有生产环境的打包压缩,这些都可以使用到Webpack

我们的打包有两种不同的环境,一种是生产环境,一种是开发环境,所以我们需要两个webpack的配置文件。

当然傻傻的直接写两个就有点憨憨了,我们将其中重复的内容给解构出来。

我们在根目录下创建webpack.common.jswebpack.dev.jswebpack.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文件了,接下来我们将它分配给developmentproduction中,其中production将会压缩jscss以及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"},
//...
}

接下来,我们试试devstart的效果吧。

可以看到使用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 = {}){// idthis.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;

上面这个类是所有游戏对象都要继承的类,它定义了游戏世界里每一个元素的基本属性。

接下来就是playerPropBullet的定义了。

// 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;// 拥有的buffsthis.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)

上面的代码就可以让我们进入页面就直接加入游戏了,去页面看看效果吧。

image.png

编写游戏界面

我们先将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-rebootbootstrap的重置基础样式的文件,这个可以在网络上下载,因为太长,本文就不贴出来了。

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

最后我们就可以得到下面这张图的效果了。

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)

这个时候,我们在页面中就可以看到这样的输出了。

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

上面的代码实现的效果就是下图。

我们玩家的位置在服务器中设置的是随机数字,所以每次进入游戏都是随机的位置。

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

这样就可以将玩家正确的绘制出来了。

image.png

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: 0break;}}}
}

然后在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;

通过上面的代码,我们就实现了玩家移动的逻辑了,下面我们看看效果。

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位置和自身位置比较时,不能小于0this.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: 0break;// 更新鼠标位置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();
}

这个时候,我们就将发射子弹的功能完成了。

来看看效果吧。

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了,重新进入游戏吧。');
}

这个时候我们就可以正常的进行游戏了。

来看看效果。

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

写到这里,排行榜的功能就完成了。

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;// 什么类型的buffthis.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

完成后的效果。

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

这个时候,我们打开游戏,然后关闭游戏服务,游戏就会显示这个界面了。

image.png

结束

写到这里,本文就结束啦。

感谢各位的观看,如果觉得写的不错,可以点个赞支持一下(嘿嘿)。

最后

如果觉得这篇文章还不错

点击下面卡片关注我

来个【分享、点赞、在看】三连支持一下吧

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

Node 开发一个多人对战的射击游戏(实战长文)相关推荐

  1. 使用 Node 开发一个多人对战的射击游戏

    点击上方 前端瓶子君,关注公众号 回复算法,加入前端编程面试算法每日一题群 来源:我系小西几呀 https://juejin.cn/post/6960096410305822751 相信大家都是知道游 ...

  2. java实现两人对战的五子棋游戏

    提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档 @java实现五子棋游戏 一.要求 编程实现控制台版并支持两人对战的五子棋游戏. (1)绘制棋盘 - 写一个成员方法实现 (2)提示黑 ...

  3. Vue 开发一个简略版的飞机大战小游戏

    文章目录 使用 Vue 开发一个简略版的飞机大战小游戏 一.实现思路 二.所需知识点 三.实现步骤 使用 Vue 开发一个简略版的飞机大战小游戏 如题,假设你为了向更多访问你博客的人展示你的技术,你决 ...

  4. python循环10次_开发一个循环 5 次计算的小游戏, 设置随机种子为10,每次随机产生两个 1~10的数字以及随机选择...

    开发一个循环 5 次计算的小游戏, 设置随机种子为10,每次随机产生两个 1~10的数字以及随机选择 "+.-.*"运算符,构成一个表达式, 让用户计算式子结果并输入结果,如果计算 ...

  5. 开发一个循环 5 次计算的小游戏, 设置随机种子为10,每次随机产生两个 1~10的数字以及随机选择

    开发一个循环 5 次计算的小游戏, 设置随机种子为10,每次随机产生两个 1~10的数字以及随机选择 "+.-.*"运算符,构成一个表达式, 让用户计算式子结果并输入结果,如果计算 ...

  6. 基于 Vue 开发一个 多人聊天室(万字长文) - 从 0 到 1 篇

    前言 在上个月初,接到一个需求,要开发一个 聊天通讯 模块 并且 集成到 项目中的多个 入口,实现业务数据的记录追踪. 接到需求后,还挺开心,这是我第一次 搞 通讯 类的需求,之前一直是 B 端 的业 ...

  7. 使用C++完成一个小型双人对战回合制游戏

    #include<iostream> using namespace std; class hero {public:hero();//基础属性hero(int w = 80, int f ...

  8. 一战Linux射击游戏凡尔登(Verdun),ChuChu Rocket克隆版Duck Marines等

    开源游戏综述 2014年7月27日至8月2日,一周 又是时候了. 在本周的开源游戏新闻摘要中,我们介绍了Linux上的WWI射击游戏,该游戏的突出之处很明显,很幸运地从功能列表中消失了,并对我最喜欢的 ...

  9. 如何使用cocos2dx 制作一个多向滚屏坦克类射击游戏-第二部分

    原文链接:http://www.raywenderlich.com/6888/how-to-make-a-multi-directional-scrolling-shooter-part-2 这里使用 ...

最新文章

  1. 「最新」《美国人工智能未来20年研究路线图》
  2. 描述java源程序构成_2.1 Java程序的构成
  3. 限时9.9元 | 快速领取数学建模竞赛备战必备技巧与论文详解!
  4. 客户细分模型_Avarto金融解决方案的客户细分和监督学习模型
  5. Chapter 4 Invitations——25
  6. MongoDB解决“Error parsing YAML config file: yaml-cpp: error at line 2, column value(安装服务)
  7. springboot初始化逻辑_详解Spring Boot中初始化资源的几种方式
  8. 卸载注册表_3Dmax软件无法安装?3Dmax软件正确卸载方法,重装无忧
  9. 王川: 重要的东西, 往往是看不见的
  10. 如何批量转换xls文件为xlsx?
  11. 读书笔记(8)网络故障排除工具
  12. HDU - 1253 胜利大逃亡 BFS
  13. mysql 枚举字段,MySQL字段中的枚举是什么意思 | 学步园
  14. 数学建模(1)-matlab之fprintf函数用法
  15. C++有关类的基本函数总结
  16. ROS_Kinetic_29 kamtoa simulation学习与示例分析(一)
  17. 姊妹篇:我是一块声卡
  18. 韩国社交软件Kakao Talk要开网络银行,社交软件+银行的模式会怎么转?
  19. 解决ubuntu安装搜狗输入法之后,输入栏一直固定在左下角问题
  20. 解密LED显示屏的价格标准,全彩显示屏每平方米的价格范围

热门文章

  1. 计算机怎黑夜模式么启动,Win10系统电脑夜间模式怎么开启/关闭的方法
  2. 精心收集的95个超实用的JavaScript代码片段(ES6 +编写)
  3. JAVA范例 - Applet小应用程序
  4. Linux 下 TC 命令原理及详解<一>
  5. 第02课:主流分布式缓存方案的解读及比较
  6. Sencha Touch(Extjs)
  7. 学习笔记(4):【数据分析实战训练营】 数据分析基础及方法论-row-column-len-lenb函数...
  8. cc美团_项目注册界面实现
  9. 抖音电商直播间SOP主播工作计划脚本话术模板方案
  10. 流行编曲(5)采样、小打、Pad、声场