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类并创建一群僵尸
  • 第12章 碰撞检测、拾取物与子弹
  • 第13章 视图分层与平视显示器(HUD)的实现
  • 第14章 音效、文件输入输出与完成游戏制作
  • 第15章 快跑!
  • 第16章 声音、游戏逻辑、对象间通信与玩家
  • 第17章 图形、相机与动作
  • 第18章 为平台、玩家动画和控制功能编写代码
  • 第19章 构建菜单与实现下雨效果
    • 构建交互式菜单
      • 编写MenuUpdate类代码
      • 编写MenuGraphics类
      • 在工厂类中构建菜单
    • 运行游戏
    • 实现下雨效果
      • 编写“雨图形”类
      • 在工厂类中实现下雨效果
    • 运行游戏
    • 总结
  • 第20章 火球与声音空间化
  • 第21章 视差背景与着色器
目录

第19章 构建菜单与实现下雨效果

# 第19章 构建菜单与实现下雨效果

在本章中,我们将实现两个重要功能。其中一个是创建一个菜单界面,让玩家清楚知道开始、暂停、重新开始和退出游戏的选项。另一个任务是创建一个简单的下雨效果。你可能会说下雨效果并非必要,甚至觉得它与游戏不搭,但这个效果实现起来很简单,也很有趣,是一项值得学习的技巧。到现在你应该有所预期了,不过本章最有意思的地方或许还是,我们将如何通过编写从Graphics和Update派生的类来实现这两个目标,把这些类组合到GameObject实例中,让它们能与游戏中的其他实体协同工作。

本章内容如下:

  • 构建交互式菜单
  • 编写MenuUpdate类代码
  • 编写MenuGraphics类代码
  • 在工厂(factory)中构建菜单
  • 实现下雨效果
  • 编写RainGraphics类代码
  • 在工厂中实现下雨效果

Run5文件夹中的代码展示了本章结束时的完整状态。

# 构建交互式菜单

首先,我们来看看玩家眼中菜单的两种可能状态。

img 图19.1:菜单的两种状态

从上图中可以看到这两种情况。左边的画面告知玩家可以按Esc键开始游戏或按F1键退出游戏;右边的画面则显示玩家可以按Esc键继续游戏或按F1键退出游戏。之所以会有这种细微差别,是因为在游戏进行过程中,玩家也可以通过按Esc键暂停游戏。在任意一个菜单界面中,按F1键始终是退出游戏,但在游戏进行时,按F1键没有任何作用。

# 编写MenuUpdate类代码

现在,我们要创建一个新类来控制游戏内的菜单。创建一个新类MenuUpdate,它继承自Update,同时创建一个新类MenuGraphics,继承自Graphics。

现在,我们可以开始编写代码了。在MenuUpdate.h中添加以下代码:

#pragma once

#include "Update.h"
#include "InputReceiver.h"
#include <SFML/Graphics.hpp>

using namespace sf;
using namespace std;

class MenuUpdate : public Update
{
private:
    FloatRect m_Position;
    InputReceiver m_InputReceiver;
    FloatRect* m_PlayerPosition = nullptr;

    bool m_IsVisible = false;
    bool* m_IsPaused;
    bool m_GameOver;
    RenderWindow* m_Window;

public:
    MenuUpdate(RenderWindow* window);
    void handleInput();
    FloatRect* getPositionPointer();
    bool* getGameOverPointer();
    InputReceiver* getInputReceiver();

    //From  Update  :  Component
    void update(float fps) override;
    void assemble(
        shared_ptr<LevelUpdate> levelUpdate,
        shared_ptr<PlayerUpdate> playerUpdate)
        override;
};
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

