在 前一节,我们实现了游戏主场景,设计了游戏状态并添加了简单的物理。本章将继续完善游戏物理和游戏状态。本节你将学会:
- 如何处理连续的滚动效果
- 如何处理碰撞检测
- 实现无限的游戏内容
注:如果你没有完成上一节的教程,也可以直接从 这里 下载到上节结束时的代码,以便开始本节的内容。
地面的移动
暂时注释掉鸟的物理仿真部分代码,这样可以更专心的实现这部分逻辑。如何实现地面的移动呢?在纹理图集中有一张地面的图片在第一节中我们已经把它显示出来了。这是一张比屏幕要宽的图片,要实现地面的移动,可以不断的把“地面”向左移动,这样就会产生地面在“移动”的效果,当地面的右端被完全进入屏幕的时候,我们再重新开始这个动画,这样动画可以衔接起来产生一个地面在移动的效果。
// 添加新的常量
const ScrollVelocity = 100
// 把地面相关的属性打包在一起
GameScene {
...
ground struct{
engi.Entity
f32.Vec2
vx float32
}
}
// 初始化地面的初始位置和/滚动速度
OnEnter(g *game.Game) {
...
sn.ground.Vec2 = f32.Vec2{0, 100}
sn.ground.vx = ScrollVelocity
}
上面的代码把地面的相关参数,比如滚动速度,当前位置都封装在一个结构里面了。在 OnEnter
方法中初始化,然后就可以在 OnUpdate
方法中执行仿真了:
x := sn.ground.Vec2[0]
if x < -100 {
x = x + 90 // magic number (bridge start and end of the image)
}
x -= sn.ground.vx * dt
sn.ground.Vec2[0] = x
// update ground shift
g := korok.Transform.Comp(sn.ground.Entity)
g.SetPosition(sn.ground.Vec2)
这段代码不断的对地面的位置进行数值积分,然后用 korok.Transform.Comp(sn.ground.Entity)
查询到地面的 Transfrom
组件,更新它的位置(注:90是一个魔法数字它可以正好衔接地面的头部和尾部)。运行代码,此时可以看到:

不错,现在地面已经可以滚动了。接下来,我们要把之前注释的代码解开,并添加碰撞逻辑。
添加死亡状态
在处理碰撞逻辑之前,先给鸟添加一些状态以便更好的维护代码:
- Flying 鸟的正常飞行状态
- Dead 死亡状态
碰撞检测的原理非常简单,判断鸟的y值,如果大于屏幕高度则重置为屏幕高度,这样它就不会飞出屏幕,如果y值小于地面高度,则认为撞到了地面,此时设置游戏状态为 Over
, 鸟的状态为 Dead
,并停止精灵动画。
// 定义鸟的状态
type BirdStateEnum int
const (
Flying BirdStateEnum = iota
Dead
)
// 在此处添加 state 属性
bird struct{
state BirdStateEnum
engi.Entity
f32.Vec2
vy float32
w, h float32
}
// 在地面移动的代码下面添加下面的碰撞检测代码
if y := sn.bird.Vec2[1]; y > 480 {
sn.bird.Vec2[1] = 480
} else if y < 100 {
y = 100; sn.state = Over
if sn.bird.state != Dead {
sn.bird.state = Dead
korok.Flipbook.Comp(sn.bird.Entity).Stop()
}
}
执行以上的代码:

完美!现在鸟撞击到地面后状态被重置,精灵动画也被停止了。这是我们期望的状态。但是这个动作现在看来还有些怪异,在游戏中我们会看到鸟向上飞的时候头是仰着的,鸟落地的时候头是低下去的。解决这个问题,其实很简单,如果鸟的速度大于某个值,我们就把鸟的图片进行逆时针旋转,如果鸟的速度小于某个值,则把鸟的图片进行顺时针旋转。这样就会有抬头和低头的感觉了。
// 添加新的常量定义
const (
RotTrigger = 200
MaxAngle = 3.14/6
MinAngle = -3.14/2
AngleVelocity = 3.14 * 4
)
// 添加 rotate 属性
bird struct{
...
rotate float32
}
// 添加旋转的仿真代码
if sn.bird.vy > -RotTrigger && sn.bird.rotate < MaxAngle {
sn.bird.rotate += AngleVelocity * dt
} else if sn.bird.vy < -RotTrigger && sn.bird.rotate > MinAngle {
sn.bird.rotate += -AngleVelocity * dt
}
// update bird position
b := korok.Transform.Comp(sn.bird.Entity)
b.SetPosition(sn.bird.Vec2)
b.SetRotation(sn.bird.rotate)
以上代码,先定义了一些常量分别控制鸟的最大仰角和俯角,旋转的速度和触发旋转的速度;然后在 bird struct
中添加新的属性 rotate
, 最后在鸟的物理仿真代码下面添加旋转的仿真代码。现在运行会得到:

现在生动多了,基本和原作也是非常接近。
管道系统
管道的移动原理和地面类似,只要不断的把管道从右到左移动即可。但是管道的不同之处在于,右边会不断的有管道移动到左边,我们不可能真的生成成千上万个管道并不断的向左移动。一个更简单的方法是,可以准备几个管道,一旦它移动到了最左边,我们就把管道重新放到最右边,这样就可以模拟出无限的管道了。这部分的代码比较独立,所以我们新建了新的文件pipe.go
:
type Pipe struct {
top struct{
engi.Entity
f32.Vec2
}
bottom struct{
engi.Entity
f32.Vec2
}
high float32
active bool
x, vx float32
}
func (p *Pipe) initialize(texTop, texBottom gfx.Tex2D) {
top := korok.Entity.New()
spr := korok.Sprite.NewComp(top)
spr.SetSprite(texTop)
spr.SetSize(65, 400)
spr.SetGravity(.5, 0)
bottom := korok.Entity.New()
spr = korok.Sprite.NewComp(bottom)
spr.SetSize(65, 400)
spr.SetSprite(texBottom)
spr.SetGravity(.5, 1)
// out of screen
korok.Transform.NewComp(top).SetPosition(f32.Vec2{-100, 210})
korok.Transform.NewComp(bottom).SetPosition(f32.Vec2{-100, 160})
p.top.Entity = top
p.bottom.Entity = bottom
p.vx = ScrollVelocity
}
func (p *Pipe) reset(x, high, gap float32) {
p.active = true
p.x = x
p.top.Vec2 = f32.Vec2{x, high + gap}
p.bottom.Vec2 = f32.Vec2{x, high}
}
func (p *Pipe) update(dt float32) {
p.x -= p.vx * dt
if p.x < -50 {
p.active = false
}
p.top.Vec2[0] = p.x
p.bottom.Vec2[0] = p.x
korok.Transform.Comp(p.top.Entity).SetPosition(p.top.Vec2)
korok.Transform.Comp(p.bottom.Entity).SetPosition(p.bottom.Vec2)
}
type PipeSystem struct {
gap, top, bottom float32 // gap, top, bottom limit
respawn float32 // respawn location
scroll bool
delay struct{
clock float32
limit float32
}
generate struct{
clock float32
limit float32
}
pipes []*Pipe
frees []*Pipe
_pool []Pipe
}
func (ps *PipeSystem) initialize(texTop, texBottom gfx.Tex2D, size int) {
ps._pool = make([]Pipe, size)
ps.frees = make([]*Pipe, size) // add to freelist
for i := range ps._pool {
ps.frees[i] = &ps._pool[i]
ps.frees[i].initialize(texTop, texBottom)
}
ps.respawn = 320 + 20
}
func (ps *PipeSystem) setDelay(d float32) {
ps.delay.limit = d
}
func (ps *PipeSystem) setRate(r float32) {
ps.generate.limit = r
}
func (ps *PipeSystem) setGap(gap float32) {
ps.gap = gap
}
func (ps *PipeSystem) setLimit(top, b float32) {
ps.top, ps.bottom = top, b
}
func (ps *PipeSystem) Update(dt float32) {
if !ps.scroll {
return
}
// delay some time
if d := &ps.delay; d.clock < d.limit {
d.clock += dt; return
}
// generate new pipe
if g := &ps.generate; g.clock < g.limit {
g.clock += dt
} else {
g.clock = 0
ps.newPipe()
}
// update pipe
for _, p := range ps.pipes {
p.update(dt)
}
// recycle
ps.recycle()
}
func (ps *PipeSystem) StopScroll() {
ps.scroll = false
}
func (ps *PipeSystem) StartScroll() {
ps.scroll = true
}
func (ps *PipeSystem) Reset() {
for _, p := range ps.pipes {
p.x = -100
// out of screen
korok.Transform.NewComp(p.top.Entity).SetPosition(f32.Vec2{-100, 210})
korok.Transform.NewComp(p.bottom.Entity).SetPosition(f32.Vec2{-100, 160})
}
}
func (ps *PipeSystem) newPipe() {
if sz := len(ps.frees); sz > 0 {
p := ps.frees[sz-1]; ps.frees = ps.frees[:sz-1]
ps.pipes = append(ps.pipes, p)
p.reset(ps.respawn, math.Random(ps.bottom, ps.top), ps.gap)
}
}
// inactive pipes come first
func (ps *PipeSystem) recycle() {
pipes, inactive := ps.pipes, -1
for i, p := range pipes {
if p.active {
break
}
inactive = i
}
if inactive >= 0 {
ps.pipes = pipes[inactive+1:]
ps.frees = append(ps.frees, pipes[:inactive+1]...)
}
}
这部分代码比较多,但是并不复杂,Pipe
表示一个管道,它包含了上下两根,还有相应的位置,移动速度,高度等参数。initialize
方法会初始化这些参数(注:为了方便绘制,上面的管道把锚点设置在了中下位置,下面的管道设置在了中上的位置),update
方法会不断的根据管道的速度重新计算位置。PipeSystem
里面维护了一组待用的 Pipe
结构,我们会不断的从这里取出新的管道,如果有管道移动到了最左边的屏幕外,也会被回收到这里。
// generate new pipe
if g := &ps.generate; g.clock < g.limit {
g.clock += dt
} else {
g.clock = 0
ps.newPipe()
}
这段代码实现了按照一定的频率控制管道的生成。现在我们把管道的管理系统集成到 GameScene
, 添加 PipeSystem
并初始化:
// 添加管道系统
type GameScene struct {
...
PipeSystem
}
// 在 OnEnter 方法中初始化
top, _ := at.GetByName("top_pipe.png")
bottom, _ := at.GetByName("bottom_pipe.png")
ps := &sn.PipeSystem
ps.initialize(top, bottom, 6)
ps.setDelay(0) // 3 seconds
ps.setRate(2.5) // generate pipe every 2 seconds
ps.setGap(100)
ps.setLimit(300, 150)
ps.StartScroll()
// 在 OnUpdate 方法中,模拟仿真
sn.PipeSystem.Update(dt)
这段代码主要是初始化了管道管理系统,设置每2.5秒生成新的管道,使之上下管道之间的空隙为 100 像素,设置随机的高度范围为[150, 300],然后启动管道管理系统(依然需要在 Update
方法中调用 PipeSystem.Update(dt)
),现在运行程序:

