課題11: 単一責任の原則(SRP)を適用したデータ保存・読み込み機能のリファクタリングガイド


Unityプロジェクトにおいて、コードの可読性や保守性を高めるためには、設計原則を適用することが重要です。本ガイドでは、GameDirector クラスに組み込まれていたデータ保存・読み込み機能を単一責任の原則(Single Responsibility Principle, SRP)に基づいてリファクタリングし、専用の SaveLoadManager クラスに分離する方法を解説します。


単一責任の原則(SRP)とは

単一責任の原則(SRP) は、ソフトウェア設計のSOLID原則の一つであり、「クラスは単一の責任を持ち、その責任を完全にカプセル化すべきである」と定義されています。これにより、クラスの変更が他の部分に与える影響を最小限に抑え、コードの再利用性や保守性を向上させることができます。


現在の課題

現状の GameDirector クラスでは、以下の二つの責任を担っています:

  1. ゲームロジックの管理
  2. データの保存・読み込み

このように複数の責任を一つのクラスが持つことで、以下の問題が発生します:

  • 可読性の低下:クラスの役割が不明確になり、コードの理解が困難になる。
  • 保守性の低下:一つの変更が他の機能に予期せぬ影響を及ぼす可能性がある。
  • 再利用性の低下:他のクラスで同様の機能を再利用する際に、無関係なロジックも含まれるため、柔軟性が低下する。

これらの問題を解決するために、データ保存・読み込みの機能を専用のクラスに分離します。


リファクタリングのステップ

以下のステップに従って、GameDirector クラスからデータ保存・読み込みのロジックを分離し、SaveLoadManager クラスを実装します。


1. シングルトン SaveLoadManager クラスの実装

データの保存と読み込みを担当する SaveLoadManager クラスを作成します。このクラスはシングルトンパターンを採用し、プロジェクト全体で一つのインスタンスのみが存在するようにします。

using UnityEngine;

public class SaveLoadManager : MonoBehaviour
{
    public static SaveLoadManager Instance { get; private set; }

    private string saveKey = "GameData"; // PlayerPrefsのキー

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject); // シーンを跨いでオブジェクトを保持
        }
        else
        {
            Destroy(gameObject); // 既にインスタンスが存在する場合は新しいオブジェクトを破棄
        }
    }

    /// <summary>
    /// ゲームデータを保存します。
    /// </summary>
    /// <param name="data">保存するGameDataオブジェクト</param>
    public void SaveGameData(GameData data)
    {
        string jsonData = JsonUtility.ToJson(data);
        PlayerPrefs.SetString(saveKey, jsonData);
        PlayerPrefs.Save();
        Debug.Log("ゲームデータを保存しました: " + jsonData);
    }

    /// <summary>
    /// ゲームデータを読み込みます。
    /// </summary>
    /// <returns>読み込んだGameDataオブジェクト、データが存在しない場合はnull</returns>
    public GameData LoadGameData()
    {
        if (PlayerPrefs.HasKey(saveKey))
        {
            string jsonData = PlayerPrefs.GetString(saveKey);
            GameData data = JsonUtility.FromJson<GameData>(jsonData);
            Debug.Log("ゲームデータを読み込みました: " + jsonData);
            return data;
        }
        else
        {
            Debug.Log("保存されたゲームデータがありません。");
            return null;
        }
    }
}

ポイント:

  • シングルトンパターンの実装Awake メソッドで Instance を設定し、既に存在する場合は新しいオブジェクトを破棄します。これにより、常に一つのインスタンスのみが存在することを保証します。
  • DontDestroyOnLoad:シーンが切り替わっても SaveLoadManager が破棄されないようにします。
  • 保存機能 (SaveGameData)GameData オブジェクトをJSON文字列に変換し、PlayerPrefs に保存します。
  • 読み込み機能 (LoadGameData)PlayerPrefs からJSON文字列を取得し、GameData オブジェクトにデシリアライズします。

2. GameDirector クラスのリファクタリング

GameDirector クラスから保存および読み込みのロジックを削除し、SaveLoadManager を利用するように変更します。

注:このコードでは、インスタンス変数やメソッドを明示的に参照するthisキーワードは削除しています

using System.Collections.Generic;
using TMPro;
using UnityEngine;

public class GameDirector : MonoBehaviour
{
    public List<GenerationParameters> generationParametersList; // ScriptableObjectのリスト
    private int currentStage = 0;
    private float elapsedTime = 0.0f;

