🦁

Unityでリプレイシステムを作ってみる

2022/12/14に公開

はじめに

こんにちは。Kaibaです。今回はUnityでリプレイシステムを作る方法について書いていきます。
昨今オンラインゲームでよくリプレイシステムは搭載されてますよね。LoL, R6S, PUBGなど僕がやっていたゲームでもよくリプレイシステムが存在していました。自分のミスやなぜ試合に負けたのかなど大局的に見返せるのが便利でいいシステムだと思います。

しかし、リプレイシステムはプレイヤーのみに向けに実装するものでもありません。リプレイシステムはデバッグを行う際にも結構便利に使えるのです。実際、僕がリプレイシステムを作った際はVRマルチプレイゲームのデバッグを楽に行いたいというのが動機でした。VR内での頭部と手の動きを記録することでVR空間に入らずにデバッグを行いたかったのです。 (HMD付けるの、重いし怠い)

ということで、改めて今回はUnityでリプレイシステムを作ってみます。実装当時はとにかく複雑なコードを書かずに適当に使えるシステムを作ろうとしてたので、コードにはかなり粗がありますがご容赦ください。

記録用コード

さて、早速リプレイシステムのコア、オブジェクトの動きを記録するコードについてです。中身は非常にシンプルで、事前に登録されたオブジェクトの状態を60FPSで変数に格納、記録するだけです。

記録データのクラス群

まず最初にリプレイ用の記録データを格納するクラスを宣言します。

[Serializable]
public class RecordedObjectsData
{
    public List<FrameData> frames;
}

[Serializable]
public class FrameData
{
    public float time;
    public List<ObjectPoseData> objectPoses;
}

[Serializable]
public class ObjectPoseData
{
    public Vector3 position;
    public Quaternion rotation;
    public int id;
}

ObjectPoseDataはオブジェクトの情報を格納するクラス、FrameDataObjectPoseDataの配列と、ObjectPoseDataがいつ時点での情報なのかという情報を保持するクラスです。
そして、各時間時点での情報FrameDataの配列を持つRecordedObjectsDataで記録データは構成させれています。

記録開始

さて、では宣言したクラスに実際にデータを格納していく処理を書きます。

記録は以下のStartLoggingを呼び出して開始します。引数のDictionaryは記録するGameObjectと記録用のId(int)のペアです。
関数内部でRecordedObjectsDataを初期化して記録用のコルーチンを開始します。

    
    [SerializeField]
    RecordedObjectsData LogData;
    Dictionary<int, GameObject> loggingTarget;

    List<int> objectIDs;
    List<GameObject> loggingObjects;

    bool isLogging;

    [SerializeField]
    float frameInterval = 0.0166666666666f;

    Coroutine LogRoutine;

    public void StartLogging(Dictionary<int, GameObject> objects)
    {
        LogData = new RecordedObjectsData();
        LogData.frames = new List<FrameData>();

        loggingTarget = objects;
        objectIDs = loggingTarget.Keys.ToList();
        loggingObjects = loggingTarget.Values.ToList();

        isLogging = true;

        LogRoutine = StartCoroutine(LoggingCoroutine());
    }

記録用コルーチン

記録用のコルーチンは以下の通りです。frameIntervalで設定された時間間隔(0.016...は60FPSの場合です。この値は1/60で求められます。)の分だけWaitForSecondsし、毎フレーム分のデータを保存します。


    IEnumerator LoggingCoroutine()
    {
        float time = 0;
        while (true)
        {
            if (isLogging)
            {
                FrameData frame = new FrameData();
                frame.time = time;
                frame.objectPoses = new List<ObjectPoseData>();

                for (int i = 0; i < loggingObjects.Count; i++)
                {
                    ObjectPoseData pose = new ObjectPoseData();
                    pose.position = loggingObjects[i].transform.position;
                    pose.rotation = loggingObjects[i].transform.rotation;
                    pose.id = objectIDs[i];
                    frame.objectPoses.Add(pose);
                }
                LogData.frames.Add(frame);
            }

            yield return new WaitForSeconds(frameInterval);
            time += frameInterval;
        }
    }

記録終了

