Godotでコインの取得音を実装するとき、つい「プレイヤーシーンにAudioStreamPlayerを直書きして、スクリプト側でpitch_scaleをいじる」みたいな実装をしがちですよね。最初はそれで動くんですが、
- 敵もコインをドロップしたい → 敵にも同じロジックをコピペ…
- ステージごとに効果音を変えたい → プレイヤースクリプトが条件分岐だらけ…
- コインの連続取得でピッチを上げたい → 「最後に鳴った時間」や「連続カウント」の管理があちこちに…
と、だんだん「音のロジック」がプレイヤーやコイン本体のスクリプトにべったり張り付いてしまいます。継承を使って PlayerWithCoinSound みたいなクラスを増やしていくと、今度はクラス階層がどんどん深くなっていきます。
そこで今回は、
- 「コインを取った」というイベントを受け取るだけで
- 連続取得時にピッチを半音ずつ上げてくれて
- 一定時間経つと自動でリセットしてくれる
という「音だけ担当」のコンポーネント CoinJingle を作ってみましょう。プレイヤーでも敵でも動く床でも、「コインを取る可能性があるノード」にペタっと貼れば使えるようにします。
【Godot 4】連続コインで半音アップ!「CoinJingle」コンポーネント
このコンポーネントは「継承」ではなく「合成(Composition)」で使う前提です。
プレイヤーやコインのスクリプトから coin_jingle.play_jingle() を呼ぶだけで、
- 一定時間以内の連続コイン取得をカウント
- カウントに応じてピッチを「半音単位」で上昇
- 指定回数以上はピッチを頭打ち(上がりすぎ防止)
- 最後の取得から一定時間経過で連続カウントをリセット
といった処理をすべてやってくれます。
フルコード:CoinJingle.gd
extends Node
class_name CoinJingle
## コイン取得時のジングルを管理するコンポーネント。
## 連続でコインを取ると、ピッチが半音ずつ上がっていく。
## --- 設定パラメータ(インスペクタから変更可能) ---
@export var audio_player_path: NodePath = NodePath("AudioStreamPlayer2D"):
## コイン音を鳴らす AudioStreamPlayer / AudioStreamPlayer2D へのパス
## - デフォルトでは、このコンポーネントの子にある AudioStreamPlayer2D を想定
## - シーン側で別のAudioノードを使いたい場合は、ここを書き換えましょう
set = set_audio_player_path
@export var base_pitch: float = 1.0:
## 基本となるピッチスケール。
## 1.0 で元の音程、0.5で1オクターブ下、2.0で1オクターブ上くらいのイメージです。
set = set_base_pitch
@export var semitone_step: float = 1.0:
## 連続取得ごとに上げる半音数。
## 1.0 なら「半音1つ分」ずつ上がる。
## 2.0 にすると「全音(半音2つ分)」ずつ上がっていきます。
set = set_semitone_step
@export var max_steps: int = 8:
## 連続取得による「ステップ」の上限。
## 例: 8 なら、最大で半音8つ分(約1オクターブ弱)まで上がる。
set = set_max_steps
@export var chain_reset_time: float = 0.6:
## 連続取得とみなす「猶予時間(秒)」。
## 最後にコインを取ってから、この秒数を超えると連続カウントをリセットします。
set = set_chain_reset_time
@export var volume_db_offset: float = 0.0:
## コイン音全体の音量オフセット(dB)。
## ベースのAudioStreamPlayer側の音量に対して、相対的に増減します。
set = set_volume_db_offset
## --- 内部状態 ---
var _audio_player: AudioStreamPlayer = null
var _current_step: int = 0
var _last_pick_time: float = -INF
func _ready() -> void:
# シーンツリーに入ったタイミングでAudioPlayerを解決
_resolve_audio_player()
# ---------------------------------------------------------
# 公開API
# ---------------------------------------------------------
func play_jingle() -> void:
## コインを取得したときに呼び出すメイン関数。
## - 連続取得カウントの更新
## - ピッチの計算
## - 実際の再生
## をまとめて行います。
if _audio_player == null:
push_warning("CoinJingle: Audio player is not assigned or could not be found.")
return
var now := Time.get_ticks_msec() / 1000.0
# 一定時間内なら連続取得とみなしてステップを増加、
# それ以外ならリセット
if now - _last_pick_time <= chain_reset_time:
_current_step = min(_current_step + 1, max_steps)
else:
_current_step = 0
_last_pick_time = now
# 半音ステップからピッチスケールを計算
# 半音 n 個分のピッチ比は 2^(n/12)
var semitone_offset := float(_current_step) * semitone_step
var pitch_scale := base_pitch * pow(2.0, semitone_offset / 12.0)
# AudioPlayerに反映
_audio_player.pitch_scale = pitch_scale
# 音量オフセットも反映(AudioStreamPlayerとAudioStreamPlayer2Dは共通でvolume_dbを持つ)
_audio_player.volume_db += volume_db_offset
# すでに再生中でも一旦止めてから鳴らすと、連打時に頭が聞こえやすい
if _audio_player.playing:
_audio_player.stop()
_audio_player.play()
func reset_chain() -> void:
## 手動で連続取得状態をリセットしたい場合に呼ぶ関数。
## 例: ステージ遷移時や死亡時など。
_current_step = 0
_last_pick_time = -INF
if _audio_player:
_audio_player.pitch_scale = base_pitch
# ---------------------------------------------------------
# エディタから触る用のsetter(バリデーション込み)
# ---------------------------------------------------------
func set_audio_player_path(path: NodePath) -> void:
audio_player_path = path
_resolve_audio_player()
func set_base_pitch(value: float) -> void:
base_pitch = max(value, 0.01) # 0以下はNG
if _audio_player:
_audio_player.pitch_scale = base_pitch
func set_semitone_step(value: float) -> void:
semitone_step = value
func set_max_steps(value: int) -> void:
max_steps = max(value, 0)
func set_chain_reset_time(value: float) -> void:
chain_reset_time = max(value, 0.0)
func set_volume_db_offset(value: float) -> void:
volume_db_offset = value
# ---------------------------------------------------------
# 内部ユーティリティ
# ---------------------------------------------------------
func _resolve_audio_player() -> void:
## audio_player_path から AudioStreamPlayer または AudioStreamPlayer2D を取得する。
if not is_inside_tree():
return
if audio_player_path.is_empty():
_audio_player = null
return
var node := get_node_or_null(audio_player_path)
if node == null:
_audio_player = null
push_warning("CoinJingle: Node at path '%s' not found.".format([audio_player_path]))
return
if node is AudioStreamPlayer or node is AudioStreamPlayer2D:
_audio_player = node
else:
_audio_player = null
push_warning("CoinJingle: Node at path '%s' is not an AudioStreamPlayer.".format([audio_player_path]))
使い方の手順
ここでは、典型的な 2D アクションゲームを例に「プレイヤーがコインを取ると鳴る」ケースで説明します。
手順①:コンポーネントスクリプトを用意する
- 上記の
CoinJingle.gdをプロジェクト内(例:res://components/CoinJingle.gd)に保存します。 - Godotエディタを再読み込みすると、ノード追加時の「スクリプト付きカスタムノード」として
CoinJingleが選べるようになります。
手順②:プレイヤーシーンにコンポーネントをアタッチ
例として、以下のようなプレイヤーシーンを想定します:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── AudioStreamPlayer2D └── CoinJingle (Node)
- Player シーンを開きます。
- Player の子として
AudioStreamPlayer2Dを追加し、streamにコインのSE(例:coin.wav)を設定- 必要なら
volume_dbやbusを調整
- さらに子として
Nodeを追加し、そのノードにCoinJingle.gdをアタッチします。
(クラス名が効いていれば、直接「CoinJingle」ノードとして追加してもOKです) - Inspector で
audio_player_pathを"../AudioStreamPlayer2D"に設定します。
(CoinJingle から見て親の AudioStreamPlayer2D を指す)
これで、「Player はコイン音のロジックを CoinJingle コンポーネントに丸投げ」できる状態になります。
手順③:コイン取得時に play_jingle() を呼ぶ
次に、コイン(Collectible)側のスクリプトからプレイヤーの CoinJingle を呼び出します。
例として、シンプルなコインシーンを考えます:
Coin (Area2D) ├── Sprite2D └── CollisionShape2D
コイン側のスクリプト Coin.gd の例:
extends Area2D
@export var score_value: int = 1
func _ready() -> void:
# プレイヤーとの接触を検知する
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node) -> void:
# プレイヤー以外には反応しない例
if not body.is_in_group("player"):
return
# スコア加算などはここでやる
if body.has_method("add_score"):
body.add_score(score_value)
# プレイヤーが CoinJingle コンポーネントを持っていれば再生する
var jingle := body.get_node_or_null("CoinJingle")
if jingle and jingle is CoinJingle:
jingle.play_jingle()
# コイン自体は消す
queue_free()
ポイントは、
- コインは「音をどう鳴らすか」を知らなくてよい
- プレイヤーが CoinJingle を持っていれば、それに向かって
play_jingle()を呼ぶだけ
という分離になっていることです。これで、「敵がコインを拾ったときも同じジングルを鳴らしたい」となったら、敵シーンにも CoinJingle を貼るだけで済みます。
手順④:パラメータを調整して「気持ちいい」設定を探す
最後に、インスペクタから以下のパラメータを調整して、ゲームに合う「気持ちよさ」を探しましょう:
base_pitch… 元の音程。BGMとの相性で微調整。semitone_step… 半音何個分ずつ上げるか。1.0〜2.0あたりを試すと違いが分かりやすいです。max_steps… どこまで上げるか。8〜12くらいにしておくと上がりすぎ防止になります。chain_reset_time… 何秒以内なら連続取得とみなすか。0.3〜0.7秒あたりがアクションゲーム向きですね。volume_db_offset… コイン音だけ少し目立たせたいときは +3dB くらいがおすすめ。
メリットと応用
この CoinJingle コンポーネントを導入することで、いくつか良いことがあります。
1. シーン構造がスッキリする
プレイヤーや敵のスクリプトに「音のピッチ管理ロジック」を書かなくてよくなります。
Before: Player.gd ├─ コイン取得処理 ├─ スコア計算 ├─ 入力処理 ├─ 移動処理 └─ コイン音のピッチ管理(連続カウント、タイマー…) After: Player.gd ├─ コイン取得処理(CoinJingle.play_jingle() を呼ぶだけ) ├─ スコア計算 ├─ 入力処理 └─ 移動処理 CoinJingle.gd └─ コイン音のピッチ管理を完全に担当
責務が分離されて、Player.gd がだいぶ読みやすくなりますね。
2. どのキャラにも簡単に「気持ちいいコイン音」を付与できる
例えば、敵がコインを吸い寄せるギミックがあったとしても:
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── AudioStreamPlayer2D └── CoinJingle (Node)
と構成して、コイン取得時に enemy_coin_jingle.play_jingle() を呼ぶだけで、プレイヤーと同じ「連続ピッチアップ」の気持ちよさを共有できます。
「敵用のプレイヤー継承クラス」をわざわざ作る必要はありません。
3. レベルデザイン側からも調整しやすい
パラメータがすべて @export されているので、レベルデザイナーがエディタ上で「このステージだけ半音2つ分ずつ上げて、超ハイテンションにしよう」みたいな調整を簡単にできます。
- スコアが高くなる後半ステージだけ
max_stepsを増やす - ゆったりステージでは
chain_reset_timeを長めにして、途切れにくくする
といった「音によるゲーム体験の変化」をコードを書かずに実現できます。
改造案:ピッチを徐々に元に戻す「クールダウン」機能を追加
もう一歩踏み込んで、「連続取得が途切れた後、いきなりリセットではなく、徐々にピッチを戻していく」ような演出も面白いです。例えば、以下のような関数を CoinJingle に追加してみましょう:
func cool_down(delta: float, speed: float = 2.0) -> void:
## 連続取得が途切れたときに、徐々にステップを下げていくクールダウン処理。
## _process(delta) から呼び出すことを想定。
if _current_step <= 0:
return
# delta と speed に応じて、徐々にステップを減らす
var decrease := int(delta * speed)
if decrease > 0:
_current_step = max(_current_step - decrease, 0)
# ピッチを更新
var semitone_offset := float(_current_step) * semitone_step
var pitch_scale := base_pitch * pow(2.0, semitone_offset / 12.0)
if _audio_player:
_audio_player.pitch_scale = pitch_scale
これを使う場合は、CoinJingle.gd に _process(delta) を追加して、cool_down(delta) を呼べばOKです。
「連続取得が終わったあとも、しばらく高めのピッチが残って、少しずつ元に戻る」という、ちょっとリッチな演出になります。
こんな感じで、コンポーネントとして分離しておくと、「音の演出アイデア」をどんどん追加しやすくなります。継承より合成で、気持ちいいコイン取得体験を育てていきましょう。




