🧑‍🔧

初めてリファクタリングしてみた、ついでにScriptableObjectも

に公開

これまで、プレイヤーの挙動を1つの巨大クラス(Playeroperate.cs)にすべて詰め込んでいました。
しかし、開発が進むにつれて、以下のような問題が出てきました。

  • クラスがどんどん肥大化し、機能追加や仕様変更のたびに影響範囲が爆増
  • 「何がどこで動いているのか?」と迷子になりがちで、バグの原因特定が難しい

これはマズいと判断し、(SRP)単一責任の原則を意識してリファクタリングに挑戦しました。
SRPとは「クラスはひとつの責務だけを持つべき」という設計原則で、これを守ると保守性や拡張性が大幅に向上すると言われています。

ちなみに、300行程度だったコードをリファクタリングした結果、
最終的に1200行くらい書き換える羽目になりました...

リファクタ前の問題点:Playeroperateクラスの構造

https://youtu.be/yLoMSF-MqYU

元のPlayeroperateクラスは次のような特徴がありました。

public class Playeroperate : MonoBehaviour
{
    public float PlayerHp = 100f;
    [SerializeField] private float flywindHorizontalAmount = 3f;
    [SerializeField] private bool IsGround = false;
    [SerializeField] private bool IsJump = false;
    //...(移動用のパラメータ多数)
    //...(色管理用のbool多数)
    //...(攻撃、ダメージ、色切り替えも全てここで処理)

    void Update()
    {
        GroundYposCheck();
        if (talksystem.isTalking) return;
        changeColor();
        CheckAttack();
    }

    void FixedUpdate()
    {
        if (talksystem.isTalking) return;
        Move();
    }

    private void Move()
    {
        if (ColorPlayer == PlayerColorState.Red)
            HandleMovementForColor(MaxRedSpeed, RedAddRunforceply, Defaultgravity, RedJumpSpeed);
        else if (ColorPlayer == PlayerColorState.Green)
            HandleMovementForColor(MaxGreenSpeed, GreemAddRunforceply, Lightgravity, GreenJumpSpeed);
        else if (ColorPlayer == PlayerColorState.Blue)
            HandleMovementForColor(MaxBlueSpeed, BlueAddRunforceply, Defaultgravity, BlueJumpSpeed);
    }
    // 他にも攻撃処理、色変更処理、床判定、ダメージ処理など膨大なメソッドが1クラスに集約
}

しかもさらにやばいのがシリアライズした変数たちです。

見るからにやばいですね。


問題点まとめ

  • 移動、ジャンプ、攻撃、色切り替え、状態管理、HP管理など複数の責務が混在
  • 色の状態はbool変数複数で管理しており、状態遷移の意図が不明瞭
  • 入力判定も直に行い、条件分岐がUpdateやFixedUpdateに密集
  • コードの量が多く、1つのクラスを把握するのが困難
  • 致命的な量の変数

リファクタ後の設計方針

  • 責務を分割し、クラスごとに役割を限定する
  • 入力管理はInputManager、入力解析・条件分岐はPlayerInputHandler
  • 移動はPlayerMove、色状態管理はPlayerColorManager
  • 攻撃はPlayerAttack、ダメージやHP管理はPlayerStateManager
  • ScriptableObjectで色ごとのパラメータを一元管理
  • 状態管理はenumやフラグで明確にし、bool多数管理は避ける
  • クラス間は極力疎結合にし、必要な情報は明確なAPIで受け渡す
  • HP,速度等はScriptableObjectで管理し、色ごとの変数を作らない

具体的なコード例:ScriptableObject

using UnityEngine;

