Ebitengine介绍

Ebitengine (旧称 Ebiten) 是一款由Go 语言开发的开源游戏引擎。Ebitengine 的简单 API 可以让您的 2D 游戏开发更加简单快捷,并支持同时发布到多平台。

安装

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

示例代码

 1// Game implements ebiten.Game interface.
 2type Game struct{}
 3
 4// Update proceeds the game state.
 5// Update is called every tick (1/60 [s] by default).
 6func (g *Game) Update() error {
 7    // Write your game's logical update.
 8    return nil
 9}
10
11// Draw draws the game screen.
12// Draw is called every frame (typically 1/60[s] for 60Hz display).
13func (g *Game) Draw(screen *ebiten.Image) {
14    // Write your game's rendering.
15}
16
17// Layout takes the outside size (e.g., the window size) and returns the (logical) screen size.
18// If you don't have to adjust the screen size with the outside size, just return a fixed size.
19func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
20    return 320, 240
21}
22
23func main() {
24    game := &Game{}
25    // Specify the window size as you like. Here, a doubled size is specified.
26    ebiten.SetWindowSize(640, 480)
27    ebiten.SetWindowTitle("Your game's title")
28    // Call ebiten.RunGame to start your game loop.
29    if err := ebiten.RunGame(game); err != nil {
30        log.Fatal(err)
31    }
32}

框架结构

从上面的示例代码可以看出,Ebitengine的使用十分简单,我们只需要一个实现ebiten.Game这个接口的对象,将其传入RunGame即可。

1type Game interface {
2	Update() error
3	Draw(screen *Image)
4	Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
5}

该接口定义了三个方法,其中

  • Update() 方法在每一帧都会被调用,用于处理游戏的逻辑更新。我们可以在这个方法中处理玩家输入,游戏元素位置,碰撞检测等,它返回一个error

  • Draw(screen *Image)方法在每一帧都会被调用,用于将游戏的当前状态渲染到屏幕上。我们可以在这里将游戏元素渲染到指定位置,每张图片按照渲染顺序依次覆盖。

  • Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)方法用于定义游戏窗口的大小。当游戏窗口的大小发生变化时,Ebiten 会调用这个方法。

实现思路

静态资源读取

首先我们应该将静态资源,也就是游戏元素的图片在游戏初始化时加载到内存中,并封装成Ebitengine可以使用的形式,方便我们后续的调用。

 1package resouse
 2
 3import (
 4	"fmt"
 5	"image"
 6	"os"
 7	"path/filepath"
 8
 9	"github.com/hajimehoshi/ebiten/v2"
10)
11
12// 保存所有静态资源
13var Images = make(map[string]*ebiten.Image)
14
15func init() {
16	dir := "./assets"
17	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
18		if err != nil {
19			return err
20		}
21		if !info.IsDir() {
22			// 如果文件不是目录,尝试加载它
23			img, err := loadImage(path)
24			if err != nil {
25				fmt.Println("Failed to load image:", path)
26			} else {
27				// 将加载的图像存储在映射中,键是文件名
28				Images[filepath.Base(path)] = img
29			}
30		}
31		return nil
32	})
33	if err != nil {
34		panic(err)
35	}
36}
37
38func loadImage(filePath string) (*ebiten.Image, error) {
39	// 打开图像文件
40	f, err := os.Open(filePath)
41	if err != nil {
42		return nil, err
43	}
44	defer f.Close()
45
46	// 解码图像文件
47	img, _, err := image.Decode(f)
48	if err != nil {
49		return nil, err
50	}
51
52	// 将图像转换为 Ebiten 图像
53	eImg := ebiten.NewImageFromImage(img)
54
55	return eImg, nil
56}

加入背景

首先我们先简单地在Draw()方法中给窗口加上背景,并将窗口大小设置为与背景图片大小一致。注意不要忘记引入**_ “image/png”**,这是为了正确加载png格式的图片。

 1package main
 2
 3import (
 4	"demo/resouse"
 5	"github.com/hajimehoshi/ebiten/v2"
 6	_ "image/png"
 7	"log"
 8)
 9
10type Game struct{}
11
12func (g *Game) Update() error {
13
14	return nil
15}
16
17func (g *Game) Draw(screen *ebiten.Image) {
18	//背景图
19	op := &ebiten.DrawImageOptions{}
20	screen.DrawImage(resouse.Images["background.png"], op)
21}
22
23func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
24	return 480, 852
25}
26
27func main() {
28	game := &Game{}
29	ebiten.SetWindowSize(480, 852)
30	ebiten.SetWindowTitle("飞机大战")
31
32	if err := ebiten.RunGame(game); err != nil {
33		log.Fatal(err)
34	}
35}

