ebiten 是啥
Ebiten 是一个用 Go 编程语言编写的开源游戏库,专门用于开发 2D 游戏。它简单易用,适合创建跨平台的游戏,支持 Windows、macOS、Linux、iOS 和 Android 等平台。Ebiten 提供了渲染、输入处理、音频播放等功能,帮助开发者快速构建和发布游戏。
主要特点
- 简单易用:提供了简洁的 API,开发者可以快速上手编写 2D 游戏,不需要深入了解复杂的图形编程。
- 跨平台支持:一个代码库可以编译和运行在多个平台上,包括桌面、移动设备和网页浏览器。
- 高效的渲染:Ebiten 利用底层的 OpenGL 或 Metal API 实现了高效的 2D 渲染,能够处理游戏中的大量图形操作。
- 集成输入处理:支持键盘、鼠标、触摸屏、手柄等多种输入方式,可以轻松实现用户交互。
- 游戏循环:提供了简单的游戏主循环,开发者只需专注于每一帧的游戏逻辑和渲染。
快速上手 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 开发一个炸弹人游戏。
内容提要
首先介绍一下炸弹人游戏的规则,每个玩家将会扮演一个炸弹人,炸弹人可以使用手中拥有的道具,攻击其他玩家,当只有一个玩家存活时,该玩家为赢家。默认情况下,攻击道具为炸弹,炸弹爆炸范围可以通过道具进行提升。其他的攻击道具一般为拓展玩法,在本篇博客中不作实现。
通过以上游戏规则,提取出以下几个对象:
- 玩家
- 炸弹
- 道具
- 墙体
玩家需要实现的功能:
- 移动
- 放置炸弹
- 吃道具
炸弹需要实现的功能:
- 爆炸
- 生成爆炸特效
道具需要实现的功能:
- 给玩家增加属性
障碍需要实现的功能:
- 阻碍玩家前进
- 破坏后生成道具
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
| type Map struct { Tiers *Tiers Players []*Player }
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) }
func NewMap(players []*Player) *Map { tiers := InitMap1() return &Map{ Tiles: tiers, Players: players, } }
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), } }
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
| type Direction uint8
const ( MoveDirectionL Direction = iota MoveDirectionR MoveDirectionU MoveDirectionD )
func (p *Player) Move(direction Direction) { 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
| 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)) }
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
| 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 }
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
|
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
| 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
| func (p *Player) Move(direction Direction, m *Map) { 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
| 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
| 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 } }
func (p *Prop) Display(screen *ebiten.Image) { op := &ebiten.DrawImageOptions{} op.GeoM.Translate(p.Coo.X(), p.Coo.Y()) screen.DrawImage(p.Img, op) }
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) } }
|
运行效果:

开始和结束游戏
上文中开发的内容包含了两个玩家,模式为双人对战。因此,当一方玩家死亡,即游戏结束。
我们先设置一个标志位,来标识游戏当前的模式。
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 方法中,需要判断游戏当前处于哪种模式。
- 开始模式:显示游戏初始蒙版,提示:点击空格开始游戏,进入游戏模式
- 游戏模式:显示当前游戏资源,当有玩家死亡后进入结束模式
- 结束模式:显示游戏结束蒙版,提示:点击空格继续游戏,进入游戏模式
处于开始和结束模式时,需要创建蒙版并文字提示。蒙版用一个半透明的矩形就可以,文字渲染的话就需要导入额外的包,并创建字体。
这里我们直接使用 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 }
|
运行效果:

拓展内容–更多攻击道具和地图
这些实现起来难度都不是很大,有一定的脑洞且逻辑完善的话都不会有啥问题,本文就不做赘述了,大家感兴趣可以自行完善。
拓展内容–实现AI玩家
目前是写了一套 AI 玩家的行动逻辑,不过不太智能。后续完善了之后再发出来。
总结
这篇文章主要介绍了 go 语言中的 2D 库 ebiten
的使用,并使用 ebiten 从零开始开发了一个炸弹人的游戏。相信通过这篇文章,大家都能入门使用 ebiten 开发简单的游戏。ebiten 还有更多强大的功能,大家感兴趣也可以去查阅官方文档以及官方 github 仓库,仓库中提供了更多的游戏示例。