用 Ebitengine 制作一款简易射击游戏
在上一篇 Ebitengine - 一款Go语言编写的2D游戏引擎 中,已经了解了 Ebitengine 的一些基本用法。下面我们将使用 Ebitengie + Go 写一款简易的射击游戏。
限制飞船的活动范围 上一篇文章还留了个尾巴,体验过的同学应该发现了:飞船可以移动出屏幕!!!现在我们就来限制一下飞船的移动范围。我们规定飞船可以左右超过半个身位,如下图所示:
很容易计算得出,左边位置的 x 坐标为:
右边位置的坐标为:
修改 input.go 的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func (g *Game) Update() error { if ebiten.IsKeyPressed(ebiten.KeyLeft) { g.ship.x -= g.cfg.ShipSpeedFactor if g.ship.x < -float64 (g.ship.width)/2 { g.ship.x = -float64 (g.ship.width) / 2 } } else if ebiten.IsKeyPressed(ebiten.KeyRight) { g.ship.x += g.cfg.ShipSpeedFactor if g.ship.x > float64 (g.cfg.ScreenWidth)-float64 (g.ship.width)/2 { g.ship.x = float64 (g.cfg.ScreenWidth) - float64 (g.ship.width)/2 } } }
飞船可以发射子弹 这里进行简化,我们不另外准备子弹的图片,直接画一个矩形就 ok。为了可以灵活控制,我们将子弹的宽、高、颜色以及速率都用配置文件来控制:
1 2 3 4 5 6 7 8 9 10 11 { "bulletWidth" : 3 , "bulletHeight" : 15 , "bulletSpeedFactor" : 2 , "bulletColor" : { "r" : 80 , "g" : 80 , "b" : 80 , "a" : 255 } }
新增一个文件 bullet.go,定义子弹的结构类型和 New 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 type Bullet struct { image *ebiten.Image width int height int x float64 y float64 speedFactor float64 } func NewBullet (cfg *Config, ship *Ship) *Bullet { rect := image.Rect(0 , 0 , cfg.BulletWidth, cfg.BulletHeight) img := ebiten.NewImageWithOptions(rect, nil ) img.Fill(cfg.BulletColor) return &Bullet{ image: img, width: cfg.BulletWidth, height: cfg.BulletHeight, x: ship.x + float64 (ship.width-cfg.BulletWidth)/2 , y: float64 (cfg.ScreenHeight - ship.height - cfg.BulletHeight), speedFactor: cfg.BulletSpeedFactor, } }
首先根据配置的宽高创建一个 rect 对象,然后调用ebiten.NewImageWithOptions
创建一个*ebiten.Image
对象。
子弹都是从飞船头部发出的,所以它的横坐标等于飞船中心的横坐标,左上角的纵坐标 = 屏幕高度 - 飞船高 - 子弹高。
随便增加子弹的绘制方法:
1 2 3 4 5 func (bullet *Bullet) Draw(screen *ebiten.Image) { op := &ebiten.DrawImageOptions{} op.GeoM.Translate(bullet.x, bullet.y) screen.DrawImage(bullet.image, op) }
我们在 Game 对象中增加一个 map 来管理子弹:
1 2 3 4 5 6 7 8 9 10 11 type Game struct { bullets map [*Bullet]struct {} } func NewGame () *Game { return &Game{ bullets: make (map [*Bullet]struct {}), } }
然后在Draw
方法中,我们需要将子弹也绘制出来:
1 2 3 4 5 6 7 func (g *Game) Draw(screen *ebiten.Image) { screen.Fill(g.cfg.BgColor) g.ship.Draw(screen) for bullet := range g.bullets { bullet.Draw(screen) } }
子弹位置如何更新呢?在Game.Update
中更新,与飞船类似,只是飞船只能水平移动,而子弹只能垂直移动。
1 2 3 4 5 6 func (g *Game) Update() error { for bullet := range g.bullets { bullet.y -= bullet.speedFactor } }
子弹的更新、绘制逻辑都完成了,可是我们还没有子弹!现在我们就来实现按空格发射子弹的功能。我们需要在 Update
方法中判断空格键是否按下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func (g *Game) Update() error { if ebiten.IsKeyPressed(ebiten.KeyLeft) { } else if ebiten.IsKeyPressed(ebiten.KeyRight) { } else if ebiten.IsKeyPressed(ebiten.KeySpace) { if len (g.bullets) < g.cfg.MaxBulletNum && time.Now().Sub(g.ship.lastBulletTime).Milliseconds() > g.cfg.BulletInterval { bullet := NewBullet(g.cfg, g.ship) g.addBullet(bullet) g.ship.lastBulletTime = time.Now() } } }
给 Game 对象增加一个addBullet
方法:
1 2 3 func (g *Game) addBullet(bullet *Bullet) { g.bullets[bullet] = struct {}{} }
这里要注意,飞船移动和发射子弹是两个独立的事件,不能将 KeyLeft/KeyRight/KeySpace
在同一个 if-else 中处理
限制子弹数量 为了防止子弹太多,通过配置 g.cfg.MaxBulletNum
来限制子弹的数量(屏幕中仅允许出现固定数量的子弹)
1 2 3 type Config struct { MaxBulletNum int `json:"maxBulletNum"` }
然后我们在 Update
方法中判断,如果目前存在的子弹数小于MaxBulletNum
才能创建新的子弹。
同时由于 Update()
的调用间隔太短了,导致我们一次 space 按键会发射多个子弹。可以控制两个子弹之间的时间间隔。同样用配置文件来控制(单位毫秒)
距离上次发射子弹的时间大于 BulletInterval
毫秒,才能再次发射,发射成功之后更新时间:
1 2 3 { "bulletInterval" : 50 }
1 2 3 type Config struct { BulletInterval int64 `json:"bulletInterval"` }
当子弹消失(飞出屏幕之外)后,需要把离开屏幕的子弹删除。否则无法发射新的子弹。
在 Bullet
中添加判断是否处于屏幕外的方法:
1 2 3 func (bullet *Bullet) outOfScreen() bool { return bullet.y < -float64 (bullet.height) }
在 Update
函数中移除子弹:
1 2 3 4 5 6 7 8 func (g *Game) Update() error { for bullet := range g.bullets { if bullet.outOfScreen() { delete (g.bullets, bullet) } } return nil }
新增怪物 怪物图片如下:
同飞船一样,编写 Monster 类,添加绘制自己的方法:
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 type Monster struct { image *ebiten.Image width int height int x float64 y float64 speedFactor float64 } func NewMonster (cfg *Config) *Monster { img, _, err := ebitenutil.NewImageFromFile("../images/monster.png" ) if err != nil { log.Fatal(err) } width, height := img.Size() return &Monster{ image: img, width: width, height: height, x: 0 , y: 0 , speedFactor: cfg.MonsterSpeedFactor, } } func (monster *Monster) Draw(screen *ebiten.Image) { op := &ebiten.DrawImageOptions{} op.GeoM.Translate(monster.x, monster.y) screen.DrawImage(monster.image, op) }
游戏开始时需要创建一组怪物,计算一行可以容纳多少个怪物,考虑到左右各留一定的空间,两个怪物之间留一点空间。
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 type Game struct { monsters map [*Monster]struct {} } func NewGame () *Game { g := &Game{ monsters: make (map [*Monster]struct {}), } g.CreateMonsters() return g } func (g *Game) CreateMonsters() { monster := NewMonster(g.cfg) availableSpaceX := g.cfg.ScreenWidth - 2 *monster.width numMonsters := availableSpaceX / (2 * monster.width) for i := 0 ; i < numMonsters; i++ { monster = NewMonster(g.cfg) monster.x = float64 (monster.width + 2 *monster.width*i) g.addMonster(monster) } }
左右各留一个怪物宽度的空间:
1 availableSpaceX := g.cfg.ScreenWidth - 2*monster.width
然后,两个怪物之间留一个怪物宽度的空间。所以一行可以创建的怪物的数量为:
1 numMonsters := availableSpaceX / (2 * monster.width)
创建一组怪物,依次排列。
同样地,我们需要在Game.Draw
方法中添加绘制怪物的代码:
1 2 3 4 5 6 func (g *Game) Draw(screen *ebiten.Image) { // -------省略------- for monster := range g.monsters { monster.Draw(screen) } }
再创建两行:
1 2 3 4 5 6 7 8 9 10 11 func (g *Game) CreateMonsters() { // -------省略------- for row := 0; row < 2; row++ { for i := 0; i < numMonsters; i++ { monster = NewMonster(g.cfg) monster.x = float64(monster.width + 2*monster.width*i) monster.y = float64(monster.height*row) * 1.5 g.addMonster(monster) } } }
让怪物都动起来,同样地还是在Game.Update
方法中更新位置:
1 2 3 4 5 6 7 func (g *Game) Update() error { // -------省略------- for monster := range g.monsters { monster.y += monster.speedFactor } // -------省略------- }
新增判断游戏胜负逻辑 当前子弹碰到怪物直接穿过去了,我们希望能击杀怪物。这需要检查子弹和怪物之间的碰撞。新增检查子弹与怪物是否碰撞的检查函数。这里采用最直观的碰撞检测方法,即子弹的 4 个顶点只要有一个位于怪物矩形中,就认为它们碰撞。
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 func CheckCollision (bullet *Bullet, monster *Monster) bool { monsterTop, monsterLeft := monster.y, monster.x monsterBottom, monsterRight := monster.y+float64 (monster.height), monster.x+float64 (monster.width) x, y := bullet.x, bullet.y if y > monsterTop && y < monsterBottom && x > monsterLeft && x < monsterRight { return true } x, y = bullet.x+float64 (bullet.width), bullet.y if y > monsterTop && y < monsterBottom && x > monsterLeft && x < monsterRight { return true } x, y = bullet.x, bullet.y+float64 (bullet.height) if y > monsterTop && y < monsterBottom && x > monsterLeft && x < monsterRight { return true } x, y = bullet.x+float64 (bullet.width), bullet.y+float64 (bullet.height) if y > monsterTop && y < monsterBottom && x > monsterLeft && x < monsterRight { return true } return false }
接着我们在Game.Update
方法中调用这个方法,并且将碰撞的子弹和怪物删除。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func (g *Game) CheckCollision() { for monster := range g.monsters { for bullet := range g.bullets { if CheckCollision(bullet, monster) { delete (g.monsters, monster) delete (g.bullets, bullet) } } } } func (g *Game) Update() error { g.CheckCollision() return nil }
注意将碰撞检测放在位置更新之后。
新增游戏界面 现在一旦运行程序,怪物们就开始运动了。我们想要增加一个按下空格键才开始的功能,并且游戏结束之后,我们也希望能显示一个 Game Over 的界面。首先,我们定义几个常量,表示游戏当前所处的状态:
1 2 3 4 5 6 type Mode int const ( ModeTitle Mode = iota ModeGame ModeOver )
Game 结构中需要增加 mode 字段表示当前游戏所处的状态:
1 2 3 4 type Game struct { mode Mode }
为了显示开始界面,涉及到文字的显示,文字显示和字体处理起来都比较麻烦。ebitengine 内置了一些字体,我们可以据此创建几个字体对象:
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 32 33 34 35 36 37 var ( titleArcadeFont font.Face arcadeFont font.Face smallArcadeFont font.Face ) func (g *Game) CreateFonts() { tt, err := opentype.Parse(fonts.PressStart2P_ttf) if err != nil { log.Fatal(err) } const dpi = 72 titleArcadeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{ Size: float64 (g.cfg.TitleFontSize), DPI: dpi, Hinting: font.HintingFull, }) if err != nil { log.Fatal(err) } arcadeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{ Size: float64 (g.cfg.FontSize), DPI: dpi, Hinting: font.HintingFull, }) if err != nil { log.Fatal(err) } smallArcadeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{ Size: float64 (g.cfg.SmallFontSize), DPI: dpi, Hinting: font.HintingFull, }) if err != nil { log.Fatal(err) } }
fonts.PressStart2P_ttf
就是 ebitengine 提供的字体。创建字体的方法一般在需要的时候微调即可。将创建怪物和字体封装在 Game 的 init 方法中:
1 2 3 4 5 6 7 8 9 10 func (g *Game) init() { g.CreateMonsters() g.CreateFonts() } func NewGame () *Game { g.init() return g }
启动时游戏处于 ModeTitle 状态,处于 ModeTitle 和 ModeOver 时只需要在屏幕上显示一些文字即可。只有在 ModeGame 状态才需要显示飞船和怪物:
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 func (g *Game) Draw(screen *ebiten.Image) { screen.Fill(g.cfg.BgColor) var titleTexts []string var texts []string switch g.mode { case ModeTitle: titleTexts = []string {"ALIEN INVASION" } texts = []string {"" , "" , "" , "" , "" , "" , "" , "PRESS SPACE KEY" , "" , "OR LEFT MOUSE" } case ModeGame: g.ship.Draw(screen) for bullet := range g.bullets { bullet.Draw(screen) } for monster := range g.monsters { monster.Draw(screen) } case ModeOver: texts = []string {"" , "GAME OVER!" } } for i, l := range titleTexts { x := (g.cfg.ScreenWidth - len (l)*g.cfg.TitleFontSize) / 2 text.Draw(screen, l, titleArcadeFont, x, (i+4 )*g.cfg.TitleFontSize, color.White) } for i, l := range texts { x := (g.cfg.ScreenWidth - len (l)*g.cfg.FontSize) / 2 text.Draw(screen, l, arcadeFont, x, (i+4 )*g.cfg.FontSize, color.White) } }
在Game.Update
方法中,我们判断在 ModeTitle 状态下按下空格,鼠标左键游戏开始,切换为 ModeGame 状态。游戏结束时切换为 GameOver 状态,在 GameOver 状态后按下空格或鼠标左键即重新开始游戏。
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 func (g *Game) Update() error { switch g.mode { case ModeTitle: if ebiten.IsKeyPressed(ebiten.KeySpace) || ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) { g.mode = ModeGame } case ModeGame: for bullet := range g.bullets { bullet.y -= bullet.speedFactor } for monster := range g.monsters { monster.y += monster.speedFactor } if ebiten.IsKeyPressed(ebiten.KeyLeft) { g.ship.x -= g.cfg.ShipSpeedFactor if g.ship.x < -float64 (g.ship.width)/2 { g.ship.x = -float64 (g.ship.width) / 2 } } else if ebiten.IsKeyPressed(ebiten.KeyRight) { g.ship.x += g.cfg.ShipSpeedFactor if g.ship.x > float64 (g.cfg.ScreenWidth)-float64 (g.ship.width)/2 { g.ship.x = float64 (g.cfg.ScreenWidth) - float64 (g.ship.width)/2 } } if ebiten.IsKeyPressed(ebiten.KeySpace) { if len (g.bullets) < g.cfg.MaxBulletNum && time.Now().Sub(g.ship.lastBulletTime).Milliseconds() > g.cfg.BulletInterval { bullet := NewBullet(g.cfg, g.ship) g.addBullet(bullet) g.ship.lastBulletTime = time.Now() } } g.CheckCollision() for bullet := range g.bullets { if bullet.outOfScreen() { delete (g.bullets, bullet) } } for monster := range g.monsters { if monster.outOfScreen(g.cfg) { g.failCount++ delete (g.monsters, monster) continue } if CheckCollision(g.ship, monster) { log.Print("---- 飞船碰撞怪物 ----" ) g.failCount++ delete (g.monsters, monster) continue } } if g.failCount >= g.cfg.FailCount { g.overMsg = "Game Over!" } else if len (g.monsters) == 0 { g.overMsg = "You Win!" } if len (g.overMsg) > 0 { g.mode = ModeOver g.monsters = make (map [*Monster]struct {}) g.bullets = make (map [*Bullet]struct {}) } case ModeOver: if ebiten.IsKeyPressed(ebiten.KeySpace) || ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) { g.init() g.mode = ModeTitle } } return nil }
新增判断游戏胜负逻辑 我们规定如果击杀一定怪物则游戏胜利,有 failCount
个怪物移出屏幕外或者碰撞到飞船则游戏失败。
首先增加一个字段failCount
用于记录移出屏幕外的怪物数量和与飞船碰撞的怪物数量之和:
1 2 3 4 type Game struct { failCount int }
然后我们在Game.Update
方法中检测怪物是否移出屏幕,以及是否与飞船碰撞:
1 2 3 4 5 6 7 8 9 10 11 12 13 for monster := range g.monsters { if monster.outOfScreen(g.cfg) { g.failCount++ delete (g.monsters, monster) continue } if CheckCollision(monster, g.ship) { g.failCount++ delete (g.monsters, monster) continue } }
这里有一个问题,还记得吗?我们前面编写的CheckCollision
函数接受的参数类型是*Monster
和*Bullet
,这里我们需要重复编写接受参数为*Ship
和*Monster
的函数吗?不用!
我们将表示游戏中的实体对象抽象成一个GameObject
结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 type GameObject struct { width int height int x float64 y float64 } func (gameObj *GameObject) Width() int { return gameObj.width } func (gameObj *GameObject) Height() int { return gameObj.height } func (gameObj *GameObject) X() float64 { return gameObj.x } func (gameObj *GameObject) Y() float64 { return gameObj.y }
然后定义一个接口Entity
:
1 2 3 4 5 6 type Entity interface { Width() int Height() int X() float64 Y() float64 }
最后让我们游戏中的实体内嵌这个GameObject
对象,即可自动实现Entity
接口:
1 2 3 4 5 type Monster struct { GameObject image *ebiten.Image speedFactor float64 }
这样CheckCollision
即可改为接受两个Entity
接口类型的参数:
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 32 func CheckCollision (entity1, entity2 Entity) bool { top, left := entity1.Y(), entity1.X() bottom, right := entity1.Y()+float64 (entity1.Height()), entity1.X()+float64 (entity1.Width()) x, y := entity2.X(), entity2.Y() if y > top && y < bottom && x > left && x < right { return true } x, y = entity2.X()+float64 (entity2.Width()), entity2.Y() if y > top && y < bottom && x > left && x < right { return true } x, y = entity2.X(), entity2.Y()+float64 (entity2.Height()) if y > top && y < bottom && x > left && x < right { return true } x, y = entity2.X()+float64 (entity2.Width()), entity2.Y()+float64 (entity2.Height()) if y > top && y < bottom && x > left && x < right { return true } return false }
如果游戏失败则切换为 ModeOver 模式,屏幕上显示 “Game Over!”。如果游戏胜利,则显示 “You Win!”:
1 2 3 4 5 6 7 8 9 10 11 if g.failCount >= g.cfg.FailCount { g.overMsg = "Game Over!" } else if len (g.monsters) == 0 { g.overMsg = "You Win!" } if len (g.overMsg) > 0 { g.mode = ModeOver g.monsters = make (map [*Monster]struct {}) g.bullets = make (map [*Bullet]struct {}) }
注意,为了下次游戏能顺序进行,这里需要清除所有的子弹和怪物。运行:
新增记分板 在游戏界面中显示分数(击杀怪物数量 )
在 Game 中新增分数
1 2 3 4 type Game struct { score int }
击杀怪物时,分数增加
1 2 3 4 5 6 7 8 9 10 11 12 func (g *Game) CheckCollision() { for monster := range g.monsters { for bullet := range g.bullets { if CheckCollision(monster, bullet) { log.Print("---- 子弹击中怪物 ----" ) g.score++ delete (g.monsters, monster) delete (g.bullets, bullet) } } } }
在屏幕中,将分数显示出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func (g *Game) Draw(screen *ebiten.Image) { switch g.mode { case ModeTitle: case ModeGame: scoreStr := strings.Builder{} scoreStr.WriteString("scores: " ) scoreStr.WriteString(strconv.Itoa(g.score)) ebitenutil.DebugPrintAt(screen, scoreStr.String(), 0 , g.cfg.ScreenHeight-20 ) case ModeOver: } }
总结 至此一个简答的游戏就做出来了。可以看出使用 ebitengine 做一个游戏还是很简单的,非常推荐尝试呢!上手之后,建议看看官方仓库 examples 目录中的示例,会非常有帮助。
大家可以亲自上手尝试一下,如果想要本地调试,可参考如下项目
如果想要将项目打包并在网页中运行,可参考 大俊 的一起用Go来做一个游戏(下)