【Unity 6】AppleCatchのスクリプトを整理しよう ― フォルダ分けとコードの読みやすさ改善

広告

この記事は、北村愛実 著『Unityの教科書 Unity 6完全対応版』(SBクリエイティブ)の第8章サンプル「AppleCatch」をもとにした補足学習記事です。ゲームの作り方やコードの詳しい解説は書籍を参照してください。ここでは「作り終えたあとの整理」だけを扱います。

  • 対象: AppleCatch(PC版)を最後まで作り終えた方
  • 環境: Unity 6(6000.1.1f1)、Input System 1.14.0
  • ゴール: ゲームの動きは変えずに、①フォルダ構成と②コードの読みやすさを整える

動いているコードを書き換えるので、始める前にプロジェクトフォルダごとコピーするか、Gitでコミットしておきましょう。うまくいかなければ、いつでも元に戻せます。

なお、整理(リファクタリング)とは「見た目と中身の構造を整えること」で、ゲームの動作は変わりません。この記事のあとにプレイして、整理前と同じ動きになっていれば成功です。

現状を確認する

教科書どおりに作ると、ProjectウィンドウのAssets直下にスクリプト・モデル・音・プレハブ・シーンが全部並んでいます。

Assets/
├─ BasketController.cs
├─ GameDirector.cs
├─ ItemController.cs
├─ ItemGenerator.cs
├─ applePrefab.prefab
├─ bombPrefab.prefab
├─ apple.fbx / basket.fbx / bomb.fbx / stage.fbx
├─ get_se.mp3 / damage_se.mp3
├─ GameScene.unity
├─ Scenes/          (SampleScene)
├─ Settings/  TextMesh Pro/  TutorialInfo/
└─ InputSystem_Actions.inputactions / Readme.asset

このゲームには4本のスクリプトがあり、それぞれ役割が違います。先に役割をつかんでおくと、整理の辿り着き先が見えやすくなります。

AppleCatchのゲーム画面と4本のスクリプトの役割
スクリプト役割付いている場所
BasketControllerカゴの移動と、アイテムとの接触処理カゴ
GameDirector制限時間・ポイント・UI表示、難易度の切り替えGameDirector
ItemGeneratorリンゴと爆弾を一定間隔で生成ItemGenerator
ItemControllerアイテムの落下と、画面外での削除リンゴ・爆弾のプレハブ

この状態だと、素材が増えるほど目的のファイルを探すのが大変になります。スクリプトの中身にも、後から読むと「この数字は何」「この public は本当に必要?」と迷う箇所があります。

手順1: フォルダを作ってファイルを分類する

まずはフォルダ分けです。必ずUnityのProjectウィンドウの中でドラッグ&ドロップして移動してください。Finderやエクスプローラーで動かすと、トラブルの原因になります(理由は次の見出しで説明します)。

Assetsフォルダの整理前と整理後(Scripts・Prefabs・Models・Audio・Scenesに分類)
  1. ProjectウィンドウでAssetsを右クリック → Create → Folder で、Scripts、Prefabs、Models、Audio の4つを作ります(Scenes はすでにあります)
  2. 下の表のとおりに、ファイルをドラッグ&ドロップします
  3. 移動が終わったら、Consoleウィンドウにエラーが出ていないことを確認します
移動するファイル移動先
BasketController.cs / GameDirector.cs / ItemController.cs / ItemGenerator.csScripts
applePrefab / bombPrefabPrefabs
apple.fbx / basket.fbx / bomb.fbx / stage.fbxModels
get_se.mp3 / damage_se.mp3Audio
GameSceneScenes(既存のフォルダ)
Settings / TextMesh Pro / TutorialInfo / InputSystem_Actions / Readme動かさない

最後の行のファイルは、Unityやパッケージが自動で作って管理しているものです。中身をよくわからないうちは触らないのが安全です。

整理後は次のようになります。

Assets/
├─ Scripts/   BasketController.cs  GameDirector.cs  ItemController.cs  ItemGenerator.cs
├─ Prefabs/   applePrefab  bombPrefab
├─ Models/    apple.fbx  basket.fbx  bomb.fbx  stage.fbx
├─ Audio/     get_se.mp3  damage_se.mp3
├─ Scenes/    GameScene  SampleScene
├─ Settings/  TextMesh Pro/  TutorialInfo/
└─ InputSystem_Actions.inputactions / Readme.asset