[CreateAssetMenu(fileName = "PlayerColorDataExtended", menuName = "Player/ColorDataExtended")]
public class PlayerColorDataExtended : ScriptableObject
{
    [Header("基本ステータス")]
    public Color displayColor;          // プレイヤーの見た目の色
    public float maxSpeed;              // 最大走る速さ
    public float runForce;              // 加速度
    public float gravityScale;          // 重力倍率
    public float jumpForce;             // ジャンプ力
    public string colorName;            // 色の名前
    public float chargeCoolTime;        // クールタイム(秒)
    public float defensePower;          // 防御力
    public float levity;                // ノックバック時のはじかれる強さ(軽さ)
    public float NormalAttackCoolTime; // 通常攻撃のクールタイム(秒)
    [Header("同色床からのダメージ量")]
    public float hitDamageOnFloor;      // 床によるダメージ量

    [Header("色特有の基本行動プレファブ")]
    public GameObject attackEffectPrefab; // 攻撃エフェクトプレハブ
}

効果

  • 色ごとのパラメータをScriptableObjectで一元管理でき、管理が簡単でミスが減る
  • Playerクラスの変数が大幅削減され、コードの可読性と保守性が向上
  • 新色追加やパラメータ調整が容易で、拡張性と一貫性が高まる

具体的なコード例:攻撃のクールタイム

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

public class PlayerAttackCooltimeManager : MonoBehaviour
{
    private Dictionary<PlayerColorManager.PlayerColorState, bool> isColorOnCooldown = new();
    private Dictionary<PlayerColorManager.PlayerColorState, Coroutine> cooldownCoroutines = new();

    [SerializeField] private PlayerColorManager playerColorManager;

    private void Awake()
    {
        // 全色クールタイム初期化
        foreach (PlayerColorManager.PlayerColorState color in System.Enum.GetValues(typeof(PlayerColorManager.PlayerColorState)))
        {
            isColorOnCooldown[color] = false;
        }
    }

    public bool IsOnCooldown(PlayerColorManager.PlayerColorState color)
    {
        return isColorOnCooldown[color];
    }

    public void StartCooldown(PlayerColorManager.PlayerColorState color)
    {
        float time = playerColorManager.GetDataByColor(color).NormalAttackCoolTime;

        if (IsOnCooldown(color))
        {
            return;
        }
        cooldownCoroutines[color] = StartCoroutine(CooldownCoroutine(color, time));
    }

    public void ResetCooldown(PlayerColorManager.PlayerColorState color)
    {
        if (cooldownCoroutines.ContainsKey(color) && cooldownCoroutines[color] != null)
        {
            StopCoroutine(cooldownCoroutines[color]);
        }
        isColorOnCooldown[color] = false;//色の切り替え時はクールタイムをリセットする
    }

    private IEnumerator CooldownCoroutine(PlayerColorManager.PlayerColorState color, float cooldownTime)
    {
        isColorOnCooldown[color] = true;
        yield return new WaitForSeconds(cooldownTime);
        isColorOnCooldown[color] = false;
    }
}

効果

  • 色変更時にクールタイムを即座にリセットできる柔軟な仕様
  • クールタイムの秒数を色ごとにScriptableObjectで一元管理可能
  • 状態の可視化・デバッグがしやすくなる構造

具体的なコード例:入力の分離と条件分岐

// 入力収集のみ(InputManager)
public class InputManager : MonoBehaviour
{
    public float Horizontal { get; private set; }
    public bool JumpPressed { get; private set; }
    public bool ChangeToRed { get; private set; }
    public bool NormalAttack { get; private set; }

    void Update()
    {
        Horizontal = (Input.GetKey(KeyCode.D) ? 1f : Input.GetKey(KeyCode.A) ? -1f : 0f);
        JumpPressed = Input.GetKeyDown(KeyCode.W);
        ChangeToRed = Input.GetKeyDown(KeyCode.Alpha1);
        NormalAttack = Input.GetMouseButtonDown(0);
    }
}

// 入力解釈・処理振り分け(PlayerInputHandler)
using UnityEngine;

public class PlayerInputHandler : MonoBehaviour

