学習記事一覧 · Unity

IkinokoBattleで学ぶ シングルトンパターン

対象読者:C# のクラスと static を理解しており、Unity のコードを読み進められる方
このブログでは、いきのこバトル(IkinokoBattle) に登場する 3種類のシングルトン実装を比較しながら、「なぜシングルトンが必要か」「どう書き分けるか」を学びます。
Unity本格入門シリーズ(書籍補助の機能解説)とは別シリーズです。本シリーズ目次


記事の目次

おすすめの読み方

セクション一覧

ポイント一覧


シングルトンとは

シングルトン(Singleton)パターンとは、あるクラスのインスタンスを アプリケーション全体でただひとつ に制限するオブジェクト指向設計のパターンです。

ゲーム開発で特に重宝されます。たとえば「プレイヤーの所持品データ」を管理するオブジェクトが複数存在すると、どちらが正しいデータを持っているか曖昧になってしまいます。「サウンドを再生するスピーカー」も2台あれば音が重なります。このような「どこからアクセスしても同じひとつのオブジェクト」が必要な場面で、シングルトンパターンが登場します。

いきのこバトルには、目的の異なる 3種類のシングルトン が実装されています。この記事では 3 つを並べて読むことで、「シングルトンの書き方は目的によって変わる」ことを体感してください。


題材の範囲と登場クラス

今回は以下の 3 クラスを題材にします。

クラス 役割 シングルトンの種類
OwnedItemsData 所持品データの保存・読み込み 純粋 C# クラスのシングルトン(遅延初期化)
AudioManager 効果音の一元管理 MonoBehaviour シングルトン(シーンまたぎ)
LifeGaugeContainer HPゲージ UI の管理 MonoBehaviour シングルトン(シーン内限定・厳格)

注目ポイント:同じ「シングルトン」でも、クラスの性質(MonoBehaviour か否か、シーンをまたぐか否か)によってコードの書き方が変わります。


コードを全部見てみよう

OwnedItemsData.cs(純粋 C# クラスのシングルトン)

using System;
using System.Linq;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class OwnedItemsData
{
    private const string PlayerPrefsKey = "OWNED_ITEMS_DATA";
    public static OwnedItemsData Instance
    {
        get
        {
            if (null == _instance)
            {
                _instance = PlayerPrefs.HasKey(PlayerPrefsKey)
                    ? JsonUtility.FromJson<OwnedItemsData>(PlayerPrefs.GetString(PlayerPrefsKey))
                    : new OwnedItemsData();
            }
            return _instance;
        }
    }
    private static OwnedItemsData _instance;
    public OwnedItem[] OwnedItems
    {
        get { return ownedItems.ToArray(); }
    }
    [SerializeField] private List<OwnedItem> ownedItems = new List<OwnedItem>();
    private OwnedItemsData() { }   // ← private コンストラクタ
    public void Save()
    {
        var jsonString = JsonUtility.ToJson(this);
        PlayerPrefs.SetString(PlayerPrefsKey, jsonString);
        PlayerPrefs.Save();
    }
    public void Add(Item.ItemType type, int number = 1) { /* 省略 */ }
    public void Use(Item.ItemType type, int number = 1) { /* 省略 */ }
    public OwnedItem GetItem(Item.ItemType type) { /* 省略 */ }
    [Serializable]
    public class OwnedItem
    {
        public Item.ItemType Type { get { return type; } }
        public int Number { get { return number; } }
        [SerializeField] private Item.ItemType type;
        [SerializeField] private int number;
        public OwnedItem(Item.ItemType type) { this.type = type; }
        public void Add(int number = 1) { this.number += number; }
        public void Use(int number = 1) { this.number -= number; }
    }
}

AudioManager.cs(MonoBehaviour シングルトン・シーンまたぎ)

using System;
using System.Collections.Generic;
using UnityEngine;
public class AudioManager : MonoBehaviour
{
    private static AudioManager instance;
    [SerializeField] private AudioSource _audioSource;
    private readonly Dictionary<string, AudioClip> _clips = new Dictionary<string, AudioClip>();
    public static AudioManager Instance
    {
        get { return instance; }
    }
    private void Awake()
    {
        if (null != instance)
        {
            Destroy(gameObject);   // ← 既存があれば自分を破棄
            return;
        }
        DontDestroyOnLoad(gameObject);   // ← シーンをまたいで存続
        instance = this;
        AudioClip[] audioClips = Resources.LoadAll<AudioClip>("2D_SE");
        foreach (var clip in audioClips)
        {
            _clips.Add(clip.name, clip);
        }
    }
    public void Play(string clipName)
    {
        if (!_clips.ContainsKey(clipName))
            throw new Exception("Sound " + clipName + " is not defined");
        _audioSource.clip = _clips[clipName];
        _audioSource.Play();
    }
}

LifeGaugeContainer.cs(MonoBehaviour シングルトン・シーン内厳格)

using System;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(RectTransform))]
public class LifeGaugeContainer : MonoBehaviour
{
    public static LifeGaugeContainer Instance
    {
        get { return _instance; }
    }
    private static LifeGaugeContainer _instance;
    [SerializeField] private Camera mainCamera;
    [SerializeField] private LifeGauge lifeGaugePrefab;
    private RectTransform rectTransform;
    private readonly Dictionary<MobStatus, LifeGauge> _statusLifeBarMap
        = new Dictionary<MobStatus, LifeGauge>();
    private void Awake()
    {
        if (null != _instance) throw new Exception("LifeBarContainer instance already exists.");  // ← 重複は例外
        _instance = this;
        rectTransform = GetComponent<RectTransform>();
    }
    public void Add(MobStatus status)
    {
        var lifeGauge = Instantiate(lifeGaugePrefab, transform);
        lifeGauge.Initialize(rectTransform, mainCamera, status);
        _statusLifeBarMap.Add(status, lifeGauge);
    }
    public void Remove(MobStatus status)
    {
        Destroy(_statusLifeBarMap[status].gameObject);
        _statusLifeBarMap.Remove(status);
    }
}