ここで一度ゲームを再生してみてください。ファイルの場所を変えただけなので、今までどおり動くはずです。

なぜ移動しても壊れないのか

Unityは、ファイルごとに .meta ファイルを作って専用のID(GUID)を記録しています。シーンやプレハブは「ファイルの場所」ではなくこのGUIDでスクリプトやプレハブを参照しているので、Unityの中で移動すれば .meta も一緒に動き、参照は切れません。逆に、Unityの外で .cs だけを動かして .meta を置いていくと、GUIDが変わって「The referenced script on this Behaviour is missing」という警告が出ます。

Gitで管理する場合も、.meta は必ず .cs やフォルダと一緒にコミットします。シーンを移動したときも、Build Profiles の Scene List は同じ仕組みで追従するので、登録し直しは不要です。

手順2: 4本のスクリプトを順に整える

スクリプトは、次の4つのルールだけでずいぶん読みやすくなります。

  1. 並べる順番を決める: 定数 → Inspectorで設定する値 → 内部の状態 → Start/Updateなど → 他のスクリプトから呼ぶ public メソッド → 自分専用のメソッド
  2. Inspectorで設定するだけの値は public をやめて [SerializeField] にする: 他のスクリプトから勝手に書き換えられなくなります
  3. 意味のある数字には名前をつける: 30.0f や 100 を const にします
  4. 長いメソッドは役割ごとに分ける: あわせて、毎フレーム呼ぶ GetComponent は Start で一度だけにします
SerializeField・定数・メソッド分割・GetComponentのキャッシュによるコード整理

注意点があります。変数の名前を変えると、Inspectorに設定済みの値(プレハブや効果音など)が外れてしまいます。ここでは applePrefab や appleSE など、元の名前をそのまま使います。

スクリプト同士が関係しているので、4本とも書き換え終わってから保存・コンパイルしてください。途中でエラーが出ても、全部終えれば消えます。

2-1. ItemController(最もシンプル)

  • dropSpeed は ItemGenerator から直接代入されていた。[SerializeField] の非公開にし、SetDropSpeed を通して変更する形にする
  • -1.0f に「削除する高さ」という名前をつける
using UnityEngine;

/// <summary>
/// 落ちてくるアイテム(リンゴ・爆弾)の動きを担当する
/// </summary>
public class ItemController : MonoBehaviour
{
    const float DestroyY = -1.0f;   // この高さより下に落ちたら消す

    [SerializeField] float dropSpeed = -0.03f;   // 1フレームあたりの落下量(マイナスが下向き)

    void Update()
    {
        transform.Translate(0, this.dropSpeed, 0);
        if (transform.position.y < DestroyY)
        {
            Destroy(gameObject);
        }
    }

    // ItemGeneratorから落下速度を指定するためのメソッド
    public void SetDropSpeed(float speed)
    {
        this.dropSpeed = speed;
    }
}

2-2. ItemGenerator

  • applePrefab / bombPrefab を [SerializeField] にする(名前はそのまま)
  • Update の中の「アイテムを一つ作る」処理を SpawnItem メソッドに切り出す
  • if / else で二回書いていた Instantiate を、三項演算子で一回にまとめる
  • 生成の高さ 4 に SpawnHeight という名前をつける
using UnityEngine;

/// <summary>
/// リンゴと爆弾を一定間隔で生成する。難易度(間隔・速度・爆弾の割合)はGameDirectorが指定する
/// </summary>
public class ItemGenerator : MonoBehaviour
{
    const float SpawnHeight = 4.0f;   // アイテムを生成する高さ

    [SerializeField] GameObject applePrefab;
    [SerializeField] GameObject bombPrefab;

    float span = 1.0f;      // 生成間隔(秒)
    float delta = 0;        // 前回の生成からの経過時間
    int ratio = 2;          // 爆弾の割合(10段階中いくつが爆弾か)
    float speed = -0.03f;   // 生成するアイテムの落下速度

    void Update()
    {
        this.delta += Time.deltaTime;
        if (this.delta > this.span)
        {
            this.delta = 0;
            SpawnItem();
        }
    }

    // GameDirectorから難易度を変更するためのメソッド
    public void SetParameter(float span, float speed, int ratio)
    {
        this.span = span;
        this.speed = speed;
        this.ratio = ratio;
    }

