Golang开发2D小游戏全套教程(已完结)

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 思路

  1. 程序入口: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):设置页面的布局及大小
  1. 定义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方法,重新渲染页面,实现页面动态效果
  1. 定义GameObj结构体(子弹、怪物、用户角色都需要用到宽高、以及x、y坐标,所以可以抽取出一个Obj)
  • width int
  • height int
  • x int
  • y int
  • func1:Width() int
  • func2:Height() int
  • func3:X() int
  • func4:Y() int
  1. 定义model.Bullet
  • GameObj 包含x、y坐标(方便后续移动子弹)
  • image:子弹的样式
  • speedFactor:子弹的移动速度
  • fun1:NewBullet
  • func2:实现自己的Draw方法
  • func3:outOfScreen,判断子弹是否移出了屏幕。当子弹超出屏幕时,应当删除,不再维护。
  1. 定义model.Monster(类比model.Bullet,此处包含怪物的样式、移动速度同时通过GameObj维护怪物坐标x、y)
  • GameObj
  • img *ebiten.Image
  • speedFactor int
  • fun1:NewMonster
  • func2:Draw
  • func3:OutOfScreen
  1. 定义model.Ship(类比model.Bullet,此处包含用户角色样式、移动速度)
  • GameObj

  • img *ebiten.Image

  • speedFactor int

  • fun1:NewShip

  • func2:Draw
    tips:

  • 游戏胜负判定规则:

  1. 胜利win:
    • 遗漏的怪物数<=N(配置文件配置)
  2. 失败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

参考文章:https://juejin.cn/post/7174070809864962055

相关推荐
睡觉谁叫~~~42 分钟前
一文解秘Rust如何与Java互操作
java·开发语言·后端·rust
PandaQue43 分钟前
《怪物猎人:荒野》游戏可以键鼠直连吗
游戏
2401_865854883 小时前
iOS应用想要下载到手机上只能苹果签名吗?
后端·ios·iphone
AskHarries3 小时前
Spring Boot集成Access DB实现数据导入和解析
java·spring boot·后端
2401_857622663 小时前
SpringBoot健身房管理:敏捷与自动化
spring boot·后端·自动化
程序员阿龙3 小时前
基于SpringBoot的医疗陪护系统设计与实现(源码+定制+开发)
java·spring boot·后端·医疗陪护管理平台·患者护理服务平台·医疗信息管理系统·患者陪护服务平台
程思扬4 小时前
为什么Uptime+Kuma本地部署与远程使用是网站监控新选择?
linux·服务器·网络·经验分享·后端·网络协议·1024程序员节
阿华的代码王国4 小时前
【Spring】——SpringBoot项目创建
java·spring boot·后端·启动类·target文件
九鼎科技-Leo4 小时前
什么是 ASP.NET Core?与 ASP.NET MVC 有什么区别?
windows·后端·c#·asp.net·mvc·.net
阿芯爱编程5 小时前
平衡二叉树
java·后端·算法