【Unity】Timelineのクリップデータをjsonで管理する

# やりたいこと
Unityでリズムゲームを作るうえで、再生するノーツタイミングデータの作成は必須です。
Unityでも様々な方法がありますが、自分はTimelineでノーツのアニメーション再生、判定アクションを行う子Timelineを譜面代わりに組み合わせて作成しています。

再生する音楽によってTimelineもサーバーからAssetBundleとして取得、インスタンス化して再生としていますが、この場合タイミングデータに修正をかけるとなると、都度ビルドのし直しが必要になります。

そこで、jsonデータでタイミング情報を管理して動的にTimelineを生成する方法を考え中です。

# Timelineの構成

Timelineはスクリプトから再生が呼び出されるメインTimelineと、ノーツ単体のアニメーション、判定を行うサブTimelineで作成しています。

サブはノーツの色や再生するレーンごとに座標を固定で作成しており、4種×4レーンの16個分あります。
これをメインからControlTrackで呼び出して一つの譜面として再生します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2796558/ac6b2190-a3a5-62d5-95e8-72957bd68f76.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2796558/5f4a1516-5413-40ae-aa27-add37c0d9de6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2796558/5d90ff72-36e1-eddf-5527-ed1201a29f9b.png)

# jsonのエクスポート
最初に作成したのは、Unityエディタ上で作成したTimelineをもとに、ノーツタイミングをjsonファイルに書き出す処理です。

``` C#
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.PackageManager.UI;
using UnityEngine;
using UnityEngine.Timeline;

public class TimelineTrackExport : EditorWindow
{

    TimelineAsset target;
    List<List<TimelineClip>> clipList = new();
    string exportString = "";
    string path = Application.dataPath;
    string selectedPath;
    string fileName = "TimelineJSON.json";
    string message = "";
    const int LERN_ID_SUBSTRING_INDEX = 5;

    [MenuItem("Window/TimelineExport")]
    static void Open()
    {
        TimelineTrackExport window = (TimelineTrackExport)GetWindow(typeof(TimelineTrackExport));
        window.Show(); window.titleContent = new GUIContent("Timelineからノーツtimingを出力");
    }

    private void OnGUI()
    {
        target = (TimelineAsset)EditorGUILayout.ObjectField("出力元", target, typeof(TimelineAsset), true);
        fileName = EditorGUILayout.TextField(fileName);


        if (GUILayout.Button("抽出"))
        {
             exportString = "";
             path = Application.dataPath;
             selectedPath = AssetDatabase.GetAssetPath(Selection.activeObject);

            target.GetRootTracks().ToList().ForEach(track =>
            {
                if (track.name.Contains("NotesGoup"))
                {
                    //レーングループ取得
                    track.GetChildTracks()
                    .Where<TrackAsset>(s => s.name.Contains("Lern")).ToList()
                    .ForEach(t =>
                    {
                        List<TimelineClip> lernClips = new();
                        t.GetChildTracks().ToList().ForEach(ct =>
                        {
                            //子のグループで持つクリップをまとめて追加
                            lernClips.AddRange(ct.GetClips().ToList());
                        });
                        clipList.Add(lernClips);
                    });
                }
            });

            if (clipList == null || clipList.Count <= 0)
            {
                message = "対象がありません";
                return;
            }

            
            clipList.ForEach(clip => {
                clip.ForEach(c =>
                {
                    ControlPlayableAsset controlPlayableAsset = c.asset as ControlPlayableAsset;
                    NotesType notesType = controlPlayableAsset.prefabGameObject.GetComponent<NoteTimeline>().GetNotestype();

                    string prefabName = controlPlayableAsset.prefabGameObject.name
                    .Substring(controlPlayableAsset.prefabGameObject.name.Length - 3);
                    int type = notesType == NotesType.ATK ? 1 :
                    notesType == NotesType.DEF ? 2 :
                    notesType == NotesType.ENH ? 3 :
                    notesType == NotesType.JAM ? 4 : 5;

                    exportString += $"{{ \n\"lern\": \"{c.displayName[LERN_ID_SUBSTRING_INDEX]}\",\n" +
                        $" \"start\": {c.start},\n" +
                        $" \"end\": {c.end}, \n" +
                        $" \"type\": {type} }},\n";
                });
            });
            exportString = exportString.TrimEnd(',', '\n');
            exportString = $"\n\"notes\":[ \n{exportString}\n ]";
            exportString = $"{{\n{exportString}\n}}";
            message = "ファイルを出力しました=>" + path;
            CreateJsonFile();
        }

        EditorGUILayout.LabelField(message);
    }


    private void CreateJsonFile()
    {
        if (fileName == null || fileName.Equals(""))
        {
            message = "ファイル名を入力してください";
            return;
        }
        if (selectedPath.Length != 0)
        {
            if (!IsFolder(selectedPath))
            {
                selectedPath = selectedPath.Substring(0, selectedPath.LastIndexOf("/", StringComparison.CurrentCulture));
            }
            path = path.Remove(path.Length - "Assets".Length, "Assets".Length);
            path += selectedPath;
        }

        path += "/TimelineAsset/BattleScene/TimingJson/" + fileName;
        int cnt = 0;
        while (File.Exists(path))
        {
            if (path.Contains(fileName))
            {
                cnt++;
                string newFileName = fileName + " " + cnt + ".json";
                path = path.Replace(fileName, newFileName);
                fileName = newFileName;
            }
            else
            {
                Debug.LogError("path dont contain " + fileName);
                break;
            }
        }

        // 空のテキストを書き込む.
        File.WriteAllText(path, exportString, System.Text.Encoding.UTF8);
        AssetDatabase.Refresh();
    }

    /// <summary>
    /// 指定パスがフォルダかどうかチェックします
    /// </summary>
    /// <param name="path"></param>
    /// <returns></returns>
    public static bool IsFolder(string path)
{
    try
    {
        return File.GetAttributes(path).Equals(FileAttributes.Directory);
    }
    catch (Exception ex)
    {
        if (ex.GetType() == typeof(FileNotFoundException))
        {
            return false;
        }
        throw ex;
    }
}
}

```
Unityで読み込む際のjson形式の制約として、配列にプロパティ名を指定する必要があるため名前を付けて囲っています。

