系列文章
《球球大作戰》源碼解析——(1)運行起來
《球球大作戰》源碼解析:服務器與客戶端架構
《球球大作戰》源碼解析:移動算法
《球球大作戰》源碼解析(6):碰撞處理
《球球大作戰》源碼解析(7):遊戲循環
《球球大作戰》源碼解析(8):消息廣播
鑑於agar.io類型遊戲的火爆場面,一些公司紛紛效仿,一時間出現各種《XX大作戰》類型的遊戲。出於學習的目的,亦是做些技術和方案儲備,接下來會有大概10篇文章,分析下面這款使用nodejs編寫的開源“球球大作戰”。由於該遊戲採用服務端運算、客戶端顯示的方式,服務端的邏輯處理是該源碼的重點,故而系列文章主要針對服務端。通過這套源碼,可以學習到“一種基於nodejs的簡單服務器實現方法”“一種簡單的服務端物理邏輯的實現方式”“一種基於redis pub/sub的跨服設計思想”“nodejs語法、框架及其使用方式”等內容。
系列文章將會分析huytd/agar.io-clone的源碼,這是一套簡約而不簡單的Agar.IO實現。該項目使用NodeJS開發,使用socket.IO作爲網絡通信,使用HTML5實現客戶端。
一、運行起來
下圖爲遊戲運行畫面,遊戲規則如下。
1、玩家可以移動鼠標控制小球
2、當小球吞食場景中的食物或其他玩家控制的小球時,玩家控制的小球會變大
3、小球越大,移動速度越慢
4、小球的質量代表它的大小,質量爲它吞食的食物或其他玩家的質量之和
5、遊戲目標是儘可能的吞食其他玩家,使小球變大
6、玩家剛出生時會有無敵屬性,直到它吞食食物
7、每當有玩家進入遊戲,場景中會生成3個食物
8、每當吞食食物時,場景中亦會生成一個新的食物
第一步便是要讓遊戲運行起來,只有運行起來了,才談得上後續的源碼分析。爲了“從零開始”,筆者購買Ubuntu系統的騰訊雲,新的系統幾乎沒有安裝額外軟件,一步一步安裝所需的軟件,然後將遊戲運行起來吧。筆者選用了最低一檔配置的服務器,花費近50大洋(此處是不是應該發個求贊助的鏈接?)配置如下圖所示。
1、安裝nodeJs
遊戲使用nodejs開發,那就必須要安裝nodejs,可以有兩種方法安裝。
方法1:輸入sudo apt install nodejs,這是最簡單的安裝方法了。不過使用該方式安裝的程序名叫爲nodejs,而不是普遍使用的node。可以使用sudo ln-s/usr/bin/nodejs/usr/bin/node建立名爲node的連接,以解決這個問題。
方法2:下載源碼、編譯、安裝。具體可以參考這篇文章在Ubuntu下安裝Node.JS的不同方式-技術◆學習|Linux.中國-開源社區(文章裏使用的node-v6.9.5要改爲最新版的)
完成後,可以使用node-v查看nodejs版本號,以驗證是否成功安裝。
2、上傳代碼文件
從github上下載源碼,然後上傳到linux服務器上。如下圖所示,筆者將源碼上傳到/home/ubuntu/agar.io-clone-master目錄下
3、安裝npm
npm(node package manager)是nodejs的包管理和分發工具,一般安裝nodejs後都需要安裝該軟件,可以使用以下命令安裝:sudo apt install npm
4、安裝gulp
項目使用到了gulp,需要安裝它。gulp是一個前端構建工具,開發者可以使用它在項目開發過程中自動執行常見任務,比如複製文件,比如替換文件中某些字符。進入源碼目錄,執行sudo npm install-g gulp即可安裝。
5、安裝項目所需的包文件
進入源碼目錄,執行npm install即可安裝項目所需包文件。npm install會檢查當前目錄下的package.json文件,文件包含了項目所需的模塊,npm根據該文件的描述下載這些文件並把模塊放到./node_modules目錄下。關於package.json的格式可以參考這篇文章package.json for NPM文件詳解
6、運行服務器
在源碼目錄下執行gulp run,可以看到服務器啓動的提示信息。
7、運行客戶端
運行瀏覽器,輸入地址即可,筆者的騰訊雲ip爲139.199.179.39,由於默認配置了3000端口,所以要輸入http://139.199.179.39:3000/,即可看到如下的遊戲界面。
在筆者的試驗中,該頁面報錯,點擊按鈕沒有反應。原因是src/client中的index.html最後面有這麼一句,<script src="//code.jquery.com/jquery-2.2.0.min.js"></script>,該語句用於加載jquery的,而http://code.jquery.com/jquery-2.2.0.min.js無法訪問(或國內網絡訪問速度慢),導致報錯。只要換個文件地址即可,例如改成下面這樣:
<script src="http://libs.baidu.com/jquery/1.9.0/jquery.js"></script >
運行遊戲,服務端也會打印出相應的信息,如下圖所示。
把遊戲運行起來後,下一步就要分析下游戲的流程了。
二、程序流程
在解析源碼之前,需要先了解該項目的程序流程,瞭解客戶端和服務端是如何運行和通信的。本文是wiki文檔Game Architecture的翻譯,以幫助讀者從大方向上了解《球球大作戰》。
程序架構
遊戲程序使用NodeJs編寫,服務端通過http://Socket.IO創建WebSocket服務並默認監聽3000號端口。程序還使用ExpressJS建立一個簡單的HTTP服務器,它負責html頁面的顯示。index.html是遊戲主頁面,它通過Canvas渲染遊戲,通過Javascript腳本和服務端通信。
目錄結構該項目由3部分組成:
1、配置文件,如package.json,config.json等等
2、客戶端程序
3、服務端程序
配置文件package.json列出了項目所需的庫文件,讀者只需在項目目錄下執行“npm install”即可自動安裝這些文件。package.json的格式可以參考下面的文章:
npm package.json屬性詳解
遊戲客戶端
client文件夾裏包含了客戶端所需的代碼,它是一個簡單的HTML文件,該文件會通過canvas繪製遊戲場景、聊天框等元素。
js/app.js是客戶端的邏輯代碼,它實現了畫面渲染、網絡延遲檢測、觀戰模式、聊天等功能,處理了鼠標輸入、服務端通信等事項。遊戲採用服務端運算模式,客戶端只是負責將服務端發來的數據顯示到屏幕上,以及接收鼠標事件。
客戶端程序使用了requestAnimationFrame程序渲染循環,而不是使用setInterval,這讓遊戲有着更好的渲染性能。你可以試着修改代碼,調用setInterval方法,看看低效率的渲染是個啥樣子。
(function animloop(){
requestAnimFrame(animloop);
gameLoop();
})();
to
setInterval(gameLoop, 16);
遊戲服務端
server/server.js包含了服務端的配置和邏輯處理,配置了諸如食物質量、移動速度、無敵狀態的最大質量,處理了食物顏色計算、碰撞檢測、玩家移動處理等等事項。
所有的遊戲邏輯都在服務端處理,服務端和客戶端的通信有着下面幾個要點。
1、服務端使用list保存玩家列表,而不是使用array,使用list保存食物列表,而不是使用array。服務端保存着socket列表,用於記錄所有客戶端連接。
2、之前的版本設置了一個定時器,每隔幾秒鐘就產生一些食物,但這種方法的效率不高,會延遲服務端處理速度。所有在此版本中使用了一種新的方式來產生食物,當一個玩家進入遊戲時,程序會隨機產生3個食物(可以修改配置文件的newFoodPerPlayervariable改變該數值),當玩家喫掉一個食物時,程序會產生另外一個食物(可以修改配置文件的respawnFoodPerPlayer改變該數值)。如果場景中的食物數量大於50(配置文件的maxFoodCount),服務端會停止產生新食物。
客戶端服務端通信
客戶端與服務端通信可以分爲兩個部分,分別是登錄認證和遊戲內通信。
登陸認證
當一個玩家打開遊戲網頁,他先會看到一個輸入用戶名的對話框,點擊“Play”按鈕後,客戶端發起socket連接,服務端accept連接後發出welcome協議,並把該客戶端的UserID附帶在協議中。
當客戶端收到welcome協議,它會返回附帶用戶名的gotit協議。
當服務端收到gotit協議,它會其它的已連接玩家廣播playerJoin協議,告訴他們有新的玩家加入。其它玩家收到該協議後,會在屏幕上繪製這個新加入的角色。
此時,對於新加入的玩家來說,遊戲剛剛開始。
遊戲內通信
遊戲內通信分爲3個部分,分別是遊戲邏輯、聊天和Ping(測試網絡延遲)。
遊戲邏輯
玩家在遊戲中會有移動、吞食食物、吞食其他玩家三種行爲,這些邏輯全部由服務端運算,客戶端只是根據運算結果將圖像顯示在對應的位置上。
移動
當玩家移動鼠標,小球會朝着鼠標的位置移動。客戶端會發送附帶了目的地座標的playerSendTarget協議。服務端收到協議後會更新小球的運動狀態,然後向該客戶端回覆serverTellPlayerMove協議,然後發送serverUpdateAllPlayers給其他客戶端,讓全部客戶端更新所有玩家的座標。
小球移動期間,服務端還會檢測小球是否吞食了食物,或者吞食了其他玩家。
吞食食物
服務端維持了users列表和food列表來保存所有的小球和食物的信息,如果小球碰到食物,服務端會執行相應的邏輯,增加小球質量、刪除列表裏的食物、產生新的食物。然後服務端廣播serverUpdateAllPlayers和serverUpdateAllFoods協議,讓客戶的更新玩家和食物。
吞食其他玩家
如果小球吞食了其他玩家的小球,服務端會比較兩者的質量和距離,質量小的被吞食。服務端會發送RIP協議告訴質量下的玩家他死掉了,然後斷開與該玩家的連接,同時在users列表裏刪除他。還會廣播serverUpdateAllPlayers協議通知客戶端。
聊天
聊天的流程如下圖所示
當玩家在聊天框中輸入信息並按下回車鍵時,客戶端向服務端發送playerChat協議,服務端收到協議後廣播serverSendPlayerChat協議。
當客戶端收到serverSendPlayerChat協議時,它會解析該協議,將聊天內容顯示到屏幕上。
Ping(延遲檢測)
網絡遊戲都會實現ping機制來檢測客戶端和服務端之間的延遲,而它的實現也很簡單。
檢測開始時,客戶端會保存當前的開始時間,然後發送ping協議給服務端,服務端收到後,會返回pong協議。客戶端收到pong協議會計算時間差,如果時間差很大,說明網絡延遲很嚴重。
願這份文檔能夠協助讀者理解agar.io-clone這個項目,你還可以繼續完善這款遊戲,將它做得更好。也希望各位能夠在項目wiki中分享心得。
三、gulp工具
運行遊戲使用的命令是gulp run,agar.io-clones使用了nodejs開發,gulp是基於nodejs的一個工具,它能夠批量的做一些文件操作。gulp run意思是執行目錄下gulpfile.js下的run任務,那麼源碼中使用了gulp的哪些功能呢?這篇文章將會做個簡單介紹。
gulp能自動化地完成javascript/coffee/sass/less/html/image/css等文件的的測試、檢查、合併、壓縮、格式化、瀏覽器自動刷新、部署文件生成,並檢測文件變化。在實現上,gulp鑑了Unix操作系統的管道(pipe)思想,前一級的輸出,直接變成後一級的輸入。
關於gulp入門,可以參考下面的文章:
一點|gulp詳細入門教程
入門指南-gulp.js中文文檔
一個最簡單的示例
要使用gulp根據,當然得先安裝它,有兩種方式安裝,對應於不同的命令參數。
全局安裝gulp:npm install--global gulp
作爲項目的開發依賴(devDependencies)安裝:npm install--save-dev gulp
現在新建一個目錄並創建一個名爲gulpfile.js的文件,在裏面編寫如下代碼
var gulp = require('gulp');gulp.task('default', function() { // 將你的默認的任務代碼放在這});
在目錄下執行gulp,此時程序會搜尋目錄下gulpfile.js文件中的默認(default)任務,也就是上面代碼中“//將你的默認的任務代碼放在這”處的代碼去執行。“gulp run”即表示執行名爲run的任務,相關代碼可以在項目文件夾下的gulpfile.js中看到。相關代碼如下
gulp.task('run', ['build'], function () {
nodemon({
delay: 10,
script: './server/server.js',
cwd: "./bin/",
args: ["config.json"],
ext: 'html js css'
})
.on('restart', function () {
util.log('server restarted!');
});
});
代碼解析
要看懂上面的代碼,必須要瞭解gulp的一些API,知道“nodemon”等單詞到底是什麼意思,實現什麼功能,gulp的api可以參考下面的文章:
一點|gulp教程之gulp中文API
依賴
上面代碼中的“gulp.task('run',['build'],function(){}”意爲run依賴於build,當執行gulp run時,程序會先執行build任務,再執行run任務。
nodemon
先看看nodemon,詳細的解釋可以參考gulp-nodemon。
nodemon是一個工具,用於項目代碼發生變化時可以自動重啓,nodemon本意時檢測項目變化的,對項目做監控的。重啓只是它的一個功能。在上面的代碼中,相當於執行./server/server.js這個文件。而這個文件其實是build任務中生成的。
build任務
接下來看看build任務是什麼樣子的,會發現build任務依賴於build-client、build-server、test、todo這4個任務,也就是說,需要按順序先執行這4個任務,纔會執行build。此時我們會發現,代碼的執行流程是build-client、build-server、test、todo、run
gulp.task('build',['build-client','build-server','test','todo']);
build-client任務
build-client處理了客戶端代碼的創建,它用到了uglify、webpack和babel。
其中uglify表示壓縮javascript文件,減小文件大小(參見一點|gulp教程之gulp-uglify)
webpack表示模塊打包,它能幫我們把本來需要在服務端運行的JS代碼,通過模塊的引用和依賴打包成前端可用的靜態文件(參考《nodejs+gulp+webpack基礎實戰篇》課程筆記(三)--webpack篇-亡命小卒-博客園)
babel是一個JavaScript轉換編譯器,它可以將ES6(下一代JavaScript規範,添加了一些新的特性和語法)轉換成ES5(可以在瀏覽器中運行的代碼)。這就意味你可以在一些暫時還不支持某些ES6特性的瀏覽器引擎中,使用ES6的這些特性。比如說,class和箭頭方法。
pipe表示管道,下面的代碼是指將源文件(.src)“src/client/js/app.js”通過uglify方法壓縮,然後將壓縮後的結果通過webpack打包,然後通過babel做兼容性,最後通過將文件存入dest指定的目錄下“bin/client/js/”
gulp.task('build-client', ['lint', 'move-client'], function () { return gulp.src(['src/client/js/app.js']) .pipe(uglify()) .pipe(webpack(require('./webpack.config.js'))) .pipe(babel({ presets: [ ['es2015', { 'modules': false }] ] })) .pipe(gulp.dest('bin/client/js/'));});
webpack()方法的參數是“require('./webpack.config.js') ”“ ./webpack.config.js”,該文件的內容如下,它是打包的配置文件。
module.exports = {
entry: "./src/client/js/app.js",
output: {
path: require("path").resolve("./src/bin/client/js"),
library: "app",
filename: "app.js"
},
module: {
loaders: [
{
test: /\.jsx?$/,
exclude: /(node_modules|bower_components)/,
loader: 'babel'
}
]
}
};
“build-client”依賴於“lint”和“move-client”,先要完成這兩個任務,程序纔會執行“build-client”任務。
lint任務
“lint”任務如下所示,它使用了jshint方法。jshint是用來檢測javascript的語法錯誤的。如果有錯誤,就報告fail。
gulp.task('lint', function () {
return gulp.src(['**/*.js', '!node_modules/**/*.js', '!bin/**/*.js'])
.pipe(jshint({
esnext: true
}))
.pipe(jshint.reporter('default', { verbose: true}))
.pipe(jshint.reporter('fail'));
});
move-client任務
“build-client”還依賴於“move-client”代碼如下,它只是移動一些文件
gulp.task('move-client', function () {
return gulp.src(['src/client/**/*.*', '!client/js/*.js'])
.pipe(gulp.dest('./bin/client/'));
});
build-server任務
build-server任務比較簡單,它也是複製下文件
gulp.task('build-server', ['lint'], function () {
return gulp.src(['src/server/**/*.*', 'src/server/**/*.js'])
.pipe(babel())
.pipe(gulp.dest('bin/server/'));
});
test任務
build任務依賴於build-client、build-server、test和todo任務,在建了客戶端和服務端文件後,自然需要對它測試一下,test任務調用了mocha方法,它是一個測試方法。
gulp.task('test', ['lint'], function () {
gulp.src(['test/**/*.js'])
.pipe(mocha());
});
todo任務
todo任務調用了todo方法,該方法會收集符合“src/**/*js”匹配符的文件信息,生成一個名爲TODO.md的文件。
gulp.task('todo', ['lint'], function() { gulp.src('src/**/*.js') .pipe(todo()) .pipe(gulp.dest('./'));});
生成的TODO.md如下圖所示。
由於實際運行的文件在是bin/目錄下,如果修改了源文件,需要重新執行gulp run才能生效。
四、Websocket
運行服務端後,玩家只要打開瀏覽器,輸入地址和端口,就可以看到遊戲畫面。這就意味着,遊戲服務端開了個http服務器。Node.js標準庫提供了http模塊,其中封裝了一個高效的HTTP服務器和一個簡易的HTTP客戶端。http.Server是一個基於事件的HTTP服務器,它的核心由Node.js下層C++部分實現,而接口由JavaScript封裝,兼顧了高性能與簡易性。http.request則是一個HTTP客戶端工具,用於向HTTP服務器發起請求。關於http服務端的入門,可以參考下面教程。
Node.js學習(11)----HTTP服務器與客戶端-推酷
安裝http包
使用http模塊,必須先安裝它,執行npm install http命令安裝即可。
顯示Html文本
新建一個js文件,然後輸入如下的代碼。通過require('http').Server創建一個http服務器,“http.listen”表示開啓監聽,如下代碼是監聽3001端口,監聽成功後會在屏幕中打印出“[DEBUG]Listening”。http.on('request'function(){……})表示當服務端收到客戶端的請求時做出怎樣的處理,這裏向客戶端返回html信息。
var http = require('http').Server()
http.on('request',function(req,res){
console.log('[DEBUG] on request ' );
res.writeHead(200,{'Content-Type':'text/html'});
res.write('<h1>Node.js</h1>');
res.end('<p>HelloWorld</p>');
});
http.listen(3001, function() {
console.log('[DEBUG] Listening ' );
});
運行腳本,然後用瀏覽器打開3001端口,即可看到html文本。
用express顯示Html文件
Express是一個基於Node.js平臺的web應用開發框架,可以使用它指定要顯示的網頁文件。在使用之前需要使用npm install express命令安裝express。
新建js文件填入下面的代碼,除了創建http服務器外,還使用express指定了網頁目錄“__dirname+'/'”,即代碼文件的同一目錄下。
var express = require('express');
var app = express();
var http = require('http').Server(app)
var io = require('socket.io')(http);
app.use(express.static(__dirname + '/'));
console.log("hehe");
http.listen(3001, function() {
console.log('[DEBUG] Listening ' );
});
在同一目錄下新建index.html,輸入下面的文本。
<html>
<head>
<title>Ssocket</title>
</head>
<body>
<P>測試</P>
</body>
</html>
運行服務端,用瀏覽器打開頁面,將會看到如下網頁。
WebSocket介紹
談到Web實時推送,就不得不說WebSocket。在WebSocket出現之前,很多網站爲了實現實時推送技術,通常採用的方案是輪詢(Polling)和Comet技術,Comet又可細分爲兩種實現方式,一種是長輪詢機制,一種稱爲流技術,這兩種方式實際上是對輪詢技術的改進,這些方案帶來很明顯的缺點,需要由瀏覽器對服務器發出HTTP request,大量消耗服務器帶寬和資源。面對這種狀況,HTML5定義了WebSocket協議,能更好的節省服務器資源和帶寬並實現真正意義上的實時推送。
WebSocket協議本質上是一個基於TCP的協議,它由通信協議和編程API組成,WebSocket能夠在瀏覽器和服務器之間建立雙向連接,以基於事件的方式,賦予瀏覽器實時通信能力。既然是雙向通信,就意味着服務器端和客戶端可以同時發送並響應請求,而不再像HTTP的請求和響應。
具體可以參考下面的文章
使用Node.js+Socket.IO搭建WebSocket實時應用-OPEN開發經驗庫
WebSocket簡單實例
下面通過一個簡單的例子介紹WebSocket的使用方法,在安裝WebSocket後編寫如下的代碼和html文件。當客戶端發起連接(connection)後,它會打印出“A user connected!”
var express = require('express');
var app = express();
var http = require('http').Server(app)
var io = require('socket.io')(http);
app.use(express.static(__dirname + '/'));
console.log("hehe");
io.on('connection', function (socket) {
console.log('A user connected!', socket.handshake.query.type);
})
http.listen(3001, function() {
console.log('[DEBUG] Listening ' );
});
html代碼如下所示,頁面中會有一個按鈕,當點擊按鈕時,會通過io.connect連接服務端
<html>
<head>
<title>Ssocket</title>
http://139.199.179.39:3001/socket.io/socket.io.js</a>">
</head>
<body>
<P>測試</P>
<input type="button" id="btn" value="click" />
<script type="text/javascript">
var oBtn = document.getElementById('btn');
oBtn.onclick = function(){
var socket = io.connect('http://139.199.179.39:3001/');
alert("send");
};
</script>
</body>
</html>
運行程序,點擊客戶端上的按鈕,服務端會顯示“A user connected!”
收發信息
客戶端和服務端可要相互通信,在下面的例子中,網頁上有connect和send兩個按鈕,點擊send按鈕後,會發送login協議,服務端收到login協議後,會打印客戶端傳來的信息。
var express = require('express');
var app = express();
var http = require('http').Server(app)
var io = require('socket.io')(http);
app.use(express.static(__dirname + '/'));
console.log("hehe");
io.on('connection', function (socket) {
console.log('A user connected!', socket.handshake.query.type);
socket.on('login', function (data) {
console.log(data);
});
})
http.listen(3001, function() {
console.log('[DEBUG] Listening ' );
});
<html>
<head>
<title>Socket</title>
http://139.199.179.39:3001/socket.io/socket.io.js</a>">
</head>
<body>
<P>測試</P>
<input type="button" id="btn1" value="connect" />
<input type="button" id="btn2" value="send" />
<script type="text/javascript">
var oBtn1 = document.getElementById('btn1');
oBtn1.onclick = function(){
socket = io.connect('http://139.199.179.39:3001/');
alert("connect");
};
var oBtn2 = document.getElementById('btn2');
oBtn2.onclick = function(){
socket.emit('login', { name: 'LPY' });
alert("send");
};
</script>
</body>
</html>
運行程序,點擊按鈕,服務端將會顯示客戶端login協議傳入的用戶名“LPY”,如下圖所示。
客戶端
服務端
客戶端回顯
在下面的代碼中,服務端收到客戶端的login協議後會恢復客戶端loginBack協議,客戶端收到loginBack協議後會彈出對話框。
var express = require('express');
var app = express();
var http = require('http').Server(app)
var io = require('socket.io')(http);
app.use(express.static(__dirname + '/'));
console.log("hehe");
io.on('connection', function (socket) {
console.log('A user connected!', socket.handshake.query.type);
socket.on('login', function (data) {
console.log(data);
socket.emit('loginBack', { result: 'success' });
});
})
http.listen(3001, function() {
console.log('[DEBUG] Listening ' );
});
<html>
<head>
<title>Socket</title>
http://139.199.179.39:3001/socket.io/socket.io.js</a>">
</head>
<body>
<P>測試</P>
<input type="button" id="btn1" value="connect" />
<input type="button" id="btn2" value="send" />
<script type="text/javascript">
var oBtn1 = document.getElementById('btn1');
oBtn1.onclick = function(){
socket = io.connect('http://139.199.179.39:3001/');
alert("connect");
socket.on('loginBack', function (data) {
alert(data.result);
});
};
var oBtn2 = document.getElementById('btn2');
oBtn2.onclick = function(){
socket.emit('login', { name: 'LPY' });
alert("send");
};
</script>
</body></html>
運行程序,結果如下圖所示。