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讲解,其他操作系统类似。
bash
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
go
package main
import (
"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
# 运行程序
go run main.go
效果:
2 实战RUN Gopher
2.1 最终效果
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
go
package config
import (
"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
go
package model
import (
"github.com/hajimehoshi/ebiten/v2"
"image"
"ziyi.game.com/config"
)
type Bullet struct {
GameObj
image *ebiten.Image
speedFactor 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.BulletWidth
b.GameObj.height = cfg.BulletHeight
b.GameObj.y = ship.Y() + (ship.Height()-cfg.BulletHeight)/2
b.GameObj.x = ship.X() + (ship.Width()-cfg.BulletWidth)/2
return 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
go
package model
type Entity interface {
Width() int
Height() int
X() int
Y() int
}
④game_demo/model/game.go
go
package model
import (
"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 int
const (
ModeTitle Mode = iota
ModeGame
ModeOver
)
var r *rand.Rand
func init() {
source := rand.NewSource(time.Now().UnixMicro())
r = rand.New(source)
}
type Game struct {
input *Input
ship *Ship
config *config.Config
bullets map[*Bullet]struct{}
monsters map[*Monster]struct{}
mode Mode
failedCountLimit int
failedCount int
}
func (g *Game) init() {
g.mode = ModeTitle
g.failedCount = 0
g.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 & title
ebiten.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 []string
var texts []string
switch g.mode {
case ModeTitle:
titleTexts = []string{"RUN GOPHER"}
texts = []string{"", "", "", "", "", "", "", "PRESS SPACE KEY", "", "OR LEFT MOUSE"}
case ModeGame:
//set screen color
screen.Fill(g.config.BgColor)
//draw gopher
g.ship.Draw(screen, g.config)
//draw bullet
for b := range g.bullets {
b.Draw(screen)
}
//draw monsters
for 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) / 2
text.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) / 2
text.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 = ModeOver
log.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()*i
monster.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.Face
arcadeFont font.Face
smallArcadeFont font.Face
)
// CreateFonts 初始化页面字体信息
func (g *Game) CreateFonts() {
tt, err := opentype.Parse(fonts.PressStart2P_ttf)
if err != nil {
log.Fatalf("%v", err)
}
const dpi = 72
titleArcadeFont, 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
go
package model
// GameObj 后续除了普通敌人还可能有其他小boss,因此我们直接将所有物体抽象出来
type GameObj struct {
width int
height int
x int
y 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
go
package model
import (
"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.config
s := g.ship
//listen the key event
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
s.GameObj.x -= cfg.MoveSpeed
//防止飞船跑出页面 prevents movement out of the page
if s.X() < -s.Width()/2 {
s.x = -s.Width() / 2
}
} else if ebiten.IsKeyPressed(ebiten.KeyRight) {
s.GameObj.x += cfg.MoveSpeed
if 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
go
package model
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/ziyifast/log"
"ziyi.game.com/config"
)
type Monster struct {
GameObj
img *ebiten.Image
speedFactor 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 = width
a.GameObj.height = height
a.GameObj.x = 0
a.GameObj.y = 0
return 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
go
package model
import (
"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 {
GameObj
image *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 = width
s.GameObj.height = height
s.GameObj.x = screenWidth / 2
s.GameObj.y = screenHeight - height
return s
}
func (ship *Ship) Draw(screen *ebiten.Image, cfg *config.Config) {
// draw by self
op := &ebiten.DrawImageOptions{}
//init ship at the screen center
op.GeoM.Translate(float64(ship.X()), float64(ship.Y()))
screen.DrawImage(ship.image, op)
}
⑨game_demo/config.json
go
{
"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
go
package main
import (
"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
go run main.go