白黒羊

Airtableで宴のシナリオを書く

この記事は Unity アセット真夏のアドベントカレンダー 2020 Summer! の16日目です。
昨日はたなかゆうさんの「無料で使えるネットライブラリMirrorのざっくり紹介」でした!

Airtableで宴のシナリオを書く

Unity用ビジュアルノベルツール「」 、最高ですよね!
Excel ファイルにシナリオを書いていくことでお手軽にADVが作成できる神アセットですが、その宴を扱ってシナリオを書く際に、気持ちよく執筆できるような方法を考えてみました。
Airtableを使えるようにEditor拡張をしてみます。

Airtableというクラウド型データベースを利用して執筆、データを作成し、使うときは.xls形式でデータを保存することができるようにします。
いろいろな要素を組み合わせているので、他の目的にも流用できると思います。

こんな人たちが読むと意味があるかも。

  • UnityのEditor拡張に着手したい
  • Excelを持っていないけれどExcel形式のファイル(.xls)を取り扱いたい
  • NPOI を使いたい
  • AirtableのAPIをC#(Unity)から叩きたい

コード全文

先にコード全文を載せておきます。
詳細は後で書きます。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NPOI.SS.UserModel;
using Project.Scripts.Application.Extensions;
using Project.Scripts.Application.Tools.AirtableClient;
using Cysharp.Threading.Tasks;
using UnityEditor;
using UnityEngine;

namespace Project.Editor
{
    public class CreateXlsFromAirTableEditorWindow : EditorWindow
    {
        [SerializeField] private string excelFileName = "Project/Scenario/Scenario.xls";
        [SerializeField] private SheetsName worksheetName = SheetsName.Start;
        [SerializeField] private string accessToken = "key***********";
        private Vector2 _scrollPosition = Vector2.zero;

        [MenuItem("Window/CreateXlsFromAirTable")]
        private static void Create()
        {
            GetWindow<CreateXlsFromAirTableEditorWindow>("UtageScenario");
        }

        private async void OnGUI()
        {
            _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUI.skin.scrollView);
            {
                EditorGUILayout.BeginVertical(GUI.skin.box);
                {
                    EditorGUILayout.LabelField("CreateXlsFromAirTable");
                    EditorGUILayout.Space();

                    excelFileName = EditorGUILayout.TextField(".xlsファイルへのローカルパス", excelFileName);
                    accessToken = EditorGUILayout.TextField("Airtable AccessToken", accessToken);

                    EditorGUILayout.Space();
                    EditorGUILayout.LabelField("全てのシート");
                    var allSheetsButton = GUILayout.Button("実行");

                    EditorGUILayout.Space();
                    EditorGUILayout.LabelField("選択したシート");
                    worksheetName = (SheetsName) EditorGUILayout.EnumPopup("シート", worksheetName);
                    var oneSheetButton = GUILayout.Button("実行");

                    if (allSheetsButton)
                    {
                        foreach (SheetsName sheet in Enum.GetValues(typeof(SheetsName)))
                        {
                            var sheetName = sheet.ToString();
                            if (sheet == SheetsName.ParamTbl) sheetName = "ParamTbl{}";
                            var data = await FetchJsonFromAirTableAsync(sheetName);
                            SaveXls(sheetName, data);
                            Debug.Log($"{sheetName}を更新します");
                        }

                        Debug.Log("全てのシートを更新しました");
                        return;
                    }

                    if (oneSheetButton)
                    {
                        var sheetName = worksheetName.ToString();
                        var data = await FetchJsonFromAirTableAsync(sheetName);
                        SaveXls(sheetName, data);
                        Debug.Log($"{sheetName}の更新が完了しました");
                        return;
                    }
                } EditorGUILayout.EndVertical();
            } EditorGUILayout.EndScrollView();
        }

        private async UniTask<IEnumerable<Dictionary<string, dynamic>>> FetchJsonFromAirTableAsync(string sheetName)
        {
            var client = new AirtableClient(accessToken);
            var tableBase = client.GetBase("app*************");
            var allRows = await tableBase.LoadTableAsync<Dictionary<string, dynamic>>(sheetName);

            return allRows;
        }