从中我们可以看出screen.DrawImage()方法用于将一张图片渲染到窗口上,其中ebiten.DrawImageOptions{}用于决定渲染参数。默认从窗口左上角为坐标原点算起,正方向如图所示

ebiten.DrawImageOptions{}中的GeoM表示图片的几何参数。

1type GeoM struct {
2	a_1 float64 // The actual 'a' value minus 1
3	b   float64
4	c   float64
5	d_1 float64 // The actual 'd' value minus 1
6	tx  float64
7	ty  float64
8}

其中tx和ty即代表图片的位置坐标,默认为0,同样是以图片的左上角为基准。我们可以通过Translate()方法更改图片的坐标。

1// Translate translates the matrix by (tx, ty).
2func (g *GeoM) Translate(tx, ty float64) {
3	g.tx += tx
4	g.ty += ty
5}

效果

加入英雄机

按照面向对象思维,我们应该新建一个Hero对象,这个对象的属性包括坐标,大小,背景图片等,以便于我们对其进行渲染和控制。

1type Hero struct {
2	x             float64
3	y             float64
4	Img           *ebiten.Image
5	Weight        int
6	Height        int
7
8}

构造一个英雄机

我们希望英雄机的初始位置在屏幕正中心,所以在创建英雄机的时候,我们需要屏幕的宽高参数,同时,为了显示出英雄机的喷气效果,我们需要开启一个协程用于定时更新英雄机的背景图片。

 1func NewHero(weight int, height int) *Hero {
 2    img := resouse.Images["hero1.png"]
 3    heroWeight := img.Bounds().Size().X
 4    heroHeight := img.Bounds().Size().Y
 5    Hero := &Hero{
 6        x:       float64(weight-heroWeight) / 2,
 7        y:       float64(height - heroHeight),
 8        Img:     img,
 9        Weight:  heroWeight,
10        Height:  heroHeight,
11    }
12    go func() {
13        //英雄机喷气效果
14        ticker := time.NewTicker(70 * time.Millisecond)
15        defer ticker.Stop()
16        i := 1
17        for range ticker.C {
18            if i == 1 {
19                Hero.Img = resouse.Images["hero1.png"]
20            } else {
21                Hero.Img = resouse.Images["hero0.png"]
22            }
23            i = 3 - i
24        }
25    }()
26    return Hero
27
28}

渲染方法

1func (Hero *Hero) Draw(screen *ebiten.Image) {
2	op := &ebiten.DrawImageOptions{}
3	op.GeoM.Translate(Hero.x, Hero.y)
4	screen.DrawImage(Hero.Img, op)
5}

效果

我们将这个英雄机加入到Game对象中,并在Draw方法中调用英雄机的Draw方法。

 1package main
 2
 3import (
 4	"demo/entity"
 5	"demo/resouse"
 6	_ "image/png"
 7	"log"
 8
 9	"github.com/hajimehoshi/ebiten/v2"
10)
11
12type Game struct{
13	hero      *entity.Hero
14}
15
16func (g *Game) Update() error {
17
18	return nil
19}
20
21func (g *Game) Draw(screen *ebiten.Image) {
22	//背景图
23	op := &ebiten.DrawImageOptions{}
24	screen.DrawImage(resouse.Images["background.png"], op)
25	g.hero.Draw(screen)
26}
27
28func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
29	return 480, 852
30}
31
32func main() {
33	game := &Game{hero: entity.NewHero(480, 852)}
34	ebiten.SetWindowSize(480, 852)
35	ebiten.SetWindowTitle("飞机大战")
36
37	if err := ebiten.RunGame(game); err != nil {
38		log.Fatal(err)
39	}
40}

可以看到喷气效果已经实现

控制方法

我们可以通过 ebiten.IsKeyPressed()方法判断某个按键是否按下,来改变英雄机的坐标,从而控制移动。

当然,我做了一些限制,让英雄机无法移动出窗口的左右边界。

 1func (Hero *Hero) Update() {
 2	if ebiten.IsKeyPressed(ebiten.KeyA) && Hero.x > 0 {
 3		Hero.x -= 2
 4	}
 5	if ebiten.IsKeyPressed(ebiten.KeyD) && Hero.x < float64(480-Hero.Weight) {
 6		Hero.x += 2
 7	}
 8	if ebiten.IsKeyPressed(ebiten.KeyW) {
 9		Hero.y -= 3
10	}
11	if ebiten.IsKeyPressed(ebiten.KeyS) {
12		Hero.y += 1.5
13	}
14
15}

之后,在Game的Update方法中调用Herio的Update方法。

1func (g *Game) Update() error {
2	g.hero.Update()
3	return nil
4}

效果

加入敌机

同样定义一个结构体

1type AirPlane struct {
2	X      float64
3	Y      float64
4	Img    *ebiten.Image
5	Weight int
6	Height int
7}

构造一个敌机

