ebiten 是啥

Ebiten 是一个用 Go 编程语言编写的开源游戏库,专门用于开发 2D 游戏。它简单易用,适合创建跨平台的游戏,支持 Windows、macOS、Linux、iOS 和 Android 等平台。Ebiten 提供了渲染、输入处理、音频播放等功能,帮助开发者快速构建和发布游戏。

主要特点

  1. 简单易用:提供了简洁的 API,开发者可以快速上手编写 2D 游戏,不需要深入了解复杂的图形编程。
  2. 跨平台支持:一个代码库可以编译和运行在多个平台上,包括桌面、移动设备和网页浏览器。
  3. 高效的渲染:Ebiten 利用底层的 OpenGL 或 Metal API 实现了高效的 2D 渲染,能够处理游戏中的大量图形操作。
  4. 集成输入处理:支持键盘、鼠标、触摸屏、手柄等多种输入方式,可以轻松实现用户交互。
  5. 游戏循环:提供了简单的游戏主循环,开发者只需专注于每一帧的游戏逻辑和渲染。

快速上手 ebiten

在 Ebiten 中,游戏的核心是一个实现了 ebiten.Game 接口的结构体。该接口的原型如下:

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

其中:

Update() 用于更新游戏数据,这个方法会在每一帧调用。所有与游戏逻辑相关的内容都应该在这个方法中完成。

Draw() 用于绘制游戏画面,这个方法会在每一帧调用。该方法不负责任何游戏逻辑,只负责将当前的游戏状态绘制到屏幕上。

Layout() 用于设置游戏屏幕的逻辑尺寸。这个方法会在用户调整窗口大小的时候调用,通过这个方法可以指定游戏应该使用哪种逻辑尺寸来渲染内容。

通过以上三个方法,就已经满足了 ebiten 游戏运行起来的基础。举个简单的例子(以下例子中省略了导包):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

struct Game struct{}

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

func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(color.RGBA{0, 0, 0, 255})
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
// 返回固定的逻辑分辨率,不管窗口多大
return 640, 480
}

func main(){
game := Game{}
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}

运行该程序,将会使用白色填充整个背景,由于 update 方法中没有任何游戏数据的改动,所以该程序只会一直显示白色。

以下在 Game 结构中加一个变量,用来控制背景颜色,演示一下更新和绘制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

type Game struct{
ColorR int
ColorG int
ColorB int
}

func (g *Game) Update() error {
g.ColorR = rand.IntN(255)
g.ColorG = rand.IntN(255)
g.ColorB = rand.IntN(255)
return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(color.RGBA{g.ColorR, g.ColorG, g.colorB, 255})
}

运行以上修改后的程序,游戏界面的背景将会每一帧随机变化。

手搓炸弹人

介绍完了 ebiten 的基本用法,接下来开始这篇博客的主要内容,使用 ebiten 开发一个炸弹人游戏。

内容提要

首先介绍一下炸弹人游戏的规则,每个玩家将会扮演一个炸弹人,炸弹人可以使用手中拥有的道具,攻击其他玩家,当只有一个玩家存活时,该玩家为赢家。默认情况下,攻击道具为炸弹,炸弹爆炸范围可以通过道具进行提升。其他的攻击道具一般为拓展玩法,在本篇博客中不作实现。

通过以上游戏规则,提取出以下几个对象:

  1. 玩家
  2. 炸弹
  3. 道具
  4. 墙体

玩家需要实现的功能:

  1. 移动
  2. 放置炸弹
  3. 吃道具

炸弹需要实现的功能:

  1. 爆炸
  2. 生成爆炸特效

道具需要实现的功能:

  1. 给玩家增加属性

障碍需要实现的功能:

  1. 阻碍玩家前进
  2. 破坏后生成道具

OK,完成以上内容提要,我们真正进入开发阶段。

游戏框架搭建

首先需要实现 Game 对象,我们将本项目命名为 bombman,包名也是用 bombman。

game 对象一般为全局对象,用来操控整个游戏的生命周期,对整个游戏的资源进行管理和更新,所以在 game 对象中,我们需要维护这个游戏中所有的对象,并且可以对这些对象进行更新。

1
2
3
type Game struct {
Map *Map
}

以上,我们只对类型进行定义,不制定完整的属性。其中,Map 维护游戏的地图属性,玩家、炸弹、道具、障碍等都会在地图上进行绘制和展示。

接着,实现一个创建游戏对象的函数。

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

同上,以上的各个 New 方法,在此仅做展示,在讲到指定章节的内容时,再做具体的实现。

然后,需要实现 ebiten.Game 接口的三个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (g *Game) Update() error {
g.Map.Update()
return nil
}

func (g *Game)Draw(screen *ebiten.Image){
g.Map.Draw(screen)
}

const (
ScreenWidth = 600
ScreenHeight = 600
)

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

以上,框架就基本搭建好了,后续只需要完成子对象的实现,再回到此处,完善对象的处理细节即可。

地图实现

炸弹人游戏的地图,是一个典型的瓦片地图。将完整的地图分割为若干个正方形瓦片,每个瓦片有自己的横纵坐标。炸弹、障碍、道具等只能位于每个瓦片的正中心位置,而玩家则可以没有限制地在整个地图上行动。