        private void SaveXls(string sheetName, IEnumerable<Dictionary<string, dynamic>> fields)
        {
            try
            {
                var excelFilePath = $"{UnityEngine.Application.dataPath}/{excelFileName}";
                
                //ブック読み込み
                var book = WorkbookFactory.Create(excelFilePath);

                //シート名からシート作成
                var sheet = book.GetSheet(sheetName);
                if (sheet == null)
                {
                    sheet = book.CloneSheet(book.GetSheetIndex(SheetsName.Start.ToString()));
                    book.SetSheetName(book.GetSheetIndex(sheet), sheetName);
                }
                for (var i = 1;  i <= sheet.LastRowNum; i++)
                {
                    var row = sheet.GetRow(i);
                    if (row != null) sheet.RemoveRow(row);
                }

                var fieldTypes = sheet.GetRow(0).Cells.Select(cell => cell.StringCellValue).ToList();

                //セルに設定
                foreach (var (field, r) in fields.Indexed())
                {
                    var row = r + 1;
                    if(sheet.GetRow(row) != null) sheet.RemoveRow(sheet.GetRow(row));
                    foreach (var data in field)
                    {
                        WriteCell(
                            sheet, 
                            fieldTypes.FindIndex(type => type == data.Key),
                            row,
                            data.Value);
                    }
                }

                //ブックを保存
                using (var fs = new FileStream(excelFilePath, FileMode.Create))
                {
                    book.Write(fs);
                }
            }
            catch (Exception ex)
            {
                Debug.Log(ex);
            }
        }

        //セル設定
        private static void WriteCell<T>(ISheet sheet, int columnIndex, int rowIndex, T value)
        {
            var row = sheet.GetRow(rowIndex) ?? sheet.CreateRow(rowIndex);
            var cell = row.GetCell(columnIndex) ?? row.CreateCell(columnIndex);

            cell.SetCellValue(value);
        }
        
        private enum SheetsName
        {
            Character,
            Texture,
            Particle,
            Layer,
            EyeBlink,
            LipSynch,
            Animation,
            Sound,
            Param,
            ParamTbl,
            SceneGallery,
            Localize,
            Macro,
            Start,
        }
    }

    internal static class NPoiExtension
    {
        internal static void SetCellValue<T>(this ICell cell, T value)
        {
            if (typeof(T) == typeof(double))
            {
                var d = value is double dValue
                    ? dValue
                    : 0;
                cell.SetCellValue(d);
            }
            else if (typeof(T) == typeof(DateTime))
            {
                var dt = value is double dtValue
                    ? dtValue
                    : 0;
                cell.SetCellValue(dt);
            }
            else cell.SetCellValue(value as string);
        } 
    }
}

.xlsファイルを(比較的)楽に取り扱いたい

Microsoft Excel がない環境でもエクセルデータをストレスなく使う方法を考えました。

Macデフォルトの numbers でも「書き出し」をすれば、Excel の形式でも保存できるのですがちょっと面倒……。
スプレッドシートは他にも色々あり、Excel Online を使う方法も考えられますが、ここは Airtableを使ってみましょう。

Airtable を使う

Airtableとは、スプレッドシート型のデータベースです。

Airtable

まだ私も使いこなせてはいないのですが、スマートな見た目で、あらかじめ選択肢を作っておいて選べたりしてスプレッドシートとしても使い勝手は良い感じ。

Airtableの画面のサンプル

これは宴用ではなくまだ下書き段階ですが、こんな風にゲームのシナリオを書いたり、予定表を作ったり、プログラミングする前にクラスのリストを作って設計を考えたりするのに使ってみたりしています。

ここに.xls形式で保存したいデータをサクサク書き込んでいきましょう。
複数シートも使えます。

Airtable のことは以前 PlayFab Meetup #1 in Microsoft に参加したときにきみか (@kimika127) さん の LT で教えていただきました!

AirtableのAPIを叩く

Excel Online にもAPI(参考: Browse code samples | Microsoft Docs)があるようですが、AirtableのAPIもとてもわかりやすいものが用意されています。
REST API – Airtable

curl と JavaScript のサンプル付きなので認証について知識がなくても簡単にデータを取得することができます。
今回はただシンプルにデータを取得したいだけだったので使わなかったのですが、C# ではこちらのライブラリが使えるようです。