我们希望英雄机的位置时是随机出现在窗口上方的,所以构造函数的参数需要提供初始位置。

 1func newAirPlane(x float64, y float64) *AirPlane {
 2	img := resouse.Images["airplane.png"]
 3	heroWeight := img.Bounds().Size().X
 4	heroHeight := img.Bounds().Size().Y
 5	airplane := &AirPlane{
 6		X:      x,
 7		Y:      y,
 8		Img:    img,
 9		Weight: heroWeight,
10		Height: heroHeight,
11	}
12
13	return airplane
14}

开启一个协程用于敌机的随机定时生成

在Game对象中加入敌机,通过map保存所有的敌机,考虑到在敌机运动和生成敌机时可能会对敌机map产生并发访问问题,这里加入互斥锁。

1type Game struct {
2	hero      *entity.Hero
3	mu        *sync.Mutex
4	airPlanes map[*entity.AirPlane]any
5}

敌机生成函数

 1func StartAirPlane(airplanes map[*AirPlane]any, mu *sync.Mutex) {
 2	go func() {
 3		ticker := time.NewTicker(300 * time.Millisecond)
 4		defer ticker.Stop()
 5
 6		for range ticker.C {
 7			r := rand.New(rand.NewSource(time.Now().UnixNano()))
 8			x := r.Int63n(430)
 9			mu.Lock()
10			airplanes[newAirPlane(float64(x), -20)] = struct{}{}
11			mu.Unlock()
12		}
13
14	}()
15}

敌机的Update和Draw方法

 1func (airplane *AirPlane) Update() {
 2    airplane.Y += 1.5
 3}
 4
 5func (airplane *AirPlane) Draw(screen *ebiten.Image) {
 6    op := &ebiten.DrawImageOptions{}
 7    op.GeoM.Translate(airplane.X, airplane.Y)
 8    screen.DrawImage(airplane.Img, op)
 9
10}

加入Game的Update和Draw方法中

 1func (g *Game) Update() error {
 2	g.hero.Update()
 3	g.mu.Lock()
 4
 5	for v := range g.airPlanes {
 6		v.Update()
 7	}
 8	g.mu.Unlock()
 9
10	return nil
11}
 1func (g *Game) Draw(screen *ebiten.Image) {
 2	//背景图
 3	op := &ebiten.DrawImageOptions{}
 4	screen.DrawImage(resouse.Images["background.png"], op)
 5	for v := range g.airPlanes {
 6		if v.Y > 860 {
 7                        //超出屏幕,清除这个敌机
 8			delete(g.airPlanes, v)
 9		}
10		v.Draw(screen)
11	}
12	g.hero.Draw(screen)
13}

在主函数中启用敌机生成协程

 1func main() {
 2	ebiten.SetWindowSize(480, 852)
 3	ebiten.SetWindowTitle("飞机大战")
 4	air := map[*entity.AirPlane]any{}
 5	game := &Game{hero: entity.NewHero(480, 852), mu: &sync.Mutex{}, airPlanes: air}
 6	entity.StartAirPlane(air, game.mu)
 7	if err := ebiten.RunGame(game); err != nil {
 8		log.Fatal(err)
 9	}
10}

效果

加入子弹

由于子弹的形成与英雄机的位置密切相关,在发射子弹时需要通过英雄机的位置计算子弹产生的位置,所以我们应该在英雄机的对象中加入子弹对象。

子弹的初始化与Draw和Update方法

 1type Bullet struct {
 2	X      float64
 3	Y      float64
 4	Img    *ebiten.Image
 5	Weight int
 6	Height int
 7}
 8
 9func newBullet(x float64, y float64) *Bullet {
10	img := resouse.Images["bullet.png"]
11	bulletWeight := img.Bounds().Size().X
12	bulletHeight := img.Bounds().Size().Y
13	bullet := &Bullet{
14		X:      x - float64(bulletWeight)/2 + 1,
15		Y:      y - float64(bulletHeight)/2,
16		Img:    img,
17		Weight: bulletWeight,
18		Height: bulletHeight,
19	}
20
21	return bullet
22}
23
24func (bullet *Bullet) Update() {
25	bullet.Y -= 10
26}
27
28func (bullet *Bullet) Draw(screen *ebiten.Image) {
29	op := &ebiten.DrawImageOptions{}
30	op.GeoM.Translate(bullet.X, bullet.Y)
31	screen.DrawImage(bullet.Img, op)
32}

在英雄机中加入子弹

这里我们使用map保存所有的子弹,更方便后续的删除操作,并且,玩过游戏的人都知道,任何攻击都是有冷却时间的,不会因为玩家的手速越快,攻击频率越高,所以,这里我们还加入一个时间参数,记录上一次英雄机的子弹发射时间,以便于设置发射冷却。

