bada 2D
游戏编程之四——设计游戏循环
上篇文章中提到的时间驱动的游戏机制,就是不断重复执行游戏中的输入模块、逻辑模块和输出模块,这个不断重复的过程可以通过循环来实现,而这个循环就是所说的游戏循环。我们将输入模块、逻辑模块和输出模块的功能抽象为三个处理函数,分别为
HandleEvent()
,
UpdateLogic()
和
Draw()
,将这个三个函数按照先后关系放到游戏循环中就出现了下面的逻辑关系图:

上面的图只不过是游戏循环的一个基本逻辑情况,这个图蕴含着各种变化,每种变化都影响着游戏的性能。如果我们去设计一个游戏循环,会如何设计呢?这篇文章就是由这个基本的游戏循环出发,演变出几种基本的游戏循环,对这几个循环进行分析和介绍,并进行优缺点分析,加深大家对游戏循环的理解,并最终能够应用到游戏开发中去。
1
,相关用语解释
首先解释一下游戏循环中会提到的几个用语:
帧:
在游戏中帧就是指游戏中的一副画面,游戏就是由连续的帧组成的,通过不断更新帧来形成游戏动画
帧间隔:
在游戏中连续显示两个帧之间的时间间隔,一般以毫秒作为它的单位。
帧率:
游戏中帧率也称为
FPS(Frame Per Second)
,它表示在游戏中每秒钟显示帧的次数,也可以理解为
Draw()
函数被调用的频率。
游戏速率:
它指的是每秒钟游戏状态更新的次数,也可以理解为
UpdateLogic()
被调用的频率。
2
,游戏循环的实现方式
在设计游戏循环时,时间是影响实现方式的重要元素,因为它可以参与改变游戏状态,在进行游戏逻辑的运算时可以将时间做为变量加入进去。例如游戏中精灵的移动位移,就可以根据精灵的移动速度乘以移动时间来得到。其中按照游戏的逻辑更新与时间之间的关系可以将游戏循环分为基于帧的循环和基于时间的循环。在基于帧的游戏循环中,游戏的逻辑更新于时间无关,而基于时间的循环是需要在游戏逻辑更新时考虑时间变量。
下面就来设计各种类型的游戏循环。
2.1
,基于帧的游戏循环
在基于帧的游戏循环中,游戏逻辑的更新不依赖于时间,而是以一帧为单位来进行计算,这样在游戏中需要设定在每一帧中游戏状态变化的单位值,也就是每次调用
UpdateLogic()
函数时的变化值。比如说在游戏中的一个精灵,在每一帧中它的位移
(Sprite.step)
将增加
1
个像素,这样在
UpdateLogic()
函数中可以将它的位移增加
1
个像素来改变它的位置状态。
下面是这种游戏循环实现方式和逻辑更新的代码:
while(isRunning)
{
HandleEvent();
UpdateLogic();
Draw();
}
void UpdateLogic()
{
Sprite.position += Sprite.step;
}
这个游戏循环实现起来是不是很简单,和上面的图示一模一样。这也往往是刚开始进行游戏开发时常用的设计方式,但它会存在一些问题。因为这样设计的游戏在不同性能的设备上可能会造成游戏运行的速度不一致的情况。在性能高的设备上,运行
HandleEvent(),UpdateLogic()
和
Draw()
耗时会很小,表示游戏的帧间隔时间会比较短;而在性能低的设备上,计算比较耗时,这样游戏的帧间隔时间相对会长,这样会导致在单位时间内,性能高的设备上
UpdateLogic()
调用的次数会高于性能低的设备。
假如在一个高速设备上,游戏的帧间隔为
20
毫秒,这样在
1
秒钟内
UpdateLogic()
会被调用
50
次,移动的位移则为
50
×
1 = 50
像素;同样在低速设备上,游戏的帧间隔为
50
毫秒,这样在
1
秒钟内
UpdateLogic()
会被调用
20
次,移动的位移则为
20
×
1 = 20
像素。
这样出现在不同的设备上运行速度不一致的情况。
设备
|
帧间隔
|
游戏速率
|
单位位移
(
以帧为单位
)
|
位移位移
|
效果
|
高速设备
|
20
|
50
|
1
像素
/
帧
|
50
×
1 = 50
像素
|
快
|
低速设备
|
40
|
25
|
1
像素
/
帧
|
25
×
1 = 25
像素
|
慢
|
还有一个问题就是即时在同一款设备上,也会出现游戏运行的速度时快时慢的情况,因为在不同的时刻,根据
CPU
的繁忙程度,处理
HandleEvent(),UpdateLogic()
和
Draw()
的耗时也会出现不一样的情况。
2.2
,基于时间的游戏循环
为了解决游戏在不同性能的硬件下运行速度不同的问题,在基于时间的游戏循环中引入了时间作为变量来控制游戏的状态变化,会为游戏添加速度属性来保持不同设备间的一致性。在这种游戏循环中,需要在
UpdateLogic()
函数中传入时间值用作游戏状态的计算因子。而根据传入的时间变量产生的方式不同,又可以分为可变间隔循环和固定间隔循环。可变间隔循环中的时间变量是实时的帧间隔时长,而固定间隔循环中的时间变量是人为设定的一个值。
2.2.1
基于时间的可变间隔游戏循环
这种实现方式是在
UpdateLogic()
函数中传入一个时间参数
frameTime
,这个值是从开始运行上一次循环到执行当前循环之间的间隔时长,也就是帧时间。这个值在处理能力不同的设备上是不同的,即时在同一设备上也会发生波动,所以是一个可变的值。
还是拿游戏中的一个精灵来说,它的速度为
10
像素
/s
,则在游戏中通过速度乘以时间的方式来计算它的位移。这样可以保证即使在不同的设备上,只要经过的时长相等,运动的位移就是一样的。
下面是这种游戏循环实现方式和逻辑更新的伪代码:
lastFrameTime = GetCurrentTime();
while(isRunning)
{
currentFrameTime = GetCurrentTime();
frameTime = currentFrameTime – lastFrameTime;
HandleEvent();
UpdateLogic(frameTime);
Draw();
lastFrameTime = currentFrameTime;
}
void UpdateLogic(frameTime)
{
Sprite.position += frameTime/1000*Sprite.velocity;
}
虽然这种方法解决了游戏在不同性能的设备上运行速度不同的问题。但是也还存在一些问题,因为在通常情况下,
frameTime
的值都保持平稳,不会有太大的变化,但由于
frameTime
值完全依赖于运算效率,所以设备有时会出现
CPU
忙不过来,而导致
frameTime
增大的情况,比如在玩游戏时,有其它后台程序占用了大量的
CPU
而导致运算游戏逻辑的效率降低,处理
HandleEvent(),UpdateLogic(frameTime),Draw()
的时间增加,也就是
frameTime
增加。这样如果游戏中有在逻辑更新时进行碰撞检测的情况,则有可能会出现漏掉部分碰撞点的情况。
给大家用图来说明一下这个问题,
这种图示情况下
frame time
比较小,游戏中调用
UpdateLogic()
函数并进行碰撞检测的频率比较高,这样在
t3
时刻进行碰撞检测时刚好能够将和
wall
的碰撞情况检测出来。

