SDL-游戏开发-全-

SDL 游戏开发(全)

原文:zh.annas-archive.org/md5/48db1a23f0f893bcd8ad5db87e7c5256

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

使用 C++创建游戏是一个复杂的过程,需要大量的时间和投入才能取得成果。一个良好的可重用类库可以加快开发速度,并使重点放在创建优秀的游戏上,而不是与底层代码斗争。本书旨在展示一种创建可重用框架的方法,该框架可用于任何游戏,无论是 2D 还是 3D。

本书涵盖的内容

第一章, SDL 入门,介绍了在 Visual C++ 2010 express 中设置 SDL,然后转向 SDL 的基础知识,包括创建窗口和监听退出事件。

第二章, 在 SDL 中绘图,介绍了几个核心绘图类,以帮助简化 SDL 渲染。还介绍了SDL_image扩展,允许加载各种不同的图像文件类型。

第三章, 与游戏对象协同工作,提供了继承和多态的基本介绍,并开发了一个将在本书其余部分使用的可重用GameObject类。

第四章, 探索运动和输入处理,详细探讨了在 SDL 中处理事件的方法。包括操纵杆、键盘和鼠标输入,以及可重用类的开发。

第五章, 处理游戏状态,涵盖了有限状态机的设计和实现,用于管理游戏状态。详细介绍了状态的实现和转换。

第六章, 数据驱动设计,介绍了使用 TinyXML 加载状态的方法。同时开发了一个解析状态的类,并提供了不同状态下的示例。

第七章, 创建和显示瓦片地图,将前几章的内容综合起来,允许使用 Tiled 地图编辑器创建关卡。创建了一个用于从 XML 文件加载地图的关卡解析类。

第八章, 创建外星攻击,涵盖了创建 2D 横版射击游戏的过程,利用了前几章所学到的所有知识。

第九章, 创建康纳洞穴人,介绍了第二个游戏的创建,修改了外星攻击的代码,展示了框架的灵活性,足以用于任何 2D 游戏类型。

本书所需的软件

使用本书需要以下软件:

  • Visual C++ 2010 Express

  • Tiled 地图编辑器

  • TinyXML

  • zlib 库

本书面向的对象

本书面向希望将现有技能应用于 C++游戏开发的初学者/中级 C++程序员。这不是一本入门书,并预期你知道 C++的基础知识,包括继承、多态和类设计。

约定

在本书中,你会发现许多不同风格的文本,用于区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

文本中的代码词汇如下所示:“我们可以通过使用include指令来包含其他上下文。”

代码块设置如下:

void Player::update()
{
  m_currentFrame = int(((SDL_GetTicks() / 100) % 6));

  m_acceleration.setX(1);

  SDLGameObject::update();
}

新术语重要词汇将以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的文字,将以如下方式显示:“右键单击项目并选择构建。”

注意

警告或重要提示将以如下方式显示在一个框中。

小贴士

小贴士和技巧将以如下方式显示。

读者反馈

我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大收益的标题非常重要。

要发送给我们一般性的反馈,只需发送电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。

如果你在一个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

既然你已经是 Packt 图书的骄傲拥有者,我们有许多事情可以帮助你从购买中获得最大收益。

下载示例代码

你可以从你购买的所有 Packt 书籍的账户中下载示例代码文件www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

勘误表

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——如果你能向我们报告这一点,我们将不胜感激。通过这样做,你可以帮助其他读者避免挫败感,并帮助我们改进本书的后续版本。如果你发现任何勘误,请通过访问www.packtpub.com/submit-errata来报告它们,选择你的书籍,点击勘误提交表单链接,并输入你的勘误详情。一旦你的勘误得到验证,你的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误表部分。任何现有的勘误都可以通过从www.packtpub.com/support选择你的标题来查看。

盗版

在互联网上,版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们提供有价值内容的能力方面提供的帮助。

问题

如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章. SDL 入门

简单直接媒体层SDL)是一个由 Sam Oscar Latinga 创建的跨平台多媒体库。它提供了对输入(通过鼠标、键盘和游戏手柄/摇杆)、3D 硬件和 2D 视频帧缓冲区的低级访问。SDL 使用 C 编程语言编写,但具有对 C++ 的原生支持。该库还对 Pascal、Objective-C、Python、Ruby 和 Java 等几种其他语言提供了绑定;支持的语言完整列表可在 www.libsdl.org/languages.php 上找到。

SDL 已被用于许多商业游戏,包括《World of Goo》、《Neverwinter Nights》和《Second Life》。它也被用于诸如 ZSNES、Mupen64 和 VisualBoyAdvance 这样的模拟器。一些流行的游戏,如移植到 Linux 平台上的《Quake 4》、《Soldier of Fortune》和《Civilization: Call to Power》,以某种形式使用了 SDL。

SDL 不仅用于游戏。它对各种应用程序都很有用。如果你的软件需要访问图形和输入,那么 SDL 可能会非常有帮助。SDL 官方网站列出了使用该库创建的应用程序列表(www.libsdl.org/applications.php)。

在本章中,我们将涵盖以下内容:

  • 从 Mercurial 仓库获取最新的 SDL 构建

  • 在 Visual C++ 2010 Express 中构建和设置 SDL

  • 使用 SDL 创建窗口

  • 实现一个基本的游戏类

为什么使用 SDL?

每个平台都有其创建和显示窗口、处理用户输入以及访问任何底层硬件的独特方式;每种方式都有其复杂性以及语法。SDL 提供了一种统一的方式来访问这些特定平台的特性。这种一致性使得你花更多的时间调整游戏,而不是担心特定平台如何让你渲染或获取用户输入等问题。游戏编程可能相当困难,而拥有像 SDL 这样的库可以使你的游戏相对快速地启动和运行。

能够在 Windows 上编写游戏,然后将其编译到 OSX 或 Linux 上,而代码几乎不需要任何修改,这种能力非常强大,非常适合希望针对尽可能多的平台进行开发的开发者;SDL 使得这种跨平台开发变得轻而易举。虽然 SDL 对于跨平台开发非常有效,但它也是一个创建仅针对一个平台的游戏的绝佳选择,因为它易于使用且功能丰富。

SDL 拥有庞大的用户群体,并且正在积极地进行更新和维护。同时,还有一个响应迅速的社区以及一个有帮助的邮件列表。SDL 2.0 的文档是最新的,并且持续得到维护。访问 SDL 网站 libsdl.org,可以找到大量的文章和信息,包括对文档、邮件列表和论坛的链接。

总体而言,SDL 为游戏开发提供了一个很好的起点,让你能够专注于游戏本身,而忽略你正在为哪个平台开发,直到完全必要。现在,随着 SDL 2.0 及其带来的新特性,SDL 已经成为使用 C++ 进行游戏开发的一个更强大的库。

注意

要了解 SDL 及其各种功能可以做什么,最好的方法是使用在 wiki.libsdl.org/moin.cgi/CategoryAPI 找到的文档。在那里,你可以看到 SDL 2.0 所有功能的列表以及各种代码示例。

SDL 2.0 的新特性是什么?

本书将要介绍的 SDL 和 SDL 2.0 的最新版本仍在开发中。它为现有的 SDL 1.2 框架添加了许多新特性。SDL 2.0 路线图 (wiki.libsdl.org/moin.cgi/Roadmap) 列出了以下特性:

  • 一个基于纹理的 3D 加速渲染 API

  • 硬件加速 2D 图形

  • 支持渲染目标

  • 多窗口支持

  • 支持剪贴板访问的 API

  • 多输入设备支持

  • 支持 7.1 音频

  • 多音频设备支持

  • 游戏手柄的力反馈 API

  • 水平鼠标滚轮支持

  • 多点触控输入 API 支持

  • 音频捕获支持

  • 多线程改进

虽然我们游戏编程冒险中不会使用所有这些特性,但其中一些是非常宝贵的,使得 SDL 成为开发游戏时更好的框架。我们将利用新的硬件加速 2D 图形,确保我们的游戏有出色的性能。

迁移 SDL 1.2 扩展

SDL 有独立的扩展,可以用来向库添加新功能。这些扩展最初没有被包含在内,是为了使 SDL 尽可能保持轻量级,扩展的作用是在必要时添加功能。下表展示了某些有用的扩展及其用途。这些扩展已经从 SDL1.2/3 版本更新,以支持 SDL 2.0,本书将介绍如何从各自的仓库克隆和构建它们,当需要时。

名称 描述
SDL_image 这是一个支持 BMP、GIF、PNG、TGA、PCX 等图像文件加载的库。
SDL_net 这是一个跨平台网络库。
SDL_mixer 这是一个音频混音库。它支持 MP3、MIDI 和 OGG。
SDL_ttf 这是一个支持在 SDL 应用中使用 TrueType 字体的库。
SDL_rtf 这是一个支持渲染富文本格式(RTF)的库。

在 Visual C++ Express 2010 中设置 SDL

本书将介绍在微软的 Visual C++ Express 2010 IDE 中设置 SDL 2.0。选择这个 IDE 是因为它可以在网上免费使用,并且在游戏行业中是一个广泛使用的开发环境。应用程序可在 www.microsoft.com/visualstudio/en-gb/express 获取。一旦安装了 IDE,我们就可以继续下载 SDL 2.0。如果您不是在 Windows 上开发游戏,则可以修改这些说明以适应您选择的 IDE,使用其特定的步骤来链接库和包含文件。

SDL 2.0 仍在开发中,因此目前还没有官方发布版本。库可以通过两种不同的方式检索:

  • 一种方法是下载正在构建的快照;然后您可以将它链接起来构建您的游戏(最快的选择)

  • 第二种方法是使用 mercurial 分布式源控制克隆最新源并从头开始构建(跟踪库最新发展的好方法)

这两个选项均可在 www.libsdl.org/hg.php 找到。

在 Windows 上构建 SDL 2.0 还需要最新的 DirectX SDK,它可在 www.microsoft.com/en-gb/download/details.aspx?id=6812 获取,因此请确保首先安装它。

使用 Mercurial 在 Windows 上获取 SDL 2.0

直接从不断更新的仓库获取 SDL 2.0 是确保您拥有 SDL 2.0 的最新构建版本并利用任何当前错误修复的最佳方式。要在 Windows 上下载和构建 SDL 2.0 的最新版本,我们必须首先安装一个 mercurial 版本控制客户端,以便我们可以镜像最新的源代码并从中构建。有各种命令行工具和 GUI 可用于与 mercurial 一起使用。我们将使用 TortoiseHg,这是一个免费且用户友好的 mercurial 应用程序;它可在 tortoisehg.bitbucket.org 获取。一旦安装了应用程序,我们就可以继续获取最新的构建。

从仓库克隆和构建最新的 SDL 2.0 仓库

按照以下步骤从仓库直接克隆和构建 SDL 的最新版本相对简单:

  1. 打开 TortoiseHg 工作台 窗口。从仓库克隆和构建最新的 SDL 2.0 仓库

  2. Ctrl + Shift + N 将打开克隆对话框。

  3. 输入仓库的源;在这个例子中,它列在 SDL 2.0 网站上,网址为 hg.libsdl.org/SDL

  4. 输入或浏览以选择克隆仓库的目的地——本书将假设 C:\SDL2 被设置为位置。

  5. 点击 克隆 并允许仓库复制到所选位置。从仓库克隆和构建最新的 SDL 2.0 仓库

  6. C:\SDL2目录下将有一个VisualC文件夹;在文件夹内部有一个 Visual C++ 2010 解决方案,我们必须使用 Visual C++ Express 2010 打开它。

  7. Visual C++ Express 可能会抛出一些关于 Express 版本不支持解决方案文件夹的错误,但可以安全忽略,而不会影响我们构建库的能力。

  8. 将当前构建配置更改为发布,并根据您的操作系统选择 32 位或 64 位。克隆和构建最新的 SDL 2.0 存储库

  9. 右键单击解决方案资源管理器列表中名为SDL的项目,并选择构建

  10. 现在我们有了 SDL 2.0 库的构建版本可以使用。它将位于C:\SDL2\VisualC\SDL\Win32(or x64)\Release\SDL.lib

  11. 我们还需要构建 SDL 主库文件,因此在解决方案资源管理器列表中选择它并构建它。此文件将构建到C:\SDL2\VisualC\SDLmain\Win32(or x64)\Release\SDLmain.lib

  12. C:\SDL2中创建一个名为lib的文件夹,并将SDL.libSDLmain.lib复制到这个新创建的文件夹中。

我已经有了库;现在该做什么?

现在可以创建一个 Visual C++ 2010 项目,并将其链接到 SDL 库。以下是涉及的步骤:

  1. 在 Visual C++ express 中创建一个新的空项目,并给它起一个名字,例如SDL-game

  2. 创建完成后,右键单击解决方案资源管理器列表中的项目,并选择属性

  3. 将配置下拉列表更改为所有配置

  4. VC++目录下,点击包含目录。一个小箭头将允许下拉菜单;点击<编辑…>。我有了库;现在该做什么?

  5. 双击框内创建一个新位置。您可以在其中键入或浏览到C:\SDL2.0\include,然后点击确定

  6. 接下来,在库目录下做同样的事情,这次传递你创建的lib文件夹(C:\SDL2\lib)。

  7. 接下来,导航到链接器标题;在标题内部将有一个输入选项。在附加依赖项中输入SDL.lib SDLmain.lib我有了库;现在该做什么?

  8. 导航到系统标题,并将子系统标题设置为Windows(/SUBSYSTEM:WINDOWS)我有了库;现在该做什么?

  9. 点击确定,我们就完成了。

Hello SDL

现在我们有一个空的项目,它链接到了 SDL 库,所以是时候开始我们的 SDL 开发了。点击源文件,并使用键盘快捷键Ctrl + Shift + A添加一个新项。创建一个名为main.cpp的 C++文件。创建此文件后,将以下代码复制到源文件中:

#include<SDL.h>

SDL_Window* g_pWindow = 0;
SDL_Renderer* g_pRenderer = 0;

int main(int argc, char* args[])
{
  // initialize SDL
  if(SDL_Init(SDL_INIT_EVERYTHING) >= 0)
  {
    // if succeeded create our window
    g_pWindow = SDL_CreateWindow("Chapter 1: Setting up SDL", 
    SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
    640, 480, 
    SDL_WINDOW_SHOWN);

    // if the window creation succeeded create our renderer
    if(g_pWindow != 0)
    {
      g_pRenderer = SDL_CreateRenderer(g_pWindow, -1, 0);
    }
  }
  else
  {
    return 1; // sdl could not initialize
  }

  // everything succeeded lets draw the window

  // set to black // This function expects Red, Green, Blue and 
  //  Alpha as color values
  SDL_SetRenderDrawColor(g_pRenderer, 0, 0, 0, 255);

  // clear the window to black
  SDL_RenderClear(g_pRenderer);

  // show the window
  SDL_RenderPresent(g_pRenderer);

  // set a delay before quitting
  SDL_Delay(5000);

  // clean up SDL
  SDL_Quit();

  return 0;
}

我们现在可以尝试构建我们的第一个 SDL 应用程序。右键单击项目并选择构建。将会有一个关于找不到SDL.dll文件的错误:

Hello SDL

尝试构建应该在项目目录内创建一个DebugRelease文件夹(通常位于 Visual Studio 下的Documents文件夹中)。这个文件夹包含我们尝试构建的.exe文件;我们需要将SDL.dll文件添加到这个文件夹中。SDL.dll文件位于C:\SDL2\VisualC\SDL\Win32(或x64)\Release\SDL.dll。当你想要将你的游戏分发到另一台计算机时,你将不得不分享这个文件以及可执行文件。在你将SDL.dll文件添加到可执行文件文件夹后,项目现在将编译并显示一个 SDL 窗口;等待 5 秒钟然后关闭。

Hello SDL 的概述

