Unityを触り始めた頃は、Update() に「移動処理」「アニメーション制御」「入力処理」「サウンド再生」などを全部まとめて書きがちですよね。
歩くたびに足音を鳴らしたいだけなのに、巨大なプレイヤースクリプトの中で if 文だらけになってしまう……というのもよくあるパターンです。
こうなると、
- どこで足音が鳴っているのかコードから追いづらい
- 敵やNPCにも同じ足音ロジックを使いたくてもコピペ地獄になる
- アニメーションを変えるときに、サウンド側の条件も修正が必要になってバグりやすい
この記事では、「足音を鳴らす」という責務だけに集中した小さなコンポーネント 「FootstepAudio」 を作って、
プレイヤーでも敵でも「足音を鳴らしたいキャラにアタッチするだけ」で済むようにしてみます。
【Unity】アニメと速度からスマートに足音制御!「FootstepAudio」コンポーネント
今回の FootstepAudio コンポーネントは、
- 親オブジェクトの
Animatorから「歩き・走りアニメ中か」を監視 - 親オブジェクトの移動速度(
Transformの位置変化)を監視 - 一定距離ごとに足音SEを再生(歩きと走りで間隔を変えられる)
というシンプルな責務に絞っています。
足音の再生タイミングを「距離ベース」で管理することで、アニメーションのフレーム数や再生速度が変わっても、
実際の移動スピードに応じた自然な足音になります。
FootstepAudio.cs(フルコード)
using UnityEngine;
/// <summary>
/// 足音再生コンポーネント。
/// 親オブジェクトの Animator と移動量を監視して、
/// 一定距離ごとに足音 SE を再生します。
///
/// 責務は「足音をいつ鳴らすか」のみ。
/// 実際の移動やアニメーション制御は別コンポーネントに任せましょう。
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class FootstepAudio : MonoBehaviour
{
// ==============================
// 設定用フィールド(インスペクター表示)
// ==============================
[Header("参照設定")]
[Tooltip("移動元となる Transform。未指定なら親の Transform を使用します。")]
[SerializeField] private Transform targetTransform;
[Tooltip("歩行状態を判定するための Animator。未指定なら親階層から自動取得を試みます。")]
[SerializeField] private Animator targetAnimator;
[Header("アニメーション判定")]
[Tooltip("歩行・走行中かどうかを判定する Animator の bool パラメータ名(例: \"IsWalking\")")]
[SerializeField] private string movingBoolParameter = "IsMoving";
[Header("足音設定")]
[Tooltip("足音として再生する AudioClip の配列。ランダムに1つ選ばれます。")]
[SerializeField] private AudioClip[] footstepClips;
[Tooltip("歩き時:何メートルごとに足音を鳴らすか")]
[SerializeField] private float walkStepDistance = 1.2f;
[Tooltip("走り時:何メートルごとに足音を鳴らすか(0以下なら walkStepDistance を使用)")]
[SerializeField] private float runStepDistance = 0.8f;
[Tooltip("走りかどうかを判定する速度のしきい値(この速度を超えたら走り扱い)")]
[SerializeField] private float runSpeedThreshold = 3.5f;
[Header("音量・ピッチ")]
[Tooltip("足音の基本音量")]
[SerializeField] private float baseVolume = 0.9f;
[Tooltip("足音の基本ピッチ")]
[SerializeField] private float basePitch = 1.0f;
[Tooltip("ピッチのランダム幅(例: 0.1 => 0.9~1.1 の範囲でランダム)")]
[SerializeField] private float pitchRandomRange = 0.1f;
[Header("デバッグ")]
[Tooltip("移動していなくても Animator が動いている場合に足音を鳴らすか")]
[SerializeField] private bool allowFootstepWithoutMovement = false;
[Tooltip("現在の移動速度をインスペクターに表示(読み取り専用)")]
[SerializeField, ReadOnly] private float currentSpeed = 0f;
// ==============================
// 内部状態
// ==============================
private AudioSource audioSource;
private Vector3 lastPosition;
private float distanceAccumulated = 0f;
private int movingBoolHash;
// ==============================
// Unity ライフサイクル
// ==============================
private void Reset()
{
// コンポーネント追加時に、親から Animator を探して自動設定
if (targetAnimator == null)
{
targetAnimator = GetComponentInParent<Animator>();
}
// 足音用 AudioSource の初期設定
audioSource = GetComponent<AudioSource>();
audioSource.playOnAwake = false;
audioSource.loop = false;
audioSource.spatialBlend = 1.0f; // 3D サウンドとして再生
}
private void Awake()
{
audioSource = GetComponent<AudioSource>();
// Transform が未指定なら親の Transform を使用
if (targetTransform == null)
{
targetTransform = transform.parent != null ? transform.parent : transform;
}
// Animator が未指定なら親階層から取得
if (targetAnimator == null)
{
targetAnimator = GetComponentInParent<Animator>();
}
// Animator パラメータ名をハッシュ化
if (!string.IsNullOrEmpty(movingBoolParameter))
{
movingBoolHash = Animator.StringToHash(movingBoolParameter);
}
lastPosition = targetTransform.position;
}
private void Update()
{
if (targetTransform == null)
{
return; // 対象 Transform がない場合は何もしない
}
// 1. 移動距離と速度を計算
Vector3 currentPosition = targetTransform.position;
Vector3 delta = currentPosition - lastPosition;
float deltaMagnitude = delta.magnitude;
// フレームごとの速度(m/s)を計算
float deltaTime = Time.deltaTime;
if (deltaTime > 0f)
{
currentSpeed = deltaMagnitude / deltaTime;
}
else
{
currentSpeed = 0f;
}
// 累積距離を加算
distanceAccumulated += deltaMagnitude;
// 2. 現在「歩行/走行状態」かどうかを Animator から判定
bool isMovingState = true; // Animator がない場合は「移動中」とみなす
if (targetAnimator != null && movingBoolHash != 0)
{
// Animator の bool パラメータで判定
isMovingState = targetAnimator.GetBool(movingBoolHash);
}
// 実際に「動いている」と判定するか
bool isActuallyMoving = currentSpeed > 0.05f; // 微小な揺れは無視
// アニメ上は歩行中だが、実際には動いていないケースを制御
if (!allowFootstepWithoutMovement)
{
// 実際に動いていなければ足音は鳴らさない
if (!isActuallyMoving)
{
lastPosition = currentPosition;
return;
}
}
// Animator で「停止中」なら、距離はリセットしておく
if (!isMovingState)
{
distanceAccumulated = 0f;
lastPosition = currentPosition;
return;
}
// 3. 歩き or 走りの判定
bool isRunning = currentSpeed >= runSpeedThreshold;
// 使用するステップ距離を決定
float stepDistance = walkStepDistance;
if (isRunning && runStepDistance > 0f)
{
stepDistance = runStepDistance;
}
// 4. 一定距離を超えたら足音を鳴らす
if (distanceAccumulated >= stepDistance)
{
TryPlayFootstep(isRunning);
distanceAccumulated = 0f;
}
lastPosition = currentPosition;
}
// ==============================
// 足音再生ロジック
// ==============================
/// <summary>
/// 足音を再生する。
/// 走りかどうかでピッチや音量を微調整したい場合はここで調整します。
/// </summary>
/// <param name="isRunning">走っているかどうか</param>
private void TryPlayFootstep(bool isRunning)
{
if (footstepClips == null || footstepClips.Length == 0)
{
// 足音が未設定の場合は何もしない
return;
}
// ランダムに 1 つ選択
int index = Random.Range(0, footstepClips.Length);
AudioClip clip = footstepClips[index];
if (clip == null)
{
return;
}
// ピッチをランダムに揺らして、同じ音でも単調にならないようにする
float randomPitchOffset = Random.Range(-pitchRandomRange, pitchRandomRange);
audioSource.pitch = basePitch + randomPitchOffset;
// 走りのときは少し音量を上げるなどの調整
float volume = baseVolume;
if (isRunning)
{
volume *= 1.1f; // 走りは少し大きめ
}
audioSource.PlayOneShot(clip, volume);
}
}
/// <summary>
/// インスペクター上でフィールドを読み取り専用表示にするための属性。
/// 実行時の値をデバッグ表示したいときに便利です。
/// </summary>
public class ReadOnlyAttribute : PropertyAttribute { }
#if UNITY_EDITOR
using UnityEditor;
/// <summary>
/// ReadOnlyAttribute 用のカスタムプロパティドロワー。
/// エディタ上で編集不可にするだけで、実行時挙動には影響しません。
/// </summary>
[CustomPropertyDrawer(typeof(ReadOnlyAttribute))]
public class ReadOnlyDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
bool previous = GUI.enabled;
GUI.enabled = false; // 編集不可にする
EditorGUI.PropertyField(position, property, label, true);
GUI.enabled = previous;
}
}
#endif
使い方の手順
ここでは、プレイヤーキャラクターに足音をつける例で説明します。敵キャラやNPCでも手順はほぼ同じです。
-
① 足音用の AudioClip を用意する
- プロジェクト内に
Audioフォルダなどを作り、足音SE(例:footstep_grass_01.wavなど)を入れます。 - できれば 2〜3 種類以上のバリエーションを用意しておくと、ランダム再生で自然に聞こえます。
- プロジェクト内に
-
② プレイヤーの階層構造を確認する
- 一般的には「Player(親)」に
CharacterControllerやRigidbody、Animatorが付いている構成が多いです。 - 足音は 3D サウンドとして再生したいので、
Playerの子オブジェクトとして空オブジェクトFootstepAudioSourceを作ると管理しやすいです。
- 一般的には「Player(親)」に
-
③ FootstepAudio コンポーネントを追加する
- 作成した
FootstepAudioSourceオブジェクトを選択します。 Add ComponentからAudioSourceを追加します(RequireComponentにより自動追加されてもOK)。- 同じオブジェクトに
FootstepAudioコンポーネントを追加します。 Target Transformにプレイヤーのルート(親の Transform)を指定します。未指定でも自動で親が使われます。Target AnimatorにプレイヤーのAnimatorを指定します。親に付いていれば自動取得されます。- Animator のパラメータに「移動中かどうか」を示す
bool(例:IsMoving)を用意して、Moving Bool Parameterにその名前を入力します。 Footstep Clipsに足音SEを複数ドラッグ&ドロップします。Walk Step Distance/Run Step Distance/Run Speed Thresholdをゲームに合わせて調整します。
- 作成した
-
④ 実行して確認する
- プレイヤーを歩かせて、一定距離ごとに足音が鳴るか確認します。
- 走り動作がある場合は、
Run Speed Thresholdを越えたときに足音間隔が変わるかをチェックします。 - 足音のピッチ変化が大きすぎる/小さすぎる場合は、
Pitch Random Rangeを微調整します。 - もし「アニメーション上は足踏みしているけど、実際には移動していない(その場足踏み)」という演出で足音を鳴らしたい場合は、
Allow Footstep Without Movementにチェックを入れます。
敵キャラや巡回NPCに足音をつけたい場合も、同じように子オブジェクトに FootstepAudio を付けて、Target Transform と Target Animator を設定するだけで再利用できます。
メリットと応用
FootstepAudio をコンポーネントとして分離しておくと、以下のようなメリットがあります。
- プレイヤーや敵の「移動ロジック」と「足音ロジック」が完全に分離される
=> 移動処理を書き換えても、足音のコードには一切手を入れずに済みます。 - プレハブ化が簡単
=> 「足音付きプレイヤー」「足音付き敵」のプレハブを作っておけば、シーンに置くだけで足音が鳴るようになります。 - レベルデザイン時にサウンドを個別調整しやすい
=> 砂地・木床・金属床など、シーンごとに足音SEのセットを差し替えるだけで環境にあった音に変更できます。 - Animator を変えても使い回せる
=> 判定に使うのは「bool パラメータ名」だけなので、アニメーションの中身を作り直しても、IsMovingを維持しておけばそのまま動きます。
さらに、「地面の材質によって足音を変える」などの応用も簡単に追加できます。
例えば、Physics.Raycast で足元のレイヤーやタグを調べて、再生するクリップ配列を切り替える、といった拡張ですね。
改造案:地面のタグ別に足音セットを切り替える
以下は、現在の地面のタグに応じて、使用する足音クリップ配列を切り替える簡単な例です。
本体の FootstepAudio に組み込む場合は、TryPlayFootstep の中で呼び出すなどしてください。
/// <summary>
/// 足元の地面タグに応じて、使用する足音クリップ配列を切り替える例。
/// 例として "Grass", "Wood", "Metal" タグを使用しています。
/// </summary>
private AudioClip[] GetFootstepClipsByGroundTag()
{
// 足元に向かってレイを飛ばす
Ray ray = new Ray(targetTransform.position + Vector3.up * 0.2f, Vector3.down);
if (Physics.Raycast(ray, out RaycastHit hit, 2f))
{
string groundTag = hit.collider.tag;
// タグに応じて別の配列を返すような実装にする
// ここでは例としてフィールドに用意した配列を返すイメージ
switch (groundTag)
{
case "Grass":
return footstepClips; // 草用の配列
case "Wood":
return woodFootstepClips; // 木床用
case "Metal":
return metalFootstepClips; // 金属用
}
}
// どれにも当てはまらない場合はデフォルト
return footstepClips;
}
このように、小さな責務に分けたコンポーネントにしておくと、
「足音コンポーネントを差し替える/拡張する」だけでゲーム全体の足音の質を底上げしやすくなります。
巨大な God クラスにロジックを詰め込まず、「音は音のコンポーネント」に任せる設計を心がけていきましょう。