现在管子已经可以正常移动了,而且它可以不断的生成新的管子并一直游戏下去。只是现在鸟在碰撞到管子之后,不会发生任何变化,这是因为,我们并没有添加管子的碰撞检测。管道的检测比地面和天空的检测略复杂一点,此处我们使用简单的AABB检测,我们认为鸟和管道的边框都是轴对齐的矩形。这样便可以使用AABB检测算法了。在 pipe.go
中定义 AABB 结构和它的碰撞检测算法:
type AABB struct {
x, y float32
width, height float32
}
func OverlapAB(a, b *AABB) bool {
if a.x < b.x+b.width && a.x+a.width>b.x && a.y < b.y+b.height && a.y+a.height > b.y {
return true
}
return false
}
接下来给 PipeSystem
添加管道和鸟的碰撞检测方法:
// check collision
func (ps *PipeSystem) CheckCollision(p f32.Vec2, sz f32.Vec2) (bool, float32) {
tolerance := float32(8)
sz[0], sz[1] = sz[0]-tolerance, sz[1]-tolerance
bird := &AABB{p[0]-sz[0]/2, p[1]-sz[1]/2, sz[0], sz[1]}
for _, p := range ps.pipes {
top := &AABB{
p.top.Vec2[0] - 32,
p.top.Vec2[1],
65,
400,
}
if OverlapAB(bird, top) {
return true, bird.x - top.x
}
bottom := &AABB{
p.bottom.Vec2[0] - 32,
p.bottom.Vec2[1] - 400,
65,
400,
}
if OverlapAB(bird, bottom) {
return true, bird.x - top.x
}
}
return false, 0
}
在这个方法中,我们遍历所有的可见的管子,然后让它和鸟做AABB检测(注意:这个方法的输入参数,分别是鸟的位置和大小)。如果发生碰撞则返回 true
,否则返回 false
。在 GameScene
的 Update
方法中调用这个方法,检测鸟个管道的碰撞:
// detect collision with pipes
ps := &sn.PipeSystem
if c, _ := ps.CheckCollision(sn.bird.Vec2, f32.Vec2{sn.bird.w, sn.bird.h}); c {
if sn.bird.state != Dead {
ps.StopScroll()
sn.bird.state = Dead
korok.Flipbook.Comp(sn.bird.Entity).Stop() // stop bird animation
}
}
这段代码,检测碰撞事件。如果发生碰撞则停止管道的移动,同时设置鸟的状态为 Dead
,并停止鸟的精灵动画。运行一下:

总结
本节实现了游戏的核心逻辑,鸟的飞行,地面的滚动,管道的不断生成和回收,检测碰撞等。下一节,我们将给菜单和场景添加动画效果,让场景转换变得更加自然。代码我已经传到 GitHub - ntop001/flappybird,请关注 ch3 分支。
网友评论