note:本文是《用 Pulsar 開發(fā)多人在線小游戲》的第三篇,配套源碼和全部文檔參見我的 GitHub 倉庫 play-with-pulsar 以及我的文章列表。
我選擇了 Go 語言的一款 2D 游戲框架來制作這個炸彈人游戲,叫做 Ebitengine,官網(wǎng)如下:
之所以選擇這款 Go 語言的框架,主要是兩個原因:
1、非常簡單易學(xué),適合快速上手寫 2D 小游戲。
2、支持編譯成 WebAssembly,如果需要的話可以直接編譯到網(wǎng)頁上運(yùn)行。
這個庫的使用原理特別簡單,只要你實現(xiàn)這個 Game 接口的幾個核心方法就可以:
type Game interface {
// 在 Update 函數(shù)里填寫數(shù)據(jù)更新的邏輯
Update() error
// 在 Draw 函數(shù)里填寫圖像渲染的邏輯
Draw(screen *Image)
// 返回游戲界面的大小
Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
}
我們知道顯示器能夠顯示動態(tài)影像的原理其實就是快速的刷新一幀一幀的圖像,肉眼看起來就好像是動態(tài)影像了。
這個游戲框架做的事情其實很簡單:
在每一幀圖像刷新之前,這個游戲框架會先調(diào)用 Update 方法更新游戲數(shù)據(jù),再調(diào)用 Draw 方法根據(jù)游戲數(shù)據(jù)渲染出每一幀圖像,這樣就能夠制作出簡單的 2D 小游戲了。
下面我們實現(xiàn)一個貪吃蛇游戲來具體看看這個框架的用法。
貪吃蛇游戲是框架官網(wǎng)給出的一個例子,只有一個 main.go 文件,鏈接如下:
https://github.com/hajimehoshi/ebiten/blob/main/examples/snake/main.go
這個游戲其實很簡單,總共也就 200 多行代碼,我這里簡單過一下代碼中的核心邏輯,因為我們的炸彈人游戲是基于貪吃蛇游戲的布局之上開發(fā)的。
貪吃蛇游戲的數(shù)據(jù)都存在 Game 中:
// 存儲游戲數(shù)據(jù)
type Game struct {
// 貪吃蛇移動的方向
moveDirection int
// 蛇身
snakeBody []Position
// 食物的位置
apple Position
// 控制蛇的移動速度隨著難度增加而增加
timer int
moveTime int
level int
// 分?jǐn)?shù)統(tǒng)計
score int
bestScore int
}
接下來看 Update 方法,這個方法主要的任務(wù)是監(jiān)聽玩家的動作并更新 Game 結(jié)構(gòu)體中的游戲數(shù)據(jù):
func (g *Game) Update() error {
// 監(jiān)聽 WASD 和方向鍵,更新蛇的行進(jìn)方向
if inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft)
|| inpututil.IsKeyJustPressed(ebiten.KeyA) {
if g.moveDirection != dirRight {
g.moveDirection = dirLeft
}
} else if (...)
if g.needsToMoveSnake() {
if g.collidesWithWall() || g.collidesWithSelf() {
// 蛇撞墻或者咬到自己,游戲結(jié)束,重置相關(guān)游戲數(shù)據(jù)
g.reset()
}
if g.collidesWithApple() {
// 蛇吃到食物
// 1. 在隨機(jī)位置生成新的食物
g.apple.X = rand.Intn(xGridCountInScreen - 1)
g.apple.Y = rand.Intn(yGridCountInScreen - 1)
// 2. 蛇身變長
g.snakeBody = append(g.snakeBody, Position{X, Y})
// 3. 更新分?jǐn)?shù)
g.score++
// 4. 加快貪吃蛇移動速度從而增加游戲難度
g.level++
}
// 蛇身前進(jìn)一格
switch g.moveDirection {
case dirLeft:
g.snakeBody[0].X--
case dirRight:
g.snakeBody[0].X++
case dirDown:
g.snakeBody[0].Y++
case dirUp:
g.snakeBody[0].Y--
}
}
}
Update 方法更新數(shù)據(jù)之后,Draw 方法會根據(jù)更新后的數(shù)據(jù)渲染游戲界面:
func (g *Game) Draw(screen *ebiten.Image) {
// 畫出蛇身
for _, v := range g.snakeBody {
ebitenutil.DrawRect(v.X, v.Y, color.RGBA{0x80, 0xa0, 0xc0, 0xff})
}
// 畫出食物
ebitenutil.DrawRect(g.apple.X, g.apple.Y, color.RGBA{0xFF, 0x00, 0x00, 0xff})
}
是不是非常簡單?完成這些代碼之后,就實現(xiàn)了一個經(jīng)典的貪吃蛇游戲:
類比一下我們的炸彈人游戲:

可以發(fā)現(xiàn),基本的游戲布局其實和貪吃蛇游戲差不多,用不同顏色的方塊代表障礙物、玩家、炸彈,這主要也是因為實現(xiàn)起來簡單,不需要美術(shù)貼圖之類的非編程工作。所以炸彈人游戲的 Draw 方法和貪吃蛇游戲應(yīng)該差不多,就是渲染一些不同顏色方塊。
我們這個炸彈人游戲最關(guān)鍵的是加入了聯(lián)機(jī)的要素,所以最核心的改動是 Update 方法,下面介紹一下實現(xiàn)思路。
炸彈人游戲的實現(xiàn)思路
首先,我們也創(chuàng)建一個 Game 結(jié)構(gòu)體存儲炸彈人游戲的數(shù)據(jù):
type Game struct {
// 當(dāng)前玩家的名字
localPlayerName string
// 記錄所有聯(lián)機(jī)玩家的名字及位置
posToPlayers map[Position]*playerInfo
// 記錄所有炸彈的位置
posToBombs map[Position]*Bomb
// 記錄炸彈爆炸火焰的位置
flameMap map[Position]*Bomb
// 記錄障礙物的位置
obstacleMap map[Position]ObstacleType
// 從 Pulsar 發(fā)來的事件都會傳遞到這個 channel
receiveCh chan Event
// 塞進(jìn)這個 channel 的事件都會發(fā)給 Pulsar
sendCh chan Event
// 管理和 Pulsar 的連接
client *pulsarClient
// 存儲房間內(nèi)玩家的分?jǐn)?shù)信息
scores *lru.Cache
}
Draw 方法很簡單,去渲染所有游戲?qū)ο缶托辛耍?/p>
func (g *Game) Draw(screen *ebiten.Image) {
// 畫出炸彈
for pos, _ := range g.posToBombs {
ebitenutil.DrawRect(pos.X, pos.Y, bombColor)
}
// 畫出障礙物
for pos, t := range g.obstacleMap {
ebitenutil.DrawRect(pos.X, pos.Y, obstacleColor)
}
// 畫出玩家
for _, player := range g.nameToPlayers {
ebitenutil.DrawRect(player.X, player.Y, userColor)
}
// 畫出火焰
for pos, val := range g.flameMap {
ebitenutil.DrawRect(pos.X, pos.Y, flameColor)
}
}
因為貪吃蛇游戲只是單機(jī)游戲,所以可能更新游戲數(shù)據(jù)的事件不多,無非就是本地玩家按動方向鍵、貪吃蛇撞到墻或者咬到自己這幾個事件。
而炸彈人游戲可能更新游戲數(shù)據(jù)的事件非常多,除了本地玩家的鍵盤事件之外,還要考慮到聯(lián)機(jī)玩家產(chǎn)生的事件,比如新玩家加入房間、某個玩家死亡、某個玩家復(fù)活,某個玩家移動等等。
為了簡化各種復(fù)雜情況的處理,我們可以按照前文 如何用 Pulsar 實現(xiàn)游戲需求 所描述的那樣,我們創(chuàng)建了一個 Event 接口,玩家的所有動作都被抽象成一個 Event:
type Event interface {
handle(game *Game)
}
Game 結(jié)構(gòu)會傳入 handle 方法,由實現(xiàn) Event 接口的具體類去決定如何更新游戲數(shù)據(jù)。
比如玩家移動被抽象成了 UserMoveEvent 類,它實現(xiàn)了 Event 接口:
// UserMoveEvent makes playerInfo move
type UserMoveEvent struct {
playerName string
pos Position
}
// 處理玩家移動的事件,更新相應(yīng)的數(shù)據(jù)
func (e *UserMoveEvent) handle(g *Game) {
// 防止移動出界
if !validCoordinate(e.pos) {
return
}
// 防止移動到障礙物上
if _, ok := g.obstacleMap[e.pos]; ok {
return
}
// 已經(jīng)死亡的玩家不允許再移動
if player, ok := g.nameToPlayers[e.name]; ok && !player.alive {
return
}
// 更新玩家的位置信息
g.posToPlayers[e.pos] = &playerInfo {
name : e.playerName
pos : e.pos
}
}
類似的,其他的事件也會在 handle 方法中處理游戲數(shù)據(jù)的更新。所有事件類的實現(xiàn)代碼都放在 event.go 中。
有了 Event 接口的抽象,就可以大幅簡化 Update 中的代碼:
func (g *Game) Update() error {
// Pulsar 那邊的事件都會發(fā)到 receiveCh 中,
// 非阻塞地處理這些事件,更新本地游戲數(shù)據(jù)
select {
case event := <-g.receiveCh:
event.handle(g)
default:
}
// 監(jiān)聽本地玩家產(chǎn)生的事件,
// 全部通過 sendCh 發(fā)送給 Pulsar
dir, setBomb := listenLocalKeyboard()
if dir != dirNone {
// 產(chǎn)生玩家移動的事件
nextPlayerPos := getNextPosition(localPlayer.pos, dir)
localEvent := &UserMoveEvent{
name: localPlayer.playerName
pos: nextPlayerPos,
}
g.sendCh <- localEvent
}
if setBomb {
// 產(chǎn)生放炸彈的事件
localEvent := &SetBombEvent{
pos: localPlayer.pos,
}
g.sendCh <- localEvent
}
// ...
}
我們把本地產(chǎn)生的事件塞進(jìn) sendCh,并從 receiveCh 讀取并渲染 Pulsar 中的事件;而且 Update 在每一幀刷新時都會被調(diào)用,就好像一個死循環(huán),所以上面這段邏輯就實現(xiàn)了 多人游戲難點(diǎn)分析 中提到的同步多個玩家事件的偽碼邏輯:
// 一個線程負(fù)責(zé)拉取并顯示事件
new Thread(() -> {
while (true) {
// 不斷從消息隊列拉取事件
Event event = consumer.receive();
// 然后更新本地狀態(tài),顯示給玩家
updateLocalScreen(event);
}
});
// 一個線程負(fù)責(zé)生成并發(fā)送本地事件
new Thread(() -> {
while (true) {
// 本地玩家產(chǎn)生的事件,要發(fā)送到消息隊列
Event localEvent = listenLocalKeyboard();
producer.send(event);
}
});
sendCh 和 receiveCh 另一端有 Pulsar 的 client 去處理事件的收發(fā),它們具體是如何做的呢?我會在后面的章節(jié)介紹。
更多高質(zhì)量干貨文章,請關(guān)注我的微信公眾號 labuladong 和算法博客 labuladong 的算法秘籍。