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章 声音、游戏逻辑、对象间通信与玩家
    • 编写SoundEngine类
    • 编写游戏逻辑
      • 编写LevelUpdate类
      • 为PlayerUpdate类编写代码
      • 为PlayerGraphics类编写代码
      • 记住纹理坐标
  • 第17章 图形、相机与动作
  • 第18章 为平台、玩家动画和控制功能编写代码
  • 第19章 构建菜单与实现下雨效果
  • 第20章 火球与声音空间化
  • 第21章 视差背景与着色器
目录

第16章 声音、游戏逻辑、对象间通信与玩家

# 第16章 声音、游戏逻辑、对象间通信与玩家

在本章中,我们将快速为游戏实现声音功能。我们之前做过类似的事情,所以这并不难。实际上,只需编写几行代码,我们就能在声音功能中添加音乐播放。在项目后续阶段(但不是在本章),我们会添加定向(空间化)音效。不过这次,我们会把所有与声音相关的代码封装到一个名为SoundEngine的类中。实现声音功能后,我们将着手创建玩家角色。通过添加两个类,我们就能实现玩家角色的全部功能:一个类继承自Update,另一个类继承自Graphics。在整个游戏开发过程中,我们几乎都是通过继承这两个类来创建新的游戏对象。我们还会了解到对象如何通过指针简单地相互通信。本章完整的代码可以在Run2文件夹中找到。

简而言之,在本章中,我们将:

  • 编写SoundEngine类:编写一个与声音相关的类,该类还能循环播放音乐。
  • 编写游戏逻辑:编写一个处理所有游戏逻辑的类,并学习它如何与其他游戏对象进行通信。
  • 编写玩家相关代码:使用图形组件和更新组件编写玩家角色的第一部分代码。
  • 编写工厂类以使用所有新类:进一步编写工厂类,使其能够组装不同的游戏对象,并在它们之间共享适当的数据。
  • 运行游戏。

我们将从添加一个声音类开始。

# 编写SoundEngine类

你可能还记得,在上一个项目中,所有的声音代码占用了相当多行。现在想想,在第20章添加空间化音效时,我们还需要更多代码,代码量会变得更长。为了便于管理代码,我们将编写一个类来管理所有正在播放的音效和音乐。

所有这些代码都很常见。即使是播放音乐这个新功能,鉴于我们在其他游戏中的经验,也应该很容易理解。创建一个名为SoundEngine的新类。在SoundEngine.h文件中,添加以下代码:

#pragma once
#include <SFML/Audio.hpp>
using namespace sf;

class SoundEngine
{
private:
    static Music music;
    
    static SoundBuffer m_ClickBuffer;
    static Sound m_ClickSound;
    
    static SoundBuffer m_JumpBuffer;
    static Sound m_JumpSound;
    
public:
    SoundEngine();
    static SoundEngine* m_sInstance;
    
    static bool mMusicIsPlaying;

    static void startMusic();
    static void pauseMusic();
    static void resumeMusic();
    static void stopMusic();

    static void playClick();
    static void playJump();
};
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

在上述代码中,我们有一个SFML的Music对象、SoundBuffer,以及为每个要播放的音效准备的Sound对象。在类的公有部分,我们有用于启动、暂停、停止和恢复音乐的函数,还有两个用于播放每个音效的函数。一旦了解其工作原理,就可以轻松地为游戏添加任意数量的音效。

在SoundEngine.cpp类中,添加以下代码:

#include &quot;SoundEngine.h&quot;
#include <assert.h>

SoundEngine* SoundEngine::m_s_Instance = nullptr;
bool SoundEngine::mMusicIsPlaying = false;
Music SoundEngine::music;

SoundBuffer SoundEngine::m_ClickBuffer;
Sound SoundEngine::m_ClickSound;
SoundBuffer SoundEngine::m_JumpBuffer;
Sound SoundEngine::m_JumpSound;

SoundEngine::SoundEngine()
{
    assert(m_s_Instance == nullptr);
    m_s_Instance = this;
    m_ClickBuffer.loadFromFile(&quot;sound/click.wav&quot;);
    m_ClickSound.setBuffer(m_ClickBuffer);
    m_JumpBuffer.loadFromFile(&quot;sound/jump.wav&quot;);
    m_JumpSound.setBuffer(m_JumpBuffer);
}

void SoundEngine::playClick()
{
    m_ClickSound.play();
}

void SoundEngine::playJump()
{
    m_JumpSound.play();
}

void SoundEngine::startMusic()
{
    music.openFromFile(&quot;music/music.wav&quot;);
    m_s_Instance->music.play();
    m_s_Instance->music.setLoop(true);
    mMusicIsPlaying = true;
}

void SoundEngine::pauseMusic()
{
    m_s_Instance->music.pause();
    mMusicIsPlaying = false;
}

void SoundEngine::resumeMusic()
{
    m_s_Instance->music.play();
    mMusicIsPlaying = true;
}