让我们来看一下Hello SDL的代码:

  1. 首先,我们包含了SDL.h头文件,以便我们可以访问 SDL 的所有函数:

    #include<SDL.h>
    
  2. 下一步是创建一些全局变量。一个是SDL_Window函数的指针,它将通过SDL_CreateWindow函数来设置。另一个是SDL_Renderer对象的指针;通过SDL_CreateRenderer函数来设置:

    SDL_Window* g_pWindow = 0;
    SDL_Renderer* g_pRenderer = 0;
    
  3. 我们现在可以初始化 SDL。这个例子使用SDL_INIT_EVERYTHING标志初始化了 SDL 的所有子系统,但这并不总是必须的(见 SDL 初始化标志):

    int main(int argc, char* argv[])
    {
      // initialize SDL
      if(SDL_Init(SDL_INIT_EVERYTHING) >= 0)
       {
    
  4. 如果 SDL 初始化成功,我们可以创建指向我们的窗口的指针。SDL_CreateWindow返回一个指向匹配传递参数的窗口的指针。参数是窗口标题、窗口的x位置、窗口的y位置、宽度、高度以及任何所需的SDL_flags(我们将在本章后面介绍这些)。SDL_WINDOWPOS_CENTERED将使窗口相对于屏幕居中:

    // if succeeded create our window
    g_pWindow = SDL_CreateWindow("Chapter 1: Setting up SDL", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 640, 480, SDL_WINDOW_SHOWN);
    
  5. 现在我们可以检查窗口创建是否成功,如果是的话,继续设置指向我们的渲染器的指针,传递我们想要渲染器使用的窗口作为参数;在我们的例子中,是新建的g_pWindow指针。传递的第二个参数是初始化的渲染驱动程序的索引;在这种情况下,我们使用-1来使用第一个可用的驱动程序。最后一个参数是SDL_RendererFlag(见 SDL 渲染器标志):

    // if the window creation succeeded create our renderer
    if(g_pWindow != 0)
    {
      g_pRenderer = SDL_CreateRenderer(g_pWindow, -1, 0);
    }
    else
    {
      return 1; // sdl could not initialize
    }
    
  6. 如果一切顺利,我们现在可以创建并显示我们的窗口:

    // everything succeeded lets draw the window
    
      // set to black
    SDL_SetRenderDrawColor(g_pRenderer, 0, 0, 0, 255);
    
       // clear the window to black
    SDL_RenderClear(g_pRenderer);
    
       // show the window
    SDL_RenderPresent(g_pRenderer);
    
       // set a delay before quitting
    SDL_Delay(5000);
    
       // clean up SDL
    SDL_Quit();
    

SDL 初始化标志

事件处理、文件 I/O 和线程子系统在 SDL 中默认初始化。其他子系统可以使用以下标志进行初始化:

标志 初始化的子系统
SDL_INIT_HAPTIC 力反馈子系统
SDL_INIT_AUDIO 音频子系统
SDL_INIT_VIDEO 视频子系统
SDL_INIT_TIMER 计时器子系统
SDL_INIT_JOYSTICK 游戏手柄子系统
SDL_INIT_EVERYTHING 所有子系统
SDL_INIT_NOPARACHUTE 不捕获致命信号

我们也可以使用位运算符(|)来初始化多个子系统。要仅初始化音频和视频子系统,我们可以使用对SDL_Init的调用,例如:

SDL_Init(SDL_INIT_AUDIO | SDL_INIT_VIDEO);

检查一个子系统是否已初始化可以通过调用SDL_WasInit()函数来完成:

if(SDL_WasInit(SDL_INIT_VIDEO) != 0)
{
  cout << "video was initialized";
}

SDL 渲染器标志

当初始化 SDL_Renderer 标志时,我们可以传递一个标志来决定其行为。以下表格描述了每个标志的目的:

标志 目的
SDL_RENDERER_SOFTWARE 使用软件渲染
SDL_RENDERER_ACCELERATED 使用硬件加速
SDL_RENDERER_PRESENTVSYNC 将渲染器更新与屏幕刷新率同步
SDL_RENDERER_TARGETTEXTURE 支持渲染到纹理

构成游戏的因素

除了游戏的设计和玩法之外,底层机制基本上是各种子系统的交互,如图形、游戏逻辑和用户输入。图形子系统不应该知道游戏逻辑是如何实现的,反之亦然。我们可以将游戏的结构想象如下:

构成游戏的因素

一旦游戏初始化,它就会进入一个循环,检查用户输入,根据游戏物理更新任何值,然后渲染到屏幕上。一旦用户选择退出,循环就会中断,游戏就会进入清理一切并退出的阶段。这是游戏的基本框架,也是本书中将使用的内容。

我们将构建一个可重用的框架,它将消除在 SDL 2.0 中创建游戏的所有繁琐工作。当涉及到样板代码和设置代码时,我们真的只想写一次,然后在新的项目中重用它。绘图代码、事件处理、地图加载、游戏状态以及所有游戏可能需要的其他内容也是如此。我们将从将 Hello SDL 2.0 示例分解成单独的部分开始。这将帮助我们开始思考如何将代码分解成可重用的独立块,而不是将所有内容都打包到一个大文件中。

分解 Hello SDL 代码

我们可以将 Hello SDL 分解成单独的函数:

bool g_bRunning = false; // this will create a loop

按照以下步骤分解 Hello SDL 代码:

  1. 在两个全局变量之后创建一个 init 函数,它接受任何必要的值作为参数并将它们传递给 SDL_CreateWindow 函数:

    bool init(const char* title, int xpos, int ypos, int 
    height, int width, int flags)
    {
      // initialize SDL
      if(SDL_Init(SDL_INIT_EVERYTHING) >= 0)
      {
        // if succeeded create our window
        g_pWindow = SDL_CreateWindow(title, xpos, ypos, 
        height, width, flags);
    
        // if the window creation succeeded create our 
        renderer
        if(g_pWindow != 0)
        {
          g_pRenderer = SDL_CreateRenderer(g_pWindow, -1, 0);
        }
      }
      else
      {
        return false; // sdl could not initialize
      }
    
      return true;
    }
    
    void render()
    {
      // set to black
      SDL_SetRenderDrawColor(g_pRenderer, 0, 0, 0, 255);
    
      // clear the window to black
      SDL_RenderClear(g_pRenderer);
    
      // show the window
      SDL_RenderPresent(g_pRenderer);
    }
    
  2. 我们的主函数现在可以使用这些函数来初始化 SDL:

    int main(int argc, char* argv[])
    {
      if(init("Chapter 1: Setting up SDL", 
      SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 640, 
      480, SDL_WINDOW_SHOWN))
      {
        g_bRunning = true;
      }
      else
      {
        return 1; // something's wrong
      }
    
      while(g_bRunning)
      {
        render();
      }
    
      // clean up SDL
      SDL_Quit();
    
      return 0;
    }
    

如您所见,我们已经将代码分解成单独的部分:一个函数为我们执行初始化,另一个执行渲染代码。我们添加了一种方式来保持程序运行,即一个持续运行的 while 循环,渲染我们的窗口。

让我们更进一步,尝试确定一个完整游戏可能包含哪些单独的部分以及我们的主循环可能看起来像什么。参考第一张截图,我们可以看到我们需要的功能是 initializeget inputdo physicsrenderexit。我们将稍微泛化这些函数并将它们重命名为 init()handleEvents()update()render()clean()。让我们将这些函数放入 main.cpp

void init(){}
void render(){}
void update(){}
void handleEvents(){}
void clean(){}

bool g_bRunning = true;

int main()
{
  init();

  while(g_bRunning)
  {
    handleEvents();
    update();
    render();
  }

  clean();
}

这段代码做了什么?

这段代码目前并没有做太多,但它展示了游戏的基本结构和主循环可能被拆分的方式。我们声明了一些可以用来运行我们的游戏的功能:首先,init() 函数,它将初始化 SDL 并创建我们的窗口;其次,我们声明了核心循环函数 renderupdatehandle events。我们还声明了一个 clean 函数,它将在游戏结束时清理代码。我们希望这个循环持续运行,所以我们设置了一个布尔值,将其设置为 true,这样我们就可以连续调用我们的核心循环函数。

游戏类

因此,现在我们已经了解了构成游戏的基本要素,我们可以按照以下步骤将这些函数分离到它们自己的类中:

  1. 在项目中创建一个名为 Game.h 的新文件:

    #ifndef __Game__
    #define __Game__
    
    class Game
    {
    };
    
    #endif /* defined(__Game__) */
    
  2. 接下来,我们可以将我们的函数从 main.cpp 文件移动到 Game.h 头文件中:

    class Game
    {
    public:
    
      Game() {}
      ~Game() {}
    
      // simply set the running variable to true
      void init() { m_bRunning = true; }    
    
      void render(){}
      void update(){}
      void handleEvents(){}
      void clean(){}
    
      // a function to access the private running variable 
      bool running() { return m_bRunning; }
    
    private:
    
      bool m_bRunning;
    };
    
  3. 现在,我们可以修改 main.cpp 文件以使用这个新的 Game 类:

    #include "Game.h"
    
    // our Game object
    Game* g_game = 0;
    
    int main(int argc, char* argv[])
    {
      g_game = new Game();
    
      g_game->init("Chapter 1", 100, 100, 640, 480, 0);
    
      while(g_game->running())
      {
        g_game->handleEvents();
        g_game->update();
        g_game->render();
      }
      g_game->clean();
    
      return 0;
    }
    

    我们的 main.cpp 文件现在不声明或定义这些函数;它只是创建 Game 的一个实例并调用所需的方法。

  4. 现在我们有了这个骨架代码,我们可以继续将其与 SDL 集成以创建窗口;我们还将添加一个小的事件处理器,以便我们可以退出应用程序而不是强制它退出。我们将稍微修改我们的 Game.h 文件,以便我们可以添加一些 SDL 特定内容,并允许我们使用实现文件而不是在头文件中定义函数:

    #include "SDL.h"
    
    class Game
    {
    public:
    
      Game();
      ~Game();
    
      void init();
    
      void render();
      void update();
      void handleEvents();
      void clean();
    
      bool running() { return m_bRunning; }
    
    private:
    
      SDL_Window* m_pWindow;
      SDL_Renderer* m_pRenderer;
    
      bool m_bRunning;
    };
    

回顾本章的第一部分(我们创建了一个 SDL 窗口),我们知道我们需要一个指向 SDL_Window 对象的指针,该对象在调用 SDL_CreateWindow 时设置,以及一个指向由将窗口传递给 SDL_CreateRenderer 创建的 SDL_Renderer 对象的指针。init 函数可以扩展以使用与初始示例相同的参数。这个函数现在将返回一个布尔值,这样我们就可以检查 SDL 是否正确初始化:

bool init(const char* title, int xpos, int ypos, int width, int height, int flags);

我们现在可以在项目中创建一个新的实现文件 Game.cpp,以便我们可以为这些函数创建定义。我们可以从 Hello SDL 部分取代码并添加到我们新的 Game 类中。

打开 Game.cpp 文件,我们可以开始添加一些功能:

  1. 首先,我们必须包含我们的 Game.h 头文件:

    #include "Game.h"
    
  2. 接下来,我们可以定义我们的 init 函数;它基本上与我们在 main.cpp 文件中之前编写的 init 函数相同:

    bool Game::init(const char* title, int xpos, int ypos, int width, int height, int flags)
    {
      // attempt to initialize SDL
      if(SDL_Init(SDL_INIT_EVERYTHING) == 0)
      {
        std::cout << "SDL init success\n";
        // init the window
        m_pWindow = SDL_CreateWindow(title, xpos, ypos, 
        width, height, flags);
    
        if(m_pWindow != 0) // window init success
        {
          std::cout << "window creation success\n";
          m_pRenderer = SDL_CreateRenderer(m_pWindow, -1, 0);
    
          if(m_pRenderer != 0) // renderer init success
          {
            std::cout << "renderer creation success\n";
            SDL_SetRenderDrawColor(m_pRenderer, 
            255,255,255,255);
          }
          else
          {
            std::cout << "renderer init fail\n";
            return false; // renderer init fail
          }
        }
        else
        {
          std::cout << "window init fail\n";
          return false; // window init fail
        }
      }
      else
      {
        std::cout << "SDL init fail\n";
        return false; // SDL init fail
      }
    
      std::cout << "init success\n";
      m_bRunning = true; // everything inited successfully, 
      start the main loop
    
      return true;
    }
    
  3. 我们还将定义 render 函数。它清除渲染器,然后使用清除颜色重新渲染:

    void Game::render()
    {
      SDL_RenderClear(m_pRenderer); // clear the renderer to 
      the draw color
    
      SDL_RenderPresent(m_pRenderer); // draw to the screen
    }
    
  4. 最后,我们可以进行清理。我们销毁窗口和渲染器,并调用 SDL_Quit 函数来关闭所有子系统:

    {
      std::cout << "cleaning game\n";
      SDL_DestroyWindow(m_pWindow);
      SDL_DestroyRenderer(m_pRenderer);
      SDL_Quit();
    }
    

因此,我们将 Hello SDL 2.0 代码从 main.cpp 文件移动到了一个名为 Game 的类中。我们使 main.cpp 文件空闲出来,只处理 Game 类;它对 SDL 或 Game 类的实现一无所知。让我们给这个类添加一个功能,以便我们能够以常规方式关闭应用程序:

void Game::handleEvents()
{
  SDL_Event event;
  if(SDL_PollEvent(&event))
  {
    switch (event.type)
    {
      case SDL_QUIT:
        m_bRunning = false;
      break;

      default:
      break;
    }
  }
}

我们将在后续章节中更详细地介绍事件处理。现在这个函数所做的就是检查是否有事件需要处理,如果有,就检查它是否是 SDL_QUIT 事件(通过点击窗口上的叉号来关闭窗口)。如果事件是 SDL_QUIT,我们将 Game 类的 m_bRunning 成员变量设置为 false。将此变量设置为 false 使得主循环停止,应用程序进入清理和退出阶段:

void Game::clean()
{
  std::cout << "cleaning game\n";
  SDL_DestroyWindow(m_pWindow);
  SDL_DestroyRenderer(m_pRenderer);
  SDL_Quit();
}

clean() 函数销毁窗口和渲染器,然后调用 SDL_Quit() 函数,关闭所有初始化的 SDL 子系统。

注意

为了能够查看我们的 std::cout 消息,我们首先必须包含 Windows.h,然后调用 AllocConsole();freopen("CON", "w", stdout);。你可以在 main.cpp 文件中这样做。只需记住在分享你的游戏时将其移除。

全屏 SDL

SDL_CreateWindow 函数接受一个类型为 SDL_WindowFlags 的枚举值。这些值决定了窗口的行为。我们在 Game 类中创建了一个 init 函数:

bool init(const char* title, int xpos, int ypos, int width, int height, int flags);

最后一个参数是一个 SDL_WindowFlags 值,然后在初始化时传递给 SDL_CreateWindow 函数:

// init the window
m_pWindow = SDL_CreateWindow(title, xpos, ypos, width, height, flags);

这里是 SDL_WindowFlags 函数的表格:

标志 目的
SDL_WINDOW_FULLSCREEN 使窗口全屏
SDL_WINDOW_OPENGL 窗口可以作为 OpenGL 上下文使用
SDL_WINDOW_SHOWN 窗口是可见的
SDL_WINDOW_HIDDEN 隐藏窗口
SDL_WINDOW_BORDERLESS 窗口无边框
SDL_WINDOW_RESIZABLE 允许调整窗口大小
SDL_WINDOW_MINIMIZED 最小化窗口
SDL_WINDOW_MAXIMIZED 最大化窗口
SDL_WINDOW_INPUT_GRABBED 窗口已捕获输入焦点
SDL_WINDOW_INPUT_FOCUS 窗口拥有输入焦点
SDL_WINDOW_MOUSE_FOCUS 窗口拥有鼠标焦点
SDL_WINDOW_FOREIGN 窗口不是使用 SDL 创建的

让我们将 SDL_WINDOW_FULLSCREEN 传递给 init 函数,并测试一下全屏的 SDL。打开 main.cpp 文件并添加此标志:

g_game->init("Chapter 1", 100, 100, 640, 580, SDL_WINDOW_FULLSCREEN))

再次构建应用程序,你应该会看到窗口已全屏。要退出应用程序,必须强制退出(Windows 上的 Alt + F4);我们将在后续章节中能够使用键盘退出应用程序,但现在我们不需要全屏。我们在这里遇到的一个问题是,我们已经将一些 SDL 特定的内容添加到了 main.cpp 文件中。虽然我们在这本书中不会使用任何其他框架,但在将来我们可能想使用另一个。我们可以移除这个 SDL 特定的标志,并用一个布尔值替换,以表示我们是否想要全屏。

将我们 Game init 函数中的 int flags 参数替换为 boolfullscreen 参数:

  • Game.h 的代码片段:

    bool init(const char* title, int xpos, int ypos, int width, int height, bool fullscreen);
    
  • Game.cpp 的代码片段:

    bool Game::init(const char* title, int xpos, int ypos, int width, int height, bool fullscreen)
    {
      int flags = 0;
    
      if(fullscreen)
      {
        flags = SDL_WINDOW_FULLSCREEN;
      }
    }
    

我们创建一个 int 类型的 flags 变量,将其传递给 SDL_CreateWindow 函数;如果我们已将 fullscreen 设置为 true,则此值将被设置为 SDL_WINDOW_FULLSCREEN 标志,否则它将保持为 0,表示没有使用任何标志。现在让我们在我们的 main.cpp 文件中测试一下:

if(g_game->init("Chapter 1", 100, 100, 640, 480, true))

这将再次将我们的窗口设置为全屏,但我们不会使用 SDL 特定的标志来完成它。再次将其设置为 false,因为我们暂时不需要全屏。您可以自由尝试其他标志,看看它们会产生什么效果。

摘要

本章涵盖了大量的内容。我们学习了 SDL 是什么以及为什么它是游戏开发的伟大工具。我们探讨了游戏的整体结构以及如何将其分解成单独的部分,并通过创建一个可以用来初始化 SDL 并将内容渲染到屏幕上的 Game 类来开始构建我们框架的骨架。我们还简要地了解了 SDL 通过监听 quit 事件来处理事件的方式,以关闭我们的应用程序。在下一章中,我们将探讨在 SDL 中绘图以及构建 SDL_image 扩展。

第二章 SDL 中的绘图

图形对游戏非常重要,如果处理不当,它们也可能成为主要的性能瓶颈。使用 SDL 2.0,我们可以在渲染时真正利用 GPU,这为我们提供了渲染速度的实际提升。

在本章中,我们将涵盖:

  • SDL 绘图的基础

  • 源和目标矩形

  • 加载和显示纹理

  • 使用SDL_image扩展

基本的 SDL 绘图

在上一章中,我们创建了一个 SDL 窗口,但我们还没有将任何内容渲染到屏幕上。SDL 可以使用两种结构来绘制到屏幕上。一个是SDL_Surface结构,它包含一组像素,并使用软件渲染过程(而不是 GPU)进行渲染。另一个是SDL_Texture;这可以用于硬件加速渲染。我们希望我们的游戏尽可能高效,所以我们将专注于使用SDL_Texture

获取一些图像

在本章中,我们需要一些图像来加载。我们不想在这个阶段花费任何时间来为我们的游戏创建艺术资产;我们希望完全专注于编程方面。在这本书中,我们将使用来自www.widgetworx.com/widgetworx/portfolio/spritelib.htmlSpriteLib集合中的资产。

我已经修改了一些文件,以便我们可以在接下来的章节中轻松使用它们。这些图像可以在本书的源代码下载中找到。我们将使用的第一张是rider.bmp图像文件:

获取一些图像

创建 SDL 纹理

首先,我们将在我们的Game.h头文件中创建一个指向SDL_Texture对象的指针作为成员变量。我们还将创建一些矩形,用于绘制纹理。

SDL_Window* m_pWindow;
SDL_Renderer* m_pRenderer;

SDL_Texture* m_pTexture; // the new SDL_Texture variable
SDL_Rect m_sourceRectangle; // the first rectangle
SDL_Rect m_destinationRectangle; // another rectangle

我们现在可以在游戏的init函数中加载这个纹理。打开Game.cpp,按照以下步骤加载和绘制SDL_Texture

  1. 首先,我们将创建一个资产文件夹来存放我们的图像,将其放置在与您的源代码相同的文件夹中(不是可执行代码)。当您想要分发游戏时,您将复制此资产文件夹以及您的可执行文件。但为了开发目的,我们将将其保留在源代码的同一文件夹中。将rider.bmp文件放入此资产文件夹。

  2. 在我们的游戏的init函数中,我们可以加载我们的图像。我们将使用SDL_LoadBMP函数,它返回一个SDL_Surface*。从这个SDL_Surface*,我们可以使用SDL_CreateTextureFromSurface函数创建SDL_Texture结构。然后我们释放临时表面,释放任何使用的内存。

    SDL_Surface* pTempSurface = SDL_LoadBMP("assets/rider.bmp");
    
    m_pTexture = SDL_CreateTextureFromSurface(m_pRenderer, pTempSurface);
    
    SDL_FreeSurface(pTempSurface);
    
  3. 现在,我们已经有了SDL_Texture准备绘制到屏幕上。我们将首先获取我们刚刚加载的纹理的尺寸,并使用这些尺寸来设置m_sourceRectangle的宽度和高度,以便我们可以正确地绘制它。

    SDL_QueryTexture(m_pTexture, NULL, NULL, &m_sourceRectangle.w, &m_sourceRectangle.h);
    
  4. 查询纹理将允许我们将源矩形的宽度和高度设置为所需的精确尺寸。因此,现在我们已经将纹理的正确宽度和高度存储在 m_sourceRectangle 中,我们还必须设置目标矩形的宽度和高度。这样做是为了让渲染器知道要将图像绘制到窗口的哪个部分,以及我们想要渲染的图像的宽度和高度。我们将 x 和 y 坐标都设置为 0(左上角)。窗口坐标可以用 xy 值表示,其中 x 是水平位置,y 是垂直位置。因此,SDL 中窗口左上角的坐标是 (0,0),中心点对于 x 是窗口宽度的一半,对于 y 是窗口高度的一半。

    m_destinationRectangle.x = m_sourceRectangle.x = 0;
    m_destinationRectangle.y = m_sourceRectangle.y = 0;
    m_destinationRectangle.w = m_sourceRectangle.w;
    m_destinationRectangle.h = m_sourceRectangle.h;
    
  5. 现在我们已经加载了纹理及其尺寸,我们可以继续将其渲染到屏幕上。移动到我们的游戏的 render 函数,我们将添加代码来绘制我们的纹理。将此函数放置在 SDL_RenderClearSDL_RenderPresent 调用之间。

    SDL_RenderCopy(m_pRenderer, m_pTexture, &m_sourceRectangle, &m_destinationRectangle);
    
  6. 构建项目,你会看到我们加载的纹理。创建 SDL 纹理

源和目标矩形

现在我们已经在屏幕上绘制了一些内容,解释源矩形和目标矩形的作用是个好主意,因为它们对于诸如瓦片地图加载和绘制等主题将极为重要。它们对于精灵表动画也很重要,我们将在本章后面讨论。

我们可以将源矩形视为定义从纹理复制到窗口的区域的区域:

  1. 在前面的示例中,我们使用了整个图像,因此我们可以简单地使用与加载的纹理相同的尺寸来定义源矩形的尺寸。源和目标矩形

  2. 前一个屏幕截图中的红色框是我们绘制到屏幕时所使用的源矩形的视觉表示。我们希望从源矩形内部复制像素到渲染器的特定区域,即目标矩形(以下屏幕截图中的红色框)。源和目标矩形

  3. 如您所预期,这些矩形可以按照您的意愿定义。例如,让我们再次打开我们的 Game.cpp 文件,看看如何更改源矩形的尺寸。将此代码放置在 SDL_QueryTexture 函数之后。

    m_sourceRectangle.w = 50;
    m_sourceRectangle.h = 50;
    

    现在再次构建项目,你应该会看到只有图像的 50 x 50 平方区域被复制到了渲染器中。

    源和目标矩形

  4. 现在让我们通过更改其 xy 值来移动目标矩形。

    m_destinationRectangle.x = 100;
    m_destinationRectangle.y = 100;
    

    再次构建项目,你会看到我们的源矩形位置保持不变,但目标矩形已经移动。我们所做的只是移动了我们要将源矩形内的像素复制到的位置。

    源和目标矩形

  5. 到目前为止,我们已将源矩形的xy坐标保持在 0,但它们也可以移动,以仅绘制所需的图像部分。我们可以移动源矩形的xy坐标,以绘制图像的右下角部分而不是左上角。将此代码放置在设置目标矩形位置的代码之前。

    m_sourceRectangle.x = 50;
    m_sourceRectangle.y = 50;
    

    您可以看到,我们仍在绘制到相同的目标位置,但我们正在复制图像的不同 50 x 50 部分。

    源矩形和目标矩形

  6. 我们还可以将null传递给任一矩形的渲染复制。

    SDL_RenderCopy(m_pRenderer, m_pTexture, 0, 0);
    

    null传递给源矩形参数将使渲染器使用整个纹理。同样,将null传递给目标矩形参数将使用整个渲染器进行显示。

    源矩形和目标矩形

我们已经介绍了几种我们可以使用矩形来定义我们想要绘制的图像区域的方法。现在,我们将通过显示动画精灵图来将这些知识付诸实践。

精灵图动画

我们可以将我们对源矩形和目标矩形的理解应用到精灵图动画中。精灵图是一系列动画帧组合成的一张图片。单独的帧需要具有非常特定的宽度和高度,以便它们能够创建出无缝的运动。如果精灵图的一部分不正确,将会使整个动画看起来不协调或完全错误。以下是我们将用于此演示的示例精灵图:

精灵图动画

  1. 这个动画由六个帧组成,每个帧的大小为 128 x 82 像素。根据前面的章节,我们知道我们可以使用源矩形来获取图像的某个部分。因此,我们可以首先定义一个源矩形,仅包含动画的第一帧。精灵图动画

  2. 由于我们知道精灵图上帧的宽度、高度和位置,我们可以将这些值硬编码到我们的源矩形中。首先,我们必须加载新的animate.bmp文件。将其放入您的资产文件夹中,并修改加载代码。

    SDL_Surface* pTempSurface = SDL_LoadBMP("assets/animate.bmp");
    
  3. 这将现在加载我们新的精灵图 BMP。我们可以删除SDL_QueryTexture函数,因为我们现在正在定义自己的尺寸。调整源矩形的大小,以仅获取图的第一帧。

    m_sourceRectangle.w = 128;
    m_sourceRectangle.h = 82;
    
  4. 我们将保持两个矩形的xy位置为0,这样我们就可以从左上角绘制图像,并将其复制到渲染器的左上角。我们还将保持目标矩形的尺寸不变,因为我们希望它保持与源矩形相同。将两个矩形传递给SDL_RenderCopy函数:

    SDL_RenderCopy(m_pRenderer, m_pTexture, &m_sourceRectangle, &m_destinationRectangle);
    

    现在我们构建时,将得到动画的第一帧。

    精灵图动画

  5. 现在我们有了第一帧,我们可以继续动画精灵图。每一帧都有完全相同的尺寸。这对于正确动画此图非常重要。我们只想移动源矩形的定位,而不是其尺寸。动画精灵图

  6. 每次我们想要移动另一帧时,我们只需移动源矩形的定位并将其复制到渲染器中。为此,我们将使用我们的update函数。

    void Game::update()
    {
      m_sourceRectangle.x = 128 * int(((SDL_GetTicks() / 100) % 6));
    }
    
  7. 在这里,我们使用了SDL_GetTicks()来找出自 SDL 初始化以来经过的毫秒数。然后我们将其除以我们希望在帧之间想要的时间(以毫秒为单位),然后使用取模运算符来保持它在我们的动画中帧的数量范围内。此代码(每 100 毫秒)将我们的源矩形的x值移动 128 像素(帧的宽度),乘以我们想要的当前帧,从而给出正确的位置。构建项目后,你应该会看到动画正在显示。

翻转图像

在大多数游戏中,玩家、敌人等等都会在多个方向上移动。为了使精灵面向移动的方向,我们必须翻转我们的精灵图。当然,我们可以在精灵图中创建一个新的行来包含翻转的帧,但这会使用更多的内存,而我们不想这样做。SDL 2.0 有一个允许我们传入我们想要图像如何翻转或旋转的渲染函数。我们将使用的函数是SDL_RenderCopyEx。此函数与SDL_RenderCopy具有相同的参数,但还包含特定于旋转和翻转的参数。第四个参数是我们想要图像显示的角度,第五个参数是我们想要旋转的中心点。最后一个参数是一个名为SDL_RendererFlip的枚举类型。

以下表格显示了SDL_RendererFlip枚举类型可用的值:

SDL_RendererFlip 值 目的
SDL_FLIP_NONE 不翻转
SDL_FLIP_HORIZONTAL 水平翻转纹理
SDL_FLIP_VERTICAL 垂直翻转纹理

我们可以使用此参数来翻转我们的图像。以下是修改后的渲染函数:

void Game::render()
{
  SDL_RenderClear(m_pRenderer);

  SDL_RenderCopyEx(m_pRenderer, m_pTexture,
  &m_sourceRectangle, &m_destinationRectangle,
  0, 0, SDL_FLIP_HORIZONTAL); // pass in the horizontal flip

  SDL_RenderPresent(m_pRenderer);
}

构建项目后,你会看到图像已经被翻转,现在面向左侧。我们的角色和敌人也将有专门用于动画的帧,例如攻击和跳跃。这些可以添加到精灵图的不同的行中,并且源矩形的y值相应增加。(我们将在创建游戏对象时更详细地介绍这一点。)

安装 SDL_image

到目前为止,我们只加载了 BMP 图像文件。这是 SDL 在不使用任何扩展的情况下支持的所有内容。我们可以使用SDL_image来使我们能够加载许多不同的图像文件类型,如 BMP、GIF、JPEG、LBM、PCX、PNG、PNM、TGA、TIFF、WEBP、XCF、XPM 和 XV。首先,我们需要克隆SDL_image的最新构建版本,以确保它与 SDL 2.0 兼容:

  1. 打开 TortoiseHg 工作台,使用 Ctrl + Shift + N 克隆一个新的仓库。

  2. SDL_image 的仓库列在 www.libsdl.org/projects/SDL_image/hg.libsdl.org/SDL_image/ 上。所以让我们继续在 框中输入这些内容。

  3. 我们的目标将是一个新的目录,C:\SDL2_image。在 目标 框中输入此内容后,点击 克隆 并等待其完成。

  4. 一旦创建了此文件夹,导航到我们的 C:\SDL2_image 克隆仓库。打开 VisualC 文件夹,然后使用 Visual Studio 2010 express 打开 SDL_image_VS2010 VC++ 项目。

  5. 右键单击 SDL2_image 项目,然后点击 属性。在这里,我们需要包含 SDL.h 头文件。将配置更改为 所有配置,导航到 VC++ 目录,点击 包含目录 下拉菜单,然后点击 <编辑…>。在这里,我们可以输入我们的 C:\SDL2\include\ 目录。

  6. 接下来,转到 库目录 并添加我们的 C:\SDL2\lib\ 文件夹。现在导航到 链接器 | 输入 | 附加依赖项,并添加 SDL2.lib

  7. 点击 确定,我们几乎准备好构建了。我们现在使用 SDL2.lib,所以我们可以从 SDL_image 项目中删除 SDL.libSDLmain.lib 文件。在解决方案资源管理器中定位文件,右键单击然后删除文件。将构建配置更改为 发布,然后构建。

  8. 可能会出现一个无法启动程序的错误。只需点击 确定,然后我们可以关闭项目并继续。

  9. 现在,在 C:\SDL2_image\VisualC\ 文件夹中会有一个 Release 文件夹。打开它,将 SDL_image.dll 复制到我们的游戏可执行文件文件夹中。

  10. 接下来,将 SDL2_image.lib 文件复制到我们原始的 C:\SDL2\lib\ 目录中。也将 SDL_image 头文件从 C:\SDL2_image\ 复制到 C:\SDL2\include\ 目录中。

  11. 我们只需要再获取几个库,然后就可以完成了。从 www.libsdl.org/projects/SDL_image/ 下载 SDL_image-1.2.12-win32.zip 文件(或者如果你是针对 64 位平台,则下载 x64)。解压所有内容,然后将所有 .dll 文件(除了 SDL_image.dll)复制到我们的游戏可执行文件文件夹中。

  12. 打开我们的游戏项目,进入其属性。导航到 链接器 | 输入 | 附加依赖项,并添加 SDL2_image.lib。安装 SDL_image

  13. 我们现在已经安装了 SDL_image,可以开始加载各种不同的图像文件了。将 animate.pnganimate-alpha.png 图像从源下载复制到我们的游戏资源文件夹中,然后我们可以开始加载 PNG 文件。

使用 SDL_image

因此,我们已经安装了库,现在该如何使用它呢?使用 SDL_image 替代常规的 SDL 图像加载很简单。在我们的例子中,我们只需要替换一个函数,并添加 #include <SDL_image.h>

SDL_Surface* pTempSurface = SDL_LoadBMP("assets/animate.bmp");

上述代码将按如下方式更改:

SDL_Surface* pTempSurface = IMG_Load("assets/animate.png");

我们现在正在加载 .png 图像。PNG 文件非常适合使用,它们具有较小的文件大小并支持 alpha 通道。让我们进行一次测试。将我们的渲染器清除颜色更改为红色。

SDL_SetRenderDrawColor(m_pRenderer, 255,0,0,255);

你会看到我们仍然在使用图像时的黑色背景;这绝对不是我们目的的理想选择。

使用 SDL_image

当使用 PNG 文件时,我们可以通过使用 alpha 通道来解决这个问题。我们移除图像的背景,然后在加载时,SDL 不会从 alpha 通道绘制任何内容。

使用 SDL_image

让我们加载此图像并看看它的样子:

SDL_Surface* pTempSurface = IMG_Load("assets/animate-alpha.png");

这正是我们想要的:

使用 SDL_image

将其整合到框架中

我们已经对使用 SDL 绘制图像的主题进行了很多介绍,但我们还没有将所有内容整合到我们的框架中,以便在整个游戏中重用。我们现在要介绍的是创建一个纹理管理器类,它将包含我们轻松加载和绘制纹理所需的所有函数。

创建纹理管理器

纹理管理器将具有允许我们从图像文件加载和创建 SDL_Texture 结构的函数,绘制纹理(静态或动画),并保持 SDL_Texture* 的列表,这样我们就可以在需要时使用它们。让我们继续创建 TextureManager.h 文件:

  1. 首先,我们声明我们的 load 函数。作为参数,该函数接受我们想要使用的图像文件名、我们想要使用的用于引用纹理的 ID,以及我们想要使用的渲染器。

    bool load(std::string fileName,std::string id, SDL_Renderer* pRenderer);
    
  2. 我们将创建两个绘制函数,drawdrawFrame。它们都将接受我们想要绘制的纹理的 ID、我们想要绘制的 xy 位置、框架或我们使用的图像的高度和宽度、我们将复制的渲染器,以及一个 SDL_RendererFlip 值来描述我们想要如何显示图像(默认为 SDL_FLIP_NONE)。drawFrame 函数将接受两个额外的参数,即我们想要绘制的当前帧和它在精灵图中的行。

    // draw
    void draw(std::string id, int x, int y, int width, int height, SDL_Renderer* pRenderer, SDL_RendererFlip flip = SDL_FLIP_NONE);
    
    // drawframe
    
    void drawFrame(std::string id, int x, int y, int width, int height, int currentRow, int currentFrame, SDL_Renderer* pRenderer, SDL_RendererFlip flip = SDL_FLIP_NONE);
    
  3. TextureManager 类还将包含指向 SDL_Texture 对象的指针的 std::map,使用 std::strings 作为键。

    std::map<std::string, SDL_Texture*> m_textureMap;
    
  4. 我们现在必须在 TextureManager.cpp 文件中定义这些函数。让我们从 load 函数开始。我们将从之前的纹理加载代码中提取代码,并在 load 方法中使用它。

    bool TextureManager::load(std::string fileName, std::string id, SDL_Renderer* pRenderer)
    {
      SDL_Surface* pTempSurface = IMG_Load(fileName.c_str());
    
      if(pTempSurface == 0)
      {
        return false;
      }
    
      SDL_Texture* pTexture = 
      SDL_CreateTextureFromSurface(pRenderer, pTempSurface);
    
      SDL_FreeSurface(pTempSurface);
    
      // everything went ok, add the texture to our list
      if(pTexture != 0)
      {
        m_textureMap[id] = pTexture;
        return true;
      }
    
      // reaching here means something went wrong
      return false;
    }
    
  5. 当我们调用此函数时,我们将拥有可以使用的 SDL_Texture,我们可以通过使用其 ID 从映射中访问它;我们将在我们的 draw 函数中使用它。draw 函数可以定义如下:

    void TextureManager::draw(std::string id, int x, int y, int width, int height, SDL_Renderer* pRenderer, SDL_RendererFlip flip)
    {
      SDL_Rect srcRect;
      SDL_Rect destRect;
    
      srcRect.x = 0;
      srcRect.y = 0;
      srcRect.w = destRect.w = width;
      srcRect.h = destRect.h = height;
      destRect.x = x;
      destRect.y = y;
    
      SDL_RenderCopyEx(pRenderer, m_textureMap[id], &srcRect, 
      &destRect, 0, 0, flip);
    }
    
  6. 我们再次使用 SDL_RenderCopyEx,通过传入的 ID 变量获取我们想要绘制的 SDL_Texture 对象。我们还使用传入的 xywidthheight 值构建我们的源和目标变量。现在我们可以继续到 drawFrame

    void TextureManager::drawFrame(std::string id, int x, int y, int width, int height, int currentRow, int currentFrame, SDL_Renderer *pRenderer, SDL_RendererFlip flip)
    {
      SDL_Rect srcRect;
      SDL_Rect destRect;
      srcRect.x = width * currentFrame;
      srcRect.y = height * (currentRow - 1);
      srcRect.w = destRect.w = width;
      srcRect.h = destRect.h = height;
      destRect.x = x;
      destRect.y = y;
    
      SDL_RenderCopyEx(pRenderer, m_textureMap[id], &srcRect, 
      &destRect, 0, 0, flip);
    }
    

    在这个函数中,我们创建一个源矩形来使用动画的适当帧,使用 currentFramecurrentRow 变量。当前帧的源矩形 x 位置是源矩形宽度乘以 currentFrame 值(在 动画精灵表 部分中介绍过)。它的 y 值是矩形高度乘以 currentRow – 1(使用第一行而不是零行听起来更自然)。

  7. 现在我们已经拥有了在游戏中轻松加载和绘制纹理所需的一切。让我们继续测试它,使用 animated.png 图像。打开 Game.h 文件。我们不再需要纹理成员变量或矩形,所以请从 Game.hGame.cpp 文件中删除任何处理它们的代码。然而,我们将创建两个新的成员变量。

    int m_currentFrame;
    TextureManager m_textureManager;
    
  8. 我们将使用 m_currentFrame 变量来允许我们动画化精灵表,并且我们还需要一个我们新的 TextureManager 类的实例(确保你包含了 TextureManager.h)。我们可以在游戏的 init 函数中加载纹理。

    m_textureManager.load("assets/animate-alpha.png", "animate", m_pRenderer);
    
  9. 我们已经给这个纹理分配了一个名为 "animate" 的 ID,我们可以在我们的 draw 函数中使用它。我们将首先在 0,0 位置绘制一个静态图像,并在 100,100 位置绘制一个动画图像。以下是渲染函数:

    void Game::render()
    {
    
      SDL_RenderClear(m_pRenderer);
    
      m_textureManager.draw("animate", 0,0, 128, 82, 
      m_pRenderer);
    
      m_textureManager.drawFrame("animate", 100,100, 128, 82, 
      1, m_currentFrame, m_pRenderer);
    
      SDL_RenderPresent(m_pRenderer);
    
    }
    
  10. drawFrame 函数使用我们的 m_currentFrame 成员变量。我们可以在 update 函数中增加这个值,就像我们之前做的那样,但现在我们在 draw 函数内部进行源矩形的计算。

    void Game::update()
    {
      m_currentFrame = int(((SDL_GetTicks() / 100) % 6));
    }
    

    现在我们可以构建并看到我们的辛勤工作付诸实践了。

创建纹理管理器

使用纹理管理器作为单例

现在我们已经设置了纹理管理器,但我们仍然有一个问题。我们希望在整个游戏中重用这个 TextureManager,因此我们不希望它是 Game 类的成员,因为那样我们就必须将它传递给我们的绘制函数。对我们来说,将 TextureManager 实现为单例是一个好选择。单例是一个只能有一个实例的类。这对我们来说很适用,因为我们希望在游戏中重用相同的 TextureManager。我们可以通过首先将构造函数设为私有来使我们的 TextureManager 成为单例。

private:

TextureManager() {}

这是为了确保它不能像其他对象那样被创建。它只能通过使用 Instance 函数来创建和访问,我们将声明和定义它。

static TextureManager* Instance()
{
  if(s_pInstance == 0)
  {
    s_pInstance = new TextureManager();
    return s_pInstance;
  }

  return s_pInstance;
}

这个函数检查我们是否已经有了 TextureManager 的实例。如果没有,则构建它,否则简单地返回静态实例。我们还将 typedef TextureManager

typedef TextureManager TheTextureManager;

我们还必须在 TextureManager.cpp 中定义静态实例。

TextureManager* TextureManager::s_pInstance = 0;

我们现在可以将我们的 TextureManager 作为单例使用。我们不再需要在 Game 类中有一个 TextureManager 的实例,我们只需包含头文件并按以下方式使用它:

// to load
if(!TheTextureManager::Instance()->load("assets/animate-alpha.png", "animate", m_pRenderer))
{
   return false;
}
// to draw
TheTextureManager::Instance()->draw("animate", 0,0, 128, 82, m_pRenderer);

当我们在 Game(或任何其他)类中加载纹理时,我们可以在整个代码中访问它。

摘要

本章主要讲述了将图像渲染到屏幕上的过程。我们涵盖了源矩形和目标矩形以及精灵表的动画处理。我们将所学知识应用于创建一个可重用的纹理管理器类,使我们能够轻松地在整个游戏中加载和绘制图像。在下一章中,我们将介绍如何使用继承和多态来创建一个基础游戏对象类,并在我们的游戏框架中使用它。

第三章. 与游戏对象协作

所有游戏都有对象,例如,玩家、敌人、非玩家角色(NPC)、陷阱、子弹和门。跟踪所有这些对象以及它们如何相互作用是一项庞大的任务,我们希望尽可能简化这项任务。如果我们没有坚实的实现,我们的游戏可能会变得难以控制且难以更新。那么我们如何使我们的任务更容易呢?我们可以从真正尝试利用面向对象编程(OOP)的强大功能开始。在本章中,我们将涵盖以下内容:

  • 使用继承

  • 实现多态

  • 使用抽象基类

  • 有效的继承设计

使用继承

我们将要探讨的第一个面向对象编程(OOP)的强大特性是继承。这个特性在开发可重用的框架时能给我们带来巨大的帮助。通过使用继承,我们可以在相似类之间共享通用功能,并从现有类型中创建子类型。我们不会深入探讨继承本身,而是开始思考如何将其应用到我们的框架中。

如前所述,所有游戏都有各种类型的对象。在大多数情况下,这些对象将拥有大量相同的数据和需要大量相同的基本功能。让我们看看一些这种常见功能性的例子:

  • 几乎我们所有的对象都将被绘制到屏幕上,因此需要draw函数

  • 如果我们的对象需要被绘制,它们将需要一个绘制位置,即 x 和 y 位置变量

  • 我们不总是需要静态对象,所以我们需要一个update函数

  • 对象将负责清理自己的事务;处理这个问题的函数将非常重要

这是我们第一个游戏对象类的一个很好的起点,所以让我们继续并创建它。向项目中添加一个新的类名为GameObject,然后我们可以开始:

class GameObject
{
public:

  void draw() { std::cout << "draw game object"; }
  void update() { std::cout << "update game object"; }
  void clean() { std::cout << "clean game object"; }

protected:

  int m_x;
  int m_y;
};

注意

公共(public)、受保护(protected)和私有(private)关键字非常重要。公共函数和数据可以从任何地方访问。受保护状态仅允许从其派生的类访问。私有成员仅对该类可用,甚至其派生类也无法访问。

因此,我们有了第一个游戏对象类。现在让我们从它继承并创建一个名为Player的类:

class Player : public GameObject // inherit from GameObject
{
public:

  void draw()
  {
    GameObject::draw();
    std::cout << "draw player";
  }
  void update()
  {
    std::cout << "update player";
    m_x = 10;
    m_y = 20;
  }
  void clean()
  {
    GameObject::clean();
    std::cout << "clean player";
  }
};

我们已经实现的能力是重用我们在GameObject中原本拥有的代码和数据,并将其应用到我们新的Player类中。正如你所看到的,派生类可以覆盖父类的功能:

void update()
{
  std::cout << "update player";
  m_x = 10;
  m_y = 20;
}

或者它甚至可以使用父类的功能,同时在其之上拥有自己的附加功能:

void draw()
{
  GameObject::draw();
  std::cout << "draw player";
}

在这里,我们调用GameObject中的draw函数,然后定义一些玩家特定的功能。

注意

::运算符被称为作用域解析运算符,它用于标识某些数据或函数的具体位置。

好的,到目前为止,我们的类没有做太多,所以让我们添加一些 SDL 功能。我们将在GameObject类中添加一些绘图代码,然后在Player类中重用它。首先,我们将更新GameObject头文件,添加一些新的值和函数,以便我们可以使用现有的 SDL 代码:

class GameObject
{
public:

  void load(int x, int y, int width, int height, std::string 
  textureID);
  void draw(SDL_Renderer* pRenderer);
  void update();
  void clean();

protected:

  std::string m_textureID;

  int m_currentFrame;
  int m_currentRow;

  int m_x;
  int m_y;

  int m_width;
  int m_height;
};

现在我们有一些新的成员变量,它们将在新的load函数中设置。我们还在draw函数中传递了我们要使用的SDL_Renderer对象。让我们在一个实现文件中定义这些函数并创建GameObject.cpp

首先定义我们的新load函数:

void GameObject::load(int x, int y, int width, int height, std::string textureID)
{
  m_x = x;
  m_y = y;
  m_width = width;
  m_height = height;
  m_textureID = textureID;

  m_currentRow = 1;
  m_currentFrame = 1;
}

这里我们设置了在头文件中声明的所有值。现在我们可以创建我们的draw函数,它将使用这些值:

void GameObject::draw(SDL_Renderer* pRenderer)
{
  TextureManager::Instance()->drawFrame(m_textureID, m_x, m_y, 
  m_width, m_height, m_currentRow, m_currentFrame, pRenderer);
}

我们使用m_textureIDTextureManager获取我们想要的纹理,并根据我们设置的值绘制它。最后,我们可以在update函数中添加一些内容,这些内容可以在Player类中重写:

void GameObject::update()
{
  m_x += 1;
}

我们的GameObject类现在已经完成。我们现在可以修改Player头文件以反映我们的更改:

#include "GameObject.h"

class Player : public GameObject
{
public:

  void load(int x, int y, int width, int height, std::string 
  textureID);
  void draw(SDL_Renderer* pRenderer);
  void update();
  void clean();
};

我们现在可以继续在实现文件中定义这些函数。创建Player.cpp,我们将遍历这些函数。首先,我们将从load函数开始:

void Player::load(int x, int y, int width, int height, string textureID)
{
  GameObject::load(x, y, width, height, textureID);
}

这里我们可以使用我们的GameObject::load函数。同样也适用于我们的draw函数:

void Player::draw(SDL_Renderer* pRenderer)
{
  GameObject::draw(pRenderer);
}

让我们用不同的方式重写update函数;让我们让这个对象动画化并朝相反方向移动:

void Player::update()
{
  m_x -= 1;
}

现在我们已经准备好了;我们可以在Game头文件中创建这些对象:

GameObject m_go;
Player m_player;

然后在init函数中加载它们:

m_go.load(100, 100, 128, 82, "animate");
m_player.load(300, 300, 128, 82, "animate");

然后需要将它们添加到renderupdate函数中:

void Game::render()
{

  SDL_RenderClear(m_pRenderer); // clear to the draw colour

  m_go.draw(m_pRenderer);
  m_player.draw(m_pRenderer);

  SDL_RenderPresent(m_pRenderer); // draw to the screen

}

void Game::update()
{
  m_go.update();
  m_player.update();
}

为了使程序正确运行,我们还需要添加一个东西。我们需要稍微限制帧率;如果不这样做,那么我们的对象会移动得太快。我们将在后面的章节中详细介绍这一点,但现在我们可以在主循环中添加一个延迟。所以,回到main.cpp,我们可以添加这一行:

while(g_game->running())
{
  g_game->handleEvents();
  g_game->update();
  g_game->render();

  SDL_Delay(10); // add the delay
}

现在构建并运行以查看我们的两个独立对象:

使用继承

我们的Player类编写起来非常简单,因为我们已经在GameObject类中编写了一些代码,以及所需的变量。然而,你可能已经注意到,我们在Game类中的很多地方都复制了代码。创建和添加新对象到游戏需要很多步骤。这并不理想,因为很容易遗漏一个步骤,而且当游戏对象超过两个或三个不同对象时,管理和维护会变得极其困难。

我们真正想要的是让Game类不需要关心不同类型;然后我们可以一次性遍历所有游戏对象,并为它们的每个函数分别使用循环。

实现多态

这引出了我们的下一个面向对象编程特性,多态。多态允许我们通过其父类或基类的指针来引用对象。一开始这可能看起来并不强大,但这将允许我们做到的是,我们的Game类只需要存储一个指向一种类型的指针列表,任何派生类型也可以添加到这个列表中。

让我们以GameObjectPlayer类为例,加上一个派生类Enemy。在我们的Game类中,我们有一个GameObject*数组:

std::vector<GameObject*> m_gameObjects;

然后我们声明四个新对象,它们都是GameObject*

GameObject* m_player;
GameObject* m_enemy1;
GameObject* m_enemy2;
GameObject* m_enemy3;

在我们的Game::init函数中,我们可以创建对象的实例,使用它们的单独类型:

m_player = new Player();
m_enemy1 = new Enemy();
m_enemy2 = new Enemy();
m_enemy3 = new Enemy();

现在它们可以被推入GameObject*数组中:

m_gameObjects.push_back(m_player);
m_gameObjects.push_back(m_enemy1);
m_gameObjects.push_back(m_enemy2);
m_gameObjects.push_back(m_enemy3);

Game::draw函数现在可能看起来像这样:

void Game::draw()
{
  for(std::vector<GameObject*>::size_type i = 0; i != 
  m_gameObjects.size(); i++) 
  {
    m_gameObjects[i]->draw(m_pRenderer);
  }
}

注意,我们正在遍历所有对象并调用draw函数。循环并不关心我们的某些对象实际上是PlayerEnemy;它以相同的方式处理它们。我们通过它们基类的指针访问它们。因此,要添加新类型,它只需从GameObject派生即可,Game类可以处理它。

  • 因此,让我们在我们的框架中真正实现这一点。首先,我们需要一个基类;我们将坚持使用GameObject。我们将不得不对这个类做一些修改,以便我们可以将其用作基类:

    class GameObject
    {
    public:
    
      virtual void load(int x, int y, int width, int height, 
      std::string textureID);
      virtual void draw(SDL_Renderer* pRenderer);
      virtual void update();
      virtual void clean();
    
    protected:
    
      std::string m_textureID;
    
      int m_currentFrame;
      int m_currentRow;
    
      int m_x;
      int m_y;
    
      int m_width;
      int m_height;
    };
    

注意,我们现在已经用虚拟关键字前缀了我们的函数。虚拟关键字意味着当通过指针调用此函数时,它使用对象本身的类型定义,而不是其指针的类型:

void Game::draw()
{
  for(std::vector<GameObject*>::size_type i = 0; i != 
  m_gameObjects.size(); i++) 
  {
    m_gameObjects[i]->draw(m_pRenderer);  
  }
}

换句话说,这个函数总是会调用GameObject中包含的draw函数,无论是Player还是Enemy。我们永远不会得到我们想要的覆盖行为。虚拟关键字将确保调用PlayerEnemydraw函数。

现在我们有一个基类,所以让我们在我们的Game类中实际尝试一下。我们首先在Game头文件中声明对象:

GameObject* m_go;
GameObject* m_player;

现在声明与我们的GameObject*数组一起:

std::vector<GameObject*> m_gameObjects;

现在在init函数中创建和加载对象,然后将它们推入数组中:

m_go = new GameObject();
m_player = new Player();

m_go->load(100, 100, 128, 82, "animate");
m_player->load(300, 300, 128, 82, "animate");

m_gameObjects.push_back(m_go);
m_gameObjects.push_back(m_player);

到目前为止,一切顺利;我们现在可以创建一个循环来绘制我们的对象,另一个循环来更新它们。现在让我们看看renderupdate函数:

void Game::render()
{

  SDL_RenderClear(m_pRenderer); // clear to the draw colour

  // loop through our objects and draw them
  for(std::vector<GameObject*>::size_type i = 0; i != 
  m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->draw(m_pRenderer);
  }

  SDL_RenderPresent(m_pRenderer); // draw to the screen

}

void Game::update()
{
  // loop through and update our objects
  for(std::vector<GameObject*>::size_type i = 0; i != 
  m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->update();
  }
}

如您所见,这要整洁得多,也更容易管理。让我们再从GameObject派生一个类,以便我们更深入地理解这个概念。创建一个名为Enemy的新类:

class Enemy : public GameObject
{
public:

  void load(int x, int y, int width, int height, std::string 
  textureID);
  void draw(SDL_Renderer* pRenderer);
  void update();
  void clean();
};

我们将定义这个类的函数与Player相同,只有update函数是一个例外:

void Enemy::update()
{
  m_y += 1;
  m_x += 1;
  m_currentFrame = int(((SDL_GetTicks() / 100) % 6));
}

现在让我们将其添加到游戏中。首先,我们这样声明:

GameObject* m_enemy;

然后创建、加载并将它们添加到数组中:

m_enemy = new Enemy();
m_enemy->load(0, 0, 128, 82, "animate");
m_gameObjects.push_back(m_enemy);

我们刚刚添加了一个新类型,而且非常快速简单。运行游戏,看看我们的三个对象,每个对象都有它们自己的不同行为。

实现多态

我们在这里已经涵盖了大量的内容,并有一个处理游戏对象的非常不错的系统,但我们仍然有一个问题。没有任何东西阻止我们派生一个没有我们在这里使用的updatedraw函数的类,甚至可以声明一个不同的函数并将update代码放在那里。作为开发者,我们不太可能犯这样的错误,但其他人使用框架时可能会。我们希望的是能够强制我们的派生类实现我们决定的一个函数,创建一个我们希望所有游戏对象都遵循的蓝图。我们可以通过使用抽象基类来实现这一点。

使用抽象基类

如果我们要正确实现我们的设计,那么我们必须确保所有派生类都有我们希望通过基类指针访问的每个函数的声明和定义。我们可以通过将GameObject设为抽象基类来确保这一点。抽象基类本身不能被初始化;它的目的是规定派生类的设计。这使我们能够重用,因为我们知道从GameObject派生的任何对象都将立即在游戏的整体方案中工作。

抽象基类是一个包含至少一个纯虚函数的类。纯虚函数是一个没有定义且必须在任何派生类中实现的函数。我们可以通过在函数后添加=0来使其成为纯虚函数。

我们是否应该总是使用继承?

继承和多态都非常有用,并且真正展示了面向对象编程的强大之处。然而,在某些情况下,继承可能会造成比解决的问题更多的问题,因此,在决定是否使用它时,我们应该牢记一些经验法则。

是否可以用更简单的解决方案达到同样的效果?

假设我们想要创建一个更强大的Enemy对象;它将具有与普通Enemy对象相同的行为,但拥有更多的生命值。一个可能的解决方案是从Enemy派生一个新的类PowerEnemy并给它双倍的生命值。在这个解决方案中,新类看起来会非常稀疏;它将使用Enemy的功能,但有一个不同的值。一个更简单的解决方案是提供一种方法来设置Enemy类的生命值,无论是通过访问器还是构造函数。在这种情况下,继承根本不是必需的。

派生类应该模拟“是”关系

在派生一个类时,让它模拟“是一个”关系是一个好主意。这意味着派生类也应该与父类具有相同的类型。例如,从 Player 类派生一个 Player2 类是符合模型的,因为 Player2 “是一个” Player。但是,假设我们有一个 Jetpack 类,并且从该类派生 Player 类以使其能够访问 Jetpack 类的所有功能。这不会模拟“是一个”关系,因为 Player 类不是 Jetpack 类。更合理的是说 Player 类有一个 Jetpack 类,因此 Player 类应该有一个类型为 Jetpack 的成员变量,没有继承;这被称为包含。

可能的性能惩罚

在 PC 和 Mac 等平台上,使用继承和虚函数的性能惩罚是可以忽略不计的。然而,如果你正在为功能较弱的设备,如手持式游戏机、手机或嵌入式系统开发,这将是你需要考虑的事情。如果你的核心循环每秒多次调用虚函数,性能惩罚可能会累积。

将所有内容组合在一起

我们现在可以将所有这些知识结合起来,尽可能地将它们应用到我们的框架中,同时考虑到可重用性。我们有很多工作要做,所以让我们从我们的抽象基类 GameObject 开始。我们将移除所有与 SDL 相关的内容,以便在需要时可以在其他 SDL 项目中重用这个类。以下是我们的简化版 GameObject 抽象基类:

class GameObject
{
public:

  virtual void draw()=0;
  virtual void update()=0;
  virtual void clean()=0;

protected:

  GameObject(const LoaderParams* pParams) {}
  virtual ~GameObject() {}
};

已经创建了纯虚函数,迫使任何派生类也必须声明和实现它们。现在也没有了 load 函数;这样做的原因是我们不希望为每个新项目都创建一个新的 load 函数。我们可以相当肯定,在加载不同游戏的对象时,我们将需要不同的值。我们将采取的方法是创建一个新的类 LoaderParams 并将其传递到对象的构造函数中。

LoaderParams 是一个简单的类,它接受构造函数中的值并将它们设置为成员变量,然后可以访问这些变量来设置对象的初始值。虽然这看起来我们只是将参数从 load 函数移动到其他地方,但创建一个新的 LoaderParams 类比追踪和修改所有对象的 load 函数要容易得多。

因此,这是我们的 LoaderParams 类:

class LoaderParams
{
public:

  LoaderParams(int x, int y, int width, int height, std::string 
  textureID) : m_x(x), m_y(y), m_width(width), m_height(height), 
  m_textureID(textureID)
  {

  }

  int getX() const { return m_x; }
  int getY() const { return m_y; }
  int getWidth() const { return m_width; }
  int getHeight() const { return m_height; }
  std::string getTextureID() const { return m_textureID; }

private:

  int m_x;
  int m_y;

  int m_width;
  int m_height;

  std::string m_textureID;
};

这个类在创建对象时持有我们需要的任何值,其方式与我们的 load 函数曾经做的一样。

我们还从 draw 函数中移除了 SDL_Renderer 参数。我们将使 Game 类成为一个单例,例如 TextureManager。因此,我们可以将以下内容添加到我们的 Game 类中:

// create the public instance function
static Game* Instance()
{
  if(s_pInstance == 0)
  {
    s_pInstance = new Game();
    return s_pInstance;
  }

  return s_pInstance;
}
// make the constructor private
private:

  Game();
// create the s_pInstance member variable
  static Game* s_pInstance;

// create the typedef
  typedef Game TheGame;

Game.cpp 文件中,我们必须定义我们的静态实例:

Game* Game::s_pInstance = 0;

让我们在头文件中创建一个函数,该函数将返回我们的 SDL_Renderer 对象:

SDL_Renderer* getRenderer() const { return m_pRenderer; }

现在,由于Game是一个单例,我们将在main.cpp文件中以不同的方式使用它:

int main(int argc, char* argv[])
{
  std::cout << "game init attempt...\n";
  if(TheGame::Instance()->init("Chapter 1", 100, 100, 640, 480, 
  false))
  {
    std::cout << "game init success!\n";
    while(TheGame::Instance()->running())
    {
      TheGame::Instance()->handleEvents();
      TheGame::Instance()->update();
      TheGame::Instance()->render();

      SDL_Delay(10);
    }
  }
  else
  {
    std::cout << "game init failure - " << SDL_GetError() << "\n";
    return -1;
  }

  std::cout << "game closing...\n";
  TheGame::Instance()->clean();

  return 0;
}

现在我们想要从Game访问m_pRenderer值时,我们可以使用getRenderer函数。由于GameObject基本上是空的,我们如何实现我们最初所期望的代码共享?我们将从一个新的通用类GameObject派生出一个新类,并将其命名为SDLGameObject

class SDLGameObject : public GameObject
{
public:

  SDLGameObject(const LoaderParams* pParams);

  virtual void draw();
  virtual void update();
  virtual void clean();

protected:

  int m_x;
  int m_y;

  int m_width;
  int m_height;

  int m_currentRow;
  int m_currentFrame;

  std::string m_textureID;
};

使用这个类,我们可以创建可重用的 SDL 代码。首先,我们可以使用我们新的LoaderParams类来设置我们的成员变量:

SDLGameObject::SDLGameObject(const LoaderParams* pParams) : 
GameObject(pParams)
{
  m_x = pParams->getX();
  m_y = pParams->getY();
  m_width = pParams->getWidth();
  m_height = pParams->getHeight();
  m_textureID = pParams->getTextureID();

  m_currentRow = 1;
  m_currentFrame = 1;
}

我们还可以使用之前相同的draw函数,利用我们的单例Game类来获取我们想要的渲染器:

void SDLGameObject::draw()
{
  TextureManager::Instance()->drawFrame(m_textureID, m_x, m_y, 
  m_width, m_height, m_currentRow, m_currentFrame, 
  TheGame::Instance()->getRenderer());
}

PlayerEnemy现在可以继承自SDLGameObject

class Player : public SDLGameObject
{
public:

  Player(const LoaderParams* pParams);

  virtual void draw();
  virtual void update();
  virtual void clean();
};
// Enemy class
class Enemy : public SDLGameObject
{
public:

  Enemy(const LoaderParams* pParams);

  virtual void draw();
  virtual void update();
  virtual void clean();
};

Player类的定义可以如下所示(Enemy类非常相似):

Player::Player(const LoaderParams* pParams) : 
SDLGameObject(pParams)
{

}

void Player::draw()
{
  SDLGameObject::draw(); // we now use SDLGameObject
}

void Player::update()
{
  m_x -= 1;
  m_currentFrame = int(((SDL_GetTicks() / 100) % 6));
}

void Player::clean()
{
}

现在一切准备就绪,我们可以继续创建Game类中的对象,并观察一切的实际运行情况。这次我们不会将对象添加到头文件中;我们将使用一个快捷方式,在init函数中一行内构建我们的对象:

m_gameObjects.push_back(new Player(new LoaderParams(100, 100, 128, 82, "animate")));

m_gameObjects.push_back(new Enemy(new LoaderParams(300, 300, 128, 82, "animate")));

构建项目。我们现在已经准备好了一切,可以轻松地重用我们的GameGameObject类。

摘要

在本章中,我们涵盖了大量的复杂主题,这些概念和想法需要一些时间才能深入人心。我们介绍了如何轻松创建类,而无需重写大量类似的功能,以及继承的使用方法,它使我们能够在类似类之间共享代码。我们探讨了多态性以及它如何使对象管理变得更加简洁和可重用,而抽象基类则通过创建我们希望所有对象遵循的蓝图,将我们的继承知识提升到一个新的层次。最后,我们将所有新的知识融入到我们的框架中。

第四章:探索运动和输入处理

我们已经介绍了如何在屏幕上绘制以及如何处理对象,但到目前为止还没有任何东西在屏幕上移动。从用户那里获取输入并控制我们的游戏对象是游戏开发中最重要的话题之一。它可以决定游戏的感受和响应速度,并且是用户能够真正感受到的。在本章中,我们将涵盖以下内容:

  • 笛卡尔坐标系

  • 2D 向量

  • 创建变量以控制游戏对象的运动

  • 设置简单的运动系统

  • 设置来自游戏手柄、键盘和鼠标的输入处理

  • 创建固定帧率

为运动设置游戏对象

在上一章中,我们给我们的对象分配了 x 和 y 值,然后我们可以将这些值传递到我们的绘图代码中。我们使用的 x 和 y 值可以用笛卡尔坐标系来表示。

为运动设置游戏对象

上面的图显示了一个笛卡尔坐标系(Y 轴翻转)和两个坐标。将它们表示为 (x,y) 给我们位置 1 为 (3,3) 和位置 2 为 (7,4)。这些值可以用来表示 2D 空间中的位置。想象这个图是我们游戏窗口左上角的放大图像,每个网格方块代表我们游戏窗口的一个像素。考虑到这一点,我们可以看到如何使用这些值在正确的位置上绘制东西。我们现在需要一种方法来更新这些位置值,以便我们可以移动我们的对象。为此,我们将查看 2D 向量。

什么是向量?

向量可以被描述为一个具有方向和大小的实体。我们可以使用它们来表示游戏对象的各个方面,例如速度和加速度,这些可以用来创建运动。以速度为例,为了完全表示我们对象的运动,我们需要它们移动的方向以及它们在该方向上移动的量(或大小)。

什么是向量?

让我们定义一些关于我们将如何使用向量的内容:

  • 我们将向量表示为 v(x,y)

    我们可以使用以下方程式来获取向量的长度:

    什么是向量?

前面的图显示了向量 v1(3,-2),其长度将为 √(3²+(-2)²)。我们可以使用向量的 x 和 y 分量来表示我们的对象在 2D 空间中的位置。然后我们可以使用一些常见的向量运算来移动我们的对象。在我们继续这些运算之前,让我们在项目中创建一个名为 Vector2D 的向量类。然后我们可以查看我们需要的每个运算并将它们添加到类中。

#include<math.h>
class Vector2D
{
public:
  Vector2D(float x, float y): m_x(x), m_y(y) {}

  float getX() { return m_x; }
  float getY() { return m_y; }

  void setX(float x) { m_x = x; }
  void setY(float y) { m_y = y; }
private:

  float m_x;
  float m_y;
};

你可以看到,Vector2D 类目前非常简单。我们有 x 和 y 值以及获取和设置它们的方法。我们已经知道如何获取向量的长度,所以让我们创建一个用于此目的的函数:

float length() { return sqrt(m_x * m_x + m_y * m_y); }

一些常见运算

现在我们已经有了基本类,我们可以开始逐渐添加一些操作。

两个向量的加法

我们将要查看的第一个操作是两个向量的加法。为此,我们只需将每个向量的各个分量相加。

两个向量的加法

让我们使用重载的运算符来简化我们添加两个向量的操作:

Vector2D operator+(const Vector2D& v2) const
{
  return Vector2D(m_x + v2.m_x, m_y + v2.m_y);
}

friend Vector2D& operator+=(Vector2D& v1, const Vector2D& v2)
{
  v1.m_x += v2.m_x;
  v1.m_y += v2.m_y;

  return v1;
}

使用这些函数,我们可以使用标准的加法运算符将两个向量相加,例如:

Vector2D v1(10, 11);
Vector2D v2(35,25);
v1 += v2;
Vector2D v3 = v1 + v2;

乘以一个标量数

另一个操作是将向量乘以一个常规的标量数。对于这个操作,我们将向量的每个分量乘以标量数:

乘以一个标量数

我们可以再次使用重载的运算符来创建这些函数:

Vector2D operator*(float scalar)
{
  return Vector2D(m_x * scalar, m_y * scalar);
}

Vector2D& operator*=(float scalar)
{
  m_x *= scalar;
  m_y *= scalar;

  return *this;
}

两个向量的减法

减法与加法非常相似。

两个向量的减法

让我们创建一些函数来为我们完成这项工作:

Vector2D operator-(const Vector2D& v2) const
{ 
  return Vector2D(m_x - v2.m_x, m_y - v2.m_y); 
}

friend Vector2D& operator-=(Vector2D& v1, const Vector2D& v2)
{
  v1.m_x -= v2.m_x;
  v1.m_y -= v2.m_y;

  return v1;
}

除以一个标量数

到现在为止,我相信你已经注意到一个模式的产生,并且可以猜测除以标量向量将如何工作,但无论如何我们都会介绍它。

除以一个标量数

我们的函数:

Vector2D operator/(float scalar)    
{
  return Vector2D(m_x / scalar, m_y / scalar);
}

Vector2D& operator/=(float scalar)
{
  m_x /= scalar;
  m_y /= scalar;

  return *this;
}

规范化一个向量

我们还需要另一个非常重要的操作,那就是向量的规范化能力。规范化一个向量使其长度等于 1。长度(大小)为 1 的向量称为单位向量,用于表示仅方向,例如对象的面向方向。为了规范化一个向量,我们将其乘以其长度的倒数。

规范化一个向量

我们可以创建一个新的成员函数来规范化我们的向量:

void normalize()
{
  float l = length();
  if ( l > 0) // we never want to attempt to divide by 0
  {
    (*this) *= 1 / l;
  }
}

现在我们已经建立了一些基本函数,让我们开始在SDLGameObject类中使用这些向量。

添加 Vector2D 类

  1. 打开SDLGameObject.h,我们可以开始实现向量。首先,我们需要包含新的Vector2D类。

    #include "Vector2D.h"
    
  2. 我们还需要删除之前的m_xm_y值,并用Vector2D替换它们。

    Vector2D m_position;
    
  3. 现在,我们可以移动到SDLGameObject.cpp文件并更新构造函数。

    SDLGameObject::SDLGameObject(const LoaderParams* pParams) : GameObject(pParams), m_position(pParams->getX(), pParams->getY())
    {
      m_width = pParams->getWidth();
      m_height = pParams->getHeight();
      m_textureID = pParams->getTextureID();
    
      m_currentRow = 1;
      m_currentFrame = 1;
    }
    
  4. 我们现在使用成员初始化列表构建m_position向量,并且必须在我们的绘制函数中使用m_position向量。

    void SDLGameObject::draw()
    {
      TextureManager::Instance()->drawFrame(m_textureID, 
      (int)m_position.getX(), (int)m_position.getY(), m_width, 
      m_height, m_currentRow, m_currentFrame, 
      TheGame::Instance()->getRenderer());
    }
    
  5. 在测试之前,最后一件事是在Enemy::update函数中使用我们的向量。

    void Enemy::update()
    {
      m_position.setX(m_position.getX() + 1);
      m_position.setY(m_position.getY() + 1);
    }
    

这个函数将很快使用向量加法,但就目前而言,我们只是将1加到当前位置以获得我们之前已经有的相同行为。现在我们可以运行游戏,我们将看到我们已经实现了一个非常基本的向量系统。继续使用Vector2D函数进行尝试。

添加速度

我们之前必须单独设置对象的xy值,但现在我们的位置是一个向量,我们有能力向它添加一个新的向量以更新我们的移动。我们将这个向量称为速度向量,我们可以将其视为我们想要对象在特定方向上移动的量:

  1. 速度向量可以表示如下:添加速度

  2. 我们可以将这个添加到我们的 SDLGameObject 更新函数中,因为这是我们更新所有派生对象的方式。所以首先让我们创建速度成员变量。

    Vector2D m_velocity;
    
  3. 我们将在成员初始化列表中将它设置为 0,0。

    SDLGameObject::SDLGameObject(const LoaderParams* pParams) : GameObject(pParams), m_position(pParams->getX(), pParams->getY()), m_velocity(0,0)
    
  4. 接下来,我们将转向 SDLGameObject::update 函数。

    void SDLGameObject::update()
    {
      m_position += m_velocity;
    }
    
  5. 我们可以在我们的派生类中测试这一点。转到 Player.cpp 并添加以下内容:

    void Player::update()
    {
      m_currentFrame = int(((SDL_GetTicks() / 100) % 6));
    
      m_velocity.setX(1);
    
      SDLGameObject::update();
    }
    

我们将 m_velocity 的 x 值设置为 1。这意味着每次调用更新函数时,我们将向 m_position 的 x 值添加 1。现在我们可以运行这个来看到我们的对象使用新的速度向量移动。

添加加速度

并非所有对象都会以恒定的速度移动。一些游戏可能需要我们通过加速度逐渐增加对象的速率。汽车或宇宙飞船是很好的例子。没有人会期望这些对象瞬间达到最高速度。我们需要一个新的加速度向量,所以让我们将其添加到我们的 SDLGameObject.h 文件中。

Vector2D m_acceleration;

然后我们可以将其添加到我们的 update 函数中。

void SDLGameObject::update()
{
  m_velocity += m_acceleration;
  m_position += m_velocity;
}

现在修改我们的 Player::update 函数,使其设置加速度而不是速度。

void Player::update()
{
  m_currentFrame = int(((SDL_GetTicks() / 100) % 6));

  m_acceleration.setX(1);

  SDLGameObject::update();
}

运行我们的游戏后,你会看到对象逐渐加速。

创建固定每秒帧数

在本书的早期,我们添加了一个 SDL_Delay 函数来减慢一切速度并确保我们的对象不会移动得太快。现在我们将在此基础上扩展,使我们的游戏以固定帧率运行。固定每秒帧数(FPS)并不一定总是好的选择,尤其是当你的游戏包含更高级的物理时。当你从本书中继续前进并开始开发自己的游戏时,这一点值得记住。然而,对于本书中我们将要努力实现的简单 2D 游戏,固定 FPS 将是合适的。

话虽如此,让我们继续看代码:

  1. 打开 main.cpp 文件,我们将创建一些常量变量。

    const int FPS = 60;
    const int DELAY_TIME = 1000.0f / FPS;
    
    int main()
    {
    
  2. 在这里,我们定义了我们的游戏希望以多少帧每秒运行。每秒 60 帧的帧率是一个好的起点,因为这基本上与大多数现代显示器和电视的刷新率同步。然后我们可以将这个除以一秒钟中的毫秒数,得到我们在循环之间延迟游戏所需的时间,以保持恒定的帧率。我们还需要在主函数的顶部添加另外两个变量;这些将用于我们的计算。

    int main()
    {
        Uint32 frameStart, frameTime;
    
  3. 我们现在可以在主循环中实现我们的固定帧率。

    while(TheGame::Instance()->running())
    {
      frameStart = SDL_GetTicks();
    
      TheGame::Instance()->handleEvents();
      TheGame::Instance()->update();
      TheGame::Instance()->render();
    
      frameTime = SDL_GetTicks() - frameStart;
    
      if(frameTime< DELAY_TIME)
      {
        SDL_Delay((int)(DELAY_TIME - frameTime));
      }
    }
    

首先,我们在循环开始时获取时间并将其存储在frameStart中。为此,我们使用SDL_GetTicks,它返回自我们调用SDL_Init以来的毫秒数。然后我们运行我们的游戏循环,并通过从帧开始的时间减去当前时间来存储运行所需的时间。如果它小于我们想要的帧所需的时间,我们就调用SDL_Delay,使我们的循环等待我们想要的时间,并减去循环已经完成所需的时间。

输入处理

我们现在已经根据速度和加速度使对象移动,因此接下来我们必须引入一种方法来通过用户输入控制这种移动。SDL 支持多种不同类型用户界面设备,包括游戏手柄、游戏控制器、鼠标和键盘,这些内容将在本章中介绍,以及如何将它们添加到我们的框架实现中。

创建我们的输入处理类

我们将创建一个类来处理所有设备输入,无论它来自控制器、键盘还是鼠标。让我们从一个基本的类开始,并在此基础上构建。首先,我们需要一个头文件,InputHandler.h

#include "SDL.h"
class InputHandler
{
public:
  static InputHandler* Instance()
  {
    if(s_pInstance == 0)
    {
      s_pInstance = new InputHandler();
    }

    return s_pInstance;
  }

  void update();
  void clean();

private:

  InputHandler();
  ~InputHandler() {}

  static InputHandler* s_pInstance;
};
typedef InputHandler TheInputHandler;

这是我们的单例InputHandler。到目前为止,我们有一个update函数,它将轮询事件并相应地更新我们的InputHandler,还有一个干净的函数,它将清除我们已初始化的任何设备。随着我们开始添加设备支持,我们将进一步完善这个功能。

处理游戏手柄/游戏控制器输入

目前市面上有大量的游戏手柄和游戏控制器,它们通常具有不同数量的按钮和模拟摇杆等不同功能。当 PC 游戏开发者试图支持所有这些不同的游戏控制器时,他们有很多事情要做。SDL 对游戏手柄和游戏控制器的支持很好,因此我们应该能够设计出一个系统,该系统不会很难扩展以支持不同的游戏控制器。

SDL 游戏手柄事件

SDL 中有几种不同的结构用于处理游戏手柄事件。下表列出了每一个及其用途。

SDL 游戏手柄事件 用途
SDL_JoyAxisEvent 轴运动信息
SDL_JoyButtonEvent 按钮按下和释放信息
SDL_JoyBallEvent 轨迹球事件运动信息
SDL_JoyHatEvent 游戏手柄帽子位置变化

我们最感兴趣的事件是轴运动和按钮按下事件。这些事件中的每一个都有一个枚举类型,我们可以在事件循环中检查以确保我们只处理我们想要处理的事件。下表显示了上述每个事件的类型值。

SDL 游戏手柄事件 类型值
SDL_JoyAxisEvent SDL_JOYAXISMOTION
SDL_JoyButtonEvent SDL_JOYBUTTONDOWNSDL_JOYBUTTONUP
SDL_JoyBallEvent SDL_JOYBALLMOTION
SDL_JoyHatEvent SDL_JOYHATMOTION

注意

使用 Windows 中的Joystick Control Panel属性或 OSX 上的JoystickShow来查找您在 SDL 中需要使用的特定按钮的按钮编号是个好主意。这些应用程序对于了解您的摇杆/游戏手柄信息,以便正确支持它们非常有价值。

我们将放置的代码将假设我们正在使用微软 Xbox 360 控制器(可以在 PC 或 OSX 上使用),因为这是 PC 游戏中最受欢迎的控制器之一。其他控制器,如 PS3 控制器,按钮和轴可能具有不同的值。Xbox 360 控制器由以下部分组成:

  • 两个模拟摇杆

  • 模拟摇杆按键

  • 开始和选择按钮

  • 四个方向按钮:A、B、X 和 Y

  • 四个扳机:两个数字和两个模拟

  • 一个数字方向垫

初始化摇杆

  1. 要在 SDL 中使用游戏手柄和摇杆,我们首先需要初始化它们。我们将在InputHandler类中添加一个新的公共函数。这个函数将找出 SDL 可以访问多少个摇杆,然后初始化它们。

    void initialiseJoysticks();
    bool joysticksInitialised() { 
    return m_bJoysticksInitialised; }
    
  2. 我们还将声明一些我们将需要的私有成员变量。

    std::vector<SDL_Joystick*> m_joysticks;
    bool m_bJoysticksInitialised;
    
  3. SDL_Joystick*是指向我们将要初始化的摇杆的指针。当我们使用摇杆时,我们实际上不需要这些指针,但我们在完成后需要关闭它们,因此保留一个列表以供以后访问是有帮助的。我们现在将定义我们的initialiseJoysticks函数,然后通过它进行操作。

    void InputHandler::initialiseJoysticks()
    {
      if(SDL_WasInit(SDL_INIT_JOYSTICK) == 0)
      {
        SDL_InitSubSystem(SDL_INIT_JOYSTICK);
      }
    
      if(SDL_NumJoysticks() > 0)
      {
        for(int i = 0; i < SDL_NumJoysticks(); i++)
        {
          SDL_Joystick* joy = SDL_JoystickOpen(i);
          if(SDL_JoystickOpened(i) == 1)
          {
            m_joysticks.push_back(joy);
          }
          else
          {
            std::cout << SDL_GetError();
          }
        }
        SDL_JoystickEventState(SDL_ENABLE);
        m_bJoysticksInitialised = true;
    
        std::cout << "Initialised "<< m_joysticks.size() << " 
        joystick(s)";
      }
      else
      {
        m_bJoysticksInitialised = false;
      }
    }
    
  4. 让我们逐行分析。首先,我们使用SDL_WasInit检查摇杆子系统是否已初始化。如果没有初始化,我们使用SDL_InitSubSystem来初始化它。

    if(SDL_WasInit(SDL_INIT_JOYSTICK) == 0)
    {
      SDL_InitSubSystem(SDL_INIT_JOYSTICK);
    }
    
  5. 接下来是打开每个可用的摇杆。在我们尝试打开对象之前,我们使用SDL_NumJoysticks来确保有一些摇杆可用。然后我们可以遍历摇杆的数量,依次使用SDL_JoystickOpen打开它们。然后它们可以被推入我们的数组以便稍后关闭。

    if(SDL_NumJoysticks() > 0)
    {
      for(int i = 0; i < SDL_NumJoysticks(); i++)
      {
        SDL_Joystick* joy = SDL_JoystickOpen(i);
        if(SDL_JoystickOpened(i))
        {
          m_joysticks.push_back(joy);
        }
        else
        {
          std::cout << SDL_GetError();
        }
      }
    }
    
  6. 最后,我们告诉 SDL 开始监听摇杆事件,通过启用SDL_JoystickEventState。我们还根据初始化的结果设置我们的m_bJoysticksEnabled成员变量。

    SDL_JoystickEventState(SDL_ENABLE);
    m_bJoysticksInitialised = true;
    
    std::cout << "Initialised " << m_joysticks.size() << " joystick(s)";
    
    }
    else
    {
      m_bJoysticksInitialised = false;
    }
    
  7. 因此,我们现在有了一种初始化我们的摇杆的方法。我们还需要定义另外两个函数,即updateclean函数。clean函数将遍历我们的SDL_Joystick*数组,并在每次迭代中调用SDL_JoystickClose

    void InputHandler::clean()
    {
      if(m_bJoysticksInitialised)
      {
        for(unsigned int i = 0; i < SDL_NumJoysticks(); i++)
        {
          SDL_JoystickClose(m_joysticks[i]);
        }
      }
    }
    
  8. update函数将在主游戏循环的每一帧中被调用以更新事件状态。不过,目前它将简单地监听退出事件并调用游戏的quit函数(这个函数简单地调用SDL_Quit())。

    void InputHandler::update()
    {
      SDL_Event event;
      while(SDL_PollEvent(&event))
      {
        if(event.type == SDL_QUIT)
        {
          TheGame::Instance()->quit();
        }
      }
    }
    
  9. 现在我们将在我们的Game类函数中使用这个InputHandler。首先,我们在Game::init函数中调用initialiseJoysticks

    TheInputHandler::Instance()->initialiseJoysticks();
    

    我们将在Game::handleEvents函数中更新它,清除之前的内容:

    void Game::handleEvents()
    {
      TheInputHandler::Instance()->update();
    }
    
  10. 我们还可以将clean函数添加到我们的Game::clean函数中。

    TheInputHandler::Instance()->clean();
    
  11. 现在我们可以插入一个手柄或操纵杆并运行构建。如果一切按计划进行,我们应该得到以下输出,其中x是您插入的操纵杆数量:

    Initialised x joystick(s)
    
  12. 理想情况下,我们希望能够轻松地使用一个或多个控制器,而无需更改我们的代码。我们已经有了一种方法来加载和打开已插入的控制器,但我们需要知道哪个事件对应哪个控制器;我们通过在事件中存储的一些信息来完成这项工作。每个操纵杆事件都将有一个存储在其内的which变量。使用这个变量将允许我们找出事件来自哪个操纵杆。

    if(event.type == SDL_JOYAXISMOTION) // check the type value
    {
      int whichOne = event.jaxis.which; // get which controller
    

监听和处理轴移动

我们不会以模拟的方式处理模拟摇杆。相反,它们将被处理为数字信息,即它们要么开启要么关闭。我们的控制器有四个运动轴,两个用于左模拟摇杆,两个用于右模拟摇杆。

我们将对我们的控制器做出以下假设(您可以使用外部应用程序来找出您控制器的具体值):

  • 一号摇杆的左右移动是轴 0

  • 一号摇杆的上下移动是轴 1

  • 二号摇杆的左右移动是轴 3

  • 二号摇杆的上下移动是轴 4

Xbox 360 控制器使用轴 2 和 5 作为模拟触发器。为了处理具有多个轴的多台控制器,我们将创建一个Vector2D*对的向量,每个摇杆一个。

std::vector<std::pair<Vector2D*, Vector2D*>> m_joystickValues;

我们使用Vector2D值来设置摇杆是否向上、向下、向左或向右移动。现在当我们初始化我们的操纵杆时,我们需要在m_joystickValues数组中创建一个Vector2D*对。

for(int i = 0; i < SDL_NumJoysticks(); i++)
{
  SDL_Joystick* joy = SDL_JoystickOpen(i);
  if(SDL_JoystickOpened(i))
  {
    m_joysticks.push_back(joy);
    m_joystickValues.push_back(std::make_pair(new 
    Vector2D(0,0),new Vector2D(0,0))); // add our pair
  }
  else
  {
    std::cout << SDL_GetError();
  }
}

我们需要一种方法来从这对数组的值中获取我们需要的值;我们将在InputHandler类中声明两个新函数:

int xvalue(int joy, int stick);
int yvalue(int joy, int stick);

joy参数是我们想要使用的操纵杆的标识符(ID),摇杆为 1 表示左摇杆,为 2 表示右摇杆。让我们定义这些函数:

int InputHandler::xvalue(int joy, int stick);
{
  if(m_joystickValues.size() > 0)
  {
    if(stick == 1)
    {
      return m_joystickValues[joy].first->getX();
    }
    else if(stick == 2)
    {
      return m_joystickValues[joy].second->getX();
    }
  }
  return 0;
}

int InputHandler::yvalue(int joy, int stick)
{
  if(m_joystickValues.size() > 0)
  {
    if(stick == 1)
    {
      return m_joystickValues[joy].first->getY();
    }
    else if(stick == 2)
    {
      return m_joystickValues[joy].second->getY();
    }
  }
  return 0;
}

根据传递给每个函数的参数,我们获取 x 或 y 值。firstsecond值是数组中这对的第一个或第二个对象,其中joy是数组的索引。我们现在可以在事件循环中相应地设置这些值。

SDL_Event event;
while(SDL_PollEvent(&event))
{
  if(event.type == SDL_QUIT)
  {
    TheGame::Instance()->quit();
  }

  if(event.type == SDL_JOYAXISMOTION)
  {
    int whichOne = event.jaxis.which;

    // left stick move left or right
    if(event.jaxis.axis == 0)
    {
      if (event.jaxis.value > m_joystickDeadZone)
      {
        m_joystickValues[whichOne].first->setX(1);
      }
      else if(event.jaxis.value < -m_joystickDeadZone)
      {
        m_joystickValues[whichOne].first->setX(-1);
      }
      else
      {
        m_joystickValues[whichOne].first->setX(0);
      }
    }

    // left stick move up or down
    if(event.jaxis.axis == 1)
    {
      if (event.jaxis.value > m_joystickDeadZone)
      {
        m_joystickValues[whichOne].first->setY(1);
      }
      else if(event.jaxis.value < -m_joystickDeadZone)
      {
        m_joystickValues[whichOne].first->setY(-1);
      }
      else
      {
        m_joystickValues[whichOne].first->setY(0);
      }
    }

    // right stick move left or right
    if(event.jaxis.axis == 3)
    {
      if (event.jaxis.value > m_joystickDeadZone)
      {
        m_joystickValues[whichOne].second->setX(1);
      }
      else if(event.jaxis.value < -m_joystickDeadZone)
      {
        m_joystickValues[whichOne].second->setX(-1);
      }
      else
      {
        m_joystickValues[whichOne].second->setX(0);
      }
    }

    // right stick move up or down
    if(event.jaxis.axis == 4)
    {
      if (event.jaxis.value > m_joystickDeadZone)
      {
        m_joystickValues[whichOne].second->setY(1);
      }
      else if(event.jaxis.value < -m_joystickDeadZone)
      {
        m_joystickValues[whichOne].second->setY(-1);
      }
      else
      {
        m_joystickValues[whichOne].second->setY(0);
      }
    }
  }
}

这是一个很大的函数!然而,它相对简单。我们首先检查SDL_JOYAXISMOTION事件,然后使用which值找出事件来自哪个控制器。

int whichOne = event.jaxis.which;

从这里我们可以知道事件来自哪个操纵杆,并可以相应地在数组中设置一个值;例如:

m_joystickValues[whichOne]

首先,我们检查事件来自哪个轴:

if(event.jaxis.axis == 0) // …1,3,4

如果轴是 0 或 1,则是左摇杆,如果是 3 或 4,则是右摇杆。我们使用这对的firstsecond来设置左摇杆或右摇杆。您可能也注意到了m_joystickDeadZone变量。我们使用这个变量来考虑控制器的灵敏度。我们可以在InputHandler头文件中将此作为常量变量设置:

const int m_joystickDeadZone = 10000;

10000这个值可能看起来对于静止的摇杆来说很大,但控制器的灵敏度可能非常高,因此需要这么大的值。根据您自己的控制器相应地更改此值。

为了巩固我们在这里所做的工作,让我们仔细看看一个场景。

// left stick move left or right
{
  if (event.jaxis.value > m_joystickDeadZone)
  {
    m_joystickValues[whichOne].first->setX(1);
  }
  else if(event.jaxis.value < -m_joystickDeadZone)
  {
    m_joystickValues[whichOne].first->setX(-1);
  }
  else
  {
    m_joystickValues[whichOne].first->setX(0);
  }
}

如果我们到达第二个 if 语句,我们知道我们正在处理左摇杆的左右移动事件,因为轴是 0。我们已经设置了事件来自哪个控制器,并将whichOne调整为正确的值。我们还想让这对中的first是左摇杆。所以如果轴是 0,我们使用数组的first对象并设置其 x 值,因为我们正在处理一个 x 移动事件。那么为什么我们将值设置为 1 或-1 呢?我们将通过开始移动我们的Player对象来回答这个问题。

打开Player.h,我们可以开始使用我们的InputHandler来获取事件。首先,我们将声明一个新的私有函数:

private:

void handleInput();

现在在我们的Player.cpp文件中,我们可以定义这个函数,使其与InputHandler一起工作。

void Player::handleInput()
{
  if(TheInputHandler::Instance()->joysticksInitialised())
  {
    if(TheInputHandler::Instance()->xvalue(0, 1) > 0 || 
    TheInputHandler::Instance()->xvalue(0, 1) < 0)
    {
      m_velocity.setX(1 * TheInputHandler::Instance()->xvalue(0, 
      1));
    }

    if(TheInputHandler::Instance()->yvalue(0, 1) > 0 || 
    TheInputHandler::Instance()->yvalue(0, 1) < 0)
    {
      m_velocity.setY(1 * TheInputHandler::Instance()->yvalue(0, 
      1));
    }

    if(TheInputHandler::Instance()->xvalue(0, 2) > 0 || 
    TheInputHandler::Instance()->xvalue(0, 2) < 0)
    {
      m_velocity.setX(1 * TheInputHandler::Instance()->xvalue(0, 
      2));
    }

    if(TheInputHandler::Instance()->yvalue(0, 2) > 0 || 
    TheInputHandler::Instance()->yvalue(0, 2) < 0)
    {
      m_velocity.setY(1 * TheInputHandler::Instance()->yvalue(0, 
      2));
    }

  }
}

然后,我们可以在Player::update函数中调用这个函数。

void Player::update()
{
  m_velocity.setX(0);
  m_velocity.setY(0);

  handleInput(); // add our function

  m_currentFrame = int(((SDL_GetTicks() / 100) % 6));

  SDLGameObject::update();
}

一切都已经就绪,但首先让我们看看我们是如何设置我们的移动方式的。

if(TheInputHandler::Instance()->xvalue(0, 1) > 0 || TheInputHandler::Instance()->xvalue(0, 1) < 0)
{
  m_velocity.setX(1 * TheInputHandler::Instance()->xvalue(0, 1));
}

在这里,我们首先检查左摇杆的xvalue是否大于 0(它已经移动了)。如果是这样,我们将我们的Player x 速度设置为想要的速率乘以左摇杆的xvalue,我们知道这是 1 或-1。正如你所知道的那样,一个正数乘以一个负数的结果是一个负数,所以将我们想要的速率乘以-1 意味着我们将我们的 x 速度设置为负值(向左移动)。我们对其他摇杆和 y 值也做同样的处理。构建项目并使用游戏手柄开始移动你的Player对象。你也可以插入另一个控制器,并更新Enemy类以使用它。

处理摇杆按钮输入

我们接下来的步骤是实现处理控制器按钮输入的方法。实际上,这比处理轴要简单得多。我们需要知道每个按钮的当前状态,以便我们可以检查是否有一个被按下或释放。为此,我们将声明一个布尔值数组,因此每个控制器(数组的第一个索引)将有一个布尔值数组,每个控制器上的一个按钮对应一个布尔值。

std::vector<std::vector<bool>> m_buttonStates;

我们可以通过一个查找正确摇杆上正确按钮的功能来获取当前按钮状态。

bool getButtonState(int joy, int buttonNumber)
{
  return m_buttonStates[joy][buttonNumber];
}

第一个参数是数组的索引(摇杆 ID),第二个是按钮的索引。接下来,我们必须为每个控制器及其每个按钮初始化这个数组。我们将在initialiseJoysticks函数中完成这项工作。

for(int i = 0; i < SDL_NumJoysticks(); i++)
{
  SDL_Joystick* joy = SDL_JoystickOpen(i);
  if(SDL_JoystickOpened(i))
  {
    m_joysticks.push_back(joy);
    m_joystickValues.push_back(std::make_pair(new 
    Vector2D(0,0),new Vector2D(0,0)));

    std::vector<bool> tempButtons;

    for(int j = 0; j < SDL_JoystickNumButtons(joy); j++)
    {
      tempButtons.push_back(false);
    }

    m_buttonStates.push_back(tempButtons);
  }
}

我们使用SDL_JoystickNumButtons来获取每个摇杆的按钮数量。然后我们将每个按钮的值推入一个数组。我们开始时推入false,因为没有按钮被按下。然后这个数组被推入our m_buttonStates数组,以便与getButtonState函数一起使用。现在我们必须监听按钮事件并相应地设置数组中的值。

if(event.type == SDL_JOYBUTTONDOWN)  
{
  int whichOne = event.jaxis.which;

  m_buttonStates[whichOne][event.jbutton.button] = true;
}

if(event.type == SDL_JOYBUTTONUP)
{
  int whichOne = event.jaxis.which;

  m_buttonStates[whichOne][event.jbutton.button] = false;
}

当按钮被按下(SDL_JOYBUTTONDOWN)时,我们可以知道哪个控制器被按下,并使用这个作为m_buttonStates数组的索引。然后我们使用按钮号(event.jbutton.button)来设置正确的按钮为true;当按钮被释放(SDL_JOYBUTTONUP)时,也是同样的处理。这就是按钮处理的主要内容。让我们在我们的Player类中测试一下。

if(TheInputHandler::Instance()->getButtonState(0, 3))
{
  m_velocity.setX(1);
}

在这里,我们检查按钮 3 是否被按下(在 Xbox 控制器上为黄色或 Y 键),如果按下,则设置我们的速度。这就是本书中关于摇杆的所有内容。你会意识到支持多个摇杆非常复杂,需要大量调整以确保每个摇杆都能正确处理。然而,有方法可以开始支持多个摇杆;例如,通过配置文件或甚至通过使用继承来处理不同类型的摇杆。

处理鼠标事件

与摇杆不同,我们不需要初始化鼠标。我们还可以安全地假设一次只会插入一个鼠标,因此我们不需要处理多个鼠标设备。我们可以从查看 SDL 提供的可用鼠标事件开始:

SDL 鼠标事件 目的
SDL_MouseButtonEvent 鼠标上的按钮已被按下或释放
SDL_MouseMotionEvent 鼠标已移动
SDL_MouseWheelEvent 鼠标滚轮已移动

就像摇杆事件一样,每个鼠标事件都有一个类型值;以下表格显示了这些值:

SDL 鼠标事件 类型值
SDL_MouseButtonEvent SDL_MOUSEBUTTONDOWNSDL_MOUSEBUTTONUP
SDL_MouseMotionEvent SDL_MOUSEMOTION
SDL_MouseWheelEvent SDL_MOUSEWHEEL

我们不会实现任何鼠标滚轮移动事件,因为大多数游戏都不会使用它们。

使用鼠标按钮事件

实现鼠标按钮事件与摇杆事件一样简单,甚至更简单,因为我们只有三个按钮可供选择:左键、右键和中键。SDL 将这些分别编号为 0(左键)、1(中键)和 2(右键)。在我们的InputHandler头文件中,让我们声明一个与摇杆按钮相似的数组,但这次是一个一维数组,因为我们不会处理多个鼠标设备。

std::vector<bool> m_mouseButtonStates;

然后在我们的InputHandler构造函数中,我们可以将我们的三个鼠标按钮状态(默认为false)推入数组:

for(int i = 0; i < 3; i++)
{
  m_mouseButtonStates.push_back(false);
}

在我们的头文件中,让我们创建一个enum属性来帮助我们处理鼠标按钮的值。把这个属性放在类上方,这样其他包含我们的InputHandler.h头文件的文件也可以使用它。

enum mouse_buttons
{
    LEFT = 0,
    MIDDLE = 1,
    RIGHT = 2
};

现在,让我们在我们的事件循环中处理鼠标事件:

if(event.type == SDL_MOUSEBUTTONDOWN)
{
  if(event.button.button == SDL_BUTTON_LEFT)
  {
    m_mouseButtonStates[LEFT] = true;
  }

  if(event.button.button == SDL_BUTTON_MIDDLE)
  {
    m_mouseButtonStates[MIDDLE] = true;
  }

  if(event.button.button == SDL_BUTTON_RIGHT)
  {
    m_mouseButtonStates[RIGHT] = true;
  }
}

if(event.type == SDL_MOUSEBUTTONUP)
{
  if(event.button.button == SDL_BUTTON_LEFT)
  {
    m_mouseButtonStates[LEFT] = false;
  }

  if(event.button.button == SDL_BUTTON_MIDDLE)
  {
    m_mouseButtonStates[MIDDLE] = false;
  }

  if(event.button.button == SDL_BUTTON_RIGHT)
  {
    m_mouseButtonStates[RIGHT] = false;
  }
}

我们还需要一个函数来访问我们的鼠标按钮状态。让我们在InputHandler头文件中添加这个公共函数:

bool getMouseButtonState(int buttonNumber)
{
  return m_mouseButtonStates[buttonNumber];
}

这是我们需要的所有关于鼠标按钮事件的内容。现在我们可以在Player类中测试它。

if(TheInputHandler::Instance()->getMouseButtonState(LEFT))
{
  m_velocity.setX(1);
}

处理鼠标移动事件

鼠标移动事件非常重要,尤其是在大型 3D 第一人称或第三人称动作游戏中。对于我们的 2D 游戏,我们可能想让我们的角色跟随鼠标作为控制对象的一种方式,或者我们可能想让对象移动到鼠标点击的位置(例如,在策略游戏中)。我们甚至可能只想知道鼠标点击的位置,以便我们可以用它来制作菜单。幸运的是,鼠标移动事件相对简单。我们首先在头文件中创建一个私有的Vector2D*,用作鼠标的位置变量:

Vector2D* m_mousePosition;

接下来,我们需要一个公共访问器来获取这个:

Vector2D* getMousePosition()
{
  return m_mousePosition;
}

现在我们可以在我们的事件循环中处理这个了:

if(event.type == SDL_MOUSEMOTION)
{
  m_mousePosition->setX(event.motion.x);
  m_mousePosition->setY(event.motion.y);
}

这就是处理鼠标移动所需的所有内容。所以,让我们让我们的Player函数跟随鼠标位置来测试这个功能:

Vector2D* vec = TheInputHandler::Instance()->getMousePosition();

m_velocity = (*vec - m_position) / 100;

在这里,我们将速度设置为从玩家当前位置到鼠标位置的向量。你可以通过从当前位置减去期望的位置来获取这个向量;我们已经有了一个向量减法重载运算符,所以这对我们来说很容易。我们还把这个向量除以 100;这只是为了稍微降低速度,这样我们就可以看到它跟随鼠标而不是仅仅停留在鼠标位置。如果你想使你的对象精确跟随鼠标,请移除/

实现键盘输入

我们最终的输入方法是键盘输入。我们不需要处理任何移动事件,我们只想知道每个按钮的状态。我们不会在这里声明一个数组,因为 SDL 有一个内置函数会给我们一个包含每个键状态的数组;1 表示按下,0 表示未按下。

SDL_GetKeyboardState(int* numkeys)

numkeys参数将返回键盘上可用的键的数量(keystate数组的长度)。所以,在我们的InputHandler头文件中,我们可以声明一个指向SDL_GetKeyboardState返回的数组的指针。

Uint8* m_keystate;

当我们更新事件处理器时,我们也可以更新键的状态;把这个放在事件循环的顶部。

m_keystates = SDL_GetKeyboardState(0);

我们现在需要创建一个简单的函数来检查一个键是否被按下。

bool InputHandler::isKeyDown(SDL_Scancode key)
{
  if(m_keystates != 0)
  {
    if(m_keystates[key] == 1)
    {
      return true;
    }
    else
    {
      return false;
    }
  }

  return false;
}

这个函数接受SDL_SCANCODE作为参数。SDL_SCANCODE的所有值可以在 SDL 文档中找到,具体请参阅wiki.libsdl.org/moin.cgi

我们可以在Player类中测试这些键。我们将使用箭头键来移动我们的玩家。

if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_RIGHT))
{
  m_velocity.setX(2);
}

if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_LEFT))
{
  m_velocity.setX(-2);
}