GitHub – ngocnicholas_airtable.net_ Airtable .NET API Client

UnityWebRequest を使うならばこんな感じでしょうか。

private async UniTask<IEnumerable<Dictionary<string, dynamic>>> FetchJsonFromAirTableAsync(string sheetName)
{
    var url = $@"{airTableUrl}/{sheetName}?view=Grid%20view";
    using (var request = UnityWebRequest.Get(url))
    {
        request.SetRequestHeader("Authorization", $"Bearer {accessToken}");
        await request.SendWebRequest();
        var fields = JsonSerializer.Deserialize<Dictionary<string, List<Dictionary<string, dynamic>>>>
            ($@"{request.downloadHandler.text}")["records"]
            .Select(record => record["fields"]);
        var fieldsText = JsonSerializer.ToJsonString(fields);
        
        return JsonSerializer.Deserialize<List<Dictionary<string, dynamic>>>($@"{fieldsText}");
    }
}

Json のシリアライズには、Utf8Json をお借りしました。

GitHub – neuecc_Utf8Json_ Definitely Fastest and Zero Allocation JSON Serializer for C#(NET, .NET Core, Unity, Xamarin).

airTableUrl と accessToken は事前に設定しておく必要がありますが、それはあとでEditor拡張として入力フィールドを用意します。
引数にシートの名前を入れることで、そのシートのデータを引っ張ってくることができます。

非同期に UniTask を使っています(neuecc さんのライブラリ2つ目……)。
下記から .unitypackage がダウンロードできます。

GitHub – Cysharp_UniTask_ Provides an efficient allocation free async_await integration to Unity.

UniTask である必要は無いので、新たなライブラリ入れるのがいやな人は、コルーチンとかにすると良さそうです。

また、あらかじめ excelFileName で指定したパスに宴用のエクセルファイルを置いておきましょう。
空の.xlsファイルではなく、サンプルをコピーするなどして、すべてのシートとセルが揃っている状態にしておいてください。
この部分もコードを書いて自動で行うことができますが、今回は省略しています。
ここでセルのタイトルなどが合わないとうまく読み込めないことがあります。

UnityのEditor拡張を書いてみる

はじめに、シートの名前の enum を作っておきます。
設定シートリファレンス _ Unity用ビジュアルノベルツール「宴」

private enum SheetsName
{
     Character,
     Texture,
     Particle,
     Layer,
     EyeBlink,
     LipSynch,
     Animation,
     Sound,
     Param,
     ParamTbl,
     SceneGallery,
     Localize,
     Macro,
     Start,
}

ここから Editor 拡張部分を書きます。
EditorWindow を継承したクラスを作って、先ほど事前に設定するとした airTableUrl と accessToken、さらに 保存するExcel ファイルのパスを入力するために [SerializeField] アトリビュートをつけた変数を作ります。
accessToken は、 REST API – Airtable でBaseの名前をクリックして出てきたページにある「The ID of this base is ****」 と書かれている部分です。

ピンクの枠に囲まれたところに access token があります

worksheetName は1シートだけを取ってくるとき用の変数です。

public class CreateXlsFromAirTableEditorWindow : EditorWindow
{
    [SerializeField] private string airTableUrl  = default;
    [SerializeField] private string accessToken = default;
    [SerializeField] private string excelFileName = "Project/Scenario/Scenario.xls";
    [SerializeField] private SheetsName worksheetName = SheetsName.Start;
    [MenuItem("Window/CreateXlsxFromAirTable")]
    private static void Create()
    {
        GetWindow<CreateXlsFromAirTableEditorWindow>("CreateXlsxFromAirTable");
    }

OnGUI() に表示する部分を書きます。

