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章 碰撞、音效与结束条件:让游戏可玩
    • 准备玩家(及其他精灵)
    • 绘制玩家和其他精灵
    • 处理玩家输入
      • 处理新游戏设置
      • 检测玩家砍树操作
      • 检测按键释放
      • 让砍断的木头和斧头动起来
    • 处理死亡
    • 简单的音效
      • SFML音效的工作原理
      • 何时播放音效
      • 添加音效代码
    • 改进游戏和代码
    • 总结
    • 常见问题解答
  • 第6章 面向对象编程——开始开发《乒乓》游戏
  • 第7章 AABB碰撞检测与物理效果——完成乒乓球游戏
  • 第8章 SFML视图——开启丧尸射击游戏
  • 第9章 C++引用、精灵表和顶点数组
  • 第10章 指针、标准模板库和纹理管理
  • 第11章 编写TextureHolder类并创建一群僵尸
  • 第12章 碰撞检测、拾取物与子弹
  • 第13章 视图分层与平视显示器(HUD)的实现
  • 第14章 音效、文件输入输出与完成游戏制作
  • 第15章 快跑!
  • 第16章 声音、游戏逻辑、对象间通信与玩家
  • 第17章 图形、相机与动作
  • 第18章 为平台、玩家动画和控制功能编写代码
  • 第19章 构建菜单与实现下雨效果
  • 第20章 火球与声音空间化
  • 第21章 视差背景与着色器
目录

第5章 碰撞、音效与结束条件:让游戏可玩

# 第5章 碰撞、音效与结束条件:让游戏可玩

这是第一个项目的最后阶段。在本章结束时,你将完成自己的第一款游戏。等《Timber!!!》运行起来后,一定要阅读本章的最后一部分内容,其中会给出让游戏变得更出色的建议。

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

  • 准备玩家(及其他精灵)
  • 绘制玩家和其他精灵
  • 处理玩家输入
  • 处理死亡情况
  • 简单音效
  • 改进游戏和代码

在本章中,我们会复用之前学过的C++概念,但将首次接触SFML(简单快速多媒体库,Simple and Fast Multimedia Library)的音效功能。

# 准备玩家(及其他精灵)

我们来添加玩家精灵的代码,同时再添加一些其他精灵和纹理。以下代码添加了一个墓碑精灵,用于玩家被压扁时显示;一把斧头精灵,用于砍伐;还有一个原木精灵,玩家每次砍伐时它会快速飞走。

注意,在spritePlayer对象之后,我们还声明了一个side类型的变量playerSide,用于追踪玩家当前所在的位置。此外,我们为spriteLog对象添加了一些额外变量,包括logSpeedX、logSpeedY和logActive,用于存储原木的移动速度以及它当前是否在移动。spriteAxe也有两个相关的float常量变量,用于记录左右两侧理想的像素位置。

像之前多次做过的那样,在while(window.isOpen())代码之前添加下面这部分代码。请注意,接下来列出的所有代码都是新的,不只是突出显示的部分。我没有为下面这部分代码提供额外的上下文,因为while(window.isOpen())很容易识别。突出显示的代码是我们刚刚专门讨论过的。

在while(window.isOpen())这一行之前添加全部这些代码,并记住我们简要讨论过的突出显示的行。这会让本章后面的代码更容易理解:

// 准备玩家
Texture texturePlayer;
texturePlayer.loadFromFile("graphics/player.png");
Sprite spritePlayer;
spritePlayer.setTexture(texturePlayer);
spritePlayer.setPosition(580, 720);

// 玩家从左侧开始
side playerSide = side::LEFT;

// 准备墓碑
Texture textureRIP;
textureRIP.loadFromFile("graphics/rip.png");
Sprite spriteRIP;
spriteRIP.setTexture(textureRIP);
spriteRIP.setPosition(600, 860);

