CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • C++游戏编程入门(零基础学C++) 说明
  • 第1章 欢迎阅读
  • 第2章 变量、运算符与决策:精灵动画
  • 第3章 C++字符串、SFML时间:玩家输入与平视显示器
  • 第4章 循环、数组、switch语句、枚举和函数:实现游戏机制
  • 第5章 碰撞、音效与结束条件:让游戏可玩
  • 第6章 面向对象编程——开始开发《乒乓》游戏
  • 第7章 AABB碰撞检测与物理效果——完成乒乓球游戏
  • 第8章 SFML视图——开启丧尸射击游戏
  • 第9章 C++引用、精灵表和顶点数组
  • 第10章 指针、标准模板库和纹理管理
  • 第11章 编写TextureHolder类并创建一群僵尸
    • 实现TextureHolder类
      • 编写TextureHolder头文件
      • 编写TextureHolder函数定义
      • TextureHolder类的作用
    • 创建一群僵尸
      • 编写Zombie.h文件
      • 编写Zombie.cpp文件
      • 使用Zombie类创建一群僵尸
      • 让这群僵尸“活起来”
    • 所有纹理都使用TextureHolder类
      • 修改背景加载纹理的方式
      • 修改Player类加载纹理的方式
    • 总结
    • 常见问题解答
  • 第12章 碰撞检测、拾取物与子弹
  • 第13章 视图分层与平视显示器(HUD)的实现
  • 第14章 音效、文件输入输出与完成游戏制作
  • 第15章 快跑!
  • 第16章 声音、游戏逻辑、对象间通信与玩家
  • 第17章 图形、相机与动作
  • 第18章 为平台、玩家动画和控制功能编写代码
  • 第19章 构建菜单与实现下雨效果
  • 第20章 火球与声音空间化
  • 第21章 视差背景与着色器
目录

第11章 编写TextureHolder类并创建一群僵尸

# 第11章 编写TextureHolder类并创建一群僵尸

现在我们已经了解了标准模板库(STL,Standard Template Library )的基础知识,就能够运用这些新知识来管理游戏中的所有纹理。因为,如果游戏中有1000只僵尸,我们肯定不想为每一只僵尸都向图形处理单元(GPU)加载一份僵尸图像副本。

我们还会更深入地探究面向对象编程(OOP,Object-Oriented Programming ),并使用静态函数。静态函数是类的一种函数,无需类的实例即可调用。同时,我们将了解如何设计一个类,以确保它仅存在一个实例。当我们需要保证代码的不同部分使用相同的数据时,这种设计非常理想。

在本章中,我们将涵盖以下主题:

  • 实现TextureHolder类
  • 创建一群僵尸
  • 在所有纹理中使用TextureHolder类

# 实现TextureHolder类

数以千计的僵尸带来了新的挑战。加载、存储和处理三种不同僵尸纹理的数千份副本,不仅会占用大量内存,还会消耗大量处理能力。我们将创建一种新型类来解决这个问题,使每种纹理仅存储一份。

我们还将以特定方式编写这个类,确保它只能有一个实例。这种类型的类被称为单例(singleton)。

单例是一种设计模式。设计模式是一种经过验证有效的代码结构组织方式。

此外,我们编写的这个类,在游戏代码的任何地方都可以直接通过类名使用,而无需访问类的实例。这是一种特殊类型的类,称为静态类。

# 编写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
    map<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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

在上述代码中,请注意我们包含了来自标准模板库(STL)的map头文件。我们声明了一个map实例m_Textures,它存储string类型和SFML的Texture类型的键值对。

在前面的代码中,有这样一行:

static TextureHolder* m_s_Instance;
1

这行代码很有意思。我们声明了一个指向TextureHolder类型对象的静态指针m_s_Instance。这意味着TextureHolder类有一个与自身类型相同的对象。不仅如此,因为它是静态的,所以可以通过类本身使用,而无需类的实例。在编写相关的.cpp文件时,我们将了解如何使用它。

在类的公共部分,我们有构造函数TextureHolder的原型。该构造函数不带参数,和往常一样没有返回类型,这和默认构造函数相同。我们将用一个定义覆盖默认构造函数,使我们的单例按预期工作。

