第09回 音声の管理と再生
4 / 4 音声機能の動作確認
このステップの目的
BGMの切り替え、効果音の再生タイミング、リソース解放の整合性を確認し、演出の強化へつなげます。
動作確認の手順
プロジェクトをビルドして実行し、スピーカーやヘッドフォンで以下の項目を順に確認します。
- タイトルBGMの再生
ゲーム起動時に、タイトル画面専用のBGMがループ再生されていることを確認します。 - プレイ中BGMへのスムーズな切り替え
ゲームを始めるとタイトルBGMがすぐに止まり、プレイ中のBGMへ滑らかに切り替わることを確認してください。2曲が同時に鳴り、音が濁っていないかも聞きます。 - 射撃音と連射の確認
射撃キーを押して弾を撃った際、弾が飛ぶタイミングと同期して射撃音が正しく鳴ることを確認します。 - 命中音と被弾音の聞き分け
敵に弾が当たったときに「命中音」が鳴り、敵弾に被弾した瞬間に「被弾音」が鳴ることを確認します。また、被弾直後の点滅中(無敵時間中)に敵弾が当たっても被弾音が鳴らないことを確認します。 - 結果画面でのジングルとBGM停止
クリアまたはゲームオーバー時に、プレイ中BGMが停止し、結果画面用のファンファーレや効果音が1回だけ鳴ることを確認します。 - リトライ時のBGM復帰
結果画面からリトライした際、再びプレイ中BGMが最初から正しく再生されることを確認します。
音が聞こえない場合のトラブルシューティング
もし効果音やBGMが聞こえない場合は、以下の項目を順に点検してください。
Assets/Sounds/フォルダ内に、TitleBgm.wavやShot.wavなどの音声ファイルが正しく存在しているか確認します。- PC本体の音量設定やミュート状態、スピーカーの接続を確認します。
GameTuning.hの音量設定(BgmVolumeやSeVolume)が0になっていないか確認します。
チェックリスト
- タイトル画面でタイトルBGMがループ再生されていることを確認できる
- ゲーム開始時に古いBGMが停止し、プレイ中BGMへ正しく切り替わることを確認できる
- 弾が実際に発射された瞬間のみ射撃音が鳴ることを確認できる
- 敵への命中、被弾成立時に対応する効果音が鳴ることを確認できる
- 結果画面でBGMが停止し、結果専用のジングルが鳴ることを確認できる
- ゲーム終了時にデストラクタによってすべてのサウンドハンドルが自動解放されていることを説明できる
まとめと次回への引き継ぎ
第09回では、画像管理で学んだRAIIを音声にも使いました。BGMの切り替えと、出来事に応じた効果音を実装しました。映像に音が加わり、ゲームの手応えと完成度が大きく高まりました。
第10回では、画面演出を仕上げるため、エフェクトとスクロール背景を実装します。命中時の火花や爆発をプールで管理し、宇宙空間が流れる背景を加えて、画面を華やかにします。
参考実装
今回追加・変更するファイルの一例です。宣言や補助処理を確認したいときに開いてください。自分のコードへ必要な変更を反映し、既存の調整値や素材をそのまま上書きしないようにします。
Source/Shooting/Game.cpp
Source/Shooting/Game.cppcpp
#include <cmath>
#include <string>
#include <DxLib.h>
#include <Windows.h>
#include "Config/ApplicationConfig.h"
#include "Shooting/Tuning/GameTuning.h"
#include "Shooting/Game.h"
namespace
{
void ShowResourceLoadError(
const wchar_t* resourceType,
const std::wstring& failedPath)
{
std::wstring message = resourceType;
message += L"の読み込みに失敗しました。\n\n対象: ";
message += failedPath;
message += L"\n\nAssetsフォルダーとファイル名を確認してください。";
MessageBoxW(
nullptr,
message.c_str(),
L"リソース読み込みエラー",
MB_OK | MB_ICONERROR);
}
}
namespace dxgame::shooting
{
namespace config = dxgame::config;
namespace
{
using SoundId = SoundManager::Id;
}
bool Game::Run()
{
const ImageManager::LoadResult imageResult = images.LoadAll();
if (!imageResult.succeeded)
{
ShowResourceLoadError(L"画像", imageResult.failedPath);
return false;
}
const SoundManager::LoadResult soundResult = sounds.LoadAll();
if (!soundResult.succeeded)
{
ShowResourceLoadError(L"音声", soundResult.failedPath);
return false;
}
ChangeState(State::Title);
// 小数分の待ち時間を次フレームへ持ち越す
double nextFrame = GetNowCount();
while (ProcessMessage() == 0)
{
input.Update();
if (input.IsDown(Input::Action::Exit))
break;
Update(config::DeltaTime);
ClearDrawScreen();
Draw();
ScreenFlip();
nextFrame += config::FrameMilliseconds;
int waitMilliseconds = static_cast<int>(nextFrame - GetNowCount());
if (waitMilliseconds > 0)
WaitTimer(waitMilliseconds);
else
nextFrame = GetNowCount();
}
return true;
}
void Game::Update(float deltaTime)
{
switch (state)
{
case State::Title:
UpdateTitle();
break;
case State::Playing:
UpdatePlaying(deltaTime);
break;
case State::GameClear:
case State::GameOver:
UpdateResult();
break;
}
}
void Game::Draw() const
{
switch (state)
{
case State::Title:
DrawTitle();
break;
case State::Playing:
DrawPlaying();
break;
case State::GameClear:
case State::GameOver:
DrawPlaying();
DrawResult();
break;
}
}
void Game::StartGame()
{
// 前回の結果を残さず、全オブジェクトを開始状態へ戻す
player.Reset();
playerShots.Reset();
enemies.Reset();
enemyShots.Reset();
timeLeft = tuning::game::TimeLimit;
score = 0;
ChangeState(State::Playing);
}
void Game::ChangeState(State nextState)
{
state = nextState;
// Stateに入ったときのBGMをここで切り替える
switch (state)
{
case State::Title:
sounds.PlayBgm(SoundId::TitleBgm);
break;
case State::Playing:
sounds.PlayBgm(SoundId::PlayingBgm);
break;
case State::GameClear:
sounds.StopBgm();
sounds.PlaySe(SoundId::Clear);
break;
case State::GameOver:
sounds.StopBgm();
sounds.PlaySe(SoundId::GameOver);
break;
}
}
void Game::UpdateTitle()
{
if (input.IsPressed(Input::Action::Confirm))
StartGame();
}
void Game::UpdatePlaying(float deltaTime)
{
timeLeft -= deltaTime;
if (timeLeft < 0.0f)
timeLeft = 0.0f;
player.Update(input, deltaTime);
playerShots.Update(deltaTime);
enemies.Update(deltaTime);
enemyShots.Update(deltaTime);
if (player.TryShoot(input, playerShots))
{
sounds.PlaySe(SoundId::Shot);
}
ResolveCollisions();
if (!player.IsAlive())
{
ChangeState(State::GameOver);
return;
}
if (score >= tuning::game::ClearScore || timeLeft <= 0.0f)
{
ChangeState(State::GameClear);
return;
}
// 発射時点のプレイヤー位置を、すべての敵が狙う位置として共有する
Vector2 playerPosition = player.GetHitbox().GetCenter();
for (Enemy& enemy : enemies.GetAll())
{
enemy.TryShoot(enemyShots, playerPosition);
}
}
void Game::UpdateResult()
{
if (input.IsPressed(Input::Action::Confirm))
StartGame();
else
if (input.IsPressed(Input::Action::Back))
ChangeState(State::Title);
}
void Game::ResolveCollisions()
{
// プレイヤーの弾は最初に重なった敵だけに命中させる
for (PlayerShot& playerShot : playerShots.GetAll())
{
if (!playerShot.IsActive())
continue;
for (Enemy& enemy : enemies.GetAll())
{
if (!enemy.IsActive())
continue;
if (!playerShot.GetHitbox().Overlaps(enemy.GetHitbox()))
continue;
enemy.TakeDamage(tuning::playerShot::Damage);
playerShot.Deactivate();
if (!enemy.IsActive())
++score;
sounds.PlaySe(SoundId::Hit);
break;
}
}
for (EnemyShot& enemyShot : enemyShots.GetAll())
{
if (!enemyShot.IsActive())
continue;
if (!enemyShot.GetHitbox().Overlaps(player.GetHitbox()))
continue;
enemyShot.Deactivate();
if (player.TryTakeDamage())
sounds.PlaySe(SoundId::Damage);
}
for (Enemy& enemy : enemies.GetAll())
{
if (!enemy.IsActive())
continue;
if (!enemy.GetHitbox().Overlaps(player.GetHitbox()))
continue;
enemy.Deactivate();
if (player.TryTakeDamage())
sounds.PlaySe(SoundId::Damage);
}
}
void Game::DrawTitle() const
{
DrawString(194, 128, L"VERTICAL SHOOTING", GetColor(80, 220, 255));
DrawString(248, 210, L"PRESS ENTER", GetColor(255, 230, 80));
DrawString(208, 276, L"ARROW / WASD : MOVE", GetColor(210, 218, 232));
DrawString(238, 306, L"Z / SPACE : SHOT", GetColor(210, 218, 232));
}
void Game::DrawPlaying() const
{
// 描画順はプレイヤー、プレイヤーの弾、敵、敵の弾の順
player.Draw(images);
playerShots.Draw(images);
enemies.Draw(images);
enemyShots.Draw(images);
DrawHud();
}
void Game::DrawResult() const
{
SetDrawBlendMode(DX_BLENDMODE_ALPHA, 168);
DrawBox(0, 0, tuning::ScreenWidth, tuning::ScreenHeight, GetColor(0, 0, 0), TRUE);
SetDrawBlendMode(DX_BLENDMODE_NOBLEND, 0);
if (state == State::GameClear)
DrawString(254, 164, L"GAME CLEAR", GetColor(100, 255, 160));
else
DrawString(254, 164, L"GAME OVER", GetColor(255, 96, 112));
DrawFormatString(276, 216, GetColor(255, 255, 255), L"SCORE %02d", score);
DrawString(170, 282, L"ENTER : RETRY T : TITLE", GetColor(255, 230, 80));
}
void Game::DrawHud() const
{
int secondsLeft = static_cast<int>(std::ceil(timeLeft));
DrawFormatString(16, 16, GetColor(255, 255, 255), L"SCORE %02d / %02d", score, tuning::game::ClearScore);
DrawFormatString(16, 42, GetColor(255, 255, 255), L"HP %d", player.GetHp());
DrawFormatString(532, 16, GetColor(255, 255, 255), L"TIME %02d", secondsLeft);
}
}Source/Shooting/Game.h
Source/Shooting/Game.hcpp
#pragma once
#include "Shooting/SoundManager.h"
#include "Shooting/ImageManager.h"
#include "Shooting/Input.h"
#include "Shooting/EnemyManager.h"
#include "Shooting/EnemyShotManager.h"
#include "Shooting/Player.h"
#include "Shooting/PlayerShotManager.h"
namespace dxgame::shooting
{
// ゲームに必要なオブジェクトを所有しStateごとの進行を取りまとめる
class Game
{
public:
// 必須画像を読み込んでゲームループを開始する
// 画像を読み込めない場合はループを開始せずfalseを返す
bool Run();
private:
// 今どの画面を表示しているかを表す
enum class State
{
Title,
Playing,
GameClear,
GameOver
};
// 現在のStateに応じて更新処理を分ける
void Update(float deltaTime);
// 現在のStateに応じて描画処理を分ける
void Draw() const;
// オブジェクトとスコアを初期化してプレイを開始する
void StartGame();
// 現在の画面をnextStateへ変更する
void ChangeState(State nextState);
// タイトルで決定入力を待つ
void UpdateTitle();
// プレイ中のオブジェクトと命中判定を更新する
void UpdatePlaying(float deltaTime);
// 結果画面で再開またはタイトルへの戻りを受け付ける
void UpdateResult();
// プレイヤー弾、敵弾、敵本体の重なりを判定する
void ResolveCollisions();
// タイトル画面を描く
void DrawTitle() const;
// プレイ画面とHUDを描く
void DrawPlaying() const;
// 結果画面を重ねて描く
void DrawResult() const;
// スコア、HP、残り時間を描く
void DrawHud() const;
ImageManager images;
SoundManager sounds;
Input input;
Player player;
PlayerShotManager playerShots;
EnemyManager enemies;
EnemyShotManager enemyShots;
State state = State::Title;
float timeLeft = 0.0f;
int score = 0;
};
}Source/Shooting/SoundManager.cpp
Source/Shooting/SoundManager.cppcpp
#include <array>
#include <cstddef>
#include <DxLib.h>
#include "Shooting/Tuning/GameTuning.h"
#include "Shooting/SoundManager.h"
namespace dxgame::shooting
{
namespace
{
using SoundId = SoundManager::Id;
// 音声ごとのIdとファイルパスのセット
struct SoundEntry
{
SoundId id;
const wchar_t* path;
};
// Idとファイルパスの対応
constexpr std::array<SoundEntry, static_cast<std::size_t>(SoundId::Count)> SoundTable =
{
SoundEntry { SoundId::TitleBgm, L"Assets/Sounds/TitleBgm.wav" },
SoundEntry { SoundId::PlayingBgm, L"Assets/Sounds/PlayingBgm.wav" },
SoundEntry { SoundId::Shot, L"Assets/Sounds/Shot.wav" },
SoundEntry { SoundId::Hit, L"Assets/Sounds/Hit.wav" },
SoundEntry { SoundId::Damage, L"Assets/Sounds/Damage.wav" },
SoundEntry { SoundId::Clear, L"Assets/Sounds/Clear.wav" },
SoundEntry { SoundId::GameOver, L"Assets/Sounds/GameOver.wav" }
};
// Idをhandlesの添字へ変換する
std::size_t ToIndex(SoundId id)
{
return static_cast<std::size_t>(id);
}
}
SoundManager::SoundManager()
{
// -1はDXライブラリのハンドルをまだ取得していない状態を表す
handles.fill(-1);
}
SoundManager::~SoundManager()
{
Release();
}
SoundManager::LoadResult SoundManager::LoadAll()
{
for (const SoundEntry& entry : SoundTable)
{
std::size_t index = ToIndex(entry.id);
handles[index] = LoadSoundMem(entry.path);
// 途中で失敗した場合は、ここで読み込み済み音声を解放する
if (handles[index] == -1)
{
Release();
return { false, entry.path };
}
// BGMとSEで共通の対応表を使いIdから音量を選ぶ
bool isBgm = entry.id == SoundId::TitleBgm || entry.id == SoundId::PlayingBgm;
int volume = isBgm ? tuning::sound::BgmVolume : tuning::sound::SeVolume;
ChangeVolumeSoundMem(volume, handles[index]);
}
return { true, {} };
}
void SoundManager::PlayBgm(Id id)
{
// BGMは同時に1曲だけ再生する
StopBgm();
currentBgm = handles[ToIndex(id)];
PlaySoundMem(currentBgm, DX_PLAYTYPE_LOOP);
}
void SoundManager::StopBgm()
{
if (currentBgm == -1)
return;
StopSoundMem(currentBgm);
currentBgm = -1;
}
void SoundManager::PlaySe(Id id) const
{
PlaySoundMem(handles[ToIndex(id)], DX_PLAYTYPE_BACK);
}
void SoundManager::Release()
{
StopBgm();
// 解放後は再び呼ばれても処理しないよう-1へ戻す
for (int& handle : handles)
{
if (handle == -1)
continue;
DeleteSoundMem(handle);
handle = -1;
}
}
}Source/Shooting/SoundManager.h
Source/Shooting/SoundManager.hcpp
#pragma once
#include <array>
#include <cstddef>
#include <string>
namespace dxgame::shooting
{
// 音声の読み込み、ハンドルの所有、再生、停止、解放を管理する
class SoundManager
{
public:
// ゲーム内で使うBGMとSEを種類ごとに識別する
enum class Id
{
TitleBgm,
PlayingBgm,
Shot,
Hit,
Damage,
Clear,
GameOver,
Count
};
// 全ハンドルを未取得の状態で初期化する
SoundManager();
// 再生中のBGMを止め取得済みの音声ハンドルをすべて解放する
~SoundManager();
// ハンドルを二重に解放しないようコピー構築を禁止する
SoundManager(const SoundManager&) = delete;
// ハンドルを二重に解放しないようコピー代入を禁止する
SoundManager& operator=(const SoundManager&) = delete;
// 音声の読み込み結果と、失敗した場合の相対パスを表す
struct LoadResult
{
bool succeeded = true;
std::wstring failedPath;
};
// 対応表にある音声をすべて読み込んで種類に応じた音量を設定する
// 1つでも読み込めない場合は取得済み音声を解放して失敗を返す
LoadResult LoadAll();
// 現在のBGMを止めidのBGMをループ再生する
void PlayBgm(Id id);
// BGMが再生中なら停止して未再生状態へ戻す
void StopBgm();
// idのSEを最初から1回再生する
void PlaySe(Id id) const;
private:
// BGMを停止し取得済みの全音声ハンドルを解放する
void Release();
std::array<int, static_cast<std::size_t>(Id::Count)> handles = {};
int currentBgm = -1;
};
}Source/Shooting/Tuning/GameTuning.h
Source/Shooting/Tuning/GameTuning.hcpp
#pragma once
#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"
#include "Config/ApplicationConfig.h"
namespace dxgame::shooting::tuning
{
// ゲーム画面の横幅 単位はピクセル
// ゲーム画面の横幅(単位:ピクセル)
inline constexpr int ScreenWidth = 640;
// ゲーム画面の縦幅 単位はピクセル
// ゲーム画面の縦幅(単位:ピクセル)
inline constexpr int ScreenHeight = 480;
namespace game
{
inline constexpr int ClearScore = 30;
inline constexpr float TimeLimit = 60.0f;
}
// 座標、距離、Rectの各値はピクセル単位で指定する
// 時間は秒、速度は1秒あたりの移動量で指定する
// プレイヤーのpositionは中心座標を表す
// 各Rectはpositionからの相対範囲として指定する
namespace player
{
// ゲーム開始時のプレイヤー中心座標
inline constexpr Vector2 StartPosition = { 320.0f, 424.0f };
// プレイヤー中心を基準にした画像の表示範囲
inline constexpr Rect DrawRect = Rect::Centered(48.0f, 58.0f).MovedBy({ 0.0f, 3.0f });
inline constexpr Rect Bounds = Rect::Centered(28.0f, 28.0f);
// プレイヤー中心を基準にした命中範囲
inline constexpr Rect Hitbox = Rect::Centered(28.0f, 28.0f);
// プレイヤーの速度 単位はピクセル/秒
// プレイヤーの速度(単位:ピクセル/秒)
inline constexpr float MoveSpeed = 240.0f;
// 弾を発射してから次に発射できるまでの時間 ここでは8フレーム間隔の意味
// 弾を発射してから次に発射できるまでの時間(ここでは8フレーム間隔)
inline constexpr float ShotInterval = 8.0f * config::DeltaTime;
// 被弾した際の無敵時間 単位は秒
// 被弾した際の無敵時間(単位:秒)
inline constexpr float InvincibleTime = 1.5f;
// ゲーム開始時のHP
inline constexpr int MaxHp = 3;
}
namespace playerShot
{
// 弾の中心を基準にした画像の表示範囲
inline constexpr Rect DrawRect = Rect::Centered(10.0f, 28.0f);
// 弾の中心を基準にした命中範囲
inline constexpr Rect Hitbox = Rect::Centered(8.0f, 20.0f);
// 弾の速度 単位はピクセル/秒
// 弾の速度(単位:ピクセル/秒)
inline constexpr float Speed = 480.0f;
// 敵へ与えるダメージ
inline constexpr int Damage = 1;
}
namespace enemy
{
inline constexpr int LaneCount = 8;
inline constexpr float LaneStart = 64.0f;
inline constexpr float LaneSpacing = 72.0f;
inline constexpr Rect DrawRect = Rect::Centered(42.0f, 42.0f);
inline constexpr Rect Hitbox = Rect::Centered(32.0f, 32.0f);
inline constexpr float BaseSpeed = 120.0f;
inline constexpr float SpeedStep = 12.0f;
inline constexpr int SpeedLevels = 4;
inline constexpr float SpawnInterval = 0.60f;
inline constexpr float SpawnDelay = 0.40f;
inline constexpr float ShotDelay = 0.80f;
inline constexpr float ShotInterval = 1.80f;
inline constexpr float WaveAmplitude = 30.0f;
inline constexpr float WaveSpeed = 3.0f;
inline constexpr int WaveInterval = 3;
inline constexpr int MaxHp = 1;
}
namespace enemyShot
{
inline constexpr Rect DrawRect = Rect::Centered(14.0f, 14.0f);
inline constexpr Rect Hitbox = Rect::Centered(10.0f, 10.0f);
inline constexpr float Speed = 180.0f;
}
namespace sound
{
inline constexpr int BgmVolume = 96;
inline constexpr int SeVolume = 160;
}
}