用Go和Korok写一个Flappybird游戏-2
在 前一篇 中我们实现了一个简单的场景和一只用精灵动画驱动的 Flappy Bird。本章将会用 GUI 系统创建菜单和第二个场景,通过本章你会学会:
- 如何使用 GUI 系统
- 如何管理场景
- 给精灵添加物理
注:如果你没有完成上一节的教程,也可以直接从 这里 下载到上节结束时的代码,以便开始本节的内容。
添加菜单
在第一个场景中,我们需要显示 "Flappy Bird" 这个标题和一个 "开始" 按钮。我们并不打算使用 Entity 来实现这个功能,因为我们有更快的方式 - GUI系统。标题其实是张图片我们只要把它在正确的位置显示出来即可,而按钮只是一张可以点击的图片。
注:不同于 Android/iOS/WPF 这样的 RetainedMode 的 GUI,Korok 中使用的 ImmediateMode GUI,这种 GUI 很适合处理这种简单的 GUI 场景。
我们在 StartScene
中添加一些属性,这些属性分别记录了标题的纹理和应该绘制的位置,然后我们在 OnEnter
方法中初始化这些属性:
// 分别添加标题和开始按钮的属性
type StartScene struct {
title struct{
gfx.Tex2D
gui.Rect
}
start struct{
btnNormal gfx.Tex2D
btnPressed gfx.Tex2D
gui.Rect
}
}
// setup gui
// title
tt, _ := at.GetByName("game_name.png")
sn.title.Tex2D = tt
sn.title.Rect = gui.Rect {
X: (320 - 233)/2,
Y: 80,
W: 233,
H: 70,
}
// start button
btn, _ := at.GetByName("start.png")
sn.start.btnNormal = btn
sn.start.btnPressed = btn
sn.start.Rect = gui.Rect{
X: (320 - 120)/2,
Y: 300,
W: 120,
H: 60,
}
然后就可以在 OnUpdate
方法中绘制 UI 了:
func (sn *StartScene) Update(dt float32) {
// draw title
gui.Image(1, sn.title.Rect, sn.title.Tex2D, nil)
// draw start button
e := gui.ImageButton(2, sn.start.Rect, sn.start.btnNormal, sn.start.btnPressed, nil)
if e.JustPressed() {
// do something
}
}
如你所见,这就是 IMGUI 的使用方式,直接调用 gui.Image
就可以绘制一张图片,使用 gui.ImageButton
就可以绘制一个按钮,并且可以通过返回值来得到按钮的状态,一切都是立即完成的,不需要提前 new Button
也不需要 window.Add(button)
这样的操作 。当你调用这个方法的时候这个 UI 就已经在绘制了;同样当你不再调用这个方法的时候,UI就不再绘制没有任何缓存的状态存在。因为 Update
方法不断被调用的缘故,我们实际上是在不断重新绘制UI,所以它能显示出来。
现在运行一下,会看待下面的界面:
gui
GUI 默认会绘制在一个更高的z-order上,所以它不会被挡住。此时点击开始按钮不会有什么变化,还记得这句吗:
if e.JustPressed() {
// do something
}
我们并没有处理按钮的点击逻辑。
添加新的场景
我们可以把游戏逻辑也写在这个场景,但是这样的话,游戏逻辑会混在一起不利于维护,为此我们创建一个新的场景,并把游戏逻辑再此实现。添加一个新的文件game.go
:
// game.go
type GameScene struct {
}
func (sn *GameScene) OnEnter(g *game.Game) {
}
func (sn *GameScene) Update(dt float32) {
}
func (sn *GameScene) OnExit() {
}
然后在 StartScene
中添加一个新的方法 LoadGame()
,我们会在之前的按钮实现中调用它,这样点击按钮我们会跳转到新的场景:
// 处理按钮事件
if e.JustPressed() {
sn.LoadGame()
}
// 加载场景
func (sn *StartScene) LoadGame() {
gsn := &GameScene{}
// load game scene
korok.SceneMan.Load(gsn)
korok.SceneMan.Push(gsn)
}
调用 Load
方法加载场景,然后调用 Push
方法把当前场景入栈,这个操作会导致前一个场景“退出”(并没有真的退出只是它的 Update
方法不会再被调用)。同时新的场景的生命周期方法开始被依次调用,比如 OnEnter
和 OnUpdate
. 再次运行(go run main.go game.go
):
当我们点击“开始”按钮的时候,标题和按钮都消失了,但是背景/前景和小鸟都在。这并不奇怪,在 Korok 中是不主动删除前一个场景初始化的 Entity 的,所以即使场景转换了,在前一个场景中初始化的 Entity 还是会被绘制的(如果不希望保留前一场景的Entity可以在OnExit回调中删除,删除一个 Entity 只要删除它拥有的组件即可)。为什么菜单却消失了呢?这就是我们的 GUI 系统的工作原理,当前一帧的
Update()
方法不再被调用的时候,它的UI也就不再绘制了,而当前帧的 Update
方法是空的,所以不会再绘制任何 UI。为了让这个略显拗口的描述更形象些,让我们在新的场景中添加一些元素:
type GameScene struct {
ready struct{
gfx.Tex2D
gui.Rect
}
tap struct{
gfx.Tex2D
gui.Rect
}
}
func (sn *GameScene) OnEnter(g *game.Game) {
at, _ := asset.Texture.Atlas("images/bird.png")
// ready and tap image
sn.ready.Tex2D, _ = at.GetByName("getready.png")
sn.ready.Rect = gui.Rect{
X: (320-233)/2,
Y: 70,
W: 233,
H: 70,
}
sn.tap.Tex2D, _ = at.GetByName("tap.png")
sn.tap.Rect = gui.Rect{
X: (320-143)/2,
Y: 200,
W: 143, // 286
H: 123, // 246
}
}
func (sn *GameScene) Update(dt float32) {
// show ready
gui.Image(1, sn.ready.Rect, sn.ready.Tex2D, nil)
// show tap hint
gui.Image(2, sn.tap.Rect, sn.tap.Tex2D, nil)
}
func (sn *GameScene) OnExit() {
}
上面代码,在 GameScene
场景中绘制了两张图片,分别是 Ready 和 Tap 指示图片。让我们再次运行:
现在场景切换更明显了,只是还有些小小的瑕疵 —— 鸟的位置比较尴尬。如果玩过 Flappy Bird 这个游戏就会知道,场景切换后鸟的位置在中间偏左的位置。为此我们需要重新调整一下鸟的位置,那么如何从上一帧拿到鸟的 Entity 呢?我们可以把前一帧的 Entity “借”给当前帧:
// 在当前帧添加方法和属性
func (sn *GameScene) borrow(bird, bg, ground engi.Entity) {
sn.bird, sn.bg, sn.ground = bird, bg, ground
}
// 在前一帧调用
func (sn *StartScene) LoadGame() {
gsn := &GameScene{}
gsn.borrow(sn.bird, sn.bg, sn.ground)
// load game scene
korok.SceneMan.Load(gsn)
korok.SceneMan.Push(gsn)
}
// 重新调整鸟的位置
korok.Transform.Comp(sn.bird).SetPosition(f32.Vec2{80, 240})
这次我们分别在 StartScene
和 GameScene
中记录了 bird/bg/ground 的 Entity,然后在 LoadGame
方法中传给 GameScene
,这样就可以在 GameScene
中查询 bird 的 Transform 组件并重新调整位置。
调整之后鸟的位置已经正确了,现在看起来好多了。
处理游戏状态
点击屏幕之后我们需要让鸟儿做自由落体运动,但是目前点击屏幕不会有任何反应,一是我们并没有处理屏幕点击事件,二是我们并没有给鸟儿添加物理属性,它只会永远呆在原来的位置。
首先解决第一个问题,检测屏幕点击事件,可以通过用户输入系统来获取点击事件,在 Update
方法中添加如下方法:
if input.PointerButton(0).JustPressed() {
// do something
}
这个方法会不断的在每帧调用的时候检测用户输入事件,如果发现了屏幕点击,那么可以做出相应的处理(目前什么都没有做)。点击之后需要先隐藏 “Ready” 菜单,然后让鸟做自由落体。此时需要设计几个游戏状态来管理游戏的生命周期:
- Ready 准备状态,显示 Ready 菜单
- Running 运行状态,鸟受候物理作用并隐藏菜单
- Over 挂掉状态,显示失败菜单
在 GameScene.go
中定义这三个状态,然后在 GameScene
结构中添加一个游戏状态属性(默认是 Ready 状态):
type StateEnum int
const (
Ready StateEnum = iota
Running
Over
)
type GameScene struct {
state StateEnum
...
}
func (sn *GameScene) Update(dt float32) {
if st := sn.state; st == Ready {
sn.showReady(dt); return
} else if st == Over {
sn.showOver(dt)
return
}
}
func (sn *GameScene) showReady(dt float32) {
// show ready
gui.Image(1, sn.ready.Rect, sn.ready.Tex2D, nil)
// show tap hint
gui.Image(2, sn.tap.Rect, sn.tap.Tex2D, nil)
// check any click
if input.PointerButton(0).JustPressed() {
sn.state = Running
}
}
func (sn *GameScene) showOver(dt float32) {
}
现在我们可以通过状态来控制应该显示什么样的菜单,如果是 Ready
状态,则显示 READY 菜单,如果是 Over
状态则显示 OVER 菜单,并且我们把代码剥离到了各自的函数之中,这样更方便的管理。在 showReady
方法中,我们检测当前的屏幕事件,如果有屏幕点击则把游戏的状态改为 Running
,再次运行:
好了,现在在
Ready
状态下,点击屏幕,会隐藏菜单。然后游戏进入 Running
状态,但是鸟还是不动的,我们马上就给他加上物理。
添加物理效果
在游戏中,鸟分别在 x 和 y 方向做运动,y 方向实际上并没有发生变化而是让地面和游戏中管子不断向左运动而产生的效果,这样看上去像是鸟在向前飞行。但是 x 方向的运动是确确实实的。
这里仅仅用到了很少的物理知识,速度、加速和冲量。开始时鸟的速度为0,并受加速度影响(这回导致下落),如果点击屏幕则给鸟一个向上的冲量(换算后可以直接认为给鸟一个向上的速度)。根据以上逻辑,实现下面的物理代码:
// 定义重力加速度和冲量
const (
Gravity = 600
TapImpulse = 280
)
// 把所有鸟需要的属性重新封装到一个结构里面
type GameScene struct {
...
bird struct{
engi.Entity
f32.Vec2
vy float32
w, h float32
}
}
func (sn *GameScene) Update(dt float32) {
if st := sn.state; st == Ready {
sn.showReady(dt); return
} else if st == Over {
sn.showOver(dt)
return
}
// 检测屏幕点击,每次点击给鸟施加一次冲量
if input.PointerButton(0).JustPressed() {
sn.bird.vy = TapImpulse
}
// 模拟物理加速
sn.bird.vy -= Gravity * dt
sn.bird.Vec2[1] += sn.bird.vy * dt
// update bird position
b := korok.Transform.Comp(sn.bird.Entity)
b.SetPosition(sn.bird.Vec2)
}
首先定义了重力加速度和冲量(数值可以再微调)。现在把鸟的位置,速度都封装在一个结构里面这样方便管理,在 Update
方法检测点击,施加冲量,同时模拟物理仿真,最后调用 SetPosition
方法更新鸟的位置。运行这段代码:
现在鸟已经受到物理的作用了(点击两个两次,所以会看到有两次向上飞的样子). 但是并没有处理碰撞,所以它一直落到屏幕下面去了。
总结
本节,我们学习GUI系统,场景管理并且给精灵添加了物理效果。但是还没有处理碰撞,地面和管子也没有滚起来所以看起来还有点怪异,我们将在下一节完成这些操作。
代码我已经传到 GitHub - ntop001/flappybird,请关注 ch2 分支。