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章 图形、相机与动作
    • 相机、绘制调用和SFML视图
    • 编写相机类
      • 编写CameraUpdate类
      • 为CameraGraphics类编写代码(第1部分)
      • SFML的View类
      • 编写CameraGraphics类
    • 向游戏中添加相机实例
    • 运行游戏
    • 总结
  • 第18章 为平台、玩家动画和控制功能编写代码
  • 第19章 构建菜单与实现下雨效果
  • 第20章 火球与声音空间化
  • 第21章 视差背景与着色器
目录

第17章 图形、相机与动作

# 第17章 图形、相机与动作

我们需要深入探讨本项目中图形的工作方式。由于本章我们将编写负责绘图的相机相关代码,现在讨论图形相关内容正合适。如果你查看graphics文件夹,会发现里面只有一张图片。此外,到目前为止,我们的代码中任何地方都没有调用window.draw。我们将讨论为何要尽量减少绘制调用次数,还会实现相机类来帮我们处理这个问题。最后,在本章结束时,我们将能够运行游戏,看到相机发挥作用,包括主视图、雷达视图和计时器文本。

本章完整的代码位于Run3文件夹中。以下是本章的主要内容:

  • 相机、绘制调用和SFML视图(SFML View)
  • 编写相机类
  • 向游戏中添加相机实例
  • 运行游戏

本章代码位于Run3文件夹中。

# 相机、绘制调用和SFML视图

在我们之前的所有项目中,游戏里的所有实体(只有一个例外)都是用精灵(sprite)进行图形化表示的。当绘制的实体只有几个、几十个,甚至几百个时,这种方式没问题。这一点很重要,因为SFML绘制游戏每一帧的速度,与我们调用window.draw的次数直接相关。这并非SFML的缺陷,而是与OpenGL对显卡的使用方式直接相关。

原因在于,每次调用draw时,幕后会发生很多操作来设置OpenGL,使其为绘制做好准备。引用SFML官网的话来说:

“……每次[对draw的]调用都涉及设置一组OpenGL状态、重置矩阵、更改纹理等等。即使只是绘制两个三角形(一个精灵),所有这些操作都是必要的。”

所以,只要有可能,使用顶点数组(vertex array)并通过一次绘制调用渲染多个图像,是个非常好的主意。这意味着我们需要从整体上改变处理图形的方式。我们不再为每个游戏对象都创建一个精灵,而是在顶点数组中设置一个起始索引;不再使用数百个精灵和许多纹理,而是使用单个顶点数组和包含所有图形的一张纹理。此外,想想每次我们在屏幕上绘制分数、时间或其他任何文本时,也都进行了一次绘制调用。SFML中与文本相关的类非常有用,我们不会停止使用它们,而且在这个项目中,我们也只有一处会用到SFML的文本功能,指的就是屏幕左上角显示的时间。

与菜单相关的文本是静态的,无需计算,因此是从纹理图集(texture atlas)中的图像绘制的。如果你想知道,要是有大量文本需要显示,如何尽量减少绘制调用次数,答案是,你可以像处理常规图形一样处理文本,提供一个包含字母表、数字0到9以及所需标点符号的纹理图集。这比使用SFML的文本类要复杂得多,但也并非难以实现。

这只需要让当前帧中绘制的每个字符(数字、字母等)在顶点数组中都有一个索引(以及相关坐标),然后解析要绘制到屏幕上的字符串,并将合适的顶点位置与合适的纹理坐标进行分层。等你完成这个项目时,你可能已经做过五六次甚至更多次这样的操作,到那时就不会觉得神秘了。当然,在僵尸游戏中,我们用顶点数组来绘制背景,在前一章中,我们也已经将纹理坐标与顶点数组索引结合起来绘制玩家图形。

现在我们将编写相机相关代码。相机将在需要时负责调用绘制函数。我们会有两个相机。普通相机将调用draw两次:一次用于绘制顶点数组,一次用于绘制屏幕左上角的计时器。小地图相机将调用draw一次,为玩家展示一个不同视角的世界,类似雷达视图。

为实现这一点,每个相机都需要访问同一个渲染窗口(RenderWindow)实例,并且需要调整SFML视图(View)实例的设置,该实例定义了相机在屏幕上的位置、长宽比以及用于特定目的的缩放级别。

# 编写相机类