// 准备斧头
Texture textureAxe;
textureAxe.loadFromFile("graphics/axe.png");
Sprite spriteAxe;
spriteAxe.setTexture(textureAxe);
spriteAxe.setPosition(700, 830);

// 使斧头与树对齐
const float AXE_POSITION_LEFT = 700;
const float AXE_POSITION_RIGHT = 1075;

// 准备飞行的原木
Texture textureLog;
textureLog.loadFromFile("graphics/log.png");
Sprite spriteLog;
spriteLog.setTexture(textureLog);
spriteLog.setPosition(810, 720);

// 其他一些与原木相关的有用变量
bool logActive = false; 
float logSpeedX = 1000; 
float logSpeedY = -1500;
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

现在,我们可以绘制所有新添加的精灵了。

# 绘制玩家和其他精灵

在添加移动玩家以及使用所有新精灵的代码之前,我们先把它们绘制出来。这样,在添加更新、改变或移动它们的代码时,我们就能看到效果。

添加突出显示的代码来绘制四个新精灵:

// 绘制树
window.draw(spriteTree);

// 绘制玩家
window.draw(spritePlayer);

// 绘制斧头
window.draw(spriteAxe);

// 绘制飞行的原木
window.draw(spriteLog);

// 绘制墓碑
window.draw(spriteRIP);

// 绘制蜜蜂
window.draw(spriteBee);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

运行游戏,你会在场景中看到新添加的精灵。 img 图5.1:场景中的新精灵

我们离一个可运行的游戏真的很近了。

# 处理玩家输入

很多不同的情况都取决于玩家的移动,其中包括:

  • 何时显示斧头
  • 何时开始让原木动画
  • 何时向下移动所有树枝

因此,为玩家砍伐操作设置键盘处理是很有意义的。完成这项工作后,我们就可以把刚才提到的所有功能整合到代码的同一部分。

我们来思考一下如何检测键盘按键。在每一帧中,我们都会检测某个特定的键盘按键当前是否被按下。

如果被按下,我们就采取相应行动。如果按下Escape键,我们就关闭游戏;如果按下Enter键,我们就重新开始游戏。到目前为止,这种方式足以满足我们的需求。

然而,当我们尝试处理砍伐树木操作时,这种方法就出现了问题。这个问题其实一直存在,只是之前无关紧要。根据你电脑性能的不同,游戏循环每秒可能会执行数千次。只要有按键被按下,游戏循环每次执行时都会检测到,相关代码也会随之执行。

所以实际上,每次你按下Enter键重新开始游戏时,很可能重启了远超一百次。这是因为即使是最短促的按键操作,也会持续相当长的一段时间(以秒为单位)。你可以运行游戏并按住Enter键来验证这一点。注意时间条不会移动,这是因为游戏每秒都在被反复重启数百次甚至数千次。

如果我们在处理玩家砍伐操作时不换一种方法,那么玩家每次尝试砍伐,可能都会直接把整棵树砍倒。我们需要更巧妙一些。我们的做法是允许玩家砍伐,当玩家砍伐时,禁用按键检测代码。然后检测玩家何时松开按键,之后再重新启用按键检测。具体步骤如下:

  1. 等待玩家使用左右箭头键砍伐原木。
  2. 玩家砍伐时,禁用按键检测。
  3. 等待玩家松开按键。
  4. 重新启用砍伐检测。
  5. 从步骤1开始重复。

这听起来可能有点复杂,但借助SFML,实现起来会很简单。我们现在就一步一步来实现它。

添加突出显示的代码行,声明一个名为acceptInput的bool变量,它将用于判断何时监听砍伐操作、何时忽略:

float logSpeedX = 1000; 
float logSpeedY = -1500;

// 控制玩家输入
bool acceptInput = false;