我们还有另一个名为GetTexture的函数。让我们再看一下它的签名,并详细分析其中的原理:

static Texture& GetTexture(string const& filename);
1

首先,注意这个函数返回一个Texture的引用。这意味着GetTexture将返回一个引用,这种方式很高效,因为避免了复制可能较大的图形。另外,注意该函数被声明为static(静态)。这意味着无需类的实例就可以使用这个函数。该函数接受一个string类型的常量引用作为参数。这样做有两个好处:其一,操作效率高;其二,由于引用是常量,所以它不能被修改。

接下来,我们继续编写TextureHolder函数的定义。

# 编写TextureHolder函数定义

现在,我们可以创建一个新的.cpp文件,用于包含函数定义。这将帮助我们理解这些新类型的函数和变量背后的原理。在解决方案资源管理器中右键单击“源文件”,选择“添加”>“新建项” 。在“添加新项”窗口中,(通过左键单击)突出显示“C++文件(.cpp)”,然后在“名称”字段中输入“TextureHolder.cpp”。最后,点击“添加”按钮。现在我们可以开始编写类的代码了。

添加以下代码,然后进行讨论:

#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;
}
1
2
3
4
5
6
7
8
9
10
11

在上述代码中,我们将TextureHolder类型的指针初始化为nullptr。在构造函数中,assert(m_s_Instance == nullptr)用于确保m_s_Instance等于nullptr。如果不相等,游戏将停止运行。然后,m_s_Instance = this将指针赋值为this实例。现在,思考一下这段代码的执行位置。这段代码在构造函数中,而构造函数是我们从类创建对象实例的方式。所以,实际上我们现在有了一个指向TextureHolder的指针,它指向其唯一的实例。

将代码的最后一部分添加到TextureHolder.cpp文件中。这里注释比代码多。添加代码时仔细查看以下代码并阅读注释,之后我们再详细讲解:

Texture& TextureHolder::GetTexture(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;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

你可能首先注意到上述代码中的auto关键字。上一节已经解释过auto关键字。

如果你想知道被auto替代的实际类型,可以查看每次使用auto之后的注释。在Visual Studio中,你还可以将鼠标悬停在auto关键字上,会出现一个工具提示,显示完整的类型。

在代码开头,我们获取对m_textures的引用。然后,尝试获取一个指向由传入文件名(filename)表示的键值对的迭代器。如果找到匹配的键,就通过return keyValuePair->second返回纹理。否则,我们将纹理添加到map中,然后返回给调用代码。

不可否认,TextureHolder类引入了许多新概念(单例、静态函数、常量引用、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
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

上述代码声明了Zombie类的所有私有成员变量。在代码开头,我们有三个常量变量,用于表示每种类型僵尸的速度:行动非常缓慢的爬行者(Crawler)、速度稍快的膨胀者(Bloater)和速度较快的追逐者(Chaser)。我们可以尝试调整这三个常量的值,以平衡游戏的难度。这里还值得一提的是,这三个值仅作为每种僵尸类型速度的起始值。在本章后面的内容中我们会看到,每种僵尸的速度都会在这些值的基础上有小幅度的变化。这可以防止同类型的僵尸在追逐玩家时聚集在一起。

接下来的三个常量确定了每种僵尸类型的生命值。注意,膨胀者是最难对付的,其次是爬行者。为了平衡游戏,追逐者僵尸将是最容易被消灭的。

接下来,我们还有另外两个常量MAX_VARRIANCE和OFFSET。它们将帮助我们确定每只僵尸的个体速度。在编写Zombie.cpp文件时,我们将详细了解其具体用法。

在这些常量之后,我们声明了一系列变量,这些变量应该看起来很眼熟,因为在Player类中我们有非常相似的变量。m_Position、m_Sprite、m_Speed和m_Health变量顾名思义,分别表示僵尸对象的位置、精灵、速度和生命值。

最后,在上述代码中,我们声明了一个名为m_Alive的布尔变量。当僵尸存活并进行攻击时,它的值为true;当僵尸的生命值降为0,变成背景上的一滩血迹时,它的值为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);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

在上述代码中,有一个hit函数,每当僵尸被子弹击中时,我们都可以调用这个函数。该函数可以执行必要的操作,比如减少僵尸的生命值(降低m_Health的值)或杀死它(将m_Alive设置为false)。

isAlive函数返回一个布尔值,让调用代码知道僵尸是活着还是死了。我们不想对玩家踩到血迹的情况进行碰撞检测或扣除玩家生命值。

spawn函数接受起始位置、类型(爬行者、膨胀者或追逐者,用int表示),以及下一节中用于随机数生成的种子。

和Player类一样,Zombie类也有getPosition和getSprite函数,分别用于获取表示僵尸所占空间的矩形和每一帧中要绘制的精灵。

上述代码中的最后一个原型是update函数。我们可能已经猜到它会接收自上一帧以来经过的时间,但注意它还接收一个名为playerLocation的Vector2f向量。这个向量实际上就是玩家中心的精确坐标。我们很快就会看到如何利用这个向量让僵尸追逐玩家。

现在,我们可以在.cpp文件中编写函数定义了。

# 编写Zombie.cpp文件

接下来,我们将为Zombie类编写功能,即函数定义。

创建一个新的.cpp文件来存放这些函数定义。在“解决方案资源管理器”中右键单击“源文件”,选择“添加”|“新建项” 。在“添加新项”窗口中,(通过左键单击)选中“C++文件(.cpp)”,然后在“名称”字段中输入Zombie.cpp。最后,点击“添加”按钮。现在我们准备为这个类编写代码。

在Zombie.cpp文件中添加以下代码:

#include "zombie.h"
#include "TextureHolder.h" 
#include <cstdlib>
#include <ctime>

using namespace std;
1
2
3
4
5
6

首先,我们添加必要的包含指令,然后使用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 = BLOATER_SPEED;
        m_Health = BLOATER_HEALTH;
        break; 
    case 1:
        // Chaser
        m_Sprite = Sprite(TextureHolder::GetTexture(
            "graphics/chaser.png")); 
        m_Speed = CHASER_SPEED;
        m_Health = CHASER_HEALTH;
        break; 
    case 2:
        // Crawler
        m_Sprite = Sprite(TextureHolder::GetTexture(
            "graphics/crawler.png"));
        m_Speed = CRAWLER_SPEED;
        m_Health = CRAWLER_HEALTH; 
        break;
    }

    // 修改速度,使僵尸具有独特性
    // 每个僵尸都是独一无二的。创建一个速度修正值
    srand((int)time(0) * seed);
    // 在80到100之间
    float modifier = (rand() % MAX_VARRIANCE) + OFFSET;
    // 表示为1的分数形式
    modifier /= 100; 
    // 现在在0.7到1之间
    m_Speed *= modifier;

    // 初始化位置
    m_Position.x = startX; 
    m_Position.y = startY;

    // 将其原点设置为中心
    m_Sprite.setOrigin(25, 25);

    // 设置其位置
    m_Sprite.setPosition(m_Position); 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

该函数做的第一件事是根据作为参数传入的int值来切换执行路径。在switch代码块中,每种类型的僵尸都有一个对应的case分支。根据僵尸的类型,合适的纹理、速度和生命值会被初始化为相关的成员变量。

我们本可以使用枚举来表示不同类型的僵尸。项目完成后,你可以自行升级代码。

这里值得注意的是,我们使用静态的TextureHolder::GetTexture函数来分配纹理。这意味着无论我们生成多少僵尸,GPU内存中最多只会有三种纹理。

接下来的三行代码(不包括注释)执行以下操作:

  • 使用作为参数传入的seed变量为随机数生成器设置种子。
  • 使用rand函数以及MAX_VARRIANCE和OFFSET常量声明并初始化modifier变量。结果是一个介于0到1之间的分数,可用于使每个僵尸的速度独一无二。我们这么做是为了避免僵尸过于聚集在一起。
  • 现在我们将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) {
        // 死亡
        m_Alive = false;

        m_Sprite.setTexture(TextureHolder::GetTexture(
            "graphics/blood.png"));
        return true; 
    }
    // 受伤但未死亡
    return false; 
}
1
2
3
4
5
6
7
8
9
10
11
12
13

hit函数非常简单:将m_Health减1,然后检查m_Health是否小于0。

