用 Pulsar 开发多人小游戏(三):Golang 2D 游戏框架 Ebiten 实战

note:本文是《用 Pulsar 开发多人在线小游戏》的第三篇,配套源码和全部文档参见我的 GitHub 仓库 play-with-pulsar 以及我的文章列表。

我选择了 Go 语言的一款 2D 游戏框架来制作这个炸弹人游戏,叫做 Ebitengine,官网如下:

https://ebitengine.org/

之所以选择这款 Go 语言的框架,主要是两个原因:

1、非常简单易学,适合快速上手写 2D 小游戏。

2、支持编译成 WebAssembly,如果需要的话可以直接编译到网页上运行。

这个库的使用原理特别简单,只要你实现这个 Game 接口的几个核心方法就可以:

type Game interface {
    // 在 Update 函数里填写数据更新的逻辑
    Update() error

    // 在 Draw 函数里填写图像渲染的逻辑
    Draw(screen *Image)

    // 返回游戏界面的大小
    Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
}

我们知道显示器能够显示动态影像的原理其实就是快速的刷新一帧一帧的图像,肉眼看起来就好像是动态影像了。

这个游戏框架做的事情其实很简单:

在每一帧图像刷新之前,这个游戏框架会先调用 Update 方法更新游戏数据,再调用 Draw 方法根据游戏数据渲染出每一帧图像,这样就能够制作出简单的 2D 小游戏了。

下面我们实现一个贪吃蛇游戏来具体看看这个框架的用法。

贪吃蛇游戏是框架官网给出的一个例子,只有一个 main.go 文件,链接如下:

https://github.com/hajimehoshi/ebiten/blob/main/examples/snake/main.go

这个游戏其实很简单,总共也就 200 多行代码,我这里简单过一下代码中的核心逻辑,因为我们的炸弹人游戏是基于贪吃蛇游戏的布局之上开发的。

贪吃蛇游戏的数据都存在 Game 中:

// 存储游戏数据
type Game struct {
    // 贪吃蛇移动的方向
    moveDirection int
    // 蛇身
    snakeBody     []Position
    // 食物的位置
    apple         Position

    // 控制蛇的移动速度随着难度增加而增加
    timer         int
    moveTime      int
    level         int

    // 分数统计
    score         int
    bestScore     int
}

接下来看 Update 方法,这个方法主要的任务是监听玩家的动作并更新 Game 结构体中的游戏数据:

func (g *Game) Update() error {
    // 监听 WASD 和方向键,更新蛇的行进方向
    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() {
            // 蛇撞墙或者咬到自己,游戏结束,重置相关游戏数据
            g.reset()
        }

        if g.collidesWithApple() {
            // 蛇吃到食物
            // 1. 在随机位置生成新的食物
            g.apple.X = rand.Intn(xGridCountInScreen - 1)
            g.apple.Y = rand.Intn(yGridCountInScreen - 1)
            // 2. 蛇身变长
            g.snakeBody = append(g.snakeBody, Position{X, Y})
            // 3. 更新分数
            g.score++
            // 4. 加快贪吃蛇移动速度从而增加游戏难度
            g.level++
        }

        // 蛇身前进一格
        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 方法更新数据之后,Draw 方法会根据更新后的数据渲染游戏界面:

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})
}

是不是非常简单?完成这些代码之后,就实现了一个经典的贪吃蛇游戏:

类比一下我们的炸弹人游戏:

可以发现,基本的游戏布局其实和贪吃蛇游戏差不多,用不同颜色的方块代表障碍物、玩家、炸弹,这主要也是因为实现起来简单,不需要美术贴图之类的非编程工作。所以炸弹人游戏的 Draw 方法和贪吃蛇游戏应该差不多,就是渲染一些不同颜色方块。

我们这个炸弹人游戏最关键的是加入了联机的要素,所以最核心的改动是 Update 方法,下面介绍一下实现思路。

炸弹人游戏的实现思路

首先,我们也创建一个 Game 结构体存储炸弹人游戏的数据:

type Game struct {
    // 当前玩家的名字
    localPlayerName string
    // 记录所有联机玩家的名字及位置
    posToPlayers    map[Position]*playerInfo
    // 记录所有炸弹的位置
    posToBombs  map[Position]*Bomb
    // 记录炸弹爆炸火焰的位置
    flameMap  map[Position]*Bomb
    // 记录障碍物的位置
    obstacleMap map[Position]ObstacleType

    // 从 Pulsar 发来的事件都会传递到这个 channel
    receiveCh chan Event
    // 塞进这个 channel 的事件都会发给 Pulsar
    sendCh chan Event

    // 管理和 Pulsar 的连接
    client *pulsarClient
    // 存储房间内玩家的分数信息
    scores *lru.Cache
}

