CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
  • C++语言面试问题集锦
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • 🔥C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 🔥从零用C语言写一个Redis
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 🔥使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • C++游戏编程入门(零基础学C++) 说明
  • 第1章 欢迎阅读
  • 第2章 变量、运算符与决策:精灵动画
  • 第3章 C++字符串、SFML时间:玩家输入与平视显示器
  • 第4章 循环、数组、switch语句、枚举和函数:实现游戏机制
  • 第5章 碰撞、音效与结束条件:让游戏可玩
  • 第6章 面向对象编程——开始开发《乒乓》游戏
  • 第7章 AABB碰撞检测与物理效果——完成乒乓球游戏
  • 第8章 SFML视图——开启丧尸射击游戏
    • 规划并开启丧尸竞技场游戏
      • 创建新项目
      • 项目资源
      • 探索资源
      • 将资源添加到项目中
    • 面向对象编程与丧尸竞技场项目
    • 创建玩家类——第一个类
      • 编写Player类头文件
      • 编写Player类函数定义
    • 使用SFML视图(View)控制游戏相机
    • 启动《僵尸竞技场》游戏引擎
    • 管理代码文件
    • 开始编写主游戏循环
    • 总结
    • 常见问题解答
  • 第9章 C++引用、精灵表和顶点数组
  • 第10章 指针、标准模板库和纹理管理
  • 第11章 编写TextureHolder类并创建一群僵尸
  • 第12章 碰撞检测、拾取物与子弹
  • 第13章 视图分层与平视显示器(HUD)的实现
  • 第14章 音效、文件输入输出与完成游戏制作
  • 第15章 快跑!
  • 第16章 声音、游戏逻辑、对象间通信与玩家
  • 第17章 图形、相机与动作
  • 第18章 为平台、玩家动画和控制功能编写代码
  • 第19章 构建菜单与实现下雨效果
  • 第20章 火球与声音空间化
  • 第21章 视差背景与着色器
目录

第8章 SFML视图——开启丧尸射击游戏

# 第8章 SFML视图——开启丧尸射击游戏

在这个项目中,我们将更充分地利用面向对象编程(Object-Oriented Programming,OOP),并发挥其强大的作用。我们还将探索SFML的视图类(View class)。这个多功能的类能让我们轻松地将游戏划分为不同的层,以对应游戏的不同方面。在丧尸射击项目中,我们会有一个用于平视显示器(heads-up display,HUD)的层和一个用于主游戏的层。这是很有必要的,因为每当玩家清除一波丧尸后,游戏世界就会扩大。最终,游戏世界会比屏幕大,需要滚动显示。使用视图类可以防止平视显示器的文本随着背景一起滚动。

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

  • 规划并开启丧尸竞技场(Zombie Arena)游戏
  • 面向对象编程与丧尸竞技场项目
  • 创建玩家类——第一个类
  • 使用SFML视图控制游戏摄像头
  • 开启丧尸竞技场游戏引擎
  • 管理代码文件
  • 开始编写主游戏循环

你可以在GitHub代码库中找到本章的源代码:https://github.com/PacktPublishing/Beginning-C-Game-Programming-Third-Edition/tree/main/ZombieShooter 。

# 规划并开启丧尸竞技场游戏

