第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的高级音效功能。另一个常见技巧是每次改变枪声的音调,这样能让声音更真实,减少单调感。