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

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

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 文件,链接如下:

github.com/hajimehoshi…

这个游戏其实很简略,总共也就 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})
}

是不是十分简略?完成这些代码之后,就完成了一个经典的贪吃蛇游戏:

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

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

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

可以发现,根本的游戏布局其实和贪吃蛇游戏差不多,用不同颜色的方块代表障碍物、玩家、炸弹,这首要也是由于完成起来简略,不需要美术贴图之类的非编程工作。所以炸弹人游戏的 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 的算法秘籍