C++-游戏编程入门指南(全)
C++ 游戏编程入门指南(全)
原文:
annas-archive.org/md5/8b22c2649bdec9fa4ee716ae82ae0bb1
译者:飞龙
前言
这本书是关于以有趣的方式学习 C ++编程。从零开始,您将学习 C ++的基础知识,如变量和循环,直到高级主题,如继承和多态。您学到的一切都将被应用到构建三个完全可玩的游戏中。
这是我们这本书的三个项目。
Timber!!!
第一个游戏是一个令人上瘾的,快节奏的模仿非常成功的伐木工的游戏,store.steampowered.com/app/398710/
。我们的游戏 Timber!!!将让我们同时学习所有 C ++的基础知识,同时构建一个真正可玩的游戏。
僵尸竞技场
接下来,我们将构建一个疯狂的僵尸生存射击游戏,类似于 Steam 的热门游戏 Over 9,000 Zombies,store.steampowered.com/app/273500/
。玩家将拥有一把机关枪,并必须抵御不断增长的僵尸浪潮。所有这些将发生在一个随机生成的滚动世界中。为了实现这一点,我们将学习面向对象编程以及它如何使我们能够拥有一个大量的易于编写和维护的代码库。期待令人兴奋的功能,如数百个敌人,快速射击武器,拾取物品以及每一波后都可以“升级”的角色。
Thomas was Late
第三个游戏将是一个时尚而具有挑战性的单人和合作解谜平台游戏。它基于非常受欢迎的游戏 Thomas was Alone,store.steampowered.com/app/220780/
。期待学习一些酷炫的主题,如粒子效果,OpenGL 着色器和分屏合作多人游戏。
本书涵盖的内容
第一章,“C ++,SFML,Visual Studio 和开始第一个游戏”,这是一个相当庞大的第一章,但我们将学到我们需要的一切,以便让我们的第一个游戏的第一部分运行起来。以下是我们将要做的事情:
-
了解我们将要构建的游戏更多信息
-
学习一些关于 C ++的知识
-
探索 SFML 及其与 C ++的关系
-
查看我们将在整本书中使用的软件 Visual Studio
-
设置游戏开发环境
-
创建一个可重复使用的项目模板,这将节省大量时间
-
计划并准备第一个游戏项目,伐木者!!!
-
编写本书的第一个 C ++代码,并制作一个可运行的游戏,绘制一个背景
第二章,“变量,运算符和决策-动画精灵”,在本章中,我们将在屏幕上进行更多的绘制,并且为了实现这一点,我们需要学习一些 C ++的基础知识。以下是我们将要做的事情:
-
学习所有关于 C ++变量的知识
-
了解如何操作存储在变量中的值
-
添加一个静态树,准备让玩家砍伐
-
绘制和动画一个蜜蜂和三朵云
第三章,“C++字符串,SFML 时间-玩家输入和 HUD”,在本章中,我们将花大约一半的时间学习如何操作文本并在屏幕上显示它,另一半时间看计时和视觉时间条如何通知玩家并在游戏中制造紧迫感。我们将涵盖:
-
暂停和重新开始游戏
-
C ++字符串
-
SFML 文本和 SFML 字体类
-
为 Timber!!!添加 HUD
-
为 Timber!!!添加一个时间条
第四章,循环、数组、开关、枚举和函数-实现游戏机制,这一章可能包含比书中其他任何章节都多的 C++信息。它充满了基本概念,将极大地提高我们的理解。它还将开始阐明一些我们一直略过的模糊领域,比如函数和游戏循环。一旦我们探索了 C++语言的一系列必需知识,我们将利用我们所知道的一切来使主要游戏机制——树枝移动。到本章结束时,我们将准备好进入最后阶段,完成 Timber!!!。准备好接下来的主题:
-
循环
-
数组
-
使用开关进行决策
-
枚举
-
开始使用函数
-
创建和移动树枝
第五章,碰撞、声音和结束条件-使游戏可玩,这是第一个项目的最后阶段。到本章结束时,你将拥有你的第一个完成的游戏。一旦你让 Timber!!!运行起来,请务必阅读本章的最后一节,因为它将提出改进游戏的建议:
-
添加其余的精灵
-
处理玩家输入
-
动画飞行原木
-
处理死亡
-
添加音效
-
添加功能和改进 Timber!!!
第六章,面向对象编程、类和 SFML 视图,这是本书最长的一章。有相当多的理论,但这些理论将使我们有能力开始有效地使用面向对象编程。此外,我们将不会浪费任何时间来充分利用这些理论。在探索 C++面向对象编程之前,我们将了解并计划我们的下一个游戏项目。我们将做以下事情:
-
计划僵尸竞技场游戏
-
了解面向对象编程和类
-
编写 Player 类
-
了解 SFML View 类
-
构建僵尸竞技场游戏引擎
-
让 Player 类投入使用
第七章,C++引用、精灵表和顶点数组,在本章中,我们将探索 C++引用,它允许我们处理变量和对象,否则超出范围。此外,引用将帮助我们避免在函数之间传递大型对象,这是一个缓慢的过程。这是一个缓慢的过程,因为每次这样做时,都必须复制变量或对象。
掌握了关于引用的新知识,我们将看看 SFML VertexArray
类,它允许我们构建一个大图像,可以使用单个图像文件中的多个图像非常快速和高效地绘制到屏幕上。到本章结束时,我们将拥有一个可扩展的、随机的、滚动的背景,使用引用和VertexArray
对象。
我们现在将讨论:
-
C++引用
-
SFML
VertexArrays
-
编写随机滚动背景
第八章,指针、标准模板库和纹理管理,在本章中,我们将学到很多,同时也会为游戏做很多工作。我们将首先学习指针这一基本的 C++主题。指针是保存内存地址的变量。通常,指针将保存另一个变量的内存地址。这听起来有点像引用,但我们将看到它们更加强大,我们将使用指针来处理不断增多的僵尸群。
我们还将学习标准模板库(STL),这是一组允许我们快速、轻松地实现常见数据管理技术的类。
一旦我们理解了 STL 的基础知识,我们就能够利用这些新知识来管理游戏中的所有纹理,因为如果我们有 1000 个僵尸,我们实际上不想为每一个加载一份僵尸图形到 GPU 中。
我们还将深入了解面向对象编程,并使用静态函数,这是一个可以在没有类实例的情况下调用的类函数。同时,我们将看到如何设计一个类,以确保只能存在一个实例。当我们需要保证代码的不同部分将使用相同的数据时,这是理想的。
在这一章中,我们将:
-
学习指针
-
学习 STL
-
使用静态函数和单例类实现 Texture Holder 类
-
实现一个指向一群僵尸的指针
-
编辑一些现有的代码,使用 TextureHolder 类为玩家和背景
第九章, 碰撞检测、拾取物品和子弹,到目前为止,我们已经实现了游戏的主要视觉部分。我们有一个可控制的角色在一个充满追逐他的僵尸的竞技场中奔跑。问题是它们彼此之间没有互动。僵尸可以毫无阻碍地穿过玩家。我们需要检测僵尸和玩家之间的碰撞。
如果僵尸能够伤害并最终杀死玩家,那么给玩家一些子弹是公平的。然后我们需要确保子弹能够击中并杀死僵尸。
同时,如果我们为子弹、僵尸和玩家编写碰撞检测代码,那么现在是添加一个用于健康和弹药拾取物品的类的好时机。
以下是我们将要做的事情和我们将要涵盖的顺序:
-
射击子弹
-
添加准星并隐藏鼠标指针
-
生成拾取物品
-
检测碰撞
第十章, 分层视图和实现 HUD,在这一章中,我们将看到 SFML 视图的真正价值。我们将添加大量的 SFML 文本对象,并像之前在 Timber!!!项目中那样操纵它们。新的是我们将使用第二个视图实例来绘制 HUD。这样,HUD 将始终整齐地定位在主游戏动作的顶部,而不管背景、玩家、僵尸和其他游戏对象在做什么。
我们将做以下事情:
-
在主页/游戏结束屏幕上添加文本和背景
-
在升级屏幕上添加文本
-
创建第二个视图
-
添加 HUD
第十一章, 音效、文件 I/O 和完成游戏,我们快要完成了。这一小节将演示我们如何使用 C++标准库轻松操作存储在硬盘上的文件,我们还将添加音效。当然,我们知道如何添加音效,但我们将讨论在代码中play
的调用应该放在哪里。我们还将收尾一些松散的地方,使游戏完整。在这一章中,我们将做以下事情:
-
保存和加载最高分
-
添加音效
-
允许玩家升级
-
创建无尽的多波次
第十二章 ,抽象和代码管理-更好地利用 OOP,在本章中,我们将首次查看本书的最终项目。该项目将具有高级功能,例如与玩家位置相关的从扬声器发出的定向声音。它还将具有分屏合作游戏。此外,该项目将介绍着色器的概念,这是用另一种语言编写的直接在图形卡上运行的程序。到第十六章 结束时,您将拥有一个完全功能的多人平台游戏,其风格类似于经典游戏 Thomas Was Alone。本章的主要重点将是启动项目,特别是探索如何构造代码以更好地利用 OOP。以下是本章的详细信息。
-
介绍最终项目 Thomas Was Late,包括游戏功能和项目资产
-
详细讨论我们将如何改进代码结构,与之前的项目相比
-
编写 Thomas Was Late 游戏引擎
-
实现分屏功能
第十三章 ,高级 OOP-继承和多态,在本章中,我们将通过查看继承和多态的略微更高级的概念,进一步扩展我们对 OOP 的知识。然后,我们将能够使用这些新知识来实现我们游戏的明星角色 Thomas 和 Bob。以下是我们将更详细地涵盖的内容:
-
学习如何使用继承扩展和修改类
-
使用多态将类的对象视为多种类型的类
-
学习抽象类以及设计从未实例化的类如何实际上是有用的
-
构建一个抽象的
PlayableCharacter
类 -
使用继承与
Thomas
和Bob
类 -
将 Thomas 和 Bob 添加到游戏项目
第十四章 ,构建可玩关卡和碰撞检测,本章可能是本项目中最令人满意的章节之一。原因是到最后,我们将拥有一个可玩的游戏。尽管还有一些功能需要实现(声音、粒子效果、HUD、着色器效果),但 Bob 和 Thomas 将能够奔跑、跳跃和探索世界。此外,您将能够通过简单地在文本文件中创建平台和障碍物,轻松创建几乎任何大小或复杂度的自己的关卡设计。我们将通过以下主题实现所有这些:
-
探索如何在文本文件中设计关卡
-
构建
LevelManager
类,该类将从文本文件加载关卡,将其转换为我们的游戏可以使用的数据,并跟踪关卡细节,如生成位置、当前关卡和允许的时间限制 -
更新游戏引擎以使用
LevelManager
-
编写一个多态函数来处理 Bob 和 Thomas 的碰撞检测
第十五章 ,声音空间化和 HUD,在本章中,我们将添加所有的音效和 HUD。我们在之前的两个项目中都做过这个,但这次我们会有所不同。我们将探索声音空间化的概念,以及 SFML 如何使这个本来复杂的概念变得简单易行;此外,我们将构建一个 HUD 类来封装我们的代码,将信息绘制到屏幕上。
我们将按照以下顺序完成这些任务:
-
什么是空间化?
-
SFML 如何处理空间化
-
构建一个
SoundManager
类 -
部署发射器
-
使用
SoundManager
类 -
构建
HUD
类 -
使用
HUD
类
第十六章, 扩展 SFML 类、粒子系统和着色器,在这一章中,我们将探讨 C++ 中扩展其他人类的概念。更具体地说,我们将研究 SFML Drawable
类以及将其用作我们自己类的基类的好处。我们还将浅尝 OpenGL 着色器的主题,并看看如何使用另一种语言(GLSL)编写代码,该代码可以直接在图形卡上运行,从而产生可能以其他方式不可能实现的平滑图形效果。像往常一样,我们还将使用我们的新技能和知识来增强当前项目。
以下是我们将按顺序涵盖的主题列表:
-
SFML 可绘制
-
构建粒子系统
-
OpenGl 着色器和 GLSL
-
在《Thomas Was Late》游戏中使用着色器
第十七章,“在你离开之前...”,快速讨论接下来可能要做的事情。
本书所需的内容
-
Windows 7 Service Pack 1、Windows 8 或 Windows 10
-
1.6 GHz 或更快的处理器
-
1 GB 的 RAM(对于 x86)或 2 GB 的 RAM(对于 x64)
-
15 GB 的可用硬盘空间
-
5400 RPM 硬盘驱动器
-
DirectX 9 兼容的视频卡,支持 1024 x 768 或更高的显示分辨率
本书中使用的所有软件都是免费的。在书中逐步介绍了获取和安装软件的步骤。本书始终在 Windows 上使用 Visual Studio,但有经验的 Linux 用户可能不会在其喜爱的 Linux 编程环境中运行代码和按照说明出现问题。
本书适合对象
如果以下任何情况描述您,本书非常适合您:您完全不了解 C++ 编程,或需要初学者级别的复习课程;如果您想学习制作游戏或者只是想以一种引人入胜的方式学习 C++;如果您有志于有朝一日发布游戏,也许是在 Steam 上;或者如果您只是想玩得开心,并以您的创作给朋友留下深刻印象。
约定
在本书中,您将找到一些区分不同信息种类的文本样式。以下是这些样式的一些示例及其含义的解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"我们可以通过使用 include 指令包含其他上下文。"
代码块设置如下:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都将按如下方式书写:
# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample /etc/asterisk/cdr_mysql.conf
新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“单击下一步按钮将您移至下一个屏幕。”
注意
警告或重要提示将以如下方式显示在一个框中。
提示
提示和技巧显示如下。
第一章:C++,SFML,Visual Studio 和开始第一个游戏
欢迎来到《开始 C++游戏编程》。我将立即让你开始你的写作之旅,使用 C++和OpenGL-powered SFML为 PC 编写出色的游戏。
这是一个相当庞大的第一章,但我们将学到我们需要的一切,以便让我们第一个游戏的第一部分运行起来。在本章中,我们将涵盖以下内容:
-
了解我们将构建的游戏
-
学习一些关于 C++的知识
-
探索 SFML 及其与 C++的关系
-
查看我们将在整本书中使用的 Visual Studio 软件
-
设置游戏开发环境
-
创建一个可重用的项目模板,这将节省大量时间
-
计划并准备第一个游戏项目,伐木者!!!
-
编写本书的第一个 C++代码,并制作一个可运行的游戏来绘制背景
游戏
我们将逐步学习超快的 C++语言的基础知识,然后将新知识应用到实践中,因此应该相当容易地为我们正在构建的三款游戏添加酷炫的功能。
提示
如果你在本章的任何内容上遇到困难,请查看最后的处理错误和常见问题解答部分。
这是我们书中的三个项目:
伐木者!!!
第一个游戏是一款令人上瘾、节奏快速的《伐木工》的克隆版本,该游戏可以在store.steampowered.com/app/398710/
找到。我们的游戏《伐木者!!!》将在构建一个真正可玩的游戏的同时,向我们介绍所有 C++的基础知识。当我们完成并添加了一些最后一刻的增强功能时,我们的游戏版本将是这个样子。
僵尸竞技场
接下来,我们将构建一个疯狂的僵尸生存射击游戏,类似于 Steam 的热门游戏《超过 9000 只僵尸》,该游戏可以在store.steampowered.com/app/273500/
找到。玩家将拥有一把机关枪,并必须抵御不断增长的僵尸浪潮。所有这些将发生在一个随机生成的滚动世界中。为了实现这一点,我们将学习面向对象编程以及它如何使我们能够拥有一个庞大的代码库(大量代码),易于编写和维护。期待令人兴奋的功能,如数百个敌人、快速射击武器、拾取物品以及每一波后都可以“升级”的角色。
托马斯迟到了
第三款游戏将是一款时尚而具有挑战性的益智平台游戏,可以作为单人和合作游玩。它基于非常受欢迎的游戏《托马斯是孤独的》,该游戏可以在store.steampowered.com/app/220780/
找到。期待学习有关粒子效果、OpenGL 着色器和分屏合作多人游戏等酷炫主题。
提示
如果你现在想玩任何游戏,可以在Runnable Games
文件夹中的下载包中进行。只需双击相应的.exe
文件。请注意,在这个文件夹中,你可以运行已完成的游戏,也可以从任何章节的部分完成状态中运行任何游戏。
让我们开始介绍 C++、Visual Studio 和 SFML!
了解 C++
你可能会有一个问题,为什么要使用 C++?C++很快,非常快。使其成为这种情况的原因是我们编写的代码直接转换为机器可执行指令。这些指令构成了游戏。可执行游戏包含在一个.exe
文件中,玩家只需双击即可运行。
这个过程中有几个步骤。首先,预处理器查看我们的代码中是否需要包含其他代码,并在必要时添加它。接下来,编译器程序将所有代码编译成目标文件。最后,一个名为链接器的第三个程序将所有目标文件连接成可执行文件,这就是我们的游戏。
此外,C++既是一个成熟的语言,同时又非常现代化。C++是一种面向对象的编程语言,这意味着我们可以以一种经过验证的方式编写和组织我们的代码,使我们的游戏高效且易于管理。这些好处以及这种必要性将随着我们在书中的进展而显现。
我提到的大部分其他代码都是 SFML,我们将在接下来的一分钟内了解更多关于 SFML 的信息。我刚刚提到的预处理器、编译器和链接器程序都是 Visual Studio 集成开发环境(IDE)的一部分。
Microsoft Visual Studio
Visual Studio 隐藏了预处理、编译和链接的复杂性。它将所有这些封装成一个按钮。此外,它为我们提供了一个流畅的用户界面,让我们输入我们的代码并管理将成为大量代码文件和其他项目资产的选择。
虽然有高级版本的 Visual Studio 需要花费数百美元,但我们可以在免费的Express 2015 for Desktop版本中构建我们的三个游戏。
SFML
Simple Fast Media Library(SFML)不是唯一的 C++游戏和多媒体库。可能有人会主张使用其他库,但对我来说,SFML 似乎是最好的选择。首先,它是使用面向对象的 C++编写的。这样做的好处是多方面的。随着你在书中的进展,你将体验到大部分这些好处。
SFML 非常容易上手,因此对于初学者来说是一个很好的选择。同时,如果你是专业人士,它也有潜力构建最高质量的 2D 游戏。因此,初学者可以开始使用 SFML,而不必担心随着经验的增长需要重新开始学习新的语言/库。
也许最大的好处是大多数现代 C++编程都使用面向对象编程。我读过的每一本 C++初学者指南都使用并教授面向对象编程。事实上,面向对象编程几乎是所有语言中编码的未来(和现在)。因此,如果你从头开始学习 C++,为什么要以其他方式学习呢?
SFML 几乎为 2D 游戏中你可能想做的任何事情提供了模块(代码)。SFML 使用 OpenGL 工作,它也可以制作 3D 游戏。OpenGL 是游戏的事实上免费使用的图形库,当你希望它们在多个平台上运行时。当你使用 SFML 时,你自动使用 OpenGL。
SFML 大大简化了:
-
2D 图形和动画,包括滚动游戏世界。
-
包括高质量的定向声音在内的音效和音乐播放。
-
在线多人游戏功能
-
相同的代码可以在所有主要桌面操作系统上进行编译和链接,很快也可以在移动设备上进行。
广泛的研究并没有发现任何更适合的方式来为 PC 构建 2D 游戏,即使对于专业开发人员来说也是如此,尤其是如果你是初学者,并且想在有趣的游戏环境中学习 C++。
设置开发环境
现在你对我们将如何制作这些游戏有了一些了解,是时候设置开发环境,让我们开始编码了。
那么 Mac 和 Linux 呢?
我们制作的游戏可以在 Windows、Mac 和 Linux 上运行!我们使用的代码对于每个平台都是相同的。然而,每个版本都需要在其预期的平台上进行编译和链接,而 Visual Studio 将无法帮助我们处理 Mac 和 Linux。
说这本书完全适合 Mac 和 Linux 用户,尤其是完全的初学者,可能有些不公平。尽管,我猜,如果你是一个热衷于 Mac 或 Linux 的用户,并且对你的操作系统感到舒适,你将遇到的大部分额外挑战将在开发环境、SFML 和第一个项目的初始设置中。
为此,我强烈推荐以下教程,希望能替代接下来的大约 10 页(大约),直到Planning Timber!!!部分,当这本书应该再次适用于所有操作系统。
对于 Linux,阅读这篇概述:www.sfml-dev.org/tutorials/2.0/start-linux.php
。
对于 Linux,阅读这篇逐步指导:en.sfml-dev.org/forums/index.php?topic=9808.0
。
在 Mac 上,阅读这篇教程以及链接的文章:www.edparrish.net/common/sfml-.osx.html
。
在你的桌面上安装 Visual Studio Express 2015
安装 Visual Studio 几乎和下载一个文件并点击几下按钮一样简单。然而,如果我们仔细地按照我们的步骤来做,这将对我们有所帮助。因此,我将一步一步地走过安装过程。
微软 Visual Studio 网站表示你需要 5GB 的硬盘空间。然而,根据经验,我建议你至少需要 10GB 的可用空间。此外,这些数字有些模糊。如果你计划将其安装在辅助硬盘上,你仍然需要主硬盘上至少 5GB 的空间,因为无论你选择在哪里安装 Visual Studio,它也需要这个空间。
注意
总结这种模糊的情况:如果你打算将 Visual Studio 安装到主硬盘上,那么在主硬盘上必须有完整的 10GB 空间是必不可少的。另一方面,如果你打算安装到辅助硬盘上,确保你的主硬盘上有 5GB 的空间,辅助硬盘上有 10GB 的空间。是的,愚蠢,我知道!
-
你需要的第一件事是一个微软账户和登录详情。如果你有 Hotmail 或 MSN 邮箱地址,那么你已经有了一个。如果没有,你可以在这里免费注册一个:
login.live.com/
。 -
访问这个链接:
www.visualstudio.com/en-us/downloads/download-visual-studio-vs.aspx
。点击Visual Studio 2015,然后点击Express 2015 for desktop,然后点击Downloads按钮。下一个截图显示了三个点击的位置: -
等待短暂的下载完成,然后运行下载的文件。现在你只需要按照屏幕上的指示进行操作。但是,请记下你选择安装 Visual Studio 的文件夹。如果你想和我做的一样,就在你喜欢的硬盘上创建一个名为
Visual Studio 2015
的新文件夹,并安装到这个文件夹中。整个过程可能需要一段时间,取决于你的互联网连接速度。 -
当你看到下一个屏幕时,点击Launch并输入你的微软账户登录详情。
现在我们可以转向 SFML。
设置 SFML
这个简短的教程将带你下载 SFML 文件,使我们能够包含库中包含的功能。此外,我们将看到如何使用 SFML DLL 文件,这将使我们编译的目标代码能够与 SFML 一起运行。
-
访问 SFML 网站上的这个链接:
www.sfml-dev.org/download.php
。点击下一个显示的Latest stable version按钮。 -
当您阅读本指南时,最新版本几乎肯定已经更改。只要您正确执行下一步,这并不重要。我们要下载Visual C++ 2014的 32 位版本。这可能听起来有些违反直觉,因为我们刚刚安装了 Visual Studio 2015,您可能(最常见)有一台 64 位 PC。我们选择此下载的原因是因为 Visual C++ 2014 是 Visual Studio 2015 的一部分(Visual Studio 提供的不仅仅是 C++),我们将以 32 位构建游戏,以便它们在 32 位和 64 位机器上运行。为了明确起见,单击以下下载:
-
下载完成后,在安装 Visual Studio 的相同驱动器的根目录创建一个名为
SFML
的文件夹。还在安装 Visual Studio 的相同驱动器的根目录创建另一个文件夹,并将其命名为Visual Studio Stuff
。我们将在这里存储各种与 Visual Studio 相关的东西,因此Visual Studio Stuff
似乎是一个不错的名字。为了明确起见,这是在完成此步骤后我的硬盘的样子: -
显然,您在截图中突出显示的三个文件夹之间的文件夹可能与我的完全不同。现在我们准备好了即将创建的所有项目,创建一个新文件夹在
Visual Studio Stuff
内。将新文件夹命名为Projects
。 -
最后,解压 SFML 下载。在桌面上进行此操作。解压完成后,可以删除
zip
文件夹。您将在桌面上留下一个单独的文件夹。其名称将反映您下载的 SFML 版本。我的称为SFML-2.3.2-windows-vc14-32-bit
。您的文件名可能反映了一个更新的版本。双击此文件夹以查看内容,然后再次双击进入下一个文件夹(我的称为SFML-2.3.2
)。以下截图显示了当选择了整个内容时,我的SFML-2.3.2
文件夹的内容是什么样子的。您的应该看起来一样。 -
复制前面截图中所见的整个文件夹的内容,并将所有内容粘贴/拖放到第 3 步中创建的
SFML
文件夹中。在本书的其余部分,我将简称此文件夹为您的 SFML 文件夹。
现在我们准备在 Visual Studio 中开始使用 C++和 SFML。
创建可重用的项目模板
由于设置项目是一个相当繁琐的过程,我们将创建一个项目,然后将其保存为 Visual Studio 模板。这将节省我们每次开始新游戏时相当大量的工作。因此,如果您发现下一个教程有点乏味,请放心,您将永远不需要再次这样做:
-
启动 Visual Studio,在新项目窗口中,单击Visual C++旁边的小下拉箭头以显示更多选项,然后单击Win32,再单击Win32 控制台应用程序。您可以在下一个截图中看到所有这些选择。
-
现在,在新项目窗口的底部,在名称:字段中键入
HelloSFML
。 -
接下来,浏览到我们在上一篇教程中创建的
Visual Studio Stuff\Projects\
文件夹。这将是我们保存所有项目文件的位置。所有模板都是基于实际项目的。因此,我们将有一个名为HelloSFML
的项目,但我们将做的唯一事情就是从中制作一个模板。 -
完成上述步骤后,单击确定。下一个截图显示了应用程序设置窗口。选中控制台应用程序的复选框,并将其他选项保持如下所示。
-
单击完成,Visual Studio 将创建新项目。
-
接下来,我们将添加一些相当复杂和重要的项目设置。这是费力的部分,但由于我们将创建一个模板,我们只需要做一次。我们需要告诉 Visual Studio,或者更具体地说,Visual Studio 的代码编译器,从哪里找到 SFML 的特殊类型的代码文件。我所指的特殊类型的文件是头文件。头文件定义了 SFML 代码的格式。因此,当我们使用 SFML 代码时,编译器知道如何处理它。请注意,头文件与主源代码文件不同,并且它们包含在扩展名为
.hpp
的文件中。(当我们最终开始在第二个项目中添加自己的头文件时,所有这些将变得更清晰)。此外,我们需要告诉 Visual Studio 它在哪里可以找到 SFML 库文件。从 Visual Studio 的主菜单中选择项目 | HelloSFML 属性。 -
在生成的HelloSFML 属性页窗口中,执行下一截图中标记的步骤。
-
从配置:下拉菜单中选择所有配置。
-
从左侧菜单中选择C/C++,然后选择常规。
-
定位附加包含目录编辑框,并输入您的 SFML 文件夹所在的驱动器号,然后加上
\SFML\include
。如果您的SFML
文件夹位于 D 驱动器上,则要输入的完整路径如截图所示:D:\SFML\include
。如果您将 SFML 安装到其他驱动器上,则需要更改路径。 -
点击应用以保存到目前为止的配置。
-
现在,在同一窗口中,执行下一截图中标记的步骤。选择链接器,然后选择常规。
-
找到附加库目录编辑框,并输入您的
SFML
文件夹所在的驱动器号,然后加上\SFML\lib
。因此,如果您的SFML
文件夹位于 D 驱动器上,则要输入的完整路径如截图所示:D:\SFML\lib
。如果您将 SFML 安装到其他驱动器上,则需要更改路径。 -
点击应用以保存到目前为止的配置。
-
最后,在同一窗口中,执行下一截图中标记的步骤。将配置:下拉菜单(1)切换到调试,因为我们将在调试模式下运行和测试游戏。
-
选择链接器,然后选择输入(2)。
-
找到附加依赖项编辑框(3),并在最左侧点击进入。现在复制并粘贴/输入以下内容:
sfml-graphics-d.lib;sfml-window-d.lib;sfml-system-d.lib;sfml-network-d.lib;sfml-audio-d.lib;
。再次要非常小心地将光标放置在正确的位置,并且不要覆盖已经存在的任何文本。 -
点击确定。
-
让我们从
HelloSFML
项目中创建一个模板,这样我们就永远不必再做这个略显乏味的任务了。创建可重用的项目模板非常简单。在 Visual Studio 中选择文件 | 导出模板...。然后,在导出模板向导窗口中,确保选择了项目模板选项,然后选择HelloSFML项目作为要创建模板的项目选项。 -
点击下一步,然后点击完成。
哦,就是这样!下次我们创建项目时,我会告诉您如何从这个模板中创建。现在让我们构建 Timber!!!
规划 Timber!!!
每当制作游戏时,最好都要先用铅笔和纸开始。如果您不确定游戏在屏幕上的工作方式,又怎么可能在代码中使其正常工作呢?
提示
此时,如果你还没有这样做,我建议你去观看一段 Timberman 的游戏视频,这样你就可以看到我们的目标是什么。如果你的预算允许,那就买一份来玩玩。在 Steam 上通常会以不到一美元的价格出售。store.steampowered.com/app/398710/
.
游戏的特性和物体,定义了游戏玩法,被称为机制。游戏的基本机制是:
-
时间总是在流逝。
-
通过砍树来获得更多时间。
-
砍树会导致树枝掉落。
-
玩家必须避开掉落的树枝。
-
重复直到时间用完或玩家被压扁。
在这个阶段期望你计划 C++代码显然有点傻。当然,这是 C++初学者指南的第一章。然而,我们可以看一下我们将使用的所有资源以及我们需要让我们的 C++做我们想要的事情的概述。
看一下游戏的注释截图:
你可以看到我们有以下特性:
-
玩家当前得分:每次玩家砍一根木头,他就会得到一个点。他可以用左箭头或右箭头砍木头。
-
玩家角色:每次玩家砍的时候,他会移动/停留在树的同一侧。因此,玩家必须小心选择砍树的哪一侧。当玩家砍的时候,一个简单的斧头图形会出现在玩家角色的手中。
-
缩小的时间条:每次玩家砍的时候,一小段时间将被添加到不断缩小的时间条上。
-
致命的树枝:玩家砍得越快,他得到的时间就越多,但树枝也会更快地从树上掉下来,因此他被压扁的可能性就越大。树枝在树顶随机生成,并且每次砍树都会向下移动。
-
当玩家被压扁时,他会经常被压扁,一个墓碑图形会出现。
-
被砍的木头:当玩家砍的时候,一个被砍的木头图形会从玩家身边飞走。
-
有三朵漂浮的云,它们会以随机的高度和速度飘动,还有一只蜜蜂,除了四处飞来飞去什么也不做。
-
所有这些都发生在一个漂亮的背景上。
因此,简而言之,玩家必须疯狂地砍树来获得积分,并避免时间用尽。作为一个略微扭曲但有趣的结果,他砍得越快,他被压扁的可能性就越大。
现在我们知道游戏的外观,玩法以及游戏机制背后的动机。我们可以继续开始构建它。
从模板创建项目
现在创建一个新项目非常容易。只需在 Visual Studio 中按照这些简单的步骤操作:
-
从主菜单中选择文件 | 新项目。
-
确保在左侧菜单中选择Visual C++,然后从所呈现的选项列表中选择HelloSFML。下一个截图应该能清楚地说明这一点。
-
在名称:字段中键入
Timber
,并确保选中为解决方案创建目录选项。现在点击确定。 -
现在我们需要将 SFML 的
.dll
文件复制到主项目目录中。我的主项目目录是D:\Visual Studio Stuff\Projects\Timber\Timber
。它是在上一步中由 Visual Studio 创建的。如果你把你的Projects
文件夹放在其他地方,那么就在那里执行这一步。我们需要复制到项目文件夹中的文件位于你的SFML\bin
文件夹中。打开两个位置的窗口,并按照左侧下一个截图中显示的要求文件进行突出显示。 -
现在将突出显示的文件复制并粘贴到上一张截图右侧的项目文件夹中。
项目现在已经设置好,准备就绪。您将能够在下一个截图中看到屏幕。我已经对截图进行了注释,这样您就可以开始熟悉 Visual Studio 了。我们很快会重新访问所有这些区域以及其他区域。
您的布局可能与截图略有不同,因为 Visual Studio 的窗口,像大多数应用程序一样,是可定制的。花些时间找到右侧的Solution Explorer窗口,并调整它使其内容清晰明了,就像前面的截图一样。
我们很快会回到这里开始编码。
项目资产
资产是制作游戏所需的任何东西。在我们的情况下,资产包括:
-
屏幕上的书写字体
-
不同动作的音效,如砍伐、死亡和时间耗尽
-
角色、背景、树枝和其他游戏对象的图形
游戏所需的所有图形和声音都包含在下载包中。它们可以在相应的Chapter 1/graphics
和Chapter 1/sound
文件夹中找到。
所需的字体尚未提供。这是因为我想避免任何可能的许可歧义。不过这不会造成问题,因为我将向您展示确切的位置和方式来选择和下载字体。
尽管我将提供资产本身或获取它们的信息,但您可能希望自己创建和获取它们。
外包资产
有许多网站可以让您与艺术家、声音工程师甚至程序员签约。其中最大的之一是www.upwork.com。您可以免费加入该网站并发布您的工作。您需要清晰地解释您的要求,以及说明您愿意支付多少。然后您可能会得到许多承包商竞标做这项工作。请注意,有很多不合格的承包商,他们的工作可能令人失望,但如果您选择得当,您可能会找到一个称职、热情和物有所值的人或公司来完成工作。
制作自己的音效
可以从网站(如www.freesound.org)免费下载音效,但通常许可证不允许您在出售游戏时使用它们。另一个选择是使用名为 BFXR 的开源软件,该软件可以帮助您生成许多不同的音效,这些音效是您自己保留并随意使用的。
将资产添加到项目
一旦您决定要使用哪些资产,就该是将它们添加到项目的时候了。下面的说明将假定您正在使用书籍下载包中提供的所有资产。如果您使用自己的资产,只需用您自己的相应音效或图形文件替换,文件名完全相同即可。
-
浏览到 Visual
D:\Visual Studio Stuff\Projects\Timber\Timber
。 -
在此文件夹中创建三个新文件夹,并将它们命名为
graphics
、sound
和fonts
。 -
从下载包中,将
Chapter 1/graphics
的整个内容复制到D:\Visual Studio Stuff\Projects\Timber\Timber\graphics
文件夹中。 -
从下载包中,将
Chapter 1/sound
的整个内容复制到D:\Visual Studio Stuff\Projects\Timber\Timber\sound
文件夹中。 -
现在访问:
www.1001freefonts.com/komika_poster.font
在您的网络浏览器中下载Komika Poster字体。 -
解压缩下载的内容,并将
KOMIKAP_.ttf
文件添加到D:\Visual Studio Stuff\Projects\Timber\Timber\fonts
文件夹中。
让我们来看看这些资产,特别是图形,这样我们在使用它们在我们的 C++代码中时可以更好地可视化发生了什么。
探索资产
图形资产构成了我们的《伐木者!!!》游戏屏幕的各个部分。看一看这些图形资产,就能清楚地知道它们在我们的游戏中将被使用在哪里。
声音文件都是.wav
格式。这些文件包含了我们在游戏中特定事件播放的音效。它们都是用 BFXR 生成的。它们包括:
-
chop.wav
:一种像斧头(复古斧头)砍树的声音 -
death.wav
:一种有点像复古“失败”声音的声音。 -
out_of_time.wav
:当玩家因时间耗尽而失败时播放,而不是被压扁
理解屏幕和内部坐标
在我们进行实际的 C++编码之前,让我们谈谈坐标。我们在监视器上看到的所有图像都是由像素组成的。像素是一小点光,它们组合在一起形成我们看到的图像。
有许多不同的监视器分辨率,但是举个例子,一个相当典型的游戏玩家的监视器可能在水平上有 1920 个像素,在垂直上有 1080 个像素。
像素从屏幕的左上角开始编号。正如你从下一个图表中看到的,我们的 1920 x 1080 的示例在水平(x)轴上从 0 到 1919,垂直(y)轴上从 0 到 1079 编号。
因此,特定而准确的屏幕位置可以通过 x 和 y 坐标来确定。我们通过在屏幕的特定位置绘制游戏对象,比如背景、角色、子弹和文本,来创建我们的游戏。这些位置由像素的坐标来确定。看一看下面这个假设性的例子,我们可能在屏幕的中心坐标,大约在 960, 540 的位置绘制。
除了屏幕坐标,我们的游戏对象也将有自己类似的坐标系统。与屏幕坐标系统一样,它们的内部或本地坐标从左上角的 0,0 开始。
在上一个屏幕截图中,我们可以看到角色的 0,0 点被绘制在屏幕的 960, 540 位置。
视觉上,2D 游戏对象,比如角色或者僵尸,被称为精灵。精灵通常由图像文件制作而成。所有精灵都有所谓的原点。
如果我们在屏幕的特定位置绘制一个精灵,原点将位于这个特定位置。精灵的 0,0 坐标就是原点。下一个屏幕截图演示了这一点。
这就是为什么在显示角色绘制到屏幕的截图中,尽管我们在中心位置(960, 540)绘制了图像,它看起来有点偏右和向下的原因。
在我们进行第一个项目时,我们只需要牢记这是如何工作的。
请注意,在现实世界中,玩家有各种各样的屏幕分辨率,我们的游戏需要尽可能适应其中的许多。在第二个项目中,我们将看到如何使我们的游戏动态适应几乎任何分辨率。在这个第一个项目中,我们需要假设屏幕分辨率是 1920 x 1080。如果你的屏幕分辨率与此不同,不要担心,因为我为 Timber!!!游戏的每一章提供了单独的代码。这些代码文件几乎是相同的,只是在开头添加和交换了一些代码行。如果你有较低分辨率的屏幕,那么只需按照假设 1920 x 1080 分辨率的书中的代码进行操作,当试玩游戏时,你可以从每一章的低分辨率
文件夹中复制和粘贴代码文件。实际上,一旦在本章中添加了额外的代码行,无论你的屏幕分辨率如何,其余的代码都将是相同的。我为每一章提供了低分辨率的代码,只是为了方便起见。我们将在第二个项目中讨论这几行代码是如何发挥作用的(缩放屏幕)。备用代码将适用于分辨率低至 960 x 540,因此几乎可以在任何 PC 或笔记本电脑上使用。
现在我们可以编写我们的第一个 C++代码,很快我们就会看到它在运行中。
开始编写游戏
如果尚未打开 Visual Studio,请打开它,从主 Visual Studio 窗口的最近列表中左键单击打开 Timber 项目(如果尚未打开)。
我们将要做的第一件事是重命名我们的主代码文件。它目前被称为HelloSFML.cpp
,我们将把它重命名为更合适的Timber.cpp
。.cpp
代表 C++。
-
在右侧找到解决方案资源管理器窗口。
-
在源文件文件夹下找到
HelloSFML.cpp
文件。 -
右键单击
HelloSFML.cpp
,选择重命名。 -
编辑文件名为
Timber.cpp
,然后按Enter。
在代码窗口中进行一些微小的编辑,以便你的代码与下面显示的完全相同。你可以像使用任何文本编辑器或文字处理软件一样进行编辑;如果你愿意,甚至可以复制粘贴。在进行了轻微的编辑之后,我们可以讨论它们:
// Include important C++ libraries here
#include "stdafx.h"
int main()
{
return 0;
}
这个简单的 C++程序是一个很好的起点。让我们逐行来看一下
用注释使代码更清晰
正如你所看到的,唯一需要更改的代码是顶部的一点点。第一行代码是这样的:
// Include important C++ libraries here
任何以//
开头的代码行都是注释,编译器会忽略它们。因此,这行代码什么也不做。它用于在以后回到代码时留下我们可能会发现有用的任何信息。注释在行尾结束,因此下一行的任何内容都不是注释的一部分。还有另一种类型的注释叫做多行或c 风格注释,它可以用来留下占据多于一行的注释。我们将在本章后面看到一些这样的注释。在本书中,我将留下数百条注释,以帮助添加上下文并进一步解释代码。
#include Windows 基本组件
现在你知道注释是用来干什么的,你可能可以猜到下一行代码是做什么的。这里再次给出:
#include "stdafx.h"
#include
指令告诉 Visual Studio 在编译之前包含或添加另一个文件的内容。这样做的效果是,当我们运行程序时,一些我们没有自己编写的其他代码将成为我们程序的一部分。将其他文件中的代码添加到我们的代码中的过程称为预处理,或许不足为奇的是,这是由一个叫做预处理器的东西执行的。文件扩展名.h
代表头文件。
你可能想知道这段代码会做什么?stdafx.h
文件实际上包含了更多的#include
指令。它将我们程序所需的所有必要代码添加到我们的程序中,以便在 Windows 上运行我们的程序。我们永远不需要看到这个文件,绝对不需要关心它里面有什么。我们只需要在我们制作的每个游戏的顶部添加一行代码。
对我们来说更重要和相关的是,值得讨论#include
指令的原因是,我们将在代码文件的顶部添加许多#include
指令。这是为了包含我们将使用和费力理解的代码。
我们将包含的主要文件是 SFML 头文件,它为我们提供了所有酷炫的游戏编码功能。我们还将使用#include
来访问C++标准库头文件。这些头文件为我们提供了访问 C++语言核心功能的权限。
这是两行解决了,让我们继续。
主函数
我们在代码中看到的下一行是这样的:
int main()
代码int
被称为类型。C++有许多类型,它们代表不同类型的数据。int
是整数或整数。记住这一点,我们一会儿会回来讨论它。
main()
代码部分是随后的代码部分的名称。这段代码在开放的花括号{
和下一个闭合的花括号}
之间标出。
因此,这些花括号{...}
之间的所有内容都是main
的一部分。我们把这样的一段代码称为函数。
每个 C++程序都有一个main
函数,它是整个程序执行(运行)的起点。随着我们在书中的进展,最终我们的游戏将有许多代码文件。然而,只会有一个main
函数,无论我们写什么代码,我们的游戏总是从main
函数的开放花括号内的第一行代码开始执行。
现在,不要担心跟在函数名()
后面的奇怪括号。我们将在第四章中进一步讨论它们:循环、数组、开关、枚举和函数-实现游戏机制,在那里我们将以全新和更有趣的方式看到函数。
让我们仔细看看Main
函数中的一行代码。
演示和语法
再次看看我们的Main
函数的全部内容:
int main()
{
return 0;
}
我们可以看到,在Main
中只有一行代码,return 0;
。在我们继续了解这行代码的作用之前,让我们看看它是如何呈现的。这很有用,因为它可以帮助我们准备编写易于阅读和区分的代码,与我们代码的其他部分。
首先注意到return 0;
向右缩进了一个制表符。这清楚地标志着它是main
函数内部的一部分。随着我们的代码长度增加,我们将看到缩进我们的代码和留下空白将是保持可读性的关键。
接下来,注意一下行末的标点符号。分号;
告诉编译器这是指令的结束,其后的任何内容都是新的指令。我们称以分号终止的指令为语句
。
请注意,编译器不在乎你在分号和下一条语句之间留下一个新行甚至一个空格。然而,不为每个语句开启新行将导致代码难以阅读,而完全忽略分号将导致语法错误,使得游戏无法编译或运行。
一起的一段代码,通常由其与部分的缩进表示,称为块。
现在你已经对main
函数的概念感到舒适,缩进你的代码以保持整洁,并在每个语句的末尾加上一个分号,我们可以继续找出return 0;
语句实际上是做什么的。
从函数返回值
实际上,在我们的游戏中,return 0;
几乎没有做任何事情。然而,这个概念是重要的。当我们使用return
关键字时,无论是单独使用还是后面跟着一个值,它都是一个指示程序执行跳转/返回到最初启动函数的代码的指令。
通常启动函数的代码将是我们代码中其他地方的另一个函数。然而,在这种情况下,是操作系统启动了main
函数。因此,当执行return 0;
时,main
函数退出,整个程序结束。
由于在return
关键字后面有一个 0,这个值也被发送到操作系统。我们可以将零的值更改为其他值,那个值将被发送回去。
我们说启动函数的代码调用函数,并且函数返回值。
你现在不需要完全掌握所有这些函数信息。这里只是介绍它是有用的。在我们继续之前,还有一个关于函数的最后一件事。还记得int Main()
中的int
吗?那告诉编译器Main
返回的值的类型必须是int
(整数/整数)。我们可以返回任何符合int
的值。也许是 0、1、999、6358 等等。如果我们尝试返回一个不是 int 的值,比如 12.76,那么代码将无法编译,游戏也无法运行。
函数可以返回各种不同类型的值,包括我们自己发明的类型!然而,这种类型必须以我们刚刚看到的方式告知编译器。
这些关于函数的背景信息将使我们在进展中更加顺利。
运行游戏
你现在可以运行游戏。通过点击 Visual Studio 快速启动栏中的本地 Windows 调试器按钮,或者使用F5快捷键来运行。
你将只看到一个黑屏的闪烁。这个闪烁是 C++控制台,我们可以用它来调试我们的游戏。现在我们不需要这样做。正在发生的是我们的程序启动,从Main
的第一行开始执行,当然是return 0;
,然后立即退出返回到操作系统。
使用 SFML 打开一个窗口
现在让我们添加一些更多的代码。接下来的代码将打开一个窗口,Timber!!!最终将在其中运行。窗口将是 1920 像素宽,1080 像素高,并且将是全屏的(没有边框或标题)。
输入下面突出显示的新代码到现有代码中,然后我们将对其进行检查。在输入(或复制和粘贴)时,尝试弄清楚发生了什么:
// Include important C++ libraries here
#include "stdafx.h"
#include <SFML/Graphics.hpp>
// Make code easier to type with "using namespace"
using namespace sf;
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game RenderWindow
window(vm, "Timber!!!", Style::Fullscreen);
return 0;
}
包括 SFML 功能
在我们的新代码中,我们注意到的第一件事是另一个略有不同的#include
指令。#include <SFML/Graphics.hpp>
告诉预处理器包含文件夹中名为SFML
的文件夹中包含的Graphics.hpp
文件的内容。
所以这行代码的作用是添加来自上述文件的代码,这使我们可以访问 SFML 的一些功能。当我们开始编写自己的独立代码文件并使用#include
来使用它们时,它的实现方式将变得更加清晰。
注意
如果你想知道预处理器指令中包含文件名的<filename.hpp>
和"filename.h"
之间的区别,<...>
是用于我们文件夹结构中包含的文件,比如 SFML 文件或我们自己编写的任何文件。"..."
是用于包含在 Visual Studio 中的文件。此外,.hpp
文件扩展名只是.h
文件的更加面向 C++的版本,而.h
文件更像是 C 风格的扩展名。这两种风格和文件扩展名最终都会做同样的事情,并且在我们的游戏中都能正常工作。
目前重要的是,我们有一大堆新的功能由 SFML 提供,可供使用。下一行是using namespace sf;
。我们将在几段时间内回到这行代码的作用。
面向对象编程,类,对象
随着我们继续阅读本书,我们将更全面地讨论面向对象编程、类和对象。接下来是最简短的介绍,以便我们能够理解发生了什么。
我们已经知道 OOP 代表面向对象编程。OOP 是一种编程范式,一种编码方式。OOP 通常被全球范围内的编程界所接受,在几乎每种语言中,作为编写代码的最佳、如果不是唯一的专业方式。
面向对象编程引入了许多编码概念,但它们所有的基础都是类和对象。当我们编写代码时,我们希望尽可能地编写可重用的代码。我们这样做的方式是将我们的代码结构化为一个类。我们将在第六章中学习如何做到这一点:面向对象编程,类和 SFML 视图。
目前我们只需要知道关于类的一切,一旦我们编写了我们的类,我们不仅仅执行该代码作为游戏的一部分,而是创建可用的对象从类中。
例如,如果我们想要一百个僵尸非玩家角色(NPCs),我们可以仔细设计和编写一个名为Zombie
的类,然后从这个单个类中创建任意数量的僵尸对象。每个僵尸对象都具有相同的功能和内部数据类型,但每个僵尸对象都是一个独立的实体。
进一步以假设的僵尸示例为例,但不显示任何Zombie
类的代码,我们可以像这样创建一个基于Zombie
类的新对象:
Zombie z1;
现在,对象z1
是一个完全编码和功能的Zombie
对象。然后我们可以这样做:
Zombie z2;
Zombie z3;
Zombie z4;
Zombie z5;
现在我们有五个独立的僵尸,但它们都是基于一个精心编写的类。在我们回到刚刚编写的代码之前,让我们再进一步。我们的僵尸可以包含行为(由函数定义)以及可能代表僵尸健康、速度、位置或行进方向等事物的数据。例如,我们可以编写我们的Zombie
类,使我们能够像这样使用我们的僵尸对象:
z1.attack(player);
z2.growl();
z3.headExplode();
注意
再次注意,所有这些僵尸代码目前都是假设的。不要将这些代码输入 Visual Studio;它只会产生一堆错误。
我们将设计我们的类,以便我们可以以最合适的方式使用数据和行为来满足我们游戏的目标。例如,我们可以设计我们的类,以便我们可以在创建每个僵尸对象时为数据分配值。
也许我们需要在创建每个僵尸时分配一个唯一的名称和以米每秒为单位的速度。Zombie
类的仔细编码可以使我们编写这样的代码:
// Dave was a 100 meter Olympic champion before infection
// He moves at 10 meters per second
Zombie z1("Dave", 10);
// Gill had both of her legs eaten before she was infected
// She drags along at .01 meters per second
Zombie z2("Gill", .01);
重点是类几乎是无限灵活的,一旦我们编写了类,我们就可以通过创建对象来使用它们。正是通过类和我们从中创建的对象,我们将利用 SFML 的强大功能。是的,我们还将编写我们自己的类,包括一个Zombie
类。
让我们回到我们刚刚编写的真正代码。
使用 sf 命名空间
在我们继续更仔细地查看VideoMode
和RenderWindow
之前,您可能已经猜到,这些都是 SFML 提供的类,我们将学习using namespace sf;
这行代码的作用。
当我们创建一个类时,我们是在一个命名空间中创建的。我们这样做是为了区分我们编写的类和其他人编写的类。考虑一下VideoMode
类。在 Windows 等环境中,完全有可能有人已经编写了一个名为VideoMode
的类。通过使用命名空间,我们和 SFML 程序员可以确保类的名称永远不会冲突。
使用VideoMode
类的完整方式如下:
sf::VideoMode...
代码using namespace sf;
使我们可以在代码中的任何地方省略前缀sf::
。如果没有它,在这个简单的游戏中将会有超过 100 个sf::
的实例。它还使我们的代码更易读,同时也更短。
SFML VideoMode 和 RenderWindow
在Main
函数中,我们现在有两个新的注释和两行新的实际代码。第一行实际代码是这样的:
VideoMode vm(1920, 1080);
这段代码创建了一个名为vm
的对象,从名为VideoMode
的类中创建,并设置了内部值1920
和1080
。这些值代表玩家屏幕的分辨率。
下一行新的代码是这样的:
RenderWindow window(vm, "Timber!!!", Style::Fullscreen);
在前一行代码中,我们正在从 SFML 提供的名为RenderWindow
的类中创建一个名为window
的新对象。此外,我们正在设置窗口对象内部的一些值。
首先,vm
对象用于初始化window
的一部分。起初这可能看起来令人困惑。然而,请记住,类可以像其创建者想要的那样多样化和灵活。是的,有些类可以包含其他类。
提示
此时不必完全理解这是如何工作的,只要您能理解这个概念就可以了。我们编写一个类,然后从该类中创建可用的对象。有点像建筑师可能会绘制蓝图。您当然不能把所有家具、孩子和狗都搬进蓝图中;但您可以根据蓝图建造一座房子(或者多座房子)。在这个类比中,类就像蓝图,对象就像房子。
接下来,我们使用值 Timber!!!来给窗口命名。我们使用预定义的值Style::FullScreen
来使我们的window
对象全屏显示。
提示
Style::FullScreen
是 SFML 中定义的一个值。这样做是为了我们不需要记住内部代码用来表示全屏的整数。这种类型的值的编码术语是常量
。常量及其近亲 C++中的变量将在下一章中介绍。
让我们看看我们的窗口对象在运行中的样子。
运行游戏
在这一点上,您可以再次运行游戏。您会看到一个更大的黑屏一闪而过。这就是我们刚刚编写的 1920 x 1080 全屏窗口。不幸的是,我们的程序仍然是从Main
的第一行开始执行,创建了一个很酷的新游戏窗口,然后到达return 0;
,立即退出到操作系统。
主游戏循环
我们需要一种方法来保持程序运行,直到玩家想要退出。同时,随着我们在 Timber!!!中的进展,我们应该清楚地标出代码的不同部分将放在哪里。此外,如果我们要阻止游戏退出,我们最好提供一种让玩家在准备好退出时退出的方法。否则游戏将永远进行下去!
添加高亮代码,放入现有代码中,然后我们将一起讨论它们:
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game
RenderWindow window(vm, "Timber!!!", Style::Fullscreen);
while (window.isOpen())
{
/*
****************************************
Handle the players input
****************************************
*/
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
/*
****************************************
Update the scene
****************************************
*/
/*
****************************************
Draw the scene
****************************************
*/
// Clear everything from the last frame
window.clear();
// Draw our game scene here
// Show everything we just drew
window.display();
}
return 0;
}
While 循环
在新代码中,我们看到的第一件事是:
while (window.isOpen())
{
在新代码中,我们看到的最后一件事是一个闭合的}
。我们创建了一个while
循环。在while
循环的开头{
和结尾}
之间的所有内容将会一遍又一遍地执行,可能会永远执行下去。
仔细看一下下一个代码中突出显示的while
循环的括号(...)
之间的部分:
while (window.isOpen())
这段代码的完整解释将等到我们在第四章讨论循环和条件时再说:循环、数组、开关、枚举和函数-实现游戏机制。现在重要的是,当window
对象被设置为关闭时,代码的执行将跳出while
循环并进入下一个语句。窗口如何关闭将很快涵盖。
下一个声明当然是return 0;
,这结束了我们的游戏。
现在我们知道我们的while
循环会快速循环执行其中的代码,直到我们的窗口对象被设置为关闭。
C 风格的代码注释
在 while 循环内部,我们看到了乍一看可能有点像 ASCII 艺术的东西:
/*
****************************************
Handle the player's input
****************************************
*/
注意
ASCII 艺术是一种利用计算机文本创建图像的小众但有趣的方式。您可以在这里阅读更多信息:en.wikipedia.org/wiki/ASCII_art
.
先前的代码只是另一种类型的注释。这种注释被称为 C 风格注释。注释以/*
开头,以*/
结尾。中间的任何内容只是用于信息,不会被编译。我使用了这种略微复杂的文本,以确保清楚地表明我们将在代码文件的这部分做什么。当然,您现在可以推断出接下来的任何代码都将与处理玩家的输入有关。
跳过几行代码,您会看到我们有另一个 C 风格的注释,宣布在代码的这部分,我们将更新场景。
跳到下一个 C 风格的注释,很明显我们将在那里绘制所有的图形。
输入、更新、绘制、重复
尽管这个第一个项目使用了最简单的游戏循环版本,但每个游戏都需要在代码中经历这些阶段:
-
获取玩家的输入(如果有)。
-
根据人工智能、物理或玩家的输入更新场景。
-
绘制当前场景。
-
以足够快的速度重复以上步骤,以创建一个平滑和动画的游戏世界。
现在让我们看看实际在游戏循环中执行的代码。
检测按键
首先,在标记为处理玩家输入
的部分中,我们有以下代码:
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
这段代码检查当前是否按下了Escape键。如果是,突出显示的代码使用window
对象关闭自身。现在,下一次while
循环开始时,它将看到window
对象已关闭,并跳到while
循环的结束大括号}
后面的代码,游戏将退出。我们将在第二章更全面地讨论if
语句:变量、运算符和决策-动画精灵。
清除和绘制场景
目前在更新场景
部分没有代码,所以让我们继续到绘制场景
部分。
我们要做的第一件事是使用以下代码擦除先前的动画帧:
window.clear();
现在我们要做的是绘制游戏中的每一个对象。然而,目前我们没有任何游戏对象。
下一行代码是:
window.display();
当我们绘制所有游戏对象时,我们将它们绘制到一个隐藏的表面上,准备好显示。代码window.display()
从先前显示的表面翻转到新更新的(先前隐藏的)表面。这样,玩家永远不会看到绘图过程,因为表面上添加了所有精灵。它还保证了在翻转之前场景将会完整。这可以防止图形故障,称为撕裂。这个过程称为双缓冲。
还要注意,所有这些绘制和清除功能都是使用我们的window
对象执行的,该对象是从 SFML 的RenderWindow
类创建的。
运行游戏
运行游戏,您将得到一个空白的全屏窗口,直到您按下Esc键。
绘制游戏背景
最后,我们将在游戏中看到一些真正的图形。我们需要做的是创建一个精灵。我们将创建的第一个精灵将是游戏背景。然后我们可以在清除窗口和显示/翻转窗口之间绘制它。
使用纹理准备精灵
SFML 的RenderWindow
类允许我们创建window
对象来处理游戏窗口所需的所有功能。
现在我们将探索另外两个 SFML 类,它们将负责在屏幕上绘制精灵。其中一个类,也许不足为奇的是,被称为Sprite
。另一个类被称为Texture
。纹理是存储在图形处理单元(GPU)上的图形。
从Sprite
类创建的对象需要从Texture
类创建的对象才能将自己显示为图像。添加以下突出显示的代码。尝试弄清楚发生了什么。然后我们将逐行解释:
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game
RenderWindow window(vm, "Timber!!!", Style::Fullscreen);
// Create a texture to hold a graphic on the GPU
Texture textureBackground;
// Load a graphic into the texture
textureBackground.loadFromFile("graphics/background.png);
// Create a sprite
Sprite spriteBackground;
// Attach the texture to the sprite
spriteBackground.setTexture(textureBackground);
// Set the spriteBackground to cover the screen
spriteBackground.setPosition(0,0);
while (window.isOpen())
{
首先,我们从 SFML 的Texture
类创建一个名为textureBackground
的对象。
Texture textureBackground;
完成后,我们可以使用textureBackground
对象从我们的graphics
文件夹加载图形到textureBackground
中,就像这样:
textureBackground.loadFromFile("graphics/background.png");
提示
我们只需要指定graphics/background
作为路径,因为路径是相对于我们创建文件夹并添加图像的 Visual Studio 工作目录的。
接下来,我们使用以下代码从 SFML 的Sprite
类创建一个名为spriteBackground
的对象:
Sprite spriteBackground;
然后,我们可以将纹理对象textureBackground
与精灵对象spriteBackground
关联起来,就像这样:
spriteBackground.setTexture(textureBackground);
最后,我们可以将spriteBackground
对象定位在window
对象的坐标0,0
处:
spriteBackground.setPosition(0,0);
background.png
图形在graphics
文件夹中的尺寸为 1920 像素宽,1080 像素高,它将完全填满整个屏幕。只是请注意,这行代码并不实际显示精灵,它只是设置好位置,以便在显示时使用。
现在,backgroundSprite
对象可以用来显示背景图形。当然,您几乎肯定想知道为什么我们不得不以这种复杂的方式做事。原因是因为显卡和 OpenGL 的工作方式。
纹理占用图形内存,而这种内存是有限的资源。此外,将图形加载到 GPU 内存中的过程非常缓慢。并不是缓慢到可以看到它发生,或者在发生时会明显减慢您的 PC,但足够缓慢,以至于您无法在游戏循环的每一帧中都这样做。因此,将实际纹理textureBackground
与在游戏循环期间我们将操纵的任何代码分离开来是有用的。
当我们开始移动我们的图形时,您将会看到我们将使用精灵。任何从Texture
类创建的对象都将愉快地停留在 GPU 上,只等待一个关联的Sprite
对象告诉它们在哪里显示自己。在以后的项目中,我们还将重复使用相同的Texture
对象与多个不同的Sprite
对象,这样可以有效地利用 GPU 内存。
总之:
-
纹理加载到 GPU 上非常缓慢
-
一旦纹理存储在 GPU 上,访问速度非常快
-
我们将精灵对象与纹理关联起来
-
我们通常在“更新场景”部分操纵精灵对象的位置和方向。
-
我们绘制
Sprite
对象,然后显示与其关联的纹理(通常在“绘制场景”部分)。
所以现在我们需要做的就是使用我们的window
对象提供的双缓冲系统来绘制我们的新Sprite
对象(spriteBackground
),然后我们实际上应该能够看到我们的游戏在运行。
双缓冲背景精灵
最后,我们需要在游戏循环中的适当位置绘制该精灵及其相关纹理。
提示
请注意,当我展示的代码都来自同一个块时,我不添加缩进,因为这会减少书中文本的换行次数。缩进是暗示的。请查看下载包中的代码文件,以查看缩进的完整用法。
添加突出显示的代码:
/*
****************************************
Draw the scene
****************************************
*/
// Clear everything from the last run frame
window.clear();
// Draw our game scene here
window.draw(spriteBackground);
// Show everything we just drew
window.display();
新的代码行只是使用window
对象来绘制spriteBackground
对象,在清除显示并显示新绘制的场景之间。
运行游戏
现在运行程序,您将看到我们正在进行真正的游戏的第一个迹象。
它目前还不能在 Steam 上获得绿光,但至少我们已经在路上了!
让我们看看本章可能出现的一些问题,以及随着书的进行我们将继续进行的工作。
处理错误
每个项目都会出现问题和错误,这是肯定的!问题越棘手,解决它时就会越令人满意。经过数小时的挣扎后,一个新的游戏功能终于实现,会让人真正兴奋。如果没有挣扎,它可能会变得不那么值得。
在本书的某个时候,可能会遇到一些困难。保持冷静,相信自己能够克服它,然后开始工作。
请记住,无论您遇到什么问题,您都极不可能是世界上第一个遇到同样问题的人。想出一个简洁的句子来描述您的问题或错误,然后在 Google 中输入。您会惊讶地发现,有人很快、准确地解决了您的问题,而且经常会有人已经为您解决了问题。
话虽如此,在这里有一些提示(双关语;请参阅第八章:指针、标准模板库和纹理管理),以便在您努力使本章工作时帮助您入门。
配置错误
本章中问题最有可能的原因是配置错误。您可能已经在设置 Visual Studio、SFML、项目模板和项目本身的过程中注意到,有很多文件名、文件夹和设置需要完全正确。只要有一个错误的设置,就可能导致多种错误,其中文本并没有清楚地说明出了什么问题。
如果您无法使从模板创建可重用模板部分中的黑屏空项目运行起来,可能更容易重新开始该部分。确保所有的文件名和文件夹适合于您特定的设置,然后让代码的最简单部分运行起来(屏幕闪烁黑色然后关闭的部分)。如果您能够达到这个阶段,那么配置可能不是问题所在。
编译错误
编译错误可能是我们未来经历的最常见的错误。检查您的代码是否与我的相同,特别是行尾的分号和类和对象名称的大小写的微妙变化。如果一切都失败了,打开下载包中的代码文件并复制粘贴。虽然书中可能存在代码拼写错误,但代码文件是从实际工作的项目中制作的 - 它们绝对有效!
链接错误
链接错误很可能是由于缺少 SFML 的.dll
文件造成的。您是否将它们全部复制到了从模板创建项目的项目文件夹中?
错误
当您的代码工作时发生错误时,这就是错误。调试实际上可以很有趣。您消灭的错误越多,您的游戏就会越好,您一天的工作就会越令人满意。解决错误的诀窍是尽早找到它们!为此,我建议每次实现新功能时都运行和玩游戏。您越早发现错误,原因就越可能新鲜在您的脑海中。在本书中,我们将在每个可能的阶段运行代码以查看结果。
常见问题解答
以下是一些可能会让你困惑的问题:
Q)我对目前呈现的内容感到困难。我适合编程吗?
A)设置开发环境并理解 OOP 作为一个概念可能是你在这本书中做的最艰难的事情。只要你的游戏正常运行(绘制背景),你就可以继续进行下一章。
Q)所有关于面向对象编程(OOP)、类和对象的讨论都太多了,有点破坏了整个学习体验。
A)别担心。我们会不断地回到面向对象编程、类和对象。在第六章:面向对象编程、类和 SFML 视图中,我们将真正开始掌握整个面向对象编程的东西。你现在需要理解的是,SFML 已经编写了大量有用的类,我们可以通过从这些类创建可用对象来使用这些代码。
Q)我真的不懂这个函数的东西。
A)没关系,我们会再次回到这个问题,并且会更彻底地学习函数。你只需要知道,当一个函数被调用时,它的代码被执行,当它完成时(达到return
语句),程序会跳回调用它的代码。
总结
那是一个相当具有挑战性的章节,也许我让它变得如此苛刻了一点。配置 IDE 以使用 C++库可能有点棘手和耗时。同时,众所周知,对于编程新手来说,类和对象的概念可能有点棘手。
现在我们已经到了这个阶段,我们可以完全专注于 C++、SFML 和游戏。随着章节的进展,我们将学习更多的 C++,以及如何实现越来越有趣的游戏功能。在这个过程中,我们将进一步研究诸如函数、类和对象之类的东西,以帮助更好地揭开它们的神秘面纱。接下来,我们将学习所有绘制更多精灵并对它们进行动画处理所需的 C++知识。
第二章:变量、运算符和决策 - 动画精灵
在本章中,我们将在屏幕上进行更多的绘图,为了实现这一点,我们需要学习一些 C++的基础知识。
这里有什么:
-
学习所有关于 C++变量的知识
-
了解如何操作变量中存储的值
-
添加一个静态树,准备好供玩家砍伐
-
绘制和动画一个蜜蜂和三朵云
C++变量
变量是我们的 C++游戏存储和操作值的方式。如果我们想知道玩家有多少生命值,那么我们就需要一个变量。也许你想知道当前波中还剩下多少僵尸?那也是一个变量。如果您需要记住获得特定高分的玩家的名字,你猜对了,我们也需要一个变量。游戏结束了还是还在进行?是的,那也是一个变量。
变量是内存中位置的命名标识符。因此,我们可以将一个变量命名为numberOfZombies
,该变量可以指向存储表示当前波中剩余僵尸数量的值的内存位置。
计算机系统寻址内存位置的方式是复杂的。编程语言使用变量以人性化的方式管理我们在内存中的数据。
我们对变量的简要讨论意味着必须有不同类型的变量。
变量类型
C++有各种各样的变量类型(请参阅有关变量的下一个提示)。很容易花一个整章的时间来讨论它们。接下来是本书中最常用的类型的表格。然后我们将看看如何实际使用每种变量类型。
类型 | 值的示例 | 解释 |
---|---|---|
Int | -42 ,0 ,1 ,9826 ,等等。 |
整数整数。 |
Float | -1.26f ,5.8999996f ,10128.3f |
浮点值,精度高达 7 位数字。 |
Double | 925.83920655234 ,1859876.94872535 |
浮点值,精度高达 15 位数字。 |
Char | a ,b ,c ,1 ,2 ,3 (包括? ,~ ,# 等共 128 个符号) |
ASCII 表中的任何符号(请参阅有关变量的下一个提示)。 |
Bool | 真或假 | Bool 代表布尔值,只能是true 或false 。 |
String | 大家好!我是一个字符串。 | 从单个字母或数字到整本书的任何文本值。 |
编译器必须告诉变量是什么类型,以便为其分配正确的内存量。对于您使用的每个变量,使用最佳和最合适的类型是一个良好的实践。然而,在实践中,您通常可以提升一个变量。也许您只需要一个具有五个有效数字的浮点数?如果您将其存储为double
,编译器不会抱怨。然而,如果您尝试将float
或double
存储在int
中,它将更改/转换值以适应int
。随着我们在书中的进展,我将澄清在每种情况下使用的最佳变量类型是什么,我们甚至会看到一些有意转换/转换变量类型的情况。
在上面的表中,还有一些额外的细节值得注意,包括所有float
值旁边的f
后缀。这个f
告诉编译器该值是float
类型而不是double
。没有f
前缀的浮点值被假定为double
。有关此内容的更多信息,请参阅有关变量的下一个提示。
如前所述,还有许多其他类型。如果您想了解更多关于类型的信息,请参阅有关变量的下一个提示。
常量
有时我们需要确保一个值永远不会被改变。为了实现这一点,我们可以使用const
关键字声明和初始化一个常量:
const float PI = 3.141f;
const int PLANETS_IN_SOLAR_SYSTEM = 8;
const int NUMBER_OF_ENEMIES = 2000;
习惯上,常量的声明都是大写的。前面常量的值永远不能被改变。我们将在第四章中看到一些常量的实际应用:循环、数组、开关、枚举和函数 - 实现游戏机制。
用户定义的类型
用户定义的类型比我们刚刚看到的类型要先进得多。当我们在 C++中谈论用户定义的类型时,通常是指类。我们在上一章中简要讨论了类及其相关对象。我们可以在一个单独的文件中编写代码,有时甚至是在两个单独的文件中。然后我们将能够声明、初始化和使用它们。我们将把如何定义/创建我们自己的类型留到第六章:面向对象编程、类和 SFML 视图。
声明和初始化变量
到目前为止,我们知道变量用于存储游戏中需要的数据/值。例如,一个变量可以表示玩家拥有的生命值或玩家的姓名。我们还知道这些变量可以表示各种不同类型的值,比如int
、float
、bool
等。当然,我们还没有看到如何实际使用变量。
创建和准备新变量有两个阶段。这两个阶段称为声明和初始化。
声明变量
我们可以在 C++中这样声明变量:
// What is the player's score?
int playerScore;
// What is the players first initial
char playerInitial;
// What is the value of pi
float valuePi;
// Is the player alive or dead?
bool isAlive;
初始化变量
现在我们已经用有意义的名称声明了变量,我们可以用适当的值初始化这些变量,就像这样:
playerScore = 0;
playerInitial = 'J';
valuePi = 3.141f;
isAlive = true;
一步声明和初始化
当适合我们时,我们可以将声明和初始化步骤合并为一步:
int playerScore = 0;
char playerInitial = 'J';
float valuePi = 3.141f;
bool isAlive = true;
提示
变量提示正如承诺的那样,这是关于变量的提示。如果你想看到完整的 C++类型列表,那么请查看这个网页:www.tutorialspoint.com/cplusplus/cpp_data_types.htm
。如果你想深入讨论浮点数、双精度和f
后缀,那么请阅读这篇文章:www.cplusplus.com/forum/beginner/24483/
。如果你想了解 ASCII 字符代码的方方面面,那么这里有更多信息:www.cplusplus.com/doc/ascii/
。请注意,这些链接是给好奇的读者的,我们已经讨论了足够的内容以便继续进行。
声明和初始化用户定义的类型
我们已经看到了如何声明和初始化一些 SFML 定义的类型的示例。由于我们可以创建/定义这些类型(类)的方式非常灵活,因此我们声明和初始化它们的方式也是多种多样的。以下是前一章中关于声明和初始化用户定义的类型的一些提醒。
创建一个类型为VideoMode
的对象,名为vm
,并用两个int
值1920
和1080
进行初始化:
// Create a video mode object
VideoMode vm(1920, 1080);
创建一个类型为Texture
的对象,名为textureBackground
,但不进行任何初始化:
// Create a texture to hold a graphic on the GPU
Texture textureBackground;
请注意,即使我们没有建议使用哪些特定值来初始化textureBackground
,某些变量可能已在内部设置。对象是否需要/具有在此时给出初始化值的选项完全取决于类的编码方式,几乎是无限灵活的。这进一步表明,当我们开始编写自己的类时,会有一些复杂性。幸运的是,这也意味着我们将有重大的权力来设计我们的类型/类,使它们正是我们需要的来制作我们的游戏!将这种巨大的灵活性添加到 SFML 设计的类中,我们的游戏的潜力几乎是无限的。
在本章中,我们还将看到 SFML 提供的一些用户创建的类型/类,以及本书中的更多内容。
操作变量
到目前为止,我们确切地知道了变量是什么,主要类型是什么,以及如何声明和初始化它们,但我们仍然不能做太多事情。我们需要操作我们的变量,加上它们,减去它们,乘以它们,除以它们,并测试它们。
首先,我们将处理如何操作它们,稍后我们将看看我们如何以及为什么测试它们。
C++算术和赋值运算符
为了操作变量,C++有一系列算术运算符和赋值运算符。幸运的是,大多数算术和赋值运算符使用起来相当直观,而那些不直观的则很容易解释。为了让我们开始,让我们先看一张算术运算符表,然后是一张我们将在本书中经常使用的赋值运算符表:
算术运算符 | 解释 |
---|---|
+ |
加法运算符可用于将两个变量或值的值相加。 |
- |
减法运算符可用于从另一个变量或值中减去一个变量或值的值。 |
* |
乘法运算符可以将变量和值的值相乘。 |
/ |
除法运算符可以除以变量和值的值。 |
% |
取模运算符将一个值或变量除以另一个值或变量,以找到操作的余数。 |
现在是赋值运算符的时候了:
赋值运算符 | 解释 |
---|---|
= |
我们已经见过这个了。这是赋值运算符。我们用它来初始化/设置变量的值。 |
+= |
将右侧的值加到左侧的变量上。 |
-= |
从左侧的变量中减去右侧的值。 |
*= |
将右侧的值乘以左侧的变量。 |
/= |
将右侧的值除以左侧的变量。 |
++ |
递增运算符;将变量加 1 |
-- |
递减运算符;从变量中减去 1 |
注意
从技术上讲,除了=, --
和++
之外,上述所有运算符都被称为复合赋值运算符,因为它们包含多个运算符。
现在我们已经看到了一系列算术和赋值运算符,我们实际上可以看到如何通过组合运算符、变量和值来操作我们的变量形成表达式。
通过表达式完成任务
表达式是变量、运算符和值的组合。使用表达式,我们可以得出一个结果。此外,正如我们很快将看到的那样,我们可以在测试中使用表达式。这些测试可以用来决定我们的代码接下来应该做什么。首先,让我们看一些可能在游戏代码中看到的简单表达式:
// Player gets a new high score
hiScore = score;
或者
// Set the score to 100
score = 100;
看一下加法运算符,与赋值运算符一起使用:
// Add to the score when an alien is shot
score = aliensShot + wavesCleared;
或者
// Add 100 to whatever the score currently is
score = score + 100;
请注意,在运算符的两侧使用相同的变量是完全可以接受的。
看一下减法运算符与赋值运算符的结合。下面的代码从减法运算符右侧的值中减去左侧的值。它通常与赋值运算符一起使用,例如:
// Uh oh lost a life
lives = lives - 1;
或者
// How many aliens left at end of game
aliensRemaining = aliensTotal - aliensDestroyed;
这是我们可能使用除法运算符的方式。下面的代码将左边的数字除以右边的数字。同样,它通常与赋值运算符一起使用,如下所示:
// Make the remaining hit points lower based on swordLevel
hitPoints = hitPoints / swordLevel;
或者
// Give player something back for recycling a block
recycledValueOfBlock = originalValue / .9f;
显然,在前面的例子中,变量recycledValueOfBlock
需要是float
类型,以准确存储这样的计算结果。
也许并不令人惊讶,我们可以像这样使用乘法运算符:
// answer is equal to 100 - of course
answer = 10 * 10;
或者
// biggerAnswer = 1000 - of course
biggerAnswer = 10 * 10 * 10;
注意
顺便说一下,你是否曾经想过 C++是怎么得到它的名字的?C++是 C 语言的扩展。它的发明者 Bjarne Stroustrup 最初称其为C with classes,但名称发生了变化。如果您感兴趣,请阅读 C++的故事:www.cplusplus.com/info/history/
。
现在,让我们看看增量运算符的运行情况。这是一个非常巧妙的方法,可以将1
添加到我们游戏变量的值中。
看一下这段代码:
// Add one to myVariable
myVariable = myVariable + 1;
它产生了与这段代码相同的结果:
// Much neater and quicker
myVariable ++;
递减运算符--
,你猜对了,是从某个数值中减去1
的一个非常巧妙的方法:
playerHealth = playerHealth -1;
这与这个是一样的:
playerHealth --;
让我们看看一些操作符的运行情况,然后我们可以继续构建 Timber!!!游戏:
someVariable = 10;
// Multiply the variable by 10 and put the answer back in variable
someVariable *= 10;
// someVariable now equals 100
// Divide someVariable by 5 put the answer back into the variable
someVariable /= 5;
// someVariable now equals 20
// Add 3 to someVariable and put the answer back into the variable
someVariable += 3;
// someVariable now equals 23
// Take 25 from someVariable and put the answer back into the variable
someVariable -= 25;
// someVariable now equals -2
现在是时候向我们的游戏添加一些更多的精灵了。
添加云,树和嗡嗡蜜蜂
首先我们将添加一棵树。这将非常容易。之所以容易是因为树不会移动。我们将使用与我们在上一章绘制背景时完全相同的过程。
准备树
添加下面突出显示的代码。注意未突出显示的代码,这是我们已经编写的代码。这应该帮助您确定新代码应该在设置背景位置之后立即输入,但在主游戏循环开始之前。在您添加新代码之后,我们将回顾新代码的实际情况:
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game
RenderWindow window(vm, "Timber!!!", Style::Fullscreen);
// Create a texture to hold a graphic on the GPU
Texture textureBackground;
// Load a graphic into the texture
textureBackground.loadFromFile("graphics/background.png");
// Create a sprite
Sprite spriteBackground;
// Attach the texture to the sprite
spriteBackground.setTexture(textureBackground);
// Set the spriteBackground to cover the screen
spriteBackground.setPosition(0, 0);
// Make a tree sprite
Texture textureTree;
textureTree.loadFromFile("graphics/tree.png");
Sprite spriteTree;
spriteTree.setTexture(textureTree);
spriteTree.setPosition(810, 0);
while (window.isOpen())
{
我们刚刚添加的五行代码(不包括注释)做了以下事情:
-
首先,我们创建了一个名为
textureTree
的Texture
类型对象。 -
接下来,我们从
tree.png
图形文件中将图形加载到纹理中。 -
接下来,我们声明了一个名为
spriteTree
的Sprite
类型对象。 -
现在,我们将
textureTree
与spriteTree
关联起来。每当我们绘制spriteTree
时,它将显示textureTree
纹理,这是一个漂亮的树形图形。 -
最后,我们使用 x 轴上的坐标
810
和 y 轴上的坐标 0 设置了树的位置。
让我们继续处理蜜蜂对象,这几乎是以相同的方式处理的。
准备蜜蜂
下一个代码与树代码之间的差异很小但很重要。由于蜜蜂需要移动,我们还声明了两个与蜜蜂相关的变量。在所示的位置添加突出显示的代码,并看看我们如何使用变量beeActive
和beeSpeed
:
// Make a tree sprite
Texture textureTree;
textureTree.loadFromFile("graphics/tree.png");
Sprite spriteTree;
spriteTree.setTexture(textureTree);
spriteTree.setPosition(810, 0);
// Prepare the bee
Texture textureBee;
textureBee.loadFromFile("graphics/bee.png");
Sprite spriteBee;
spriteBee.setTexture(textureBee);
spriteBee.setPosition(0, 800);
// Is the bee currently moving?
bool beeActive = false;
// How fast can the bee fly
float beeSpeed = 0.0f;
while (window.isOpen())
{
我们创建蜜蜂的方式与我们创建背景和树的方式完全相同。我们使用Texture
和Sprite
,并将两者关联起来。请注意,在以前的蜜蜂代码中,有一些我们以前没有见过的新代码。有一个用于确定蜜蜂是否活动的bool
变量。请记住,bool
变量可以是true
或false
。我们暂时将beeActive
初始化为false
。
接下来,我们声明一个名为beeSpeed
的新float
变量。这将保存我们的蜜蜂在屏幕上飞行的速度,以像素为单位每秒。
很快我们将看到如何使用这两个新变量来移动蜜蜂。在我们这样做之前,让我们以几乎相同的方式设置一些云。
准备云
添加下面显示的突出显示的代码。研究新代码,尝试弄清楚它将做什么:
// Prepare the bee
Texture textureBee;
textureBee.loadFromFile("graphics/bee.png");
Sprite spriteBee;
spriteBee.setTexture(textureBee);
spriteBee.setPosition(0, 800);
// Is the bee currently moving?
bool beeActive = false;
// How fast can the bee fly
float beeSpeed = 0.0f;
// make 3 cloud sprites from 1 texture
Texture textureCloud;
// Load 1 new texture
textureCloud.loadFromFile("graphics/cloud.png");
// 3 New sprites with the same texture
Sprite spriteCloud1;
Sprite spriteCloud2;
Sprite spriteCloud3;
spriteCloud1.setTexture(textureCloud);
spriteCloud2.setTexture(textureCloud);
spriteCloud3.setTexture(textureCloud);
// Position the clouds off screen
spriteCloud1.setPosition(0, 0);
spriteCloud2.setPosition(0, 250);
spriteCloud3.setPosition(0, 500);
// Are the clouds currently on screen?
bool cloud1Active = false;
bool cloud2Active = false;
bool cloud3Active = false;
// How fast is each cloud?
float cloud1Speed = 0.0f;
float cloud2Speed = 0.0f;
float cloud3Speed = 0.0f;
while (window.isOpen())
{
我们刚刚添加的代码中唯一有点奇怪的是,我们只有一个Texture
类型的对象。多个Sprite
对象共享一个纹理是完全正常的。一旦Texture
存储在 GPU 内存中,它就可以与Sprite
对象快速关联。只有在loadFromFile
代码中加载图形的初始操作相对较慢。当然,如果我们想要三个不同形状的云,那么我们就需要三个纹理。
除了轻微的纹理问题,我们刚刚添加的代码与蜜蜂相比并没有什么新的。唯一的区别是有三个云精灵,三个用于确定每朵云是否活动的bool
变量和三个用于保存每朵云速度的float
变量。
绘制树、蜜蜂和云
最后,我们可以通过在绘图部分添加这个突出显示的代码将它们全部绘制到屏幕上:
/*
****************************************
Draw the scene
****************************************
*/
// Clear everything from the last run frame
window.clear();
// Draw our game scene here
window.draw(spriteBackground);
// Draw the clouds
window.draw(spriteCloud1);
window.draw(spriteCloud2);
window.draw(spriteCloud3);
// Draw the tree
window.draw(spriteTree);
// Draw the insect
window.draw(spriteBee);
// Show everything we just drew
window.display();
绘制三朵云、蜜蜂和树的方式与绘制背景的方式完全相同。然而,请注意我们绘制不同对象到屏幕的顺序。我们必须在背景之后绘制所有图形,否则它们将被覆盖,而且我们必须在树之前绘制云,否则它们在树前飘来飘去会看起来有点奇怪。蜜蜂无论在树前还是树后看起来都可以。我选择在树前画蜜蜂,这样它就可以试图分散我们的伐木工的注意力,有点像真正的蜜蜂可能会做的。
运行 Timber!!!并对树、三朵云和一只蜜蜂感到敬畏,它们什么都不做!它们看起来像是在为比赛排队,蜜蜂倒着飞。
利用我们对运算符的了解,我们可以尝试移动我们刚刚添加的图形,但有一些问题。首先,真实的云和蜜蜂以不均匀的方式移动。它们没有固定的速度或位置。尽管它们的位置和速度是由风速或蜜蜂可能的匆忙程度等因素决定的,但对于一般观察者来说,它们所采取的路径和速度似乎是随机的。
随机数
随机数在游戏中有很多用途。也许你可以用它们来确定玩家得到的是什么牌,或者从敌人的健康中减去多少范围内的伤害。正如暗示的那样,我们将使用随机数来确定蜜蜂和云的起始位置和速度。
在 C++中生成随机数
为了生成随机数,我们需要使用更多的 C++函数,确切地说是两个。现在不要向游戏中添加任何代码。让我们只看一下语法和一些假设代码所需的步骤。
计算机实际上不能选择随机数。它们只能使用算法/计算来选择一个看起来是随机的数字。为了使这个算法不断返回相同的值,我们必须种子随机数生成器。种子可以是任何整数,尽管每次需要一个唯一的随机数时,它必须是一个不同的种子。看一下这段代码,它种子了随机数生成器:
// Seed the random number generator with the time
srand((int)time(0));
上面的代码使用time
函数从 PC 获取时间,就像这样time(0)
。对time
函数的调用被封装为要发送到srand
函数的值。其结果是当前时间被用作种子。
由于略显不寻常的(int)
语法,前面的代码看起来有点复杂。这样做的目的是将从time
返回的值转换/转型为int
。在这种情况下,这是srand
函数所必需的。
注意
从一种类型转换为另一种类型称为转换。
因此,总结一下,前一行代码发生了什么:
-
它使用
time
获取时间 -
它将其转换为类型
int
-
它将这个结果值发送给
srand
,用于生成随机数
当然,时间是不断变化的。这使得time
函数成为种子随机数生成器的好方法。然而,想想如果我们多次并且在很短的时间内种子随机数生成器,以至于time
返回相同的值会发生什么?当我们给云动画时,我们将看到并解决这个问题。
在这个阶段,我们可以创建一个在范围内的随机数,并将其保存到一个变量中以备后用:
// Get the random number & save it to a variable called number
int number = (rand() % 100);
注意我们分配一个值给number
的奇怪方式。通过使用取模运算符%
和值100
,我们要求在将rand
返回的数字除以100
后得到余数。当你除以100
时,你可能得到的最大数字是 99。最小的可能数字是 0。因此,前面的代码将生成一个在 0 到 99 之间的数字。这个知识对于为我们的蜜蜂和云生成随机速度和起始位置将非常有用。
我们很快就会做到这一点,但我们首先需要学习如何在 C++中做出决定。
使用 if 和 else 做决定
C++的if
和else
关键字是让我们做决定的关键。实际上,在上一章中,当我们在每一帧中检测到玩家是否按下了Esc键时,我们已经看到了if
的作用:
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
到目前为止,我们已经看到了如何使用算术和赋值运算符来创建表达式。现在我们可以看到一些新的运算符。
逻辑运算符
逻辑运算符将通过构建可以测试为真或假的表达式来帮助我们做出决定。起初,这可能看起来像是一个非常狭窄的选择,不足以满足高级 PC 游戏中可能需要的选择。一旦我们深入挖掘,我们将看到我们实际上可以只用几个逻辑运算符就能做出所有需要的决定。
下面是一个最有用的逻辑运算符的表格。看一下它们及其相关的例子,然后我们将看看如何使用它们。
逻辑运算符 | 名称和例子 |
---|---|
== |
比较运算符测试相等性,要么为真,要么为假。例如,表达式(10 == 9) 是假的。10 显然不等于 9。 |
! |
这是逻辑非运算符。表达式(! (2 + 2 == 5)) 。这是真的,因为2 + 2 不等于5 。 |
!= |
这是另一个比较运算符,但与= 比较运算符不同。这测试是否不相等。例如,表达式(10 != 9) 是真的。10 不等于9 。 |
> |
另一个比较运算符 - 实际上还有几个。这测试某物是否大于其他某物。表达式(10 > 9) 是真的。 |
< |
你猜对了。这测试小于的值。表达式(10 < 9) 是假的。 |
>= |
这个运算符测试一个值是否大于或等于另一个值,如果其中一个为真,结果就为真。例如,表达式(10 >= 9) 是真的。表达式(10 >= 10) 也是真的。 |
<= |
像前一个运算符一样,这个运算符测试两个条件,但这次是小于或等于。表达式(10 <= 9) 是假的。表达式(10 <= 10) 是真的。 |
&& |
这个运算符称为逻辑与。它测试表达式的两个或多个单独部分,两个部分都必须为真,结果才为真。逻辑 AND 通常与其他运算符一起用于构建更复杂的测试。表达式((10 > 9) && (10 < 11)) 是真的,因为两个部分都为真,所以表达式为真。表达式((10 > 9) && (10 < 9)) 是假的,因为表达式的一部分为真,另一部分为假。 |
|| |
这个运算符称为逻辑或,它与逻辑 AND 类似,只是表达式的两个或多个部分中至少有一个为真,表达式才为真。让我们看看我们上面使用的最后一个例子,但用|| 替换&& 。表达式((10 > 9) || (10 < 9)) 现在为真,因为表达式的一部分为真。 |
让我们来认识一下 C++的if
和else
关键字,它们将使我们能够充分利用所有这些逻辑运算符。
C++的 if 和 else
让我们把之前的例子变得不那么抽象。见识一下 C++的if
关键字。我们将使用if
和一些运算符以及一个小故事来演示它们的用法。接下来是一个虚构的军事情况,希望它比之前的例子更具体。
如果他们过桥了,就射击他们!
队长垂危,知道他剩下的部下经验不足,决定编写一个 C++程序,在他死后传达他的最后命令。部队必须在等待增援时守住桥的一侧。
队长想要确保他的部队理解的第一个命令是:
“如果他们过桥了,就射击他们!”
那么,我们如何在 C++中模拟这种情况呢?我们需要一个bool
变量:isComingOverBridge
。下一段代码假设isComingOverBridge
变量已经被声明并初始化为true
或false
。
然后我们可以这样使用if
:
if(isComingOverBridge)
{
// Shoot them
}
如果isComingOverBridge
变量等于true
,则大括号{...}
内的代码将运行。如果不是,则程序在if
块之后继续运行,而不运行其中的代码。
或者做这个
队长还想告诉他的部队,如果敌人没有过桥就待在原地。
现在我们可以介绍另一个 C++关键字,else
。当if
的评估结果不为true
时,我们可以使用else
来明确执行某些操作。
例如,要告诉部队如果敌人没有过桥就待在原地,我们可以写下这段代码:
if(isComingOverBridge)
{
// Shoot them
}
else
{
// Hold position
}
然后队长意识到问题并不像他最初想的那么简单。如果敌人过桥,但是人数太多怎么办?他的小队将被压垮和屠杀。所以,他想出了这段代码(这次我们也会使用一些变量。):
bool isComingOverBridge;
int enemyTroops;
int friendlyTroops;
// Initialize the previous variables, one way or another
// Now the if
if(isComingOverBridge && friendlyTroops > enemyTroops)
{
// shoot them
}
else if(isComingOverBridge && friendlyTroops < enemyTroops)
{
// blow the bridge
}
else
{
// Hold position
}
上面的代码有三种可能的执行路径。第一种是如果敌人从桥上过来,友军人数更多:
if(isComingOverBridge && friendlyTroops > enemyTroops)
第二种是如果敌军正在过桥,但人数超过友军:
else if(isComingOveBridge && friendlyTroops < enemyTroops)
然后第三种可能的结果,如果其他两种都不为true
,则由最终的else
捕获,没有if
条件。
读者挑战
你能发现上述代码的一个缺陷吗?这可能会让一群经验不足的部队陷入完全混乱的状态吗?敌军和友军人数完全相等的可能性没有被明确处理,因此将由最终的else
处理。最终的else
是用于没有敌军的情况。我想任何有自尊心的队长都会期望他的部队在这种情况下战斗。他可以改变第一个if
语句以适应这种可能性:
if(isComingOverBridge && friendlyTroops >= enemyTroops)
最后,队长最后关心的是,如果敌人拿着白旗过桥投降,然后被立即屠杀,那么他的士兵最终会成为战争罪犯。显而易见的是需要 C++代码。使用wavingWhiteFlag
布尔变量,他写下了这个测试:
if (wavingWhiteFlag)
{
// Take prisoners
}
但是放置这段代码的问题并不太清楚。最后,队长选择了以下嵌套解决方案,并将wavingWhiteFlag
的测试更改为逻辑非,就像这样:
if (!wavingWhiteFlag)
{
// not surrendering so check everything else
if(isComingOverTheBridge && friendlyTroops >= enemyTroops)
{
// shoot them
}
else if(isComingOverTheBridge && friendlyTroops < enemyTroops)
{
// blow the bridge
}
}
else
{
// this is the else for our first if
// Take prisoners
{
// Holding position
这表明我们可以嵌套if
和else
语句以创建相当深入和详细的决策。
我们可以继续使用if
和else
做出更复杂的决定,但我们已经看到的足够作为介绍。值得指出的是,通常解决问题的方法不止一种。通常正确的方法是以最清晰和最简单的方式解决问题。
我们正在接近拥有所有我们需要的 C++知识,以便能够为我们的云和蜜蜂制作动画。我们还有一个最后的动画问题要讨论,然后我们可以回到游戏中。
时间
在我们移动蜜蜂和云之前,我们需要考虑时间。正如我们已经知道的,主游戏循环一遍又一遍地执行,直到玩家按下Esc键。
我们还学到了 C++和 SFML 非常快。事实上,我的老旧笔记本电脑每秒执行一个简单的游戏循环(比如当前的循环)大约有五千次。
帧率问题
让我们考虑一下蜜蜂的速度。为了讨论的目的,我们可以假装我们要以每秒 200 像素的速度移动它。在一个宽度为 1920 像素的屏幕上,它大约需要 10 秒才能横穿整个宽度,因为 10 乘以 200 等于 2000(接近 1920)。
此外,我们知道我们可以用setPosition(...,...)
来定位我们的精灵中的任何一个。我们只需要把 x 和 y 坐标放在括号里。
除了设置精灵的位置,我们还可以获取精灵的位置。例如,要获取蜜蜂的水平 x 坐标,我们将使用这段代码:
int currentPosition = spriteBee.getPosition().x;
蜜蜂的当前 x 坐标现在存储在currentPosition
中。要将蜜蜂向右移动,我们可以将 200(我们预期的速度)除以 5000(我笔记本电脑上的近似帧率)的适当分数添加到currentPosition
中,就像这样:
currentPosition += 200/5000;
现在我们可以使用setPosition
来移动我们的蜜蜂。它将每帧平滑地从左到右移动 200 除以 5000 像素。但是这种方法有两个大问题。
帧率是我们的游戏循环每秒处理的次数。也就是说,我们处理玩家的输入、更新游戏对象并将它们绘制到屏幕上的次数。我们将在本书的其余部分扩展并讨论帧率的影响。
我的笔记本电脑上的帧率可能并不总是恒定的。蜜蜂可能看起来像是断断续续地在屏幕上加速。
当然,我们希望我们的游戏能够吸引更广泛的受众,而不仅仅是我的笔记本电脑!每台 PC 的帧率都会有所不同,至少会略有不同。如果你有一台非常老旧的 PC,蜜蜂看起来会像被铅压住,如果你有最新的游戏设备,它可能会是一个模糊的涡轮蜜蜂。
幸运的是,这个问题对每个游戏来说都是一样的,SFML 提供了一个解决方案。理解解决方案的最简单方法是实施它。
SFML 帧率解决方案
现在我们将测量并使用帧率来控制我们的游戏。要开始实施这个,只需在主游戏循环之前添加这段代码:
// How fast is each cloud?
float cloud1Speed = 0;
float cloud2Speed = 0;
float cloud3Speed = 0;
// Variables to control time itself
Clock clock;
while (window.isOpen())
{
在前面的代码中,我们声明了一个Clock
类型的对象,并将其命名为clock
。类名以大写字母开头,对象名(我们实际使用的)以小写字母开头。对象名是任意的,但clock
似乎是一个合适的名字,嗯,一个时钟的名字。我们很快也会在这里添加一些与时间相关的变量。
现在,在我们的游戏代码的更新部分添加这个突出显示的代码:
/*
****************************************
Update the scene
****************************************
*/
// Measure time
Time dt = clock.restart();
/*
****************************************
Draw the scene
****************************************
*/
clock.restart()
函数,正如你所期望的那样,重新启动时钟。我们希望每一帧都重新启动时钟,以便我们可以计算每一帧花费的时间。此外,它返回自上次我们重新启动时钟以来经过的时间。
因此,在前面的代码中,我们声明了一个Time
类型的对象,称为dt
,并使用它来存储clock.restart()
函数返回的值。
现在,我们有一个名为dt
的Time
对象,它保存了自上次更新场景并重新启动时经过的时间。也许你能看出这是怎么回事。
让我们向游戏添加一些更多的代码,然后我们将看看我们可以用dt
做些什么。
注意
dt
代表增量时间,即两次更新之间的时间。
移动云和蜜蜂
让我们利用自上一帧以来经过的时间,为蜜蜂和云注入生命。这将解决在不同 PC 上拥有一致的帧速率的问题。
给蜜蜂注入生命
我们想要做的第一件事是在特定高度和特定速度下设置蜜蜂。我们只想在蜜蜂不活动时才这样做。因此,我们将下一个代码放在一个if
块中。检查并添加下面突出显示的代码,然后我们将讨论它:
/*
****************************************
Update the scene
****************************************
*/
// Measure time
Time dt = clock.restart();
// Setup the bee
if (!beeActive)
{
// How fast is the bee
srand((int)time(0));
beeSpeed = (rand() % 200) + 200;
// How high is the bee
srand((int)time(0) * 10);
float height = (rand() % 500) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
/*
****************************************
Draw the scene
****************************************
*/
现在,如果蜜蜂不活动,就像游戏刚开始时一样,if(!beeActive)
将为true
,上面的代码将按照以下顺序执行以下操作:
-
给随机数生成器设定种子
-
获取一个在 199 和 399 之间的随机数,并将结果赋给
beeSpeed
-
再次给随机数生成器设定种子
-
在 499 和 999 之间获取一个随机数,并将结果赋给一个名为
height
的新的float
变量 -
将蜜蜂的位置设置为 x 轴上的
2000
(刚好在屏幕右侧)和 y 轴上等于height
的值 -
将
beeActive
设置为 true
注意
请注意,height
变量是我们在游戏循环内声明的第一个变量。此外,因为它是在if
块内声明的,所以在if
块外部实际上是“不可见”的。对于我们的用途来说,这是可以接受的,因为一旦我们设置了蜜蜂的高度,我们就不再需要它了。这种影响变量的现象称为作用域。我们将在第四章中更全面地探讨这一点:循环、数组、开关、枚举和函数 - 实现游戏机制。
如果我们运行游戏,蜜蜂实际上还不会发生任何事情,但现在蜜蜂是活跃的,我们可以编写一些代码,当beeActive
为true
时运行。
添加下面突出显示的代码,可以看到,这段代码在beeActive
为true
时执行。这是因为在if(!beeActive)
块之后有一个else
:
// Set up the bee
if (!beeActive)
{
// How fast is the bee
srand((int)time(0) );
beeSpeed = (rand() % 200) + 200;
// How high is the bee
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Move the bee
{
spriteBee.setPosition(
spriteBee.getPosition().x -
(beeSpeed * dt.asSeconds()),
spriteBee.getPosition().y);
// Has the bee reached the right hand edge of the screen?
if (spriteBee.getPosition().x < -100)
{
// Set it up ready to be a whole new cloud next frame
beeActive = false;
}
}
/*
****************************************
Draw the scene
****************************************
*/
在else
块中发生以下事情。
使用以下标准更改蜜蜂的位置。setPosition
函数使用getPosition
函数获取蜜蜂当前的 x 坐标。然后将beeSpeed * dt.asSeconds()
添加到该坐标。
beeSpeed
变量的值是每秒多个像素,并且是在先前的if
块中随机分配的。dt.asSeconds()
的值将是一个小于 1 的分数,表示动画上一帧的持续时间。
假设蜜蜂当前的 x 坐标是 1000。现在假设一个相当基本的 PC 以每秒 5000 帧的速度循环。这意味着dt.asSeconds
将是 0.0002。再假设beeSpeed
被设置为最大的 399 像素每秒。那么决定setPosition
用于 x 坐标的值的代码可以解释如下:
1000 - 0.0002 x 399
因此,蜜蜂在 x 轴上的新位置将是 999.9202。我们可以看到,蜜蜂非常平稳地向左飘移,每帧不到一个像素。如果帧速率波动,那么公式将产生一个新的值来适应。如果我们在每秒只能达到 100 帧或者每秒能达到一百万帧的 PC 上运行相同的代码,蜜蜂将以相同的速度移动。
setPosition
函数使用getPosition().y
来确保蜜蜂在整个活动周期内保持完全相同的 y 坐标。
我们刚刚添加的else
块中的最终代码是这样的:
// Has the bee reached the right hand edge of the screen?
if (spriteBee.getPosition().x < -100)
{
// Set it up ready to be a whole new cloud next frame
beeActive = false;
}
这段代码在每一帧(当beeActive
为true
时)测试,蜜蜂是否已经从屏幕的左侧消失。如果getPosition
函数返回小于-100,那么它肯定已经超出了玩家的视野。当发生这种情况时,beeActive
被设置为false
,在下一帧,一个新的蜜蜂将以新的随机高度和新的随机速度飞行。
尝试运行游戏,看着我们的蜜蜂忠实地从右到左飞行,然后再次回到右侧,高度和速度都不同。几乎就像每次都是一只新的蜜蜂。
提示
当然,真正的蜜蜂会在你专心砍树时黏在你身边,让你烦恼很久。在下一个项目中,我们将制作一些更聪明的游戏角色。
现在我们将以非常相似的方式让云移动。
吹云
我们想要做的第一件事是在特定高度和特定速度设置第一朵云。只有在云处于非活动状态时才想要这样做。因此,我们将下一个代码包装在if
块中。在我们为蜜蜂添加代码之后,检查并添加突出显示的代码,然后我们将讨论它。它几乎与我们用于蜜蜂的代码完全相同:
else
// Move the bee
{
spriteBee.setPosition(
spriteBee.getPosition().x -
(beeSpeed * dt.asSeconds()),
spriteBee.getPosition().y);
// Has the bee reached the right hand edge of the screen?
if (spriteBee.getPosition().x < -100)
{
// Set it up ready to be a whole new bee next frame
beeActive = false;
}
}
// Manage the clouds
// Cloud 1
if (!cloud1Active)
{
// How fast is the cloud
srand((int)time(0) * 10);
cloud1Speed = (rand() % 200);
// How high is the cloud
srand((int)time(0) * 10);
float height = (rand() % 150);
spriteCloud1.setPosition(-200, height);
cloud1Active = true;
}
/*
****************************************
Draw the scene
****************************************
*/
我们刚刚添加的代码与蜜蜂代码之间唯一的区别是我们使用不同的精灵并为我们的随机数使用不同的范围。此外,我们使用*10
来对 time(0)返回的结果进行操作,以便确保每个云都得到不同的种子。当我们下一步编写其他云移动代码时,您将看到我们分别使用*20
和*30
。
现在我们可以在云处于活动状态时采取行动。我们将在else
块中这样做。与if
块一样,代码与蜜蜂的代码完全相同,只是所有代码都作用于云而不是蜜蜂:
// Manage the clouds
if (!cloud1Active)
{
// How fast is the cloud
srand((int)time(0) * 10);
cloud1Speed = (rand() % 200);
// How high is the cloud
srand((int)time(0) * 10);
float height = (rand() % 150);
spriteCloud1.setPosition(-200, height);
cloud1Active = true;
}
else
{
spriteCloud1.setPosition(
spriteCloud1.getPosition().x +
(cloud1Speed * dt.asSeconds()),
spriteCloud1.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud1.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud1Active = false;
}
}
/*
****************************************
Draw the scene
****************************************
*/
现在我们知道该怎么做了,我们可以复制相同的代码用于第二和第三朵云。在第一朵云的代码之后立即添加处理第二和第三朵云的突出代码:
...
// Cloud 2
if (!cloud2Active)
{
// How fast is the cloud
srand((int)time(0) * 20);
cloud2Speed = (rand() % 200);
// How high is the cloud
srand((int)time(0) * 20);
float height = (rand() % 300) - 150;
spriteCloud2.setPosition(-200, height);
cloud2Active = true;
}
else
{
spriteCloud2.setPosition(
spriteCloud2.getPosition().x +
(cloud2Speed * dt.asSeconds()),
spriteCloud2.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud2.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud2Active = false;
}
}
if (!cloud3Active)
{
// How fast is the cloud
srand((int)time(0) * 30);
cloud3Speed = (rand() % 200);
// How high is the cloud
srand((int)time(0) * 30);
float height = (rand() % 450) - 150;
spriteCloud3.setPosition(-200, height);
cloud3Active = true;
}
else
{
spriteCloud3.setPosition(
spriteCloud3.getPosition().x +
(cloud3Speed * dt.asSeconds()),
spriteCloud3.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud3.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud3Active = false;
}
}
/*
****************************************
Draw the scene
****************************************
*/
现在你可以运行游戏,云将随机连续地在屏幕上漂移,蜜蜂将在从右到左飞行后重新出现在右侧。
提示
所有这些云和蜜蜂处理似乎有点重复?我们将看看如何节省大量输入并使我们的代码更易读。在 C++中,有处理相同类型的变量或对象的多个实例的方法。这些被称为数组,我们将在第四章中学习:循环、数组、开关、枚举和函数-实现游戏机制。在项目结束时,一旦我们学习了数组,我们将讨论如何改进我们的云代码。
看一看与本章主题相关的一些常见问题解答。
常见问题解答
问:为什么我们在蜜蜂到达-100 时将其设置为非活动状态?为什么不是零,因为零是窗口的左侧?
答:蜜蜂的图形宽 60 像素,其原点位于左上像素。因此,当蜜蜂以 x 等于零的原点绘制时,整个蜜蜂图形仍然在屏幕上供玩家看到。等到它到达-100 时,我们可以确信它肯定已经超出了玩家的视野。
问:我怎么知道我的游戏循环有多快?
答:为了衡量这一点,我们需要学习更多的东西。我们将在第五章中添加测量和显示当前帧速率的功能:碰撞、声音和结束条件-使游戏可玩。
总结
在本章中,我们了解到变量是内存中的命名存储位置,我们可以在其中保存特定类型的值。类型包括int
、float
、double
、bool
、String
和char
。
我们可以声明和初始化我们需要的所有变量,以存储我们游戏的数据。一旦我们有了变量,我们就可以使用算术和赋值运算符来操作它们,并使用逻辑运算符在测试中使用它们。与if
和else
关键字一起使用,我们可以根据游戏中的当前情况分支我们的代码。
利用所有这些新知识,我们制作了一些云和一只蜜蜂的动画。在下一章中,我们将继续使用这些技能,为玩家添加HUD(抬头显示)并增加更多的输入选项,同时通过时间条来直观地表示时间。
第三章:C++字符串,SFML 时间,玩家输入和 HUD
在本章中,我们将花大约一半的时间学习如何操作文本并在屏幕上显示它,另一半时间将用于研究时间和视觉时间条如何在游戏中制造紧迫感。
我们将涵盖以下主题:
-
暂停和重新开始游戏
-
C++字符串
-
SFML 文本和 SFML 字体类
-
为 Timber!!!添加 HUD
-
为 Timber!!!添加时间条
暂停和重新开始游戏
随着接下来三章的游戏进展,代码显然会变得越来越长。因此,现在似乎是一个很好的时机,考虑未来并在我们的代码中添加更多结构。我们将添加这种结构以使我们能够暂停和重新开始游戏。
我们将添加代码,以便在游戏首次运行时暂停。玩家将能够按下Enter键来启动游戏。然后游戏将运行,直到玩家被压扁或时间用尽。此时游戏将暂停并等待玩家按下Enter键,以重新开始。
让我们一步一步地设置这个。首先,在主游戏循环之外声明一个新的名为paused
的bool
变量,并将其初始化为true
:
// Variables to control time itself
Clock clock;
// Track whether the game is running
bool paused = true;
while (window.isOpen())
{
/*
****************************************
Handle the players input
****************************************
*/
现在,每当游戏运行时,我们都有一个名为paused
的变量,它将是true
。
接下来,我们将添加另一个if
语句,其中表达式将检查Enter键当前是否被按下。如果被按下,它将将paused
设置为false
。在我们其他处理键盘的代码之后添加突出显示的代码:
/*
****************************************
Handle the players input
****************************************
*/
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Start the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
}
/*
****************************************
Update the scene
****************************************
*/
现在我们有一个名为paused
的bool
,它起初是true
,但当玩家按下Enter键时会变为false
。此时,我们必须使我们的游戏循环根据paused
的当前值做出适当的响应。
这就是我们将要进行的步骤。我们将使用if
语句包装整个更新部分的代码,包括我们在上一章中编写的用于移动蜜蜂和云的代码。
请注意,在下一段代码中,只有当paused
不等于true
时,if
块才会执行。换句话说,游戏在暂停时不会移动/更新。
这正是我们想要的。仔细看看添加新的if
语句以及相应的左花括号和右花括号{...}
的确切位置。如果它们放错地方,事情将不会按预期工作。
添加突出显示的代码以包装代码的更新部分,密切关注下面显示的上下文。我在一些行上添加了...
来表示隐藏的代码。显然,...
不是真正的代码,不应该添加到游戏中。您可以通过周围未突出显示的代码来确定要放置新代码(突出显示)的位置,即开头和结尾:
/*
****************************************
Update the scene
****************************************
*/
if (!paused)
{
// Measure time
...
...
...
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud3.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud3Active = false;
}
}
} // End if(!paused)
/*
****************************************
Draw the scene
****************************************
*/
请注意,当您放置新的if
块的右花括号时,Visual Studio 会自动调整所有缩进,以保持代码整洁。
现在您可以运行游戏,直到按下Enter键之前一切都是静态的。现在可以开始为我们的游戏添加功能,只需记住当玩家死亡或时间用尽时,我们需要将paused
设置为true
。
在上一章中,我们初步了解了 C++字符串。我们需要更多地了解它们,以便实现玩家的 HUD。
C++字符串
在上一章中,我们简要提到了字符串,并且了解到字符串可以包含从单个字符到整本书的字母数字数据。我们没有研究声明、初始化或操作字符串。所以现在让我们来做。
声明字符串
声明字符串变量很简单。我们声明类型,然后是名称:
String levelName;
String playerName;
一旦我们声明了一个字符串,我们就可以为它赋值。
为字符串赋值
与常规变量一样,要为字符串赋值,我们只需放置名称,然后是赋值运算符,然后是值:
levelName = "Dastardly Cave";
playerName = "John Carmack";
注意,值需要用引号括起来。与常规变量一样,我们也可以在一行中声明和赋值:
String score = "Score = 0";
String message = "GAME OVER!!";
这就是我们如何改变我们的字符串变量。
操作字符串
我们可以使用#include <sstream>
指令为我们的字符串提供一些额外的功能。sstream
类使我们能够将一些字符串连接在一起。当我们这样做时,它被称为连接:
String part1 = "Hello ";
String part2 = "World";
sstream ss;
ss << part1 << part2;
// ss now holds "Hello World"
除了使用sstream
对象外,字符串变量甚至可以与不同类型的变量连接在一起。下面的代码开始揭示了字符串对我们可能非常有用:
String scoreText = "Score = ";
int score = 0;
// Later in the code
score ++;
sstream ss;
ss << scoreText << score;
// ss now holds "Score = 1"
提示
<<
运算符是一个位运算符。然而,C++允许您编写自己的类,并在类的上下文中重写特定运算符的功能。sstream
类已经这样做了,使<<
运算符按照它的方式工作。复杂性被隐藏在类中。我们可以使用它的功能而不必担心它是如何工作的。如果你感到有冒险精神,你可以阅读关于运算符重载的内容:www.tutorialspoint.com/cplusplus/cpp_overloading.htm
。为了继续项目,你不需要更多的信息。
现在我们知道了 C++字符串的基础知识,以及我们如何使用sstream
,我们可以看到如何使用一些 SFML 类来在屏幕上显示它们。
SFML Text 和 Font
在我们实际添加代码到我们的游戏之前,让我们简要讨论一下Text
和Font
类以及一些假设的代码。
在屏幕上绘制文本的第一步是拥有一个字体。在第一章中,我们将一个字体文件添加到了项目文件夹中。现在我们可以将字体加载到 SFML Font
对象中,准备使用。
要这样做的代码看起来像这样:
Font font;
font.loadFromFile("myfont.ttf");
在前面的代码中,我们首先声明了Font
对象,然后加载了一个实际的字体文件。请注意,myfont.ttf
是一个假设的字体,我们可以使用项目文件夹中的任何字体。
一旦我们加载了一个字体,我们就需要一个 SFML Text
对象:
Text myText;
现在我们可以配置我们的Text
对象。这包括大小、颜色、屏幕上的位置、包含消息的字符串,当然,将其与我们的font
对象关联起来:
// Assign the actual message
myText.setString("Press Enter to start!");
// assign a size
myText.setCharacterSize(75);
// Choose a color
myText.setFillColor(Color::White);
// Set the font to our Text object
myText.setFont(font);
让我们给 Timber 添加一个 HUD!!!
添加分数和消息
现在我们已经了解了足够关于字符串、SFML Text
和 SFML Font
,可以开始实现 HUD 了。
我们需要做的下一件事是在代码文件的顶部添加另一个#include
指令。正如我们所学到的,sstream
类为将字符串和其他变量类型组合成一个字符串提供了一些非常有用的功能。
添加下面高亮代码的一行:
#include "stdafx.h"
#include <sstream>
#include <SFML/Graphics.hpp>
using namespace sf;
int main()
{
接下来我们将设置我们的 SFML Text
对象。一个将包含一条消息,我们将根据游戏状态进行变化,另一个将包含分数,并且需要定期更新。
声明Text
和Font
对象的下一个代码加载字体,将字体分配给Text
对象,然后添加字符串消息、颜色和大小。这应该从我们在上一节讨论中看起来很熟悉。此外,我们添加了一个名为score
的新int
变量,我们可以操纵它来保存玩家的分数。
提示
请记住,如果你在第一章中选择了不同的字体,你需要更改代码的部分以匹配你在Visual Studio Stuff/Projects/Timber/Timber/fonts
文件夹中拥有的.ttf
文件。
添加高亮代码,我们就可以准备好继续更新 HUD 了:
// Track whether the game is running
bool paused = true;
// Draw some text
int score = 0;
sf::Text messageText;
sf::Text scoreText;
// We need to choose a font
Font font;
font.loadFromFile("fonts/KOMIKAP_.ttf");
// Set the font to our message
messageText.setFont(font);
scoreText.setFont(font);
// Assign the actual message
messageText.setString("Press Enter to start!");
scoreText.setString("Score = 0");
// Make it really big
messageText.setCharacterSize(75);
scoreText.setCharacterSize(100);
// Choose a color
messageText.setFillColor(Color::White);
scoreText.setFillColor(Color::White);
while (window.isOpen())
{
/*
****************************************
Handle the players input
****************************************
*/
下面的代码可能看起来有点复杂,甚至复杂。然而,当你稍微分解一下时,它实际上非常简单。检查并添加新代码,然后我们将一起讨论:
// Choose a color
messageText.setFillColor(Color::White);
scoreText.setFillColor(Color::White);
// Position the text
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
scoreText.setPosition(20, 20);
while (window.isOpen())
{
/*
****************************************
Handle the players input
****************************************
*/
我们有两个Text
类型的对象将显示在屏幕上。我们希望将scoreText
定位在左上角并留有一点填充。这并不困难;我们只需使用scoreText.setPosition(20, 20)
,它就会在左上角定位,并留有 20 像素的水平和垂直填充。
然而,定位messageText
并不那么容易。我们希望将其定位在屏幕的正中间。最初这可能看起来不是问题,但我们记得我们绘制的一切的原点都是左上角。因此,如果我们简单地将屏幕的宽度和高度除以二,并在mesageText.setPosition...
中使用结果,那么文本的左上角将位于屏幕的中心,并且会不整齐地向右边展开。
我们需要一种方法来将messageText
的中心设置为屏幕的中心。您刚刚添加的看起来相当恶劣的代码重新定位了messageText
的原点到其自身的中心。为了方便起见,这里是当前讨论的代码:
// Position the text
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
首先,在这段代码中,我们声明了一个名为textRect
的新的FloatRect
类型的对象。正如其名称所示,FloatRect
对象保存了一个带有浮点坐标的矩形。
然后,代码使用messageText.getLocalBounds
函数来使用messageText
包装的矩形的坐标来初始化textRect
。
接下来的代码行,由于它相当长,分成了四行,使用messageText.setOrigin
函数将原点(我们绘制的点)更改为textRect
的中心。当然,textRect
保存了一个矩形,它完全匹配包装messageText
的坐标。然后,执行下一行代码:
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
现在,messageText
将被整齐地定位在屏幕的正中间。每次更改messageText
的文本时,我们将使用完全相同的代码,因为更改消息会改变messageText
的大小,因此其原点需要重新计算。
接下来,我们声明了一个名为ss
的stringstream
类型的对象。请注意,我们使用了完整的名称,包括命名空间std::stringstream
。我们可以通过在代码文件顶部添加using namespace std
来避免这种语法。然而,我们没有这样做,因为我们很少使用它。看一下代码,将其添加到游戏中,然后我们可以更详细地讨论一下。由于我们只希望在游戏暂停时执行此代码,请确保将其与其他代码一起添加到if(!paused)
块中,如下所示:
else
{
spriteCloud3.setPosition(
spriteCloud3.getPosition().x +
(cloud3Speed * dt.asSeconds()),
spriteCloud3.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud3.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud3Active = false;
}
}
// Update the score text
std::stringstream ss;
ss << "Score = " << score;
scoreText.setString(ss.str());
}// End if(!paused)
/*
****************************************
Draw the scene
****************************************
*/
我们使用ss
和<<
运算符提供的特殊功能,它将变量连接到stringstream
中。因此,代码ss << "Score = " << score
的效果是创建一个包含"Score = "
和score
值的字符串,它们被连接在一起。例如,当游戏刚开始时,score
等于零,所以ss
将保存值"Score = 0"
。如果score
发生变化,ss
将在每一帧适应。
接下来的代码简单地显示/设置了ss
中包含的字符串到scoreText
。
scoreText.setString(ss.str());
现在可以绘制到屏幕上了。
接下来的代码绘制了两个Text
对象(scoreText
和messageText
),但请注意,绘制messageText
的代码包含在一个if
语句中。这个if
语句导致只有在游戏暂停时才绘制messageText
。
添加下面显示的突出代码:
// Now draw the insect
window.draw(spriteBee);
// Draw the score
window.draw(scoreText);
if (paused)
{
// Draw our message
window.draw(messageText);
}
// Show everything we just drew
window.display();
现在我们可以运行游戏,看到我们的 HUD 绘制在屏幕上。您将看到SCORE = 0和 PRESS ENTER TO START!消息。当您按下Enter时,后者将消失。
如果您想要看到分数更新,请在while(window.isOpen)
循环中的任何位置添加临时代码score ++;
。如果您添加了这行临时代码,您将看到分数迅速上升,非常快!
如果您添加了临时代码score ++;
,请务必在继续之前将其删除。
添加时间条
由于时间是游戏中的一个关键机制,必须让玩家意识到它。他需要知道自己被分配的六秒即将用完。这将在游戏接近结束时给他一种紧迫感,并且如果他表现得足够好以保持或增加剩余时间,他会有一种成就感。
在屏幕上绘制剩余秒数并不容易阅读(当专注于分支时),也不是实现目标的特别有趣的方式。
我们需要的是一个时间条。我们的时间条将是一个简单的红色矩形,在屏幕上显眼地展示。它将从宽度开始,但随着时间的流逝迅速缩小。当玩家剩余时间达到零时,时间条将完全消失。
同时添加时间条的同时,我们将添加必要的代码来跟踪玩家剩余的时间,并在他用完时间时做出响应。让我们一步一步地进行。
从前面的Clock clock;
声明中添加突出显示的代码:
// Variables to control time itself
Clock clock;
// Time bar
RectangleShape timeBar;
float timeBarStartWidth = 400;
float timeBarHeight = 80;
timeBar.setSize(Vector2f(timeBarStartWidth, timeBarHeight));
timeBar.setFillColor(Color::Red);
timeBar.setPosition((1920 / 2) - timeBarStartWidth / 2, 980);
Time gameTimeTotal;
float timeRemaining = 6.0f;
float timeBarWidthPerSecond = timeBarStartWidth / timeRemaining;
// Track whether the game is running
bool paused = true;
首先,我们声明了一个RectangleShape
类型的对象,并将其命名为timeBar
。RectangleShape
是一个适合绘制简单矩形的 SFML 类。
接下来,我们添加了一些float
变量,timeBarStartWidth
和timeBarHeight
。我们分别将它们初始化为400
和80
。这些变量将帮助我们跟踪每一帧需要绘制timeBar
的大小。
接下来,我们使用timeBar.setSize
函数设置timeBar
的大小。我们不只是传入我们的两个新的float
变量。首先,我们创建一个Vector2f
类型的新对象。然而,这里的不同之处在于,我们没有给新对象命名。我们只是用我们的两个浮点变量初始化它,并直接传递给setSize
函数。
提示
Vector2f
是一个持有两个float
变量的类。它还有一些其他功能,将在整本书中介绍。
之后,我们使用setFillColor
函数将timeBar
颜色设置为红色。
我们在前面的代码中对timeBar
做的最后一件事是设置它的位置。y 坐标非常直接,但我们设置 x 坐标的方式略微复杂。这里是计算:
(1920 / 2) - timeBarStartWidth / 2
代码首先将1920
除以2
。然后将timeBarStartWidth
除以2
。最后从前者中减去后者。
结果使timeBar
在屏幕上漂亮地水平居中。
我们要讨论的代码的最后三行声明了一个名为gameTimeTotal
的新Time
对象,一个名为timeRemaining
的新float
,它初始化为6
,以及一个听起来奇怪的名为timeBarWidthPerSecond
的float
,我们将进一步讨论。
timeBarWidthPerSecond
变量是用timeBarStartWidth
除以timeRemaining
初始化的。结果恰好是timeBar
每秒需要缩小的像素数量。这在我们每一帧调整timeBar
的大小时会很有用。
显然,我们需要在玩家开始新游戏时重置剩余时间。这样做的逻辑位置是Enter键按下。我们也可以同时将score
重置为零。现在让我们通过添加这些突出显示的代码来做到这一点:
// Start the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
// Reset the time and the score
score = 0;
timeRemaining = 5;
}
现在,每一帧我们都必须减少剩余时间的数量,并相应地调整timeBar
的大小。在更新部分添加以下突出显示的代码,如下所示:
/*
****************************************
Update the scene
****************************************
*/
if (!paused)
{
// Measure time
Time dt = clock.restart();
// Subtract from the amount of time remaining
timeRemaining -= dt.asSeconds();
// size up the time bar
timeBar.setSize(Vector2f(timeBarWidthPerSecond *
timeRemaining, timeBarHeight));
// Set up the bee
if (!beeActive)
{
// How fast is the bee
srand((int)time(0) * 10);
beeSpeed = (rand() % 200) + 200;
// How high is the bee
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Move the bee
首先,我们用这段代码减去了玩家剩余的时间与上一帧执行所花费的时间:
timeRemaining -= dt.asSeconds();
然后,我们用以下代码调整了timeBar
的大小:
timeBar.setSize(Vector2f(timeBarWidthPerSecond *
timeRemaining, timeBarHeight));
Vector2F
的 x 值是用timebarWidthPerSecond
乘以timeRemaining
初始化的。这产生了与玩家剩余时间相关的正确宽度。高度保持不变,timeBarHeight
在没有任何操作的情况下使用。
当然,我们必须检测时间是否已经用完。现在,我们将简单地检测时间是否已经用完,暂停游戏,并更改messageText
的文本。稍后我们会在这里做更多的工作。在我们添加的先前代码之后添加突出显示的代码,我们将更详细地查看它:
// Measure time
Time dt = clock.restart();
// Subtract from the amount of time remaining
timeRemaining -= dt.asSeconds();
// resize up the time bar
timeBar.setSize(Vector2f(timeBarWidthPerSecond *
timeRemaining, timeBarHeight));
if (timeRemaining <= 0.0f)
{
// Pause the game
paused = true;
// Change the message shown to the player
messageText.setString("Out of time!!");
//Reposition the text based on its new size
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
}
// Set up the bee
if (!beeActive)
{
// How fast is the bee
srand((int)time(0) * 10);
beeSpeed = (rand() % 200) + 200;
// How high is the bee
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Move the bee
逐步执行先前的代码:
-
首先,我们用
if(timeRemaining <= 0.0f)
测试时间是否已经用完 -
然后我们将
paused
设置为true
,这样我们的代码的更新部分将被执行的最后一次(直到玩家再次按Enter)。 -
然后我们更改
messageText
的消息,计算其新的中心以设置为其原点,并将其定位在屏幕中心。
最后,在代码的这一部分,我们需要绘制timeBar
。在这段代码中,没有任何新的东西,我们以前见过很多次。只需注意我们在树之后绘制timeBar
,这样它就可见。添加突出显示的代码来绘制时间条:
// Draw the score
window.draw(scoreText);
// Draw the timebar
window.draw(timeBar);
if (paused)
{
// Draw our message
window.draw(messageText);
}
// Show everything we just drew
window.display();
现在您可以运行游戏。按Enter开始,并观察时间条平稳地消失到无。
游戏暂停,时间用完了!!消息将出现。
当然,您可以再次按Enter从头开始运行整个游戏。
常见问题解答
Q) 我可以预见,通过精灵的左上角定位有时可能会不方便。
A) 幸运的是,您可以选择使用精灵的哪个点作为定位/原点像素,就像我们使用setOrigin
函数设置messageText
一样。
Q) 代码变得相当长,我很难跟踪一切的位置。
A) 是的,我同意。在下一章中,我们将看到我们可以组织我们的代码的第一种方式,使其更易读。当我们学习编写 C++函数时,我们将看到这一点。此外,当我们学习关于 C++数组时,我们将学习一种处理相同类型的多个对象/变量(如云)的新方法。
总结
在本章中,我们学习了关于字符串、SFML Text
和 SFML Font
。它们使我们能够在屏幕上绘制文本,为玩家提供了 HUD。我们还使用了sstream
,它允许我们连接字符串和其他变量来显示分数。
我们探索了 SFML RectangleShape
类,它正是其名称所暗示的。我们使用了RectangleShape
类型的对象和一些精心计划的变量来绘制一个时间条,直观地显示玩家剩余的时间。一旦我们实现了砍树和移动的树枝可以压扁玩家,时间条将产生紧张感和紧迫感。
接下来,我们将学习一系列新的 C++特性,包括循环、数组、切换、枚举和函数。这将使我们能够移动树枝,跟踪它们的位置,并压扁玩家。
第四章:循环,数组,开关,枚举和函数-实现游戏机制
本章可能包含的 C++信息比书中的任何其他章节都要多。它充满了将极大地推动我们的理解的基本概念。它还将开始阐明我们一直略微忽略的一些模糊领域,例如函数和游戏循环。
一旦我们探索了整个 C++语言必需品清单,然后我们将利用我们所知道的一切来使主要游戏机制-树枝移动。在本章结束时,我们将准备进入最后阶段并完成《伐木者》。
我们将研究以下主题:
-
循环
-
数组
-
使用开关进行决策
-
枚举
-
开始使用函数
-
创建和移动树枝
循环
在编程中,我们经常需要做同样的事情超过一次。到目前为止,我们看到的明显例子是我们的游戏循环。在剥离所有代码的情况下,我们的游戏循环看起来像这样:
while (window.isOpen())
{
}
有几种不同类型的循环,我们将看看最常用的。这种类型的循环的正确术语是while
循环。
while 循环
while
循环非常简单。回想一下if
语句及其表达式,这些表达式评估为true
或false
。我们可以在while
循环的条件表达式中使用相同的运算符和变量的组合。
与if
语句一样,如果表达式为true
,则代码执行。然而,与while
循环相比,C++代码将继续执行,直到条件为false
。看看这段代码:
int numberOfZombies = 100;
while(numberOfZombies > 0)
{
// Player kills a zombie
numberOfZombies--;
// numberOfZombies decreases each pass through the loop
}
// numberOfZOmbies is no longer greater than 0
这是以前的代码中发生的事情。在while
循环之外,声明并初始化int numberOfZombies
为100
。然后while
循环开始。它的条件表达式是numberOfZombies > 0
。因此,while
循环将继续循环执行其主体中的代码,直到条件评估为false
。这意味着上面的代码将执行 100 次。
在循环的第一次通过中,numberOfZombies
等于 100,然后等于 99,然后等于 98,依此类推。但一旦numberOfZOmbies
等于零,当然不再大于零。然后代码将跳出while
循环并继续运行,在闭合大括号之后。
就像if
语句一样,while
循环可能不会执行一次。看看这个:
int availableCoins = 10;
while(availableCoins > 10)
{
// more code here.
// Won't run unless availableCoins is greater than 10
}
此外,表达式的复杂性或可以放入循环主体的代码量没有限制。考虑游戏循环的这种假设变体:
int playerLives = 3;
int alienShips = 10;
while(playerLives !=0 && alienShips !=0 )
{
// Handle input
// Update the scene
// Draw the scene
}
// continue here when either playerLives or alienShips equals 0
以前的while
循环将继续执行,直到playerLives
或alienShips
之一等于零。一旦发生其中一个条件,表达式将评估为false
,程序将从while
循环之后的第一行代码继续执行。
值得注意的是,一旦进入循环的主体,即使表达式在中途评估为false
,它也将至少完成一次,因为在代码尝试开始另一个传递之前不会再次测试。例如:
int x = 1;
while(x > 0)
{
x--;
// x is now 0 so the condition is false
// But this line still runs
// and this one
// and me!
}
// Now I'm done!
以前的循环体将执行一次。我们还可以设置一个永远运行的while
循环,毫不奇怪地称为无限循环。这是一个例子:
int y = 0;
while(true)
{
y++; // Bigger... Bigger...
}
如果您觉得上面的循环令人困惑,只需字面理解。当条件为true
时,循环执行。嗯,true
总是true
,因此将继续执行。
跳出 while 循环
我们可能会使用无限循环,以便我们可以决定何时从循环中退出,而不是在表达式中。当我们准备离开循环主体时,我们将使用break
关键字来做到这一点。也许会像这样:
int z = 0;
while(true)
{
z++; // Bigger... Bigger...
break; // No you're not
// Code doesn't reach here
}
你可能也能猜到,我们可以在 while
循环和其他循环类型中结合使用任何 C++ 决策工具,比如 if
、else
,以及我们即将学习的 switch
。考虑这个例子:
int x = 0;
int max = 10;
while(true)
{
x++; // Bigger... Bigger...
if(x == max)
{
break;
} // No you're not
// code reaches here only until x = 10
}
我们可以花很长时间来研究 C++ while
循环的各种排列,但在某个时候我们想要回到制作游戏。所以让我们继续前进,看看另一种类型的循环。
for 循环
for
循环的语法比 while
循环稍微复杂一些,因为它需要三个部分来设置。先看看代码,然后我们将把它分解开来:
for(int x = 0; x < 100; x ++)
{
// Something that needs to happen 100 times goes here
}
这是 for
循环条件的所有部分的作用。
for(
声明和初始化;
条件;
每次迭代前更改)
为了进一步澄清,这里有一个表格来解释前面 for
循环例子中的所有三个关键部分。
部分 | 描述 |
---|---|
声明和初始化 | 我们创建一个新的 int 变量 i ,并将其初始化为 0 |
条件 | 就像其他循环一样,它指的是必须为循环执行的条件 |
循环通过每次迭代后更改 | 在这个例子中,x ++ 表示每次迭代时 x 增加/递增 1 |
我们可以改变 for
循环来做更多的事情。下面是另一个简单的例子,从 10 开始倒数:
for(int i = 10; i > 0; i--)
{
// countdown
}
// blast off
for
循环控制初始化、条件评估和控制变量。我们将在本章后面在我们的游戏中使用 for
循环。
数组
如果一个变量是一个可以存储特定类型值的盒子,比如 int
、float
或 char
,那么我们可以把数组看作是一整行盒子。盒子的行可以是几乎任何大小和类型,包括类的对象。然而,所有的盒子必须是相同的类型。
提示
在最终项目中,一旦我们学习了更高级的 C++,就可以规避在每个盒子中使用相同类型的限制。
这个数组听起来可能对我们在第二章中的云有用:变量、运算符和决策 - 动画精灵。那么我们如何创建和使用数组呢?
声明一个数组
我们可以这样声明一个 int
类型变量的数组:
int someInts[10];
现在我们有一个名为 someInts
的数组,可以存储十个 int
值。然而,目前它是空的。
初始化数组的元素
为了向数组的元素添加值,我们可以使用我们已经熟悉的类型的语法,结合一些新的语法,称为数组表示法。在下面的代码中,我们将值 99
存储到数组的第一个元素中:
someInts[0] = 99;
要在第二个元素中存储值 999
,我们写下这段代码:
someInts[1] = 999;
我们可以将值 3
存储在最后一个元素中,如下所示:
someInts[9] = 3;
请注意,数组的元素始终从零开始,直到数组大小减 1。与普通变量类似,我们可以操作数组中存储的值。唯一的区别是我们会使用数组表示法来做到这一点,因为虽然我们的数组有一个名字 someInts
,但是单独的元素没有名字。
在下面的代码中,我们将第一个和第二个元素相加,并将答案存储在第三个元素中:
someInts[2] = someInts[0] + someInts[1];
数组也可以与常规变量无缝交互,比如下面的例子:
int a = 9999;
someInts[4] = a;
快速初始化数组的元素
我们可以快速地向元素添加值,比如这个使用 float
数组的例子:
float myFloatingPointArray[3] {3.14f, 1.63f, 99.0f};
现在值 3.14
,1.63
和 99.0
分别存储在第一、第二和第三位置。请记住,使用数组表示法访问这些值时,我们将使用 [0]、[1] 和 [2]。
还有其他方法来初始化数组的元素。这个稍微抽象的例子展示了使用 for
循环将值 0 到 9 放入 uselessArray
数组中:
for(int i = 0; i < 10; i++)
{
uselessArray[i] = i;
}
该代码假设 uslessArray
之前已经被初始化为至少包含 10
个 int
变量。
那么这些数组对我们的游戏到底有什么作用呢?
我们可以在任何常规变量可以使用的地方使用数组。例如,它们可以在表达式中使用,如下所示:
// someArray[] is declared and initialized with 9999 values
for(int i = 0; i < 9999; i++)
{
// Do something with each entry in the array
}
数组在游戏代码中的最大好处可能是在本节开始时暗示的。数组可以保存对象(类的实例)。假设我们有一个Zombie
类,并且我们想要存储大量的Zombie
。我们可以像在这个假设的例子中那样做:
Zombie horde [5] {zombie1, zombie2, zombie3}; // etc...
horde
数组现在保存了大量Zombie
类的实例。每个实例都是一个独立的、活着的(有点),呼吸着的、自主决定的Zombie
对象。然后我们可以循环遍历horde
数组,在游戏循环的每一次通过中,移动僵尸,检查它们的头是否被斧头砍中,或者它们是否设法抓住了玩家。
如果当时我们知道数组,它们将非常适合处理我们的云。我们可以拥有任意数量的云,并且编写的代码比我们为我们的三朵微不足道的云所做的要少。
提示
要查看完整的改进的云代码,并且看它实际运行,可以查看下载包中《伐木工》(代码和可玩游戏)的增强版本。或者您可以在查看代码之前尝试使用数组实现云。
了解所有这些数组内容的最佳方法是看它们的实际应用。当我们实现我们的树枝时,我们将会看到它们的应用。
现在我们将保留我们的云代码,以便尽快回到游戏中添加功能。但首先让我们再看一下使用switch
进行更多 C++决策的内容。
使用switch
做决策
我们已经看到了if
,它允许我们根据表达式的结果来决定是否执行一段代码块。有时,在 C++中做决定可能有其他更好的方法。
当我们必须基于一系列可能的结果做出决定时,其中不涉及复杂的组合或广泛的数值范围,通常情况下会使用switch
。我们可以在以下代码中看到switch
决策的开始:
switch(expression)
{
// More code here
}
在前面的例子中,expression
可以是一个实际的表达式或一个变量。然后,在花括号内,我们可以根据表达式的结果或变量的值做出决定。我们可以使用case
和break
关键字来实现这一点:
case x:
//code to for x
break;
case y:
//code for y
break;
在前面的抽象例子中,您可以看到,每个case
表示一个可能的结果,每个break
表示该case
的结束以及执行离开switch
块的地方。
我们还可以选择使用default
关键字而不带值,以便在没有任何case
语句评估为true
时运行一些代码。以下是一个例子:
default: // Look no value
// Do something here if no other case statements are true
break;
作为switch
的最后一个不太抽象的例子,考虑一个复古的文本冒险游戏,玩家输入一个字母,比如'n'
、'e'
、's'
或'w'
来向北、东、南或西移动。switch
块可以用来处理玩家的每个可能的输入,就像我们在这个例子中看到的那样:
// get input from user in a char called command
switch(command){
case 'n':
// Handle move here
break;
case 'e':
// Handle move here
break;
case 's':
// Handle move here
break;
case 'w':
// Handle move here
break;
// more possible cases
default:
// Ask the player to try again
break;
}
了解我们学到的关于switch
的一切最好的方法是将它与我们正在学习的所有其他新概念一起应用。
类枚举
枚举是逻辑集合中所有可能值的列表。C++枚举是列举事物的好方法。例如,如果我们的游戏使用的变量只能在特定范围的值中,而且这些值在逻辑上可以形成一个集合或一组,那么枚举可能是合适的。它们将使您的代码更清晰,更不容易出错。
在 C++中声明类枚举,我们使用两个关键字enum
和class
,然后是枚举的名称,然后是枚举可以包含的值,用一对花括号{...}
括起来。
例如,检查这个枚举声明。请注意,按照惯例,将枚举的可能值全部大写声明是常见的。
enum class zombieTypes {REGULAR, RUNNER, CRAWLER, SPITTER, BLOATER };
注意,此时我们还没有声明任何zombieType
的实例,只是类型本身。如果这听起来有点奇怪,可以这样想:SFML 创建了Sprite
、RectangleShape
和RenderWindow
类,但要使用这些类中的任何一个,我们必须声明一个对象/实例。
此时我们已经创建了一个名为zombieTypes
的新类型,但我们还没有它的实例。所以现在让我们创建它们:
zombieType dave = zombieTypes::CRAWLER;
zombieType angela = zombieTypes::SPITTER
zombieType jose = zombieTypes::BLOATER
/*
Zombies are fictional creatures and any resemblance
to real people is entirely coincidental
*/
接下来是对我们即将添加到 Timber!!!中的代码类型的 sneak preview。我们将想要跟踪树的哪一侧有分支或玩家,因此我们将声明一个名为side
的枚举,如以下示例所示:
enum class side { LEFT, RIGHT, NONE };
我们可以将玩家定位在左侧,如下所示:
// The player starts on the left
side playerSide = side::LEFT;
我们可以使分支位置数组的第四级(数组从零开始)根本没有分支,如下所示:
branchPositions[3] = side::NONE;
我们也可以在表达式中使用枚举:
if(branchPositions[5] == playerSide)
{
// The lowest branch is the same side as the player
// SQUISHED!!
}
我们将再看一个重要的 C++主题,然后我们将回到编写游戏的代码。
开始使用函数
那么 C++函数到底是什么?函数是一组变量、表达式和控制流语句(循环和分支)。事实上,我们迄今为止在书中学到的任何代码都可以在函数中使用。我们编写的函数的第一部分称为签名。以下是一个示例函数签名:
public void bombPlayer(int power, int direction)
如果我们添加一对大括号{...}
,里面包含一些函数实际执行的代码,那么我们就有了一个完整的函数,一个定义:
void shootLazers(int power, int direction)
{
// ZAPP!
}
然后我们可以在代码的其他部分使用我们的新函数,如下所示:
// Attack the player
bombPlayer(50, 180) // Run the code in the function
// I'm back again - code continues here after the function ends
当我们使用一个函数时,我们说我们调用它。在我们调用bombPlayer
的地方,我们的程序的执行分支到该函数中包含的代码。函数将运行直到达到结尾或被告知return
。然后代码将从函数调用后的第一行继续运行。我们已经在使用 SFML 提供的函数。这里不同的是,我们将学习编写和调用我们自己的函数。
这是另一个函数的例子,包括使函数返回到调用它的代码的代码:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
调用上述函数的方式可能如下所示:
int myAnswer = addAToB(2, 4);
显然,我们不需要编写函数来将两个变量相加,但这个例子帮助我们更深入地了解函数的工作原理。首先我们传入值2
和4
。在函数签名中,值2
被赋给int a
,值4
被赋给int b
。
在函数体内,变量a
和b
相加并用于初始化新变量int answer
。行return answer;
就是这样。它将存储在answer
中的值返回给调用代码,导致myAnswer
被初始化为值6
。
请注意,上面示例中的每个函数签名都有所不同。之所以如此,是因为 C++函数签名非常灵活,允许我们构建我们需要的函数。
函数签名的确切方式定义了函数必须如何被调用以及函数必须如何返回值,这值得进一步讨论。让我们给该签名的每个部分命名,这样我们就可以将其分解成部分并学习它们。
以下是一个函数签名,其各部分由其正式的技术术语描述:
return type | name of function | (parameters)
以下是我们可以用于每个部分的一些示例:
-
返回类型:
bool
、float
、int
等,或任何 C++类型或表达式 -
函数名称:
bombPlayer
,shootLazers
,setCoordinates
,addAToB
等等 -
参数:
(int number, bool hitDetected)
,(int x, int y)
(float a, float b)
现在让我们依次看看每个部分。
函数返回类型
返回类型,顾名思义,是从函数返回到调用代码的值的类型:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
在我们稍微沉闷但有用的addAtoB
示例中,签名中的返回类型是int
。函数addAToB
将一个值返回给调用它的代码,这个值将适合在一个int
变量中。返回类型可以是我们到目前为止看到的任何 C++类型,或者是我们还没有看到的类型之一。
然而,函数不一定要返回一个值。在这种情况下,签名必须使用void
关键字作为返回类型。当使用void
关键字时,函数体不得尝试返回一个值,否则将导致错误。但是,它可以使用没有值的return
关键字。以下是一些返回类型和return
关键字的组合:
void doWhatever()
{
// our code
// I'm done going back to calling code here
// no return is necessary
}
另一个可能性如下:
void doSomethigCool()
{
// our code
// I can do this as long as I don't try and add a value
return;
}
以下代码给出了更多可能的函数示例。一定要阅读注释以及代码:
void doYetAnotherThing()
{
// some code
if(someCondition)
{
// if someCondition is true returning to calling code
// before the end of the function body
return;
}
// More code that might or might not get executed
return;
// As I'm at the bottom of the function body
// and the return type is void, I'm
// really not necessary but I suppose I make it
// clear that the function is over.
}
bool detectCollision(Ship a, Ship b)
{
// Detect if collision has occurred
if(collision)
{
// Bam!!!
return true;
}
else
{
// Missed
return false;
}
}
上面的最后一个函数示例detectCollision
是我们 C++代码即将到来的一个预览,并且演示了我们也可以将用户定义的类型,称为对象,传递到函数中对它们进行计算。
我们可以像这样依次调用上面的每个函数:
// OK time to call some functions
doWhatever();
doSomethingCool();
doYetAnotherThing();
if (detectCollision(milleniumFalcon, lukesXWing))
{
// The jedi are doomed!
// But there is always Leia.
// Unless she was on the Falcon?
}
else
{
// Live to fight another day
}
//continue with code from here
不要担心关于detectCollision
函数的奇怪语法,我们很快就会看到像这样的真实代码。简单地说,我们将使用返回值(true
或false
)作为表达式,直接在if
语句中。
函数名称
函数名称,当我们设计自己的函数时,可以是几乎任何东西。但最好使用单词,通常是动词,来清楚地解释函数将要做什么。例如,看看这个函数:
void functionaroonieboonie(int blibbityblob, float floppyfloatything)
{
//code here
}
上面的示例是完全合法的,并且可以工作,但是下面的函数名称更加清晰:
void doSomeVerySpecificTask()
{
//code here
}
void getMySpaceShipHealth()
{
//code here
}
void startNewGame()
{
//code here
}
接下来,让我们更仔细地看一下如何与函数共享一些值。
函数参数
我们知道函数可以将结果返回给调用代码。如果我们需要与函数共享一些来自调用代码的数据值呢?参数允许我们与函数共享值。实际上,我们在查看返回类型时已经看到了参数的示例。我们将更仔细地看一下相同的示例:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
在上面的示例中,参数是int a
和int b
。请注意,在函数主体的第一行中,我们使用a + b
,就好像它们已经声明和初始化了变量一样。那是因为它们确实是。函数签名中的参数是它们的声明,调用函数的代码初始化它们。
提示
重要的行话说明
请注意,我们在函数签名括号(int a, int b)
中引用的变量被称为参数。当我们从调用代码中将值传递到函数中时,这些值被称为参数。当参数到达时,它们被称为参数,并用于初始化真正可用的变量:int returnedAnswer = addAToB(10,5);
此外,正如我们在先前的示例中部分看到的,我们不必只在参数中使用int
。我们可以使用任何 C++类型。我们还可以使用尽可能少的参数列表来解决我们的问题,但是将参数列表保持短并且易于管理是一个很好的做法。
正如我们将在未来的章节中看到的,我们已经在这个入门教程中留下了一些更酷的函数用法,这样我们就可以在进一步学习函数主题之前学习相关的 C++概念。
函数主体
主体部分是我们一直在避免的部分,比如:
// code here
// some code
但实际上,我们已经完全知道在这里该做什么!到目前为止,我们学到的任何 C++代码都可以在函数体中工作。
函数原型
我们已经看到了如何编写函数,也看到了如何调用函数。然而,我们还需要做一件事才能使它们工作。所有函数都必须有一个原型。原型是使编译器意识到我们的函数的东西;没有原型,整个游戏将无法编译。幸运的是,原型很简单。
我们可以简单地重复函数的签名,后面跟一个分号。但是要注意的是,原型必须出现在任何尝试调用或定义函数之前。因此,一个完全可用的函数的最简单示例如下。仔细看看注释以及函数的不同部分在代码中的位置:
// The prototype
// Notice the semicolon
int addAToB(int a, int b);
int main()
{
// Call the function
// Store the result in answer
int answer = addAToB(2,2);
// Called before the definition
// but that's OK because of the prototype
// Exit main
return 0;
}// End of main
// The function definition
int addAToB(int a, int b)
{
return a + b;
}
前面的代码演示了以下内容:
-
原型在
main
函数之前 -
使用函数的调用,正如我们可能期望的那样,位于
main
函数内部 -
定义在
main
函数之后/外部
注意
请注意,当定义出现在函数使用之前时,我们可以省略函数原型直接进入定义。然而,随着我们的代码变得越来越长并且跨越多个文件,这几乎永远不会发生。我们将一直使用单独的原型和定义。
让我们看看如何保持我们的函数有组织性。
组织函数
值得指出的是,如果我们有多个函数,特别是如果它们相当长,我们的.cpp
文件很快就会变得难以控制。这违背了函数的意图。我们将在下一个项目中看到的解决方案是,我们可以将所有函数原型添加到我们自己的头文件(.hpp
或.h
)中。然后我们可以在另一个.cpp
文件中编写所有函数的代码,然后在我们的主.cpp
文件中简单地添加另一个#include...
指令。通过这种方式,我们可以使用任意数量的函数,而不需要将它们的任何代码(原型或定义)添加到我们的主代码文件中。
函数陷阱!
我们应该讨论的另一点是作用域。如果我们在函数中声明一个变量,无论是直接声明还是作为参数之一,那么该变量在函数外部是不可用/可见的。此外,函数外部声明的任何变量在函数内部也是看不到/使用不了的。
我们应该通过参数/参数和返回值在函数代码和调用代码之间共享值。
当一个变量不可用,因为它来自另一个函数,就说它是不在作用域内。当它可用和可用时,就说它在作用域内。
注意
实际上,在 C++中,只有在块内声明的变量才在该块内有效!这包括循环和if
块。在main
的顶部声明的变量在main
中的任何地方都是有效的。在游戏循环中声明的变量只在游戏循环内有效,依此类推。在函数或其他块中声明的变量称为局部变量。我们写的代码越多,这一点就越有意义。每当我们在代码中遇到作用域问题时,我都会讨论一下,以澄清事情。在下一节中将会出现这样的问题。还有一些 C++的基本知识,会让这个问题变得更加明显。它们被称为引用和指针,我们将在第七章中学习:C++ 引用、精灵表和顶点数组和第八章中学习:指针、标准模板库和纹理管理。
函数的最终话-暂时
关于函数,我们还有很多东西可以学习,但我们已经了解足够的知识来实现游戏的下一部分。如果所有技术术语,如参数、签名和定义等等,还没有完全理解,不要担心。当我们开始使用它们时,概念会变得更清晰。
函数的终极最后一句话-暂时
你可能已经注意到,我们一直在调用函数,特别是 SFML 函数,通过在函数名之前附加对象的名称和一个句号,如下例所示:
spriteBee.setPosition...
window.draw...
// etc
然而,我们对函数的整个讨论都是在没有任何对象的情况下调用函数。我们可以将函数编写为类的一部分,也可以将其编写为独立的函数。当我们将函数编写为类的一部分时,我们需要该类的对象来调用函数,而当我们有一个独立的函数时,我们不需要。
我们将在一分钟内编写一个独立的函数,并且我们将在第六章中编写以函数开头的类:面向对象编程、类和 SFML 视图。到目前为止,我们对函数的所有了解在这两种情况下都是相关的。
生长树枝
接下来,正如我在过去大约十七页中一直承诺的那样,我们将使用所有新的 C++技术来绘制和移动树上的一些树枝。
将此代码添加到main
函数之外。为了绝对清楚,我的意思是在代码int main()
之前:
#include "stdafx.h"
#include <sstream>
#include <SFML/Graphics.hpp>
using namespace sf;
// Function declaration
void updateBranches(int seed);
const int NUM_BRANCHES = 6;
Sprite branches[NUM_BRANCHES];
// Where is the player/branch?
// Left or Right
enum class side { LEFT, RIGHT, NONE };
side branchPositions[NUM_BRANCHES];
int main()
{
我们刚刚用新代码实现了很多事情:
-
首先,我们为一个名为
updateBranches
的函数声明了一个函数原型。我们可以看到它不返回值(void
),并且它接受一个名为seed
的int
参数。我们将很快编写函数定义,然后我们将看到它确切地做了什么。 -
接下来,我们声明了一个名为
NUM_BRANCHES
的常量int
,并将其初始化为6
。树上将有六个移动的树枝,很快我们将看到NUM_BRANCHES
对我们有多有用。 -
接下来,我们声明了一个名为
branches
的Sprite
对象数组,可以容纳六个精灵。 -
之后,我们声明了一个名为
side
的新枚举,有三个可能的值,LEFT
、RIGHT
和NONE
。这将用于描述个别树枝的位置,以及在我们的代码中的一些地方描述玩家的位置。 -
最后,在之前的新代码中,我们初始化了一个
side
类型的数组,大小为NUM_BRANCHES
(6)。为了清楚地说明这实现了什么;我们将有一个名为branchPositions
的数组,其中包含六个值。这些值中的每一个都是side
类型,可以是LEFT
、RIGHT
或NONE
。
注意
当然,你真正想知道的是为什么常量、两个数组和枚举被声明在main
函数之外。通过在main
之上声明它们,它们现在具有全局范围。或者,换句话说,常量、两个数组和枚举在整个游戏中都有范围。这意味着我们可以在main
函数和updateBranches
函数中的任何地方访问和使用它们。请注意,将所有变量尽可能地局部化到实际使用它们的地方是一个好的做法。将所有东西都变成全局变量可能看起来很有用,但这会导致难以阅读和容易出错的代码。
准备树枝
现在我们将准备好我们的六个Sprite
对象,并将它们加载到branches
数组中。在我们的游戏循环之前添加以下突出显示的代码:
// Position the text
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
scoreText.setPosition(20, 20);
// Prepare 6 branches
Texture textureBranch;
textureBranch.loadFromFile("graphics/branch.png");
// Set the texture for each branch sprite
for (int i = 0; i < NUM_BRANCHES; i++)
{
branches[i].setTexture(textureBranch);
branches[i].setPosition(-2000, -2000);
// Set the sprite's origin to dead center
// We can then spin it round without changing its position
branches[i].setOrigin(220, 20);
}
while (window.isOpen())
{
之前的代码没有使用任何新概念。首先,我们声明了一个 SFML Texture
对象,并将branch.png
图形加载到其中。
接下来,我们创建一个for
循环,将i
设置为零,并在每次循环通过时递增i
,直到i
不再小于NUM_BRANCHES
。这是完全正确的,因为NUM_BRANCHES
是 6,而branches
数组的位置是 0 到 5。
在for
循环中,我们使用setTexture
为branches
数组中的每个Sprite
设置Texture
,然后用setPosition
将其隐藏在屏幕外。
最后,我们使用setOrigin
将原点(绘制时所在的点)设置为精灵的中心。很快,我们将旋转这些精灵,并且将原点设置在中心意味着它们将很好地围绕旋转,而不会使精灵移出位置。
每帧更新树枝精灵
在下面的代码中,我们根据branchPositions
数组中的位置和相应的branchPositions
数组中的side
的值,设置branches
数组中所有精灵的位置。添加高亮代码并尝试理解它,然后我们可以详细讨论一下:
// Update the score text
std::stringstream ss;
ss << "Score: " << score;
scoreText.setString(ss.str());
// update the branch sprites
for (int i = 0; i < NUM_BRANCHES; i++)
{
float height = i * 150;
if (branchPositions[i] == side::LEFT)
{
// Move the sprite to the left side
branches[i].setPosition(610, height);
// Flip the sprite round the other way
branches[i].setRotation(180);
}
else if (branchPositions[i] == side::RIGHT)
{
// Move the sprite to the right side
branches[i].setPosition(1330, height);
// Set the sprite rotation to normal
branches[i].setRotation(0);
}
else
{
// Hide the branch
branches[i].setPosition(3000, height);
}
}
} // End if(!paused)
/*
****************************************
Draw the scene
****************************************
我们刚刚添加的代码是一个大的for
循环,将i
设置为零,每次通过循环递增i
,并持续进行,直到i
不再小于 6。
在for
循环内,设置了一个名为height
的新的float
变量,其值为i * 150
。这意味着第一个树枝的高度为 0,第二个为 150,第六个为 750。
接下来是一系列if
和else
块的结构。看一下剥离了代码的结构:
if()
{
}
else if()
{
}
else
{
}
第一个if
使用branchPositions
数组来查看当前树枝是否应该在左边。如果是的话,它会将branches
数组中的相应Sprite
设置为屏幕上适合左边(610 像素)和当前height
的位置。然后它将精灵翻转180
度,因为branch.png
图形默认向右悬挂。
else if
只有在树枝不在左边时才执行。它使用相同的方法来查看它是否在右边。如果是的话,树枝就会被绘制在右边(1330 像素)。然后将精灵旋转为 0 度,以防它之前是 180 度。如果 x 坐标看起来有点奇怪,只需记住我们将树枝精灵的原点设置为它们的中心。
最后的else
假设,正确地,当前的branchPosition
必须是NONE
,并将树枝隐藏在屏幕外的3000
像素处。
此时,我们的树枝已经就位,准备绘制。
绘制树枝
在这里,我们使用另一个for
循环,从 0 到 5 遍历整个branches
数组,并绘制每个树枝精灵。添加以下高亮代码:
// Draw the clouds
window.draw(spriteCloud1);
window.draw(spriteCloud2);
window.draw(spriteCloud3);
// Draw the branches
for (int i = 0; i < NUM_BRANCHES; i++)
{
window.draw(branches[i]);
}
// Draw the tree
window.draw(spriteTree);
当然,我们还没有编写实际移动所有树枝的函数。一旦我们编写了该函数,我们还需要解决何时以及如何调用它的问题。让我们解决第一个问题并编写该函数。
移动树枝
我们已经在main
函数上面添加了函数原型。现在我们编写实际的函数定义,该函数将在每次调用时将所有树枝向下移动一个位置。我们将这个函数分为两部分编写,以便更容易地检查发生了什么。
在main
函数的右花括号后添加updateBranches
函数的第一部分:
// Function definition
void updateBranches(int seed)
{
// Move all the branches down one place
for (int j = NUM_BRANCHES-1; j > 0; j--)
{
branchPositions[j] = branchPositions[j - 1];
}
}
在函数的第一部分中,我们只是将所有的树枝向下移动一个位置,一次一个,从第六个树枝开始。这是通过使for
循环从 5 计数到 0 来实现的。代码branchPositions[j] = branchPositions[j - 1];
实现了实际的移动。
在前面的代码中,另一件需要注意的事情是,当我们将位置 4 的树枝移动到位置 5,然后将位置 3 的树枝移动到位置 4,依此类推,我们需要在位置 0 添加一个新的树枝,这是树的顶部。
现在我们可以在树的顶部生成一个新的树枝。添加高亮代码,然后我们将讨论它:
// Function definition
void updateBranches(int seed)
{
// Move all the branches down one place
for (int j = NUM_BRANCHES-1; j > 0; j--)
{
branchPositions[j] = branchPositions[j - 1];
}
// Spawn a new branch at position 0
// LEFT, RIGHT or NONE
srand((int)time(0)+seed);
int r = (rand() % 5);
switch (r)
{
case 0:
branchPositions[0] = side::LEFT;
break;
case 1:
branchPositions[0] = side::RIGHT;
break;
default:
branchPositions[0] = side::NONE;
break;
}
}
在updateBranches
函数的最后部分,我们使用传入函数调用的整数seed
变量。我们这样做是为了确保随机数seed
始终不同,并且我们将在下一章中看到这个值是如何得到的。
接下来,我们生成一个介于零和四之间的随机数,并将结果存储在int
变量r
中。现在我们使用r
作为表达式进行switch
。
case
语句意味着,如果r
等于零,那么我们在树的顶部左侧添加一个新的分支。如果r
等于 1,那么分支就在右侧。如果r
是其他任何值(2、3 或 4),那么default
确保在顶部不会添加任何分支。左、右和无的平衡使得树看起来很真实,游戏运行得相当不错。你可以很容易地改变代码,使分支更频繁或更少。
即使为我们的分支编写了所有这些代码,我们仍然无法在游戏中看到任何一个分支。这是因为在我们实际调用updateBranches
之前,我们还有更多的工作要做。
如果你现在真的想看到一个分支,你可以添加一些临时代码,并在游戏循环之前调用该函数五次,每次使用一个独特的种子:
updateBranches(1);
updateBranches(2);
updateBranches(3);
updateBranches(4);
updateBranches(5);
while (window.isOpen())
{
现在你可以看到分支在它们的位置上。但是如果分支实际上要移动,我们需要定期调用updateBranches
。
提示
在继续之前不要忘记删除临时代码。
现在我们可以把注意力转向玩家,并真正调用updateBranches
函数。
FAQ
Q) 你提到了几种类型的 C++循环。
A) 是的,看一下这个do...while
循环的教程和解释:
www.tutorialspoint.com/cplusplus/cpp_do_while_loop.htm
Q) 我可以假设我是数组的专家吗?
A) 就像本书中的许多主题一样,总是有更多的东西可以学习。你已经了解足够的关于数组的知识来继续,但如果你还想了解更多,请查看这个更详细的数组教程:www.cplusplus.com/doc/tutorial/arrays/
。
Q) 我可以假设我是函数的专家吗?
A) 就像本书中的许多主题一样,总是有更多的东西可以学习。你已经了解足够的关于函数的知识来继续,但如果想了解更多,请查看这个教程:www.cplusplus.com/doc/tutorial/functions/
。
总结
虽然这不是最长的一章,但可能是我们涵盖最多 C++知识的一章。我们研究了不同类型的循环,比如for
和while
循环。我们学习了处理大量变量和对象的数组,而不费吹灰之力。我们还学习了枚举和switch
。也许这一章最重要的概念是允许我们组织和抽象游戏代码的函数。随着书的继续,我们将在更多地方深入研究函数。
现在我们有一个完全可用的树,我们可以在这个项目的最后一章中完成游戏。
第五章:碰撞、声音和结束条件-使游戏可玩
这是第一个项目的最后阶段。在本章结束时,您将拥有您的第一个完成的游戏。一旦您运行了 Timber!!!,一定要阅读本章的最后一节,因为它将提出改进游戏的建议。我们将讨论以下主题:
-
添加其余的精灵
-
处理玩家输入
-
动画飞行原木
-
处理死亡
-
添加音效
-
添加功能并改进 Timber!!!
准备玩家(和其他精灵)
让我们同时为玩家的精灵添加代码,以及一些更多的精灵和纹理。这下面的相当大的代码块还为玩家被压扁时添加了一个墓碑精灵,一个用来砍伐的斧头精灵,以及一个可以在玩家砍伐时飞走的原木精灵。
请注意,在spritePlayer
对象之后,我们还声明了一个side
变量playerSide
,以跟踪玩家当前站立的位置。此外,我们为spriteLog
对象添加了一些额外的变量,包括logSpeedX
、logSpeedY
和logActive
,用于存储原木的移动速度以及它当前是否在移动。spriteAxe
还有两个相关的float
常量变量,用于记住左右两侧的理想像素位置。
像以前那样,在while(window.isOpen())
代码之前添加下一个代码块。请注意,下一个清单中的所有代码都是新的,而不仅仅是突出显示的代码。我没有为下一个代码块提供任何额外的上下文,因为while(window.isOpen())
应该很容易识别。突出显示的代码是我们刚刚讨论过的代码。
在while(window.isOpen())
行之前添加整个代码,并在脑海中记住我们简要讨论过的突出显示的行。这将使本章其余的代码更容易理解:
// Prepare the player
Texture texturePlayer;
texturePlayer.loadFromFile("graphics/player.png");
Sprite spritePlayer;
spritePlayer.setTexture(texturePlayer);
spritePlayer.setPosition(580, 720);
// The player starts on the left
side playerSide = side::LEFT;
// Prepare the gravestone
Texture textureRIP;
textureRIP.loadFromFile("graphics/rip.png");
Sprite spriteRIP;
spriteRIP.setTexture(textureRIP);
spriteRIP.setPosition(600, 860);
// Prepare the axe
Texture textureAxe;
textureAxe.loadFromFile("graphics/axe.png");
Sprite spriteAxe;
spriteAxe.setTexture(textureAxe);
spriteAxe.setPosition(700, 830);
// Line the axe up with the tree
const float AXE_POSITION_LEFT = 700;
const float AXE_POSITION_RIGHT = 1075;
// Prepare the flying log
Texture textureLog;
textureLog.loadFromFile("graphics/log.png");
Sprite spriteLog;
spriteLog.setTexture(textureLog);
spriteLog.setPosition(810, 720);
// Some other useful log related variables
bool logActive = false;
float logSpeedX = 1000;
float logSpeedY = -1500;
现在我们可以绘制所有新的精灵。
绘制玩家和其他精灵
在我们添加移动玩家和使用所有新精灵的代码之前,让我们先绘制它们。这样,当我们添加代码来更新/改变/移动精灵时,我们将能够看到发生了什么。
添加突出显示的代码以绘制四个新的精灵:
// Draw the tree
window.draw(spriteTree);
// Draw the player
window.draw(spritePlayer);
// Draw the axe
window.draw(spriteAxe);
// Draraw the flying log
window.draw(spriteLog);
// Draw the gravestone
window.draw(spriteRIP);
// Draw the bee
window.draw(spriteBee);
运行游戏,你会看到我们在场景中的新精灵。
我们现在离一个可运行的游戏非常接近了。
处理玩家的输入
许多不同的事情取决于玩家的移动,比如何时显示斧头,何时开始动画原木,以及何时将所有的树枝移动到一个地方。因此,为玩家砍伐设置键盘处理是有意义的。一旦完成这一点,我们就可以将刚才提到的所有功能放入代码的同一部分。
让我们思考一下我们如何检测键盘按键。在每一帧中,我们测试特定的键盘键当前是否被按下。如果是,我们就采取行动。如果按下Esc键,我们退出游戏,或者如果按下Enter键,我们重新开始游戏。到目前为止,这对我们的需求已经足够了。
然而,当我们尝试处理砍树时,这种方法存在问题。这个问题一直存在,只是直到现在才变得重要。根据您的 PC 有多强大,游戏循环可能每秒执行数千次。在游戏循环中每次按下键时,都会检测到并执行相关代码。
实际上,每次按下Enter重新开始游戏时,您很可能会重新开始游戏超过一百次。这是因为即使是最短暂的按键按下也会持续相当长的时间。您可以通过运行游戏并按住Enter键来验证这一点。请注意,时间条不会移动。这是因为游戏一遍又一遍地重新启动,每秒甚至数千次。
如果我们不对玩家的砍伐采取不同的方法,那么只需一次尝试的砍伐就会在短短的时间内将整棵树砍倒。我们需要更加复杂一些。我们将允许玩家进行砍伐,然后在他这样做时禁用检测按键的代码。然后我们将检测玩家何时从按键上移开手指,然后重新启用按键检测。以下是清晰列出的步骤:
-
等待玩家使用左右箭头键砍伐木头。
-
当玩家砍伐时,禁用按键检测。
-
等待玩家从按键上移开手指。
-
重新启用砍伐检测。
-
从步骤 1 重复。
这可能听起来很复杂,但借助 SFML 的帮助,这将非常简单。让我们现在一步一步地实现这个。
添加代码中的突出显示行,声明一个bool
变量和acceptInput
,用于确定何时监听砍伐动作和何时忽略它们:
float logSpeedX = 1000;
float logSpeedY = -1500;
// Control the player input
bool acceptInput = false;
while (window.isOpen())
{
现在我们已经设置好了布尔值,可以继续下一步了。
处理设置新游戏
现在我们准备处理砍伐,将突出显示的代码添加到开始新游戏的if
块中:
/*
****************************************
Handle the players input
****************************************
*/
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Start the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
// Reset the time and the score
score = 0;
timeRemaining = 6;
// Make all the branches disappear
for (int i = 1; i < NUM_BRANCHES; i++)
{
branchPositions[i] = side::NONE;
}
// Make sure the gravestone is hidden
spriteRIP.setPosition(675, 2000);
// Move the player into position
spritePlayer.setPosition(580, 720);
acceptInput = true;
}
/*
****************************************
Update the scene
****************************************
*/
在之前的代码中,我们使用for
循环将树设置为没有分支。这对玩家是公平的,因为如果游戏从他的头顶上方开始,那将被认为是不公平的。然后我们简单地将墓碑移出屏幕,玩家移动到左侧的起始位置。这个新代码的最后一件事是将acceptInput
设置为true
。我们现在准备好接收砍伐按键了。
检测玩家的砍伐
现在我们可以准备处理左右方向键的按下。添加这个简单的if
块,只有当acceptInput
为true
时才执行:
// Start the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
// Reset the time and the score
score = 0;
timeRemaining = 5;
// Make all the branches disappear
for (int i = 1; i < NUM_BRANCHES; i++)
{
branchPositions[i] = side::NONE;
}
// Make sure the gravestone is hidden
spriteRIP.setPosition(675, 2000);
// Move the player into position
spritePlayer.setPosition(675, 660);
acceptInput = true;
}
// Wrap the player controls to
// Make sure we are accepting input
if (acceptInput)
{
// More code here next...
}
/*
****************************************
Update the scene
****************************************
*/
现在,在我们刚刚编写的if
块中,添加突出显示的代码来处理玩家在键盘上按下右箭头键(→)时发生的情况:
// Wrap the player controls to
// Make sure we are accepting input
if (acceptInput)
{
// More code here next...
// First handle pressing the right cursor key
if (Keyboard::isKeyPressed(Keyboard::Right))
{
// Make sure the player is on the right
playerSide = side::RIGHT;
score ++;
// Add to the amount of time remaining
timeRemaining += (2 / score) + .15;
spriteAxe.setPosition(AXE_POSITION_RIGHT,
spriteAxe.getPosition().y);
spritePlayer.setPosition(1200, 720);
// update the branches
updateBranches(score);
// set the log flying to the left
spriteLog.setPosition(810, 720);
logSpeedX = -5000;
logActive = true;
acceptInput = false;
}
// Handle the left cursor key
}
在上面的代码中发生了很多事情,让我们逐步进行。首先,我们检测玩家是否在树的右侧砍伐。如果是,我们将playerSide
设置为side::RIGHT
。我们将在代码的后面对playerSide
的值做出响应。
然后我们用score ++
将分数加 1。下一行代码有点神秘,但实际上我们只是增加了剩余时间的数量。我们正在奖励玩家采取行动。然而,对于玩家来说,问题在于分数越高,增加的时间就越少。您可以通过调整这个公式来使游戏变得更容易或更难。
然后,斧头移动到右侧位置,使用spriteAxe.setPosition
,玩家精灵也移动到右侧位置。
接下来,我们调用updateBranches
将所有的分支向下移动一个位置,并在树的顶部生成一个新的随机分支(或空格)。
然后,spriteLog
移动到起始位置,伪装成树,它的speedX
变量设置为负数,这样它就会向左飞去。此外,logActive
设置为true
,这样我们即将编写的移动木头的代码就会在每一帧中使木头动起来。
最后,acceptInput
被设置为false
。此时,玩家无法再进行砍伐。我们已经解决了按键被频繁检测的问题,很快我们将看到如何重新启用砍伐。
现在,在我们刚刚编写的if(acceptInput)
块内,添加突出显示的代码来处理玩家在键盘上按下左箭头键(←)时发生的情况:
// Handle the left cursor key
if (Keyboard::isKeyPressed(Keyboard::Left))
{
// Make sure the player is on the left
playerSide = side::LEFT;
score++;
// Add to the amount of time remaining
timeRemaining += (2 / score) + .15;
spriteAxe.setPosition(AXE_POSITION_LEFT,
spriteAxe.getPosition().y);
spritePlayer.setPosition(580, 720);
// update the branches
updateBranches(score);
// set the log flying
spriteLog.setPosition(810, 720);
logSpeedX = 5000;
logActive = true;
acceptInput = false;
}
}
前面的代码与处理右侧砍伐的代码完全相同,只是精灵的位置不同,并且logSpeedX
变量设置为正值,使得木头向右飞去。
检测按键释放
为了使上述代码在第一次砍伐之后继续工作,我们需要检测玩家何时释放键,并将acceptInput
设置回true
。
这与我们迄今为止看到的按键处理略有不同。SFML 有两种不同的方式来检测玩家的键盘输入。我们已经看到了第一种方式。它是动态和瞬时的,正是我们需要立即对按键做出响应的。
下面的代码使用了另一种方法。输入下一个突出显示的代码到处理玩家输入
部分的顶部,然后我们将逐步讲解它:
/*
****************************************
Handle the players input
****************************************
*/
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyReleased && !paused)
{
// Listen for key presses again
acceptInput = true;
// hide the axe
spriteAxe.setPosition(2000,
spriteAxe.getPosition().y);
}
}
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
首先,我们声明了一个名为event
的Event
类型的对象。然后我们调用window.pollEvent
函数,传入我们的新对象event
。pollEvent
函数将数据放入event
对象中,描述了操作系统事件。这可能是按键、释放键、鼠标移动、鼠标点击、游戏控制器动作或发生在窗口本身的事件(例如调整大小等)。
我们将代码包装在while
循环中的原因是因为队列中可能存储了许多事件。window.pollEvent
函数将这些事件一个接一个地加载到event
中。我们将在循环中的每次通过中看到当前事件,如果我们感兴趣,就会做出响应。当window.pollEvent
返回false
时,这意味着队列中没有更多事件,while
循环将退出。
当释放一个键并且游戏没有暂停时,这个if
条件(event.type == Event::KeyReleased && !paused
)为true
。
在if
块中,我们将acceptInput
设置回true
,并将斧头精灵隐藏在屏幕外。
现在您可以运行游戏,惊叹于移动的树木、摆动的斧头和动画的玩家。然而,它不会压扁玩家,砍伐时木头也需要移动。
动画砍伐的木头和斧头
当玩家砍木头时,logActive
被设置为true
,因此我们可以将一些代码包装在一个块中,只有当logActive
为true
时才执行。此外,每次砍木头都会将logSpeedX
设置为正数或负数,因此木头准备好朝着正确的方向飞离树。
在我们更新分支精灵之后,添加下面突出显示的代码:
// update the branch sprites
for (int i = 0; i < NUM_BRANCHES; i++)
{
float height = i * 150;
if (branchPositions[i] == side::LEFT)
{
// Move the sprite to the left side
branches[i].setPosition(610, height);
// Flip the sprite round the other way
branches[i].setRotation(180);
}
else if (branchPositions[i] == side::RIGHT)
{
// Move the sprite to the right side
branches[i].setPosition(1330, height);
// Flip the sprite round the other way
branches[i].setRotation(0);
}
else
{
// Hide the branch
branches[i].setPosition(3000, height);
}
}
// Handle a flying log
if (logActive)
{
spriteLog.setPosition(
spriteLog.getPosition().x +
(logSpeedX * dt.asSeconds()),
spriteLog.getPosition().y +
(logSpeedY * dt.asSeconds()));
// Has the log reached the right hand edge?
if (spriteLog.getPosition().x < -100 ||
spriteLog.getPosition().x > 2000)
{
// Set it up ready to be a whole new log next frame
logActive = false;
spriteLog.setPosition(810, 720);
}
}
} // End if(!paused)
/*
****************************************
Draw the scene
****************************************
*/
代码通过使用getPosition
获取精灵的当前 x 和 y 位置,然后分别使用logSpeedX
和logSpeedY
乘以dt.asSeconds
加到其上,来设置精灵的位置。
在每一帧中移动木头精灵后,代码使用if
块来查看精灵是否已经从左侧或右侧消失在视野中。如果是,木头就会移回到起点,准备下一次砍伐。
如果您运行游戏,您将能够看到木头飞向屏幕的适当一侧。
现在是一个更敏感的话题。
处理死亡
每个游戏都必须以不好的方式结束,要么是玩家时间用完(这已经处理过了),要么是被分支压扁。
检测玩家被压扁非常简单。我们只想知道branchPositions
数组中的最后一个分支是否等于playerSide
。如果是,玩家就死了。
添加检测这一点的突出代码,然后我们将讨论玩家被压扁时的所有操作:
// Handle a flying log
if (logActive)
{
spriteLog.setPosition(
spriteLog.getPosition().x + (logSpeedX * dt.asSeconds()),
spriteLog.getPosition().y + (logSpeedY * dt.asSeconds()));
// Has the log reached the right hand edge?
if (spriteLog.getPosition().x < -100 ||
spriteLog.getPosition().x > 2000)
{
// Set it up ready to be a whole new cloud next frame
logActive = false;
spriteLog.setPosition(800, 600);
}
}
// Has the player been squished by a branch?
if (branchPositions[5] == playerSide)
{
// death
paused = true;
acceptInput = false;
// Draw the gravestone
spriteRIP.setPosition(525, 760);
// hide the player
spritePlayer.setPosition(2000, 660);
// Change the text of the message
messageText.setString("SQUISHED!!");
// Center it on the screen
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top + textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f,
1080 / 2.0f);
}
} // End if(!paused)
/*
****************************************
Draw the scene
****************************************
*/
在玩家死亡后,代码的第一件事是将paused
设置为true
。现在循环将完成这一帧,并且在玩家开始新游戏之前不会再次运行循环的更新部分。
然后我们将墓碑移动到靠近玩家站立的位置,并将玩家精灵隐藏在屏幕外。
我们将messageText
的字符串设置为"SQUISHED !!"
,然后使用通常的技术将其居中显示在屏幕上。
现在您可以运行游戏并真正玩它。这张图片显示了玩家的最终得分和他的墓碑,以及SQUISHED消息。
还有一个问题。只是我吗,还是有点安静?
简单的声音效果
我们将添加三种声音。每种声音都将在特定的游戏事件上播放。每当玩家砍伐时播放简单的重击声音,当玩家时间用尽时播放沮丧的失败声音,当玩家被压扁致死时播放复古的压碎声音。
SFML 声音是如何工作的?
SFML 使用两种不同的类来播放声音效果。第一个类是SoundBuffer
类。这个类保存了来自声音文件的实际音频数据。它是SoundBuffer
负责将.wav
文件加载到 PC 的 RAM 中,以一种无需进一步解码工作即可播放的格式。
一会儿,当我们为声音效果编写代码时,我们将看到,一旦我们有了一个包含我们声音的SoundBuffer
对象,我们将创建另一个类型为Sound
的对象。然后,我们可以将这个Sound
对象与SoundBuffer
对象关联起来。然后,在我们的代码中适当的时刻,我们将能够调用适当Sound
对象的play
函数。
何时播放声音
很快我们将看到,加载和播放声音的 C++代码真的很简单。然而,我们需要考虑的是何时调用play
函数。我们的代码中何处将调用play
函数?以下是我们想要实现的一些功能:
-
砍伐声音可以从按下左右光标键时调用
-
死亡声音可以从检测到树木将玩家搅碎的
if
块中播放 -
时间用尽的声音可以从检测到
timeRemaining
小于零的if
块中播放
现在我们可以编写我们的声音代码。
添加声音代码
首先,我们添加另一个#include
指令,以使 SFML 与声音相关的类可用。添加下面突出显示的代码:
#include "stdafx.h"
#include <sstream>
#include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp>
using namespace sf;
现在我们声明三个不同的SoundBuffer
对象,将三个不同的声音文件加载到它们中,并将三个不同的Sound
对象与相关的SoundBuffer
对象关联起来。添加下面突出显示的代码:
// Control the player input
bool acceptInput = false;
// Prepare the sound
SoundBuffer chopBuffer;
chopBuffer.loadFromFile("sound/chop.wav");
Sound chop;
chop.setBuffer(chopBuffer);
SoundBuffer deathBuffer;
deathBuffer.loadFromFile("sound/death.wav");
Sound death;
death.setBuffer(deathBuffer);
// Out of time
SoundBuffer ootBuffer;
ootBuffer.loadFromFile("sound/out_of_time.wav");
Sound outOfTime;
outOfTime.setBuffer(ootBuffer);
while (window.isOpen())
{
现在我们可以播放我们的第一个声音效果。在检测到玩家按下左光标键的if
块旁边添加如下一行代码:
// Wrap the player controls to
// Make sure we are accepting input
if (acceptInput)
{
// More code here next...
// First handle pressing the right cursor key
if (Keyboard::isKeyPressed(Keyboard::Right))
{
// Make sure the player is on the right
playerSide = side::RIGHT;
score++;
timeRemaining += (2 / score) + .15;
spriteAxe.setPosition(AXE_POSITION_RIGHT,
spriteAxe.getPosition().y);
spritePlayer.setPosition(1120, 660);
// update the branches
updateBranches(score);
// set the log flying to the left
spriteLog.setPosition(800, 600);
logSpeedX = -5000;
logActive = true;
acceptInput = false;
// Play a chop sound
chop.play();
}
提示
在下一个以if (Keyboard::isKeyPressed(Keyboard::Left))
开头的代码块的末尾添加完全相同的代码,以使玩家在树的左侧砍伐时发出砍伐声音。
找到处理玩家时间用尽的代码,并添加下一个突出显示的代码,以播放与时间相关的音效:
if (timeRemaining <= 0.f) {
// Pause the game
paused = true;
// Change the message shown to the player
messageText.setString("Out of time!!");
//Reposition the text based on its new size
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
// Play the out of time sound
outOfTime.play();
}
最后,当玩家被压扁时播放死亡声音,将下面突出显示的代码添加到执行当底部树枝与玩家同侧时的if
块中:
// has the player been squished by a branch?
if (branchPositions[5] == playerSide)
{
// death
paused = true;
acceptInput = false;
// Draw the gravestone
spriteRIP.setPosition(675, 660);
// hide the player
spritePlayer.setPosition(2000, 660);
messageText.setString("SQUISHED!!");
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top + textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
// Play the death sound
death.play();
}
就是这样!我们已经完成了第一个游戏。在我们继续进行第二个项目之前,让我们讨论一些可能的增强功能。
改进游戏和代码
看看 Timber!!!项目的这些建议的增强功能。您可以在下载包的Runnable
文件夹中看到增强功能的效果:
-
加快代码速度:我们的代码中有一部分正在减慢我们的游戏。对于这个简单的游戏来说无所谓,但我们可以通过将
sstream
代码放在仅偶尔执行的块中来加快速度。毕竟,我们不需要每秒更新得分数百次! -
调试控制台:让我们添加一些文本,以便我们可以看到当前的帧速率。与得分一样,我们不需要经常更新这个。每一百帧更新一次就足够了。
-
在背景中添加更多的树:只需添加一些更多的树精灵并将它们绘制在看起来不错的位置(你可以在相机附近放一些,远一些)。
-
改善 HUD 文本的可见性:我们可以在分数和 FPS 计数器后面绘制简单的
RectangleShape
对象;黑色并带有一些透明度看起来会很好。 -
使云代码更有效率:正如我们已经提到过几次的,我们可以利用我们对数组的知识使云代码变得更短。
看看游戏中额外的树、云和文本的透明背景。
要查看这些增强的代码,请查看下载包中的“伐木工增强版”文件夹。
常见问题
Q)我承认,对于云的数组解决方案更有效率。但是我们真的需要三个单独的数组吗,一个用于活动,一个用于速度,一个用于精灵本身吗?
A)如果我们查看各种对象的属性/变量,例如Sprite
对象,我们会发现它们很多。精灵有位置、颜色、大小、旋转等等。但如果它们有active
、speed
,甚至更多的话就更完美了。问题在于 SFML 的程序员不可能预测我们将如何使用他们的Sprite
类。幸运的是,我们可以制作自己的类。我们可以制作一个名为Cloud
的类,其中有一个布尔值用于active
和一个整数用于速度。我们甚至可以给我们的Cloud
类一个 SFML 的Sprite
对象。然后我们甚至可以进一步简化我们的云代码。我们将在下一章中设计我们自己的类。
总结
在本章中,我们为《伐木工》游戏添加了最后的修饰和图形。如果在读这本书之前,你从未编写过一行 C++代码,那么你可以为自己鼓掌。在短短的五章中,你已经从零基础到一个可运行的游戏。
然而,我们不会为自己的成就而沾沾自喜太久,因为在下一章中,我们将直接转向一些稍微更复杂和更全面的 C++,这可以用来构建更复杂和更全面的游戏。
第六章:面向对象编程,类和 SFML 视图
这是本书最长的章节。有相当多的理论,但这些理论将使我们有能力开始有效地使用面向对象编程(OOP)。此外,我们不会浪费时间来将理论付诸实践。在探索 C++ OOP 之前,我们将了解并计划我们的下一个游戏项目。
以下是我们将在接下来的章节中要做的事情:
-
规划“僵尸竞技场”游戏
-
学习面向对象编程和类
-
编写
Player
类 -
了解 SFML 的
View
类 -
构建僵尸竞技场游戏引擎
-
让
Player
类开始工作
规划和开始僵尸竞技场游戏
此时,如果你还没有的话,我建议你去观看《超过 9000 只僵尸》(store.steampowered.com/app/273500/
)和《血色之地》(store.steampowered.com/app/262830/
)的视频。
我们的游戏显然不会像这两个示例那样深入或先进,但我们将拥有相同的基本功能和游戏机制:
-
显示一些细节的“HUD”,比如得分、最高分、弹夹中的子弹、剩余子弹、玩家生命和剩余待杀僵尸数
-
玩家将在疯狂逃离僵尸的同时射击它们
-
在使用鼠标瞄准枪支的同时,使用 W、A、S 和 D 键在滚动世界中移动
-
在每个级别之间,选择一个会影响游戏成功方式的“升级”。
-
收集“拾取物”以恢复生命和弹药
-
每一波都会带来更多的僵尸和更大的竞技场
将有三种类型的僵尸需要消灭。它们将具有不同的属性,如外观、生命和速度。我们将称它们为追逐者、膨胀者和爬行者。看一下游戏的注释截图,看看一些功能的运作以及组成游戏的组件和资源:
以下是关于每个编号点的更多信息:
-
得分和最高分。这些与 HUD 的其他部分一起将在一个称为“视图”的单独图层中绘制。最高分将被保存并加载到文件中。
-
这是一个将在竞技场周围建造墙壁的纹理。这个纹理包含在一个称为“精灵表”的单个图形中,还有其他背景纹理(3、5 和 6)。
-
精灵表中的两个泥浆纹理之一。
-
这是一个“弹药拾取物”。玩家获得这个拾取物后将获得更多弹药。还有一个“生命拾取物”。玩家可以选择在僵尸波之间升级这些拾取物。
-
精灵表中的草纹理。
-
精灵表中的第二个泥浆纹理。
-
曾经有僵尸的地方现在是一滩血迹。
-
HUD 的底部部分。从左到右依次是代表弹药的图标、弹夹中的子弹数量、备用子弹数量、生命条、当前僵尸波数以及本波剩余僵尸数量。
-
玩家角色。
-
玩家用鼠标瞄准的准星。
-
一个移动缓慢但力量强大的膨胀僵尸
-
一个移动速度稍快但较弱的爬行僵尸。还有一个追逐者僵尸,速度非常快但很弱。不幸的是,在它们被全部杀死之前,我没能在截图中找到一个。
我们有很多事情要做,还有新的 C++技能要学习。让我们从创建一个新项目开始。
从模板创建项目
现在创建一个新项目非常容易。只需在 Visual Studio 中按照这些简单的步骤进行:
-
从主菜单中选择“文件”|“新建项目”。
-
确保在左侧菜单中选择了“Visual C++”,然后从所呈现的选项列表中选择“HelloSFML”。下一张图片应该能清楚地说明这一点:
-
在名称:字段中,键入
ZombieArena
,并确保为解决方案创建目录选项已被选中。现在点击确定。 -
现在我们需要将 SFML 的
.dll
文件复制到主项目目录中。我的主项目目录是D:\Visual Studio Stuff\Projects\ZombieArena\ZombieArena
。这个文件夹是由 Visual Studio 在上一步中创建的。如果您将Projects
文件夹放在其他地方,请在那里执行此步骤。我们需要复制到Projects
文件夹中的文件位于您的SFML\bin
文件夹中。为每个位置打开一个窗口,并突出显示所需的.dll
文件。
现在将突出显示的文件复制并粘贴到项目中。项目现在已经设置好,准备好了。
项目资产
该项目中的资产比以前的游戏更多样化和丰富。资产包括:
-
屏幕上的字体
-
不同动作的音效,如射击、装弹或被僵尸击中。
游戏所需的角色、僵尸、背景和声音的所有图形都包含在下载包中。它们分别可以在第六章/图形
和第六章/声音
文件夹中找到。
所需的字体尚未提供。这是因为我想避免任何关于许可证的可能歧义。不过这不会造成问题,因为我将向您展示确切的位置和方式来选择和下载字体。
虽然我将提供资产本身或获取它们的信息,但您可能希望自己创建和获取它们。
探索资产
图形资产构成了我们的僵尸竞技场游戏场景的一部分。看一下图形资产,应该清楚我们的游戏中它们将被用在哪里:
然而,可能不太明显的是background_sheet.png
,其中包含四个不同的图像。这是我之前提到的精灵表,我们将看到如何使用它来节省内存并提高游戏速度,详见第七章,C++参考、精灵表和顶点数组。
声音文件都是.wav
格式。这些文件包含了我们在游戏中的某些事件中播放的音效。它们是:
-
hit.wav
:僵尸与玩家接触时播放的声音 -
pickup.wav
:玩家触摸(收集)健康提升(拾取)时播放的声音 -
powerup.wav
:玩家在每波僵尸之间选择增加属性(power-up)时播放的声音 -
reload.wav
:令玩家知道他们已装入新弹药的满意点击声 -
reload_failed.wav
:指示未能装入新子弹的不太令人满意的声音 -
shoot.wav
:射击声音 -
splat.wav
:像僵尸被子弹击中的声音
将资产添加到项目中
一旦您决定使用哪些资产,就该是将它们添加到项目中的时候了。下面的说明将假定您正在使用本书下载包中提供的所有资产。如果您使用自己的资产,只需用您自己的适当的声音或图形文件替换本书中使用的完全相同的文件名即可:
-
浏览到
D:\Visual Studio Stuff\Projects\ZombieArena\ZombieArena
。 -
在此文件夹中创建三个新文件夹,分别命名为
图形
、声音
和字体
。 -
从下载包中,将
第六章/图形
文件夹的全部内容复制到D:\Visual Studio Stuff\Projects\ZombieArena\ZombieArena\图形
文件夹中。 -
从下载包中,将
第六章/声音
文件夹的全部内容复制到D:\Visual Studio Stuff\Projects\ZombieArena\ZombieArena\声音
文件夹中。 -
现在在您的网络浏览器中访问
www.1001freefonts.com/zombie_control.font
,并下载Zombie Control字体。
提取压缩下载的内容,并将zombiecontrol.ttf
文件添加到D:\Visual Studio Stuff\Projects\ZombieArena\ZombieArena\fonts
文件夹。现在是时候学习更多的 C++了,这样我们就可以开始为 Zombie Arena 编写代码了。
OOP
OOP 是一种编程范式,我们可以认为它几乎是编码的标准方式。的确,有非 OOP 的编码方式,甚至有一些非 OOP 的游戏编码语言和库。然而,从零开始,就像这本书所做的那样,没有理由以其他方式做事。当 OOP 的好处变得明显时,你将永远不会回头看。
OOP 将会:
-
使我们的代码更易管理,更改或更新
-
使我们的代码更快,更可靠地编写
-
使其可以轻松使用其他人的代码(如 SFML)
我们已经看到了第三个好处的实际效果。让我们通过引入一个需要解决的问题来看一下前两个好处。我们面临的问题是当前项目的复杂性。让我们考虑一个单一的僵尸以及我们需要让它在游戏中运行的内容:
-
水平和垂直位置
-
大小
-
它所面对的方向
-
每种僵尸类型的不同纹理
-
精灵
-
每种僵尸类型的不同速度
-
每种僵尸类型的不同生命值
-
跟踪每个僵尸的类型
-
碰撞检测数据
-
智能(追逐玩家)
-
僵尸是活着的还是死了?
这个列表可能为一个僵尸提供了大约十几个变量!我们可能需要整个数组来管理僵尸群。那么机枪的所有子弹、拾取物品和不同的升级呢?简单的 Timber!!!游戏到最后开始变得有点难以管理,可以推测这个更复杂的射击游戏可能会更糟!
幸运的是,处理复杂性并不是一个新问题,C++从一开始就被设计为解决这种复杂性。
什么是 OOP?
OOP 是一种编程方式,它涉及将我们的需求分解成比整体更易管理的块。
每个块是自包含的,但可能被其他程序重复使用,同时作为一个整体一起工作。
这些块就是我们所说的对象。当我们计划和编写一个对象时,我们使用一个类。
提示
类可以被认为是对象的蓝图。
我们实现一个类的对象。这被称为类的实例。想象一下一个房子的蓝图。你不能住在里面,但你可以建造一座房子。你建造了它的一个实例。通常,当我们为我们的游戏设计类时,我们会写一些代表现实世界事物的类。在这个项目中,我们将为玩家、僵尸、子弹等编写类。然而,OOP 不仅仅是这样。
提示
OOP 是一种做事的方式,一种定义最佳实践的方法。
OOP 的三个核心原则是封装,多态和继承。这可能听起来很复杂,但实际上,一步一步地进行,它是相当简单的。
封装
封装意味着保护代码的内部工作,使其不受使用它的代码的干扰。你可以通过只允许你选择的变量和函数来访问来实现这一点。这意味着只要暴露的部分仍然以相同的方式被访问,你的代码就可以随时更新、扩展或改进,而不会影响使用它的程序。
举个例子,通过适当的封装,如果 SFML 团队需要更新他们的Sprite
类的工作方式,这并不重要。只要函数签名保持不变,我们就不必担心内部发生了什么。更新之前编写的代码仍然可以在更新后继续工作。
多态
多态性使我们能够编写不太依赖于我们试图操作的类型的代码。这将使我们的代码更清晰、更高效。多态性意味着不同的形式。如果我们编码的对象可以是多种类型的东西,那么我们就可以利用这一点。多态性在第十二章中的最终项目中将会得到应用,抽象和代码管理-更好地利用 OOP。一切都会变得更清晰。
继承
就像听起来的那样,继承意味着我们可以利用其他人类的所有功能和好处,包括封装和多态性,同时进一步调整他们的代码以适应我们的情况。我们将在第十二章中的最终项目中使用继承,抽象和代码管理 - 更好地利用 OOP。
为什么要这样做?
当正确编写时,所有这些 OOP 都允许您添加新功能,而无需过多担心它们与现有功能的交互。当您必须更改类时,其自包含(封装)的特性意味着对程序的其他部分的影响较少,甚至可能为零。
您可以使用其他人的代码(例如 SFML 类),而无需知道甚至关心其内部工作原理。
OOP,以及扩展的 SFML,使您能够编写使用复杂概念的游戏,例如多个摄像机、多人游戏、OpenGL、定向声音等等。所有这些都可以轻松实现。
使用继承,您可以创建多个相似但不同版本的类,而无需从头开始编写类。
由于多态性,您仍然可以使用原始对象类型的函数来处理新对象。
所有这些都是有道理的。而且,正如我们所知,C++从一开始就考虑了所有这些 OOP。
提示
OOP 和制作游戏(或任何其他类型的应用程序)的最终成功关键,除了决心成功外,还包括规划和设计。重要的不仅仅是了解所有 C++、SFML 和 OOP 主题,而是将所有这些知识应用到编写结构良好、设计良好的代码中。本书中的代码按照适合在游戏环境中学习各种 C++主题的顺序和方式呈现。结构化代码的艺术和科学称为设计模式。随着代码变得越来越长和复杂,有效使用设计模式将变得更加重要。好消息是,我们不需要自己发明这些设计模式。随着我们的项目变得更加复杂,我们需要了解它们。最终章节将更多地介绍设计模式。
在这个项目中,我们将学习和使用基本类和封装,而在最终项目中,我们将更加大胆地使用继承、多态性和其他与 C++相关的 OOP 特性。
什么是类?
类是一堆代码,可以包含函数、变量、循环和我们已经学过的所有其他 C++语法。每个新类将在其自己的.h
代码文件中声明,文件名与类名相同,其函数将在其自己的.cpp
文件中定义。当我们实际编写一些类时,这将变得更清晰。
一旦我们编写了一个类,我们可以使用它来创建任意数量的对象。记住,类是蓝图,我们根据蓝图制作对象。房子不是蓝图,就像对象不是类。它是从类制作的对象。
提示
您可以将对象视为变量,将类视为类型。
当然,谈论 OOP 和类时,我们实际上还没有看到任何代码。所以现在让我们来解决这个问题。
类变量和函数声明
让我们用一个不同的游戏例子来看看,比如僵尸竞技场。考虑一下最基本的游戏,乒乓球。一个弹球的球拍。球拍将是一个很好的类候选。
提示
如果你不知道乒乓球是什么,那就看看这个链接:en.wikipedia.org/wiki/Pong
看一下一个假设的Paddle.h
文件:
class Paddle
{
private:
// Length of the pong paddle
int m_Length = 100;
// Height of the pong paddle
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on y axis
int m_YPosition;
public:
void moveRight();
void moveLeft();
};
乍一看,代码可能看起来有点复杂,但当解释时,我们会发现其中几乎没有新概念。
首先要注意的是使用class
关键字声明了一个新类,后面跟着类的名称,整个声明被大括号括起来,后面跟着一个分号:
class Paddle
{
};
现在看看变量的声明和它们的名称:
// Length of the pong paddle
int m_Length = 100;
// Length of the pong paddle
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on x axis
int m_YPosition;
所有的名称都以m_
为前缀。这不是必需的,但这是一个很好的约定。作为类的一部分声明的变量称为成员变量。以m_
为前缀使得当我们处理成员变量时变得非常明显。当我们为我们的类编写函数时,我们将开始看到局部变量和参数。m_
约定将证明自己是有用的。
还要注意的是,所有的变量都在以private:
关键字开头的代码部分中。扫一眼之前的示例代码,注意类代码的主体分为两个部分:
private:
// more code here
public:
// More code here
public
和private
关键字控制了我们的类的封装。任何私有的东西都不能被类的实例或对象的用户直接访问。如果你正在为其他人设计一个类来使用,你不希望他们能够随意改变任何东西。
这意味着我们的四个成员变量不能被main
中的游戏引擎直接访问。它们可以通过类的代码间接访问。对于m_Length
和m_Height
变量,这是相当容易接受的,只要我们不需要改变球拍的大小。然而,m_XPosition
和m_YPosition
成员变量需要被访问,否则我们怎么移动球拍呢?
这个问题在代码的public:
部分得到了解决:
void moveRight();
void moveLeft();
该类提供了两个公共函数,可以与Paddle
类型的对象一起使用。当我们看到这些函数的定义时,我们将看到这些函数如何操纵私有变量。
总之,我们有一堆无法访问的(私有)变量,不能从main
函数中使用。这是很好的,因为封装使我们的代码更少出错,更易维护。然后,我们通过提供两个公共函数来解决移动球拍的问题,间接访问m_XPosition
和m_YPosition
变量。
main
中的代码可以调用这些函数,但函数内部的代码控制着变量的具体修改方式。
让我们来看看函数的定义。
类函数定义
我们将在本书中编写的函数定义都将放在一个单独的文件中,与类和函数声明分开。我们将使用与类相同名称的文件和.cpp
文件扩展名。因此,在我们的假设示例中,下一个代码将放在一个名为Paddle.cpp
的文件中。看一下这个非常简单的代码,其中只有一个新概念:
#include "stdafx.h"
#include "Paddle.h"
void Paddle::moveRight()
{
// Move the paddle a pixel to the right
m_XPosition ++;
}
void Paddle::moveLeft()
{
// Move the paddle a pixel to the left
m_XPosition --;
}
首先要注意的是,我们必须使用包含指令来包含Paddle.h
类中的类和函数声明。
我们在这里看到的新概念是作用域解析运算符::
的使用。由于函数属于一个类,我们必须通过在函数名前加上类名和::
来编写签名部分。void Paddle::moveLeft()
和void Paddle::moveRight
。
注意
实际上,我们之前已经简要看到了作用域解析运算符。每当我们声明一个类的对象并且之前没有使用using namespace..
。
还要注意,我们可以把函数的定义和声明放在一个文件中,就像这样:
class Paddle
{
private:
// Length of the pong paddle
int m_Length = 100;
// Height of the pong paddle
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on x axis
int m_YPosition;
public:
void Paddle::moveRight()
{
// Move the paddle a pixel to the right
m_XPosition ++;
}
void Paddle::moveLeft()
{
// Move the paddle a pixel to the left
m_XPosition --;
}
};
然而,当我们的类变得更长(就像我们的第一个 Zombie Arena 类一样),将函数定义分离到它们自己的文件中会更有组织性。此外,头文件被认为是公共的,并且通常用于文档目的,如果其他人将使用我们编写的代码。
使用类的实例
尽管我们已经看到了与类相关的所有代码,但我们实际上还没有使用这个类。我们已经知道如何做到这一点,因为我们已经多次使用了 SFML 类。
首先,我们会像这样创建一个Paddle
的实例:
Paddle paddle;
paddle
对象拥有我们在Paddle.h
中声明的所有变量。我们只是不能直接访问它们。然而,我们可以使用它的公共函数来移动我们的挡板,就像这样:
paddle.moveLeft();
或者像这样:
paddle.moveRight();
请记住,paddle
是一个Paddle
,因此它拥有所有的成员变量和所有的可用函数。
我们可以决定在以后的某个日期将我们的Pong游戏改为多人游戏。在main
函数中,我们可以改变代码以拥有两个挡板。可能像这样:
Paddle paddle;
Paddle paddle2;
非常重要的是要意识到,每个Paddle
实例都是具有自己独特变量集的单独对象。
构造函数和 getter 函数
简单的 Pong 挡板示例是介绍类基础知识的好方法。类可以像Paddle
一样简单和简短,但它们也可以更长,更复杂,并且本身包含其他对象。
在制作游戏时,假设的Paddle
类中缺少一个重要的东西。对于所有这些私有成员变量和公共函数来说可能还好,但我们如何绘制任何东西呢?我们的 Pong 挡板也需要一个精灵和一个纹理。
我们可以以与在main
中包含它们相同的方式在我们的类中包含其他对象。
提示
这是Paddle.h
代码中private:
部分的更新版本,其中包括一个成员Sprite
和一个成员Texture
。请注意,该文件还需要相关的 SFML 包含指令,以便该代码能够编译。
private:
// Length of a pong paddle
int m_Length = 100;
// Height of a pong paddle
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on x axis
int m_YPosition;
// Of course we will need a sprite
Sprite m_Sprite;
// And a texture
Texture m_Texture;
新问题立即出现。如果m_Sprite
和m_Texture
是私有的,那么我们怎么在main
函数中绘制它们呢?
我们需要提供一个函数,允许访问m_Sprite
以便绘制。仔细看看Paddle.h
公共部分的新函数声明。
public:
void moveRight();
void moveLeft();
// Send a copy of the sprite to main
Sprite getSprite();
先前的代码声明了一个名为getSprite
的函数。要注意的重要事情是getSprite
返回一个Sprite
对象。我们很快就会看到getSprite
的定义。
如果你很敏锐,你也会注意到在任何时候我们都没有加载纹理或调用m_Sprite.setTexture(m_Texture)
来将纹理与精灵关联起来。
当一个类被编码时,编译器会创建一个特殊的函数。我们在代码中看不到这个函数,但它确实存在。它被称为构造函数。当我们需要编写一些代码来准备一个对象供使用时,通常一个很好的地方就是构造函数。当我们希望构造函数做的事情不仅仅是创建一个实例时,我们必须替换编译器提供的默认(看不见的)构造函数。
注意
首先,我们提供一个构造函数声明。请注意,构造函数没有返回类型,甚至没有void
。还要注意,我们可以立即看到它是构造函数,因为函数名与类名Paddle
相同。
public:
// The constructor
Paddle();
void moveRight();
void moveLeft();
// Send a copy of the sprite to main
Sprite getSprite();
下面的代码显示了Paddle.cpp
中的新函数定义(getSprite
和构造函数Paddle
):
// The constructor
Paddle::Paddle()
{
// Code assumes paddle.png is a real image
m_Texture.loadFromFile("graphics/paddle.png");
// Associate a texture with the sprite
m_Sprite.setTexture(m_Texture);
}
// Return a copy of the sprite to main
Sprite Paddle::getSprite()
{
return m_Sprite;
}
在先前的代码中,我们使用构造函数Paddle
来加载纹理并将其与精灵关联起来。请记住,这个函数是在声明Paddle
类型的对象时调用的。更具体地说,当执行代码Paddle paddle
时,构造函数被调用。
在getSprite
函数中,只有一行代码将m_Sprite
的副本返回给调用代码。
我们还可以在构造函数中为我们的对象进行其他设置工作,并且在构建我们的第一个真正的类时会这样做。
如果您想看看getSprite
函数如何被使用,main
中的代码将如下所示:
window.draw(paddle.getSprite());
上一行代码假设我们有一个名为window
的 SFML RenderWindow
对象。由于getSprite
返回一个Sprite
类型的对象,上一行代码的工作方式与在main
中声明 sprite 的方式完全相同。现在我们有了一个通过其公共函数提供受控访问的封装良好的类。
在代码中跳来跳去
我发现当我阅读跳来跳去的代码文件的书时,我经常发现很难准确地理解发生了什么。接下来是假设的Paddle.h
和Paddle.cpp
的完整清单,以便在继续之前仔细研究它们:
Paddle.h
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Paddle
{
private:
// Length of a pong paddle
int m_Length = 100;
// Height of a pong paddle
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on y axis
int m_YPosition;
// Of course we will need a sprite
Sprite m_Sprite;
// And a texture
Texture m_Texture;
public:
// The constructor
Paddle();
void moveRight();
void moveLeft();
// Send a copy of the sprite to main
Sprite getSprite();
};
Paddle.cpp
#include "stdafx.h"
#include "Paddle.h"
// The constructor
Paddle::Paddle()
{
// Code assumes paddle.png is a real image
m_Texture.loadFromFile("graphics/paddle.png");
// Associate a texture with the sprite
m_Sprite.setTexture(m_Texture);
}
// Return a copy of the sprite to main
Sprite Paddle::getSprite()
{
return m_Sprite;
}
void Paddle::moveRight()
{
// Move the paddle a pixel to the right
m_XPosition ++;
}
void Paddle::moveLeft()
{
// Move the paddle a pixel to the left
m_XPosition --;
}
在本书的其余部分,我们将不断回顾类和面向对象编程。然而,现在我们已经知道足够的知识来开始我们的第一个真正的 Zombie Arena 游戏类。
构建 Player-第一个类
让我们考虑一下我们的Player
类需要做什么。该类需要知道自己可以移动多快,当前在游戏世界中的位置以及拥有多少生命值。由于Player
类在玩家眼中被表示为一个 2D 图形角色,该类将需要一个Sprite
和一个Texture
对象。
此外,尽管此时可能不明显,我们的Player
类还将受益于了解游戏运行的整体环境的一些细节。这些细节包括屏幕分辨率、构成竞技场的瓦片大小以及当前竞技场的整体大小。
由于Player
类将全权负责每帧更新自身,它需要知道玩家在任何给定时刻的意图。例如,玩家当前是否按住特定的键盘方向键?或者玩家当前是否按住多个键盘方向键?布尔变量来确定W、A、S和D**键的状态将是必不可少的。
很明显,我们将需要在我们的新类中使用相当多的变量。通过学习了关于面向对象编程的所有知识,我们当然会将所有这些变量设置为私有的。这意味着我们必须在适当的时候从main
函数中提供访问。
我们将使用一大堆getter
函数,以及一些其他函数来设置我们的对象。这些函数相当多;实际上,在这个类中有 21 个函数。起初这可能看起来有点令人生畏,但我们将逐个查看它们,并看到其中大多数只是设置或获取其中一个私有变量。
有一些相当深入的函数,比如update
,它将从main
函数中每帧调用一次,以及spawn
,它将处理一些私有变量的初始化。然而,正如我们将看到的,它们都不复杂,并且将被详细描述。
继续进行的最佳方式是编写头文件。这将使我们有机会查看所有私有变量并检查所有函数签名。请特别注意返回值和参数类型,因为这将使理解函数定义中的代码变得更容易。
编写 Player 类头文件
在“解决方案资源管理器”中右键单击“头文件”,然后选择添加 | 新建项目...。在“添加新项目”窗口中,通过左键单击头文件(.h
),然后在“名称”字段中输入Player.h
。最后,单击添加按钮。现在我们准备为我们的第一个类编写头文件。
通过添加声明来开始编写Player
类,包括开放和关闭的大括号,然后是一个分号:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Player
{
};
现在让我们添加所有私有成员变量。根据我们已经讨论的内容,看看你能否弄清楚它们每一个将要做什么。我们将逐个讨论它们:
class Player
{
private:
const float START_SPEED = 200;
const float START_HEALTH = 100;
// Where is the player
Vector2f m_Position;
// Of course we will need a sprite
Sprite m_Sprite;
// And a texture
// !!Watch this space!!
Texture m_Texture;
// What is the screen resolution
Vector2f m_Resolution;
// What size is the current arena
IntRect m_Arena;
// How big is each tile of the arena
int m_TileSize;
// Which directions is the player currently moving in
bool m_UpPressed;
bool m_DownPressed;
bool m_LeftPressed;
bool m_RightPressed;
// How much health has the player got?
int m_Health;
// What is the maximum health the player can have
int m_MaxHealth;
// When was the player last hit
Time m_LastHit;
// Speed in pixels per second
float m_Speed;
// All our public functions will come next
};
之前的代码声明了我们所有的成员变量。有些是常规变量,有些是对象本身。请注意,它们都在类的private:
部分下,并且因此不能直接从类外部访问。
还要注意,我们使用了将m_
前缀添加到所有非常量变量的命名约定。m_
前缀将在编写函数定义时提醒我们,它们是成员变量,并且与我们将在一些函数中创建的一些局部变量以及与函数参数不同。
所有变量的用途都是明显的,比如m_Position
、m_Texture
和m_Sprite
,它们分别用于玩家的当前位置、纹理和精灵。此外,每个变量(或变量组)都有注释,以便明确它们的用途。
然而,它们为什么需要以及它们将在什么上下文中使用可能并不那么明显。例如,m_LastHit
是一个Time
类型的对象,用于记录玩家上次受到僵尸攻击的时间。我们将m_LastHit
用于的用途很明显,但同时,为什么我们可能需要这些信息并不明显。
随着我们将游戏的其余部分拼凑在一起,每个变量的上下文将变得更加清晰。现在重要的是要熟悉变量的名称和类型,以便在项目的其余部分中跟随进行时无忧。
提示
您不需要记住变量名称和类型,因为我们在使用它们时会讨论所有代码。您需要花时间仔细查看它们,并对它们有一点熟悉。此外,随着我们的进行,如果有任何地方看起来不清楚,回头参考这个头文件可能是值得的。
现在我们可以添加一整长串的函数。添加以下所有突出显示的代码,看看你能否弄清楚它们的作用。密切关注每个函数的返回类型、参数和名称。这对于理解我们将在项目的其余部分中编写的代码至关重要。它们告诉我们关于每个函数的什么信息?添加以下突出显示的代码,然后我们将对其进行检查:
// All our public functions will come next
public:
Player();
void spawn(IntRect arena, Vector2f resolution, int tileSize);
// Call this at the end of every game
void resetPlayerStats();
// Handle the player getting hit by a zombie
bool hit(Time timeHit);
// How long ago was the player last hit
Time getLastHitTime();
// Where is the player
FloatRect getPosition();
// Where is the center of the player
Vector2f getCenter();
// Which angle is the player facing
float getRotation();
// Send a copy of the sprite to main
Sprite getSprite();
// The next four functions move the player
void moveLeft();
void moveRight();
void moveUp();
void moveDown();
// Stop the player moving in a specific direction
void stopLeft();
void stopRight();
void stopUp();
void stopDown();
// We will call this function once every frame
void update(float elapsedTime, Vector2i mousePosition);
// Give player a speed boost
void upgradeSpeed();
// Give the player some health
void upgradeHealth();
// Increase the maximum amount of health the player can have
void increaseHealthLevel(int amount);
// How much health has the player currently got?
int getHealth();
};
首先注意,所有函数都是公共的。这意味着我们可以使用类的实例从main
函数中调用所有这些函数,代码如下:player.getSprite();
。
假设player
是Player
类的一个完全设置好的实例,之前的代码将返回m_Sprite
的副本。将这段代码放入真实的上下文中,我们可以在main
函数中编写如下代码:
window.draw(player.getSprite());
之前的代码会在正确的位置绘制玩家图形,就好像精灵是在main
函数中声明的一样。这就像我们之前对假设的Paddle
类所做的一样。
在我们继续在相应的.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()
:main
中的代码有时需要知道玩家当前面向的方向(以度为单位)。三点钟为零度,顺时针增加。 -
Sprite getSprite()
: 如前所述,此函数返回代表玩家的精灵的副本。 -
void moveLeft()
,...Right()
,...Up()
,...Down()
: 这四个函数没有返回类型或参数。它们将从main
函数中调用,然后Player
类将能够在按下W、A、S和D**键时采取行动。 -
void stopLeft()
,...Right()
,...Up()
,...Down()
: 这四个函数没有返回类型或参数。它们将从main
函数中调用,然后Player
类将能够在释放W、A、S和D**键时采取行动。 -
void update(float elapsedTime, Vector2i mousePosition)
: 这将是整个类中唯一相对较长的函数。它将从main
每帧调用一次。它将做一切必要的工作,以确保玩家对象的数据已更新,以便进行碰撞检测和绘制。请注意它不返回数据,但接收自上一帧以来经过的时间量,以及一个Vector2i
,其中包含鼠标指针或准星的水平和垂直屏幕位置。
提示
请注意,这些是整数屏幕坐标,与浮点世界坐标不同。
-
void upgradeSpeed()
: 当玩家选择使玩家更快时,可以从升级屏幕调用的函数。 -
void upgradeHealth()
: 当玩家选择使玩家更强壮(拥有更多健康)时,可以从升级屏幕调用的另一个函数。 -
void increaseHealthLevel(int amount)
: 与前一个函数相比,这个函数的一个微妙但重要的区别在于它将增加玩家的健康值,直到当前设置的最大值。当玩家拾取健康道具时,将使用此函数。 -
int getHealth()
: 由于健康水平如此动态,我们需要能够确定玩家在任何给定时刻有多少健康。此函数返回一个包含该值的int
。与变量一样,现在应该清楚每个函数的用途。此外,与变量一样,随着项目的进展,使用其中一些函数的原因和确切上下文只有在我们进行项目时才会显现。
提示
您不需要记住函数名称、返回类型或参数,因为我们将在使用它们时讨论所有代码。您需要花时间仔细查看它们,以及之前的解释,并对它们更加熟悉一些。此外,随着我们的进行,如果有任何地方看起来不清楚,回头参考这个头文件可能是值得的。
现在我们可以继续进行我们函数的核心部分,即定义。
编写Player
类函数定义
最后,我们可以开始编写实际执行我们类工作的代码。
右键单击在解决方案资源管理器中的源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击选择C++文件(.cpp
),然后在名称字段中键入Player.cpp
。最后,单击添加按钮。现在我们准备好为我们的第一个类编写.cpp
文件了。
以下是必要的包含指令,后面是构造函数的定义。记住,当我们首次实例化Player
类型的对象时,构造函数将被调用。将此代码添加到Player.cpp
文件中,然后我们可以更仔细地查看:
#include "stdafx.h"
#include "player.h"
Player::Player()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
// Associate a texture with the sprite
m_Texture.loadFromFile("graphics/player.png");
m_Sprite.setTexture(m_Texture);
// Set the origin of the sprite to the centre,
// for smooth rotation
m_Sprite.setOrigin(25, 25);
}
在构造函数中,当然,它与类名相同且没有返回类型,我们编写了一些代码,开始设置Player
对象以便随时使用。
要非常清楚:当我们从main
函数中编写这段代码时,这段代码将运行。
Player player;
暂时不要添加上述代码。
我们只需从相关的常量中初始化m_Speed
、m_Health
和m_MaxHealth
。然后将玩家图形加载到m_Texture
中,将m_Texture
与m_Sprite
关联,并将m_Sprite
的原点设置为中心(25, 25)
。
提示
请注意神秘的注释// !!Watch this space!!
,表明我们将返回到纹理的加载以及一些重要的相关问题。一旦我们发现了问题并学到了更多的 C++知识,我们将改变处理这个纹理的方式。我们将在第八章中进行这样的操作,指针、标准模板库和纹理管理。
接下来,我们将编写spawn
函数。我们只会创建Player
类的一个实例。然而,我们需要在当前关卡中生成它,每一波都需要。这就是spawn
函数将为我们处理的内容。将以下代码添加到Player.cpp
文件中。确保仔细检查细节并阅读注释:
void Player::spawn(IntRect arena, Vector2f resolution, int tileSize)
{
// Place the player in the middle of the arena
m_Position.x = arena.width / 2;
m_Position.y = arena.height / 2;
// Copy the details of the arena to the player's m_Arena
m_Arena.left = arena.left;
m_Arena.width = arena.width;
m_Arena.top = arena.top;
m_Arena.height = arena.height;
// Remember how big the tiles are in this arena
m_TileSize = tileSize;
// Store the resolution for future use
m_Resolution.x = resolution.x;
m_Resolution.y = resolution.y;
}
前面的代码首先将m_Position.x
和m_Position.y
的值初始化为传入arena
的高度和宽度的一半。这样做的效果是将玩家移动到关卡的中心,而不管其大小如何。
接下来,我们将传入arena
的所有坐标和尺寸复制到相同类型的成员对象m_Arena
中。当前竞技场的大小和坐标的细节使用如此频繁,这样做是有道理的。现在我们可以使用m_Arena
来执行任务,比如确保玩家不能穿过墙壁。此外,我们将传入的tileSize
复制到成员变量m_TileSize
中,以达到相同的目的。我们将在update
函数中看到m_Arena
和m_TileSize
的作用。
最后两行代码将spawn
的参数Vector2f
中的屏幕分辨率复制到Player
的成员变量m_Resolution
中。
现在添加resetPlayerStats
函数的非常简单的代码。当玩家死亡时,我们将使用它来重置他们可能使用的任何升级:
void Player::resetPlayerStats()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
}
直到我们几乎完成项目时,我们才会编写实际调用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;
}
}
getLastHitTime
函数的代码非常简单。返回m_LastHit
中存储的任何值。
hit
函数有点更深入和微妙。首先,if
语句检查传入的时间是否比m_LastHit
中存储的时间晚 200 毫秒。如果是,就用传入的时间更新m_LastHit
,并且从当前值中减去10
。这个if
语句中的最后一行代码是return true
。请注意,else
子句只是向调用代码返回false
。
这个函数的整体效果是,每秒只能从玩家身上扣除最多五次健康点。请记住,我们的游戏循环可能每秒运行数千次。在这种情况下,如果没有限制,僵尸只需要与玩家接触一秒钟,就会扣除成千上万的健康点。 hit
函数控制和限制了这种情况。它还通过返回true
或false
让调用代码知道是否已经注册了新的命中。
这段代码意味着我们将在main
函数中检测僵尸和玩家之间的碰撞。然后我们将调用“player.hit()”来确定是否扣除任何健康点。
接下来,对于Player
类,我们将实现一堆 getter 函数。它们使我们能够将数据整洁地封装在Player
类中,同时使它们的值可用于main
函数。
在上一个代码块之后添加以下代码,然后我们将讨论每个函数的确切作用:
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;
}
前面的代码非常直接。前面的五个函数中的每一个都返回我们的成员变量的值。仔细看看每个,并熟悉哪个函数返回哪个值。
接下来的八个简短的函数使键盘控件(我们将从main
中使用)能够改变我们的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;
}
前面的代码有四个函数(moveLeft
,moveRight
,moveUp
,moveDown
),它们将相关的布尔变量(m_LeftPressed
,m_RightPressed
,m_UpPressed
,m_DownPressed
)设置为true
。另外四个函数(stopLeft
,stopRight
,stopUp
,stopDown
)则相反,将相同的“布尔”变量设置为false
。现在,Player
类的实例可以清楚地知道哪些键被按下,哪些没有。
下一个函数是做所有繁重工作的函数。 update
函数将在我们游戏循环的每一帧上调用一次。添加接下来的代码,然后我们将详细讨论它。如果你跟着前面的八个函数,并且记得我们如何为“Timber!!!”项目中的云动画,你可能会发现以下大部分代码都很容易理解:
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);
}
前面代码的第一部分移动了玩家精灵。四个if
语句检查与移动相关的“布尔”变量(m_LeftPressed
,m_RightPressed
,m_UpPressed
,m_DownPressed
)中哪些是 true,并相应地更改m_Position.x
和m_Position.y
。与“Timber!!!”项目相同的计算移动量的公式被使用。
“位置(+或-)速度*经过的时间。”
在这四个if
语句之后,调用m_Sprite.setPosition
并传入m_Position
。精灵现在已经根据该帧的正确量进行了调整。
接下来的四个if
语句检查m_Position.x
或m_Position.y
是否超出了当前竞技场的任何边缘。请记住,当前竞技场的范围是在spawn
函数中存储在m_Arena
中的。让我们看看这四个if
语句中的第一个,以便理解它们所有的含义:
if (m_Position.x > m_Arena.width - m_TileSize)
{
m_Position.x = m_Arena.width - m_TileSize;
}
前面的代码测试了 m_position.x
是否大于 m_Arena.width
减去一个瓷砖的大小(m_TileSize
)。当我们创建背景图形时,这个计算将检测玩家是否偏离墙壁。
当 if
语句为真时,计算 m_Arena.width - m_TileSize
用于初始化 m_Position.x
。这使得玩家图形的中心无法偏离右侧墙壁的左侧边缘。
我们刚刚讨论过的 if
语句后面的下三个 if
语句对其他三面墙做了同样的事情。
代码的最后两行计算并设置玩家精灵的旋转角度(面向)。这行代码可能看起来有点复杂,但它只是使用了十分成熟的三角函数,即使用了准星的位置(mousePosition.x
和 mousePosition.y
)和屏幕中心(m_Resolution.x
和 m_Resolution.y
)。
atan
如何使用这些坐标以及 Pi(3.141)是非常复杂的,这就是为什么它被包装在一个方便的函数中供我们使用。如果您想更详细地探索三角函数,可以在www.cplusplus.com/reference/cmath/
上这样做。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;
}
}
在前面的代码中,upgradeSpeed
和 upgradeHealth
函数分别增加了存储在 m_Speed
和 m_MaxHealth
中的值。这些值通过将起始值乘以 0.2 并加上当前值来增加 20%。这些函数将在玩家在关卡之间选择要改进的角色属性时,从 main
函数中调用。
increaseHealthLevel
从 main
中的 amount
参数中获取一个 int
值。这个 int
值将由一个名为 Pickup
的类提供,我们将在第九章中编写,碰撞检测、拾取物品和子弹。m_Health
成员变量增加了传入的值。然而,对于玩家来说有一个陷阱。if
语句检查 m_Health
是否超过了 m_MaxHealth
,如果超过了,则将其设置为 m_MaxHealth
。这意味着玩家不能简单地从拾取物品中获得无限的生命值。他们必须在关卡之间谨慎平衡他们选择的升级。
当然,我们的 Player
类实际上无法做任何事情,直到我们实例化它并在游戏循环中让它工作。在这之前,让我们先了解一下游戏摄像机的概念。
使用 SFML View 控制游戏摄像机
在我看来,SFML View
类是最整洁的类之一。如果在完成本书后,您制作游戏而不使用媒体或游戏库,您将真正注意到缺少 View
。
View
类允许我们将游戏视为发生在自己的世界中,具有自己的属性。我是什么意思?当我们创建游戏时,通常是在尝试创建一个虚拟世界。那个虚拟世界很少,如果有的话,是以像素为单位的,很少,如果有的话,会与玩家的显示器像素数完全相同。我们需要一种方式来抽象我们正在构建的虚拟世界,以便它可以是我们喜欢的任何大小或形状。
另一种看待 SFML View
的方式是作为玩家查看我们虚拟世界的一部分的摄像机。大多数游戏都会有多个摄像机或对世界的视图。
例如,考虑一个分屏游戏,两个玩家可以在同一个世界的不同部分,不同时间。
或者考虑一个游戏,屏幕上有一个小区域代表整个游戏世界,但是在非常高的层次上,或者缩小,就像一个迷你地图。
即使我们的游戏比前两个示例简单得多,不需要分屏或迷你地图,我们可能还是想要创建一个比正在播放的屏幕更大的世界。当然,这就是僵尸竞技场的情况。
如果我们不断地移动游戏摄像机以显示虚拟世界的不同部分(通常是跟踪玩家),HUD 会发生什么?如果我们绘制分数和其他屏幕 HUD 信息,然后滚动世界以跟随玩家,那么分数将相对于该摄像机移动。
SFML View
类很容易实现所有这些功能,并且通过非常简单的代码解决了这个问题。关键是为每个摄像机创建一个View
实例。也许为迷你地图创建一个View
,为滚动游戏世界创建一个View
,然后为 HUD 创建一个View
。
View
的实例可以根据需要移动、调整大小和定位。因此,主View
可以跟踪玩家,迷你地图视图可以保持在屏幕的固定缩小角落,而 HUD 可以覆盖整个屏幕并且永远不会移动,尽管主View
可以随着玩家的移动而移动。
让我们看一些使用几个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));
前面的代码创建了两个填充 1920 x 1080 监视器的View
对象。现在我们可以在保持hudView
完全不变的情况下对mainView
进行一些魔术操作:
// 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
当我们操纵视图的属性时,我们就像之前展示的那样。当我们向视图绘制精灵、文本或其他对象时,我们必须明确将视图设置为窗口的当前视图:
// Set the current view
window.setView(mainView);
现在我们可以在该视图中绘制我们想要的一切:
// Do all the drawing for this view
window.draw(playerSprite);
window.draw(otherGameObject);
// etc
玩家可能在任何坐标。这并不重要,因为mainView
是围绕图形中心的。
现在我们可以将 HUD 绘制到hudView
中。请注意,就像我们按照从后到前的顺序绘制单个元素(背景、游戏对象、文本等)一样,我们也按照从后到前的顺序绘制视图。因此,HUD 在主游戏之后绘制:
// Switch to the hudView
window.setView(hudView);
// Do all the drawing for the HUD
window.draw(scoreText);
window.draw(healthBar);
// etc
最后,我们可以以通常的方式绘制或显示窗口和当前帧的所有视图:
window.display();
提示
如果您想要深入了解 SFML View,超出了这个项目所需的范围,包括如何实现分屏和迷你地图,那么 Web 上最好的指南是在官方 SFML 网站上www.sfml-dev.org/tutorials/2.0/graphics-view.php
上。
现在我们已经了解了View
,我们可以开始编写僵尸竞技场main
函数,并真正使用我们的第一个View
。在第十章中,分层视图和实现 HUD,我们将为 HUD 介绍第二个View
实例,修复它,并将其层叠在主View
的顶部。
启动僵尸竞技场游戏引擎
在这个游戏中,我们将需要一个稍微升级的游戏引擎在main
中。特别是,我们将有一个名为state
的枚举,它将跟踪游戏的当前状态。然后,在整个main
中,我们可以包装我们的代码的部分,以便在不同的状态下发生不同的事情。
在解决方案资源管理器中右键单击HelloSFML
文件,然后选择重命名。将名称更改为ZombieArena.cpp
。这将是包含我们的main
函数和实例化和控制所有类的代码的文件。
我们从现在熟悉的main
函数和一些包含指令开始。请注意,增加了一个Player
类的包含指令。
将以下代码添加到ZombieArena.cpp
文件中:
#include "stdafx.h"
#include <SFML/Graphics.hpp>
#include "Player.h"
using namespace sf;
int main()
{
return 0;
}
前面的代码除了#include "Player.h"
行之外没有任何新内容,这意味着我们现在可以在我们的代码中使用Player
类。
让我们充实一下我们的游戏引擎。接下来的代码做了很多事情。在添加代码时,请务必阅读注释,以了解发生了什么。然后我们将详细讨论它。
在main
函数的开头添加突出显示的代码:
int main()
{
// The game will always be in one of four states
enum class State
{
PAUSED, LEVELING_UP, GAME_OVER, PLAYING
};
// Start with the GAME_OVER state
State state = State::GAME_OVER;
// Get the screen resolution and create an SFML window
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
RenderWindow window(VideoMode(resolution.x, resolution.y),
"Zombie Arena", Style::Fullscreen);
// Create a an SFML View for the main action
View mainView(sf::FloatRect(0, 0, resolution.x, resolution.y));
// Here is our clock for timing everything
Clock clock;
// How long has the PLAYING state been active
Time gameTimeTotal;
// Where is the mouse in relation to world coordinates
Vector2f mouseWorldPosition;
// Where is the mouse in relation to screen coordinates
Vector2i mouseScreenPosition;
// Create an instance of the Player class
Player player;
// The boundaries of the arena
IntRect arena;
// The main game loop
while (window.isOpen())
{
}
return 0;
}
让我们快速浏览一下我们刚刚输入的代码的每个部分。在main
函数的内部,我们有这段代码:
// The game will always be in one of four states
enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };
// Start with the GAME_OVER state
State state = State::GAME_OVER;
前面的代码创建了一个名为State
的新枚举类。然后代码创建了一个名为state
的State
实例。state
枚举现在可以是声明中定义的四个值之一。这些值是PAUSED
、LEVELING_UP
、GAME_OVER
和PLAYING
。这四个值将正是我们需要的,用于跟踪和响应游戏在任何给定时间可能处于的不同状态。请注意,state
不可能同时保存多个值。
紧接着,我们添加以下代码:
// Get the screen resolution and create an SFML window
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
RenderWindow window(VideoMode(resolution.x, resolution.y),
"Zombie Arena", Style::Fullscreen);
前面的代码声明了一个名为resolution
的Vector2f
。我们通过调用VideoMode::getDesktopMode
函数来初始化resolution
的两个变量(x
和y
)分别为width
和height
。resolution
对象现在保存了游戏运行的显示器的分辨率。最后一行代码使用适当的分辨率创建了一个名为window
的新RenderWindow
。
接下来的代码创建了一个 SFML View
对象。视图的位置(最初)位于显示器像素的确切坐标处。如果我们要在当前位置使用这个View
进行一些绘图,它将完全没有任何效果。然而,我们最终将开始移动这个视图,以便关注玩家需要看到的游戏世界的部分。然后,当我们开始使用一个保持固定的第二个View
(用于 HUD)时,我们将看到这个View
如何跟踪动作,而另一个保持静态以显示 HUD:
// Create a an SFML View for the main action
View mainView(sf::FloatRect(0, 0, resolution.x, resolution.y));
接下来,我们创建一个Clock
来处理计时和一个名为gameTimeTotal
的Time
对象,它将保持游戏经过的总时间。随着项目的进展,我们将引入更多的变量和对象来处理计时:
// Here is our clock for timing everything
Clock clock;
// How long has the PLAYING state been active
Time gameTimeTotal;
接下来的代码声明了两个向量。一个包含两个浮点数,名为mouseWorldPosition
,另一个包含两个整数,名为mouseScreenPosition
。鼠标指针有点反常,因为它存在于两个不同的坐标空间。如果你愿意,你可以把它们想象成平行宇宙。首先,当玩家在世界中移动时,我们需要跟踪十字准星在世界中的位置。这些将是浮点坐标,并将存储在mouseWorldCoordinates
中。当然,显示器本身的实际像素坐标永远不会改变。它们将始终是 0,0 到水平分辨率-1,垂直分辨率-1。我们将使用存储在mouseScreenPosition
中的整数来跟踪鼠标指针相对于这个坐标空间的位置:
// Where is the mouse in relation to world coordinates
Vector2f mouseWorldPosition;
// Where is the mouse in relation to screen coordinates
Vector2i mouseScreenPosition;
最后,我们要使用我们的Player
类。这行代码将导致构造函数(Player::Player
)执行。如果您想要刷新对这个函数的记忆,请参考Player.cpp
:
// Create an instance of the Player class
Player player;
这个IntRect
对象将保存起始的水平和垂直坐标以及宽度和高度。一旦初始化,我们将能够通过诸如arena.left
、arena.top
、arena.width
和arena.height
的代码访问当前竞技场的大小和位置详情:
// The boundaries of the arena
IntRect arena;
我们之前添加的代码的最后部分当然是我们的主游戏循环:
// The main game loop
while (window.isOpen())
{
}
您可能已经注意到,代码变得相当长了。让我们谈谈这种不便之处。
管理代码文件
使用类和函数进行抽象的一个优点是,我们的代码文件的长度(行数)可以减少。尽管我们将在这个项目中使用超过十几个代码文件,但ZombieArena.cpp
中的代码长度在最后仍然会变得有点难以控制。在最终项目中,我们将探讨更多抽象和管理代码的方法。
现在,使用这个提示来保持事情的可管理性。请注意,在 Visual Studio 的代码编辑器的左侧,有许多+和-符号,其中一个显示在下一个图像中:
每个代码块(if
、while
、for
等)都会有一个符号。您可以通过单击+和-符号来展开和折叠这些块。我建议将当前不在讨论中的所有代码都折叠起来。这将使事情变得更清晰。
此外,我们可以创建自己的可折叠块。我建议将主游戏循环开始之前的所有代码制作成一个可折叠块。为此,选择代码,右键单击,然后选择Outlining | Hide Selection,如下图所示:
现在您可以单击+和-符号来展开和收缩块。每次我们在主游戏循环之前添加代码(这将经常发生),您可以展开代码,添加新行,然后再次折叠。当折叠时,代码看起来像下面这张图片:
这比以前更容易管理。
开始编写主游戏循环
正如您所看到的,前面代码的最后部分是游戏循环,while (window.isOpen()){}
。现在我们将把注意力转向这一部分。具体来说,我们将编写游戏循环的输入处理部分。
我们将添加的下一个代码非常长。这并不复杂,我们将在一分钟内仔细研究它。
只需添加下面代码中显示的突出显示的代码到主游戏循环中:
// The main game loop
while (window.isOpen())
{
/*
************
Handle input
************
*/
// Handle events by polling
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Pause a game while playing
if (event.key.code == Keyboard::Return &&
state == State::PLAYING)
{
state = State::PAUSED;
}
// Restart while paused
else if (event.key.code == Keyboard::Return &&
state == State::PAUSED)
{
state = State::PLAYING;
// Reset the clock so there isn't a frame jump
clock.restart();
}
// Start a new game while in GAME_OVER state
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
}
if (state == State::PLAYING)
{
}
}
}// End event polling
}// End game loop
在前面的代码中,我们实例化了一个Event
类型的对象。我们将使用event
,就像在 Timber!!!项目中一样,来轮询系统事件。为此,我们将前一个块的其余代码放入一个带有条件window.pollEvent(event)
的while
循环中。这将在每一帧中保持循环,直到没有更多事件需要处理为止。
在这个while
循环内,我们处理我们感兴趣的事件。首先,我们测试Event::KeyPressed
事件。如果在游戏处于PLAYING
状态时按下Enter键,那么我们将state
切换到PAUSED
。
如果在游戏处于PAUSED
状态时按下Enter键,那么我们将state
切换到PLAYING
并重新启动clock
。在从PAUSED
切换到PLAYING
后重新启动clock
的原因是,当游戏暂停时,经过的时间仍然会累积。如果我们不重新启动时钟,那么所有对象会更新它们的位置,就好像帧花了很长时间。随着我们在这个文件中完善其余代码,这一点将变得更加明显。
然后我们有一个else if
测试,看看在GAME_OVER
状态下是否按下了Enter键。如果是的话,那么state
将被改变为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 the 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
}// End game loop
在前面的代码中,我们首先测试玩家是否按下了Esc键。如果按下,游戏窗口将被关闭。
接下来,在一个大的if(state == State::PLAYING)
块内,我们依次检查W、A、S和D**键。如果按下某个键,我们调用相应的player.move...
函数。如果没有按下,则调用相关的player.stop...
函数。
这段代码确保在每一帧中,玩家对象将准确更新哪些W、A、S、D键被按下,哪些没有被按下。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 the middle of the arena
player.spawn(arena, resolution, tileSize);
// Reset the clock so there isn't a frame jump
clock.restart();
}
}// End LEVELING up
}// End game loop
在前面的代码中,所有代码都包含在一个测试中,以查看state
的当前值是否为LEVELING_UP
,我们处理键盘键1、2、3、4、5和6。在每个if
块中,我们只需将state
设置为State::PLAYING
。我们将在第十一章音效、文件 I/O 和完成游戏中稍后添加处理每个升级选项的代码。
这段代码的作用是:
-
如果
state
是LEVELING_UP
,等待按下1、2、3、4、5或6键。 -
当按下时,将
state
更改为PLAYING
。 -
当状态改变时,在
if (state == State::LEVELING_UP)
块内,嵌套的if(state == State::PLAYING)
块将运行。 -
在此块中,我们设置
arena
的位置和大小,tileSize
为50
,将所有信息传递给player.spawn
,并重新启动clock
。
现在我们有了一个真正的生成的玩家对象,它知道自己的环境并可以响应按键。我们现在可以在每次循环中更新场景。
确保将游戏循环的输入处理部分的代码整理好,因为我们现在已经完成了。接下来的代码在游戏循环的更新部分。添加并学习下面突出显示的代码,然后我们可以讨论它:
}// 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 decimal fraction of 1 from the delta time
float dtAsSeconds = dt.asSeconds();
// Where is the mouse pointer
mouseScreenPosition = Mouse::getPosition();
// Convert mouse position to world 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 around the player
mainView.setCenter(player.getCenter());
}// End updating the scene
}// End game loop
首先注意,所有先前的代码都包含在一个测试中,以确保游戏处于PLAYING
状态。如果游戏暂停、结束或玩家正在选择升级,我们不希望这段代码运行。
首先,我们重新启动时钟,并将上一帧所用的时间存储在dt
变量中:
// Update the delta time
Time dt = clock.restart();
接下来,我们将上一帧所用的时间添加到游戏已运行的累积时间gameTimeTotal
中:
// Update the total game time
gameTimeTotal += dt;
现在,我们使用dt.AsSeconds
函数返回的值初始化一个名为dtAsSeconds
的float
。对于大多数帧,这将是一个小数。这非常适合传递给player.update
函数,用于计算玩家精灵的移动量。
现在我们可以使用MOUSE::getPosition
函数初始化mouseScreenPosition
。
注意
你可能会对获取鼠标位置的略微不寻常的语法感到好奇?这被称为静态函数。如果我们在一个类中用static
关键字定义一个函数,我们可以使用类名调用该函数,而无需类的实例。C++面向对象编程有很多这样的怪癖和规则。随着我们的学习,我们会看到更多。
然后,我们使用 SFML 的mapPixelToCoords
函数在window
上初始化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();
}// End game loop
return 0;
}
在前面的代码中,if(state == State::PLAYING)
部分,我们清除屏幕,将窗口视图设置为mainView
,然后用window.draw(player.getSprite())
绘制玩家精灵。
在处理完所有不同的状态之后,代码以通常的方式显示场景,使用window.display();
。
您可以运行游戏,看到我们的玩家角色在响应鼠标移动时旋转。
提示
当您运行游戏时,您需要按Enter来开始游戏,然后输入1到6之间的数字来模拟选择升级选项。然后游戏将开始。
您还可以在(空的)500 x 500 像素的竞技场内移动玩家。您可以在屏幕中央看到我们孤独的玩家,如下所示:
但是,您无法感受到任何移动,因为我们还没有实现背景。我们将在下一章中实现。
常见问题解答
问题)我注意到我们已经编写了许多Player
类的函数,但我们并没有使用。
答案)我们不再需要不断返回Player
类,我们已经添加了整个代码,这是我们在整个项目中需要的。到第十一章结束时,音效,文件 I/O 和完成游戏,我们将充分利用所有这些功能。
问题)我学过其他语言,C++中的 OOP 看起来简单得多。
答案)这是面向对象编程及其基本原理的介绍。它不仅仅是这样。我们将在整本书中学习更多面向对象编程的概念和细节。
总结
呼!这是一个漫长的过程。在本章中,我们学到了很多。我们发现了面向对象编程的基础知识,包括如何使用封装来控制类外部代码如何访问成员变量。我们建立了我们的第一个真正的类Player
,并在即将成为我们新游戏 Zombie Arena 的开始中使用了它。
如果围绕 OOP 和类的一些细节不是很清楚,不要太担心。我这么说的原因是因为我们将在本书的剩余部分中制作类,我们使用它们越多,它们就会变得越清晰。
在下一章中,我们将通过探索精灵表来构建我们的竞技场背景。我们还将学习 C++引用,它允许我们操纵变量,即使它们超出了范围(在另一个函数中)。
第七章:C++引用、精灵表和顶点数组
在第四章中,我们谈到了作用域。在函数或内部代码块中声明的变量只在该函数或块中具有作用域(可以被看到或使用)。仅使用我们目前拥有的 C++知识,这可能会导致问题。如果我们需要处理一些复杂对象,这些对象在main
中是必需的,我们该怎么办?这可能意味着所有的代码都必须在main
中。
在本章中,我们将探讨C++引用,它允许我们处理变量和对象,否则它们将超出作用域。此外,引用将帮助我们避免在函数之间传递大型对象,这是一个缓慢的过程。这是一个缓慢的过程,因为每次这样做时,都必须制作变量或对象的副本。
掌握了关于引用的这些新知识后,我们将看一下 SFML VertexArray
类,它允许我们构建一个大图像,可以使用来自单个图像文件的多个图像快速有效地绘制到屏幕上。在本章结束时,我们将拥有一个可扩展的、随机的、滚动的背景,使用引用和一个VertexArray
对象。
我们现在将讨论以下主题:
-
C++引用
-
SFML 顶点数组
-
编写随机和滚动的背景
C++引用
当我们向函数传递值或从函数返回值时,这正是我们所做的。通过值传递/返回。发生的情况是变量持有的值的副本被制作,并发送到函数中使用。
这具有双重意义:
-
如果我们希望函数对变量进行永久性更改,那么这个系统对我们来说就不好了。
-
当制作副本以作为参数传递或从函数返回时,会消耗处理能力和内存。对于一个简单的
int
,甚至可能是一个精灵,这是相当微不足道的。然而,对于一个复杂的对象,也许是整个游戏世界(或背景),复制过程将严重影响我们游戏的性能。
引用是这两个问题的解决方案。引用是一种特殊类型的变量。引用指的是另一个变量。一个例子将是有用的:
int numZombies = 100;
int& rNumZombies = numZombies;
在上面的代码中,我们声明并初始化了一个常规的int
,名为numZombies
。然后我们声明并初始化了一个int
引用,名为rNumZombies
。跟随类型的引用运算符&
确定正在声明一个引用。
注意
引用名称前面的r
前缀是可选的,但对于记住我们正在处理引用是有用的。
现在我们有一个名为numZombies
的int
,它存储值100
,以及一个引用int
,名为rNumZombies
,它指的是numZombies
。
我们对numZombies
所做的任何事情都可以通过rNumZombies
看到,我们对rNumZombies
所做的任何事情实际上都是在做numZombies
。看一下以下代码:
int score = 10;
int& rScore = score;
score++;
rScore++;
在前面的代码中,我们声明了一个名为score
的int
。接下来,我们声明了一个引用int
,名为rScore
,它指的是score
。请记住,我们对score
所做的任何事情都可以被rScore
看到,我们对rScore
所做的任何事情实际上都是在做score
。
因此,当我们像这样增加分数时:
score++;
分数变量现在存储值 11。此外,如果我们输出rScore
,它也将输出 11。以下代码行如下:
rScore++;
现在score
实际上持有值 12,因为我们对rScore
所做的任何事情实际上都是对score
做的。
提示
如果您想知道这是如何工作的,那么在下一章中讨论指针时将会有更多揭示。但简单来说,您可以将引用视为存储计算机内存中的位置/地址。内存中的位置与其引用的变量存储其值的位置相同。因此,对引用或变量的操作具有完全相同的效果。
现在,更重要的是更多地讨论引用的原因。使用引用有两个原因,我们已经提到过。这里再次总结一下:
-
更改/读取另一个函数中变量/对象的值,否则超出范围
-
传递/返回而不制作副本(因此更有效)
研究这段代码,然后我们可以讨论它:
void add(int n1, int n2, int a);
void referenceAdd(int n1, int n2, int& a);
int main()
{
int number1 = 2;
int number2 = 2;
int answer = 0;
add(number1, number2, answer);
// answer still equals zero because it is passed as a copy
// Nothing happens to answer in the scope of main
referenceAdd(number1, number2, answer);
// Now answer equals 4 because it was passed by reference
// When the referenceAdd funtion did this:
// answer = num1 + num 2;
// It is actually changing the value stored by a
return 0;
}
// Here are the two function definitions
// They are exactly the same except that
// the second passes a reference to a
add(int n1, int n2, int a)
{
a = n1 + n2;
// a now equals 4
// But when the function returns a is lost forever
}
referenceAdd(int n1, int n2, int& a)
{
a = n1 + n2;
// a now equals 4
// But a is a reference!
// So it is actually answer, back in main, that equals 4
}
先前的代码以add
和referenceAdd
两个函数的原型开始。add
函数接受三个int
变量,而referenceAdd
函数接受两个int
变量和一个int
引用。
当调用add
函数并传入变量number1
,number2
和answer
时,将复制这些值并操作新的本地变量以添加(n1
,n2
和a
)。因此,main
中的answer
仍然为零。
当调用referenceAdd
函数时,number1
和number2
再次按值传递。但是,answer
是按引用传递的。当将n1
加到n2
的值分配给引用a
时,实际上发生的是该值被分配回main
函数中的answer
。
很明显,我们永远不需要实际使用引用来处理如此简单的事情。但是,它确实演示了按引用传递的机制。
参考摘要
先前的代码演示了如何使用引用来使用另一个作用域中的代码来更改变量的值。除了非常方便之外,按引用传递也非常高效,因为不会进行复制。使用引用传递int
的示例有点模糊,因为int
太小,没有真正的效率提升。在本章后期,我们将使用引用传递整个级别布局,效率提升将是显著的。
提示
引用有一个需要注意的地方!您必须在创建引用时将其分配给一个变量。这意味着它并不完全灵活。现在不要担心这个问题。我们将在下一章中进一步探讨引用以及它们更灵活(稍微更复杂)的关系,指针。
这对于int
来说并不重要,但对于类的大对象来说可能很重要。当我们实现僵尸竞技场游戏的滚动背景时,我们将使用这种确切的技术。
SFML 顶点数组和精灵表
我们几乎准备好实现滚动背景了。我们只需要学习关于 SFML 顶点数组和精灵表。
什么是精灵表?
精灵表是一组图像,可以是动画帧或完全独立的图形,包含在一个图像文件中。仔细观察包含四个单独图像的精灵表,这些图像将用于绘制僵尸竞技场的背景:
SFML 允许我们以与本书中迄今为止的每个纹理完全相同的方式加载精灵表作为常规纹理。当我们将多个图像加载为单个纹理时,GPU 可以更有效地处理它。
提示
实际上,现代 PC 可以处理这四个纹理而不使用精灵表。由于我们的游戏将逐渐对硬件要求更高,因此值得使用这些技术。
当我们从精灵表中绘制图像时,我们需要确保引用我们需要的精灵表部分的精确像素坐标:
上一张图标记了每个部分/瓦片在精灵表中位置的坐标。这些坐标称为纹理坐标。我们将在我们的代码中使用这些纹理坐标来绘制我们需要的部分。
什么是顶点数组?
首先,我们需要问:什么是顶点?顶点是单个图形点,一个坐标。这个点由水平和垂直位置定义。顶点的复数是顶点。然后,顶点数组是整个顶点的集合。
在 SFML 中,顶点数组中的每个顶点还具有颜色和相关的额外顶点(一对坐标)称为纹理坐标。纹理坐标是我们想要使用的图像在精灵表中的位置。我们很快将看到如何使用单个顶点数组定位图形并选择要在每个位置显示的精灵表的一部分。
SFML VertexArray
类可以保存不同类型的顶点集。但是每个VertexArray
只能保存一种类型的集。我们使用适合场合的集类型。
视频游戏中常见的场景包括但不限于以下基元类型:
-
点:每个点一个单独的顶点。
-
线:每组两个顶点定义线的起点和终点。
-
三角形:每个点三个顶点。在使用的成千上万个中,这可能是复杂的 3D 模型或成对创建简单矩形(如精灵)中最常见的。
-
四边形:每组四个顶点,一种方便的方式来从精灵表中映射矩形区域。
在这个项目中,我们将使用四边形。
从瓦片构建背景
僵尸竞技场背景将由随机排列的方形图像组成。您可以将此排列视为地板上的瓦片。
在这个项目中,我们将使用带有四边形集的顶点数组。每个顶点将是四个(一个四边形)的集的一部分。每个顶点将定义背景瓦片的一个角落。每个纹理坐标将根据精灵表中特定图像的适当值进行保持。
让我们看一些代码来开始。这不是我们在项目中将使用的确切代码,但它非常接近,并使我们能够在转向我们将使用的实际实现之前研究顶点数组。
构建顶点数组
就像我们创建类的实例时一样,我们声明我们的新对象。以下代码声明了一个名为背景的VertexArray
类型的新对象:
// Create a vertex array
VertexArray background;
我们希望让我们的VertexArray
实例知道我们将使用哪种类型的基元。请记住,点、线、三角形和四边形都有不同数量的顶点。通过设置VertexArray
来保存特定类型,将可以知道每个基元的起始位置。在我们的情况下,我们想要四边形。以下是将执行此操作的代码:
// What primitive type are we using
background.setPrimitiveType(Quads);
与常规的 C++数组一样,VertexArray
需要设置大小。但是,VertexArray
更加灵活。它允许我们在游戏运行时更改其大小。大小可以在声明的同时配置,但是我们的背景需要随着每一波扩展。VertexArray
类通过resize
函数提供了这种功能。以下是将设置我们的竞技场大小为 10x10 个瓦片大小的代码:
// Set the size of the vertex array
background.resize(10 * 10 * 4);
在上一行代码中,第一个10
是宽度,第二个10
是高度,4
是四边形中的顶点数。我们可以直接传入 400,但是像这样显示计算清楚我们正在做什么。当我们真正编写项目时,我们将进一步声明每个计算部分的变量。
现在我们有一个VertexArray
准备好配置其数百个顶点。以下是我们如何设置前四个顶点(第一个四边形)的位置坐标:
// Position each vertex in the current quad
background[0].position = Vector2f(0, 0);
background[1].position = Vector2f(49, 0);
background[2].position = Vector2f(49,49);
background[3].position = Vector2f(0, 49);
这是我们如何将这些相同顶点的纹理坐标设置为精灵表中的第一个图像。图像文件中的这些坐标是0,0
(在左上角)到49,49
(在右下角):
// Set the texture coordinates of each vertex
background[0].texCoords = Vector2f(0, 0);
background[1].texCoords = Vector2f(49, 0);
background[2].texCoords = Vector2f(49, 49);
background[3].texCoords = Vector2f(0, 49);
如果我们想将纹理坐标设置为精灵表中的第二个图像,我们将编写如下代码:
// Set the texture coordinates of each vertex
background[0].texCoords = Vector2f(0, 50);
background[1].texCoords = Vector2f(49, 50);
background[2].texCoords = Vector2f(49, 99);
background[3].texCoords = Vector2f(0, 99);
当然,如果我们像这样逐个定义每个顶点,那么即使是一个简单的10
乘10
的竞技场也需要很长时间来配置。
当我们真正实现背景时,我们将设计一组嵌套的for
循环,循环遍历每个四边形,选择一个随机的背景图像,并分配适当的纹理坐标。
代码需要非常智能。它需要知道何时是边缘瓷砖,以便可以使用精灵表中的墙图像。它还需要使用适当的变量,知道精灵表中每个背景瓷砖的位置以及所需竞技场的总体大小。
我们将通过将所有代码放在单独的函数和单独的文件中,使这种复杂性变得可管理。我们将通过使用 C++引用,使VertexArray
在main
中可用。
我们很快就会谈到这些细节。您可能已经注意到,在任何时候我们都没有关联纹理(使用顶点数组的精灵表)。
使用顶点数组进行绘制
我们可以以与加载任何其他纹理相同的方式加载精灵表作为纹理,如下面的代码所示:
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
然后我们可以通过一次调用draw
来绘制整个VertexArray
:
// Draw the background
window.draw(background, &textureBackground);
前面的代码比将每个瓷砖作为单独精灵绘制要高效得多。
注意
在继续之前,请注意textureBackground
之前看起来有点奇怪的&
。您可能会立刻想到这与引用有关。这里发生的是,我们传递纹理的地址而不是实际的纹理。我们将在下一章中了解更多关于这个的知识。
现在我们可以利用我们对引用和顶点数组的知识来实现 Zombie Arena 项目的下一个阶段。
创建随机生成的滚动背景
我们将创建一个在单独文件中创建背景的函数。我们将确保通过使用顶点数组引用,背景将可用(在范围内)到main
函数。
由于我们将编写其他与main
函数共享数据的函数,我们将在一个新的头文件中提供这些函数的原型,并在ZombieArena.cpp
中包含它们(使用包含指令)。
为了实现这一点,让我们首先制作新的头文件。在解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,突出显示(通过左键单击)头文件(.h
),然后在名称字段中键入ZombieArena.h
。最后点击添加按钮。现在我们准备好为我们的新函数编写头文件。
在这个新的ZombieArena.h
头文件中,添加以下突出显示的代码,包括函数原型:
#pragma once
using namespace sf;
int createBackground(VertexArray& rVA, IntRect arena);
前面的代码使我们能够编写名为createBackground
的函数的定义。为了匹配原型,函数必须返回一个int
值,并接收VertexArray
引用和IntRect
对象作为参数。
现在我们可以创建一个新的.cpp
文件,在其中我们将编写函数定义。在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,突出显示(通过左键单击)C++文件(.cpp
),然后在名称字段中键入CreateBackground.cpp
。最后点击添加按钮。现在我们准备好编写将创建我们的背景的函数定义。
将以下代码添加到CreateBackground.cpp
文件中,然后我们将对其进行审查:
#include "stdafx.h"
#include "ZombieArena.h"
int createBackground(VertexArray& rVA, IntRect arena)
{
// Anything we do to rVA we are actually doing
// to background (in the main function)
// How big is each tile/texture
const int TILE_SIZE = 50;
const int TILE_TYPES = 3;
const int VERTS_IN_QUAD = 4;
int worldWidth = arena.width / TILE_SIZE;
int worldHeight = arena.height / TILE_SIZE;
// What type of primitive are we using?
rVA.setPrimitiveType(Quads);
// Set the size of the vertex array
rVA.resize(worldWidth * worldHeight * VERTS_IN_QUAD);
// Start at the beginning of the vertex array
int currentVertex = 0;
return TILE_SIZE;
}
在前面的代码中,我们编写了函数签名以及标记函数主体的大括号。
在函数主体中,我们声明并初始化了三个新的int
常量,用于保存我们在函数其余部分需要引用的值。它们是TILE_SIZE
、TILE_TYPES
和VERTS_IN_QUAD
。TILE_SIZE
常量指的是精灵表中每个图块的像素大小。TILE_TYPES
指的是精灵表中不同图块的数量。我们可以向精灵表中添加更多图块,将TILE_TYPES
更改为匹配,即将仍然有效。VERTS_IN_QUAD
指的是每个四边形中有四个顶点。与反复输入数字4
相比,使用这个常量更不容易出错,这点更清晰。
然后,我们声明并初始化了两个int
变量,worldWidth
和worldHeight
。这些变量可能看起来显而易见,因为它们的用途。它们的名称已经透露了它们的用途,但值得指出的是,它们指的是世界在图块数量上的宽度和高度,而不是像素。worldWidth
和worldHeight
变量通过将传入的竞技场的高度和宽度除以常量TILE_SIZE
来初始化。
接下来,我们将首次使用我们的引用。请记住,我们对rVA
所做的任何事情实际上都是对传入的变量所做的,该变量在main
函数中是可见的(或者当我们编写它时将可见)。
首先,我们准备使用rVA.setType
将顶点数组设置为四边形,然后通过调用rVA.resize
将其调整为合适的大小。我们向resize
函数传递worldWidth * worldHeight * VERTS_IN_QUAD
的结果,这恰好等于我们在准备完成后顶点数组的数量。
代码的最后一行声明并初始化currentVertex
为零。我们将使用currentVertex
循环遍历顶点数组,初始化所有顶点。
我们现在可以编写嵌套的for
循环的第一部分,以准备顶点数组。添加以下突出显示的代码,并根据我们对顶点数组的了解,尝试弄清楚它的作用:
// Start at the beginning of the vertex array
int currentVertex = 0;
for (int w = 0; w < worldWidth; w++)
{
for (int h = 0; h < worldHeight; h++)
{
// Position each vertex in the current quad
rVA[currentVertex + 0].position =
Vector2f(w * TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 1].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 2].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, (h * TILE_SIZE)
+ TILE_SIZE);
rVA[currentVertex + 3].position =
Vector2f((w * TILE_SIZE), (h * TILE_SIZE)
+ TILE_SIZE);
// Position ready for the next for vertices
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
}
我们刚刚添加的代码通过使用嵌套的for
循环来遍历顶点数组,首先遍历前四个顶点。currentVertex + 1
,currentVertex + 2
等等。
我们使用数组表示法访问数组中的每个顶点。rvA[currentVertex + 0]..
等等。使用数组表示法,我们调用position
函数rvA[currentVertex + 0].position...
。
在position
函数中,我们传递每个顶点的水平和垂直坐标。我们可以通过使用w
、h
和TILE_SIZE
的组合来以编程方式计算这些坐标。
在前面的代码结束时,我们通过使用代码currentVertex = currentVertex + VERTS_IN_QUAD
将currentVertex
定位到下一个嵌套for
循环的位置,使其向前移动四个位置(加四)。
当然,所有这些只是设置了我们顶点的坐标;它并没有从精灵表中分配纹理坐标。这是我们接下来要做的事情。
为了清楚地表明新代码放在哪里,我已经在我们刚刚编写的所有代码的上下文中显示了它。添加并学习突出显示的代码:
for (int w = 0; w < worldWidth; w++)
{
for (int h = 0; h < worldHeight; h++)
{
// Position each vertex in the current quad
rVA[currentVertex + 0].position =
Vector2f(w * TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 1].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 2].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, (h * TILE_SIZE)
+ TILE_SIZE);
rVA[currentVertex + 3].position =
Vector2f((w * TILE_SIZE), (h * TILE_SIZE)
+ TILE_SIZE);
// Define the position in the Texture for current quad
// Either grass, stone, bush or wall
if (h == 0 || h == worldHeight-1 ||
w == 0 || w == worldWidth-1)
{
// Use the wall texture
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
}
// Position ready for the next for vertices
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
}
前面的代码设置了每个顶点在精灵表中的坐标。请注意有点长的 if 条件。该条件检查当前四边形是否是竞技场中的第一个或最后一个四边形。如果是,则意味着它是边界的一部分。然后我们可以使用一个简单的公式,使用TILE_SIZE
和TILE_TYPES
来从精灵表中选择墙壁纹理。
逐个初始化数组表示法和texCoords
成员,以为每个顶点分配墙纹理在精灵表中的适当角落。
以下代码包含在else
块中。这意味着每次通过嵌套的 for 循环时,当四边形不代表边界/墙砖时,它将运行。在现有代码中添加突出显示的代码,然后我们可以检查它:
// Define position in Texture for current quad
// Either grass, stone, bush or wall
if (h == 0 || h == worldHeight-1 ||
w == 0 || w == worldWidth-1)
{
// Use the wall texture
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
}
else
{
// Use a random floor texture
srand((int)time(0) + h * w - h);
int mOrG = (rand() % TILE_TYPES);
int verticalOffset = mOrG * TILE_SIZE;
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + verticalOffset);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 + verticalOffset);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE + verticalOffset);
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE + verticalOffset);
}
// Position ready for the next for vertices
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
}
前面的新代码首先使用一个公式来为随机数生成器提供种子,每次通过循环时都会有不同的公式。然后,mOrG
变量用一个介于 0 和TILE_TYPES
之间的数字进行初始化。这正是我们随机选择瓦片类型所需要的。
注意
mOrG
代表泥土或草。名称是任意的。
现在,通过将mOrG
乘以TileSize
来声明和初始化一个名为verticalOffset
的变量。现在我们在精灵表中有一个垂直参考点,指向当前四边形随机选择的纹理的起始高度。
现在,我们使用一个简单的公式,涉及TILE_SIZE
和verticalOffset
,来为纹理的每个角分配精确的坐标到适当的顶点。
现在我们可以让我们的新函数在游戏引擎中发挥作用了。
使用背景
我们已经完成了棘手的事情,这将很简单。有三个步骤:
-
创建一个
VertexArray
。 -
在每个波次升级后初始化它。
-
在每一帧中绘制它。
添加以下突出显示的代码来声明一个名为background
的VertexArray
,并加载background_sheet.png
作为纹理:
// Create an instance of the Player class
Player player;
// The boundaries of the arena
IntRect arena;
// Create the backgroundVertexArray background;
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
// The main game loop
while (window.isOpen())
添加以下代码来调用createBackground
函数,传入background
作为引用和arena
作为值。请注意在突出显示的代码中,我们还修改了初始化tileSize
变量的方式。按照突出显示的代码精确添加:
if (state == State::PLAYING)
{
// Prepare thelevel
// We will modify the next two lines later
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// We will modify this line of code later
// int tileSize = 50;
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Reset the clock so there isn't a frame jump
clock.restart();
}
请注意,我们已经替换了int tileSize = 50
这行代码,因为我们直接从createBackground
函数的返回值中获取了该值。
提示
为了以后的代码清晰起见,你应该删除int tileSize = 50
这行代码及其相关的注释。我只是将它注释掉,以便为新代码提供更清晰的上下文。
最后,是时候开始绘制了。这很简单。我们只需要调用window.draw
并传递VertexArray
以及textureBackground
纹理:
/*
**************
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 background
window.draw(background, &textureBackground);
// Draw the player
window.draw(player.getSprite());
}
提示
如果你想知道textureBackground
前面那个奇怪的&
符号是什么意思,那么一切将在下一章中变得清晰起来。
你现在可以按照下图运行游戏:
请注意,玩家精灵在竞技场范围内平稳滑动和旋转。尽管主要代码中绘制了一个小竞技场,但CreateBackground
函数可以创建我们告诉它的任何大小的竞技场。我们将在第十一章中看到比屏幕更大的竞技场:声音效果,文件 I/O 和完成游戏。
FAQ
以下是一些可能在你脑海中的问题:
Q)你能再总结一下这些参考资料吗?
A)你必须立即初始化引用,并且不能将其更改为引用另一个变量。使用引用与函数一起,这样你就不会在副本上工作。这对效率很有好处,因为它避免了制作副本,并帮助我们更容易地将代码抽象成函数。
Q)有没有一种简单的方法来记住使用引用的主要好处?
A)为了帮助你记住引用的用途,考虑一下这首简短的韵文:
移动大对象可能会使我们的游戏变得卡顿,通过引用传递比复制更快。
总结
在本章中,我们发现了 C++引用,它们是特殊的变量,充当另一个变量的别名。当我们通过引用而不是值传递变量时,我们对引用所做的任何工作都会发生在调用函数中的变量上。
我们还学习了关于顶点数组,并创建了一个充满四边形的顶点数组,以从精灵表中绘制瓦片作为背景。
当然,房间里的大象是,我们的僵尸游戏没有任何僵尸。现在让我们通过学习 C++指针和标准模板库来解决这个问题。
第八章:指针,标准模板库和纹理管理
在这一章中,我们将学到很多,也会在游戏中完成很多工作。我们将首先学习关于指针的基本 C++主题。指针是保存内存地址的变量。通常,指针将保存另一个变量的内存地址。这听起来有点像引用,但我们将看到它们更加强大。我们还将使用指针来处理一个不断扩大的僵尸群。
我们还将学习标准模板库(STL),这是一组允许我们快速轻松地实现常见数据管理技术的类集合。
一旦我们理解了 STL 的基础知识,我们就能够利用这些新知识来管理游戏中的所有纹理,因为如果我们有 1000 个僵尸,我们实际上不希望为每一个加载一份僵尸图形到 GPU 中。
我们还将深入研究面向对象编程,并使用静态函数,这是一个类的函数,可以在没有类实例的情况下调用。同时,我们将看到如何设计一个类,以确保只能存在一个实例。当我们需要保证代码的不同部分将使用相同的数据时,这是理想的。
在这一章中,我们将学习以下主题:
-
学习关于指针
-
学习关于 STL
-
使用静态函数和单例类实现
Texture Holder
类 -
实现一个指向一群僵尸的指针
-
编辑一些现有的代码,使用
TextureHolder
类为玩家和背景
指针
在学习 C++编程时,指针可能会引起挫折。但实际上,这个概念很简单。
注意
指针是一个保存内存地址的变量。
就是这样!没有什么需要担心的。对初学者可能引起挫折的是语法,我们用来处理指针的代码。考虑到这一点,我们将逐步介绍使用指针的代码的每个部分。然后你可以开始不断地掌握它们。
提示
在这一部分,我们实际上会学到比这个项目需要的更多关于指针。在下一个项目中,我们将更多地使用指针。尽管如此,我们只是浅尝辄止。强烈建议进一步学习,我们将在最后一章更多地谈论这个问题。
我很少建议记忆事实、数字或语法是学习的最佳方式。然而,记忆与指针相关的相当简短但至关重要的语法可能是值得的。这样它就会深深地扎根在我们的大脑中,我们永远不会忘记它。然后我们可以讨论为什么我们需要指针,并研究它们与引用的关系。指针的类比可能会有所帮助。
提示
如果一个变量就像一座房子,它的内容就是它所持有的值,那么指针就是房子的地址。
我们在上一章中学到,当我们将值传递给函数,或者从函数返回值时,实际上是在制作一个完全与之前相同的新房子。我们正在复制传递给函数或从函数返回的值。
此时,指针可能开始听起来有点像引用。那是因为它们有点像引用。然而,指针更加灵活、强大,并且有它们自己特殊和独特的用途。这些特殊和独特的用途需要特殊和独特的语法。
指针语法
与指针相关的主要运算符有两个。第一个是取地址运算符:
'&'
第二个是解引用运算符:
'*'
现在我们将看一下我们如何使用这些运算符与指针。
你会注意到的第一件事是地址运算符与引用运算符相同。为了增加一个渴望成为 C++游戏程序员的人的困境,这两个运算符在不同的上下文中做不同的事情。从一开始就知道这一点是很有价值的。如果你盯着一些涉及指针的代码看,感觉自己要发疯,知道这一点:
提示
你是完全理智的!你只需要看看上下文的细节。
现在你知道,如果有什么东西不清楚和立即明显,那不是你的错。指针不是清晰和立即明显的,但仔细观察上下文会揭示发生了什么。
有了这个知识,你需要比以前的语法更加关注指针,以及这两个运算符是什么(地址运算符和解引用),我们现在可以开始看一些真正的指针代码了。
提示
确保在继续之前已经记住了这两个运算符。
声明指针
要声明一个新的指针,我们使用解引用运算符以及指针将要保存的变量的类型。看一下代码,我们将进一步讨论它:
// Declare a pointer to hold the address of a variable of type int
int* pHealth;
这段代码声明了一个名为pHealth
的新指针,可以保存int
类型变量的地址。请注意,我说的是可以保存int
类型的变量。与其他变量一样,指针也需要初始化一个值才能正确使用它。与其他变量一样,名称pHealth
是任意的。
通常习惯上,将指针的名称前缀为p
。这样在处理指针时更容易记住,并且可以将它们与常规变量区分开来。
解引用运算符周围使用的空格是可选的(因为 C++在语法上很少关心空格),但建议使用,因为它有助于可读性。看一下以下三行代码,它们做的事情完全相同。
我们刚刚在前面的例子中看到的格式,带有解引用运算符紧挨着类型:
int* pHealth;
解引用运算符两侧的空格是可选的。
int * pHealth;
解引用运算符紧挨着指针的名称:
int *pHealth;
了解这些可能性是值得的,这样当你阅读代码时,也许在网上,你会明白它们都是一样的。在本书中,我们将始终使用与类型紧挨着的解引用运算符的第一个选项。
就像常规变量只能成功地包含适当类型的数据一样,指针也应该只保存适当类型的变量的地址。
指向int
类型的指针不应该保存 String、Zombie、Player、Sprite、float 或任何其他类型的地址。
初始化指针
接下来我们可以看到如何将变量的地址存入指针中。看一下以下代码:
// A regular int variable called health
int health = 5;
// Declare a pointer to hold the address of a variable of type int
int* pHealth;
// Initialize pHealth to hold the address of health,
// using the "address of" operator
pHealth = &health;
在前面的代码中,我们声明了一个名为health
的int
变量,并将其初始化为5
。尽管我们以前从未讨论过,但这个变量必须在计算机内存中的某个地方。它必须有一个内存地址。
我们可以使用地址运算符访问这个地址。仔细看前面代码的最后一行。我们用health
的地址初始化了pHealth
,就像这样:
pHealth = &health;
我们的pHealth
现在保存了常规int
变量health
的地址。在 C++术语中,我们说pHealth
指向 health。
我们可以通过将pHealth
传递给一个函数来使用它,这样函数就可以处理health
,就像我们用引用一样。如果我们只是这样做,指针就没有存在的理由了。
重新初始化指针
指针,不像引用,可以重新初始化以指向不同的地址。看一下以下代码:
// A regular int variable called health
int health = 5;
int score = 0;
// Declare a pointer to hold the address of a variable of type int
int* pHealth;
// Initialize pHealth to hold the address of health
pHealth = &health;
// Re-initialize pHealth to hold the address of score
pHealth = &score;
现在pHealth
指向int
变量score
。
当然,我们的指针名称pHealth
现在有点模糊,可能应该被称为pIntPointer
。在这里要理解的关键是我们可以进行这种重新赋值。
到目前为止,我们实际上还没有使用指针来做任何其他事情,而只是简单地指向(保存内存地址)。 让我们看看如何访问指针指向的地址存储的值。 这将使它们真正有用。
解引用指针
因此,我们知道指针保存内存中的地址。 如果我们在游戏中输出这个地址,也许在我们的 HUD 中,声明并初始化后,它可能看起来像这样:9876
。
它只是一个值。 一个代表内存中地址的值。 在不同的操作系统和硬件类型上,这些值的范围会有所不同。 在本书的上下文中,我们从不需要直接操作地址。 我们只关心指向的地址存储的值是什么。
变量使用的实际地址是在游戏执行时(在运行时)确定的,因此,在编写游戏时,无法知道变量的地址以及指针中存储的值。
我们通过使用解引用运算符*
访问指针指向的地址存储的值。 以下代码直接操作了一些变量,并使用了指针。 试着跟着走,然后我们会解释一下。
提示
警告! 接下来的代码毫无意义(有点刻意)。 它只是演示使用指针。
// Some regular int variables
int score = 0;
int hiScore = 10;
// Declare 2 pointers to hold the addresses of ints
int* pIntPointer1;
int* pIntPointer2;
// Initialize pIntPointer1 to hold the address of score
pIntPointer1 = &score;
// Initialize pIntPointer2 to hold the address of hiScore
pIntPointer2 = &hiScore;
// Add 10 to score directly
score += 10;
// Score now equals 10
// Add 10 to score using pIntPointer1
*pIntPointer1 += 10;
// score now equals 20- A new high score
// Assign the new hi score to hiScore using only pointers
*pIntPointer2 = *pIntPointer1;
// hiScore and score both equal 20
在前面的代码中,我们声明了两个 int 变量,score
和hiScore
。 然后我们分别用零和十初始化它们。 接下来,我们声明了两个指向int
的指针。 它们是pIntPointer1
和pIntPointer2
。 我们在声明它们的同时初始化它们,以保存(指向)变量score
和hiScore
的地址。
接下来,我们以通常的方式给score
加上十分,score += 10
。 然后我们看到,通过在指针上使用解引用运算符,我们可以访问指向的地址存储的值。 以下代码实际上改变了由pIntPointer1
指向的变量存储的值:
// Add 10 to score using pIntPointer1
*pIntPointer1 += 10;
// score now equals 20, A new high score
前面代码的最后一部分解引用了两个指针,将pIntPointer1
指向的值分配为pIntPointer2
指向的值:
// Assign the new hi-score to hiScore with only pointers
*pIntPointer2 = *pIntPointer1;
// hiScore and score both equal 20
score
和hiScore
现在都等于20
。
指针是多才多艺且强大的
我们可以用指针做更多的事情。 以下是一些有用的事情。
动态分配的内存
到目前为止,我们所见过的所有指针都指向作用域仅限于它们创建的函数的内存地址。 因此,如果我们声明并初始化一个指向局部变量的指针,当函数返回时,指针、局部变量和内存地址都会消失。 它超出了作用域。
到目前为止,我们一直在使用预先决定的固定内存量。 此外,我们一直在使用的内存由操作系统控制,变量在我们调用和返回函数时会丢失和创建。 我们需要的是一种使用始终在作用域内的内存的方法,直到我们完成为止。 我们希望拥有可以自己调用并负责的内存。
当我们声明变量(包括指针)时,它们位于称为堆栈的内存区域中。 还有另一个内存区域,尽管由操作系统分配/控制,但可以在运行时分配。 这另一个内存区域称为自由存储,有时也称为堆。
提示
堆上的内存没有特定函数的作用域。 从函数返回不会删除堆上的内存。
这给了我们很大的力量。 通过访问计算机运行游戏的资源所限制的内存,我们可以规划具有大量对象的游戏。 在我们的情况下,我们想要一个庞大的僵尸群。 然而,正如蜘蛛侠的叔叔会毫不犹豫地提醒我们的那样,伴随着巨大的力量而来的是巨大的责任。
让我们看看如何使用指针来利用自由存储器上的内存,以及在完成后如何将该内存释放回操作系统。
要创建一个指向堆上值的指针,首先我们需要一个指针:
int* pToInt = nullptr;
在上一行代码中,我们声明了一个指针,就像我们以前看到的那样,但是由于我们没有将其初始化为指向一个变量,而是将其初始化为nullptr
。我们这样做是因为这是一个好习惯。考虑解引用一个指针(更改它指向的地址的值),当你甚至不知道它指向什么时。这将是编程等同于去射击场,蒙住某人的眼睛,让他转个圈,然后告诉他射击。通过将指针指向空(nullptr
),我们不会对其造成任何伤害。
当我们准备在自由存储器上请求内存时,我们使用new
关键字,如下面的代码行所示:
pToInt = new int;
指针pToInt
现在保存了在自由存储器上的内存地址,该内存大小刚好可以容纳一个int
值。
提示
任何分配的内存在程序结束时都会被返回。然而,重要的是要意识到,除非我们释放它,否则这段内存永远不会被释放(在我们的游戏执行中)。如果我们继续从自由存储器中获取内存而不归还,最终它将耗尽并且游戏会崩溃。
我们不太可能因为偶尔从自由存储器中获取int
大小的内存块而耗尽内存。但是,如果我们的程序有一个频繁执行请求内存的函数或循环,最终游戏将变慢然后崩溃。此外,如果我们在自由存储器上分配了大量对象并且没有正确管理它们,那么这种情况可能会很快发生。
下面的代码行,将之前由pToInt
指向的自由存储器上的内存返回(删除):
delete pToInt;
现在,之前由pToInt
指向的内存不再属于我们,我们必须采取预防措施。尽管内存已经返回给操作系统,但pToInt
仍然保存着这段内存的地址,这段内存不再属于我们。
下面的代码行确保pToInt
不能用于尝试操作或访问这段内存:
pToInt = nullptr;
提示
如果指针指向的地址无效,则称为野指针或悬空指针。如果您尝试对悬空指针进行解引用,如果幸运的话,游戏会崩溃,并且会收到内存访问违规错误。如果不幸的话,您将创建一个非常难以找到的错误。此外,如果我们使用自由存储器上的内存超出函数生命周期,我们必须确保保留指向它的指针,否则我们将泄漏内存。
现在我们可以声明指针并将它们指向自由存储器上新分配的内存。我们可以通过对它们进行解引用来操作和访问它们指向的内存。当我们完成后,我们可以将内存返回到自由存储器,并且我们知道如何避免悬空指针。
让我们看看指针的一些更多优势。
将指针传递给函数
首先,我们需要编写一个具有指针在签名中的函数,如下面的代码:
void myFunction(int *pInt)
{
// dereference and increment the value stored
// at the address pointed to by the pointer
*pInt ++
return;
}
前面的函数只是对指针进行解引用,并将存储在指定地址的值加一。
现在我们可以使用该函数,并显式地传递一个变量的地址或另一个指向变量的指针:
int someInt = 10;
int* pToInt = &someInt;
myFunction(&someInt);
// someInt now equals 11
myFunction(pToInt);
// someInt now equals 12
现在,如前面的代码所示,在函数内部,我们实际上正在操作来自调用代码的变量,并且可以使用变量的地址或指向该变量的指针来这样做。
声明并使用指向对象的指针
指针不仅适用于常规变量。我们还可以声明指向用户定义类型(如我们的类)的指针。这是我们声明指向类型为Player
的对象的指针的方法:
Player player;
Player* pPlayer = &Player;
我们甚至可以直接从指针访问Player
对象的成员函数,就像下面的代码一样:
// Call a member function of the player class
pPlayer->moveLeft()
在这个项目中,我们不需要使用指向对象的指针,我们将在下一个项目中更加仔细地探讨它们。
指针和数组
数组和指针有一些共同之处。数组名是一个内存地址。更具体地说,数组的名称是数组中第一个元素的内存地址。换句话说,数组名指向数组的第一个元素。理解这一点的最好方法是继续阅读,看下一个例子。
我们可以创建一个指向数组保存的类型的指针,然后使用指针以与我们使用数组完全相同的方式使用相同的语法:
// Declare an array of ints
int arrayOfInts[100];
// Declare a pointer to int and initialize it with the address of the first element of the array, arrayOfInts
int* pToIntArray = arrayOfInts;
// Use pToIntArray just as you would arrayOfInts
arrayOfInts[0] = 999;
// First element of arrayOfInts now equals 999
pToIntArray[0] = 0;
// First element of arrayOfInts now equals 0
这也意味着一个具有接受指针原型的函数也接受指针指向的类型的数组。当我们建立我们不断增加的僵尸群时,我们将利用这一事实。
提示
关于指针和引用之间的关系,编译器在实现我们的引用时实际上使用指针。这意味着引用只是一个方便的工具(在幕后使用指针)。你可以把引用看作是一种自动变速箱,适合在城里开车,而指针是一种手动变速箱,更复杂,但正确使用时能够获得更好的结果/性能/灵活性。
指针总结
指针有时有点棘手。事实上,我们对指针的讨论只是对这个主题的一个介绍。要想熟练掌握它们,唯一的方法就是尽可能多地使用它们。在完成这个项目时,你需要理解关于指针的以下内容:
-
指针是存储内存地址的变量。
-
我们可以将指针传递给函数,直接从调用函数的范围内调用函数中操作值。数组是第一个元素的内存地址。我们可以将这个地址作为指针传递,因为这正是它的作用。
-
我们可以使用指针指向自由存储器上的内存。这意味着我们可以在游戏运行时动态分配大量内存。
提示
为了进一步使指针的问题变得神秘,C++最近进行了升级。现在有更多的方法来使用指针。我们将在最后一章学习一些关于智能指针的知识。
还有一个主题要讨论,然后我们可以再次开始编写僵尸竞技场项目。
标准模板库
STL 是一组数据容器和操作我们放入这些容器中的数据的方法。更具体地说,它是一种存储和操作不同类型的 C++变量和类的方法。
我们可以将不同的容器视为定制和更高级的数组。STL 是 C++的一部分。它不是一个可选的需要设置的东西,比如 SFML。
STL 是 C++的一部分,因为它的容器和操作它们的代码对许多应用程序需要使用的许多类型的代码至关重要。
简而言之,STL 实现了我们和几乎每个 C++程序员几乎肯定需要的代码,至少在某个时候可能会经常需要。
如果我们要编写自己的代码来包含和管理我们的数据,那么我们不太可能像编写 STL 的人那样高效地编写它。
因此,通过使用 STL,我们保证使用最佳编写的代码来管理我们的数据。甚至 SFML 也使用 STL。例如,在幕后,VertexArray
类使用 STL。
我们所需要做的就是从可用的容器中选择正确的类型。通过 STL 可用的容器类型包括以下内容:
-
向量:就像一个带有助推器的数组。动态调整大小,排序和搜索。这可能是最有用的容器。
-
列表:允许对数据进行排序的容器。
-
Map:一种允许用户将数据存储为键/值对的关联容器。这是一种数据是查找另一种数据的关键的地方。地图也可以增长和缩小,以及进行搜索。
-
Set:一个容器,保证每个元素都是唯一的。
注意
有关 STL 容器类型和解释的完整列表,请访问以下链接:www.tutorialspoint.com/cplusplus/cpp_stl_tutorial.htm
在僵尸竞技场游戏中,我们将使用地图。
提示
如果您想一窥 STL 为我们节省的复杂性,那么请看一下这个教程,该教程实现了列表将要做的事情。请注意,该教程仅实现了列表的最简单的基本功能:www.sanfoundry.com/cpp-program-implement-single-linked-list/
。
我们可以很容易地看到,如果我们探索 STL,我们将节省大量时间,并且最终会得到一个更好的游戏。让我们更仔细地看看如何使用 Map,然后我们将看到它在僵尸竞技场游戏中对我们有多有用。
什么是地图
Map是一个动态可调整大小的容器。我们可以轻松地添加和删除元素。与 STL 中的其他容器相比,地图的特殊之处在于我们访问其中的数据的方式。
地图中的数据是成对存储的。考虑这样一种情况,您登录到一个帐户,可能使用用户名和密码。地图非常适合查找用户名,然后检查相关密码的值。
地图也可以用于诸如帐户名称和数字,或者公司名称和股价等事物。
请注意,当我们使用 STL 中的 Map 时,我们决定形成键值对的值的类型。这些值可以是数据类型,如 string 和 int,例如帐户号码,用户名和密码等字符串,或者用户定义的类型,如对象。
接下来是一些真实的代码,让我们熟悉地图。
声明地图
这是我们如何声明一个 Map 的方式:
map<string, int> accounts;
前一行代码声明了一个名为accounts
的新map
,它具有 String 对象的键,每个键将引用一个 int 值。
现在我们可以存储字符串到数据类型(如 int)的键值对,接下来我们将看到如何做到这一点。
向地图中添加数据
让我们继续向帐户添加键值对:
accounts["John"] = 1234567;
现在有一个可以使用 John 作为键访问的地图条目。以下代码向帐户map
添加了另外两个条目:
accounts["Onkar"] = 7654321;
accounts["Wilson"] = 8866772;
我们的地图中有三个条目。让我们看看如何访问帐户号码。
在地图中查找数据
我们访问数据的方式与添加数据的方式完全相同,即使用键。例如,我们可以将键Onkar
存储的值赋给一个新的 intaccountNumber
,就像这样的代码:
int accountNumber = accounts["Onkar"];
int 变量accountNumber
现在存储值7654321
。我们可以对存储在地图中的值做任何我们可以对该类型的值做的事情。
从地图中删除数据
从我们的地图中取值也很简单。下一行代码删除了键John
及其关联的值:
accounts.erase("John");
让我们看看我们可以用 Map 做些什么。
检查地图的大小
我们可能想知道我们的地图中有多少键值对。下一行代码就是这样做的:
int size = accounts.size();
现在,int 变量 size 保存的值是 2。这是因为 accounts 保存了Onkar
和 Wilson 的值,我们删除了 John。
检查地图中的键
地图最相关的特性是使用键查找值的能力。我们可以这样测试特定键的存在与否:
if(accounts.find("John") != accounts.end())
{
// This code won't run because John was erased
}
if(accounts.find("Onkar") != accounts.end())
{
// This code will run because Onkar is in the map
}
在前面的代码中,“!= accounts.end”用于确定键是否存在或不存在。如果搜索的键在地图中不存在,那么accounts.end
将成为if
语句的结果。
循环/迭代地图的键值对
我们已经看到了如何使用for
循环来循环/迭代数组的所有值。如果我们想对 Map 做类似的事情怎么办?
以下代码显示了我们如何循环遍历 accounts Map 的每个键值对,并为每个帐户号码加一:
for (map<string,int>::iterator it = accounts.begin(); it ! =
accounts.end(); ++ it)
{
it->second +=1;
}
for 循环的条件可能是前面代码中最有趣的部分。条件的第一部分是最长的部分。如果我们把map<string,int>::iterator it = accounts.begin()
代码分解开来,它会更容易理解。
map<string,int>::iterator
代码是一种类型。我们声明了一个适用于具有string
和int
键值对的map
的iterator
。迭代器的名称是it
。我们将从accounts.begin()
返回的值赋给it
。迭代器it
现在保存了map
中的第一个键值对。
for
循环的条件的其余部分工作如下。代码it != accounts.end()
表示循环将继续直到达到map
的末尾,it++
只是在循环中每次通过时步进到下一个键值对。
在for
循环内,it->second
访问键值对的第二个元素,+=1
将值加一。请注意,我们可以使用it->first
访问键(它是键值对的第一部分)。
auto 关键字
在for
循环的条件中的代码相当冗长,特别是map<string,int>::iterator
类型。C++提供了一种简洁的方法来减少冗长,即使用auto
关键字。使用auto
关键字,我们可以改进前面的代码如下:
for (auto it = accounts.begin(); it != accounts.end(); ++ it)
{
it->second +=1;
}
auto 关键字指示编译器自动为我们推断类型。这将在我们编写的下一个类中特别有用。
STL 摘要
与本书中涵盖的几乎每个 C++概念一样,STL 是一个庞大的主题。已经有整整一本书专门讨论 STL。然而,到目前为止,我们已经了解到足够的知识来构建一个使用 STL Map 来存储 SFML Texture
对象的类。然后我们可以通过使用文件名作为键的键值对来检索/加载纹理。
为什么我们要增加这种额外的复杂性,而不是像到目前为止一样继续使用Texture
类,随着我们的进行,这将变得明显。
TextureHolder 类
成千上万的僵尸代表了一个新的挑战。不仅加载、存储和操作三种不同僵尸纹理的成千上万个副本会占用大量内存,还会占用大量处理能力。我们将创建一个新类型的类来解决这个问题,并允许我们只存储每种纹理的一个副本。
我们还将以这样的方式编写类,使得它只能有一个实例。这种类型的类被称为单例。
单例是一种设计模式,一种已被证明有效的代码结构方式。
此外,我们还将编写类,以便可以直接通过类名在我们的游戏代码中的任何地方使用它,而无需访问实例。
编写 TextureHolder 头文件
创建新的头文件。在解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,选择(通过左键单击)头文件( .h
),然后在名称字段中输入TextureHolder.h
。
将以下代码添加到TextureHolder.h
文件中,然后我们可以讨论它:
#pragma once
#ifndef TEXTURE_HOLDER_H
#define TEXTURE_HOLDER_H
#include <SFML/Graphics.hpp>
#include <map>
using namespace sf;
using namespace std;
class TextureHolder
{
private:
// A map container from the STL,
// that holds related pairs of String and Texture
std::map<std::string, Texture> m_Textures;
// A pointer of the same type as the class itself
// the one and only instance
static TextureHolder* m_s_Instance;
public:
TextureHolder();
static Texture& GetTexture(string const& filename);
};
#endif
在前面的代码中,注意我们为 STL 中的map
包含了一个包含指令。我们声明了一个包含 String 和 SFML Texture
键值对的map
。这个map
被称为m_Textures
。
在前面的代码中,接下来是这行:
static TextureHolder* m_s_Instance;
前一行代码非常有趣。我们声明了一个指向TextureHolder
类型对象的静态指针,称为m_s_Instance
。这意味着TextureHolder
类有一个与自身相同类型的对象。不仅如此,因为它是静态的,所以可以通过类本身使用,而无需类的实例。当我们编写相关的.cpp
文件时,我们将看到如何使用它。
在类的public
部分,我们有构造函数TextureHolder
的原型。构造函数不带参数,并且像通常一样没有返回类型。这与默认构造函数相同。我们将使用定义来覆盖默认构造函数,使我们的单例工作如我们所希望的那样。
我们还有另一个名为GetTexture
的函数。让我们再次看一下签名,并分析到底发生了什么:
static Texture& GetTexture(string const& filename);
首先,注意函数返回一个Texture
的引用。这意味着GetTexture
将返回一个引用,这是有效的,因为它避免了对可能是相当大的图形进行复制。还要注意函数声明为static
。这意味着该函数可以在没有类实例的情况下使用。该函数以String
作为常量引用作为参数。这样做的效果是双重的。首先,操作是有效的,其次,因为引用是常量的,所以它是不可改变的。
编写 TextureHolder 函数定义
现在我们可以创建一个新的.cpp
文件,其中包含函数定义。这将使我们能够看到我们新类型的函数和变量背后的原因。在解决方案资源管理器中右键单击源文件,然后选择添加 | 新项目...。在添加新项窗口中,通过左键单击突出显示C++文件(.cpp
),然后在名称字段中键入TextureHolder.cpp
。最后,单击添加按钮。我们现在准备编写类的代码。
添加以下代码,然后我们可以讨论它:
#include "stdafx.h"
#include "TextureHolder.h"
// Include the "assert feature"
#include <assert.h>
TextureHolder* TextureHolder::m_s_Instance = nullptr;
TextureHolder::TextureHolder()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
}
在前面的代码中,我们将指向TextureHolder
类型的指针初始化为nullptr
。在构造函数中,代码assert(m_s_Instance == nullptr)
确保m_s_Instance
等于nullptr
。如果不是,则游戏将退出执行。然后代码m_s_Instance = this
将指针分配给此实例。现在考虑一下这段代码发生在哪里。代码在构造函数中。构造函数是我们从类中创建对象实例的方式。因此,实际上我们现在有一个指向TextureHolder
的指针,指向自身的唯一实例。
将最后一部分代码添加到TextureHolder.cpp
文件中。接下来的注释比代码更多。在添加代码时,请检查代码并阅读注释,然后我们可以一起讨论:
sf::Texture& TextureHolder::GetTexture(std::string const& filename)
{
// Get a reference to m_Textures using m_S_Instance
auto& m = m_s_Instance->m_Textures;
// auto is the equivalent of map<string, Texture>
// Create an iterator to hold a key-value-pair (kvp)
// and search for the required kvp
// using the passed in filename
auto keyValuePair = m.find(filename);
// auto is equivelant of map<string, Texture>::iterator
// Did we find a match?
if (keyValuePair != m.end())
{
// Yes
// Return the texture,
// the second part of the kvp, the texture
return keyValuePair->second;
}
else
{
// Filename not found
// Create a new key value pair using the filename
auto& texture = m[filename];
// Load the texture from file in the usual way
texture.loadFromFile(filename);
// Return the texture to the calling code
return texture;
}
}
您可能会注意到前面代码中的第一件事是auto
关键字。auto
关键字在前一节中有解释。
提示
如果您想知道auto
替换的实际类型是什么,请看一下前面代码中每次使用auto
后面的注释。
在代码的开头,我们获取了对m_textures
的引用。然后我们尝试获取一个迭代器,该迭代器表示传入的文件名(filename
)所代表的键值对。如果我们找到匹配的键,我们返回return keyValuePair->second
的纹理。否则,我们将纹理添加到map
中,然后将其返回给调用代码。
诚然,TextureHolder
类引入了许多新概念(单例、static
函数、常量引用、this
和auto
关键字)和语法。再加上我们刚刚学习了指针和 STL,这一部分的代码可能有点令人生畏。
TextureHolder 到底实现了什么?
重点是现在我们有了这个类,我们可以在代码中随意使用纹理,而不必担心内存不足或者在特定函数或类中访问特定纹理。我们很快就会看到如何使用TextureHolder
。
构建一群僵尸
现在我们有了TextureHolder
类,以确保我们的僵尸纹理易于获取,并且只加载到 GPU 一次,我们可以着手创建一整群僵尸。
我们将把僵尸存储在一个数组中,由于构建和生成一群僵尸的过程涉及相当多的代码行,因此将其抽象为一个单独的函数是一个很好的选择。很快我们将编写CreateHorde
函数,但首先,当然,我们需要一个Zombie
类。
编写 Zombie.h 文件
构建代表僵尸的类的第一步是在头文件中编写成员变量和函数原型。
在解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,突出显示(单击左键)头文件(.h),然后在名称字段中键入Zombie.h
。
将以下代码添加到Zombie.h
文件中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Zombie
{
private:
// How fast is each zombie type?
const float BLOATER_SPEED = 40;
const float CHASER_SPEED = 80;
const float CRAWLER_SPEED = 20;
// How tough is each zombie type
const float BLOATER_HEALTH = 5;
const float CHASER_HEALTH = 1;
const float CRAWLER_HEALTH = 3;
// Make each zombie vary its speed slightly
const int MAX_VARRIANCE = 30;
const int OFFSET = 101 - MAX_VARRIANCE;
// Where is this zombie?
Vector2f m_Position;
// A sprite for the zombie
Sprite m_Sprite;
// How fast can this one run/crawl?
float m_Speed;
// How much health has it got?
float m_Health;
// Is it still alive?
bool m_Alive;
// Public prototypes go here
};
先前的代码声明了Zombie
类的所有私有成员变量。在先前的代码顶部,我们有三个常量变量来保存每种类型僵尸的速度。一个非常缓慢的爬行者,一个稍快的膨胀者,以及一个相当快的追逐者。我们可以尝试调整这三个常量的值,以帮助平衡游戏的难度级别。值得一提的是,这三个值仅用作每种僵尸类型速度的起始值。正如我们将在本章后面看到的,我们将从这些值中以一小百分比变化每个僵尸的速度。这样可以防止相同类型的僵尸在追逐玩家时聚集在一起。
接下来的三个常量确定了每种僵尸类型的生命值。请注意,膨胀者是最坚韧的,其次是爬行者。为了平衡,追逐者僵尸将是最容易被杀死的。
接下来我们有两个更多的常量MAX_VARIANCE
和OFFSET;
,这些将帮助我们确定每个僵尸的个体速度。当我们编写Zombie.cpp
文件时,我们将看到具体如何做到这一点。
在这些常量之后,我们声明了一堆变量,这些变量应该看起来很熟悉,因为我们在Player
类中有非常相似的变量。m_Position
、m_Sprite
、m_Speed
和m_Health
变量分别代表了僵尸对象的位置、精灵、速度和生命值。
最后,在先前的代码中,我们声明了一个布尔值m_Alive
,当僵尸活着并追捕时为true
,但当其生命值降到零时为false
,它只是我们漂亮背景上的一滩血迹。
现在来完成Zombie.h
文件。添加下面突出显示的函数原型,然后我们将讨论它们:
// Is it still alive?
bool m_Alive;
// Public prototypes go here
public:
// Handle when a bullet hits a zombie
bool hit();
// Find out if the zombie is alive
bool isAlive();
// Spawn a new zombie
void spawn(float startX, float startY, int type, int seed);
// Return a rectangle that is the position in the world
FloatRect getPosition();
// Get a copy of the sprite to draw
Sprite getSprite();
// Update the zombie each frame
void update(float elapsedTime, Vector2f playerLocation);
};
在先前的代码中,有一个hit
函数,我们可以在僵尸被子弹击中时调用它。该函数可以采取必要的步骤,比如从僵尸身上减少生命值(减少m_Health
的值)或者将其杀死(将m_Alive
设置为 false)。
isAlive
函数返回一个布尔值,让调用代码知道僵尸是活着还是死了。我们不希望对走过血迹时发生碰撞检测或从玩家身上减少生命值。
spawn
函数接受一个起始位置、一个类型(爬行者、膨胀者或追逐者,用一个整数表示),以及一个种子,用于一些我们将在下一节中看到的随机数生成。
就像在Player
类中一样,Zombie
类有getPosition
和getSprite
函数,用于获取代表僵尸所占空间的矩形和可以在每一帧绘制的精灵。
上一个代码中的最后一个原型是update
方法。我们可能已经猜到它会接收自上一帧以来的经过的时间,但也要注意它接收了一个名为playerLocation
的Vector2f
。这个向量确实是玩家中心的确切坐标。我们很快就会看到我们如何使用这个向量来追逐玩家。
编写 Zombie.cpp 文件
接下来我们将编写 Zombie 类的实际功能,即函数定义。
创建一个新的.cpp
文件,其中包含函数定义。在解决方案资源管理器中右键单击源文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击C++文件(.cpp
),然后在名称字段中键入Zombie.cpp
。最后,单击添加按钮。我们现在准备好编写类了。
现在将以下代码添加到Zombie.cpp
文件中:
#include "stdafx.h"
#include "zombie.h"
#include "TextureHolder.h"
#include <cstdlib>
#include <ctime>
using namespace std;
首先添加必要的包含指令,然后添加using namespace std
这一行。您可能还记得我们在一些情况下在对象声明前面加上了std::
。这个using
指令意味着我们在这个文件中的代码不需要这样做。
现在添加以下代码,这是spawn
函数的定义。添加后,请仔细研究代码,然后我们将逐步讲解:
void Zombie::spawn(float startX, float startY, int type, int seed)
{
switch (type)
{
case 0:
// Bloater
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/bloater.png"));
m_Speed = 40;
m_Health = 5;
break;
case 1:
// Chaser
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/chaser.png"));
m_Speed = 70;
m_Health = 1;
break;
case 2:
// Crawler
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/crawler.png"));
m_Speed = 20;
m_Health = 3;
break;
}
// Modify the speed to make the zombie unique
// Every zombie is unique. Create a speed modifier
srand((int)time(0) * seed);
// Somewhere between 80 an 100
float modifier = (rand() % MAX_VARRIANCE) + OFFSET;
// Express this as a fraction of 1
modifier /= 100; // Now equals between .7 and 1
m_Speed *= modifier;
// Initialize its location
m_Position.x = startX;
m_Position.y = startY;
// Set its origin to its center
m_Sprite.setOrigin(25, 25);
// Set its position
m_Sprite.setPosition(m_Position);
}
函数的第一件事是基于传入的int
类型进行switch
。在switch
块内,为每种僵尸类型都有一个 case。根据类型和相应的纹理,速度和生命值被初始化为相关的成员变量。
有趣的是,我们使用静态的TextureHolder::GetTexture
函数来分配纹理。这意味着无论我们生成多少僵尸,GPU 的内存中最多只会有三种纹理。
前面代码的最后三行(不包括注释)分别执行以下操作:
-
用作参数传入的
seed
变量来初始化随机数生成器。 -
使用
rand
函数和MAX_VARIANCE
和OFFSET
常量声明和初始化modifier
浮点变量。结果是一个介于零和一之间的分数,可以用来使每个僵尸的速度都是独特的。我们之所以要这样做,是因为我们不希望僵尸们太过拥挤。 -
现在我们可以将
m_Speed
乘以modifier
,这样我们就得到了一个速度在这种特定类型的僵尸速度常量的MAX_VARRIANCE
百分比内的僵尸。
解决了速度之后,我们将startX
和startY
中传入的位置分别赋给m_Position.x
和m_Position.y
。
前面列表中的最后两行代码设置了精灵的原点为中心,并使用m_Position
向量来设置精灵的位置。
现在将以下代码添加到Zombie.cpp
文件中,用于hit
函数:
bool Zombie::hit()
{
m_Health--;
if (m_Health < 0)
{
// dead
m_Alive = false;
m_Sprite.setTexture(TextureHolder::GetTexture(
"graphics/blood.png"));
return true;
}
// injured but not dead yet
return false;
}
hit
函数非常简单。将m_Health
减一,然后检查m_Health
是否小于零。
如果小于零,将m_Alive
设置为 false,将僵尸的纹理替换为血迹,并返回 true 给调用代码,这样它就知道僵尸现在已经死了。
如果僵尸幸存下来,返回 false。
添加下面的三个 getter 函数,它们只是将一个值返回给调用代码:
bool Zombie::isAlive()
{
return m_Alive;
}
FloatRect Zombie::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Sprite Zombie::getSprite()
{
return m_Sprite;
}
前面的三个函数相当容易理解,也许除了getPosition
函数使用m_Sprite.getLocalBounds
函数来获取FloatRect
之外,这个例外。这个函数返回给调用代码。
最后,为Zombie
类添加update
函数的代码;仔细查看代码,然后我们将逐步讲解:
void Zombie::update(float elapsedTime,
Vector2f playerLocation)
{
float playerX = playerLocation.x;
float playerY = playerLocation.y;
// Update the zombie position variables
if (playerX > m_Position.x)
{
m_Position.x = m_Position.x +
m_Speed * elapsedTime;
}
if (playerY > m_Position.y)
{
m_Position.y = m_Position.y +
m_Speed * elapsedTime;
}
if (playerX < m_Position.x)
{
m_Position.x = m_Position.x -
m_Speed * elapsedTime;
}
if (playerY < m_Position.y)
{
m_Position.y = m_Position.y -
m_Speed * elapsedTime;
}
// Move the sprite
m_Sprite.setPosition(m_Position);
// Face the sprite in the correct direction
float angle = (atan2(playerY - m_Position.y,
playerX - m_Position.x)
* 180) / 3.141;
m_Sprite.setRotation(angle);
}
首先将playerLocation.x
和playerLocation.y
复制到本地变量playerX
和playerY
中。
接下来有四个if
语句。它们测试僵尸是否在当前玩家位置的左侧、右侧、上方或下方。这四个if
语句在评估为true
时,使用通常的公式speed * time
来适当地调整僵尸的m_Position.x
和m_Position.y
值。更具体地说,代码是m_Speed * elapsedTime
。
在四个if
语句之后,m_Sprite
被移动到它的新位置。
然后我们使用与之前用于玩家和鼠标指针的相同计算;不过这次是用于僵尸和玩家。这个计算找到了面向玩家的僵尸所需的角度。
最后,我们调用m_Sprite.setRotation
来实际旋转僵尸精灵。请记住,这个函数将在游戏的每一帧中为每个(活着的)僵尸调用。
使用 Zombie 类创建一个僵尸群
现在我们有了一个类来创建一个活着的、攻击的和可杀死的僵尸,我们想要生成一整群它们。
为了实现这一点,我们将编写一个单独的函数,并使用指针,以便我们可以引用在main
中声明但在不同范围内配置的我们的僵尸群。
在 Visual Studio 中打开ZombieArena.h
文件,并添加下面显示的突出显示的代码行:
#pragma once
#include "Zombie.h"
using namespace sf;
int createBackground(VertexArray& rVA, IntRect arena);
Zombie* createHorde(int numZombies, IntRect arena);
现在我们有了一个原型,我们可以编写函数定义了。
创建一个新的.cpp
文件,其中包含函数定义。在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,选择(通过左键单击)C++文件(.cpp
),然后在名称字段中键入CreateHorde.cpp
。最后,单击添加按钮。
将下面显示的代码添加到CreateHorde.cpp
文件中并学习它。之后,我们将把它分解成块并讨论它:
#include "stdafx.h"
#include "ZombieArena.h"
#include "Zombie.h"
Zombie* createHorde(int numZombies, IntRect arena)
{
Zombie* zombies = new Zombie[numZombies];
int maxY = arena.height - 20;
int minY = arena.top + 20;
int maxX = arena.width - 20;
int minX = arena.left + 20;
for (int i = 0; i < numZombies; i++)
{
// Which side should the zombie spawn
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
switch (side)
{
case 0:
// left
x = minX;
y = (rand() % maxY) + minY;
break;
case 1:
// right
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// top
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// bottom
x = (rand() % maxX) + minX;
y = maxY;
break;
}
// Bloater, crawler or runner
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// Spawn the new zombie into the array
zombies[i].spawn(x, y, type, i);
}
return zombies;
}
让我们再次逐步查看所有以前的代码。
首先我们添加了现在熟悉的包含指令:
#include "stdafx.h"
#include "ZombieArena.h"
#include "Zombie.h"
接下来是函数签名。请注意,函数必须返回一个指向Zombie
对象的指针。我们将创建一个Zombie
对象的数组。一旦我们创建了这个僵尸群,我们将返回这个数组。当我们返回数组时,实际上是返回数组的第一个元素的地址。这与本章前面学到的内容相同,也就是指针。函数签名还显示我们有两个参数。第一个参数numZombies
将是当前僵尸群所需的僵尸数量,第二个参数arena
是一个IntRect
,用于保存当前竞技场的大小,以便创建这个僵尸群。
在函数签名之后,我们声明了一个名为zombies
的指向Zombie
类型的指针,并用数组的第一个元素的内存地址进行初始化,这个数组是我们在堆上动态分配的。
Zombie* createHorde(int numZombies, IntRect arena)
{
Zombie* zombies = new Zombie[numZombies];
接下来的代码简单地将竞技场的边界复制到maxY
、minY
、maxX
和minX
中。我们从右边和底部减去 20 像素,同时在顶部和左边加上 20 像素。我们使用这四个局部变量来帮助定位每个僵尸。我们进行了 20 像素的调整,以防止僵尸出现在墙上。
int maxY = arena.height - 20;
int minY = arena.top + 20;
int maxX = arena.width - 20;
int minX = arena.left + 20;
现在我们进入一个for
循环,该循环将遍历从零到numZombies
的每个Zombie
对象在僵尸数组中的元素:
for (int i = 0; i < numZombies; i++)
在for
循环内,代码的第一件事是初始化随机数生成器,然后生成一个介于零和三之间的随机数。这个数字存储在side
变量中。我们将使用side
变量来决定僵尸是在竞技场的左侧、顶部、右侧还是底部生成。我们还声明了两个int
变量x
和y
。这两个变量将临时保存当前僵尸的实际水平和垂直坐标。
// Which side should the zombie spawn
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
在for
循环中,我们有一个switch
块,包含四个case
语句。注意case
语句分别为 0、1、2 和 3,而 switch 语句中的参数是 side。在每个 case 块内,我们使用一个预定值(minX、maxX、minY 或 maxY)和一个随机生成的值来初始化 x 和 y。仔细观察每个预定值和随机值的组合,你会发现它们适合将当前僵尸随机放置在竞技场的左侧、顶部、右侧或底部。这样做的效果是,每个僵尸可以在竞技场的外边缘随机生成:
switch (side)
{
case 0:
// left
x = minX;
y = (rand() % maxY) + minY;
break;
case 1:
// right
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// top
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// bottom
x = (rand() % maxX) + minX;
y = maxY;
break;
}
在for
循环内部,我们再次初始化随机数生成器,并生成一个介于 0 和 2 之间的随机数。我们将这个数字存储在 type 变量中。type 变量将决定当前僵尸是 Chaser、Bloater 还是 Crawler。
确定类型后,我们在zombies
数组中的当前Zombie
对象上调用spawn
函数。作为提醒,传入spawn
函数的参数确定了僵尸的起始位置和僵尸的类型。看似任意的i
被传入,因为它被用作一个唯一的种子,可以在适当的范围内随机变化僵尸的速度。这样可以防止我们的僵尸聚集在一起,而不是形成一群:
// Bloater, crawler or runner
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// Spawn the new zombie into the array
zombies[i].spawn(x, y, type, i);
for
循环对numZombies
中包含的每个僵尸重复一次,然后返回数组。再次提醒,数组只是它自身的第一个元素的地址。数组是在堆上动态分配的,因此在函数返回后它将持续存在:
return zombies;
现在我们可以让僵尸活过来。
让僵尸群复活
我们有一个Zombie
类和一个函数来随机生成一群僵尸。我们有TextureHolder
单例作为一种简洁的方式来保存仅三个纹理,可以用于数十甚至数千个僵尸。现在我们可以在main
中将僵尸群添加到我们的游戏引擎中。
添加以下突出显示的代码以包含TextureHolder
类。然后,在main
内部,我们初始化了唯一的TextureHolder
实例,可以在游戏的任何地方使用:
#include "stdafx.h"
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
using namespace sf;
int main()
{
// Here is the instance of TextureHolder
TextureHolder holder;
// The game will always be in one of four states
enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };
// Start with the GAME_OVER state
State state = State::GAME_OVER;
接下来几行突出显示的代码声明了一些控制变量,用于波开始时僵尸的数量、仍需杀死的僵尸数量,当然还有一个名为zombies
的Zombie
指针,我们将其初始化为nullptr
。
添加突出显示的代码:
// Create the background
VertexArray background;
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
// Prepare for a horde of zombies
int numZombies;
int numZombiesAlive;
Zombie* zombies = nullptr;
// The main game loop
while (window.isOpen())
接下来,在LEVELING_UP
部分嵌套的PLAYING
部分中,我们添加以下代码:
-
将
numZombies
初始化为10
。随着项目的进展,这将最终变得动态,并基于当前波数。 -
删除任何已分配的内存,否则每次调用
createHorde
都会占用越来越多的内存,而不释放先前僵尸群的内存 -
然后调用
createHorde
并将返回的内存地址分配给zombies
-
将
zombiesAlive
初始化为numZombies
,因为在这一点上我们还没有杀死任何僵尸
添加我们刚刚讨论过的突出显示的代码:
if (state == State::PLAYING)
{
// Prepare thelevel
// We will modify the next two lines later
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Create a horde of zombies
numZombies = 10;
// Delete the previously allocated memory (if it exists)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Reset the clock so there isn't a frame jump
clock.restart();
}
现在将以下突出显示的代码添加到ZombieArena.cpp
文件中:
/*
****************
UPDATE THE FRAME
****************
*/
if (state == State::PLAYING)
{
// Update the delta time
Time dt = clock.restart();
// Update the total game time
gameTimeTotal += dt;
// Make a decimal fraction of 1 from the delta time
float dtAsSeconds = dt.asSeconds();
// Where is the mouse pointer
mouseScreenPosition = Mouse::getPosition();
// Convert mouse position to world 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 center around the player
mainView.setCenter(player.getCenter());
// Loop through each Zombie and update them
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
}// End updating the scene
新代码所做的一切就是循环遍历僵尸数组,检查当前僵尸是否还活着,如果是的话,就用必要的参数调用它的update
函数。
添加以下代码来绘制所有的僵尸:
/*
**************
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 background
window.draw(background, &textureBackground);
// Draw the zombies
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
// Draw the player
window.draw(player.getSprite());
}
先前的代码循环遍历所有的僵尸,并调用getSprite
函数以允许draw
方法发挥作用。我们不检查僵尸是否还活着,因为即使僵尸已经死亡,我们也希望绘制血迹。
在主函数的末尾,我们确保删除了我们的指针,尽管从技术上讲这并非必要,因为游戏即将退出,操作系统将在return 0
语句之后回收所有使用的内存:
}// End of main game loop
// Delete the previously allocated memory (if it exists)
delete[] zombies;
return 0;
}
您可以运行游戏,看到僵尸在竞技场的边缘生成。它们会立即以各自的速度直奔玩家而去。为了好玩,我增加了竞技场的大小,并将僵尸数量增加到 1000。
这将以失败告终!
请注意,由于我们在第六章中编写的代码,您还可以使用Enter键暂停和恢复僵尸群的袭击:面向对象编程,类和 SFML 视图。
使用TextureHolder
类加载所有纹理
既然我们有了TextureHolder
类,我们可能会一致地使用它来加载所有的纹理。让我们对加载背景精灵表和玩家纹理的现有代码进行一些非常小的修改。
更改背景获取纹理的方式
在ZombieArena.cpp
文件中,找到这段代码:
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
删除先前突出显示的代码,并用以下突出显示的代码替换,该代码使用我们的新TextureHolder
类:
// Load the texture for our background vertex array
Texture textureBackground = TextureHolder::GetTexture(
"graphics/background_sheet.png");
更改 Player 获取纹理的方式
在Player.cpp
文件中,在构造函数内,找到这段代码:
#include "stdafx.h"
#include "player.h"
Player::Player()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
// Associate a texture with the sprite
// !!Watch this space!!
m_Texture.loadFromFile("graphics/player.png");
m_Sprite.setTexture(m_Texture);
// Set the origin of the sprite to the center,
// for smooth rotation
m_Sprite.setOrigin(25, 25);
}
删除先前突出显示的代码,并用使用我们的新TextureHolder
类的以下代码替换。此外,添加包含指令以将TextureHolder
头文件添加到文件中。新代码如下所示,突出显示在上下文中:
#include "stdafx.h"
#include "player.h"
#include "TextureHolder.h"
Player::Player()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
// Associate a texture with the sprite
// !!Watch this space!!
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/player.png"));
// Set the origin of the sprite to the center,
// for smooth rotation
m_Sprite.setOrigin(25, 25);
}
从现在开始,我们将使用TextureHolder
类加载所有纹理。
常见问题
以下是您可能会想到的一些问题:
Q)指针和引用有什么区别?
A)指针就像带有助推器的引用。指针可以更改指向不同变量(内存地址),以及指向自由存储器上动态分配的内存。
Q)数组和指针有什么关系?
A)数组实际上是指向它们第一个元素的常量指针。
Q)您能提醒我一下new
关键字和内存泄漏吗?
A)当我们使用new
关键字在自由存储器上使用内存时,即使创建它的函数已经返回并且所有局部变量都消失了,它仍然存在。当我们使用自由存储器上的内存时,我们必须释放它。因此,如果我们使用自由存储器上的内存,我们希望它在函数的生命周期之外持续存在,我们必须确保保留指向它的指针,否则我们将泄漏内存。这就像把所有的东西放在我们的房子里然后忘记我们住在哪里一样!当我们从createHorde
返回僵尸数组时,就像是把接力棒(内存地址)从createHorde
传递给main
。这就像是说好的,这是你的一群僵尸 - 现在它们是你的责任了。我们不希望我们的 RAM 中有任何泄漏的僵尸,所以我们必须记得在指向动态分配内存的指针上调用delete
。
总结
您可能已经注意到,这些僵尸似乎并不那么危险。它们只是漂浮在玩家身边,而不留下任何伤痕。目前这是件好事,因为玩家没有办法自卫。
在下一章中,我们将制作另外两个类。一个用于弹药和生命值的拾取,另一个用于玩家可以射击的子弹。在完成这些之后,我们将学习如何检测碰撞,以便子弹和僵尸造成一些伤害,并且玩家可以收集拾取物品。
第九章:碰撞检测、拾取物品和子弹
到目前为止,我们已经实现了游戏的主要视觉方面。我们有一个可控的角色在一个充满追逐他的僵尸的竞技场中奔跑。问题是它们彼此之间没有互动。僵尸可以毫无阻碍地穿过玩家。我们需要检测僵尸和玩家之间的碰撞。
如果僵尸能够伤害并最终杀死玩家,那么给玩家一些子弹是公平的。然后我们需要确保子弹能够击中并杀死僵尸。
同时,如果我们正在为子弹、僵尸和玩家编写碰撞检测代码,那么现在是添加用于健康和弹药拾取的类的好时机。
以下是我们将要做的事情以及我们将涵盖的主题顺序:
-
射击子弹
-
添加准星并隐藏鼠标指针
-
生成拾取物品
-
检测碰撞
编写子弹类
我们将使用 SFML 的RectangleShape
类来直观表示子弹。我们将编写一个Bullet
类,其中包含一个RectangleShape
成员以及其他成员数据和函数。我们将分几步向游戏中添加子弹:
-
首先,我们将编写
Bullet.h
文件。这将显示成员数据的所有细节和函数的原型。 -
接下来,我们将编写
Bullet.cpp
文件,其中当然将包含Bullet
类所有函数的定义。当我们逐步进行时,我将解释Bullet
类型的对象将如何工作和被控制。 -
最后,在
main
函数中,我们将声明一个完整的子弹数组。我们还将实现射击的控制方案,管理玩家剩余的弹药,并进行重新加载。
让我们从第一步开始。
编写子弹头文件
要创建新的头文件,右键单击 解决方案资源管理器中的头文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击头文件(.h
),然后在名称字段中键入Bullet.h
。
在Bullet.h
文件中,添加以下私有成员变量以及Bullet
类声明。然后我们可以运行并解释它们的用途:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Bullet
{
private:
// Where is the bullet?
Vector2f m_Position;
// What each bullet looks like
RectangleShape m_BulletShape;
// Is this bullet currently whizzing through the air
bool m_InFlight = false;
// How fast does a bullet travel?
float m_BulletSpeed = 1000;
// What fraction of 1 pixel does the bullet travel,
// Horizontally and vertically each frame?
// These values will be derived from m_BulletSpeed
float m_BulletDistanceX;
float m_BulletDistanceY;
// Some boundaries so the bullet doesn't fly forever
float m_MaxX;
float m_MinX;
float m_MaxY;
float m_MinY;
// Public function prototypes go here
在前面的代码中,第一个成员是一个名为m_Position
的Vector2f
,它将保存子弹在游戏世界中的位置。
接下来,我们声明了一个名为m_BulletShape
的RectangleShape
,因为我们为每颗子弹使用了一个简单的非纹理图形,有点像我们在 Timber!!!中为时间条所做的那样。
代码然后声明了一个Boolean m_InFlight
,它将跟踪子弹当前是否在空中飞行。这将使我们能够决定是否需要在每帧调用其update
函数,以及我们是否需要运行碰撞检测检查。
float
变量m_BulletSpeed
将(你可能猜到了)保存子弹的像素速度。它被初始化为1000
的值,这有点随意,但效果很好。
接下来我们有另外两个float
变量,m_BulletDistanceX
和m_BulletDistanceY
。由于移动子弹的计算比移动僵尸或玩家的计算稍微复杂一些,我们将受益于这两个变量,我们将对它们进行计算。它们将用于决定每帧子弹位置的水平和垂直变化。
最后,对于前面的代码,我们有另外四个float
变量(m_MaxX
、m_MinX
、m_MaxY
和m_MinY
),它们将稍后初始化以保存子弹的水平和垂直位置的最大和最小值。
很可能有些变量的需求并不立即显而易见,但当我们在Bullet.cpp
文件中看到它们各自发挥作用时,它们将变得更清晰。
现在将所有公共函数原型添加到Bullet.h
文件中:
// Public function prototypes go here
public:
// The constructor
Bullet();
// Stop the bullet
void stop();
// Returns the value of m_InFlight
bool isInFlight();
// Launch a new bullet
void shoot(float startX, float startY,
float xTarget, float yTarget);
// Tell the calling code where the bullet is in the world
FloatRect getPosition();
// Return the actual shape (for drawing)
RectangleShape getShape();
// Update the bullet each frame
void update(float elapsedTime);
让我们依次审查每个函数,然后我们可以继续编写它们的定义。
首先是Bullet
函数,当然是构造函数。在这个函数中,我们将为每个Bullet
实例设置好准备行动。
stop
函数将在子弹已经在行动但需要停止时被调用。
isInFlight
函数返回一个布尔值,用于测试子弹当前是否在飞行中。
shoot
函数的用途可以从其名称中得知,但它的工作方式值得讨论。现在,只需注意它有四个float
参数将被传入。这四个值代表子弹的起始(玩家所在位置)水平和垂直位置,以及垂直和水平目标位置(准星所在位置)。
getPosition
函数返回一个FloatRect
,表示子弹的位置。这个函数将用于检测与僵尸的碰撞。您可能还记得来自第八章:指针、标准模板库和纹理管理中,僵尸也有一个getPosition
函数。
接下来我们有getShape
函数,它返回一个RectangleShape
类型的对象。正如我们讨论过的,每个子弹在视觉上都由一个RectangleShape
对象表示。因此,getShape
函数将被用来获取RectangleShape
当前状态的副本,以便绘制它。
最后,也希望如预期的那样,有update
函数,它有一个float
参数,表示自上次调用update
以来经过的一秒钟的时间。update
方法将在每一帧改变子弹的位置。
让我们来看看并编写函数定义。
编写子弹源文件
现在我们可以创建一个新的.cpp
文件,其中包含函数定义。在解决方案资源管理器中右键单击源文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击C++文件(.cpp
)来突出显示,然后在名称字段中键入Bullet.cpp
。最后,单击添加按钮。我们现在准备好编写类了。
添加以下代码,这是包含指令和构造函数。我们知道这是构造函数,因为函数的名称与类名相同:
#include "stdafx.h"
#include "bullet.h"
// The constructor
Bullet::Bullet()
{
m_BulletShape.setSize(sf::Vector2f(2, 2));
}
Bullet
构造函数唯一需要做的事情就是设置m_BulletShape
的大小,这是RectangleShape
对象。代码将大小设置为两像素乘以两像素。
接下来是更实质性的shoot
函数。将以下代码添加到Bullet.cpp
文件中,研究它,然后我们可以讨论它:
void Bullet::shoot(float startX, float startY,
float targetX, float targetY)
{
// Keep track of the bullet
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
// Calculate the gradient of the flight path
float gradient = (startX - targetX) / (startY - targetY);
// Any gradient less than 1 needs to be negative
if (gradient < 0)
{
gradient *= -1;
}
// Calculate the ratio between x and y
float ratioXY = m_BulletSpeed / (1 + gradient);
// Set the "speed" horizontally and vertically
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
// Point the bullet in the right direction
if (targetX < startX)
{
m_BulletDistanceX *= -1;
}
if (targetY < startY)
{
m_BulletDistanceY *= -1;
}
// Set a max range of 1000 pixels
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
// Position the bullet ready to be drawn
m_BulletShape.setPosition(m_Position);
为了揭开shoot
函数的神秘面纱,我们将把它分解并讨论我们刚刚添加的代码块。
首先让我们回顾一下签名。shoot
函数接收子弹的起始和目标水平和垂直位置。调用代码将根据玩家精灵的位置和准星的位置提供这些值。这里是它的签名:
void Bullet::shoot(float startX, float startY, float targetX, float targetY)
在shoot
函数内部,我们将m_InFlight
设置为true
,并使用参数startX
和startY
定位子弹。这里是那段代码:
// Keep track of the bullet
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
现在我们使用一些简单的三角学来确定子弹的行进斜率。子弹的水平和垂直进展必须根据在子弹起始和目标之间绘制的线的斜率而变化。变化的速率不能相同,否则非常陡峭的射击将在水平位置到达之前到达垂直位置,对于较浅的射击则相反。
以下代码首先根据一条直线的方程推导出斜率。然后它检查斜率是否小于零,如果是,则乘以-1
。这是因为传入的起始和目标坐标可以是负数或正数,我们总是希望每帧的进度量是正数。乘以-1
只是将负数变成它的正数等价物,因为负数乘以负数得正数。实际的行进方向将在update
函数中处理,通过在这个函数中得到的正值进行加减。
接下来,我们通过将我们的子弹速度(m_BulletSpeed
)除以斜率加一来计算水平到垂直距离的比率。这将允许我们根据子弹所指向的目标,每帧正确地改变子弹的水平和垂直位置。
最后,在代码的这一部分,我们为m_BulletDistanceY
和m_BulletDistanceX
赋值:
// Calculate the gradient of the flight path
float gradient = (startX - targetX) / (startY - targetY);
// Any gradient less than zero needs to be negative
if (gradient < 0)
{
gradient *= -1;
}
// Calculate the ratio between x and y
float ratioXY = m_BulletSpeed / (1 + gradient);
// Set the "speed" horizontally and vertically
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
以下代码要简单得多。我们只是设置了子弹可以到达的最大水平和垂直位置。我们不希望子弹一直飞下去。我们将在update
函数中看到这一点,我们会测试子弹是否已经超过了它的最大或最小位置:
// Set a max range of 1000 pixels in any direction
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
以下代码将代表子弹的RectangleShape移动到其起始位置。我们像以前经常做的那样使用setPosition
函数:
// Position the bullet ready to be drawn
m_BulletShape.setPosition(m_Position);
接下来我们有四个简单直接的函数。添加stop
,isInFlight
,getPosition
和getShape
函数:
void Bullet::stop()
{
m_InFlight = false;
}
bool Bullet::isInFlight()
{
return m_InFlight;
}
FloatRect Bullet::getPosition()
{
return m_BulletShape.getGlobalBounds();
}
RectangleShape Bullet::getShape()
{
return m_BulletShape;
stop
函数只是将m_InFlight
变量设置为false
。isInFlight
函数返回当前这个变量的值。所以我们可以看到shoot
让子弹飞出去,stop
让它停下来,isInFlight
让我们知道当前的状态是什么。
getPosition
函数返回一个FloatRect
,我们将看到如何使用每个游戏对象的FloatRect
来检测碰撞,很快就会看到。
最后,对于之前的代码,getShape
返回一个RectangleShape
,所以我们可以在每一帧中绘制子弹。
在我们开始使用Bullet
对象之前,我们需要实现的最后一个函数是update
。添加以下代码,研究一下,然后我们可以讨论一下:
void Bullet::update(float elapsedTime)
{
// Update the bullet position variables
m_Position.x += m_BulletDistanceX * elapsedTime;
m_Position.y += m_BulletDistanceY * elapsedTime;
// Move the bullet
m_BulletShape.setPosition(m_Position);
// Has the bullet gone out of range?
if (m_Position.x < m_MinX || m_Position.x > m_MaxX ||
m_Position.y < m_MinY || m_Position.y > m_MaxY)
{
m_InFlight = false;
}
}
在update
函数中,我们使用m_BulletDistanceX
和m_BulletDistanceY
乘以自上一帧以来的时间来移动子弹。记住,这两个变量的值是在shoot
函数中计算的,并且表示移动子弹所需的斜率(彼此的比率)。然后我们使用setPosition
函数来实际移动RectangleShape
。
在update
中我们做的最后一件事是测试子弹是否已经超过了它的最大射程。稍微复杂的if
语句检查m_Position.x
和m_Position.y
与在shoot
函数中计算的最大和最小值。这些最大和最小值存储在m_MinX
,m_MaxX
,m_MinY
和m_MaxY
中。如果测试为真,则m_InFlight
设置为false
。
Bullet
类已经完成。现在我们可以看看如何在main
函数中射击一些子弹。
让子弹飞
我们将通过以下六个步骤使子弹可用:
-
为
Bullet
类添加必要的包含指令。 -
添加一些控制变量和一个数组来保存一些
Bullet
实例。 -
处理玩家按下R键重新装填。
-
处理玩家按下鼠标左键发射子弹。
-
在每一帧中更新所有正在飞行的子弹。
-
在每一帧中绘制正在飞行中的子弹。
包含 Bullet 类
添加包含指令以使 Bullet 类可用:
#include "stdafx.h"
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h" using namespace sf;
让我们继续下一步。
控制变量和子弹数组
这里有一些变量来跟踪子弹、弹夹大小、备用/剩余子弹、弹夹中的子弹、当前射速(每秒开始为一颗),以及上一颗子弹被射击的时间。
添加突出显示的代码,我们可以继续看到本节中所有这些变量的实际运行情况:
// Prepare for a horde of zombies
int numZombies;
int numZombiesAlive;
Zombie* zombies = NULL;
// 100 bullets should do
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
int clipSize = 6;
float fireRate = 1;
// When was the fire button last pressed?
Time lastPressed;
// The main game loop
while (window.isOpen())
接下来,让我们处理玩家按下R键时会发生什么,这个键用于重新装弹。
重新装弹
现在我们处理与射击子弹相关的玩家输入。首先,我们将处理按下R键重新装弹。我们使用 SFML 事件来实现。
添加下面突出显示的代码块。为了确保代码放在正确的位置,提供了大量上下文来展示。研究代码,然后我们可以讨论它:
// Handle events
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Pause a game while playing
if (event.key.code == Keyboard::Return &&
state == State::PLAYING)
{
state = State::PAUSED;
}
// Restart while paused
else if (event.key.code == Keyboard::Return &&
state == State::PAUSED)
{
state = State::PLAYING;
// Reset the clock so there isn't a frame jump
clock.restart();
}
// Start a new game while in GAME_OVER state
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
}
if (state == State::PLAYING)
{
// Reloading
if (event.key.code == Keyboard::R)
{
if (bulletsSpare >= clipSize)
{
// Plenty of bullets. Reload.
bulletsInClip = clipSize;
bulletsSpare -= clipSize;
}
else if (bulletsSpare > 0)
{
// Only few bullets left
bulletsInClip = bulletsSpare;
bulletsSpare = 0;
}
else
{
// More here soon?!
}
}
}
}
}// End event polling
先前的代码嵌套在游戏循环的事件处理部分(while(window.pollEvent)
)中,只有在游戏实际进行时执行的代码块内(if(state == State::Playing)
)。很明显,我们不希望在游戏结束或暂停时玩家重新装弹,通过描述的新代码实现了这一点。
在新代码本身中,我们首先测试是否按下了R键,使用if (event.key.code == Keyboard::R)
。一旦检测到按下R键,剩下的代码就会执行。以下是if
、else if
和else
块的结构:
if(bulletsSpare >= clipSize)
...
else if(bulletsSpare > 0)
...
else
...
先前的结构允许我们处理三种可能的情况。
-
玩家按下了
R
,并且他们有比弹夹能装下的更多的备用子弹。在这种情况下,弹夹被重新填充,备用子弹的数量减少。 -
玩家有一些备用子弹,但不足以完全填满弹夹。在这种情况下,弹夹将填满玩家拥有的尽可能多的备用子弹,并且备用子弹的数量被设置为零。
-
玩家按下了 R,但他们没有备用子弹。对于这种情况,我们实际上不需要改变变量。但是当我们在第十一章中实现声音时,我们会在这里播放声音效果,所以我们留下了空的
else
块。
最后,让我们实际射击一颗子弹。
射击子弹
接下来,我们可以处理按下鼠标左键来实际射击子弹。添加下面突出显示的代码并仔细研究它:
if (Keyboard::isKeyPressed(Keyboard::D))
{
player.moveRight();
}
else
{
player.stopRight();
}
// Fire a bullet
if (Mouse::isButtonPressed(sf::Mouse::Left))
{
if (gameTimeTotal.asMilliseconds()
- lastPressed.asMilliseconds()
> 1000 / fireRate && bulletsInClip > 0)
{
// Pass the center of the player
// and the center of the crosshair
// to the shoot function
bullets[currentBullet].shoot(
player.getCenter().x, player.getCenter().y,
mouseWorldPosition.x, mouseWorldPosition.y);
currentBullet++;
if (currentBullet > 99)
{
currentBullet = 0;
}
lastPressed = gameTimeTotal;
bulletsInClip--;
}
}// End fire a bullet
}// End WASD while playing
所有先前的代码都包裹在一个if
语句中,只有当按下鼠标左键时执行,if (Mouse::isButtonPressed(sf::Mouse::Left))
。请注意,即使玩家只是按住按钮,代码也会重复执行。我们现在要讨论的代码控制射速。
在先前的代码中,我们检查游戏中经过的总时间(gameTimeTotal
)减去玩家上次射击子弹的时间(lastPressed
)是否大于1000
除以当前射速,以及玩家弹夹中至少有一颗子弹。我们使用1000
是因为这是一秒钟内的毫秒数。
如果这个测试成功,那么实际射击子弹的代码就会执行。射击子弹很容易,因为我们在Bullet
类中已经做了所有的工作。我们只需在bullets
数组中的当前子弹上调用shoot
。我们传入玩家和准星的当前水平和垂直位置。子弹将由Bullet
类的shoot
函数中的代码进行配置和发射。
我们所要做的就是跟踪子弹数组。首先我们增加currentBullet
变量。然后我们检查是否用语句if (currentBullet > 99)
发射了最后一颗子弹(99
)。如果是最后一颗子弹,我们将currentBullet
设置为零。如果不是最后一颗子弹,那么下一颗子弹就准备好了,只要射速允许并且玩家按下鼠标左键。
最后,对于之前的代码,我们将子弹发射的时间存储在lastPressed
中,并减少bulletsInClip
。
现在我们可以每帧更新每一颗子弹。
每帧更新子弹
添加高亮代码来循环遍历子弹数组,检查子弹是否在飞行,如果是,调用它的更新函数:
// Loop through each Zombie and update them
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// Update any bullets that are in-flight
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
bullets[i].update(dtAsSeconds);
}
} }// End updating the scene
最后,我们可以绘制所有的子弹。
每帧绘制子弹
添加高亮代码来循环遍历bullets
数组,检查子弹是否在飞行中,如果是,就绘制它:
/*
**************
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 background
window.draw(background, &textureBackground);
// Draw the zombies
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
window.draw(bullets[i].getShape());
}
}
// Draw the player
window.draw(player.getSprite());
}
运行游戏来尝试子弹。注意你可以连续射击六次,然后需要按R重新装填。明显缺少的是弹夹中子弹数量和备用子弹数量的一些视觉指示。另一个问题是玩家很快就会用尽子弹,特别是因为子弹根本没有停止力。它们直接穿过僵尸。再加上玩家期望以鼠标指针而不是精确的准星瞄准,我们明显还有工作要做。
在下一章中,我们将通过 HUD 给出视觉反馈。接下来我们将用一个准星替换鼠标光标,然后在此之后生成一些拾取物品来补充子弹和生命值。最后,在本章中,我们将处理碰撞检测,使子弹和僵尸造成伤害,并使玩家能够真正获得拾取物品。
给玩家一个准星
添加一个准星很容易,只需要一个新的概念。添加高亮代码,然后我们可以运行它:
// 100 bullets should do
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
int clipSize = 6;
float fireRate = 1;
// When was the fire button last pressed?
Time lastPressed;
// Hide the mouse pointer and replace it with crosshair
window.setMouseCursorVisible(true);
Sprite spriteCrosshair;
Texture textureCrosshair =
TextureHolder::GetTexture("graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// The main game loop
while (window.isOpen())
首先我们在window
对象上调用setMouseCursorVisible
函数。然后我们加载一个Texture
,声明一个Sprite
,并以通常的方式初始化它。此外,我们将精灵的原点设置为它的中心,以使子弹飞向中心更加方便和简单,正如你所期望的那样。
现在我们需要每帧更新准星的世界坐标。添加高亮代码行,它使用mouseWorldPosition
向量来设置每帧的准星位置:
/*
****************
UPDATE THE FRAME
****************
*/
if (state == State::PLAYING)
{
// Update the delta time
Time dt = clock.restart();
// Update the total game time
gameTimeTotal += dt;
// Make a decimal fraction of 1 from the delta time
float dtAsSeconds = dt.asSeconds();
// Where is the mouse pointer
mouseScreenPosition = Mouse::getPosition();
// Convert mouse position to world coordinates of mainView
mouseWorldPosition = window.mapPixelToCoords(
Mouse::getPosition(), mainView);
// Set the crosshair to the mouse world location
spriteCrosshair.setPosition(mouseWorldPosition);
// Update the player
player.update(dtAsSeconds, Mouse::getPosition());
接下来,正如你可能期望的那样,我们可以为每一帧绘制准星。在指定位置添加高亮代码行。这行代码不需要解释,但它在所有其他游戏对象之后的位置很重要,这样它就会被绘制在最上面:
/*
**************
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 background
window.draw(background, &textureBackground);
// Draw the zombies
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
window.draw(bullets[i].getShape());
}
}
// Draw the player
window.draw(player.getSprite());
//Draw the crosshair
window.draw(spriteCrosshair);
}
现在你可以运行游戏,看到酷炫的准星,而不是鼠标光标:
注意子弹是如何整齐地穿过准星中心的。射击机制的工作方式类似于允许玩家选择从腰部射击或瞄准射击。如果玩家保持准星靠近中心,他可以快速射击和转身,但必须仔细判断远处僵尸的位置。
或者,玩家可以直接将准星悬停在远处僵尸的头部,进行精确射击;然而,如果僵尸从另一个方向袭击,那么他就需要更远地移动准星。
对游戏的一个有趣改进是为每一枪增加一点小的随机不准确性。这种不准确性可能会在波之间的升级中得到缓解。
编写一个用于拾取的类
我们将编写一个Pickup
类,其中有一个Sprite
成员以及其他成员数据和函数。我们将在几个步骤中向我们的游戏中添加拾取物品:
-
首先,我们将编写
Pickup.h
文件。这将揭示所有成员数据的细节和函数的原型。 -
接下来,我们将编写
Pickup.cpp
文件,其中当然将包含Pickup
类的所有函数的定义。当我们逐步进行时,我将解释Pickup
类型的对象将如何工作和被控制。 -
最后,我们将在
main
函数中使用Pickup
类来生成、更新和绘制它们。
让我们从第 1 步开始。
编写拾取物头文件
要创建新的头文件,在 解决方案资源管理器 中右键单击 头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件( .h
),然后在名称字段中键入Pickup.h
。
在Pickup.h
文件中添加并学习以下代码,然后我们可以逐步进行:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Pickup
{
private:
//Start value for health pickups
const int HEALTH_START_VALUE = 50;
const int AMMO_START_VALUE = 12;
const int START_WAIT_TIME = 10;
const int START_SECONDS_TO_LIVE = 5;
// The sprite that represents this pickup
Sprite m_Sprite;
// The arena it exists in
IntRect m_Arena;
// How much is this pickup worth?
int m_Value;
// What type of pickup is this?
// 1 = health, 2 = ammo
int m_Type;
// Handle spawning and disappearing
bool m_Spawned;
float m_SecondsSinceSpawn;
float m_SecondsSinceDeSpawn;
float m_SecondsToLive;
float m_SecondsToWait;
// Public prototypes go here
};
之前的代码声明了Pickup
类的所有私有变量。虽然这些变量的名称应该很直观,但为什么需要这么多变量可能并不明显。让我们从顶部开始逐个讲解:
-
const int HEALTH_START_VALUE = 50
:这个常量变量用于设置所有生命值拾取物的起始值。这个值将用于初始化m_Value
变量,在游戏过程中需要对其进行操作。 -
const int AMMO_START_VALUE = 12
:这个常量变量用于设置所有弹药拾取物的起始值。这个值将用于初始化m_Value
变量,在游戏过程中需要对其进行操作。 -
const int START_WAIT_TIME = 10
:这个变量是拾取物在消失后重新生成前要等多久。它将用于初始化m_SecondsToWait
变量,在游戏过程中可以对其进行操作。 -
const int START_SECONDS_TO_LIVE = 5
:这个变量确定拾取物在生成和消失之间持续多长时间。和前面三个常量一样,它有一个与之关联的非常量,可以在游戏过程中进行操作。它用于初始化m_SecondsToLive
。 -
Sprite m_Sprite
:这是用来直观表示对象的精灵。 -
IntRect m_Arena
:这将保存当前竞技场的大小,以帮助拾取物在合理的位置生成。 -
int m_Value
:这个拾取物值多少生命值或弹药?当玩家升级生命值或弹药拾取物的值时会使用这个值。 -
int m_Type
:这将是生命值或弹药的零或一。我们本可以使用一个枚举类,但对于只有两个选项来说,这似乎有点杀鸡用牛刀。 -
bool m_Spawned
:拾取物当前是否生成? -
float m_SecondsSinceSpawn
:拾取物生成后多长时间了? -
float m_SecondsSinceDeSpawn
:拾取物消失后多长时间了? -
float m_SecondsToLive
:这个拾取物在生成后应该存活多久? -
float m_SecondsToWait
:这个拾取物在消失后应该等多久才重新出现?
提示
请注意,这个类的大部分复杂性是由于变量生成时间及其可升级的特性。如果拾取物在收集后只是重新生成并具有固定值,那么这将是一个非常简单的类。我们需要我们的拾取物可以升级,所以玩家被迫制定策略来通过僵尸的波次。
然后,在Pickup.h
文件中添加以下公共函数原型。确保熟悉新代码,以便我们可以逐步进行:
// Public prototypes go here
public:
Pickup::Pickup(int type);
// Prepare a new pickup
void setArena(IntRect arena);
void spawn();
// Check the position of a pickup
FloatRect getPosition();
// Get the sprite for drawing
Sprite getSprite();
// Let the pickup update itself each frame
void update(float elapsedTime);
// Is this pickup currently spawned?
bool isSpawned();
// Get the goodness from the pickup
int gotIt();
// Upgrade the value of each pickup
void upgrade();
};
让我们简要讨论每个函数定义:
-
第一个函数是构造函数,以类的名称命名。注意它只接受一个
int
参数。这将用于初始化它将是什么类型的拾取物(生命值还是弹药)。 -
setArena
函数接收一个IntRect
。这个函数将在每个波次开始时为每个Pickup
实例调用。然后Pickup
对象将知道它们可以生成的区域。 -
spawn
函数当然会处理生成拾取物。 -
getPosition
函数,就像在Player
、Zombie
和Bullet
类中一样,将返回一个代表游戏世界中对象当前位置的FloatRect
。 -
getSprite
函数返回一个Sprite
对象,使得拾取物可以在每一帧中被绘制。 -
update
函数接收上一帧所用的时间。它使用这个值来更新它的私有变量,并决定何时生成和取消生成。 -
isSpawned
函数返回一个布尔值,让调用代码知道拾取物当前是否已生成。 -
gotIt
函数在检测到与玩家的碰撞时将被调用。然后Pickup
类代码可以准备在适当的时间重新生成。请注意,它返回一个int
,以便调用代码知道拾取物的价值是健康还是弹药。 -
upgrade
函数将在玩家选择在游戏的升级阶段升级拾取物的属性时被调用。
现在我们已经浏览了成员变量和函数原型,应该很容易跟着我们编写函数定义。
编写 Pickup 类函数定义
现在我们可以创建一个新的.cpp
文件,其中包含函数定义。在解决方案资源管理器中右键单击源文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击C++文件(.cpp
)突出显示,然后在名称字段中键入Pickup.cpp
。最后,单击添加按钮。我们现在准备好编写类的代码了。
将此处显示的代码添加到Pickup.cpp
文件中。确保审查代码,以便我们可以讨论它:
#include "stdafx.h"
#include "Pickup.h"
#include "TextureHolder.h"
Pickup::Pickup(int type)
{
// Store the type of this pickup
m_Type = type;
// Associate the texture with the sprite
if (m_Type == 1)
{
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/health_pickup.png"));
// How much is pickup worth
m_Value = HEALTH_START_VALUE;
}
else
{
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/ammo_pickup.png"));
// How much is pickup worth
m_Value = AMMO_START_VALUE;
}
m_Sprite.setOrigin(25, 25);
m_SecondsToLive = START_SECONDS_TO_LIVE;
m_SecondsToWait = START_WAIT_TIME;
}
在之前的代码中,我们添加了熟悉的包含指令。然后我们添加了Pickup
构造函数。我们知道这是构造函数,因为它与类名相同。
构造函数接收一个名为type
的int
,代码的第一件事就是将从type
接收到的值赋给m_Type
。之后,有一个if…else
块,检查m_Type
是否等于1
。如果是,m_Sprite
将与健康拾取纹理相关联,m_Value
将设置为HEALTH_START_VALUE
。
如果m_Type
不等于1
,else
块将把弹药拾取纹理与m_Sprite
相关联,并将AMMO_START_VALUE
的值赋给m_Value
。
在if…else
块之后,代码使用setOrigin
函数将m_Sprite
的原点设置为中心,并将START_SECONDS_TO_LIVE
和START_WAIT_TIME
分别赋给m_SecondsToLive
和m_SecondsToWait
。
构造函数已成功准备了一个可以使用的Pickup
对象。
接下来我们将添加setArena
函数。在添加时检查代码:
void Pickup::setArena(IntRect arena)
{
// Copy the details of the arena to the pickup's m_Arena
m_Arena.left = arena.left + 50;
m_Arena.width = arena.width - 50;
m_Arena.top = arena.top + 50;
m_Arena.height = arena.height - 50;
spawn();
}
我们刚刚编写的setArena
函数只是简单地复制了传入的arena
对象的值,但在左侧和顶部增加了五十,右侧和底部减少了五十。现在 Pickup 对象已经知道它可以生成的区域。setArena
函数然后调用自己的spawn
函数,为每一帧的绘制和更新做最后的准备。
接下来是spawn
函数。在setArena
函数之后添加以下代码:
void Pickup::spawn()
{
// Spawn at a random location
srand((int)time(0) / m_Type);
int x = (rand() % m_Arena.width);
srand((int)time(0) * m_Type);
int y = (rand() % m_Arena.height);
m_SecondsSinceSpawn = 0;
m_Spawned = true;
m_Sprite.setPosition(x, y);
}
spawn
函数执行准备拾取物所需的一切。首先它为随机数生成器设置种子,并获取对象的水平和垂直位置的随机数。请注意,它使用m_Arena.width
和m_Arena.height
作为可能水平和垂直位置的范围。
m_SecondsSinceSpawn
设置为零,因此在取消生成之前允许的时间长度被重置。m_Spawned
变量设置为true
,因此当我们从main
中调用isSpawned
时,我们将得到一个积极的响应。最后,m_Sprite
通过setPosition
移动到位置,准备绘制到屏幕上。
在以下代码块中,我们有三个简单的 getter 函数。getPosition
函数返回m_Sprite
当前位置的FloatRect
,getSprite
返回m_Sprite
本身的副本,isSpawned
根据对象当前是否生成返回true
或false
。
添加并检查我们刚刚讨论的代码:
FloatRect Pickup::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Sprite Pickup::getSprite()
{
return m_Sprite;
}
bool Pickup::isSpawned()
{
return m_Spawned;
}
接下来我们将编写gotIt
函数。当玩家触摸/碰撞(获得)拾取物时,将从main
中调用此函数。在isSpawned
函数之后添加gotIt
函数:
int Pickup::gotIt()
{
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
return m_Value;
}
gotIt
函数将m_Spawned
设置为false
,所以我们知道此刻不要绘制和检查碰撞。m_SecondsSinceDespawn
设置为零,因此再次开始生成的倒计时从头开始,m_Value
返回给调用代码,以便调用代码可以处理添加额外的弹药或生命值。
接下来是update
函数,它将我们迄今为止看到的许多变量和函数联系在一起。添加并熟悉update
函数,然后我们可以讨论它:
void Pickup::update(float elapsedTime)
{
if (m_Spawned)
{
m_SecondsSinceSpawn += elapsedTime;
}
else
{
m_SecondsSinceDeSpawn += elapsedTime;
}
// Do we need to hide a pickup?
if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned)
{
// Remove the pickup and put it somewhere else
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
}
// Do we need to spawn a pickup
if (m_SecondsSinceDeSpawn > m_SecondsToWait && !m_Spawned)
{
// spawn the pickup and reset the timer
spawn();
}
}
update
函数分为四个块,每帧考虑执行一次:
-
如果
m_Spawned
为 true,则执行if
块——if (m_Spawned)
。这段代码将本帧的时间添加到m_SecondsSinceSpawned
,以跟踪拾取物已经生成的时间。 -
相应的
else
块,如果m_Spawned
为false
,则执行。此块将本帧所花费的时间添加到m_SecondsSinceDeSpawn
,以跟踪拾取物自上次取消生成(隐藏)以来等待的时间。 -
另一个
if
块,当生成的拾取物已经存在的时间超过应该存在的时间时执行——if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned)
。这个块将m_Spawned
设置为false
,并将m_SecondsSinceDeSpawn
重置为零。现在块 2 将执行,直到再次生成的时间到来。 -
最后一个
if
块,当自上次取消生成以来等待的时间超过必要的等待时间,并且拾取物当前未生成时执行——if (m_SecondsSinceDeSpawn > m_SecondsToWait && !m_Spawned)
。当执行此块时,是时候再次生成了,并调用生成函数。
这四个测试和代码控制着拾取物的隐藏和显示。
最后,添加upgrade
函数的定义:
void Pickup::upgrade()
{
if (m_Type == 1)
{
m_Value += (HEALTH_START_VALUE * .5);
}
else
{
m_Value += (AMMO_START_VALUE * .5);
}
// Make them more frequent and last longer
m_SecondsToLive += (START_SECONDS_TO_LIVE / 10);
m_SecondsToWait -= (START_WAIT_TIME / 10);
}
upgrade
函数测试拾取物的类型,无论是生命值还是弹药,然后将m_Value
的初始值的 50%添加到其中。在if…else
块之后的两行增加了拾取物生成的时间和玩家等待生成之间的时间。
当玩家在LEVELING_UP
状态下选择升级拾取物时,将调用此函数。我们的Pickup
类已经准备就绪。
使用 Pickup 类
经过所有那些辛苦工作实现Pickup
类之后,我们现在可以继续在游戏引擎中编写代码,真正将一些拾取物放入游戏中。
我们首先在ZombieArena.cpp
文件中添加一个包含指令:
#include "stdafx.h"
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h" using namespace sf;
在以下代码中,我们添加了两个Pickup
实例,一个称为healthPickup
,另一个称为ammoPickup
。我们分别将值1
和2
传递给构造函数,以便它们被初始化为正确类型的拾取物。添加我们刚刚讨论过的突出显示的代码:
// Hide the mouse pointer and replace it with crosshair
window.setMouseCursorVisible(true);
Sprite spriteCrosshair;
Texture textureCrosshair = TextureHolder::GetTexture("graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// Create a couple of pickups
Pickup healthPickup(1);
Pickup ammoPickup(2);
// The main game loop
while (window.isOpen())
在键盘处理的LEVELING_UP
状态中,添加在嵌套的PLAYING
代码块中显示的突出行:
if (state == State::PLAYING)
{
// Prepare thelevel
// We will modify the next two lines later
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Configure the pickups
healthPickup.setArena(arena);
ammoPickup.setArena(arena);
// Create a horde of zombies
numZombies = 10;
// Delete the previously allocated memory (if it exists)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Reset the clock so there isn't a frame jump
clock.restart();
}
先前的代码简单地将arena
传递给每个拾取物的setArena
函数。拾取物现在知道它们可以生成的位置。这段代码对于每个新波次都会执行,因此随着竞技场的大小增长,Pickup
对象将得到更新。
以下代码简单地为每个Pickup
对象在每一帧调用update
函数:
// Loop through each Zombie and update them
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// Update any bullets that are in-flight
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
bullets[i].update(dtAsSeconds);
}
}
// Update the pickups
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds); }// End updating the scene
游戏循环的绘制部分中的以下代码检查拾取物当前是否生成,如果是,则绘制它。添加我们刚讨论过的突出显示的代码:
// Draw the player
window.draw(player.getSprite());
// Draw the pickups, if currently spawned
if (ammoPickup.isSpawned())
{
window.draw(ammoPickup.getSprite());
}
if (healthPickup.isSpawned())
{
window.draw(healthPickup.getSprite());
}
//Draw the crosshair
window.draw(spriteCrosshair);
}
现在您可以运行游戏并看到拾取物的生成和消失。但是,您目前无法实际拾取它们。
现在我们已经在游戏中有了所有的对象,是时候让它们相互作用(碰撞)了。
检测碰撞
我们只需要知道游戏中的某些对象何时接触到其他对象。然后我们可以以适当的方式对该事件做出响应。在我们的类中,我们已经添加了在对象碰撞时调用的函数。它们如下:
-
Player
类有一个hit
函数。当僵尸与玩家发生碰撞时,我们将调用它。 -
Zombie
类有一个hit
函数。当子弹与僵尸发生碰撞时,我们将调用它。 -
Pickup
类有一个gotIt
函数。当玩家与拾取物发生碰撞时,我们将调用它。
如果需要,回顾一下每个函数的工作原理。现在我们只需要检测碰撞并调用适当的函数。我们将使用矩形相交来检测碰撞。这种类型的碰撞检测非常简单(特别是使用 SFML)。我们可以想象绘制一个虚拟的矩形——我们可以称之为碰撞框或边界矩形——围绕我们想要测试碰撞的对象,然后测试它们是否相交。如果它们相交,我们就有了碰撞:
从前面的图像中可以看出,这还远非完美。但在这种情况下已经足够了。要实现这种方法,我们只需要使用两个对象碰撞框的 x 和 y 坐标进行相交测试。
检测两个矩形相交的代码看起来可能是这样的。不要使用以下代码。这仅用于演示目的:
if(objectA.getPosition().right > objectB.getPosition().left
&& objectA.getPosition().left < objectB.getPosition().right )
{
// objectA is intersecting enemy on x axis
// But they could be at different heights
if(objectA.getPosition().top < objectB.getPosition().bottom
&& objectA.getPosition().bottom > objectB.getPosition().top )
{
// objectA is intersecting objectB on y axis as well
// Collision detected
}
}
然而,我们不需要编写这段代码。我们将使用 SFML 的intersects
函数,它适用于FloatRect
对象。回想一下Zombie
、Player
、Bullet
和Pickup
类,它们都有一个getPosition
函数,返回对象当前位置的FloatRect
。我们将看到如何使用getPosition
和intersects
来进行所有的碰撞检测。
我们将分三个代码部分处理这个问题,它们将依次跟在游戏引擎更新部分的末尾。
我们需要每帧知道以下三个问题的答案:
-
是否有僵尸被击中?
-
玩家是否被僵尸触碰?
-
玩家是否触碰到了拾取物?
首先让我们添加几个变量score
和hiscore
。然后当杀死僵尸时我们可以改变它们。添加以下代码:
// Create a couple of pickups
Pickup healthPickup(1);
Pickup ammoPickup(2);
// About the game
int score = 0;
int hiScore = 0;
// The main game loop
while (window.isOpen())
现在让我们开始检测僵尸是否与子弹发生碰撞。
是否有僵尸被击中?
以下代码可能看起来很复杂,但当我们逐步进行时,我们会发现这实际上并不是我们以前没有见过的东西。在每帧更新拾取物后,添加以下代码。然后我们可以逐步进行:
// Update the pickups
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds);
// Collision detection
// Have any zombies been shot?
for (int i = 0; i < 100; i++)
{
for (int j = 0; j < numZombies; j++)
{
if (bullets[i].isInFlight() &&
zombies[j].isAlive())
{
if (bullets[i].getPosition().intersects
(zombies[j].getPosition()))
{
// Stop the bullet
bullets[i].stop();
// Register the hit and see if it was a kill
if (zombies[j].hit())
{
// Not just a hit but a kill too
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// When all the zombies are dead (again)
if (numZombiesAlive == 0)
{
state = State::LEVELING_UP;
}
}
}
}
}
}// End zombie being shot
在接下来的部分中,我们将再次看到所有的僵尸和子弹碰撞检测代码。我们将一点一点地进行讨论。首先注意嵌套的for
循环的结构(去掉代码后)如下:
// Collision detection
// Have any zombies been shot?
for (int i = 0; i < 100; i++)
{
for (int j = 0; j < numZombies; j++)
{
...
...
...
}
}
该代码循环遍历每一颗子弹(从 0 到 99),对于每一个僵尸(从 0 到numZombies
的前一个)。
在嵌套的for
循环中,我们执行以下操作:
- 使用以下代码检查当前子弹是否在飞行中,当前僵尸是否仍然活着:
if (bullets[i].isInFlight() && zombies[j].isAlive())
- 假设僵尸还活着,子弹正在飞行,我们使用以下代码测试矩形相交:
if (bullets[i].getPosition().intersects (zombies[j].getPosition()))
如果当前子弹和僵尸发生了碰撞,那么我们会采取一些步骤。
- 使用以下代码停止子弹:
// Stop the bullet
bullets[i].stop();
- 通过调用其
hit
函数向当前僵尸注册一次命中。请注意,hit
函数返回一个Boolean
,让调用代码知道僵尸是否已经死亡。这显示在以下代码行中:
// Register the hit and see if it was a kill
if (zombies[j].hit()) {
在此if
块内,检测僵尸是否死亡而不仅仅是受伤时,我们执行以下操作:
-
将
score
增加十 -
如果分数超过(击败)
score
,则更改hiScore
-
将
numZombiesAlive
减少一个 -
检查是否所有僵尸都死了,
(numZombiesAlive == 0)
,如果是,则更改state
为LEVELING_UP
这是我们刚讨论的if(zombies[j].hit())
内的代码块:
// Not just a hit but a kill too
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// When all the zombies are dead (again)
if (numZombiesAlive == 0)
{
state = State::LEVELING_UP;
}
这样就处理了僵尸和子弹。您可以运行游戏并看到血液。当然,在我们在下一章中实现 HUD 之前,您不会看到分数。
玩家是否被僵尸触碰?
这段代码比僵尸和子弹碰撞检测要简短和简单得多。在我们编写的先前代码之后添加以下突出显示的代码:
}// End zombie being shot
// Have any zombies touched the player
for (int i = 0; i < numZombies; i++)
{
if (player.getPosition().intersects
(zombies[i].getPosition()) && zombies[i].isAlive())
{
if (player.hit(gameTimeTotal))
{
// More here later
}
if (player.getHealth() <= 0)
{
state = State::GAME_OVER;
}
} }// End player touched
我们通过使用for
循环遍历所有僵尸来检测僵尸是否与玩家发生碰撞。对于每个活着的僵尸,代码使用intersects
函数来测试与玩家的碰撞。发生碰撞时,我们调用player.hit
。然后我们通过调用player.getHealth
来检查玩家是否死亡。如果玩家的健康值等于或小于零,则我们将state
更改为GAME_OVER
。
您可以运行游戏并检测碰撞。但是,由于尚未添加 HUD 或音效,因此不清楚是否发生了碰撞。此外,我们需要在玩家死亡并开始新游戏时做更多工作。因此,尽管游戏运行,但目前的结果并不特别令人满意。我们将在接下来的两章中改进这一点。
玩家是否触碰了物品?
玩家与两个物品之间的碰撞检测代码如下。在我们添加的先前代码之后添加以下突出显示的代码:
}// End player touched
// Has the player touched health pickup
if (player.getPosition().intersects
(healthPickup.getPosition()) && healthPickup.isSpawned())
{
player.increaseHealthLevel(healthPickup.gotIt());
}
// Has the player touched ammo pickup
if (player.getPosition().intersects
(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
}
}// End updating the scene
先前的代码使用两个简单的if
语句来查看healthPickup
或ammoPickup
是否被玩家触碰。
如果已收集了健康物品,则player.increaseHealthLevel
函数使用从healthPickup.gotIt
函数返回的值来增加玩家的健康水平。
如果弹药捡起已被收集,那么bulletsSpare
将增加ammoPickup.gotIt
返回的值。
您可以运行游戏,杀死僵尸并收集物品!请注意,当您的健康值等于零时,游戏将进入GAME_OVER
状态并暂停。要重新开始,您需要按Enter,然后输入1到6之间的数字。当我们实现 HUD、主屏幕和升级屏幕时,这些步骤对玩家来说将是直观和简单的。我们将在下一章中这样做。
常见问题
以下是您可能会问的一些问题:
Q)是否有更好的碰撞检测方法?
A)是的。有许多更多的碰撞检测方法,包括但不限于以下方法:
-
可以将对象分成多个更适合精灵形状的矩形。对于 C++来说,每帧检查成千上万个矩形是完全可管理的。特别是当您使用邻居检查等技术来减少每帧所需的测试数量时。
-
对于圆形对象,可以使用半径重叠方法。
-
对于不规则多边形,可以使用交叉数算法。
所有这些技术都可以在以下网站上进行调查:
-
邻居检查:
gamecodeschool.com/essentials/collision-detection-neighbor-checking/
-
半径重叠方法:
gamecodeschool.com/essentials/collision-detection-radius-overlap/
-
穿越数算法:
gamecodeschool.com/essentials/collision-detection-crossing-number/
摘要
这是一个忙碌的章节,但我们取得了很多成就。我们不仅通过两个新的类为游戏添加了子弹和拾取物,而且还使所有的物体按照应有的方式进行交互,当它们相互碰撞时进行检测。
尽管取得了这些成就,我们仍需要做更多的工作来设置每个新游戏,并通过 HUD 向玩家提供反馈。在下一章中,我们将构建 HUD。
第十章:分层视图和实现 HUD
在本章中,我们将看到 SFML Views的真正价值。我们将添加大量的 SFML Text
对象,并像在Timber!!!项目中一样操纵它们。新的是,我们将使用第二个视图实例来绘制 HUD。这样,HUD 将始终整齐地定位在主游戏动作的顶部,而不管背景、玩家、僵尸和其他游戏对象在做什么。
这是我们将要做的事情:
-
在主页/游戏结束屏幕上添加文本和背景
-
在升级屏幕上添加文本
-
创建第二个视图
-
添加 HUD
添加所有文本和 HUD 对象
在本章中,我们将操纵一些字符串。这样我们就可以格式化 HUD 和升级屏幕。
添加下一个高亮显示的include
指令,以便我们可以创建一些sstream
对象来实现这一点:
#include "stdafx.h"
#include <sstream>
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
接下来添加这段相当冗长但易于解释的代码。为了帮助确定应该添加代码的位置,新代码已经高亮显示,而现有代码没有。您可能需要调整一些文本/元素的位置/大小以适应您的屏幕:
int score = 0;
int hiScore = 0;
// For the home/game over screen
Sprite spriteGameOver;
Texture textureGameOver =
TextureHolder::GetTexture("graphics/background.png");
spriteGameOver.setTexture(textureGameOver);
spriteGameOver.setPosition(0, 0);
// Create a view for the HUD
View hudView(sf::FloatRect(0, 0, resolution.x, resolution.y));
// Create a sprite for the ammo icon
Sprite spriteAmmoIcon;
Texture textureAmmoIcon =
TextureHolder::GetTexture("graphics/ammo_icon.png");
spriteAmmoIcon.setTexture(textureAmmoIcon);
spriteAmmoIcon.setPosition(20, 980);
// Load the font
Font font;
font.loadFromFile("fonts/zombiecontrol.ttf");
// Paused
Text pausedText;
pausedText.setFont(font);
pausedText.setCharacterSize(155);
pausedText.setFillColor(Color::White);
pausedText.setPosition(400, 400);
pausedText.setString("Press Enter \n to continue");
// Game Over
Text gameOverText;
gameOverText.setFont(font);
gameOverText.setCharacterSize(125);
gameOverText.setFillColor(Color::White);
gameOverText.setPosition(250, 850);
gameOverText.setString("Press Enter to play");
// LEVELING up
Text levelUpText;
levelUpText.setFont(font);
levelUpText.setCharacterSize(80);
levelUpText.setFillColor(Color::White);
levelUpText.setPosition(150, 250);
std::stringstream levelUpStream;
levelUpStream <<
"1- Increased rate of fire" <<
"\n2- Increased clip size(next reload)" <<
"\n3- Increased max health" <<
"\n4- Increased run speed" <<
"\n5- More and better health pickups" <<
"\n6- More and better ammo pickups";
levelUpText.setString(levelUpStream.str());
// Ammo
Text ammoText;
ammoText.setFont(font);
ammoText.setCharacterSize(55);
ammoText.setColor(Color::White);
ammoText.setPosition(200, 980);
// Score
Text scoreText;
scoreText.setFont(font);
scoreText.setCharacterSize(55);
scoreText.setFillColor(Color::White);
scoreText.setPosition(20, 0);
// Hi Score
Text hiScoreText;
hiScoreText.setFont(font);
hiScoreText.setCharacterSize(55);
hiScoreText.setFillColor(Color::White);
hiScoreText.setPosition(1400, 0);
std::stringstream s;
s << "Hi Score:" << hiScore;
hiScoreText.setString(s.str());
// Zombies remaining
Text zombiesRemainingText;
zombiesRemainingText.setFont(font);
zombiesRemainingText.setCharacterSize(55);
zombiesRemainingText.setFillColor(Color::White);
zombiesRemainingText.setPosition(1500, 980);
zombiesRemainingText.setString("Zombies: 100");
// Wave number
int wave = 0;
Text waveNumberText;
waveNumberText.setFont(font);
waveNumberText.setCharacterSize(55);
waveNumberText.setFillColor(Color::White);
waveNumberText.setPosition(1250, 980);
waveNumberText.setString("Wave: 0");
// Health bar
RectangleShape healthBar;
healthBar.setFillColor(Color::Red);
healthBar.setPosition(450, 980);
// The main game loop
while (window.isOpen())
先前的代码非常简单,没有什么新东西。它基本上创建了一堆 SFML Text
对象。它分配它们的颜色和大小,然后格式化它们的位置,使用我们之前见过的函数。
最重要的是,我们创建了另一个名为hudView
的View
对象,并将其初始化为适应屏幕的分辨率。
正如我们所看到的,主视图对象随着玩家的移动而滚动。相比之下,我们永远不会移动hudView
。这样做的结果是,只要在绘制 HUD 元素之前切换到这个视图,我们就会产生这样的效果:游戏世界在下方滚动,而玩家的 HUD 保持静止。
提示
类比一下,您可以想象在电视屏幕上放置一张带有一些文字的透明塑料片。电视将继续正常播放移动图片,而塑料片上的文字将保持在同一位置,不管下面发生了什么。
然而,下一件要注意的事情是,高分并没有以任何有意义的方式设置。我们需要等到下一章,当我们调查文件 I/O 以保存和检索高分时。
值得注意的另一点是,我们声明并初始化了一个名为healthBar
的RectangleShape
,它将是玩家剩余生命的视觉表示。这将几乎与上一个项目中的时间条工作方式完全相同,当然,它代表的是生命而不是时间。
在先前的代码中,有一个名为ammoIcon
的新精灵,它为我们将在屏幕左下角旁边绘制的子弹和弹夹统计数据提供了上下文。
虽然我们刚刚添加的大量代码没有什么新的或技术性的,但一定要熟悉细节,特别是变量名,以便更容易跟随本章的其余部分。
每帧更新 HUD
正如您所期望的,我们将在代码的更新部分更新 HUD 变量。然而,我们不会在每一帧都这样做。原因是这是不必要的,而且还会减慢我们的游戏循环速度。
举个例子,考虑这样一种情况:玩家杀死了一个僵尸并获得了一些额外的分数。无论Text
对象中的分数是在千分之一秒、百分之一秒,甚至十分之一秒内更新,玩家都不会察觉到任何区别。这意味着没有必要在每一帧重新构建我们设置给Text
对象的字符串。
因此,我们可以确定何时以及多久更新 HUD,添加以下变量:
// When did we last update the HUD?
int framesSinceLastHUDUpdate = 0;
// How often (in frames) should we update the HUD
int fpsMeasurementFrameInterval = 1000;
// The main game loop
while (window.isOpen())
在先前的代码中,我们有变量来跟踪自上次更新 HUD 以来经过了多少帧,以及我们希望在 HUD 更新之间等待的帧数间隔。
现在我们可以使用这些新变量并实际上每帧更新 HUD。然而,直到我们开始操纵最终变量(例如wave
)在下一章中,我们才会真正看到所有 HUD 元素的变化。
按照以下所示,在游戏循环的更新部分中添加突出显示的代码:
// Has the player touched ammo pickup
if (player.getPosition().intersects
(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
}
// size up the health bar
healthBar.setSize(Vector2f(player.getHealth() * 3, 50));
// Increment the number of frames since the previous update
framesSinceLastHUDUpdate++;
// re-calculate every fpsMeasurementFrameInterval frames
if (framesSinceLastHUDUpdate > fpsMeasurementFrameInterval)
{
// Update game HUD text
std::stringstream ssAmmo;
std::stringstream ssScore;
std::stringstream ssHiScore;
std::stringstream ssWave;
std::stringstream ssZombiesAlive;
// Update the ammo text
ssAmmo << bulletsInClip << "/" << bulletsSpare;
ammoText.setString(ssAmmo.str());
// Update the score text
ssScore << "Score:" << score;
scoreText.setString(ssScore.str());
// Update the high score text
ssHiScore << "Hi Score:" << hiScore;
hiScoreText.setString(ssHiScore.str());
// Update the wave
ssWave << "Wave:" << wave;
waveNumberText.setString(ssWave.str());
// Update the high score text
ssZombiesAlive << "Zombies:" << numZombiesAlive;
zombiesRemainingText.setString(ssZombiesAlive.str());
framesSinceLastHUDUpdate = 0;
}// End HUD update
}// End updating the scene
在新代码中,我们更新了healthBar
精灵的大小,增加了timeSinceLastUpdate
对象,然后增加了framesSinceLastUpdate
变量。
接下来,我们开始一个if
块,测试framesSinceLastHUDUpdate
是否大于我们存储在fpsMeasurementFrameInterval
中的首选间隔。
在这个if
块中是所有操作发生的地方。首先,我们为需要设置为Text
对象的每个字符串声明一个字符串流对象。
然后我们依次使用这些字符串流对象,并使用setString
函数将结果设置为适当的Text
对象。
最后,在退出if
块之前,将framesSinceLastHUDUpdate
设置回零,以便计数可以重新开始。
现在,当我们重新绘制场景时,新值将出现在玩家的 HUD 中。
绘制 HUD,主页和升级屏幕
接下来三个代码块中的所有代码都在游戏循环的绘制阶段中。我们只需要在主游戏循环的绘制部分的适当状态下绘制适当的Text
对象。
在PLAYING
状态下,添加以下突出显示的代码:
//Draw the crosshair
window.draw(spriteCrosshair);
// Switch to the HUD view
window.setView(hudView);
// Draw all the HUD elements
window.draw(spriteAmmoIcon);
window.draw(ammoText);
window.draw(scoreText);
window.draw(hiScoreText);
window.draw(healthBar);
window.draw(waveNumberText);
window.draw(zombiesRemainingText);
}
if (state == State::LEVELING_UP)
{
}
在上一个代码块中需要注意的重要事情是,我们切换到了 HUD 视图。这会导致所有东西都以我们给 HUD 的每个元素的精确屏幕位置绘制。它们永远不会移动。
在LEVELING_UP
状态下,添加以下突出显示的代码:
if (state == State::LEVELING_UP)
{
window.draw(spriteGameOver);
window.draw(levelUpText);
}
在PAUSED
状态下,添加以下突出显示的代码:
if (state == State::PAUSED)
{
window.draw(pausedText);
}
在GAME_OVER
状态下,添加以下突出显示的代码:
if (state == State::GAME_OVER)
{
window.draw(spriteGameOver);
window.draw(gameOverText);
window.draw(scoreText);
window.draw(hiScoreText);
}
现在我们可以运行游戏,并在游戏过程中看到我们的 HUD 更新。
这显示了主页/游戏结束屏幕上的HI SCORE和得分:
接下来,我们看到文本显示玩家的升级选项,尽管这些选项目前还没有任何作用。
在这里,我们在暂停屏幕上看到了一条有用的消息:
提示
SFML Views 比这个简单的 HUD 更强大。要了解 SFML Views 的潜力以及它们的易用性,可以查看 SFML 网站关于View
的教程www.sfml-dev.org/tutorials/2.0/graphics-view.php
。
FAQ
这里可能会有一个让您在意的问题:
Q)我在哪里可以看到View
类的更多功能?
A)查看下载包中Zombie Arena游戏的增强版。您可以使用键盘光标键旋转和缩放操作。警告!旋转场景会使控制变得笨拙,但您可以看到View
类可以做的一些事情。
缩放和旋转功能是在主游戏循环的输入处理部分中只用了几行代码就实现的。您可以在下载包的Zombie Arena Enhanced Version
文件夹中查看代码,或者从Runnable Games/Zombie Arena
文件夹中运行增强版。
总结
这是一个快速简单的章节。我们看到了如何使用sstream
显示不同类型的变量持有的值,然后使用第二个 SFMLView
对象在主游戏动作的顶部绘制它们。
我们现在几乎完成了僵尸竞技场。所有的截图都显示了一个小竞技场,没有充分利用整个显示器。在这个项目的最后阶段,我们将加入一些最后的修饰,比如升级、音效和保存最高分。竞技场可以随后扩大到与显示器相同的大小甚至更大。
第十一章:音效,文件 I/O 和完成游戏
我们快要完成了。这一小节将演示如何使用 C++标准库轻松操作存储在硬盘上的文件,我们还将添加音效。当然,我们知道如何添加音效,但我们将讨论在代码中play
的调用应该放在哪里。我们还将解决一些问题,使游戏完整。
在本章中,我们将学习以下主题:
-
保存和加载最高分
-
添加音效
-
允许玩家升级
-
创建永无止境的多波
保存和加载最高分
文件 I/O,或输入/输出,是一个相当技术性的主题。幸运的是,由于它在编程中是一个如此常见的需求,有一个库可以为我们处理所有的复杂性。与我们为 HUD 连接字符串一样,是标准库通过fstream
提供了必要的功能。
首先,我们以与包含sstream
相同的方式包含fstream
:
#include "stdafx.h"
#include <sstream>
#include <fstream>
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
现在,在ZombieArena/ZombieArena
文件夹中添加一个名为gamedata
的新文件夹。接下来,在此文件夹中右键单击,创建一个名为scores.txt
的新文件。我们将保存玩家的最高分数在这个文件中。您可以打开文件并向其中添加分数。如果您这样做,请确保它是一个相当低的分数,这样我们就可以轻松测试是否击败该分数会导致新分数被添加。确保在完成后关闭文件,否则游戏将无法访问它。
在下一段代码中,我们创建了一个名为InputFile
的ifstream
对象,并将刚刚创建的文件夹和文件作为参数传递给它的构造函数。
if(InputFile.is_open())
代码检查文件是否存在并准备好读取。然后我们将文件的内容放入hiScore
中并关闭文件。添加突出显示的代码:
// Score
Text scoreText;
scoreText.setFont(font);
scoreText.setCharacterSize(55);
scoreText.setFillColor(Color::White);
scoreText.setPosition(20, 0);
// Load the high-score from a text file
std::ifstream inputFile("gamedata/scores.txt");
if (inputFile.is_open())
{
inputFile >> hiScore;
inputFile.close();
}
// Hi Score
Text hiScoreText;
hiScoreText.setFont(font);
hiScoreText.setCharacterSize(55);
hiScoreText.setFillColor(Color::White);
hiScoreText.setPosition(1400, 0);
std::stringstream s;
s << "Hi Score:" << hiScore;
hiScoreText.setString(s.str());
现在我们处理保存可能的新最高分。在处理玩家健康小于或等于零的块中,我们创建一个名为outputFile
的ofstream
对象,将hiScore
的值写入文本文件,然后关闭文件:
// Have any zombies touched the player
for (int i = 0; i < numZombies; i++)
{
if (player.getPosition().intersects
(zombies[i].getPosition()) && zombies[i].isAlive())
{
if (player.hit(gameTimeTotal))
{
// More here later
}
if (player.getHealth() <= 0)
{
state = State::GAME_OVER;
std::ofstream outputFile("gamedata/scores.txt");
outputFile << hiScore;
outputFile.close();
}
}
}// End player touched
您可以玩游戏,您的最高分将被保存。退出游戏并注意,如果您再次玩游戏,您的最高分仍然存在。
让我们制造一些噪音。
准备音效
在本节中,我们将创建所有我们需要为游戏添加一系列音效的SoundBuffer
和Sound
对象。
首先添加所需的 SFML 包含文件:
#include "stdafx.h"
#include <sstream>
#include <fstream>
#include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
现在继续添加七个SoundBuffer
和Sound
对象,它们加载和准备了我们在第六章中准备的七个音频文件:
// When did we last update the HUD?
int framesSinceLastHUDUpdate = 0;
// What time was the last update
Time timeSinceLastUpdate;
// How often (in frames) should we update the HUD
int fpsMeasurementFrameInterval = 1000;
// Prepare the hit sound
SoundBuffer hitBuffer;
hitBuffer.loadFromFile("sound/hit.wav");
Sound hit;
hit.setBuffer(hitBuffer);
// Prepare the splat sound
SoundBuffer splatBuffer;
splatBuffer.loadFromFile("sound/splat.wav");
sf::Sound splat;
splat.setBuffer(splatBuffer);
// Prepare the shoot soundSoundBuffer shootBuffer;shootBuffer.loadFromFile("sound/shoot.wav");
Sound shoot;shoot.setBuffer(shootBuffer);
// Prepare the reload sound
SoundBuffer reloadBuffer;
reloadBuffer.loadFromFile("sound/reload.wav");
Sound reload;
reload.setBuffer(reloadBuffer);
// Prepare the failed sound
SoundBuffer reloadFailedBuffer;
reloadFailedBuffer.loadFromFile("sound/reload_failed.wav");
Sound reloadFailed;
reloadFailed.setBuffer(reloadFailedBuffer);
// Prepare the powerup sound
SoundBuffer powerupBuffer;
powerupBuffer.loadFromFile("sound/powerup.wav");
Sound powerup;
powerup.setBuffer(powerupBuffer);
// Prepare the pickup sound
SoundBuffer pickupBuffer;
pickupBuffer.loadFromFile("sound/pickup.wav");
Sound pickup;
pickup.setBuffer(pickupBuffer);
// The main game loop
while (window.isOpen())
现在七个音效已经准备好播放。我们只需要弄清楚在我们的代码中每个play
函数的调用应该放在哪里。
升级
我们将添加的下一段代码使玩家可以在波之间升级。由于我们已经完成的工作,这是很容易实现的。
在我们处理玩家输入的LEVELING_UP
状态中添加突出显示的代码:
// Handle the LEVELING up state
if (state == State::LEVELING_UP)
{
// Handle the player LEVELING up
if (event.key.code == Keyboard::Num1)
{
// Increase fire rate
fireRate++;
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num2)
{
// Increase clip size
clipSize += clipSize;
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num3)
{
// Increase health
player.upgradeHealth();
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num4)
{
// Increase speed
player.upgradeSpeed();
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num5)
{
// Upgrade pickup
healthPickup.upgrade();
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num6)
{
// Upgrade pickup
ammoPickup.upgrade();
state = State::PLAYING;
}
if (state == State::PLAYING)
{
玩家现在可以在每次清除一波僵尸时升级。然而,我们目前无法增加僵尸的数量或级别的大小。
在LEVELING_UP
状态的下一部分,在我们刚刚添加的代码之后,修改从LEVELING_UP
到PLAYING
状态改变时运行的代码。
以下是完整的代码。我已经突出显示了要么是新的要么已经稍作修改的行。
添加或修改突出显示的代码:
if (event.key.code == Keyboard::Num6)
{
ammoPickup.upgrade();
state = State::PLAYING;
}
if (state == State::PLAYING)
{
// Increase the wave number
wave++;
// Prepare thelevel
// We will modify the next two lines later
arena.width = 500 * wave;
arena.height = 500 * wave;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Configure the pickups
healthPickup.setArena(arena);
ammoPickup.setArena(arena);
// Create a horde of zombies
numZombies = 5 * wave;
// Delete the previously allocated memory (if it exists)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Play the powerup sound
powerup.play();
// Reset the clock so there isn't a frame jump
clock.restart();
}
}// End LEVELING up
前面的代码首先递增wave
变量。然后修改代码,使僵尸的数量和竞技场的大小与wave
的新值相关。最后,我们添加了powerup.play()
的调用来播放升级音效。
重新启动游戏
我们已经通过wave
变量的值确定了竞技场的大小和僵尸的数量。我们还必须在每场新游戏开始时将弹药、枪支、wave
和score
重置为零。在游戏循环的事件处理部分找到以下代码,并添加高亮显示的代码:
// Start a new game while in GAME_OVER state
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
wave = 0;
score = 0;
// Prepare the gun and ammo for next game
currentBullet = 0;
bulletsSpare = 24;
bulletsInClip = 6;
clipSize = 6;
fireRate = 1;
// Reset the player's stats
player.resetPlayerStats();
}
现在我们可以玩游戏了,玩家可以在不断增大的竞技场中变得更加强大,而僵尸的数量也会不断增加,直到他死亡,然后一切重新开始。
播放其余的声音
现在我们将添加对play
函数的其余调用。我们会逐个处理它们,因为准确确定它们的位置对于在正确时刻播放它们至关重要。
添加玩家重新装填时的声音效果
在三个地方添加高亮显示的代码,以在玩家按下R键尝试重新装填枪支时播放适当的reload
或reloadFailed
声音:
if (state == State::PLAYING)
{
// Reloading
if (event.key.code == Keyboard::R)
{
if (bulletsSpare >= clipSize)
{
// Plenty of bullets. Reload.
bulletsInClip = clipSize;
bulletsSpare -= clipSize;
reload.play();
}
else if (bulletsSpare > 0)
{
// Only few bullets left
bulletsInClip = bulletsSpare;
bulletsSpare = 0;
reload.play();
}
else
{
// More here soon?!
reloadFailed.play();
}
}
}
发出射击声音
在处理玩家点击鼠标左键的代码末尾附近添加对shoot.play()
的高亮调用:
// Fire a bullet
if (sf::Mouse::isButtonPressed(sf::Mouse::Left))
{
if (gameTimeTotal.asMilliseconds()
- lastPressed.asMilliseconds()
> 1000 / fireRate && bulletsInClip > 0)
{
// Pass the centre of the player and crosshair
// to the shoot function
bullets[currentBullet].shoot(
player.getCenter().x, player.getCenter().y,
mouseWorldPosition.x, mouseWorldPosition.y);
currentBullet++;
if (currentBullet > 99)
{
currentBullet = 0;
}
lastPressed = gameTimeTotal;
shoot.play();
bulletsInClip--;
}
}// End fire a bullet
在玩家被击中时播放声音
在下面的代码中,我们将对hit.play
的调用包装在一个测试中,以查看player.hit
函数是否返回true
。请记住,player.hit
函数用于测试前 100 毫秒内是否记录了击中。这将导致播放一个快速、重复的、沉闷的声音,但不会太快以至于声音模糊成一个噪音。
在这里添加对hit.play
的调用:
// Have any zombies touched the player
for (int i = 0; i < numZombies; i++)
{
if (player.getPosition().intersects
(zombies[i].getPosition()) && zombies[i].isAlive())
{
if (player.hit(gameTimeTotal))
{
// More here later
hit.play();
}
if (player.getHealth() <= 0)
{
state = State::GAME_OVER;
std::ofstream OutputFile("gamedata/scores.txt");
OutputFile << hiScore;
OutputFile.close();
}
}
}// End player touched
在拾取时播放声音
当玩家拾取生命值时,我们会播放常规的拾取声音,但当玩家获得弹药时,我们会播放重新装填的声音效果。
在适当的碰撞检测代码中,添加如下高亮显示的两个调用来播放声音:
// Has the player touched health pickup
if (player.getPosition().intersects
(healthPickup.getPosition()) && healthPickup.isSpawned())
{
player.increaseHealthLevel(healthPickup.gotIt());
// Play a sound
pickup.play();
}
// Has the player touched ammo pickup
if (player.getPosition().intersects
(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
// Play a sound
reload.play();
}
当射中僵尸时发出尖啸声
在检测子弹与僵尸碰撞的代码部分末尾添加对splat.play
的调用:
// Have any zombies been shot?
for (int i = 0; i < 100; i++)
{
for (int j = 0; j < numZombies; j++)
{
if (bullets[i].isInFlight() &&
zombies[j].isAlive())
{
if (bullets[i].getPosition().intersects
(zombies[j].getPosition()))
{
// Stop the bullet
bullets[i].stop();
// Register the hit and see if it was a kill
if (zombies[j].hit()) {
// Not just a hit but a kill too
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// When all the zombies are dead (again)
if (numZombiesAlive == 0) {
state = State::LEVELING_UP;
}
}
// Make a splat sound
splat.play();
}
}
}
}// End zombie being shot
您现在可以玩完整的游戏,并观看每一波僵尸和竞技场的增加。谨慎选择您的升级:
恭喜!
常见问题解答
以下是您可能会考虑的一些问题:
问:尽管使用了类,我发现代码变得非常冗长和难以管理,再次。
答:最大的问题之一是我们的代码结构。随着我们学习更多的 C++,我们也会学会使代码更易管理,通常更简洁。
问:声音效果似乎有点单调和不真实。如何改进?
答:显著改善玩家从声音中获得的感觉的一种方法是使声音具有方向性,并根据声源到玩家角色的距离改变音量。在下一个项目中,我们将使用 SFML 的高级声音功能。
总结
我们已经完成了僵尸竞技场游戏。这是一次相当的旅程。我们学到了很多 C++基础知识,比如引用、指针、面向对象编程和类。此外,我们还使用了 SFML 来管理摄像机、顶点数组和碰撞检测。我们学会了如何使用精灵表来减少对window.draw
的调用次数,并提高帧率。使用 C++指针、STL 和一点面向对象编程,我们构建了一个单例类来管理我们的纹理,在下一个项目中,我们将扩展这个想法来管理我们游戏的所有资源。
在本书的结束项目中,我们将探索粒子效果、定向声音和分屏多人游戏。在 C++中,我们还将遇到继承、多态和一些新概念。
第十二章:抽象和代码管理 - 更好地利用 OOP
在本章中,我们将首次查看本书的最终项目。该项目将具有高级特点,如方向性声音,根据玩家位置从扬声器发出。它还将具有分屏合作游戏。此外,该项目还将引入着色器的概念,这是用另一种语言编写的程序,直接在图形卡上运行。到第十六章结束时,您将拥有一个完全功能的多人平台游戏,以命中经典托马斯独自一人的风格构建。
本章的主要重点将是启动项目,特别是探索如何构建代码结构以更好地利用 OOP。将涵盖以下主题:
-
最终项目《托马斯迟到》,包括游戏特点和项目资产的介绍
-
详细讨论我们将如何改进代码结构,与之前的项目相比
-
编写《托马斯迟到》游戏引擎
-
实施分屏功能
《托马斯迟到的游戏》
此时,如果您还没有,我建议您去观看《托马斯独自一人》的视频store.steampowered.com/app/220780/
。请注意其简单但美观的图形。视频还展示了各种游戏挑战,例如使用角色的不同属性(身高,跳跃,力量等)。为了保持我们的游戏简单而不失挑战,我们将比《托马斯独自一人》少一些解谜特点,但将增加需要两名玩家合作玩游戏的挑战。为了确保游戏不会太容易,我们还将让玩家与时间赛跑,这就是我们的游戏名字叫《托马斯迟到》的原因。
《托马斯迟到的特点》
我们的游戏不会像我们试图模仿的杰作那样先进,但它将具有一系列令人兴奋的游戏特点:
-
一个从适合关卡挑战的时间开始倒计时的时钟。
-
发射火坑会根据玩家的位置发出咆哮声,并在玩家掉下去时重新生成玩家。水坑也有同样的效果,但没有方向性的声音效果。
-
合作游戏 - 两名玩家必须在规定的时间内将他们的角色带到目标。他们经常需要一起工作,例如,身材较矮,跳跃力较低的鲍勃需要站在他朋友(托马斯)的头上。
-
玩家将有选择在全屏和分屏之间切换,因此他可以尝试自己控制两个角色。
-
每个关卡将设计并从文本文件中加载。这将使设计各种各样的关卡变得非常容易。
看看游戏的注释截图,看看一些特点的实际操作和组件/资产,构成了游戏:
让我们看看这些特点,并描述一些更多的特点:
-
截图显示了一个简单的 HUD,详细说明了关卡编号和剩余秒数,直到玩家失败并不得不重新开始关卡。
-
您还可以清楚地看到分屏合作模式的实际操作。请记住这是可选的。单人玩家可以全屏玩游戏,同时在托马斯和鲍勃之间切换摄像头焦点。
-
在截图中并不是很清楚(尤其是在打印品中),但是当一个角色死亡时,他会爆炸成星花/烟火般的粒子效果。
-
水和火砖可以被策略性地放置,使得关卡更有趣,并迫使角色之间合作。更多内容请参见第十四章,“构建可玩关卡和碰撞检测”。
-
注意 Thomas 和 Bob——它们不仅在高度上不同,而且跳跃能力也有显著不同。这意味着 Bob 依赖于 Thomas 进行大跳跃,可以设计关卡来迫使 Thomas 选择避免碰头的路线。
-
此外,火砖会发出咆哮声。这些声音将与 Thomas 的位置有关。它们不仅是方向性的,可以从左侧或右侧扬声器发出,而且随着 Thomas 离开或接近源头,声音会变得越来越大或越来越小。
-
最后,在带注释的截图中,您可以看到背景。如果您将其与
background.png
文件(本章后面显示)进行比较,您会发现它们是完全不同的。我们将在第十六章,“扩展 SFML 类、粒子系统和着色器”中使用 OpenGL 着色器效果来实现背景中移动的——几乎是冒泡的——效果。
所有这些功能都需要更多的截图,这样我们在编写 C++代码时可以记住最终的产品。
以下截图显示了 Thomas 和 Bob 到达一个火坑,Bob 没有机会跳过去:
以下截图显示了 Bob 和 Thomas 合作清除一个危险的跳跃:
以下截图显示了我们如何设计需要“信仰之跃”才能达到目标的谜题:
以下截图展示了我们如何设计几乎任意大小的压抑洞穴系统。我们还可以设计需要 Bob 和 Thomas 分开并走不同路线的关卡:
从模板创建项目
创建“Thomas Was Late”项目与其他两个项目相同。只需在 Visual Studio 中按照这些简单的步骤进行操作:
-
从主菜单中选择文件 | 新建项目。
-
确保在左侧菜单中选择Visual C++,然后从所呈现的选项列表中选择HelloSFML。以下截图应该可以说明这一点:
-
在名称:字段中,键入
TWL
,并确保为解决方案创建目录选项已被选中。现在点击确定。 -
现在我们需要将 SFML 的
.dll
文件复制到主项目目录中。我的主项目目录是D:\Visual Studio Stuff\Projects\ TWL\TWL
。这个文件夹是在上一步中由 Visual Studio 创建的。如果您将Projects
文件夹放在其他地方,请在那里执行此步骤。我们需要复制到project
文件夹中的文件位于您的SFML\bin
文件夹中。为每个位置打开一个窗口,并突出显示所需的.dll
文件。 -
现在将突出显示的文件复制并粘贴到项目中。
项目现在已经设置好,准备就绪。
项目资源
该项目中的资源比僵尸竞技场游戏中的资源更加丰富和多样。通常,资源包括屏幕上的字体、不同动作的声音效果(如跳跃、达到目标或远处火焰的咆哮)以及 Thomas 和 Bob 的图形以及所有背景砖块的精灵表。
游戏所需的所有资源都包含在下载包中。它们分别位于第十二章/图形
和第十二章/声音
文件夹中。
所需的字体没有提供。这是因为我想避免任何可能的许可歧义。不过这不会造成问题,因为我会准确地向你展示在哪里以及如何选择和下载字体。
虽然我会提供资产本身或者获取它们的信息,但你可能也想自己创建和获取它们。
除了我们期望的图形、声音和字体之外,这个游戏还有两种新的资产类型。它们是关卡设计文件和 GLSL 着色器程序。让我们接下来了解一下它们各自的情况。
游戏关卡设计
所有的关卡都是在一个文本文件中创建的。通过使用 0 到 3 的数字,我们可以构建挑战玩家的关卡设计。所有的关卡设计都在与其他资产相同目录下的 levels 文件夹中。现在可以随意偷看一下,但我们将在第十四章中详细讨论,构建可玩关卡和碰撞检测。
除了这些关卡设计资产,我们还有一种特殊类型的图形资产,叫做着色器。
GLSL 着色器
着色器是用GLSL(图形库着色语言)编写的程序。不用担心要学习另一种语言,因为我们不需要深入学习就能利用着色器。着色器很特殊,因为它们是完整的程序,与我们的 C++代码分开,由 GPU 每一帧执行。事实上,一些着色器程序每一帧都会运行,对每一个像素!我们将在第十六章中了解更多细节,扩展 SFML 类、粒子系统和着色器。如果你等不及了,可以看一下下载包的Chapter 12/shaders
文件夹中的文件。
图形资产特写
图形资产构成了我们游戏场景的部分。看一下图形资产,就能清楚地知道它们在我们的游戏中将被使用在哪里:
如果tiles_sheet
图形上的图块看起来与游戏截图有些不同,那是因为它们部分是透明的,背景透过显示会使它们有些变化。如果背景图与游戏截图中的实际背景完全不同,那是因为我们将编写的着色器程序会每一帧操纵每一个像素,创造一种"熔化"效果。
声音资产特写
声音文件都是.wav
格式。这些文件包含了我们在游戏中的某些事件中播放的音效。它们如下:
-
fallinfire.wav
:当玩家的头进入火焰并且没有逃脱的机会时会播放这个音效。 -
fallinwater.wav
:水和火一样会导致死亡。这个音效会通知玩家他们需要从关卡的开始重新开始。 -
fire1.wav
:这个音效是以单声道录制的。它将根据玩家距离火焰图块的距离以不同的音量播放,并根据玩家相对于火焰图块的左右位置从不同的扬声器播放。显然,我们需要学习一些更多的技巧来实现这个功能。 -
jump.wav
:当玩家跳跃时会播放一个令人愉悦(稍微可预测)的欢呼声。 -
reachgoal.wav
:当玩家(或玩家)将 Thomas 和 Bob 两个角色都带到目标方块时,会播放令人愉悦的胜利音效。
这些音效非常简单直接,你可以很容易地创建自己的音效。如果你打算替换fire1.wav
文件,确保将你的声音保存为单声道(而不是立体声)格式。这其中的原因将在第十五章中解释,声音空间化和 HUD。
将资产添加到项目中
一旦您决定要使用哪些资产,就是将它们添加到项目的时候了。以下说明将假定您使用了书籍下载包中提供的所有资产。
如果您使用自己的资产,只需用您选择的文件替换相应的声音或图形文件,文件名完全相同:
-
浏览到 Visual
D:\Visual Studio Stuff\Projects\TWL\TWL
目录。 -
在此文件夹中创建五个新文件夹,并将它们命名为
graphics
,sound
,fonts
,shaders
和levels
。 -
从下载包中,将
Chapter 12/graphics
的全部内容复制到D:\Visual Studio Stuff\Projects\TWL\TWL\graphics
文件夹中。 -
从下载包中,将
Chapter 12/sound
的全部内容复制到D:\Visual Studio Stuff\Projects\TWL\TWL\sound
文件夹中。 -
现在在您的网络浏览器中访问
www.dafont.com/roboto.font
,并下载Roboto Light字体。 -
提取压缩下载的内容,并将
Roboto-Light.ttf
文件添加到D:\Visual Studio Stuff\Projects\TWL\TWL\fonts
文件夹中。 -
从下载包中,将
Chapter 12/levels
的全部内容复制到D:\Visual Studio Stuff\Projects\TWL\TWL\levels
文件夹中。 -
从下载包中,将
Chapter 12/shaders
的全部内容复制到D:\Visual Studio Stuff\Projects\TWL\TWL\shaders
文件夹中。
现在我们有了一个新项目,以及整个项目所需的所有资产,我们可以讨论如何构建游戏引擎代码。
构建 Thomas Was Late 代码的结构
到目前为止,在两个项目中都很明显的一个问题是代码变得非常冗长和难以控制。OOP 允许我们将项目分解为称为类的逻辑和可管理的块。
通过引入Engine 类,我们将大大改善此项目中代码的可管理性。Engine 类将具有三个私有函数,分别是input
,update
和draw
。这应该听起来非常熟悉。这些函数中的每一个将保存以前全部在main
函数中的代码的一部分。这些函数将分别在自己的代码文件中,Input.cpp
,Update.cpp
和Draw.cpp
中。
Engine
类中还将有一个公共函数,可以使用Engine
的实例调用。这个函数是run
,将负责调用input
,update
和draw
,每帧游戏调用一次:
此外,由于我们已经将游戏引擎的主要部分抽象为Engine
类,我们还可以将许多变量从main
中移动并将它们作为Engine
的成员。要启动我们的游戏引擎,我们只需要创建一个Engine
的实例并调用它的run
函数。这里是一个超级简单的主函数的预览:
int main()
{
// Declare an instance of Engine
Engine engine;
// Start the engine
engine.run();
// Quit in the usual way when the engine is stopped
return 0;
}
提示
暂时不要添加上述代码。
为了使我们的代码更加可管理和可读,我们还将抽象出加载关卡和碰撞检测等重要任务的责任,放到单独的函数中(在单独的代码文件中)。这两个函数分别是loadLevel
和detectCollisions
。我们还将编写其他函数来处理 Thomas Was Late 项目的一些新功能。随着它们的出现,我们将详细介绍它们。
为了更好地利用 OOP,我们将完全将游戏特定领域的责任委托给新的类。您可能还记得以前项目中的声音和 HUD 代码非常冗长。我们将构建一个SoundManager
和HUD
类来以更清晰的方式处理这些方面。当我们实现它们时,它们的工作方式将被深入探讨。
游戏关卡本身比以前的游戏更加深入,因此我们还将编写一个LevelManager
类。
正如您所期望的,可玩角色也将使用类制作。但是,对于这个项目,我们将学习更多的 C++,并实现一个PlayableCharacter
类,其中包含 Thomas 和 Bob 的所有常见功能,然后Thomas
和Bob
类,它们将继承这些常见功能,并实现自己的独特功能和能力。这,也许并不奇怪,被称为继承。我将在接下来的第十三章,“高级面向对象编程,继承和多态”中更详细地介绍继承。
我们还将实现许多其他类来执行特定的职责。例如,我们将使用粒子系统制作一些漂亮的爆炸效果。您可能能够猜到,为了做到这一点,我们将编写一个Particle
类和一个ParticleSystem
类。所有这些类都将作为Engine
类的成员具有实例。以这种方式做事将使游戏的所有功能都可以从游戏引擎中访问,但将细节封装到适当的类中。
在我们继续查看将创建 Engine 类的实际代码之前,要提到的最后一件事是,我们将重用我们为“Zombie Arena”游戏讨论和编写的TextureHolder
类,而不做任何更改。
构建游戏引擎
如前面的讨论所建议的,我们将编写一个名为Engine
的类,它将控制并绑定 Thomas Was Late 游戏的不同部分。
我们将首先使上一个项目中的TextureHolder
类在这个项目中可用。
重用 TextureHolder 类
我们讨论并编写的TextureHolder
类对于这个项目也会很有用。虽然可以直接从上一个项目添加文件(TextureHolder.h
和TextureHolder.cpp
),而无需重新编码或重新创建文件,但我不想假设您没有直接跳转到这个项目。接下来是非常简要的说明,以及创建TextureHolder
类的完整代码清单。如果您想要解释该类或代码,请参阅第八章,“指针、标准模板库和纹理管理”。
提示
如果您完成了上一个项目,并且确实想要从“Zombie Arena”项目中添加该类,只需执行以下操作:在“解决方案资源管理器”窗口中,右键单击“头文件”,然后选择“添加”|“现有项...”。浏览到上一个项目的TextureHolder.h
并选择它。在“解决方案资源管理器”窗口中,右键单击“源文件”,然后选择“添加”|“现有项...”。浏览到上一个项目的TextureHolder.cpp
并选择它。现在您可以在这个项目中使用TextureHolder
类。请注意,文件在项目之间共享,任何更改都将在两个项目中生效。
要从头开始创建TextureHolder
类,请在“解决方案资源管理器”中右键单击“头文件”,然后选择“添加”|“新项...”。在“添加新项”窗口中,通过左键单击突出显示(高亮)“头文件(.h)”,然后在“名称”字段中输入TextureHolder.h
。最后,单击“添加”按钮。
将以下代码添加到TextureHolder.h
中:
#pragma once
#ifndef TEXTURE_HOLDER_H
#define TEXTURE_HOLDER_H
#include <SFML/Graphics.hpp>
#include <map>
class TextureHolder
{
private:
// A map container from the STL,
// that holds related pairs of String and Texture
std::map<std::string, sf::Texture> m_Textures;
// A pointer of the same type as the class itself
// the one and only instance
static TextureHolder* m_s_Instance;
public:
TextureHolder();
static sf::Texture& GetTexture(std::string const& filename);
};
#endif
在“解决方案资源管理器”中右键单击“源文件”,然后选择“添加”|“新项...”。在“添加新项”窗口中,通过左键单击突出显示(高亮)“C++文件(.cpp)”,然后在“名称”字段中输入TextureHolder.cpp
。最后,单击“添加”按钮。
将以下代码添加到TextureHolder.cpp
中:
#include "stdafx.h"
#include "TextureHolder.h"
#include <assert.h>
using namespace sf;
using namespace std;
TextureHolder* TextureHolder::m_s_Instance = nullptr;
TextureHolder::TextureHolder()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
}
sf::Texture& TextureHolder::GetTexture(std::string const& filename)
{
// Get a reference to m_Textures using m_S_Instance
auto& m = m_s_Instance->m_Textures;
// auto is the equivalent of map<string, Texture>
// Create an iterator to hold a key-value-pair (kvp)
// and search for the required kvp
// using the passed in file name
auto keyValuePair = m.find(filename);
// auto is equivalent of map<string, Texture>::iterator
// Did we find a match?
if (keyValuePair != m.end())
{
// Yes
// Return the texture,
// the second part of the kvp, the texture
return keyValuePair->second;
}
else
{
// File name not found
// Create a new key value pair using the filename
auto& texture = m[filename];
// Load the texture from file in the usual way
texture.loadFromFile(filename);
// Return the texture to the calling code
return texture;
}
}
我们现在可以开始创建我们的新Engine
类了。
编写 Engine.h
和往常一样,我们将从头文件开始,其中包含函数声明和成员变量。请注意,我们将在整个项目中重新访问此文件,以添加更多函数和成员变量。目前,我们将只添加在这个阶段必要的代码。
在 解决方案资源管理器 中右键单击 头文件,然后选择 添加 | 新建项...。在 添加新项 窗口中,通过左键单击突出显示(高亮) 头文件( .h
),然后在 名称 字段中键入 Engine.h
。最后,单击 添加 按钮。现在我们准备好为 Engine
类编写头文件了。
添加以下成员变量以及函数声明。其中许多我们在其他项目中已经见过,有些我们在 Structuring the Thomas Was Late 代码部分讨论过。注意函数和变量的名称,以及它们是私有的还是公共的。添加以下代码到 Engine.h
文件中,然后我们将讨论它:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
// The force pushing the characters down
const int GRAVITY = 300;
// A regular RenderWindow
RenderWindow m_Window;
// The main Views
View m_MainView;
View m_LeftView;
View m_RightView;
// Three views for the background
View m_BGMainView;
View m_BGLeftView;
View m_BGRightView;
View m_HudView;
// Declare a sprite and a Texture
// for the background
Sprite m_BackgroundSprite;
Texture m_BackgroundTexture;
// Is the game currently playing?
bool m_Playing = false;
// Is character 1 or 2 the current focus?
bool m_Character1 = true;
// Start in fullscreen mode
bool m_SplitScreen = false;
// How much time is left in the current level
float m_TimeRemaining = 10;
Time m_GameTimeTotal;
// Is it time for a new/first level?
bool m_NewLevelRequired = true;
// Private functions for internal use only
void input();
void update(float dtAsSeconds);
void draw();
public:
// The Engine constructor
Engine();
// Run will call all the private functions
void run();
};
这是所有私有变量和函数的完整概述。在适当的情况下,我会在解释上花费更多时间:
-
TextureHolder th
:TextureHolder
类的唯一实例。 -
TILE_SIZE
:一个有用的常量,提醒我们精灵表中的每个瓦片都是五十像素宽和五十像素高。 -
VERTS_IN_QUAD
:一个有用的常量,使我们对VertexArray
的操作更不容易出错。事实上,一个四边形中有四个顶点。现在我们不会忘记它了。 -
GRAVITY
:一个表示游戏角色每秒向下推动的像素数的常量int
值。一旦游戏完成,这是一个非常有趣的值。我们将其初始化为300
,因为这对我们最初的级别设计效果很好。 -
m_Window
:像我们在所有项目中看到的那样,通常的RenderWindow
对象。 -
SFML
View
对象,m_MainView
,m_LeftView
,m_RightView
,m_BGMainView
,m_BGLeftView
,m_BGRightView
和m_HudView
:前三个View
对象用于全屏视图,游戏的左右分屏视图。我们还为这三个分别有一个单独的 SFMLView
对象,用于绘制背景。最后一个View
对象m_HudView
,将在其他六个视图的适当组合上方显示得分、剩余时间和任何玩家的消息。有七个不同的View
对象可能会暗示复杂性,但当你看到本章的进展如何处理它们时,你会发现它们非常简单。我们将在本章结束时解决整个分屏/全屏问题。 -
Sprite m_BackgroundSprite
和Texture m_BackgroundTexture
:可以预料到,这组 SFMLSprite
和Texture
将用于显示和保存来自图形资源文件夹的背景图形。 -
m_Playing
:这个布尔值将让游戏引擎知道当前级别是否已经开始(通过按下 Enter 键)。一旦玩家开始游戏,他们就没有暂停游戏的选项。 -
m_Character1
:当屏幕是全屏时,它应该以 Thomas(m_Character1 = true)还是 Bob(m_Character1 = false)为中心?最初,它被初始化为 true,以便以 Thomas 为中心。 -
m_SplitScreen
:游戏当前是否以分屏模式进行?我们将使用这个变量来决定如何使用我们之前声明的所有View
对象。 -
m_TimeRemaining
变量:这个float
变量保存了当前级别剩余的时间。在之前的代码中,它被设置为10
用于测试目的,直到我们真正为每个级别设置一个特定的时间。 -
m_GameTimeTotal
变量:这个变量是一个 SFML 时间对象。它跟踪游戏已经进行了多长时间。 -
m_NewLevelRequired
布尔变量:这个变量用于检查玩家是否刚刚完成或失败了一个关卡。然后我们可以使用它来触发加载下一个关卡或重新开始当前关卡。 -
input
函数:这个函数将处理玩家的所有输入,这个游戏中全部来自键盘。乍一看,它似乎直接处理所有的键盘输入。然而,在这个游戏中,我们将直接处理影响 Thomas 或 Bob 的键盘输入,这将直接在Thomas
和Bob
类中进行。我们将调用input
函数,这个函数将直接处理键盘输入,比如退出、切换到分屏等其他键盘输入。 -
update
函数:这个函数将完成我们之前在main
函数的更新部分中做的所有工作。我们还将从update
函数中调用一些其他函数,以保持代码的组织性。如果你回顾代码,你会看到它接收一个float
参数,这个参数将保存自上一帧以来经过的秒数的分数。当然,这正是我们需要更新所有游戏对象的内容。 -
draw
函数:这个函数将包含以前项目中主函数绘图部分的所有代码。然而,当我们学习使用 SFML 进行其他绘图方式时,会有一些绘图代码不在这个函数中。当我们学习第十六章中的粒子系统时,我们将看到这些新代码,扩展 SFML 类、粒子系统和着色器。
现在让我们来看一下所有的公共函数:
-
Engine
构造函数:正如我们所期望的那样,当我们首次声明Engine
的实例时,将调用这个函数。它将进行所有的设置和类的初始化。我们很快将在编写Engine.cpp
文件时看到具体内容。 -
run
函数:这是我们需要调用的唯一公共函数。它将触发输入、更新和绘制的执行,完成所有工作。
接下来,我们将看到所有这些函数的定义以及一些变量的作用。
编写 Engine.cpp
在我们之前的所有类中,我们将所有的函数定义放在.cpp
文件中,并以类名为前缀。由于我们这个项目的目标是使代码更易管理,我们正在以稍微不同的方式做事情。
在Engine.cpp
文件中,我们将放置构造函数(Engine
)和公共run
函数。所有其他函数将放在它们自己的.cpp
文件中,文件名清楚地说明了哪个函数放在哪里。只要我们在包含Engine
类的所有文件的顶部添加适当的包含指令(#include "Engine.h"
),这对编译器来说不会是问题。
让我们开始编写Engine
并在Engine.cpp
中运行它。在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,选择(单击左键)C++文件(.cpp),然后在名称字段中输入Engine.cpp
。最后,单击添加按钮。现在我们已经准备好为Engine
类编写.cpp
文件。
编写 Engine 类构造函数定义
这个函数的代码将放在我们最近创建的Engine.cpp
文件中。
添加以下代码,然后我们可以讨论它:
#include "stdafx.h"
#include "Engine.h"
Engine::Engine()
{
// Get the screen resolution
// and create an SFML window and View
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
m_Window.create(VideoMode(resolution.x, resolution.y),
"Thomas was late",
Style::Fullscreen);
// Initialize the fullscreen view
m_MainView.setSize(resolution);
m_HudView.reset(
FloatRect(0, 0, resolution.x, resolution.y));
// Inititialize the split screen Views
m_LeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_RightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
m_BGLeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_BGRightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
m_BackgroundTexture = TextureHolder::GetTexture(
"graphics/background.png");
// Associate the sprite with the texture
m_BackgroundSprite.setTexture(m_BackgroundTexture);
}
我们之前看到的大部分代码都很熟悉。例如,有通常的代码行来获取屏幕分辨率以及创建一个RenderWindow
。在前面的代码结束时,我们使用了现在熟悉的代码来加载纹理并将其分配给一个 Sprite。在这种情况下,我们正在加载background.png
纹理并将其分配给m_BackgroundSprite
。
需要一些解释的是setViewport
函数的四次调用之间的代码。setViewport
函数将屏幕的一部分分配给 SFML 的View
对象。但它不使用像素坐标。它使用比例。其中“1”是整个屏幕(宽度或高度),每次调用setViewport
的前两个值是起始位置(水平,然后垂直),最后两个值是结束位置。
注意,m_LeftView
和m_BGLeftView
的位置完全相同,从屏幕的几乎最左侧(0.001)开始,结束于距离中心的两千分之一(0.498)。
m_RightView
和m_BGRightView
也位于完全相同的位置,从前两个View
对象的左侧开始(0.5),延伸到屏幕的几乎最右侧(0.998)。
此外,所有视图在屏幕的顶部和底部留下了一小部分空隙。当我们在白色背景上绘制这些View
对象时,它将产生在屏幕的两侧之间有一条细白线以及屏幕边缘周围有一条细白色边框的效果。
我已经尝试在以下图表中表示这种效果:
最好的理解方法是完成本章,运行代码,看到它的实际效果。
编写 run 函数定义
这个函数的代码将放在我们最近创建的Engine.cpp
文件中。
在上一个构造函数代码之后立即添加以下代码:
void Engine::run()
{
// Timing
Clock clock;
while (m_Window.isOpen())
{
Time dt = clock.restart();
// Update the total game time
m_GameTimeTotal += dt;
// Make a decimal fraction from the delta time
float dtAsSeconds = dt.asSeconds();
// Call each part of the game loop in turn
input();
update(dtAsSeconds);
draw();
}
}
run 函数是我们引擎的中心-它启动所有其他部分。首先,我们声明一个 Clock 对象。接下来,我们有熟悉的while(window.isOpen())
循环,它创建游戏循环。在这个 while 循环内,我们做以下事情:
-
重新启动
clock
并将上一个循环所花费的时间保存在dt
中。 -
跟踪
m_GameTimeTotal
中经过的总时间。 -
声明并初始化一个
float
来表示上一帧中经过的秒数的一部分。 -
调用
input
。 -
调用
update
并传入经过的时间(dtAsSeconds
)。 -
调用
draw
。
所有这些都应该看起来非常熟悉。新的是它包含在run
函数中。
编写 input 函数定义
如前所述,这个函数的代码将放在自己的文件中,因为它比构造函数或run
函数更复杂。我们将使用#include "Engine.h"
并在函数签名前加上Engine::
以确保编译器了解我们的意图。
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新项目...。在添加新项目窗口中,突出显示(通过左键单击)C++文件(.cpp
),然后在名称字段中输入Input.cpp
。最后,单击添加按钮。我们现在准备编写input
函数的代码。
添加以下代码:
void Engine::input()
{
Event event;
while (m_Window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Handle the player quitting
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
m_Window.close();
}
// Handle the player starting the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
m_Playing = true;
}
// Switch between Thomas and Bob
if (Keyboard::isKeyPressed(Keyboard::Q))
{
m_Character1 = !m_Character1;
}
// Switch between full and split screen
if (Keyboard::isKeyPressed(Keyboard::E))
{
m_SplitScreen = !m_SplitScreen;
}
}
}
}
与之前的两个项目一样,我们每帧都会检查RenderWindow
事件队列。同样,我们像以前一样使用if (Keyboard::isKeyPressed(Keyboard::E))
来检测特定的键盘键。我们刚刚添加的代码中最重要的是这些键实际上做了什么:
-
像往常一样,Esc键关闭窗口,游戏将退出。
-
Enter键将
m_Playing
设置为 true,最终,这将导致关卡开始。 -
Q键在全屏模式下在
true
和false
之间切换m_Character1
的值。它将在主View
的中心之间切换 Thomas 和 Bob。 -
E键在
true
和false
之间切换m_SplitScreen
。这将导致在全屏和分屏视图之间切换。
大部分键盘功能将在本章结束时完全可用。我们即将能够运行我们的游戏引擎。接下来,让我们编写update
函数。
编写 update 函数定义
如前所述,这个函数的代码将放在自己的文件中,因为它比构造函数或run
函数更加广泛。我们将使用#include "Engine.h"
并在函数签名前加上Engine::
以确保编译器知道我们的意图。
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件(.cpp
),然后在名称字段中输入Update.cpp
。最后,单击添加按钮。现在我们准备为update
函数编写一些代码。
将以下代码添加到Update.cpp
文件中以实现update
函数:
#include "stdafx.h"
#include "Engine.h"
#include <SFML/Graphics.hpp>
#include <sstream>
using namespace sf;
void Engine::update(float dtAsSeconds)
{
if (m_Playing)
{
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
}
首先注意,update
函数接收上一帧所用时间作为参数。当然,这对于update
函数履行其职责至关重要。
在这个阶段,前面的代码并没有实现任何可见的效果。它确立了我们将来需要的结构。它从m_TimeRemaining
中减去了上一帧所用的时间。它检查时间是否已经用完,如果是,就将m_NewLevelRequired
设置为true
。所有这些代码都包裹在一个if
语句中,只有当m_Playing
为true
时才执行。原因是,与以前的项目一样,我们不希望在游戏尚未开始时时间推移和对象更新。
随着项目的继续,我们将在这段代码的基础上构建。
编写绘制函数定义
如前所述,这个函数的代码将放在自己的文件中,因为它比构造函数或run
函数更加广泛。我们将使用#include "Engine.h"
并在函数签名前加上Engine::
以确保编译器知道我们的意图。
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件(.cpp
),然后在名称字段中输入Draw.cpp
。最后,单击添加按钮。现在我们准备为draw
函数添加一些代码。
将以下代码添加到Draw.cpp
文件中以实现draw
函数:
#include "stdafx.h"
#include "Engine.h"
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_MainView
m_Window.setView(m_MainView);
}
else
{
// Split screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
// Show everything we have just drawn
m_Window.display();
}
在前面的代码中,我们没有看到任何新东西。代码通常从清除屏幕开始。在这个项目中,我们用白色清除屏幕。新的是不同的绘制选项是如何通过条件分隔的,检查屏幕当前是分割还是全屏。
if (!m_SplitScreen)
{
}
else
{
}
如果屏幕没有分割,我们在背景View
(m_BGView
)中绘制背景精灵,然后切换到主全屏View
(m_MainView
)。请注意,目前我们实际上并没有在m_MainView
中进行任何绘制。
另一方面,如果屏幕被分割,else
块中的代码将被执行,我们将用屏幕左侧的背景精灵绘制m_BGLeftView
,然后切换到m_LeftView
。
然后,在else
块中,我们用屏幕右侧的背景精灵绘制m_BGRightView
,然后切换到m_RightView
。
在刚才描述的if...else
结构之外,我们切换到m_HUDView
。在这个阶段,我们实际上并没有在m_HUDView
中绘制任何东西。
与另外两个(input
、update
)最重要的函数一样,我们将经常回到draw
函数。我们将添加需要绘制的游戏新元素。您会注意到,每次我们这样做时,我们都会在主、左和右部分中添加代码。
让我们快速回顾一下Engine
类,然后我们可以启动它。
到目前为止的 Engine 类
我们已经将以前在main
函数中的所有代码抽象成了input
、update
和draw
函数。这些函数的连续循环以及时间控制都由run
函数处理。
考虑在 Visual Studio 中保持Input.cpp、Update.cpp和Draw.cpp标签打开,可能按顺序组织,如下面的截图所示:
在项目的过程中,我们将重新审视每一个这些函数,以添加更多的代码。现在我们有了Engine
类的基本结构和功能,我们可以在main
函数中创建一个实例,并看到它的运行。
编写主函数
让我们将HelloSFML.cpp
文件重命名为Main.cpp
。右键单击解决方案资源管理器中的HelloSFML
文件,然后选择重命名。将名称更改为Main.cpp
。这将是包含我们的main
函数和实例化Engine
类的代码的文件。
将以下代码添加到Main.cpp
中:
#include "stdafx.h"
#include "Engine.h"
int main()
{
// Declare an instance of Engine
Engine engine;
// Start the engine VRRrrrrmmm
engine.run();
// Quit in the usual way when the engine is stopped
return 0;
}
我们所做的就是为Engine
类添加一个包含指令,声明一个Engine
的实例,然后调用它的run
函数。直到玩家退出并且执行返回到main
和return 0
语句,一切都将由Engine
类处理。
这很容易。现在我们可以运行游戏,看到空的背景,无论是全屏还是分屏,最终都将包含所有的动作。
到目前为止,游戏在全屏模式下,只显示了背景:
现在按下E键,你将能够看到屏幕被整齐地分成两半,准备好进行分屏合作游戏:
以下是一些可能会让你困惑的问题。
常见问题
Q)我不完全理解代码文件的结构。
A)抽象确实可以使我们的代码结构变得不太清晰,但实际的代码本身变得更容易。我们将代码分割成Input.cpp
、Update.cpp
和Draw.cpp
,而不是像以前的项目那样把所有东西塞进主函数中。此外,随着我们的进行,我们将使用更多的类来将相关的代码分组在一起。再次学习《构建 Thomas Was Late 代码》部分,特别是图表。
总结
在本章中,我们介绍了 Thomas Was Late 游戏,并为项目的其余部分奠定了理解和代码结构的基础。在解决方案资源管理器中确实有很多文件,但只要我们理解每个文件的目的,我们会发现项目的实现变得更加容易。
在接下来的章节中,我们将学习另外两个基本的 C++主题,继承和多态。我们还将开始利用它们,构建三个类来代表两个可玩角色。
第十三章:高级 OOP-继承和多态
在本章中,我们将通过更深入地了解继承和多态的略微更高级的概念来进一步扩展我们对 OOP 的知识。然后,我们将能够使用这些新知识来实现我们游戏的明星角色 Thomas 和 Bob。在本章中,我们将更详细地介绍以下内容:
-
如何使用继承扩展和修改一个类?
-
通过多态将一个类的对象视为多种类型的类
-
抽象类以及设计从未实例化的类实际上可以很有用
-
构建一个抽象的
PlayableCharacter
类 -
在
Thomas
和Bob
类中使用继承 -
将 Thomas 和 Bob 添加到游戏项目中
继承
我们已经看到了如何通过实例化/创建来使用 SFML 库的类的对象来使用其他人的辛勤工作。但是这整个 OOP 的东西甚至比这更深入。
如果有一个类中有很多有用的功能,但不完全符合我们的要求怎么办?在这种情况下,我们可以从其他类中继承。就像它听起来的那样,继承意味着我们可以利用其他人的类的所有功能和好处,包括封装,同时进一步完善或扩展代码,使其特别适合我们的情况。在这个项目中,我们将从一些 SFML 类中继承并扩展。我们还将对我们自己的类进行同样的操作。
让我们看一些使用继承的代码,
扩展一个类
考虑到所有这些,让我们看一个示例类,并看看我们如何扩展它,只是为了看看语法和作为第一步。
首先,我们定义一个要继承的类。这与我们创建其他任何类的方式没有区别。看一下这个假设的Soldier
类声明:
class Soldier
{
private:
// How much damage can the soldier take
int m_Health;
int m_Armour;
int m_Range;
int m_ShotPower;
Public:
void setHealth(int h);
void setArmour(int a);
void setRange(int r);
void setShotPower(int p);
};
在前面的代码中,我们定义了一个Soldier
类。它有四个私有变量,m_Health
、m_Armour
、m_Range
和m_ShotPower
。它有四个公共函数setHealth
、setArmour
、setRange
和setShotPower
。我们不需要看函数的定义,它们只是初始化与它们的名称明显相关的适当变量。
我们还可以想象,一个完全实现的Soldier
类会比这个更加深入。它可能有shoot
、goProne
等函数。如果我们在一个 SFML 项目中实现了Soldier
类,它可能会有一个Sprite
对象,以及一个update
和一个getPostion
函数。
这里呈现的简单场景适合学习继承。现在让我们看看一些新的东西,实际上是从Soldier
类继承。看看这段代码,特别是突出显示的部分:
class Sniper : public Soldier
{
public:
// A constructor specific to Sniper
Sniper::Sniper();
};
通过在Sniper
类声明中添加: public Soldier
代码,Sniper
继承自Soldier
。但这到底意味着什么呢?Sniper
是一个Soldier
。它拥有Soldier
的所有变量和函数。然而,继承不仅仅是这样。
还要注意,在前面的代码中,我们声明了一个Sniper
构造函数。这个构造函数是Sniper
独有的。我们不仅继承了Soldier
,还扩展了Soldier
。Soldier
类的所有功能(定义)都由Soldier
类处理,但Sniper
构造函数的定义必须由Sniper
类处理。
这是假设的Sniper
构造函数定义可能是这样的:
// In Sniper.cpp
Sniper::Sniper()
{
setHealth(10);
setArmour(10);
setRange(1000);
setShotPower(100);
}
我们可以继续编写一堆其他类,这些类是Soldier
类的扩展,也许是Commando
和Infantryman
。每个类都有完全相同的变量和函数,但每个类也可以有一个独特的构造函数,用于初始化适合Soldier
类型的变量。Commando
可能有非常高的m_Health
和m_ShotPower
,但是m_Range
非常小。Infantryman
可能介于Commando
和Sniper
之间,每个变量的值都是中等的。
提示
好像面向对象编程还不够有用,我们现在可以对现实世界的对象进行建模,包括它们的层次结构。我们通过子类化、扩展和继承其他类来实现这一点。
我们可能想要学习的术语是,被扩展的类是超类,从超类继承的类是子类。我们也可以说父类和子类。
提示
你可能会发现自己对继承这个问题感到困惑:为什么?原因大致如下:我们可以一次编写通用代码;在父类中,我们可以更新这些通用代码,所有继承它的类也会被更新。此外,子类只能使用公共和受保护的实例变量和函数。因此,如果设计得当,这也进一步增强了封装的目标。
你说过受保护的吗?是的。类变量和函数有一个叫做protected的访问限定符。你可以把受保护的变量看作介于公共和私有之间。这里是访问限定符的快速摘要,以及有关受保护限定符的更多细节:
-
公共
变量和函数可以被任何人访问和使用。 -
私有
变量和函数只能被类的内部代码访问/使用。这对封装是有利的,当我们需要访问/更改私有变量时,我们可以提供公共的getter
和setter
函数(如getSprite
等)。如果我们扩展了一个具有私有
变量和函数的类,那么子类不能直接访问其父类的私有数据。 -
受保护
变量和函数几乎与私有相同。它们不能被类的实例直接访问/使用。但是,它们可以被任何扩展它们所声明的类的类直接使用。因此,它们就像是私有的,除了对子类。
要完全理解受保护的变量和函数是什么以及它们如何有用,让我们先看看另一个主题,然后我们可以看到它们的作用。
多态
多态允许我们编写的代码不那么依赖于我们要操作的类型。这可以使我们的代码更清晰和更高效。多态意味着不同的形式。如果我们编码的对象可以是多种类型的东西,那么我们就可以利用这一点。
注意
对我们来说,多态意味着什么?简化到最简单的定义,多态就是:任何子类都可以作为使用超类的代码的一部分。这意味着我们可以编写更简单、更易于理解的代码,也更容易修改或更改。此外,我们可以为超类编写代码,并依赖于这样一个事实,即在一定的参数范围内,无论它被子类化多少次,代码仍然可以正常工作。
让我们讨论一个例子。
假设我们想要使用多态来帮助编写一个动物园管理游戏,我们需要喂养和照顾动物的需求。我们可能会想要一个名为feed
的函数。我们可能还想将要喂食的动物的实例传递给feed
函数。
当然,动物园有很多种类的动物——狮子
、大象
和三趾树懒
。有了我们对 C++继承的新知识,编写一个Animal
类并让所有不同类型的动物从中继承将是合理的。
如果我们想要编写一个可以将狮子、大象和三趾树懒作为参数传递的函数(feed
),似乎我们需要为每种类型的动物
编写一个feed
函数。然而,我们可以编写多态函数,具有多态返回类型和参数。看看这个假设的feed
函数的定义:
void feed(Animal& a)
{
a.decreaseHunger();
}
前面的函数将Animal
引用作为参数,这意味着可以将从扩展Animal
类构建的任何对象传递给它。
因此,即使今天编写代码并在一周、一个月或一年后创建另一个子类,相同的函数和数据结构仍将起作用。此外,我们可以对子类强制执行一组规则,规定它们可以做什么,不能做什么,以及如何做。因此,一个阶段的良好设计可以影响其他阶段。
但我们真的会想要实例化一个真正的动物吗?
抽象类-虚函数和纯虚函数
抽象类是一种不能被实例化的类,因此不能成为对象。
提示
我们可能想在这里学习的一些术语是具体类。具体类是指任何不是抽象的类。换句话说,到目前为止我们编写的所有类都是具体类,可以实例化为可用的对象。
那么,这段代码永远不会被使用吗?但这就像支付一个建筑师设计你的房子,然后永远不建造它!
如果我们或类的设计者想要强制其用户在使用其类之前继承它,他们可以将一个类设为抽象。然后,我们就不能从中创建对象;因此,我们必须首先扩展它,然后从子类创建对象。
为此,我们可以使一个函数纯虚,并且不提供任何定义。然后,该函数必须在扩展它的任何类中重写(重新编写)。
让我们看一个例子;这会有所帮助。我们通过添加一个纯虚函数来使一个类成为抽象类,比如这个只能执行通用动作 makeNoise 的抽象Animal
类:
Class Animal
private:
// Private stuff here
public:
void virtual makeNoise() = 0;
// More public stuff here
};
如您所见,我们在函数声明之前添加了 C++关键字virtual
,并在函数声明之后添加了= 0
。现在,任何扩展/继承自Animal
的类都必须重写makeNoise
函数。这可能是有道理的,因为不同类型的动物发出非常不同类型的噪音。我们可能会假设任何扩展Animal
类的人都足够聪明,以注意到Animal
类不能发出噪音,并且他们将需要处理它,但如果他们没有注意到呢?关键是通过制作一个纯虚函数,我们保证他们会注意到,因为他们必须。
抽象类也很有用,因为有时我们需要一个可以用作多态类型的类,但我们需要保证它永远不能被用作对象。例如,Animal
本身并没有太多意义。我们不谈论动物;我们谈论动物的类型。我们不会说,“哦,看那只可爱的、蓬松的、白色的动物!”或者,“昨天我们去宠物店买了一只动物和一个动物床”。这太抽象了。
因此,抽象类有点像一个模板,可以被任何继承它的类使用。如果我们正在构建一个类似于“工业帝国”类型的游戏,玩家管理企业及其员工,我们可能需要一个Worker
类,并将其扩展为Miner
,Steelworker
,OfficeWorker
,当然还有Programmer
。但是一个普通的Worker
到底是做什么的?我们为什么要实例化一个?
答案是我们不想实例化一个,但我们可能想要将其用作多态类型,以便在函数之间传递多个Worker
子类,并且具有可以容纳所有类型的工作者的数据结构。
所有纯虚函数必须被扩展父类的任何类重写。这意味着抽象类可以提供一些在其所有子类中都可用的常见功能。例如,Worker
类可能有m_AnnualSalary
,m_Productivity
和m_Age
成员变量。它可能还有getPayCheck
函数,这不是纯虚的,在所有子类中都是相同的,但它可能有一个doWork
函数,这是纯虚的,必须被重写,因为所有不同类型的Worker
将以非常不同的方式doWork
。
注意
顺便说一下,virtual,与纯虚相反,是一个可以选择性重写的函数。你声明一个虚函数的方式与声明纯虚函数相同,但是最后不加上= 0
。在当前的游戏项目中,我们将使用纯虚函数。
如果这些虚拟、纯虚或抽象的东西有任何不清楚的地方,使用它可能是理解它的最好方法。
构建 PlayableCharacter 类
现在我们了解了继承、多态和纯虚函数的基础知识,我们将把它们应用起来。我们将构建一个PlayableCharacter
类,该类将拥有游戏中任何角色所需的绝大部分功能。它将有一个纯虚函数handleInput
。handleInput
函数在子类中需要有很大的不同,所以这是有道理的。
由于PlayableCharacter
将有一个纯虚函数,它将是一个抽象类,不可能有它的对象。然后我们将构建Thomas
和Bob
类,它们将继承自PlayableCharacter
,实现纯虚函数的定义,并允许我们在游戏中实例化Bob
和Thomas
对象。
编写 PlayableCharacter.h
通常情况下,创建一个类时,我们将从包含成员变量和函数声明的头文件开始。新的是,在这个类中,我们将声明一些protected成员变量。记住,受保护的变量可以被继承自具有受保护变量的类的类使用,就好像它们是Public
一样。
在解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件( .h
),然后在名称字段中输入PlayableCharacter.h
。最后,单击添加按钮。现在我们准备编写PlayableCharacter
类的头文件。
我们将在三个部分添加和讨论PlayableCharacter.h
文件的内容。首先是protected部分,然后是private,最后是public。
在PlayableCharacter.h
文件中添加下面显示的代码:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class PlayableCharacter
{
protected:
// Of course we will need a sprite
Sprite m_Sprite;
// How long does a jump last
float m_JumpDuration;
// Is character currently jumping or falling
bool m_IsJumping;
bool m_IsFalling;
// Which directions is the character currently moving in
bool m_LeftPressed;
bool m_RightPressed;
// How long has this jump lasted so far
float m_TimeThisJump;
// Has the player just initialted a jump
bool m_JustJumped = false;
// Private variables and functions come next
在我们刚刚编写的代码中,要注意的第一件事是所有变量都是protected
。这意味着当我们扩展这个类时,我们刚刚编写的所有变量将对那些扩展它的类可访问。我们将用Thomas
和Bob
类扩展这个类。
除了protected
访问规范之外,之前的代码没有什么新的或复杂的。然而,值得注意的是一些细节。然后随着我们的进展,理解类的工作原理将会变得容易。所以,让我们逐个运行那些protected
变量。
我们有一个相当可预测的Sprite
,m_Sprite
。我们有一个名为m_JumpDuration
的浮点数,它将保存代表角色能够跳跃的时间。数值越大,角色跳得越远/高。
接下来,我们有一个布尔值m_IsJumping
,当角色跳跃时为true
,否则为false
。这将有助于确保角色在空中时不能跳跃。
m_IsFalling
变量与m_IsJumping
具有类似的用途。知道角色何时下落将是有用的。
接下来,我们有两个布尔值,如果角色的左键或右键当前被按下,将为 true。这些取决于角色(A和D代表 Thomas,左右箭头键代表 Bob)。我们将在Thomas
和Bob
类中看到如何响应这些布尔值。
m_TimeThisJump
浮点变量在每一帧m_IsJumping
为true
时更新。然后我们就知道m_JumpDuration
已经达到了。
最后一个protected
变量是布尔值m_JustJumped
。如果在当前帧中启动了跳跃,它将为true
。这将有助于知道何时播放跳跃音效。
接下来,在PlayableCharacter.h
文件中添加以下private
变量:
private:
// What is the gravity
float m_Gravity;
// How fast is the character
float m_Speed = 400;
// Where is the player
Vector2f m_Position;
// Where are the characters various body parts?
FloatRect m_Feet;
FloatRect m_Head;
FloatRect m_Right;
FloatRect m_Left;
// And a texture
Texture m_Texture;
// All our public functions will come next
在前面的代码中,我们有一些有趣的private
变量。请记住,这些变量只能被PlayableCharacter
类中的代码直接访问。Thomas
和Bob
类将无法直接访问它们。
m_Gravity
变量将保存角色下落的每秒像素数。m_Speed
变量将保存角色每秒左右移动的像素数。
Vector2f
,m_Position
变量是角色在世界中(而不是屏幕上)的位置,即角色中心的位置。
接下来的四个FloatRect
对象很重要。在Zombie Arena游戏中进行碰撞检测时,我们只是简单地检查两个FloatRect
对象是否相交。每个FloatRect
对象代表整个角色、拾取物或子弹。对于非矩形形状的对象(僵尸和玩家),这有点不准确。
在这个游戏中,我们需要更精确。m_Feet
,m_Head
,m_Right
和m_Left
的FloatRect
对象将保存角色身体不同部位的坐标。这些坐标将在每一帧中更新。
通过这些坐标,我们将能够准确地知道角色何时落在平台上,跳跃时碰到头部,或者与侧面的瓷砖擦肩而过。
最后,我们有Texture
。Texture
是private
的,因为它不会被Thomas
或Bob
类直接使用,但是,正如我们所看到的,Sprite
是protected
的,因为它被直接使用。
现在在PlayableCharacter.h
文件中添加所有的public
函数,然后我们将讨论它们:
public:
void spawn(Vector2f startPosition, float gravity);
// This is a pure virtual function
bool virtual handleInput() = 0;
// This class is now abstract and cannot be instanciated
// Where is the player
FloatRect getPosition();
// A rectangle representing the position
// of different parts of the sprite
FloatRect getFeet();
FloatRect getHead();
FloatRect getRight();
FloatRect getLeft();
// Send a copy of the sprite to main
Sprite getSprite();
// Make the character stand firm
void stopFalling(float position);
void stopRight(float position);
void stopLeft(float position);
void stopJump();
// Where is the center of the character
Vector2f getCenter();
// We will call this function once every frame
void update(float elapsedTime);
};// End of the class
让我们谈谈我们刚刚添加的每个函数声明。这将使编写它们的定义更容易跟踪。
-
spawn
函数接收一个名为startPosition
的Vector2f
和一个名为gravity
的float
。顾名思义,startPosition
将是角色开始的关卡坐标,gravity
将是角色下落的每秒像素数。 -
bool virtual handleInput() = 0
当然是我们的纯虚函数。由于PlayableCharacter
有这个函数,任何扩展它的类,如果我们想要实例化它,必须为这个函数提供一个定义。因此,当我们一会儿为PlayableCharacter
写所有函数定义时,我们不会为handleInput
提供定义。当然,Thomas
和Bob
类中也需要有定义。 -
getPosition
函数返回一个FloatRect
,表示整个角色的位置。 -
getFeet()
函数,以及getHead
,getRight
和getLeft
,每个都返回一个FloatRect
,表示角色身体特定部位的位置。这正是我们需要进行详细的碰撞检测。 -
getSprite
函数像往常一样,将m_Sprite
的副本返回给调用代码。 -
stopFalling
,stopRight
,stopLeft
和stopJump
函数接收一个float
值,函数将使用该值重新定位角色,并阻止其通过实心瓷砖行走或跳跃。 -
getCenter
函数返回一个Vector2f
给调用代码,让它准确知道角色的中心在哪里。这个值当然保存在m_Position
中。我们将在后面看到,它被Engine
类用来围绕适当的角色中心适当地调整View
。 -
我们以前多次看到的
update
函数,像往常一样,它接受一个float
参数,表示当前帧所花费的秒数的一部分。然而,这个update
函数需要做的工作比以前的update
函数(来自其他项目)更多。它需要处理跳跃,以及更新表示头部、脚部、左侧和右侧的FloatRect
对象。
现在我们可以为所有函数编写定义,当然,除了handleInput
。
编写 PlayableCharacter.cpp
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,突出显示(通过左键单击)C++文件(.cpp
),然后在名称字段中键入PlayableCharacter.cpp
。最后,单击添加按钮。我们现在准备好为PlayableCharacter
类编写.cpp
文件了。
我们将把代码和讨论分成几个部分。首先,添加包含指令和spawn
函数的定义:
#include "stdafx.h"
#include "PlayableCharacter.h"
void PlayableCharacter::spawn(Vector2f startPosition, float gravity)
{
// Place the player at the starting point
m_Position.x = startPosition.x;
m_Position.y = startPosition.y;
// Initialize the gravity
m_Gravity = gravity;
// Move the sprite in to position
m_Sprite.setPosition(m_Position);
}
spawn
函数使用传入的位置初始化m_Position
,并初始化m_Gravity
。代码的最后一行将m_Sprite
移动到其起始位置。
接下来,在上述代码之后立即添加update
函数的定义:
void PlayableCharacter::update(float elapsedTime)
{
if (m_RightPressed)
{
m_Position.x += m_Speed * elapsedTime;
}
if (m_LeftPressed)
{
m_Position.x -= m_Speed * elapsedTime;
}
// Handle Jumping
if (m_IsJumping)
{
// Update how long the jump has been going
m_TimeThisJump += elapsedTime;
// Is the jump going upwards
if (m_TimeThisJump < m_JumpDuration)
{
// Move up at twice gravity
m_Position.y -= m_Gravity * 2 * elapsedTime;
}
else
{
m_IsJumping = false;
m_IsFalling = true;
}
}
// Apply gravity
if (m_IsFalling)
{
m_Position.y += m_Gravity * elapsedTime;
}
// Update the rect for all body parts
FloatRect r = getPosition();
// Feet
m_Feet.left = r.left + 3;
m_Feet.top = r.top + r.height - 1;
m_Feet.width = r.width - 6;
m_Feet.height = 1;
// Head
m_Head.left = r.left;
m_Head.top = r.top + (r.height * .3);
m_Head.width = r.width;
m_Head.height = 1;
// Right
m_Right.left = r.left + r.width - 2;
m_Right.top = r.top + r.height * .35;
m_Right.width = 1;
m_Right.height = r.height * .3;
// Left
m_Left.left = r.left;
m_Left.top = r.top + r.height * .5;
m_Left.width = 1;
m_Left.height = r.height * .3;
// Move the sprite into position
m_Sprite.setPosition(m_Position);
}
代码的前两部分检查m_RightPressed
或m_LeftPressed
是否为true
。如果其中任何一个是,m_Position
将使用与上一个项目相同的公式(经过的时间乘以速度)进行更改。
接下来,我们看看角色当前是否正在执行跳跃。我们从if(m_IsJumping)
知道这一点。如果这个if
语句为true
,代码将执行以下步骤:
-
使用
elapsedTime
更新m_TimeThisJump
。 -
检查
m_TimeThisJump
是否仍然小于m_JumpDuration
。如果是,则通过两倍的重力乘以经过的时间更改m_Position
的 y 坐标。 -
在
else
子句中,当m_TimeThisJump
不低于m_JumpDuration
时,m_Falling
被设置为true
。这样做的效果将在下面看到。此外,m_Jumping
被设置为false
。这可以防止我们刚刚讨论的代码执行,因为if(m_IsJumping)
现在为 false。
if(m_IsFalling)
块在每帧移动m_Position
向下。它使用当前的m_Gravity
值和经过的时间进行移动。
以下代码(几乎是剩下的所有代码)更新了角色的身体部位,相对于整个精灵的当前位置。查看以下图表,了解代码如何计算角色的虚拟头部、脚部、左侧和右侧的位置:
代码的最后一行使用setPosition
函数将精灵移动到正确的位置,以便在update
函数的所有可能性之后。
现在立即添加getPosition
、getCenter
、getFeet
、getHead
、getLeft
、getRight
和getSprite
函数的定义,紧接在上述代码之后:
FloatRect PlayableCharacter::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Vector2f PlayableCharacter::getCenter()
{
return Vector2f(
m_Position.x + m_Sprite.getGlobalBounds().width / 2,
m_Position.y + m_Sprite.getGlobalBounds().height / 2
);
}
FloatRect PlayableCharacter::getFeet()
{
return m_Feet;
}
FloatRect PlayableCharacter::getHead()
{
return m_Head;
}
FloatRect PlayableCharacter::getLeft()
{
return m_Left;
}
FloatRect PlayableCharacter::getRight()
{
return m_Right;
}
Sprite PlayableCharacter::getSprite()
{
return m_Sprite;
}
getPosition
函数返回包装整个精灵的FloatRect
,getCenter
返回一个包含精灵中心的Vector2f
。请注意,我们将精灵的高度和宽度除以 2,以便动态地得出这个结果。这是因为托马斯和鲍勃的身高不同。
getFeet
、getHead
、getLeft
和getRight
函数返回代表角色身体部位的FloatRect
对象,我们在update
函数中每帧更新。我们将在下一章中编写使用这些函数的碰撞检测代码。
getSprite
函数像往常一样返回m_Sprite
的副本。
最后,对于PlayableCharacter
类,添加stopFalling
、stopRight
、stopLeft
和stopJump
函数的定义。在上一段代码之后立即执行:
void PlayableCharacter::stopFalling(float position)
{
m_Position.y = position - getPosition().height;
m_Sprite.setPosition(m_Position);
m_IsFalling = false;
}
void PlayableCharacter::stopRight(float position)
{
m_Position.x = position - m_Sprite.getGlobalBounds().width;
m_Sprite.setPosition(m_Position);
}
void PlayableCharacter::stopLeft(float position)
{
m_Position.x = position + m_Sprite.getGlobalBounds().width;
m_Sprite.setPosition(m_Position);
}
void PlayableCharacter::stopJump()
{
// Stop a jump early
m_IsJumping = false;
m_IsFalling = true;
}
每个先前的函数都接收一个值作为参数,用于重新定位精灵的顶部、底部、左侧或右侧。这些值是什么以及如何获得它们将在下一章中看到。每个先前的函数还会重新定位精灵。
最后一个函数是stopJump
函数,也将用于碰撞检测。它设置了m_IsJumping
和m_IsFalling
的必要值来结束跳跃。
构建 Thomas 和 Bob 类
现在我们要真正使用继承。我们将为 Thomas 建立一个类,为 Bob 建立一个类。它们都将继承我们刚刚编写的PlayableCharacter
类。然后它们将拥有PlayableCharacter
类的所有功能,包括直接访问其protected
变量。我们还将为纯虚函数handleInput
添加定义。您将注意到Thomas
和Bob
的handleInput
函数将不同。
编写 Thomas.h
在解决方案资源管理器中右键单击头文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击突出显示(高亮)头文件(.h
),然后在名称字段中键入Thomas.h
。最后,单击添加按钮。我们现在准备为Thomas
类编写头文件。
现在将此代码添加到Thomas.h
类中:
#pragma once
#include "PlayableCharacter.h"
class Thomas : public PlayableCharacter
{
public:
// A constructor specific to Thomas
Thomas::Thomas();
// The overridden input handler for Thomas
bool virtual handleInput();
};
前面的代码非常简短。我们可以看到我们有一个构造函数,并且我们将要实现纯虚拟的handleInput
函数,所以现在让我们来做。
编写 Thomas.cpp
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击突出显示(高亮)C++文件(.cpp
),然后在名称字段中键入Thomas.cpp
。最后,单击添加按钮。我们现在准备为Thomas
类编写.cpp
文件。
将Thomas
构造函数添加到Thomas.cpp
文件中,如下面的片段所示:
#include "stdafx.h"
#include "Thomas.h"
#include "TextureHolder.h"
Thomas::Thomas()
{
// Associate a texture with the sprite
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/thomas.png"));
m_JumpDuration = .45;
}
我们只需要加载thomas.png
图形并将跳跃的持续时间(m_JumpDuration
)设置为.45
(几乎半秒)。
添加handleInput
函数的定义,如下面的片段所示:
// A virtual function
bool Thomas::handleInput()
{
m_JustJumped = false;
if (Keyboard::isKeyPressed(Keyboard::W))
{
// Start a jump if not already jumping
// but only if standing on a block (not falling)
if (!m_IsJumping && !m_IsFalling)
{
m_IsJumping = true;
m_TimeThisJump = 0;
m_JustJumped = true;
}
}
else
{
m_IsJumping = false;
m_IsFalling = true;
}
if (Keyboard::isKeyPressed(Keyboard::A))
{
m_LeftPressed = true;
}
else
{
m_LeftPressed = false;
}
if (Keyboard::isKeyPressed(Keyboard::D))
{
m_RightPressed = true;
}
else
{
m_RightPressed = false;
}
return m_JustJumped;
}
这段代码应该看起来很熟悉。我们正在使用 SFML 的isKeyPressed
函数来查看W,A或D键是否被按下。
当按下W键时,玩家正在尝试跳跃。然后代码使用if(!m_IsJumping && !m_IsFalling)
代码,检查角色是否已经在跳跃,而且也不在下落。当这些测试都为真时,m_IsJumping
设置为true
,m_TimeThisJump
设置为零,并且m_JustJumped
设置为true
。
当前两个测试不为true
时,将执行else
子句,并将m_Jumping
设置为false
,m_IsFalling
设置为true
。
处理按下A和D键的操作就是简单地将m_LeftPressed
和/或m_RightPressed
设置为true
或false
。update
函数现在将能够处理移动角色。
函数中的最后一行代码返回m_JustJumped
的值。这将让调用代码知道是否需要播放跳跃音效。
现在我们将编写Bob
类,尽管这几乎与Thomas
类相同,除了具有不同的跳跃能力,不同的Texture
,并且在键盘上使用不同的键。
编写 Bob.h
Bob
类在结构上与Thomas
类相同。它继承自PlayableCharacter
,有一个构造函数,并提供了handleInput
函数的定义。与Thomas
相比的区别是,我们以不同的方式初始化了一些 Bob 的成员变量,并且我们也以不同的方式处理输入(在handleInput
函数中)。让我们编写这个类并查看细节。
在解决方案资源管理器中右键单击头文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击突出显示(高亮)头文件( .h
),然后在名称字段中键入Bob.h
。最后,单击添加按钮。我们现在准备为Bob
类编写头文件。
将以下代码添加到Bob.h
文件中:
#pragma once
#include "PlayableCharacter.h"
class Bob : public PlayableCharacter
{
public:
// A constructor specific to Bob
Bob::Bob();
// The overriden input handler for Bob
bool virtual handleInput();
};
前面的代码与Thomas.h
文件相同,除了类名和构造函数名。
编写 Bob.cpp
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件( .cpp
),然后在名称字段中键入Thomas.cpp
。最后,单击添加按钮。现在我们已经准备好为Bob
类编写.cpp
文件。
将Bob
构造函数的代码添加到Bob.cpp
文件中。请注意,纹理不同(bob.png
),并且m_JumpDuration
初始化为一个明显较小的值。Bob 现在是他自己独特的自己:
#include "stdafx.h"
#include "Bob.h"
#include "TextureHolder.h"
Bob::Bob()
{
// Associate a texture with the sprite
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/bob.png"));
m_JumpDuration = .25;
}
在Bob
构造函数之后立即添加handleInput
代码:
bool Bob::handleInput()
{
m_JustJumped = false;
if (Keyboard::isKeyPressed(Keyboard::Up))
{
// Start a jump if not already jumping
// but only if standing on a block (not falling)
if (!m_IsJumping && !m_IsFalling)
{
m_IsJumping = true;
m_TimeThisJump = 0;
m_JustJumped = true;
}
}
else
{
m_IsJumping = false;
m_IsFalling = true;
}
if (Keyboard::isKeyPressed(Keyboard::Left))
{
m_LeftPressed = true;
}
else
{
m_LeftPressed = false;
}
if (Keyboard::isKeyPressed(Keyboard::Right))
{
m_RightPressed = true;;
}
else
{
m_RightPressed = false;
}
return m_JustJumped;
}
请注意,这段代码几乎与Thomas
类的handleInput
函数中的代码相同。唯一的区别是我们对不同的按键做出响应(左箭头键,右箭头键和上箭头键用于跳跃)。
现在我们有了一个通过Bob
和Thomas
扩展的PlayableCharacter
类,我们可以在游戏中添加一个Bob
和一个Thomas
实例。
更新游戏引擎以使用 Thomas 和 Bob
为了能够运行游戏并看到我们的新角色,我们必须声明它们的实例,调用它们的spawn
函数,每帧更新它们,并每帧绘制它们。现在让我们来做这些。
更新 Engine.h 以添加 Bob 和 Thomas 的实例
打开Engine.h
文件,并添加下面显示的代码行:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
...
...
现在我们有了Thomas
和Bob
的实例,它们都是从PlayableCharacter
派生出来的。
更新 input 函数以控制 Thomas 和 Bob
现在我们将添加控制这两个角色的能力。这段代码将放在代码的输入部分。当然,对于这个项目,我们有一个专门的input
函数。打开Input.cpp
并添加这段突出显示的代码:
void Engine::input()
{
Event event;
while (m_Window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Handle the player quitting
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
m_Window.close();
}
// Handle the player starting the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
m_Playing = true;
}
// Switch between Thomas and Bob
if (Keyboard::isKeyPressed(Keyboard::Q))
{
m_Character1 = !m_Character1;
}
// Switch between full and split-screen
if (Keyboard::isKeyPressed(Keyboard::E))
{
m_SplitScreen = !m_SplitScreen;
}
}
}
// Handle input specific to Thomas
if(m_Thomas.handleInput())
{
// Play a jump sound
}
// Handle input specific to Bob
if(m_Bob.handleInput())
{
// Play a jump sound
}
}
请注意,以前的代码是多么简单,因为所有的功能都包含在Thomas
和Bob
类中。代码只需要为Thomas
和Bob
类中的每一个添加一个包含指令。然后,在input
函数中,代码只是调用m_Thomas
和m_Bob
上的纯虚拟handleInput
函数。我们将在第十五章中处理播放跳跃音效,声音空间化和 HUD。
更新 update 函数以生成和更新 PlayableCharacter 实例
这可以分为两部分。首先,我们需要在新关卡开始时生成 Bob 和 Thomas,其次,我们需要每帧更新(通过调用它们的update
函数)。
生成 Thomas 和 Bob
随着项目的进展,我们需要在几个不同的地方调用我们的Thomas
和Bob
对象的生成函数。最明显的是,当一个新的关卡开始时,我们需要生成这两个角色。在接下来的章节中,随着在关卡开始时需要执行的任务数量增加,我们将编写一个loadLevel
函数。现在,让我们在update
函数中调用m_Thomas
和m_Bob
的spawn
,如下所示的突出显示的代码。添加这段代码,但请记住,这段代码最终将被删除和替换:
void Engine::update(float dtAsSeconds)
{
if (m_NewLevelRequired)
{
// These calls to spawn will be moved to a new
// loadLevel() function soon
// Spawn Thomas and Bob
m_Thomas.spawn(Vector2f(0,0), GRAVITY);
m_Bob.spawn(Vector2f(100, 0), GRAVITY);
// Make sure spawn is called only once
m_TimeRemaining = 10;
m_NewLevelRequired = false;
}
if (m_Playing)
{
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
}
以前的代码只是调用spawn
并传入游戏世界中的位置以及重力。代码包裹在一个if
语句中,检查是否需要新的关卡。实际的生成代码将被移动到一个专门的loadLevel
函数中,但if
条件将成为完成项目的一部分。此外,m_TimeRemaining
被设置为一个相当任意的 10 秒。
每帧更新 Thomas 和 Bob
接下来,我们将更新 Thomas 和 Bob。我们需要做的就是调用它们的update
函数,并传入这一帧所花费的时间。
添加下面突出显示的代码:
void Engine::update(float dtAsSeconds)
{
if (m_NewLevelRequired)
{
// These calls to spawn will be moved to a new
// LoadLevel function soon
// Spawn Thomas and Bob
m_Thomas.spawn(Vector2f(0,0), GRAVITY);
m_Bob.spawn(Vector2f(100, 0), GRAVITY);
// Make sure spawn is called only once
m_NewLevelRequired = false;
}
if (m_Playing)
{
// Update Thomas
m_Thomas.update(dtAsSeconds);
// Update Bob
m_Bob.update(dtAsSeconds);
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
}
现在角色可以移动了,我们需要更新适当的View
对象,使它们围绕角色居中,并使它们成为关注的中心。当然,直到我们在游戏世界中有一些物体,才能实现实际运动的感觉。
请按照以下片段所示添加突出显示的代码:
void Engine::update(float dtAsSeconds)
{
if (m_NewLevelRequired)
{
// These calls to spawn will be moved to a new
// LoadLevel function soon
// Spawn Thomas and Bob
m_Thomas.spawn(Vector2f(0,0), GRAVITY);
m_Bob.spawn(Vector2f(100, 0), GRAVITY);
// Make sure spawn is called only once
m_NewLevelRequired = false;
}
if (m_Playing)
{
// Update Thomas
m_Thomas.update(dtAsSeconds);
// Update Bob
m_Bob.update(dtAsSeconds);
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
// Set the appropriate view around the appropriate character
if (m_SplitScreen)
{
m_LeftView.setCenter(m_Thomas.getCenter());
m_RightView.setCenter(m_Bob.getCenter());
}
else
{
// Centre full screen around appropriate character
if (m_Character1)
{
m_MainView.setCenter(m_Thomas.getCenter());
}
else
{
m_MainView.setCenter(m_Bob.getCenter());
}
}
}
先前的代码处理了两种可能的情况。首先,if(mSplitScreen)
条件将左侧视图定位在m_Thomas
周围,右侧视图定位在m_Bob
周围。当游戏处于全屏模式时执行的else
子句测试m_Character1
是否为true
。如果是,则全屏视图(m_MainView
)围绕托马斯居中,否则围绕鲍勃居中。您可能还记得玩家可以使用E键在分屏模式和全屏模式之间切换,使用Q键在全屏模式下切换 Bob 和 Thomas。我们在Engine
类的input
函数中编写了这个代码,回到第十二章,抽象和代码管理-更好地利用 OOP。
绘制鲍勃和托马斯
确保Draw.cpp
文件已打开,并添加如下突出显示的代码,如下片段所示:
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_MainView
m_Window.setView(m_MainView);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
}
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw bob
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
// Show everything we have just drawn
m_Window.display();
}
请注意,我们在全屏模式下绘制了托马斯和鲍勃的全屏,左侧和右侧。还要注意,在分屏模式下绘制角色的方式有非常微妙的差异。在绘制屏幕的左侧时,我们切换了绘制角色的顺序,并在鲍勃之后绘制了托马斯。因此,托马斯将始终位于左侧的顶部,鲍勃位于右侧。这是因为左侧为托马斯控制的玩家,右侧为鲍勃控制的玩家。
您可以运行游戏,看到托马斯和鲍勃在屏幕中央:
如果您按Q键从托马斯切换焦点到鲍勃,您将看到View
进行轻微调整。如果您移动其中一个角色向左或向右(托马斯使用A和D,鲍勃使用箭头键),您将看到它们相对于彼此移动。
尝试按E键在全屏和分屏之间切换。然后再次尝试移动两个角色以查看效果。在下面的截图中,您可以看到托马斯始终位于左侧窗口的中心,鲍勃始终位于右侧窗口的中心:
如果您让游戏运行足够长的时间,角色将每十秒重新生成在它们的原始位置。这是我们在完成游戏时需要的功能的开端。这种行为是由m_TimeRemaining
下降到零以下,然后将m_NewLevelRequired
变量设置为true
引起的。
还要注意的是,直到我们绘制了层级的细节,我们才能看到移动的完整效果。实际上,虽然看不到,但两个角色都在以每秒 300 像素的速度持续下落。由于摄像头每帧都围绕它们居中,并且游戏世界中没有其他物体,我们看不到这种向下运动。
如果您想自己演示一下,请按照以下代码更改对m_Bob.spawn
的调用:
m_Bob.spawn(Vector2f(0,0), 0);
现在鲍勃没有重力效应,托马斯将明显远离他。如下截图所示:
我们将在下一章中添加一些可玩的关卡以进行交互。
常见问题解答
Q)我们学习了多态性,但到目前为止,我没有注意到游戏代码中有任何多态性。
A)我们将在下一章中看到多态性的作用,当我们编写一个以PlayableCharacter
作为参数的函数时。我们将看到如何将 Bob 或 Thomas 传递给这个新函数,并且它们将以相同的方式工作。
摘要
在这一章中,我们学习了一些新的 C++概念。首先,继承允许我们扩展一个类并获得其所有功能。我们还学到,我们可以将变量声明为受保护的,这将使子类可以访问它们,但它们仍然会被封装(隐藏)在所有其他代码之外。我们还使用了纯虚函数,这使得一个类成为抽象类,意味着该类不能被实例化,因此必须从中继承/扩展。我们还介绍了多态的概念,但需要等到下一章才能在我们的游戏中使用它。
接下来,我们将为游戏添加一些重要功能。在接下来的一章中,Thomas 和 Bob 将会行走、跳跃和下落。他们甚至可以跳在彼此的头上,以及探索一些从文本文件加载的关卡设计。
第十四章:构建可玩级别和碰撞检测
这一章可能是这个项目中最令人满意的。原因是到最后,我们将有一个可玩的游戏。虽然还有一些功能要实现(声音,粒子效果,HUD 和着色器效果),但鲍勃和托马斯将能够奔跑,跳跃和探索世界。此外,你将能够通过简单地在文本文件中制作平台和障碍物来创建几乎任何大小或复杂度的级别设计。
本章将通过以下主题来实现所有这些内容:
-
探索如何在文本文件中设计级别
-
构建一个
LevelManager
类,它将从文本文件加载级别,将它们转换为我们的游戏可以使用的数据,并跟踪级别细节,如生成位置,当前级别和允许的时间限制 -
更新游戏引擎以使用
LevelManager
-
编写一个多态函数来处理 Bob 和 Thomas 的碰撞检测
设计一些级别
记得我们在第十二章中介绍的精灵表吗,抽象和代码管理-更好地利用 OOP。这里再次显示,用数字注释表示我们将构建级别的每个瓦片:
我将屏幕截图放在灰色背景上,这样你可以清楚地看到精灵表的不同细节。方格背景表示透明度级别。因此,除了数字 1 之外的所有瓦片都会至少显示一点背后的背景:
-
瓦片 0 是完全透明的,将用于填补没有其他瓦片的空隙
-
瓷砖 1 是为了托马斯和鲍勃将走的平台
-
瓷砖 2 是用于火瓦片,瓦片 3 是用于水瓦片
-
你可能需要仔细查看瓦片 4。它有一个白色的方形轮廓。这是托马斯和鲍勃必须一起到达的级别目标。
在讨论设计级别时,请记住这个屏幕截图。
我们将把这些瓦片号码的组合输入到文本文件中来设计布局。举个例子:
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
1111111111000111111222222221111133111111111411
0000000000000000001222222221000133100000001110
0000000000000000001222222221000133100000000000
0000000000000000001222222221000133100000000000
0000000000000000001111111111000111100000000000
前面的代码转换为以下级别布局:
请注意,为了获得前面屏幕截图中显示的视图,我必须缩小View
。此外,屏幕截图被裁剪了。级别的实际开始看起来像下面的屏幕截图:
向你展示这些屏幕截图的目的有两个。首先,你可以看到如何使用简单和免费的文本编辑器快速构建级别设计。
提示
只需确保使用等宽字体,这样所有数字都是相同大小。这样设计级别就会更容易。
其次,这些屏幕截图展示了设计的游戏方面。在级别的左侧,托马斯和鲍勃首先需要跳过一个小洞,否则他们将掉入死亡(重生)。然后他们需要穿过大片火焰。鲍勃不可能跳过那么多瓦片。玩家需要共同解决问题。鲍勃清除火瓦片的唯一方法是站在托马斯的头上,然后从那里跳,如下面的屏幕截图所示:
然后很容易到达目标并进入下一个级别。
提示
我强烈鼓励你完成本章,然后花一些时间设计你自己的级别。
我已经包含了一些级别设计,让你开始。它们在我们在第十二章中添加到项目中的levels
文件夹中,抽象和代码管理-更好地利用 OOP。
接下来是游戏的一些缩小视图,以及关卡设计代码的截图。代码的截图可能比重现实际的文本内容更有用。如果您确实想看到代码,只需打开levels
文件夹中的文件。
代码如下所示:
代码声明Vector2i
m_LevelSize
来保存当前地图包含的水平和垂直瓦片数的两个整数值。Vector2f
m_StartPosition
包含 Bob 和 Thomas 应该生成的世界坐标。请注意,这不是与m_LevelSize
单位相关的瓦片位置,而是关卡中水平和垂直像素位置。
这是前面的代码将产生的关卡布局:
这个关卡是我在第十二章中提到的“信任之跃”关卡,抽象和代码管理-更好地利用 OOP:
我已经突出显示了平台,因为它们在缩小的截图中不太清晰:
提供的设计很简单。游戏引擎将能够处理非常大的设计。您可以自由发挥想象力,构建一些非常大且难以完成的关卡。
当然,这些设计在我们学会如何加载它们并将文本转换为可玩的关卡之前实际上不会做任何事情。此外,在实现碰撞检测之前,将无法站在任何平台上。
首先,让我们处理加载关卡设计。
构建 LevelManager 类
我们将需要经过多个阶段的编码才能使我们的关卡设计生效。我们将首先编写LevelManager
头文件。这将使我们能够查看和讨论LevelManger
类中的成员变量和函数。
接下来,我们将编写LevelManager.cpp
文件,其中将包含所有的函数定义。由于这是一个很长的文件,我们将把它分成几个部分,以便编写和讨论它们。
一旦LevelManager
类完成,我们将在游戏引擎(Engine
类)中添加一个实例。我们还将在Engine
类中添加一个新函数loadLevel
,我们可以在需要新关卡时从update
函数中调用。loadLevel
函数不仅将使用LevelManager
实例来加载适当的关卡,还将处理诸如生成玩家角色和准备时钟等方面。
如前所述,让我们通过编写LevelManager.h
文件来概述LevelManager
。
编写 LevelManager.h
在解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件( .h
),然后在名称字段中键入LevelManager.h
。最后,单击添加按钮。现在我们准备好为LevelManager
类编写头文件了。
添加以下包含指令和私有变量,然后我们将讨论它们:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
using namespace std;
class LevelManager
{
private:
Vector2i m_LevelSize;
Vector2f m_StartPosition;
float m_TimeModifier = 1;
float m_BaseTimeLimit = 0;
int m_CurrentLevel = 0;
const int NUM_LEVELS = 4;
// public declarations go here
m_TimeModifier
成员变量是一个浮点数,将用于乘以当前关卡中可用的时间。我们之所以要这样做,是因为通过改变(减少)这个值,我们将在玩家尝试同一关卡时缩短可用时间。例如,如果玩家第一次尝试第一关卡时获得 60 秒,那么 60 乘以 1 当然是 60。当玩家完成所有关卡并再次回到第一关卡时,m_TimeModifier
将减少 10%。然后,当可用时间乘以 0.9 时,玩家可用的时间将是 54 秒。这比 60 少 10%。游戏将逐渐变得更加困难。
浮点变量m_BaseTimeLimit
保存了我们刚刚讨论的原始未修改的时间限制。
您可能已经猜到m_CurrentLevel
将保存当前正在播放的关卡编号。
int
NUM_LEVELS
常量将用于标记何时适合再次返回到第一关,并减少m_TimeModifier
的值。
现在添加以下公共变量和函数声明:
public:
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
float getTimeLimit();
Vector2f getStartPosition();
int** nextLevel(VertexArray& rVaLevel);
Vector2i getLevelSize();
int getCurrentLevel();
};
在前面的代码中,有两个常量int
成员。TILE_SIZE
是一个有用的常量,提醒我们精灵表中的每个瓦片都是五十像素宽和五十像素高。VERTS_IN_QUAD
是一个有用的常量,使我们对VertexArray
的操作不那么容易出错。实际上,一个四边形中有四个顶点。现在我们不能忘记它。
getTimeLimit
、getStartPosition
、getLevelSize
和getCurrentLevel
函数是简单的 getter 函数,返回我们在前面的代码块中声明的私有成员变量的当前值。
值得仔细研究的一个函数是nextLevel
。这个函数接收一个VertexArray
的引用,就像我们在 Zombie Arena 游戏中使用的那样。该函数可以在VertexArray
上工作,所有的更改都将出现在调用代码中的VertexArray
中。nextLevel
函数返回一个指向指针的指针,这意味着我们可以返回一个地址,该地址是int
值的二维数组的第一个元素。我们将构建一个int
值的二维数组,该数组将表示每个关卡的布局。当然,这些 int 值将从关卡设计文本文件中读取。
编写 LevelManager.cpp 文件
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件( .cpp
),然后在名称字段中键入LevelManager.cpp
。最后,单击添加按钮。我们现在准备为LevelManager
类编写.cpp
文件。
由于这是一个相当长的类,我们将把它分成六个部分来讨论。前五个将涵盖nextLevel
函数,第六个将涵盖所有其他内容。
添加以下包含指令和nextLevel
函数的第一部分(共五部分):
#include "stdafx.h"
#include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp>
#include "TextureHolder.h"
#include <sstream>
#include <fstream>
#include "LevelManager.h"
using namespace sf;
using namespace std;
int** LevelManager::nextLevel(VertexArray& rVaLevel)
{
m_LevelSize.x = 0;
m_LevelSize.y = 0;
// Get the next level
m_CurrentLevel++;
if (m_CurrentLevel > NUM_LEVELS)
{
m_CurrentLevel = 1;
m_TimeModifier -= .1f;
}
// Load the appropriate level from a text file
string levelToLoad;
switch (m_CurrentLevel)
{
case 1:
levelToLoad = "levels/level1.txt";
m_StartPosition.x = 100;
m_StartPosition.y = 100;
m_BaseTimeLimit = 30.0f;
break;
case 2:
levelToLoad = "levels/level2.txt";
m_StartPosition.x = 100;
m_StartPosition.y = 3600;
m_BaseTimeLimit = 100.0f;
break;
case 3:
levelToLoad = "levels/level3.txt";
m_StartPosition.x = 1250;
m_StartPosition.y = 0;
m_BaseTimeLimit = 30.0f;
break;
case 4:
levelToLoad = "levels/level4.txt";
m_StartPosition.x = 50;
m_StartPosition.y = 200;
m_BaseTimeLimit = 50.0f;
break;
}// End switch
在包含指令之后,代码将m_LevelSize.x
和m_LevelSize.y
初始化为零。
接下来,m_CurrentLevel
增加。随后的if
语句检查m_CurrentLevel
是否大于NUM_LEVELS
。如果是,m_CurrentLevel
被设置回1
,并且m_TimeModifier
减少了.1f
,以缩短所有关卡允许的时间。
代码然后根据m_CurrentLevel
的值进行切换。每个case
语句都初始化文本文件的名称,该文件包含了关卡设计和 Thomas 和 Bob 的起始位置,以及m_BaseTimeLimit
,这是问题关卡的未修改时间限制。
提示
如果您设计自己的关卡,请在此处添加case
语句和相应的值。还要编辑LevelManager.h
文件中的NUM_LEVELS
常量。
现在添加nextLevel
函数的第二部分,如所示。在上一段代码之后立即添加代码。在添加代码时,仔细研究代码,以便我们可以讨论它:
ifstream inputFile(levelToLoad);
string s;
// Count the number of rows in the file
while (getline(inputFile, s))
{
++m_LevelSize.y;
}
// Store the length of the rows
m_LevelSize.x = s.length();
在前面(第二部分)我们刚刚编写的代码中,我们声明了一个名为inputFile
的ifstream
对象,它打开了一个流到levelToLoad
中包含的文件名。
代码使用getline
循环遍历文件的每一行,但不记录任何内容。它只是通过递增m_LevelSize.y
来计算行数。在for
循环之后,使用s.length
将关卡的宽度保存在m_LevelSize.x
中。这意味着所有行的长度必须相同,否则我们会遇到麻烦。
此时,我们知道并已保存了m_LevelSize
中当前关卡的长度和宽度。
现在添加nextLevel
函数的第三部分,如所示。在上一段代码之后立即添加代码。在添加代码时,请仔细研究代码,以便我们讨论它:
// Go back to the start of the file
inputFile.clear();
inputFile.seekg(0, ios::beg);
// Prepare the 2d array to hold the int values from the file
int** arrayLevel = new int*[m_LevelSize.y];
for (int i = 0; i < m_LevelSize.y; ++i)
{
// Add a new array into each array element
arrayLevel[i] = new int[m_LevelSize.x];
}
首先,使用其clear
函数清除inputFile
。使用0, ios::beg
参数调用的seekg
函数将流重置到第一个字符之前。
接下来,我们声明一个指向指针的arrayLevel
。请注意,这是使用new
关键字在自由存储/堆上完成的。一旦我们初始化了这个二维数组,我们就能够将其地址返回给调用代码,并且它将持续存在,直到我们删除它或游戏关闭。
for
循环从 0 到m_LevelSize.y -1
。在每次循环中,它向堆中添加一个新的int
值数组,以匹配m_LevelSize.x
的值。现在,我们有一个完全配置好的(对于当前关卡)二维数组。唯一的问题是里面什么都没有。
现在添加nextLevel
函数的第四部分,如所示。在上一段代码之后立即添加代码。在添加代码时,请仔细研究代码,以便我们讨论它:
// Loop through the file and store all the values in the 2d array
string row;
int y = 0;
while (inputFile >> row)
{
for (int x = 0; x < row.length(); x++) {
const char val = row[x];
arrayLevel[y][x] = atoi(&val);
}
y++;
}
// close the file
inputFile.close();
首先,代码初始化一个名为row
的string
,它将一次保存一个关卡设计的行。我们还声明并初始化一个名为y
的int
,它将帮助我们计算行数。
while
循环重复执行,直到inputFile
超过最后一行。在while
循环内部有一个for
循环,它遍历当前行的每个字符,并将其存储在二维数组arrayLevel
中。请注意,我们使用arrayLevel[y][x] =
准确访问二维数组的正确元素。atoi
函数将char val
转换为int
。这是必需的,因为我们有一个用于int
而不是char
的二维数组。
现在添加nextLevel
函数的第五部分,如所示。在上一段代码之后立即添加代码。在添加代码时,请仔细研究代码,以便我们讨论它:
// What type of primitive are we using?
rVaLevel.setPrimitiveType(Quads);
// Set the size of the vertex array
rVaLevel.resize(m_LevelSize.x * m_LevelSize.y * VERTS_IN_QUAD);
// Start at the beginning of the vertex array
int currentVertex = 0;
for (int x = 0; x < m_LevelSize.x; x++)
{
for (int y = 0; y < m_LevelSize.y; y++)
{
// Position each vertex in the current quad
rVaLevel[currentVertex + 0].position =
Vector2f(x * TILE_SIZE,
y * TILE_SIZE);
rVaLevel[currentVertex + 1].position =
Vector2f((x * TILE_SIZE) + TILE_SIZE,
y * TILE_SIZE);
rVaLevel[currentVertex + 2].position =
Vector2f((x * TILE_SIZE) + TILE_SIZE,
(y * TILE_SIZE) + TILE_SIZE);
rVaLevel[currentVertex + 3].position =
Vector2f((x * TILE_SIZE),
(y * TILE_SIZE) + TILE_SIZE);
// Which tile from the sprite sheet should we use
int verticalOffset = arrayLevel[y][x] * TILE_SIZE;
rVaLevel[currentVertex + 0].texCoords =
Vector2f(0, 0 + verticalOffset);
rVaLevel[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 + verticalOffset);
rVaLevel[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE + verticalOffset);
rVaLevel[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE + verticalOffset);
// Position ready for the next four vertices
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return arrayLevel;
} // End of nextLevel function
尽管这是我们将nextLevel
分成五个部分中最长的代码部分,但它也是最直接的。这是因为我们在 Zombie Arena 项目中看到了非常相似的代码。
嵌套的for
循环循环从零到关卡的宽度和高度。对于数组中的每个位置,将四个顶点放入VertexArray
,并从精灵表中分配四个纹理坐标。顶点和纹理坐标的位置是使用currentVertex
变量、TILE SIZE
和VERTS_IN_QUAD
常量计算的。在内部for
循环的每次循环结束时,currentVertex
增加VERTS_IN_QUAD
,很好地移动到下一个瓷砖上。
关于这个VertexArray
的重要事情是,它是通过引用传递给nextLevel
的。因此,VertexArray
将在调用代码中可用。我们将从Engine
类中的代码中调用nextLevel
。
一旦调用了这个函数,Engine
类将拥有一个VertexArray
来图形化表示关卡,并且拥有一个int
值的二维数组,作为关卡中所有平台和障碍物的数值表示。
LevelManager
的其余函数都是简单的 getter 函数,但请花时间熟悉每个函数返回的私有值。添加LevelManager
类的其余函数:
Vector2i LevelManager::getLevelSize()
{
return m_LevelSize;
}
int LevelManager::getCurrentLevel()
{
return m_CurrentLevel;
}
float LevelManager::getTimeLimit()
{
return m_BaseTimeLimit * m_TimeModifier;
}
Vector2f LevelManager::getStartPosition()
{
return m_StartPosition;
}
现在LevelManager
类已经完成,我们可以继续使用它。我们将在Engine
类中编写另一个函数来实现。
编写 loadLevel 函数
要清楚,这个函数是Engine
类的一部分,尽管它将把大部分工作委托给其他函数,包括我们刚刚构建的LevelManager
类的函数。
首先,让我们在Engine.h
文件中添加新函数的声明,以及一些其他新代码。打开Engine.h
文件,并添加以下Engine.h
文件的摘要快照中显示的突出显示的代码行:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
#include "LevelManager.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
// A class to manage all the levels
LevelManager m_LM;
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
// The force pushing the characters down
const int GRAVITY = 300;
// A regular RenderWindow
RenderWindow m_Window;
// The main Views
View m_MainView;
View m_LeftView;
View m_RightView;
// Three views for the background
View m_BGMainView;
View m_BGLeftView;
View m_BGRightView;
View m_HudView;
// Declare a sprite and a Texture for the background
Sprite m_BackgroundSprite;
Texture m_BackgroundTexture;
// Is the game currently playing?
bool m_Playing = false;
// Is character 1 or 2 the current focus?
bool m_Character1 = true;
// Start in full screen mode
bool m_SplitScreen = false;
// How much time is left in the current level
float m_TimeRemaining = 10;
Time m_GameTimeTotal;
// Is it time for a new/first level?
bool m_NewLevelRequired = true;
// The vertex array for the level tiles
VertexArray m_VALevel;
// The 2d array with the map for the level
// A pointer to a pointer
int** m_ArrayLevel = NULL;
// Texture for the level tiles
Texture m_TextureTiles;
// Private functions for internal use only
void input();
void update(float dtAsSeconds);
void draw();
// Load a new level
void loadLevel();
public:
// The Engine constructor
Engine();
...
...
...
你可以在先前的代码中看到以下内容:
-
我们包括了
LevelManager.h
文件 -
我们添加了一个名为
m_LM
的LevelManager
实例 -
我们添加了一个名为
m_VALevel
的VertexArray
-
我们添加了一个指向
int
的指针,该指针将保存从nextLevel
返回的二维数组 -
我们为精灵表添加了一个新的
Texture
对象 -
我们添加了
loadLevel
函数的声明,现在我们将编写该函数
在Solution Explorer中右键单击Source Files,然后选择Add | New Item...。在Add New Item窗口中,通过左键单击突出显示( .cpp
)C++ File,然后在Name字段中键入LoadLevel.cpp
。最后,单击Add按钮。现在我们准备编写loadLevel
函数。
将loadLevel
函数的代码添加到LoadLevel.cpp
文件中,然后我们可以讨论它:
#include "stdafx.h"
#include "Engine.h"
void Engine::loadLevel()
{
m_Playing = false;
// Delete the previously allocated memory
for (int i = 0; i < m_LM.getLevelSize().y; ++i)
{
delete[] m_ArrayLevel[i];
}
delete[] m_ArrayLevel;
// Load the next 2d array with the map for the level
// And repopulate the vertex array as well
m_ArrayLevel = m_LM.nextLevel(m_VALevel);
// How long is this new time limit
m_TimeRemaining = m_LM.getTimeLimit();
// Spawn Thomas and Bob
m_Thomas.spawn(m_LM.getStartPosition(), GRAVITY);
m_Bob.spawn(m_LM.getStartPosition(), GRAVITY);
// Make sure this code isn't run again
m_NewLevelRequired = false;
}
首先,我们将m_Playing
设置为 false,以阻止更新函数的部分执行。接下来,我们循环遍历m_ArrayLevel
中的所有水平数组,并将它们删除。在for
循环之后,我们删除m_ArrayLevel
。
代码m_ArrayLevel = m_LM.nextLevel(m_VALevel)
,调用了nextLevel
并准备了VertexArray
和m_VALevel
,以及二维m_ArrayLevel
数组。关卡已经设置好,准备就绪。
通过调用getTimeLimit
初始化了m_TimeRemaining
,并使用spawn
函数生成了 Thomas 和 Bob,以及从getStartPosition
返回的值。
最后,m_NewLevelRequired
被设置为false
。正如我们将在几页后看到的那样,m_NewLevelRequired
被设置为true
会导致调用loadLevel
。我们只想运行这个函数一次。
更新引擎
打开Engine.cpp
文件,并在Engine
构造函数的末尾添加突出显示的代码,以加载精灵表纹理:
Engine::Engine()
{
// Get the screen resolution and create an SFML window and View
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
m_Window.create(VideoMode(resolution.x, resolution.y),
"Thomas was late",
Style::Fullscreen);
// Initialize the full screen view
m_MainView.setSize(resolution);
m_HudView.reset(
FloatRect(0, 0, resolution.x, resolution.y));
// Inititialize the split-screen Views
m_LeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_RightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
m_BGLeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_BGRightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
// Can this graphics card use shaders?
if (!sf::Shader::isAvailable())
{
// Time to get a new PC
m_Window.close();
}
m_BackgroundTexture = TextureHolder::GetTexture(
"graphics/background.png");
// Associate the sprite with the texture
m_BackgroundSprite.setTexture(m_BackgroundTexture);
// Load the texture for the background vertex array
m_TextureTiles = TextureHolder::GetTexture("graphics/tiles_sheet.png");
}
在先前的代码中,我们只是将精灵表加载到m_TextureTiles
中。
打开Update.cpp
文件,并进行以下突出显示的更改和添加:
void Engine::update(float dtAsSeconds)
{
if (m_NewLevelRequired)
{
// These calls to spawn will be moved to a new
// LoadLevel function soon
// Spawn Thomas and Bob
//m_Thomas.spawn(Vector2f(0,0), GRAVITY);
//m_Bob.spawn(Vector2f(100, 0), GRAVITY);
// Make sure spawn is called only once
//m_TimeRemaining = 10;
//m_NewLevelRequired = false;
// Load a level
loadLevel();
}
实际上,你应该删除而不是注释掉我们不再使用的行。我只是以这种方式向你展示,以便更清楚地看到更改。在先前的if
语句中,应该只有对loadLevel
的调用。
最后,在我们能够看到本章工作成果之前,打开Draw.cpp
文件,并进行以下突出显示的添加,以绘制表示关卡的顶点数组:
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_MainView
m_Window.setView(m_MainView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw thomas
m_Window.draw(m_Bob.getSprite());
}
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
// Show everything we have just drawn
m_Window.display();
}
请注意,我们需要为所有屏幕选项(全屏、左侧和右侧)绘制VertexArray
。
现在你可以运行游戏了。不幸的是,Thomas 和 Bob 直接穿过了我们精心设计的所有平台。因此,我们无法尝试通过关卡并打败时间。
碰撞检测
我们将使用矩形相交和 SFML 相交函数来处理碰撞检测。在这个项目中的不同之处在于,我们将把碰撞检测代码抽象成自己的函数,并且正如我们已经看到的,Thomas 和 Bob 有多个矩形(m_Head
、m_Feet
、m_Left
、m_Right
),我们需要检查碰撞。
编写detectCollisions
函数
要明确,这个函数是 Engine 类的一部分。打开Engine.h
文件,并添加一个名为detectCollisions
的函数声明。在下面的代码片段中突出显示了这一点:
// Private functions for internal use only
void input();
void update(float dtAsSeconds);
void draw();
// Load a new level
void loadLevel();
// Run will call all the private functions
bool detectCollisions(PlayableCharacter& character);
public:
// The Engine constructor
Engine();
从签名中可以看出,detectCollision
函数接受一个多态参数,即PlayerCharacter
对象。正如我们所知,PlayerCharacter
是抽象的,永远不能被实例化。然而,我们可以用Thomas
和Bob
类继承它。我们将能够将m_Thomas
或m_Bob
传递给detectCollisions
。
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件( .cpp
),然后在名称字段中键入DetectCollisions.cpp
。最后,单击添加按钮。现在我们准备编写detectCollisions
函数。
将以下代码添加到DetectCollisions.cpp
。请注意,这只是该函数的第一部分:
#include "stdafx.h"
#include "Engine.h"
bool Engine::detectCollisions(PlayableCharacter& character)
{
bool reachedGoal = false;
// Make a rect for all his parts
FloatRect detectionZone = character.getPosition();
// Make a FloatRect to test each block
FloatRect block;
block.width = TILE_SIZE;
block.height = TILE_SIZE;
// Build a zone around thomas to detect collisions
int startX = (int)(detectionZone.left / TILE_SIZE) - 1;
int startY = (int)(detectionZone.top / TILE_SIZE) - 1;
int endX = (int)(detectionZone.left / TILE_SIZE) + 2;
// Thomas is quite tall so check a few tiles vertically
int endY = (int)(detectionZone.top / TILE_SIZE) + 3;
// Make sure we don't test positions lower than zero
// Or higher than the end of the array
if (startX < 0)startX = 0;
if (startY < 0)startY = 0;
if (endX >= m_LM.getLevelSize().x)
endX = m_LM.getLevelSize().x;
if (endY >= m_LM.getLevelSize().y)
endY = m_LM.getLevelSize().y;
首先我们声明一个名为reachedGoal
的布尔值。这是detectCollisions
函数返回给调用代码的值。它被初始化为false
。
接下来我们声明一个名为detectionZone
的FloatRect
,并用表示角色精灵整个矩形的相同矩形进行初始化。请注意,我们实际上不会使用这个矩形进行交集测试。之后,我们声明另一个名为block
的FloatRect
。我们将block
初始化为一个 50x50 的矩形。我们很快就会看到block
的使用。
接下来我们看看如何使用detectionZone
。我们通过扩展detectionZone
周围的区域几个块来初始化四个int
变量startX
、startY
、endX
和endY
。在接下来的四个if
语句中,我们检查不可能尝试在不存在的瓦片上进行碰撞检测。我们通过确保永远不检查小于零或大于getLevelSize().x
或.y
返回的值来实现这一点。
前面的所有代码所做的是创建一个用于碰撞检测的区域。在角色数百或数千像素远的方块上进行碰撞检测是没有意义的。此外,如果我们尝试在数组位置不存在的地方进行碰撞检测(小于零或大于getLevelSize()...
),游戏将崩溃。
接下来,添加以下处理玩家掉出地图的代码:
// Has the character fallen out of the map?
FloatRect level(0, 0,
m_LM.getLevelSize().x * TILE_SIZE,
m_LM.getLevelSize().y * TILE_SIZE);
if (!character.getPosition().intersects(level))
{
// respawn the character
character.spawn(m_LM.getStartPosition(), GRAVITY);
}
角色要停止下落,必须与平台发生碰撞。因此,如果玩家移出地图(没有平台的地方),它将不断下落。前面的代码检查角色是否不与FloatRect
、level
相交。如果不相交,那么它已经掉出地图,spawn
函数会将其发送回起点。
添加以下相当大的代码,然后我们将逐步讲解它的功能:
// Loop through all the local blocks
for (int x = startX; x < endX; x++)
{
for (int y = startY; y < endY; y++)
{
// Initialize the starting position of the current block
block.left = x * TILE_SIZE;
block.top = y * TILE_SIZE;
// Has character been burnt or drowned?
// Use head as this allows him to sink a bit
if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)
{
if (character.getHead().intersects(block))
{
character.spawn(m_LM.getStartPosition(), GRAVITY);
// Which sound should be played?
if (m_ArrayLevel[y][x] == 2)// Fire, ouch!
{
// Play a sound
}
else // Water
{
// Play a sound
}
}
}
// Is character colliding with a regular block
if (m_ArrayLevel[y][x] == 1)
{
if (character.getRight().intersects(block))
{
character.stopRight(block.left);
}
else if (character.getLeft().intersects(block))
{
character.stopLeft(block.left);
}
if (character.getFeet().intersects(block))
{
character.stopFalling(block.top);
}
else if (character.getHead().intersects(block))
{
character.stopJump();
}
}
// More collision detection here once we have
// learned about particle effects
// Has the character reached the goal?
if (m_ArrayLevel[y][x] == 4)
{
// Character has reached the goal
reachedGoal = true;
}
}
}
前面的代码使用相同的技术做了三件事。它循环遍历了 startX、endX 和 startY、endY 之间包含的所有值。对于每次循环,它都会检查并执行以下操作:
-
角色是否被烧伤或淹死?代码
if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)
确定当前被检查的位置是否是火瓦或水瓦。如果角色的头与这些瓦片之一相交,玩家将重新生成。我们还编写了一个空的if…else
块,为下一章添加声音做准备。 -
角色是否触碰了普通瓦片?代码
if (m_ArrayLevel[y][x] == 1)
确定当前被检查的位置是否持有普通瓦片。如果它与表示角色各个身体部位的矩形之一相交,相关的函数就会被调用(stopRight
、stopLeft
、stopFalling
和stopJump
)。传递给这些函数的值以及函数如何使用这些值重新定位角色是相当微妙的。虽然不必仔细检查这些值来理解代码,但您可能会喜欢查看传递的值,然后参考上一章PlayableCharacter
类的适当函数。这将帮助您准确理解发生了什么。 -
角色是否触碰到了目标瓦片?这是通过代码
if (m_ArrayLevel[y][x] == 4)
来确定的。我们只需要将reachedGoal
设置为true
。Engine
类的update
函数将跟踪托马斯和鲍勃是否同时到达了目标。我们将在update
中编写这段代码,马上就会。
在detectCollisions
函数中添加最后一行代码:
// All done, return, whether or not a new level might be required
return reachedGoal;
}
前面的代码返回reachedGoal
,以便调用代码可以跟踪并适当地响应如果两个角色同时到达目标。
现在我们只需要每帧调用一次detectCollision
函数。在Update.cpp
文件的if(m_Playing)
代码块中添加以下突出显示的代码:
if (m_Playing)
{
// Update Thomas
m_Thomas.update(dtAsSeconds);
// Update Bob
m_Bob.update(dtAsSeconds);
// Detect collisions and see if characters
// have reached the goal tile
// The second part of the if condition is only executed
// when thomas is touching the home tile
if (detectCollisions(m_Thomas) && detectCollisions(m_Bob))
{
// New level required
m_NewLevelRequired = true;
// Play the reach goal sound
}
else
{
// Run bobs collision detection
detectCollisions(m_Bob);
}
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
先前的代码调用了detectCollision
函数,并检查鲍勃和托马斯是否同时到达了目标。如果是,下一个关卡将通过将m_NewLevelRequired
设置为true
来准备好。
您可以运行游戏并走在平台上。您可以到达目标并开始新的关卡。此外,首次,跳跃按钮(W或箭头上)将起作用。
如果您达到目标,下一个关卡将加载。如果您达到最后一关的目标,则第一关将以减少 10%的时间限制加载。当然,由于我们还没有构建 HUD,所以时间或当前关卡没有视觉反馈。我们将在下一章中完成。
然而,许多关卡需要托马斯和鲍勃一起合作。更具体地说,托马斯和鲍勃需要能够爬到彼此的头上。
更多碰撞检测
在Update.cpp
文件中添加前面添加的代码后面,即在if (m_Playing)
部分内:
if (m_Playing)
{
// Update Thomas
m_Thomas.update(dtAsSeconds);
// Update Bob
m_Bob.update(dtAsSeconds);
// Detect collisions and see if characters
// have reached the goal tile
// The second part of the if condition is only executed
// when thomas is touching the home tile
if (detectCollisions(m_Thomas) && detectCollisions(m_Bob))
{
// New level required
m_NewLevelRequired = true;
// Play the reach goal sound
}
else
{
// Run bobs collision detection
detectCollisions(m_Bob);
}
// Let bob and thomas jump on each others heads
if (m_Bob.getFeet().intersects(m_Thomas.getHead()))
{
m_Bob.stopFalling(m_Thomas.getHead().top);
}
else if (m_Thomas.getFeet().intersects(m_Bob.getHead()))
{
m_Thomas.stopFalling(m_Bob.getHead().top);
}
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
您可以再次运行游戏,并站在托马斯和鲍勃的头上,以到达以前无法到达的难以到达的地方:
总结
本章中有相当多的代码。我们学会了如何从文件中读取并将文本字符串转换为 char,然后转换为int
。一旦我们有了一个二维数组的int
,我们就能够填充一个VertexArray
来在屏幕上显示关卡。然后,我们使用完全相同的二维数组 int 来实现碰撞检测。我们使用了矩形相交,就像我们在僵尸竞技场项目中所做的那样,尽管这次,为了更精确,我们给了每个角色四个碰撞区域,分别代表他们的头部、脚部和左右两侧。
现在游戏完全可玩,我们需要在屏幕上表示游戏的状态(得分和时间)。在下一章中,我们将实现 HUD,以及比目前使用的更高级的音效。
第十五章:声音空间定位和 HUD
在本章中,我们将添加所有的音效和 HUD。我们在之前的两个项目中都做过这个,但这次我们会有些不同。我们将探讨声音空间定位的概念,以及 SFML 如何使这个本来复杂的概念变得简单;此外,我们将构建一个 HUD 类来封装将信息绘制到屏幕上的代码。
我们将按照以下顺序完成这些任务:
-
什么是空间定位?
-
SFML 如何处理空间定位
-
构建一个
SoundManager
类 -
部署发射器
-
使用
SoundManager
类 -
构建一个
HUD
类 -
使用
HUD
类
什么是空间定位?
空间定位是使某物相对于其所在的空间或内部的行为。在我们的日常生活中,自然界中的一切默认都是空间化的。如果一辆摩托车从左到右呼啸而过,我们会听到声音从一侧变得微弱到大声,当它经过时,它会在另一只耳朵中变得更加显著,然后再次消失在远处。如果有一天早上醒来,世界不再是空间化的,那将异常奇怪。
如果我们能让我们的视频游戏更像现实世界,我们的玩家就能更加沉浸其中。如果玩家能够在远处微弱地听到僵尸的声音,并且当它们靠近时,它们的非人类的哀嚎声从一个方向或另一个方向变得更大声,我们的僵尸游戏将会更有趣。
很明显,空间定位的数学将会很复杂。我们如何计算特定扬声器中的声音有多大声,基于声音来自的方向,以及从玩家(声音的听者)到发出声音的物体(发射器)的距离?
幸运的是,SFML 为我们做了所有复杂的事情。我们只需要熟悉一些技术术语,然后我们就可以开始使用 SFML 来对我们的音效进行空间定位。
发射器、衰减和听众
为了让 SFML 能够正常工作,我们需要了解一些信息。我们需要知道声音在游戏世界中来自哪里。这个声音的来源被称为发射器。在游戏中,发射器可以是僵尸、车辆,或者在我们当前的项目中,是一个火焰图块。我们已经在游戏中跟踪了对象的位置,所以给 SFML 发射器位置将会非常简单。
我们需要了解的下一个因素是衰减。衰减是波动衰减的速率。你可以简化这个说法,并将其具体化为声音,说衰减是声音减小的速度。这在技术上并不准确,但对于本章的目的来说,这已经足够好了。
最后一个因素我们需要考虑的是听众。当 SFML 对声音进行空间定位时,它是相对于什么进行空间定位的?在大多数游戏中,逻辑的做法是使用玩家角色。在我们的游戏中,我们将使用 Thomas。
SFML 如何处理空间定位
SFML 有许多函数,允许我们处理发射器、衰减和听众。让我们假设地看一下它们,然后我们将编写一些代码,真正为我们的项目添加空间化声音。
我们可以设置好一个准备播放的音效,就像我们经常做的那样,如下所示:
// Declare SoundBuffer in the usual way
SoundBuffer zombieBuffer;
// Declare a Sound object as-per-usual
Sound zombieSound;
// Load the sound from a file like we have done so often
zombieBuffer.loadFromFile("sound/zombie_growl.wav");
// Associate the Sound object with the Buffer
zombieSound.setBuffer(zombieBuffer);
我们可以使用setPosition
函数设置发射器的位置,如下面的代码所示:
// Set the horizontal and vertical positions of the emitter
// In this case the emitter is a zombie
// In the Zombie Arena project we could have used
// getPosition().x and getPosition().y
// These values are arbitrary
float x = 500;
float y = 500;
zombieSound.setPosition(x, y, 0.0f);
如前面代码的注释所建议的,你如何获得发射器的坐标可能取决于游戏的类型。就像在 Zombie Arena 项目中所示的那样,这将会非常简单。在这个项目中,当我们设置位置时,我们将面临一些挑战。
我们可以使用以下代码设置衰减级别:
zombieSound.setAttenuation(15);
实际的衰减级别可能有点模糊。您希望玩家得到的效果可能与基于衰减的距离减小音量的准确科学公式不同。通常通过实验来获得正确的衰减级别。一般来说,衰减级别越高,声音级别降至静音的速度就越快。
此外,您可能希望在发射器周围设置一个区域,其中音量根本不会衰减。如果该功能在一定范围之外不合适,或者您有大量的声源并且不想过度使用该功能,您可以这样做。为此,我们可以使用setMinimumDistance
函数,如下所示:
zombieSound.setMinDistance(150);
通过上一行代码,衰减将不会开始计算,直到听者距离发射器150
像素/单位。
SFML 库中的一些其他有用的函数包括setLoop
函数。当传入 true 作为参数时,此函数将告诉 SFML 保持播放声音,如下面的代码所示:
zombieSound.setLoop(true);
声音将继续播放,直到我们用以下代码结束它:
zombieSound.stop();
不时地,我们会想要知道声音的状态(正在播放或已停止)。我们可以通过getStatus
函数实现这一点,如下面的代码所示:
if (zombieSound.getStatus() == Sound::Status::Stopped)
{
// The sound is NOT playing
// Take whatever action here
}
if (zombieSound.getStatus() == Sound::Status::Playing)
{
// The sound IS playing
// Take whatever action here
}
在使用 SFML 进行声音空间化的最后一个方面是什么?听者在哪里?我们可以使用以下代码设置听者的位置:
// Where is the listener?
// How we get the values of x and y varies depending upon the game
// In the Zombie Arena game or the Thomas Was Late game
// We can use getPosition()
Listener::setPosition(m_Thomas.getPosition().x,
m_Thomas.getPosition().y, 0.0f);
上述代码将使所有声音相对于该位置播放。这正是我们需要的远处火瓦或迫近的僵尸的咆哮声,但对于像跳跃这样的常规音效来说,这是一个问题。我们可以开始处理一个发射器来定位玩家的位置,但 SFML 为我们简化了这些操作。每当我们想播放普通声音时,我们只需调用setRelativeToListener
,如下面的代码所示,然后以与迄今为止完全相同的方式播放声音。以下是我们可能播放普通、非空间化的跳跃音效的方式:
jumpSound.setRelativeToListener(true);
jumpSound.play();
我们只需要在播放任何空间化声音之前再次调用Listener::setPosition
。
现在我们有了广泛的 SFML 声音函数,我们准备为真实制作一些空间化的噪音。
构建 SoundManager 类
您可能还记得在上一个项目中,所有的声音代码占用了相当多的行数。现在考虑到空间化,它将变得更长。为了使我们的代码易于管理,我们将编写一个类来管理所有声音效果的播放。此外,为了帮助我们进行空间化,我们还将向 Engine 类添加一个函数,但是当我们到达这一点时,我们将在本章后面讨论。
编写 SoundManager.h
让我们开始编写和检查头文件。
在解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,选择(通过左键单击)头文件( .h
),然后在名称字段中输入SoundManager.h
。最后,单击添加按钮。现在我们准备为SoundManager
类编写头文件。
添加并检查以下代码:
#pragma once
#include <SFML/Audio.hpp>
using namespace sf;
class SoundManager
{
private:
// The buffers
SoundBuffer m_FireBuffer;
SoundBuffer m_FallInFireBuffer;
SoundBuffer m_FallInWaterBuffer;
SoundBuffer m_JumpBuffer;
SoundBuffer m_ReachGoalBuffer;
// The Sounds
Sound m_Fire1Sound;
Sound m_Fire2Sound;
Sound m_Fire3Sound;
Sound m_FallInFireSound;
Sound m_FallInWaterSound;
Sound m_JumpSound;
Sound m_ReachGoalSound;
// Which sound should we use next, fire 1, 2 or 3
int m_NextSound = 1;
public:
SoundManager();
void playFire(Vector2f emitterLocation,
Vector2f listenerLocation);
void playFallInFire();
void playFallInWater();
void playJump();
void playReachGoal();
};
我们刚刚添加的代码中没有什么棘手的地方。有五个SoundBuffer
对象和八个Sound
对象。其中三个Sound
对象将播放相同的SoundBuffer
。这解释了不同数量的Sound
/SoundBuffer
对象的原因。我们这样做是为了能够同时播放多个咆哮声效,具有不同的空间化参数。
请注意,有一个m_NextSound
变量,它将帮助我们跟踪这些潜在同时发生的声音中我们应该下一个使用哪一个。
有一个构造函数SoundManager
,在那里我们将设置所有的音效,还有五个函数将播放音效。其中四个函数只是简单地播放普通音效,它们的代码将非常简单。
其中一个函数playFire
将处理空间化的音效,并且会更加深入。注意playFire
函数的参数。它接收一个Vector2f
,这是发射器的位置,和第二个Vector2f
,这是听众的位置。
编写 SoundManager.cpp 文件
现在我们可以编写函数定义。构造函数和playFire
函数有相当多的代码,所以我们将分别查看它们。其他函数很简短,所以我们将一次处理它们。
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件( .cpp
),然后在名称字段中输入SoundManager.cpp
。最后,单击添加按钮。现在我们准备好为SoundManager
类编写.cpp
文件。
编写构造函数
在SoundManager.cpp
中添加以下代码以包含指令和构造函数:
#include "stdafx.h"
#include "SoundManager.h"
#include <SFML/Audio.hpp>
using namespace sf;
SoundManager::SoundManager()
{
// Load the sound in to the buffers
m_FireBuffer.loadFromFile("sound/fire1.wav");
m_FallInFireBuffer.loadFromFile("sound/fallinfire.wav");
m_FallInWaterBuffer.loadFromFile("sound/fallinwater.wav");
m_JumpBuffer.loadFromFile("sound/jump.wav");
m_ReachGoalBuffer.loadFromFile("sound/reachgoal.wav");
// Associate the sounds with the buffers
m_Fire1Sound.setBuffer(m_FireBuffer);
m_Fire2Sound.setBuffer(m_FireBuffer);
m_Fire3Sound.setBuffer(m_FireBuffer);
m_FallInFireSound.setBuffer(m_FallInFireBuffer);
m_FallInWaterSound.setBuffer(m_FallInWaterBuffer);
m_JumpSound.setBuffer(m_JumpBuffer);
m_ReachGoalSound.setBuffer(m_ReachGoalBuffer);
// When the player is 50 pixels away sound is full volume
float minDistance = 150;
// The sound reduces steadily as the player moves further away
float attenuation = 15;
// Set all the attenuation levels
m_Fire1Sound.setAttenuation(attenuation);
m_Fire2Sound.setAttenuation(attenuation);
m_Fire3Sound.setAttenuation(attenuation);
// Set all the minimum distance levels
m_Fire1Sound.setMinDistance(minDistance);
m_Fire2Sound.setMinDistance(minDistance);
m_Fire3Sound.setMinDistance(minDistance);
// Loop all the fire sounds
// when they are played
m_Fire1Sound.setLoop(true);
m_Fire2Sound.setLoop(true);
m_Fire3Sound.setLoop(true);
}
在之前的代码中,我们将五个声音文件加载到五个SoundBuffer
对象中。接下来,我们将八个Sound
对象与其中一个SoundBuffer
对象关联起来。注意m_Fire1Sound
、m_Fire2Sound
和m_Fire3Sound
都将从同一个SoundBuffer
,m_FireBuffer
中播放。
接下来,我们设置了三种火焰声音的衰减和最小距离。
提示
分别通过实验得到了150
和15
的值。一旦游戏运行起来,我鼓励你通过改变这些值来进行实验,看(或者说听)听到的差异。
最后,对于构造函数,我们在每个与火相关的Sound
对象上使用了setLoop
函数。现在当我们调用play
时,它们将持续播放。
编写 playFire 函数
添加下面的playFire
函数,然后我们可以讨论它:
void SoundManager::playFire(
Vector2f emitterLocation, Vector2f listenerLocation)
{
// Where is the listener? Thomas.
Listener::setPosition(listenerLocation.x,
listenerLocation.y, 0.0f);
switch(m_NextSound)
{
case 1:
// Locate/move the source of the sound
m_Fire1Sound.setPosition(emitterLocation.x,
emitterLocation.y, 0.0f);
if (m_Fire1Sound.getStatus() == Sound::Status::Stopped)
{
// Play the sound, if its not already
m_Fire1Sound.play();
}
break;
case 2:
// Do the same as previous for the second sound
m_Fire2Sound.setPosition(emitterLocation.x,
emitterLocation.y, 0.0f);
if (m_Fire2Sound.getStatus() == Sound::Status::Stopped)
{
m_Fire2Sound.play();
}
break;
case 3:
// Do the same as previous for the third sound
m_Fire3Sound.setPosition(emitterLocation.x,
emitterLocation.y, 0.0f);
if (m_Fire3Sound.getStatus() == Sound::Status::Stopped)
{
m_Fire3Sound.play();
}
break;
}
// Increment to the next fire sound
m_NextSound++;
// Go back to 1 when the third sound has been started
if (m_NextSound > 3)
{
m_NextSound = 1;
}
}
我们首先调用Listener::setPosition
,根据传入的Vector2f
设置听众的位置。
接下来,代码根据m_NextSound
的值进入switch
块。每个case
语句都做完全相同的事情,但是针对m_Fire1Sound
、m_Fire2Sound
或m_Fire3Sound
。
在每个case
块中,我们使用传入的参数设置了发射器的位置。在每个case
块的代码的下一部分检查声音当前是否停止,如果是,则播放声音。我们很快就会看到如何得到传递给这个函数的发射器和听众的位置。
playFire
函数的最后部分增加了m_NextSound
,并确保它只能等于 1、2 或 3,这是switch
块所要求的。
编写其余的 SoundManager 函数
添加这四个简单的函数:
void SoundManager::playFallInFire()
{
m_FallInFireSound.setRelativeToListener(true);
m_FallInFireSound.play();
}
void SoundManager::playFallInWater()
{
m_FallInWaterSound.setRelativeToListener(true);
m_FallInWaterSound.play();
}
void SoundManager::playJump()
{
m_JumpSound.setRelativeToListener(true);
m_JumpSound.play();
}
void SoundManager::playReachGoal()
{
m_ReachGoalSound.setRelativeToListener(true);
m_ReachGoalSound.play();
}
playFallInFire
、playFallInWater
和playReachGoal
函数只做两件事。首先,它们各自调用setRelativeToListener
,所以音效不是空间化的,使音效成为普通,而不是定向的,然后它们在适当的Sound
对象上调用play
。
这就结束了SoundManager
类。现在我们可以在Engine
类中使用它。
将 SoundManager 添加到游戏引擎
打开Engine.h
文件,并添加一个新的SoundManager
类的实例,如下面突出显示的代码所示:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
#include "LevelManager.h"
#include "SoundManager.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
// A class to manage all the levels
LevelManager m_LM;
// Create a SoundManager
SoundManager m_SM;
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
在这一点上,我们可以使用m_SM
来调用各种play...
函数。不幸的是,仍然需要做更多的工作来管理发射器(火焰瓦片)的位置。
填充声音发射器
打开Engine.h
文件,并为populateEmitters
函数添加一个新的原型和一个新的 STL vector
of Vector2f
对象:
...
...
...
// Run will call all the private functions
bool detectCollisions(PlayableCharacter& character);
// Make a vector of the best places to emit sounds from
void populateEmitters(vector <Vector2f>& vSoundEmitters,
int** arrayLevel);
// A vector of Vector2f for the fire emitter locations
vector <Vector2f> m_FireEmitters;
public:
...
...
...
populateEmitters
函数以vector
的Vector2f
对象作为参数,以及指向int
(二维数组)的指针。vector
将保存每个级别中发射器的位置,而数组是我们的二维数组,它保存级别的布局。
编写 populateEmitters 函数
populateEmitters
函数的工作是扫描arrayLevel
的所有元素,并决定在哪里放置发射器。它将其结果存储在m_FireEmitters
中。
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击C++文件(.cpp
)并在名称字段中键入PopulateEmitters.cpp
来突出显示。最后,单击添加按钮。现在我们可以编写新函数populateEmitters
。
添加完整的代码;确保在学习代码时,我们可以讨论它:
#include "stdafx.h"
#include "Engine.h"
using namespace sf;
using namespace std;
void Engine::populateEmitters(
vector <Vector2f>& vSoundEmitters, int** arrayLevel)
{
// Make sure the vector is empty
vSoundEmitters.empty();
// Keep track of the previous emitter
// so we don't make too many
FloatRect previousEmitter;
// Search for fire in the level
for (int x = 0; x < (int)m_LM.getLevelSize().x; x++)
{
for (int y = 0; y < (int)m_LM.getLevelSize().y; y++)
{
if (arrayLevel[y][x] == 2)// fire is present
{
// Skip over any fire tiles too
// near a previous emitter
if (!FloatRect(x * TILE_SIZE,
y * TILE_SIZE,
TILE_SIZE,
TILE_SIZE).intersects(previousEmitter))
{
// Add the coordiantes of this water block
vSoundEmitters.push_back(
Vector2f(x * TILE_SIZE, y * TILE_SIZE));
// Make a rectangle 6 blocks x 6 blocks,
// so we don't make any more emitters
// too close to this one
previousEmitter.left = x * TILE_SIZE;
previousEmitter.top = y * TILE_SIZE;
previousEmitter.width = TILE_SIZE * 6;
previousEmitter.height = TILE_SIZE * 6;
}
}
}
}
return;
}
一些代码乍一看可能会很复杂。了解我们用来选择发射器位置的技术将使其变得更简单。在我们的级别中,通常有大块的火瓦。在我设计的一个级别中,有超过 30 个火瓦。代码确保在给定矩形内只有一个发射器。这个矩形存储在previousEmitter
中,大小为 300x300 像素(TILE_SIZE * 6
)。
该代码设置了一个嵌套的for
循环,循环遍历arrayLevel
以寻找火瓦。当找到一个时,它确保它不与previousEmitter
相交。只有这样,它才使用pushBack
函数向vSoundEmitters
添加另一个发射器。在这样做之后,它还更新previousEmitter
以避免获得大量的声音发射器。
让我们发出一些声音。
播放声音
打开LoadLevel.cpp
文件,并添加对新的populateEmitters
函数的调用,如下面的代码所示:
void Engine::loadLevel()
{
m_Playing = false;
// Delete the previously allocated memory
for (int i = 0; i < m_LM.getLevelSize().y; ++i)
{
delete[] m_ArrayLevel[i];
}
delete[] m_ArrayLevel;
// Load the next 2d array with the map for the level
// And repopulate the vertex array as well
m_ArrayLevel = m_LM.nextLevel(m_VALevel);
// Prepare the sound emitters
populateEmitters(m_FireEmitters, m_ArrayLevel);
// How long is this new time limit
m_TimeRemaining = m_LM.getTimeLimit();
// Spawn Thomas and Bob
m_Thomas.spawn(m_LM.getStartPosition(), GRAVITY);
m_Bob.spawn(m_LM.getStartPosition(), GRAVITY);
// Make sure this code isn't run again
m_NewLevelRequired = false;
}
要添加的第一个声音是跳跃声音。您可能还记得键盘处理代码位于Bob
和Thomas
类中的纯虚函数中,并且handleInput
函数在成功启动跳跃时返回true
。
打开Input.cpp
文件,并添加突出显示的代码行,以在 Thomas 或 Bob 成功开始跳跃时播放跳跃声音:
// Handle input specific to Thomas
if (m_Thomas.handleInput())
{
// Play a jump sound
m_SM.playJump();
}
// Handle input specific to Bob
if (m_Bob.handleInput())
{
// Play a jump sound
m_SM.playJump();
}
打开Update.cpp
文件,并添加突出显示的代码行,以在 Thomas 和 Bob 同时达到当前级别目标时播放成功声音:
// Detect collisions and see if characters have reached the goal tile
// The second part of the if condition is only executed
// when thomas is touching the home tile
if (detectCollisions(m_Thomas) && detectCollisions(m_Bob))
{
// New level required
m_NewLevelRequired = true;
// Play the reach goal sound
m_SM.playReachGoal();
}
else
{
// Run bobs collision detection
detectCollisions(m_Bob);
}
同样在Update.cpp
文件中,我们将添加代码来循环遍历m_FireEmitters
向量,并决定何时需要调用SoundManager
类的playFire
函数。
仔细观察新突出显示的代码周围的少量上下文。在完全正确的位置添加此代码是至关重要的:
}// End if playing
// Check if a fire sound needs to be played
vector<Vector2f>::iterator it;
// Iterate through the vector of Vector2f objects
for (it = m_FireEmitters.begin();it != m_FireEmitters.end(); it++)
{
// Where is this emitter?
// Store the location in pos
float posX = (*it).x;
float posY = (*it).y;
// is the emiter near the player?
// Make a 500 pixel rectangle around the emitter
FloatRect localRect(posX - 250, posY - 250, 500, 500);
// Is the player inside localRect?
if (m_Thomas.getPosition().intersects(localRect))
{
// Play the sound and pass in the location as well
m_SM.playFire(Vector2f(posX, posY), m_Thomas.getCenter());
}
}
// Set the appropriate view around the appropriate character
以前的代码有点像声音的碰撞检测。每当 Thomas 停留在一个 500x500 像素的矩形内,围绕一个火焰发射器时,就会调用playFire
函数,传入发射器和 Thomas 的坐标。playFire
函数会完成其余的工作并触发一个空间化的循环声音效果。
打开DetectCollisions.cpp
文件,找到适当的位置,并按照以下所示添加突出显示的代码。这两行突出显示的代码触发了当角色掉入水或火瓦时播放声音效果:
// Has character been burnt or drowned?
// Use head as this allows him to sink a bit
if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)
{
if (character.getHead().intersects(block))
{
character.spawn(m_LM.getStartPosition(), GRAVITY);
// Which sound should be played?
if (m_ArrayLevel[y][x] == 2)// Fire, ouch!
{
// Play a sound
m_SM.playFallInFire();
}
else // Water
{
// Play a sound
m_SM.playFallInWater();
}
}
}
玩游戏将允许您听到所有声音,包括附近火瓦的酷空间化。
HUD 类
HUD 非常简单,与书中的其他两个项目没有什么不同。我们要做的不同之处在于将所有代码封装在一个新的 HUD 类中。如果我们将所有字体、文本和其他变量声明为这个新类的成员,然后在构造函数中初始化它们并为所有值提供 getter 函数。这将使Engine
类清除大量的声明和初始化。
编写 HUD.h
首先,我们将使用所有成员变量和函数声明编写HUD.h
文件。在解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,选择(通过左键单击)头文件(.h
),然后在名称字段中键入HUD.h
。最后,单击添加按钮。现在我们准备为HUD
类编写头文件。
将以下代码添加到HUD.h
中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Hud
{
private:
Font m_Font;
Text m_StartText;
Text m_TimeText;
Text m_LevelText;
public:
Hud();
Text getMessage();
Text getLevel();
Text getTime();
void setLevel(String text);
void setTime(String text);
};
在前面的代码中,我们添加了一个Font
实例和三个Text
实例。Text
对象将用于显示提示用户启动、剩余时间和当前级别编号的消息。
公共函数更有趣。首先是构造函数,大部分代码将在其中。构造函数将初始化Font
和Text
对象,并将它们相对于当前屏幕分辨率定位在屏幕上。
三个 getter 函数,getMessage
、getLevel
和getTime
将返回一个Text
对象,以便能够将它们绘制到屏幕上。
setLevel
和setTime
函数将用于更新m_LevelText
和m_TimeText
中显示的文本,分别。
现在我们可以编写刚刚概述的所有函数的定义。
编写 HUD.cpp 文件
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,选择(通过左键单击)C++文件( .cpp
),然后在名称字段中键入HUD.cpp
。最后,单击添加按钮。现在我们准备为HUD
类编写.cpp
文件。
添加包含指令和以下代码,然后我们将讨论它:
#include "stdafx.h"
#include "Hud.h"
Hud::Hud()
{
Vector2u resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
// Load the font
m_Font.loadFromFile("fonts/Roboto-Light.ttf");
// when Paused
m_StartText.setFont(m_Font);
m_StartText.setCharacterSize(100);
m_StartText.setFillColor(Color::White);
m_StartText.setString("Press Enter when ready!");
// Position the text
FloatRect textRect = m_StartText.getLocalBounds();
m_StartText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
m_StartText.setPosition(
resolution.x / 2.0f, resolution.y / 2.0f);
// Time
m_TimeText.setFont(m_Font);
m_TimeText.setCharacterSize(75);
m_TimeText.setFillColor(Color::White);
m_TimeText.setPosition(resolution.x - 150, 0);
m_TimeText.setString("------");
// Level
m_LevelText.setFont(m_Font);
m_LevelText.setCharacterSize(75);
m_LevelText.setFillColor(Color::White);
m_LevelText.setPosition(25, 0);
m_LevelText.setString("1");
}
首先,我们将水平和垂直分辨率存储在名为resolution
的Vector2u
中。接下来,我们从我们在第十二章中添加的fonts
目录中加载字体,抽象和代码管理 - 更好地利用面向对象编程。
接下来的四行代码设置了m_StartText
的字体、颜色、大小和文本。此后的代码块捕获了包裹m_StartText
的矩形的大小,并进行计算以确定如何将其居中放置在屏幕上。如果您想对代码的这部分进行更详细的解释,请参考第三章:C++字符串、SFML 时间 - 玩家输入和 HUD。
构造函数中的最后两个代码块设置了m_TimeText
和m_LevelText
的字体、文本大小、颜色、位置和实际文本。然而,我们很快就会看到,这两个Text
对象将通过两个 setter 函数进行更新,只要需要就可以更新。
在我们刚刚添加的代码之后,立即添加以下 getter 和 setter 函数:
Text Hud::getMessage()
{
return m_StartText;
}
Text Hud::getLevel()
{
return m_LevelText;
}
Text Hud::getTime()
{
return m_TimeText;
}
void Hud::setLevel(String text)
{
m_LevelText.setString(text);
}
void Hud::setTime(String text)
{
m_TimeText.setString(text);
}
前面代码中的前三个函数简单地返回了适当的Text
对象,m_StartText
、m_LevelText
和m_TimeText
。在屏幕上绘制 HUD 时,我们将很快使用这些函数。最后两个函数setLevel
和setTime
使用setString
函数来更新适当的Text
对象,该值将从Engine
类的update
函数中每 500 帧传入。
完成所有这些后,我们可以在游戏引擎中使用 HUD 类。
使用 HUD 类
打开Engine.h
,为我们的新类添加一个包含,声明新的HUD
类的实例,并且声明并初始化两个新的成员变量,用于跟踪我们更新 HUD 的频率。正如我们在前两个项目中学到的那样,我们不需要为每一帧都这样做。
将以下代码添加到Engine.h
中:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
#include "LevelManager.h"
#include "SoundManager.h"
#include "HUD.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
// A class to manage all the levels
LevelManager m_LM;
// Create a SoundManager
SoundManager m_SM;
// The Hud Hud m_Hud;
int m_FramesSinceLastHUDUpdate = 0;
int m_TargetFramesPerHUDUpdate = 500;
const int TILE_SIZE = 50;
接下来,我们需要在Engine
类的update
函数中添加一些代码。打开Update.cpp
并添加突出显示的代码以在每 500 帧更新一次 HUD:
// Set the appropriate view around the appropriate character
if (m_SplitScreen)
{
m_LeftView.setCenter(m_Thomas.getCenter());
m_RightView.setCenter(m_Bob.getCenter());
}
else
{
// Centre full screen around appropriate character
if (m_Character1)
{
m_MainView.setCenter(m_Thomas.getCenter());
}
else
{
m_MainView.setCenter(m_Bob.getCenter());
}
}
// Time to update the HUD?
// Increment the number of frames since the last HUD calculation
m_FramesSinceLastHUDUpdate++;
// Update the HUD every m_TargetFramesPerHUDUpdate frames
if (m_FramesSinceLastHUDUpdate > m_TargetFramesPerHUDUpdate)
{
// Update game HUD text
stringstream ssTime;
stringstream ssLevel;
// Update the time text
ssTime << (int)m_TimeRemaining;
m_Hud.setTime(ssTime.str());
// Update the level text
ssLevel << "Level:" << m_LM.getCurrentLevel();
m_Hud.setLevel(ssLevel.str());
m_FramesSinceLastHUDUpdate = 0;
}
}// End of update function
在之前的代码中,m_FramesSinceLastUpdate
每帧递增。当m_FramesSinceLastUpdate
超过m_TargetFramesPerHUDUpdate
时,执行进入if
块。在if
块内,我们使用stringstream
对象来更新我们的Text
,就像我们在之前的两个项目中所做的那样。然而,正如你可能期望的那样,在这个项目中我们使用了HUD
类,所以我们调用setTime
和setLevel
函数,传入Text
对象需要设置的当前值。
if
块中的最后一步是将m_FramesSinceLastUpdate
设置回零,这样它就可以开始计算下一个更新。
最后,打开Draw.cpp
文件,并添加高亮代码以在每一帧绘制 HUD:
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
m_Window.draw(m_Hud.getLevel());
m_Window.draw(m_Hud.getTime());
if (!m_Playing)
{
m_Window.draw(m_Hud.getMessage());
}
// Show everything we have just drawn
m_Window.display();
}// End of draw
之前的代码通过使用 HUD 类的 getter 函数来绘制 HUD。请注意,只有在游戏当前没有进行时才会调用绘制提示玩家开始的消息(!m_Playing)
。
运行游戏并玩几个关卡,看时间倒计时和关卡增加。当你再次回到第一关时,注意你的时间比之前少了 10%。
总结
我们的游戏《Thomas Was Late》不仅可以完全玩得了,还有方向性的音效和简单但信息丰富的 HUD,而且我们还可以轻松添加新的关卡。在这一点上,我们可以说它已经完成了。
添加一些闪光效果会很好。在接下来的章节中,我们将探讨两个游戏概念。首先,我们将研究粒子系统,这是我们如何处理爆炸或其他特殊效果的方法。为了实现这一点,我们需要学习更多的 C++知识,看看我们如何彻底重新思考我们的游戏代码结构。
之后,当我们学习 OpenGL 和可编程图形管线时,我们将为游戏添加最后的点睛之笔。然后,我们将有机会涉足GLSL语言,这使我们能够编写直接在 GPU 上执行的代码,以创建一些特殊效果。
第十六章:扩展 SFML 类,粒子系统和着色器
在本章的最后,我们将探讨 C++概念,即扩展其他人的类。更具体地说,我们将研究 SFML Drawable
类以及将其用作我们自己类的基类的好处。我们还将浅尝 OpenGL 着色器的主题,并看看如何使用另一种语言OpenGL 着色语言(GLSL)编写代码,可以直接在图形卡上运行,可以产生平滑的图形效果,否则可能是不可能的。像往常一样,我们还将利用我们的新技能和知识来增强当前项目。
以下是我们将按顺序涵盖的主题列表:
-
SFML Drawable 类
-
构建一个粒子系统
-
OpenGl 着色器和 GLSL
-
在 Thomas Was Late 游戏中使用着色器
SFML Drawable 类
Drawable
类只有一个函数。它也没有变量。此外,它唯一的功能是纯虚拟的。这意味着如果我们从Drawable
继承,我们必须实现它唯一的功能。这个目的,你可能还记得第十二章,抽象和代码管理-更好地利用 OOP,就是我们可以使用从drawable
继承的类作为多态类型。更简单地说,SFML 允许我们对Drawable
对象做的任何事情,我们都可以用从它继承的类来做。唯一的要求是我们必须为纯虚拟函数draw
提供定义。
一些从Drawable
继承的类已经包括Sprite
和VertexArray
(以及其他类)。每当我们使用Sprite
或VertexArray
时,我们都将它们传递给RenderWindow
类的draw
函数。
我们之所以能够在本书中绘制的每个对象都继承自Drawable
。我们可以利用这一知识来使我们受益。
我们可以用任何我们喜欢的对象从Drawable
继承,只要我们实现纯虚拟的draw
函数。这也是一个简单的过程。假设从Drawable
继承的SpaceShip
类的头文件(SpaceShip.h
)将如下所示:
class SpaceShip : public Drawable
{
private:
Sprite m_Sprite;
// More private members
public:
virtual void draw(RenderTarget& target,
RenderStates states) const;
// More public members
};
在前面的代码中,我们可以看到纯虚拟的draw
函数和一个 Sprite。请注意,没有办法在类的外部访问私有的Sprite
,甚至没有getSprite
函数!
SpaceShip.cpp
文件看起来可能是这样的:
void SpaceShip::SpaceShip
{
// Set up the spaceship
}
void SpaceShip::draw(RenderTarget& target, RenderStates states) const
{
target.draw(m_Sprite, states);
}
// Any other functions
在前面的代码中,请注意draw
函数的简单实现。参数超出了本书的范围。只需注意target
参数用于调用draw
并传递m_Sprite
以及states
,另一个参数。
提示
虽然不需要理解参数就能充分利用Drawable
,但在本书的背景下,你可能会感兴趣。您可以在 SFML 网站上阅读有关 SFML Drawable
类的更多信息:www.sfml-dev.org/tutorials/2.3/graphics-vertex-array.php#creating-an-sfml-like-entity
在主游戏循环中,我们现在可以将SpaceShip
实例视为Sprite
,或者从Drawable
继承的任何其他类:
SpaceShip m_SpaceShip;
// create other objects here
// ...
// In the draw function
// Rub out the last frame
m_Window.clear(Color::Black);
// Draw the spaceship
m_Window.draw(m_SpaceShip);
// More drawing here
// ...
// Show everything we have just drawn
m_Window.display();
正因为SpaceShip
是Drawable
,我们才能将其视为Sprite
或VertexArray
,并且因为我们覆盖了纯虚拟的draw
函数,一切都按我们想要的方式工作。让我们看看另一种将绘图代码封装到游戏对象中的方法。
从 Drawable 继承的另一种选择
还可以通过在我们的类中实现自己的函数来保留所有绘图功能,也许像以下代码一样:
void drawThisObject(RenderWindow window)
{
window.draw(m_Sprite)
}
先前的代码假定m_Sprite
代表我们正在绘制的当前类的视觉外观,就像在本项目和上一个项目中一样。假设包含drawThisObject
函数的类的实例称为playerHero
,并且进一步假设我们有一个名为m_Window
的RenderWindow
的实例,然后我们可以使用以下代码从主游戏循环中绘制对象:
playerHero.draw(m_Window);
在这个解决方案中,我们将RenderWindow``m_Window
作为参数传递给drawThisObject
函数。然后,drawThisObject
函数使用RenderWindow
来绘制Sprite``m_Sprite
。
这种解决方案似乎比扩展Drawable
更简单。我们之所以按照建议的方式进行操作(扩展 Drawable)并不是因为这个项目本身有很大的好处。我们很快将用这种方法绘制一个漂亮的爆炸,原因是这是一个很好的学习技巧。
为什么最好继承自 Drawable?
通过本书完成的每个项目,我们都学到了更多关于游戏、C++和 SFML。从一个游戏到下一个游戏,我们所做的最大的改进可能是我们的代码结构——我们使用的编程模式。
如果这本书有第四个项目,我们可能会更进一步。不幸的是,没有,但是想一想如何改进我们的代码。
想象一下,我们游戏中的每个对象都是从一个简单的抽象基类派生出来的。让我们称之为GameObject
。游戏对象可能会有具体的函数用于getPosition
和其他函数。它可能会有一个纯虚拟的update
函数(因为每个对象的更新方式都不同)。此外,考虑GameObject
继承自Drawable
。
现在看看这个假设的代码:
vector<GameObject> m_GameObjects;
// Code to initialise all game objects
// Including tiles, characters, enemies, bullets and anything else
// In the update function
for (i = m_GameObjects.begin(); i != m_GameObjects.end(); i++)
{
(*i).update(elapsedTime);
}
// That's it!
// In the draw function
// Rub out the last frame
m_Window.clear(Color::Black);
for (i = m_GameObjects.begin(); i != m_GameObjects.end(); i++)
{
m_Window.draw(*i);
}
// Show everything we have just drawn
m_Window.display();
// That's it!
与最终项目相比,上述代码在封装、代码可管理性和优雅性方面有了很大的进步。如果你看一下以前的代码,你会发现有一些未解答的问题,比如碰撞检测的位置在哪里。然而,希望你能看到,进一步的学习(通过构建很多游戏)将是掌握 C++所必需的。
虽然我们不会以这种方式实现整个游戏,但我们将看到如何设计一个类(ParticleSystem
)并将其直接传递给m_Window.draw(m_MyParticleSystemInstance)
。
构建粒子系统
在我们开始编码之前,看一看我们要实现的确切内容将会很有帮助。看一下以下的屏幕截图:
这是一个纯色背景上的粒子效果的屏幕截图。我们将在游戏中使用这个效果。
我们实现效果的方式如下:
-
生成 1,000 个点(粒子),一个在另一个顶部,在选择的像素位置。
-
在游戏的每一帧中,以预定但随机的速度和角度将 1,000 个粒子向外移动。
-
重复第二步两秒钟,然后使粒子消失。
我们将使用VertexArray
来绘制所有的点,使用Point
作为原始类型来直观表示每个粒子。此外,我们将继承自Drawable
,以便我们的粒子系统可以自行处理绘制。
编写 Particle 类
Particle
类将是一个简单的类,表示 1,000 个粒子中的一个。让我们开始编码。
编码 Particle.h
在“解决方案资源管理器”中右键单击“头文件”,然后选择“添加”|“新项目...”。在“添加新项目”窗口中,突出显示(通过左键单击)“头文件”(.h),然后在“名称”字段中键入Particle.h
。最后,单击“添加”按钮。我们现在准备为Particle
类编写头文件。
将以下代码添加到Particle.h
文件中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Particle
{
private:
Vector2f m_Position;
Vector2f m_Velocity;
public:
Particle(Vector2f direction);
void update(float dt);
void setPosition(Vector2f position);
Vector2f getPosition();
};
在上述代码中,我们有两个Vector2f
对象。一个表示粒子的水平和垂直坐标,另一个表示水平和垂直速度。
注意
当速度在多个方向上发生变化时,合并的值也定义了一个方向。这就是所谓的速度;因此,Vector2f 被称为m_Velocity
。
我们还有一些公共函数。首先是构造函数。它接受一个Vector2f
,将用于让它知道这个粒子将具有什么方向/速度。这意味着系统而不是粒子本身将选择速度。
接下来是update
函数,它接受前一帧所花费的时间。我们将使用这个时间来精确地移动粒子。
最后两个函数setPosition
和getPosition
用于将粒子移动到位置并找出其位置。
当我们编写它们时,所有这些功能都会变得非常清晰。
编写 Particle.cpp 文件
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击C++文件(.cpp
)然后在名称字段中输入Particle.cpp
,最后,单击添加按钮。我们现在准备为Particle
类编写.cpp
文件。
将以下代码添加到Particle.cpp
中:
#include "stdafx.h"
#include "Particle.h"
Particle::Particle(Vector2f direction)
{
// Determine the direction
//m_Velocity = direction;
m_Velocity.x = direction.x;
m_Velocity.y = direction.y;
}
void Particle::update(float dtAsSeconds)
{
// Move the particle
m_Position += m_Velocity * dtAsSeconds;
}
void Particle::setPosition(Vector2f position)
{
m_Position = position;
}
Vector2f Particle::getPosition()
{
return m_Position;
}
所有这些函数都使用了我们之前见过的概念。构造函数使用传入的Vector2f
对象设置了m_Velocity.x
和m_Velocity.y
的值。
update
函数通过将m_Velocity
乘以经过的时间(dtAsSeconds
)来移动粒子的水平和垂直位置。请注意,为了实现这一点,我们只需将两个Vector2f
对象相加即可。无需分别为x和y成员执行计算。
如前所述,setPosition
函数将使用传入的值初始化m_Position
对象。getPosition
函数将m_Position
返回给调用代码。
我们现在有一个完全功能的Particle
类。接下来,我们将编写一个ParticleSystem
类来生成和控制粒子。
编写 ParticleSystem 类
ParticleSystem
类为我们的粒子效果大部分工作。我们将在Engine
类中创建此类的实例。
编写 ParticleSystem.h
在解决方案资源管理器中右键单击头文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击头文件(.h
)然后在名称字段中输入ParticleSystem.h
,最后,单击添加按钮。我们现在准备为ParticleSystem
类编写头文件。
将ParticleSystem
类的代码添加到ParticleSystem.h
中:
#pragma once
#include <SFML/Graphics.hpp>
#include "Particle.h"
using namespace sf;
using namespace std;
class ParticleSystem : public Drawable
{
private:
vector<Particle> m_Particles;
VertexArray m_Vertices;
float m_Duration;
bool m_IsRunning = false;
public:
virtual void draw(RenderTarget& target, RenderStates states) const;
void init(int count);
void emitParticles(Vector2f position);
void update(float elapsed);
bool running();
};
让我们一点一点地来。首先,注意我们是从Drawable
继承的。这将使我们能够将我们的ParticleSystem
实例传递给m_Window.draw
,因为ParticleSystem
是Drawable
。
有一个名为m_Particles
的vector
,类型为Particle
。这个vector
将保存每个Particle
实例。接下来是一个名为m_Vertices
的VertexArray
。这将用于以一堆Point
原语的形式绘制所有粒子。
m_Duration
,float
变量是每个效果将持续的时间。我们将在构造函数中初始化它。
布尔m_IsRunning
变量将用于指示粒子系统当前是否正在使用。
接下来,在公共部分,我们有纯虚函数draw
,我们将很快实现它来处理当我们将ParticleSystem
实例传递给m_Window.draw
时发生的情况。
init
函数将准备VertexArray
和vector
。它还将使用它们的速度和初始位置初始化所有Particle
对象(由vector
持有)。
update
函数将循环遍历vector
中的每个Particle
实例,并调用它们各自的update
函数。
running
函数提供对m_IsRunning
变量的访问,以便游戏引擎可以查询ParticleSystem
当前是否正在使用。
让我们编写函数定义来看看ParticleSystem
内部发生了什么。
编写 ParticleSystem.cpp 文件
在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件( .cpp
),然后在名称字段中输入ParticleSystem.cpp
。最后,单击添加按钮。现在我们准备为ParticleSystem
类编写.cpp
文件。
我们将把这个文件分成五个部分来编码和讨论它。按照这里所示的方式添加代码的第一部分:
#include "stdafx.h"
#include <SFML/Graphics.hpp>
#include "ParticleSystem.h"
using namespace sf;
using namespace std;
void ParticleSystem::init(int numParticles)
{
m_Vertices.setPrimitiveType(Points);
m_Vertices.resize(numParticles);
// Create the particles
for (int i = 0; i < numParticles; i++)
{
srand(time(0) + i);
float angle = (rand() % 360) * 3.14f / 180.f;
float speed = (rand() % 600) + 600.f;
Vector2f direction;
direction = Vector2f(cos(angle) * speed,
sin(angle) * speed);
m_Particles.push_back(Particle(direction));
}
}
在必要的includes
之后,我们有init
函数的定义。我们使用Points
作为参数调用setPrimitiveType
,以便m_VertexArray
知道它将处理什么类型的基元。我们使用传入init
函数的numParticles
来调整m_Vertices
的大小。
for
循环为速度和角度创建随机值。然后使用三角函数将这些值转换为一个存储在Vector2f
中的向量,即direction
。
提示
如果您想了解三角函数(cos
、sin
和tan
)如何将角度和速度转换为向量,您可以查看这个系列文章:gamecodeschool.com/essentials/calculating-heading-in-2d-games-using-trigonometric-functions-part-1/
for
循环(以及init
函数)中发生的最后一件事是将向量传递给Particle
构造函数。新的Particle
实例使用push_back
函数存储在m_Particles
中。因此,使用值为1000
的init
调用意味着我们有一千个Particle
实例,具有随机速度,存储在m_Particles
中等待爆炸!
接下来,在ParticleSysytem.cpp
中添加update
函数:
void ParticleSystem::update(float dt)
{
m_Duration -= dt;
vector<Particle>::iterator i;
int currentVertex = 0;
for (i = m_Particles.begin(); i != m_Particles.end(); i++)
{
// Move the particle
(*i).update(dt);
// Update the vertex array
m_Vertices[currentVertex].position = (*i).getPosition();
// Move to the next vertex
currentVertex++;
}
if (m_Duration < 0)
{
m_IsRunning = false;
}
}
update
函数比起一开始看起来要简单得多。首先,m_Duration
减去传入的时间dt
。这样我们就知道两秒已经过去了。声明了一个向量迭代器i
,用于m_Particles
。
for
循环遍历m_Particles
中的每个Particle
实例。对于每一个粒子,它调用其update
函数并传入dt
。每个粒子都会更新其位置。粒子更新完毕后,使用粒子的getPosition
函数更新m_Vertices
中的适当顶点。在每次循环结束时,for
循环中的currentVertex
会递增,准备下一个顶点。
在for
循环完成后,if(m_Duration < 0)
检查是否是时候关闭效果了。如果两秒已经过去,m_IsRunning
被设置为false
。
接下来,添加emitParticles
函数:
void ParticleSystem::emitParticles(Vector2f startPosition)
{
m_IsRunning = true;
m_Duration = 2;
vector<Particle>::iterator i;
int currentVertex = 0;
for (i = m_Particles.begin(); i != m_Particles.end(); i++)
{
m_Vertices[currentVertex].color = Color::Yellow;
(*i).setPosition(startPosition);
currentVertex++;
}
}
这是我们将调用以启动粒子系统运行的函数。因此,可以预料到,我们将m_IsRunning
设置为true
,m_Duration
设置为2
。我们声明一个iterator
i
,用于迭代m_Particles
中的所有Particle
对象,然后在for
循环中这样做。
在for
循环中,我们将顶点数组中的每个粒子设置为黄色,并将每个位置设置为传入的startPosition
。请记住,每个粒子的生命都是从完全相同的位置开始的,但它们每个都被分配了不同的速度。
接下来,添加纯虚拟的 draw 函数定义:
void ParticleSystem::draw(RenderTarget& target, RenderStates states) const
{
target.draw(m_Vertices, states);
}
在上面的代码中,我们简单地使用target
调用draw
,传入m_Vertices
和states
。这正如我们在本章早些时候讨论Drawable
时所讨论的一样,只是我们传入了我们的VertexArray
,它包含了 1000 个点的基元,而不是假设的飞船 Sprite。
最后,添加running
函数:
bool ParticleSystem::running()
{
return m_IsRunning;
}
running
函数是一个简单的 getter 函数,返回m_IsRunning
的值。我们将看到这在确定粒子系统的当前状态时是有用的。
使用 ParticleSystem
让我们的粒子系统工作非常简单,特别是因为我们继承自Drawable
。
将 ParticleSystem 对象添加到 Engine 类
打开Engine.h
并添加一个ParticleSystem
对象,如下所示的高亮代码:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
#include "LevelManager.h"
#include "SoundManager.h"
#include "HUD.h"
#include "ParticleSystem.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// create a particle system
ParticleSystem m_PS;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
接下来,初始化系统。
初始化 ParticleSystem
打开Engine.cpp
文件,并在Engine
构造函数的末尾添加短暂的高亮代码:
Engine::Engine()
{
// Get the screen resolution and create an SFML window and View
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
m_Window.create(VideoMode(resolution.x, resolution.y),
"Thomas was late",
Style::Fullscreen);
// Initialize the full screen view
m_MainView.setSize(resolution);
m_HudView.reset(
FloatRect(0, 0, resolution.x, resolution.y));
// Inititialize the split-screen Views
m_LeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_RightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
m_BGLeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_BGRightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
// Can this graphics card use shaders?
if (!sf::Shader::isAvailable())
{
// Time to get a new PC
m_Window.close();
}
m_BackgroundTexture = TextureHolder::GetTexture(
"graphics/background.png");
// Associate the sprite with the texture
m_BackgroundSprite.setTexture(m_BackgroundTexture);
// Load the texture for the background vertex array
m_TextureTiles = TextureHolder::GetTexture(
"graphics/tiles_sheet.png");
// Initialize the particle system
m_PS.init(1000);
}// End Engine constructor
VertexArray
和Particle
实例的vector
已经准备就绪。
在每一帧更新粒子系统
打开Update.cpp
文件,并添加以下高亮代码。它可以直接放在update
函数的末尾:
// Update the HUD every m_TargetFramesPerHUDUpdate frames
if (m_FramesSinceLastHUDUpdate > m_TargetFramesPerHUDUpdate)
{
// Update game HUD text
stringstream ssTime;
stringstream ssLevel;
// Update the time text
ssTime << (int)m_TimeRemaining;
m_Hud.setTime(ssTime.str());
// Update the level text
ssLevel << "Level:" << m_LM.getCurrentLevel();
m_Hud.setLevel(ssLevel.str());
m_FramesSinceLastHUDUpdate = 0;
}
// Update the particles
if (m_PS.running())
{
m_PS.update(dtAsSeconds);
}
}// End of update function
在先前的代码中,只需要调用update
。请注意,它被包裹在一个检查中,以确保系统当前正在运行。如果它没有运行,更新它就没有意义。
启动粒子系统
打开DetectCollisions.cpp
文件,其中包含detectCollisions
函数。我们在第十五章中编写它时留下了一个注释,构建可玩级别和碰撞检测。
从上下文中确定正确的位置,并添加高亮代码,如下所示:
// Is character colliding with a regular block
if (m_ArrayLevel[y][x] == 1)
{
if (character.getRight().intersects(block))
{
character.stopRight(block.left);
}
else if (character.getLeft().intersects(block))
{
character.stopLeft(block.left);
}
if (character.getFeet().intersects(block))
{
character.stopFalling(block.top);
}
else if (character.getHead().intersects(block))
{
character.stopJump();
}
}
// More collision detection here once
// we have learned about particle effects
// Has the character's feet touched fire or water?
// If so, start a particle effect
// Make sure this is the first time we have detected this
// by seeing if an effect is already running
if (!m_PS.running())
{
if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)
{
if (character.getFeet().intersects(block))
{
// position and start the particle system
m_PS.emitParticles(character.getCenter());
}
}
}
// Has the character reached the goal?
if (m_ArrayLevel[y][x] == 4)
{
// Character has reached the goal
reachedGoal = true;
}
首先,代码检查粒子系统是否已经运行。如果没有,它会检查当前正在检查的瓷砖是否是水砖或火砖。如果是其中之一,它会检查角色的脚是否接触。当这些if
语句中的每一个为true
时,通过调用emitParticles
函数并传入角色中心的位置作为启动效果的坐标来启动粒子系统。
绘制粒子系统
这是最棒的部分。看看绘制ParticleSystem
有多简单。在检查粒子系统实际运行后,我们直接将实例传递给m_Window.draw
函数。
打开Draw.cpp
文件,并在以下代码中显示的所有位置添加高亮代码:
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_MainView
m_Window.setView(m_MainView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw thomas
m_Window.draw(m_Bob.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
}
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
m_Window.draw(m_Hud.getLevel());
m_Window.draw(m_Hud.getTime());
if (!m_Playing)
{
m_Window.draw(m_Hud.getMessage());
}
// Show everything we have just drawn
m_Window.display();
}
请注意在先前的代码中,我们必须在所有的左、右和全屏代码块中绘制粒子系统。
运行游戏,将角色的一只脚移动到火砖的边缘。注意粒子系统突然活跃起来:
现在是新的东西。
OpenGL、着色器和 GLSL
OpenGL(Open Graphics Library)是一个处理 2D 和 3D 图形的编程库。OpenGL 适用于所有主要的桌面操作系统,也有一个在移动设备上运行的版本 OpenGL ES。
OpenGL 最初发布于 1992 年。它在二十多年的时间里得到了改进和完善。此外,图形卡制造商设计他们的硬件以使其与 OpenGL 良好地配合工作。告诉你这一点的目的不是为了历史课,而是要解释如果你想让游戏在不仅仅是 Windows 上运行,特别是在桌面上的 2D(和 3D)游戏中使用 OpenGL 是一个明显的选择。我们已经在使用 OpenGL,因为 SFML 使用 OpenGL。着色器是在 GPU 上运行的程序,所以让我们接下来了解更多关于它们。
可编程管线和着色器
通过 OpenGL,我们可以访问所谓的可编程管线。我们可以将我们的图形发送到RenderWindow
的draw
函数中进行绘制,每一帧。我们还可以编写在 GPU 上运行的代码,能够在调用draw
之后独立地操作每个像素。这是一个非常强大的功能。
在 GPU 上运行的这些额外代码称为着色器程序。我们可以编写代码来操作我们图形的几何(位置),这称为顶点着色器。我们还可以编写代码,以独立地操作每个像素的外观,这称为片段着色器。
尽管我们不会深入探讨着色器,但我们将使用 GLSL 编写一些着色器代码,并了解一些可能性。
在 OpenGL 中,一切都是点、线或三角形。此外,我们可以将颜色和纹理附加到这些基本几何图形,并且还可以组合这些元素以制作我们今天现代游戏中看到的复杂图形。这些统称为基元。我们可以通过 SFML 基元和VertexArray
,以及我们看到的Sprite
和Shape
类来访问 OpenGL 基元。
除了基元,OpenGL 还使用矩阵。矩阵是一种执行算术的方法和结构。这种算术可以从非常简单的高中水平计算移动(平移)坐标,或者可以非常复杂,执行更高级的数学;例如,将我们的游戏世界坐标转换为 OpenGL 屏幕坐标,GPU 可以使用。幸运的是,正是这种复杂性在幕后由 SFML 处理。
SFML 还允许我们直接处理 OpenGL。如果您想了解更多关于 OpenGL 的信息,可以从这里开始:learnopengl.com/#!Introduction
。如果您想直接在 SFML 中使用 OpenGL,可以阅读以下文章:www.sfml-dev.org/tutorials/2.3/window-opengl.php
。
一个应用程序可以有许多着色器。然后我们可以附加不同的着色器到不同的游戏对象上,以创建所需的效果。在这个游戏中,我们只有一个顶点着色器和一个片段着色器。我们将它应用到每一帧的背景上。
然而,当您看到如何将着色器附加到draw
调用时,您会发现添加更多着色器是微不足道的。
我们将按照以下步骤进行:
-
首先,我们需要在 GPU 上执行的着色器代码。
-
然后我们需要编译该代码。
-
最后,我们需要将着色器附加到游戏引擎的绘制函数中的适当绘制调用。
GLSL 是一种独立的语言,它也有自己的类型,可以声明和使用这些类型的变量。此外,我们可以从我们的 C++代码与着色器程序的变量进行交互。
提示
如果对可编程图形管线和着色器的强大功能有更多了解的话,我强烈推荐 Jacobo RodrÃguez 的《GLSL Essentials》:www.packtpub.com/hardware-and-creative/glsl-essentials
。该书探讨了桌面上的 OpenGL 着色器,并且对于具有良好的 C++编程知识并愿意学习不同语言的任何读者来说都非常易懂。
正如我们将看到的,GLSL 与 C++有一些语法相似之处。
编写片段着色器
这是shaders
文件夹中rippleShader.frag
文件中的代码。您不需要编写此代码,因为它是我们在第十二章中添加的资产中的代码,抽象和代码管理-更好地利用 OOP:
// attributes from vertShader.vert
varying vec4 vColor;
varying vec2 vTexCoord;
// uniforms
uniform sampler2D uTexture;
uniform float uTime;
void main() {
float coef = sin(gl_FragCoord.y * 0.1 + 1 * uTime);
vTexCoord.y += coef * 0.03;
gl_FragColor = vColor * texture2D(uTexture, vTexCoord);
}
前四行(不包括注释)是片段着色器将使用的变量。但它们不是普通的变量。我们首先看到的类型是varying
。这些变量在两个shaders
之间的范围内。接下来是uniform
变量。这些变量可以直接从我们的 C++代码中操作。我们很快将看到如何做到这一点。
除了varying
和uniform
类型之外,每个变量还有一个更常规的类型,用于定义实际数据:
-
vec4
是一个具有四个值的向量 -
vec2
是一个具有两个值的向量 -
sampler2d
将保存一个纹理 -
float
就像 C++中的float
main
函数中的代码是实际执行的内容。如果仔细观察main
中的代码,你会看到每个变量的使用情况。然而,这段代码的具体作用超出了本书的范围。总之,纹理坐标(vTexCoord
)和像素/片段的颜色(glFragColor
)会受到许多数学函数和操作的影响。请记住,这将在游戏的每一帧中的每个绘制调用中执行,对每个像素都会执行。此外,请注意,uTime
会在每一帧中传入不同的值。很快我们就会看到结果,会产生一种波纹效果。
编写顶点着色器
这是vertShader.vert
文件中的代码。你不需要编写这个代码,因为它是我们在第十二章中添加的资产中的一部分,抽象和代码管理-更好地使用 OOP:
//varying "out" variables to be used in the fragment shader
varying vec4 vColor;
varying vec2 vTexCoord;
void main() {
vColor = gl_Color;
vTexCoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
首先,注意两个varying
变量。这些变量与我们在片段着色器中操作的变量是一样的。在main
函数中,代码会操作每个顶点的位置。代码的工作原理超出了本书的范围,但在幕后进行了一些相当深入的数学运算,如果你感兴趣,那么探索 GLSL 将会很有趣(参见前面的提示)。
现在我们有两个着色器(一个片段着色器和一个顶点着色器)。我们可以在游戏中使用它们。
将着色器添加到 Engine 类
打开Engine.h
文件。添加突出显示的代码行,将一个名为m_RippleShader
的 SFML Shader
实例添加到Engine
类中:
// Three views for the background
View m_BGMainView;
View m_BGLeftView;
View m_BGRightView;
View m_HudView;
// Declare a sprite and a Texture for the background
Sprite m_BackgroundSprite;
Texture m_BackgroundTexture;
// Declare a shader for the background
Shader m_RippleShader;
// Is the game currently playing?
bool m_Playing = false;
// Is character 1 or 2 the current focus?
bool m_Character1 = true;
现在,引擎对象及其所有函数都可以访问m_RippleShadder
。请注意,一个 SFML Shader
对象将由两个着色器代码文件组成。
加载着色器
添加以下代码,检查玩家的 GPU 是否能处理着色器。如果不能,游戏将退出。
提示
你的电脑必须非常老旧才无法运行。如果你的 GPU 无法处理着色器,请接受我的道歉。
接下来,我们将添加一个 else 子句,如果系统能够处理着色器,则实际加载着色器。打开Engine.cpp
文件,并将以下代码添加到构造函数中:
// Can this graphics card use shaders?
if (!sf::Shader::isAvailable())
{
// Time to get a new PC
m_Window.close();
}
else
{
// Load two shaders (1 vertex, 1 fragment)
m_RippleShader.loadFromFile("shaders/vertShader.vert",
"shaders/rippleShader.frag");}
m_BackgroundTexture = TextureHolder::GetTexture(
"graphics/background.png");
现在我们几乎准备好看到我们的波纹效果了。
在每一帧更新和绘制着色器
打开Draw.cpp
文件。正如我们在编写着色器时讨论的那样,我们将直接从 C++代码中每帧更新uTime
变量。我们使用Uniform
函数来实现。
添加突出显示的代码以更新着色器的uTime
变量,并更改每种可能的绘制场景中m_BackgroundSprite
的draw
调用:
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
// Update the shader parameters
m_RippleShader.setUniform("uTime", m_GameTimeTotal.asSeconds());
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
//m_Window.draw(m_BackgroundSprite);
// Draw the background, complete with shader effect
m_Window.draw(m_BackgroundSprite, &m_RippleShader);
// Switch to m_MainView
m_Window.setView(m_MainView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw thomas
m_Window.draw(m_Bob.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
}
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
//m_Window.draw(m_BackgroundSprite);
// Draw the background, complete with shader effect
m_Window.draw(m_BackgroundSprite, &m_RippleShader);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
//m_Window.draw(m_BackgroundSprite);
// Draw the background, complete with shader effect
m_Window.draw(m_BackgroundSprite, &m_RippleShader);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
m_Window.draw(m_Hud.getLevel());
m_Window.draw(m_Hud.getTime());
if (!m_Playing)
{
m_Window.draw(m_Hud.getMessage());
}
// Show everything we have just drawn
m_Window.display();
}
最好是实际删除我展示的注释掉的代码行。我只是这样做是为了清楚地表明哪些代码行正在被替换。
运行游戏,你会得到一种怪异的熔岩效果。如果想玩得开心,可以尝试更改背景图像:
!在每一帧更新和绘制着色器
就是这样!我们的第三个也是最后一个游戏完成了。
总结
在大结局中,我们探讨了粒子系统和着色器的概念。虽然我们可能只是看了最简单的情况,但我们还是成功地创建了一个简单的爆炸和一种怪异的熔岩效果。
请查看最终的简短章节,讨论接下来该做什么。
第十七章:在你离开之前...
当你第一次翻开这本厚重的书时,最后一页可能看起来很遥远。但我希望这并不太困难!
重点是,你现在在这里,希望你对如何在 C++中构建游戏有很好的见解。
本章的重点不仅是祝贺你取得了很好的成就,还要指出这一页可能不应该是你旅程的终点。如果像我一样,每当你让一个新的游戏特性变得生动起来时,你可能想要学到更多。
也许让你惊讶的是,即使经过了这么多页的内容,我们只是浅尝辄止 C++。即使我们涉及的主题可能需要更深入地讨论,还有许多主题,一些相当重要的主题,我们甚至没有提到。考虑到这一点,让我们来看看接下来可能会发生什么。
如果你绝对必须获得正式的资格,那么唯一的方法就是接受正规教育。当然,这是昂贵且耗时的,我无法提供更多帮助。
另一方面,如果你想在工作中学习,也许是在开始制作最终发布的游戏时,接下来将讨论你可能想要做的事情。
也许我们每个项目面临的最困难的决定是如何构建我们的代码结构。在我看来,关于如何构建你的 C++游戏代码的绝佳信息来源是gameprogrammingpatterns.com/
。其中一些讨论涉及到本书未涉及的概念,但其中很多内容都是完全可以理解的。如果你理解类、封装、纯虚函数和单例模式,那就深入了解这个网站吧。
我在整本书中已经多次提到了 SFML 的网站。如果你还没有访问过,请看一下这个链接:www.sfml-dev.org/
。
当你遇到你不理解的 C++主题(或者甚至从未听说过的主题)时,最简洁和最有组织的 C++教程可以在这个链接找到:www.cplusplus.com/doc/tutorial/
除此之外,还有四本关于 SFML 的书,你可能会感兴趣。它们都是很好的书,但适合的读者有很大不同。以下是这些书的列表,按照从最适合初学者到最技术性的顺序排列:
-
SFML Essentials,作者 Milcho G. Milchev:
www.packtpub.com/game-development/sfml-essentials
-
SFML 蓝图,作者 Maxime Barbier:
www.packtpub.com/game-development/sfml-blueprints
-
SFML 游戏开发示例,作者 Raimondas Pupius:
www.packtpub.com/game-development/sfml-game-development-example
-
SFML 游戏开发,作者 Jan Haller,Henrik Vogelius Hansson 和 Artur Moreira:
www.packtpub.com/game-development/sfml-game-development
-
你可能还想考虑为你的游戏添加逼真的 2D 物理效果。SFML 与 Box2D 物理引擎完美配合。这是官方网站的链接:http://box2d.org/。下一个链接可能是使用 C++的最佳指南:
www.iforce2d.net/
。 -
最后,我可以不要脸地为初学游戏程序员推荐我的网站:
gamecodeschool.com
。
谢谢!
最重要的是,非常感谢购买这本书,继续制作游戏!