    GameObject timerText;
    GameObject pointText;
    float time = 30.0f;
    public int point = 0; // 現在のスコア
    private int highScore = 0; // ハイスコア
    GameObject generator;
    bool isGameOver = false; // ゲーム終了フラグ

    // 例: ゲームオーバー画面のUIを表示のとき必要
    // public GameObject gameOverUI;

    void Start()
    {
        timerText = GameObject.Find("Time");
        pointText = GameObject.Find("Point");
        generator = GameObject.Find("ItemGenerator");

        // 保存データがあれば読み込み
        LoadGameData();

        // 初期パラメータセットを適用
        ApplyCurrentGenerationParameters();
    }

    void Update()
    {
        if (isGameOver)
            return; // ゲーム終了後は処理を停止

        time -= Time.deltaTime;
        elapsedTime += Time.deltaTime;

        // パラメータセットの切り替え
        if (currentStage < generationParametersList.Count && elapsedTime > generationParametersList[currentStage].duration)
        {
            currentStage++;
            elapsedTime = 0.0f;  // elapsedTime をリセット
            if (currentStage < generationParametersList.Count)
            {
                ApplyCurrentGenerationParameters();
            }
        }

        // タイマー終了時の処理
        if (time < 0)
        {
            time = 0;
            generator.GetComponent<ItemGenerator>().SetParameter(10000.0f, 0, 0);
            EndGame(); // ゲーム終了処理を呼び出す
        }

        timerText.GetComponent<TextMeshProUGUI>().text = time.ToString("F1");
        UpdateScoreUI();
    }

    // ゲーム終了処理
    private void EndGame()
    {
        isGameOver = true; // フラグを立ててUpdateメソッドを停止
        Debug.Log("Game Over!");

        // ゲームオーバー画面のUIを表示(オプション)
        // if(gameOverUI != null)
        // {
        //     gameOverUI.SetActive(true);
        // }

        // ゲームデータを保存
        SaveGameData();
    }

    // パラメータセットを適用するメソッド
    private void ApplyCurrentGenerationParameters()
    {
        GenerationParameters currentParams = generationParametersList[currentStage];
        generator.GetComponent<ItemGenerator>().SetParameter(currentParams.span, currentParams.speed, currentParams.ratio);
    }

    // スコア変更イベントハンドラ
    public void HandleScoreChange(object sender, ScoreEventArgs e)
    {
        point += e.ScoreChange;
        if (point > highScore)
        {
            highScore = point; // ハイスコアを更新
        }
        UpdateScoreUI();
    }

    // スコアUIの更新
    private void UpdateScoreUI()
    {
        pointText.GetComponent<TextMeshProUGUI>().text = $"{point} point (High Score: {highScore})";
    }

    // ゲームデータを保存するメソッド
    private void SaveGameData()
    {
        GameData data = new GameData
        {
            highScore = this.highScore;
        };

        SaveLoadManager.Instance.SaveGameData(data);
    }

    // ゲームデータを読み込むメソッド
    private void LoadGameData()
    {
        GameData data = SaveLoadManager.Instance.LoadGameData();
        if (data != null)
        {
            this.highScore = data.highScore;
            // generationParametersList は読み込まない
        }

        UpdateScoreUI();
    }
}

変更点:

  • 保存・読み込みロジックの削除SaveGameDataLoadGameData メソッド内のJSONシリアライズおよび PlayerPrefs 操作を削除し、SaveLoadManager を利用するように変更しました。
  • SaveLoadManager の利用SaveLoadManager.Instance.SaveGameData(data) および SaveLoadManager.Instance.LoadGameData() を呼び出して、データの保存と読み込みを行います。

3. シーンへの SaveLoadManager の配置

シンプルなシングルトンパターンを採用する場合、SaveLoadManager をシーン内に配置する必要があります。以下の手順で設定してください。

  1. 新しい空のGameObjectを作成
    • Hierarchyビューで右クリックし、Create Empty を選択します。
    • このGameObjectの名前を SaveLoadManager に変更します。
  2. SaveLoadManager スクリプトをアタッチ
    • 作成した SaveLoadManager GameObject を選択します。
    • Inspectorビューで Add Component をクリックし、SaveLoadManager スクリプトを追加します。
  3. DontDestroyOnLoad の確認
    • SaveLoadManagerDontDestroyOnLoad を呼び出しているため、シーンが切り替わってもこのオブジェクトは破棄されません。