我们可以先定义一个坐标对象,标识其在地图上的位置,并实现判断这是否是一个瓦片坐标的方法。

在此之前,我们还需要先定义一个瓦片的边长。

1
const TileLength float64 = 20

接着,定义一个坐标点对象及其方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Coordinate [2]float64

func NewCoordinate(x, y float64) *Coordinate {
return &Coordinate{x, y}
}
func (coo *Coordinate) X() float64 {
return coo[0]
}
func (coo *Coordinate) Y() float64 {
return coo[1]
}
func (coo *Coordinate) IsTilePoint() bool {
int64X, int64Y := int64(coo[0]), int64(coo[1])
if float64(int64X) != coo[0] || float64(int64Y) != coo[1] {
return false
}
if int64X%TileLength == 0 && int64Y%TileLength == 0 {
return true
}
return false
}

坐标类型定义后,定义地图类型,并创建初始化函数。

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
// Map 地图对象,包含一个全局的瓦片集合以及玩家对象列表
type Map struct {
Tiers *Tiers
Players []*Player
}

// Tiers 瓦片集合对象,用来存取记录每个瓦片中的内容
type Tiers struct {
*sync.Map
}

func (t *Tiers) Get(coo Coordinate) Obstacle {
value, ok := t.m.Load(coo)
if !ok {
return nil
}
return value.(Obstacle)
}
func (t *Tiers) Set(coo Coordinate, val Obstacle) {
t.m.Store(coo, val)
}
func (t *Tiers) Del(coo Coordinate) {
t.m.Delete(coo)
}

// NewMap 新建一个地图对象
func NewMap(players []*Player) *Map {
tiers := InitMap1()
return &Map{
Tiles: tiers,
Players: players,
}
}

// InitMap1 使用 Map1 预设初始化地图(后续完善)
func InitMap1() *Tiers {
return &Tiers{}
}

炸弹实现

炸弹的功能就是爆炸,需要摧毁所在位置上下左右四个方向炸弹威力覆盖范围内的东西。

代码实现:

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 Bomb struct {
X, Y float64 // 炸弹位置
FireXBegin, FireXEnd float64 // 横向的火力蔓延区域
FireYBegin, FireYEnd float64 // 纵向的火力蔓延区域
Power int64 // 炸弹的威力
ExplodeTime time.Time // 炸弹的爆炸时间
}

func NewBomb(x, y float64, power int) *Bomb {
add := float64(power) * float64(TileLength)
xBegin, xEnd := math.Max(x-add, 0), math.Min(x+add, ScreenWidth)
yBegin, yEnd := math.Max(y-add, 0), math.Min(y+add, ScreenHeight)
return &Bomb{
X: x,
Y: y,
FireXBegin: xBegin,
FireXEnd: xEnd,
FireYBegin: yBegin,
FireYEnd: yEnd,
Power: power,
ExplodeTime: time.Now().Add(3 * time.Second), // 引线时间为 3s
}
}

func (b *Bomb) Explode() {
// 其他对象暂未实现,炸弹效果留待后续实现
}

玩家实现

玩家最基本的两个方法:1. 移动 2. 放置炸弹。

基于此,我们来定义一个玩家的结构体:

1
2
3
4
5
6
7
8
9

type Player struct {
X, Y float64 // 位置
Speed float64 // 移动速度
BombCount int // 持有炸弹数
BombPower int // 炸弹威力
Bombs map[*Bomb]struct{} // 炸弹集合
}

玩家的形象一般采用贴图,因此,在结构体中还需要存储贴图数据,以及贴图的宽高。

1
2
3
4
5
type Player struct {
// ... 原有的省略
Img *ebiten.Image
W, H float64 // 宽高
}

之后,通过 New 方法来创建一个玩家对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func NewPlayer(imgPath string, playerNum uint8) *Player {
img, _, _ := ebitenutil.NewImageFromFile(imgPath)
bounds := img.Bounds()
x := (float64(TileLength) - float64(bounds.Dx())) / 2
y := (float64(TileLength) - float64(bounds.Dy())) / 2
if playerNum == 2 {
x = float64(ScreenWidth) - float64(TileLength) + (float64(TileLength)-float64(bounds.Dx()))/2
y = float64(ScreenWidth) - float64(TileLength) + (float64(TileLength)-float64(bounds.Dy()))/2
}
return &Player{
Img: img,
W: float64(bounds.Dx()),
H: float64(bounds.Dy()),
X: x,
Y: y,
Speed: 2,
BombCount: 1,
BombPower: 1,
Bombs: make(map[*Bomb]struct{}),
}
}

接着,实现一个玩家移动的方法:

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
// Direction 方向
type Direction uint8

const (
MoveDirectionL Direction = iota // 左
MoveDirectionR // 右
MoveDirectionU // 上
MoveDirectionD // 下
)

func (p *Player) Move(direction Direction) {
// 为了使屏幕上玩家的位置连贯,玩家移动前后的坐标位置差值不可太大,不然会出现玩家瞬移的情况
// 玩家的速度属性单位为一个像素点,最大值为一个瓦片的大小,即玩家一帧最多可以移动 TileLength
add := math.Min(float64(TileLength), p.Speed)
switch direction {
case MoveDirectionL:
p.X -= add
case MoveDirectionR:
p.X += add
case MoveDirectionU:
p.Y -= add
case MoveDirectionD:
p.Y += add
}
return
}

