大家好,我是漫步,今天分享一個Nodejs實戰長文,希望對你有所幫助。
……
相信大家都是知道遊戲的吧。
這玩意還是很有意思的,無論是超級瑪麗,還是魂鬥羅,亦或者是王者榮耀以及陰陽師。
當然,這篇文章不涉及到那麼牛逼的遊戲,這裡就簡單的做一個小遊戲吧。
先給它取個名字,就叫“球球作戰”吧。
咳咳,簡單易懂嘛
玩法
任何人進入遊戲輸入名字然後就可以連接配接進入遊戲,控制一個小球。
你可以操作這個小球進行一些動作,比如:移動,發射子彈。
通過殺死其他玩家來擷取積分,并在排行榜上進行排名。
其實這類遊戲有一個統一的名稱,叫做IO類遊戲,在這個網站中有大量的這類遊戲:https://iogames.space/
這個遊戲的github位址:https://github.com/lionet1224/node-game
線上體驗: 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進行安裝
然後在根目錄中瘋狂建檔案夾以及檔案。
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
-
這個的話說白了就是監聽檔案變化并重置Node服務。nodemon
啟動服務看一下吧
執行以下指令開啟開發模式。
> 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)
上面的代碼就可以讓我們進入頁面就直接加入遊戲了,去頁面看看效果吧。
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;
}
最後我們就可以得到下面這張圖的效果了。
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
: 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;
通過上面的代碼,我們就實作了玩家移動的邏輯了,下面我們看看效果。
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();
}
這個時候,我們就将發射子彈的功能完成了。
來看看效果吧。
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;
// 什麼類型的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
。
完成後的效果。
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
轉自:我系小西幾呀
連結:https://juejin.cn/post/6960096410305822751
推薦閱讀
53道常見NodeJS基礎面試題(附答案)
關注下方「前端開發部落格」,回複 “加群”
加入我們一起學習,天天進步
如果覺得這篇文章還不錯,來個【分享、點贊、在看】三連吧,讓更多的人也看到~