最後に記録を終了してRecordedObjectsDataをJSON形式でテキストファイルに書き出します。以下の処理では記録再開のためにStopCoroutineは呼び出していませんが、必要に応じて呼び出してください。また、ファイル名・パスは適宜変更してください。


    public async void EndLogging()
    {
        isLogging = false;
        StreamWriter writer = new StreamWriter("Assets/Resources/MovementData/MovementDataLog_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".txt",false,System.Text.Encoding.UTF8);
        await writer.WriteAsync(JsonUtility.ToJson(LogData));
        writer.Close();
    }

あとはここまで宣言した関数を適当に呼び出して記録を行いましょう。

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.P))
        {
            if (isLogging)
            {
                EndLogging();
            }
            else
            {
                Dictionary<int, GameObject> objects = new Dictionary<int, GameObject>();
                objects.Add(0, Camera.main.gameObject);
                StartLogging(objects);
            }
        }
    }

記録データを読み出す

さて、次に記録したデータを読み込んで実際にリプレイを行ってみます。
以下は記録データのテキストファイルを読み込んでリプレイ用コルーチンを開始する関数です。
引数pathはテキストファイルへのパスです。ただし、以下のコードではResources.Loadを使っている為Assets/Resources/フォルダ以下のパスを指定し、拡張子を入れずに指定します。第二引数はId(int)GameObjectの辞書です。このIdは記録時のIdと一致している必要があります。また、第三引数のstartPosは記録データの再生開始位置です。0~1で指定し、0なら初めから、0.5は再生時間のちょうど半分の時点から開始します。


    [SerializeField]
    RecordedObjectsData loadedData;

    Dictionary<int, GameObject> applyObjects;

    bool isLoaded;

    Coroutine readCoroutine;

    public void LoadMovementLog(string path, Dictionary<int, GameObject> objects, float startPos = 0)
    {
        var txt = Resources.Load(path) as TextAsset;

        var data = JsonUtility.FromJson<RecordedObjectsData>(txt.text);

        loadedData = data;
        applyObjects = objects;
        isLoaded = true;

        readCoroutine = StartCoroutine(ApplyMovementCoroutine(true, (int)(startPos * loadedData.frames.Count)));
    }

読み込んだデータを適用するコルーチンです。引数のloopfalseの場合、最後まで読み込みが終了した時点でコルーチンは終了します。


    IEnumerator ApplyMovementCoroutine(bool loop, int initindex = 0)
    {
        int currentIndex = initindex; //FrameDataの配列におけるインデックス
        while (true)
        {
            if (currentIndex >= loadedData.frames.Count)
                break;

            var poses = loadedData.frames[currentIndex].objectPoses;
            for (int i = 0; i < poses.Count; i++)
            {
                var obj = applyObjects[poses[i].id];
                obj.transform.localPosition = poses[i].position;
                obj.transform.localRotation = poses[i].rotation;
            }

            if (currentIndex == loadedData.frames.Count - 1)
            {
                if (loop)
                    currentIndex = 0;
                else
                    break; //最後まで読み込み終了
            }

            var interval = loadedData.frames[currentIndex + 1].time - loadedData.frames[currentIndex].time;
            yield return new WaitForSeconds(interval);
            currentIndex++;
        }
    }

あとは適当に関数を呼び出して読み込みを行います。

    void Update()
    {

        if (Input.GetKeyDown(KeyCode.Alpha0))
        {
            if (isLoaded)
            {
                StopCoroutine(readCoroutine);
                isLoaded = false;
            }
            else
            {
                Dictionary<int, GameObject> objects = new Dictionary<int, GameObject>();
                objects.Add(0, Camera.main.gameObject);
                LoadMovementLog("MovementData/MovementDataLog_XXXX_YYYY", objects);
            }
        }
    }

おわりに

さて、今回はUnityでリプレイシステムを作る方法でした。見直すと結構見苦しいコードを書いていたのですが、取り敢えず動く程度のクオリティのものでも良ければ使用してください。
今回はボタンなどの入力を記録してはいませんが、RecordedObjectsData に入力の情報を格納する変数を追加すれば入力も再現することは可能です。 ただし、その場合は入力処理をちゃんと抽象化しておかなければスパゲティを茹でることになるでしょう。
リプレイシステムは実は結構簡単に作成できるので、デバッグ用途でも機能としてでも、是非導入してみてください。不具合・改良案・質問その他なんでも何かあればTwitterのDMにでも叩きつけてください! それではまた!

Discussion