    private async void OnGUI()
    {
        EditorGUILayout.LabelField("CreateXlsxFromAirTable");
        EditorGUILayout.Space();
        excelFileName = EditorGUILayout.TextField("ExcelFileName", excelFileName);
        accessToken = EditorGUILayout.TextField("AccessToken of AirTable", accessToken);
        EditorGUILayout.Space();
        EditorGUILayout.LabelField("All sheets");
        var allSheetsButton = GUILayout.Button("Download all the sheets");       
        EditorGUILayout.Space();
        EditorGUILayout.LabelField("Selected sheet");
        worksheetName = (SheetsName) EditorGUILayout.EnumPopup("WorksheetName", worksheetName);
        var oneSheetButton = GUILayout.Button("Download the selected sheet");
        
        if (allSheetsButton)
        {
            foreach (SheetsName sheet in Enum.GetValues(typeof(SheetsName)))
            {
                var sheetName = sheet.ToString();
                if (sheet == SheetsName.ParamTbl) sheetName = "ParamTbl{}"; // 「宴」のシート名に「{}」が含まれるのでその対応
                var data = await FetchJsonFromAirTableAsync(sheetName);
                SaveXls(sheetName, data);
                Debug.Log($"update {sheetName}");
            }
            Debug.Log($"Update all the sheets");
        }
        if (oneSheetButton)
        {
            var sheetName = worksheetName.ToString();
            var data = await FetchJsonFromAirTableAsync(sheetName);
            SaveXls(sheetName, data);
            Debug.Log($"Update {sheetName}");
        }
    }

ボタンを押すと先ほどのFetchJsonFromAirTableAsyncを呼びます。

NPOIでExcelファイルに変換する

Excelファイルとして保存する関数は以下の通りです。
NPOI を利用しています。
宴を使っているならばNPOIは既にプロジェクト内にあると思いますが、ない場合はnugetからダウンロードしましょう。

https://www.nuget.org/packages/NPOI/

    private void SaveXls(string sheetName, IEnumerable<Dictionary<string, dynamic>> fields)
    {
            var excelFilePath = $"{UnityEngine.Application.dataPath}/{excelFileName}";
            
            var book = WorkbookFactory.Create(excelFilePath);
            var sheet = book.GetSheet(sheetName);
            var fieldTypes = sheet.GetRow(0).Cells.Select(cell => cell.StringCellValue).ToList();
            foreach (var (field, r) in fields.Indexed())
            {
                var row = r + 1;
                if(sheet.GetRow(row) != null) sheet.RemoveRow(sheet.GetRow(row));
                foreach (var data in field)
                {
                    WriteCell(
                        sheet, 
                        fieldTypes.FindIndex(type => type == data.Key),
                        row,
                        data.Value);
                }
            }
            using (var fs = new FileStream(excelFilePath, FileMode.Create))
            {
                book.Write(fs);
            }
    }

    private static void WriteCell(ISheet sheet, int columnIndex, int rowIndex, string value)
    {
        var row = sheet.GetRow(rowIndex) ?? sheet.CreateRow(rowIndex);
        var cell = row.GetCell(columnIndex) ?? row.CreateCell(columnIndex);
        cell.SetCellValue(value);
    }

    private static void WriteCell(ISheet sheet, int columnIndex, int rowIndex, double value)
    {
        var row = sheet.GetRow(rowIndex) ?? sheet.CreateRow(rowIndex);
        var cell = row.GetCell(columnIndex) ?? row.CreateCell(columnIndex);
        cell.SetCellValue(value);
    }
}

ところで、宴で.xlsx 形式を取り扱うよう指定すると、以下のようなエラーが出ます。