接在,再实现放置炸弹的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// NewBomb 放置炸弹
func (p *Player) NewBomb() {
if len(p.Bombs) >= p.BombCount {
return
}
// 炸弹只能放置在瓦片中心,因此需要查找玩家当前占据空间最大的一个瓦片,在该瓦片放置炸弹
closestTile := findClosestTile(p.X, p.Y, p.W, p.H)
p.Bombs = append(p.Bombs, NewBomb(closestTile.X(), closestTile.Y(), p.BombPower))
}

// findClosestTile 寻找当前占据空间最大的一个瓦片
func findClosestTile(x, y float64, w, h float64) *Coordinate {
// 中心点坐标
centerX := x + w
centerY := y + h

tileLenFloat64 := float64(TileLength)
nearestTileX := math.Floor(centerX/tileLenFloat64) * tileLenFloat64
nearestTileY := math.Floor(centerY/tileLenFloat64) * tileLenFloat64

return NewCoordinate(nearestTileX, nearestTileY)
}

障碍物实现

游戏中的障碍有多种,比如说:

  1. 不可摧毁的墙
  2. 可摧毁的墙
  3. 道具
  4. 炸弹

这些障碍与上文的两个对象之间是需要有碰撞的,并发生各自的效果的,比如说墙会阻碍玩家前进,道具需要给玩家增加属性,炸弹会摧毁墙,导致玩家死亡等等。因此,将障碍抽象为一个接口,所有障碍都需要实现这个接口,并描述与玩家和炸弹碰撞之后的行为。

1
2
3
4
type Obstacle interface {
PlayerCollisionEffect() func(p *Player) // 玩家碰撞行为(玩家为主体,表示的是玩家与障碍原型之间的碰撞)
BombCollisionEffect(b *Bomb) // 炸弹碰撞行为(此处的炸弹碰撞表示的是炸弹爆炸后的碰撞)
}

接口包含两个方法,分别描述为与玩家的碰撞效果和与炸弹的碰撞效果。

玩家与炸弹,炸弹与炸弹之间也是有碰撞行为的,因此,我们可以先回过头,让炸弹对象实现该接口。

1
2
3
4
5
6
7
8
func (b *Bomb) PlayerCollisionEffect() func(p *Player) {
return nil
}

func (b *Bomb) BombCollisionEffect(sourceB *Bomb) {
b.ExplodeTime = time.Now() // 使其他炸弹立即爆炸
return
}

其他对象的接口实现,我们就放在具体的篇章中再作实现。

道具实现

道具说白了就是用来增长用户属性的东西,而目前设计上,用户的属性有速度、炸弹数量和炸弹威力,因此可以设计出三种道具,分别是增速鞋子、威力药水和炸弹。

不同的道具肯定是有不同的贴图的,所以道具结构可以这么设计:

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
const (
ImgPathPropAddSpeed = "" // 此处用来设置贴图的存放位置
ImgPathPropAddBombCount = ""
ImgPathPropAddBombPower = ""
)

var (
ImgPropAddSpeed, _, _ = ebitenutil.NewImageFromFile(ImgPathPropAddSpeed)
ImgPropAddBombCount, _, _ = ebitenutil.NewImageFromFile(ImgPathPropAddBombCount)
ImgPropAddBombPower, _, _ = ebitenutil.NewImageFromFile(ImgPathPropAddBombPower)
)

type Prop struct {
Img *ebiten.Image
Coo Coordinate
Effect func(p *Player)
}

func EffectAddSpeed(p *Player) {
p.Speed++
}
func EffectAddBombCount(p *Player) {
p.BombCount++
}
func EffectAddBombPower(p *Player) {
p.BombPower++
}

type PropFlag uint8

const (
PropAddSpeedFlag PropFlag = iota
PropAddBombCountFlag
PropAddBombPowerFlag
)

func NewProp(flag PropFlag) *Prop {
prop := &Prop{
Coo: NewCoordinate(x, y),
}
switch flag {
case PropAddSpeedFlag:
prop.Img = ImgPropAddSpeed
prop.Effect = EffectAddSpeed
case PropAddBombCountFlag:
prop.Img = ImgPropAddBombCount
prop.Effect = EffectAddBombCount
case PropAddBombPowerFlag:
prop.Img = ImgPropAddBombPower
prop.Effect = EffectAddBombPower
}
return prop
}

// RandomProp 随机生成道具的函数
func RandomProp(x, y float64) *Prop {
return NewProp(PropFlag(rand.Intn(10)), x, y)
}

接着,道具对象需要实现障碍接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Prop struct {
// ...
IsDestroy bool
}

func (p *Prop) PlayerCollisionEffect(player *Player) func(p *Player){
return func(player *Player) {
p.Effect(player)
p.IsDestroy = true
}
}

func (p *Prop) BombCollisionEffect(b *Bomb) {
p.IsDestroy = true
return
}

墙的实现

墙分为可摧毁和不可摧毁,可摧毁的会概率性随机掉落道具,不可摧毁的将会阻止炸弹的火力蔓延。

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
type Wall struct {
Destructible bool
Coo Coordinate
IsDestroyed bool
InnerProp *Prop
}