Draw 方法很简单,去渲染所有游戏对象就行了:

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)
    }
}

因为贪吃蛇游戏只是单机游戏,所以可能更新游戏数据的事件不多,无非就是本地玩家按动方向键、贪吃蛇撞到墙或者咬到自己这几个事件。

而炸弹人游戏可能更新游戏数据的事件非常多,除了本地玩家的键盘事件之外,还要考虑到联机玩家产生的事件,比如新玩家加入房间、某个玩家死亡、某个玩家复活,某个玩家移动等等。

为了简化各种复杂情况的处理,我们可以按照前文 如何用 Pulsar 实现游戏需求 所描述的那样,我们创建了一个 Event 接口,玩家的所有动作都被抽象成一个 Event

type Event interface {
    handle(game *Game)
}

Game 结构会传入 handle 方法,由实现 Event 接口的具体类去决定如何更新游戏数据。

比如玩家移动被抽象成了 UserMoveEvent 类,它实现了 Event 接口:

// UserMoveEvent makes playerInfo move
type UserMoveEvent struct {
    playerName string
    pos        Position
}

// 处理玩家移动的事件,更新相应的数据
func (e *UserMoveEvent) handle(g *Game) {
    // 防止移动出界
    if !validCoordinate(e.pos) {
        return
    }
    // 防止移动到障碍物上
    if _, ok := g.obstacleMap[e.pos]; ok {
        return
    }
    // 已经死亡的玩家不允许再移动
    if player, ok := g.nameToPlayers[e.name]; ok && !player.alive {
        return
    }
    // 更新玩家的位置信息
    g.posToPlayers[e.pos] = &playerInfo {
        name   : e.playerName
        pos    : e.pos
    }
}

类似的,其他的事件也会在 handle 方法中处理游戏数据的更新。所有事件类的实现代码都放在 event.go 中。

有了 Event 接口的抽象,就可以大幅简化 Update 中的代码:

func (g *Game) Update() error {
    // Pulsar 那边的事件都会发到 receiveCh 中,
    // 非阻塞地处理这些事件,更新本地游戏数据
    select {
    case event := <-g.receiveCh:
        event.handle(g)
    default:
    }

    // 监听本地玩家产生的事件,
    // 全部通过 sendCh 发送给 Pulsar
    dir, setBomb := listenLocalKeyboard()

    if dir != dirNone {
        // 产生玩家移动的事件
        nextPlayerPos := getNextPosition(localPlayer.pos, dir)
        localEvent := &UserMoveEvent{
            name: localPlayer.playerName
            pos:  nextPlayerPos,
        }
        g.sendCh <- localEvent
    }
    if setBomb {
        // 产生放炸弹的事件
        localEvent := &SetBombEvent{
            pos: localPlayer.pos,
        }
        g.sendCh <- localEvent
    }
    // ...
}

我们把本地产生的事件塞进 sendCh,并从 receiveCh 读取并渲染 Pulsar 中的事件;而且 Update 在每一帧刷新时都会被调用,就好像一个死循环,所以上面这段逻辑就实现了 多人游戏难点分析 中提到的同步多个玩家事件的伪码逻辑:

// 一个线程负责拉取并显示事件
new Thread(() -> {
    while (true) {
        // 不断从消息队列拉取事件
        Event event = consumer.receive();
        // 然后更新本地状态,显示给玩家
        updateLocalScreen(event);
    }
});

// 一个线程负责生成并发送本地事件
new Thread(() -> {
    while (true) {
        // 本地玩家产生的事件,要发送到消息队列
        Event localEvent = listenLocalKeyboard();
        producer.send(event);
    }
});

sendChreceiveCh 另一端有 Pulsar 的 client 去处理事件的收发,它们具体是如何做的呢?我会在后面的章节介绍。

更多高质量干货文章,请关注我的微信公众号 labuladong 和算法博客 labuladong 的算法秘籍

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,744评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,505评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,105评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,242评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,269评论 6 389
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,215评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,096评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,939评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,354评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,573评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,745评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,448评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,048评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,683评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,838评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,776评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,652评论 2 354

推荐阅读更多精彩内容