NodeJS 開發多人實時對戰遊戲服務器 (一)

從一個遊戲情懷說起

接觸的第一款多人對戰遊戲是帝國時代,依稀記得那時候上學每週最期待的就是衝到電腦課擼一把羅馬復興,高中開始接觸《魔獸爭霸3》,一款真正讓我迷戀十多年的遊戲,懷念那時候的《魔獸爭霸十大經典戰役》還有到圖書館翻 《大衆軟件》找各種電子遊戲相關的新聞的日子,之後和很多人的經歷一樣,有了 Dota 有了王者榮耀,打一款MOBA遊戲幾乎成家常便飯,最近也沒忍住擼到王者六十多星 ╮(╯_╰)╭。

帝國時代

魔獸爭霸3

陰差陽錯成爲了一名碼農,但不幸的是從來沒有機會真正去涉足遊戲開發者行業。去年魔獸3重製版出來,沒忍住交出了一筆情懷稅,算是彌補這麼多年對暴雪的虧欠,然後轉念一想,碼農快十載了難道還任由自己繼續墮落下去嗎?對戰類遊戲最大的樂趣就是 “與人鬥主宰一切的感覺”,“Triple Kill” “Monster Kill” 繚繞於耳,然而再想想那個真正在虛擬世界主宰一切的其實是制定遊戲規則的人,也就是遊戲創作者,那種當作者的感覺不是2.5D視角的而是真正的上帝視角,所以去年年中開始決定轉行求變,從零起步瞭解下游戲設計,先從技術入手,囉嗦很多,當然不是爲了給自己沉迷網絡遊戲找藉口啦。

一個簡單的聊天室

若要問一個能集合多人互動又需要實時同步的簡單場景是什麼?答案就是聊天室,這也是很多遊戲框架的入門demo,不例外,我也是從聊天室開始學習的,很快,寫這篇文章的現在我大概花了那麼丁點時間快速擼了一個,順帶憑着這麼多年積累的前端美感對界面稍微加了點樣式,代碼地址

聊天室

如果你是一個前端從業者,相信你很快會想到使用 socket.io, 如果你不是,相信你也聽過 Websocket。是的,因爲簡單,我們不用花時間去理解 TCP 的三次握手,拿來即用。爲什麼聊天室需要Websocket,答案是長連接,在聊天室裏,一個房間的任何消息變化都要通過服務端實時廣播推送給各個客戶端,如下,client1 發送一條消息,其他的 client 都需要收到服務端的消息,而這個的前提是服務端需要知道有多少客戶端連接着。

C/S

對比下 http 請求,client1 發送完消息 (Request) 服務端接收後並返回 (Response) 即斷開,如此服務端是無法獲得其他客戶端的連接狀態並推送,不過 http 可以使用輪詢 (Polling),每個客戶端隔一段時間發送一個請求到服務端,如果有發現別的客戶端的發送聊天室消息就返回數據,消息延遲跟輪詢時間間隔有關, 如此也能做一個聊天室,想想任務也就完成了,但是如果這個聊天室是馬化騰發起的呢,目標是做成微信呢?

性能,纔是一款優秀的遊戲服務器追尋的目標,一條消息服務端廣播的數量和客戶端數量成正比,n 條消息就是 n * n, 如果再配上輪詢,想想王者榮耀 460ms 的延遲是一個玩家能忍受的嗎。回到 Websocket 同樣會帶來性能瓶頸,早期的網絡遊戲服務器大多是單臺服務器單進程架構,所有邏輯都寫在一起,同時長連接也需要比短連接帶來更多的內存開銷,如存儲所有客戶端Session信息,且內部其實也是通過某種輪詢去實現的,這些總總,當我們想去打造一個 “企業級的遊戲框架” (這個說法來自 eggjs =。=) 的時候,簡單的使用 socket.io, 在面臨大量的在線客戶端時候,我們可能就到此就止步了,這也是這篇文章的一個背景和初衷,我想聊聊遊戲服務器爲了性能到底能做什麼,可能經驗不足,但至少搞下來收穫滿滿。

分佈式多進程模型設計

我在 Github 搜了很多遊戲框架並對比,最終映入眼簾的就是網易的 pomelo。一是網易的大廠背景,想想當年的夢幻西遊,二是它的文檔架構的完備性,所以我花了很多時間把它的代碼幾乎都看完,但是由於它的代碼年久失修幾乎不維護,同時秉承前端造論圈的壞風氣,我重新參考它的代碼以更現代化的方式寫了一個遊戲框架 Regax,並美化了下架構圖:

我們回顧上節所講的性能瓶頸:

  • 單進程單服務器無法承載更多的客戶端。
  • 長連接廣播帶來的開銷巨大,特別是遊戲場景很頻繁需要推送消息。

再看下上邊的圖到底做了什麼:

  • 第一點,所有業務邏輯都以進程服務器粒度拆分,拆分越細越好,提升伸縮性,進程間通過RPC調用,如此可保證進程可跨集羣服務器調用,這是分佈式架構的基本。
  • 第二點,Socket連接服務器單獨拆分,這是最關鍵的,它只負責連接及廣播,不負責任何其他的業務邏輯,保證其性能最大化。