下面介绍上述代码中的所有成员变量和函数:

  • FloatRect类型的变量m_Position将用于存储水平和垂直位置以及尺寸信息。
  • InputReceiver实例m_InputReceiver的作用与PlayerUpdate和CameraUpdate中同名变量的作用完全相同,只不过它会按照我们前面讨论菜单功能时预期的那样,对Esc键和F1键做出响应。
  • FloatRect指针m_PlayerPosition将追踪玩家的位置,这样当菜单需要显示时,就可以根据玩家的位置来确定自身的位置。
  • 布尔型变量m_IsVisible用于告知菜单何时显示、何时隐藏。
  • 布尔型指针m_IsPaused将保存LevelUpdate类中定义游戏是否暂停的变量地址。然后,结合m_IsVisible和m_GameOver这两个变量,菜单就能知道何时显示自身以及显示哪张图片。布尔型变量m_GameOver与前面这两个变量协同工作。
  • RenderWindow指针m_Window赋予菜单关闭应用程序窗口、结束应用程序运行的能力。
  • MenuUpdate构造函数用于为MenuUpdate类执行其任务做准备,它会处理assemble函数无法处理的细节。
  • handleInput函数在update函数中每帧调用一次,用于处理主游戏循环中InputDispatcher发送的操作系统事件。
  • getPositionPointer函数返回一个指向菜单位置和缩放信息的FloatRect指针。
  • getGameOverPointer函数返回一个布尔型变量的地址,该变量用于指示玩家失败导致游戏结束的情况。
  • getInputReceiver函数将InputReceiver实例的地址返回给InputDispatcher。
  • 重写的update函数在游戏循环的每次迭代中执行。很快我们就会看到具体的代码。
  • 正如我们所预期的,重写的assemble函数返回void类型,有以下参数:shared_ptr<LevelUpdate> levelUpdate和shared_ptr<PlayerUpdate> playerUpdate。稍后我们将编写这个函数的代码,展示该类使用它的特定但相似的方式。

现在我们可以继续实现MenuUpdate类。我们将在MenuUpdate.cpp中分四个主要部分添加代码。为了使这些部分正常工作,我们首先需要添加以下包含指令:

#include "MenuUpdate.h"
#include "LevelUpdate.h"
#include "PlayerUpdate.h"
#include "SoundEngine.h"
1
2
3
4

在MenuUpdate.cpp中添加第一部分主要代码:

MenuUpdate::MenuUpdate(RenderWindow* window)
{
    m_Window = window;
}

FloatRect* MenuUpdate::getPositionPointer()
{
    return &m_Position;
}

bool* MenuUpdate::getGameOverPointer()
{
    return &m_GameOver;
}