func NewWall(des bool,x,y float64) *Wall{
return &Wall{
Destructible: des,
Coo: NewCoordinate(x, y),
}
}

func (w *Wall) PlayerCollisionEffect() func(p *Player) {
return nil
}

func (w *Wall) BombCollisionEffect(b *Bomb) {
if w.Destructible {
w.InnerProp = RandomProp(w.Coo.X(), w.Coo.Y())
w.IsDestroyed = true
} else {
// 阻止炸弹火力蔓延
if b.X == w.Coo.X() {
if b.FireYBegin <= w.Coo.Y() && b.Y > w.Coo.Y() {
b.FireYBegin = w.Coo.NearOneTileCoo(DirectionD).Y()
}
if b.FireYEnd >= w.Coo.Y() && b.Y < w.Coo.Y() {
b.FireYEnd = w.Coo.NearOneTileCoo(DirectionU).Y()
}
}
if b.Y == w.Coo.Y() {
if b.FireXBegin <= w.Coo.X() && b.X > w.Coo.X() {
b.FireXBegin = w.Coo.NearOneTileCoo(DirectionR).X()
}
if b.FireXEnd >= w.Coo.X() && b.X < w.Coo.X() {
b.FireXEnd = w.Coo.NearOneTileCoo(DirectionL).X()
}
}
}
return
}

游戏逻辑开发

上文中已经设计完了游戏涉及到的所有对象,接下来,按照一局游戏的流程,对整个游戏的逻辑进行开发。

首先,地图内的墙在一局游戏开始时需要全部生成。

这里,我们预设一张地图,并在游戏开始后调用函数来生成地图资源。

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

// InitMap1 使用 Map1 预设初始化地图
func InitMap1() *Tiers {
t := &Tiers{
Map: &sync.Map{},
}
tlFloat64 := float64(TileLength)
for i := float64(1); i < ScreenWidth/(tlFloat64*2); i += 2 {
for j := float64(1); j < ScreenHeight/(tlFloat64*2); j += 2 {
coo := NewCoordinate(i*tlFloat64*2, j*tlFloat64*2)
t.Set(coo, NewWall(false, coo))
}
}
for i := float64(3); i < ScreenWidth/tlFloat64-3; i += 4 {
for j := float64(3); j < ScreenWidth/tlFloat64-3; j += 4 {
for k := float64(0); k < 3; k++ {
coo := NewCoordinate((i)*tlFloat64, ((j)+k)*tlFloat64)
t.Set(coo, NewWall(true, coo))
}
for k := float64(0); k < 3; k++ {
coo := NewCoordinate(((i)+1)*tlFloat64, ((j)+k)*tlFloat64)
t.Set(coo, NewWall(true, coo))
}
for k := float64(0); k < 3; k++ {
coo := NewCoordinate(((i)+2)*tlFloat64, ((j)+k)*tlFloat64)
t.Set(coo, NewWall(true, coo))
}
}
}
return t
}

地图有了,接下来是如何操控玩家进行移动并放置炸弹。

首先,给每个玩家分配一套键盘映射方案,用来操控角色移动。常用的方案就是 WASD+Space 的组合以及 ↑←↓→+Enter 的组合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Player struct {
// ...
KeyProgramme KeyProgramme
}

type KeyProgramme struct {
MoveL ebiten.Key
MoveR ebiten.Key
MoveU ebiten.Key
MoveD ebiten.Key
Planted ebiten.Key
}

// 键盘操控方案
var (
KeyProgrammeA = KeyProgramme{ebiten.KeyA, ebiten.KeyD, ebiten.KeyW,
ebiten.KeyS, ebiten.KeySpace}
KeyProgrammeB = KeyProgramme{ebiten.KeyLeft, ebiten.KeyRight, ebiten.KeyUp,
ebiten.KeyDown, ebiten.KeyNumpadEnter}
)

在 New 函数中给玩家分配键盘操控方案;

1
2
3
4
5
6
7
8
9
10
11
12
13
func NewPlayer(imgPath string, playerNum uint8) *Player {
// ...
kp := KeyProgrammeA
if playerNum == 2 {
// ...
kp = KeyProgrammeB
}
return &Player{
// ...
KeyProgramme: kp,
}
}

其次,监听键盘事件,实现玩家移动以及炸弹的放置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (p *Player) Update(m *Map) {
if ebiten.IsKeyPressed(p.KeyProgramme.MoveL) {
p.Move(MoveDirectionL)
}
if ebiten.IsKeyPressed(p.KeyProgramme.MoveR) {
p.Move(MoveDirectionR)
}
if ebiten.IsKeyPressed(p.KeyProgramme.MoveU) {
p.Move(MoveDirectionU)
}
if ebiten.IsKeyPressed(p.KeyProgramme.MoveD) {
p.Move(MoveDirectionD)
}
// 炸弹放置只监听按键按下事件,无法长按连续放置炸弹
if inpututil.IsKeyJustPressed(p.KeyProgramme.Planted) {
p.NewBomb(m)
}
}

其中 m 为上文中创建的地图,对于 NewBomb 方法也进行了修改,需要将炸弹添加到地图上。

