 第14章 音效、文件输入输出与完成游戏制作
第14章 音效、文件输入输出与完成游戏制作
  # 第14章 音效、文件输入/输出与完成游戏制作
我们的项目已接近尾声。这简短的一章将展示如何使用C++标准库轻松操作存储在硬盘上的文件,同时还会添加音效。当然,我们已经知道如何添加音效,但接下来会详细探讨在代码中调用播放函数的具体位置。此外,我们还会处理一些收尾工作,让游戏更加完善。
在本章中,我们将涵盖以下主题:
- 保存和加载最高分
- 准备音效
- 允许玩家升级并生成新一波僵尸
- 重新开始游戏
- 播放其余音效
# 保存和加载最高分
文件输入/输出(File I/O)是一个颇具技术含量的话题。幸运的是,由于这在编程中是常见需求,有一个库可以帮我们处理其中的复杂操作。就像为平视显示器(HUD)拼接字符串一样,C++标准库通过fstream提供了必要的功能。
首先,像包含sstream一样包含fstream:
#include <sstream>
#include <fstream>
#include <SFML/Graphics.hpp> 
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h" 
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
2
3
4
5
6
7
8
9
现在,在“ZombieArena”文件夹中新建一个名为“gamedata”的文件夹。接着,在这个文件夹中右键创建一个名为“scores.txt”的新文件,我们将在这个文件中保存玩家的最高分。你可以轻松打开该文件并添加一个分数,添加时要确保分数较低,这样方便测试当玩家打破这个分数时,新分数能否被正确添加。完成操作后务必关闭文件,否则游戏将无法访问它。
在下面的代码中,我们将创建一个名为inputFile的ifstream对象,并把刚创建的文件夹和文件作为参数传递给它的构造函数。if(inputFile.is_open())用于检查文件是否存在且可以读取。然后,我们将文件内容读取到hiScore变量中并关闭文件。添加以下突出显示的代码:
// Score
Text scoreText;
scoreText.setFont(font);
scoreText.setCharacterSize(55);   
scoreText.setColor(Color::White);
scoreText.setPosition(20, 0);
//  Load  the  high  score from  a  text file
std::ifstream  inputFile("gamedata/scores.txt");   
if   (inputFile.is_open ())
{
    //  >>  Reads  the  data
    inputFile   >>   hiScore;   
    inputFile.close();
}
// Hi  Score
Text hiScoreText;
hiScoreText.setFont(font);
hiScoreText.setCharacterSize(55);   
hiScoreText.setColor(Color::White); 
hiScoreText.setPosition(1400, 0);   
std::stringstream s;
s << "Hi Score:" << hiScore;
hiScoreText.setString(s.str());
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
现在,我们来处理保存可能出现的新最高分。在处理玩家生命值小于等于零的代码块中,需要创建一个名为outputFile的ofstream对象,将hiScore的值写入文本文件,然后关闭文件,如下所示:
// Have  any  zombies  touched  the player
for (int i = 0; i < numZombies; i++) {
    if (player.getPosition().intersects
        (zombies[i].getPosition()) && zombies[i].isAlive()) {
        if (player.hit(gameTimeTotal)) {
            // More  here  later
        }
        if (player.getHealth() <= 0) {
            state = State::GAME_OVER;
            std::ofstream  outputFile("gamedata/scores.txt");
            //  <<  writes  the  data
            outputFile   <<   hiScore;   
            outputFile.close();
        }
    }
}//  End player  touched
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
你可以玩游戏,你的最高分将会被保存。退出游戏后,再次游玩时会发现最高分依然存在。
在下一节中,我们将为游戏添加音效。
# 准备音效
在本节中,我们将创建所有需要的SoundBuffer和Sound对象,为游戏添加一系列音效。
首先添加所需的SFML #include语句:
#include <sstream> 
#include <fstream>
#include <SFML/Graphics.hpp> 
#include <SFML/Audio.hpp>
#include "ZombieArena.h" 
#include "Player.h"
#include "TextureHolder.h" 
#include "Bullet.h"
#include "Pickup.h"
2
3
4
5
6
7
8
9
现在,继续添加七个SoundBuffer和Sound对象,用于加载和准备我们在第8章“SFML视图——开始僵尸射击游戏”中准备的七个声音文件:
// When  did  we  last  update  the  HUD?
int framesSinceLastHUDUpdate = 0;
// What  time  was  the  last  update
Time timeSinceLastUpdate;
// How  often  (in frames)  should  we  update  the HUD
int fpsMeasurementFrameInterval = 1000;
// Prepare  the  hit  sound
SoundBuffer  hitBuffer;
hitBuffer.loadFromFile ("sound/hit.wav");   
Sound   hit;
hit.setBuffer(hitBuffer);
// Prepare  the  splat  sound
SoundBuffer  splatBuffer;
splatBuffer.loadFromFile ("sound/splat.wav");   
Sound   splat;
splat.setBuffer(splatBuffer);
// Prepare  the  shoot  sound
SoundBuffer   shootBuffer;
shootBuffer.loadFromFile ("sound/shoot.wav");   
Sound   shoot;
shoot.setBuffer(shootBuffer);
// Prepare  the  reload  sound
SoundBuffer   reloadBuffer;
reloadBuffer.loadFromFile ("sound/reload.wav");   
Sound   reload;
reload.setBuffer(reloadBuffer);
// Prepare  the failed  sound
SoundBuffer  reloadFailedBuffer;
reloadFailedBuffer.loadFromFile ("sound/reload_failed.wav");    
Sound   reloadFailed;
reloadFailed.setBuffer(reloadFailedBuffer);
// Prepare  the powerup  sound
SoundBuffer   powerupBuffer;
powerupBuffer.loadFromFile ("sound/powerup.wav");   
Sound   powerup;
powerup.setBuffer(powerupBuffer);
// Prepare  the pickup  sound
SoundBuffer   pickupBuffer;
pickupBuffer.loadFromFile ("sound/pickup.wav");   
Sound   pickup;
pickup.setBuffer(pickupBuffer);
//  The  main  game  loop
while (window.isOpen())
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
现在,七个音效已准备就绪,只需确定在代码中调用每个play函数的位置即可。
# 允许玩家升级并生成新一波僵尸
在下面的代码中,我们允许玩家在波次之间升级。由于之前已经做了一些工作,实现起来很简单。
在处理玩家输入的“升级(LEVELING_UP)”状态中添加以下突出显示的代码:
// Handle  the  LEVELING  up  state
if (state == State::LEVELING_UP) {
    // Handle  the player  LEVELING  up
    if (event.key.code == Keyboard::Num1) {
        // Increase fire  rate
        fireRate++;
        state = State::PLAYING; 
    }
    if (event.key.code == Keyboard::Num2) {
        // Increase  clip  size
        clipSize  +=   clipSize;
        state = State::PLAYING; 
    }
    if (event.key.code == Keyboard::Num3) {
        // Increase  health
        player.upgradeHealth();
        state = State::PLAYING; 
    }
    if (event.key.code == Keyboard::Num4) {
        // Increase  speed
        player.upgradeSpeed();
        state = State::PLAYING; 
    }
    if (event.key.code == Keyboard::Num5) {
        // Upgrade pickup
        healthPickup.upgrade();
        state = State::PLAYING; 
    }
    if (event.key.code == Keyboard::Num6) {
        // Upgrade pickup
        ammoPickup.upgrade();
        state = State::PLAYING; 
    }
    if (state == State::PLAYING) {
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
现在,玩家每次清除一波僵尸后都可以升级。不过,目前我们还不能增加僵尸数量或关卡大小。
在“升级(LEVELING_UP)”状态的下一部分,就在刚刚添加的代码之后,修改从“升级(LEVELING_UP)”状态切换到“游玩(PLAYING)”状态时运行的代码。
以下是完整代码,新添加或稍有修改的行已突出显示。添加或修改以下突出显示的代码:
if (event.key.code == Keyboard::Num6) {
    ammoPickup.upgrade();
    state = State::PLAYING; 
}
if (state == State::PLAYING) {
    // Increase  the  wave  number
    wave++;
    //  Prepare  the  level
    // We  will modify  the  next  two  lines  later
    arena.width   =   500   *  wave;     
    arena.height   =   500   *  wave;
    arena.left = 0; arena.top = 0;
    //  Pass  the  vertex  array  by  reference 
    //  to  the  createBackground function
    int tileSize = createBackground(background, arena);
    // Spawn  the player  in  the middle  of  the  arena
    player.spawn(arena, resolution, tileSize);
    //  Configure  the pick-ups
    healthPickup.setArena(arena); 
    ammoPickup.setArena(arena);
    //  Create  a  horde  of zombies
    numZombies   =   5   *  wave;
    // Delete  the previously  allocated memory  (if  it  exists)
    delete[] zombies;
    zombies = createHorde(numZombies, arena); 
    numZombiesAlive = numZombies;
    // Play  the powerup  sound
    powerup.play();
    //  Reset  the  clock  so  there  isn't  a frame jump
    clock.restart(); 
}
}//  End  LEVELING  up
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
上述代码首先增加wave变量的值,然后修改代码,使僵尸数量和竞技场大小与wave的新值相关联。这很有用,因为之前在小区域内有10个僵尸,游戏难度可能较高,现在游戏开始时会有5个僵尸。最后,添加对powerup.play()的调用,以播放“升级”音效。
# 重新开始游戏
我们已经通过wave变量的值确定了竞技场的大小和僵尸的数量。在每局新游戏开始时,我们还必须重置弹药和枪支相关的变量,并将wave和score设置为零。
在游戏循环的事件处理部分找到以下代码,并添加突出显示的代码,如下所示:
// Start  a  new  game  while  in  GAME_OVER  state
else if (event.key.code == Keyboard::Return && state == State::GAME_OVER)
{
    state = State::LEVELING_UP;
    wave   =   0;       
    score   =   0;
    // Prepare  the  gun  and  ammo for  next  game
    currentBullet   =  0;   
    bulletsSpare   =   24;  
    bulletsInClip   =  6;   
    clipSize   =   6;
    fireRate   =   1;
    // Reset  the player's  stats
    player.resetPlayerStats();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
现在,玩家可以投入游戏,随着僵尸数量在不断扩大的竞技场中增多,玩家的能力也会越来越强。游戏会一直进行,直到玩家死亡,之后游戏会重新开始。
# 播放其余音效
现在,我们将添加对play函数的其余调用。我们会逐个处理这些调用,因为准确确定它们在代码中的位置对于在正确的时机使用音效至关重要。
# 添加玩家重新装填弹药时的音效
在三个特定位置添加以下突出显示的代码,以便在玩家按下R键尝试重新装填枪支时,触发相应的重新装填或重新装填失败音效:
if (state == State::PLAYING) {
    //  Reloading
    if (event.key.code == Keyboard::R) {
        if (bulletsSpare >= clipSize) {
            //  Plenty  of bullets.  Reload.
            bulletsInClip = clipSize; 
            bulletsSpare -= clipSize;
            reload.play();
        }
        else if (bulletsSpare > 0) {
            //  Only few  bullets  left
            bulletsInClip = bulletsSpare; 
            bulletsSpare = 0;
            reload.play();
        }
        else
        {
            // More  here  soon?!
            reloadFailed.play();
        } 
    }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
现在,玩家重新装填或尝试重新装填枪支时会听到相应的声音反馈。接下来,我们来添加射击音效。
# 制作射击音效
在处理玩家点击鼠标左键的代码末尾附近,添加以下突出显示的对shoot.play()的调用:
//  Fire  a  bullet
if (sf::Mouse::isButtonPressed(sf::Mouse::Left)) {
    if (gameTimeTotal.asMilliseconds()
        - lastPressed.asMilliseconds()
        > 1000 / fireRate && bulletsInClip > 0) {
        //  Pass  the  centre  of  the player  and  crosshair 
        //  to  the  shoot function
        bullets[currentBullet].shoot(
            player.getCenter().x, player.getCenter().y,
            mouseWorldPosition.x, mouseWorldPosition.y); 
        currentBullet++;
        if (currentBullet > 99) {
            currentBullet = 0; 
        }
        lastPressed = gameTimeTotal;
        shoot.play();
        bulletsInClip--; 
    }
}//  End fire  a  bullet
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
现在,游戏会播放令人满意的射击音效。接下来,我们添加玩家被僵尸击中时的音效。
# 玩家被击中时播放音效
在以下代码中,我们将对hit.play的调用放在一个测试中,检查player.hit函数是否返回true。请记住,player.hit函数用于检查在过去100毫秒内是否记录到了一次击中。这会产生一种快速重复的重击声效果,但又不会快到声音模糊成一种噪音。
添加对hit.play的调用,如下代码中突出显示部分:
// Have  any  zombies  touched  the player
for (int i = 0; i < numZombies; i++) {
    if (player.getPosition().intersects
        (zombies[i].getPosition()) && zombies[i].isAlive()) {
        if (player.hit(gameTimeTotal)) {
            // More  here  later
            hit.play();
        }
        if (player.getHealth() <= 0) {
            state = State::GAME_OVER;
            std::ofstream OutputFile("gamedata/scores.txt"); 
            OutputFile << hiScore;
            OutputFile.close();
        } 
    }
}//  End player  touched
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
当僵尸碰到玩家时,玩家会听到一种不祥的重击声,如果僵尸持续碰到玩家,这种声音每秒大约会重复五次。这背后的逻辑包含在Player类的hit函数中。
# 拾取物品时播放音效
当玩家拾取生命值补给品时,我们将播放常规的拾取音效。然而,当玩家拾取弹药补给品时,我们将播放重新装填音效。
在相应的碰撞检测代码中添加两个播放音效的调用:
// Has  the player  touched  health pickup
if (player.getPosition().intersects
    (healthPickup.getPosition()) && healthPickup.isSpawned())
{
    player.increaseHealthLevel(healthPickup.gotIt());
    // Play  a  sound
    pickup.play();
}
// Has  the player  touched  ammo pickup
if (player.getPosition().intersects
    (ammoPickup.getPosition()) && ammoPickup.isSpawned()) {
    bulletsSpare += ammoPickup.gotIt();
    // Play  a  sound
    reload.play();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 僵尸被击中时制作溅血音效
在检测到子弹与僵尸碰撞的代码部分末尾,添加对splat.play的调用:
// Have  any  zombies  been  shot?
for (int i = 0; i < 100; i++) {
    for (int j = 0; j < numZombies; j++) {
        if (bullets[i].isInFlight() &&
            zombies[j].isAlive())
        {
            if (bullets[i].getPosition().intersects (zombies[j].getPosition()))
            {
                // Stop  the  bullet
                bullets[i].stop();
                //  Register  the  hit  and  see  if  it  was  a  kill
                if (zombies[j].hit()) {
                    // Not just  a  hit  but  a  kill  too
                    score += 10;
                    if (score >= hiScore)
                    {
                        hiScore = score; 
                    }
                    numZombiesAlive--;
                    // When  all  the  zombies  are  dead  (again)
                    if (numZombiesAlive == 0) {
                        state = State::LEVELING_UP;
                    }
                }
                // Make  a  splat  sound
                splat.play();
            } 
        }
    }
}//  End  zombie  being  shot
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
现在,你可以玩完整的游戏,看着每一波僵尸的数量和竞技场的大小不断增加。记得要谨慎选择升级选项。
恭喜!
# 总结
我们完成了《僵尸竞技场》这款游戏。这一路走来收获颇丰。我们学习了许多C++基础知识,比如引用、指针、面向对象编程(OOP)和类。此外,我们使用了SFML来管理相机(视图)、顶点数组和碰撞检测。我们还了解了如何使用精灵表(sprite sheets)减少对window.draw的调用次数,提高帧率。通过使用C++指针、标准模板库(STL)和一些面向对象编程知识,我们构建了一个单例类来管理纹理。
# 常见问题解答
以下是一些你可能会想到的问题:
问:尽管使用了类,但我发现代码又变得很长且难以管理。
答:最大的问题之一是我们代码的结构。随着对C++学习的深入,我们也会学到让代码更易于管理、总体上更简洁的方法。在下一个也是最后一个项目中我们也会这么做。在本书结尾,你将了解到许多可以用来管理代码的策略。
问:音效听起来有点平淡且不真实。如何改进呢?
答:显著提升玩家对音效感受的一种方法是让音效具有方向性。你还可以根据声音源与玩家角色之间的距离来改变音量。我们将在下一个项目中使用SFML的高级音效功能。另一个常见技巧是每次改变枪声的音调,这样能让声音更真实,减少单调感。