我们的游戏中将有两个相机,每个相机都有一个继承自Update的类和一个继承自Graphics的类。CameraUpdate类将通过InputReceiver实例来处理相机跟随玩家的移动,以及与操作系统的交互。CameraGraphics类将通过引用CameraUpdate中的数据,并持有纹理图集、渲染窗口实例和一个SFML文本对象的副本,来处理所有绘制操作。在后面的第21章,我们会引入更多功能(和绘制调用),添加视差背景(parallax background)和一种很棒的着色器效果(shader effect)。

# 编写CameraUpdate类

创建两个新类来表示相机:CameraUpdate,其基类为Update;CameraGraphics,其基类为Graphics。正如我们所预期的,这些类将被包装或组合在一个GameObject实例中,以便在游戏循环中使用。

在CameraUpdate.h文件中添加以下代码:

#pragma once

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

using namespace sf;

class CameraUpdate : public Update
{
private:
    FloatRect m_Position;
    FloatRect* m_PlayerPosition;
    bool m_ReceivesInput = false;
    InputReceiver* m_InputReceiver = nullptr;

public:
    FloatRect* getPositionPointer();
    void handleInput();
    InputReceiver* getInputReceiver();
    //From 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

在上述代码中,声明了名为m_Position的FloatRect类型变量,用于存储相机的位置。浮点型矩形非常适合在世界空间中存储相机视图的顶部、左侧、长度和宽度,而不是整数值的像素位置。

名为m_PlayerPosition的FloatRect类型变量是一个指针,它将被初始化为PlayerUpdate类中FloatRect实例的地址。这将使CameraUpdate类能够跟随玩家在游戏世界中移动。

布尔变量m_ReceivesInput很有用,因为我们的相机中只有一个需要接收输入。主相机只需跟随玩家角色移动,不需要玩家控制,所以我们无需在主相机中处理接收和处理输入的额外开销,只在小地图相机中处理即可。默认情况下,这个布尔变量被初始化为false。

InputReceiver指针m_InputReceiver用于向InputDispatcher注册。默认情况下,它被设置为nullptr,因为如前所述,我们的两个相机中只有一个需要它。

getPositionPointer函数将返回定义相机视图的FloatRect实例的地址,这样CameraGraphics类就能跟踪CameraUpdate类。总之,这个类(CameraUpdate)将跟踪玩家,而CameraGraphics类将跟踪这个类。

handleInput函数将在游戏循环的每次迭代中,从update函数中被调用,但只在需要它的相机中调用。

getInputReceiver函数由InputDispatcher类在工厂类中调用。这样它就能访问并存储指向CameraUpdate中InputReceiver的指针。

assemble函数将为这个类的运行做准备。提醒一下,参数是一个名为levelUpdate的shared_ptr<LevelUpdate>,以及一个名为playerUpdate的shared_ptr<PlayerUpdate>。这是我们从Update类重写的第一个函数。我们很快就会看到如何使用这些参数。

update函数在每一帧被调用一次,它接收主游戏循环的持续时间。现在我们来看看如何实现这些函数以及使用所有这些变量。

我们分几个部分来编写这些函数的实现代码。首先,在CameraUpdate.cpp文件中添加以下内容:

#include "CameraUpdate.h"
#include "PlayerUpdate.h"

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

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

InputReceiver* CameraUpdate::getInputReceiver()
{
    m_InputReceiver = new InputReceiver;
    m_ReceivesInput = true;
    return m_InputReceiver;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

getPositionPointer函数只是返回m_Position变量的地址。assemble函数只是将玩家位置的地址存储在m_PlayerPosition中,以便随时引用。

getInputReceiver函数初始化一个新的InputReceiver实例,将m_ReceivesInput变量设置为true,然后返回新创建实例的地址。这个函数的作用是,只有在调用它时,我们的代码才会初始化并共享一个InputReceiver实例。因此,在Factory类中,我们可以轻松选择哪些相机处理输入,哪些不处理。通过将m_ReceivesInput变量设置为true,并且因为默认值是false,这个类的每个实例都将知道自己是否处理输入。简而言之,每个类都会被告知并准备好是否处理输入,而无需类本身去选择。

对于CameraUpdate类的下一部分,在CameraUpdate.cpp中前面代码的后面添加以下内容:

void CameraUpdate::handleInput()
{
    m_Position.width = 1.0f;

    for (const Event& event : m_InputReceiver->getEvents())
    {
        // Handle mouse  wheel  event for  zooming
        if (event.type == sf::Event::MouseWheelScrolled)
        {
            if (event.mouseWheelScroll.wheel == sf::Mouse::VerticalWheel)
            {
                // Accumulate  the  zoom factor  based  on  delta
                m_Position.width *=
                    (event.mouseWheelScroll.delta > 0) ? 0.95f : 1.05f;
            }
        }
        m_InputReceiver->clearEvents();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在上述代码中,handleInput函数监听鼠标滚轮滚动事件。m_Position.width的值被设置为1,我们稍后会明白原因。下面的代码像我们到目前为止在每个游戏中所做的那样,遍历所有事件,唯一的区别是我们通过调用m_InputReceiver->getEvents函数来捕获事件。

在事件循环中,我们只关心一个事件,即sf::Event::MouseWheelScrolled。当检测到这个事件时,执行以下if语句:

if (event.mouseWheelScroll.wheel ==
    sf::Mouse::VerticalWheel)
1
2

上述语句检查鼠标滚轮是否被滚动,如果是,则执行下一行代码:

m_Position.width *=
    (event.mouseWheelScroll.delta > 0) ? 0.95f : 1.05f;
1
2

这行代码根据鼠标滚轮滚动的方向修改m_Position.width的值。

event.mouseWheelScroll.delta中存储的值描述了鼠标滚轮滚动的幅度。如果这个值是正数,意味着鼠标滚轮向上滚动;如果是负数,则意味着鼠标滚轮向下滚动。

表达式(event.mouseWheelScroll.delta > 0)是一个三元条件运算符。它检查这个值是否大于0。如果是,表达式的值为true;否则为false。根据运算符的结果,选择两个值中的一个:

  • 如果delta > 0,即鼠标滚轮向上滚动,则选择0.95f。
  • 如果delta <= 0,即鼠标滚轮向下滚动,则选择1.05f。

选择的值(0.95f或1.05f)然后与我们之前初始化为1的m_Position.width相乘。如果结果大于0,则将m_Position.width减小5%;如果结果小于0,则将m_Position.width增大5%。如果鼠标滚轮没有被操作,该值将保持为1。我们很快会在update函数中看到如何使用这个值。handleInput函数的最后一行代码清除所有事件,为游戏的下一帧做准备。

最后,对于CameraUpdate类,在CameraUpdate.cpp中添加update函数:

void CameraUpdate::update(float fps)
{
    if (m_ReceivesInput)
    {
        handleInput();

        m_Position.left = m_PlayerPosition->left;
        m_Position.top = m_PlayerPosition->top;
    }
    else
    {
        m_Position.left = m_PlayerPosition->left;
        m_Position.top = m_PlayerPosition->top;
        m_Position.width = 1;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

update函数检查这个实例是否接收输入。如果接收,则调用handleInput。这意味着,任何调用了getInputReceiver的CameraUpdate实例都将执行这段代码。在if块中,m_Position的left和top变量的值被设置为与玩家相同的位置。注意,代码的关键部分是没有设置宽度。这意味着我们在handleInput函数中为m_Position.width设置的任何值都将保留。当CameraGraphics类在每一帧为SFML视图实例设置参数时,这将产生与鼠标滚轮同步缩放的效果。

在else块中,我们执行与if块相同的操作,但额外将m_Position.width设置为1。当else块执行时,CameraGraphics类将不会产生缩放效果。现在我们来看看CameraGraphics类。

# 为CameraGraphics类编写代码(第1部分)

我们已经了解了CameraUpdate类的工作原理,它如何根据鼠标滚轮滚动情况做出响应,以及它如何将滚动距离存储在其width变量中。我们还知道它如何将玩家的位置存储在其left和top变量中。此外,我们了解到这个CameraGraphics类将使用所有这些值。下面来看看它们是如何协同工作的。在CameraGraphics.h中添加以下内容。

#pragma once

#include "SFML/Graphics.hpp"
#include "Graphics.h"

using namespace sf;

class CameraGraphics : public Graphics
{
private:
    RenderWindow* m_Window;

    View m_View;
    int m_VertexStartIndex = -999;
    Texture* m_Texture = nullptr;
    FloatRect* m_Position = nullptr;
    bool m_IsMiniMap = false;
    // 用于缩放小地图
    const float MIN_WIDTH = 640.0f;
    const float MAX_WIDTH = 2000.0f;

    // 用于时间UI
    Text m_Text;
    Font m_Font;
    int m_TimeAtEndOfGame = 0;
    float m_Time = 0;

public:
    CameraGraphics(RenderWindow* window,
                   Texture* texture,
                   Vector2f viewSize,
                   FloatRect viewport);

    float* getTimeConnection();

    // 来自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
34
35
36
37
38
39

代码量不少,下面来逐行分析。在CameraGraphics.h文件的私有部分,我们声明了一个指向RenderWindow的指针m_Window和一个View实例m_View;这些通常不会出现在我们的Graphics派生类中。CameraGraphics类需要它们的原因是,它将负责绘制VertexArray,这个VertexArray会在每一帧中包含更新后的顶点位置和纹理坐标。这是合理的,因为相机可以控制移动、缩放,然后进行绘制。我们可以根据需要创建任意数量的相机。我们可以制作一个四人游戏,将屏幕分成四部分;也可以制作一个双人分屏游戏;或者像我们即将做的那样,制作一个全屏相机和一个类似小地图/雷达的视图。

整数m_VertexStartIndex将保存相机四边形在顶点数组中的起始索引。你可能会想,相机只是绘制顶点数组,为什么它需要自己的四边形呢?你有这样的疑问很正常,因为通常情况下相机不需要自己的四边形和纹理坐标,但我们的相机将有一个几乎完全透明的矩形,用于在小地图和主屏幕之间创建边界。

Texture指针m_Texture是保存包含所有内容的图像的纹理。m_Position变量是一个FloatRect,它保存相机对游戏世界视图的大小和坐标。

布尔值m_IsMiniMap将帮助我们编写在主视图和小地图视图之间略有差异的代码。如果你愿意,你可以轻松创建两个单独的类,比如MainCameraGraphics和RadarCameraGraphics,这样可以避免代码中出现一些if语句。

常量MIN_WIDTH和MAX_WIDTH限定了游戏世界视图的最小和最大尺寸,这是必要的,因为我们将编写允许小地图缩放的代码。

Text、Font和float类型的m_Time成员用于在屏幕左上角显示时间。游戏每一帧会有三次绘制调用,一次针对每个相机,还有一次针对文本,但我们只会在主相机中调用绘制文本的操作。在后面的第21章中,当我们添加视差背景时,还会添加第四次绘制调用。

整数m_TimeAtEndOfGame帮助我们在游戏结束后显示时间。

在CameraGraphics.h文件的公有部分,我们有构造函数声明。构造函数接收用于初始化RenderWindow指针和Texture指针的参数,以及用于相机视图大小和视口的变量。视口(viewport)是SFML中的概念,它定义了视图将显示在屏幕上的区域。等一下我们编写.cpp文件的代码时,这就会完全说得通了。为了确保你真正理解视口的概念以及它与视图的区别,我们将在“SFML视图类”这部分深入讨论,但我们先添加代码,这样讨论时会有更多上下文。

getTimeConnection函数返回一个指向m_Time变量的指针,LevelUpdate类将调用这个函数。这使得LevelUpdate类能够更改m_Time变量的值,正如我们将看到的,这将进而更改屏幕左上角的文本内容。

assemble函数是我们常见的重写函数,它接收一个VertexArray、一个指向通用Update实例的共享指针以及纹理坐标。draw函数也是从Graphics类重写的,它只需要VertexArray来完成工作。

现在,我们来编写这些函数的代码,以便全面理解它们。然后,我们将如承诺的那样,更深入地研究SFML的View类。在CameraGraphics.cpp文件中添加以下内容。

#include "CameraGraphics.h"
#include "CameraUpdate.h"

CameraGraphics::CameraGraphics(
    RenderWindow* window,
    Texture* texture,
    Vector2f viewSize,
    FloatRect viewport)
{
    m_Window = window;
    m_Texture = texture;

    m_View.setSize(viewSize);
    m_View.setViewport(viewport);

    // 小地图视口小于1
    if (viewport.width < 1) {
        m_IsMiniMap = true;
    }
    else
    {
        // 只有全屏相机有时间文本
        m_Font.loadFromFile("fonts/KOMIKAP_.ttf");
        m_Text.setFont(m_Font);
        m_Text.setFillColor(Color(255, 0, 0, 255));
        m_Text.setScale(0.2f, 0.2f);
    }
}
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

我们刚刚添加了CameraGraphics类的构造函数。在这段代码中,我们首先初始化RenderWindow指针和Texture指针。通过调用setSize并传入viewSize参数来设置视图大小,通过调用setViewport并传入viewport来设置视口。接下来几页,我们先暂时放下CameraGraphics类,仔细研究一下SFML的View类。

# SFML的View类

为了更详细地解释视口到底是什么,请看下面的图片,其中有一些示例。

img 图17.1:视口说明

在上图中,我们看到了一些示例,展示了如何设置View实例的视口,以控制绘制调用在屏幕上的显示位置和占据的屏幕区域大小。在之前所有项目中,我们使用的都是默认值,即整个屏幕。

视口由一个SFML的FloatRect定义,其中left和top值定义了左上角的位置,width和height值定义了视口的宽度和高度。视口和视图大小(通过setSize函数设置)之间的区别很重要。

setSize函数决定视图将显示多少游戏世界单位。然而,视口的高度和宽度决定了该视图将使用屏幕多少比例的空间。根据游戏的需求,你可以在一个非常小的视口中显示大量游戏世界内容,也可以在一个非常大的视口中显示少量游戏世界内容。视口是用归一化坐标定义的,这意味着最小可能值是0,最大可能值是1。

因此,覆盖整个屏幕的默认视口的top和left值为0,height和width值为1。更多示例有助于阐明这一点。

参考上图中的屏幕示例1。标记为a的视口将具有以下值:left = 0,top = 0,width = 0.5,height = 1。这是因为该视口从屏幕最左边和最顶部(0)开始,占据屏幕宽度的一半(0.5)和整个高度(1)。视口b的值如下:left = 0.5,top = 0,width = 0.5,height = 1。这是因为它从屏幕水平方向的中间(0.5)、垂直方向的顶部(0)开始,宽度为一半(0.5),高度为整个高度(1)。

查看屏幕示例2,并花点时间理解视口a、b、c和d的值:

  • 视口a:left = 0,top = 0,width = 0.5,height = 0.5
  • 视口b:left = 0.5,top = 0,width = 0.5,height = 0.5
  • 视口c:left = 0,top = 0.5,width = 0.5,height = 0.5
  • 视口d:left = 0.5,top = 0.5,width = 0.5,height = 0.5

所有视口的宽度和高度均为0.5,因为它们都使用了屏幕高度和宽度的一半。屏幕左侧的两个视口left值为0,从屏幕中心开始的两个视口left值为0.5,依此类推。

以下是屏幕示例3中视口的值。我不会逐个描述,你只需根据我们刚刚讨论的内容来理解这些值:

  • 视口a:left = 0,top = 0,width = 1,height = 0.33
  • 视口b:left = 0,top = 0.33,width = 1,height = 0.33
  • 视口c:left = 0,top = 0.66,width = 1,height = 0.33

最后一个示例,屏幕示例4,大致表示了我们游戏中的视口。视口a是从左上角开始的全屏视口,与默认值相同:left = 0,top = 0,width = 1,height = 1。视口b是小地图/雷达视口,其值如下:left = 0.2,top = 0.8,width = 0.6,height = 0.19。我们在向Factory类添加代码时会看到,在传递视口的值时,我们还需要考虑屏幕分辨率以及水平与垂直分辨率的比例。此外,由于视口b与视口a的屏幕区域重叠,我们必须确保使用视口b的相机在第二个绘制,否则它会被使用视口a的相机覆盖。

让我们回到代码部分。

# 编写CameraGraphics类

综上所述,我们刚刚了解到,如果视口在任一方向上的值小于1(在我们的游戏中),那么它就不是全屏相机。因此,如果if(viewport.width<1)这个条件为真,那么这将是小地图相机。当然,我们可能会不小心传递错误的值,但代码假设我们传递的值是正确的,所以任何宽度小于1的视口对应的就是小地图。在if语句内部,m_IsMiniMap被设置为true。

在else语句中(当是主相机时执行),我们加载字体、设置字体、给字体上色并缩放字体,就像我们在其他项目中所做的一样,只是代码位置不同。如前所述,小地图不会使用或显示时间,因此不需要Font或Text实例。

接下来,将下面这个assemble函数的代码也添加到CameraGraphics.cpp中。

void CameraGraphics::assemble(
    VertexArray& canvas,
    shared_ptr<Update> genericUpdate,
    IntRect texCoords)
{
    shared_ptr<CameraUpdate> cameraUpdate =
        static_pointer_cast<CameraUpdate>(genericUpdate);
    m_Position = cameraUpdate->getPositionPointer();
    m_VertexStartIndex = canvas.getVertexCount();

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

    const int uPos = texCoords.left;
    const int vPos = texCoords.top;
    const int texWidth = texCoords.width;
    const int texHeight = texCoords.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

在我们刚刚添加的assemble函数中,第一行代码使用static_pointer_cast将通用的Update共享指针转换为CameraUpdate共享指针。现在CameraGraphics类可以调用CameraUpdate类的所有公有函数。第二行代码利用这一点,通过调用CameraUpdate类的getPositionPointer函数来初始化m_Position指针。这样我们就可以随时跟踪相机应该绘制的位置。

assemble函数中的其余代码保存相机四边形的索引,并将与该四边形相关的所有纹理坐标初始化为VertexArray。这些纹理坐标组合起来是一个非常浅的透明矩形,用于在主相机和小地图相机之间创建视觉分隔。

接下来,将下面这个getTimeConnection函数的代码也添加到CameraGraphics.cpp文件中。

float* CameraGraphics::getTimeConnection()
{
    return &m_Time;
}
1
2
3
4

getTimeConnection函数很简短,它只是返回m_Time变量的地址。一旦LevelUpdate类调用了这个函数并存储了结果,它就能够更新m_Time变量,然后在draw函数的每一帧中更新这个变量。说到draw函数,下面我们就来编写它的代码。

最后,为CameraGraphics类编写的代码,还有下面这段也添加到CameraGraphics.cpp中。

void CameraGraphics::draw(VertexArray& canvas) {
    m_View.setCenter(m_Position->getPosition());

    Vector2f startPosition;
    startPosition.x = m_View.getCenter().x - m_View.getSize().x / 2;
    startPosition.y = m_View.getCenter().y - m_View.getSize().y / 2;

    Vector2f scale;
    scale.x = m_View.getSize().x;
    scale.y = m_View.getSize().y;

    canvas[m_VertexStartIndex].position = startPosition;
    canvas[m_VertexStartIndex + 1].position = startPosition + Vector2f(scale.x, 0);
    canvas[m_VertexStartIndex + 2].position = startPosition + scale;
    canvas[m_VertexStartIndex + 3].position = startPosition + Vector2f(0, scale.y);

    if (m_IsMiniMap) {
        if (m_View.getSize().x <
            MAX_WIDTH && m_Position->width > 1) {
            m_View.zoom(m_Position->width);
        }
        else if (m_View.getSize().x >
                 MIN_WIDTH && m_Position->width < 1) {
            m_View.zoom(m_Position->width);
        }
    }

    m_Window->setView(m_View);

    // 仅在主相机中绘制时间UI
    if (!m_IsMiniMap) {
        m_Text.setString(std::to_string(m_Time));
        m_Text.setPosition(
            m_Window->mapPixelToCoords(Vector2i(5, 5)));
        m_Window->draw(m_Text);
    }

    // 绘制主画布
    m_Window->draw(canvas, m_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
27
28
29
30
31
32
33
34
35
36
37
38
39
40

在上面的draw函数中,有些代码是所有相机都会用到的,有些代码只有小地图相机才会用到(if (m_IsMiniMap)),还有些代码只有普通相机才会用到(if (!m_IsMiniMap))。

函数开头的大部分代码是两个相机都会用到的。提醒一下,代码如下:

m_View.setCenter(m_Position->getPosition());

Vector2f startPosition;
startPosition.x = m_View.getCenter().x - m_View.getSize().x / 2;
startPosition.y = m_View.getCenter().y - m_View.getSize().y / 2;

Vector2f scale;
scale.x = m_View.getSize().x;
scale.y = m_View.getSize().y;

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

在上述两个相机实例都会用到的代码中,startPosition这个Vector2f变量是使用View实例的大小和中心来初始化的。接着,scale这个Vector2f变量则是使用View实例的大小来初始化的。

最后(在上述代码中),使用startPosition和scale对VertexArray(顶点数组)中的相关顶点进行定位。

为了更清晰地展示,下面再次列出小地图专用的代码。

if (m_IsMiniMap) {
    if (m_View.getSize().x <
        MAX_WIDTH && m_Position->width > 1) {
        m_View.zoom(m_Position->width);
    }
    else if (m_View.getSize().x >
        MIN_WIDTH && m_Position->width < 1) {
        m_View.zoom(m_Position->width);
    }
}
1
2
3
4
5
6
7
8
9
10

在上述小地图专用的代码中,有一个if部分,其中包含一个if - else if结构。在if部分,当m_IsMiniMap为true时会进入外层if。当View(视图)实例的大小小于允许的最大尺寸且m_Position.width小于1时,对视图进行缩放。回顾CameraUpdate(相机更新)类,所需的缩放比例存储在m_Position.width中。

当超过允许的最小缩放比例且m_Position.width小于1时,会执行内层的else if部分。在else if结构中,对视图进行放大。

注意,这段代码执行完毕后,会为两个相机将视图设置为合适的实例。在CameraUpdate类的update函数开始时,默认值始终设置为1,这意味着常规相机永远不会缩放,如果不使用鼠标滚轮,两个相机都不会缩放。

m_Window->setView(m_View);
1

接下来的代码仅由常规相机使用,为清晰起见,在此再次列出。

if (!m_IsMiniMap) {
    m_Text.setString(std::to_string(m_Time));
    m_Text.setPosition(
        m_Window->mapPixelToCoords(Vector2i(5, 5)));
    m_Window->draw(m_Text);
}
1
2
3
4
5
6

在上述常规相机使用的代码中,通过setString和setPosition配置屏幕左上角的文本。最后,我们使用RenderWindow(渲染窗口)指针调用draw函数。

与我们的僵尸游戏中调用数十次不同,CameraGraphics(相机图形)类的每个实例只会调用一次draw函数。这是因为所有游戏对象都在顶点数组中。这样效率更高,这意味着我们的游戏可以在配置较低的电脑上运行,或者在性能出现问题之前,我们可以添加更多的游戏对象。

为完整起见,再次列出draw调用。

m_Window->draw(canvas, m_Texture);
1

为了看到我们新创建的两个与相机相关的类的实际效果,我们需要在Factory(工厂)类中实例化它们,将它们包装在GameObject(游戏对象)实例中,并添加到我们在游戏循环中迭代的向量中。

# 向游戏中添加相机实例

我们将有两个相机,一个用于游戏的主视图,一个用于小地图。

打开Factory.cpp文件。在文件顶部添加以下两个突出显示的包含指令。

#include "Factory.h"

#include "LevelUpdate.h"
#include "PlayerGraphics.h"
#include "PlayerUpdate.h"
#include "InputDispatcher.h"

#include "CameraUpdate.h"
#include "CameraGraphics.h"
1
2
3
4
5
6
7
8
9

现在添加第一个相机的代码。在处理玩家的代码之后、loadLevel函数的末尾(但在函数内部)添加所有相机代码。请注意,前几行代码是两个相机共用的;接下来我们会处理第二个相机。首先添加常规的全屏相机,否则它会遮挡小地图相机。

//  For  both  the  cameras
const float width = float(VideoMode::getDesktopMode().width);
const float height = float(VideoMode::getDesktopMode().height);
const float ratio = width / height;

// Main  camera
GameObject camera;
shared_ptr<CameraUpdate> cameraUpdate = make_shared<CameraUpdate>();
cameraUpdate->assemble(nullptr, playerUpdate);
camera.addComponent(cameraUpdate);

shared_ptr<CameraGraphics> cameraGraphics = make_shared<CameraGraphics>(
    m_Window, m_Texture,
    Vector2f(CAM_VIEW_WIDTH, CAM_VIEW_WIDTH / ratio),
    FloatRect(CAM_SCREEN_RATIO_LEFT, CAM_SCREEN_RATIO_TOP, CAM_SCREEN_RATIO_WIDTH, CAM_SCREEN_RATIO_HEIGHT));
cameraGraphics->assemble(
    canvas,
    cameraUpdate,
    IntRect(CAM_TEX_LEFT, CAM_TEX_TOP,
    CAM_TEX_WIDTH, CAM_TEX_HEIGHT));

camera.addComponent(cameraGraphics);
gameObjects.push_back(camera);

levelUpdate->connectToCameraTime(
    cameraGraphics->getTimeConnection());

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

在上述代码中,你可能开始觉得有些熟悉了。首先,我们添加了一些对两个实例都有用的代码。width、height和ratio变量是根据游戏运行的屏幕分辨率初始化的。我们会在两个相机中使用这些值。然后我们开始编写主相机的代码。

首先,我们创建一个名为camera的GameObject实例。接下来,创建一个指向CameraUpdate实例的共享指针。然后调用assemble函数,并传入playerUpdate共享指针。接着,使用addComponent函数将cameraUpdate添加到camera中。

接下来,我们创建一个CameraGraphics共享指针,并调用构造函数,传入RenderWindow、Texture、相机尺寸的常量以及视口尺寸。然后,调用assemble函数,传入VertexArray、cameraUpdate实例(以其父类Update的形式)和纹理坐标。然后将CameraGraphics实例添加到GameObject实例中,并将GameObject实例添加到gameObjects向量中。

最后,通过在levelUpdate上调用connectToCameraTime,并传入在cameraGraphics上调用getTimeConnection的结果,在LevelUpdate实例和CameraGraphics实例之间建立连接。

接下来,添加用于小地图的相机代码。

// MapCamera
GameObject mapCamera;
shared_ptr<CameraUpdate> mapCameraUpdate = make_shared<CameraUpdate>();
mapCameraUpdate->assemble(nullptr, playerUpdate);
mapCamera.addComponent(mapCameraUpdate);

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

shared_ptr<CameraGraphics> mapCameraGraphics = make_shared<CameraGraphics>(
    m_Window, m_Texture,
    Vector2f(MAP_CAM_VIEW_WIDTH, MAP_CAM_VIEW_HEIGHT / ratio),
    FloatRect(MAP_CAM_SCREEN_RATIO_LEFT, MAP_CAM_SCREEN_RATIO_TOP,
    MAP_CAM_SCREEN_RATIO_WIDTH,
    MAP_CAM_SCREEN_RATIO_HEIGHT));

mapCameraGraphics->assemble(
    canvas,
    mapCameraUpdate,
    IntRect(MAP_CAM_TEX_LEFT, MAP_CAM_TEX_TOP, MAP_CAM_TEX_WIDTH, MAP_CAM_TEX_HEIGHT));

mapCamera.addComponent(mapCameraGraphics);
gameObjects.push_back(mapCamera);

//  End  Map  Camera
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

在上述代码中,除了尺寸和视口不同之外,我们使用了与第一个相机完全相同的技术和代码。小地图的尺寸更大,因为它显示的区域更宽,但视口更小,因为它被压缩到了一个较小的区域。一旦我们在游戏中添加更多图形,这一点会更加明显。

# 运行游戏

现在我们的相机正在将顶点数组绘制到屏幕上,我们可以删除在主函数中临时添加的额外代码行。从Run.cpp文件中删除以下代码:

…
//  Temporary  code  until  next  chapter
window.draw(canvas, factory.m_Texture);
…
1
2
3
4

现在我们可以运行游戏,查看相机的实际效果。

图17.2:相机运行效果

在上面的图片中,你可以看到玩家的位置和缩放都是正确的。

此外,如果你滚动鼠标滚轮,你可以看到小地图进行缩放,尽管目前小地图中可显示的内容还不多。

在下一章中,我们将添加平台,然后在同一章中,我们可以为玩家添加动画和键盘控制。请记住,PlayerUpdate类中的InputReceiver实例已经在接收所有事件;我们只需要对这些事件做出响应。

# 总结

在本章中,我们了解到尽量减少绘制调用的次数会更高效,并且我们可以通过为游戏中的所有实体使用单个顶点数组来实现这一点,尽管我们也有一个单独的SFML Text实例,并在其上也调用了draw函数。此外,在本章中,我们使用更新派生类和图形派生类的实体组件模式编写了两个相机的代码。此外,我们看到这些类相互共享数据以有效工作,并且它们还与玩家相关的类共享数据。

我们了解了如何在工厂中添加相机,通过将其他类的适当更新和图形派生实例等所需参数传递给assemble函数,我们可以将相机配置为按我们期望的方式工作。

现在我们的相机已经正常运行,并且我们在第16章中通过LevelUpdate类添加的游戏逻辑也已就绪,现在我们向游戏中添加的任何内容都能立即带来满足感,因为我们可以立即看到它的实际效果。在下一章中,我们将为游戏添加平台,并为玩家添加动画以及对键盘输入做出响应。

第16章 声音、游戏逻辑、对象间通信与玩家
第18章 为平台、玩家动画和控制功能编写代码

← 第16章 声音、游戏逻辑、对象间通信与玩家 第18章 为平台、玩家动画和控制功能编写代码→

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