媒体号
首页
原创
论坛
产品库
开测表
招聘
首页
原创
论坛
产品库
开测表
招聘
论坛
更多
登录
GameRes游资网在以下平台有媒体号,欢迎关注收藏:
微信公众号
微博
头条号
百家号
网易号
知乎号
开
放
分
享
游戏梦的起点!
投稿邮箱:news@gameres.com
产品邮箱:cp@gameres.com
产业
专访
厂商
观察·投资
产品
政策
电子竞技
直播
区块链
元宇宙
人工智能
平台
手游
页游·H5
端游·PC
主机
虚拟·VR·AR
云游戏
研发
拆解分析
策划
程序·引擎
美术
音乐
测试
市场
职场·创业
运营·渠道
海外
数据·报告
App Store
Steam
其他
硬件·周边
八卦
活动
综合
游戏编程设计模式——Update Method
2016-08-11
意图
模拟一批相互独立的物体,让他们每次只进行一帧的动作。
动机
玩家控制着强大的瓦尔基里(北欧神话中奥丁神的婢女之一),去偷巫王遗留的稀世珍宝。她试探着接近巫王宏伟的藏宝室,然后…没有遇到任何阻击。没有被诅咒的雕像向她射击。没有不死的骷髅兵在入口巡逻。她只是径直走过去用连锁勾起战利品。游戏结束,你赢了。
呃,不会吧。
藏宝室里需要有守卫的敌人,让我们的英雄去干他们。首先,我们需要一队带动作的骷髅兵,在门前来回巡逻。如果忽略掉你可能已经掌握的游戏编程内容,最简单的,让他们来回巡逻的代码就像这样:
while (true)
{
// Patrol right.
for (double x = 0; x < 100; x++)
{
skeleton.setX(x);
}
// Patrol left.
for (double x = 100; x > 0; x--)
{
skeleton.setX(x);
}
}
复制代码
问题来了,的确,这些骷髅兵在来回巡逻,但是我们看不到。问题在于程序陷进了一个无限循环,这可不是我们想要的结果。我们想要的其实是骷髅兵每帧移动一小步。
所以我们必须拆掉原来的循环,用外部的游戏循环去驱动。确保游戏能够持续地响应用户输入,渲染,同时守卫也不停巡逻。就像这样:
Entity skeleton;
bool patrollingLeft = false;
double x = 0;
// Main game loop:
while (true)
{
if (patrollingLeft)
{
x--;
if (x == 0) patrollingLeft = false;
}
else
{
x++;
if (x == 100) patrollingLeft = true;
}
skeleton.setX(x);
// Handle user input and render game...
}
复制代码
我在这里演示了一下代码是怎么变得越来越复杂的。左右巡逻用了循环中的两个部分。在循环的执行过程中,这两部分是根据巡逻方向分开的。然后,我们依赖外部循环,在每帧都去计算下一个位置,所以,要用到一个表示方向的变量patrollingLeft。
这总归是管用的,所以让我们继续。一个骷髅不能给我们的英雄太大的麻烦,所以下一个,我们加入一个被诅咒的雕像。他们频繁的射出闪电,逼迫我们的英雄踮着脚尖走。
我们继续,用最简洁的方式:
// Skeleton variables...
Entity leftStatue;
Entity rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;
// Main game loop:
while (true)
{
// Skeleton code...
if (++leftStatueFrames == 90)
{
leftStatueFrames = 0;
leftStatue.shootLightning();
}
if (++rightStatueFrames == 80)
{
rightStatueFrames = 0;
rightStatue.shootLightning();
}
// Handle user input and render game...
}
复制代码
可以说,这样就会把代码改的约来约难维护。我们在游戏循环里面加入了大量的变量,去处理游戏中的每一个对象。为了让他们同时得到处理,我们把代码堆到了一起。
我们将要使用的模式其实很简单,可能你也想到了:每一个对象应该把他们自己的行为封装到一起。这样可以让游戏循环的代码稳定下来,并且可以很容易的增删对象。
为此,我们需要一个抽象层,它含有一个虚函数update()。游戏循环包含了很多对象,但是它不需要关心对象的具体类别。它只需要知道对象有一个update方法即可。这样就可以把每个对象的update方法跟游戏循环和其他对象的update脱离。
每一帧,游戏循环都会遍历这些对象,并且调用update。它给每一个对象一次处理机会。这样,调用了所有对象的update,就相当于让他们同时进行了动作。
这种游戏循环维护的这些对象集合是动态的,所以添加和删除对象就比较容易,只需要从集合中添加和删除即可。再也不需要硬编码了,我们甚至可以把关卡配置在数据文件里面,这样我们的关卡设计者会很喜欢。
模式
游戏世界维护了一个对象的集合。每一个对象实现了一次update,去模拟它一帧的行为。每一帧,游戏都会更新集合中的所有对象。
什么时候用
如果游戏循环就像面包片,那Update Method就是它的黄油。游戏中的那些活蹦乱跳的对象就是用这种方法排着长队跟玩家交互。如果游戏中有太空战士,龙,火星人,幽灵,或者运动员,就比较适合这种模式。
但是,如果游戏更抽象,活动空间不像真实世界而更像棋盘,这种模式就不适合了。在棋类游戏中,你不需要模拟并发的事情,也没有必要去告诉里面的人物去每帧更新自己。
你可能不需要每帧更新他们的行为,但是即便是在棋类游戏中,你也可能仍然希望每帧更新他们的动作。这种模式也可能管用。
Update Method 可以很好地应用在:
1、你的游戏有很多对象或者系统需要模拟。
2、每一个对象都不怎么依赖其他对象。
3、这些对象需要一直模拟。
牢记
这个模式非常简单,所以没什么坑。不过,每一行代码都值得探讨。
将代码切分成单帧执行会让事情变得复杂
你可以比较一下前两段示例代码,第二个要更复杂一些。两者都可以让骷髅守卫前后走动,但是第二个可以在游戏循环的每一帧交出控制权。
这种变化对处理用户输入,渲染和一些其他的游戏循环关心的事情来讲,是非常必要的,所以第一个示例并没有什么实际意义。它的价值在于让你记住,要让代码变成单帧执行的模式,要付出提高复杂度的代价。
为了保证运行你必须在离开一帧的时候保存一些状态
在一段示例代码中,我们不需要任何变量去记录现在守卫移动到了左边还是右边。很显然,代码执行到什么地方,就表示它在什么位置。
当我们编程了每次执行一帧的形式,我们需要创建一个patrollingLeft变量去跟踪它。每次我们退出执行的那次循环,执行的位置就丢掉了,所以我们必须显式得保存这个信息,在下一帧用。
State模式通常就是解决这个问题。游戏中经常用到状态机的部分原因,就是他们可以保存一些状态,而这些状态在你离开那段代码的时候也会用到。
所有对象每帧都进行模拟但是并非真正同时运行
在这个模式中,游戏循环会便利所有对象,并且更新每一个对象。在调用update()方法的时候,大部分对象都可能跟游戏世界中的其他对象进行交互,包括哪些还没有更新过的。这就意味着这些对象的更新顺序很重要。
如果对象列表中,A排在B的前面,它会看到B更新前的状态。但是当B更新的时候,它会看到A更新后的状态,因为A在这一帧中已经更新过了。即使在玩家眼里这一切动作都是同时发生的,但是在游戏内部依然是依次进行的。只不过每一次“排队”都在一帧的时间里进行。
在游戏逻辑关心的范围内这是可以接受的。并行更新对象会带来一些理解上的麻烦。想象一下,一个棋类游戏如果黑色和白色同时走,并且都走向同一个空位,咋整?
顺序更新就会解决这个问题,每一个对象都进行增量更新,从一个确定的状态到另外一个确定的状态。
在更新的时候修改对象列表要小心
你在用这个模式的时候,很多游戏行为会在更新方法中结束。它们往往会包含一些添加或者删除可更新对象的代码。
例如,一个骷髅守卫被击杀后会掉落一些道具。作为一个新对象,你通常可以把它添加到对象列表的末尾,不会带来什么麻烦。你依然可以枚举完整个列表,然后发现有一个新对象,然后更新它。
但是,这样做就让新对象在被加载的同一帧就发生了一次更新,甚至是在玩家看到它之前。如果你不希望发生这种情况,一种简单的方法就是把列表的对象个数在循环开始的时候缓存下来:
int numObjectsThisTurn = numObjects_;
for (int i = 0; i < numObjectsThisTurn; i++)
{
objects_[i]->update();
}
复制代码
这里,objects_是游戏中一个可更新对象的数组,numObjects_是它的长度。当新对象被添加进来,它就会增加。我们在循环开始的时候用numObjectsThisTurn把长度缓存起来,所以这次遍历会在遍历到新对象前停止。
另一个麻烦的问题是在遍历过程中删除怎么办。你击败了一个邪恶的野兽,需要从对象列表中移除。如果它位于你正在更新的对象之前,你很可能会跳过一个对象:
C++
for (int i = 0; i < numObjects_; i++)
{
objects_[i]->update();
}
复制代码
这个循环非常简单,每次迭代索引自增1。下图左面表示当我们开始更新heroine时的列表状况:
开始更新heroine时,i是1。她击败了野兽所以野兽被从数组中清除。heroine被置换到了0,hapless peasant被置换到了1。更新完heroine后,i变成了2.就像有图所示hapless peasant没有被更新,直接越过去了。
一种解决方法是在移除对象的时候小心点,把迭代变量也考虑进去。另一种是在遍历完整个列表后在处理移除。把这些要移除的对象标记成“dead”,但是仍放在那里。在更新的时候,确保略过那些死掉的对象,更新完后。再遍历一遍列表去清除掉这些尸体。
实例代码
这个模式非常直观,因此示例代码看起来有些多余。但这不代表这个模式没用。它是一种很简洁的解决方案。
具体起见,我们从头做一个简单的实例。从一个Entity类开始,它表示那些骷髅和他们的状态。
class Entity
{
public:
Entity()
: x_(0), y_(0)
{}
virtual ~Entity() {}
virtual void update() = 0;
double x() const { return x_; }
double y() const { return y_; }
void setX(double x) { x_ = x; }
void setY(double y) { y_ = y; }
private:
double x_;
double y_;
};
复制代码
我这里罗列的东西,都是后面我们用到的,最简化的内容。在真实代码中,会有很多额外的东西想图形和物理。这里最重要的一点就是那个update()虚函数。
游戏中会有一个Entity的集合。在我们把它放在我们准备好的游戏世界中:
class World
{
public:
World()
: numEntities_(0)
{}
void gameLoop();
private:
Entity* entities_[MAX_ENTITIES];
int numEntities_;
};
复制代码
万事俱备,游戏每一帧更新所有的entity,实现了这个模式。
void World::gameLoop()
{
while (true)
{
// Handle user input...
// Update each entity.
for (int i = 0; i < numEntities_; i++)
{
entities_[i]->update();
}
// Physics and rendering...
}
}
复制代码
子类化Entity
有些读者可能会感到不对劲,因为我用了继承Entity类的方式去定义不同的行为。如果你还没意识到问题,我可以提供一些线索。
当游戏工业从6502汇编和VBLANKS这种原始技术的海洋,到达面向对象语言的彼岸时,开发者对软件架构有点入魔了。最显著的一点就是使用继承。一些庞大的,复杂的继承体系被建立起来,大到冲出太阳系了。
他的一个最恶劣的结果就是,没有人能够维护这个庞大的继承体系了。早在1994年GOF就写到:
用对象组合,别用继承。
当这种思想渗透到游戏工业中后,就产生了Component模式作为解决方案。用这种模式,update()函数就挪到了Entity的组件中,而不是在Entity本身中。这避免了让你创建一些entity的子类去定义和复用行为。而是去组合匹配一些组件即可。
如果你要实现一个真正的游戏,我肯定会这样做。但是这一章并不介绍组件模式。而是update方法,我用最简单的方法来说明它们,尽可能的少做改动。因此我把这个方法放进了Entity,然后写几个子类。
定义Entity
好了,我们回到题目中来。我们的目的是定义巡逻的骷髅守卫和一些会射出魔法箭的雕像。让我们从我们可爱的骷髅朋友开始吧。为了定义他的巡逻行为,我们创建一个新的entity去实现update() ,大概是这个样子:
class Skeleton : public Entity
{
public:
Skeleton()
: patrollingLeft_(false)
{}
virtual void update()
{
if (patrollingLeft_)
{
setX(x() - 1);
if (x() == 0) patrollingLeft_ = false;
}
else
{
setX(x() + 1);
if (x() == 100) patrollingLeft_ = true;
}
}
private:
bool patrollingLeft_;
};
复制代码
可以看到,我们只是把原来游戏循环中的一坨代码拷贝到了Skeleton的update函数中。唯一不同是patrollingLeft_成了类的一个属性,而不是局部变量。这样这个值就会在多个update调用中保留下来。
让我们定义下面的雕像:
class Statue : public Entity
{
public:
Statue(int delay)
: frames_(0),
delay_(delay)
{}
virtual void update()
{
if (++frames_ == delay_)
{
shootLightning();
// Reset the timer.
frames_ = 0;
}
}
private:
int frames_;
int delay_;
void shootLightning()
{
// Shoot the lightning...
}
};
复制代码
还是如此,大多数修改就是把代码从游戏循环中挪到类里面,改名。这样,我们就可以把代码变的更简洁。在原来的代码里,我们使用了几个局部变量去记录雕像的帧数和开火速度。
现在,这些都被移进了Statue类中,你可以随意创建很多实例,每一个都有自己的小计时器。这就是这个模式真正的动机——在游戏世界中添加Entity更方便,因为每一个Entity只需要关系自己。
这种模式让我们把实现游戏世界和填充游戏元素隔离开来。甚至它提供了一种适应性,让我们可以用数据文件和关卡编辑器来填充游戏内容。
传递时间
目前为止,我们都假设每次调用update,都用的的固定时间间隔。
我当然希望这样做,但是更多游戏使用的时可变的时间间隔。每次游戏循环可能在模拟更多或者更少的内容,因此这个时间取决于上一帧处理和渲染的时间。
这意味着每次update都需要知道,虚拟时钟走了多长时间,所以你需要把消耗的时间传入。例如,我们可以这样处理巡逻骷髅的时间间隔:
void Skeleton::update(double elapsed)
{
if (patrollingLeft_)
{
x -= elapsed;
if (x <= 0)
{
patrollingLeft_ = false;
x = -x;
}
}
else
{
x += elapsed;
if (x >= 100)
{
patrollingLeft_ = true;
x = 100 - (x - 100);
}
}
}
复制代码
现在,随着耗费时间的增长,骷髅移动的距离也会变大。你也可以看到这个时间间隔的变量带来的额外的复杂度。如果时间间隔太长,骷髅有可能会超出巡逻范围,这个我们要小心处理。
设计决策
对这样一个简单的模式,可讨论的点比较少,但是仍然有几个需要注意的地方。
update方法放在哪个类里
最明显最重要的决策就是,你要把update放到哪个类里。
1> Entity类
这是最简单的选项,如果你只有一个entity类,或者entity的种类不多,可以这样用。但是实际上的游戏工业离这个条件相去甚远。
每次用子类化Entity的方式去实现一个新行为,时非常脆弱和痛苦的,因为你有大量不同类型的Entity。你会发现有时候你不得不用一些不优雅的方式复用代码,去将就一个继承结构,然后你就蒙逼了。
2> Component类
如果你已经使用Compnent模式了,那就很容易了。它可以让每一个组件相互独立得更新。同时,Update Method模式让你实现了游戏中Entity得解偶。渲染,物理,和AI只需要关注自己就可以了。
3> Delegate类
有另外几种模式可以实现代理另外一个对象的行为。State模式可以让你通过改变代理对象去修改被代理对象的行为。Type Object模式可以让很多同类型的Entity之间共享行为。
如果你用到这些模式,就可以很自然地把update放进代理类中。这样,你仍然可以在主类中放update,但是不需要虚拟函数,只需要转调到代理对象中即可:
void Entity::update()
{
// Forward to state object.
state_->update();
}
复制代码
未激活的对象如何处理
游戏中经常有这样的一些对象,处于各种各样的原因,他们临时不需要更新。他们可能无效,可能在屏幕以外,可能未解锁。如果有大量类似的对象存在,每一帧都去遍历这样的对象可能浪费CPU时钟。
一个可选的解决方法是维护一个激活对象的子集,这个子集中的元素才会被更新。当一个对象无效后,就从这个集合中移除。当再次被激活时,就添加回来。这样,你就可以遍历那些真正起作用的对象了。
1> 如果用同一个集合保存无效对象
这样做浪费时间,对于无效对象,你仍然需要访问它是否有效的标记,或者调用一个什么都不做的方法。
2> 如果用另外一个集合保存激活对象
你需要额外的内存去保存第二个集合。但仍然需要一个主集合去保存所有的Entity,以备不时之需。这样,这个集合严格来说就是多余的。如果速度比内存更敏感(通常都是这样),这就是个有用的方案。
另外一个选择同样需要两个集合,不过另一个只保存无效对象,而不是所有的。
你必须保证这两个集合同步。当对象被创建或者完全销毁(而不是临时失效)时,你需要记住,要修改主集合和激活对象集合。
这里有一个参考的标准就是你有多少无效对象。无效对象越多,把他们分离到一个单独集合从而节约游戏循环的遍历时间,就显得越重要。
www.wantgame.net
编译 【
原译文
】
相关阅读:
游戏编程设计模式——Game Loop
收藏
评论
评论
侧栏打开
登录
后参与讨论
暂无评论,来抢沙发
相关推荐
思考:即便叙事手段不断借鉴,端游与单机的叙事为何无法平替?
直到这个夏天变成方舟的酸橙味
「侠心论道」4位武侠游戏制作人畅谈创作思考和取舍
为什么2026年Chinajoy上这些国产单机游戏能引发热议?
推广
开测游戏
查看更多
奥星热浪
派对游戏
限量删档测试
研发 : 奥星热浪工作室
遮天世界
策略
限量删档测试
发行 : 腾讯
湮灭之潮
PC游戏
限量线下试玩
研发 : 日蚀边缘工作室
雾海之下
搜打撤
限量删档测试
研发 : 网易游戏
蜀山幻想志
仙侠
删档计费测试
发行 : 金鳞互娱
山海烬墟
角色扮演
公测
研发 : 广州乐牛软件科技有限公司
编辑推荐
查看更多
失控进化
遗忘之海
《喇叭之城 (Phonopolis)》
夜幕之下
【爆款新游】【潜力佳作】分析系列
推广
类魂游戏设计专题
推广