if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_UP))
{
  m_velocity.setY(-2);
}

if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_DOWN))
{
  m_velocity.setY(2);
}

我们现在已经实现了键处理。尽可能多地测试键,并查找你最可能想要使用的键的SDL_Scancode

总结

我们现在已经实现了将要处理的全部设备,但此刻我们的事件循环有些混乱。我们需要将其分解成更易于管理的块。我们将通过使用事件类型的 switch 语句和一些私有函数,在我们的InputHandler中实现这一点。首先,让我们在头文件中声明我们的函数:

// private functions to handle different event types

// handle keyboard events
void onKeyDown();
void onKeyUp();

// handle mouse events
void onMouseMove(SDL_Event& event);
void onMouseButtonDown(SDL_Event& event);
void onMouseButtonUp(SDL_Event& event);

// handle joysticks events
void onJoystickAxisMove(SDL_Event& event);
void onJoystickButtonDown(SDL_Event& event);
void onJoystickButtonUp(SDL_Event& event);

我们将事件循环中的事件传递给每个函数(除了键以外),这样我们就可以相应地处理它们。现在我们需要在事件循环中创建我们的 switch 语句。

void InputHandler::update()
{
  SDL_Event event;
  while(SDL_PollEvent(&event))
  {
    switch (event.type)
    {
    case SDL_QUIT:
      TheGame::Instance()->quit();
    break;

    case SDL_JOYAXISMOTION:
      onJoystickAxisMove(event);
    break;

    case SDL_JOYBUTTONDOWN:
      onJoystickButtonDown(event);
    break;

    case SDL_JOYBUTTONUP:
      onJoystickButtonUp(event);
    break;

    case SDL_MOUSEMOTION:
      onMouseMove(event);
    break;

    case SDL_MOUSEBUTTONDOWN:
      onMouseButtonDown(event);
    break;

    case SDL_MOUSEBUTTONUP:
      onMouseButtonUp(event);
    break;

    case SDL_KEYDOWN:
      onKeyDown();
    break;

    case SDL_KEYUP:
      onKeyUp();
    break;

    default:
    break;
    }
  }
}

