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语言做网络游戏的服务端应该还是比较合适的,无论从开发效率还是从性能上来看。