此时,如果你还没有做过,我建议你去观看《Over 9000 Zombies》(http://store.steampowered.com/app/273500/ )和《血腥大地》(Crimson Land,http://store.steampowered.com/app/262830/ )的游戏视频。显然,我们的游戏不会像这两个例子那么有深度或那么高级,但我们也会有相同的基本功能和游戏机制,比如:

  • 一个平视显示器,显示得分、最高分、弹夹中的子弹数量、剩余子弹数量、玩家生命值以及剩余待消灭的丧尸数量等详细信息。
  • 玩家一边疯狂地逃离丧尸,一边射击它们。
  • 使用WASD键盘按键在滚动的世界中移动,同时使用鼠标瞄准枪支。
  • 在每一关之间,玩家将选择一项“升级”,这会影响玩家为了获胜而需要采用的游戏方式。
  • 玩家需要收集“补给品”来恢复生命值和弹药。
  • 每一波都会带来更多的丧尸和更大的竞技场,使游戏更具挑战性。

游戏中会有三种类型的丧尸。它们会有不同的属性,比如外观、生命值和速度。我们分别称它们为追逐者(chasers)、膨胀者(bloaters)和爬行者(crawlers)。它们分别是速度快的、体型胖的和在地上爬行的丧尸。看一下下面这张带注释的游戏截图,了解游戏中的一些功能,以及构成游戏的组件和资源:

img

图8.1:游戏中的功能、构成游戏的组件和资源

以下是关于图中编号各点的更多信息:

  • 得分和最高分:这些以及平视显示器的其他部分将绘制在一个单独的层(称为视图,由视图类的一个实例表示)中。最高分将被保存并加载到一个文件中。
  • 用于在竞技场周围建造墙壁的纹理:这个纹理与其他背景纹理(第3、5和6点)一起包含在一个名为精灵表(sprite sheet)的单个图形中。
  • 精灵表中的两种泥地纹理之一。
  • 这是一个“弹药补给品”。玩家拿到它时,会获得更多弹药。还有一个“生命补给品”,玩家拿到它时会恢复更多生命值。玩家可以在丧尸波之间选择升级这些补给品。
  • 同样来自精灵表的草地纹理。
  • 精灵表中的第二种泥地纹理。
  • 曾经有丧尸的地方留下的血迹。
  • 平视显示器的底部:从左到右,有一个代表弹药的图标、弹夹中的子弹数量、备用子弹数量、一个生命值条、当前的丧尸波数以及当前波剩余的丧尸数量。
  • 玩家角色。
  • 玩家用鼠标瞄准的准星。
  • 行动缓慢但很强壮的“膨胀者”丧尸。
  • 行动稍快但较弱的“爬行者”丧尸。还有一种“追逐者丧尸”,速度非常快但很脆弱。遗憾的是,在我截图之前,它们都被消灭了,所以没能在截图中展示出来。

所以,我们有很多工作要做,还要学习新的C++技能。让我们从创建一个新项目开始吧。

# 创建新项目

由于项目设置过程比较繁琐,我们将像创建《木材!!!》(Timber!!!)项目时那样,一步一步来。我不会像在《木材!!!》项目中那样展示相同的图片,但过程是一样的;所以,如果你想回顾各种项目属性的位置,可以翻回到第1章《C++、SFML、Visual Studio与开启第一款游戏》。

让我们按以下步骤操作:

  1. 启动Visual Studio,点击“创建新项目”按钮。如果你已经打开了另一个项目,可以选择“文件”|“新建项目”。
  2. 在下一个显示的窗口中,选择“控制台应用”,然后点击“下一步”按钮。接着你会看到“配置新项目”窗口。
  3. 在“配置新项目”窗口中,在“项目名称”字段中输入“Zombie Arena”。
  4. 在“位置”字段中,浏览到“VS Projects”文件夹。
  5. 勾选“将解决方案和项目放在同一目录中”选项。
  6. 完成上述步骤后,点击“创建”。
  7. 现在我们要配置项目,使其使用我们放在“SFML”文件夹中的SFML文件。从主菜单中选择“项目”|“Zombie Arena属性…”。此时,你应该已经打开了“Zombie Arena属性页”窗口。
  8. 在“Zombie Arena属性页”窗口中,从“配置:”下拉菜单中选择“所有配置”,并确保右侧的下拉菜单设置为“Win32”,而不是“x64”。
  9. 现在,从左侧菜单中选择“C/C++”,然后选择“常规”。
  10. 接下来,找到“附加包含目录”编辑框,输入你的“SFML”文件夹所在的盘符,后面跟着“\SFML\include”。如果你把“SFML”文件夹放在D盘,要输入的完整路径就是“D:\SFML\include”。如果你的“SFML”安装在不同的盘符,请相应修改路径。
  11. 点击“应用”保存到目前为止的配置。
  12. 仍然在同一个窗口中,执行以下步骤。从左侧菜单中选择“链接器”,然后选择“常规”。
  13. 现在,找到“附加库目录”编辑框,输入你的“SFML”文件夹所在的盘符,后面跟着“\SFML\lib”。所以,如果你把“SFML”文件夹放在D盘,要输入的完整路径就是“D:\SFML\lib”。如果你的“SFML”安装在不同的盘符,请相应修改路径。
  14. 点击“应用”保存到目前为止的配置。
  15. 选择“链接器”,然后选择“输入”。
  16. 找到“附加依赖项”编辑框,点击其最左侧。现在,复制并粘贴/输入以下内容:“sfml-graphics-d.lib;sfml-window-d.lib;sfml-system-d.lib;sfml-network-d.lib;sfml-audio-d.lib;”。特别要注意将光标准确地放在编辑框当前内容的开头,以免覆盖已有的任何文本。
  17. 点击“确定”。
  18. 点击“应用”,然后点击“确定”。
  19. 在Visual Studio主屏幕上,检查主菜单工具栏是否设置为“调试”和“x86”,而不是“x64”。

现在,你已经配置好了项目属性,基本准备就绪。接下来,我们需要按照以下步骤将SFML的.dll文件复制到主项目目录中:

  1. 我的主项目目录是“D:\VS Projects\Zombie Arena”。这个文件夹是Visual Studio在上一步中创建的。如果你把项目文件夹放在其他位置,就在你自己的目录中执行这一步。我们需要复制到项目文件夹中的文件在你的“SFML\bin”文件夹中。分别打开这两个位置的窗口,选中所有的.dll文件。
  2. 现在,将选中的文件复制并粘贴到项目中。

项目现在已经设置好了,可以开始了。接下来,我们将探索并添加项目资源。

# 项目资源

这个项目中的资源比之前的游戏更多样化。资源包括:

  • 用于屏幕文本显示的字体
  • 用于不同动作(如射击、重新装填弹药或被丧尸击中)的音效
  • 角色、丧尸的图形,以及包含各种背景纹理的精灵表

游戏所需的所有图形和音效都包含在下载包中。它们分别可以在“第8章/graphics”和“第8章/sound”文件夹中找到。

所需的字体没有提供,这样做是为了避免任何可能的版权问题。这不会造成问题,因为我们会提供下载字体的链接,以及选择字体的方法和位置。

# 探索资源

图形资源构成了我们丧尸竞技场游戏场景的各个部分。看看下面的图形资源,你应该能清楚地知道游戏中的资源将用于何处:

img

图8.2图形资源

然而,不太明显的可能是“background_sheet.png”文件,它包含四种不同的图像。这就是我们之前提到的精灵表。在第9章《C++引用、精灵表和顶点数组》中,我们将了解如何使用精灵表节省内存并提高游戏速度。

音效文件都是.wav格式。这些文件包含在特定事件触发时播放的音效,具体如下:

  • “hit.wav”:当丧尸与玩家接触时播放的声音。
  • “pickup.wav”:当玩家碰撞或踩到(收集)生命补给品时播放的声音。
  • “powerup.wav”:当玩家在每波丧尸之间选择一项属性来增强自己的力量(升级)时播放的声音。
  • “reload.wav”:一种令人满意的咔哒声,让玩家知道他们已经装填了一个新的弹夹。
  • “reload_failed.wav”:一种不太令人满意的声音,表示装填新子弹失败。
  • “shoot.wav”:射击的声音。
  • “splat.wav”:类似丧尸被子弹击中的声音。

一旦你决定了要使用哪些资源,就可以将它们添加到项目中了。

# 将资源添加到项目中

以下说明假设你使用的是本书下载包中提供的所有资源。如果你使用自己的资源,只需用你自己的声音或图形文件替换相应的文件,并使用相同的文件名即可。让我们看看具体步骤:

  1. 浏览到“D:\VS Projects\ZombieArena”。
  2. 在这个文件夹中创建三个新文件夹,分别命名为“graphics”、“sound”和“fonts”。
  3. 从下载包中,将“第8章/graphics”的全部内容复制到“D:\VS Projects\ZombieArena\graphics”文件夹中。
  4. 从下载包中,将“第8章/sound”的全部内容复制到“D:\VS Projects\ZombieArena\sound”文件夹中。
  5. 现在,在网络浏览器中访问http://www.1001freefonts.com/zombie_control.font ,下载“Zombie Control”字体。
  6. 解压下载的压缩文件,将“zombiecontrol.ttf”文件添加到“D:\VS Projects\ZombieArena\fonts”文件夹中。

现在,是时候思考面向对象编程将如何帮助我们完成这个项目了,然后我们就可以开始为丧尸竞技场编写代码了。

# 面向对象编程与丧尸竞技场项目

我们目前面临的首要问题是当前项目的复杂性。让我们设想只有一只丧尸,为了让它在游戏中正常运作,我们需要考虑以下方面:

  • 它的水平和垂直位置
  • 它的大小
  • 它面对的方向
  • 每种丧尸类型的不同纹理
  • 一个精灵(sprite)
  • 每种丧尸类型的不同速度
  • 每种丧尸类型的不同生命值
  • 记录每只丧尸的类型
  • 碰撞检测数据
  • 它的智能(用于追逐玩家),每种丧尸类型的智能略有不同
  • 指示丧尸是活着还是死了

这意味着,仅仅一只丧尸可能就需要十几个变量,而管理一群丧尸则需要这些变量的完整数组。但是,机枪发射的所有子弹、补给品以及不同的升级选项呢?之前更简单的《木材!!!》和《乒乓球》游戏的代码都开始变得有点难以管理了,不难想象这个更复杂的射击游戏会糟糕很多倍!

幸运的是,我们将运用在前两章学到的所有面向对象编程技能,同时还会学习一些新的C++技术。

我们将从创建一个代表玩家的类开始这个项目的编码工作。

# 创建玩家类——第一个类

让我们思考一下玩家类(Player class)需要做什么以及我们对它有哪些需求。这个类需要知道玩家的移动速度、当前在游戏世界中的位置以及生命值。由于从玩家的视角看,玩家类是由一个二维图形角色表示的,所以这个类既需要一个精灵对象(Sprite object),也需要一个纹理对象(Texture object)。

此外,虽然目前原因可能还不明显,但我们的玩家类了解游戏运行的整体环境的一些细节会很有帮助。这些细节包括屏幕分辨率、构成竞技场的图块大小以及当前竞技场的整体大小。

由于玩家类将负责在每一帧中更新自身状态(就像《木材!!!》中的蝙蝠和球一样),所以它需要随时了解玩家的意图。例如,玩家当前是否按住了键盘上的某个方向键?或者,玩家当前是否同时按住了多个键盘方向键?我们将使用布尔变量(Boolean variables)来确定W、A、S和D键的状态。

显然,我们的新类需要很多变量。基于我们所学的面向对象编程知识,我们当然会将所有这些变量设为私有变量。这意味着,我们必须在适当的地方从主函数中提供访问这些变量的途径。

我们将使用大量的获取函数(getter functions),以及一些用于设置对象的函数。这些函数数量相当多,这个类中有21个函数。

一开始,这可能看起来有点令人生畏,但我们会逐一研究这些函数,你会发现大多数函数只是设置或获取一个私有变量而已。

只有几个比较复杂的函数:update函数,它将在主函数的每一帧中被调用一次;spawn函数,它将在每次玩家重生时处理一些私有变量的初始化工作。不过,我们会发现这些都并不复杂。

最好的做法是先编写头文件。这将让我们有机会查看所有的私有变量,并检查所有函数的签名。

密切关注返回值和参数类型,这会让你在理解函数定义中的代码时更加容易。

# 编写Player类头文件

首先,在“解决方案资源管理器”(Solution Explorer)中右键单击“头文件”(Header Files),选择“添加”(Add)|“新建项”(New Item…)。在“添加新建项”窗口中,(通过左击)选中“头文件(.h)”,然后在“名称”字段中输入“Player.h”。最后,点击“添加”按钮。现在我们准备为第一个类编写头文件。

开始编写玩家类(Player class),添加声明,包括开始和结束的花括号,后面跟着一个分号:

#pragma once

#include <SFML/Graphics.hpp> 
using namespace sf;

class Player
{  
};
1
2
3
4
5
6
7
8

现在,让我们在文件中添加所有私有成员变量。根据我们已经讨论过的内容,看看你是否能弄清楚每个变量的作用。我们马上会逐个讲解它们:

class Player
{
private:
	const float START_SPEED = 200;    
	const float START_HEALTH = 100;

    // 玩家位置
    Vector2f m_Position;
    // 精灵
    Sprite m_Sprite;
    // 纹理
    // !!留意此处 - 很快会有变动!!
    Texture m_Texture;
    // 屏幕分辨率是多少
    Vector2f m_Resolution;
    // 当前场景(arena)的大小
    IntRect m_Arena;
    // 场景的每个图块(tile)有多大
    int m_TileSize;
    // 玩家正在向哪个(些)方向移动
    bool m_UpPressed;
    bool m_DownPressed;    
    bool m_LeftPressed;    
    bool m_RightPressed;
    // 玩家有多少生命值
    int m_Health;
    // 玩家最多能拥有的生命值是多少
    int m_MaxHealth;
    // 玩家最后一次受击是什么时候
    Time m_LastHit;
    // 速度,单位为像素每秒
    float m_Speed;
    // 接下来是所有公共函数
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

前面的代码声明了所有成员变量。有些是普通变量,而有些是对象。注意,它们都在类的私有部分,因此不能从类外部直接访问。

此外,注意我们使用的命名约定,给所有非常量变量的名称加上“m_”前缀。“m_”前缀会在编写函数定义时提醒我们,它们是成员变量,与我们在某些函数中创建的局部变量不同,也与函数参数不同。

所有使用的变量都很直观,比如m_Position、m_Texture和m_Sprite,它们分别用于表示玩家的当前位置、纹理和精灵。除此之外,每个变量(或变量组)都有注释,以明确其用途。

然而,为什么确切需要它们,以及使用它们的上下文可能并不那么明显。例如,m_LastHit是Time类型的对象,用于记录玩家最后一次受到僵尸攻击的时间。为什么我们需要这些信息并不明显,但我们很快会讲到。

随着我们逐步完成游戏的其他部分,每个变量的上下文会变得更加清晰。目前重要的是熟悉这些名称和数据类型,以便后续项目顺利进行。

你不需要记住变量的名称和类型,因为在使用代码时我们会讨论所有内容。不过,你需要花时间查看它们,更加熟悉它们。此外,在后续过程中,如果有任何不清楚的地方,参考这个头文件可能会很有帮助。

现在,我们可以添加一长串完整的函数。添加以下突出显示的代码,看看你是否能弄清楚它们的作用。密切注意每个函数的返回类型、参数和名称。这是理解我们在项目其余部分编写的代码的关键。它们能让我们了解每个函数的哪些信息呢?添加以下突出显示的代码,然后我们来分析它:

// 接下来是所有公共函数
public:

    Player();
    void spawn(IntRect arena, Vector2f resolution, int tileSize);
    // 在每场游戏结束时调用这个函数
    void resetPlayerStats();

    // 处理玩家被僵尸击中的情况
    bool hit(Time timeHit);
    // 玩家最后一次被击中是多久以前
    Time getLastHitTime();
    // 玩家在哪里
    FloatRect getPosition();
    // 玩家的中心位置在哪里
    Vector2f getCenter();
    // 玩家面向什么角度
    float getRotation();
    // 将精灵的副本发送到主函数
    Sprite getSprite();
    // 接下来的四个函数用于移动玩家
    void moveLeft ();    
    void moveRight(); 
    void moveUp();
    void moveDown ();
    // 停止玩家向特定方向移动
    void stopLeft();    
    void stopRight(); 
    void stopUp();
    void stopDown();
    // 我们将每帧调用一次这个函数
    void update(float elapsedTime, Vector2i mousePosition);
    // 给玩家加速
    void upgradeSpeed();
    // 给玩家增加一些生命值
    void upgradeHealth();
    // 增加玩家能拥有的最大生命值
    void increaseHealthLevel (int amount);
    // 玩家目前有多少生命值?
    int getHealth();
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

首先,注意所有函数都是公共的。这意味着我们可以使用类的实例从主函数中调用所有这些函数,代码如下:

player.getSprite();
1

假设player是Player类的一个已完全设置好的实例,前面的代码将返回m_Sprite的一个副本。将这段代码放到实际场景中,我们可以在主函数中编写如下代码:

window.draw(player.getSprite());
1

前面的代码会在正确的位置绘制玩家图形,就好像精灵是在主函数中声明的一样。这和我们在乒乓球(Pong)项目中对蝙蝠类(Bat class)所做的操作一样。

在继续在相应的.cpp文件中实现(即编写定义)这些函数之前,让我们依次仔细看看每个函数:

  • void spawn(IntRect arena, Vector2f resolution, int tileSize):这个函数如其名所示。它将准备好对象以供使用,包括将其放置在起始位置(即生成它)。注意,它不返回任何数据,但有三个参数。它接收一个名为arena的IntRect实例,这将是当前关卡的大小和位置;一个Vector2f实例,它将包含屏幕分辨率;还有一个int类型的值,它将保存背景图块的大小。
  • void resetPlayerStats:一旦我们让玩家能够在波次之间升级,我们就需要能够在新游戏开始时取消/重置这些能力。
  • Time getLastHitTime():这个函数只做一件事——返回玩家最后一次被僵尸击中的时间。我们将在检测碰撞时使用这个函数,它能确保玩家不会因为与僵尸接触而被过于频繁地惩罚。
  • FloatRect getPosition():这个函数返回一个FloatRect实例,描述包含玩家图形的矩形的水平和垂直浮点坐标。这对于碰撞检测也很有用。
  • Vector2f getCenter():这个函数与getPosition略有不同,因为它是Vector2f类型,只包含玩家图形正中心的x和y坐标。
  • float getRotation():主函数中的代码有时需要知道玩家当前面向的方向(以度为单位)。3点钟方向是0度,顺时针方向增加。
  • Sprite getSprite():正如我们前面讨论的,这个函数返回代表玩家的精灵的副本。
  • void moveLeft(), ..Right(), ..Up(), ..Down():这四个函数没有返回类型和参数。它们将从主函数中调用,然后当按下一个或多个W、A、S、D键时,Player类将能够做出相应动作。
  • void stopLeft(), ..Right(), ..Up(), ..Down():这四个函数没有返回类型和参数。它们将从主函数中调用,然后当释放一个或多个W、A、S、D键时,Player类将能够做出相应动作。
  • void update(float elapsedTime, Vector2i mousePosition):这将是整个类中唯一的一个长函数。它将在主函数中每帧调用一次。它将完成所有必要的操作,确保更新玩家对象的数据,以便进行碰撞检测和绘制。注意,它不返回任何数据,但接收自上一帧以来经过的时间,以及一个Vector2i实例,该实例将保存鼠标指针/准星的水平和垂直屏幕位置。注意,这些是整数屏幕坐标,与浮点世界坐标不同。
  • void upgradeSpeed():当玩家在升级界面选择让角色跑得更快时,可以调用这个函数。
  • void upgradeHealth():当玩家在升级界面选择让角色更强(即拥有更多生命值)时,可以调用这个函数。
  • void increaseHealthLevel(int amount):与前面的函数有一个细微但重要的区别,这个函数将增加玩家的生命值,最多达到当前设置的最大值。当玩家捡起生命值道具时,将使用这个函数。
  • int getHealth():由于生命值是动态变化的,我们需要能够随时确定玩家拥有多少生命值。这个函数返回一个int类型的值,即玩家的生命值。

和变量一样,现在每个函数的用途应该很清楚了。同样,使用其中一些函数的原因和具体上下文只有在项目推进过程中才会显现出来。

你不需要记住函数的名称、返回类型或参数,因为在使用代码时我们会讨论这些内容。不过,你需要花时间查看它们,并结合前面的解释,更加熟悉它们。此外,在后续过程中,如果有任何不清楚的地方,参考这个头文件可能会很有帮助。

现在,我们可以继续编写函数的核心部分:定义。

# 编写Player类函数定义

最后,我们可以开始编写实现类功能的代码了。

在“解决方案资源管理器”中右键单击“源文件”(Source Files),选择“添加”|“新建项”。在“添加新建项”窗口中,(通过左击)选中“C++文件(.cpp)”,然后在“名称”字段中输入“Player.cpp”。最后,点击“添加”按钮。

从现在开始,我只会让你创建一个新的类或头文件。所以,请记住前面的步骤,或者在需要时参考这里。

现在我们准备为项目中的第一个类编写.cpp文件。

以下是必要的包含指令,后面跟着构造函数的定义。记住,当我们首次实例化一个Player类型的对象时,将调用构造函数。将以下代码添加到Player.cpp文件中,然后我们仔细分析它:

#include "player.h" 
Player::Player()
: m_Speed(START_SPEED),
m_Health(START_HEALTH),
m_MaxHealth(START_HEALTH), 
m_Texture(),
m_Sprite() 
{
    // 将纹理与精灵关联
    // !!留意此处!!
    m_Texture.loadFromFile("graphics/player.png"); 
    m_Sprite.setTexture(m_Texture);

    // 将精灵的原点设置为中心,以便平滑旋转
    m_Sprite.setOrigin(25, 25); 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

在构造函数中(构造函数的名称当然与类名相同,且没有返回类型),我们编写代码来开始设置Player对象,使其可以使用。

需要明确的是,当我们在主函数中编写以下代码时,这段代码将运行:

Player player;
1

暂时不要添加前面这行代码。

m_Speed、m_Health、m_MaxHealth、m_Texture和m_Sprite成员在初始化列表中进行初始化。这被认为是一种良好的实践,因为它可以使代码更高效,并确保在进入构造函数体之前初始化成员。

我们在构造函数体中所做的就是将玩家图形加载到m_Texture中,将m_Texture与m_Sprite关联,并将m_Sprite的原点设置为中心(25, 25)。

注意那个神秘的注释“// !!留意此处!!”,这表明我们会回到纹理加载以及与之相关的一些重要问题上。一旦发现问题并对C++有更多了解,我们最终会改变处理这个纹理的方式。我们将在第10章“指针、标准模板库和纹理管理”中进行修改。

接下来,我们将编写spawn函数。我们只会创建一个Player类的实例。然而,对于每一波游戏,我们都需要将其生成到当前关卡中。这就是spawn函数要为我们处理的事情。将以下代码添加到Player.cpp文件中,务必仔细查看细节并阅读注释:

void Player::spawn(IntRect arena, Vector2f resolution,
int tileSize)
{
    // 将玩家放置在场景中间
    m_Position.x = arena.width / 2; 
    m_Position.y = arena.height / 2;

    // 将场景的详细信息复制到玩家的m_Arena
    m_Arena.left = arena.left;
    m_Arena.width = arena.width; 
    m_Arena.top = arena.top;
    m_Arena.height = arena.height;

    // 记住这个场景中图块的大小
    m_TileSize = tileSize;

    // 存储分辨率以供将来使用
    m_Resolution.x = resolution.x;
    m_Resolution.y = resolution.y; 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

前面的代码首先将m_Position.x和m_Position.y的值初始化为传入场景高度和宽度的一半。这会将玩家移动到关卡的中心,无论关卡大小如何。

接下来,我们将传入场景的所有坐标和尺寸复制到相同类型的成员对象m_Arena中。当前场景的大小和坐标信息使用非常频繁,所以这样做很有意义。现在我们可以使用m_Arena来完成一些任务,比如确保玩家不能穿墙。除此之外,我们将传入的tileSize实例复制到成员变量m_TileSize中,目的相同。我们将在update函数中看到m_Arena和m_TileSize的实际作用。

前面代码的最后两行将屏幕分辨率从spawn函数的参数Vector2f resolution复制到Player类的成员变量m_Resolution中。现在我们在Player类内部可以访问这些值了。

现在,添加非常简单的resetPlayerStats函数代码:

void Player::resetPlayerStats() 
{
    m_Speed = START_SPEED;
    m_Health = START_HEALTH;
    m_MaxHealth = START_HEALTH; 
}
1
2
3
4
5
6

当玩家死亡时,我们将使用这个函数来重置他们可能使用过的任何升级。

在项目几乎完成之前,我们不会编写调用resetPlayerStats函数的代码,但它已经准备好,等我们需要时使用。

在接下来的代码中,我们将添加另外两个函数。它们将处理玩家被僵尸击中时发生的情况。我们将能够调用player.hit()并传入当前游戏时间。我们还可以通过调用player.getLastHitTime()查询玩家最后一次被击中的时间。当我们有了一些僵尸后,这些函数的用处就会变得很明显。

将这两个新定义添加到Player.cpp文件中,然后我们再更仔细地研究一下C++代码:

Time Player::getLastHitTime() {
    return m_LastHit; 
}

bool Player::hit(Time timeHit) {
    if (timeHit.asMilliseconds() - m_LastHit.asMilliseconds() > 200) {
        m_LastHit = timeHit; 
        m_Health -= 10;
        return true; 
    }
    else {
        return false;
    } 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

getLastHitTime()函数的代码非常简单明了,它会返回存储在m_LastHit中的任何值。

hit函数则更深入且微妙一些。首先,if语句检查作为参数传入的时间是否比存储在m_LastHit中的时间晚200毫秒。如果是,m_LastHit会更新为传入的时间,并且m_Health会从当前值中减去10。这个if语句的最后一行代码是return true。注意,else子句只是向调用代码返回false。

这个函数的整体效果是,玩家的生命值每秒最多减少5次。请记住,我们的游戏循环可能每秒运行数千次迭代。在这种情况下,如果没有这个函数的限制,一只僵尸只需要与玩家接触一秒钟,就会扣除数万点生命值。hit函数控制并限制了这种情况。它还通过返回true或false,让调用代码知道是否记录了一次新的攻击。

这段代码意味着我们将在主函数中检测僵尸和玩家之间的碰撞。然后,我们会调用player.hit()来决定是否扣除生命值。

接下来,对于Player类,我们将实现一系列获取器(getter)函数。这些函数让我们可以将数据整齐地封装在Player类中,同时又能将它们的值提供给主函数使用。

在前面的代码块之后添加以下代码:

FloatRect Player::getPosition() {
    return m_Sprite.getGlobalBounds(); 
}

Vector2f Player::getCenter() {
    return m_Position; 
}

float Player::getRotation() {
    return m_Sprite.getRotation(); 
}

Sprite Player::getSprite() {
    return m_Sprite; 
}

int Player::getHealth() {
    return m_Health; 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

前面的代码非常简单明了。前面这五个函数分别返回我们一个成员变量的值。仔细查看每个函数,熟悉哪个函数返回哪个值。

接下来的八个简短函数用于实现键盘控制(我们将在主函数中使用),这样我们就可以更改Player类型对象中包含的数据。将以下代码添加到Player.cpp文件中,然后我们来总结一下它的工作原理:

void Player::moveLeft() {
    m_LeftPressed = true; 
}

void Player::moveRight() {
    m_RightPressed = true; 
}

void Player::moveUp() {
    m_UpPressed = true; 
}

void Player::moveDown() {
    m_DownPressed = true; 
}

void Player::stopLeft() {
    m_LeftPressed = false; 
}

void Player::stopRight() {
    m_RightPressed = false; 
}

void Player::stopUp() {
    m_UpPressed = false; 
}

void Player::stopDown() {
    m_DownPressed = false; 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

前面的代码中有四个函数(moveLeft、moveRight、moveUp和moveDown),它们将相关的布尔变量(m_LeftPressed、m_RightPressed、m_UpPressed和m_DownPressed)设置为true。另外四个函数(stopLeft、stopRight、stopUp和stopDown)则相反,将相同的布尔变量设置为false。现在,Player类的实例可以随时知道W、A、S、D键哪些被按下了,哪些没有被按下。

下面这个函数才是真正起主要作用的。update函数会在游戏循环的每一帧中调用一次。添加以下代码,然后我们详细分析它。如果你理解了前面的八个函数,并且还记得在《Timber!!!》项目中我们是如何为云朵和蜜蜂,以及在《Pong》游戏中为球拍和球制作动画的,那么你可能大部分代码都能看懂:

void Player::update(float elapsedTime, Vector2i mousePosition) {
    if (m_UpPressed) {
        m_Position.y -= m_Speed * elapsedTime; 
    }
    if (m_DownPressed) {
        m_Position.y += m_Speed * elapsedTime; 
    }
    if (m_RightPressed) {
        m_Position.x += m_Speed * elapsedTime; 
    }
    if (m_LeftPressed) {
        m_Position.x -= m_Speed * elapsedTime; 
    }
    m_Sprite.setPosition(m_Position);

    //  Keep  the player  in  the  arena
    if (m_Position.x > m_Arena.width - m_TileSize) {
        m_Position.x = m_Arena.width - m_TileSize; 
    }
    if (m_Position.x < m_Arena.left + m_TileSize) {
        m_Position.x = m_Arena.left + m_TileSize; 
    }
    if (m_Position.y > m_Arena.height - m_TileSize) {
        m_Position.y = m_Arena.height - m_TileSize; 
    }

    if (m_Position.y < m_Arena.top + m_TileSize) {
        m_Position.y = m_Arena.top + m_TileSize; 
    }

    //  Calculate  the  angle  the player  is facing
    float angle = (atan2(mousePosition.y - m_Resolution.y / 2, mousePosition.x - m_Resolution.x / 2) * 180) / 3.141;
    m_Sprite.setRotation(angle);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

前面代码的第一部分用于移动玩家精灵。四个if语句检查与移动相关的布尔变量(m_LeftPressed、m_RightPressed、m_UpPressed或m_DownPressed)哪些为true,并相应地更改m_Position.x和m_Position.y的值。这里也使用了和前两个项目中相同的移动距离计算公式: 位置(加或减)速度×经过的时间。

在这四个if语句之后,调用m_Sprite.setPosition并传入m_Position。这样,精灵在这一帧中就被精确地调整了位置。

接下来的四个if语句检查m_Position.x或m_Position.y是否超出了当前游戏区域(arena)的任何边界。记住,当前游戏区域的边界是在spawn函数中存储在m_Arena中的。让我们看一下这四个if语句中的第一个,以便理解它们的作用:

if (m_Position.x > m_Arena.width - m_TileSize) {
    m_Position.x = m_Arena.width - m_TileSize; 
}
1
2
3

前面的代码检查m_position.x是否大于m_Arena.width减去一个图块(m_TileSize)的大小。当我们创建背景图形时会发现,这个计算可以检测玩家是否碰到了墙壁。

当if语句为true时,使用m_Arena.width - m_TileSize的计算结果来初始化m_Position.x。这意味着玩家图形的中心永远不会超出右侧墙壁的左边缘。

在我们刚刚讨论的这个if语句之后的另外三个if语句,对另外三面墙做了同样的处理。

前面代码的最后两行计算并设置玩家精灵的旋转角度(即朝向)。这行代码看起来可能有点复杂,让我们深入分析一下。

首先,再次给出这行代码以供参考:

//  Calculate  the  angle  the player  is facing
float angle = (atan2(mousePosition.y - m_Resolution.y / 2, mousePosition.x - m_Resolution.x / 2) * 180) / 3.141;
m_Sprite.setRotation(angle);
1
2
3

总之,这段代码计算了屏幕中心(假设为(m_Resolution.x / 2, m_Resolution.y / 2))和当前鼠标位置之间的角度。然后,根据这个角度设置代表玩家的精灵的旋转角度。

首先,代码计算角度:

atan2(mousePosition.y - m_Resolution.y / 2, mousePosition.x - m_Resolution.x / 2)
1

atan2函数用于计算屏幕中心(m_Resolution.x / 2, m_Resolution.y / 2)和当前鼠标位置(mousePosition.x, mousePosition.y)之间的假想线所形成的角度。

这个计算结果是以弧度为单位的,但SFML使用的是度。同一行代码的下一部分将弧度转换为度:

* 180
1

乘以180就将其转换为度。接下来,除以3.141(即圆周率Pi),使角度在0到360度的范围内。这意味着这个角度是在一个完整的圆周内。

/ 3.141
1

最后,我们设置精灵的旋转角度:

m_Sprite.setRotation(angle);
1

顺便说一句,我在这里极大地简化了atan函数在底层的工作方式,但这就是函数的作用。这就是我的解释,我就这么理解了。如果你想更深入地研究C++数学库,可以去探索一下。

如果你想更详细地了解三角函数,可以访问这里:http://www.cplusplus.com/reference/cmath/ (opens new window)。

我们为Player类添加的最后三个函数分别是让玩家速度提升20%、生命值提升20%,以及按传入的数值增加玩家生命值。

在Player.cpp文件的末尾添加以下代码,然后我们仔细研究一下:

void Player::upgradeSpeed() {
    //  20% speed  upgrade
    m_Speed += (START_SPEED * .2); 
}

void Player::upgradeHealth() {
    //  20% max  health  upgrade
    m_MaxHealth += (START_HEALTH * .2); 
}

void Player::increaseHealthLevel(int amount) {
    m_Health += amount;
    //  But  not  beyond  the maximum
    if (m_Health > m_MaxHealth) {
        m_Health = m_MaxHealth; 
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

在前面的代码中,upgradeSpeed()和upgradeHealth()函数分别增加了存储在m_Speed和m_MaxHealth中的值。这些值通过将起始值乘以0.2并加到当前值上,从而提升20%。当玩家在关卡之间选择想要提升角色的哪些属性(即升级)时,这些函数将在主函数中被调用。

increaseHealthLevel()函数从主函数的amount参数中获取一个int值。这个int值将由一个名为Pickup的类提供,我们将在第12章“碰撞检测、拾取物和子弹”中编写这个类。

m_Health成员变量会增加传入的值。不过,对玩家来说有个限制。if语句会检查m_Health是否超过了m_MaxHealth,如果超过了,就将其设置为m_MaxHealth。这意味着玩家不能通过拾取物简单地获得无限生命值。相反,他们必须在关卡之间仔细权衡所选择的升级。

当然,在我们实例化Player类并在游戏循环中使用它之前,它什么也做不了。在那之前,让我们先来了解一下游戏相机的概念。

# 使用SFML视图(View)控制游戏相机

在我看来,SFML的View类是最有用的类之一。学完这本书后,如果你在制作游戏时不使用媒体/游戏库,你会真切地感受到没有View类会带来诸多不便。

View类让我们可以把游戏看作是发生在一个拥有自身属性的独立世界中。我这么说是什么意思呢?嗯,当我们制作游戏时,通常是在尝试创建一个虚拟世界。那个虚拟世界很少(如果有的话)是以像素为单位来衡量的,而且那个世界的像素数量也很少(如果有的话)会和玩家的显示器一样。我们需要一种方法来抽象我们正在构建的虚拟世界,这样它就可以是我们想要的任何大小或形状。

另一种理解SFML View的方式是把它看作玩家用来查看虚拟世界一部分的相机。大多数游戏会有不止一个相机/视图来查看游戏世界。

例如,想想分屏游戏,两个玩家可以同时处于游戏世界的不同部分。或者,想想这样一种游戏,屏幕上有一个小区域代表整个游戏世界,但显示的是高视角/缩放后的画面,就像一个小地图。

即使我们的游戏比前面两个例子简单得多,不需要分屏或小地图,我们可能还是希望创建一个比游戏运行时屏幕更大的世界。《僵尸竞技场(Zombie Arena)》就是这种情况。

此外,如果我们不断移动游戏相机来展示虚拟世界的不同部分(通常是为了追踪玩家),那么平视显示器(HUD,Head-Up Display)会怎样呢?如果我们绘制分数和其他屏幕上的HUD信息,然后滚动游戏世界来跟随玩家,分数就会相对于相机移动。

SFML的View类可以轻松实现所有这些功能,并且用非常简单的代码解决这个问题。诀窍是为每个相机创建一个View实例——比如为小地图创建一个View实例,为滚动的游戏世界创建一个View实例,然后为HUD创建一个View实例。

View实例可以根据需要进行移动、调整大小和定位。所以,跟随游戏的主View实例可以追踪玩家,小地图视图可以固定在屏幕的一个缩小的角落,而HUD可以覆盖整个屏幕,并且无论主View实例跟随玩家移动到哪里,它都不会移动。

让我们看一些使用几个View实例的代码。

这段代码是用来介绍View类的。不要把这段代码添加到《僵尸竞技场》项目中。

//  Create  a  view  to fill  a  1920 x  1080 monitor
View mainView(sf::FloatRect(0, 0, 1920, 1080));
//  Create  a  view for  the HUD
View hudView(sf::FloatRect(0, 0, 1920, 1080));
1
2
3
4

前面的代码创建了两个填满1920×1080分辨率显示器的View对象。现在,我们可以对mainView进行一些操作,同时完全不影响hudView:

//  In  the  update part  of  the  game
//  There  are  lots  of  things you  can  do  with  a  View 
// Make  the  view  centre  around  the player
mainView.setCenter(player.getCenter());
//  Rotate  the  view 45  degrees
mainView.rotate(45)
// Note  that  hudView  is  totally  unaffected  by  the previous  code
1
2
3
4
5
6
7

当我们操作View实例的属性时,就是这样做的。当我们向一个视图绘制精灵、文本或其他对象时,必须专门将该视图设置为窗口的当前视图:

// Set  the  current  view
window.setView(mainView);
1
2

现在,我们可以在这个视图中绘制所有我们想要的内容:

// Do  all  the  drawing for  this  view
window.draw(playerSprite);
window.draw(otherGameObject);
//  etc
1
2
3
4

玩家可能在任何坐标位置,这都没关系,因为mainView是以代表玩家的图形为中心的。

现在,我们可以在hudView中绘制HUD。注意,就像我们从后向前分层绘制各个元素(背景、游戏对象、文本等等)一样,我们也是从后向前绘制视图的。因此,HUD是在主游戏场景之后绘制的:

// Switch  to  the  hudView
window.setView(hudView);
// Do  all  the  drawing for  the HUD
window.draw(scoreText); window.draw(healthBar);
//  etc
1
2
3
4
5

最后,我们可以像往常一样绘制/显示窗口及其所有视图的当前帧:

window.display();
1

如果你想对SFML View的理解超出本项目的要求,包括如何实现分屏和小地图,那么网上最好的指南在SFML官方网站上:https://www.sfml-dev.org/tutorials/2.5/graphics-view.php (opens new window)。

现在我们已经了解了View,就可以开始编写《僵尸竞技场》的主函数,并真正使用我们的第一个View实例了。在第13章“视图分层和实现HUD”中,我们将为HUD引入第二个View实例,并将其层叠在主View实例之上。

# 启动《僵尸竞技场》游戏引擎

在这个游戏中,我们需要在main函数里对游戏引擎进行一些升级。我们将创建一个名为state的枚举类型,用于跟踪游戏的当前状态。然后,在整个main函数中,我们可以将部分代码进行封装,以便在不同状态下执行不同的操作。

创建项目时,Visual Studio 为我们创建了一个名为ZombieArena.cpp的文件。这个文件将包含我们的main函数,以及实例化和控制所有类的代码。

我们从熟悉的main函数和一些包含指令开始。注意,这里添加了对Player类的包含指令。

删除Visual Studio添加到ZombieArena.cpp中的代码,并在ZombieArena.cpp文件中添加以下代码:

#include <SFML/Graphics.hpp> 
#include "Player.h"

using namespace sf; 
int main()
{
    return 0; 
}
1
2
3
4
5
6
7
8

上述代码中除了#include "Player.h"这一行外,没有新内容。这一行代码意味着我们现在可以在代码中使用Player类了。

让我们进一步完善游戏引擎。以下代码功能丰富,添加代码时请务必阅读注释,以便了解其功能,之后我们会详细讲解。

在main函数开头添加以下高亮显示的代码:

int main() {
    // 游戏将始终处于四种状态之一
    enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };

    // 初始状态为GAME_OVER
    State state = State::GAME_OVER;

    // 获取屏幕分辨率并创建一个SFML窗口
    Vector2f resolution;  
    resolution.x = VideoMode::getDesktopMode().width;  
    resolution.y = VideoMode::getDesktopMode().height;  
    RenderWindow window(
        VideoMode(resolution.x, resolution.y), "Zombie Arena", Style::Fullscreen);

    // 创建一个用于主要游戏场景的SFML视图
    View mainView(sf::FloatRect(0, 0,
        resolution.x, resolution.y));

    // 这里是用于计时的时钟
    Clock clock;

    // PLAYING状态持续了多长时间
    Time gameTimeTotal;

    // 鼠标在世界坐标中的位置
    Vector2f mouseWorldPosition;

    // 鼠标在屏幕坐标中的位置
    Vector2i mouseScreenPosition;

    // 创建一个Player类的实例
    Player player;

    // 竞技场的边界
    IntRect arena;

    // 主游戏循环
    while (window.isOpen()) {
        
    }
    return 0; 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

让我们逐段分析输入的所有代码。在main函数内部,有以下代码:

// 游戏将始终处于四种状态之一
enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };

// 初始状态为GAME_OVER
State state = State::GAME_OVER;
1
2
3
4
5

上述代码创建了一个名为State的新枚举类,然后创建了一个State类的实例state。state枚举现在可以取声明中定义的四个值之一,即PAUSED、LEVELING_UP、GAME_OVER和PLAYING。这四个值将用于跟踪和响应游戏在任何给定时间可能处于的不同状态。请注意,state一次只能持有一个值。

紧接着,我们添加了以下代码:

// 获取屏幕分辨率并创建一个SFML窗口
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
RenderWindow window(VideoMode(resolution.x, resolution.y), "Zombie Arena", Style::Fullscreen);
1
2
3
4
5

上述代码声明了一个名为resolution的Vector2f实例。我们通过调用VideoMode::getDesktopMode函数获取宽度和高度,来初始化resolution的两个成员变量(x和y)。resolution对象现在保存了运行游戏的显示器的分辨率。代码的最后一行使用合适的分辨率创建了一个名为window的新RenderWindow实例。

以下代码创建了一个SFML视图对象。该视图(最初)位于显示器像素的精确坐标处。如果我们在当前位置使用这个视图进行绘图,效果与不使用视图绘制窗口相同。然而,我们最终会移动这个视图,使其聚焦于玩家需要看到的游戏世界部分。然后,当我们开始使用第二个固定不变的视图实例(用于显示平视显示器,HUD)时,我们将看到这个视图实例如何跟踪游戏动作,而另一个视图保持静态以显示HUD:

// 创建一个用于主要游戏场景的SFML视图
View mainView(sf::FloatRect(0, 0, resolution.x, resolution.y));
1
2

接下来,我们创建了一个Clock实例用于计时,并创建了一个名为gameTimeTotal的Time对象,用于累计游戏已流逝的时间。随着项目的推进,我们还将引入更多变量和对象来处理计时问题:

// 这里是用于计时的时钟
Clock clock;

// PLAYING状态持续了多长时间
Time gameTimeTotal;
1
2
3
4
5

以下代码声明了两个向量:一个包含两个浮点型变量,名为mouseWorldPosition;另一个包含两个整型变量,名为mouseScreenPosition。鼠标指针有点特殊,因为它存在于两个不同的坐标空间中。如果愿意,我们可以将其想象成两个平行宇宙。首先,当玩家在游戏世界中移动时,我们需要跟踪准星在游戏世界中的位置。这些位置将是浮点型坐标,并存储在mouseWorldCoordinates中。当然,显示器实际的像素坐标是不变的,始终是从0,0到水平分辨率减1、垂直分辨率减1。我们将使用存储在mouseScreenPosition中的整型变量来跟踪相对于这个坐标空间的鼠标指针位置:

// 鼠标在世界坐标中的位置
Vector2f mouseWorldPosition;

// 鼠标在屏幕坐标中的位置
Vector2i mouseScreenPosition;
1
2
3
4
5

最后,我们使用Player类。这行代码将调用构造函数(Player::Player)。如果你想回顾这个函数的内容,可以查看Player.cpp:

// 创建一个Player类的实例
Player player;
1
2

这个IntRect对象将保存起始的水平和垂直坐标,以及宽度和高度。初始化后,我们可以使用arena.left、arena.top、arena.width和arena.height等代码访问当前竞技场的大小和位置信息:

// 竞技场的边界
IntRect arena;
1
2

我们之前添加的代码的最后一部分当然是游戏循环:

// 主游戏循环
while (window.isOpen()) {
    
}
1
2
3
4

你可能已经注意到代码变得相当长了。我们将在下一节讨论这个不便之处。

# 管理代码文件

使用类和函数进行抽象的优点之一是可以减少代码文件的长度(行数)。尽管在这个项目中我们将使用十几个以上的代码文件,但到最后,ZombieArena.cpp中的代码长度仍会有些难以处理。在下一个也是最后一个项目中,我们将探讨更多抽象和管理代码的方法。

目前,使用以下技巧可以使代码更易于管理。注意在Visual Studio代码编辑器的左侧,有几个+和-符号,其中一个如图所示:

img 图8.3:Visual Studio代码编辑器中的符号

代码中的每个代码块(if、while、for等)都会有一个这样的符号。你可以通过点击+和-符号来展开和折叠这些代码块。我建议折叠当前未讨论的所有代码,这样会使代码更清晰。

此外,我们可以创建自己的可折叠代码块。我建议将主游戏循环开始前的所有代码创建为一个可折叠代码块。为此,选中代码,然后右键单击并选择“大纲显示”|“隐藏选定内容”,如下图所示:

img

图8.4:创建可折叠代码块

现在,你可以点击-和+符号来展开和折叠该代码块。每次在主游戏循环前添加代码(这种情况会经常出现)时,你可以展开代码,添加新行,然后再次折叠。折叠后的代码如下图所示:

img

图8.5:折叠后的代码

这样比之前好管理多了。现在,我们可以开始编写主游戏循环了。

# 开始编写主游戏循环

如你所见,前面代码的最后一部分是游戏循环(while (window.isOpen()) {})。我们现在将注意力转向这里,具体来说,我们将编写游戏循环中的输入处理部分。

我们要添加的代码相当长,但并不复杂,稍后我们会详细分析。

在游戏循环中添加以下高亮显示的代码:

// 主游戏循环
while (window.isOpen()) {
    /*
        ************    处理输入        ************        
    */
    // 通过轮询处理事件
    Event event;
    while (window.pollEvent(event)) {
        if (event.type == Event::KeyPressed) {
            // 游戏进行时暂停游戏
            if (event.key.code == Keyboard::Return && state == State::PLAYING) {
                state = State::PAUSED; 
            }
            // 暂停时重新开始游戏
            else if (event.key.code == Keyboard::Return && state == State::PAUSED) {
                state = State::PLAYING;
                // 重置时钟,避免帧率跳跃
                clock.restart(); 
            }
            // 在GAME_OVER状态下开始新游戏
            else if (event.key.code == Keyboard::Return && state == State::GAME_OVER) {
                state = State::LEVELING_UP; 
            }
            if (state == State::PLAYING) {
                
            }
        }
    }// 结束事件轮询
}// 结束游戏循环
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

在上述代码中,我们实例化了一个Event类型的对象。与之前的项目一样,我们将使用event来轮询系统事件。为此,我们将之前代码块的其余部分包裹在一个while循环中,循环条件为window.pollEvent(event)。这个循环会在每一帧持续执行,直到没有更多事件需要处理。

在这个while循环内部,我们处理感兴趣的事件。首先,我们检测Event::KeyPressed事件。如果在游戏处于PLAYING状态时按下回车键,我们将状态切换为PAUSED。

如果在游戏处于PAUSED状态时按下回车键,我们将状态切换为PLAYING并重新启动clock对象。从PAUSED切换到PLAYING后重新启动clock的原因是,游戏暂停时,经过的时间仍在累计。如果不重新启动时钟,我们所有的对象在更新位置时,就好像这一帧的时间特别长。随着我们在这个文件中完善其余代码,这一点会更加明显。

接着,我们有一个else if代码块,用于检测在游戏处于GAME_OVER状态时是否按下了回车键。如果按下了回车键,那么游戏状态会变为LEVELING_UP。

请注意,GAME_OVER状态就是显示主屏幕的状态。所以,GAME_OVER状态既指玩家刚刚死亡后的状态,也指玩家首次运行游戏时的状态。玩家在每一局游戏开始时要做的第一件事,就是选择一个属性进行提升(也就是升级)。

在前面的代码中,有最后一个if条件用于检测游戏状态是否等于PLAYING。这个if代码块目前是空的,在整个项目过程中我们会往里面添加代码。

在整个项目中,我们会在这个文件的很多不同部分添加代码。因此,花时间去理解游戏可能处于的不同状态以及我们在何处处理这些状态是很有价值的。在适当的时候折叠和展开不同的if、else和while代码块也会非常有帮助。

花些时间彻底熟悉一下我们刚刚编写的while、if和else if代码块。我们会经常用到它们。

接下来,在前面代码的紧后面,并且仍然在游戏循环(这个循环仍在处理输入)内部,添加以下突出显示的代码。注意现有的代码(未突出显示),它准确表明了新(突出显示)代码的插入位置:

}//  End  event polling

// Handle  the player  quitting
if  (Keyboard::isKeyPressed(Keyboard::Escape))      {
    window.close();     }

// Handle WASD  while playing
if   (state  ==   State::PLAYING)   {
    // Handle  the pressing  and  releasing  of WASD  keys
    if   (Keyboard::isKeyPressed(Keyboard::W))      {
        player.moveUp();    }
    else
    {
        player.stopUp();
    }
    if  (Keyboard::isKeyPressed(Keyboard::S))       {
        player.moveDown ();     }
    else
    {
        player.stopDown();
    }
    if  (Keyboard::isKeyPressed(Keyboard::A))       {
        player.moveLeft ();     }
    else
    {
        player.stopLeft();
    }
    if   (Keyboard::isKeyPressed(Keyboard::D))      {
        player.moveRight();     }
    else
    {
        player.stopRight();     }
}//  End WASD  while playing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

在前面的代码中,我们首先检测玩家是否按下了Escape键。如果按下了,游戏窗口将会关闭。

接下来,在一个大的if(state == State::PLAYING)代码块中,我们依次检查每个WASD键。如果某个键被按下,我们就调用相应的player.move...函数。如果没有按下,我们就调用相关的player.stop...函数。

这段代码确保在每一帧中,玩家对象会根据按下和未按下的WASD键进行更新。player.move...和player.stop...函数会将信息存储在成员布尔变量(m_LeftPressed、m_RightPressed、m_UpPressed和m_DownPressed)中。然后,在每帧中,Player类会在player.update函数中根据这些布尔值做出响应,我们会在游戏循环的更新部分调用这个函数。

现在,我们可以处理键盘输入,让玩家在每局游戏开始以及每一波游戏之间进行升级。添加并研究以下突出显示的代码,然后我们再进行讨论:

}//  End  WASD  while playing

// Handle  the  LEVELING  up  state
if   (state  ==   State::LEVELING_UP)   {
    // Handle  the player  LEVELING  up
    if   (event.key.code  ==   Keyboard::Num1)      {
        state   =  State::PLAYING;      }
    if   (event.key.code  ==   Keyboard::Num2)      {
        state   =  State::PLAYING;      }
    if   (event.key.code  ==   Keyboard::Num3)      {
        state   =  State::PLAYING;      }
    if   (event.key.code  ==   Keyboard::Num4)      {
        state   =  State::PLAYING;      }
    if   (event.key.code  ==   Keyboard::Num5)      {
        state   =  State::PLAYING;      }
    if   (event.key.code  ==   Keyboard::Num6)      {
        state   =  State::PLAYING;      }

    if   (state  ==   State::PLAYING)   {
        // Prepare  the  level
        // We  will modify  the  next  two  lines  later
        arena.width   =   500;      
        arena.height   =   500;     
        arena.left   =  0;
        arena.top   =   0;
        // We  will modify  this  line  of  code  later
        int  tileSize   =   50;
        // Spawn  the player  in middle  of  the  arena
        player.spawn(arena,  resolution,  tileSize);

        // Reset  clock  so  there  isn't  a frame jump
        clock. restart();   }
}//  End  LEVELING  up
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

在前面的代码中,所有内容都包含在一个用于检测当前state值是否等于LEVELING_UP的测试中,我们处理键盘上的1、2、3、4、5和6键。在每个if代码块中,我们只是将state设置为State::PLAYING。在第14章“音效、文件输入/输出和完成游戏”中,我们会添加一些代码来处理每个升级选项。

这段代码实现了以下功能:

  1. 如果游戏状态等于LEVELING_UP,等待按下1、2、3、4、5或6键。
  2. 当按下这些键时,将游戏状态更改为PLAYING。
  3. 当游戏状态改变时,仍然在if (state == State::LEVELING_UP)代码块内,嵌套的if(state == State::PLAYING)代码块将会运行。
  4. 在这个代码块内,我们设置arena的位置和大小,将tileSize设置为50,把所有信息传递给player.spawn,并调用clock.restart。

现在,我们有了一个实际生成的玩家对象,它能感知自身所处的环境并且可以对按键做出响应。我们现在可以在每次循环时更新场景了。

一定要整齐地折叠游戏循环中处理输入部分的代码,因为我们目前已经完成了这部分内容。以下代码位于游戏循环的更新部分。添加并研究以下突出显示的代码,然后我们可以进行讨论:

}//  End  LEVELING  up

/*
    ****************    UPDATE  THE  FRAME      ****************        
*/
if   (state  ==   State::PLAYING)   {
    // Update  the  delta  time
    Time  dt  =   clock.restart();

    // Update  the  total  game  time
    gameTimeTotal   +=   dt;

    // Make  a fraction  of 1 from  the  delta  time
    float  dtAsSeconds   =  dt.asSeconds ();
    // Where  is  the mouse pointer
    mouseScreenPosition  =  Mouse::getPosition();
    //  Convert mouse position  to  world   // based  coordinates  of mainView
    mouseWorldPosition  =  window.mapPixelToCoords(         Mouse::getPosition(),  mainView);
    // Update  the player
    player.update(dtAsSeconds,  Mouse::getPosition());
    // Make  a  note  of  the players  new position
    Vector2f  playerPosition(player.getCenter());

    // Make  the  view  centre
    //  the  around player                                      
    mainView.setCenter (player.getCenter());    }//  End  updating  the  scene
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

请注意,前面的代码包含在一个测试中,以确保游戏处于PLAYING状态。如果游戏处于暂停、结束状态,或者玩家正在选择升级内容,我们不希望这段代码运行。

首先,我们重启时钟,并将上一帧所用的时间存储在dt变量中:

//  Update  the  delta  time
Time dt = clock.restart();
1
2

接下来,我们将上一帧所用的时间加到游戏运行的累计时间上,累计时间由gameTimeTotal保存:

//  Update  the  total  game  time
gameTimeTotal += dt;
1
2

现在,我们用dt.AsSeconds函数返回的值初始化一个名为dtAsSeconds的float变量。对于大多数帧来说,这个值是一个小于1的分数。这非常适合传递给player.update函数,用于计算玩家精灵的移动距离。

现在,我们可以使用MOUSE::getPosition函数初始化mouseScreenPosition。

你可能对获取鼠标位置的这种有点不寻常的语法感到疑惑。这被称为静态函数。如果我们在一个类中使用static关键字定义一个函数,我们就可以使用类名来调用这个函数,而无需该类的实例。C++面向对象编程(Object Oriented Programming,OOP)有很多这样的特殊情况和规则。随着学习的深入,我们会看到更多。

然后,我们使用SFML库中window的mapPixelToCoords函数初始化mouseWorldPosition。在本章前面讨论View类时,我们提到过这个函数。

此时,我们现在可以调用player.update,并按要求传入dtAsSeconds和鼠标位置。

我们将玩家新的中心位置存储在一个名为playerPosition的Vector2f实例中。目前,这个变量还未使用,但在项目的后续阶段我们会用到它。

然后,我们可以使用mainView.setCenter(player.getCenter())将视图中心设置为玩家最新位置的中心。

现在,我们可以将玩家绘制到屏幕上了。添加以下突出显示的代码,这段代码将主游戏循环的绘制部分按不同状态进行了划分:

}//  End  updating  the  scene

/*
    **************  Draw  the  scene        **************      
*/
if   (state  ==   State::PLAYING)
{
    window.clear();
    // set  the mainView  to  be  displayed  in  the  window    // And  draw  everything  related  to  it
    window.setView(mainView);
    // Draw  the player
    window.draw(player.getSprite());    }
if   (state  ==   State::LEVELING_UP)
{   }
if   (state   ==  State::PAUSED)    {
}
if   (state   ==  State::GAME_OVER)     {
}
window.display();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在前面代码的if(state == State::PLAYING)部分中,我们清空屏幕,将窗口的视图设置为mainView,然后使用window.draw(player.getSprite())绘制玩家精灵。

在处理完所有不同的游戏状态后,代码会像往常一样使用window.display();显示场景。

你可以运行游戏,看到我们的玩家角色会随着鼠标移动而转动。

当你运行游戏时,需要按下回车键开始游戏,然后从1到6中选择一个数字来模拟选择升级选项。接着,游戏就会开始。

你还可以在(空的)500×500像素的游戏区域内移动玩家。你可以看到孤独的玩家位于屏幕中央,如下图所示:

img

图8.6:孤独的玩家位于屏幕中央

不过,你可能感觉不到任何移动效果,因为我们还没有实现背景。我们将在下一章完成这个部分。

# 总结

唷!这章内容真不少。在本章中我们做了很多工作:为“僵尸竞技场”项目构建了第一个类Player,并在游戏循环中使用了它。我们还学习并使用了View类的一个实例,不过目前还没有深入探究它的优势。

在下一章中,我们将通过探索什么是精灵表(sprite sheets)来构建游戏区域的背景。我们还将学习C++引用,它能让我们即使在变量超出作用域(变量在另一个函数中)的情况下,依然可以对其进行操作。

# 常见问题解答

**问:**我注意到我们编写的代码中有一些奇怪的地方。在if语句中,比如下面这个:

if (event.type == Event::KeyPressed)…
1

传递给pollEvent函数的Event参数是如何被使用的呢?毕竟,变量和对象不是只在它们被声明的函数中有作用域吗?

**答:**原因是C++引用。C++中的引用是作为其他变量别名的变量。在讨论的这段代码中,并没有显式的引用。然而,引用被用于高效地将对象传递给函数,避免不必要的复制。由于pollEvent函数的参数被定义为引用,所以可以给传入的event对象赋值,并且这些值在我们的主函数中仍然有效。在下一章讨论引用时,我们会对此有更深入的理解。

**问:**我注意到我们编写了很多Player类的函数,但并没有使用。为什么会这样呢?

**答:**我们没有在后续过程中反复回到Player类去添加代码,而是一次性添加了整个项目所需的所有代码。到第14章“音效、文件输入/输出和完成游戏”结束时,我们会充分利用所有这些函数。

上次更新: 2025/05/07, 21:40:50
第7章 AABB碰撞检测与物理效果——完成乒乓球游戏
第9章 C++引用、精灵表和顶点数组

← 第7章 AABB碰撞检测与物理效果——完成乒乓球游戏 第9章 C++引用、精灵表和顶点数组→

最近更新
01
第二章 关键字static及其不同用法
03-27
02
第一章 auto与类型推导
03-27
03
C++语言面试问题集锦 目录与说明
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式