除了解決上節問題再進一步優化:

  • 第三點,協議層更加靈活,不再只是Websocket,由於連接服務器的隔離加純粹性,服務器可支持多種連接方式共存,如此我們能承載的客戶端更多,還可支持靈活切換,如真正的業務場景tcp和udp可根據客戶端支持情況自動切換。
  • 第四點,引入網關層,網關層用來控制連接的路由算法,想想農藥裏的服務器分區策略,再比如地理位置就近原則,分配就近的服務器,進一步提升網絡傳輸效率。
  • 第五點,進程支持權重,權重越高,分配的進程越多機會越大,這也是伸縮性的一種提升。

其他模塊就是大衆服務器所通用的擴展,如監控及存儲等,這裏不贅述,真正去理解專研一款優秀的框架設計時候,真的會愛不釋手。

一切準備就緒,設計完框架後急需一個業務場景去試煉一番,以此來反哺框架,想想現在能做的太多了,擼一個頁遊傳奇Online渣渣灰綽綽有餘,在我所在的支付寶小程序團隊也很需要創新場景,框架本身也能給業務帶來更多的可能性更多的玩法,最終敲定做了一款簡單的多人實時對戰貪喫蛇, 可支持和好友一起玩,這時候纔是體會開發遊戲的樂趣所在。

多人實時對戰貪喫蛇

我們參照了王者榮耀的好友匹配+對戰的模式設計了下貪喫蛇,如下:

貪喫蛇房間匹配頁面

貪喫蛇對戰頁面

貪喫蛇遊戲結束排名

首先按上節的架構,我對服務器做了拆分:

  1. 連接服務器 (ConnectorServer):負責和客戶端的Websocket連接及通知,同時校驗登陸Token,如果Token不合法直接關閉連接,連接後通過token再去數據庫拿用戶的暱稱等信息。
class ConnectorServer {
    enter({ token }){
        // 1. 校驗 Token
        // 2. 通過 Token從數據庫獲取用戶信息, 並創建 Session
        // 3. 監聽 Socket關閉
        this.ctx.session.on('disconnect', () => {
            // 4. 如果當前用戶在某個房間,發送RPC通知房間服務器踢掉用戶
            this.ctx.rpc.room.kickUser(this.ctx.session.uid)
        })
    }
}

 

2. 房間服務器 (RoomServer): 負責房間的創建及加入,並通知房間裏所有的用戶信息

class RoomServer {
    kickUser() {
        // 1. 踢掉用戶
        // 2. 發送 RPC 給 ConnectorServer 廣播給客戶端房間信息, 這裏channel內部封裝了rpc
        this.ctx.channel.room.pushMessage('onRoomChange', roomData)
    }
    joinUser() {
        // 1. 加入用戶
        // 2. 發送 RPC 給 ConnectorServer 廣播給客戶端房間信息, 這裏channel內部封裝了rpc
        this.ctx.channel.room.pushMessage('onRoomChange', roomData)
    }
    startGame() {
        // 1. 發送RPC給 BattleServer 開始遊戲
        this.ctx.rpc.battle.start(roomMembers)
    }
}

3. 對戰服務器 (BattleServer): 貪喫蛇開始遊戲後,會在服務端建立 幀同步 模式,並定時推送消息, 幀同步會再之後介紹:

class BattleServer {
   start() {
     // 模擬幀同步,真正實現會比這個複雜
     setInterval(() => {
        // 按每秒三十幀的頻率發送幀數據給所有客戶端
        this.ctx.channel.battle.pushMessage('onBattleFrame', currentFrame)
     },1000 / 30)
   },
   syncFrameAction() {
      // 從客戶端接收到貪喫蛇的操作動作並插入到當前幀數據裏
   }
}

而在客戶端:

import { Client } from '@regax/client-websocket'

const client = new Client({ url: 'ws://localhost:8002', reconnect: true })

// 監聽服務端斷線
client.on('disconnect', () => {})

// 1. 創建 WebSocket 連接
await client.connect()

// 2. 監聽房間成員變化,這裏會通過服務端廣播接收到
client.on('onRoomChange', ( roomData) => {} )

// 3. 監聽遊戲開始後的幀數據變化
client.on('onBattleFrame', ( frame) => {
  // 每接收到一幀,就驅動貪喫蛇渲染引擎渲染一次
})

// 4. 登陸並校驗token
await client.request('connector.enter', { token })
// 5. 加入房間
await client.request('room.joinUser')
// 6. 點擊開始遊戲
await client.request('room.startGame')
// 7. 操作貪喫蛇時候發送操作行爲
await client.request('battle.syncFrameAction', { action })

這樣一款多人對戰版的貪喫蛇算是基本完成了,但是真正實現的時候遇到不少的坑,如卡頓嚴重,另外爲什麼要使用幀同步,幀同步和狀態同步的區別在哪,再下一章我會聊一聊這個話題。

最後

如果大家想體驗可以到支付寶搜下 `福利貪喫蛇`, 目前集羣機器還比較少請輕虐,最後,不忘記招聘,如果你有興趣,可以私信我, 阿里系能給你的自由度及想象空間挺大。

轉自https://zhuanlan.zhihu.com/p/114150098

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章