    // アイテムを一つ生成して、ランダムな位置に置く
    void SpawnItem()
    {
        bool isBomb = Random.Range(1, 11) <= this.ratio;
        GameObject item = Instantiate(isBomb ? this.bombPrefab : this.applePrefab);

        float x = Random.Range(-1, 2);
        float z = Random.Range(-1, 2);
        item.transform.position = new Vector3(x, SpawnHeight, z);
        item.GetComponent<ItemController>().SetDropSpeed(this.speed);
    }
}

2-3. BasketController

  • appleSE / bombSE を [SerializeField] にする
  • director を GameObject ではなく GameDirector 型で持ち、Start で一度だけ GetComponent する(接触のたびに探さない)
  • other.gameObject.tag == "Apple" を other.CompareTag("Apple") にする。結果は同じで、Unityに推奨されている書き方
  • Update の中の「クリック位置へ移動」を MoveToClickedPosition に切り出す
  • Unityイベント(Start / Update / OnTriggerEnter)を上に、自作メソッドを下に並べる
using UnityEngine;
using UnityEngine.InputSystem;  // 入力を検知するために必要!!

/// <summary>
/// カゴの移動(クリックした位置へ)と、アイテムとの接触処理を担当する
/// </summary>
public class BasketController : MonoBehaviour
{
    [SerializeField] AudioClip appleSE;
    [SerializeField] AudioClip bombSE;

    AudioSource aud;
    GameDirector director;

    void Start()
    {
        Application.targetFrameRate = 60;
        this.aud = GetComponent<AudioSource>();
        this.director = GameObject.Find("GameDirector").GetComponent<GameDirector>();
    }

    void Update()
    {
        if (Mouse.current.leftButton.wasPressedThisFrame)
        {
            MoveToClickedPosition();
        }
    }

    void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Apple"))
        {
            this.aud.PlayOneShot(this.appleSE);
            this.director.GetApple();
        }
        else
        {
            this.aud.PlayOneShot(this.bombSE);
            this.director.GetBomb();
        }
        Destroy(other.gameObject);
    }

    // クリックした位置(整数座標に丸めて)にカゴを移動する
    void MoveToClickedPosition()
    {
        Ray ray = Camera.main.ScreenPointToRay(Mouse.current.position.value);
        RaycastHit hit;
        if (Physics.Raycast(ray, out hit, Mathf.Infinity))
        {
            float x = Mathf.RoundToInt(hit.point.x);
            float z = Mathf.RoundToInt(hit.point.z);
            transform.position = new Vector3(x, 0, z);
        }
    }
}

2-4. GameDirector(最も変更が大きい)

  • 30.0f(制限時間)と 100(リンゴの点数)に名前をつける
  • timerText / pointText / generator を GameObject ではなく、この後使う型(TextMeshProUGUI、ItemGenerator)で持ち、Start で一度だけ GetComponent する。毎フレームの GetComponent がなくなる
  • 長い Update を「難易度を切り替える」UpdateDifficulty と「画面を更新する」UpdateUI に分ける
  • 0 <= time && time < 4 のように、上の条件ですでに否定されている下限のチェックをはずして、time < 4 だけにする
  • 「時間切れ」を isTimeUp という名前の変数にして、元のとおり time を 0 にそろえる前に判定する
using UnityEngine;
using TMPro;    // TextMeshProを使う時は忘れないように注意!!

/// <summary>
/// ゲーム全体の進行(制限時間・ポイント・UI表示・難易度の切り替え)を管理する
/// </summary>
public class GameDirector : MonoBehaviour
{
    const float TimeLimit = 30.0f;   // 制限時間(秒)
    const int ApplePoint = 100;      // リンゴ1個で増えるポイント

    float time = TimeLimit;
    int point = 0;

    // 他のオブジェクトへの参照(Startで一度だけ取得する)
    TextMeshProUGUI timerText;
    TextMeshProUGUI pointText;
    ItemGenerator generator;

    void Start()
    {
        this.timerText = GameObject.Find("Time").GetComponent<TextMeshProUGUI>();
        this.pointText = GameObject.Find("Point").GetComponent<TextMeshProUGUI>();
        this.generator = GameObject.Find("ItemGenerator").GetComponent<ItemGenerator>();
    }

    void Update()
    {
        this.time -= Time.deltaTime;

        // 時間切れの判定は、0にそろえる前に行う
        bool isTimeUp = this.time < 0;
        if (isTimeUp)
        {
            this.time = 0;
        }

        UpdateDifficulty(isTimeUp);
        UpdateUI();
    }