void SoundEngine::stopMusic()
{
    m_s_Instance->music.stop();
    mMusicIsPlaying = false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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

音效的实现方式与之前的项目相同,只是现在我们将它们封装在了一个类中。在构造函数中加载缓冲区和声音,并将它们关联起来,相关的函数调用会在相应的Sound实例上播放音效。

让我们来探究一下音乐的工作原理。音乐实例没有缓冲区。从技术上讲,你可以将音乐文件加载到普通的Sound对象中,但由于音乐通常比音效长得多,这样做效果并不好。因此,SFML提供了Music类。在startMusic函数中,你可以看到我们使用了openFromFile函数。这会准备好文件以便进行流式传输,而不是一次性全部加载。然后,我们调用music.play函数,开始流式传输并播放音乐。接着,我们调用music.setLoop并传入true,这会使音乐不断重复播放。

在pauseMusic、resumeMusic和stopMusic函数中,我们分别调用了SFML提供的pause、play和stop函数。注意,我们还适当地设置了m_MusicIsPLaying布尔值,以便跟踪音乐的状态。

在项目接近尾声添加定向音效时,我们还需要为声音管理器添加更多代码,这样我们就能判断火球是从左边还是右边传来的。

# 编写游戏逻辑

为了控制游戏逻辑,我们将其封装在游戏中的一个游戏对象内,并与其他游戏对象建立必要的通信连接,实现双向信息传递。这种通信将通过指向关键值的指针来实现。例如,所有对象都将有一个指向与逻辑相关的游戏对象的指针,以便了解诸如游戏是否暂停等信息。

将游戏逻辑放在一个单独的类中,这个想法很有趣。假设你的游戏有三种不同的游戏模式。想象一下,如果我们将所有这些逻辑都整合到主函数中,会需要多少if、else和else if语句,那将是一团糟。通过这种方式,工厂可以根据玩家选择的游戏模式简单地挑选一个游戏对象。虽然这个游戏只有一种游戏模式,但一旦你看过代码,就会发现,在不同的类中创建一套不同的逻辑非常容易。

注意,这里不会有LevelGraphics类,因为我们不需要。在项目的后续阶段,当我们创建一个下雨特效的游戏对象时,会看到有一个继承自Graphics的RainGraphics类,但不需要派生自Update的对象。我们创建的大多数游戏对象都将有一个更新组件和一个基于图形的组件。关键在于,这是一个灵活的系统。

# 编写LevelUpdate类

创建一个新类LevelUpdate,它以Update为基类。在LevelUpdate.h中添加以下代码:

#pragma once
#include "Update.h"
using namespace sf;
using namespace std;

class LevelUpdate : public Update {
private:
    bool m_IsPaused = true;
    vector <FloatRect*> m_PlatformPositions;
    float* m_CameraTime = new float;
    FloatRect* m_PlayerPosition;
    float m_PlatformCreationInterval = 0;
    float m_TimeSinceLastPlatform = 0;
    int m_NextPlatformToMove = 0;
    int m_NumberOfPlatforms = 0;
    int m_MoveRelativeToPlatform = 0;
    bool m_GameOver = true;
    void positionLevelAtStart();
public:
    void addPlatformPosition(FloatRect* newPosition);
    void connectToCameraTime(float* cameraTime);
    bool* getIsPausedPointer();
    int getRandomNumber(int minHeight, int maxHeight);

    //  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

在上述代码中,有很多成员变量,如下所示:

  • m_IsPaused布尔变量用于跟踪游戏是否暂停。任何需要知道这个信息的游戏对象都可以获取指向这个值的指针。
  • m_PlatformPositions变量是一个向量,其中保存着指向FloatRect实例的指针。顾名思义,这些实例将保存游戏中所有平台的位置和大小。需要注意的是,这些指针一旦初始化,将直接指向与平台相关的游戏对象中的值。这意味着这个类可以直接操作平台。在编写平台相关代码时,我们会看到如何实现这一点。在编写这个类的函数时,我们也会看到如何操作这些平台的位置。
  • m_CameraTime变量是一个简单的浮点数。它表示当前游戏尝试运行的秒数,包括小数部分。这是衡量玩家是否成功的关键指标。很快,我们会在屏幕左上角显示这个时间。
  • m_PlayerPosition指针是一个指向FloatRect实例的指针,该实例保存着玩家的位置。由于它将直接指向与玩家相关的类,LevelUpdate类将能够根据玩家的当前位置做出决策,比如玩家是否落后太远导致游戏结束。
  • m_PlatformCreationInterval浮点型变量用于保存创建新平台之间的等待时间。很快我们会看到,我们不是创建新平台,而是重复使用一组平台。这个时间间隔将基于之前重复使用的/新平台的长度。这是合理的,因为玩家在某些平台上需要跑更远的距离,使时间间隔与平台长度相关似乎更公平。
  • m_TimeSinceLastPlatform浮点型变量与m_PlatformCreationInterval一起工作。当m_TimeSinceLastPlatform大于或等于m_PlatformCreationInterval时,就该在前一个平台前面创建/重复使用另一个平台了。
  • m_NextPlatformToMove整型变量表示下一个要重复使用的平台在平台位置向量中的位置。
  • m_NumberOfPlatforms整型变量是已创建的平台数量。代码可以处理非常小的数字,比如5,也可以处理大得多的数字,比如500。主要的区别在于,我们将在Factory类的loadLevel函数中使用既能保证游戏可玩性又能使代码高效运行的最小、最有效的数字。
  • m_MoveRelativeToPlatform整型变量是下一个平台相对于前一个平台在平台位置向量中的移动位置。想象一下,在最新的平台上跑到尽头,然后下一个平台及时出现。那么下一个平台需要相对于前一个平台处于可到达的位置。
  • m_GameOver布尔变量用于跟踪游戏是否结束,或者在程序首次执行时,游戏是否尚未开始。这与m_IsPaused是不同的。

现在让我们来了解一下这些函数:

  • 第一个是一个名为positionLevelAtStart的私有函数,它在每次游戏开始时设置所有游戏对象的初始位置。
  • addPlatformPosition函数接收一个名为newPosition的FloatRect指针,并将用于定位单个平台。
  • connectToCameraTime函数接收一个浮点型指针,该指针可以与m_CameraTime保持同步。通过这种机制,我们将更新屏幕上显示给玩家的时间文本。这个文本将在CameraGraphics类中使用SFML的Text实例绘制,我们将在下一章编写这个类。
  • getIsPausedPointer函数返回一个指向布尔值的指针。具体来说,它返回一个指向m_IsPaused变量的指针。这使得我们代码中任何需要知道游戏是否暂停的部分都可以访问这个信息。在项目的其余部分,我们会看到这个函数的实际作用,因为多个游戏对象都需要知道游戏是否暂停。
  • getRandomNumber函数接受两个值,并返回这两个值之间的一个随机数。在整个项目中,我们会经常看到类似这样的代码。在这个类中,这个函数最常见的用途是在重复使用平台时确定平台的位置。

最后,我们有两个从Update类继承并重写的函数:

  • update函数接收游戏最后一次循环执行所花费的时间。与我们其他游戏一样,这对于在update函数中为所有动作设置时间至关重要。
  • 如前所述,assemble函数将在工厂中用于准备组件以供使用。完成LevelUpdate类的编写后,我们将编写一些与玩家相关的类。然后,我们将看到如何在Factory类中使用assemble函数。

接下来,我们将把代码添加到LevelUpdate.cpp文件中。从LevelUpdate.h文件中可以看到,有不少函数。因此,我们将分几个部分添加并解释这些函数。在LevelUpdate.cpp文件中添加以下代码来开始:

#include "LevelUpdate.h"
#include<Random>
#include "SoundEngine.h"
#include "PlayerUpdate.h"
using namespace std;

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

    //temp
    SoundEngine::startMusic();
}

void LevelUpdate::connectToCameraTime(float* cameraTime) {
    m_CameraTime = cameraTime;
}

void LevelUpdate::addPlatformPosition(FloatRect* newPosition) {
    m_PlatformPositions.push_back(newPosition);
    m_NumberOfPlatforms++;
}

bool* LevelUpdate::getIsPausedPointer() {
    return &m_IsPaused;
}
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

在前面的代码中,我们添加了所需的包含指令。注意,包含指令和函数中存在错误,因为我们还没有编写玩家相关的代码。

在assemble函数中,我们调用了playerUpdate->getPositionPointer。这意味着当我们创建PlayerUpdate类时,还需要创建一个名为getPositionPointer的函数。我们很快就会这么做,但现在需要注意的是,LevelUpdate实例将始终能够获取玩家的位置。接下来,作为临时措施,我们调用startMusic,这样我们就能首次听到音乐。最终,一个与菜单相关的游戏对象将控制音乐的开始、停止和暂停。

在connectToCameraTime函数中,我们用cameraTime中包含的内存地址初始化m_CameraTime。在一段时间内我们实际上不会调用这个函数,但它已经准备好,等我们需要时就可以使用。

addPlatformPosition函数使用push_back将传入的平台位置添加到向量中。每次我们在工厂中创建一个新平台时,都会调用这个函数。我们还会增加m_NumberOfPlatforms变量的值,以记录我们拥有的平台数量。

getPausedPointer函数返回m_IsPaused布尔值的地址,为任何请求并保留返回地址的对象提供对游戏状态的永久访问权限。

接下来,在LevelUpdate.cpp文件中添加positionLevelAtStart函数:

Void LevelUpdate::positionLevelAtStart() 
{
    float startOffset = m_PlatformPositions[0]->left;
    for (int I = 0; i < m_NumberOfPlatforms; ++i)
    {
        m_PlatformPositions[i]->left = i * 100 + startOffset;
        m_PlatformPositions[i]->top = 0;
        m_PlatformPositions[i]->width = 100;
        m_PlatformPositions[i]->height = 20;
    }

    m_PlayerPosition->left =
        m_PlatformPositions[m_NumberOfPlatforms / 2]->left + 2;
    m_PlayerPosition->top =
        m_PlatformPositions[m_NumberOfPlatforms / 2]->top– - 22;

    m_MoveRelativeToPlatform = m_NumberOfPlatforms– - 1;
    m_NextPlatformToMove = 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在positionLevelAtStart函数中,第一行代码通过获取向量中第一个平台的左坐标来初始化一个名为startOffset的浮点型变量。接下来,代码从0到m_NumberOfPlatforms遍历向量中的所有平台。循环的每次迭代将一个平台水平放置在i * 100个单位加上起始偏移量的位置,垂直放置在0个单位处,宽度为100个单位,高度为20个单位。这本来可以在工厂中完成,但当玩家开始第二次游戏时,平台可能会到处都是。结果是所有平台首尾相连排成一条直线,大小和高度都没有变化。这就像是在位置开始随机化之前,给玩家的一个简单开场。

在for循环之外,接下来的两行代码使用[m_NumberOfPlatforms / 2]将玩家定位到向量中大约中间平台的左边缘。水平方向的神奇数字+2和垂直方向的 - 22是为了确保玩家的脚稳稳地站在这个平台上。在你了解了我们如何编写Factory类之后,欢迎改进这段代码。

接下来的一行代码将m_MoveRelativeToPlatform初始化为向量中的最后一个平台。这是有意义的,因为我们希望在最右边平台的右边缘之外继续放置新平台。最后一行代码将向量中的第一个平台设置为下一个要移动的候选平台。这意味着最左边的平台将被移动到最右边,而玩家将在中间生成。

接下来,添加getRandomNumber函数。这个函数正如其名所示,在代码中的几个地方,当我们想要生成传入的两个值之间的随机值时,会用到它。在LevelUpdate.cpp文件中添加以下代码:

int LevelUpdate::getRandomNumber(int minHeight, int maxHeight) 
{
    #include <random>
    // Seed  the  random  number  generator  with  current  time

    random_device rd;
    mt19937 gen(rd());

    // Define  a  uniform  distribution for  the  desired
    uniform_int_distribution<int>
    distribution(minHeight, maxHeight);

    //  Generate  a  random  height  within  the  specified
    int randomHeight = distribution(gen);

    return randomHeight;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

与我们在之前的游戏中使用的方法相比,这个函数是一种更现代的生成随机数的方式。

第一行创建了一个随机设备对象rd,用于为随机数生成器播种。随机设备是一个非确定性随机数源,通常基于硬件值。这比我们之前使用的方法可靠得多。

接下来,用随机设备的种子(rd)初始化一个梅森旋转伪随机数生成器(mt19937)。梅森旋转算法是一种广泛使用的生成高质量随机数的算法。

接下来,一个名为distribution的uniform_int_distribution实例创建了一个均匀分布对象,用于在指定范围内(包括minHeight到maxHeight)生成整数。uniform_int_distribution类确保范围内的每个整数被选中的概率相等。

代码distribution(gen)使用前面定义的分布和梅森旋转生成器生成一个随机整数。结果存储在randomHeight变量中。

最后,将随机生成的数字返回给调用代码。你只需要记住,如果你调用这个函数,你将得到一个介于传入的两个值之间的真正随机值。

LevelUpdate类的最后一个函数是update函数。回想一下,update函数由GameObject类每帧调用一次,而GameObject类又由游戏循环每帧调用一次。这是一个相对复杂的函数,处理整个游戏逻辑。在添加代码时,试着研究它的结构,然后我们再讨论它是如何工作的。

将update函数添加到LevelUpdate类中。我们将分解这段代码并进行讨论,但我建议一次性复制粘贴或编写整个函数,因为如果分段编写,很容易混淆结构:

void LevelUpdate::update(float timeSinceLastUpdate) 
{
    if (!m_IsPaused)
    {
        if (m_GameOver) {
            m_GameOver = false;
            *m_CameraTime = 0;
            m_TimeSinceLastPlatform = 0;
            int platformToPlacePlayerOn;
            positionLevelAtStart();
        }

        *m_CameraTime += timeSinceLastUpdate;
        m_TimeSinceLastPlatform += timeSinceLastUpdate;

        if (m_TimeSinceLastPlatform > m_PlatformCreationInterval) {
            m_PlatformPositions[m_NextPlatformToMove]->top =
                m_PlatformPositions[m_MoveRelativeToPlatform]->top + getRandomNumber(-40, 40);

            // How far  away  to  create  the  next platform
            //  Bigger  gap  if  lower  than previous
            if (m_PlatformPositions[m_MoveRelativeToPlatform]->top < m_PlatformPositions[m_NextPlatformToMove]->top)
            {
                m_PlatformPositions[m_NextPlatformToMove]->left = m_PlatformPositions[m_MoveRelativeToPlatform]-
                    >left +
                    m_PlatformPositions[m_MoveRelativeToPlatform]- >width +
                    getRandomNumber(20, 40);
            }
            else
            {
                m_PlatformPositions[m_NextPlatformToMove]->left = m_PlatformPositions[m_MoveRelativeToPlatform]-
                    >left +
                    m_PlatformPositions[m_MoveRelativeToPlatform]- >width +
                    getRandomNumber(0, 20);
            }

            m_PlatformPositions[m_NextPlatformToMove]->width = getRandomNumber(20, 200);

            m_PlatformPositions[m_NextPlatformToMove]->height = getRandomNumber(10, 20);

            //  Base  the  time  to  create  the  next platform
            //  on  the  width  of  the  one just  created
            m_PlatformCreationInterval =
                m_PlatformPositions[m_NextPlatformToMove]->width / 90;

            m_MoveRelativeToPlatform = m_NextPlatformToMove;
            m_NextPlatformToMove++;
            if (m_NextPlatformToMove == m_NumberOfPlatforms) {
                m_NextPlatformToMove = 0;
            }

            m_TimeSinceLastPlatform = 0;
        }

        // Has  the player  lagged  behind  the furthest  back platform
        bool laggingBehind = true;
        for (auto platformPosition : m_PlatformPositions) {
            if (platformPosition->left < m_PlayerPosition->left) {
                laggingBehind = false;
                break;// At  least  one platform  is  behind  the player
            }
            else
            {
                laggingBehind = true;
            }
        }

        if (laggingBehind) {
            m_IsPaused = true;
            m_GameOver = true;
            SoundEngine::pauseMusic();
        }
    }
}
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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

我们将把这段冗长的代码分成几个部分,但请务必完整地研究它,尤其要注意其中的if语句和循环结构,这样下面的讨论就更容易理解。update函数在timeSinceLastUpdate变量中接收主游戏循环上一次迭代执行所花费的时间。

update函数的第一部分设置了以下结构,该结构仅在游戏未暂停时运行。我们讨论的其他所有内容都在这个if语句内部发生,这意味着游戏暂停时不会发生任何事情:

if (!m_IsPaused) {
1

在update函数中,接下来的代码仅在游戏结束时运行——换句话说,当玩家刚刚运行应用程序,或者刚刚死亡且尚未重新开始时:

if (m_GameOver) {
    m_GameOver = false;
    *m_CameraTime = 0;
    m_TimeSinceLastPlatform = 0;
    positionLevelAtStart();
}
1
2
3
4
5
6

前面的代码将m_GameOver设置为false,重置计时器,重置自上一个平台生成以来的时间,并调用我们已经讨论过的positionLevelAtStart函数。这一整块代码的最终效果是只会运行一次,并且在完成其余代码后,它会完成启动新游戏所需的所有操作。

在update函数中,接下来有一个if语句,如下所示:

*m_CameraTime += timeSinceLastUpdate;
m_TimeSinceLastPlatform += timeSinceLastUpdate;

if (m_TimeSinceLastPlatform > m_PlatformCreationInterval) {
    m_PlatformPositions[m_NextPlatformToMove]->top =
        m_PlatformPositions[m_MoveRelativeToPlatform]->top + getRandomNumber(-40, 40);
1
2
3
4
5
6

在前面的代码中,m_CameraTime变量增加了自上一次执行update以来经过的时间。这是最终将显示给玩家的时间。m_TimeSinceLastPlatform也以同样的方式增加。

接下来,有一个if语句,当m_TimeSinceLastPlatform超过m_PlatformCreationInterval时执行。换句话说,是时候将一个平台从玩家后面移动到玩家前面了。然后,玩家后面最远的平台(m_NextPlatformToMove)相对于玩家前面最远的平台(m_MoveRelativeToPlatform)进行随机定位,但此时仅调整高度。

同样,在前面的if语句中,有一个if - else结构来处理水平定位。我们再来看一下:

// How far  away  to  create  the  next platform
//  Bigger  gap  if  lower  than previous
if (m_PlatformPositions[m_MoveRelativeToPlatform]->top < m_PlatformPositions[m_NextPlatformToMove]->top)
{
    m_PlatformPositions[m_NextPlatformToMove]->left =
        m_PlatformPositions[m_MoveRelativeToPlatform]->left + m_PlatformPositions[m_MoveRelativeToPlatform]
        ->width + getRandomNumber(20, 40);
}
else
{
    m_PlatformPositions[m_NextPlatformToMove]->left = m_PlatformPositions[m_MoveRelativeToPlatform]
        ->left +
        m_PlatformPositions[m_MoveRelativeToPlatform]
        ->width + getRandomNumber(0, 20);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在前面的if - else语句中,if部分检查上一行代码将下一个平台垂直定位的距离。如果它更低,那么执行与if相关的代码;如果它更高,那么执行与else相关的代码。与else相关的代码在水平方向上使用较小的值来间隔平台,这是有意义的,因为如果平台在玩家所在平台的上方,可跳跃的距离会更小。

接下来,执行以下代码:

m_PlatformPositions[m_NextPlatformToMove]->width = getRandomNumber(20, 200);

m_PlatformPositions[m_NextPlatformToMove]->height = getRandomNumber(10, 20);

//  Base  the  time  to  create  the  next platform
//  on  the  width  of  the  one just  created
m_PlatformCreationInterval =
    m_PlatformPositions[m_NextPlatformToMove]->width
    / 90;

m_MoveRelativeToPlatform = m_NextPlatformToMove;
m_NextPlatformToMove++;
if (m_NextPlatformToMove == m_NumberOfPlatforms) 
{
    m_NextPlatformToMove = 0;
}

m_TimeSinceLastPlatform = 0;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

在前面的代码中,为新平台选择一个随机宽度,然后选择一个随机高度,然后根据随机选择的宽度初始化一个时间量并赋值给m_PlatformCreationInterval。下一行增加要移动的下一个平台在向量中的位置,后面的if语句检查该值是否超出向量中的最后一个位置,如果是,则将该值更改为零(向量中的第一个条目)。

上面的最后一行代码将m_TimeSinceLastPlatform设置为零,这样我们就可以不断累加每次循环所花费的时间,直到最终移动另一个平台,然后再次重复所有操作。

此时,我们关闭if (m_TimeSinceLastPlatform > m_PlatformCreationInterval)块的花括号。

接着完成if(!m_Paused)代码和整个update函数,以下代码检查消失的平台是否追上了玩家(因此玩家游戏失败):

// Has  the player  lagged  behind  the furthest  back platform
bool laggingBehind = true;
for (auto platformPosition : m_PlatformPositions) 
{
    if (platformPosition->left < m_PlayerPosition->left) 
    {
        laggingBehind = false;
        break;// At  least  one platform  is  behind  the player
    }
    else
    {
        laggingBehind = true;
    }
}

if (laggingBehind) 
{
    m_IsPaused = true;
    m_GameOver = true;
    SoundEngine::pauseMusic();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

在前面的代码中,将laggingBehind布尔值设置为true。接下来,for循环遍历每个平台位置,检查是否有任何平台的左坐标小于(因此在玩家后面)玩家的左坐标。如果有任何一个平台在玩家后面,那么玩家还有机会,laggingBehind布尔值将被设置为false。

如果laggingBehind变量仍然设置为true,则意味着所有平台都在玩家前面,游戏结束。如果laggingBehind设置为true,则游戏暂停,m_GameOver变量设置为true,音乐暂停。

很快,我们将编写一个菜单,允许玩家在失败后重新开始游戏。

最后,在update函数中,我们关闭决策结构中剩余的花括号(不再显示)。

我们已经完成了对update函数的解释和编码,但我们还不能运行代码,因为存在引用PlayerUpdate类的错误。此外,我们还没有创建任何LevelUpdate类的实例。

接下来,我们将通过从名为Update的类派生出一个对象(即PlayerUpdate),从名为Graphics的类派生出一个对象(即PlayerGraphics),来编写玩家角色的基本代码。然后,为了完成本章的代码,我们将在工厂中添加代码,组装所有这些不同的组件,并将它们放入GameObject实例中,这样我们就可以在游戏的每一帧中

# 为玩家角色编写代码:第1部分

在本节中,我们将开始创建可控制的玩家角色。我们会让角色在屏幕上可见,但之后还会回到PlayerUpdate和PlayerGraphics类,添加键盘控制和动画效果。

创建两个新类:PlayerUpdate(以Update为基类)和PlayerGraphics(以Graphics为基类)。在接下来的两部分内容完成后,我们将得到一个可见但功能尚未完全完善的玩家角色。

# 为PlayerUpdate类编写代码

让我们从PlayerUpdate类的定义开始。在PlayerUpdate.h中添加以下代码:

#pragma once

#include "Update.h"

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

using namespace sf;

class PlayerUpdate : public Update
{
private:
    const float PLAYER_WIDTH = 20.f;  
    const float PLAYER_HEIGHT = 16.f;  
    FloatRect m_Position;

    bool* m_IsPaused = nullptr;  
    float m_Gravity = 165;
    float m_RunSpeed = 150;  
    float m_BoostSpeed = 250;

    InputReceiver m_InputReceiver;

    Clock m_JumpClock;
    bool m_SpaceHeldDown = false;  
    float m_JumpDuration = .50;  
    float m_JumpSpeed = 400;

public:
    bool m_RightIsHeldDown = false;  
    bool m_LeftIsHeldDown = false;  
    bool m_BoostIsHeldDown = false;

    bool m_IsGrounded;
    bool m_InJump = false;

    FloatRect* getPositionPointer();

    bool* getGroundedPointer();  
    void handleInput();
    InputReceiver* getInputReceiver();

    // 来自Update:Component
    void assemble(
        shared_ptr<LevelUpdate> levelUpdate,  
        shared_ptr<PlayerUpdate> playerUpdate) override;

    void update(float fps) 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
36
37
38
39
40
41
42
43
44
45
46
47
48
49

在前面的代码中,我们有很多变量和五个函数。下面逐一介绍:

  • 名为PLAYER_WIDTH的float常量定义了玩家在游戏单位中的宽度,而名为PLAYER_HEIGHT的float常量变量定义了玩家在游戏单位中的高度。
  • 名为m_Position的FloatRect实例将保存玩家的位置。我们很快会看到,这个实例将与任何需要它的游戏实体共享。平台会用它来进行碰撞检测,在上一节中我们看到LevelUpdate类用它来判断玩家是否落后于平台到了游戏结束的程度。
  • 布尔指针m_IsPaused将用于连接到LevelUpdate类中用于暂停游戏的变量。
  • float变量m_Gravity用于在玩家没有站在平台上时将其向下拉,并调节向上推进的力。float变量m_RunSpeed是玩家在平台上接地时向左或向右移动的速度。float变量m_BoostSpeed是玩家在推进时向上移动的速度。
  • 名为m_InputReceiver的InputReceiver实例是InputReceiver类的一个实例。很快我们将看到Factory类如何将m_InputReceiver连接到InputDispatcher,从而使PlayerUpdate类能够访问键盘和鼠标的所有事件。
  • Clock实例m_JumpClock、布尔值m_SpaceHeldDown、float变量m_JumpDuration和float变量m_JumpSpeed都是用于调节玩家跳跃距离和时间的值。
  • 私有部分的第一个变量是布尔值m_RightIsHeldDown,后面还有更多布尔值,m_LeftIsHeldDown、m_BoostIsHeldDown、m_IsGrounded和m_InJump。所有这些值都可以根据玩家与键盘的交互进行设置和取消设置。然后可以在update函数中对这些值做出响应。
  • getPositionPointer函数返回一个FloatRect指针,为任何需要的其他类提供对m_Position的访问。需要的类会在工厂中调用这个函数。
  • getGroundedPointer函数返回一个指向布尔值的指针,该指针共享由m_IsGrounded布尔变量确定的玩家当前是否接地的信息。
  • handleInput函数将使用InputReceiver实例处理在主游戏循环中每一帧从InputDispatcher实例接收到的所有输入数据。
  • getInputReceiver函数返回一个指向InputReceiver实例的指针。实现这个函数只需要一行代码,但它至关重要,因为它允许主函数中的InputDispatcher实例与PlayerUpdate类共享所有事件。
  • assemble函数是我们对Update类中纯虚函数的实现。参数是shared_ptr<LevelUpdate> levelUpdate和shared_ptr<PlayerUpdate> playerUpdate。这意味着我们可以通过调用LevelUpdate类的任何公共函数来为PlayerUpdate类的运行做准备。当然,将PlayerUpdate传递给自己是没有必要的,但这是实现最简单版本的实体组件系统的一种表现。
  • update函数是我们对Update类中纯虚函数的实现,它只是接收游戏循环执行所花费的时间。在函数实现中如何利用这个时间会更有意思。

由于我们要在PlayerUpdate.cpp中添加相当多的代码,所以我们分三步来完成。首先,在PlayerUpdate.cpp中添加以下代码:

#include "PlayerUpdate.h" 
#include "SoundEngine.h"

#include "LevelUpdate.h"

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

bool* PlayerUpdate::getGroundedPointer() {
    return &m_IsGrounded; 
}

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

在PlayerUpdate代码的第一部分,我们添加了所需的包含指令。getPositionPointer函数返回保存玩家位置的FloatRect实例的地址。getGroundedPointer函数返回检测玩家当前是否站在平台上的布尔值的地址。有趣的是,平台将使用这个指针来确定并设置这个布尔值,而PlayerGraphics类将使用这个指针的值来做出关于动画的决策。getInputReceiver函数返回一个指向InputReceiver实例的指针,使InputDispatcher能够连接并发送所有所需的事件数据。第二步,添加以下代码:

void PlayerUpdate::assemble(
    shared_ptr<LevelUpdate> levelUpdate,
    shared_ptr<PlayerUpdate> playerUpdate) 
{
    SoundEngine::SoundEngine();

    m_Position.width = PLAYER_WIDTH;  
    m_Position.height = PLAYER_HEIGHT;
    m_IsPaused = levelUpdate->getIsPausedPointer(); 
}
1
2
3
4
5
6
7
8
9
10

在PlayerUpdate代码的第二部分,我们编写了assemble函数。注意,如前所述,PlayerUpdate参数未被使用。SoundEngine类被初始化并准备播放一些声音,位置和高度被初始化,最有意思的是,LevelUpdate的共享指针被用于调用getIsPausedPointer函数并初始化m_IsPaused。现在PlayerUpdate类可以随时检查游戏是否被暂停。

接下来,你需要为PlayerUpdate.cpp添加第三部分也是最后一部分代码:

void PlayerUpdate::handleInput() 
{
    m_InputReceiver.clearEvents(); 
}

void PlayerUpdate::update(float timeTakenThisFrame) 
{
    handleInput(); 
}
1
2
3
4
5
6
7
8
9

在我们添加的PlayerUpdate.cpp代码的第三部分也是最后一部分中,handleInput函数调用m_InputReceiver实例的clearEvents函数。这为循环的下一次迭代清除了事件。这目前没有实际作用,因为我们还没有读取任何事件,但我们将在第18章处理这个问题。最后,我们添加了update函数。它目前所做的只是调用我们刚刚编写的函数;不过,在第18章中,我们将编写一个功能齐全且响应灵敏的玩家角色。

# 为PlayerGraphics类编写代码

我们已经为玩家角色搭建了基本的行为框架。接下来,我们将通过用PlayerGraphics类扩展Graphics类来开始编写角色的外观代码。和PlayerUpdate类一样,我们先从基础部分开始,随着项目的推进再逐步完善。在PlayerGraphics.h中添加以下代码:

#pragma once

#include "Graphics.h"

// 我们很快会回到这里 
//class Animator;
class PlayerUpdate;

class PlayerGraphics : public Graphics {
private:
    FloatRect* m_Position = nullptr;  
    int m_VertexStartIndex = -999;

    // 我们很快会回到这里 
    //Animator* m_Animator;
    IntRect* m_SectionToDraw = new IntRect;
    IntRect* m_StandingStillSectionToDraw = new IntRect; 
    std::shared_ptr<PlayerUpdate> m_PlayerUpdate;

    const int BOOST_TEX_LEFT = 536;  
    const int BOOST_TEX_TOP = 0;
    const int BOOST_TEX_WIDTH = 69;  
    const int BOOST_TEX_HEIGHT = 100;

    bool m_LastFacingRight = true; 

public:
    // 来自Component:Graphics
    void assemble(VertexArray& canvas,
        shared_ptr<Update> genericUpdate, IntRect texCoords) override;

    void draw(VertexArray& canvas) 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

注意有几处对Animator类的注释引用。在后面的第18章中,我们将为玩家添加动画,使其看起来像是在奔跑。我们还将为火球上的火焰添加动画。为了尽快让代码能够无错误运行,前面的代码将Animator类注释掉了。

在前面的代码中,我们有以下变量和函数声明。下面逐一介绍:

  • FloatRect变量m_Position被设置为nullptr。它将表示玩家的位置。int变量m_VertexStartIndex将保存代表玩家的四边形在VertexArray中的起始位置。所以,当要移动玩家时,我们就知道我们感兴趣的顶点是m_VertexStartIndex、m_VertexStartIndex + 1、m_VertexStartIndex + 2和m_VertexStartIndex + 3。
  • m_SectionToDraw变量是一个IntRect指针。它将保存玩家当前动画帧在纹理图集(texture atlas)中的整数纹理坐标。Animate类将根据需要操作这些值。我们将在第18章编写Animate类。
  • IntRect指针m_StandingStillSectionToDraw将保存玩家不奔跑时的纹理坐标。
  • shared_ptr<PlayerUpdate> m_PlayerUpdate变量是一个指向PlayerUpdate实例的指针。持有指向PlayerUpdate实例的指针将使这个类能够调用PlayerUpdate类的所有公共函数并读取其所有公共变量。
  • 常量int类型的BOOST_TEX_LEFT、BOOST_TEX_TOP、BOOST_TEX_WIDTH和BOOST_TEX_HEIGHT被初始化为纹理图集中代表玩家推进的动画帧的坐标。
  • 布尔值m_LastFacingRight被初始化为true,它将跟踪玩家转向的方向。这在制作动画时会用到。
  • assemble函数是对Graphics类中assemble函数的重写实现。assemble函数接收一个名为canvas的VertexArray引用和一个名为genericUpdate的shared_ptr<Update>。看看我们如何处理genericUpdate会很有意思。在每个不同的Update派生类中,我们将看到如何将其转换为所需的特定Update变体,从而提供对其公共函数的访问。assemble函数还接收一个名为texCoords的IntRect实例,它将保存纹理图集中图形的纹理坐标。
  • draw函数接收VertexArray的引用,并且每一帧都会被调用。这使得draw函数能够在游戏的每一帧中根据需要处理移动顶点或纹理坐标。

让我们编写所有这些函数,并开始使用我们一直在讨论的变量。在PlayerGraphics.cpp中添加以下代码:

#include "PlayerGraphics.h" 
#include "PlayerUpdate.h"

void PlayerGraphics::assemble(
    VertexArray& canvas,
    shared_ptr<Update> genericUpdate, IntRect texCoords)
{
    m_PlayerUpdate =
        static_pointer_cast<PlayerUpdate>(genericUpdate);
    m_Position =
        m_PlayerUpdate->getPositionPointer();

    m_VertexStartIndex = canvas.getVertexCount();
    canvas.resize(canvas.getVertexCount() + 4);

    canvas[m_VertexStartIndex].texCoords.x = texCoords.left;
    canvas[m_VertexStartIndex].texCoords.y =
        texCoords.top;
    canvas[m_VertexStartIndex + 1].texCoords.x =
        texCoords.left + texCoords.width;
    canvas[m_VertexStartIndex + 1].texCoords.y =
        texCoords.top;
    canvas[m_VertexStartIndex + 2].texCoords.x =
        texCoords.left + texCoords.width;
    canvas[m_VertexStartIndex + 2].texCoords.y =
        texCoords.top + texCoords.height;
    canvas[m_VertexStartIndex + 3].texCoords.x =
        texCoords.left;
    canvas[m_VertexStartIndex + 3].texCoords.y =
        texCoords.top + texCoords.height;
}

void PlayerGraphics::draw(VertexArray& canvas) {
    const Vector2f& position =
        m_Position->getPosition();
    const Vector2f& scale =
        m_Position->getSize();

    canvas[m_VertexStartIndex].position = position;
    canvas[m_VertexStartIndex + 1].position =
        position + Vector2f(scale.x, 0);
    canvas[m_VertexStartIndex + 2].position =
        position + scale;
    canvas[m_VertexStartIndex + 3].position =
        position + Vector2f(0, scale.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
38
39
40
41
42
43
44
45
46

前面的一些代码可能看起来很眼熟,因为我们正在为一个SFML的VertexArray实例分配顶点和纹理坐标,就像我们在僵尸游戏中为背景所做的那样。我们在每个Graphics派生类中都会做这样或类似的事情。

不过,这里我们的工作环境与僵尸游戏不同,所以让我们把刚刚添加的代码分成四个部分,来看看它们是如何工作的。

首先,我们有这个函数签名:

void PlayerGraphics::assemble(
    VertexArray& canvas,
    shared_ptr<Update> genericUpdate, IntRect texCoords)
{
    …
    …
}
1
2
3
4
5
6
7

我们正在看的PlayerGraphics.cpp的第一部分是assemble函数的签名。提醒一下,如前所述,assemble函数是对Graphics类中assemble函数的重写实现。assemble函数接收一个名为canvas的VertexArray引用和一个名为genericUpdate的shared_ptr<Update>。assemble函数还接收一个名为texCoords的IntRect实例,它将保存纹理图集中图形的纹理坐标。

其次,在assemble函数的花括号内,我们有以下代码:

m_PlayerUpdate =
    static_pointer_cast<PlayerUpdate>(genericUpdate);
m_Position =
    m_PlayerUpdate->getPositionPointer();

m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);

canvas[m_VertexStartIndex].texCoords.x = texCoords.left;
canvas[m_VertexStartIndex].texCoords.y = texCoords.top;
canvas[m_VertexStartIndex + 1].texCoords.x = texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 1].texCoords.y = texCoords.top;
canvas[m_VertexStartIndex + 2].texCoords.x = texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 2].texCoords.y = texCoords.top + texCoords.height;
canvas[m_VertexStartIndex + 3].texCoords.x = texCoords.left;
canvas[m_VertexStartIndex + 3].texCoords.y = texCoords.top + texCoords.height;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

PlayerGraphics.cpp文件的第二部分(上述代码)在assemble函数的代码中,使用static_pointer_cast函数将基类Update实例转换为子类PlayerUpdate实例,然后将结果保存到m_PlayerUpdate中。

接下来,我们通过调用canvas.getVertexCount初始化m_VertexStartIndex,然后通过调用canvas.resize为顶点数组增加足够的空间来容纳另一个四边形。接着,在接下来的八行代码中,我们使用作为参数传入的IntRect texCoords来初始化VertexArray中所有玩家角色的纹理坐标。

第三点,也是最后一点,我们有以下代码:

void PlayerGraphics::draw(VertexArray& canvas) {
    const Vector2f& position =
        m_Position->getPosition();
    const Vector2f& scale =
        m_Position->getSize();

    canvas[m_VertexStartIndex].position =
        position;
    canvas[m_VertexStartIndex + 1].position =
        position + Vector2f(scale.x, 0);
    canvas[m_VertexStartIndex + 2].position =
        position + scale;
    canvas[m_VertexStartIndex + 3].position =
        position + Vector2f(0, scale.y);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

PlayerGraphics.cpp文件的第三部分,也是最后一部分是draw函数。目前,我们只是使用m_Position.getPosition函数初始化一个名为position的Vector2f实例,并使用getSize函数初始化一个名为scale的Vector2f实例。然后,我们使用position和scale来设置玩家角色在VertexArray中的顶点位置。

一旦我们添加了一些与相机相关的类,以便正确地看到玩家,并且添加了玩家可以在上面奔跑的平台,我们将完成PlayerUpdate类的更新和输入处理,以及PlayerGraphics类的动画和Animator类(动画器类)的编写。

# 编写工厂类以使用我们所有的新类

工厂(Factory)类是一个重要的类。在这里,我们将创建所有指向派生的Update和Graphics实例的智能指针。我们将调用所有的构造函数和assemble函数的实现,同时共享我们一直在编写的各种所需指针。例如,在工厂类中,我们将把玩家的指针和平台的位置共享给LevelUpdate实例。

# 记住纹理坐标

首先,我们将在Factory.h文件中添加代码。在Factory.h文件的私有部分添加以下变量:

const int PLAYER_TEX_LEFT = 0;
const int PLAYER_TEX_TOP = 0;
const int PLAYER_TEX_WIDTH = 80;
const int PLAYER_TEX_HEIGHT = 96;

const float CAM_VIEW_WIDTH = 300.f;

const float CAM_SCREEN_RATIO_LEFT = 0.f;
const float CAM_SCREEN_RATIO_TOP = 0.f;
const float CAM_SCREEN_RATIO_WIDTH = 1.f;
const float CAM_SCREEN_RATIO_HEIGHT = 1.f;

const int CAM_TEX_LEFT = 610;
const int CAM_TEX_TOP = 36;
const int CAM_TEX_WIDTH = 40;
const int CAM_TEX_HEIGHT = 30;

const float MAP_CAM_SCREEN_RATIO_LEFT = 0.3f;
const float MAP_CAM_SCREEN_RATIO_TOP = 0.84f;
const float MAP_CAM_SCREEN_RATIO_WIDTH = 0.4f;
const float MAP_CAM_SCREEN_RATIO_HEIGHT = 0.15f;

const float MAP_CAM_VIEW_WIDTH = 800.f;
const float MAP_CAM_VIEW_HEIGHT = MAP_CAM_VIEW_WIDTH / 2;

const int MAP_CAM_TEX_LEFT = 665;
const int MAP_CAM_TEX_TOP = 0;
const int MAP_CAM_TEX_WIDTH = 100;
const int MAP_CAM_TEX_HEIGHT = 70;

const int PLATFORM_TEX_LEFT = 607;
const int PLATFORM_TEX_TOP = 0;
const int PLATFORM_TEX_WIDTH = 10;
const int PLATFORM_TEX_HEIGHT = 10;

const int TOP_MENU_TEX_LEFT = 770;
const int TOP_MENU_TEX_TOP = 0;
const int TOP_MENU_TEX_WIDTH = 100;
const int TOP_MENU_TEX_HEIGHT = 100;

const int RAIN_TEX_LEFT = 0;
const int RAIN_TEX_TOP = 100;
const int RAIN_TEX_WIDTH = 100;
const int RAIN_TEX_HEIGHT = 100;
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

在项目的后续过程中,我们将使用所有这些新的常量值。这些变量代表纹理图集(texture atlas)中所有图像的纹理坐标。通常,你会从文件中加载这些值,但这样设置也能满足我们的需求。

最后,为了使用这些新类,我们将在工厂类中实例化并配置它们。

在Factory.cpp文件中添加以下突出显示的指令,这样我们就可以使用新创建的类:

#include "Factory.h"
#include "LevelUpdate.h"
#include "PlayerGraphics.h"
#include "PlayerUpdate.h"
#include "InputDispatcher.h"
1
2
3
4
5

在Factory.cpp文件的loadLevel函数中添加以下代码,在GameObject实例中实例化一个LevelUpdate实例:

// 构建一个关卡游戏对象
GameObject level;
shared_ptr<LevelUpdate> levelUpdate = make_shared<LevelUpdate>();
level.addComponent(levelUpdate);
gameObjects.push_back(level);
1
2
3
4
5

前面这段代码有很多值得讨论的地方,但正如我们将看到的,它并不像乍看起来那么复杂。

这段代码创建了一个名为level的新GameObject实例。接下来,我们创建一个LevelUpdate类型的共享指针。然后,我们在level上调用addComponent函数,并传入LevelUpdate实例level。最后,我们在gameObjects向量上调用push_back函数。这是重要的一步,因为这意味着我们终于有了一个可以在游戏循环的每一帧中进行遍历的GameObject实例。细心的读者可能已经注意到,我们还没有调用assemble函数。我们很快就会讲到。

接下来,在loadLevel函数中添加以下代码,在GameObject实例中实例化一个PlayerGraphics和一个PlayerUpdate实例:

// 构建一个玩家对象
GameObject player;
shared_ptr<PlayerUpdate> playerUpdate = make_shared<PlayerUpdate>();
playerUpdate->assemble(levelUpdate, nullptr);
player.addComponent(playerUpdate);

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

shared_ptr<PlayerGraphics> playerGraphics = make_shared<PlayerGraphics>();
playerGraphics->assemble(canvas, playerUpdate,
    IntRect(PLAYER_TEX_LEFT, PLAYER_TEX_TOP, PLAYER_TEX_WIDTH, PLAYER_TEX_HEIGHT));
player.addComponent(playerGraphics);
gameObjects.push_back(player);

// 让LevelUpdate知道玩家的存在
levelUpdate->assemble(nullptr, playerUpdate);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

在前面的代码中,我们创建了另一个名为player的GameObject实例和一个名为playerUpdate的PlayerUpdate共享指针。我们在playerUpdate上调用assemble函数,并传入所需的参数。这些所需参数是LevelUpdate共享指针,但在应该传入PlayerUpdate指针的地方我们传入了nullptr。如前所述,这是因为我们使用的实体组件系统(Entity Component system)做了简化。显然,PlayerUpdate类不需要自身的副本。

然后,我们在player上调用addComponent函数,并在InputDispatcher实例上调用registerNewInputReceiver。注意,我们通过调用PlayerUpdate实例的getInputReceiver来传入所需的值。此时,我们不仅在gameObjects向量中有了另一个几乎准备好在游戏循环中进行迭代的GameObject实例,而且还建立了与SFML提供的所有操作系统事件的连接。现在我们可以继续处理PlayerGraphics类实例。

接下来,我们实例化一个PlayerGraphics实例,并调用assemble函数,传入VertexArray、LevelUpdate实例和纹理坐标。现在,我们使用addComponent函数将GraphicsComponent实例添加到GameObject实例中,并调用push_back将玩家角色的GameObject添加到gameObjects向量中。

最后一行代码在levelUpdate上调用assemble函数,因为之前由于PlayerUpdate实例还不存在,我们无法调用。这是工厂类应该具备的知识。

# 运行游戏

如果此时运行游戏,我们仍然会看到空白的灰色屏幕。这是因为我们没有绘制VertexArray。在下一章中,我们将学习如何绘制VertexArray两次,以创建常规视图和小地图。我们将通过编写一些类来表示游戏的相机或视图来实现这一点。目前,只需在Run.cpp的main函数中,在调用window.display之前添加以下突出显示的代码行:

// 在下一章之前的临时代码
window.draw(canvas,  factory.m_Texture);

// 显示新的一帧。
window.display();
1
2
3
4
5

现在,如果你运行游戏并仔细观察,非常仔细地观察,在屏幕的左上角,你几乎可以看到一个微小的、静止的玩家图形。我没有提供截图,因为它太小了。继续阅读以找到解决方案。你可能还注意到有一段短音乐在循环播放。如果你希望在后续测试代码时安静地工作,只需从LevelUpdate assemble函数中删除这两行代码,反正它们只是用于测试目的:

//temp
SoundEngine::startMusic();
1
2

当我们为游戏编写菜单时,我们将正确处理音乐的播放和停止。

如果你想更清楚地查看玩家图形,可以临时编辑PlayerUpdate.cpp文件中的assemble函数,增大玩家的尺寸,如以下两行突出显示的代码所示:

void PlayerUpdate::assemble(
    shared_ptr<LevelUpdate> levelUpdate,
    shared_ptr<PlayerUpdate> playerUpdate) {
    SoundEngine::SoundEngine();

    m_Position.width   =   PLAYER_WIDTH   *   10;
    m_Position.height   =   PLAYER_HEIGHT   *  10;

    m_IsPaused = levelUpdate->getIsPausedPointer();
}
1
2
3
4
5
6
7
8
9
10

运行游戏,你可以清楚地看到玩家在屏幕的左上角,如下图所示:

img 图16.1:放大后的玩家图像

一定要从之前编辑的两行代码中删除* 10。

我们本可以很容易地添加一些代码来使玩家角色可控制,但这一章已经有点长了,我们将在几章之后,当我们的相机游戏对象可以正常工作,并且能更好地看到玩家时,再进行这一操作 。

# 总结

在本章中,我们取得了很多成果。我们编写了一个与声音相关的类,它还可以循环播放音乐,并且编写了一个处理所有游戏逻辑的类,并将其封装在一个在游戏循环的每一次循环中运行一次的GameObject中。

我们使用组合在游戏对象中的图形组件和更新组件,编写了可玩角色的初始版本。这是实体组件系统的核心。我们游戏中的每种实体都将重复这个过程。

我们继续编写工厂类,它负责组装所有不同的游戏对象,并在它们之间共享适当的数据。

在下一章中,我们将专注于图形和绘制,编写同样派生自Graphics和Update的CameraGraphics和CameraUpdate类。

第15章 快跑!
第17章 图形、相机与动作

← 第15章 快跑! 第17章 图形、相机与动作→

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