1
2
3
4
5
6
7
8
9
10
11
// NewBomb 放置炸弹
func (p *Player) NewBomb(m *Map) {
if len(p.Bombs) >= p.BombCount {
return
}
// 炸弹只能放置在瓦片中心,因此需要查找玩家当前占据空间最大的一个瓦片,在该瓦片放置炸弹
closestTile := findClosestTile(p.X, p.Y, p.W, p.H)
bomb := NewBomb(closestTile.X(), closestTile.Y(), p.BombPower)
p.Bombs[bomb] = struct{}{}
m.Tiles.Set(closestTile, bomb)
}

另外,玩家的移动方法也需要进行完善。前文中只做了简单的位置修改,并未实现障碍物的碰撞检测。

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
82
83
84
85
86
87
88
89
90
91
92
93
94
// Move 移动
func (p *Player) Move(direction Direction, m *Map) {
// 为了使屏幕上玩家的位置连贯,玩家移动前后的坐标位置差值不可太大,不然会出现玩家瞬移的情况。
// 玩家的速度属性单位为一个像素点,最大值为一个瓦片的大小,即玩家一帧最多可以移动 TileLength
add := math.Min(float64(TileLength), p.Speed)
targetX, targetY := p.X, p.Y
obsCooCheckList := make([]*Coordinate, 0, 5) // 存放需要进行碰撞检测的坐标点
closestCoo := findClosestTile(p.X, p.Y, p.W, p.H) // 当前占据位置最大的瓦片
appendIfNotNil := func(p *Coordinate) {
if p != nil {
obsCooCheckList = append(obsCooCheckList, p)
}
} // 障碍物添加函数
switch direction {
case DirectionL:
if p.X == 0 {
return
}
if targetX = p.X - add; targetX <= 0 {
targetX = 0
}
// 将障碍物添加到碰撞检测列表中
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionL))
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionU))
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionD))
appendIfNotNil(obsCooCheckList[0].NearOneTileCoo(DirectionU))
appendIfNotNil(obsCooCheckList[0].NearOneTileCoo(DirectionD))
case DirectionR:
if p.X == ScreenWidth-p.W {
return
}
if targetX = p.X + add; targetX >= ScreenWidth-p.W {
targetX = ScreenWidth - p.W
}
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionR))
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionU))
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionD))
appendIfNotNil(obsCooCheckList[0].NearOneTileCoo(DirectionU))
appendIfNotNil(obsCooCheckList[0].NearOneTileCoo(DirectionD))
case DirectionU:
if p.Y == 0 {
return
}
if targetY = p.Y - add; targetY <= 0 {
targetY = 0
}
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionU))
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionL))
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionR))
appendIfNotNil(obsCooCheckList[0].NearOneTileCoo(DirectionL))
appendIfNotNil(obsCooCheckList[0].NearOneTileCoo(DirectionR))
case DirectionD:
if p.Y == ScreenHeight-p.H {
return
}
if targetY = p.Y + add; targetY >= ScreenHeight-p.H {
targetY = ScreenHeight - p.H
}
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionD))
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionL))
appendIfNotNil(closestCoo.NearOneTileCoo(DirectionR))
appendIfNotNil(obsCooCheckList[0].NearOneTileCoo(DirectionL))
appendIfNotNil(obsCooCheckList[0].NearOneTileCoo(DirectionR))
}

// 碰撞检测
effectList := make([]func(p *Player), 0)
for _, coo := range obsCooCheckList {
if obs := m.Tiles.Get(*coo); obs != nil {
if !isColliding(targetX, targetY, p.W, p.H, (*coo).X(), (*coo).Y(), float64(TileLength), float64(TileLength)) {
continue
}
// 检测是否有碰撞效果,有的话取出,并在允许移动的时候生效
if effect := obs.PlayerCollisionEffect(); effect != nil {
effectList = append(effectList, effect)
continue
}
return
}
}
// 改变位置,并使碰撞效果生效
p.X, p.Y = targetX, targetY
for _, e := range effectList {
e(p)
}
return
}

// 碰撞检测辅助函数
func isColliding(x1, y1, w1, h1, x2, y2, w2, h2 float64) bool {
return (x1+w1 > x2 && x2+w2 > x1) && (y1+h1 > y2 && y2+h2 > y1)
}


最后,在 Update 中,还需要更新玩家持有的炸弹列表。已经到达爆炸时间的,触发爆炸,产生效果。同时,已经死亡的玩家不再监听其键盘活动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

func (p *Player) Update(m *Map) {
for b, _ := range p.Bombs {
if time.Now().After(b.ExplodeTime) {
b.Explode(m)
delete(p.Bombs, b)
}
}
if p.IsDie {
return
}
// ...省略键盘监听部分
}

玩家的逻辑更新完成后,就需要完善炸弹的爆炸逻辑了。这也是这个游戏最核心的部分。