    // ItemControllerやBasketControllerから呼ばれる
    public void GetApple()
    {
        this.point += ApplePoint;
    }

    public void GetBomb()
    {
        this.point /= 2;
    }

    // 残り時間に応じて、アイテムの出現間隔・速度・爆弾の割合を変える
    void UpdateDifficulty(bool isTimeUp)
    {
        if (isTimeUp)
        {
            this.generator.SetParameter(10000.0f, 0, 0);    // 実質、生成を止める
        }
        else if (this.time < 4)
        {
            this.generator.SetParameter(0.3f, -0.06f, 0);
        }
        else if (this.time < 12)
        {
            this.generator.SetParameter(0.5f, -0.05f, 6);
        }
        else if (this.time < 23)
        {
            this.generator.SetParameter(0.8f, -0.04f, 4);
        }
        else if (this.time < 30)
        {
            this.generator.SetParameter(1.0f, -0.03f, 2);
        }
    }

    // 残り時間とポイントを画面に表示する
    void UpdateUI()
    {
        this.timerText.text = this.time.ToString("F1");
        this.pointText.text = this.point.ToString() + " point";
    }
}

これで4本とも終わりです。保存してUnityに戻り、Consoleに赤いエラーが出ていないことを確認しましょう。

手順3: 動作確認とつまずきやすい点

ゲームを再生して、整理前と同じ動きになっているかを確認します。

整理してもゲームの動作が変わらないことを確認する項目
  • □ Consoleにエラーや警告(Missing / NullReference)が出ていない
  • □ 残り時間が30.0から減っていく
  • □ クリックした位置にカゴが移動する
  • □ リンゴを取ると効果音が鳴り、100 point ずつ増える
  • □ 爆弾を取ると効果音が鳴り、ポイントが半分になる
  • □ 残り時間が減るにつれて、アイテムが速く・多くなり、爆弾も増える
  • □ 0.0になったあと、新しいアイテムが生成されない

うまくいかないときは、次の表を見てください。

症状原因対処
「The referenced script on this Behaviour is missing」が出るUnityの外でファイルを動かして .meta と分かれた元の場所に戻し、Unityの中で移動し直す
ItemGenerator で「dropSpeed にアクセスできません」とエラーItemController を更新して、ItemGenerator をまだ更新していない2-2 のコードに書き換える
Inspectorのプレハブや効果音が None になっている変数の名前を変えてしまったapplePrefab / bombPrefab / appleSE / bombSE の名前を元に戻す。済んでしまったら、Inspectorで設定し直す
NullReferenceException(Start内)GameObject.Find で探す名前と、シーン上のオブジェクト名が違うTime / Point / ItemGenerator / GameDirector の綴りを確認する
リンゴを取っても爆弾扱いになるプレハブの Tag が設定されていない、または "Apple" の綴り違いapplePrefab の Tag を Apple にする

まとめと発展課題

この記事では、ゲームの動きを変えずに次の整理を行いました。

  • ファイルを Scripts / Prefabs / Models / Audio / Scenes に分類した(Unityの中で移動すれば .meta が参照を守ってくれる)
  • スクリプトの並び順をそろえ、Inspector用の変数を [SerializeField] にした
  • 意味のある数字に名前をつけ、長い Update を役割ごとのメソッドに分けた
  • 毎フレームの GetComponent を Start での一度きりにした

この種類の整理は、「動くようになった」直後にやっておくと、あとから機能を足すときの迷いが減ります。

発展課題

もう少し整理を進めたい方は、次の課題に挑戦してみてください。

  1. UpdateDifficulty の数字(4・12・23・30 と、各段階のパラメータ)を、配列や構造体にまとめて if / else if を短くする
  2. GameObject.Find をやめて、[SerializeField] でInspectorから参照を設定する形に変える
  3. ゲームが大きくなったときに備えて、Scripts フォルダを役割ごとのサブフォルダに分ける
  4. 整理前の状態をGitでコミットしてから、ブランチを切って上の課題を試す(うまくいかなければブランチごと捨てられます)

参考書籍

  • 北村愛実 著『Unityの教科書 Unity 6完全対応版』 SBクリエイティブ(第8章 AppleCatch)
訪問数 4 回, 今日の訪問数 4回

広告

Unity,Unity6

Posted by hidepon