はじめに
Claude Code + MCP + Godot 4.xで脱出ゲームを作る連載の第11回です。
- 第1回:環境構築とプロジェクト作成
- 第2回:Claude Code + godot-mcpのセットアップ
- 第3回:Claude CodeとMCPでスプラッシュ画面を実装する
- 第4回:Claude CodeとMCPでタイトル画面を実装してシーンを遷移する
- 第5回:背景の特定領域をタップしてフェード遷移する
- 第6回:再利用可能なモーダルポップアップを作る
- 第7回:スプライトシートを使ったダイヤル錠ギミックを実装する
- 第8回:戻るボタン・正解判定・開封演出・ローカル保存を実装する
- 第9回:アイテム管理:シーン常駐のインベントリシステムを実装する
- 第10回:アイテムで扉を開ける
- 第11回:BGM・効果音を実装する(今回)
前回までにゲームプレイの骨格(謎解き・アイテム管理・シーン遷移)が一通り完成しました。今回はBGMと効果音(SE)を実装し、ゲームとしての手触りを仕上げます。
この記事で作るもの
シーン(タイトル/メインなど)に応じてBGMが自動的に切り替わり、ループ再生される仕組みを作ります。ドアの鍵穴・ダイヤル・郵便受けなど、操作のたびに対応する効果音が鳴る仕組みも実装し、音量バランスを取れるようBGMとSFXを別バスに分離する設定も行います。
前提条件
BGMはGeminiで生成。
効果音は下記サイトから頂戴いたしました。
Claude Code + godot-mcpのセットアップ完了(第2回参照)、scripts/global.gd(オートロード)が作成済み(第8・9・10回参照)という前提で進めます。以下の音声素材も assets/sounds/ に配置済みです。
| ファイル | 用途 |
|---|---|
bgm_title.mp3 |
タイトル画面BGM |
bgm_game.mp3 |
ゲーム中BGM(Main/Door/Post/PostOpen共通) |
se_success.mp3 |
ダイヤル正解時 |
se_open_door.mp3 |
ドアが開く |
se_use_key.mp3 |
鍵を使う |
se_open_post.mp3 |
郵便受けを開ける |
se_get_item.mp3 |
アイテム取得 |
se_reset.mp3 |
ダイヤルリセット |
se_tap_to_start.mp3 |
スタートタップ |
se_dial.mp3 |
ダイヤル操作 |
設計方針
BGMと効果音では音の「発生源」の性質が異なります。BGMはシーンをまたいで鳴り続ける状態、効果音はその場のイベントに紐づく単発の音です。この違いに沿って、BGMはAutoload(Global)で一元管理し、効果音は基本的に鳴らす主体(各シーン・コンポーネント)に閉じて配置する、という方針で実装します。ただしアイテム取得音のように複数シーンをまたぐ共通ロジックに紐づく効果音は例外的にAutoload側に置きます。
ファイル構成
assets/sounds/
bgm_title.mp3 ← BGM(タイトル画面)
bgm_game.mp3 ← BGM(ゲーム中共通)
se_success.mp3 ← SE(ダイヤル正解)
se_open_door.mp3 ← SE(ドアが開く)
se_use_key.mp3 ← SE(鍵を使う)
se_open_post.mp3 ← SE(郵便受けを開ける)
se_get_item.mp3 ← SE(アイテム取得)
se_reset.mp3 ← SE(ダイヤルリセット)
se_tap_to_start.mp3 ← SE(スタートタップ)
se_dial.mp3 ← SE(ダイヤル操作)
default_bus_layout.tres ← 今回作成(BGM / SFX バス定義)
scripts/global.gd ← 変更(BGM自動切り替え・共通SEを追加)
1. BGM/SFXバスを分ける
プロンプト:
BGMとSEの音量を別々に調整できるようにaudio busを分けてください
default_bus_layout.tres に BGM と SFX の2バスを定義し、Master に送るようにします。
[gd_resource type="AudioBusLayout" format=3]
[resource]
bus/1/name = "BGM"
bus/1/volume_db = -3.098039
bus/1/send = "Master"
bus/2/name = "SFX"
bus/2/volume_db = 0.0
bus/2/send = "Master"
BGMはやや音量を絞り(-3.1dB)、効果音を聞き取りやすくしています。各 AudioStreamPlayer の bus プロパティに "BGM" や "SFX" を指定するだけで、この階層に音声がルーティングされます。バスを分けておくことで、後から「BGMだけ音量を下げたい」といった調整がバス単位で一括反映できます。
2. シーン遷移に連動してBGMを自動再生する
プロンプト:
シーンが切り替わったら、そのシーンに対応するBGMを自動で再生・ループさせてください。既に同じBGMが鳴っていたら再生し直さないようにしてください
タイトルはbgm_title.mp3、メインシーン以降はbgm_game.mp3
BGMはシーンごとに切り替わるため、シーン単体のスクリプトではなくAutoload(Global)で一元管理します。
# scripts/global.gd(抜粋)
const _SCENE_BGM := {
"Title": "res://assets/sounds/bgm_title.mp3",
"Main": "res://assets/sounds/bgm_game.mp3",
"Door": "res://assets/sounds/bgm_game.mp3",
"Post": "res://assets/sounds/bgm_game.mp3",
"PostOpen": "res://assets/sounds/bgm_game.mp3",
}
var _bgm_player: AudioStreamPlayer
var _current_bgm_path: String = ""
func _ready() -> void:
_setup_bgm_player()
func _setup_bgm_player() -> void:
_bgm_player = AudioStreamPlayer.new()
_bgm_player.bus = "BGM"
add_child(_bgm_player)
_update_bgm(get_tree().current_scene)
func _on_root_child_entered(node: Node) -> void:
if node.get_parent() != get_tree().root:
return
_update_bgm(node)
func _update_bgm(scene: Node) -> void:
if scene == null:
return
var path: String = _SCENE_BGM.get(scene.name, "")
if path == _current_bgm_path:
return
_bgm_player.stop()
_bgm_player.stream = null
_current_bgm_path = path
if path.is_empty():
return
var stream := load(path) as AudioStream
if stream is AudioStreamMP3:
stream.loop = true
_bgm_player.stream = stream
_bgm_player.play()
get_tree().root.child_entered_tree を購読し、ルート直下にシーンが追加されるたびに _update_bgm() を呼んでいます。これは第9回でインベントリバーの表示切り替えに使ったのと同じ仕組みで、change_scene_to_file() によるシーン遷移をこの購読で横取りできます。
_current_bgm_path で直前のBGMパスを覚えておき、同じ曲なら stop() → play() し直しません。Main → Door のように同じ bgm_game.mp3 を使うシーン間を移動しても曲が途切れないようにするための工夫です。AudioStreamMP3.loop = true はコードから設定しているため、.import 側でループ設定をしなくても常にループ再生になります。Main/Door/Post/PostOpen 以外(Splash シーンなど)は _SCENE_BGM に登録がなく path が空文字になり、この場合は _bgm_player.stop() のみが実行されて無音状態になります。
3. ワンショットの効果音はシーンに直接ぶら下げる
プロンプト:
ダイヤルを回したらse_dial.mp3を鳴らしてください
BGMと違い、効果音は鳴らす対象のシーン・コンポーネントに閉じているため、Autoloadではなく各 .tscn に AudioStreamPlayer を直接置いて管理します。
# scenes/components/dial.tscn(抜粋)
[ext_resource type="AudioStream" path="res://assets/sounds/se_dial.mp3" id="2_se_dial"]
[node name="SeDial" type="AudioStreamPlayer" parent="."]
stream = ExtResource("2_se_dial")
bus = &"SFX"
# scripts/components/dial.gd(抜粋)
func _on_pressed() -> void:
_digit = (_digit + 1) % 10
texture_normal = _textures[_digit]
$SeDial.play()
digit_changed.emit(_digit)
同じパターンで、他のギミックにも効果音を仕込んでいます。
状況に対して鳴らしたい効果音ファイルを指定する形でプロンプト経由で対応してもらいました。
| シーン/コンポーネント | ノード名 | 再生タイミング |
|---|---|---|
dial.tscn |
SeDial |
ダイヤルをタップした瞬間 |
dial_lock.tscn |
SeReset |
リセットボタンで数字を0に戻した時 |
popup.tscn |
SeSuccess |
ダイヤルの正解判定が通った時 |
attraction_popup.tscn |
SeUseKey |
鍵の使用演出を開始した時 |
attraction_popup.tscn |
SeOpenDoor |
演出終了とともにドアが開いた時 |
post.tscn |
SeOpenPost |
郵便受けから post_open シーンへ遷移する直前 |
title.tscn |
SeTapToStart |
スタートタップ時 |
attraction_popup.gd(第10回で実装した演出ポップアップ)のように、1つのコンポーネントで開始と終了それぞれに別の効果音を鳴らすケースもあります。
# scripts/components/attraction_popup.gd(抜粋)
func show_for(duration: float) -> void:
show()
$SeUseKey.play()
modulate.a = 0.0
var tween := create_tween()
tween.tween_property(self, "modulate:a", 1.0, 0.2)
tween.tween_interval(duration)
tween.tween_property(self, "modulate:a", 0.0, 0.2)
tween.tween_callback(func(): hide(); $SeOpenDoor.play(); closed.emit())
フェードアウト完了と同時に「ドアが開く音」を鳴らすため、tween_callback の中で $SeOpenDoor.play() を呼んでいます。第3回・第8回でも使ってきた「Tweenチェーンの最後にコールバックを繋ぐ」パターンが、効果音の再生タイミング制御にもそのまま活用できています。
4. アイテム取得音だけはGlobal経由で鳴らす
プロンプト:
アイテムを取得したらどのシーンでもse_get_item.mp3を鳴らしてください
アイテム取得はどのシーンからでも起こり得ます(add_item() は第9回で実装したインベントリを扱う共通ロジックです)。そのためこちらは例外的に Global 側に AudioStreamPlayer を持たせます。
# scripts/global.gd(抜粋)
var _se_get_item_player: AudioStreamPlayer
func _setup_se_player() -> void:
_se_get_item_player = AudioStreamPlayer.new()
_se_get_item_player.bus = "SFX"
_se_get_item_player.stream = preload("res://assets/sounds/se_get_item.mp3")
add_child(_se_get_item_player)
func add_item(item: ItemData) -> void:
if has_item(item.id):
return
_items.append(item)
_se_get_item_player.play()
item_added.emit(item)
_save()
効果音の発生源が単一シーンに閉じているか、複数シーンをまたぐ共通ロジックかが、Autoloadに置くかシーン側に置くかの判断基準になります。add_item() は door.gd からも post_open.gd からも呼ばれる共通処理なので、呼び出し元それぞれに効果音再生を書くのではなく、ロジックの中心である Global 側に1箇所だけ実装するのが自然です。
動作確認
ゲームを実行し、タイトル画面で bgm_title.mp3 がループ再生され、「Tap to Start」でメイン画面に遷移すると bgm_game.mp3 に切り替わることを確認します。main.tscn → door.tscn → main.tscn と行き来しても曲が途切れず鳴り続け、ダイヤルをタップすると se_dial.mp3 が鳴り、正解判定が通ると se_success.mp3 が鳴れば順調です。鍵穴に鍵を使うと se_use_key.mp3 が鳴り、演出終了と同時に se_open_door.mp3 が鳴ります。どのシーンでアイテムを取得しても se_get_item.mp3 が鳴れば、一通りの動作確認は完了です。
BGMが鳴らない場合は default_bus_layout.tres の BGM バスが Master に送られているか、_bgm_player.bus が "BGM" になっているかを確認してください。シーン間移動のたびに曲が途切れる場合は _current_bgm_path の比較が正しく機能しているか、同じ bgm_game.mp3 を指すシーン名がすべて _SCENE_BGM に登録されているかを見ます。BGMがループしない場合は stream is AudioStreamMP3 の判定を通っているか、stream.loop = true が再生前に設定されているかを確認してください。効果音が鳴らない場合は対象ノード($SeDial など)がシーンに配置されているか、bus が "SFX" になっているかを見ます。アイテム取得音だけ鳴らない場合は Global._setup_se_player() が _ready() から呼ばれているかを確認してください。
成果物
BGM再生、各所で効果音が再生されます!
まとめ
今回はClaude Code + godot-mcpを使って以下を実装しました。
| やりたいこと | 実装場所 | 方法 |
|---|---|---|
| BGMの音量だけ下げたい | default_bus_layout.tres |
バスを分けて volume_db を個別設定 |
| シーン遷移でBGMを自動切り替え |
global.gd(Autoload) |
child_entered_tree を監視し辞書から再生パスを解決 |
| 同じBGMのシーン間移動で途切れさせない |
global.gd(Autoload) |
_current_bgm_path で直前の曲と比較 |
| ギミック固有の効果音 | 各 .tscn + 対応スクリプト |
AudioStreamPlayer をシーンに直接配置し $Node.play()
|
| シーン横断で鳴る効果音(アイテム取得等) |
global.gd(Autoload) |
共通ロジック内に AudioStreamPlayer を保持 |
BGMは「シーンに紐づく状態」としてグローバルに一元管理し、効果音は「鳴らす主体に閉じたイベント」としてローカルに配置する、という使い分けが、Godotでオーディオを破綻なく管理するコツです。この判断基準は、今後新しいギミックに音を足していく際にも一貫して使えます。