① シングルトンとは「インスタンスをひとつに絞る」パターン

通常、クラスは new するたびに新しいインスタンスが生まれます。

var a = new OwnedItemsData();  // ← 仮に public にできたとして
var b = new OwnedItemsData();  // a と b は別物

所持品データが 2 つ存在すると、どちらに Add() すればいいか分からなくなります。シングルトンパターンはこの問題を根本から防ぎます。

設計の意図: 「このクラスのインスタンスはこれひとつ」という事実をコードで明示する。外から new できないようにして、Instance プロパティ経由で必ずその 1 つにアクセスさせる。


private コンストラクタで new を禁止する

OwnedItemsData には次の宣言があります。

private OwnedItemsData() { }

コンストラクタを private にすると、クラスの外側から new OwnedItemsData() と書いてもコンパイルエラーになります。これでインスタンスの生成口を「クラス自身の内部だけ」に限定できます。

注意MonoBehaviour を継承したクラスは Unity のエンジンが new するため、この手法は使えません。AudioManagerLifeGaugeContainer が異なるアプローチを取る理由はここにあります。


static フィールドで唯一のインスタンスを保持する

private static OwnedItemsData _instance;

static フィールドはクラスに 1 つだけ存在し、インスタンスの数に関係なく共有されます。シングルトンではここにインスタンスを格納することで「どこからアクセスしても同じオブジェクト」を実現します。

AudioManager でも同じ役割を担う宣言があります。

private static AudioManager instance;

public static プロパティ Instance(大文字)が外部への窓口、private static フィールド _instance / instance(小文字)が実体の保管庫、という命名規則になっています。


④ 遅延初期化(Lazy Initialization)で必要になるまで作らない

OwnedItemsData.Instance のゲッターを見てみましょう。

get
{
    if (null == _instance)
    {
        _instance = PlayerPrefs.HasKey(PlayerPrefsKey)
            ? JsonUtility.FromJson<OwnedItemsData>(PlayerPrefs.GetString(PlayerPrefsKey))
            : new OwnedItemsData();
    }
    return _instance;
}