1type Hero struct {
2	x             float64
3	y             float64
4	Img           *ebiten.Image
5	Weight        int
6	Height        int
7	lastShootTime time.Time
8	Bullets       map[*Bullet]any
9}

注意不要忘记在英雄机初始化中,初始化子弹map

1Hero := &Hero{
2		x:       float64(weight-heroWeight) / 2,
3		y:       float64(height - heroHeight),
4		Img:     img,
5		Weight:  heroWeight,
6		Height:  heroHeight,
7		Bullets: map[*Bullet]any{},
8	}

英雄机的Update方法,加入子弹发射。

 1func (Hero *Hero) Update() {
 2	if ebiten.IsKeyPressed(ebiten.KeyA) && Hero.x > 0 {
 3		Hero.x -= 2
 4	}
 5	if ebiten.IsKeyPressed(ebiten.KeyD) && Hero.x < float64(480-Hero.Weight) {
 6		Hero.x += 2
 7	}
 8	if ebiten.IsKeyPressed(ebiten.KeyW) {
 9		Hero.y -= 3
10	}
11	if ebiten.IsKeyPressed(ebiten.KeyS) {
12		Hero.y += 1.5
13	}
14
15	if ebiten.IsKeyPressed(ebiten.KeyJ) {
16		if time.Since(Hero.lastShootTime).Seconds() >= 0.2 {
17			bullet := newBullet(Hero.x+float64(Hero.Weight)/2+32, Hero.y+40)
18			Hero.Bullets[bullet] = struct{}{}
19			bullet = newBullet(Hero.x+float64(Hero.Weight)/2-32, Hero.y+40)
20			Hero.Bullets[bullet] = struct{}{}
21			Hero.lastShootTime = time.Now() // 更新时间戳
22		}
23	}
24
25}

更新主函数

 1func (g *Game) Update() error {
 2	g.hero.Update()
 3	g.mu.Lock()
 4	for v := range g.hero.Bullets {
 5		v.Update()
 6	}
 7	for v := range g.airPlanes {
 8		v.Update()
 9	}
10	g.mu.Unlock()
11	return nil
12}
13
14func (g *Game) Draw(screen *ebiten.Image) {
15	//背景图
16	op := &ebiten.DrawImageOptions{}
17	screen.DrawImage(resouse.Images["background.png"], op)
18	g.mu.Lock()
19	for v := range g.hero.Bullets {
20		if v.Y < -10 {
21			delete(g.hero.Bullets, v)
22		}
23		v.Draw(screen)
24	}
25	for v := range g.airPlanes {
26		if v.Y > 860 {
27			delete(g.airPlanes, v)
28		}
29		v.Draw(screen)
30	}
31	g.mu.Unlock()
32	g.hero.Draw(screen)
33}

效果

碰撞检测

现在已经有了子弹发射和敌机,当子弹与敌机重叠时,我们删除这两个元素,实现碰撞检测。

碰撞检测函数

1func checkCollision(airplane *entity.AirPlane, bullet *entity.Bullet) bool {
2	return !(airplane.X > bullet.X+float64(bullet.Weight) ||
3		airplane.X+float64(airplane.Weight) < bullet.X ||
4		airplane.Y > bullet.Y+float64(bullet.Height) ||
5		airplane.Y+float64(airplane.Height) < bullet.Y)
6}

在Draw中加入判断逻辑

1for air := range g.airPlanes {
2		for bul := range g.hero.Bullets {
3			if checkCollision(air, bul) {
4				delete(g.hero.Bullets, bul)
5				delete(g.airPlanes, air)
6			}
7		}
8	}

效果

在web上运行

wasm不支持读取本地静态文件,所以我们需要将图片编译到wasm里面,这里我们用到Ebitengine官方开发的工具file2byteslice,它可以将静态资源转换为go文件以byte保存。

1git clone https://github.com/hajimehoshi/file2byteslice
2cd ./file2byteslice/cmd/file2byteslice
3go build -o file2byteslice.exe

编译好的程序就可以实现我们想要的功能了。

1file2byteslice -input INPUT_FILE -output OUTPUT_FILE -package PACKAGE_NAME -var VARIABLE_NAME
2INPUT_FILE 输入的资源文件
3OUTPUT_FILE 输入的Go文件
4PACKAGE_NAME 包名
5VARIABLE_NAME 变量名

引用示例

1	img, _, _ := ebitenutil.NewImageFromReader(bytes.NewReader(assets.ImgAirPlane))

总结

使用Ebitengine写一个小游戏还是比较简单的,或许开发小游戏,对于编程初学者来说有很强的正反馈,我们可以用代码操作游戏世界的事物,感受编程乐趣。

后续可能会研究一下联机游戏的实现思路,Go语言做网络游戏的服务端应该还是比较合适的,无论从开发效率还是从性能上来看。