以上代码中,我们通过玩家的炸弹列表来控制炸弹的爆炸。我们现在来完善一下炸弹爆炸的逻辑。

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
// explode 爆炸逻辑,重复逻辑较多,写一个单独的方法,不影响阅读主逻辑
func (b *Bomb) explode(m *Map) {
// 爆炸由中心点产生,向四个方向扩散
centerPoint := NewCoordinate(b.X, b.Y)
currentPoint := centerPoint
for i := 0; i < b.Power && currentPoint.X() != b.FireXBegin; i++ {
if point := currentPoint.NearOneTileCoo(DirectionL); point != nil {
if obs := m.Tiles.Get(*point); obs != nil {
obs.BombCollisionEffect(b)
}
currentPoint = *point
} else {
break
}
}
currentPoint = centerPoint
for i := 0; i < b.Power && currentPoint.X() != b.FireXEnd; i++ {
if point := currentPoint.NearOneTileCoo(DirectionR); point != nil {
if obs := m.Tiles.Get(*point); obs != nil {
obs.BombCollisionEffect(b)
}
currentPoint = *point
} else {
break
}
}
currentPoint = centerPoint
for i := 0; i < b.Power && currentPoint.Y() != b.FireYBegin; i++ {
if point := currentPoint.NearOneTileCoo(DirectionU); point != nil {
if obs := m.Tiles.Get(*point); obs != nil {
obs.BombCollisionEffect(b)
}
currentPoint = *point
} else {
break
}
}
currentPoint = centerPoint
for i := 0; i < b.Power && currentPoint.Y() != b.FireYEnd; i++ {
if point := currentPoint.NearOneTileCoo(DirectionD); point != nil {
if obs := m.Tiles.Get(*point); obs != nil {
obs.BombCollisionEffect(b)
}
currentPoint = *point
} else {
break
}
}
}

以上,通过逐步计算每个瓦片中的内容,并调用其中内容的炸弹碰撞方法,完整炸弹爆炸的逻辑。另外,炸弹爆炸需要有一定的爆炸特效,所以,需要一个生成爆炸特效的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Bomb struct {
// ...
ExplodeEffect *ExplodeEffect
}

type ExplodeEffect struct {
X, Y float64
XA, XB float64
YA, YB float64
FinishTime time.Time
}

func (b *Bomb) NewExplodeEffect() {
b.ExplodeEffect = &ExplodeEffect{
X: b.X,
Y: b.Y,
XA: b.FireXBegin,
XB: b.FireXEnd,
YA: b.FireYBegin,
YB: b.FireYEnd,
FinishTime: time.Now().Add(300 * time.Millisecond),
}
}

爆炸特效的生成方法有了之后,在爆炸函数中调用并生成爆炸特效。另外还需要检测爆炸范围与玩家的碰撞,碰撞到会造成玩家死亡。

1
2
3
4
5
6
7
8
9
10
11
12
func (b *Bomb) Explode(m *Map) {
b.explode(m)
// 使用最终生成的火力覆盖范围,生成一个爆炸特效
b.NewExplodeEffect()
// 使用最终生成的火力覆盖范围,检测是否与玩家有碰撞
for _, p := range m.Players {
if isColliding(p.X, p.Y, p.W, p.H, b.FireXBegin, b.Y, b.FireXEnd-b.FireXBegin+float64(TileLength), float64(TileLength)) ||
isColliding(p.X, p.Y, p.W, p.H, b.X, b.FireYBegin, float64(TileLength), b.FireYEnd-b.FireYBegin+float64(TileLength)) {
p.IsDie = true
}
}
}

引爆炸弹的逻辑就设计好了。

现在,需要开发炸弹、道具、墙等障碍物的更新逻辑。此时,在障碍物接口中,添加一个方法:

1
2
3
4
type Obstacle interface {
Update(m *Map)
// ...
}

传入 map 的目的是更新地图信息,炸弹爆炸、墙被摧毁等等都是需要更新到地图上的。

炸弹的更新方法:

1
2
3
4
5
6
func (b *Bomb) Update(m *Map) {
// 爆炸特效不为空且已经到达特效的展示时限,则删除地图瓦片中的炸弹
if b.ExplodeEffect != nil && time.Now().After(b.ExplodeEffect.FinishTime) {
m.Tiles.Del(NewCoordinate(b.X, b.Y))
}
}

接下来依次是道具以及墙:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (p *Prop) Update(m *Map) {
if p.IsDestroy {
m.Tiles.Del(p.Coo)
}
}

func (w *Wall) Update(m *Map) {
if w.IsDestroyed {
if w.InnerProp != nil {
m.Tiles.Set(w.Coo, w.InnerProp)
} else {
m.Tiles.Del(w.Coo)
}
}
}

障碍物都已经实现了 Update 方法,之后在一个统一的地方调用障碍物的更新方法,即可完成这些障碍物的状态更新。而能够全局掌管这些障碍物的对象,就是 map。所以我们要实现 map 对象的更新方法。

1
2
3
4
5
6
7
8
9
10
11
12
func (m *Map) Update() error {
// 地图资源更新
m.Tiles.m.Range(func(coo, obs any) bool {
obs.(Obstacle).Update(m)
return true
})
// 玩家状态更新
for _, p := range m.Players {
p.Update(m)
}
return nil
}

接下来回到文初的位置,我们完善一下游戏的初始化方法以及资源更新方法。

NewGame 方法中,我们需要创建一个地图对象和一个玩家列表。玩家目前我们只提供了创建一个玩家的方法,我们可以编写一个创建两个玩家的方法(更多玩家的话就自行完善了)。

