💧 Posted on 

用 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
{
"maxBulletNum": 10
}
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 {
// Game结构中的map用来存储怪物对象
monsters map[*Monster]struct{}
}

func NewGame() *Game {
g := &Game{
// 创建map
monsters: make(map[*Monster]struct{}),
}
// 调用 CreateMonsters 创建一组怪物
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
// CheckCollision 检查子弹和怪物之间是否有碰撞
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
// CheckCollision 检查两个物体之间是否有碰撞
func CheckCollision(entity1, entity2 Entity) bool {
// ps: 这里判断时需要注意两个实体的大小,小的在前,大的在后
// ps:判断逻辑是以大实体框定范围,判断小实体是否在这个范围内。(子弹可以在怪物体内,但是怪物不一定在子弹体内)
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))
// print the score
ebitenutil.DebugPrintAt(screen, scoreStr.String(), 0, g.cfg.ScreenHeight-20)

case ModeOver:
// ...
}

// ...
}

总结

至此一个简答的游戏就做出来了。可以看出使用 ebitengine 做一个游戏还是很简单的,非常推荐尝试呢!上手之后,建议看看官方仓库 examples 目录中的示例,会非常有帮助。

大家可以亲自上手尝试一下,如果想要本地调试,可参考如下项目

如果想要将项目打包并在网页中运行,可参考 大俊一起用Go来做一个游戏(下)