第6章 面向对象编程——开始开发《乒乓》游戏
# 第6章 面向对象编程——开始开发《乒乓》游戏
在本章中,会涉及一些理论知识,但这些理论能为我们提供开始使用面向对象编程(Object-Oriented Programming,OOP)所需的知识。面向对象编程有助于我们将代码组织成人类可识别的结构,并处理代码的复杂性。我们不会浪费时间,而是会立即将这些理论充分运用起来,用于编写下一个项目——《乒乓》游戏。我们将深入了解如何创建可作为对象使用的新C++类型,通过编写我们的第一个类来实现这一点。首先,我们会研究一个简化的《乒乓》游戏场景,以便学习一些类的基础知识,然后我们将重新开始,运用所学原理真正编写一个《乒乓》游戏。
在本章中,我们将涵盖以下主题:
- 面向对象编程:讨论封装、多态和继承的要点,以及我们究竟为何要使用面向对象编程。
- 《乒乓》球拍理论:通过假设的
Bat
类学习面向对象编程和类的知识。 - 创建《乒乓》项目。
- 编写
Bat
类:开始着手开发《乒乓》游戏,包括编写一个真正的Bat
类来代表玩家的球拍。 - 使用
Bat
类并编写主函数。
本书的四个项目可在以下链接获取:https://github.com/PacktPublishing/Beginning-C-Game-Programming-Third-Edition/tree/main/Pong (opens new window)。
# 面向对象编程
面向对象编程是一种编程范式,几乎可被视为标准的编程方式。确实存在非面向对象编程的方式,甚至还有一些非面向对象的游戏编程语言/库。然而,由于我们是从头开始学习,没有理由采用其他方式。
面向对象编程能实现以下几点:
- 使我们的代码更易于管理、修改或更新。
- 让我们编写代码的速度更快、可靠性更高。
- 能够轻松使用他人的代码(就像我们使用SFML库一样)。
我们已经见识到了第三点好处的实际作用。现在,让我们详细讨论一下面向对象编程究竟是什么。
面向对象编程是一种编程方法,它将我们的需求分解成比整体更易于管理的模块。每个模块都是自包含的,但又能与程序的其他部分协同工作。此外,它还能被其他程序使用。这些模块就是我们所说的对象。
当我们规划和编写一个对象时,会使用类来实现。
类可以被看作是对象的蓝图。
我们通过类来创建对象,这被称为类的实例。想象一下房屋蓝图,你不能住在蓝图里,但可以依据它建造房屋,这个房屋就是房屋蓝图的一个实例。通常,在为游戏设计类时,我们会让它们代表现实世界中的事物。在下一个项目中,我们将为玩家控制的球拍和玩家用球拍在屏幕上弹来弹去的球编写类。然而,面向对象编程的意义不止于此。
面向对象编程是一种做事方式,是一种定义最佳实践的方法。
面向对象编程的三个核心原则是封装(encapsulation)、多态(polymorphism)和继承(inheritance)。这听起来可能有些复杂,但一步一步来,其实相当简单。
# 封装
封装意味着保护代码的内部实现,使其免受使用它的代码的干扰。你可以通过只允许访问选定的变量和函数来实现这一点。这意味着只要外部访问的部分保持不变,你的代码就可以随时更新、扩展或改进,而不会影响使用它的程序。C++通过使用public
和private
关键字来实现封装,我们很快就会看到它们的实际应用。
例如,在恰当封装的情况下,即使SFML团队需要更新其Sprite
类的工作方式也没关系。只要函数签名保持不变,他们就无需担心内部的具体实现。我们在更新前编写的代码在更新后仍然可以正常工作。
面向对象编程并没有消除编写代码前进行仔细规划的必要性;相反,封装提供了一种组织代码的方式,有可能使我们的规划更加成功。如果我们是团队合作,这一点就更为重要。
# 多态
多态使我们编写的代码对所操作的对象类型依赖更少,这会使我们的代码更清晰、更高效。多态意味着多种形式。如果我们编写的对象可以是多种类型,那么我们就能利用这一点。此刻,多态听起来可能有点像魔法。多态的经典例子是动物王国中不同动物之间的关系。假设我们正在制作一款动物园游戏,为大象创建了大量的数组、函数和变量。很快我们就会发现,还需要为狮子、老虎等编写数组、函数和变量。要是我们能编写一组适用于所有动物的数组、函数和变量呢?利用多态,我们可以为通用的动物对象编写代码,并将其用于所有基于动物园的类中。我们将在最后一个项目中使用多态,届时一切就会更清楚了。
# 继承
正如其名,继承意味着我们可以利用他人编写的类的所有特性和优势,包括封装和多态,同时根据我们的具体情况对其代码进行进一步优化。如果我们正在编写一款乡村模拟器,或许可以利用动物园游戏中基于动物的代码。我们将在首次使用多态的同时首次使用继承。
# 为什么使用面向对象编程?
编写正确的面向对象编程代码,能让你在添加新功能时无需担心它们与现有功能的交互问题。当你确实需要修改一个类时,类的自包含(封装)特性意味着对程序其他部分的影响较小,甚至可能没有影响。
你可以使用他人的代码(比如SFML的类),而无需了解甚至无需关心其内部工作原理。
面向对象编程(以及由此延伸的SFML库)能让你编写包含复杂概念的游戏,比如多摄像头、多人游戏、OpenGL、定向音效等等,而这一切都能轻松实现。
通过使用继承,你可以创建一个类的多个相似但又有所不同的版本,而无需从头开始编写类。
由于多态性,你可以在新对象上仍然使用为原始对象类型设计的函数。
这些都很有意义,意味着我们有更多时间专注于自己程序的独特之处。正如我们所知,C++从一开始设计就考虑到了面向对象编程。
除了成功的决心之外,在面向对象编程和制作游戏(或任何其他类型的应用程序)方面取得成功的关键在于规划和设计。写出优秀代码的关键并非仅仅是“了解”所有C++、SFML和面向对象编程的知识,而是运用这些知识编写结构良好/设计合理的代码。本书中的代码按照适合在游戏场景中学习各种C++主题的顺序和方式呈现。组织代码的艺术和科学被称为设计模式(design patterns)。随着代码变得越来越长、越来越复杂,有效运用设计模式将变得更加重要。好消息是,我们无需自己发明这些设计模式。随着项目变得更加复杂,我们需要学习它们。随着项目越来越复杂,我们的设计模式也会不断发展。
在这个项目中,我们将学习并使用基本的类和封装。随着本书内容的推进,我们将尝试更多,也会使用继承、多态以及其他与面向对象编程相关的C++特性。
# 类究竟是什么?
类是一堆代码,其中可以包含函数、变量、循环以及我们已经学过的所有其他C++语法。每个新类都将在与其同名的.h
代码文件中声明,而其函数将在各自的.cpp
文件中定义。我们在.cpp
文件中定义函数时所使用的语法,会表明它们是在.h
文件中声明的类的一部分。
当我们在类中使用函数时,这种函数是一种特殊类型的函数,通常被称为方法(method)。为简单起见,我将继续把所有函数都称为函数,但如果你愿意,也可以把类中的函数称为方法。
一旦我们编写好了一个类,就可以用它创建任意数量的对象。记住,类是蓝图,我们根据蓝图创建对象。房屋不是蓝图,就像对象不是类一样,对象是由类创建出来的。
你可以把对象看作是变量,把类看作是类型。
当然,说了这么多关于面向对象编程和类的内容,我们实际上还没有看到任何代码。现在就让我们来解决这个问题。
# 《乒乓》球拍理论
接下来是一个关于如何通过编写Bat
类,利用面向对象编程开始《乒乓》项目的假设性讨论。目前不要在项目中添加任何代码,因为下面的内容为了便于解释理论做了过度简化。在本章后面的部分,我们将真正编写代码。当我们实际编写这个类时,代码会有所不同,但在这里学到的原理将为我们的成功做好准备。
我们将从探索作为类一部分的变量和函数(或方法)开始。
# 声明类、变量和函数
球拍是现实世界中的事物,它有属性、行为和特定外观。它承担着一个作用:当与球碰撞时,将球反弹回去。因此,能反弹球的球拍是作为类的绝佳候选对象。
如果你不知道《乒乓》游戏是什么,可以查看这个链接:https://en.wikipedia.org/wiki/Pong (opens new window)。
让我们来看一个假设的Bat.h
文件:
class Bat
{
private:
// Length of the pong bat
int m_Length = 100;
// Height of the pong bat
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on y axis
int m_YPosition;
public:
void moveRight();
void moveLeft();
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
乍一看,这段代码可能有点复杂,但解释之后就会发现,其中很少有我们没学过的概念。
首先要注意的是,声明一个新类时要使用class
关键字,后面跟着类名,整个声明部分用花括号括起来,最后以分号结尾:
class Bat
{
…
…
};
2
3
4
5
现在,让我们看看变量声明及其名称:
// Length of the pong bat
int m_Length = 100;
// Height of the pong bat
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on y axis
int m_YPosition;
2
3
4
5
6
7
8
所有变量名都带有m_
前缀。这个m_
前缀不是必需的,但它是一个很好的约定。虽然C++语言本身并没有强制使用这种命名约定,但在C++社区中,它被广泛用于类的数据成员。作为类的一部分声明的变量称为成员变量。使用m_
前缀能清楚地表明我们正在处理的是成员变量。当我们为类编写函数时,还会看到局部(非成员)变量和参数。这时,m_
约定就会体现出它的用处。遵循这个约定,能让这些变量作为类的一部分这一事实一目了然,从而将它们与局部变量或参数区分开来。不同的项目、公司和系统使用不同的变量命名约定,但为成员变量使用某种前缀是行业最佳实践。
例如,假设在同一作用域中有不带m_
前缀的非成员变量,如下所示:
int Length = 50; // Non-member variable
没有m_
前缀的话,就不太清楚Length
是否是类的成员。始终如一地使用m_
前缀有助于避免这种混淆,使代码更易于维护和自我解释。
还要注意,所有变量都在代码中以private
关键字开头的部分。浏览一下前面的代码就会发现,类代码主体被分成了两部分:
private:
// Anything the instances
// cannot directly interact with
public:
// Variables and functions here can be
// accessed by a user of the instance
2
3
4
5
6
public
和private
关键字控制着类的封装。任何被声明为private
的内容都不能被类的实例/对象的使用者直接访问。如果你在设计一个供他人使用的类,肯定不希望他们随意修改其中的内容。注意,成员变量不一定必须是private
的,但尽可能将它们设为private
能实现良好的封装。
这意味着我们的四个成员变量(m_Length
、m_Height
、m_XPosition
和m_YPosition
)不能被游戏引擎从主函数中直接访问,它们只能被类的代码间接访问,这就是封装的实际应用。对于m_Length
和m_Height
变量来说,只要我们不需要改变球拍的大小,这很容易理解接受。然而,m_XPosition
和m_YPosition
成员变量需要被访问,否则我们怎么移动球拍呢?
这个问题可以通过代码的public
部分来解决,如下所示:
void moveRight();
void moveLeft();
2
这个类提供了两个public
函数,可以在Bat
类型的对象上使用。当我们查看这些函数的定义时,就能看到它们是如何具体操作私有变量的。
总之,我们有一堆无法从主函数访问的(私有)变量,这是好事,因为封装使我们的代码更不容易出错,更易于维护。然后,我们通过提供两个公共函数,间接访问m_XPosition
和m_YPosition
变量,从而解决了移动球拍的问题。
主函数中的代码可以使用类的实例来调用公共函数,而函数内部的代码则精确控制变量的使用方式。
我们可以用下图来可视化这个类的信息:
图6.1:Bat
类的信息
在上图中,顶部部分代表类名“Bat”,中间部分包含类的成员变量,每个变量前面都有一个减号(-),表示它们是私有的。底部部分包含类的成员函数,每个函数前面都有一个加号(+),表示它们是公共的。这种约定有助于快速传达类成员的访问级别,直观地展示类中的封装特性。
这种表示类的格式是统一建模语言(Unified Modeling Language,UML)的一部分。UML本身是一个庞大的主题,超出了本书的范围,但了解这些用于表示C++代码设计决策的约定是一个良好的开端。你可以在官方网站https://www.uml.org/上了解更多关于UML的信息。
让我们来看看函数定义。
# 类函数定义
在本书中,我们编写的函数定义都将与类和函数声明放在不同的文件中。我们将使用与类同名且扩展名为.cpp的文件。请记住,这样做是为了使代码更有条理,同时将声明与定义分开。如果你想一眼了解一个类的功能(.h文件中的声明),而不是深入研究细节(.cpp文件中的定义),这种做法会很有用。例如,以下代码将放在一个名为“Bat.cpp”的文件中。看下面的代码,其中只有一个新的概念:
#include "Bat.h"
void Bat::moveRight() {
// Move the bat a pixel to the right
m_XPosition ++; }
void Bat::moveLeft() {
// Move the bat a pixel to the left
m_XPosition --; }
2
3
4
5
6
7
8
9
10
11
首先要注意的是,我们必须使用#include
指令,从“Bat.h”文件中包含类和函数声明。这使得.cpp文件中的代码能够识别.h文件中的声明。
这里我们看到的新概念是作用域解析运算符::
的使用。由于这些函数属于一个类,我们必须以与标准非成员函数略有不同的方式编写函数签名部分,即在函数名前加上类名和::
,例如void Bat::moveLeft()
和void Bat::moveRight()
。
在这个例子中,每个函数名前的Bat::
表明moveRight
和moveLeft
是Bat
类的成员函数。它明确地将这些函数与类声明联系起来,确保编译器在编译过程中正确地将它们关联起来。
作用域解析运算符的这种用法还提高了代码的清晰度,避免了命名冲突,特别是在处理多个类或名称相似的函数时。
实际上,我们之前已经简要地见过作用域解析运算符(即每当我们声明一个类的对象时),而且我们之前没有使用过
using namespace...
。
注意,我们也可以将函数定义和声明放在一个文件中,如下所示:
class Bat
{
private:
// Length of the pong bat
int m_Length = 100;
// Height of the pong bat
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on y axis
int m_YPosition;
public:
void Bat::moveRight() {
// Move the bat a pixel to the right
m_XPosition ++; }
void Bat::moveLeft() {
// Move the bat a pixel to the left
m_XPosition --;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
然而,当我们的类变得更长时(就像我们在第一个僵尸竞技场游戏中那样),将函数定义分离到它们自己的文件中会更有条理。此外,头文件被视为公共文件,如果其他人要使用我们编写的代码,头文件通常用于文档目的。
但是,一旦我们编写好了一个类,要如何使用它呢?
# 使用类的实例
尽管我们已经看到了所有与类相关的代码,但实际上我们还没有使用过类。我们已经知道如何使用类了,因为我们已经多次使用过SFML库中的类。
首先,我们要创建一个Bat
类的实例,如下所示:
Bat bat;
bat
对象拥有我们在“Bat.h”中声明的所有变量。只是我们不能直接访问它们。不过,我们可以使用其公共函数来移动球拍,如下所示:
bat.moveLeft();
或者我们也可以这样移动它:
bat.moveRight();
记住,bat
是一个Bat
类的对象,因此,它拥有所有的成员变量和所有可用的函数。
之后,我们可能会决定让我们的乒乓球游戏支持多人模式。在main
函数中,我们可以更改代码,使游戏中有两个球拍,可能如下所示:
Bat bat;
Bat bat2;
2
务必认识到,这些Bat
类的每个实例都是一个独立的对象,有其自己的一组变量,就像我们的玩家精灵、树、蜜蜂和斧头精灵都是SFML库中Sprite
类的独立实例一样。初始化类的实例还有更多方法,接下来在实际编写Bat
类代码时,我们会看到一个示例。
# 创建乒乓球游戏项目
由于设置项目是一个繁琐的过程,我们将像创建“Timber!!!”项目那样,一步一步地进行。我不会展示与“Timber!!!”项目相同的截图,但过程是一样的。如果你想回顾各种项目属性的位置,可以翻回到第1章“欢迎阅读《C++游戏编程入门(第三版)》”。
- 启动Visual Studio,点击“创建新项目”按钮。或者,如果你仍然打开着“Timber!!!”项目,可以选择“文件”|“新建项目”。
- 在出现的窗口中,选择“控制台应用”,然后点击“下一步”按钮。接着你会看到“配置新项目”窗口。
- 在“配置新项目”窗口中,在“项目名称”字段中输入“Pong”。注意,这会使Visual Studio自动将“解决方案名称”字段配置为相同的名称。
- 在“位置”字段中,浏览到我们在第1章中创建的“VS Projects”文件夹。这将是我们所有项目文件存放的位置。
- 勾选“将解决方案和项目放在同一目录中”选项。
- 完成这些步骤后,点击“创建”。Visual Studio将生成项目,包括在“main.cpp”文件中的一些C++代码。
- 现在,我们将配置项目以使用我们放在“SFML”文件夹中的SFML文件。从主菜单中选择“项目”|“Pong属性…”。此时,你应该会打开“Pong属性页”窗口。
- 在“Pong属性页”窗口中,从“配置”下拉菜单中选择“所有配置”,并确保“平台”下拉菜单设置为“Win32”。
- 现在,从左侧菜单中选择“C/C++”,然后选择“常规”。
- 之后,找到“附加包含目录”编辑框,输入你的“SFML”文件夹所在的驱动器盘符,后面跟着“\SFML\include”。如果你将“SFML”文件夹放在D盘,要输入的完整路径是“D:\SFML\include”。如果你的“SFML”文件夹安装在不同的驱动器上,请更改路径。
- 点击“应用”保存到目前为止的配置。
- 现在,仍在同一窗口中,执行以下步骤。从左侧菜单中选择“链接器”,然后选择“常规”。
- 现在,找到“附加库目录”编辑框,输入你的“SFML”文件夹所在的驱动器盘符,后面跟着“\SFML\lib”。所以,如果你将“SFML”文件夹放在D盘,要输入的完整路径是“D:\SFML\lib”。如果你的“SFML”文件在不同的驱动器上,请更改路径。
- 点击“应用”保存到目前为止的配置。
- 接下来,仍在同一窗口中,执行以下步骤。将“配置”下拉菜单切换到“调试”,因为我们将在调试模式下运行和测试“Pong”游戏。
- 选择“链接器”,然后选择“输入”。
- 找到“附加依赖项”编辑框,在最左侧点击它。现在,复制并粘贴/输入以下内容:
sfml-graphics-d.lib;sfml-window-d.lib;sfml-system-d.lib;sfml-network-d.lib;sfml-audio-d.lib;
。特别要注意将光标精确地放在编辑框当前内容的开头,这样就不会覆盖已有的任何文本。 - 点击“确定”。
- 点击“应用”,然后点击“确定”。
- 在Visual Studio主窗口中,在“调试”下拉菜单旁边,确保选择的是“x86”,而不是“x64”。
- 现在,我们需要将SFML的.dll文件复制到主项目目录中。我的主项目目录是“D:\VS Projects\Pong”。它是在前面的步骤中由Visual Studio创建的。如果你将“VS Projects”文件夹放在其他位置,那么就在那里执行此步骤。我们需要复制到项目文件夹中的文件位于“SFML\bin”文件夹中。为这两个位置分别打开一个窗口,选中“SFML\bin”文件夹中的所有文件。
- 现在,将选中的文件复制并粘贴到项目文件夹中,即“D:\VS Projects\Pong”。
现在我们已经配置好了项目属性,可以开始了。
在这个游戏中,我们将显示一些用于抬头显示(Heads Up Display,HUD)的文本,展示玩家的得分和剩余生命数。为此,我们需要一种字体。
从http://www.dafont.com/theme.php?cat=302下载这款个人免费使用的字体并解压。或者你也可以随意使用自己选择的字体。只是在加载字体时,我们需要对代码做一些小的修改。
在“VS Projects\Pong”文件夹中创建一个名为“fonts”的新文件夹,并将“DS-DIGIT.ttf”文件添加到“VS Projects\Pong\fonts”文件夹中。
现在我们准备编写第一个C++类。
# 编写Bat类
简单的乒乓球拍示例是介绍类基础知识的好方法。类可以像前面的Bat
类一样简单短小,但也可以更长、更复杂,并且包含由其他类创建的其他对象。此外,关于类还有一些新的概念我们需要学习。我们还将看到并编写一个构造函数(constructor function),为使用实例做好准备。
在制作游戏时,假设的Bat
类缺少一些关键内容。虽然这些私有成员变量和公共函数可能没问题,但我们要如何绘制图形呢?我们的乒乓球拍需要一个精灵(sprite),在一些游戏中,我们的类还需要一个纹理(texture)。此外,我们需要一种方法来控制所有游戏对象的动画速率,就像我们在上一个项目中对蜜蜂和云朵所做的那样。我们可以像在“main.cpp”文件中包含其他对象一样,在类中包含其他对象。让我们实际编写Bat
类的代码,看看如何解决所有这些问题。
# 编写Bat.h
首先,我们来编写头文件。在“解决方案资源管理器”窗口中右键单击“头文件”,选择“添加”|“新建项”。接下来,选择“头文件(.h)”选项,并将新文件命名为“Bat.h”。点击“添加”按钮。现在我们准备编写文件内容。
在“Bat.h”中添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Bat
{
private:
Vector2f m_Position;
// A RectangleShape object
RectangleShape m_Shape;
float m_Speed = 1000.0f;
bool m_MovingRight = false;
bool m_MovingLeft = false;
public:
Bat(float startX, float startY);
FloatRect getPosition();
RectangleShape getShape();
void moveLeft();
void moveRight();
void stopLeft();
void stopRight();
void update(Time dt);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
首先,注意文件顶部的#pragma once
声明。这可以防止文件被编译器多次处理。随着我们的游戏变得更加复杂,可能会有几十个类,这将加快编译时间。
注意成员变量的名称以及函数的参数和返回类型。我们有一个名为m_Position
的Vector2f
,它将保存玩家球拍的水平和垂直位置。我们还有一个SFML的RectangleShape
,它将在视觉上表示出现在屏幕上的球拍。RectangleShape
和Sprite
类都是SFML图形模块的一部分,用于在屏幕上渲染对象。RectangleShape
主要用于渲染简单的矩形或正方形,而Sprite
用于渲染带纹理的图像。由于乒乓球拍是一个简单的白色矩形,所以我选择了RectangleShape
。
有两个布尔成员变量将跟踪球拍当前是否在移动,如果在移动,则跟踪移动的方向。我们还有一个名为m_Speed
的浮点型变量,它表示玩家决定向左或向右移动球拍时,球拍每秒移动的像素数。
代码的下一部分需要一些解释,因为我们有一个名为Bat
的函数,它与类名完全相同。这被称为构造函数。
# 构造函数
回顾一下,当编写一个类时,编译器会创建一个特殊的函数。我们在代码中看不到这个函数,但它是存在的。它被称为构造函数。构造函数是由编译器在幕后提供的。如果我们使用假设的Bat
类示例,调用的就是这个函数。
当我们需要编写一些代码来为使用对象做准备时,构造函数通常是一个很好的编写位置。当我们希望构造函数做一些除了简单创建实例之外的事情时,我们必须替换编译器提供的默认(不可见)构造函数。这就是我们对Bat
构造函数要做的事情。
注意,Bat
构造函数接受两个浮点型参数。为了方便,这里再次给出声明:
Bat(float startX, float startY);
这非常适合在我们首次创建Bat
对象时初始化其在屏幕上的位置。还要注意,构造函数没有返回类型,甚至没有void
。
我们很快就会使用构造函数Bat
和初始化列表将这个游戏对象设置到起始位置。记住,在声明Bat
类型的对象时会调用这个函数。
# 继续解释Bat.h
接下来是getPosition
函数,它返回一个FloatRect
,表示定义一个矩形的四个点。然后,我们有getShape
函数,它返回一个RectangleShape
。这个函数将用于在主游戏循环中返回m_Shape
,以便进行绘制。
我们还有moveLeft
、moveRight
、stopLeft
和stopRight
函数,用于控制球拍是否移动、何时移动以及向哪个方向移动。
最后,我们有update
函数,它接受一个Time
参数。这个函数将用于计算每一帧中球拍的移动方式。由于球拍和球的移动方式不同,将移动代码封装在类中是有意义的。我们将在主函数中,在游戏的每一帧调用一次update
函数。
你可能已经猜到,
Ball
类也会有一个update
函数。
现在,我们可以编写“Bat.cpp”,它将实现所有的定义并使用成员变量。
# 编写Bat.cpp
让我们创建文件,然后开始讨论代码。在“解决方案资源管理器”窗口中右键单击“源文件”文件夹。现在,选择“C++文件(.cpp)”,在“名称:”字段中输入“Bat.cpp”。点击“添加”按钮,我们的新文件就会为我们创建好。
为了便于讨论,我们将这个文件的代码分为两部分。首先,编写Bat
构造函数,如下所示:
#include "Bat.h"
// This is the constructor and it is called
// when we create an object
Bat::Bat(float startX, float startY) : m_Position(startX, startY) {
m_Shape.setSize(sf::Vector2f(50, 5));
m_Shape.setPosition(m_Position);
}
2
3
4
5
6
7
8
在上面的代码中,我们可以看到包含了“bat.h”文件。这使得我们可以使用之前在“bat.h”中声明的所有函数和变量。
我们实现构造函数是因为我们需要做一些工作来设置实例,而编译器提供的默认不可见的空构造函数是不够的。记住,构造函数是在我们初始化Bat
类实例时运行的代码。
注意,我们使用Bat::Bat
语法作为函数名,以明确我们使用的是Bat
类中的Bat
函数。
这个构造函数接收两个浮点型值startX
和startY
。接下来,我们看到了之前没见过的内容。在函数参数之后,我们立即看到这段代码:
: m_Position(startX, startY)
这被称为初始化列表。使用成员初始化列表通常被认为比在构造函数体中初始化变量更高效,并且对某些类型的变量可能更有益。这里发生的事情是,我们使用一种更简洁明了的语法,用作为参数传递给函数的值来初始化Vector2f
类型的mPosition
。
名为m_Position
的Vector2f
现在保存着传入的值,由于m_Position
是一个成员变量,这些值在整个类中都可以访问。不过要注意,m_Position
被声明为私有成员,在我们的主函数文件中无法访问——至少不能直接访问。我们很快就会看到如何解决这个问题。
最后,在构造函数的主体中,我们通过设置大小和位置来初始化名为m_Shape
的RectangleShape
。这与我们在“乒乓球拍的理论”一节中编写假设的Bat
类的方式不同。SFML的Sprite
类有方便的大小和位置变量,我们可以使用setSize
和setPosition
函数来访问,所以我们不再需要假设的m_Length
和m_Height
了。
此外,注意我们需要改变初始化Bat
类的方式(与假设的Bat
类相比),以适应我们自定义的构造函数,我们很快就会看到这段代码。
我们需要实现Bat
类剩下的五个函数。在刚刚讨论的构造函数之后,在Bat.cpp
中添加以下代码:
FloatRect Bat::getPosition() {
return m_Shape.getGlobalBounds();
}
RectangleShape Bat::getShape() {
return m_Shape;
}
void Bat::moveLeft() {
m_MovingLeft = true;
}
void Bat::moveRight() {
m_MovingRight = true;
}
void Bat::stopLeft() {
m_MovingLeft = false;
}
void Bat::stopRight() {
m_MovingRight = false;
}
void Bat::update(Time dt) {
if (m_MovingLeft) {
m_Position.x -= m_Speed * dt.asSeconds();
}
if (m_MovingRight) {
m_Position.x += m_Speed * dt.asSeconds();
}
m_Shape.setPosition(m_Position);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
让我们逐行分析一下刚刚添加的代码。
首先是getPosition
函数。这个函数所做的就是返回一个FloatRect
给调用它的代码。m_Shape.getGlobalBounds
这行代码返回一个FloatRect
,它由RectangleShape
(即m_Shape
)四个角的坐标初始化。当我们判断球是否击中球拍时,会在主函数中调用这个函数。
接下来是getShape
函数。这个函数只是将m_Shape
的一个副本传递给调用代码。这是必要的,这样我们才能在主函数中绘制球拍。当我们编写一个公共函数,其唯一目的是从类中返回私有数据时,我们称它为访问器函数(getter function)。
现在,我们来看看moveLeft
、moveRight
、stopLeft
和stopRight
函数。它们的作用是适当地设置m_MovingLeft
和m_MovingRight
这两个布尔变量,以便跟踪玩家当前的移动意图。不过要注意,它们不会对决定位置的RectangleShape
实例或FloatRect
实例做任何操作。这正是我们所需要的。
Bat
类的最后一个函数是update
。我们将在游戏的每一帧中调用这个函数。随着我们的游戏项目变得更加复杂,update
函数的复杂度也会增加。目前,我们只需要根据玩家是向左还是向右移动来调整m_Position
。注意,这里用于调整的公式与我们在《Timber!!!》项目中更新蜜蜂和云朵的公式是一样的。代码将速度乘以时间增量,然后从位置中加上或减去这个值。这使得球拍根据帧更新所花费的时间来移动。接下来,代码用m_Position
中保存的最新值设置m_Shape
的位置。
在Bat
类中而不是在主函数中拥有一个update
函数,这就是封装。与我们在《Timber!!!》项目中在主函数中更新所有游戏对象的位置不同,每个对象将负责自我更新。
# 使用Bat类并编写主函数
切换到创建项目时自动生成的main.cpp
文件。如果你有一个自动创建的名为Pong.cpp
的文件,可以保持不变,或者在解决方案资源管理器中右键单击它,将其重命名为main.cpp
。唯一重要的是它包含main
函数,这样程序执行就会从这里开始。删除其所有自动生成的代码,并添加以下代码。
按如下方式编写Pong.cpp
文件:
#include "Bat.h"
#include <sstream>
#include <cstdlib>
#include <SFML/Graphics.hpp>
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game
RenderWindow window(vm, "Pong", Style::Fullscreen);
int score = 0;
int lives = 3;
// Create a bat at the bottom center of the screen
Bat bat(1920 / 2, 1080 - 20);
// We will add a ball in the next chapter
// Create a Text object called HUD
Text hud;
// A cool retro-style font
Font font;
font.loadFromFile("fonts/DS-DIGIT.ttf");
// Set the font to our retro-style
hud.setFont(font);
// Make it nice and big
hud.setCharacterSize(75);
// Choose a color
hud.setFillColor(Color::White);
hud.setPosition(20, 20);
// Here is our clock for timing everything
Clock clock;
while (window.isOpen()) {
/*
Handle the player input
****************************
****************************
****************************
*/
/*
Update the bat, the ball and the HUD
*****************************
*****************************
*****************************
*/
/*
Draw the bat, the ball and the HUD
*****************************
*****************************
*****************************
*/
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
在上述代码中,主游戏while
循环的结构与我们在《Timber!!!》项目中使用的类似。不过,第一个不同之处在于我们创建Bat
类实例的时候:
// Create a bat
Bat bat(1920 / 2, 1080 - 20);
2
上述代码调用构造函数创建一个Bat
类的新实例。代码传入所需的参数,让Bat
类将其位置初始化为屏幕底部中央。这是我们的球拍开始的理想位置。
还要注意,我使用了注释来指明其余代码最终的放置位置。和在《Timber!!!》项目中一样,所有代码都在游戏循环内。为了提醒你,这里再次列出其余代码的放置位置:
/*
Handle the player input
…
/*
Update the bat, the ball and the HUD
…
/*
Draw the bat, the ball and the HUD
…
2
3
4
5
6
7
8
9
10
接下来,在“处理玩家输入”部分添加如下代码:
Event event;
while (window.pollEvent(event)) {
if (event.type == Event::Closed)
// Quit the game when the window is closed
window.close();
}
// Handle the player quitting
if (Keyboard::isKeyPressed(Keyboard::Escape)) {
window.close();
}
// Handle the pressing and releasing of the arrow keys
if (Keyboard::isKeyPressed(Keyboard::Left)) {
bat.moveLeft();
}
else
{
bat.stopLeft();
}
if (Keyboard::isKeyPressed(Keyboard::Right)) {
bat.moveRight();
}
else
{
bat.stopRight();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
上述代码处理玩家通过按下Escape
键退出游戏的情况,和在《Timber!!!》项目中完全一样。接下来,有两个if - else
结构用于处理玩家移动球拍的操作。让我们分析一下这两个结构中的第一个:
if (Keyboard::isKeyPressed(Keyboard::Left)) {
bat.moveLeft();
}
else
{
bat.stopLeft();
}
2
3
4
5
6
7
上述代码会检测玩家是否按下了键盘上的左箭头光标键。如果按下了,就调用Bat
实例的moveLeft
函数。调用这个函数时,私有布尔变量m_MovingLeft
会被设置为true
。然而,如果左箭头键没有被按下,就调用stopLeft
函数,m_MovingLeft
会被设置为false
。
接下来的if - else
代码块会以完全相同的方式处理玩家按下(或未按下)右箭头键的情况。
接下来,在“更新球拍、球和HUD”部分添加如下代码:
// Update the delta time
Time dt = clock.restart();
bat.update(dt);
// Update the HUD text
std::stringstream ss;
ss << "Score:" << score << " Lives:" << lives;
hud.setString(ss.str());
2
3
4
5
6
7
8
在上述代码中,我们使用了与《Timber!!!》项目完全相同的计时技术,只是这次我们调用Bat
实例的update
函数并传入时间增量。记住,当Bat
类接收到时间增量时,它会根据玩家之前输入的移动指令以及球拍期望的速度,使用这个值来移动球拍。
接下来,在“绘制球拍、球和HUD”部分添加如下代码:
window.clear();
window.draw(hud);
window.draw(bat.getShape());
window.display();
2
3
4
在上述代码中,我们清空屏幕,绘制HUD的文本,并使用bat.getShape
函数从Bat
实例中获取RectangleShape
实例并绘制到屏幕上。最后,和之前的项目一样,我们调用window.display
,将球拍绘制在其当前位置。
此时,你可以运行游戏,你会看到HUD和一个球拍。可以使用箭头/光标键流畅地左右移动球拍:
图6.2:我们的乒乓球游戏,有HUD和球拍
恭喜!这是第一个编写并部署好的类。
# 总结
在本章中,我们学习了面向对象编程(OOP,Object - Oriented Programming)的基础知识,比如如何编写和使用一个类,包括利用封装来控制类外部的代码访问成员变量的程度和方式,这与SFML类类似,SFML类允许我们创建和使用Sprite
和Text
实例,但只能按照它们设计的方式使用。
如果围绕面向对象编程和类的一些细节你还不完全清楚,也不用太担心。我这么说是因为在本书的其余部分,我们都会编写类,使用得越多,这些内容就会越清晰。
此外,我们为乒乓球游戏制作出了可用的球拍和HUD。
在下一章中,我们将编写Ball
类,让球在屏幕上弹来弹去。然后我们就能添加碰撞检测功能,完成游戏。
# 常见问题解答
**问:**我学过其他语言,感觉在C++中面向对象编程要简单得多。这个评价正确吗? **答:**这只是面向对象编程及其基本原理的入门介绍。它的内容远不止这些。在本书中,我们将学习更多的面向对象编程概念和细节。
**问:**为什么在类声明之外定义函数时要使用::
运算符?
答:::
运算符是C++中的作用域解析运算符,用于在类声明之外定义函数。当函数在类内部声明时,它们会隐式地与该类相关联。然而,在类外部提供实际实现时,我们在函数名前使用ClassName::
来指定该函数所属的类。这确保了正确的关联,避免了命名冲突,提高了代码的清晰度和可维护性。
**问:**成员变量应该在构造函数的成员初始化列表中初始化,还是在构造函数体内初始化? **答:**只要有可能,建议在构造函数的成员初始化列表中初始化成员变量。这种方法效率更高,尤其对于复杂的类来说,并且它确保了成员变量在构造函数体执行之前就被初始化。不过,当在构造函数体内进行简单初始化适合你的使用场景时,也是完全可以的,但我们应该始终优先选择成员初始化列表。