while (window.isOpen()) {
1
2
3
4
5
6
7

现在我们设置好了布尔变量,可以接着处理新游戏的设置了。

# 处理新游戏设置

为了处理砍伐操作,在开始新游戏的if代码块中添加突出显示的代码:

/*
****************************************
处理玩家输入
****************************************
*/
if (Keyboard::isKeyPressed(Keyboard::Escape)) {
    window.close(); 
}

// 开始游戏
if (Keyboard::isKeyPressed(Keyboard::Return)) {
    paused = false;

    // 重置时间和分数
    score = 0;
    timeRemaining = 6;

    // 让所有树枝消失
    for (int i = 1; i < NUM_BRANCHES; i++) {
        branchPositions[i] = side::NONE; 
    }

    // 确保墓碑隐藏
    spriteRIP.setPosition(675, 2000);

    // 将玩家移动到起始位置
    spritePlayer.setPosition(580, 720);

    acceptInput = true;
}

/*
****************************************
更新场景
****************************************
*/
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

在上述代码中,我们使用for循环让树没有树枝,这对玩家来说是公平的,因为如果游戏开始时玩家头顶就有树枝,可能会被认为不太公平。玩家可以接受有难度的游戏,但他们讨厌不公平的游戏。然后,我们只是把墓碑移出屏幕,将玩家移动到左侧的起始位置。上述代码的最后一步是将acceptInput设置为true。现在我们准备好接收砍伐按键的输入了。

# 检测玩家砍树操作

现在,我们可以准备处理玩家按下左右方向键的操作了。添加这个简单的if代码块,只有当acceptInput为true时它才会执行:

// 开始游戏
if (Keyboard::isKeyPressed(Keyboard::Return)) {
    paused = false;

    // 重置时间和分数
    score = 0;
    timeRemaining = 5;

    // 让所有树枝消失
    for (int i = 1; i < NUM_BRANCHES; i++) {
        branchPositions[i] = side::NONE; 
    }

    // 确保墓碑隐藏
    spriteRIP.setPosition(675, 2000);

    // 将玩家移动到指定位置
    spritePlayer.setPosition(675, 660);

    acceptInput = true; 
}

// 包装玩家控制代码
// 确保我们正在接受输入
if (acceptInput) {
    // 接下来更多代码...
}
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
/****************************************
更新场景
****************************************/
1
2
3

现在,在我们刚刚编写的if代码块内,添加以下突出显示的代码,以处理玩家按下键盘上的右方向键时发生的情况:

// 包装玩家控制代码
// 确保我们正在接受输入
if (acceptInput) {
    // 接下来更多代码...

    // 首先处理按下右方向键的情况
    if (Keyboard::isKeyPressed(Keyboard::Right))    {
        // 确保玩家在右侧
        playerSide   =   side::RIGHT;

        score   ++;

        // 增加剩余时间
        timeRemaining  +=   (2   /   score)   +   .15;

        spriteAxe.setPosition(AXE_POSITION_RIGHT,
        spriteAxe.getPosition().y);

        spritePlayer.setPosition (1200 ,  720 );

        // 更新树枝
        updateBranches(score);

        // 将圆木设置为向左飞行
        spriteLog.setPosition (810 ,  720 );
        logSpeedX   =   -5000; 
        logActive   =  true ;

        acceptInput   =  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

前面这段代码执行了很多操作,下面我们来逐行分析。首先,我们检测玩家是否在树的右侧进行砍树操作。如果是,我们将playerSide设置为side::RIGHT。后续代码中,我们会根据playerSide的值做出不同的响应。

然后,使用score++将分数加1。下一行代码有点难以理解,这里再提醒一下:

timeRemaining += (2 / score) + .15;
1

这段代码其实并不复杂。在继续阅读之前,你可以试着自己理解一下。代码所做的就是通过timeRemaining +=...增加剩余时间,这是对玩家操作的奖励。然而,对于玩家来说,分数越高,增加的额外时间就越少。通过像这样用2除以分数(2 / score),砍树操作获得的奖励会迅速减少。你可以调整这个公式,让游戏变得更简单或更困难。

接下来,使用spriteAxe.setPosition将斧头移动到右侧位置,玩家的精灵(sprite)也移动到右侧位置。

接着,我们调用updateBranches函数,将所有树枝向下移动一格,并在树顶生成一个新的随机树枝(或空白位置)。

然后,将spriteLog移动到起始位置,使其与树融为一体,并将其speedX变量设置为负数,这样它就会向左快速移动。同时,将logActive设置为true,这样我们即将编写的圆木移动代码就能在每一帧中为圆木添加动画效果。

最后,将acceptInput设置为false。此时,玩家不能再进行砍树操作。我们解决了按键被频繁检测的问题,接下来我们将看到如何重新启用砍树操作。

现在,仍然在刚刚编写的if(acceptInput)代码块内,添加以下突出显示的代码,以处理玩家按下键盘上的左方向键时发生的情况:

// 处理左方向键
if (Keyboard::isKeyPressed(Keyboard::Left))
{
    // 确保玩家在左侧
    playerSide  =   side::LEFT;

    score++;

    // 增加剩余时间
    timeRemaining  +=   (2   /   score)   +   .15;

    spriteAxe.setPosition(AXE_POSITION_LEFT,   spriteAxe.getPosition().y);

    spritePlayer.setPosition (580 ,  720 );

    // 更新树枝
    updateBranches(score);

    // 设置圆木飞行
    spriteLog.setPosition (810 ,  720 );

    logSpeedX   =   5000; 
    logActive   =  true ;

    acceptInput   =  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

前面这段代码与处理右侧砍树操作的代码基本相同,只是精灵的位置不同,并且logSpeedX变量被设置为正数,这样圆木就会向右快速移动。这是因为精灵向右移动时,其水平坐标会增加。

接下来,我们看看如何检测按键释放。

# 检测按键释放

为了使前面的代码在第一次砍树操作之后仍然能正常工作,我们需要检测玩家何时释放按键,然后将acceptInput重新设置为true。

这与我们目前看到的按键处理方式略有不同。SFML库有两种不同的检测玩家键盘输入的方法。我们已经看到了第一种方法,它是动态且即时的,能让我们立即响应按键操作。

下面的代码使用了另一种方法。在“处理玩家输入”部分的顶部输入以下突出显示的代码,然后我们来分析它:

/****************************************
处理玩家输入
****************************************/
Event   event;

while   (window.pollEvent(event))  {
    if   (event.type  ==   Event::KeyReleased   &&   !paused)       	{
        // 再次监听按键操作
        acceptInput   =  true ;

        // 隐藏斧头
        spriteAxe.setPosition (2000 ,
        spriteAxe.getPosition().y);
    } 
}

if (Keyboard::isKeyPressed(Keyboard::Escape)) {
    window.close(); 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

首先,我们声明一个Event类型的对象event。然后,调用window.pollEvent函数,并传入我们新创建的对象event。pollEvent函数会将描述操作系统事件的数据放入event对象中。这些事件可以是按键按下、按键释放、鼠标移动、鼠标点击、游戏控制器操作,或者窗口自身发生的事件(如窗口被调整大小、移动等)。

我们将代码放在while循环中,是因为可能有许多事件存储在队列中。window.pollEvent函数会将它们一个一个地加载到event对象中。每次循环时,我们都会检查当前事件是否是我们感兴趣的,并做出相应的响应。当window.pollEvent返回false时,意味着队列中没有更多事件,while循环就会退出。

细心的读者可能会注意到,与我们之前讨论的函数相比,这里有些不同。在第9章讨论引用(reference)时,我们会详细解释这里发生的情况。简单来说,我们可以将一个值传递给函数,被调用的函数可以修改这个值,使修改后的值在调用函数中可用。这是通过引用的概念实现的,而不是使用return语句返回一个值。

当按键被释放且游戏未暂停时,这个if条件(event.type == Event::KeyReleased &&!paused)为true。

在if代码块内,我们将acceptInput重新设置为true,并将斧头精灵隐藏到屏幕外。

现在你可以运行游戏,惊叹于移动的树、挥舞的斧头和动画化的玩家。不过,树还不会压扁玩家,而且砍树时圆木也需要移动。

# 让砍断的木头和斧头动起来

当玩家砍树时,logActive 会被设置为 true,所以我们可以将一些代码放在一个代码块中,使其仅在 logActive 为 true 时执行。此外,每次砍树都会将 logSpeedX 设置为正数或负数,这样木头就准备好朝着正确的方向从树上飞出去。

在更新树枝精灵(sprite)的代码之后,添加以下突出显示的代码:

// 更新树枝精灵
for (int i = 0; i < NUM_BRANCHES; i++) {

    float height = i * 150;

    if (branchPositions[i] == side::LEFT) {
        // 将精灵移动到左边
        branches[i].setPosition(610, height);

        // 翻转精灵
        branches[i].setRotation(180);
    }
    else if (branchPositions[i] == side::RIGHT) {
        // 将精灵移动到右边
        branches[i].setPosition(1330, height);

        // 翻转精灵
        branches[i].setRotation(0);
    }
    else {
        // 隐藏树枝
        branches[i].setPosition(3000, height);
    }
}

// 处理飞行的木头
if (logActive)
{
    spriteLog.setPosition(
        spriteLog.getPosition().x + (logSpeedX * dt.asSeconds()),
        spriteLog.getPosition().y + (logSpeedY * dt.asSeconds()));

    // 木头是否到达了右边边缘?
    if (spriteLog.getPosition().x < -100 || spriteLog.getPosition().x > 2000)
    {
        // 设置它以便在下一帧成为全新的木头
        logActive = false;
        spriteLog.setPosition(810, 720);
    }
}

} // End if(!paused)
/*
****************************************
绘制场景
****************************************
*/
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

这段代码通过 getPosition 获取精灵当前的水平和垂直位置,然后分别加上 logSpeedX 和 logSpeedY 乘以 dt.asSeconds() 的结果,以此来设置精灵的位置。

在每一帧移动木头精灵之后,代码会使用一个 if 代码块来检查精灵是否从屏幕左边或右边消失不见。如果是,木头会被移回起始位置,为下一次砍树做好准备。

如果运行游戏,你将能够看到木头朝着屏幕的相应一侧飞去。

img

图5.2:飞行的木头

现在来处理一个更敏感的问题。让我们看看如何处理玩家失败的情况。

# 处理死亡

每一款游戏都必定会有不好的结局,要么玩家时间耗尽(我们已经处理过这种情况),要么被树枝砸扁。蜉蝣是一种水生生物,寿命在几小时到几天不等。玩《Timber!!!》这款游戏就像一只匆忙的蜉蝣——你要么时间不够用,要么感觉命运的树枝压碎了你的希望!在《Timber!!!》游戏中,我们的主角可能只能坚持几秒钟,即使是经验丰富的玩家也很难坚持几分钟以上。

幸运的是,检测玩家被砸扁非常简单。我们只需要知道 branchPositions 数组中的最后一个树枝是否等于 playerSide。如果相等,玩家就死了。

添加下面这段用于检测这种情况的突出显示代码,然后我们再讨论玩家被砸扁时需要做的所有事情:

// 处理飞行的木头
if (logActive) {
    spriteLog.setPosition(
        spriteLog.getPosition().x + (logSpeedX * dt.asSeconds()),
        spriteLog.getPosition().y + (logSpeedY * dt.asSeconds()));

    // 木头是否到达了右边边缘?
    if (spriteLog.getPosition().x < -100 || spriteLog.getPosition().x > 2000)
    {
        // 设置它以便在下一帧成为全新的云(此处代码疑似有误,原代码为new cloud,结合前文推测应为new log)
        logActive = false;
        spriteLog.setPosition(800, 600);
    }
}

// 玩家是否被树枝砸扁了?
if (branchPositions[5] == playerSide)
{
    // 死亡
    paused = true;
    acceptInput = false;
    // 绘制墓碑
    spriteRIP.setPosition(525, 760);

    // 隐藏玩家
    spritePlayer.setPosition(2000, 660);

    // 更改消息文本
    messageText.setString("SQUISHED!!");

    // 将其在屏幕上居中显示
    FloatRect textRect = messageText.getLocalBounds();
    messageText.setOrigin(textRect.left + textRect.width / 2.0f, textRect.top + textRect.height / 2.0f);
    messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
}

} // End if(!paused)

/*
****************************************
绘制场景
****************************************
*/
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

玩家死亡后,新代码做的第一件事就是将 paused 设置为 true。现在,循环将完成这一帧,并且在玩家开始新游戏之前,不会再次运行循环的更新部分。

然后,我们将墓碑移动到玩家站立位置附近,并将玩家的精灵隐藏到屏幕外。

我们将 messageText 的字符串设置为 “Squished!!”,然后使用常用的技术将其在屏幕上居中显示。

现在你可以运行游戏并真正玩一玩了。这张图片展示了玩家的最终得分、墓碑以及 “SQUISHED” 消息。

img

图5.3:被砸扁

还有最后一个问题。不,不是那个人把斧头留在树上这件事。是只有我有这种感觉,还是游戏有点太安静了?

# 简单的音效

我们将添加三种音效。每种音效会在特定的游戏事件发生时播放:玩家每次砍树时播放一个简单的重击声,玩家时间耗尽时播放一个令人沮丧的失败音效,玩家被砸死时播放一个复古的挤压音效。

# SFML音效的工作原理

SFML 使用两个不同的类来播放音效。第一个类是 SoundBuffer 类。这个类用于存储来自声音文件的实际音频数据。SoundBuffer 负责将 .wav 文件加载到电脑内存中,并且加载后的格式无需进一步解码即可播放。

在我们很快编写音效代码时,会看到一旦我们有了一个存储声音的 SoundBuffer 对象,就会创建另一个 Sound 类型的对象。然后我们可以将这个 Sound 对象与一个 SoundBuffer 对象关联起来。

然后,在代码中的适当位置,我们就可以调用相应 Sound 对象的 play 函数。

# 何时播放音效

很快我们就会看到,用C++代码加载和播放音效其实非常简单。然而,我们需要考虑的是何时调用播放函数。在代码的什么位置放置播放函数的调用呢?

  • 砍树音效可以在检测到玩家按下左右方向键时调用。
  • 死亡音效可以在检测到玩家被树枝砸中的if代码块中播放。
  • 时间耗尽音效可以在检测到timeRemaining小于零的if代码块中播放 。

现在,我们可以编写音效代码了。

# 添加音效代码

首先,添加另一个#include指令,以便使用SFML中与音效相关的类。添加突出显示的代码:

#include <sstream>
#include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp>

using namespace sf;
1
2
3
4
5

现在,我们声明三个不同的SoundBuffer对象,将三个不同的声音文件加载到这些对象中,并将三个不同的Sound类型对象与相关的SoundBuffer类型对象关联起来。添加突出显示的代码:

// 控制玩家输入
bool acceptInput = false;

// 准备音效
SoundBuffer chopBuffer;
chopBuffer.loadFromFile("sound/chop.wav");
Sound chop;
chop.setBuffer(chopBuffer);

SoundBuffer deathBuffer;
deathBuffer.loadFromFile("sound/death.wav");
Sound death;
death.setBuffer(deathBuffer);

// 时间耗尽
SoundBuffer ootBuffer;
ootBuffer.loadFromFile("sound/out_of_time.wav");
Sound outOfTime;
outOfTime.setBuffer(ootBuffer);

while (window.isOpen()) {
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

现在,我们可以播放第一个音效了。在检测到玩家按下右方向键的if代码块中,添加如下单独一行代码:

// 包装玩家控制逻辑
// 确保我们正在接受输入
if (acceptInput) {
    // 接下来还有更多代码...
    // 首先处理按下右方向键的情况
    if (Keyboard::isKeyPressed(Keyboard::Right)) {
        // 确保玩家在右边
        playerSide = side::RIGHT;

        score++;

        timeRemaining += (2 / score) + .15;

        spriteAxe.setPosition(AXE_POSITION_RIGHT, spriteAxe.getPosition().y);

        spritePlayer.setPosition(1120, 660);

        // 更新树枝
        updateBranches(score);

        // 设置木头向左飞
        spriteLog.setPosition(800, 600);
        logSpeedX = -5000;
        logActive = true;

        acceptInput = false;

        // 播放砍树音效
        chop.play();
    }
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

在以if (Keyboard::isKeyPressed(Keyboard::Left))开头的下一个代码块末尾,添加完全相同的代码,以便玩家在树的左边砍树时也能播放砍树音效。

找到处理玩家时间耗尽的代码,并添加如下突出显示的代码,以播放与时间耗尽相关的音效:

if (timeRemaining <= 0.f) {
    // 暂停游戏
    paused = true;

    // 更改显示给玩家的消息
    messageText.setString("Out of time!!");

    // 根据新的大小重新定位文本
    FloatRect textRect = messageText.getLocalBounds();
    messageText.setOrigin(textRect.left + textRect.width / 2.0f, textRect.top + textRect.height / 2.0f);
    messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);

    // 播放时间耗尽音效
    outOfTime.play();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

最后,为了在玩家被砸扁时播放死亡音效,在底部树枝与玩家处于同一侧时执行的if代码块中添加突出显示的代码:

// 玩家是否被树枝砸扁了?
if (branchPositions[5] == playerSide) {
    // 死亡
    paused = true;
    acceptInput = false;

    // 绘制墓碑
    spriteRIP.setPosition(675, 660);

    // 隐藏玩家
    spritePlayer.setPosition(2000, 660);

    messageText.setString("SQUISHED!!");
    FloatRect textRect = messageText.getLocalBounds();
    messageText.setOrigin(textRect.left + textRect.width / 2.0f, textRect.top + textRect.height / 2.0f);
    messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);

    // 播放死亡音效
    death.play();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

如果音效无法播放,最可能的问题是声音文件加载失败。确定是否发生这种情况的方法是将加载声音的代码放在一个if代码块中。

首先,添加include指令,这样我们就能使用cout <<函数,就像我们在第3章学习字符串拼接时做的那样。以下是需要添加的内容以及现有的include指令,供你参考:

#include<iostream>
1

现在,像这样包装每个loadFromFile函数调用:

if (!chopBuffer.loadFromFile("sound/chop.wav")) {
    std::cout << "didn't load chop.wav";
}
1
2
3

现在,如果文件加载失败,你就会得到一条简洁的错误消息。如果文件没有加载,检查以下几点:

  • 文件名称与代码中指定的完全一致,即chop.wav。
  • 文件在sound文件夹中。
  • sound文件夹与C++文件Timber.cpp在项目根目录下。

就是这样!我们完成了第一款游戏。在继续第二个项目之前,让我们讨论一些可能的改进方案。

# 改进游戏和代码

看看这些针对《Timber!!!》项目提出的改进建议。你可以在下载包的Runnable文件夹中看到这些改进后的实际效果:

  • 加快代码速度:我们的代码中有一部分会拖慢游戏速度。对于这个简单的游戏来说,这倒无关紧要,但我们可以通过将sstream代码放在一个偶尔执行的代码块中,来加快游戏速度。毕竟,我们不需要每秒更新数千次分数!
  • 添加调试控制台:让我们添加更多文本,以便查看当前帧率。和分数一样,我们不需要过于频繁地更新帧率,每一百帧更新一次就可以。
  • 在背景中添加更多树木:只需添加更多树木精灵,并将它们绘制在看起来合适的位置(有些离相机近一些,有些远一些)。
  • 提高HUD(平视显示器,Heads-Up Display)文本的可见性:我们可以在分数和帧率计数器后面绘制简单的RectangleShape对象。黑色并带有一点透明度看起来会很不错。
  • 提高云的代码效率:正如我们已经多次提到的,我们可以利用数组知识大幅缩短云的代码。

以下是使用数组编写的云的代码,而不是为每朵云重复编写三次代码:

for (int i = 0; i < NUM_CLOUDS; i++) {
    clouds[i].setTexture(textureCloud);
    clouds[i].setPosition(-300, i * 150);
    cloudsActive[i] = false;
    cloudsSpeeds[i] = 0;
}

// 3个具有相同纹理的新精灵
//Sprite spriteCloud1;
//Sprite spriteCloud2;
//Sprite spriteCloud3;
//spriteCloud1.setTexture(textureCloud);
//spriteCloud2.setTexture(textureCloud);
//spriteCloud3.setTexture(textureCloud);

// 将云放置在屏幕外
//spriteCloud1.setPosition(0, 0);
//spriteCloud2.setPosition(0, 150);
//spriteCloud3.setPosition(0, 300);

// 云当前是否在屏幕上?
//bool cloud1Active = false;
//bool cloud2Active = false;
//bool cloud3Active = false;

// 每朵云的速度是多少?
//float cloud1Speed = 0.0f;
//float cloud2Speed = 0.0f;
//float cloud3Speed = 0.0f;
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

在前面的代码中,旧的、不再使用的代码被注释掉了,新的基于数组的代码在顶部。显然,通常情况下你会删除不需要的代码。我把它留在那里只是为了展示给你看。你可以在第5章文件夹中的enhanced.cpp文件中查看增强版的完整代码,包括数组声明和初始化。

看看添加了额外树木、云朵以及文本透明背景后的游戏实际效果:

img

图5.4:增强版《Timber》

要查看这些改进的代码,可以在下载包的“Timber增强版”文件夹中查找。

# 总结

在本章中,我们为《Timber!!!》游戏添加了最后的润色和图形效果。如果在阅读本书之前,你从未编写过一行C++代码,那么你可以好好为自己鼓鼓掌。仅仅通过五个章节的学习,你就从一无所知到做出了一款可运行的游戏。

不过,我们不会庆祝太久,因为在下一章,我们将直接进入稍微更有难度的C++内容。下一款游戏是简单的《Pong》游戏,在某些方面它比《Timber!!!》更简单,但我们所学的编写自己的类的知识,将帮助我们构建更复杂、功能更丰富的游戏。

# 常见问题解答

**问:**用数组解决云的问题更高效。但是我们真的需要三个单独的数组吗?一个用于表示云是否活动,一个用于表示速度,还有一个用于存储精灵本身?

**答:**如果我们看看各种对象(例如精灵对象)所具有的属性/变量,就会发现数量众多。精灵有位置、颜色、大小、旋转等诸多属性。要是精灵还能有是否活动、速度,甚至更多属性,那就太完美了。问题在于,SFML的开发者不可能预测到我们使用他们的Sprite类的所有方式。幸运的是,我们可以创建自己的类。我们可以创建一个名为Cloud的类,用一个布尔值表示是否活动,用一个整数表示速度。我们甚至可以给Cloud类添加一个SFML的Sprite对象。这样我们就能进一步简化云的代码。下一章我们将学习设计自己的类。

第4章 循环、数组、switch语句、枚举和函数:实现游戏机制
第6章 面向对象编程——开始开发《乒乓》游戏

← 第4章 循环、数组、switch语句、枚举和函数:实现游戏机制 第6章 面向对象编程——开始开发《乒乓》游戏→

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