3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

PONOSAdvent Calendar 2022

Day 25

個人で製作中のゲームにおいて意識している設計と実装

Last updated at Posted at 2022-12-24

PONOS Advent Calendar 2022 最終日です...!
昨日は@ANIZA_15さんのTypescriptだけでAWS Lambdaを発火させるでした。

はじめに

まさかの大トリになってしまいました。(⌒-⌒; )

Unityを本格的に使い始めて半年...時の流れというものは早いですね...
さて、最近、昔のゲームをUnityで作ってみるということにハマっています。
そこで、個人開発においてどのように設計・開発をしているのか

1. 大まかなクラス設計
2. オブジェクトの状態ごとの挙動の実装について
3. シーン間を跨いで保存するデータの扱い
4. SE・BGMの再生

この4つの観点から書いていこうと思います。

開発環境

Unity : 2020 3.31f
コードエディタ : VisualStudio Comunity 2019

製作中のもの

まず、制作段階のものを紹介します。
Sample.gif

このように床を落として、同時に対戦相手も落下させていくといったゲームです。

現段階でプレイヤーは1人しかいません。(オンライン未実装のため、一つのPCで一人でやることになり、撮影が物理的に不可能であったため断念。申し訳ありません...)
ゲームシステムの部分をメインで製作しているため、キャラクターモデルやUIは仮のものとなっております。
今後やることとして、キャラクターモデルやUIの実装はもちろん、PUN2を用いたオンラインでの対戦に対応していきたいところです。

大まかなクラス設計

Qiita_UML.png

念の為、大まかなクラスの設計の画像を用意しました。
ゲームに登場するキャラクター(プレイヤーなど)はCharacterBehaviourを派生させる、オブジェクト(今回でいうと、地面)はObjectBehaviourを派生させて実装しています。
この画像を見る限り、MonoBehaviourを継承した際に自動的に作られるStart()とUpdate()の定義がありませんが、これとは別にGameManagerというクラスを実装しそこで初期化と更新を一括でできるようにします。

GameManager.cs

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

public class GameManager:MonoBehaviour
{
    [SerializeField] GameObjectBehaviour player;

    void Start()
    {
        player.ManagedInitialize();
    }
    
    void Update()
    {
        player.ManagedUpdate();
    }
}

GameManagerの実装はこんな感じです。
GameManagerをアタッチしたオブジェクトを用意し、管理するオブジェクトの参照を設定します。
あとはStart()でManagedUpdate()を、Update()でManagedUpdate()を呼び出してあげるだけで初期化と更新ができます。
ちなみに、MonoBehaviourのStart()とUpdate()はGameManager中にしか存在しないので、初期化順と更新順をこちら側で決めることもできます。

オブジェクトの状態毎の挙動の実装について

各ゲームオブジェクトには状態(ステートともいう。以降、ステートと記載)があります。(プレイヤーなら移動、攻撃、ダウンなど)
これをどのように実装するかということです。

実装方法

