电子游戏设计/结构
虽然电子游戏种类繁多,但有些属性是恒定的:每个游戏都需要至少一名玩家,每个游戏都给玩家至少一个挑战,每个游戏都使用显示器,每个游戏都至少有一种输入/控制方式。
正如本章开头所述,用户界面由精灵、菜单等组成。它是用户用来控制游戏内操作的东西。这些图形被定义为按钮,可以被按下,或者角色,可以用箭头键移动。所有这些元素都是用户界面的一部分。
首先,几乎所有电子游戏启动时都会显示一个主菜单。这通常是一个带有背景的屏幕,上面排列着用于执行诸如新游戏或开始游戏、选项、加载游戏和退出游戏等操作的按钮。
此屏幕充当游戏的控制面板,允许玩家更改设置、选择模式或访问实际游戏。
有时,游戏会将主菜单用作游戏内菜单。游戏内菜单通常在游戏过程中通过Esc 键或开始按钮访问。游戏内菜单允许玩家访问大部分主菜单操作,以及其他操作,如显示角色属性、分数、库存等。不过,并非所有菜单都必须是带有文字的正方形。游戏Mana 秘史使用了一个创意菜单,其中关卡保持焦点,而选择项围绕玩家形成一个圆圈。
这些菜单不是必需的,但传统上会包含它们。
当你第一次启动游戏时,会显示一系列启动画面。启动画面包含诸如徽标、电影等元素。这通常用于告知玩家参与游戏开发的公司,有时还会介绍部分或全部情节。
当实际游戏开始时,通常会播放一段介绍性电影,作为情节的前奏。这不像你在影院里看到的电影,而是通常更好地利用游戏自身图形和声音来渲染。
在大多数游戏中,你接下来会被要求输入你的姓名,而在一些游戏中,你还可以自定义你的角色、设置等。
游戏的这个阶段称为教程。它并不总是被认为是游戏情节的一部分,但在某些游戏中,它被整合到游戏本身中,即使它是教程阶段,它仍然是游戏情节的一部分。我们将此称为教程整合。它广泛应用于诸如塞尔达传说和超级马里奥 64等游戏中。
在游戏过程中,几乎所有游戏都使用一些基本概念。它们列在下面
玩家在角色中的作用。玩家如何控制角色?通常有 3 种 PCR 类型,即第三人称、第一人称和影响
第三人称:玩家不是角色,而是以非个人的方式控制角色/角色。
第一人称:玩家就是角色/角色 - 并以个人和视觉的方式从角色的角度看待事物。
影响:玩家不与任何角色/角色绑定,而只是对游戏有影响。这在诸如俄罗斯方块等益智游戏中,以及在 RTS 游戏中可见。
游戏描绘的“世界”是什么?在这个问题中,有两个考量。
角色作用:还有一个问题是角色在游戏本身中扮演什么角色,从这个意义上说,有 3 种类型。
主角:一切围绕着角色/角色展开,拯救世界类型的事情。在诸如塞尔达、马里奥、最终幻想等游戏中可见。
街机传统:非个人的街机角色。
影响:角色是游戏中无形的影響。
法则 定义世界的法律、概念、规则等是什么?
图形 看到什么以及风格的法则
声音 听到什么以及风格的法则
游戏玩法 玩什么,如何玩游戏
考虑到游戏的保存和加载,通常这可以是一个简单的菜单操作,玩家输入一个保存名称,游戏就会被保存。不过,在某些游戏中,采取了更具创意的方法,这样玩家就不会被拉出游戏体验。银河战士就是通过其保存站来实现这一点的。
然而,加载通常是菜单操作。
我们游戏的核心是主循环(或游戏循环)。与大多数交互式程序一样,我们的游戏会一直运行,直到我们告诉它停止。每次循环都是游戏心跳的象征。实时游戏的循环通常与视频更新(vsync)同步。如果我们的主循环与固定时间硬件事件同步,例如 vsync,那么我们必须将每次更新调用的总处理时间控制在该时间间隔内,否则我们的游戏会“卡顿”。
// a simple game loop in C++ int main( int argc, char* argv[] ) { game our_game; while ( our_game.is_running()) { our_game.update(); } return our_game.exit_code(); }
每个游戏机制造商都有自己的游戏发布标准,但大多数都要求游戏在启动后的几秒钟内提供视觉反馈。作为一般设计准则,最好尽快向玩家提供反馈。
因此,大多数启动和关闭代码通常都在主循环中处理。冗长的启动和关闭代码可以在主更新() 中监视的子线程中运行,或者被切分成小块,并在更新() 例程本身中按顺序执行。
即使不考虑游戏本身的各种游戏模式,大多数游戏代码也属于几种状态之一。游戏可能包含以下状态和子状态
- 启动
- 许可证
- 介绍性电影
- 前端
- 游戏选项
- 声音选项
- 视频选项
- 加载屏幕
- 主游戏
- 介绍
- 游戏玩法
- 游戏模式
- 暂停选项
- 游戏结束电影
- 制作人员名单
- 关闭
使用状态机可以对代码进行建模
class state { public: virtual void enter( void )= 0; virtual void update( void )= 0; virtual void leave( void )= 0; };
派生类可以重写这些虚函数以提供特定于状态的代码。主游戏对象可以保存指向当前状态的指针,并允许游戏在状态之间流动。
extern state* shut_down; class game { state* current_state; public: game( state* initial_state ): current_state( initial_state ) { current_state->enter(); } ~game() { current_state->leave(); } void change_state( state* new_state ) { current_state->leave(); current_state= new_state; current_state->enter(); } void update( void ) { current_state->update(); } bool is_running( void ) const { return current_state != shut_down; } };
游戏循环必须同时考虑经过了多少真实时间和经过了多少游戏时间。将两者分开使得慢动作(例如子弹时间)效果、暂停状态和调试变得更加容易。如果您打算制作一个可以倒转时间的游戏,比如《Blinx》或《沙漏》,那么您需要能够在游戏时间倒退时向前运行游戏循环。
另一个围绕时间的考虑取决于您是想追求固定帧率还是可变帧率。固定帧率可以简化游戏中的大部分数学运算和计时,但它们会让游戏在国际上移植变得更加困难(例如,从美国 60 Hz 电视机转换到欧洲 50 Hz 电视机)。出于这个原因,建议将帧时间作为变量传递,即使该值从未改变。当每帧的工作量达到极限时,固定帧率会导致卡顿,这种卡顿的感觉可能比低帧率更糟糕。
另一方面,可变帧率会自动补偿不同的电视刷新率。但与固定帧率游戏相比,可变帧率的游戏往往感觉很“粘滞”。调试,特别是调试计时和物理问题,在可变时间下通常更加困难。在代码中实现计时时,通常存在几个特定于平台的硬件计时器,它们通常具有不同的分辨率、访问它们所需的开销和延迟。请特别注意可用的实时时钟。您必须使用分辨率足够高的时钟,同时不要使用过高的精度。您可能需要处理时钟溢出的情况(例如,32 位纳秒计时器每 2^32 纳秒就会溢出回零,这仅仅是 4.2949673 秒)。
const float game::NTSC_interval= 1.f / 59.94f; const float game::PAL_interval= 1.f / 50.f; float game::frame_interval( void ) { if ( time_system() == FIXED_RATE ) { if ( region() == NTSC ) { return NTSC_interval; } else { return PAL_interval; } } else { float current_time= get_system_time(); float interval= current_time - last_time; last_time= current_time; if ( interval < 0.f || interval > MAX_interval ) { return MAX_interval; } else { return interval; } } } void game::update( void ) { current_state->update( frame_interval()); }
现代游戏通常直接从 CD 或间接从硬盘加载。无论哪种方式,您的游戏都可能在 I/O 访问中花费大量时间。磁盘访问,尤其是 CD 和 DVD 访问,比游戏的其他部分慢得多。许多游戏机制造商将所有磁盘访问必须以视觉方式指示作为一项标准;而无论如何,这也不是一个糟糕的设计选择。
然而,大多数磁盘访问 API 函数(特别是那些通过 C 运行时库的标准 I/O 映射的函数)会使处理器停滞,直到传输完成。这被称为同步访问。
在访问磁盘时获得反馈的一种方法是在它们自己的线程中运行磁盘操作。这样做的优点是允许其他处理继续进行,包括绘制磁盘操作的一些视觉反馈。但代价是需要编写更多代码,并且需要同步对资源的访问。
一些游戏机操作系统 API 通过允许以异步读取操作调度磁盘访问来处理一些多线程代码。异步读取可以通过轮询文件句柄或使用回调来告知它们已完成。
无论游戏使用 2D 图形、3D 图形还是两者的组合,引擎都应以类似的方式处理它们。主要要考虑三个方面。
- 某些对象可能需要一段时间才能加载,并可能暂时冻结游戏。
- 有些机器的运行速度比其他机器慢,游戏必须以低帧率继续运行。
- 有些机器的运行速度更快,动画可能比以更高帧率的时间间隔更流畅。
因此,创建一个作为接口的基类以分离这些函数是一个好主意。这样,每个可绘制对象都可以以相同的方式对待,所有加载都可以同时完成(用于加载屏幕),所有绘制都可以独立于时间间隔完成。OpenGL 还要求对象显示列表具有唯一的整数标识符,因此我们还需要支持分配该值。
class IDrawable { public: virtual void load( void ) {}; virtual void draw( void ) {}; virtual void step( void ) {}; int listID() {return m_list_id;} void setListID(int id) {m_list_id = id;} protected: int m_list_id; };
一种常见的碰撞检测方法是使用轴对齐包围盒。为了实现这一点,我们将基于我们之前的接口 IDrawable。它应该与 IDrawable 保持分离,因为毕竟,并非屏幕上绘制的每个对象都需要碰撞检测。3D 盒子应由六个值定义:x、y、z、宽度、高度和深度。该盒子还应返回对象在空间中的当前最小值和最大值。这是一个示例 3D 包围盒类
class IBox : public IDrawable { public: IBox(); IBox(CVector loc, CVector size); ~IBox(); float X() {return m_loc.X();} float XMin() {return m_loc.X() - m_width / 2.;} float XMax() {return m_loc.X() + m_width / 2.;} float Y() {return m_loc.Y();} float YMin() {return m_loc.Y() - m_height / 2.;} float YMax() {return m_loc.Y() + m_height / 2.;} float Z() {return m_loc.Z();} float ZMin() {return m_loc.Z() - m_depth / 2.;} float ZMax() {return m_loc.Z() + m_depth / 2.;} protected: float m_x, m_y, m_z; float m_width, m_height, m_depth; }; IBox::IBox() { m_x = m_y = m_z = 0; m_width = m_height = m_depth = 0; } IBox::IBox(CVector loc, CVector size) { m_x = loc.X(); m_y = loc.Y(); m_z = loc.Z(); m_width = size.X(); m_height = size.Y(); m_depth = size.Z(); }
虽然在大多数 API 中显示图像或纹理立方体很简单,但当您开始为游戏添加更多复杂性时,任务自然会变得稍微困难一些。如果引擎结构不合理,随着引擎变得越来越大,这种复杂性也会越来越大。可能会不清楚需要进行哪些更改,您最终可能会得到巨大的特殊情况 switch 块,而在其中一些简单的抽象就可以简化问题。
这与上面提到的要点有关 - 随着游戏引擎的演变,您将希望添加新功能。对于结构不合理的引擎,这些新功能难以添加,并且可能会花费大量时间来找出为什么该功能没有按预期工作。可能是某些奇怪的函数正在中断它。精心设计的引擎会将任务分开,以便扩展某个区域仅仅是扩展 - 而不是必须修改以前的代码。
通过精心设计的引擎设计,您将开始了解自己的代码。您会发现自己花在盯着(或者可能诅咒)空白屏幕上的时间越来越少,想知道为什么您的代码没有按您认为的方式工作。
DRY 是一个常用缩略词(尤其是在极限编程环境中),意思是“不要重复自己”。这听起来很简单,但可以为您提供更多时间去做其他事情。此外,执行特定任务的代码位于一个中心位置,因此您可以修改该小节并查看您的更改在所有地方生效。
上面提到的要点可能对您来说并不令人难以置信 - 它们确实是常识。但是,如果没有对游戏引擎设计的思考和规划,您会发现达到这些目标要困难得多。