用 Pulsar 開發(fā)多人小游戲(三):Golang 2D 游戲框架 Ebiten 實戰(zhàn)

note:本文是《用 Pulsar 開發(fā)多人在線小游戲》的第三篇,配套源碼和全部文檔參見我的 GitHub 倉庫 play-with-pulsar 以及我的文章列表。

我選擇了 Go 語言的一款 2D 游戲框架來制作這個炸彈人游戲,叫做 Ebitengine,官網(wǎng)如下:

https://ebitengine.org/

之所以選擇這款 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);
    }
});

sendChreceiveCh 另一端有 Pulsar 的 client 去處理事件的收發(fā),它們具體是如何做的呢?我會在后面的章節(jié)介紹。

更多高質(zhì)量干貨文章,請關(guān)注我的微信公眾號 labuladong 和算法博客 labuladong 的算法秘籍。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容