実装方法ですが、有限ステートマシンというものを使用します。(有限ステートマシンとは

StateBase.cs

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

public abstract class StateBase
{
    protected GameObject gameObj;
    protected string stateName;

    public string StateName
    {
        get { return stateName; }
    }

    protected bool isActive;
    public bool IsActive
    {
        get { return isActive; }
    }

    public StateBase(GameObject obj,string stateName)
    {
        gameObj = obj;
        this.stateName = stateName;
    }

    public abstract void OnEnter();
    public abstract void Execute();
    public abstract void OnExit();
}

StateSample.cs

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

public class StateSample : StateBase
{
    public StateSample(GameObject obj, string stateName) : base(obj, stateName)
    {

    }

    public override void OnEnter()
    {
        // このステートに入ったときに呼ばれる処理
    }

    public override void Execute()
    {
        // このステート中に呼ばれる処理
    }

    public override void OnExit()
    {
        // このステートを出るときに呼ばれる処理
    }
}

StateController.cs

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

public class StateController
{
    StateBase state;
    public StateBase State
    {
        get { return state; }
    }

    public void ManagedInitialize(StateBase initialState)
    {
        state = initialState;
        state.OnEnter();
    }

    public void ManagedUpdate()
    {
        if (state == null) return;
        state.Execute();
    }

    public void ChangeState(StateBase state)
    {
        this.state.OnExit();
        this.state = state;
        this.state.OnEnter();
    }
}

クラスの実装はこんな感じです。
ステートを作成する際はStateBaseを継承して作成します。

使用方法

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

public class Player : MonoBehaviour
{
    StateController stateController;
    public StateController StateController
    {
        get { return stateController; }
    }

    StateBase stateSample;
    public StateBase StateSample
    {
        get { return stateSample; }
    }

    void Start()
    {
        // ステートのインスタンスを作成
        stateSample = new StateSample(this.gameObject, "StateSample");

        // ステートコントローラーのインスタンスを作成し、作成したステートのインスタンスを初期化時に設定
        stateController = new StateController();
        stateController.ManagedInitialize(stateSample);
    }

    void Update()
    {
        //ステートの更新を行う
        stateController.ManagedUpdate();
    }
}

使用方法はこんな感じです。
作成したステートを使用するにはStateControllerを使用して、初期化時に登録、ステートが変わるタイミングでStateControllerのChangeState()でステートを変更します。
あとはUpdate()のタイミングでStateControllerのManagedUpdate()を呼び出してあげるだけで現在のステートを更新することができます。

シーン間を跨いで保存するデータの扱い

ゲームを作る上で必要になってくるであろうものとして、スコアなどのデータがあります。
これらのデータは他のシーンで使用する可能性があります。(例えばランキング表示など)ではこれらの情報をどのようにして他のシーンで呼び出すのかということです。

実装方法

デザインパターンの一つであるシングルトンパターンを使います。(シングルトンとは)

DataSample.cs

using UnityEngine;

public class DataSample : MonoBehaviour
{
    //外部から呼び出すために自身を設定する
    public static DataSample instance;
    
    int score;
    public int Score
    {
        get{return score;}
        set{score = value;}
    }
    
    //他のシーンに遷移してもインスタンスが残るようにする
    void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }
}

クラスの実装はこんな感じです。
自身をpublic staticな変数として定義します。
続いてAwake()で自分自身のインスタンスがなければ設定し、新たなシーンを読み込んでも破壊されないようにします。
逆にインスタンスが存在すれば自身を削除するといった形です。

使用方法

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

public class Sample : MonoBehaviour
{
    int score;
    void Start()
    {
        score = 100;
        DataSample.instance.Score = score;
        int temp = DataSample.instance.Score; // ←tempには100が入っています
    }
}

使用方法はこんな感じです。
あとはDataSampleをアタッチしたオブジェクトをUnity上で作成すればシングルトンでシーンを跨いだデータの受け渡しができます。

SE・BGMの再生

SE・BGMの再生ですが、通常であればオブジェクトにAudioSourceコンポーネントをつけてAudioSourceのPlay()を呼び出すことで再生できます。が、この方法だと以下の問題点が発生します。

1. シーン間を跨いで再生することができない
2. 音を鳴らすたびにAudioClipを設定する、もしくはAudioSourceがついたオブジェクトを音の数だけ用意してそれぞれを任意のタイミングで取得し再生する必要がある

では、これらの問題を解決するにはどのようにすればいいでしょうか。

実装方法

シングルトンでSE・BGMを再生するクラスを実装するということです。 (再び登場、シングルトン)

SEManager.cs

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

public class SEManager : MonoBehaviour
{
    public enum AUDIO_KIND_SE
    {
        NONE = -1,
        DECISION,
        KIND,
    }

    static public SEManager Instance;

    [SerializeField] private AudioClip[] audioClips;

     private AudioSource audioSource;

    void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
            audioSource = GetComponent<AudioSource>();
            audioSource.volume = 0.5f;
        }
        else
        {
            Destroy(gameObject);
        }
    }

    //SEを再生する
    public void PlaySE(AUDIO_KIND_SE kind)
    {
        if(audioClips[(int)kind] == null)return;
        audioSource.PlayOneShot(audioClips[(int)kind]);
    }
    
    //ボリューム調整
    public float Volume
    {
        get { return audioSource.volume; }
        set
        {
            audioSource.volume = value * 0.5f;
        }
    }
    
    //再生中かどうかを取得する
    public bool IsPlaying
    {
        get{ return audioSource.isPlaying; }
    }
}