//ここは、inputManagerからの入力を受け取り、条件分岐するクラスです。
{
    [SerializeField] private InputManager inputManager;
    [SerializeField] private PlayerMove playerMove;
    [SerializeField] private PlayerColorManager playerColorManager;
    [SerializeField] private PlayerStateManager playerStateManager;
    [SerializeField] private PlayerAttack playerAttack;
    [SerializeField] private PlayerAttackCooltimeManager playerAttackCooltimeManager;

    void Update()
    {
        if (playerStateManager.IsTalking) return;

        if (inputManager.NormalAttack && !playerAttackCooltimeManager.IsOnCooldown(playerColorManager.GetCurrentState()))
        {
            playerAttack.normalAttack(playerColorManager.GetCurrentData().attackEffectPrefab);
            playerAttackCooltimeManager.StartCooldown(playerColorManager.GetCurrentState());
        }

        if (!playerColorManager.IsColorChangeCool)
        {
            if (inputManager.ChangeToRed)
                playerColorManager.ChangeColor(PlayerColorManager.PlayerColorState.Red);
            else if (inputManager.ChangeToBlue)
                playerColorManager.ChangeColor(PlayerColorManager.PlayerColorState.Blue);
            else if (inputManager.ChangeToGreen)
                playerColorManager.ChangeColor(PlayerColorManager.PlayerColorState.Green);
        }

        if (inputManager.JumpPress && playerStateManager.IsGround)
        { playerMove.PlayerJump(playerColorManager.GetCurrentData().jumpForce,playerColorManager.GetCurrentData().gravityScale);
            playerStateManager.SetJumping();
        }
    }

    void FixedUpdate()
    {
        if (playerStateManager.IsTalking) return;

        float horizontalInput = inputManager.Horizontal;
        var data = playerColorManager.GetCurrentData();

        if (horizontalInput != 0f && Mathf.Abs(playerMove.CurrentVelocity.x) < data.maxSpeed)
        {
            playerMove.HandleMove(data.runForce, data.maxSpeed, data.gravityScale, horizontalInput);
        }
    }
}

効果

  • 入力検知と処理が明確に分離されることで、入力方法の変更や複数入力への対応が容易に
  • PlayerInputHandlerは「処理の指示役」として責務が限定され保守性向上
  • 新規機能もこのクラスを経由して追加できるため拡張が簡単

具体的なコード例:色管理の分割

using System;
using UnityEngine;
using UnityEngine.Rendering;

public class PlayerColorManager : MonoBehaviour
{
    //色のデータを保持するクラスです。
    //色のデータの処理を行い、色切り替えの条件分岐(PlayerInputHandlerの役目)、物理的な動作(PlayerInputhandler&moveの役目)は行いません。
    //UIの処理はこのクラスの担当ですが、UIに渡すのはcurrentDataのみです。
    public enum PlayerColorState
    {
        Red,
        Blue,
        Green
    }

    [SerializeField] private PlayerColorDataExtended redData;
    [SerializeField] private PlayerColorDataExtended blueData;
    [SerializeField] private PlayerColorDataExtended greenData;

    [SerializeField] private SpriteRenderer playerSpriteRenderer;
    [SerializeField] private ChargeBar chargeBar;

    [SerializeField] private PlayerAttackCooltimeManager playerAttackCooltimeManager;

    private PlayerColorState currentState;

    public bool IsColorChangeCool { get; private set; } = false;

    void Start()
    {
        if (playerSpriteRenderer == null)
            playerSpriteRenderer = GetComponent<SpriteRenderer>();

        if (playerSpriteRenderer == null)
            Debug.LogError("PlayerColorManager: SpriteRendererがありません");

        currentState = PlayerColorState.Red;
        GetCurrentData(); // 初期状態のデータを取得しておく
        ApplyColor();
    }

    public PlayerColorDataExtended GetCurrentData()//これを呼ぶと今の色のデータを返してくれる
    {
        return currentState switch
        {
            PlayerColorState.Red => redData,
            PlayerColorState.Blue => blueData,
            PlayerColorState.Green => greenData,
            _ => throw new ArgumentOutOfRangeException()// 色切り替えはゲームの根底に関わるので、例外が来たらクラッシュさせる
        };
    }

