マスタデータを作る(2)(Unityメモ)
記事を分割しました。
前回の記事はこちらです。マスタデータを配置する前提となる、プロジェクトのフォルダ構成についてです。
データのインポート
Addressablesを使うと、マスタデータを用途ごとにサブフォルダに分割しつつ、アセットを一括ロードできます。しかしその場合でも、アセットファイルを1枚ずつ用意して各フォルダに置く手間はそのままです。コンテンツを少量ずつ追加する分にはいいのですが、開発中にまとめて配置したい場合は手作業が多すぎます。そこでCSVファイルなどを使ってデータをインポートしたくなります。
Excelファイルを使ったインポートは既存のプラグインがあります。
これらのプラグインはExcelデータを1個のScriptableObjectの中のリストとしてインポートします。しかしこの方法は同種のデータが1個のリストにまとまっているので、データ追加のたびにリスト全体が更新の対象となってしまいます。欲しいのはデータを1件ずつアセットファイルに分割できるインポータですが、そのようなプラグインは見つかりませんでした。
データを1件ずつアセットに分割するインポータを自作した
なので自作できるか試してみました。ひとまず、キャラクタの能力値データを分割保存できるインポータを試作しました。
・CSVファイルを読み込む。Excel対応はしない
・キャラクタデータを1件ずつアセットインスタンス(.assetファイル)に分割する
・キャラクタのアセットインスタンスを対応するキャラクタのフォルダに分散して保存する
このインポータが作れるかどうかわからなかったのでマスタデータの保存方式を決めるまでに時間がかかったのですが、腹をくくってインポータに取り掛かってみたら案外あっさり作れました。
CSV読み込み
よく使われているCsvHelperを使うことにしました。
最初は .NET純正のTextFieldParserを使おうとしたのですが、依存するVisual Basic.dllが読み込めなかったのであきらめました。
エディタGUI
UI Toolkitを試しに使ってみました。HTML的に見えるUXMLに対して、イベントハンドラをHTML的な onClick="" で登録できないので少し戸惑いました。UXMLはあくまでUnity UIの生成を代替するもので、UIにイベントハンドラを登録する部分はC#で書くことになります。対象となるコンポーネントを取得する際には、Query()メソッドを使ってコンポーネント名で検索する、というのがUI Toolkitの作法のようです。
コード
ざっくり書きました。
using System.Collections.Generic;
using UnityEngine;
/**
* Prototypeデータ(ユニット定義)のアセット。キャラクタごとに作成する。
* 実行時のユニットデータ(SceneUnit他)はこのアセットをロードして初期値を設定する。
*/
namespace MyRepo
{
using PrototypeID = System.Int32; //32bitを想定
using AbilityID = System.Int32; //32bitを想定
using DecisionID = System.Int32; //32bitを想定
[CreateAssetMenu(menuName = "キャラクタ/Prototype")]
public class PrototypeAsset : ScriptableObject
{
public PrototypeID Id = 0;
public string Name = ""; //表示用の名前
public string Asset = ""; //アセット名
//能力値
public int Attack = 0;
/* 以下略 */
//スキル・装備・アイテム
public List<AbilityID> AbilityIDList = new List<AbilityID>();
//行動決定関数
public DecisionID DecisionID = 0;
//与えたPrototypeで値を上書きする
public void Overwrite(PrototypeAsset asset)
{
Id = asset.Id;
Name = asset.Name;
Asset = asset.Asset;
Attack = asset.Attack;
/* 以下略 */
AbilityIDList = asset.AbilityIDList;
DecisionID = asset.DecisionID;
}
}
}
/**
* CSV Separating Importer
* Prototypeデータ(ユニット定義)をCSVファイルから読み込み、キャラクタごとにアセットファイルを分割して各フォルダへ保存する。
*
*
* CsvHelper.dllをnugetパッケージから取り出し、Asset/Editorフォルダにコピーすること。
* nugetリポジトリ https://www.nuget.org/packages/CsvHelper/
*
* 参考 https://qiita.com/BobZombie/items/f804809b7b50620802cb
*
*/
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using CsvHelper;
using CsvHelper.Configuration;
using CsvHelper.TypeConversion;
using System.IO;
using System.Collections.Generic;
using System.Linq;
using System.Globalization;
using System.Text;
using PrototypeID = System.Int32; //32bitを想定
using AbilityID = System.Int32; //32bitを想定
using DecisionID = System.Int32; //32bitを想定
//CSVから読み込むPrototypeデータ(ユニット定義)
public class PrototypeCSVRecord
{
public PrototypeID Id { get; set; } = 0;
public string Name { get; set; } = ""; //表示用の名前
public string Asset { get; set; } = ""; //アセット名
//能力値
public int Attack { get; set; } = 0;
/* 以下略 */
//スキル・装備・アイテム
[CsvHelper.Configuration.Attributes.TypeConverter(typeof(IntArrayConverter))]
public List<AbilityID> AbilityIDList { get; set; } = new List<AbilityID>();
//行動決定関数
public DecisionID DecisionID { get; set; } = 0;
//CSVから読んだデータから、アセットインスタンスを生成
public MyRepo.PrototypeAsset CreateInstance()
{
MyRepo.PrototypeAsset asset = ScriptableObject.CreateInstance("PrototypeAsset") as MyRepo.PrototypeAsset;
asset.Id = Id;
asset.Name = Name;
asset.Asset = Asset;
asset.Attack = Attack;
/* 以下略 */
asset.AbilityIDList = AbilityIDList;
asset.DecisionID = DecisionID;
return asset;
}
}
// CsvHelperで呼び出される。"1, 2, 3, 4" のような文字列を、IDの配列に変換
public class IntArrayConverter: CsvHelper.TypeConversion.DefaultTypeConverter
{
public override object ConvertFromString(string text, IReaderRow row, MemberMapData memberMapData)
{
return text.Split(",").Select(x => int.Parse(x.Trim())).ToList();
}
public override string ConvertToString(object value, IWriterRow row, MemberMapData memberMapData)
{
return string.Join(",", value as List<int>);
}
}
//GUI定義と動作
public class CSVSeparatingImporter : EditorWindow
{
[SerializeField] private VisualTreeAsset m_VisualTreeAsset = default;
//{0} にキャラクタのアセット名が入る
private string AssetPathTemplate = "Assets/Content/Character/{0}/prototype.asset";
private string AddressTemplate = "{0}";
public string CreateAssetPath(Milkeir.Repository.PrototypeAsset prototype)
{
return string.Format(AssetPathTemplate, prototype.Asset);
}
public string CreateAddress(Milkeir.Repository.PrototypeAsset prototype)
{
return string.Format(AddressTemplate, prototype.Asset);
}
private string FilePath = "";
[MenuItem("Window/UI Toolkit/CSVSeparatingImporter")]
public static void ShowExample()
{
CSVSeparatingImporter wnd = GetWindow<CSVSeparatingImporter>();
wnd.titleContent = new GUIContent("CSVSeparatingImporter");
}
public void CreateGUI()
{
// Each editor window contains a root VisualElement object
VisualElement root = rootVisualElement;
// Instantiate UXML
VisualElement labelFromUXML = m_VisualTreeAsset.Instantiate();
root.Add(labelFromUXML);
// ボタンGUIにイベントハンドラを登録
var FileSelectButton = root.Query<Button>("FileSelectButton").First();
FileSelectButton.RegisterCallback<ClickEvent>(OnSelectFile);
var ImportButton = root.Query<Button>("ImportButton").First();
ImportButton.RegisterCallback<ClickEvent>(OnImport);
}
public void OnSelectFile(ClickEvent ev)
{
FilePath = EditorUtility.OpenFilePanelWithFilters("インポートするファイルを選択", "", new string[] { "CSV File", "csv" });
var FilePathLabel = rootVisualElement.Query<Label>("FilePathLabel").First();
FilePathLabel.text = FilePath;
}
public void OnImport(ClickEvent ev)
{
//ファイルパスを確認してロード
if (FilePath.Length <= 0)
{
EditorUtility.DisplayDialog("エラー", "ファイルが選択されていません", "OK");
return;
}
if(!File.Exists(FilePath))
{
EditorUtility.DisplayDialog("エラー", "ファイルがありません", "OK");
return;
}
//CsvReaderの設定
var config = new CsvConfiguration(new System.Globalization.CultureInfo("ja-JP", true))
{
HasHeaderRecord = true,
Delimiter = ",",
NewLine = "\r\n",
IgnoreBlankLines = true,
Encoding = Encoding.UTF8,
AllowComments = true,
Comment = '#',
DetectColumnCountChanges = true,
TrimOptions = TrimOptions.Trim,
};
using ( var reader = new StreamReader(FilePath, Encoding.UTF8) )
using ( var parser = new CsvReader(reader, config) )
{
var records = parser.GetRecords<PrototypeCSVRecord>();
foreach (var record in records)
{
// 参照:https://forum.unity.com/threads/creating-addressable-asset-from-editor-script.987717/
//アセットからインスタンス生成
MyRepo.PrototypeAsset prototype = record.CreateInstance();
//アセットインスタンスをAddressablesに追加
string assetPath = CreateAssetPath(prototype);
Debug.Log(assetPath);
//設定を読み込む
AddressableAssetSettings setting = AddressableAssetSettingsDefaultObject.Settings;
//Addressablesが登録済みかどうかを検索
string assetGUID = AssetDatabase.AssetPathToGUID(assetPath);
AddressableAssetEntry assetEntry = setting.FindAssetEntry(assetGUID);
if (assetEntry is null)
{
//Addressablesが未登録ならEntryを登録
//アセットを新規作成
AssetDatabase.CreateAsset(prototype, assetPath);
//GUIDを得る
assetPath = AssetDatabase.GetAssetPath(prototype);
assetGUID = AssetDatabase.AssetPathToGUID(assetPath);
//副作用としてAddressableAssetEntryが登録される
var assetRef = setting.CreateAssetReference(assetGUID);
//登録後にEntryを取得
assetEntry = setting.FindAssetEntry(assetGUID);
//アドレスを設定
assetEntry.address = CreateAddress(prototype);
//ラベルを設定
assetEntry.SetLabel("Character", true);
}
else
{
//既存のアセットの値を変更
//TargetAssetは読み取り専用のため、代入による書き換えが不可
MyRepo.PrototypeAsset prev_asset = assetEntry.TargetAsset as MyRepo.PrototypeAsset;
prev_asset.Overwrite(prototype);
//AssetDatabase.SaveAssets()の対象とする
EditorUtility.SetDirty(prev_asset);
}
}
//セーブ
AssetDatabase.SaveAssets();
}
EditorUtility.DisplayDialog("インポート完了", "インポートが完了しました", "OK");
}
}