BGMManager.cs

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

public class BGMManager : MonoBehaviour
{
    public enum AUDIO_KIND_BGM
    {
        NONE = -1,
        TITLE,
        PLAY,
        RESULT,
        KIND,
    }

    static public BGMManager instance;

    [SerializeField] private AudioClip[] audioClips;

    private AudioSource audioSource;

    private AUDIO_KIND_BGM currentBgm;
    void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
            audioSource = GetComponent<AudioSource>();
            audioSource.volume = 0.5f;
            currentBgm = AUDIO_KIND_BGM.NONE;
        }
        else
        {
            Destroy(gameObject);
        }
    }
    
    //BGMを再生する
    public void PlayBGM(AUDIO_KIND_BGM kind)
    {
        if(audioClips[(int)kind] == null)return;
        if (kind == currentBgm) return;

        //BGMは重複して再生したくないので一旦停止処理を入れる
        audioSource.Stop();
        audioSource.clip = audioClips[(int)kind];
        audioSource.Play();
        currentBgm = kind;
    }
    
    //BGMを止める
    public void StopBGM()
    {
        audioSource.Stop();
    }
    
    //BGMをN秒後に再生する
    public IEnumerator PlayBGMDelay(float delayTime, AUDIO_KIND_BGM kind)
    {
        if(audioClips[(int)kind] == null) yield break;
        if (kind == currentBgm) yield break;

        yield return new WaitForSeconds(delayTime);

        //BGMは重複して再生したくないので一旦停止処理を入れる
        audioSource.Stop();
        audioSource.clip = audioClips[(int)kind];
        audioSource.Play();
    }
    
    //ボリューム調整
    public float Volume
    {
        get { return audioSource.volume; }
        set
        {
            audioSource.volume = value * 0.5f;
        }
    }
    
    //再生中かを取得する
    public bool IsPlaying
    {
        get{ return audioSource.isPlaying; }
    }
}

実装はこんな感じです。
SEManager.csは再生したいSEをPlaySE()で指定して一回だけaudioClipsに登録されているSEを1回だけ再生するようにしています。
BGMManager.csは再生したいBGMをPlayBGM()で指定して再生することができます。また、止めてから再生することで重複なしで再生ができるようにしています。
PlayBGMDelay()は秒数と再生したいBGMを指定して遅延再生することができます。また、こちらもPlayBGM()同様、止めてから再生することで重複なしで再生ができるようにしています。
PlayBGM()とPlayBGMDelay()は呼び出しの際に現在再生されているBGMと引数で受け取ったBGMが同じであれば再生しないようにすることで、シーンを跨いで再生処理を呼び出しても途中で停止して最初から流れるといったことがないようにしています。

使用方法

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

public class Sample : MonoBehaviour
{
    void Start()
    {
        // SEを再生する
        SEManager.instance.PlaySE(SEManager.AUDIO_KIND_SE.DECISION);
        // BGMを再生する
        BGMManager.instance.PlayBGM(BGMManager.AUDIO_KIND_BGM.TITLE);
        // 1秒後にBGMを再生する
        StartCoroutine(BGMManager.instance.PlayBGMDelay(1.0f,BGMManager.AUDIO_KIND_BGM.RESULT));
    }
}

使用方法はこんな感じです。
PlaySE()とPlayBGM()は任意の場所で再生したい音の種類を指定するだけで再生することができます。
PlayBGMDelay()は実装にコルーチンを使用しているので呼び出す際はStartCoroutineで呼び出す必要があります。

まとめ

さて、今回は上記4つに絞って紹介しました。
これらだけでも意識して開発するだけで開発効率は格段に上がるはずです。(少なくとも自分は効率が上がっていると思っています。)
ゲームを作る際は「とりあえず動くようにする」ということも大事ですが、後から変更を加えることになった際に「めんどくさっ」とならないようにするためにもあらかじめ設計したり、後々使いやすいように実装してより良い開発ライフを楽しんでいきたいところですね...!

さて、約1ヶ月にわたって開催されていました、PONOS Advent Calendar 2022 も本日でおしまいです。
ここまでご覧いただいた方、誠にありがとうございました。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?