同じファイル名で保存しようとした場合、インデックスが付くようにしていますがこれは不要かもしれません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2796558/218a3b60-378f-dc86-9431-adebe776601b.png)

拡張画面で読み込みTimeline、jsonファイル名を指定して「抽出」を押すと、決まったフォルダへ上記jsonを出力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2796558/ed6fb32f-fdbc-e88b-e70d-0da73dfb414e.png)

# jsonのインポート

上記で出力したjsonを読み込んで、Timelineアセットを生成する処理です。
ゲーム再生中に生成して、不要になったら破棄するのでわざわざ保存する必要はないですがいったんの形です。

``` C#
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Timeline;

public class TimelineTrackImport : EditorWindow
{
    string timelineFileName = "TimelineDEMO";
    string selectedPath = "Assets/Game Server Services/";
    string generatePath = "";
    const string NOTES_TRACK_GROUP_NAME = "NotesGoup";
    const string LERN_TRACK_GROUP_NAME = "Lern";
    const string NOTES_PREFAB_PATH = "Assets/Prefabs/Notes/Lern";
    AudioClip audioClip = null;
    TextAsset file;
    List<List<NotesImportObject>> notesList = new ();

    [MenuItem("Window/TimelineImport")]
    static void Open()
    {
        TimelineTrackImport window = (TimelineTrackImport)GetWindow(typeof(TimelineTrackImport));
        window.Show(); window.titleContent = new GUIContent("Timelineからノーツtimingをインポート");
    }

    private void OnGUI()
    {
        generatePath = selectedPath + "GenerateTimeline/" + timelineFileName + ".playable";

        timelineFileName = EditorGUILayout.TextField("出力Timeline名", timelineFileName);
        audioClip = EditorGUILayout.ObjectField("音源", audioClip, typeof(AudioClip), true) as AudioClip;
        file = EditorGUILayout.ObjectField("インポートJsonファイル", file, typeof(TextAsset), true) as TextAsset;

        if (GUILayout.Button("インポート"))
        {
            notesList.Clear();
            notesList.AddRange(Enumerable.Range(0, 4).Select(i => new List<NotesImportObject>()));

            //JSONからレーンごとにノーツを分ける
            string json = file.text;
            NotesJson clips = JsonUtility.FromJson<NotesJson>(json);
            clips.notes.ToList().ForEach(c =>
            { 
                notesList[c.lern - 1].Add(c);
            });

            //Timeline生成
            TimelineAsset timelineAsset = ScriptableObject.CreateInstance<TimelineAsset>();
            AssetDatabase.CreateAsset(timelineAsset, generatePath);

            //トラック生成
            AudioTrack audioTrack = timelineAsset.CreateTrack<AudioTrack>(null, "Audio Track");
            audioTrack.CreateClip(audioClip);

            GroupTrack groupTrack = timelineAsset.CreateTrack<GroupTrack>(null, NOTES_TRACK_GROUP_NAME);
            for (int i = 1; i < 5; i++)
            {
                GroupTrack lernTrack = timelineAsset.CreateTrack<GroupTrack>(groupTrack, LERN_TRACK_GROUP_NAME + i);
                ControlTrack controlTrack = timelineAsset.CreateTrack<ControlTrack>(null, "Control Track");
                controlTrack.SetGroup(lernTrack);

                //レーンリストごとにクリップ生成
                notesList[i - 1].ForEach(n =>
                {
                    string prefabPath = NOTES_PREFAB_PATH + i + "/Notes" + i + "_" +
                        (n.type == 1 ? "ATK" :
                        n.type == 2 ? "DEF" :
                        n.type == 3 ? "ENH" : "JAM")
                    + ".prefab";

                    GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
                    if (!PrefabUtility.IsPartOfPrefabAsset(prefab))
                    {
                        return;
                    }

                    //ControleTrackにノーツクリップ生成
                    ControlTrack controlTrack1 = controlTrack;
                    TimelineClip timelineClip1 = controlTrack1.CreateClip<ControlPlayableAsset>();
                    
                    timelineClip1.start = n.start;
                    timelineClip1.duration = 1.6f;
                    timelineClip1.displayName = $"Notes{n.lern}";
                    ControlPlayableAsset asset = timelineClip1.asset as ControlPlayableAsset;
                    asset.prefabGameObject = prefab;
                    
                });
            }
            EditorGUILayout.LabelField("Timelineアセットの生成が完了しました:" + generatePath + "/" + timelineFileName);
        }
    }

    [Serializable]
    public class NotesJson
    {
        public NotesImportObject[] notes;
}

    [Serializable]
    public class NotesImportObject
    {
        public int lern;
        public double start;
        public double end;
        public int type;
    }
}

```
notesListにレーンごとの配列を作成して、レーン用サブトラック作成の中でサブTimelineのControlTrack、Clip生成をします。
Clipに設定するprefabも予め用意しておき、レーンID、種類に合わせたオブジェクトをセットするようにします。

controlTrackはなぜかTimelineClip timelineClip1で使用すると、「宣言前の変数は使えません！」といわれたので、レーン別のサブトラック作成直後に作成後に参照先を取るcontrolTrack1 を嚙ませています。

拡張画面で再生したい音楽ファイル、TimelineAsset名、上記で出力したjsonと同じフォーマットのファイルを選択しインポートすると…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2796558/38c98eca-52ca-d800-c913-e311532c1a9a.png)

指定されたフォルダにTimelineAssetが作成されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2796558/61e3ae2a-0bab-0735-b6f6-3beb61b28063.png)

TimelineAssetsのバックアップにも使えますね。

# 今後の修正
現時点ではEditor上での操作を前提としたものになっているため、この処理を流用して
ゲームプレイ中に動的にTimelineの生成をする処理を組み込みたいと思います。

音楽ファイルもAssetBundleに内包していたので、個別にして必要なものを取得する処理の追加が必要です…。
いつか長押しや同時押しのノーツも対応したいです。

# 参考
https://qiita.com/jukey17/items/af494ee34d0b9669bafb
https://developers.10antz.co.jp/archives/2352