_instancenull(まだ作られていない)のときだけ初期化しています。これを**遅延初期化(Lazy Initialization)**と呼びます。

さらに「PlayerPrefs にセーブ済みのデータがあれば JSON から復元し、なければ新規作成する」という読み込み処理も Instance の中に含まれています。つまり、初めてアクセスしたそのタイミングで自動的に永続データを読み込んでくれます。

ポイント:初期化のタイミングを「使われる瞬間」に委ねることで、起動直後に重い処理を行わなくて済む。また初期化コードを Instance に集中させることで、呼び出し側は「Instance を使えばいい」という簡潔な形で書ける。


MonoBehaviour シングルトン(AudioManager)との違い

OwnedItemsData は Unity のコンポーネント機能(UpdateAwake など)を必要としないため、純粋 C# クラスとして実装されています。一方、AudioManagerAudioSource コンポーネントを使って実際に音声を再生するため、MonoBehaviour を継承しています。

MonoBehaviour クラスは new で作れないため、シングルトンの仕組みも変わります。

private void Awake()
{
    if (null != instance)
    {
        Destroy(gameObject);   // 既に存在するなら自分(後から生まれた方)を消す
        return;
    }
    DontDestroyOnLoad(gameObject);
    instance = this;
    // ...(クリップ辞書の構築)
}

インスタンスの作成は Unity エディタ上でシーンに GameObject として配置することで行います。Awake() の先頭で「既に instance が存在するか」を確認し、存在していれば自分自身(後から生まれた複製)を Destroy します。これが MonoBehaviour でのシングルトン実装の定石です。


DontDestroyOnLoad でシーンをまたいで生き続ける

通常、Unity ではシーンを切り替えると前のシーンにあった GameObject はすべて破棄されます。AudioManager は BGM・SE をシーンをまたいで途切れなく再生するために、これを回避する必要があります。

DontDestroyOnLoad(gameObject);

この一行を Awake() に書くだけで、そのオブジェクトはシーン遷移時に破棄されなくなります。タイトルシーンで初期化された AudioManager がメインシーン・ゲームオーバーシーンでもそのまま生き続け、AudioManager.Instance.Play("SE_Name") でどこからでも呼べる状態を維持します。

注意点DontDestroyOnLoad は「存続させる」だけで「ひとつしか作らない」は別の仕組みが必要です。シーンを再ロードすると再び Awake() が走って 2 つ目が生まれようとするため、先に示した「既存があれば Destroy」の処理と組み合わせてはじめてシングルトンが成立します。


⑦ 重複インスタンスの防止の 2 つの方法

重複防止の戦略は AudioManagerLifeGaugeContainer で異なります。

AudioManager:後から来た方を黙って削除

if (null != instance)
{
    Destroy(gameObject);
    return;
}

2 つ目が誕生しようとした瞬間に自分を削除します。既存のインスタンスはそのまま生き続けるため、ゲームの動作に影響を与えません。シーンをまたいで長生きするオブジェクトに向いたアプローチです。

LifeGaugeContainer:重複を見つけたら例外を投げる

if (null != _instance) throw new Exception("LifeBarContainer instance already exists.");

こちらは例外(Exception)を投げてプログラムを止めます。LifeGaugeContainer はメインシーンの UI に密接に紐付いており、2 つ存在すること自体が「シーン設計の誤り」として扱います。開発中に早期発見・早期修正するための厳格なアプローチです。

使い分けの目安: DontDestroyOnLoad でシーンをまたぐものは「黙って削除」方式、シーン内限定で絶対に 1 つでなければならないものは「例外を投げる」方式が読みやすい傾向があります。


3種類のシングルトン比較

項目 OwnedItemsData AudioManager LifeGaugeContainer
基底クラス 純粋 C# MonoBehaviour MonoBehaviour
インスタンス生成 Instance ゲッター内で new Unity エディタで GameObject 配置 Unity エディタで GameObject 配置
初期化タイミング 初アクセス時(遅延) Awake() Awake()
シーンをまたぐか またぐ(static で自然に残る) またぐ(DontDestroyOnLoad) またがない
重複防止 private コンストラクタ Destroy(黙って削除) throw Exception(例外)
セーブ機能 あり(PlayerPrefs + JSON) なし なし