而在这种情况下由于
frame time
比较大,游戏中调用
UpdateLogic()
函数并进行碰撞检测的频率比较低,次数比较少,所以当在
t3
时刻进行碰撞检测时,
Sprite
已经越过
wall
了,检测不到和
wall
的碰撞情况。这样就会出现小球穿墙而过的情况,不符合真实的物理规律。

这样设计的游戏循环还有一个显著的缺点,就是游戏
while
循环在不停的运行,一直占用
CPU
,比较耗
CPU
资源。
2.2.2
,
基于时间的固定间隔游戏循环
前面的两种游戏循环都是在让
CPU
尽情飞奔,将游戏的帧率发挥到了最大极限。而在游戏中一般
50-60
的帧率是最优的,很多情况下下最好将帧率设定为
30
,这对复杂的游戏很有帮助,因为这样可以避免由于帧率无法达到
60
,而在游戏过程中帧率发生大幅波动。在这种情况下,把帧率设为可能达到的最低帧率,因为较低但是稳定的帧率可以保证游戏的流畅运行,而平均帧率较高但是帧率可能发生大幅波动的游戏会降低玩家的用户体验。
基于时间的固定间隔的游戏循环就是为游戏设定一个理想的帧率,让游戏逻辑基于固定的帧时间进行计算。
下面是这种游戏循环实现方式的代码:
const int FAME_RATE = 40;
const long FRAME_TIME = 1000/FRAME_RATE;
while(isRunning)
{
startTime = GetCurrentTime();
HandleEvent();
UpdateLogic(FRAME_TIME);
Draw();
endTime = GetCurrentTime();
deltaTime = endTime – startTime - FRAME_TIME;
if(deltaTime > 0)
{
sleep(deltaTime);
}
Else
{
//
发生意外情况,运算超时了
}
}
void UpdateLogic(FRAME_TIME)
{
Sprite.position += Sprite.velocity* FRAME_TIME;
}
这样如果执行
HandleEvent(),UpdateLogic(),Draw()
的时间小于设定的帧时间,则可以通过让执行循环的线程
sleep
,来让出
CUP
的时间片。
在这种循环中,由于向
UpdateLogic()
传入的是固定的
FRAME_TIME
值,游戏中依靠时间来进行计算已经失去了意义,反而还会增加计算量,可将它和基于帧的循环结合起来,让游戏以每一帧为单位进行运算,省去与时间相乘的运算过程,提高运行效率。
这样就可以简化为下面的情况。
const int FAME_RATE = 40;
const long FRAME_TIME = 1000/FRAME_RATE;
while(isRunning)
{
startTime = GetCurrentTime();
HandleEvent();
UpdateLogic();
Draw();
endTime = GetCurrentTime();
deltaTime = endTime – startTime - FRAME_TIME;
if(deltaTime > 0)
{
sleep(deltaTime);
}
else
{
//
发生意外情况,运算超时了
}
}
void UpdateLogic()
{
Sprite.position += Sprite.step;
}
3
,其它的设计方式
上面也只是列举出了几个基本的游戏循环,还有很多种不同的设计方式。比如可以将游戏的帧频率和速率分开处理,就是让调用
UpdateLogic()
的次数和
Draw()
的次数不保持一致,这样在当游戏设定的帧率比较低时,可以通过在同一帧中增加调用
UpdateLogic()
次数来增加碰撞检测的次数,从而可以减少漏掉碰撞检测的概率。