    public void ChangeColor(PlayerColorState newState)
    {
        if (currentState == newState) return;

        currentState = newState;
        playerAttackCooltimeManager.ResetCooldown(newState);
        ApplyColor();

        if (chargeBar != null)
        {
            var data = GetCurrentData();
            chargeBar.ChangeCoolTime(data);
            IsColorChangeCool = true;
        }
    }

    private void ApplyColor()
    {
        if (playerSpriteRenderer != null)
        {
            playerSpriteRenderer.color = GetCurrentData().displayColor;
        }
    }

    public PlayerColorState GetCurrentState()
    {
        return currentState;
    }

    public void ResetColorChangeCool()
    {
        IsColorChangeCool = false;
    }
    public PlayerColorDataExtended GetDataByColor(PlayerColorState color)
    {
        return color switch
        {
            PlayerColorState.Red => redData,
            PlayerColorState.Blue => blueData,
            PlayerColorState.Green => greenData,
            _ => throw new ArgumentOutOfRangeException(nameof(color), color, null)
        };
    }
}

反省と今後の課題

  • 依存関係の見直し:イベントやデリゲートでの疎結合化
  • 状態遷移管理の強化:ステートマシンの導入
  • 結合度が高い:DIコンテナを使ってみたい

まとめ

項目 リファクタ前 リファクタ後
責務の集中度 全て1クラスに集中 責務ごとにクラス分割し、責務を分割
状態管理方法 bool多数で曖昧 enum+状態管理クラスで明確化
入力処理 直にInput呼び出し 入力判定クラス、橋渡しクラス、処理クラス
パラメータ管理 クラス内でハードコード ScriptableObject化でデータ駆動を意識
可読性・保守性 可読性は高い、保守性が低い 可読性は低い、保守性は高い
拡張・修正容易さ 難しい SRPを意識しつつ実装する事に慣れたら簡単

おわりに

今回のリファクタを通して、「機能を小さく分割し、役割を明確化すること」が保守性や拡張性に直結するということを少しは知れた気がします。
また、ScriptableObjectを実際に活用する事ができ、SOの学習に対する基礎が若干固まった気がします。

今後は、SOLID原則オブジェクト指向設計などの王道的なアプローチにも本格的に取り組み、より良い設計・開発を意識したゲーム制作をしていきたいと思います。

ここまで長い記事となりましたが、最後までお読みいただき、本当にありがとうございました。

補足:今回のリファクタで実際に分割・追加・再設計したクラス一覧

  • InputManager:入力取得のみを担当。責務分離により入力変更への柔軟性が向上。
  • PlayerInputHandler:InputManagerからの入力を解釈し、移動や攻撃などの処理へ振り分け。
  • PlayerMove:移動やジャンプの物理挙動を担当。gravityScaleの初期化強化なども実施。
  • PlayerColorManager:色状態の管理。ScriptableObject(PlayerColorData)を用いて色ごとのパラメータを制御。
  • PlayerColorData:各色の移動速度やジャンプ力などを保持するScriptableObject。
  • PlayerAttack:攻撃処理。各色ごとのエフェクト処理を担い、再利用性を確保。
  • PlayerAttackCooltimeManager:攻撃のクールタイム管理。SOと連携し、色別の管理を可能に。
  • PlayerStateManager:HP・ジャンプ・地面判定などプレイヤーの状態を一括で管理。
  • CheckPlayerstatus:床や地面判定などの環境情報を提供。状態管理の判断材料に使用。
  • PlayerHitDamage:同色床からや、敵からのダメージ量の計算、被弾エフェクトの実行。
  • PlayerBase:従来の肥大化したPlayeroperateを廃止し、責務分割の起点となった(途中で責務が肥大化したので削除し、Baseを除いた並列的な設計に移行)。

Discussion