💧 Posted on 

Ebitengine - 一款Go语言编写的2D游戏引擎

简介

最近使用 Go 写项目的过程中,接触到 labuladong 大佬曾经实现的一款用消息队列做的联机游戏 Play with Apache Pulsar 项目中使用到了 Ebitengine

为了更好的了解项目,先简单了解下 Ebitengine

官网上展示了几款 Ebitengine 实现的小游戏,在线即可体验(利用 WASM 技术)

更多 Ebiten 游戏:30+ More Examples

当然只要安装了 Go,我们也键入下面的命令本地运行这个游戏(或者本地使用 GoLand 启动):

1
$ go run -tags=example github.com/hajimehoshi/ebiten/v2/examples/2048@latest

这些瞬间让我产生了极大的兴趣。简单浏览一下文档,整体感觉下来,虽然与成熟的游戏引擎(如 Cocos2dx,DirectX,Unity3d 等)相比,ebiten 功能还不算丰富。但是麻雀虽小,五脏俱全。ebiten 的 API 设计比较简单,使用也很方便,即使对于新手也可以在 1-2 个小时内掌握,并开发出一款简单的游戏。更妙的是,Go 语言让 ebitengine 实现了跨平台!

下面体验一下 ebitengine 的基础功能。

安装

ebitengine 要求 Go 版本 >= 1.15。使用 go module 下载这个包:

1
$ go get -u github.com/hajimehoshi/ebiten/v2

生成游戏窗口

游戏开发第一步是将游戏窗口显示出来,并且能在窗口上显示一些文字。先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"log"

"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
)

// 仅用来展示窗口,暂不定义游戏数据
type Game struct{}

func (g *Game) Update() error {
return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
ebitenutil.DebugPrint(screen, "Hello, World")
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
return 100, 100
}

func main() {
ebiten.SetWindowSize(200, 200)
ebiten.SetWindowTitle("生成游戏窗口")
if err := ebiten.RunGame(&Game{}); err != nil {
log.Fatal(err)
}
}

使用命令go run运行该程序:

我们会看到一个窗口,标题为生成游戏窗口,并且显示了文字 Hello,World

现在我们来分析使用 ebiten 开发的游戏程序的结构。

首先,ebiten 引擎运行时要求传入一个游戏对象,该对象的必须实现ebiten.Game这个接口:

1
2
3
4
5
6
7
8
9
10
// Game defines necessary functions for a game.
type Game interface {
// 在 Update 函数里填写数据更新的逻辑
Update() error

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

Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
}

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

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

ebiten.Game接口定义了 ebiten 游戏需要的 3 个方法:Update,DrawLayout

  • Update:每个 tick 都会被调用。tick 是引擎更新的一个时间单位,默认为 1/60s。tick 的倒数我们一般称为帧,即游戏的更新频率。默认 ebiten 游戏是 60 帧,即每秒更新 60 次。该方法主要用来更新游戏的逻辑状态,例如子弹位置更新。上面的例子中,游戏对象没有任何状态,故Update方法为空。注意到Update方法的返回值为error类型,当Update方法返回一个非空的error值时,游戏停止。在上面的例子中,我们一直返回 nil,故只有关闭窗口时游戏才停止。

  • Draw:每帧(frame)调用。帧是渲染使用的一个时间单位,依赖显示器的刷新率。如果显示器的刷新率为 60Hz,Draw将会每秒被调用 60 次。Draw接受一个类型为*ebiten.Image的 screen 对象。ebiten 引擎每帧会渲染这个 screen。在上面的例子中,我们调用ebitenutil.DebugPrint函数在 screen 上渲染一条调试信息。由于调用Draw方法前,screen 会被重置,故DebugPrint每次都需要调用。

  • Layout:该方法接收游戏窗口的尺寸作为参数,返回游戏的逻辑屏幕大小。我们实际上计算坐标是对应这个逻辑屏幕的,Draw将逻辑屏幕渲染到实际窗口上。这个时候可能会出现伸缩。在上面的例子中游戏窗口大小为 (200, 200),Layout返回的逻辑大小为 (100, 100),所以显示会放大 1 倍。

在 main 函数中,

1
ebiten.SetWindowSize(200, 200)

设置游戏窗口的大小。

1
ebiten.SetWindowTitle("生成游戏窗口")

设置窗口标题,标题显示在窗口的左上角。

一切准备就绪,创建一个 Game 对象,调用ebiten.RunGame()运行。是不是很简单?

响应键盘输入

没有交互的游戏不是真的游戏!下面我们来监听键盘的输入,当前只处理 ⬆️ ⬇️ ⬅️ ➡️ 四类。

ebiten 提供函数 IsKeyPressed 来判断某个键是否按下,同时内置了 100 多个键的常量定义,见源码 keys.go 文件。ebiten.KeyLeft表示左方向键,ebiten.KeyRight表示右方向键,ebiten.KeySpace表示空格。

ebiten 提供函数 inpututil.IsKeyJustPressed 来监听刚按下的键。