コードの流れを整理しよう

所持品データの流れ(OwnedItemsData)

ゲーム起動
  └─ 初めて OwnedItemsData.Instance にアクセス
       ├─ PlayerPrefs にデータあり → JSON から復元してインスタンス化
       └─ データなし → new OwnedItemsData()(空のリスト)

  アイテム取得(Item.cs の OnTriggerEnter)
       └─ OwnedItemsData.Instance.Add(ItemType)
            └─ Save() → JSON → PlayerPrefs へ書き込み

音声再生の流れ(AudioManager)

タイトルシーン起動
  └─ AudioManager(GameObject) Awake()
       └─ DontDestroyOnLoad → instance = this
            └─ Resources.LoadAll で SE 辞書を構築

  任意のシーンから AudioManager.Instance.Play("se_name")
       └─ 辞書からクリップを取得 → AudioSource.Play()

自分でカスタマイズしてみよう!

課題 A:シングルトンをリセットできるようにする

OwnedItemsData にはインスタンスをリセットするメソッドがありません。「ニューゲーム開始時に所持品を全消去したい」という要件を追加してみましょう。

// OwnedItemsData に追加
public static void ResetInstance()
{
    _instance = null;
    PlayerPrefs.DeleteKey(PlayerPrefsKey);
}

これを呼び出した後に Instance にアクセスすると、新しい空の所持品データが作られます。ゲームタイトル画面の「はじめから」ボタンのイベントに繋いでみましょう。

課題 B:AudioManager に BGM 再生を追加する

現在の AudioManager は SE(効果音)の再生のみです。AudioSource を 2 つ持つ(SE 用・BGM 用)か、BGM 専用のメソッドを追加して、ループ再生と切り替えを実装してみましょう。

[SerializeField] private AudioSource _bgmSource;
public void PlayBGM(string clipName)
{
    if (!_clips.ContainsKey(clipName))
        throw new Exception("BGM " + clipName + " is not defined");
    _bgmSource.clip = _clips[clipName];
    _bgmSource.loop = true;
    _bgmSource.Play();
}
public void StopBGM()
{
    _bgmSource.Stop();
}

課題 C:シングルトンを汎用基底クラスにまとめる

3 クラスに共通する「static フィールドで自分を保持し、Awake で登録する」パターンを、ジェネリクスを使った基底クラスにまとめると再利用できます。

public abstract class SingletonMonoBehaviour<T> : MonoBehaviour where T : MonoBehaviour
{
    public static T Instance { get; private set; }
    protected virtual void Awake()
    {
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this as T;
    }
}
// 使い方
public class AudioManager : SingletonMonoBehaviour<AudioManager>
{
    protected override void Awake()
    {
        base.Awake();
        DontDestroyOnLoad(gameObject);
        // 以降の初期化...
    }
}

まとめ

ポイント キーワード
① シングルトンはインスタンスを 1 つに制限するパターン Instance プロパティ
private コンストラクタで外からの new を禁止 アクセス修飾子
static フィールドが「唯一の実体」の保管庫 static
④ 遅延初期化で「使う瞬間に初めて作る」 Lazy Initialization
MonoBehaviournew できないので Awake で登録 Unity のライフサイクル
DontDestroyOnLoad でシーンまたぎを実現 シーン管理
⑦ 重複防止は「黙って削除」か「例外を投げる」で使い分ける 設計の意図の明示

シングルトンパターンは Unity ゲームにおいて最も頻繁に登場する設計パターンのひとつです。いきのこバトルの 3 クラスを並べて読むことで、「なぜ同じパターンでも書き方が変わるのか」が見えてきたでしょうか。次回は テンプレートメソッドパターン として MobStatusPlayerStatusEnemyStatus の継承設計を読み解きます。


← シリーズ目次に戻る
次の記事 → 第2回:テンプレートメソッドパターン(近日公開)


最終更新:2026年4月