注意点

  • シーン間の存在SaveLoadManager はシーン間で持続するため、各シーンに同じオブジェクトを配置しないように注意してください。既に存在する場合は新たに追加しないようにします。

リファクタリング後のメリット

  • 責任の分離GameDirector クラスはゲームのロジックに専念し、データの保存・読み込みは SaveLoadManager が担当します。これにより、各クラスの責任が明確になります。
  • 可読性の向上:クラスごとに役割が明確になるため、コードの理解が容易になります。
  • 保守性の向上:保存方法を変更したい場合や、新たなデータ項目を追加したい場合でも、SaveLoadManager 内のみを修正すれば済みます。
  • 再利用性の向上:他のクラスでも SaveLoadManager を利用することで、データの保存・読み込み機能を簡単に活用できます。
  • テスト容易性:各クラスが単一の責任を持つため、ユニットテストが容易になります。

オプションの改善点

さらにコードを改善するためのオプションとして、以下の点を検討できます。

1. エラーハンドリングの強化

保存や読み込み時に発生しうるエラー(例:データの破損)を適切に処理する仕組みを追加します。

public GameData LoadGameData()
{
    try
    {
        if (PlayerPrefs.HasKey(saveKey))
        {
            string jsonData = PlayerPrefs.GetString(saveKey);
            GameData data = JsonUtility.FromJson<GameData>(jsonData);
            Debug.Log("ゲームデータを読み込みました: " + jsonData);
            return data;
        }
        else
        {
            Debug.Log("保存されたゲームデータがありません。");
            return null;
        }
    }
    catch (Exception ex)
    {
        Debug.LogError("ゲームデータの読み込み中にエラーが発生しました: " + ex.Message);
        return null;
    }
}

2. 非同期処理の導入

大量のデータを扱う場合や、保存・読み込みが時間を要する場合は、非同期処理を導入してフレームドロップを防ぎます。async/await を活用することで、ユーザー体験を向上させることができます。

3. データの暗号化

セキュリティを強化するために、保存するJSONデータを暗号化します。これにより、ユーザーが簡単にデータを改ざんすることを防止できます。

using System.Security.Cryptography;
using System.Text;

public void SaveGameData(GameData data)
{
    string jsonData = JsonUtility.ToJson(data);
    string encryptedData = Encrypt(jsonData);
    PlayerPrefs.SetString(saveKey, encryptedData);
    PlayerPrefs.Save();
    Debug.Log("ゲームデータを保存しました: " + encryptedData);
}

public GameData LoadGameData()
{
    if (PlayerPrefs.HasKey(saveKey))
    {
        string encryptedData = PlayerPrefs.GetString(saveKey);
        string jsonData = Decrypt(encryptedData);
        GameData data = JsonUtility.FromJson<GameData>(jsonData);
        Debug.Log("ゲームデータを読み込みました: " + jsonData);
        return data;
    }
    else
    {
        Debug.Log("保存されたゲームデータがありません。");
        return null;
    }
}

private string Encrypt(string data)
{
    // 簡易的な暗号化例(実際のアプリケーションでは強固な暗号化を使用してください)
    byte[] dataBytes = Encoding.UTF8.GetBytes(data);
    return System.Convert.ToBase64String(dataBytes);
}

private string Decrypt(string data)
{
    byte[] dataBytes = System.Convert.FromBase64String(data);
    return Encoding.UTF8.GetString(dataBytes);
}

注意点:上記の暗号化方法は非常に簡易的であり、実際のアプリケーションでは強固な暗号化アルゴリズム(例:AES)を使用することを推奨します。


まとめ

本ガイドでは、Unityプロジェクトにおける GameDirector クラスのデータ保存・読み込み機能を単一責任の原則(SRP)に基づいてリファクタリングする方法を解説しました。SaveLoadManager クラスを導入することで、コードの可読性、保守性、再利用性が向上し、プロジェクトの品質を高めることができます。

主なポイント

  • 単一責任の原則の適用:各クラスが単一の責任を持つことで、コードの整理整頓が容易になります。
  • シングルトンパターンのシンプルな実装SaveLoadManager クラスをシンプルなシングルトンとして実装し、全体で一つのインスタンスのみが存在するようにします。
  • 責任の分離:ゲームロジックとデータ保存・読み込みの機能を明確に分離することで、各機能の管理が容易になります。
  • 拡張性と保守性の向上:将来的な機能追加や変更が容易になり、コードベースの品質が向上します。

参考資料

C#,Unity

Posted by hidepon