如果小于0,就将m_Alive设置为false,把僵尸的纹理换成溅血的纹理,并向调用代码返回true,以便调用代码知道僵尸现在已死亡。如果僵尸存活下来,hit函数返回false。

添加以下三个取值函数,它们只是将一个值返回给调用代码:

bool Zombie::isAlive() {
    return m_Alive; 
}

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

Sprite Zombie::getSprite() {
    return m_Sprite; 
}
1
2
3
4
5
6
7
8
9
10
11

前面这三个函数相当直观,可能getPosition函数除外。getPosition函数使用m_Sprite.getLocalBounds函数获取FloatRect实例,然后将其返回给调用代码。

最后,对于Zombie类,我们需要添加update函数的代码。添加代码时仔细查看以下内容,之后我们会详细讲解:

void Zombie::update(float elapsedTime, Vector2f playerLocation)
{
    float playerX = playerLocation.x;

    float playerY = playerLocation.y;

    // 更新僵尸的位置变量
    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;
    }

    // 移动精灵
    m_Sprite.setPosition(m_Position);

    // 使精灵面向正确的方向
    float angle = (atan2(playerY - m_Position.y, playerX - m_Position.x)
        * 180) / 3.141;
    m_Sprite.setRotation(angle);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

在上述代码中,我们将playerLocation.x和playerLocation.y复制到名为playerX和playerY的局部变量中。

接下来有四个if语句。它们检查僵尸是在当前玩家位置的左边、右边、上边还是下边。

当这四个if语句的计算结果为true时,会使用通常的公式(即速度乘以自上一帧以来的时间,具体代码为m_Speed * elapsedTime)适当地调整僵尸的m_Position.x和m_Position.y值。

在四个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);
1
2
3
4
5
6
7
8
9

现在有了函数原型,我们可以编写函数定义了。

创建一个新的.cpp文件来存放函数定义。在“解决方案资源管理器”中右键单击“源文件”,选择“添加”|“新建项” 。在“添加新项”窗口中,(通过左键单击)选中“C++文件(.cpp)”,然后在“名称”字段中输入CreateHorde.cpp。最后,点击“添加”按钮。

在CreateHorde.cpp文件中添加以下代码并仔细研究。之后,我们会将其拆分成多个部分进行讨论:

#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++) {
        // 僵尸应该在哪一边生成
        srand((int)time(0) * i); 
        int side = (rand() % 4); 
        float x, y;

        switch (side) {
        case 0:
            // 左边
            x = minX;
            y = (rand() % maxY) + minY; 
            break;
        case 1:
            // 右边
            x = maxX;
            y = (rand() % maxY) + minY;
            break; 
        case 2:
            // 上边
            x = (rand() % maxX) + minX; 
            y = minY;
            break; 
        case 3:
            // 下边
            x = (rand() % maxX) + minX;
            y = maxY; 
            break;
        }

        // Bloater、Crawler或Runner
        srand((int)time(0) * i * 2); 
        int type = (rand() % 3);

        // 在数组中生成新的僵尸
        zombies[i].spawn(x, y, type, i);

    }
    return zombies; 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

让我们逐部分再次查看前面的所有代码。首先,我们添加了现在已经很熟悉的包含指令:

#include "ZombieArena.h" 
#include "Zombie.h"
1
2

接下来是函数签名。注意,该函数必须返回一个指向Zombie对象的指针。我们将创建一个Zombie对象数组。创建完僵尸群后,我们将返回这个数组。当我们返回数组时,实际上返回的是数组第一个元素的地址。正如我们在上一章学到的,这和指针是一回事。函数签名还表明我们有两个参数。第一个参数numZombies是当前这群僵尸所需的数量,第二个参数arena是一个IntRect对象,它保存了当前创建这群僵尸所在竞技场的大小。

在函数签名之后,我们声明了一个指向Zombie类型的指针zombies,并用在堆上动态分配的数组第一个元素的内存地址对其进行初始化:

Zombie* createHorde(int numZombies, IntRect arena) {
    Zombie* zombies = new Zombie[numZombies];
1
2

代码的下一部分只是将竞技场的边界值复制到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;
1
2
3
4

现在,我们进入一个for循环,该循环将遍历zombies数组中从0到numZombies的每个Zombie对象:

for (int i = 0; i < numZombies; i++)
1

在for循环内部,代码做的第一件事是为随机数生成器设置种子,然后生成一个介于0到3之间的随机数。这个数字存储在side变量中。我们将使用side变量来决定僵尸是在竞技场的左边、上边、右边还是下边生成。我们还声明了两个int变量x和y。这两个变量将临时保存当前僵尸实际的水平和垂直坐标:

// 僵尸应该在哪一边生成
srand((int)time(0) * i); 
int side = (rand() % 4); 
float x, y;
1
2
3
4

仍然在for循环内部,我们有一个带有四个case语句的switch代码块。注意,case语句的值是0、1、2和3,并且switch语句中的参数是side。在每个case代码块内部,我们用一个预定值(minX、maxX、minY或maxY中的一个)和一个随机生成的值来初始化x和y。仔细观察每个预定值和随机值的组合。你会发现它们适合在竞技场的左边、上边、右边或下边随机定位当前僵尸。这样做的效果是每个僵尸都可以在竞技场的外边缘随机生成。

switch (side) {
case 0:
    // 左边
    x = minX;
    y = (rand() % maxY) + minY; 
    break;
case 1:
    // 右边
    x = maxX;
    y = (rand() % maxY) + minY;
    break; 
case 2:
    // 上边
    x = (rand() % maxX) + minX; 
    y = minY;
    break; 
case 3:
    // 下边
    x = (rand() % maxX) + minX;
    y = maxY; 
    break;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

还是在for循环内部,我们再次为随机数生成器设置种子,并生成一个介于0到2之间的随机数。我们将这个数字存储在type变量中。type变量将决定当前僵尸是Chaser、Bloater还是Crawler。

确定类型后,我们对zombies数组中的当前Zombie对象调用spawn函数。提醒一下,传入spawn函数的参数决定了僵尸的起始位置和类型。传入看似随意的i,是因为它被用作一个唯一的种子,在合适的范围内随机改变僵尸的速度。这可以防止我们的僵尸“挤成一团”,变成一堆而不是一群。

// Bloater、Crawler或Runner
srand((int)time(0) * i * 2); 
int type = (rand() % 3);

// 在数组中生成新的僵尸
zombies[i].spawn(x, y, type, i);
1
2
3
4
5
6

for循环会根据numZombies中的值,为每个僵尸重复执行一次,然后我们返回数组。再次提醒,数组实际上只是其第一个元素的地址。数组是在堆上动态分配的,所以在函数返回后它仍然存在。

return zombies;
1

现在,我们可以让僵尸“活”起来了。

# 让这群僵尸“活起来”

我们现在有了Zombie类,还有一个能随机生成尸潮的函数。我们的TextureHolder单例类能很好地管理仅有的三种纹理,这些纹理可用于几十甚至上千个僵尸。现在,我们可以在main函数中把尸潮添加到游戏引擎里了。

添加以下突出显示的代码,引入TextureHolder类。然后,在main函数内部,初始化TextureHolder的唯一实例,这样在游戏的任何地方都能使用它:

#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;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

接下来几行突出显示的代码声明了一些控制变量,用于表示每波开始时僵尸的数量、还未被消灭的僵尸数量,当然,还有一个指向Zombie的指针zombies,我们将其初始化为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 ;
1
2
3
4
5
6
7
8
9
10
//  The  main  game  loop
while (window.isOpen())
1
2

接下来,在嵌套于LEVELING_UP部分的PLAYING部分中,添加代码实现以下功能:

  • 将numZombies初始化为10。随着项目推进,这个值最终会根据当前波数动态变化。
  • 释放之前分配的内存。否则,每次新调用createHorde函数都会占用越来越多的内存,而之前尸潮占用的内存却不会被释放。
  • 然后,调用createHorde函数,并将返回的内存地址赋给zombies。
  • 我们还将zombiesAlive初始化为numZombies,因为此时还没有消灭任何僵尸。

添加我们刚刚讨论的以下突出显示的代码:

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;
    //  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();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

现在,在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  centre  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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

前面新增的所有代码只是遍历僵尸数组,检查当前僵尸是否存活,如果存活,就使用必要的参数调用其更新函数。

添加以下代码来绘制所有僵尸:

/*
**************
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());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

前面的代码遍历所有僵尸,并调用getSprite函数,以便draw函数发挥作用。我们不会检查僵尸是否存活,因为即使僵尸死了,我们也想绘制出血花四溅的效果。

在main函数末尾,我们需要确保释放指针,这不仅是良好的编程习惯,而且通常很有必要。不过,严格来说,这不是必需的,因为游戏即将退出,在执行return 0语句后,操作系统会回收所有使用过的内存:

}//  End  of main  game  loop
// Delete  the previously  allocated memory  (if  it  exists)
delete[]   zombies;

return 0;
}
1
2
3
4
5
6

你可以运行游戏,看到僵尸在竞技场边缘生成。它们会立刻以各自的速度径直冲向玩家。只是为了好玩,我扩大了竞技场的规模,并把僵尸数量增加到了1000,如下图所示:

img

图11.1:扩大竞技场规模并增加僵尸数量

这情况看起来可不太妙!

注意,由于我们在第8章“SFML视图——开启僵尸射击游戏”中编写的代码,你还可以使用回车键暂停和恢复尸潮的攻击。

让我们修正一些类仍直接使用Texture实例的问题,改为使用新的TextureHolder类。

# 所有纹理都使用TextureHolder类

既然有了TextureHolder类,我们最好保持一致,用它来加载所有纹理。让我们对现有加载背景精灵表纹理和玩家纹理的代码做一些小改动。

# 修改背景加载纹理的方式

在ZombieArena.cpp文件中,找到以下代码:

//  Load  the  texture for  our  background  vertex  array
Texture  textureBackground;
textureBackground.loadFromFile ("graphics/background_sheet.png");
1
2
3

删除前面突出显示的代码,并用以下突出显示的代码替换,这段代码使用了我们新的TextureHolder类:

//  Load  the  texture for  our  background  vertex  array
Texture  textureBackground  =   TextureHolder::GetTexture(
"graphics/background_sheet.png");
1
2
3

让我们更新Player类加载纹理的方式。

# 修改Player类加载纹理的方式

在Player.cpp文件的构造函数中,找到这段代码:

#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  centre,
    // for  smooth  rotation
    m_Sprite.setOrigin(25, 25);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

删除前面突出显示的代码,并用以下突出显示的代码替换,这段代码使用了新的TextureHolder类。此外,添加包含指令,将TextureHolder头文件添加到该文件中。下面是添加上下文后突出显示的新代码:

#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  centre,
    // for  smooth  rotation
    m_Sprite.setOrigin(25, 25);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

从现在开始,我们将使用TextureHolder类来加载所有纹理。

# 总结

我们构建了TextureHolder类,用于存放精灵使用的所有纹理,并编写了Zombie类,可重复使用这个类来创建任意数量的僵尸。

你可能已经注意到,这些僵尸看起来并不怎么危险。它们只是从玩家身体穿过,连个擦伤都不会留下。目前来说,这反而是件好事,因为玩家还没有任何防御手段。

在下一章中,我们将再创建两个类:一个用于弹药和生命值补给道具,另一个用于玩家发射的子弹。完成这些之后,我们将学习如何检测碰撞,这样子弹就能对僵尸造成伤害,玩家也能收集补给道具了。

# 常见问题解答

**问:**你能再给我讲讲new关键字和内存泄漏的问题吗?

**答:**当我们使用new关键字在自由存储区分配内存时,即使创建内存的函数返回,所有局部变量都消失了,这块内存依然存在。当我们不再使用自由存储区的内存时,必须释放它。所以,如果我们在自由存储区分配了想在函数生命周期之外持续存在的内存,就必须确保保留指向它的指针,否则就会造成内存泄漏。这就好比把所有家当都放在房子里,却忘了自己住哪儿!当我们从createHorde函数返回zombies数组时,就像是把接力棒(内存地址)从createHorde函数传递给了main函数。这就好像在说:“好了,这是你的尸潮,现在它们归你负责了。”而且,我们可不想让泄漏的僵尸在内存里到处跑!所以,对于动态分配内存的指针,一定要记得调用delete释放内存。

第10章 指针、标准模板库和纹理管理
第12章 碰撞检测、拾取物与子弹

← 第10章 指针、标准模板库和纹理管理 第12章 碰撞检测、拾取物与子弹→

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