1
2
3
4
5
6
7
8
9
10
const (
ImgPlayer1Path = ""
ImgPlayer2Path = ""
)

func NewTwoPlayer() []*Player {
ps := []*Player{NewPlayer(ImgPlayer1Path, 1), NewPlayer(ImgPlayer2Path, 2)}
return ps
}

完善后的 NewGame 方法:

1
2
3
4
5
func NewGame() *Game {
return &Game{
Map: NewMap(NewTwoPlayer()),
}
}

完善后的更新资源更新方法:

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

页面资源渲染

上文中已经完成了所有游戏逻辑的开发。接下来,就需要实现上文中各个对象的绘制方法,将各个对象显示到屏幕上。

首先是玩家对象,通过位置偏移,将其图像显示到屏幕指定的位置:

1
2
3
4
5
func (p *Player) Draw(screen *ebiten.Image) {
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(p.X, p.Y)
screen.DrawImage(p.Img, op)
}

接着,是地图对象。地图对象说白了就是包含了障碍物信息的集合。所以地图对象的绘制方法实现,就是实现各个障碍物的显示方法。因此,在障碍物接口中,添加 Display 方法。

1
2
3
4
type Obstacle interface {
// ...
Display(screen *ebiten.Image)
}

实现各个障碍物的显示方法:

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
// Display 炸弹/爆炸特效显示
func (ee *ExplodeEffect) Display(screen *ebiten.Image) {
tileLengthF32 := float32(TileLength)
tileLengthF64 := float64(TileLength)
vector.DrawFilledRect(screen, float32(ee.XA), float32(ee.Y+tileLengthF64/10),
float32(ee.XB-ee.XA+tileLengthF64), tileLengthF32/5*4,
color.RGBA{R: 249, G: 142, B: 7, A: 0}, true)
vector.DrawFilledRect(screen, float32(ee.X+tileLengthF64/10), float32(ee.YA), tileLengthF32/5*4,
float32(ee.YB-ee.YA+tileLengthF64),
color.RGBA{R: 249, G: 142, B: 7, A: 0}, true)
}
func (b *Bomb) Display(screen *ebiten.Image) {
if !time.Now().After(b.ExplodeTime) {
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(b.X, b.Y)
screen.DrawImage(BombImg, op)
return
}
if b.ExplodeEffect != nil {
b.ExplodeEffect.Display(screen)
return
}
}


// Display 道具显示
func (p *Prop) Display(screen *ebiten.Image) {
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(p.Coo.X(), p.Coo.Y())
screen.DrawImage(p.Img, op)
}

// Display 墙体根据能否摧毁,分为两种颜色
func (w *Wall) Display(screen *ebiten.Image) {
if w.Destructible {
op := new(ebiten.DrawImageOptions)
op.GeoM.Translate(w.Coo.X(), w.Coo.Y())
screen.DrawImage(newCanDesImg(), op)
} else {
vector.DrawFilledRect(screen, float32(w.Coo.X()), float32(w.Coo.Y()),
float32(TileLength), float32(TileLength), unDesColor, true)
}
}

var unDesColor = color.RGBA{R: 240, G: 189, B: 97, A: 200}
var canDesColor = color.RGBA{R: 20, G: 161, B: 20, A: 200}

func newCanDesImg() *ebiten.Image {
tileLengthInt := int(TileLength)
tileLengthF64 := float64(TileLength)
dc := gg.NewContext(tileLengthInt, tileLengthInt)
dc.SetRGBA255(int(canDesColor.R), int(canDesColor.G), int(canDesColor.B), int(canDesColor.A))
dc.SetRGBA(20, 161, 20, 200)
dc.DrawRoundedRectangle(0, 0, tileLengthF64, tileLengthF64, 8)
dc.Fill()
img := dc.Image()
return ebiten.NewImageFromImage(img)
}

之后,在 map 对象的绘制方法中,统一调用障碍物的 Display 实现即可。

1
2
3
4
5
6
7
8
9
10
func (m *Map) Draw(screen *ebiten.Image) {
screen.Fill(color.RGBA{R: 94, G: 106, B: 94, A: 100})
m.Tiles.m.Range(func(coo, obs any) bool {
obs.(Obstacle).Display(screen)
return true
})
for _, p := range m.Players {
p.Draw(screen)
}
}

最后,实现 Game 对象的绘制方法:

1
2
3
func (g *Game) Draw(screen *ebiten.Image) {
g.Map.Draw(screen)
}

游戏运行

创建一个 Game 对象,并使用 ebiten 运行游戏:

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
game := bombman.NewGame()
ebiten.SetWindowSize(bombman.ScreenWidth, bombman.ScreenHeight)
ebiten.SetWindowTitle("BombMan")
ebiten.SetWindowSizeLimits(200, 200, 960, 960)
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
img, _, _ := image.Decode(bytes.NewReader(resources.Bomb25Png))
ebiten.SetWindowIcon([]image.Image{img})
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}

运行效果:

bombman-running.gif

开始和结束游戏

上文中开发的内容包含了两个玩家,模式为双人对战。因此,当一方玩家死亡,即游戏结束。

我们先设置一个标志位,来标识游戏当前的模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Mode int

const (
ModeStart Mode = iota
ModeGaming
ModeEnd
)

type Game struct {
// ...
Mode Mode
}

