1分类文件包
比如自己要写游戏写Config、GameFrame、Image、Model、Music、Tool等文件夹以后后续要创建文件的时候把他们放置在对应的包中,这样后续管理维护、或者寻找代码的时候会方便很多
2实现游戏要的窗口
写游戏的第一步必然是要先把窗口给实现出来我用的是一起用Go做一个小游戏(上) - 知乎 (zhihu.com)这个网站的教学,直接看我的也可能看不懂,看一下呢个链接之后,然后以我的总结来做一个参考或者补充。接下来就继续看我的总结。可以先像他一样用一个Game结构体把update\draw\layout三个方法都实现,并赋予参数,首先用layout以及newGame里面的ebiten调用的一些方法,方法传递给ebiten,ebiten就能把窗口初始化出来,先把窗口显现出来,游戏就成功一半了。
想要使用ebiten的情况下
ebitengine 要求Go版本 >= 1.15。使用go module下载这个包:
$ go get -u github.com/hajimehoshi/ebiten/v2
type Game struct{} func (g *Game) Update() error { return nil} func (g *Game) Draw(screen *ebiten.Image) { ebitenutil.DebugPrint(screen, "Hello, World")} func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { return 1280, 750} func main() { ebiten.SetWindowSize(1280, 750) ebiten.SetWindowTitle("低配版天天酷跑") if err := ebiten.RunGame(&Game{}); err != nil { log.Fatal(err) }}
3开始添加基本功能让人物展现在页面
人物场景的出现,Game结构体就是写在GameFrame他就是整个游戏的框架,也就是这个游戏中其他有关游戏的代码都要转接到这个框架结构体中,都是为他服务,他再为ebiten引擎服务,之后再出现游戏界面。
假设说我现在想要在窗口的右下角出现一个人物,那我就在Model包中创建human文件,并在文件中创建human结构体并将该结构体添加到Game结构体中,human结构体中书写这个人物的信息,之后用一个newHuman来初始化它的信息,再去初始化Game结构体中的newGame方法中用Game.human.newHuman()把human的初始化方法传到框架中,当人物结构体的参数已经初始化完成,在human方法中在写一个draw方法,这个方法中写用引擎如何接收人物结构体中的信息, 我这里是找了一个protect的名字代替了human了
type Protect struct {
Image *ebiten.Image
LastTime time.Time
Display bool
X int
Y int
}
func NewProtect(screenWidth, screenHeight int) *Protect {
protectpng, _, err := ebitenutil.NewImageFromFile("./src/images/protect.png")
if err != nil {
log.Fatal(err)
}
protect := &Protect{
Image: protectpng,
LastTime: time.Now(),
Display: true,
X: 0,
Y: 0,
}
return protect
}
// 单独书写一个声明值的方法,方便后续管理
func (protect *Protect) Draw(screen *ebiten.Image, cfg *Config.Config, r *Realm) {
op := &ebiten.DrawImageOptions{}
protect.X--
op.GeoM.Translate(float64(protect.X), float64(protect.Y))
screen.DrawImage(protect.Image, op)
}
然后画在屏幕的哪一个地方。完成之后就把这个人物结构体的draw方法,再回调到Game结构体中的draw里面去使用这个方法,(后续图片有展示)这样子当游戏开始的时候就会从main方法到Game再到human,就可以成功把human里面的代码实现了。
4实现让人物可以移动就用到update方法
想要实现让人物移动,那么就要使用到update方法了,他和draw还有layout是一样的,都是这个ebiten引擎的首要部分,他们是不需要调用的。Ebiten引擎在启动游戏循环的时候会在合适时机调用实现了Game接口的类型的方法。它会在每一帧渲染之前调用Update方法,然后渲染帧之后调用Draw方法。所以Update方法就是用来更新其他结构体的参数的,比如我在Update中写human.X--那么游戏开始的时候这个人物的X值就会每一帧都--,每一帧X--了之后,human.draw方法都会根据human的X值重新渲染这个图像,也就是人眼所看到的human的移动了。 实现智能化,就是添加一个键盘监听,就能让人根据自己的意愿实现移动,或者说使用算法,让里面的场景实现智能化。
//将游戏核心逻辑转移到game.go文件中,定义游戏对象结构和创建游戏对象的方法
// ebiten引擎传入的游戏对象必须要实现一个接口,让这个接口实现Game结构体的所有方法
// 这个接口说的是ebiten.RunGame(&GameFrame{})里面传参的这个Ganme类
// 让给Game结构体作为包含所有子结构体的,也算是项目的中间轴承一样的作用。
type Game struct {
mode Mode
input *Input
realm *model.Realm
beijing *model.Beijing
live *model.Live
protect *model.Protect
obstacle *model.Obstacle
prop *model.Prop
Cfg *Config.Config
bullets map[*model.Bullet]struct{}
overMsg string
RealmDist float64
}
func NewGame() *Game {
cfg := Config.LoadConfig()
ebiten.SetWindowSize(cfg.ScreenWidth, cfg.ScreenHeight)
ebiten.SetWindowTitle(cfg.Title)
g := &Game{
input: &Input{Msg: "HelloWorld"},
realm: model.NewRealm(cfg.ScreenWidth, cfg.ScreenHeight),
beijing: model.NewBeijing(cfg.ScreenWidth, cfg.ScreenHeight),
live: model.NewLive(cfg.ScreenWidth, cfg.ScreenHeight),
protect: model.NewProtect(cfg.ScreenWidth, cfg.ScreenHeight),
obstacle: model.ObstacleInit(),
prop: model.NewProp(cfg.ScreenWidth, cfg.ScreenHeight),
bullets: make(map[*model.Bullet]struct{}),
Cfg: cfg,
RealmDist: 0,
}
tool.NewMusic()
tool.Play1music.Play()
g.CreateFonts()
return g
}
后续添加功能就是和第四个类似添加新的结构体,然后把这个结构体加入到主框架结构体Game里面,之后再把这个结构体的信息给初始化,这个结构体想要实现的相关功能就在自己的go文件内部实现。还有想要让它展现在屏幕上要实现的三个方法。Draw、updata。
5工具类的代码书写
当然也有一些go文件是没写结构体的。就是比如在工具中的,这个文件中仅仅放置了方法,方法内部传的参数也是其他结构体的内容,用来改变其他结构体的参数。所以它不需要自己的结构体。像是后面的音乐还有碰撞方法都是再tool内部放置的,可以反复使用的的功能就放置在哪里。比如碰撞把每个可能会发生碰撞的结构体内部都会有物体的x,y坐标以及宽度和高度,只要把结构体中的这些变量再用一个结构体包裹起来,然后碰撞方法里面面的参数类型就写呢个只有x,y,height,weight四个变量类型的结构体就可以把碰撞多次实现了。(我写的是因为碰撞只有人物和障碍物,所以就没有用结构体去包裹他们的内容,就直接把类型写了人物和障碍物的结构类型了)
//CheckCollision检查人物和障碍物之间是否有碰撞
//写这个碰撞检测,如果存在可能是一个障碍物完全包裹另外一个的情况的下,就需要把可能面积小的呢个物体
//放置在第一个参数,因为检测的是,第一个参数的四个角是否有一个存在于第二个参数之中
func CheckCollision(obstacle *model.Obstacle, i int, realm *model.Realm) bool {
//障碍物的四个角的坐标
obstacleLeftUpX, obstacleLeftUpY := obstacle.X[i], obstacle.Y[i]
obstacleLeftDownX, obstacleLeftDownY := obstacle.X[i], obstacle.Y[i]+obstacle.Height[i]
obstacleRightUPX, obstacleRightUPY := obstacle.X[i]+obstacle.Width[i], obstacle.Y[i]
obstacleRightDownX, obstacleRightDownY := obstacle.X[i]+obstacle.Width[i], obstacle.Y[i]+obstacle.Height[i]
//障碍物左上角
if CheckColl(obstacleLeftUpX, obstacleLeftUpY, realm) {
return true
}
//障碍物左下角
if CheckColl(obstacleLeftDownX, obstacleLeftDownY, realm) {
return true
}
//障碍物右上角
if CheckColl(obstacleRightUPX, obstacleRightUPY, realm) {
return true
}
//障碍物右下角
if CheckColl(obstacleRightDownX, obstacleRightDownY, realm) {
//fmt.Println(obstacleRightDownX, obstacleRightDownY)
return true
}
return false
}
// 判断障碍物的四个角是否有一个出现在人的内部,
//但是人站立的时候面积是大于障碍物,如果人是比障碍物小的(下蹲)那就有bug了
//这样障碍物就会包裹住人,也就是虽然有接触,但是障碍物的四个点都不在人的内部,也就无法成功
//碰撞检测
// 解决这个bug,让人下蹲的时候,只要比障碍物高就行了
func CheckColl(X, Y float64, realm *model.Realm) bool {
//realm的四个角的坐标
LeftX := realm.X + 40
RightX := realm.X + float64(realm.Width[realm.State]) - 40
UPY := realm.Y[realm.State]
DownY := realm.Y[realm.State] + float64(realm.Height[realm.State]) - 40
//判断这个点是否在Realm的长方体之内
if X >= LeftX && X <= RightX {
if Y <= DownY && Y >= UPY {
return true
}
}
return false
}
6游戏的趣味性书写
然后之后就是用自己的算法来让这个游戏的功能更加丰富,比如说我的跑酷游戏就是想要让后面的场景实现无线循环,直接找到一个无限长度的背景图片过于困难,我就找了一张首尾呢个够无缝衔接的图片,然后让这两张图片给渲染连接起来,然后因为同一时间,窗口只能显示一张图片,所以就让当图片改出现第三张的时候,就让两张连接起来的图片给整体右移一个图片大小的宽度,这样就是第一张变成第二张,第二张变成第三张了。之后在遇到第四张的时候就再同样的原理改变背景的X坐标。
比如说一张图片时400宽度,两张图片合起来就是800,我设置图片的最左边为x坐标,在每一帧的时候x就能右移动一部分。当x坐标移动到400的时候做一个判断,在判断里面在x=0,也就是说场景每移动到第二张的时候,就会有判断条件把它改成第一张,但是第一张和第二张的图片时一样的。一帧内的切换,人眼也看不出来,这样也就实现了场景无线循环的功能。
其他陆续的功能都是这样,首先用算法把想法实现,把实现的结果调用到update方法中用来更新结果,之后在draw里面把更新的参数给展示出来。这样新的功能就实现了。
//一个用于后续使用随机数的两个方法
var randSeed = rand.NewSource(time.Now().UnixNano())
var randGen = rand.New(randSeed)
type Obstacle struct {
Image []*ebiten.Image
Width []float64
Height []float64
X []float64
Y []float64
ObstacleType []int
}
func ObstacleInit() *Obstacle {
img1, _, err := ebitenutil.NewImageFromFile("./src/images/dimian.png")
if err != nil {
log.Fatal(err)
}
img2, _, err := ebitenutil.NewImageFromFile("./src/images/kongzhong.png")
if err != nil {
log.Fatal(err)
}
width1, height1 := img1.Size()
width2, height2 := img2.Size()
//fmt.Println(width1, height1)240 258
//fmt.Println(width2, height2) 240 420
obs := &Obstacle{
Image: []*ebiten.Image{img1, img2},
Width: []float64{float64(width1), float64(width2)},
Height: []float64{float64(height1), float64(height2)},
X: []float64{550, 1100},
Y: []float64{500, 120}, //315
ObstacleType: []int{0, 1},
}
return obs
}
func (Obstacle *Obstacle) Draw(screen *ebiten.Image, cfg *Config.Config) {
op1 := &ebiten.DrawImageOptions{}
//index为0的障碍物
op1.GeoM.Translate(Obstacle.X[0], Obstacle.Y[0])
Obstacle.X[0] -= cfg.ObstacleSpeed
//如果障碍物完全离开屏幕,则重新生成障碍物
if Obstacle.X[0]+Obstacle.Width[1] <= 0 {
Obstacle.X[0] = float64(randGen.Intn(600)) + 900 //1 280--1680
juddis(&Obstacle.X[0], &Obstacle.X[1])
}
//index为1的障碍物
op2 := &ebiten.DrawImageOptions{}
op2.GeoM.Translate(Obstacle.X[1], Obstacle.Y[1])
Obstacle.X[1] -= cfg.ObstacleSpeed
//如果障碍物完全离开屏幕,则重新生成障碍物
if Obstacle.X[1]+Obstacle.Width[1] <= 0 {
Obstacle.X[1] = float64(randGen.Intn(600)) + 900 //1280---2080
juddis(&Obstacle.X[1], &Obstacle.X[0])
}
screen.DrawImage(Obstacle.Image[0], op1)
screen.DrawImage(Obstacle.Image[1], op2)
}
// 写一个判断方法,如果新生成的障碍物和另外一个障碍物的距离过近的话,就让他改变位置
func juddis(a *float64, b *float64) {
if *a >= *b {
if *a-*b < 400 {
*a = 560 + *b
fmt.Printf("1")
}
} else {
//主要是新创建的又概率是在上一个创建的前面,
//如果是这样的话,就要进行接下来的操作。
if *b-*a < 400 {
*b = 600 + *a
fmt.Printf("2")
}
}
}
6当游戏功能完成实现的时候,完善游戏进入前进入后的画面渲染
就开始些整体结构的问题了,就是游戏完成了,但是游戏的进入前,以及游戏结束后,窗口又应该展现出什么东西?所以就可以把刚才实现的游戏功能可以理解为是封装起来了。然后再把游戏的状态再分个游戏前、游戏中、游戏后分三个比如我写的就是 用一个switch case三个状态:ModeTitle、ModeGame、ModeOver三个,然后游戏的中心还是ebiten引擎的update和draw方法,就把update和draw里面都是相互照应的,我把之前写的都是游戏中的功能,把功能给写道case : ModeGame 把游戏前的东西放置到ModeTitle这个时候不会调用ModeGame的内容也就不会显示它的东西,这个时候就显示游戏前的标题或者介绍,再写一个键盘监听,如果摁下空格那么g.mode 就会便变为ModeGame了。那么下一帧的时候就不会再调用游戏前的功能,而是游戏中的。当人物的血耗尽的时候也是一样一个判断内部把g.mode给改成ModeOver,在对应dupdata中的ModeOver写键盘监听,触发就让g.mode再发生改变。因为update和draw都是会每一帧都运行嘛,所以只要有条件判断更改g.mode 的时候,那么下一帧调用update和draw的时候就会根据switch去选择性的调用相应的功能啦。
func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(g.Cfg.BgColor)
/**
绘制一下游戏开始以及结束时候的文字显示
启动时游戏处于ModeTitle状态,处于ModeTitle和ModeOver时只需要在屏幕上显示一些文字即可。
只有在ModeGame状态才需要显示飞船和外星人:
*/
var titleTexts []string //它是一个切片 未定义数组长度
var texts []string
switch g.mode {
case ModeTitle:
titleTexts = []string{"ALIEN INVASION"}
texts = []string{"", "", "", "", "", "", "", "PRESS SPACE KEY", "", "OR LEFT MOUSE"}
case ModeGame:
g.beijing.Draw(screen, g.Cfg, g.realm)
g.obstacle.Draw(screen, g.Cfg)
g.prop.Draw(screen, g.Cfg, g.realm)
g.live.Draw(screen, g.Cfg, g.realm)
g.protect.Draw(screen, g.Cfg, g.realm)
g.realm.Draw(screen, g.Cfg)
for bullet := range g.bullets {
bullet.Draw(screen)
}
Str := "distant:" + strconv.FormatFloat(g.RealmDist, 'f', -1, 64)
x := g.Cfg.ScreenWidth - len(Str)*g.Cfg.TitleFontSize
//fmt.Println(x)
text.Draw(screen, Str, titleArcadeFont, x, 100, color.White)
case ModeOver:
texts = []string{"", g.overMsg}
}
for i, l := range titleTexts {
//。x 变量用于计算文本的水平位置,以便将文本居中对齐
x := (g.Cfg.ScreenWidth - len(l)*g.Cfg.TitleFontSize) / 2
//。text.Draw 函数用于在屏幕上绘制文本 i表示的是索引,l才是元素对象
/**
text.Draw 函数的第一个参数是要绘制的文本,第二个参数是字体对象,
第三个参数是字体大小,第四个参数是文本的水平位置,第五个参数
是文本的垂直位置,第六个参数是文本的颜色
*/
text.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) / 2
text.Draw(screen, l, arcadeFont, x, (i+4)*g.Cfg.FontSize, color.White)
}
}
7至此游戏就结束啦
然后就可以写一篇总结看自己学到的知识。