Golang开发2D小游戏全套教程(已完结)
本套教程将基于ebiten库开发一套2D小游戏。
全套代码:https://github.com/ziyifast/ziyifast-code_instruction/tree/main/game_demo
ebiten官网地址:https://github.com/hajimehoshi/ebiten
说明:本套教程将基于ebiten+darwin开发一套类似雷霆战机的2D
小游戏。
1 环境准备 & Hello World
1.1 依赖库安装
OS:Darwin
Go Version大于等于:1.18
本教程基于Mac讲解,其他操作系统类似。
Go version >= 1.18
# 官网地址:https://ebitengine.org/en/documents/install.html?os=darwin
# 安装依赖库
go get -u github.com/hajimehoshi/ebiten/v2
# 验证是否安装成功,如果出现GUI页面表明环境初始成功
go run github.com/hajimehoshi/ebiten/v2/examples/rotate@latest
如果发现报错:build constraints exclude all Go files in xxx
- 在命令行启用执行:CGO_ENABLED=1,启用CGO
1.2 demo
package mainimport ("github.com/hajimehoshi/ebiten/v2""github.com/hajimehoshi/ebiten/v2/ebitenutil""github.com/ziyifast/log"
)type Game struct {
}func (g *Game) Update() error {return nil
}func (g *Game) Draw(screen *ebiten.Image) {ebitenutil.DebugPrint(screen, "hi~")
}func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {return 300, 240
}func main() {ebiten.SetWindowSize(640, 480)ebiten.SetWindowTitle("alien attack")if err := ebiten.RunGame(&Game{}); err != nil {log.Fatal("%v", err)}
}
# 运行程序
go run main.go
效果:
2 实战RUN Gopher
2.1 最终效果
- 全套代码地址:https://github.com/ziyifast/ziyifast-code_instruction/tree/main/game_demo
2.2 思路
- 程序入口:ebiten.RunGame(model.NewGame())
- 定义model里的Game类,需要实现ebiten中Game这个interface
- Update() error:程序会每隔一定时间进行刷新,里面定义刷新逻辑,包括怪物的移动(调整每个怪物的x、y轴坐标),gopher(玩家)的移动,子弹的移动等
- Draw(screen *Image):通过Update调整好坐标以后,再将怪物、子弹、以及玩家调用Draw方法画到屏幕上
- Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int):设置页面的布局及大小
- 定义model.Game结构体、同时实现Update、Draw等方法
- input *Input:用于监听用户按键,比如:按下空格表示游戏开始
- ship *Ship:玩家角色
- config *config.Config:配置文件(定义玩家移动速度、怪物移动速度、游戏标题等)
- bullets map[*Bullet]struct{}:存储游戏中的子弹
- monsters map[*Monster]struct{}:存储游戏中的怪物
- mode Mode:标识当前游戏是待开始、已开始、已结束
- failedCountLimit int:最多能漏掉多少个怪物
- failedCount int:当前已经漏掉的怪物个数
- func1:添加init方法,包括初始化怪物的个数、玩家的位置等
- func2:实现Update方法:用于更新怪物、玩家、子弹的位置
- func3:实现Draw方法,重新渲染页面,实现页面动态效果
- 定义GameObj结构体(子弹、怪物、用户角色都需要用到宽高、以及x、y坐标,所以可以抽取出一个Obj)
- width int
- height int
- x int
- y int
- func1:Width() int
- func2:Height() int
- func3:X() int
- func4:Y() int
- 定义model.Bullet
- GameObj 包含x、y坐标(方便后续移动子弹)
- image:子弹的样式
- speedFactor:子弹的移动速度
- fun1:NewBullet
- func2:实现自己的Draw方法
- func3:outOfScreen,判断子弹是否移出了屏幕。当子弹超出屏幕时,应当删除,不再维护。
- 定义model.Monster(类比model.Bullet,此处包含怪物的样式、移动速度同时通过GameObj维护怪物坐标x、y)
- GameObj
- img *ebiten.Image
- speedFactor int
- fun1:NewMonster
- func2:Draw
- func3:OutOfScreen
- 定义model.Ship(类比model.Bullet,此处包含用户角色样式、移动速度)
- GameObj
- img *ebiten.Image
- speedFactor int
- fun1:NewShip
- func2:Draw
tips:
- 游戏胜负判定规则:
- 胜利win:
- 遗漏的怪物数<=N(配置文件配置)
- 失败lose:
- 飞船(用户角色碰到怪物)
- 遗漏掉太多怪物
项目结构
2.3 代码
①game_demo/config/config.go
package configimport ("encoding/json""github.com/ziyifast/log""image/color""os"
)type Config struct {ScreenWidth int `json:"screen_width"`ScreenHeight int `json:"screen_height"`Title string `json:"title"`BgColor color.RGBA `json:"bg_color"`MoveSpeed int `json:"move_speed"`BulletWidth int `json:"bullet_width"`BulletHeight int `json:"bullet_height"`BulletSpeed int `json:"bullet_speed"`BulletColor color.RGBA `json:"bullet_color"`MaxBulletNum int `json:"max_bullet_num"` //页面中最多子弹数量BulletInterval int64 `json:"bullet_interval"` //发射子弹间隔时间MonsterSpeedFactor int `json:"monster_speed_factor"`TitleFontSize int `json:"title_font_size"`FontSize int `json:"font_size"`SmallFontSize int `json:"small_font_size"`FailedCountLimit int `json:"failed_count_limit"` //最多能遗漏多少怪物
}func LoadConfig() *Config {file, err := os.Open("./config.json")if err != nil {log.Fatalf("%v", err)}defer file.Close()config := new(Config)err = json.NewDecoder(file).Decode(config)if err != nil {log.Fatalf("%v", err)}return config
}
②game_demo/model/bullet.go
package modelimport ("github.com/hajimehoshi/ebiten/v2""image""ziyi.game.com/config"
)type Bullet struct {GameObjimage *ebiten.ImagespeedFactor int
}// NewBullet 添加子弹
func NewBullet(cfg *config.Config, ship *Ship) *Bullet {rect := image.Rect(0, 0, cfg.BulletWidth, cfg.BulletHeight)img := ebiten.NewImageWithOptions(rect, nil)img.Fill(cfg.BulletColor)b := &Bullet{image: img,speedFactor: cfg.BulletSpeed,}b.GameObj.width = cfg.BulletWidthb.GameObj.height = cfg.BulletHeightb.GameObj.y = ship.Y() + (ship.Height()-cfg.BulletHeight)/2b.GameObj.x = ship.X() + (ship.Width()-cfg.BulletWidth)/2return b
}func (b *Bullet) Draw(screen *ebiten.Image) {op := &ebiten.DrawImageOptions{}op.GeoM.Translate(float64(b.X()), float64(b.Y()))screen.DrawImage(b.image, op)
}func (b *Bullet) outOfScreen() bool {return b.Y() < -b.Height()
}
③game_demo/model/entity.go
package modeltype Entity interface {Width() intHeight() intX() intY() int
}
④game_demo/model/game.go
package modelimport ("github.com/hajimehoshi/ebiten/v2""github.com/hajimehoshi/ebiten/v2/examples/resources/fonts""github.com/hajimehoshi/ebiten/v2/text""github.com/ziyifast/log""golang.org/x/image/font""golang.org/x/image/font/opentype""image/color""math/rand""time""ziyi.game.com/config"
)type Mode intconst (ModeTitle Mode = iotaModeGameModeOver
)var r *rand.Randfunc init() {source := rand.NewSource(time.Now().UnixMicro())r = rand.New(source)
}type Game struct {input *Inputship *Shipconfig *config.Configbullets map[*Bullet]struct{}monsters map[*Monster]struct{}mode ModefailedCountLimit intfailedCount int
}func (g *Game) init() {g.mode = ModeTitleg.failedCount = 0g.bullets = make(map[*Bullet]struct{})g.monsters = make(map[*Monster]struct{})g.ship = NewShip(g.config.ScreenWidth, g.config.ScreenHeight)g.createMonsters()
}func NewGame() *Game {c := config.LoadConfig()//set window size & titleebiten.SetWindowSize(c.ScreenWidth, c.ScreenHeight)ebiten.SetWindowTitle(c.Title)g := &Game{input: &Input{},ship: NewShip(c.ScreenWidth, c.ScreenHeight),config: c,bullets: make(map[*Bullet]struct{}),monsters: make(map[*Monster]struct{}),failedCount: 0,failedCountLimit: c.FailedCountLimit,}//初始化外星人g.createMonsters()g.CreateFonts()return g
}func (g *Game) Draw(screen *ebiten.Image) {var titleTexts []stringvar texts []stringswitch g.mode {case ModeTitle:titleTexts = []string{"RUN GOPHER"}texts = []string{"", "", "", "", "", "", "", "PRESS SPACE KEY", "", "OR LEFT MOUSE"}case ModeGame://set screen colorscreen.Fill(g.config.BgColor)//draw gopherg.ship.Draw(screen, g.config)//draw bulletfor b := range g.bullets {b.Draw(screen)}//draw monstersfor a := range g.monsters {a.Draw(screen)}case ModeOver:screen.Fill(color.Black)g.Update()texts = []string{"", "GAME OVER!"}}for i, l := range titleTexts {x := (g.config.ScreenWidth - len(l)*g.config.TitleFontSize) / 2text.Draw(screen, l, titleArcadeFont, x, (i+4)*g.config.TitleFontSize, color.RGBA{R: 0,G: 100,B: 0,A: 0,})}for i, l := range texts {x := (g.config.ScreenWidth - len(l)*g.config.FontSize) / 2text.Draw(screen, l, arcadeFont, x, (i+4)*g.config.FontSize, color.RGBA{R: 0,G: 100,B: 0,A: 0,})}
}func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {return g.config.ScreenWidth, g.config.ScreenHeight
}func (g *Game) Update() error {switch g.mode {case ModeTitle:if g.input.IsKeyPressed() {g.mode = ModeGame}case ModeGame:g.input.Update(g)//更新子弹位置for b := range g.bullets {if b.outOfScreen() {delete(g.bullets, b)}b.y -= b.speedFactor}//更新敌人位置for a := range g.monsters {a.y += a.speedFactor}//检查是否击相撞(击中敌人)g.CheckKillMonster()//外星人溜走 或者 是否飞机碰到外星人if g.failedCount >= g.failedCountLimit || g.CheckShipCrashed() {g.mode = ModeOverlog.Warnf("over..........")}go func() {if len(g.monsters) < 0 {//下一波怪物g.createMonsters()}}()case ModeOver://游戏结束,恢复初始状态if g.input.IsKeyPressed() {g.init()g.mode = ModeTitle}}return nil}func (g *Game) addBullet(bullet *Bullet) {g.bullets[bullet] = struct{}{}
}func (g *Game) createMonsters() {a := NewMonster(g.config)//怪物之间需要有间隔availableSpaceX := g.config.ScreenWidth - 2*a.Width()numMonsters := availableSpaceX / (2 * a.Width())//预设怪物数量for i := 0; i < numMonsters; i++ {monster := NewMonster(g.config)monster.x = monster.Width() + 2*monster.Width()*imonster.y = monster.Height() + r.Intn(g.config.ScreenHeight/10)g.addMonsters(monster)}
}func (g *Game) addMonsters(monster *Monster) {g.monsters[monster] = struct{}{}
}func (g *Game) CheckKillMonster() {for monster := range g.monsters {for bullet := range g.bullets {if checkCollision(bullet, monster) {delete(g.monsters, monster)delete(g.bullets, bullet)}}if monster.OutOfScreen(g.config) {g.failedCount++delete(g.monsters, monster)}}
}func (g *Game) CheckShipCrashed() bool {for monster := range g.monsters {if checkCollision(g.ship, monster) {return true}}return false
}// 检测子弹是否击中敌人
func checkCollision(entity1 Entity, entity2 Entity) bool {//只需要计算子弹顶点在敌人矩形之中,就认为击中敌人entity2Top := entity2.Y()entity2Left := entity2.X()entity2Bottom := entity2.Y() + entity2.Height()entity2Right := entity2.X() + entity2.Width()x, y := entity1.X(), entity1.Y()//击中敌人左上角if x > entity2Left && x < entity2Right && y > entity2Top && y < entity2Bottom {return true}//击中敌人右上角x, y = entity1.X(), entity1.Y()+entity1.Height()if x > entity2Left && x < entity2Right && y > entity2Bottom && y < entity2Top {return true}//左下角x, y = entity1.X()+entity1.Width(), entity1.Y()if y > entity2Top && y < entity2Bottom && x > entity2Left && x < entity2Right {return true}//右下角x, y = entity1.X()+entity1.Width(), entity1.Y()+entity1.Height()if y > entity2Top && y < entity2Bottom && x > entity2Left && x < entity2Right {return true}return false
}//加载页面字体var (titleArcadeFont font.FacearcadeFont font.FacesmallArcadeFont font.Face
)// CreateFonts 初始化页面字体信息
func (g *Game) CreateFonts() {tt, err := opentype.Parse(fonts.PressStart2P_ttf)if err != nil {log.Fatalf("%v", err)}const dpi = 72titleArcadeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{Size: float64(g.config.TitleFontSize),DPI: dpi,Hinting: font.HintingFull,})if err != nil {log.Fatal(err)}arcadeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{Size: float64(g.config.FontSize),DPI: dpi,Hinting: font.HintingFull,})if err != nil {log.Fatal(err)}smallArcadeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{Size: float64(g.config.SmallFontSize),DPI: dpi,Hinting: font.HintingFull,})if err != nil {log.Fatal(err)}
}
⑤game_demo/model/game_obj.go
package model// GameObj 后续除了普通敌人还可能有其他小boss,因此我们直接将所有物体抽象出来
type GameObj struct {width intheight intx inty int
}func (o *GameObj) Width() int {return o.width
}func (o *GameObj) Height() int {return o.height
}func (o *GameObj) X() int {return o.x
}func (o *GameObj) Y() int {return o.y
}
⑥game_demo/model/input.go
package modelimport ("github.com/hajimehoshi/ebiten/v2""time"
)type Input struct {lastBulletTime time.Time //上次子弹发射时间,避免用户一直按着连续发子弹
}func (i *Input) IsKeyPressed() bool {//按下空格或者鼠标左键,游戏开始if ebiten.IsKeyPressed(ebiten.KeySpace) || ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {return true}return false
}func (i *Input) Update(g *Game) {cfg := g.configs := g.ship//listen the key eventif ebiten.IsKeyPressed(ebiten.KeyLeft) {s.GameObj.x -= cfg.MoveSpeed//防止飞船跑出页面 prevents movement out of the pageif s.X() < -s.Width()/2 {s.x = -s.Width() / 2}} else if ebiten.IsKeyPressed(ebiten.KeyRight) {s.GameObj.x += cfg.MoveSpeedif s.X() > cfg.ScreenWidth-s.Width()/2 {s.GameObj.x = cfg.ScreenWidth - s.Width()/2}}if ebiten.IsKeyPressed(ebiten.KeySpace) {if len(g.bullets) < cfg.MaxBulletNum && time.Since(i.lastBulletTime).Milliseconds() > cfg.BulletInterval {//发射子弹bullet := NewBullet(cfg, s)g.addBullet(bullet)i.lastBulletTime = time.Now()}}}
⑦game_demo/model/monster.go
package modelimport ("github.com/hajimehoshi/ebiten/v2""github.com/hajimehoshi/ebiten/v2/ebitenutil""github.com/ziyifast/log""ziyi.game.com/config"
)type Monster struct {GameObjimg *ebiten.ImagespeedFactor int
}func NewMonster(cfg *config.Config) *Monster {image, _, err := ebitenutil.NewImageFromFile("/Users/ziyi2/GolandProjects/MyTest/demo_home/game_demo/images/monster.bmp")if err != nil {log.Fatal("%v", err)}width, height := image.Bounds().Dx(), image.Bounds().Dy()a := &Monster{img: image,speedFactor: cfg.MonsterSpeedFactor,}a.GameObj.width = widtha.GameObj.height = heighta.GameObj.x = 0a.GameObj.y = 0return a
}func (a *Monster) Draw(screen *ebiten.Image) {op := &ebiten.DrawImageOptions{}op.GeoM.Translate(float64(a.X()), float64(a.Y()))screen.DrawImage(a.img, op)
}func (a *Monster) OutOfScreen(cfg *config.Config) bool {if a.Y()+a.Height() > cfg.ScreenHeight {return true}return false
}
⑧game_demo/model/ship.go
package modelimport ("github.com/hajimehoshi/ebiten/v2""github.com/hajimehoshi/ebiten/v2/ebitenutil""github.com/ziyifast/log"_ "golang.org/x/image/bmp""ziyi.game.com/config"
)type Ship struct {GameObjimage *ebiten.Image
}func NewShip(screenWidth, screenHeight int) *Ship {image, _, err := ebitenutil.NewImageFromFile("/Users/ziyi2/GolandProjects/MyTest/demo_home/game_demo/images/ship.bmp")if err != nil {log.Fatalf("%v", err)}width, height := image.Bounds().Dx(), image.Bounds().Dy()s := &Ship{image: image,}s.GameObj.width = widths.GameObj.height = heights.GameObj.x = screenWidth / 2s.GameObj.y = screenHeight - heightreturn s
}func (ship *Ship) Draw(screen *ebiten.Image, cfg *config.Config) {// draw by selfop := &ebiten.DrawImageOptions{}//init ship at the screen centerop.GeoM.Translate(float64(ship.X()), float64(ship.Y()))screen.DrawImage(ship.image, op)
}
⑨game_demo/config.json
{"screen_width": 640,"screen_height": 480,"title": "ziyi game","bg_color": {"r": 255,"g": 255,"b": 255,"a": 0},"move_speed": 2,"bullet_speed": 5,"bullet_width": 5,"bullet_height": 7,"bullet_color": {"r": 80,"g": 80,"b": 80,"a": 255},"max_bullet_num": 10,"bullet_interval": 50,"monster_speed_factor": 1,"title_font_size": 15,"font_size": 8,"small_font_size": 3,"failed_count_limit": 5
}
⑩game_demo/main.go
package mainimport ("github.com/hajimehoshi/ebiten/v2""github.com/ziyifast/log""ziyi.game.com/model"
)func main() {err := ebiten.RunGame(model.NewGame())if err != nil {log.Fatal("%v", err)}
}
运行项目:
go run main.go
参考文章:https://juejin.cn/post/7174070809864962055