如您所见,我们现在分解了事件循环,根据事件类型调用相关的函数。现在我们可以将所有之前的工作拆分到这些函数中;例如,我们可以将所有鼠标按钮按下处理代码放入onMouseButtonDown函数中。

void InputHandler::onMouseButtonDown(SDL_Event& event)
{
  if(event.button.button == SDL_BUTTON_LEFT)
  {
    m_mouseButtonStates[LEFT] = true;
  }

  if(event.button.button == SDL_BUTTON_MIDDLE)
  {
    m_mouseButtonStates[MIDDLE] = true;
  }

  if(event.button.button == SDL_BUTTON_RIGHT)
  {
    m_mouseButtonStates[RIGHT] = true;
  }
}

InputHandler的其余代码可以在源代码下载中找到。

摘要

在本章中,我们介绍了一些复杂的内容。我们查看了一小部分向量数学以及我们如何使用它来移动游戏对象。我们还介绍了多个摇杆和轴的初始化和使用,以及鼠标和键盘的使用。最后,我们以一种整洁的方式处理了所有事件。

第五章:处理游戏状态

当我们第一次启动游戏时,我们期望看到显示出版商和开发者的任何品牌信息的启动画面,然后是加载画面,因为游戏正在进行初始设置。之后,我们通常会看到一个菜单屏幕;在这里,我们可以更改设置并开始游戏。开始游戏会带我们进入另一个加载画面,可能还会跟随一个剪辑场景,最终,我们就在游戏中了。当我们处于游戏状态时,我们可以暂停我们的游戏(允许我们更改任何设置),退出游戏,重新开始关卡,等等。如果我们未能通过关卡,我们会看到动画或游戏结束画面,具体取决于游戏的设置。所有这些不同的游戏部分都被称为游戏状态。我们使这些状态之间的转换尽可能容易是非常重要的。

在本章中,我们将涵盖:

  • 处理状态的两个不同方法,从一个非常简单的实现开始,逐步构建我们的框架实现

  • 实现有限状态机(FSM

  • 将状态添加到整体框架中

切换状态的一种简单方法

处理状态的最简单方法之一是在游戏的初始化阶段加载我们想要的所有东西,但只绘制和更新每个状态特定的对象。让我们看看这如何工作。首先,我们可以定义我们将要使用的一组状态:

enum game_states
{
  MENU = 0,
  PLAY = 1,
  GAMEOVER = 2
};

然后,我们可以使用Game::init函数来创建对象:

// create menu objects
m_pMenuObj1 = new MenuObject();
m_pMenuObj1 = new MenuObject();

// create play objects
m_pPlayer = new Player();
m_pEnemy = new Enemy();

// create game over objects…

然后,设置我们的初始状态:

m_currentGameState = MENU;

接下来,我们可以将我们的update函数修改为仅在特定状态下使用我们想要的东西:

void Game::update()
{
  switch(m_currentGameState)
  {
    case MENU:
      m_menuObj1->update();
      m_menuObj2->update();
      break;

    case PLAY:
      m_pPlayer->update();
      m_pEnemy->update();

    case GAMEOVER:
      // do game over stuff…
  }
}

render函数会做类似的事情。当然,这些函数仍然可以循环遍历数组并使用多态,就像我们最初所做的那样,但基于状态。改变状态就像改变m_currentGameState变量的值一样简单。

如果你发现这个方法有问题,那么你开始以面向对象的方式思考是非常令人鼓舞的。这种更新状态的方式维护起来会相当困难,出错的可能性也相当大。有太多需要更新和更改的区域,这使得它成为任何比简单街机游戏更大的游戏的可行解决方案。

实现有限状态机

我们真正需要的是在game类之外定义我们的状态,并且让状态本身负责加载、渲染和更新所需的内容。为此,我们可以创建所谓的有限状态机(FSM)。我们将使用的 FSM 定义是一个可以存在于有限数量的状态中的机器,一次只能存在于一个状态(称为当前状态),并且可以从一个状态转换到另一个状态(称为转换)。

游戏状态的基础类

让我们通过创建所有状态的基类来开始我们的实现;创建一个名为GameState.h的头文件:

#include<string>
class GameState
{
public:
  virtual void update() = 0;
  virtual void render() = 0;

  virtual bool onEnter() = 0;
  virtual bool onExit() = 0;

  virtual std::string getStateID() const = 0;
};

就像我们的 GameObject 类一样,这是一个抽象基类;我们实际上并没有将其中的任何功能放入其中,我们只是希望所有派生类都遵循这个蓝图。updaterender 函数是自解释的,因为它们将像我们在 Game 类中创建的函数一样工作。我们可以将 onEnteronExit 函数视为类似于其他 loadclean 函数;一旦创建状态,我们就调用 onEnter 函数,一旦移除状态,就调用 onExit。最后一个函数是状态 ID 的获取器;每个状态都需要定义这个函数并返回它自己的 staticconst ID。ID 用于确保状态不会重复。不应该需要更改到相同的状态,因此我们使用状态 ID 来检查这一点。

这就是我们的 GameState 基类的全部内容;我们现在可以创建一些从这个类派生的测试状态。我们将从一个名为 MenuState 的状态开始。继续在我们的项目中创建 MenuState.hMenuState.cpp,打开 MenuState.h 并开始编码:

#include"GameState.h"

class MenuState : public GameState
{
public:

  virtual void update();
  virtual void render();

  virtual bool onEnter();
  virtual bool onExit();

  virtual std::string getStateID() const { return s_menuID; }

private:

  static const std::string s_menuID;
};

我们现在可以在我们的 MenuState.cpp 文件中定义这些方法。现在我们只是将在控制台窗口中显示一些文本来测试我们的实现;我们将给这个状态一个 "MENU" 的 ID:

#include "MenuState.h"

const std::string MenuState::s_menuID = "MENU";

void MenuState::update()
{
  // nothing for now
}

void MenuState::render()
{
  // nothing for now
}

bool MenuState::onEnter()
{
  std::cout << "entering MenuState\n";
  return true;
}

bool MenuState::onExit()
{
  std::cout << "exiting MenuState\n";
  return true;
}

我们现在将创建另一个状态,称为 PlayState,在我们的项目中创建 PlayState.hPlayState.cpp,并在头文件中声明我们的方法:

#include "GameState.h"

class PlayState : public GameState
{
public:

  virtual void update();
  virtual void render();

  virtual bool onEnter();
  virtual bool onExit();

  virtual std::string getStateID() const { return s_playID; }

private:

  static const std::string s_playID;
};

这个头文件与 MenuState.h 相同,唯一的区别是 getStateID 返回这个类的特定 ID ("PLAY")。让我们定义我们的函数:

#include "PlayState.h"

const std::string PlayState::s_playID = "PLAY";

void PlayState::update()
{
  // nothing for now
}

void PlayState::render()
{
  // nothing for now
}

bool PlayState::onEnter()
{
  std::cout << "entering PlayState\n";
  return true;
}

bool PlayState::onExit()
{
  std::cout << "exiting PlayState\n";
  return true;
}

我们现在有两个状态准备测试;接下来,我们必须创建我们的有限状态机(FSM)以便我们可以处理它们。

实现有限状态机(FSM)

我们的状态机(FSM)将需要以多种方式处理我们的状态,包括:

  • 删除一个状态并添加另一个状态:我们将使用这种方法来完全改变状态而不留下返回的选项

  • 在不删除前一个状态的情况下添加一个状态:这种方法对于暂停菜单等很有用

  • 在不添加另一个状态的情况下删除一个状态:这种方法将用于删除暂停状态或任何其他被推到另一个状态之上的状态

现在我们已经想出了我们希望有限状态机(FSM)具有的行为,让我们开始创建这个类。在我们的项目中创建 GameStateMachine.hGameStateMachine.cpp 文件。我们将在头文件中声明我们的函数:

#include "GameState.h"

class GameStateMachine
{
public:

  void pushState(GameState* pState);
  void changeState(GameState* pState);
  void popState();
};

我们已经声明了所需的三个函数。pushState 函数将在不删除前一个状态的情况下添加一个状态,changeState 函数将在添加另一个状态之前删除前一个状态,最后,popState 函数将删除当前正在使用的任何状态而不添加另一个。我们需要一个地方来存储这些状态;我们将使用一个向量:

private:

std::vector<GameState*> m_gameStates;

GameStateMachine.cpp 文件中,我们可以定义这些函数,然后逐步进行:

void GameStateMachine::pushState(GameState *pState)
{
  m_gameStates.push_back(pState);
  m_gameStates.back()->onEnter();
}

这是一个非常简单的函数;我们只是将传入的pState参数推入m_gameStates数组,然后调用它的onEnter函数:

void GameStateMachine::popState()
{
  if(!m_gameStates.empty())
  {
    if(m_gameStates.back()->onExit())
    {
      delete m_gamestates.back();
      m_gameStates.pop_back();
    }
  }
}

另一个简单的函数是popState。我们首先检查是否真的有可用的状态可以移除,如果有,我们调用当前状态的onExit函数然后移除它:

void GameStateMachine::changeState(GameState *pState)
{
  if(!m_gameStates.empty())
  {
    if(m_gameStates.back()->getStateID() == pState->getStateID())
    {
      return; // do nothing
    }

    if(m_gameStates.back()->onExit())
    {
      delete m_gamestates.back();
      m_gameStates.pop_back();
    }
  }

  // push back our new state
  m_gameStates.push_back(pState);

  // initialise it
  m_gameStates.back()->onEnter();
}

我们的第三个功能稍微复杂一些。首先,我们必须检查数组中是否已经存在任何状态,如果存在,我们检查它们的州 ID 是否与当前的一个相同,如果是,那么我们就不做任何事情。如果州 ID 不匹配,那么我们就移除当前状态,添加我们的新pState,并调用它的onEnter函数。接下来,我们将添加新的GameStateMachine作为Game类的一个成员:

GameStateMachine* m_pGameStateMachine;

然后,我们可以使用Game::init函数来创建我们的状态机并添加我们的第一个状态:

m_pGameStateMachine = new GameStateMachine();
m_pGameStateMachine->changeState(new MenuState());

Game::handleEvents函数将允许我们暂时在状态之间移动:

void Game::handleEvents()
{
  TheInputHandler::Instance()->update();

  if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_RETURN))
  {
    m_pGameStateMachine->changeState(new PlayState());
  }
}

当我们按下Enter键时,状态将改变。测试项目后,你应该在改变状态后得到以下输出:

entering MenuState
exiting MenuState
entering PlayState

现在我们有了 FSM 的初步形态,接下来可以在GameStateMachine头文件中添加updaterender函数:

void update();
void render();

我们可以在GameStateMachine.cpp文件中定义它们:

void GameStateMachine::update()
{
  if(!m_gameStates.empty())
  {
    m_gameStates.back()->update();
  }
}

void GameStateMachine::render()
{
  if(!m_gameStates.empty())
  {
    m_gameStates.back()->render();
  }
}

这些函数只是检查是否有任何状态,如果有,它们就会更新并渲染当前状态。你会注意到我们使用back()来获取当前状态;这是因为我们设计我们的 FSM 总是使用数组后面的状态。我们在添加新状态时使用push_back(),这样它们就会被推到数组的后面并立即使用。我们的Game类现在将使用 FSM 函数来代替它自己的updaterender函数:

void Game::render()
{
  SDL_RenderClear(m_pRenderer); 

  m_pGameStateMachine->render();

  SDL_RenderPresent(m_pRenderer); 
}

void Game::update()
{
  m_pGameStateMachine->update();
}

我们的状态机现在已经到位。

实现菜单状态

现在我们将进入创建一个带有视觉和鼠标处理的简单菜单状态。我们将使用两个新的截图来显示我们的按钮,这些截图可以在源代码下载中找到:

实现菜单状态

以下截图显示了退出功能:

实现菜单状态

这些实际上是包含我们按钮三个状态的原生精灵表。让我们为这些按钮创建一个新的类,我们将称之为MenuButton。现在创建MenuButton.hMenuButton.cpp文件。我们将从头文件开始:

class MenuButton : public SDLGameObject
{
public:

  MenuButton(const LoaderParams* pParams);

  virtual void draw();
  virtual void update();
  virtual void clean();
};

到现在为止,这应该看起来非常熟悉,创建新类型应该感觉很简单。我们还将定义我们的按钮状态为一个枚举类型,这样我们的代码就更容易阅读;在头文件中的private部分放入以下内容:

enum button_state
{
  MOUSE_OUT = 0,
  MOUSE_OVER = 1,
  CLICKED = 2
};

打开MenuButton.cpp文件,我们可以开始充实我们的MenuButton类:

MenuButton::MenuButton(const LoaderParams* pParams) : SDLGameObject(pParams)
{
  m_currentFrame = MOUSE_OUT; // start at frame 0
}

void MenuButton::draw()
{
  SDLGameObject::draw(); // use the base class drawing
}

void MenuButton::update()
{
  Vector2D* pMousePos = TheInputHandler::Instance()
  ->getMousePosition();

  if(pMousePos->getX() < (m_position.getX() + m_width) 
  && pMousePos->getX() > m_position.getX()
  && pMousePos->getY() < (m_position.getY() + m_height) 
  && pMousePos->getY() > m_position.getY())
  {
    m_currentFrame = MOUSE_OVER;

    if(TheInputHandler::Instance()->getMouseButtonState(LEFT))
    {
      m_currentFrame = CLICKED;
    }
  }
  else
  {
    m_currentFrame = MOUSE_OUT;
  }
}

void MenuButton::clean()
{
  SDLGameObject::clean();
}

这个类中真正新的东西只有update函数。接下来,我们将逐步介绍这个函数的每个步骤:

  • 首先,我们获取鼠标指针的坐标并将它们存储在一个指向Vector2D对象的指针中:

    Vector2D* pMousePos = TheInputHandler::Instance()->getMousePosition();
    
  • 现在,检查鼠标是否在按钮上。我们首先检查鼠标位置是否小于按钮右侧的位置(x 位置 + 宽度)。然后检查鼠标位置是否大于按钮左侧的位置(x 位置)。y 位置检查与y 位置 + 高度y 位置对于底部和顶部分别相同:

    if(pMousePos->getX() < (m_position.getX() + m_width) 
    && pMousePos->getX() > m_position.getX()
    && pMousePos->getY() < (m_position.getY() + m_height) 
    && pMousePos->getY() > m_position.getY())
    
  • 如果前一个检查为真,我们知道鼠标悬停在按钮上;我们将它的框架设置为MOUSE_OVER (1)

    m_currentFrame = MOUSE_OVER;
    
  • 我们可以检查鼠标是否被点击;如果是,则将当前框架设置为CLICKED(2)

    if(TheInputHandler::Instance()->getMouseButtonState(LEFT))
    {
      m_currentFrame = CLICKED;
    }
    
  • 如果检查不为真,那么我们知道鼠标在按钮外,我们将框架设置为MOUSE_OUT (0)

    else
    {
      m_currentFrame = MOUSE_OUT;
    }
    

我们现在可以测试我们的可重用按钮类。打开我们之前创建的MenuState.hand,我们将为其实际实现。首先,我们需要一个GameObject*的向量来存储我们的菜单项:

std::vector<GameObject*> m_gameObjects;

MenuState.cpp文件中,我们现在可以开始处理我们的菜单项:

void MenuState::update()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->update();
  }
}
void MenuState::render()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->draw();
  }
}

onExitonEnter函数可以定义如下:

bool MenuState::onEnter()
{
  if(!TheTextureManager::Instance()->load("assets/button.png", 
  "playbutton", TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  if(!TheTextureManager::Instance()->load("assets/exit.png", 
  "exitbutton", TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  GameObject* button1 = new MenuButton(new LoaderParams(100, 100, 
  400, 100, "playbutton"));
  GameObject* button2 = new MenuButton(new LoaderParams(100, 300, 
  400, 100, "exitbutton"));

  m_gameObjects.push_back(button1);
  m_gameObjects.push_back(button2);

  std::cout << "entering MenuState\n";
  return true;
}

bool MenuState::onExit()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->clean();
  }
  m_gameObjects.clear();
  TheTextureManager::Instance()
  ->clearFromTextureMap("playbutton");
  TheTextureManager::Instance()
  ->clearFromTextureMap("exitbutton");

  std::cout << "exiting MenuState\n";
  return true;
}

我们使用TextureManager来加载我们的新图像,然后将这些纹理分配给两个按钮。TextureManager类还有一个名为clearFromTextureMap的新函数,它接受我们想要删除的纹理的 ID;它定义如下:

void TextureManager::clearFromTextureMap(std::string id)
{
  m_textureMap.erase(id);
}

此函数使我们能够仅从当前状态中清除纹理,而不是整个纹理图。当我们推送状态然后弹出它们时,这是非常重要的,因为我们不希望弹出的状态清除原始状态的纹理。

其他一切都是本质上与我们在Game类中处理对象的方式相同。运行项目,我们将有对鼠标事件做出反应的按钮。窗口将看起来像以下截图(请继续测试它):

实现菜单状态

函数指针和回调函数

我们的按钮对悬停和点击做出反应,但实际上还没有做任何事情。我们真正想要实现的是创建MenuButton并传递我们想要它在点击时调用的函数的能力;我们可以通过使用函数指针来实现这一点。函数指针确实如其所言:它们指向一个函数。我们可以暂时使用经典的 C 风格函数指针,因为我们只将使用不接受任何参数且总是返回类型为void的函数(因此,我们目前不需要使它们泛型)。

函数指针的语法如下:

returnType (*functionName)(parameters);

我们在MenuButton.h中将我们的函数指针声明为私有成员,如下所示:

void (*m_callback)();

我们还添加了一个新的成员变量来更好地处理点击:

bool m_bReleased;

现在我们可以修改构造函数,以便我们可以传递我们的函数:

MenuButton(const LoaderParams* pParams, void (*callback)());

在我们的MenuButton.cpp文件中,我们现在可以修改构造函数并使用初始化列表初始化我们的指针:

MenuButton::MenuButton(const LoaderParams* pParams, void (*callback)() ) : SDLGameObject(pParams), m_callback(callback)

update函数现在可以调用此函数:

void MenuButton::update()
{
  Vector2D* pMousePos = TheInputHandler::Instance()
  ->getMousePosition();

  if(pMousePos->getX() < (m_position.getX() + m_width) 
  && pMousePos->getX() > m_position.getX()
  && pMousePos->getY() < (m_position.getY() + m_height) 
  && pMousePos->getY() > m_position.getY())
  {
    if(TheInputHandler::Instance()->getMouseButtonState(LEFT) 
    && m_bReleased)
    {
      m_currentFrame = CLICKED;

      m_callback(); // call our callback function

      m_bReleased = false;
    }
    else if(!TheInputHandler::Instance()
    ->getMouseButtonState(LEFT))
    {
      m_bReleased = true;
      m_currentFrame = MOUSE_OVER;
    }
  }
  else
  {
    m_currentFrame = MOUSE_OUT;
  }
}

注意,这个update函数现在使用m_bReleased值来确保我们在再次进行回调之前释放鼠标按钮;这是我们想要的点击行为。

在我们的MenuState.h对象中,我们可以声明一些函数,这些函数将传递到我们的MenuButton对象的构造函数中:

private:
// call back functions for menu items
static void s_menuToPlay();
static void s_exitFromMenu();

我们将这些函数声明为静态的;这是因为我们的回调功能只支持静态函数。将常规成员函数作为函数指针处理要复杂一些,因此我们将避免这样做,坚持使用静态函数。我们可以在MenuState.cpp文件中定义这些函数:

void MenuState::s_menuToPlay()
{
  std::cout << "Play button clicked\n";
}

void MenuState::s_exitFromMenu()
{
  std::cout << "Exit button clicked\n";
}

我们可以将这些函数传递到按钮的构造函数中:

GameObject* button1 = new MenuButton(new LoaderParams(100, 100, 400, 100, "playbutton"), s_menuToPlay);
GameObject* button2 = new MenuButton(new LoaderParams(100, 300, 400, 100, "exitbutton"), s_exitFromMenu);

测试我们的项目,你将看到我们的函数正在打印到控制台。我们现在正在传递我们想要在按钮点击时调用的函数;这对我们的按钮功能来说非常好。让我们用一些实际的功能测试退出按钮:

void MenuState::s_exitFromMenu()
{
  TheGame::Instance()->quit();
}

现在点击我们的退出按钮将退出游戏。下一步是允许s_menuToPlay函数移动到PlayState。我们首先需要在Game.h文件中添加一个获取器,以便我们可以访问状态机:

GameStateMachine* getStateMachine(){ return m_pGameStateMachine; }

我们现在可以使用这个来在MenuState中改变状态:

void MenuState::s_menuToPlay()
{
  TheGame::Instance()->getStateMachine()->changeState(new 
  PlayState());
}

好吧,去测试一下;PlayState目前还没有做任何事情,但我们的控制台输出应该显示状态之间的移动。

实现临时播放状态

我们已经创建了MenuState;接下来,我们需要创建PlayState以便我们可以直观地看到状态的变化。对于PlayState,我们将创建一个使用我们的helicopter.png图像并跟随鼠标移动的玩家对象。我们将从Player.cpp文件开始,并添加代码使Player对象跟随鼠标位置:

void Player::handleInput()
{
  Vector2D* target = TheInputHandler::Instance()
  ->getMousePosition();

  m_velocity = *target - m_position;

  m_velocity /= 50;
}

首先,我们获取当前的鼠标位置;然后我们可以通过从鼠标位置减去当前位置来获取一个指向鼠标位置的向量。然后我们将速度除以一个标量以稍微减慢速度,并允许我们看到我们的直升机追上鼠标而不是粘附在它上面。现在我们的PlayState.h文件将需要它自己的GameObject*向量:

class GameObject;

class PlayState : public GameState
{
public:

  virtual void update();
  virtual void render();

  virtual bool onEnter();
  virtual bool onExit();

  virtual std::string getStateID() const { return s_playID; }

private:

  static const std::string s_playID;

  std::vector<GameObject*> m_gameObjects;
};

最后,我们必须更新PlayState.cpp实现文件以使用我们的Player对象:

const std::string PlayState::s_playID = "PLAY";

void PlayState::update()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->update();
  }
}

void PlayState::render()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->draw();
  }
}

bool PlayState::onEnter()
{
  if(!TheTextureManager::Instance()->load("assets/helicopter.png", 
  "helicopter", TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  GameObject* player = new Player(new LoaderParams(100, 100, 128, 
  55, "helicopter");

  m_gameObjects.push_back(player);

  std::cout << "entering PlayState\n";
  return true;
}

bool PlayState::onExit()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->clean();
  }
  m_gameObjects.clear();
  TheTextureManager::Instance()
  ->clearFromTextureMap("helicopter");

  std::cout << "exiting PlayState\n";
  return true;
}

这个文件与MenuState.cpp文件非常相似,但这次我们使用了一个Player对象而不是两个MenuButton对象。我们对SDLGameObject.cpp文件进行了一次调整,这将使PlayState看起来更加出色;我们将根据对象的速率翻转图像文件:

void SDLGameObject::draw()
{
  if(m_velocity.getX() > 0)
  {
    TextureManager::Instance()->drawFrame(m_textureID, 
    (Uint32)m_position.getX(), (Uint32)m_position.getY(),
    m_width, m_height, m_currentRow, m_currentFrame, 
    TheGame::Instance()->getRenderer(),SDL_FLIP_HORIZONTAL);
  }
  else
  {
    TextureManager::Instance()->drawFrame(m_textureID, 
    (Uint32)m_position.getX(), (Uint32)m_position.getY(),
    m_width, m_height, m_currentRow, m_currentFrame, 
    TheGame::Instance()->getRenderer());
  }
}

我们检查对象的速率是否大于0(向右移动)并相应地翻转图像。运行我们的游戏,你现在将能够在这两个状态MenuStatePlayState之间移动,每个状态都有其自己的功能和对象。以下截图显示了我们的项目到目前为止的情况:

实现临时播放状态

暂停游戏

对于我们的游戏来说,另一个非常重要的状态是暂停状态。一旦暂停,游戏可以有多种选项。我们的 PauseState 类将与 MenuState 类非常相似,但具有不同的按钮视觉和回调。以下是我们的两个新截图(同样可在源代码下载中找到):

暂停游戏

以下截图显示了恢复功能:

暂停游戏

首先,让我们在项目中创建我们的 PauseState.h 文件:

class GameObject;

class PauseState : public GameState
{
public:

  virtual void update();
  virtual void render();

  virtual bool onEnter();
  virtual bool onExit();

  virtual std::string getStateID() const { return s_pauseID; }

private:

  static void s_pauseToMain();
  static void s_resumePlay();

  static const std::string s_pauseID;

  std::vector<GameObject*> m_gameObjects;
};

接下来,创建我们的 PauseState.cpp 文件:

const std::string PauseState::s_pauseID = "PAUSE";

void PauseState::s_pauseToMain()
{
  TheGame::Instance()->getStateMachine()->changeState(new 
  MenuState());
}

void PauseState::s_resumePlay()
{
  TheGame::Instance()->getStateMachine()->popState();
}

void PauseState::update()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->update();
  }
}

void PauseState::render()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->draw();
  }
}

bool PauseState::onEnter()
{
  if(!TheTextureManager::Instance()->load("assets/resume.png", 
  "resumebutton", TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  if(!TheTextureManager::Instance()->load("assets/main.png", 
  "mainbutton", TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  GameObject* button1 = new MenuButton(new LoaderParams(200, 100, 
  200, 80, "mainbutton"), s_pauseToMain);
  GameObject* button2 = new MenuButton(new LoaderParams(200, 300, 
  200, 80, "resumebutton"), s_resumePlay);

  m_gameObjects.push_back(button1);
  m_gameObjects.push_back(button2);

  std::cout << "entering PauseState\n";
  return true;
}

bool PauseState::onExit()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->clean();
  }
  m_gameObjects.clear();
  TheTextureManager::Instance()
  ->clearFromTextureMap("resumebutton");
  TheTextureManager::Instance()
  ->clearFromTextureMap("mainbutton");
  // reset the mouse button states to false
  TheInputHandler::Instance()->reset();

  std::cout << "exiting PauseState\n";
  return true;
}

在我们的 PlayState.cpp 文件中,我们现在可以使用我们新的 PauseState 类:

void PlayState::update()
{
  if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_ESCAPE))
  {
    TheGame::Instance()->getStateMachine()->pushState(new 
    PauseState());
  }

  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->update();
  }
}

此函数监听 Esc 键被按下,一旦按下,它就会将一个新的 PauseState 类推入 FSM 的状态数组。记住,pushState 不会移除旧状态;它只是停止使用它并使用新状态。一旦我们完成推入的状态,我们就从状态数组中移除它,游戏继续使用之前的状态。我们使用恢复按钮的回调来移除暂停状态:

void PauseState::s_resumePlay()
{
  TheGame::Instance()->getStateMachine()->popState();
}

主菜单按钮将带我们回到主菜单,并完全移除任何其他状态:

void PauseState::s_pauseToMain()
{
  TheGame::Instance()->getStateMachine()->changeState(new 
  MenuState());
}

创建游戏结束状态

我们将创建一个最终的状态,GameOverState。要进入此状态,我们将在 PlayState 类中使用碰撞检测和新的 Enemy 对象。我们将检查 Player 对象是否击中了 Enemy 对象,如果是,我们将切换到我们的 GameOverState 类。我们的敌人对象将使用一个新的图像 helicopter2.png

创建游戏结束状态

我们将使 Enemy 对象的直升机在屏幕上下移动,以保持游戏的趣味性。在我们的 Enemy.cpp 文件中,我们将添加此功能:

Enemy::Enemy(const LoaderParams* pParams) : SDLGameObject(pParams)
{
  m_velocity.setY(2);
  m_velocity.setX(0.001);
}

void Enemy::draw()
{
  SDLGameObject::draw();
}

void Enemy::update()
{
  m_currentFrame = int(((SDL_GetTicks() / 100) % m_numFrames));

  if(m_position.getY() < 0)
  {
    m_velocity.setY(2);
  }
  else if(m_position.getY() > 400)
  {
    m_velocity.setY(-2);
  }

  SDLGameObject::update();
}

我们现在可以向我们的 PlayState 类添加一个 Enemy 对象:

bool PlayState::onEnter()
{
  if(!TheTextureManager::Instance()->load("assets/helicopter.png", 
  "helicopter", TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  if(!TheTextureManager::Instance()
  ->load("assets/helicopter2.png", "helicopter2", 
  TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  GameObject* player = new Player(new LoaderParams(500, 100, 128, 
  55, "helicopter"));
  GameObject* enemy = new Enemy(new LoaderParams(100, 100, 128, 
  55, "helicopter2"));

  m_gameObjects.push_back(player);
  m_gameObjects.push_back(enemy);

  std::cout << "entering PlayState\n";
  return true;
}

运行游戏将允许我们看到我们的两架直升机:

创建游戏结束状态

在我们介绍碰撞检测之前,我们将创建我们的 GameOverState 类。我们将为此状态使用两个新的图像,一个用于新的 MenuButton,另一个用于一种新类型,我们将称之为 AnimatedGraphic

创建游戏结束状态

以下截图显示了游戏结束功能:

创建游戏结束状态

AnimatedGraphic 与其他类型非常相似,所以这里不会过多介绍;然而,重要的是构造函数中添加的值,它控制动画的速度,并设置私有成员变量 m_animSpeed

AnimatedGraphic::AnimatedGraphic(const LoaderParams* pParams, int animSpeed) : SDLGameObject(pParams), m_animSpeed(animSpeed)
{

}

update 函数将使用此值来设置动画的速度:

void AnimatedGraphic::update()
{
  m_currentFrame = int(((SDL_GetTicks() / (1000 / m_animSpeed)) % 
  m_numFrames));
}

现在我们已经有了 AnimatedGraphic 类,我们可以实现我们的 GameOverState 类。在我们的项目中创建 GameOverState.hGameOverState.cpp;我们将创建的头文件应该看起来非常熟悉,如下面的代码所示:

class GameObject;

class GameOverState : public GameState
{
public:

  virtual void update();
  virtual void render();

  virtual bool onEnter();
  virtual bool onExit();

  virtual std::string getStateID() const {return s_gameOverID;}

private:

  static void s_gameOverToMain();
  static void s_restartPlay();

  static const std::string s_gameOverID;

  std::vector<GameObject*> m_gameObjects;
};

我们的实现文件也与已覆盖的其他文件非常相似,所以我又会只介绍不同的部分。首先,我们定义我们的静态变量和函数:

const std::string GameOverState::s_gameOverID = "GAMEOVER";

void GameOverState::s_gameOverToMain()
{
  TheGame::Instance()->getStateMachine()->changeState(new 
  MenuState());
}

void GameOverState::s_restartPlay()
{
  TheGame::Instance()->getStateMachine()->changeState(new 
  PlayState());
}

onEnter函数将创建三个新对象及其纹理:

bool GameOverState::onEnter()
{
  if(!TheTextureManager::Instance()->load("assets/gameover.png", 
  "gameovertext", TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  if(!TheTextureManager::Instance()->load("assets/main.png", 
  "mainbutton", TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  if(!TheTextureManager::Instance()->load("assets/restart.png", 
  "restartbutton", TheGame::Instance()->getRenderer()))
  {
    return false;
  }

  GameObject* gameOverText = new AnimatedGraphic(new 
  LoaderParams(200, 100, 190, 30, "gameovertext", 2), 2);
  GameObject* button1 = new MenuButton(new LoaderParams(200, 200, 
  200, 80, "mainbutton"), s_gameOverToMain);
  GameObject* button2 = new MenuButton(new LoaderParams(200, 300, 
  200, 80, "restartbutton"), s_restartPlay);

  m_gameObjects.push_back(gameOverText);
  m_gameObjects.push_back(button1);
  m_gameObjects.push_back(button2);

  std::cout << "entering PauseState\n";
  return true;
}

对于我们的GameOverState类,基本上就是这样,但我们现在必须创建一个条件来创建这个状态。移动到我们的PlayState.h文件,我们将创建一个新函数,允许我们检查碰撞:

bool checkCollision(SDLGameObject* p1, SDLGameObject* p2);

我们将在PlayState.cpp中定义这个函数:

bool PlayState::checkCollision(SDLGameObject* p1, SDLGameObject* 
p2)
{
  int leftA, leftB;
  int rightA, rightB;
  int topA, topB;
  int bottomA, bottomB;

  leftA = p1->getPosition().getX();
  rightA = p1->getPosition().getX() + p1->getWidth();
  topA = p1->getPosition().getY();
  bottomA = p1->getPosition().getY() + p1->getHeight();

  //Calculate the sides of rect B
  leftB = p2->getPosition().getX();
  rightB = p2->getPosition().getX() + p2->getWidth();
  topB = p2->getPosition().getY();
  bottomB = p2->getPosition().getY() + p2->getHeight();

  //If any of the sides from A are outside of B
  if( bottomA <= topB ){return false;} 
  if( topA >= bottomB ){return false; }
  if( rightA <= leftB ){return false; }
  if( leftA >= rightB ){return false;}

  return true;
}

这个函数检查两个SDLGameObject类型之间的碰撞。为了让这个函数工作,我们需要向我们的SDLGameObject类添加三个新函数:

Vector2D& getPosition() { return m_position; }
int getWidth() { return m_width; }
int getHeight() { return m_height; }

下一章将介绍这个函数是如何工作的,但到目前为止,知道它确实存在就足够了。我们的PlayState类现在将在其update函数中使用这种碰撞检测:

void PlayState::update()
{
  if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_ESCAPE))
  {
    TheGame::Instance()->getStateMachine()->pushState(new 
    PauseState());
  }

  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->update();
  }

  if(checkCollision(dynamic_cast<SDLGameObject*>
  (m_gameObjects[0]), dynamic_cast<SDLGameObject*>
  (m_gameObjects[1])))
  {
    TheGame::Instance()->getStateMachine()->pushState(new 
    GameOverState());
  }
}

我们必须使用一个dynamic_cast对象将我们的GameObject*类转换为SDLGameObject*类。如果checkCollision返回true,则添加GameOverState类。以下截图显示了GameOver状态:

创建游戏结束状态

摘要

本章留给我们的东西比之前章节更像一个游戏。我们为菜单、暂停、播放和游戏结束创建了状态,每个状态都有其自己的功能,并且使用 FSM 进行处理。现在Game类使用 FSM 来渲染和更新游戏对象,它不再直接处理对象,因为每个单独的状态都处理其自己的对象。我们还使用函数指针和静态函数为我们的按钮创建了简单的回调函数。

第六章。数据驱动设计

在上一章中,通过添加创建和处理游戏状态的能力,我们的框架已经开始成形。在本章中,我们将探讨一种新的创建状态和对象的方法,即通过移除在编译时硬编码对象创建的需求。为此,我们将解析一个外部文件,在我们的例子中是一个 XML 文件,该文件列出了我们状态所需的所有对象。这将使我们的状态变得通用,因为它们可以通过加载不同的 XML 文件而完全不同。以 PlayState 为例,在创建新关卡时,我们需要创建一个新的状态,包含不同的对象,并设置我们想要在该关卡中使用的对象。如果我们能够从外部文件加载对象,我们就可以重用相同的 PlayState,并根据我们想要的当前关卡简单地加载正确的文件。保持类通用并加载外部数据以确定其状态被称为 数据驱动设计

在本章中,我们将介绍:

  • 使用 TinyXML 库加载 XML 文件

  • 创建 分布式工厂

  • 使用工厂和 XML 文件动态加载对象

  • 从 XML 文件解析状态

  • 将一切整合到框架中

加载 XML 文件

我选择使用 XML 文件,因为它们非常容易解析。我们不会编写自己的 XML 解析器,而是将使用一个名为 TinyXML 的开源库。TinyXML 是由 Lee Thomason 编写的,并且可以在 sourceforge.net/projects/tinyxml/ 下以 zlib 许可证获得。

下载后,我们唯一需要做的设置就是将几个文件包含到我们的项目中:

  • tinyxmlerror.cpp

  • tinyxmlparser.cpp

  • tinystr.cpp

  • tinystr.h

  • tinyxml.cpp

  • tinyxml.h

tinyxml.h 的顶部添加以下代码行:

#define TIXML_USE_STL

通过这样做,我们确保正在使用 TinyXML 函数的 STL 版本。现在我们可以简要地介绍一下 XML 文件的构成。实际上它相当简单,我们只提供一个简要概述,以帮助您了解我们将如何使用它。

基本 XML 结构

这里是一个基本的 XML 文件:

<?xml version="1.0" ?>
<ROOT>
    <ELEMENT>
    </ELEMENT>
</ROOT>

文件的第一行定义了 XML 文件的格式。第二行是我们的 Root 元素;其他所有内容都是这个元素的子元素。第三行是根元素的第一个子元素。现在让我们看看一个稍微复杂一点的 XML 文件:

<?xml version="1.0" ?>
<ROOT>
    <ELEMENTS>
        <ELEMENT>Hello,</ELEMENT>
        <ELEMENT> World!</ELEMENT>
    </ELEMENTS>
</ROOT>

如您所见,我们现在已经向第一个子元素添加了子元素。您可以嵌套任意多的子元素。但是如果没有良好的结构,您的 XML 文件可能非常难以阅读。如果我们解析上述文件,我们将采取以下步骤:

  1. 加载 XML 文件。

  2. 获取根元素,<ROOT>

  3. 获取根元素的第一个子元素,<ELEMENTS>

  4. 对于 <ELEMENTS> 的每个子元素 <ELEMENT>,获取其内容。

  5. 关闭文件。

另一个有用的 XML 功能是使用属性。以下是一个示例:

<ROOT>
    <ELEMENTS>
        <ELEMENT text="Hello,"/>
        <ELEMENT text=" World!"/>
    </ELEMENTS>
</ROOT>

我们现在已经将想要存储的文本存储在一个名为text的属性中。当这个文件被解析时,我们会获取每个元素的text属性并将其存储,而不是存储<ELEMENT></ELEMENT>标签之间的内容。这对我们来说特别有用,因为我们可以使用属性来存储我们对象的大量不同值。所以让我们看看一些更接近我们将在游戏中使用的内容:

<?xml version="1.0" ?>
<STATES>

<!--The Menu State-->
<MENU>
<TEXTURES>
  <texture filename="button.png" ID="playbutton"/>
  <texture filename="exit.png" ID="exitbutton"/>
</TEXTURES>

<OBJECTS>
  <object type="MenuButton" x="100" y="100" width="400" 
  height="100" textureID="playbutton"/>
  <object type="MenuButton" x="100" y="300" width="400" 
  height="100" textureID="exitbutton"/>
</OBJECTS>
</MENU>

<!--The Play State-->
<PLAY>
</PLAY>

<!-- The Game Over State -->
<GAMEOVER>
</GAMEOVER>
</STATES>

这稍微复杂一些。我们为每个状态定义一个元素,在这个元素中,我们有具有各种属性的物体和纹理。这些属性可以加载到状态中。

通过对 XML 的这些知识,你可以轻松地创建自己的文件结构,如果本书中涵盖的内容不符合你的需求。

实现对象工厂

我们现在拥有一些 XML 知识,但在我们继续前进之前,我们将看看对象工厂。对象工厂是一个负责创建我们对象的类。本质上,我们告诉工厂我们想要它创建的对象,然后它就会创建该对象的新实例并返回它。我们可以从查看一个基本的实现开始:

GameObject* GameObjectFactory::createGameObject(ID id)
{
  switch(id)
  {
    case "PLAYER":
      return new Player();
    break;

    case "ENEMY":
      return new Enemy();
    break;

    // lots more object types 
  }
}

这个函数非常简单。我们传入一个对象的 ID,工厂使用一个大的 switch 语句来查找并返回正确的对象。这不是一个糟糕的解决方案,但也不是一个特别好的解决方案,因为工厂需要知道它需要创建的每个类型,并且维护许多不同对象的 switch 语句将会非常繁琐。就像我们在第三章中介绍遍历游戏对象时一样,与游戏对象一起工作,我们希望这个工厂不关心我们要求的是什么类型。它不需要知道我们想要它创建的所有具体类型。幸运的是,这确实是我们能够实现的事情。

使用分布式工厂

通过使用分布式工厂,我们可以创建一个通用的对象工厂,它可以创建我们的任何类型。分布式工厂允许我们动态地维护我们想要工厂创建的对象类型,而不是将它们硬编码到一个函数中(就像前面的简单示例中那样)。我们将采取的方法是让工厂包含一个std::map,它将一个字符串(我们对象的类型)映射到一个名为Creator的小类。Creator的唯一目的是创建特定的对象。我们将使用一个函数将新类型注册到工厂中,该函数接受一个字符串(ID)和一个Creator类,并将它们添加到工厂的映射中。我们将从所有Creator类型的基类开始。创建GameObjectFactory.h并在文件顶部声明这个类。

#include <string>
#include <map>
#include "GameObject.h"

class BaseCreator
{
  public:

  virtual GameObject* createGameObject() const = 0;
  virtual ~BaseCreator() {}
};

我们现在可以继续创建其余的工厂,然后逐个分析它。

class GameObjectFactory
{
  public:

  bool registerType(std::string typeID, BaseCreator* pCreator)
  {
    std::map<std::string, BaseCreator*>::iterator it = 
    m_creators.find(typeID);

    // if the type is already registered, do nothing
    if(it != m_creators.end())
    {
      delete pCreator;
      return false;
    }

    m_creators[typeID] = pCreator;

    return true;
  }

  GameObject* create(std::string typeID)
  {
    std::map<std::string, BaseCreator*>::iterator it = 
    m_creators.find(typeID);

    if(it == m_creators.end())
    {
      std::cout << "could not find type: " << typeID << "\n";
      return NULL;
    }

    BaseCreator* pCreator = (*it).second;
    return pCreator->createGameObject();
  }

  private:

  std::map<std::string, BaseCreator*> m_creators;

};

这是一个相当小的类,但实际上非常强大。我们将分别介绍每个部分,从std::map m_creators开始。

std::map<std::string, BaseCreator*> m_creators;

这张地图包含了我们工厂的重要元素,类的功能本质上要么添加要么从这张地图中移除。当我们查看 registerType 函数时,这一点变得很明显:

bool registerType(std::string typeID, BaseCreator* pCreator)

这个函数接受我们想要与对象类型关联的 ID(作为字符串),以及该类的创建者对象。然后函数尝试使用 std::mapfind 函数查找类型:

std::map<std::string, BaseCreator*>::iterator it = m_creators.find(typeID);

如果找到类型,则它已经注册。然后函数删除传入的指针并返回 false

if(it != m_creators.end())
{
  delete pCreator;
  return false;
}

如果类型尚未注册,则可以将其分配给地图,然后返回 true

m_creators[typeID] = pCreator;
return true;
}

如您所见,registerType 函数实际上非常简单;它只是将类型添加到地图的一种方式。create 函数非常相似:

GameObject* create(std::string typeID)
{
  std::map<std::string, BaseCreator*>::iterator it = 
  m_creators.find(typeID);

  if(it == m_creators.end())
  {
    std::cout << "could not find type: " << typeID << "\n";
    return 0;
  }

  BaseCreator* pCreator = (*it).second;
  return pCreator->createGameObject();
}

这个函数以与 registerType 相同的方式查找类型,但这次它检查类型是否未找到(而不是找到)。如果类型未找到,我们返回 0,如果类型找到,则我们使用该类型的 Creator 对象返回一个新的实例,作为 GameObject 指针。

值得注意的是,GameObjectFactory 类可能应该是一个单例。我们不会介绍如何将其变为单例,因为这在之前的章节中已经介绍过了。尝试自己实现它或查看源代码下载中的实现方式。

将工厂集成到框架中

现在我们已经设置了工厂,我们可以开始修改我们的 GameObject 类以使用它。我们的第一步是确保我们为每个对象都有一个 Creator 类。这里有一个 Player 的示例:

class PlayerCreator : public BaseCreator
{
  GameObject* createGameObject() const
  {
    return new Player();
  }
};

这可以添加到 Player.h 文件的底部。我们想要工厂创建的任何对象都必须有自己的 Creator 实现。我们必须做的另一个补充是将 LoaderParams 从构造函数移动到它们自己的函数 load 中。这阻止了我们需要将 LoaderParams 对象传递给工厂本身。我们将 load 函数放入 GameObject 基类中,因为我们希望每个对象都有一个。

class GameObject
{
  public:

  virtual void draw()=0;
  virtual void update()=0;
  virtual void clean()=0;

  // new load function 
  virtual void load(const LoaderParams* pParams)=0;

  protected:

  GameObject() {}
  virtual ~GameObject() {}
};

我们的所有派生类现在都需要实现这个 load 函数。SDLGameObject 类现在看起来像这样:

SDLGameObject::SDLGameObject() : GameObject()
{
}

voidSDLGameObject::load(const LoaderParams *pParams)
{
  m_position = Vector2D(pParams->getX(),pParams->getY());
  m_velocity = Vector2D(0,0);
  m_acceleration = Vector2D(0,0);
  m_width = pParams->getWidth();
  m_height = pParams->getHeight();
  m_textureID = pParams->getTextureID();
  m_currentRow = 1;
  m_currentFrame = 1;
  m_numFrames = pParams->getNumFrames();
}

我们从 SDLGameObject 派生的对象也可以使用这个 load 函数;例如,这里有一个 Player::load 函数:

Player::Player() : SDLGameObject()
{

}

void Player::load(const LoaderParams *pParams)
{
  SDLGameObject::load(pParams);
}

这可能看起来有点没有意义,但实际上它节省了我们不需要在各个地方传递 LoaderParams。如果没有它,我们就需要通过工厂的 create 函数传递 LoaderParams,然后它再传递给 Creator 对象。我们通过有一个专门处理解析我们加载值的函数来消除了这种需求。一旦我们开始从文件中解析我们的状态,这将会更有意义。

我们还有一个需要纠正的问题;我们有两个类在它们的构造函数中具有额外的参数(MenuButtonAnimatedGraphic)。这两个类都接受一个额外的参数以及 LoaderParams。为了解决这个问题,我们将这些值添加到 LoaderParams 并赋予它们默认值。

LoaderParams(int x, int y, int width, int height, std::string textureID, int numFrames, int callbackID = 0, int animSpeed = 0) :
m_x(x),
m_y(y),
m_width(width),
m_height(height),
m_textureID(textureID),
m_numFrames(numFrames),
m_callbackID(callbackID),
m_animSpeed(animSpeed)
{

}

换句话说,如果没有传递参数,则将使用默认值(两种情况下都是 0)。与 MenuButton 传递函数指针的方式不同,我们正在使用 callbackID 来决定在状态内使用哪个回调函数。现在我们可以开始使用我们的工厂并从 XML 文件解析状态。

从 XML 文件解析状态

我们将要解析的文件如下(源代码下载中的 test.xml):

<?xml version="1.0" ?>
<STATES>
<MENU>
<TEXTURES>
  <texture filename="assets/button.png" ID="playbutton"/>
  <texture filename="assets/exit.png" ID="exitbutton"/>
</TEXTURES>

<OBJECTS>
  <object type="MenuButton" x="100" y="100" width="400" 
  height="100" textureID="playbutton" numFrames="0" 
  callbackID="1"/>
  <object type="MenuButton" x="100" y="300" width="400" 
  height="100" textureID="exitbutton" numFrames="0" 
  callbackID="2"/>
</OBJECTS>
</MENU>
<PLAY>
</PLAY>

<GAMEOVER>
</GAMEOVER>
</STATES>

我们将创建一个新的类来为我们解析状态,称为 StateParserStateParser 类没有数据成员,它应该在状态的 onEnter 函数中使用一次,然后当它超出作用域时被丢弃。创建一个 StateParser.h 文件并添加以下代码:

#include <iostream>
#include <vector>
#include "tinyxml.h"

class GameObject;

class StateParser
{
  public:

  bool parseState(const char* stateFile, std::string stateID, 
  std::vector<GameObject*> *pObjects);

  private:

  void parseObjects(TiXmlElement* pStateRoot, 
  std::vector<GameObject*> *pObjects);
  void parseTextures(TiXmlElement* pStateRoot, 
  std::vector<std::string> *pTextureIDs);

};

我们这里有三个函数,一个公共的,两个私有的。parseState 函数接受一个 XML 文件的文件名作为参数,以及当前的 stateID 值和一个指向 std::vectorGameObject* 指针,该指针对应于该状态。StateParser.cpp 文件将定义此函数:

bool StateParser::parseState(const char *stateFile, string 
stateID, vector<GameObject *> *pObjects, std::vector<std::string> 
*pTextureIDs)
{
  // create the XML document
  TiXmlDocument xmlDoc;

  // load the state file
  if(!xmlDoc.LoadFile(stateFile))
  {
    cerr << xmlDoc.ErrorDesc() << "\n";
    return false;
  }

  // get the root element
  TiXmlElement* pRoot = xmlDoc.RootElement();

  // pre declare the states root node
  TiXmlElement* pStateRoot = 0;
  // get this states root node and assign it to pStateRoot
  for(TiXmlElement* e = pRoot->FirstChildElement(); e != NULL; e = 
  e->NextSiblingElement())
  {
    if(e->Value() == stateID)
    {
      pStateRoot = e;
    }
  }

  // pre declare the texture root
  TiXmlElement* pTextureRoot = 0;

  // get the root of the texture elements
  for(TiXmlElement* e = pStateRoot->FirstChildElement(); e != 
  NULL; e = e->NextSiblingElement())
  {
    if(e->Value() == string("TEXTURES"))
    {
      pTextureRoot = e;
    }
  }

  // now parse the textures
  parseTextures(pTextureRoot, pTextureIDs);

  // pre declare the object root node
  TiXmlElement* pObjectRoot = 0;

  // get the root node and assign it to pObjectRoot
  for(TiXmlElement* e = pStateRoot->FirstChildElement(); e != 
  NULL; e = e->NextSiblingElement())
  {
    if(e->Value() == string("OBJECTS"))
    {
      pObjectRoot = e;
    }
  }

  // now parse the objects
  parseObjects(pObjectRoot, pObjects);

  return true;
}

这个函数中有很多代码,所以值得深入探讨。我们将注意 XML 文件中相应的部分,以及我们使用的代码,以获取它。函数的第一部分尝试加载传递给函数的 XML 文件:

// create the XML document
TiXmlDocument xmlDoc;

// load the state file
if(!xmlDoc.LoadFile(stateFile))
{
  cerr << xmlDoc.ErrorDesc() << "\n";
  return false;
}

如果 XML 加载失败,它会显示一个错误来告诉你发生了什么。接下来,我们必须获取 XML 文件的根节点:

// get the root element
TiXmlElement* pRoot = xmlDoc.RootElement(); // <STATES>

文件中的其余节点都是这个根节点的子节点。现在我们必须获取我们正在解析的状态的根节点;比如说,我们正在寻找 MENU

// declare the states root node
TiXmlElement* pStateRoot = 0;
// get this states root node and assign it to pStateRoot
for(TiXmlElement* e = pRoot->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
{
  if(e->Value() == stateID)
  {
    pStateRoot = e;
  }
}

这段代码遍历根节点的每个直接子节点,并检查其名称是否与 stateID 相同。一旦找到正确的节点,它就将其分配给 pStateRoot。现在我们有了我们想要解析的状态的根节点。

<MENU> // the states root node

现在我们有了状态根节点的指针,我们可以开始从中获取值。首先,我们想要从文件中加载纹理,所以我们使用之前找到的 pStateRoot 对象的子节点查找 <TEXTURE> 节点:

// pre declare the texture root
TiXmlElement* pTextureRoot = 0;

// get the root of the texture elements
for(TiXmlElement* e = pStateRoot->FirstChildElement(); e != NULL;
e = e->NextSiblingElement())
{
  if(e->Value() == string("TEXTURES"))
  {
    pTextureRoot = e;
  }
}

一旦找到 <TEXTURE> 节点,我们可以将其传递给私有的 parseTextures 函数(我们稍后会介绍它)。

parseTextures(pTextureRoot, std::vector<std::string> *pTextureIDs);

然后该函数继续搜索 <OBJECT> 节点,一旦找到,它就将其传递给私有的 parseObjects 函数。我们还传递了 pObjects 参数:

  // pre declare the object root node
  TiXmlElement* pObjectRoot = 0;

  // get the root node and assign it to pObjectRoot
  for(TiXmlElement* e = pStateRoot->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
  {
    if(e->Value() == string("OBJECTS"))
    {
      pObjectRoot = e;
    }
  }
  parseObjects(pObjectRoot, pObjects);
  return true;
}

到目前为止,我们的状态已经解析完毕。现在我们可以介绍两个私有函数,从 parseTextures 开始。

void StateParser::parseTextures(TiXmlElement* pStateRoot, std::vector<std::string> *pTextureIDs)
{
  for(TiXmlElement* e = pStateRoot->FirstChildElement(); e != 
  NULL; e = e->NextSiblingElement())
  {
    string filenameAttribute = e->Attribute("filename");
    string idAttribute = e->Attribute("ID");
    pTextureIDs->push_back(idAttribute); // push into list

    TheTextureManager::Instance()->load(filenameAttribute, 
    idAttribute, TheGame::Instance()->getRenderer());
  }
}

此函数从 XML 此部分的每个纹理值中获取 filenameID 属性:

<TEXTURES>
  <texture filename="button.png" ID="playbutton"/>
  <texture filename="exit.png" ID="exitbutton"/>
</TEXTURES>

然后将它们添加到 TextureManager

TheTextureManager::Instance()->load(filenameAttribute, idAttribute, TheGame::Instance()->getRenderer());

parseObjects函数相当复杂。它使用我们的GameObjectFactory函数创建对象,并从 XML 文件的这部分读取:

<OBJECTS>
  <object type="MenuButton" x="100" y="100" width="400" 
  height="100" textureID="playbutton" numFrames="0" 
  callbackID="1"/>
  <object type="MenuButton" x="100" y="300" width="400" 
  height="100" textureID="exitbutton" numFrames="0" 
  callbackID="2"/>
</OBJECTS>

parseObjects函数的定义如下:

void StateParser::parseObjects(TiXmlElement *pStateRoot, 
std::vector<GameObject *> *pObjects)
{
  for(TiXmlElement* e = pStateRoot->FirstChildElement(); e != 
  NULL; e = e->NextSiblingElement())
  {
    int x, y, width, height, numFrames, callbackID, animSpeed;
    string textureID;

    e->Attribute("x", &x);
    e->Attribute("y", &y);
    e->Attribute("width",&width);
    e->Attribute("height", &height);
    e->Attribute("numFrames", &numFrames);
    e->Attribute("callbackID", &callbackID);
    e->Attribute("animSpeed", &animSpeed);

    textureID = e->Attribute("textureID");

    GameObject* pGameObject = TheGameObjectFactory::Instance()
    ->create(e->Attribute("type"));
    pGameObject->load(new LoaderParams
    (x,y,width,height,textureID,numFrames,callbackID, animSpeed));
    pObjects->push_back(pGameObject);
  }
}

首先,我们从当前节点获取所需的任何值。由于 XML 文件是纯文本,我们无法直接从文件中获取整数或浮点数。TinyXML 有函数可以让你传入想要设置的值和属性名称。例如:

e->Attribute("x", &x);

这将变量x设置为属性"x"中包含的值。接下来是使用工厂创建一个GameObject ***** 类:

GameObject* pGameObject = TheGameObjectFactory::Instance()->create(e->Attribute("type"));

我们传递type属性的值,并使用它从工厂创建正确的对象。之后,我们必须使用GameObjectload函数来设置从 XML 文件加载的值。

pGameObject->load(new LoaderParams(x,y,width,height,textureID,numFrames,callbackID));

最后,我们将pGameObject推入pObjects数组,这实际上是指向当前状态对象向量的指针。

pObjects->push_back(pGameObject);

从 XML 文件加载菜单状态

我们现在已经有了大部分状态加载代码,并可以在MenuState类中使用它。首先,我们必须做一些前期工作,并设置一种新的方法来分配回调到我们的MenuButton对象,因为这不是我们可以从 XML 文件传递进来的东西。我们将采取的方法是给任何想要使用回调的对象在 XML 文件中赋予一个名为callbackID的属性。其他对象不需要这个值,LoaderParams将使用默认值0MenuButton类将使用这个值,并从它的LoaderParams中拉取它,如下所示:

void MenuButton::load(const LoaderParams *pParams)
{
  SDLGameObject::load(pParams);
  m_callbackID = pParams->getCallbackID();
  m_currentFrame = MOUSE_OUT;
}

MenuButton类还需要两个其他函数,一个用于设置回调函数,另一个用于返回其回调 ID:

void setCallback(void(*callback)()) { m_callback = callback;}
int getCallbackID() { return m_callbackID; }

接下来,我们必须创建一个设置回调函数的函数。任何使用具有回调的对象的状态都需要这个函数的实现。最可能具有回调的状态是菜单状态,因此我们将我们的MenuState类重命名为MainMenuState,并将MenuState作为一个从GameState扩展的抽象类。该类将声明一个为任何需要它的项目设置回调的函数,并且它还将有一个Callback对象的向量作为成员;这将在每个状态的setCallbacks函数中使用。

class MenuState : public GameState
{
  protected:

  typedef void(*Callback)();
  virtual void setCallbacks(const std::vector<Callback>& callbacks) 
  = 0;

  std::vector<Callback> m_callbacks;
};

MainMenuState类(之前称为MenuState)现在将从这个MenuState类派生。

#include "MenuState.h"
#include "GameObject.h"

class MainMenuState : public MenuState
{
  public:

  virtual void update();
  virtual void render();

  virtual bool onEnter(); 
  virtual bool onExit(); 

  virtual std::string getStateID() const { return s_menuID; }

  private:

  virtual void setCallbacks(const std::vector<Callback>& 
  callbacks);

  // call back functions for menu items
  static void s_menuToPlay();
  static void s_exitFromMenu();

  static const std::string s_menuID;

  std::vector<GameObject*> m_gameObjects;
};

由于MainMenuState现在从MenuState派生,它当然必须声明和定义setCallbacks函数。我们现在可以使用我们的状态解析来加载MainMenuState类。我们的onEnter函数现在将看起来像这样:

bool MainMenuState::onEnter()
{
  // parse the state
  StateParser stateParser;
  stateParser.parseState("test.xml", s_menuID, &m_gameObjects, 
  &m_textureIDList);

  m_callbacks.push_back(0); //pushback 0 callbackID start from 1
  m_callbacks.push_back(s_menuToPlay);
  m_callbacks.push_back(s_exitFromMenu);

  // set the callbacks for menu items
  setCallbacks(m_callbacks);

  std::cout << "entering MenuState\n";
  return true;
}

我们创建了一个状态解析器,然后使用它来解析当前状态。我们将任何回调函数推入从MenuState继承的m_callbacks数组中。现在我们需要定义setCallbacks函数:

void MainMenuState::setCallbacks(const std::vector<Callback>& 
callbacks)
{
  // go through the game objects
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    // if they are of type MenuButton then assign a callback 
    based on the id passed in from the file
    if(dynamic_cast<MenuButton*>(m_gameObjects[i]))
    {
      MenuButton* pButton = 
      dynamic_cast<MenuButton*>(m_gameObjects[i]);
      pButton->setCallback(callbacks[pButton->getCallbackID()]);
    }
  }
}

我们使用 dynamic_cast 来检查对象是否是 MenuButton 类型;如果是,我们就进行实际的转换,然后使用对象的 callbackID 作为 callbacks 向量的索引,并分配正确的函数。虽然这种方法分配回调可能看起来不是很灵活,并且可能实现得更好,但它确实有一个优点;它允许我们将回调保留在它们需要被调用的状态中。这意味着我们不需要一个包含所有回调的大头文件。

我们需要做的最后一个更改是向每个状态添加一个纹理 ID 列表,这样我们就可以清除为该状态加载的所有纹理。打开 GameState.h 文件,我们将添加一个 protected 变量。

protected:
std::vector<std::string> m_textureIDList;

我们将在 onEnter 中的状态解析器中传递这个类型,然后我们可以在每个状态中的 onExit 函数中清除任何已使用的纹理,如下所示:

// clear the texture manager
for(int i = 0; i < m_textureIDList.size(); i++)
{
  TheTextureManager::Instance()->
  clearFromTextureMap(m_textureIDList[i]);
}

在我们开始运行游戏之前,我们需要将我们的 MenuButton 类型注册到 GameObjectFactory 中。打开 Game.cpp 文件,在 Game::init 函数中我们可以注册该类型。

TheGameObjectFactory::Instance()->registerType("MenuButton", new MenuButtonCreator());

现在,我们可以运行游戏并看到我们的完全数据驱动的 MainMenuState

从 XML 文件加载其他状态

我们的 MainMenuState 类现在从 XML 文件加载。我们需要让我们的其他状态也这样做。我们将只涵盖已更改的代码,所以假设在阅读本节时其他内容保持不变。

加载游戏状态

我们将从 PlayState.cpp 和它的 onEnter 函数开始。

bool PlayState::onEnter()
{
  // parse the state
  StateParser stateParser;
  stateParser.parseState("test.xml", s_playID, &m_gameObjects, 
  &m_textureIDList);

  std::cout << "entering PlayState\n";
  return true;
}

我们还必须在 onExit 函数中添加我们之前在 MainMenuState 中有的新纹理清除代码。

// clear the texture manager
for(int i = 0; i < m_textureIDList.size(); i++)
{
  TheTextureManager::Instance()->
  clearFromTextureMap(m_textureIDList[i]);
}

这些是我们在这里需要做的唯一更改,但我们还必须更新我们的 XML 文件,以便在 PlayState 中加载一些内容。

<PLAY>
<TEXTURES>
  <texture filename="helicopter.png" ID="helicopter"/>
  <texture filename="helicopter2.png" ID="helicopter2"/>
</TEXTURES>

<OBJECTS>
  <object type="Player" x="500" y="100" width="128" height="55" 
  textureID="helicopter" numFrames="4"/>
  <object type="Enemy" x="100" y="100" width="128" height="55" 
  textureID="helicopter2" numFrames="4"/>
</OBJECTS>
</PLAY>

我们的 Enemy 对象现在需要在它的加载函数中设置其初始速度,而不是在构造函数中,否则 load 函数将覆盖它。

void Enemy::load(const LoaderParams *pParams)
{
  SDLGameObject::load(pParams);
  m_velocity.setY(2);
}

最后,我们必须将这些对象注册到工厂中。我们可以在 Game::init 函数中这样做,就像 MenuButton 对象一样。

TheGameObjectFactory::Instance()->registerType("Player", new PlayerCreator());
TheGameObjectFactory::Instance()->registerType("Enemy", new EnemyCreator());

加载暂停状态

我们的 PauseState 类现在必须从 MenuState 继承,因为我们希望它包含回调函数。我们必须更新 PauseState.h 文件,首先从 MenuState 继承。

class PauseState : public MenuState

我们还必须声明 setCallbacks 函数。

virtual void setCallbacks(const std::vector<Callback>& callbacks);

现在,我们必须更新 PauseState.cpp 文件,从 onEnter 函数开始。

bool PauseState::onEnter()
{
  StateParser stateParser;
  stateParser.parseState("test.xml", s_pauseID, &m_gameObjects, 
  &m_textureIDList);

  m_callbacks.push_back(0);
  m_callbacks.push_back(s_pauseToMain);
  m_callbacks.push_back(s_resumePlay);

  setCallbacks(m_callbacks);

  std::cout << "entering PauseState\n";
  return true;
}

setCallbacks 函数与 MainMenuState 完全相同。

void PauseState::setCallbacks(const std::vector<Callback>& 
callbacks)
{
  // go through the game objects
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    // if they are of type MenuButton then assign a callback based 
    on the id passed in from the file
    if(dynamic_cast<MenuButton*>(m_gameObjects[i]))
    {
      MenuButton* pButton = 
      dynamic_cast<MenuButton*>(m_gameObjects[i]);
      pButton->setCallback(callbacks[pButton->getCallbackID()]);
    }
  }
}

最后,我们必须将纹理清除代码添加到 onExit 中。

// clear the texture manager
for(int i = 0; i < m_textureIDList.size(); i++)
{
  TheTextureManager::Instance()->
  clearFromTextureMap(m_textureIDList[i]);
}

然后更新我们的 XML 文件以包含此状态。

<PAUSE>
<TEXTURES>
  <texture filename="resume.png" ID="resumebutton"/>
  <texture filename="main.png" ID="mainbutton"/>
</TEXTURES>

<OBJECTS>
  <object type="MenuButton" x="200" y="100" width="200" 
  height="80" textureID="mainbutton" numFrames="0" 
  callbackID="1"/>
  <object type="MenuButton" x="200" y="300" width="200" 
  height="80" textureID="resumebutton" numFrames="0" 
  callbackID="2"/>
</OBJECTS>
</PAUSE>

加载游戏结束状态

我们最后一个状态是 GameOverState。这与其他状态非常相似,我们只涵盖已更改的部分。由于我们希望 GameOverState 处理回调,它现在将从 MenuState 继承。

class GameOverState : public MenuState

然后,我们将声明 setCallbacks 函数。

virtual void setCallbacks(const std::vector<Callback>& callbacks);

onEnter 函数现在应该看起来非常熟悉了。

bool GameOverState::onEnter()
{
  // parse the state
  StateParser stateParser;
  stateParser.parseState("test.xml", s_gameOverID, &m_gameObjects, 
  &m_textureIDList);
  m_callbacks.push_back(0);
  m_callbacks.push_back(s_gameOverToMain);
  m_callbacks.push_back(s_restartPlay);

  // set the callbacks for menu items
  setCallbacks(m_callbacks);

  std::cout << "entering PauseState\n";
  return true;
}

纹理清除方法与之前的状态相同,所以我们将其留给你自己实现。实际上,onExit在状态之间看起来非常相似,因此为GameState创建一个通用实现是一个好主意;我们再次将其留给你。

你可能已经注意到了onEnter函数之间的相似性。有一个默认的onEnter实现会很好,但不幸的是,由于需要指定不同的回调函数,我们的回调实现将不允许这样做,这也是其主要缺陷之一。

我们的AnimatedGraphic类现在需要在它的load函数中从LoaderParams获取animSpeed值。

void AnimatedGraphic::load(const LoaderParams *pParams)
{
  SDLGameObject::load(pParams);
  m_animSpeed = pParams->getAnimSpeed();
}

我们还必须将此类型注册到GameObjectFactory

TheGameObjectFactory::Instance()->registerType("AnimatedGraphic", new AnimatedGraphicCreator());

最后,我们可以更新 XML 文件以包括此状态:

<GAMEOVER>
<TEXTURES>
  <texture filename="gameover.png" ID="gameovertext"/>
  <texture filename="main.png" ID="mainbutton"/>
  <texture filename="restart.png" ID="restartbutton"/>
</TEXTURES>

<OBJECTS>
  <object type="AnimatedGraphic" x="200" y="100" width="190" 
  height="30" textureID="gameovertext" numFrames="2" 
  animSpeed="2"/>
  <object type="MenuButton" x="200" y="200" width="200" 
  height="80" textureID="mainbutton" numFrames="0" 
  callbackID="1"/>
  <object type="MenuButton" x="200" y="300" width="200" 
  height="80" textureID="restartbutton" numFrames="0" 
  callbackID="2"/>
</OBJECTS>
</GAMEOVER>

现在,我们所有的状态都是从 XML 文件中加载的,其中最大的好处之一是当你更改值时,你不需要重新编译游戏。你可以更改 XML 文件以移动位置或为对象使用不同的纹理;如果 XML 已保存,则只需再次运行游戏,它将使用新值。这对我们来说节省了大量时间,并使我们能够完全控制状态,而无需重新编译我们的游戏。

摘要

从外部文件加载数据是编程游戏中的一个极其有用的工具。这一章节使我们的游戏能够实现这一点,并将其应用于我们现有的所有状态。我们还介绍了如何使用工厂在运行时动态创建对象。下一章将涵盖更多数据驱动的设计以及瓦片地图,这样我们就可以真正地将游戏解耦,并允许它使用外部源而不是硬编码的值。

第七章。创建和显示瓦片地图

大多数你过去玩过的二维游戏都使用了瓦片地图。这是一种极其高效且快速的方式来开发复杂的二维关卡或场景。即使一个游戏有更复杂的图形内容,它也可能会以某种方式使用瓦片。在本章中,我们将使用tiled 地图编辑器,这是一个由 Thorbjørn Lindeijer 和一个大型开源社区创建的开源和跨平台工具。它可在www.mapeditor.org/找到。我们将把这个工具作为我们的关卡编辑器,并使用它来创建地图以及在这些地图中放置我们的对象。

在本章中,我们将涵盖:

  • 什么是瓦片地图

  • 瓦片图看起来像什么

  • 使用 tiled 地图编辑器创建我们的地图

  • 从 tiled 地图中解析状态

  • 在 SDL 2.0 中加载和显示基于瓦片的地图

什么是瓦片地图?

如果你玩过很多二维游戏,那么你对瓦片地图会非常熟悉。我们将从以下屏幕截图中的示例开始:

什么是瓦片地图?

这个 20 x 15 的瓦片地图是使用以下屏幕截图所示的瓦片集制作的。

什么是瓦片地图?

如你所见,这种瓦片系统的一个巨大优势是,你可以从相对较小的图像文件中创建大型地图。瓦片地图本质上是一个多维数组,其中的 ID 告诉我们想要在每个位置绘制瓦片集中的哪个部分。再次查看带有 ID 的图像将有所帮助,如下面的屏幕截图所示:

什么是瓦片地图?

这里是带有其 ID 的瓦片集,如前面的屏幕截图所示。

什么是瓦片地图?

要绘制地图,我们遍历列数和行数,使用其 ID 获取正确的瓦片,并将其绘制到屏幕上。任何 ID 为零的瓦片将不会绘制(一个空白瓦片)。这可以在前面的屏幕截图中看到。

熟悉 Tiled 应用程序

Tiled 是一个非常用户友好的应用程序,可以大大加快我们的开发时间。一旦你下载并安装了应用程序,打开它,你将看到如下所示的用户界面:

熟悉 Tiled 应用程序

在右侧,我们有瓦片集视图;左侧将包含我们的瓦片地图。首先,我们必须创建一个新的地图,这可以通过导航到文件 | 新建…Ctrl + N来完成。这将打开新的地图对话框,如下面的屏幕截图所示:

熟悉 Tiled 应用程序

在这里,我们可以定义我们地图的大小和类型。我们只将使用正交瓦片地图(与等距地图相对),因此请创建一个宽度为 20 个瓦片、高度为 15 个瓦片的正交瓦片地图,瓦片宽度和高度都设置为 32 像素。现在我们可以在 UI 的左侧看到我们的瓦片地图(Ctrl + G 将显示网格)。Tiled 还会自动为我们创建一个名为 Tile Layer 1 的图层(在右侧的 图层 视图中可见),如下面的截图所示:

熟悉 Tiled 应用程序

我们不会处理任何地形,因此我们可以通过导航到 视图 | 地形 并取消选中来关闭该选项卡。将此地图保存为 map1.tmx,与我们的其他游戏资源在同一位置。如果您打开此文件,您会看到它实际上只是一个 XML 文件:

<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0" orientation="orthogonal" width="20" height="15" tilewidth="32" tileheight="32">
    <layer name="Tile Layer 1" width="20" height="15">
        <data encoding="base64" compression="zlib">
          eJxjYBgFo2AUjIKhAQAEsAAB
        </data>
    </layer>
</map>

这一切看起来都应该非常熟悉。Tiled 有几种不同的压缩算法,可以用来存储我们地图的瓦片 ID。前面的文件使用了 zlib 压缩算法 以及 base64 编码,正如您所看到的,这给出了非常好的结果:

<data encoding="base64" compression="zlib">
  eJxjYBgFo2AUjIKhAQAEsAAB
</data>

如果我们将使用 base64 编码且没有压缩的相同地图进行比较,我们可以看到,为了解压和解析 zlib 压缩所需的额外工作绝对物有所值。以下是未压缩的地图:

 <data encoding="base64">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
</data>

一旦我们开始解析瓦片地图,我们将更深入地介绍这一点,但到目前为止,让我们看看如何添加一个瓦片集。导航到 地图 | 新建瓦片集…,它将弹出一个新的 瓦片集 对话框,如下面的截图所示:

熟悉 Tiled 应用程序

我们将开始的瓦片集是 blocks1.png,如下面的截图所示,可在源代码下载中找到。

熟悉 Tiled 应用程序

将图像复制到游戏资源位置,然后我们可以在 新建瓦片集 对话框中浏览到它。这个瓦片集在外围有 2 像素宽的边框,每个瓦片之间有 2 像素的间距;每个瓦片是 32 x 32 像素。一旦设置了这些值,点击 确定,瓦片集将出现在右侧的 瓦片集 视图中。现在我们可以开始使用提供的工具构建我们的地图,如下面的截图所示:

熟悉 Tiled 应用程序

红色突出显示的是我们的基本工具。印章工具将瓦片集中的所选瓦片添加到指定位置,油漆桶工具用瓦片集中的所选瓦片填充一个区域,而橡皮擦工具当然用于擦除。我们可以一次选择一个或多个瓦片,如下面的截图所示:

熟悉 Tiled 应用程序

通过构建一个简单的地图来熟悉这些工具。一旦地图保存,我们将看到瓦片集已被添加到地图文件中:

<tileset firstgid="1" name="blocks1" tilewidth="32" tileheight="32" spacing="2" margin="2">
  <image source="assets/blocks1.png" width="614" height="376"/>
</tileset>

firstgid属性是使用此瓦片集的第一个瓦片 ID。如果我们有多个瓦片集,它们将各自带有自己的firstgid属性,这样我们就可以知道哪些瓦片 ID 应该与哪个瓦片集关联;我们将在解析我们的地图时更详细地介绍这一点。向我们的地图添加另一个瓦片集blocks2.png(也包含在源代码下载中),然后我们将进入在游戏中绘制它的步骤。

解析和绘制瓦片地图

现在我们相对熟悉了在 Tiled 应用程序中创建瓦片地图,我们将继续解析它们并在我们的游戏中绘制它们。我们将创建许多新的类,从名为Level的类开始,这个类将存储我们的瓦片集,并绘制和更新我们的单独层。让我们继续在我们的项目中创建Level.h并添加以下代码:

class Level
{
  public:

  Level();
  ~Level() {}

  void update();
  void render();
};

我们还将在文件顶部定义一个名为Tilesetstruct

struct Tileset
{
  int firstGridID;
  int tileWidth;
  int tileHeight;
  int spacing;
  int margin;
  int width;
  int height;
  int numColumns;
  std::string name;
};

这个struct包含了我们需要了解的关于我们的瓦片集的任何信息。现在,我们的Level类也将包含一个Tileset对象的向量:

private:

  std::vector<Tileset> m_tilesets;

接下来,我们将创建一个公共的获取函数,该函数返回指向这个Tileset向量的指针:

std::vector<Tileset>* getTilesets() 
{ 
    return &m_tilesets;  
}

当我们到达加载地图时,我们将把这个传递给我们的解析器。

我们接下来要创建的类是一个名为Layer的抽象基类。我们所有的层类型都将从这个类派生。创建Layer.h并添加以下代码:

class Layer
{
  public:

  virtual void render() = 0;
  virtual void update() = 0;

  protected:

  virtual ~Layer() {}
};

现在我们有了Layer类,我们将在Level类中存储一个Layer*对象的向量。回到Level.h,添加我们的向量:

std::vector<Layer*> m_layers;

以及一个获取函数:

std::vector<Layer*>* getLayers() 
{ 
    return &m_layers; 
}

现在我们已经建立了基本的Level类;它的目的是存储、绘制和更新我们的层。我们将在Level.cpp文件中定义Level的函数:

void Level::render()
{
  for(int i = 0; i < m _layers.size(); i++)
  {
    m_layers[i]->render();
  }
}
void Level::update()
{
  for(int i = 0; i < m_layers.size(); i++)
  {
    m_layers[i]->update();
  }
}

创建 TileLayer 类

我们的第一层类型将是一个TileLayer。这种类型的层完全由瓦片组成,不包含其他任何内容。我们已经在 Tiled 应用程序中创建了一个类似的层。创建TileLayer.h,然后我们可以开始编写这个类:

class TileLayer : public Layer
{
  public:

    TileLayer(int tileSize, const std::vector<Tileset> &tilesets);

    virtual void update();
    virtual void render();

    void setTileIDs(const std::vector<std::vector<int>>& data)  
    {  
        m_tileIDs = data;  
    }

    void setTileSize(int tileSize)  
    {  
        m_tileSize = tileSize;  
    }

    Tileset getTilesetByID(int tileID);

  private:

    int m_numColumns;
    int m_numRows;
    int m_tileSize;

    Vector2D m_position;
    Vector2D m_velocity;

    const std::vector<Tileset> &m_tilesets;

    std::vector<std::vector<int>> m_tileIDs;
};

这节课并没有什么太复杂的;它为我们的瓦片层存储数据。当开始滚动地图时,会使用Vector2D变量。我们现在不会正确地定义这个类的函数,但你需要创建空的定义,并在TileLayer.cpp文件中定义向量常量。

创建 LevelParser 类

现在我们已经建立了基本的等级和层类,我们可以继续创建解析我们的.tmx文件的解析器,并从它们中创建等级。创建LevelParser.h

Class LevelParser
{
  public:

    Level* parseLevel(const char* levelFile);

  private:

    void parseTilesets(TiXmlElement* pTilesetRoot,std::vector<Tileset>* pTilesets);

    void parseTileLayer(TiXmlElement* pTileElement,std::vector<Layer*> *pLayers, const std::vector<Tileset>*pTilesets);

    int m_tileSize;
    int m_width;
    int m_height;
};

parseLevel函数是我们每次想要创建一个等级时都会调用的函数。为了确保必须使用此函数来创建Level对象,我们将Level类的构造函数设为私有,并使其成为LevelParser的友元类:

private:

  friend class LevelParser;
  Level();

现在LevelParser可以访问Level的私有构造函数,并可以返回新的实例。我们现在可以定义parseLevel函数,然后逐步进行。创建LevelParser.cpp并定义parseLevel函数如下:

Level* LevelParser::parseLevel(const char *levelFile)
{
    // create a TinyXML document and load the map XML
    TiXmlDocument levelDocument;
    levelDocument.LoadFile(levelFile);

    // create the level object
    Level* pLevel = new Level();

    // get the root node 
    TiXmlElement* pRoot = levelDocument.RootElement();

    pRoot->Attribute("tilewidth", &m_tileSize);
    pRoot->Attribute("width", &m_width);
    pRoot->Attribute("height", &m_height);

    // parse the tilesets
    for(TiXmlElement* e = pRoot->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
    {
      if(e->Value() == std::string("tileset"))
      {
         parseTilesets(e, pLevel->getTilesets());
      }
    }

    // parse any object layers
    for(TiXmlElement* e = pRoot->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
    {
      if(e->Value() == std::string("layer"))
      {
        parseTileLayer(e, pLevel->getLayers(), pLevel->getTilesets());
      }
    }

    return pLevel;
}

我们在上一章中已经介绍了 XML 文件和 TinyXML,所以在这里我不会再详细说明。函数的第一部分获取根节点:

// get the root node 
TiXmlElement* pRoot = levelDocument.RootElement();

我们可以从地图文件中看到这个节点有几个属性:

<map version="1.0" orientation="orthogonal" width="60" height="15" tilewidth="32" tileheight="32">

我们使用 TinyXML 的Attribute函数获取这些值,并设置LevelParser的成员变量:

pRoot->Attribute("tilewidth", &m_tileSize);
pRoot->Attribute("width", &m_width);
pRoot->Attribute("height", &m_height);

接下来我们必须检查任何瓦片集节点,并解析它们,使用我们新创建的Level实例的getTilesets函数将Tileset向量传递进去:

// parse the tilesets
for(TiXmlElement* e = pRoot->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
{
  if(e->Value() == std::string("tileset"))
  {
    parseTilesets(e, pLevel->getTilesets());
  }
}

最后,我们可以检查任何瓦片层,然后解析它们,再次使用我们的pLevel对象中的 getter 函数,然后将其返回:

// parse any object layers
for(TiXmlElement* e = pRoot->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
{
  if(e->Value() == std::string("layer"))
  {
    parseTileLayer(e, pLevel->getLayers(), pLevel->getTilesets());
  }
}

return pLevel;
}

你可以看到这个函数与我们上一章中的parseState函数非常相似。现在我们必须定义parseTilesetsparseTileLayer函数。

解析瓦片集

由于我们的TextureManager类,解析瓦片集实际上非常简单:

void LevelParser::parseTilesets(TiXmlElement* pTilesetRoot, std::vector<Tileset>* pTilesets)
{
  // first add the tileset to texture manager
    TheTextureManager::Instance()->load(pTilesetRoot->FirstChildElement()->Attribute("source"), pTilesetRoot->Attribute("name"), TheGame::Instance()->getRenderer());

  // create a tileset object
  Tileset tileset;
  pTilesetRoot->FirstChildElement()->Attribute("width", &tileset.width);
  pTilesetRoot->FirstChildElement()->Attribute("height", &tileset.height);
  pTilesetRoot->Attribute("firstgid", &tileset.firstGridID);
  pTilesetRoot->Attribute("tilewidth", &tileset.tileWidth);
  pTilesetRoot->Attribute("tileheight", &tileset.tileHeight);
  pTilesetRoot->Attribute("spacing", &tileset.spacing);
  pTilesetRoot->Attribute("margin", &tileset.margin);
  tileset.name = pTilesetRoot->Attribute("name");

  tileset.numColumns = tileset.width / (tileset.tileWidth + tileset.spacing);

  pTilesets->push_back(tileset);
}

我们使用其属性将瓦片集添加到TextureManager类中,然后创建一个Tileset对象并将其推入pTilesets数组。pTilesets数组实际上是我们之前在parseLevel函数中创建的pLevel对象数组的指针。以下是我们的第一个瓦片集,以便您可以与前面的函数一起查看:

<tileset firstgid="1" name="blocks1" tilewidth="32" tileheight="32"spacing="2" margin="2">
  <image source="blocks1.png" width="614" height="376"/>
</tileset>

解析瓦片层

由于我们的瓦片 ID 经过了压缩和编码,这个函数实际上相当复杂。我们将利用几个不同的库来帮助我们解码和解压缩我们的数据,其中第一个是一个Base64解码器。我们将使用由 René Nyffenegger 创建的解码器,可以从源代码下载中获取,也可以从github.com/ReneNyffenegger/development_misc/tree/master/base64获取。base64.hbase64.cpp文件可以直接添加到项目中。

我们需要的第二个库是zlib库,编译版本可在www.zlib.net找到,可以像添加其他库一样轻松地将其添加到项目中。一旦这些库可用,我们就可以开始解析我们的瓦片了:

void LevelParser::parseTileLayer(TiXmlElement* pTileElement, std::vector<Layer*> *pLayers, const std::vector<Tileset>* pTilesets)
{
  TileLayer* pTileLayer = new TileLayer(m_tileSize, *pTilesets);

    // tile data
  std::vector<std::vector<int>> data;

  std::string decodedIDs;
  TiXmlElement* pDataNode;

  for(TiXmlElement* e = pTileElement->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
  {
    if(e->Value() == std::string("data"))
    {
      pDataNode = e;
    }
  }

  for(TiXmlNode* e = pDataNode->FirstChild(); e != NULL; e = e->NextSibling())
  {
    TiXmlText* text = e->ToText();
    std::string t = text->Value();
    decodedIDs = base64_decode(t);
  }

    // uncompress zlib compression
  uLongf numGids = m_width * m_height * sizeof(int);
  std::vector<unsigned> gids(numGids);
  uncompress((Bytef*)&gids[0], &numGids,(const Bytef*)decodedIDs.c_str(), decodedIDs.size());

  std::vector<int> layerRow(m_width);

  for(int j = 0; j < m_height; j++)
  {
    data.push_back(layerRow);
  }

  for(int rows = 0; rows < m_height; rows++)
  {
    for(int cols = 0; cols < m_width; cols++)
    {
      data[rows][cols] = gids[rows * m_width + cols];
    }
  }

  pTileLayer->setTileIDs(data);

  pLayers->push_back(pTileLayer);
}

让我们逐步分析这个函数。首先我们创建一个新的TileLayer实例:

TileLayer* pTileLayer = new TileLayer(m_tileSize, *pTilesets);

接下来我们声明一些需要的变量;一个多维数组,用于存储我们最终解码和未压缩的瓦片数据,一个std::string,它将是我们 base64 解码后的信息,以及一旦找到 XML 节点后存储该节点的地方:

// tiledata
std::vector<std::vector<int>> data;

std::string decodedIDs;
TiXmlElement* pDataNode;

我们可以像之前做的那样搜索我们需要的节点:

for(TiXmlElement* e = pTileElement->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
{
    if(e->Value() == std::string("data"))
    {
      pDataNode = e;
    }
}

一旦我们找到了正确的节点,我们就可以从其中获取文本(我们的编码/压缩数据),并使用 base64 解码器对其进行解码:

for(TiXmlNode* e = pDataNode->FirstChild(); e != NULL; e = e->NextSibling())
{
  TiXmlText* text = e->ToText();
  std::string t = text->Value();
  decodedIDs = base64_decode(t);
}

我们的 decodedIDs 变量现在是一个 base64 解码后的 string。下一步是使用 zlib 库来解压缩我们的数据,这是通过 uncompress 函数完成的:

// uncompress zlib compression
uLongf sizeofids = m_width * m_height * sizeof(int);

std::vector<int> ids(m_width * m_height);

uncompress((Bytef*)&ids[0], &sizeofids,(const Bytef*)decodedIDs.c_str(), decodedIDs.size());

uncompress 函数接受一个 Bytef* 数组(在 zlib 的 zconf.h 中定义)作为目标缓冲区;我们使用一个 int 值的 std::vector 并将其转换为 Bytef* 数组。第二个参数是目标缓冲区的总大小,在我们的例子中,我们使用一个 int 值的 vector,使得总大小为行数乘以列数乘以 int 的大小;或者 m_width * m_height * sizeof(int)。然后,我们将解码后的字符串及其大小作为最后两个参数传递。现在,我们的 ids 向量包含了所有的瓦片 ID,函数继续设置我们用于填充瓦片 ID 的数据向量的大小:

std::vector<int> layerRow(m_width);
for(int j = 0; j < m_height; j++)
{
  data.push_back(layerRow);
}

我们现在可以用正确的值填充我们的数据数组:

for(int rows = 0; rows < m_height; rows++)
{
  for(int cols = 0; cols < m_width; cols++)
  {
    data[rows][cols] = ids[rows * m_width + cols];
  }
}

最后,我们设置这个层的瓦片数据,然后将层推入 Level 的层数组中。

我们现在必须在 Level.cpp 文件中定义函数。

绘制地图

我们终于到了可以开始将我们的瓦片绘制到屏幕上的阶段。在之前创建的 TileLayer.cpp 文件中,我们现在需要定义层的函数。从构造函数开始:

TileLayer::TileLayer(int tileSize, const std::vector<Tileset> &tilesets) : m_tileSize(tileSize), m_tilesets(tilesets), m_position(0,0), m_velocity(0,0)
{
  m_numColumns = (TheGame::Instance()->getGameWidth() / m_tileSize);
  m_numRows = (TheGame::Instance()->getGameHeight() / m_tileSize);
}

新的 Game::getGameWidthGame::getGameHeight 函数只是简单的获取器函数,返回在 Game::init 函数中设置的变量:

int getGameWidth() const  
{  
  return m_gameWidth;  
}
int getGameHeight() const  
{  
  return m_gameHeight;  
}

TileLayerupdate 函数使用 velocity 来设置地图的位置;当我们开始滚动地图时,我们将更详细地介绍这一点:

void TileLayer::update()
{
  m_position += m_velocity;
}

render 函数是所有魔法发生的地方:

void TileLayer::render()
{
  int x, y, x2, y2 = 0;

  x = m_position.getX() / m_tileSize;
  y = m_position.getY() / m_tileSize;

  x2 = int(m_position.getX()) % m_tileSize;
  y2 = int(m_position.getY()) % m_tileSize;

  for(int i = 0; i < m_numRows; i++)
  {
    for(int j = 0; j < m_numColumns; j++)
    {
        int id = m_tileIDs[i][j + x];

          if(id == 0)
          {
            continue;
          }

        Tileset tileset = getTilesetByID(id);

        id--;

        TheTextureManager::Instance()->drawTile(tileset.name, 2, 2, (j * m_tileSize) - x2, (i * m_tileSize) - y2, m_tileSize, m_tileSize, (id - (tileset.firstGridID - 1)) / tileset.numColumns, (id - (tileset.firstGridID - 1)) % tileset.numColumns, TheGame::Instance()->getRenderer());
    }
  }
}

你会注意到在 TextureManager 中有一个新的函数,drawTile。这个函数专门用于绘制瓦片,并包括边距和间距值。下面是它的样子:

void TextureManager::drawTile(std::string id, int margin, int spacing, int x, int y, int width, int height, int currentRow, int currentFrame, SDL_Renderer *pRenderer)
{
  SDL_Rect srcRect;
  SDL_Rect destRect;
  srcRect.x = margin + (spacing + width) * currentFrame;
  srcRect.y = margin + (spacing + height) * currentRow;
  srcRect.w = destRect.w = width;
  srcRect.h = destRect.h = height;
  destRect.x = x;
  destRect.y = y;

  SDL_RenderCopyEx(pRenderer, m_textureMap[id], &srcRect,&destRect, 0, 0, SDL_FLIP_NONE);
}

让我们更仔细地看看 render 函数;现在我们将忽略定位代码:

for(int i = 0; i < m_numRows; i++)
{
  for(int j = 0; j < m_numColumns; j++)
  {
    int id = m_tileIDs[i][j + x];

    if(id == 0)
    {
      continue;
    }

    Tilesettileset = getTilesetByID(id);

    id--;

    TheTextureManager::Instance()->drawTile(tileset.name,tileset.margin, tileset.spacing, (j * m_tileSize) - x2, (i *m_tileSize) - y2, m_tileSize, m_tileSize, (id -(tileset.firstGridID - 1)) / tileset.numColumns, (id -(tileset.firstGridID - 1)) % tileset.numColumns,TheGame::Instance()->getRenderer());
  }
}

我们遍历列数和行数:

for(int i = 0; i < m_numRows; i++)
{
  for(int j = 0; j < m_numColumns; j++)
{

这不是完整瓦片 ID 数组中的行数和列数,实际上是我们需要填充游戏大小的列数和行数。我们不希望绘制任何不必要的图形。我们之前在构造函数中获得了这些值:

m_numColumns = (TheGame::Instance()->getGameWidth() / m_tileSize);
m_numRows = (TheGame::Instance()->getGameHeight() / m_tileSize);

接下来,我们从数组中获取当前的瓦片 ID(现在忽略 + x):

int id = m_tileIDs[i + y][j + x];

我们检查瓦片 ID 是否为 0。如果是,那么我们不想绘制任何东西:

if(id == 0)
{
  continue;
}

否则,我们获取正确的 tileset:

Tileset tileset = getTilesetByID(id);

获取 tileset 使用一个非常简单的函数,getTilesetByID,它比较每个 tileset 的 firstgid 值并返回正确的 tileset:

Tileset TileLayer::getTilesetByID(int tileID)
{
  for(int i = 0; i < m_tilesets.size(); i++)
  {
    if( i + 1 <= m_tilesets.size() - 1)
    {
      if(tileID >= m_tilesets[i].firstGridID&&tileID < m_tilesets[i + 1].firstGridID)
      {
        return m_tilesets[i];
      }
    }
    else
    {
      return m_tilesets[i];
    }
  }

  std::cout << "did not find tileset, returning empty tileset\n";
  Tileset t;
  return t;
}

接下来,我们继续绘制瓦片:

id--;

TheTextureManager::Instance()->drawTile(tileset.name, 
  tileset.margin, tileset.spacing, (j * m_tileSize) - x2, (i * 
  m_tileSize) - y2, m_tileSize, m_tileSize, (id - 
  (tileset.firstGridID - 1)) / tileset.numColumns, (id - 
  (tileset.firstGridID - 1)) % tileset.numColumns, 
    TheGame::Instance()->getRenderer());
  }
}

首先,我们将 ID 减小,以便我们可以从 tilesheet 中绘制正确的瓦片,即使它位于位置 0,0。然后,我们使用 drawTile 函数,通过我们之前获取的 tileset 来复制正确的瓦片,设置函数的第一个参数,即纹理的 name。同样,我们可以使用 tileset 来设置接下来的两个参数,marginspacing

tileset.margin, tileset.spacing

下两个参数设置我们要绘制瓦片的起始位置:

(j * m_tileSize) - x2, (i * m_tileSize) - y2

现在忽略x2y2值(它们无论如何都是 0),我们可以将当前x位置设置为当前列乘以瓦片的宽度,将y值设置为当前行乘以瓦片的高度。然后我们设置我们要复制的瓦片的宽度和高度:

m_tileSize, m_tileSize,

最后,我们计算出瓦片在瓦片集中的位置:

(id - (tileset.firstGridID - 1)) / tileset.numColumns, 
(id - (tileset.firstGridID - 1)) % tileset.numColumns,

我们减去firstGridID - 1以允许我们对待每个瓦片集相同,并获得正确的位置。例如,瓦片集的firstGridID可能是 50,当前瓦片 ID 可能是 70。我们知道这实际上将是瓦片集中的第 19 个瓦片(在我们递减 ID 之后)。

最后,我们必须在我们的PlayState类中创建一个级别:

bool PlayState::onEnter()
{
  LevelParser levelParser;
  pLevel = levelParser.parseLevel("assets/map1.tmx");

  std::cout << "entering PlayState\n";
  return true;
}

接下来,在render函数中绘制它,并在update函数中也做同样的操作:

void PlayState::render()
{
  pLevel->render();
}

我们还必须注释掉任何使用对象(如collisionChecks)的函数,因为我们还没有任何对象,这将导致运行时错误。运行我们的游戏,你会看到我们的瓦片地图被绘制到屏幕上。

滚动瓦片地图

我们目前创建的内容对于在一个区域发生游戏(该区域的大小与我们的窗口大小相同)是不错的,但如果我们想要有大型地图,这些地图是开放的,可以探索的,那会怎样呢?这就是滚动发挥作用的地方。我们实际上已经实现了这个功能,但还没有一步一步地介绍它,也没有看到它的实际效果。现在让我们来做这件事。

首先,我们必须在 Tiled 应用程序中调整我们的地图大小。导航到地图 | 调整地图大小…将允许我们这样做。保持地图的高度为 15,将宽度更改为 60。用你喜欢的瓦片填满剩余的方块。地图将看起来像以下截图:

滚动瓦片地图

保存地图,我们就可以查看代码:

int x, y, x2, y2 = 0;

x = m_position.getX() / m_tileSize;
y = m_position.getY() / m_tileSize;

x2 = int(m_position.getX()) % m_tileSize;
y2 = int(m_position.getY()) % m_tileSize;

当滚动地图时,我们实际上不会移动它超过一个瓦片的宽度;我们使用位置值来确定我们应该从瓦片 ID 数组中开始绘制地图的位置。要获取x值,我们可以使用移动到的位置除以瓦片的宽度。例如,假设我们将地图移动到x 位置 = 100,瓦片宽度为 32;这将给出 3.125 的值,但由于我们使用的是int值,这将简单地是 3。我们现在知道我们应该从地图上的第三个瓦片开始绘制。y位置的工作方式相同。

为了确保我们的瓦片绘制不会在瓦片之间跳跃,而是平滑滚动,我们使用模运算来获取我们需要移动的剩余瓦片数量,并使用这个值来定位我们的地图:

x2 = int(m_position.getX()) % m_tileSize;
y2 = int(m_position.getY()) % m_tileSize;

我们然后在draw函数中减去这些值:

(j * m_tileSize) - x2, (i * m_tileSize) - y2

我们可以通过在我们的层的update函数中设置速度来测试这一点:

void TileLayer::update()
{
  m_position += m_velocity;
  m_velocity.setX(1);
}

然后在PlayState中我们可以调用这个函数:

void PlayState::update()
{
  pLevel->update();
}

运行游戏,你会看到地图滚动。目前我们还没有为循环地图或停止在末尾添加任何处理。我们将在后面的章节中创建游戏时讨论这个问题。

解析对象层

本章我们将讨论的最后一个主题是从我们的 Tiled 地图文件中加载对象。这非常实用,并消除了在级别内放置对象的猜测工作。打开 Tiled 应用程序,我们可以通过点击 | 添加对象层来创建我们的第一个对象层。这将创建一个新的层,称为对象层 1,如下面的截图所示:

解析对象层

我们可以在这些层上创建对象并分配我们想要的任何值和属性。首先我们将创建一个矩形。按R键并在你的瓦片地图上点击任何地方,你会看到一个小的正方形出现,如下面的截图所示:

解析对象层

右键点击这个正方形,然后点击对象属性…。这将弹出对象属性对话框,如下面的截图所示:

解析对象层

在这里,我们可以设置我们想要我们的对象拥有的值,就像我们之前的州 XML 文件一样。按照前面的截图填写对话框。这个对话框的位置和大小是以瓦片为单位的,而不是像素,所以x = 1实际上是x = 瓦片宽度等等。保存这个地图将把我们的新对象层添加到地图文件中:

<objectgroup name="Object Layer 1" width="60" height="15">
  <object name="Helicopter1" type="Player" x="32" y="32" width="32 height="32">
    <properties>
      <property name="numFrames" value="4"/>
      <property name="textureHeight" value="55"/>
      <property name="textureID" value="helicopter"/>
      <property name="textureWidth" value="128"/>
    </properties>
  </object>
</objectgroup>

我们还将使用另一个属性列表来加载这个地图的纹理。地图 | 地图属性将弹出地图属性对话框,如下面的截图所示:

解析对象层

在这里我们可以添加这个地图对象的所需纹理。保存的文件现在将有一个额外的属性列表供我们解析:

<properties>
  <property name="helicopter" value="helicopter.png"/>
</properties>

开发 ObjectLayer 类

在我们的项目中,我们现在将创建一个新的层类型,称为ObjectLayer。创建ObjectLayer.h,我们可以添加以下代码:

class ObjectLayer : public Layer
{
  public:
  virtual void update();
  virtual void render();

  std::vector<GameObject*>* getGameObjects()  
  {  
    return &m_gameObjects;  
  }

  private:

  std::vector<GameObject*> m_gameObjects;
};

我们还将定义这些函数在ObjectLayer.cpp中:

void ObjectLayer::update()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->update();
  }

}
void ObjectLayer::render()
{
  for(int i = 0; i < m_gameObjects.size(); i++)
  {
    m_gameObjects[i]->draw();
  }
}

我们的ObjectLayer类非常简单。它只需要绘制和更新该层的对象。现在让我们解析我们的ObjectLayer。我们将在LevelParser类中需要两个新函数:

void parseTextures(TiXmlElement* pTextureRoot);

void parseObjectLayer(TiXmlElement* pObjectElement,std::vector<Layer*> *pLayers);

parseLevel函数现在必须包含这些函数并传入正确的 XML 节点:

// we must parse the textures needed for this level, which have been added to properties
for(TiXmlElement* e = pProperties->FirstChildElement(); e != NULL;e = e->NextSiblingElement())
{
  if(e->Value() == std::string("property"))
  {
  parseTextures(e);
  }
}

我们将改变我们寻找瓦片层的方式,以也寻找对象层:

// parse any object layers
for(TiXmlElement* e = pRoot->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
{
  if(e->Value() == std::string("objectgroup") || e->Value() == std::string("layer"))
  {
    if(e->FirstChildElement()->Value() == std::string("object"))
    {
      parseObjectLayer(e, pLevel->getLayers());
    }
    else if(e->FirstChildElement()->Value() == std::string("data"))
    {
      parseTileLayer(e, pLevel->getLayers(), pLevel->getTilesets());
    }
  }
}

现在我们需要定义新的函数;parseTextures是一个非常小且简单的函数:

void LevelParser::parseTextures(TiXmlElement* pTextureRoot)
{
  TheTextureManager::Instance()->load(pTextureRoot->Attribute("value"), pTextureRoot->Attribute("name"), TheGame::Instance()->getRenderer());
}

它获取纹理值并将它们添加到TextureManagerparseObjects函数稍微长一些,但并不特别复杂:

void LevelParser::parseObjectLayer(TiXmlElement* pObjectElement, std::vector<Layer*> *pLayers)
{
    // create an object layer
  ObjectLayer* pObjectLayer = new ObjectLayer();

  std::cout << pObjectElement->FirstChildElement()->Value();

  for(TiXmlElement* e = pObjectElement->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
    {
      std::cout << e->Value();
      if(e->Value() == std::string("object"))
      {
        int x, y, width, height, numFrames, callbackID, animSpeed;
        std::string textureID;

        // get the initial node values type, x and y
        e->Attribute("x", &x);
        e->Attribute("y", &y);
        GameObject* pGameObject = TheGameObjectFactory::Instance()->create(e->Attribute("type"));

        // get the property values
        for(TiXmlElement* properties = e->FirstChildElement(); properties != NULL; properties = properties->NextSiblingElement())
        {
          if(properties->Value() == std::string("properties"))
          {
            for(TiXmlElement* property = properties->FirstChildElement(); property != NULL; property = property->NextSiblingElement())
            {
              if(property->Value() == std::string("property"))
              {
                if(property->Attribute("name") == std::string("numFrames"))
                  {
                    property->Attribute("value", &numFrames);
                  }
                else if(property->Attribute("name") == std::string("textureHeight"))
                {
                  property->Attribute("value", &height);
                }
                else if(property->Attribute("name") == std::string("textureID"))
                {
                  textureID = property->Attribute("value");
                }
                else if(property->Attribute("name") == std::string("textureWidth"))
                {
                  property->Attribute("value", &width);
                }
                else if(property->Attribute("name") == std::string("callbackID"))
                {
                  property->Attribute("value", &callbackID);
                }
                else if(e->Attribute("name") == std::string("animSpeed"))
                {
                  property->Attribute("value", &animSpeed);
                }
              }
            }
          }
        }
        pGameObject->load(newLoaderParams(x, y, width, height, textureID, numFrames, callbackID, animSpeed));
      pObjectLayer->getGameObjects()->push_back(pGameObject);
    }
  }

  pLayers->push_back(pObjectLayer);
}

我们以与状态解析器非常相似的方式加载对象,但这次我们必须检查属性的name而不是直接获取attribute

if(property->Attribute("name") == std::string("numFrames"))
{
  property->Attribute("value", &numFrames);
}

我们可以像状态解析器一样创建对象:

pGameObject->load(new LoaderParams(x,y,width,height,textureID,numFrames,callbackID, animSpeed));

并将其添加到这一层的游戏对象数组中:

pObjectLayer->getGameObjects()->push_back(pGameObject);

一旦我们加载了这一层的所有对象,我们就可以将其推入我们的 Level 层数组中:

pLayers->push_back(pObjectLayer);

运行游戏,你将再次看到我们的直升机在 PlayState 中。

开发 ObjectLayer 类

摘要

我们一直在向一个完整的游戏迈进。本章介绍了通过使用瓦片快速创建 2D 地图的方法,还探讨了使用外部应用程序在我们的关卡中放置对象。接下来的两章将解决所有剩余的悬而未决的问题,我们将创建一些实际的游戏。

第八章。创建外星攻击

框架已经取得了长足的进步,我们几乎准备好制作我们的第一个游戏了。我们将创建一个简单的 2D 侧滚动射击游戏,类似于经典的 80 年代和 90 年代的射击游戏,如 R-Type 或 Pulstar。然而,游戏不会设定在太空中。外星人袭击了地球,只有你和你武装的直升机才能阻止他们。源代码下载中提供了一个快节奏的动作关卡,本章将介绍创建它的步骤。以下是我们要创建的游戏的截图:

创建外星攻击

另一个稍微紧张一点的镜头:

创建外星攻击

在我们可以创建这个游戏之前,框架必须处理一些事情。这些添加包括:

  • 声音

  • 碰撞检测

到本章结束时,你将很好地理解如何使用框架构建这个游戏,并且你将有能力继续并改进它。在本章中,我们将涵盖:

  • 实现声音

  • 创建特定于游戏的对象类

  • 射击和检测子弹

  • 创建不同的敌人类型

  • 开发游戏

使用 SDL_mixer 扩展进行声音处理

SDL_mixer 扩展有其自己的 Mercurial 仓库,可以用来获取该扩展的最新源代码。它位于 hg.libsdl.org/SDL_mixer。可以使用 TortoiseHg 应用程序再次克隆扩展的 Mercurial 仓库。按照以下步骤构建库:

  1. 打开 TortoiseHg 并按 CTRL+SHIFT+N 开始克隆新的仓库。

  2. 在源框中输入 hg.libsdl.org/SDL_mixer

  3. 目标位置 将是 C:\SDL2_mixer

  4. 点击 克隆 并等待完成。

  5. 导航到 C:\SDL2_mixer\VisualC\ 并在 Visual Studio 2010 中打开 SDL_mixer.vcproj

  6. 只要 第二章 中概述的 x64 文件夹存在,即 在 SDL 中绘图 被创建,项目将无问题地转换。

  7. 我们将构建不带 MP3 支持的库,因为我们不需要它,而且它与 SDL 2.0 的兼容性也不太好。

  8. 在项目属性中的 预处理器定义 中添加 MP3_MUSIC_DISABLED,这可以通过导航到 C/C++ | 预处理器 来找到,并按照 第二章 中 在 SDL 中绘图SDL_image 指令进行构建。

创建 SoundManager 类

本章创建的游戏不需要任何高级声音处理,这意味着 SoundManager 类相当基础。该类仅使用 .ogg 文件进行音乐测试和 .wav 文件进行声音效果测试。以下是头文件:

enum sound_type
{
  SOUND_MUSIC = 0,
  SOUND_SFX = 1
};

class SoundManager
{
public:

  static SoundManager* Instance()
  {
    if(s_pInstance == 0)
    {
      s_pInstance = newSoundManager();
      return s_pInstance;
    }
    return s_pInstance;
  }

  bool load(std::string fileName, std::string id, sound_type type);

  void playSound(std::string id, int loop);
  void playMusic(std::string id, int loop);

  private:

  static SoundManager* s_pInstance;

  std::map<std::string, Mix_Chunk*> m_sfxs;
  std::map<std::string, Mix_Music*> m_music;

  SoundManager();
  ~SoundManager();

  SoundManager(const SoundManager&);
  SoundManager &operator=(const SoundManager&);
};

typedef SoundManager TheSoundManager;

SoundManager 类是一个单例;这样做是有道理的,因为声音应该只存储在一个地方,并且应该可以从游戏的任何地方访问。在使用声音之前,必须调用 Mix_OpenAudio 来设置游戏音频。Mix_OpenAudio 函数接受以下参数:

(int frequency, Uint16 format, int channels, int chunksize)

这是在 SoundManager 的构造函数中完成的,使用对大多数游戏都适用的值。

SoundManager::SoundManager()
{
  Mix_OpenAudio(22050, AUDIO_S16, 2, 4096);
}

SoundManager 类使用两个不同的 std::map 容器来存储声音:

std::map<std::string, Mix_Chunk*> m_sfxs;
std::map<std::string, Mix_Music*> m_music;

这些映射存储了 SDL_mixer 使用的两种不同类型的指针(Mix_Chunk*Mix_Music*),使用字符串作为键。Mix_Chunk* 类型用于音效,而 Mix_Music* 类型当然用于音乐。当将音乐文件或音效加载到 SoundManager 中时,我们传递一个名为 sound_typeenum 来表示要加载的声音类型。

bool load(std::string fileName, std::string id, sound_type type);

这种类型用于决定将加载的声音添加到哪个 std::map 中,以及从 SDL_mixer 中使用哪个 load 函数。load 函数在 SoundManager.cpp 中定义。

bool SoundManager::load(std::string fileName, std::string id, sound_type type)
{
  if(type == SOUND_MUSIC)
  {
    Mix_Music* pMusic = Mix_LoadMUS(fileName.c_str());

    if(pMusic == 0)
    {
      std::cout << "Could not load music: ERROR - "
      << Mix_GetError() << std::endl;
      return false;
    }

    m_music[id] = pMusic;
    return true;
  }
  else if(type == SOUND_SFX)
  {
    Mix_Chunk* pChunk = Mix_LoadWAV(fileName.c_str());
    if(pChunk == 0)
    {
      std::cout << "Could not load SFX: ERROR - "
      << Mix_GetError() << std::endl;

      return false;
    }

    m_sfxs[id] = pChunk;
    return true;
  }
  return false;
}

一旦加载了声音,就可以使用 **playSound****playMusic** 函数来播放:

void playSound(std::string id, int loop);
void playMusic(std::string id, int loop);

这两个函数都接受要播放的声音的 ID 和要循环播放的次数。这两个函数非常相似。

void SoundManager::playMusic(std::string id, int loop)
{
  Mix_PlayMusic(m_music[id], loop);
}

void SoundManager::playSound(std::string id, int loop)
{
  Mix_PlayChannel(-1, m_sfxs[id], loop);
}

Mix_PlayMusicMix_PlayChannel 之间的一个区别是后者将一个 int 作为第一个参数;这是声音要播放的通道。值为 -1(如前述代码所示)告诉 SDL_mixer 在任何可用的通道上播放声音。

最后,当 SoundManager 类被销毁时,它将调用 Mix_CloseAudio

SoundManager::~SoundManager()
{
  Mix_CloseAudio();
}

SoundManager 类的内容到此结束。

设置基本游戏对象

创建 Alien Attack 所做的绝大多数工作都是在对象类中完成的,而框架中几乎所有的其他工作都是由管理类处理的。以下是最重要的更改:

GameObject 进行了改进

GameObject 基类比之前要复杂得多。

class GameObject
{
public:
  // base class needs virtual destructor
  virtual ~GameObject() {}
  // load from file 
  virtual void load(std::unique_ptr<LoaderParams> const &pParams)=0;
  // draw the object
  virtual void draw()=0;
  // do update stuff
  virtual void update()=0;
  // remove anything that needs to be deleted
  virtual void clean()=0;
  // object has collided, handle accordingly
  virtual void collision() = 0;
  // get the type of the object
  virtual std::string type() = 0;
  // getters for common variables
  Vector2D& getPosition() { return m_position; }
  int getWidth() { return m_width; }
  int getHeight() { return m_height; }
  // scroll along with tile map
  void scroll(float scrollSpeed) { m_position.setX(m_position.getX() - 
  scrollSpeed); }
  // is the object currently being updated?
  bool updating() { return m_bUpdating; }
  // is the object dead?
  bool dead() { return m_bDead; }
  // is the object doing a death animation?
  bool dying() { return m_bDying; }
  // set whether to update the object or not
  void setUpdating(bool updating) { m_bUpdating = updating; }

protected:

  // constructor with default initialisation list
  GameObject() :  m_position(0,0),
  m_velocity(0,0),
  m_acceleration(0,0),
  m_width(0),
  m_height(0),
  m_currentRow(0),
  m_currentFrame(0),
  m_bUpdating(false),
  m_bDead(false),
  m_bDying(false),
  m_angle(0),
  m_alpha(255)
  {
  }
  // movement variables
  Vector2D m_position;
  Vector2D m_velocity;
  Vector2D m_acceleration;
  // size variables
  int m_width;
  int m_height;
  // animation variables
  int m_currentRow;
  int m_currentFrame;
  int m_numFrames;
  std::string m_textureID;
  // common boolean variables
  bool m_bUpdating;
  bool m_bDead;
  bool m_bDying;
  // rotation
  double m_angle;
  // blending
  int m_alpha;
};

这个类现在有很多以前在 SDLGameObject 中使用的成员变量。新增了一些变量来检查对象是否正在更新、是否正在执行死亡动画或已经死亡。当对象在滚动游戏级别后位于游戏屏幕内时,更新设置为 true。

在加载函数中,用 std::unique_ptr 指针代替了常规的 LoaderParams 指针;这是 C++11 标准 的一部分,并确保指针在超出作用域后会被删除。

virtual void load(std::unique_ptr<LoaderParams> const &pParams)=0;

现在有两个新的函数,每个派生对象都必须实现(无论是所有者还是继承):

 // object has collided, handle accordingly
virtual void collision() = 0;

 // get the type of the object
virtual std::string type() = 0;

SDLGameObject 现在更名为 ShooterObject

SDLGameObject 类现在已更名为 ShooterObject,并且更具体地针对这种类型的游戏:

class ShooterObject : public GameObject
{
public:

  virtual ~ShooterObject() {}// for polymorphism
  virtual void load(std::unique_ptr<LoaderParams> const
  &pParams);
  virtual void draw();
  virtual void update();
  virtual void clean() {}// not implemented in this class
  virtual void collision() {}//not implemented in this class
  virtual std::string type() { return "SDLGameObject"; }

protected:

  // we won't directly create ShooterObject's
  ShooterObject();

  // draw the animation for the object being destroyed
  void doDyingAnimation();

  // how fast will this object fire bullets? with a counter
  int m_bulletFiringSpeed;
  int m_bulletCounter;

  // how fast will this object move?
  int m_moveSpeed;

  // how long will the death animation takes? with a counter
  int m_dyingTime;
  int m_dyingCounter;

  // has the explosion sound played?
  bool m_bPlayedDeathSound;
};

此类为绘制和更新提供了默认实现,这些实现可以在派生类中使用;它们基本上与之前的 SDLGameObject 类相同,所以我们在这里不会介绍它们。新增的一个函数是 doDyingAnimation。此函数负责在敌人爆炸时更新动画,并将它们设置为死亡状态,以便可以从游戏中移除。

void ShooterObject::doDyingAnimation()
{
  // keep scrolling with the map
  scroll(TheGame::Instance()->getScrollSpeed());

  m_currentFrame = int(((SDL_GetTicks() / (1000 / 3)) % 
  m_numFrames));

  if(m_dyingCounter == m_dyingTime)
  {
    m_bDead = true;
  }
  m_dyingCounter++; //simple counter, fine with fixed frame rate
}

玩家继承自 ShooterObject

玩家 对象现在继承自新的 ShooterObject 类,并实现了自己的更新函数。一些新的游戏特定函数和变量已被添加:

private:

  // bring the player back if there are lives left
  void ressurect();

  // handle any input from the keyboard, mouse, or joystick
  void handleInput();

  // handle any animation for the player
  void handleAnimation();

  // player can be invulnerable for a time
  int m_invulnerable;
  int m_invulnerableTime;
  int m_invulnerableCounter;
};

ressurect 函数将玩家重置到屏幕中心,并暂时使 Player 对象免疫伤害;这一效果通过纹理的 alpha 值来可视化。此函数还负责重置在 doDyingAnimation 中更改的纹理大小值,以适应爆炸纹理:

void Player::ressurect()
{
  TheGame::Instance()->setPlayerLives(TheGame::Instance()
  ->getPlayerLives() - 1);

  m_position.setX(10);
  m_position.setY(200);
  m_bDying = false;

  m_textureID = "player";

  m_currentFrame = 0;
  m_numFrames = 5;
  m_width = 101;
  m_height = 46;

  m_dyingCounter = 0;
  m_invulnerable = true;
}

动画是 Player 对象感觉的重要组成部分;从闪烁(当无敌时),到旋转(当向前或向后移动时)。这导致有一个专门处理动画的独立函数:

void Player::handleAnimation()
{
  // if the player is invulnerable we can flash its alpha to let 
  people know
  if(m_invulnerable)
  {
    // invulnerability is finished, set values back
    if(m_invulnerableCounter == m_invulnerableTime)
    {
      m_invulnerable = false;
      m_invulnerableCounter = 0;
      m_alpha = 255;
    }
    else// otherwise, flash the alpha on and off
    {
      if(m_alpha == 255)
      {
        m_alpha = 0;
      }
      else
      {
        m_alpha = 255;
      }
    }

    // increment our counter
    m_invulnerableCounter++;
  }

  // if the player is not dead then we can change the angle with 
  the velocity to give the impression of a moving helicopter
  if(!m_bDead)
  {
    if(m_velocity.getX() < 0)
    {
      m_angle = -10.0;
    }
    else if(m_velocity.getX() > 0)
    {
      m_angle = 10.0;
    }
    else
    {
      m_angle = 0.0;
    }
  }

  // our standard animation code - for helicopter propellors
  m_currentFrame = int(((SDL_GetTicks() / (100)) % m_numFrames));
}

使用 TextureManagerdrawFrame 函数的新参数来改变对象的角和 alpha 值:

void TextureManager::drawFrame(std::string id, int x, int y, int 
width, int height, int currentRow, int currentFrame, SDL_Renderer 
*pRenderer, double angle, int alpha, SDL_RendererFlip flip)
{
  SDL_Rect srcRect;
  SDL_Rect destRect;
  srcRect.x = width * currentFrame;
  srcRect.y = height * currentRow;
  srcRect.w = destRect.w = width;
  srcRect.h = destRect.h = height;
  destRect.x = x;
  destRect.y = y;

  // set the alpha of the texture and pass in the angle
  SDL_SetTextureAlphaMod(m_textureMap[id], alpha);
  SDL_RenderCopyEx(pRenderer, m_textureMap[id], &srcRect, 
  &destRect, angle, 0, flip);
}

最后,Player::update 函数将所有这些整合在一起,同时还有额外的逻辑来处理关卡完成的情况:

void Player::update()
{
  // if the level is complete then fly off the screen
  if(TheGame::Instance()->getLevelComplete())
  {
    if(m_position.getX() >= TheGame::Instance()->getGameWidth())
    {
      TheGame::Instance()->setCurrentLevel(TheGame::Instance()
      ->getCurrentLevel() + 1);
    }
    else
    {
      m_velocity.setY(0);
      m_velocity.setX(3);
      ShooterObject::update();
      handleAnimation();
    }
  }
  else
  {
    // if the player is not doing its death animation then update 
    it normally
    if(!m_bDying)
    {
      // reset velocity
      m_velocity.setX(0);
      m_velocity.setY(0);

      // get input
      handleInput();
      // do normal position += velocity update
      ShooterObject::update();

      // update the animation
      handleAnimation();
    }
    else // if the player is doing the death animation
    {
      m_currentFrame = int(((SDL_GetTicks() / (100)) % 
      m_numFrames));

      // if the death animation has completed
      if(m_dyingCounter == m_dyingTime)
      {
        // ressurect the player
        ressurect();
      }

      m_dyingCounter++;
    }
  }
}

一旦关卡完成且玩家飞出屏幕,Player::update 函数还会告诉游戏增加当前关卡:

TheGame::Instance()->setCurrentLevel(TheGame::Instance()->getCurrentLevel() + 1);

Game::setCurrentLevel 函数将状态更改为 BetweenLevelState

void Game::setCurrentLevel(int currentLevel)
{
  m_currentLevel = currentLevel;
  m_pGameStateMachine->changeState(new BetweenLevelState());
  m_bLevelComplete = false;
}

许多敌人类型

像外星攻击这样的游戏需要很多敌人类型来保持趣味性;每种敌人都有自己的行为。敌人应该易于创建并自动添加到碰撞检测列表中。考虑到这一点,Enemy 类现在已成为一个基类:

// Enemy base class
class Enemy : public ShooterObject
{
public:
  virtual std::string type() { return"Enemy"; }

protected:
  int m_health;

  Enemy() : ShooterObject() {}
  virtual ~Enemy() {} // for polymorphism

};

所有敌人类型都将从这个类派生,但重要的是它们不要覆盖 type 方法。原因将在我们转向游戏碰撞检测类时变得清晰。现在请查看 Alien Attack 源代码中的敌人类型,看看它们是如何简单易创建的。

许多敌人类型

添加滚动背景

滚动背景对于像这样的 2D 游戏非常重要;它们有助于营造深度和运动感。这个 ScrollingBackground 类使用两个目标矩形和两个源矩形;一个扩展,另一个收缩。一旦扩展的矩形达到其完整宽度,两个矩形都会重置,循环继续:

void ScrollingBackground::load(std::unique_ptr<LoaderParams> const &pParams)
{
  ShooterObject::load(std::move(pParams));
  m_scrollSpeed = pParams->getAnimSpeed();

  m_scrollSpeed = 1;

  m_srcRect1.x = 0;
  m_destRect1.x = m_position.getX();
  m_srcRect1.y = 0;
  m_destRect1.y = m_position.getY();

  m_srcRect1.w = m_destRect1.w = m_srcRect2Width = 
  m_destRect1Width = m_width;
  m_srcRect1.h = m_destRect1.h = m_height;

  m_srcRect2.x = 0;
  m_destRect2.x = m_position.getX() + m_width;
  m_srcRect2.y = 0;
  m_destRect2.y = m_position.getY();

  m_srcRect2.w = m_destRect2.w = m_srcRect2Width = 
  m_destRect2Width = 0;
  m_srcRect2.h = m_destRect2.h = m_height;
}

void ScrollingBackground::draw()
{
  // draw first rect
  SDL_RenderCopyEx(TheGame::Instance()->getRenderer(), 
  TheTextureManager::Instance()->getTextureMap()[m_textureID], 
  &m_srcRect1, &m_destRect1, 0, 0, SDL_FLIP_NONE);

  // draw second rect
  SDL_RenderCopyEx(TheGame::Instance()->getRenderer(), 
  TheTextureManager::Instance()->getTextureMap()[m_textureID], 
  &m_srcRect2, &m_destRect2, 0, 0, SDL_FLIP_NONE);

}

void ScrollingBackground::update()
{
  if(count == maxcount)
  {
    // make first rectangle smaller
    m_srcRect1.x += m_scrollSpeed;
    m_srcRect1.w -= m_scrollSpeed;
    m_destRect1.w -= m_scrollSpeed;

    // make second rectangle bigger
    m_srcRect2.w += m_scrollSpeed;
    m_destRect2.w += m_scrollSpeed;
    m_destRect2.x -= m_scrollSpeed;

    // reset and start again
    if(m_destRect2.w >= m_width)
    {
      m_srcRect1.x = 0;
      m_destRect1.x = m_position.getX();
      m_srcRect1.y = 0;
      m_destRect1.y = m_position.getY();

      m_srcRect1.w = m_destRect1.w = m_srcRect2Width = 
      m_destRect1Width = m_width;
      m_srcRect1.h = m_destRect1.h = m_height;

      m_srcRect2.x = 0;
      m_destRect2.x = m_position.getX() + m_width;
      m_srcRect2.y = 0;
      m_destRect2.y = m_position.getY();

      m_srcRect2.w = m_destRect2.w = m_srcRect2Width = 
      m_destRect2Width = 0;
      m_srcRect2.h = m_destRect2.h = m_height;
    }
    count = 0;
  }

  count++;
}

处理子弹

游戏中的大多数对象都会发射子弹,它们几乎都需要检查与子弹的碰撞;总之——子弹在《外星攻击》中非常重要。游戏有一个专门的BulletHandler类来处理子弹的创建、销毁、更新和渲染。

两种类型的子弹

游戏中有两种子弹,PlayerBulletEnemyBullet,它们都在同一个BulletManager类中处理。这两个子弹类都在Bullet.h中声明和定义:

class PlayerBullet : public ShooterObject
{
public:

  PlayerBullet() : ShooterObject()
  {
  }

  virtual ~PlayerBullet() {}

  virtual std::string type() { return "PlayerBullet"; }

  virtual void load(std::unique_ptr<LoaderParams> pParams, Vector2D 
  heading)
  {
    ShooterObject::load(std::move(pParams));
    m_heading = heading;
  }

  virtual void draw()
  {
    ShooterObject::draw();
  }

  virtual void collision()
  {
    m_bDead = true;
  }

  virtual void update()
  {
    m_velocity.setX(m_heading.getX());
    m_velocity.setY(m_heading.getY());

    ShooterObject::update();
  }

  virtual void clean()
  {
    ShooterObject::clean();
  }

private:

  Vector2D m_heading;
};

// Enemy Bullet is just a Player Bullet with a different typename
class EnemyBullet : public PlayerBullet
{
public:

  EnemyBullet() : PlayerBullet()
  {
  }

  virtual ~EnemyBullet() {}

  virtual std::string type() { return "EnemyBullet"; }
};

子弹非常简单,它们只朝一个方向以一定速度移动。

BulletHandler

BulletHandler类使用两个公共函数来添加子弹:

void addPlayerBullet(int x, int y, int width, int height, std::string textureID, int numFrames, Vector2D heading);
void addEnemyBullet(int x, int y, int width, int height, std::string textureID, int numFrames, Vector2D heading);

BulletHandler类也是一个单例。因此,如果对象想要向游戏中添加子弹,它可以使用上述函数之一。以下是从ShotGlider类中的一个示例:

TheBulletHandler::Instance()->addEnemyBullet(m_position.getX(), m_position.getY() + 15, 16, 16, "bullet2", 1, Vector2D(-10, 0));

这将在ShotGlider的当前位置添加一个子弹,其航向向量为V(-10,0)。

两个add函数非常相似;它们创建PlayerBulletEnemyBullet的新实例,然后将它推入正确的向量中。以下是它们的定义:

void BulletHandler::addPlayerBullet(int x, int y, int width, int 
  height, std::string textureID, int numFrames, Vector2D heading)
{
  PlayerBullet* pPlayerBullet = newPlayerBullet();
  pPlayerBullet->load(std::unique_ptr<LoaderParams>(new 
  LoaderParams(x, y, width, height, textureID, numFrames)), 
  heading);

  m_playerBullets.push_back(pPlayerBullet);
}

void BulletHandler::addEnemyBullet(int x, int y, int width, int 
height, std::string textureID, int numFrames, Vector2D heading)
{
  EnemyBullet* pEnemyBullet = new EnemyBullet();
  pEnemyBullet->load(std::unique_ptr<LoaderParams>(new 
  LoaderParams(x, y, width, height, textureID, numFrames)), 
  heading);

  m_enemyBullets.push_back(pEnemyBullet);
}

与对象自己管理自己的子弹相比,在这样一个单独的地方存储子弹的一个大优点是,不需要传递对象来仅仅获取它们的子弹以检查碰撞。这个BulletHandler类为我们提供了一个集中的位置,我们可以轻松地将它传递给碰撞处理器。

updatedraw函数本质上只是循环调用每个子弹的相应函数,然而update函数还会销毁任何已经离开屏幕的子弹:

for (std::vector<PlayerBullet*>::iterator p_it = 
m_playerBullets.begin(); p_it != m_playerBullets.end();)
{
  if((*p_it)->getPosition().getX() < 0 || (*p_it)
  ->getPosition().getX() >TheGame::Instance()->getGameWidth()
  || (*p_it)->getPosition().getY() < 0 || (*p_it)->
  getPosition().getY() >TheGame::Instance()->getGameHeight() || 
  (*p_it)->dead())// if off screen or dead
  {
    delete * p_it; // delete the bullet
    p_it = m_playerBullets.erase(p_it); //remove
  }
  else// continue to update and loop
  {
    (*p_it)->update();
    ++p_it;
  }
}

处理碰撞

在周围飞舞着这么多子弹,并且需要检查与Enemy对象碰撞的情况下,有一个单独的类来为我们进行碰撞检测是非常重要的。这样,如果我们决定要实现一种新的碰撞检测方式或优化现有代码,我们就知道该往哪里查找。Collision.h文件包含一个静态方法,用于检查两个SDL_Rect对象之间的碰撞:

const static int s_buffer = 4;

static bool RectRect(SDL_Rect* A, SDL_Rect* B)
{
  int aHBuf = A->h / s_buffer;
  int aWBuf = A->w / s_buffer;

  int bHBuf = B->h / s_buffer;
  int bWBuf = B->w / s_buffer;

  // if the bottom of A is less than the top of B - no collision
  if((A->y + A->h) - aHBuf <= B->y + bHBuf)  { return false; }

  // if the top of A is more than the bottom of B = no collision
  if(A->y + aHBuf >= (B->y + B->h) - bHBuf)  { return false; }

  // if the right of A is less than the left of B - no collision
  if((A->x + A->w) - aWBuf <= B->x +  bWBuf) { return false; }

  // if the left of A is more than the right of B - no collision
  if(A->x + aWBuf >= (B->x + B->w) - bWBuf)  { return false; }

  // otherwise there has been a collision
 return true;
}

这个函数使用了一个缓冲区,这是一个用于使矩形稍微小一点的价值。在像《外星攻击》这样的游戏中,精确的碰撞检测在边界矩形上可能会稍微不公平,而且也不太有趣。使用缓冲区值,需要更直接的命中才能被注册为碰撞。这里缓冲区设置为4;这将从矩形的每一边减去四分之一。

Player类不会处理自己的碰撞。当关卡加载时,需要一种方法将玩家从其他GameObject实例中分离出来。现在Level类存储了一个指向Player的指针:

Player* m_pPlayer;

使用公共获取器和设置器:

Player* getPlayer() { return m_pPlayer; }
void setPlayer(Player* pPlayer) { m_pPlayer = pPlayer; }

LevelParser实例从关卡文件中加载Player时,它会设置这个指针:

pGameObject->load(std::unique_ptr<LoaderParams>(new LoaderParams(x, y, width, height, textureID, numFrames,callbackID, animSpeed)));

if(type == "Player") // check if it's the player
{
  pLevel->setPlayer(dynamic_cast<Player*>(pGameObject));
}

pObjectLayer->getGameObjects()->push_back(pGameObject);

Level的另一个新增功能是它包含一个单独的std::vector,其中包含TileLayer*,这些是游戏将用于碰撞检查的瓦片层。这个值来自.tmx文件,任何需要检查碰撞的TileLayer都必须在 tiled 应用程序中将collidable属性设置为属性。

处理碰撞

这也要求在检查对象层时对LevelParser::parseLevel进行轻微的修改,以防该层包含属性(在这种情况下,数据将不再是第一个子元素):

else if(e->FirstChildElement()->Value() == std::string("data") || (e->FirstChildElement()->NextSiblingElement() != 0 && e->FirstChildElement()->NextSiblingElement()->Value() == std::string("data")))
{
  parseTileLayer(e, pLevel->getLayers(), pLevel->getTilesets(), 
  pLevel->getCollisionLayers());
}

LevelParser实例现在可以在parseTileLayer中将碰撞层添加到碰撞层数组中:

// local temporary variable
bool collidable = false;

// other code…

for(TiXmlElement* e = pTileElement->FirstChildElement(); e != NULL; e = e->NextSiblingElement())
{
  if(e->Value() == std::string("properties"))
  {
    for(TiXmlElement* property = e->FirstChildElement(); property != NULL; property = property->NextSiblingElement())
    {
      if(property->Value() == std::string("property"))
      {
        if(property->Attribute("name") == std::string("collidable"))
        {
          collidable = true;
        }
      }
    }
  }

  if(e->Value() == std::string("data"))
  {
    pDataNode = e;
  }
}

// other code…

// push into collision array if necessary
if(collidable)
{
  pCollisionLayers->push_back(pTileLayer);
}

pLayers->push_back(pTileLayer);

创建一个CollisionManager

负责检查和处理所有这些碰撞的类是CollisionManager类。以下是它的声明:

class CollisionManager
{
public:

  void checkPlayerEnemyBulletCollision(Player* pPlayer);
  void checkPlayerEnemyCollision(Player* pPlayer, const 
  std::vector<GameObject*> &objects);
  void checkEnemyPlayerBulletCollision(const 
  std::vector<GameObject*> &objects);
  void checkPlayerTileCollision(Player* pPlayer, const 
  std::vector<TileLayer*> &collisionLayers);
};

查看源代码你会发现这些函数相当大,但它们相对简单。它们遍历每个需要碰撞测试的对象,为每个对象创建一个矩形,然后将它传递到在Collision.h中定义的静态RectRect函数。如果发生碰撞,它将调用该对象的collision函数。checkEnemyPlayerBulletCollisioncheckPlayerEnemyCollision函数执行额外的检查,以查看对象是否实际上是Enemy类型:

if(objects[i]->type() != std::string("Enemy") || !objects[i]->updating())
{
 continue;
}

如果不是,则不会检查碰撞。这就是为什么确保Enemy子类型不覆盖type函数,或者如果它们覆盖了,它们的类型也必须添加到这个检查中,这一点很重要。这个条件还会检查对象是否正在更新;如果不是,那么它已经不在屏幕上,不需要检查碰撞。

检查与瓦片的碰撞需要与确定从哪里开始绘制瓦片的方法类似,这已在TileLayer::render函数中实现。以下是checkPlayerTileCollision的定义:

void CollisionManager::checkPlayerTileCollision(Player* pPlayer, 
  const std::vector<TileLayer*> &collisionLayers)
{
  // iterate through collision layers
  for(std::vector<TileLayer*>::const_iterator it = 
  collisionLayers.begin(); it != collisionLayers.end(); ++it)
  {
    TileLayer* pTileLayer = (*it);
    std::vector<std::vector<int>> tiles = pTileLayer-
    >getTileIDs();

    // get this layers position
    Vector2D layerPos = pTileLayer->getPosition();

    int x, y, tileColumn, tileRow, tileid = 0;

    // calculate position on tile map
    x = layerPos.getX() / pTileLayer->getTileSize();
    y = layerPos.getY() / pTileLayer->getTileSize();

    // if moving forward or upwards
    if(pPlayer->getVelocity().getX() >= 0 || pPlayer-
    >getVelocity().getY() >= 0)
    {
      tileColumn = ((pPlayer->getPosition().getX() + pPlayer-
      >getWidth()) / pTileLayer->getTileSize());
      tileRow = ((pPlayer->getPosition().getY() + pPlayer-
      >getHeight()) 
      / pTileLayer->getTileSize());
      tileid = tiles[tileRow + y][tileColumn + x];
    }
    else if(pPlayer->getVelocity().getX() < 0 || pPlayer-
    >getVelocity().getY() < 0) // if moving backwards or downwards
    {
      tileColumn = pPlayer->getPosition().getX() / pTileLayer-
      >getTileSize();
      tileRow = pPlayer->getPosition().getY() / pTileLayer-
      >getTileSize();
      tileid = tiles[tileRow + y][tileColumn + x];
    }
    if(tileid != 0) // if the tile id not blank then collide
    {
      pPlayer->collision();
    }
  }
}

可能的改进

目前《外星攻击》是一款相当稳健的游戏;我们强烈建议查看源代码并熟悉它的每个方面。一旦你对游戏的大部分区域有了很好的理解,就更容易看到某些区域可以如何增强。以下是一些可以添加到游戏中以改进游戏的想法:

  • 子弹可以在关卡开始时创建并存储在对象池中;因此,而不是不断创建和删除子弹,它们可以从对象池中取出并放回。这种方法的主要优点是,当涉及到性能时,对象的创建和销毁可能相当昂贵。在游戏运行时消除这一点可以真正提高性能。

  • 碰撞检测可以进一步优化,可能通过添加一个四叉树来停止不必要的碰撞检查。

  • 源代码中有几个区域使用字符串比较来检查类型。这可能会对性能产生一定影响,因此使用枚举作为类型等其它选项可能是一个更好的选择。

你可能自己已经注意到了一些你认为可以改进的地方。在游戏的背景下对这些进行工作,在那里你可以测试结果,是一种很好的学习体验。

摘要

该框架已被成功用于创建一款游戏——外星人攻击。在本章中,我们涵盖了游戏最重要的部分,并简要解释了为什么它们被设计成这样。由于这款游戏的源代码现在可用,因此现在有一个很好的项目可以开始练习。

第九章:创建康纳洞穴人

在上一章中,外星人攻击的创建演示了框架现在已达到可以快速创建 2D 横版射击游戏的程度。其他类型也简单易制作,大多数更改仍然包含在对象类中。

在本章中,我们将介绍:

  • 为新游戏调整之前的代码库

  • 更精确的瓦片碰撞检测

  • 处理跳跃

  • 框架可能的添加

本章将使用框架创建一个平台游戏,康纳洞穴人。以下是完成的游戏关卡截图:

创建康纳洞穴人

这是另一个带有更多敌人的截图:

创建康纳洞穴人

与上一章一样,本章不是创建康纳洞穴人的逐步指南,而是对游戏最重要的方面的概述。游戏的项目可以在源代码下载中找到。

设置基本游戏对象

在某些方面,这个游戏比外星人攻击更复杂,而在其他方面则更简单。本节将介绍对外星人攻击源代码所做的更改:什么被修改了,什么被移除了,什么被添加了。

没有更多子弹或子弹碰撞

康纳洞穴人没有使用投射武器,因此不再有Bullet类,CollisonManager类也不再需要检查它们之间的碰撞功能;它只检查PlayerEnemy的碰撞:

class CollisionManager
{
public:

  void checkPlayerEnemyCollision(Player* pPlayer, const 
  std::vector<GameObject*>&objects);
};

游戏对象和地图碰撞

几乎所有对象都需要与瓦片地图发生碰撞并相应地反应。GameObject类现在有一个私有成员,是一个指向碰撞层的指针;之前只有Player类有这个变量:

std::vector<TileLayer*>* m_pCollisionLayers;

GameObject现在也有一个设置此变量的函数:

void setCollisionLayers(std::vector<TileLayer*>* layers) { m_pCollisionLayers = layers; }

Player类之前会在LevelParser::parseLevel函数的末尾设置这个值,如下所示:

pLevel->getPlayer()->setCollisionLayers(pLevel->getCollisionLayers());

这不再需要,因为每个GameObject在对象层解析时都会在创建时设置其m_pCollisionLayers变量:

// load the object
pGameObject->load(std::unique_ptr<LoaderParams>(new LoaderParams(x, y, width, height, textureID, numFrames,callbackID, animSpeed)));
// set the collision layers
pGameObject->setCollisionLayers(pLevel->getCollisionLayers());

ShooterObject 现在是 PlatformerObject

外星人攻击的特定代码已被从ShooterObject中移除,并将类重命名为PlatformerObject。任何这个游戏的所有游戏对象都将使用的内容都包含在这个类中:

class PlatformerObject : public GameObject
{
public:

  virtual ~PlatformerObject() {}

  virtual void load(std::unique_ptr<LoaderParams> const &pParams);

  virtual void draw();
  virtual void update();

  virtual void clean() {}
  virtual void collision() {}

  virtual std::string type() { return "SDLGameObject"; }

protected:

  PlatformerObject();

  bool checkCollideTile(Vector2D newPos);

  void doDyingAnimation();

  int m_bulletFiringSpeed;
  int m_bulletCounter;
  int m_moveSpeed;

  // how long the death animation takes, along with a counter
  int m_dyingTime;
  int m_dyingCounter;

  // has the explosion sound played?
  bool m_bPlayedDeathSound;

  bool m_bFlipped;

  bool m_bMoveLeft;
  bool m_bMoveRight;
  bool m_bRunning;

  bool m_bFalling;
  bool m_bJumping;
  bool m_bCanJump;

  Vector2D m_lastSafePos;

  int m_jumpHeight;
};

仍然有一些来自外星人攻击的有用变量和函数,还有一些新函数。其中最重要的新增功能是checkCollideTile函数,它接受Vector2D作为参数,并检查它是否会导致碰撞:

bool PlatformerObject::checkCollideTile(Vector2D newPos)
{
  if(newPos.m_y + m_height>= TheGame::Instance()->getGameHeight() 
  - 32)
  {
    return false;
  }
  else
  {
    for(std::vector<TileLayer*>::iterator it = m_pCollisionLayers
    ->begin(); it != m_pCollisionLayers->end(); ++it)
    {
      TileLayer* pTileLayer = (*it);
      std::vector<std::vector<int>> tiles = pTileLayer
      ->getTileIDs();

      Vector2D layerPos = pTileLayer->getPosition();

      int x, y, tileColumn, tileRow, tileid = 0;

      x = layerPos.getX() / pTileLayer->getTileSize();
      y = layerPos.getY() / pTileLayer->getTileSize();

      Vector2D startPos = newPos;
      startPos.m_x += 15;
      startPos.m_y += 20;
      Vector2D endPos(newPos.m_x + (m_width - 15), (newPos.m_y) + 
      m_height - 4);

      for(int i = startPos.m_x; i < endPos.m_x; i++)
      {
        for(int j = startPos.m_y; j < endPos.m_y; j++)
        {
          tileColumn = i / pTileLayer->getTileSize();
          tileRow = j / pTileLayer->getTileSize();

          tileid = tiles[tileRow + y][tileColumn + x];

          if(tileid != 0)
          {
            return true;
          }
        }
      }
    }

    return false; 
  }
}

这是一个相当大的函数,但本质上与外星人攻击检查瓦片碰撞的方式相同。一个区别是 y 位置的检查:

if(newPos.m_y + m_height >= TheGame::Instance()->getGameHeight() - 32)
{
  return false;
}

这是为了确保我们可以在地图边缘掉落(或掉入坑中)而不会尝试访问不存在的瓦片。例如,如果对象的位置在地图之外,以下代码将尝试访问不存在的瓦片,因此会失败:

tileid = tiles[tileRow + y][tileColumn + x];

y 值检查可以防止这种情况发生。

相机类

在像《外星攻击》这样的游戏中,精确的地图碰撞检测并不是特别重要;更重要的是有精确的子弹、玩家和敌人碰撞。然而,平台游戏需要非常精确的地图碰撞,这要求以稍微不同的方式移动地图,以便在滚动时不会丢失精度。

在《外星攻击》中,地图实际上并没有移动;一些变量被用来确定绘制地图的哪个点,这产生了地图滚动的错觉。在《康纳原始人》中,地图将移动,以便任何碰撞检测例程都与地图的实际位置相关。为此,创建了一个Camera类:

class Camera
{
public:

  static Camera* Instance()
  {
    if(s_pCamera == 0)
    {
      s_pCamera = new Camera();
    }

    return s_pCamera;
  }

  void update(Vector2D velocity);

  void setTarget(Vector2D* target) { m_pTarget = target; }
  void setPosition(const Vector2D& position) { m_position = 
  position; }

  const Vector2D getPosition() const;

private:

  Camera();
  ~Camera();

  // the camera's target
  Vector2D* m_pTarget;

  // the camera's position
  Vector2D m_position;

  static Camera* s_pCamera;
};

typedef Camera TheCamera;

这个类非常简单,因为它仅仅保存一个位置,并使用目标的位置更新它,该目标被称为指针m_pTarget

const Vector2DCamera::getPosition() const
{
{
  if(m_pTarget != 0)
  {
    Vector2D pos(m_pTarget->m_x - (TheGame::Instance()
    ->getGameWidth() / 2), 0);

    if(pos.m_x< 0)
    {
      pos.m_x = 0;
    }

    return pos;
  }

  return m_position;
}

这也可以更新以包括 y 值,但由于这是一个水平滚动游戏,这里不需要,因此 y 值返回为0。这个相机位置用于移动地图并决定绘制哪些瓦片。

相机控制的地图

TileLayer类现在需要知道地图的完整大小,而不仅仅是其中的一部分;这是通过构造函数传入的:

TileLayer(int tileSize, int mapWidth, int mapHeight, const std::vector<Tileset>& tilesets);

LevelParser在创建每个TileLayer时传入高度和宽度:

void LevelParser::parseTileLayer(TiXmlElement* pTileElement, std::vector<Layer*> *pLayers, const std::vector<Tileset>* pTilesets, std::vector<TileLayer*> *pCollisionLayers)
{
TileLayer* pTileLayer = new TileLayer(m_tileSize, m_width, m_height, *pTilesets);

TileLayer类使用这些值来设置其行和列变量:

TileLayer::TileLayer(int tileSize, int mapWidth, int mapHeight, const std::vector<Tileset>& tilesets) : m_tileSize(tileSize), m_tilesets(tilesets), m_position(0,0), m_velocity(0,0)
{
  m_numColumns = mapWidth;
  m_numRows = mapHeight;

  m_mapWidth = mapWidth;
}

通过这些更改,瓦片地图现在根据相机的位置移动,并跳过任何在可视区域之外的瓦片:

void TileLayer::render()
{
  int x, y, x2, y2 = 0;

  x = m_position.getX() / m_tileSize;
  y = m_position.getY() / m_tileSize;

  x2 = int(m_position.getX()) % m_tileSize;
  y2 = int(m_position.getY()) % m_tileSize;

  for(int i = 0; i < m_numRows; i++)
  {
    for(int j = 0; j < m_numColumns; j++)
    {
      int id = m_tileIDs[i + y][j + x];

      if(id == 0)
      {
        continue;
      }

      // if outside the viewable area then skip the tile
      if(((j * m_tileSize) - x2) - TheCamera::Instance()
      ->getPosition().m_x < -m_tileSize || ((j * m_tileSize) - x2) 
      - TheCamera::Instance()->getPosition()
      .m_x > TheGame::Instance()->getGameWidth())
      {
        continue;
      }

      Tileset tileset = getTilesetByID(id);

      id--;

      // draw the tile into position while offsetting its x 
      position by 
      // subtracting the camera position
      TheTextureManager::Instance()->drawTile(tileset.name, 
      tileset.margin, tileset.spacing, ((j * m_tileSize) - x2) - 
      TheCamera::Instance()->getPosition().m_x, ((i * m_tileSize) 
      - y2), m_tileSize, m_tileSize, (id - (tileset.firstGridID - 
      1)) / tileset.numColumns, (id - (tileset.firstGridID - 1)) % 
      tileset.numColumns, TheGame::Instance()->getRenderer());
    }
  }

玩家类

Player类现在必须应对跳跃以及移动,同时检查地图碰撞。Player::update函数已经发生了相当大的变化:

void Player::update()
{
  if(!m_bDying)
  {
    // fell off the edge
    if(m_position.m_y + m_height >= 470)
    {
      collision();
    }

    // get the player input
    handleInput();

    if(m_bMoveLeft)
    {
      if(m_bRunning)
      {
        m_velocity.m_x = -5;
      }
      else
      {
        m_velocity.m_x = -2;
      }
    }
    else if(m_bMoveRight)
    {
      if(m_bRunning)
      {
        m_velocity.m_x = 5;
      }
      else
      {
        m_velocity.m_x = 2;
      }
    }
    else
    {
      m_velocity.m_x = 0;
    }

    // if we are higher than the jump height set jumping to false
    if(m_position.m_y < m_lastSafePos.m_y - m_jumpHeight)
    {
      m_bJumping = false;
    }

    if(!m_bJumping)
    {
      m_velocity.m_y = 5;
    }
    else
    {
      m_velocity.m_y = -5;
    }

    handleMovement(m_velocity);
  }
  else
  {
    m_velocity.m_x = 0;
    if(m_dyingCounter == m_dyingTime)
    {
      ressurect();
    }
    m_dyingCounter++;

    m_velocity.m_y = 5;
  }
  handleAnimation();
}

由于移动是这个类中如此重要的部分,因此有一个专门处理它的函数:

void Player::handleMovement(Vector2D velocity)
{
  // get the current position
  Vector2D newPos = m_position;

  // add velocity to the x position
  newPos.m_x  = m_position.m_x + velocity.m_x;

  // check if the new x position would collide with a tile
  if(!checkCollideTile(newPos))
  {
    // no collision, add to the actual x position
    m_position.m_x = newPos.m_x;
  }
  else
  {
    // collision, stop x movement
    m_velocity.m_x = 0;
  }

  // get the current position after x movement
  newPos = m_position;

  // add velocity to y position
  newPos.m_y += velocity.m_y;

  // check if new y position would collide with a tile
  if(!checkCollideTile(newPos))
  {
    // no collision, add to the actual x position
    m_position.m_y = newPos.m_y;
  }
  else
  {
    // collision, stop y movement
    m_velocity.m_y = 0;

    //  we collided with the map which means we are safe on the 
    ground,
    //  make this the last safe position
    m_lastSafePos = m_position;

    // move the safe pos slightly back or forward so when 
    resurrected we are safely on the ground after a fall
    if(velocity.m_x > 0)
    {
      m_lastSafePos.m_x -= 32;
    }
    else if(velocity.m_x < 0)
    {
      m_lastSafePos.m_x += 32;

    }

    // allow the player to jump again
    m_bCanJump = true;

    // jumping is now false
    m_bJumping = false;
  }

小贴士

注意到 x 和 y 检查已经被分成两个不同的部分;这对于确保 x 碰撞不会停止 y 移动,反之亦然,非常重要。

m_lastSafePos变量用于在玩家重生后将玩家放回一个安全的位置。例如,如果玩家在以下截图中的平台边缘掉落,并因此落在下面的尖刺上,他将在与截图几乎相同的位置重生:

玩家类

最后,处理输入函数现在为向右移动、向左移动或跳跃设置了布尔变量:

void Player::handleInput()
{
  if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_RIGHT) && 
  m_position.m_x < ((*m_pCollisionLayers->begin())->getMapWidth() 
  * 32))
  {
    if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_A))
    {
      m_bRunning = true;
    }
    else
    {
      m_bRunning = false;
    }

    m_bMoveRight = true;
    m_bMoveLeft = false;
  }
  else if(TheInputHandler::Instance()
  ->isKeyDown(SDL_SCANCODE_LEFT) && m_position.m_x > 32)
  {
    if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_A))
    {
      m_bRunning = true;
    }
    else
    {
      m_bRunning = false;
    }

    m_bMoveRight = false;
    m_bMoveLeft = true;
  }
  else
  {
    m_bMoveRight = false;
    m_bMoveLeft = false;
  }

  if(TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_SPACE) 
  && m_bCanJump && !m_bPressedJump)
  {
    TheSoundManager::Instance()->playSound("jump", 0);
    if(!m_bPressedJump)
    {
      m_bJumping = true;
      m_bCanJump = false;
      m_lastSafePos = m_position;
      m_bPressedJump = true;
    }
  }

  if(!TheInputHandler::Instance()->isKeyDown(SDL_SCANCODE_SPACE) 
  && m_bCanJump)
  {
    m_bPressedJump = false;
  }
}

所有这些都很直观,除了跳跃。当玩家跳跃时,它会将m_bCanJump变量设置为false,因此在下一个循环中,由于跳跃只能在m_bCanJump变量为true时发生,所以不会再次调用跳跃;(跳跃后落地会将此变量重新设置为true)。

可能的添加

改进康纳洞穴人的游戏玩法并不难;增加敌人和陷阱的数量会使游戏玩起来更加刺激。游戏还可以通过增加关卡的高度来受益,这样玩家就可以真正地探索地图(类似《银河战士》风格)。其他游戏玩法改进可能包括移动平台、梯子和 Boss。

摘要

我们的可重复使用框架已经证明了自己的价值;通过最小化代码重复,已经创建了两个游戏。

本章探讨了使用玩家的位置进行滚动地图以及碰撞检测。还涵盖了瓦片地图碰撞,以及将 x 和 y 移动分开以在平台游戏中有效移动的重要点。康纳洞穴人是一个很好的起点,对于任何其他 2D 游戏,如滚动打斗游戏,甚至可以将本章和上一章结合来创建平台射击游戏。

我希望到现在为止,你已经很好地理解了如何使用 SDL2.0 和 C++来创建游戏,以及如何有效地拆分游戏代码以创建可重复使用的框架。这只是一个开始,还有更多游戏编程的冒险等着你。祝你好运!

posted @ 2025-10-24 09:59  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报