一起用Go做一个小游戏(中)
创始人
2024-03-24 12:48:18
0

限制飞船的活动范围

上一篇文章还留了个尾巴,细心的同学应该发现了:飞船可以移动出屏幕!!!现在我们就来限制一下飞船的移动范围。我们规定飞船可以左右超过半个身位,如下图所示:

b123297befac40c6f9efa84b2cff7d2c.png

很容易计算得出,左边位置的x坐标为:

x = -W2/2

右边位置的坐标为:

x = W1 - W2/2

修改input.go的代码如下:

func (i *Input) Update(ship *Ship, cfg *Config) {if ebiten.IsKeyPressed(ebiten.KeyLeft) {ship.x -= cfg.ShipSpeedFactorif ship.x < -float64(ship.width)/2 {ship.x = -float64(ship.width) / 2}} else if ebiten.IsKeyPressed(ebiten.KeyRight) {ship.x += cfg.ShipSpeedFactorif ship.x > float64(cfg.ScreenWidth)-float64(ship.width)/2 {ship.x = float64(cfg.ScreenWidth) - float64(ship.width)/2}}
}

运行结果如下:

24643b354fe73ad77fc5aaeec43341f0.gif

发射子弹

我们不用另外准备子弹的图片,直接画一个矩形就ok。为了可以灵活控制,我们将子弹的宽、高、颜色以及速率都用配置文件来控制:

{"bulletWidth": 3,"bulletHeight": 15,"bulletSpeedFactor": 2,"bulletColor": {"r": 80,"g": 80,"b": 80,"a": 255}
}

新增一个文件bullet.go,定义子弹的结构类型和New方法:

type Bullet struct {image       *ebiten.Imagewidth       intheight      intx           float64y           float64speedFactor 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对象。

子弹都是从飞船头部发出的,所以它的横坐标等于飞船中心的横坐标,左上角的纵坐标=屏幕高度-飞船高-子弹高。

随便增加子弹的绘制方法:

func (bullet *Bullet) Draw(screen *ebiten.Image) {op := &ebiten.DrawImageOptions{}op.GeoM.Translate(bullet.x, bullet.y)screen.DrawImage(bullet.image, op)
}

我们在Game对象中增加一个map来管理子弹:

type Game struct {// -------省略-------bullets map[*Bullet]struct{}
}func NewGame() *Game {return &Game{// -------省略-------bullets: make(map[*Bullet]struct{}),}
}

然后在Draw方法中,我们需要将子弹也绘制出来:

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中更新,与飞船类似,只是飞船只能水平移动,而子弹只能垂直移动。

func (g *Game) Update() error {for bullet := range g.bullets {bullet.y -= bullet.speedFactor}// -------省略-------
}

子弹的更新、绘制逻辑都完成了,可是我们还没有子弹!现在我们就来实现按空格发射子弹的功能。我们需要在Input.Update方法中判断空格键是否按下,由于该方法需要访问Game对象的多个字段,干脆传入Game对象:

func (i *Input) Update(g *Game) {if ebiten.IsKeyPressed(ebiten.KeyLeft) {// -------省略-------} else if ebiten.IsKeyPressed(ebiten.KeyRight) {// -------省略-------} else if ebiten.IsKeyPressed(ebiten.KeySpace) {bullet := NewBullet(g.cfg, g.ship)g.addBullet(bullet)}
}

给Game对象增加一个addBullet方法:

func (g *Game) addBullet(bullet *Bullet) {g.bullets[bullet] = struct{}{}
}

目前有两个问题:

  • 无法一边移动一边发射,仔细看看Input.Update方法中的代码,你能发现什么问题吗?

  • 子弹太多了,我们想要限制子弹的数量。

下面来逐一解决这些问题。

第一个问题很好解决,因为在KeyLeft/KeyRight/KeySpace这三个判断中我们用了if-else。这样会优先处理移动的操作。将KeySpace移到一个单独的if中即可:

func (i *Input) Update(g *Game) {// -------省略-------if ebiten.IsKeyPressed(ebiten.KeySpace) {bullet := NewBullet(g.cfg, g.ship)g.addBullet(bullet)}
}

第二个问题,在配置中,增加同时最多存在的子弹数:

{"maxBulletNum": 10
}
type Config struct {MaxBulletNum      int        `json:"maxBulletNum"`
}

然后我们在Input.Update方法中判断,如果目前存在的子弹数小于MaxBulletNum才能创建新的子弹:

if ebiten.IsKeyPressed(ebiten.KeySpace) {if len(g.bullets) < cfg.MaxBulletNum {bullet := NewBullet(g.cfg, g.ship)g.addBullet(bullet)}
}

再次运行:

9b2cfdcbcf905ee88c41d80241da430d.gif

数量好像被限制了,但是不是我们配置的10。原来Input.Update()的调用间隔太短了,导致我们一次space按键会发射多个子弹。我们可以控制两个子弹之间的时间间隔。同样用配置文件来控制(单位毫秒):

{"bulletInterval": 50
}
type Config struct {BulletInterval    int64      `json:"bulletInterval"`
}

Input结构中增加一个lastBulletTime字段记录上次发射子弹的时间:

type Input struct {lastBulletTime time.Time
}

距离上次发射子弹的时间大于BulletInterval毫秒,才能再次发射,发射成功之后更新时间:

func (i *Input) Update(g *Game) {// -------省略-------if ebiten.IsKeyPressed(ebiten.KeySpace) {if len(g.bullets) < g.cfg.MaxBulletNum &&time.Now().Sub(i.lastBulletTime).Milliseconds() > g.cfg.BulletInterval {bullet := NewBullet(g.cfg, g.ship)g.addBullet(bullet)i.lastBulletTime = time.Now()}}
}

运行:

f22207d40599be5365db5a5c29a97236.gif

又出现了一个问题,10个子弹飞出屏幕外之后还是不能发射子弹。我们需要把离开屏幕的子弹删除。这适合在Game.Update函数中做:

func (g *Game) Update() error {g.input.Update(g)for bullet := range g.bullets {if bullet.outOfScreen() {delete(g.bullets, bullet)}}return nil
}

为了Bullet添加判断是否处于屏幕外的方法:

func (bullet *Bullet) outOfScreen() bool {return bullet.y < -float64(bullet.height)
}

再次运行:

da0c8153e05ea70c555c0ab7d892eb63.gif

外星人来了

外星人图片如下:

9a37e0c7da67151a1632a50b6c26da65.png

同飞船一样,编写Alien类,添加绘制自己的方法:

type Alien struct {image       *ebiten.Imagewidth       intheight      intx           float64y           float64speedFactor float64
}func NewAlien(cfg *Config) *Alien {img, _, err := ebitenutil.NewImageFromFile("../images/alien.png")if err != nil {log.Fatal(err)}width, height := img.Size()return &Alien{image:       img,width:       width,height:      height,x:           0,y:           0,speedFactor: cfg.AlienSpeedFactor,}
}func (alien *Alien) Draw(screen *ebiten.Image) {op := &ebiten.DrawImageOptions{}op.GeoM.Translate(alien.x, alien.y)screen.DrawImage(alien.image, op)
}

游戏开始时需要创建一组外星人,计算一行可以容纳多少个外星人,考虑到左右各留一定的空间,两个外星人之间留一点空间。在游戏一开始就创建一组外星人:

type Game struct {// Game结构中的map用来存储外星人对象aliens  map[*Alien]struct{}
}func NewGame() *Game {g := &Game{// 创建mapaliens:  make(map[*Alien]struct{}),}// 调用 CreateAliens 创建一组外星人g.CreateAliens()return g
}func (g *Game) CreateAliens() {alien := NewAlien(g.cfg)availableSpaceX := g.cfg.ScreenWidth - 2*alien.widthnumAliens := availableSpaceX / (2 * alien.width)for i := 0; i < numAliens; i++ {alien = NewAlien(g.cfg)alien.x = float64(alien.width + 2*alien.width*i)g.addAlien(alien)}
}

左右各留一个外星人宽度的空间:

availableSpaceX := g.cfg.ScreenWidth - 2*alien.width

然后,两个外星人之间留一个外星人宽度的空间。所以一行可以创建的外星人的数量为:

numAliens := availableSpaceX / (2 * alien.width)

创建一组外星人,依次排列。

同样地,我们需要在Game.Draw方法中添加绘制外星人的代码:

func (g *Game) Draw(screen *ebiten.Image) {// -------省略-------for alien := range g.aliens {alien.Draw(screen)}
}

运行:

41d51a64c1198db2bf9a471dc76231d2.png

再创建两行:

func (g *Game) CreateAliens() {// -------省略-------for row := 0; row < 2; row++ {for i := 0; i < numAliens; i++ {alien = NewAlien(g.cfg)alien.x = float64(alien.width + 2*alien.width*i)alien.y = float64(alien.height*row) * 1.5g.addAlien(alien)}}
}

让外星人都起来,同样地还是在Game.Update方法中更新位置:

func (g *Game) Update() error {// -------省略-------for alien := range g.aliens {alien.y += alien.speedFactor}// -------省略-------
}

1ee0e7f5647412001b2ec755a93c9f3d.gif

射击!!

当前子弹碰到外星人直接穿过去了,我们希望能击杀外星人。这需要检查子弹和外星人之间的碰撞。我们新增一个文件collision.go,并编写检查子弹与外星人是否碰撞的函数。这里我采用最直观的碰撞检测方法,即子弹的4个顶点只要有一个位于外星人矩形中,就认为它们碰撞。

// CheckCollision 检查子弹和外星人之间是否有碰撞
func CheckCollision(bullet *Bullet, alien *Alien) bool {alienTop, alienLeft := alien.y, alien.xalienBottom, alienRight := alien.y+float64(alien.height), alien.x+float64(alien.width)// 左上角x, y := bullet.x, bullet.yif y > alienTop && y < alienBottom && x > alienLeft && x < alienRight {return true}// 右上角x, y = bullet.x+float64(bullet.width), bullet.yif y > alienTop && y < alienBottom && x > alienLeft && x < alienRight {return true}// 左下角x, y = bullet.x, bullet.y+float64(bullet.height)if y > alienTop && y < alienBottom && x > alienLeft && x < alienRight {return true}// 右下角x, y = bullet.x+float64(bullet.width), bullet.y+float64(bullet.height)if y > alienTop && y < alienBottom && x > alienLeft && x < alienRight {return true}return false
}

接着我们在Game.Update方法中调用这个方法,并且将碰撞的子弹和外星人删除。

func (g *Game) CheckCollision() {for alien := range g.aliens {for bullet := range g.bullets {if CheckCollision(bullet, alien) {delete(g.aliens, alien)delete(g.bullets, bullet)}}}
}func (g *Game) Update() error {// -------省略-------g.CheckCollision()// -------省略-------return nil
}

注意将碰撞检测放在位置更新之后。运行:

00a562ee07aad5d8fc1405b3e56f3e41.gif

增加主界面和结束界面

现在一旦运行程序,外星人们就开始运动了。我们想要增加一个按下空格键才开始的功能,并且游戏结束之后,我们也希望能显示一个Game Over的界面。首先,我们定义几个常量,表示游戏当前所处的状态:

const (ModeTitle Mode = iotaModeGameModeOver
)

Game结构中需要增加mode字段表示当前游戏所处的状态:

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

为了显示开始界面,涉及到文字的显示,文字显示和字体处理起来都比较麻烦。ebitengine内置了一些字体,我们可以据此创建几个字体对象:

var (titleArcadeFont font.FacearcadeFont      font.FacesmallArcadeFont font.Face
)func (g *Game) CreateFonts() {tt, err := opentype.Parse(fonts.PressStart2P_ttf)if err != nil {log.Fatal(err)}const dpi = 72titleArcadeFont, 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方法中:

func (g *Game) init() {g.CreateAliens()g.CreateFonts()
}func NewGame() *Game {// ...g.init()return g
}

启动时游戏处于ModeTitle状态,处于ModeTitle和ModeOver时只需要在屏幕上显示一些文字即可。只有在ModeGame状态才需要显示飞船和外星人:

func (g *Game) Draw(screen *ebiten.Image) {screen.Fill(g.cfg.BgColor)var titleTexts []stringvar texts []stringswitch 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 alien := range g.aliens {alien.Draw(screen)}case ModeOver:texts = []string{"", "GAME OVER!"}}for i, l := range titleTexts {x := (g.cfg.ScreenWidth - len(l)*g.cfg.TitleFontSize) / 2text.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) / 2text.Draw(screen, l, arcadeFont, x, (i+4)*g.cfg.FontSize, color.White)}
}

Game.Update方法中,我们判断在ModeTitle状态下按下空格,鼠标左键游戏开始,切换为ModeGame状态。游戏结束时切换为GameOver状态,在GameOver状态后按下空格或鼠标左键即重新开始游戏。

func (g *Game) Update() error {switch g.mode {case ModeTitle:if g.input.IsKeyPressed() {g.mode = ModeGame}case ModeGame:for bullet := range g.bullets {bullet.y -= bullet.speedFactor}for alien := range g.aliens {alien.y += alien.speedFactor}g.input.Update(g)g.CheckCollision()for bullet := range g.bullets {if bullet.outOfScreen() {delete(g.bullets, bullet)}}case ModeOver:if g.input.IsKeyPressed() {g.init()g.mode = ModeTitle}}return nil
}

input.go中增加IsKeyPressed方法判断是否有空格或鼠标左键按下。

判断游戏胜负

我们规定如果击杀所有外星人则游戏胜利,有3个外星人移出屏幕外或者碰撞到飞船则游戏失败。

首先增加一个字段failCount用于记录移出屏幕外的外星人数量和与飞船碰撞的外星人数量之和:

type Game struct {// -------省略-------failCount int // 被外星人碰撞和移出屏幕的外星人数量之和
}

然后我们在Game.Update方法中检测外星人是否移出屏幕,以及是否与飞船碰撞:

for alien := range g.aliens {if alien.outOfScreen(g.cfg) {g.failCount++delete(g.aliens, alien)continue}if CheckCollision(alien, g.ship) {g.failCount++delete(g.aliens, alien)continue}
}

这里有一个问题,还记得吗?我们前面编写的CheckCollision函数接受的参数类型是*Alien*Bullet,这里我们需要重复编写接受参数为*Ship*Alien的函数吗?不用!

我们将表示游戏中的实体对象抽象成一个GameObject结构:

type GameObject struct {width  intheight intx      float64y      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

type Entity interface {Width() intHeight() intX() float64Y() float64
}

最后让我们游戏中的实体内嵌这个GameObject对象,即可自动实现Entity接口:

type Alien struct {GameObjectimage       *ebiten.ImagespeedFactor float64
}

这样CheckCollision即可改为接受两个Entity接口类型的参数:

// CheckCollision 两个物体之间是否碰撞
func CheckCollision(entityA, entityB Entity) bool {top, left := entityA.Y(), entityA.X()bottom, right := entityA.Y()+float64(entityA.Height()), entityA.X()+float64(entityA.Width())// 左上角x, y := entityB.X(), entityB.Y()if y > top && y < bottom && x > left && x < right {return true}// 右上角x, y = entityB.X()+float64(entityB.Width()), entityB.Y()if y > top && y < bottom && x > left && x < right {return true}// 左下角x, y = entityB.X(), entityB.Y()+float64(entityB.Height())if y > top && y < bottom && x > left && x < right {return true}// 右下角x, y = entityB.X()+float64(entityB.Width()), entityB.Y()+float64(entityB.Height())if y > top && y < bottom && x > left && x < right {return true}return false
}

如果游戏失败则切换为ModeOver模式,屏幕上显示"Game Over!"。如果游戏胜利,则显示"You Win!":

if g.failCount >= 3 {g.overMsg = "Game Over!"
} else if len(g.aliens) == 0 {g.overMsg = "You Win!"
}if len(g.overMsg) > 0 {g.mode = ModeOverg.aliens = make(map[*Alien]struct{})g.bullets = make(map[*Bullet]struct{})
}

注意,为了下次游戏能顺序进行,这里需要清楚所有的子弹和外星人。运行:

b431066dc7b0c1b95883ad5e3d79113b.gif

总结

本文接着上篇文章,介绍了发射子弹,检测碰撞,增加主界面和游戏结束界面等内容。至此一个简答的游戏就做出来了。可以看出使用ebitengine做一个游戏还是很简单的,非常推荐尝试呢!上手之后,建议看看官方仓库examples目录中的示例,会非常有帮助。

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. Go 每日一库 GitHub:https://github.com/darjun/go-daily-lib

  2. ebitengine 官网:https://ebitengine.org/

  3. Python 编程(从入门到实践):https://book.douban.com/subject/35196328/

我的博客:https://darjun.github.io

欢迎关注我的微信公众号【GoUpUp】,共同学习,一起进步~

相关内容

热门资讯

监控摄像头接入GB28181平... 流程简介将监控摄像头的视频在网站和APP中直播,要解决的几个问题是:1&...
Windows10添加群晖磁盘... 在使用群晖NAS时,我们需要通过本地映射的方式把NAS映射成本地的一块磁盘使用。 通过...
protocol buffer... 目录 目录 什么是protocol buffer 1.protobuf 1.1安装  1.2使用...
在Word、WPS中插入AxM... 引言 我最近需要写一些文章,在排版时发现AxMath插入的公式竟然会导致行间距异常&#...
Fluent中创建监测点 1 概述某些仿真问题,需要创建监测点,用于获取空间定点的数据࿰...
educoder数据结构与算法...                                                   ...
MySQL下载和安装(Wind... 前言:刚换了一台电脑,里面所有东西都需要重新配置,习惯了所...
MFC文件操作  MFC提供了一个文件操作的基类CFile,这个类提供了一个没有缓存的二进制格式的磁盘...
有效的括号 一、题目 给定一个只包括 '(',')','{','}'...
【Ctfer训练计划】——(三... 作者名:Demo不是emo  主页面链接:主页传送门 创作初心ÿ...