InputReceiver* MenuUpdate::getInputReceiver()
{
    return &m_InputReceiver;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在上述MenuUpdate.cpp代码的第一部分中,有构造函数,用于初始化指向RenderWindow的指针。当玩家按下F1键关闭游戏时,我们将使用这个指向RenderWindow的指针。getPositionPointer函数返回指向m_Position的指针。当然,另一个关心菜单位置的类是MenuGraphics类,它需要绘制菜单。

getGameOverPointer函数返回布尔型变量m_GameOver的地址。getInputReceiver函数(与PlayerUpdate和CameraUpdate中的用法一样)用于获取指向InputReceiver实例的指针。同样,InputDispatcher会使用这个指针,这样它就知道每帧中应该把所有操作系统事件发送到哪里。

接下来,在MenuUpdate.cpp中添加assemble函数:

void MenuUpdate::assemble(
    shared_ptr<LevelUpdate> levelUpdate,
    shared_ptr<PlayerUpdate> playerUpdate)
{
    m_PlayerPosition =
        playerUpdate->getPositionPointer();
    m_IsPaused =
        levelUpdate->getIsPausedPointer();

    m_Position.height = 75;
    m_Position.width = 75;

    SoundEngine::startMusic();
    SoundEngine::pauseMusic();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在上述MenuUpdate.cpp代码的第二部分中,assemble函数为使用该类做准备。首先,将玩家位置的地址初始化为m_PlayerPosition,并将LevelUpdate实例中表示暂停状态的布尔型变量的地址复制到m_IsPaused。其次,菜单的宽度和高度由一个神奇数字(这里是75)定义。最后,启动音乐,然后立即暂停。现在,音乐已准备好,无论玩家何时恢复或暂停游戏,都可以恢复和暂停音乐播放。

现在,在MenuUpdate.cpp中添加第三部分代码。接下来展示handleInput函数:

void MenuUpdate::handleInput()
{
    for (const Event& event :
        m_InputReceiver.getEvents())
    {
        if (event.type ==
            Event::KeyPressed)
        {
            if (event.key.code ==
                Keyboard::F1 && m_IsVisible)
            {
                if (SoundEngine::mMusicIsPlaying) 
                {
                    SoundEngine::stopMusic();
                }
                m_Window->close();
            }
        }

        if (event.type == Event::KeyReleased) 
        {
            if (event.key.code == Keyboard::Escape)
            {
                m_IsVisible = !m_IsVisible;
                *m_IsPaused = !*m_IsPaused;

                if (m_GameOver) 
                {
                    m_GameOver = false;
                }

                if (!*m_IsPaused) 
                {
                    SoundEngine::resumeMusic();
                    SoundEngine::playClick();
                }

                if (*m_IsPaused) 
                {
                    SoundEngine::pauseMusic();
                    SoundEngine::playClick();
                }
            }
        }
    }

    m_InputReceiver.clearEvents();
}
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

在第三部分中,handleInputFunction函数对于我们在之前项目和本项目中创建的所有事件循环来说应该很熟悉。for循环遍历游戏当前帧的所有输入事件,包含两个if语句。

第一个if语句检查是否同时满足按下F1键和菜单可见这两个条件。如果音乐正在播放,则停止音乐,然后使用RenderWindow指针关闭窗口,结束游戏。

第二个if语句以及进一步嵌套的if语句检查Esc键的释放情况。首先,将m_IsVisible的值取反。如果它原本为true,则变为false;如果原本为false,则变为true。这正是我们需要的效果。每次玩家点击Esc键,游戏状态都会在暂停和运行之间切换。对m_IsVisible进行同样的取反操作,以显示和隐藏菜单。

此时,布尔型变量的状态已正确设置,代码会根据当前状态采取相应的操作。如果游戏结束(m_GameOver为true),则将m_GameOver设置为false。如果游戏未暂停,则恢复音乐播放并播放点击音效;最后,如果游戏暂停,则暂停音乐播放并播放点击音效。

在事件for循环之外,通过调用clearEvents函数,将m_InputReceiver实例中的所有事件清除,为下一次主游戏循环迭代做准备。

最后,在MenuUpdate.cpp中为update函数添加以下代码:

void MenuUpdate::update(float fps) 
{
    handleInput();

    if (*m_IsPaused && !m_IsVisible)//  Game  over  1 
    {
        m_IsVisible = true;
        m_GameOver = true;
    }

    if (m_IsVisible) 
    {
        //  Follow  the player
        m_Position.left =
            m_PlayerPosition->getPosition()–x - m_Position.width / 2;
        m_Position.top =
            m_PlayerPosition->getPosition()–y - m_Position.height / 2;
    }
    else
    {
        m_Position.left = -999;
        m_Position.top = -999;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

在前面的最后一部分中,MenuUpdate类的update函数首先调用我们之前编写的handleInput函数。当游戏暂停且菜单不可见时,执行第一个if语句。该if语句中的代码将paused设置为true,将m_GameOver设置为true。

update函数中的第二个if语句在菜单可见时执行。当菜单可见时,确保菜单实际可见是有意义的。为此,将m_Position.left和m_Position.top分别初始化为玩家位置的左边和顶部,再减去玩家的宽度和高度。这样可以将菜单定位在屏幕中心,位于玩家上方。

在最后一个else子句中(当游戏未暂停时执行),我们将m_Position.left和m_Position.top初始化为-999,这将隐藏菜单。

现在,我们可以继续研究MenuGraphics类,看看MenuUpdate和MenuGraphics是如何相互配合的。

# 编写MenuGraphics类

首先,将以下代码添加到MenuGraphics.h文件中:

#pragma once
#include &quot;Graphics.h&quot;
#include &quot;SFML/Graphics.hpp&quot;

class MenuGraphics : public Graphics {
private:
    FloatRect* m_MenuPosition = nullptr;
    int m_VertexStartIndex;
    bool* m_GameOver;
    bool m_CurrentStatus = false;
    
    int uPos;
    int vPos;
    int texWidth;
    int texHeight;

public:
    // 从Graphics:Component继承
    void draw(VertexArray& canvas) override;
    void assemble(VertexArray& canvas, 
                  shared_ptr<Update> genericUpdate, 
                  IntRect texCoords) override;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

在上述代码中,FloatRect指针m_MenuPosition被初始化为nullptr。这个变量很快会被初始化,用于跟踪MenuUpdate类中的m_Position FloatRect实例。

整数m_VertexStartIndex变量将被初始化,用于记住在为游戏每一帧绘制的VertexArray(顶点数组)中代表菜单的四边形顶点的起始索引。

布尔指针m_GameOver将被初始化,用于跟踪MenuUpdate类中的m_GameOver变量。

布尔值m_CurrentStatus将用于做出决策并记住这些决策,它会先初始化为m_GameOver的值,然后检测其变化。当我们在MenuGraphics.cpp中看到相关代码时,就会更清楚它的作用。

整数uPos将保存水平纹理坐标,vPos将保存垂直纹理坐标,texWidth表示纹理宽度,texHeight表示纹理高度。

第一个公有函数是重写的draw函数,我们对它的签名非常熟悉;很快我们就会看到如何编写它的代码。

assemble函数的参数都是常见的,我们已经多次见过。我们很快就会编写它的代码,看看如何组装MenuGraphics类。

对于MenuGraphics.cpp文件,我们将分两部分编写代码:assemble函数和draw函数。

将以下assemble函数添加到MenuGraphics.cpp中:

#include &quot;MenuGraphics.h&quot;
#include &quot;MenuUpdate.h&quot;

void MenuGraphics::assemble(VertexArray& canvas,
                             shared_ptr<Update> genericUpdate,
                             IntRect texCoords) 
{
    m_MenuPosition = static_pointer_cast<MenuUpdate>(genericUpdate)->getPositionPointer();
    m_GameOver = static_pointer_cast<MenuUpdate>(genericUpdate)->getGameOverPointer();
    m_CurrentStatus = *m_GameOver;
    m_VertexStartIndex = canvas.getVertexCount();
    canvas.resize(canvas.getVertexCount() + 4);

    // 记住UV坐标
	// 因为我们稍后会对它们进行操作
    uPos = texCoords.left;
    vPos = texCoords.top;
    texWidth = texCoords.width;
    texHeight = texCoords.height;

    canvas[m_VertexStartIndex].texCoords.x = uPos;
    canvas[m_VertexStartIndex].texCoords.y = vPos + texHeight;
    
    canvas[m_VertexStartIndex + 1].texCoords.x = uPos + texWidth;
    canvas[m_VertexStartIndex + 1].texCoords.y = vPos + texHeight;
    
    canvas[m_VertexStartIndex + 2].texCoords.x = uPos + texWidth;
    canvas[m_VertexStartIndex + 2].texCoords.y = vPos + texHeight + texHeight;
    
    canvas[m_VertexStartIndex + 3].texCoords.x = uPos;
    canvas[m_VertexStartIndex + 3].texCoords.y = vPos + texHeight + texHeight;
}
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

上述assemble函数代码从这一行开始:

m_MenuPosition = static_pointer_cast<MenuUpdate>(genericUpdate)->getPositionPointer();
1

static_pointer_cast函数将当前类型为Update的genericUpdate实例转换为特定的MenuUpdate shared_ptr实例。在转换后的同一行代码中,对MenuUpdate实例调用getPositionPointer函数。该函数调用的返回结果存储在m_MenuPosition中。

下一行代码使用相同的转换技术,但调用的是getGameOverPointer函数,并将结果存储在m_GameOver中。

接下来,对m_GameOver进行解引用,并将整数值存储在m_CurrentStatus中。

接着,通过获取VertexArray的当前大小来初始化m_StartIndex变量,然后通过调用canvas.resize增加VertexArray的大小,以容纳另一个四边形。

接下来,我们通过从传入的纹理坐标初始化uPos、vPos、texWidth和texHeight来记住纹理坐标。请记住,这些值会被存储起来,并且很快会从Factory类传入。

接下来的八行代码将纹理坐标直接初始化为VertexArray。我们需要将原始纹理坐标与VertexArray分开存储的原因是,我们很快会在update函数中添加代码,通过操作纹理坐标来显示游戏暂停(与游戏结束相对)时菜单的不同版本。

最后,对于MenuGraphics.cpp和MenuGraphics类,将以下draw函数添加到MenuGraphics.cpp中:

void MenuGraphics::draw(VertexArray& canvas)
{
    if (*m_GameOver && !m_CurrentStatus)
    {
        // 当前状态刚刚切换为游戏结束
        m_CurrentStatus = *m_GameOver;

        // 每个v坐标都加倍以引用下面的纹理
        canvas[m_VertexStartIndex].texCoords.x = uPos;
        canvas[m_VertexStartIndex].texCoords.y = vPos + texHeight;
        canvas[m_VertexStartIndex + 1].texCoords.x = uPos + texWidth;
        canvas[m_VertexStartIndex + 1].texCoords.y = vPos + texHeight;
        canvas[m_VertexStartIndex + 2].texCoords.x = uPos + texWidth;
        canvas[m_VertexStartIndex + 2].texCoords.y = vPos + texHeight + texHeight;
        canvas[m_VertexStartIndex + 3].texCoords.x = uPos;
        canvas[m_VertexStartIndex + 3].texCoords.y = vPos + texHeight + texHeight;
    }
    else if (!*m_GameOver && m_CurrentStatus)
    {
        m_CurrentStatus = *m_GameOver;

        canvas[m_VertexStartIndex].texCoords.x = uPos;
        canvas[m_VertexStartIndex].texCoords.y = vPos;
        canvas[m_VertexStartIndex + 1].texCoords.x = uPos + texWidth;
        canvas[m_VertexStartIndex + 1].texCoords.y = vPos;
        canvas[m_VertexStartIndex + 2].texCoords.x = uPos + texWidth;
        canvas[m_VertexStartIndex + 2].texCoords.y = vPos + texHeight;
        canvas[m_VertexStartIndex + 3].texCoords.x = uPos;
        canvas[m_VertexStartIndex + 3].texCoords.y = vPos + texHeight;
    }

    const Vector2f& position = m_MenuPosition->getPosition();
    canvas[m_VertexStartIndex].position = position;
    canvas[m_VertexStartIndex + 1].position = position + Vector2f(m_MenuPosition->getSize().x, 0);
    canvas[m_VertexStartIndex + 2].position = position + m_MenuPosition->getSize();
    canvas[m_VertexStartIndex + 3].position = position + Vector2f(0, m_MenuPosition->getSize().y);
}
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

在上述draw函数中,代码有一个if分支和一个else if分支。当m_GameOver为true且m_CurrentStatus为false时,执行if分支。当m_GameOver为false且m_CurrentStatus为false时,执行else if分支。

首先,让我们看看if分支中发生了什么。在if分支中,m_CurrentStatus被设置为m_GameOver的解引用值,然后设置VertexArray中的所有纹理坐标。设置方式与我们在assemble函数中使用的相同。这些值映射到纹理图集(texture atlas)中菜单的较低版本。

接下来,让我们看看else if分支中发生了什么。在else if分支中,m_CurrentStatus再次与m_GameOver同步,并且设置VertexArray中的所有纹理坐标。然而,看看所有垂直坐标的代码。它们都没有额外的+texHeight。这意味着这些坐标现在映射到纹理图集中菜单图形的较高版本。每当玩家输掉游戏以及玩家重新开始游戏后暂停游戏时,纹理坐标都会翻转。因此,纹理坐标将始终映射到暂停菜单或游戏结束菜单。

当然,我们还没有对顶点进行定位。我们必须这样做,因为在MenuUpdate类中,随着菜单的显示和隐藏,这些位置会经常变化。在我们刚刚讨论的if-else-if结构之后,会使用m_MenuPosition中的值来定位VertexArray中的顶点位置,m_MenuPosition指向MenuUpdate中的FloatRect。

为了使代码不那么冗长,我们首先通过调用m_MenuPosition->getPosition初始化一个常量Vector2f。

# 在工厂类中构建菜单

现在我们可以通过将GameObject实例与我们的两个新类组合来实例化一个可用的菜单。添加我们两个与菜单相关的类的包含指令:

#include "MenuUpdate.h"   
#include "MenuGraphics.h"
1
2

接下来,在Factory.cpp文件中loadLevel函数的右花括号之前添加以下代码:

// Menu
GameObject menu;

shared_ptr<MenuUpdate> menuUpdate = make_shared<MenuUpdate>(m_Window);
menuUpdate->assemble(levelUpdate, playerUpdate);

inputDispatcher.registerNewInputReceiver(menuUpdate->getInputReceiver());

menu.addComponent(menuUpdate);

shared_ptr<MenuGraphics> menuGraphics = make_shared<MenuGraphics>();

menuGraphics->assemble(canvas, menuUpdate, IntRect(TOP_MENU_TEX_LEFT,
                                                   TOP_MENU_TEX_TOP,
                                                   TOP_MENU_TEX_WIDTH,
                                                   TOP_MENU_TEX_HEIGHT));

menu.addComponent(menuGraphics);
gameObjects.push_back(menu);
// End menu
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

上述代码应该看起来很熟悉。我们按以下顺序进行操作:

  1. 创建一个名为menu的新GameObject实例。
  2. 创建一个指向MenuUpdate实例的共享指针,名为menuUpdate。
  3. 调用menuUpdate的assemble函数,传入levelUpdate和playerUpdate共享指针。
  4. 通过在inputDispatcher上调用registerNewInputReceiver并传入menuUpdate->getInputReceiver的返回结果,使菜单准备好接收更新。
  5. 将menuUpdate添加到menu GameObject实例中。
  6. 创建一个MenuGraphics类型的共享指针。
  7. 调用menuGraphics的assemble函数,并传入VertexArray、LevelUpdate实例以及所有必需的纹理坐标。
  8. 将MenuGraphics实例添加/组合到GameObject实例中。
  9. 最后,将代表我们菜单的GameObject实例添加到gameObjects向量中。

就是这样。我们的菜单完成了。

# 运行游戏

现在你可以运行游戏了。如果你按下Esc键,你会看到如下所示的菜单:

img 图19.2:菜单

你将能够按照菜单上的提示按下F1键退出游戏。然而,如果你尝试按下Esc键开始游戏,它也会退出游戏。这不是我们想要的行为。我们需要进行两个快速更改。

我们需要删除在项目开始时添加到InputDispatcher.cpp类中的临时代码。找到以下代码行并删除它们:

if (event.type == Event::KeyPressed &&  event.key.code == Keyboard::Escape)
{
	m_Window->close();
}
1
2
3
4

现在你可以运行游戏了,按下Esc键可以开始和暂停游戏,当菜单可见时,按下F1键可以退出游戏。InputDispatcher类不再处理任何事件;它只是将事件分发给菜单、玩家和小地图相机中的InputReceiver实例。

我们还需要阻止游戏自动开始。我们在LevelUpdate.h类中进行此操作。找到下一行代码:

bool m_IsPaused = false;
1

现在将其更改为:

bool m_IsPaused = true;
1

现在我们的暂停、开始和退出功能按预期工作。

# 实现下雨效果

我们只需要一个图形组件。这没问题,就像在关卡逻辑中只使用更新组件一样。虽然“雨图形”(RainGraphics)状态会发生变化,但它与主游戏循环的时间或玩家输入没有任何依赖关系。所有状态变化都由“雨图形”类内部的“动画控制器”(Animator)类实例控制,该实例有自己的内部时钟。我们将生成多个“雨图形”类的实例,因为每个实例只会覆盖屏幕的一小部分。每个“雨图形”实例会根据玩家的位置进行定位,并在游戏世界中跟随玩家,给人一种到处都在下雨的感觉。

# 编写“雨图形”类

纹理集(texture atlas)中的图形如下所示:

img 图19.3:纹理集中的雨

我用红色框出了每一帧动画,并将背景从透明改为了白色。每一帧动画都是100像素×100像素。因此,整个雨的精灵图(sprite sheet)是400像素×100像素。所有帧都排列整齐,准备由我们的“动画控制器”类循环播放,我们也用这个类来为玩家进行动画处理。

创建一个名为“RainGraphics”的新类,并在“RainGraphics.h”文件中添加以下代码:

#pragma once

#include "Graphics.h"
class Animator;

class RainGraphics : public Graphics
{
private:
    FloatRect* m_PlayerPosition;

    int m_VertexStartIndex;
    Vector2f m_Scale;

    float m_HorizontalOffset;
    float m_VerticalOffset;

    Animator* m_Animator;
    IntRect* m_SectionToDraw;

public:
    RainGraphics(FloatRect* playerPosition,
                 float horizontalOffset,
                 float verticalOffset,
                 int rainCoveragePerObject);

    // 来自Graphics:Component
    void draw(VertexArray& canvas) override;
    void assemble(VertexArray& canvas,
                  shared_ptr<Update> genericUpdate,
                  IntRect texCoords) override;
};
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

在上述“RainGraphics.h”代码中,FloatRect指针m_PlayerPosition将跟踪玩家的位置,这样雨就能像卡通片中被乌云跟随的倒霉角色一样,跟着玩家到处跑。

m_VertexStartIndex用于记住顶点数组(VertexArray)中顶点的起始索引。

m_Scale变量用于记住一帧动画的大小。浮点型变量m_HorizontalOffset是纹理集中图形的起始水平值,浮点型变量m_VerticalOffset则是对应的垂直值。

m_Animator实例是我们的“动画控制器”,IntRect指针m_SectionToDraw将保存当前动画帧的纹理坐标。

“雨图形”类的构造函数接收并初始化我们刚才讨论的一些变量,以及一个名为rainCoveragePerObject的整数,这个整数有助于我们调整每个雨实例的大小。

draw和assemble函数是常见的重写函数,其声明与之前相同,但它们的实现会很有趣,我们很快就会讨论到。

接下来,对于“RainGraphics.cpp”文件,我们分两部分进行编码,首先是构造函数,然后是assemble函数。在“RainGraphics.cpp”中添加以下代码:

#include "RainGraphics.h"
#include "RainGraphics.h"
#include "Animator.h"

RainGraphics::RainGraphics(
    FloatRect* playerPosition,
    float horizontalOffset,
    float verticalOffset,
    int rainCoveragePerObject)
{
    m_PlayerPosition = playerPosition;
    m_HorizontalOffset = horizontalOffset;
    m_VerticalOffset = verticalOffset;

    m_Scale.x = rainCoveragePerObject;
    m_Scale.y = rainCoveragePerObject;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

在构造函数中,我们初始化玩家位置指针、“动画控制器”的水平和垂直偏移量,以及m_Scale(它是一个IntRect类型),x和y使用相同的值。我们很快就会看到它的用途。

接下来,添加assemble函数,如下所示:

void RainGraphics::assemble(VertexArray& canvas,
                            shared_ptr<Update> genericUpdate,
                            IntRect texCoords) 
{
    m_Animator = new Animator(
        texCoords.left,
        texCoords.top,
        4, // 帧数
        texCoords.width * 4,
        texCoords.height,
        8); // 帧率

    m_VertexStartIndex = canvas.getVertexCount();
    canvas.resize(canvas.getVertexCount() + 4);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在assemble函数中,我们通过调用new并传入所需参数来初始化“动画控制器”实例。注意,正如预期的那样,这里有四帧,并且我们指定了每秒八帧。

我们记住起始索引,并在顶点数组中为表示这部分雨的四边形添加四个空间,就像我们之前做过很多次的那样。不过要记住,“雨图形”类会有多个实例。

最后,在“RainGraphics.cpp”中添加draw函数,如下所示:

void RainGraphics::draw(VertexArray& canvas) 
{
    const Vector2f& position =
        m_PlayerPosition->getPosition()
        - Vector2f(m_Scale.x / 2 + m_HorizontalOffset, m_Scale.y / 2 + m_VerticalOffset);

    // 移动雨以跟上玩家
    canvas[m_VertexStartIndex].position = position;
    canvas[m_VertexStartIndex + 1].position =
        position + Vector2f(m_Scale.x, 0);
    canvas[m_VertexStartIndex + 2].position = position + m_Scale;
    canvas[m_VertexStartIndex + 3].position =
        position + Vector2f(0, m_Scale.y);

    m_SectionToDraw =
        m_Animator->getCurrentFrame(false);

    // 记住要绘制的纹理部分
    const int uPos = m_SectionToDraw->left;
    const int vPos = m_SectionToDraw->top;
    const int texWidth = m_SectionToDraw->width;
    const int texHeight = m_SectionToDraw->height;

    canvas[m_VertexStartIndex].texCoords.x = uPos;
    canvas[m_VertexStartIndex].texCoords.y = vPos;
    canvas[m_VertexStartIndex + 1].texCoords.x = uPos + texWidth;
    canvas[m_VertexStartIndex + 1].texCoords.y = vPos;
    canvas[m_VertexStartIndex + 2].texCoords.x = uPos + texWidth;
    canvas[m_VertexStartIndex + 2].texCoords.y = vPos + texHeight;
    canvas[m_VertexStartIndex + 3].texCoords.x = uPos;
    canvas[m_VertexStartIndex + 3].texCoords.y = vPos + texHeight;
}
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

在draw函数中,第一部分代码使用玩家的位置来移动雨,以便无论玩家移动到哪里,雨都能跟上。注意,在第一行代码中,m_HorizontalOffset和m_VerticalOffset的值用于确保该实例相对于其他所有“雨图形”实例处于正确的位置。你可能还记得,这些偏移值是在我们之前编写的构造函数中传入的。正如你可能预期的那样,多个“雨图形”实例及其偏移量将在“工厂”(Factory)类中创建时进行协调。

接下来,调用“动画控制器”类的getCurrentFrame函数来获取当前纹理坐标,然后用通常的八行代码为四边形的四个顶点分配适当的x和y坐标。

# 在工厂类中实现下雨效果

首先,在“Factory.cpp”中添加以下包含指令:

#include "RainGraphics.h"
1

在平台代码之后、相机代码之前添加以下代码,以创建多个“雨图形”实例:

// 雨
int rainCoveragePerObject = 25;
int areaToCover = 350;
for (int h = -areaToCover / 2; h < areaToCover / 2;
     h += rainCoveragePerObject) 
{
    for (int v = -areaToCover / 2; v < areaToCover / 2;
         v += rainCoveragePerObject) 
    {
        GameObject rain;

        shared_ptr<RainGraphics> rainGraphics =
            make_shared<RainGraphics>(
                playerUpdate->getPositionPointer(), h, v, rainCoveragePerObject);

        rainGraphics->assemble(
            canvas, nullptr,
            IntRect(RAIN_TEX_LEFT, RAIN_TEX_TOP, RAIN_TEX_WIDTH, RAIN_TEX_HEIGHT));
        rain.addComponent(rainGraphics);
        gameObjects.push_back(rain);
    }
}
// 雨结束
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

在上述代码中,我们做了一些常规操作:创建一个GameObject实例,创建一个“雨图形”实例,将“雨图形”实例添加到GameObject中,并将GameObject添加到GameObject向量中。为什么不花点时间找出这些现在已经很熟悉的部分呢?然后,我将介绍新的内容。

新的内容是,我们首先声明了一些额外的变量来控制多个“雨图形”实例的位置和大小,如下所示:

int rainCoveragePerObject = 25;
int areaToCover = 350;
1
2

接下来,注意用于迭代并创建多个实例的for循环结构。如下所示:

for (int h = -areaToCover / 2; h < areaToCover / 2;
     h += rainCoveragePerObject)
1
2

for循环的条件意味着h将从 -175 变化到175,并且每次增加25。在“雨图形”实例中,所有这些值都是世界单位,而不是像素。

初始化v参数的内层for循环使用与外层for循环相同的公式。最后,注意对“雨图形”类构造函数的调用:

shared_ptr<RainGraphics> rainGraphics =
    make_shared<RainGraphics>(
        playerUpdate->getPositionPointer(), h, v, rainCoveragePerObject);
1
2
3

总的来说,这会在玩家周围循环创建一个14×14(共196个)的“雨图形”实例块。

# 运行游戏

现在我们可以看到努力的成果并运行游戏了。

img 图19.4:雨

下雨效果就完成啦!

# 总结

在本章中,我们通过编写“菜单更新”(MenuUpdate)类和“菜单图形”(MenuGraphics)类,创建了一个具有两种外观的交互式菜单。之后,我们在“工厂”类中以与前几章为游戏添加功能相同的方式创建了一个菜单。

最后,我们创建了一个新的派生自“图形”(Graphics)的类,名为“雨图形”(RainGraphics),它创建了一个简单而有效的下雨效果。像往常一样,我们在“工厂”类中将这个类封装在一个GameObject实例中,放入gameObjects向量中,然后,瞧,它就生效了。

在下一章中,我们将在游戏中添加从左侧或右侧飞来的火球,以干扰玩家的进程。

第18章 为平台、玩家动画和控制功能编写代码
第20章 火球与声音空间化

← 第18章 为平台、玩家动画和控制功能编写代码 第20章 火球与声音空间化→

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