ZipException: Wrong Local header signature: 0x6AC192AD
ICSharpCode.SharpZipLib.Zip.ZipInputStream.GetNextEntry () (at <8e8fa28d216a43ec8dcb2258d1f7bf00>:0)
(wrapper remoting-invoke-with-check) ICSharpCode.SharpZipLib.Zip.ZipInputStream.GetNextEntry()
NPOI.OpenXml4Net.Util.ZipInputStreamZipEntrySource..ctor (ICSharpCode.SharpZipLib.Zip.ZipInputStream inp) (at <9f9586f1ac9e44d398a1c9c481d5ab83>:0)
NPOI.OpenXml4Net.OPC.ZipPackage..ctor (System.IO.Stream in1, NPOI.OpenXml4Net.OPC.PackageAccess access) (at <9f9586f1ac9e44d398a1c9c481d5ab83>:0)
NPOI.OpenXml4Net.OPC.OPCPackage.Open (System.IO.Stream in1) (at <9f9586f1ac9e44d398a1c9c481d5ab83>:0)
NPOI.Util.PackageHelper.Open (System.IO.Stream is1) (at <5e6351597edb40f0bfbb048e5854b475>:0)
NPOI.XSSF.UserModel.XSSFWorkbook..ctor (System.IO.Stream is1) (at <5e6351597edb40f0bfbb048e5854b475>:0)
Utage.ExcelParser.Read (System.String path, System.Char ignoreSheetMark, System.Boolean parseFormula, System.Boolean parseNumreic) (at Assets/Utage/Editor/Scripts/Lib/Util/ExcelParser.cs:44)
Utage.AdvExcelImporter.ReadExcel (System.String path) (at Assets/Utage/Editor/Scripts/Menu/Project/AdvExcelImporter.cs:135)
Utage.AdvExcelImporter.ImportChapter (System.String chapterName, System.Collections.Generic.List1[T] pathList) (at Assets/Utage/Editor/Scripts/Menu/Project/AdvExcelImporter.cs:108) Utage.AdvExcelImporter.<ImportSub>b__15_0 (Utage.AdvScenarioDataProject+ChapterData x) (at Assets/Utage/Editor/Scripts/Menu/Project/AdvExcelImporter.cs:78) System.Collections.Generic.List1[T].ForEach (System.Action`1[T] action) (at <599589bf4ce248909b8a14cbe4a2034e>:0)
Utage.AdvExcelImporter.ImportSub (Utage.AdvScenarioDataProject project) (at Assets/Utage/Editor/Scripts/Menu/Project/AdvExcelImporter.cs:78)
Utage.AdvExcelImporter.ImportAll (Utage.AdvScenarioDataProject project) (at Assets/Utage/Editor/Scripts/Menu/Project/AdvExcelImporter.cs:40)
Utage.AdvScenarioDataBuilderWindow.Import (System.String[] importedAssets) (at Assets/Utage/Editor/Scripts/Menu/Project/AdvScenarioDataBuilderWindow.cs:125)
Utage.AdvScenarioDataBuilderWindow.DrawProject () (at Assets/Utage/Editor/Scripts/Menu/Project/AdvScenarioDataBuilderWindow.cs:190)
Utage.AdvScenarioDataBuilderWindow.OnGUI () (at Assets/Utage/Editor/Scripts/Menu/Project/AdvScenarioDataBuilderWindow.cs:159)
System.Reflection.MonoMethod.Invoke (System.Object obj, System.Reflection.BindingFlags invokeAttr, System.Reflection.Binder binder, System.Object[] parameters, System.Globalization.CultureInfo culture) (at <599589bf4ce248909b8a14cbe4a2034e>:0)
Rethrow as TargetInvocationException: Exception has been thrown by the target of an invocation………(略)

どうやら現状のNPOIでは.xlsx形式ファイルはMacでは取り扱えないよう。
先ほどから.xlsx(Excel2007〜)ではなく .xls(〜Excel2003)の話をしていたのはこれが理由でした。
私は .xls形式を使うことにしましたが、 .xlsx 形式を取り扱いたい場合には、NPOI 以外の他のライブラリを見てみると良さそうです。

CodePlex Archive

GitHub – JanKallman/EPPlus: Create advanced Excel spreadsheets using .NET

まとめ

アセットアドカレと言えるのか微妙な感じになってしまいましたが、このEditor拡張でのシナリオを良い感じにゴリゴリ書いて、ボタン一つで手元に持ってきましょう!

弱点としては、1Baseあたり1,200Records(1,200行)しか書けないところで、ストーリーをガリガリ書いていくとすぐに到達します。
ただBaseはいくらでも持てるので、こんな感じで章とかキャラクターごとに分割して、

私はキャラクターごとにシナリオをわけています。結構ギリギリまで使っている

Editor拡張のコードも、それぞれをダウンロードするように書き換えれば快適に使えます。

ADVを作成しているのなら、先ほどご紹介したきみかさんの例などを参考に、PlayFabと連携してイベント発生条件などもBaseを作ってまとめておけるので便利!
おすすめです!

明日はsmmさんの、「無料ドローイングアセットでお絵かき機能を作る」です!