因此我们可以在 Update 函数中添加如下逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (g *Game) Update() error {
if inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) || inpututil.IsKeyJustPressed(ebiten.KeyA) {
fmt.Println("左键 ⬅️")
} else if inpututil.IsKeyJustPressed(ebiten.KeyArrowRight) || inpututil.IsKeyJustPressed(ebiten.KeyD) {
fmt.Println("右键 ➡️️")
} else if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) {
fmt.Println("下键 ⬇️️")
} else if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) {
fmt.Println("上键 ⬆️️")
} else {
// ... 暂不处理
}
return nil
}

使用go run命令运行:

窗口与前一个例子相同,然而我们可以在窗口上按 ⬆️ ⬇️ ⬅️ ➡️ and W S A D,观察控制台输出:

设置背景

黑色背景看起来有些无趣,我们现在就来换一个背景。

1
2
3
4
func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(color.RGBA{R: 200, G: 200, B: 200, A: 255})
ebitenutil.DebugPrint(screen, g.input.msg)
}

ebiten.Image定义了一个名为Fill的方法,可以传入一个颜色对象color.RGBA,将背景填充为特定颜色。Draw函数的参数为*ebiten.Image类型,它表示的是屏幕对象,ebitengine 引擎最终会将 screen 显示出来,故填充它的背景即可修改窗口的背景。代码中我们将背景颜色修改为灰色 (R:200,G:200,B:200)。

注意:由于每帧都会调用Draw方法刷新屏幕内容,所以每次调用都需要填充背景。

运行结果如下:

在屏幕中显示图片

接下来我们尝试在屏幕中显示一张飞船的图片:

ebitengine 引擎提供了ebitenutil.NewImageFromFile函数,传入图片路径即可加载该图片,so easy。为了很好的管理游戏中的各个实体,我们给每个实体都定义一个结构。先定义飞船结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Ship struct {
image *ebiten.Image
width int
height int
}

func NewShip() *Ship {
img, _, err := ebitenutil.NewImageFromFile("../images/ship.png")
if err != nil {
log.Fatal(err)
}

width, height := img.Size()
ship := &Ship{
image: img,
width: width,
height: height,
}

return ship
}

我提供了两种图片格式,一种是 png,一种是 bmp,用哪种都可以。注意,需要将对应的图片解码包导入。Go 标准库提供了三种格式的解码包,image/pngimage/jpegimage/gif。也就是说标准库中没有 bmp 格式的解码包,所幸 golang.org/x 仓库没有让我们失望,golang.org/x/image/bmp 提供了解析 bmp 格式图片的功能。我们这里不需要显式的使用对应的图片库,故使用import _这种方式,让init函数产生副作用。

然后在游戏对象中添加飞船类型的字段:

1
2
3
4
5
func NewGame() *Game {
return &Game {
ship: NewShip(),
}
}

为了在屏幕上显示飞船图片,我们需要调用*ebiten.ImageDrawImage方法,该方法的第二个参数可以用于指定坐标相对于原点的偏移:

1
2
3
4
5
6
func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(g.cfg.BgColor)
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(float64(g.cfg.ScreenWidth-g.ship.width)/2, float64(g.cfg.ScreenHeight-g.ship.height))
screen.DrawImage(g.ship.image, op)
}

我们给Ship类型增加一个绘制自身的方法,传入屏幕对象 screen 和配置,让代码更好维护:

1
2
3
4
5
func (ship *Ship) Draw(screen *ebiten.Image) {
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(float64(screenWidth-ship.width)/2, float64(screenHeight-ship.height))
screen.DrawImage(ship.image, op)
}

这样游戏对象中的Draw方法就可以简化为:

1
2
3
4
func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(g.cfg.BgColor)
g.ship.Draw(screen)
}

运行:

移动图片

现在我们来实现使用左右方向键来控制飞船的移动。首先给飞船的类型增加 x/y 坐标字段:

1
2
3
4
5
type Ship struct {
// 与前面的代码一样
x float64 // x坐标
y float64 // y坐标
}

我们前面已经计算出飞船位于屏幕中心时的坐标,在创建飞船时将该坐标赋给 xy:

1
2
3
4
5
6
7
8
9
func NewShip(screenWidth, screenHeight int) *Ship {
ship := &Ship{
// ...
x: float64(screenWidth-width) / 2,
y: float64(screenHeight - height) / 2,
}

return ship
}

然后我们在 Update 方法中根据按下的是左方向键还是右方向键来更新飞船的坐标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (g *Game) Update() error {
if inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) || inpututil.IsKeyJustPressed(ebiten.KeyA) {
fmt.Println("左键 ⬅️")
g.ship.x -= 10 // 移动范围太小不容易出效果
} else if inpututil.IsKeyJustPressed(ebiten.KeyArrowRight) || inpututil.IsKeyJustPressed(ebiten.KeyD) {
fmt.Println("右键 ➡️️")
g.ship.x += 10
} else if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) {
fmt.Println("下键 ⬇️️")
g.ship.y -= 10
} else if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) {
fmt.Println("上键 ⬆️️")
g.ship.y += 10
} else {
// ... 暂不处理
}
return nil
}

好了,现在可以运行程序了go run .,效果如下:

总结

以上是 2D 游戏开发库 ebiten 的基本使用。

对于游戏引擎来说,只介绍它的 API 用法似乎有点纸上谈兵。恰好我想起之前看到一个《射击游戏》的小游戏,刚好可以拿来练手。