記事執筆時の動作環境
Windows10Pro(64bit)
unity2017.1、2017.2、2017.3
==
unity2018.4, unity2019.3
完成版
ボタン一つでスプレッドシートのマスタをScriptableObjectとして流し込みます。
ダウンロードはこちら
はじめに
ゲームを作っていると、たくさんのデータをやり取りする必要が出てきます。
RPGだと村人のセリフや武器のステータス、道具の効果など。
RPGでなくても、敵のステータスやプレイヤーのステータス、各ステージの情報など、様々なまとまったデータを保持する必要が出てきます。いわゆるマスタ管理です。
そこで、そういったまとまったデータをなるべく楽に管理できるシステムを作ってみました。
なぜGoogleスプレッドシートで管理するのか
楽だからです。
例えば敵のステータスを管理するとします。ゲームが小規模なうちは、敵の種類ごとにPrefabを作って、Inspectorでステータスを決めていくこともあるでしょう。
しかし、Prefabでステータス管理をすると、他の敵ステータスとの比較が面倒です。おまけに、仕様が大きくなるにつれてInspectorがどんどん煩雑になります。
また、Prefabの変更はGitと相性が悪いです。ゲームジャムでもない限りお勧めできない方法だと思います。
※昔エターナったRPGで使っていたEnemyController。今読むと訳が分かりません。
ほかにも、よく記事でも見かける方法として、Excelなどの外部テキストファイルを用意し、Resourcesなどから読み込むという方法もあります。しかし、わたしはExcelには過去に散々苦しめられてきたので、もうアプリを開きたくもありません。おまけに、管理方法によってはCSVに変換しなきゃいけなかったり、名前を付けて保存しなければなりません。面倒です。
Googleスプレッドシートなら!!
名前を付けて保存する必要はありません。自動で保存されます。また、表形式でデータを記述できるので、データごとの比較は一目瞭然です。文字コードの心配もいりません。
たぶんこれが一番楽だと思います。
どうやってスプレッドシートとUnityを連携させるのか
流れとしては、
- スプレッドシートでマスタを用意する
- GoogleAppScript(以下GAS)でスプレッドシートの内容をJson形式で返す関数を作る
- Unity側から上記の関数にアクセスし、返ってきたJsonをScriptableObjectに流し込む
というものです。順を追って説明していきます。
必要なもの
- UniRx:言わずと知れた最強アセット。今回はすこしマニアック(?)な用途です。まだ持ってない方はこちら
- 2020/4/27追記。UniRxではObservableWWWをサポートしなくなったので、UniTaskも必須です。こちらから。
- JsonHelper:Json配列をJsonUtilityで扱えるようにします。
こちらからいただきました。
2020/4/27追記。リンクが古くなっておりました。コメントで頂いたコードが素晴らしかったのでこちらを推奨します。
using System;
using UnityEngine;
using System.Collections.Generic;
public static class JsonHelper
{
public static List<T> ListFromJson<T>(string json)
{
var newJson = "{ \"list\": " + json + "}";
Wrapper<T> wrapper = JsonUtility.FromJson<Wrapper<T>>(newJson);
return wrapper.list;
}
[Serializable]
class Wrapper<T>
{
public List<T> list;
}
}
GASでスプレッドシートの内容を返す関数を作る
※2018年2月執筆時点のバージョンでの解説をしています。解説と食い違う箇所は他の記事などを参考にしてください
追記:2020/4/27
まずはスプレッドシート側の準備から行います。こんな感じでマスタを用意しましょう。
- 一行目に変数の定義
- シート名にマスタ名
の二点だけ注意してください。
次にこのマスタをUnity側で受け取れるコードを書きます。スプレッドシート上部のメニューバーの「ツール」→「スクリプトエディタ」を選択して開きます。
エディタを開いたらコードを書きます。以下サンプルです。
function doGet(e) {
// sheetName というパラメータは自分で付与します。後述。
var sheetName = e.parameter.sheetName;
// URL部分に自分で作ったスプレッドシートのURLを入れます
var ss = SpreadsheetApp.openByUrl('URL');
var sheet = ss.getSheetByName(sheetName);
if(sheet != null){
var json = ConvertSheetToJson(sheet);
return ContentService.createTextOutput(JSON.stringify(json))
.setMimeType(ContentService.MimeType.JSON);
}
else
{
Logger.log("NoSheets here");
Browser.msgBox(Logger.getLog());
}
}
function ConvertSheetToJson(sheet)
{
// GoogleAPIを叩いてシートのデータを全件取得
var lastRow = sheet.getLastRow();
// GoogleAPIを叩く回数分処理が重くなるので、ここで2次元配列としてシートデータを保持
var allValues = sheet.getDataRange().getValues();
// 取得する列。
var columnSize = allValues[0].length;
// 定義行を飛ばす
var startRow = 1;
// 定義行以外の値を全件取得
var targetValues = allValues.filter((value, index) => index >= startRow);
// 出力するJSON。一つ一つの値に定義を埋め込んでいく。
var jsonArray = [];
// 変数の定義部分を取得。1行目1列目から1列分の値を columnSize分だけ取得
var valueColumns = allValues[0].filter((cell, index) => index < columnSize);
for(var row = 0; row < targetValues.length; row++)
{
var line = targetValues[row];
var obj = new Object();
for(var column = 0; column < columnSize; column++)
{
// 定義をJsonの要素に注入
obj[valueColumns[column]] = line[column];
}
jsonArray.push(obj);
}
return ContentService.createTextOutput(JSON.stringify(jsonArray)).setMimeType(ContentService.MimeType.JSON);
}
function doGet(e)というのは、GASのテンプレ関数みたいなものです。eには自分で仕込みたいパラメータも仕込めます。
今回は、作成したシートを引数にして、そのシート内のデータをJson形式にまとめたものを返してもらいます。
function ConvertSheetToJson(sheet)は自作の関数です。シートの任意の範囲のデータを取得して、一つずつJson配列にデータを入れていっています。
注意点は、1行目1列目~n列目までを定義行としているので、JSONとして出力した場合の変数名がそのままこの定義行の命名が採用されることです。
なので定義を日本語にしてはいけません。
コードが書けたら、コードを公開し、外部からアクセスできるようにします。
公開の仕方についてはこちらなどが参考になるかと思います。
GASの解説は面倒複雑になるので、本稿では割愛いたします。上記記事はかなりこの記事の参考にしたので、とても参考になるかと思います。
==
2020/4/27追記:ここで紹介している記事も古いかも・・・。でもGASの導入記事は佃煮にするほどたくさんあるので情報は苦労せず手に入るかと思います。
Unity上でGASの関数にアクセスする
さて、いよいよUnityとGoogleスプレッドシートとのつなぎ込みです。
まずはスプレッドシートで作ったマスタのデータに対応するクラスを作ります。
[System.Serializable]属性を付与することで、Jsonからクラスに流し込めるようにします。
/// <summary>
/// マスタ管理するデータの一単位。
/// 変数は必ずスプレッドシートのマスタと同じにする
/// </summary>
[System.Serializable]
public class Temp
{
public int id;
public string tempText;
}
次に、スプレッドシートのデータを注入するコードを用意します。
コードは以下のような感じです。(まだ一部です)
using UnityEngine;
using UnityEngine.Networking;
using UniRx.Async;
#if UNITY_EDITOR
using UnityEditor;
#endif
using System;
namespace MasterLoader
{
/// <summary>
/// スプレッドシートからマスタを取得して自動生成したScriptableObjectに流し込むクラス
/// </summary>
public class MasterLoader : Editor
{
/// <summary>
/// マスタのURLは不変なのでconstにして編集できないようにしておく
/// URLはスプレッドシートのコードを公開したときに表示されるものを入れる。
/// </summary>
private const string url =
"https://script.google.com/macros/s/hogehogehogehoge/exec";
/// <summary>
/// doGet時の独自変数
/// 読み込むシートの判断用
/// </summary>
private const string sheetName = "?sheetName=";
/// <summary>
/// 用意するマスタのシート名
/// </summary>
public const string tempMaster = "TempMaster";
/// <summary>
/// スプレッドシートからマスタを取得する
/// </summary>
/// <param name="masterName">取得するマスタ名</param>
/// <returns>エラー時の警告またはロードしたマスタ名</returns>
public static async UniTask<string> LoadMaster(string masterName)
{
// シート名を追加パラメータとしてAPIを叩くURLを決定。
// GASでは "exec"のあとに "?" をつけて "hoge=fuga" などと追記することでGETにパラメータを付与できる
var url = $"{MasterLoader.url}{sheetName}{masterName}";
var result = await GetMasterAsync(url);
var assetPath = $"{path}{masterName}.asset";
try
{
switch (masterName)
{
case tempMaster:
Debug.Log(result);
var tempList = JsonHelper.ListFromJson<Temp>(result);
break;
}
return masterName;
}
catch (Exception e)
{
Debug.LogException(e);
return e.Message;
}
}
/// <summary>
/// UnityWebRequest を async/await で待ち受ける
/// </summary>
/// <param name="url"></param>
/// <returns>受け取った生データ</returns>
private static async UniTask<string> GetMasterAsync(string url)
{
var request = UnityWebRequest.Get(url);
EditorUtility.DisplayCancelableProgressBar("マスタ更新中...", "", 0.0f);
await request.SendWebRequest();
EditorUtility.ClearProgressBar();
if (request.isHttpError || request.isNetworkError)
{
throw new Exception(request.error);
}
return request.downloadHandler.text;
}
}
}
これでスプレッドシートの情報をUnity側で取得できるようになりました。
Editorを継承しているのは、この後Editor拡張したいからです。
ObservableWWW.GetWWW(url)についてですが、
これはUniRxで用意されている関数です。
UniRxは、Monobehaviourの機能を強力に拡張してくれますが、Monobehaviourを継承しなくてもコルーチンのような振る舞いをしてくれるという特長もあります。
このコードでは、その特長を生かし、Editor拡張コードでもコルーチンのような振る舞いを行っています。
すなわち、スプレッドシートのマスタにアクセスし、値が返るまで待ち、値が返ってきたら var Json に注入しています。
==
2020/4/27追記:
上記の解説は間違ってはいないのですが、ObservableWWWはもう使えなくなったので、代わりにUniTaskを使用して、awaitしています。解説は割愛。
さて、次はこの値をUnity上でいつでも参照できるように保持します。
ScriptableObjectを用いてマスタをUnity内に保持する
本稿では、マスタの値の保持にScriptableObjectを採用しました。
staticな値として保持しやすいですし、エディタ拡張によってコードから直接作成できたりと、取り回しがしやすいからです。
この辺は好みだと思いますので、これ以降はお好きなやり方で実装してしまってもいいと思います。
さて、ScriptableObjectで実装する場合は、まずはScriptableObjectにするためのクラスを用意します。クラス名は、マスタのシート名と同じにします。
using UnityEngine;
public class TempMaster : ScriptableObject
{
public List<Temp> TempList;
public SetTempList(List<Temp> temp)
{
#if UNITY_EDITOR
TempList = temp;
EditorUtility.SetDirty(this);
AssetDatabase.SaveAssets();
#endif
}
}
先ほどのMasterLoader.csも、以下のように追記します。
```csharp:MasterLoader.cs
using UnityEngine;
using UnityEngine.Networking;
using UniRx.Async;
#if UNITY_EDITOR
using UnityEditor;
#endif
using System;
namespace MasterLoader
{
/// <summary>
/// スプレッドシートからマスタを取得して自動生成したScriptableObjectに流し込むクラス
/// </summary>
public class MasterLoader : Editor
{
/// <summary>
/// マスタのURLは不変なのでconstにして編集できないようにしておく
/// URLはスプレッドシートのコードを公開したときに表示されるものを入れる。
/// </summary>
private const string url =
"https://script.google.com/macros/s/hogehogehogehoge/exec";
/// <summary>
/// doGet時の独自変数
/// 読み込むシートの判断用
/// </summary>
private const string sheetName = "?sheetName=";
/// <summary>
/// マスタを配置するパス。ResoucesディレクトリとMasterディレクトリをあらかじめ作成しておく
/// ディレクトリのパスはほんの一例。
/// </summary>
private const string path = "Assets/MasterLoader/Resources/Master/";
/// <summary>
/// 用意するマスタのシート名
/// </summary>
public const string tempMaster = "TempMaster";
/// <summary>
/// スプレッドシートからマスタを取得する
/// </summary>
/// <param name="masterName">取得するマスタ名</param>
/// <returns>エラー時の警告またはロードしたマスタ名</returns>
public static async UniTask<string> LoadMaster(string masterName)
{
// シート名を追加パラメータとしてAPIを叩くURLを決定。
// GASでは "exec"のあとに "?" をつけて "hoge=fuga" などと追記することでGETにパラメータを付与できる
var url = $"{MasterLoader.url}{sheetName}{masterName}";
var result = await GetMasterAsync(url);
var assetPath = $"{path}{masterName}.asset";
try
{
switch (masterName)
{
case tempMaster:
Debug.Log(result);
var tempList = JsonHelper.ListFromJson<Temp>(result);
if (tempList != null)
{
// すでにマスタが作成されているかを確認するために取得してみる
var master = AssetDatabase.LoadAssetAtPath<TempMaster>(assetPath);
# if UNITY_EDITOR
if (master == null)
{
master = CreateInstance<TempMaster>();
AssetDatabase.CreateAsset(master, assetPath);
EditorUtility.SetDirty(master);
}
#endif
master.SetTempList(tempList);
}
break;
}
Debug.Log($"{masterName} Loaded");
return masterName;
}
catch (Exception e)
{
Debug.LogException(e);
return e.Message;
}
}
/// <summary>
/// UnityWebRequest を async/await で待ち受ける
/// </summary>
/// <param name="url"></param>
/// <returns>受け取った生データ</returns>
private static async UniTask<string> GetMasterAsync(string url)
{
var request = UnityWebRequest.Get(url);
EditorUtility.DisplayCancelableProgressBar("マスタ更新中...", "", 0.0f);
await request.SendWebRequest();
EditorUtility.ClearProgressBar();
if (request.isHttpError || request.isNetworkError)
{
throw new Exception(request.error);
}
return request.downloadHandler.text;
}
}
}
これでようやくつなぎ込みが完了しました。
しかし、まだ肝心の LoadMaster(string masterName) を呼び出すイベントを作っていません。
本稿では、エディタ拡張によって、いつでもUnityEditorからボタン一つでマスタを更新できるようにします。
だからEditorを継承しておく必要があったんですね。
いつでもUnityEditorでマスタ更新できるボタンを作る
Editor拡張を行うために以下コードを作成します。
using UnityEditor;
namespace MasterLoader
{
public class MasterLoadWindow : EditorWindow
{
[MenuItem("Window/MasterLoader")]
static void Open()
{
GetWindow<MasterLoadWindow>();
}
void OnGUI()
{
EditorGUILayout.Space();
if (GUILayout.Button("Temp", GUILayout.Width(80.0f)))
{
// Tempボタンを押したときにTempマスタを更新する
MasterLoader.LoadMaster(MasterLoader.tempMaster);
}
EditorGUILayout.Space();
}
}
}
これにより、UnityEditorのツールバーのWindow内に「MasterLoader」という項目が追加されます。
項目をクリックすると、ボタンの置かれたウィンドウが現れます。そのボタンを押すと、
MasterLoader.LoadMasterが実行され、マスタの更新が行われます。
これでマスタ管理の全ての準備が整いました!
おまけ:もっと便利に運用しよう
さて、上記までの内容により、ボタン一つでスプレッドシート内の任意のマスタを更新するシステムが完成しました。
しかし、たぶんもっと楽になれると思います。
このシステムの問題点は、スプレッドシートの更新がUnity側からは通知されないということです。
うっかりマスタ更新を忘れて、古いバージョンのマスタを使い続けてしまった…などのリスクが考えられます。
とはいえ、スプレッドシートの更新をUnity側に通知しようとするのは面倒くさそうです。楽したいです。
ということで、定期的に自動でUnity側でマスタ更新するという方法を採りました。
たぶんこれが一番楽だと思います。
まずは、MasterLoader.csのclassのところに[InitializeOnLoad]属性を追加します。
[InitializeOnLoad]
public class MasterLoader : Editor
これにより、Unityエディタ起動時にマスタ更新を行います。
さらに、MasterLoader.csにまたまた以下関数を追記します
/// <summary>
/// アプリ起動時に自動でScriptableObjectを更新する
/// </summary>
static MasterLoader()
{
LoadMaster(tempMaster);
// ゲームプレビュー終了時に自動でScriptableObjectを更新する
EditorApplication.playModeStateChanged += (state) =>
{
if (state == PlayModeStateChange.EnteredEditMode)
{
// スプレッドシートのマスタのシート名を引数に入れる
await LoadMaster(tempMaster);
// TODO: 今後マスターが増えるたびにここに追記していくと幸せになれる
}
};
}
書いてある通りですが、EditorApplicationを利用することによって、UnityEditor内の様々なイベントに処理を滑り込ませることができます。
本稿では、エディタ上でゲームを再生し終わったときにLoadMasterを実行するようにしました。
これなら頻繁に更新されます。
ゲーム実行開始時にしなかった理由は、マスタの更新は非同期処理なので、マスタの更新が未完了のままプレビューが開始してしまうことを回避するためです。(こういったケースがあるかはわかりませんが、マスタが大きくなるとありえそうです)
まとめ
GASとエディタ拡張を活用することにより、個人的には満足のいくマスタ管理システムが完成しました。
導入には何かと大変かと思いますが、その後レベルデザインなどでちょくちょく値をいじる際にはかなり楽なものになったかと思います。
新たなマスタを作る場合は、以下の手順を踏みます。
- スプレッドシートに新しいマスタのシートを用意する
- シート名をそのマスタの名前にする
- Unity側で、マスタの1単位分のクラスとScriptableObject用のクラスを作る(例:Enemy.csとEnemyMaster.cs)
- MasterLoader.csに、新たなマスタに対応する処理を追加する
- MasterWindow.csに、新たなマスタに対応するボタン処理を追加する
- (optional)MasterLoader.csに、エディタプレビュー時に自動実行する処理を追加する
…まだ楽できそうですね。のんびりアプデしていこうと思います。
さて、満足いくシステムではあるのですが、長所短所はあります。
最後にそれらをまとめて本稿を締めようと思います。
MasterLoaderでできること
- スプレッドシートを用いたマスタ管理
- ボタン一つの簡単更新
- エディタの再生に紐づいた自動更新
MasterLoaderでできないこと
- int, string, bool, float, double 以外の変数の利用
MasterLoaderの比較的苦手なこと
- 膨大な量のデータのやり取り(APIを叩く回数を最小限にしてますが、数万行のデータとかだとさすがに重いかもです)
- 膨大な種類のマスタのやり取り(1マスタごとに通信処理をやり直しているため)(10件くらいならふつうにやっていけます)