func NewGame() *Game {
return &Game{
Map: NewMap(NewTwoPlayer()),
Mode: ModeStart,
}
}

并给 Game 对象创建一个 Restart 方法:

1
2
3
4
func (g *Game) Restart() {
g.Map = NewMap(NewTwoPlayer())
g.Mode = ModeGaming
}

Update 和 Draw 方法中,需要判断游戏当前处于哪种模式。

  1. 开始模式:显示游戏初始蒙版,提示:点击空格开始游戏,进入游戏模式
  2. 游戏模式:显示当前游戏资源,当有玩家死亡后进入结束模式
  3. 结束模式:显示游戏结束蒙版,提示:点击空格继续游戏,进入游戏模式

处于开始和结束模式时,需要创建蒙版并文字提示。蒙版用一个半透明的矩形就可以,文字渲染的话就需要导入额外的包,并创建字体。

这里我们直接使用 examples 中附带的字体:

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
package bombman

import (
"github.com/hajimehoshi/ebiten/v2/examples/resources/fonts"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"log"
)

var (
titleArcadeFont font.Face
arcadeFont font.Face
smallArcadeFont font.Face
)

const (
TitleSize float64 = 24
NoticeSize float64 = 16
SmallSize float64 = 10
)

func init() {
tt, err := opentype.Parse(fonts.PressStart2P_ttf)
if err != nil {
log.Fatal(err)
}
const dpi = 72
titleArcadeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
Size: TitleSize,
DPI: dpi,
Hinting: font.HintingFull,
})
if err != nil {
log.Fatal(err)
}
arcadeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
Size: NoticeSize,
DPI: dpi,
Hinting: font.HintingFull,
})
if err != nil {
log.Fatal(err)
}
smallArcadeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
Size: SmallSize,
DPI: dpi,
Hinting: font.HintingFull,
})
if err != nil {
log.Fatal(err)
}
}

接着,写一个函数,创建蒙版,并在蒙版中写入文字:

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 NewNoticeImg(titleTxt, noticeTxt string) *ebiten.Image {
w, h := ScreenWidth/3*2, ScreenHeight/3*2
img := ebiten.NewImage(w, h)
img.Fill(color.RGBA{
R: 163,
G: 158,
B: 158,
A: 180,
})

x := (w - len(titleTxt)*int(TileLength)) / 2
if len(titleTxt)*int(TileLength) > w {
tTxtBytes := []byte(titleTxt)
tTxt := string(tTxtBytes[:len(titleTxt)/2]) + "\n" + string(tTxtBytes[len(titleTxt)/2:])
x = (w - len(titleTxt)*int(TileLength)/2) / 2
text.Draw(img, tTxt, arcadeFont, x, h/4, color.White)
} else {
text.Draw(img, titleTxt, titleArcadeFont, x, h/4, color.White)
}
if len(noticeTxt)*int(NoticeSize) > w {
nTxtBytes := []byte(noticeTxt)
nTxt := string(nTxtBytes[:len(noticeTxt)/2]) + "\n" + string(nTxtBytes[len(noticeTxt)/2:])
x = (w - len(noticeTxt)*int(NoticeSize)/2) / 2
text.Draw(img, nTxt, arcadeFont, x, h/2, color.White)
} else {
x = (w - len(noticeTxt)*int(NoticeSize)) / 2
text.Draw(img, noticeTxt, arcadeFont, x, h/2, color.White)
}
return img
}

完善 Update 和 Draw 的逻辑:

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
func (g *Game) Update() error {
switch g.Mode {
case ModeStart:
if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
g.Mode = ModeGaming
}
case ModeGaming:
for _, p := range g.Map.Players {
if p.IsDie {
g.Mode = ModeEnd
return nil
}
}
_ = g.Map.Update()
case ModeEnd:
if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
g.Restart()
}
}
return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
if g.Mode == ModeStart {
img := NewNoticeImg("<-BombMan->", "Press the space to start the game")
size := img.Bounds()
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate((ScreenWidth-float64(size.Dx()))/2, (ScreenHeight-float64(size.Dy()))/2)
screen.DrawImage(img, op)
return
}
g.Map.Draw(screen)
if g.Mode == ModeEnd {
img := NewNoticeImg("Game Over", "Press the space to restart the game")
size := img.Bounds()
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate((ScreenWidth-float64(size.Dx()))/2, (ScreenHeight-float64(size.Dy()))/2)
screen.DrawImage(img, op)
}
return
}

运行效果:

bombman-running-2.gif

拓展内容–更多攻击道具和地图

这些实现起来难度都不是很大,有一定的脑洞且逻辑完善的话都不会有啥问题,本文就不做赘述了,大家感兴趣可以自行完善。

拓展内容–实现AI玩家

目前是写了一套 AI 玩家的行动逻辑,不过不太智能。后续完善了之后再发出来。

总结

这篇文章主要介绍了 go 语言中的 2D 库 ebiten 的使用,并使用 ebiten 从零开始开发了一个炸弹人的游戏。相信通过这篇文章,大家都能入门使用 ebiten 开发简单的游戏。ebiten 还有更多强大的功能,大家感兴趣也可以去查阅官方文档以及官方 github 仓库,仓库中提供了更多的游戏示例。