2020/11/09
この記事は「Unity 2 Advent Calendar 2016」の15日目です。
今回のお題は「Unity から YAML で定義したメッセージやパラメータを読み込む」です!
Unity 上で直接設定せずに外部の YAML ファイルに定義し、一元管理するのが目的です。
元々は仕事で「EC-CUBE3」を使う機会があり、そこで YAML が使われていたことが知ったきっかけです。
そのため、今回紹介する実装も EC-CUBE3 と似たようなものになっています。
あちらは Symfony2 というフレームワーク内でパースしており、DIコンテナ越しにキーをメッセージに変換できます。
この手のフォーマットは他に「JSON」とか「XML」とか「ini」とかありますが、私的には YAML が一番取っつきやすいです。
書き方が何種類かあることや、インデントにタブが使えない等の癖はありますが、慣れてしまえばサクサク書けます。
このあたりは好みの問題かもしれません。
ちなみに YAML は「ヤムル」or「ヤメル」と読むらしいですが、自分は前者派です。
最近は ISO も人によって「アイエスオー」だったり「イソー」だったりすることに衝撃を覚えたりしましたが・・・。
目次
YAMLファイル読み込み用クラスの作成
YAML ファイルの読み込みに関しては、以前投稿した記事で紹介しております。
基本は「YamlDotNet for Unity」を使用して YAML ファイルを読み込み、キーから対応する値を取得します。
単に列挙されたキー/値を取得するだけであれば YamlDotNet のドキュメント通りでいけますが、ネストさせた場合に特定の値をピンポイントで取得することが出来ませんでした。
EC-CUBE3 っぽく取得できるようにユーティリティを作成していたりしますが、取得する度に YAML を毎回ロードしていたり、所々ハードコーディング気味でイケてないです。
そんなわけで、専用に管理するクラスを作成し、インスタンス化して処理させてみます。
using UnityEngine; using System.IO; using YamlDotNet; using YamlDotNet.RepresentationModel; public class YamlManager { public YamlStream messageYaml; private static YamlManager yamlManager; // YAMLファイル保存パス private const string ymlPath = "Assets/Yaml/"; private YamlManager() { // メッセージ用YAMLのロード StreamReader inputFile = new StreamReader(ymlPath + "message.yml", System.Text.Encoding.UTF8); messageYaml = new YamlStream(); messageYaml.Load(inputFile); } /// <summary> /// 指定されたキーを元にメッセージを取得する /// </summary> public string getMessageValue(string key) { string message = getValue(key, messageYaml); return message; } /// <summary> /// 指定されたキーを元にYAMLから値を取得する /// </summary> private string getValue(string key, YamlStream file) { // キーをドットで分割 string[] keys = key.Split('.'); // キーの配列数(=ネストレベル)取得 int keyCount = keys.Length; // ルートのマッピング取得 YamlMappingNode mapping = (YamlMappingNode)messageYaml.Documents[0].RootNode; YamlScalarNode node = null; for (int i = 0; i < keyCount; i++) { // キー配列が最後の要素になった場合は ScalarNode を取得 if (i == keyCount - 1) { node = (YamlScalarNode)mapping.Children[new YamlScalarNode(keys[i])]; } else { // キーを元に1つ深いネストのマッピングを取得 mapping = (YamlMappingNode)mapping.Children[new YamlScalarNode(keys[i])]; } } return node.ToString(); } /// <summary> /// インスタンスを取得する /// </summary> public static YamlManager getInstance() { if (yamlManager == null) { yamlManager = new YamlManager(); } return yamlManager; } }
シーン上のオブジェクトとして用意する必要はないと思うので、MonoBehaviour は継承しません。
またインスタンスは1つあれば十分なため、Singleton パターンを適用してみました。
getValue() 内で渡されたキーを.(ドット)毎にパースし、ネストした YAML ファイル のキーを参照→取得を繰り返します。
キーが最後まで取得し終えたら、そのノードの値を ToString() で出力します。
見直してみると、キーを間違えたり定義していなかった場合のアフターケアがないのが微妙です。
YamlDotNet.Core に YamlException があるので、例外として投げちゃうのが手っ取り早そうです。
using System.IO; using System.Collections.Generic; using UnityEngine; using YamlDotNet.Core; using YamlDotNet.RepresentationModel; public class YamlManager { public YamlStream messageYaml; private static YamlManager yamlManager; // YAMLファイル保存パス private const string ymlPath = "Assets/Yaml/"; private YamlManager() { // メッセージ用YAMLのロード StreamReader inputFile = new StreamReader(ymlPath + "message.yml", System.Text.Encoding.UTF8); messageYaml = new YamlStream(); messageYaml.Load(inputFile); } /// <summary> /// 指定されたキーを元にメッセージを取得する /// </summary> public string getMessageValue(string key) { string message = null; try { message = getValue(key, messageYaml); } catch (YamlException e) { message = "メッセージ設定エラー"; Debug.LogError(e.ToString()); return message; } return message; } /// <summary> /// 指定されたキーを元にYAMLから値を取得する /// </summary> private string getValue(string key, YamlStream file) { // キーをドットで分割 string[] keys = key.Split('.'); // キーの配列数(=ネストレベル)取得 int keyCount = keys.Length; // ルートのマッピング取得 YamlMappingNode mapping = (YamlMappingNode)messageYaml.Documents[0].RootNode; YamlScalarNode node = null; YamlNode outNode; for (int i = 0; i < keyCount; i++) { YamlScalarNode currentNode = new YamlScalarNode(keys[i]); // キーから値を取得できない場合はエラー if (!mapping.Children.TryGetValue(currentNode, out outNode)) { throw new YamlException(); } // キー配列が最後の要素になった場合は ScalarNode を取得 if (i == keyCount - 1) { node = (YamlScalarNode)outNode; } else { // キーを元に1つ深いネストのマッピングを取得 if (outNode.GetType() != typeof(YamlMappingNode)) { throw new YamlException(); } mapping = (YamlMappingNode)outNode; } } return node.ToString(); } /// <summary> /// インスタンスを取得する /// </summary> public static YamlManager getInstance() { if (yamlManager == null) { yamlManager = new YamlManager(); } return yamlManager; } }
getValue() と getMessageValue() を修正してみました!
TryGetValue() で取得できない場合と、1つ下のマッピング取得時に typeof で判定した場合で投げています。
例外で処理が止まってしまうのはまずいので、getMessageValue() 内で受け止めて仮のメッセージを表示します。
メッセージやラベルをYAMLで定義する
例えば、「ステージ○○の4つ目の看板のメッセージを~」なんてことがあった場合、「シーンを開く→ヒエラルキーから看板選択→インスペクターから修正」と考えると、げんなりしそうになりますね。
これを YAML のキーを設定する形にしておき、実行時に対応するメッセージを取得すれば、YAML ファイルを開いて直すだけになります。
パス Assets/Yaml/ の下に message.yml というファイルを作成し、メッセージを定義していきます。
# 操作 control: jump_input: スペースキーを押すとジャンプします throw_action: |- アクションキーを押すと物を持ち上げます 再度押すと投げます # ステージ stage: break_block: ブロックは攻撃することで破壊できます trampoline: タイミングよくボタン入力で大ジャンプ! climbWall: 壁に近づいてボタン入力で更にジャンプできます # システムメッセージ gameover: continue: スタート地点又は直前のチェックポイントから再開します end: タイトル画面に戻ります
YAML の文法については割愛しますが、
・セミコロン後の半角スペースは必須
・#以降はコメント扱い
・ネストさせる場合はスペース(タブはNG)
あたりを抑えておけば何とかなると思われます。
取得する時は YamlManager をインスタンス化して getMessage() を呼びます。
Start() あたりで取ってしまうのがベターでしょうか。
void Start () { string message = YamlManager.getInstance().getMessageValue("stage.trampoline"); Debug.Log(message); }
以下はキーに「stage.trampoline」を設定した場合の出力です。
後は message を UI 等に出力すればOKです。
(出力方法は記事の趣旨から逸れるので割愛します)
読み込むYAMLファイルを切り替える
もし英語のゲームをやっているとき、「SELECT LANGUAGE」なんてオプション項目があったらステキですよね!
今日の家庭用ゲームでも、「日本語」「英語」を切り替えられるゲームはそれなりに見受けられます。
キーを変えずに値だけを変えた YAML ファイルを複数用意すれば、多言語への対応ができたりします。
ある程度命名規則を決めた方がやりやすいので、ここも EC-CUBE3 っぽくいきます。
先程の message.yml は日本語なので message.ja.yml に修正し、新しく作るファイルは message.en.yml にします。
しかし自分は英語が全くダメなので、最近パワーアップしたとウワサの「Google翻訳」に変換してもらいます。
# 操作 control: jump_input: Press space key to jump. throw_action: |- Press the action key to lift the object. Press again to throw. # ステージ stage: break_block: Blocks can be destroyed by attacking. trampoline: Timely well with big button jump! climbWall: You can jump further by button input as you approach the wall. # システムメッセージ gameover: continue: Restart from the start point or the previous checkpoint. end: Return to title screen.
何かエキサイト翻訳っぽい直訳気味なものもありますが、何となく伝わればそれでいいです(ぁ
次に先程の YamlManager を修正します。
ゲーム内で動的に切り替えることを考慮し、YAML ファイルをロードする処理を関数化しておきます。
以下、YamlManager のコンストラクタとロード用関数のみ記載します。
private YamlManager() { // メッセージ用YAMLのロード loadMessageYaml(); } /// <summary> /// メッセージ用YAMLファイルをロードする /// </summary> public void loadMessageYaml(string lang = "ja") { StreamReader inputFile = new StreamReader(ymlPath + "message." + lang + ".yml", System.Text.Encoding.UTF8); messageYaml = new YamlStream(); messageYaml.Load(inputFile); }
デフォルトは ja としてロードし、必要に応じて loadMessageYaml() で YAML ファイルを切り替えます。
void Start() { YamlManager yamlManager = YamlManager .getInstance(); yamlManager.loadMessageYaml("en"); string message = yamlManager.getMessageValue("stage.trampoline"); Debug.Log(message); }
キーは先程と同じく「stage.trampoline」です。
言語を2文字で指定しなければならないのが微妙ですが、とりあえず読込先を変えることができます。
後は message を UI で(ry
パラメータをYAMLで定義する
完全固定な値はC#の定数で定義しますが、ある程度固定だけど変化する値は YAML で管理してしまいます。
例としては、音量や言語などのオプション設定的なものが挙げられます。
キーコンフィグも YAML で扱いたかったのですが、InputManager を動的に変えるのはかなり面倒なようなのでパスしました。
メッセージ定義の時は文字列しかなかったので問題ありませんでしたが、パラメータとなると数値が入ってくることも考慮しなければなりません。
C# では単一の型しか返すことが出来ないわけですが、返り値を string 固定にしている時点で数値を扱うのは絶望的です。
こういった場合は自分で型や構造体を定義して返すのが良いらしいので、適当に返却用のクラス ReturnYamlNode を作成しています。
それに合わせて string で処理していた部分も ReturnYamlNode で返すように修正しました。
最終的な YamlManager が以下になります!
using System.IO; using System.Collections.Generic; using UnityEngine; using YamlDotNet.Core; using YamlDotNet.RepresentationModel; using YamlDotNet.Serialization; public class YamlManager { public YamlStream messageYaml; public YamlStream configYaml; private static YamlManager yamlManager; // YAMLファイル保存パス private const string ymlPath = "Assets/Yaml/"; private YamlManager() { // メッセージ用YAMLのロード loadMessageYaml(); // 設定用YAMLのロード loadConfigYaml(); } /// <summary> /// 指定されたキーを元にメッセージを取得する /// </summary> public string getMessageValue(string key) { ReturnYamlNode messageNode = null; try { messageNode = getValue(key, messageYaml); } catch (YamlException e) { string message = "メッセージ設定エラー"; Debug.LogError(e.ToString()); return message; } return messageNode.value; } /// <summary> /// メッセージ用YAMLファイルをロードする /// </summary> public void loadMessageYaml(string lang = "ja") { StreamReader inputFile = new StreamReader(ymlPath + "message." + lang + ".yml", System.Text.Encoding.UTF8); messageYaml = new YamlStream(); messageYaml.Load(inputFile); } /// <summary> /// 設定用YAMLファイルをロードする /// </summary> public void loadConfigYaml() { StreamReader inputFile = new StreamReader(ymlPath + "config.yml", System.Text.Encoding.UTF8); configYaml = new YamlStream(); configYaml.Load(inputFile); } /// <summary> /// 指定したキーを元に設定ファイルの値を取得する /// </summary> public ReturnYamlNode getConfig(string key) { return getValue(key, configYaml); } /// <summary> /// 指定されたキーを元にYAMLから値を取得する /// </summary> private ReturnYamlNode getValue(string key, YamlStream file) { // キーをドットで分割 string[] keys = key.Split('.'); // キーの配列数(=ネストレベル)取得 int keyCount = keys.Length; // ルートのマッピング取得 YamlMappingNode mapping = (YamlMappingNode)file.Documents[0].RootNode; YamlScalarNode node = null; YamlNode outNode; for (int i = 0; i < keyCount; i++) { YamlScalarNode currentNode = new YamlScalarNode(keys[i]); // キーから値を取得できない場合はエラー if (!mapping.Children.TryGetValue(currentNode, out outNode)) { throw new YamlException(); } // キー配列が最後の要素になった場合は ScalarNode を取得 if (i == keyCount - 1) { node = (YamlScalarNode)outNode; } else { // キーを元に1つ深いネストのマッピングを取得 if (outNode.GetType() != typeof(YamlMappingNode)) { throw new YamlException(); } mapping = (YamlMappingNode)outNode; } } ReturnYamlNode returnNode = new ReturnYamlNode(key, node.ToString()); return returnNode; } /// <summary> /// インスタンスを取得する /// </summary> public static YamlManager getInstance() { if (yamlManager == null) { yamlManager = new YamlManager(); } return yamlManager; } } public class ReturnYamlNode { public string key; public string value; public ReturnYamlNode(string key, string value) { this.key = key; this.value = value; } public int getIntValue() { int intValue; if (int.TryParse(value, out intValue)) { return intValue; } throw new UnityException(); } }
そもそも取得時に数値だろうが文字列だろうが ToString() し始めるあたり、純正の PHPer であることは隠しようもありません。
PHP でコーディングした後に C# や Java をやったりすると、型のガチガチっぷりにビックリしたりします。
まあその型の緩さのせいで PHP も面倒なことがありますが・・・。
取得する場合はインタタンス化して getConfig() を使います。
数値が欲しい場合は更に getIntValue() で取得します。
void Start () { YamlManager yamlManager = YamlManager.getInstance(); int bgm_volume = yamlManager.getConfig("bgm_volume").getIntValue(); Debug.Log(bgm_volume); }
ただし、最大HPや攻撃力などの「勝手に変えられると困るもの」は YAML で管理すべきではありません。
YAML は所詮テキストファイルなので、簡単に書き換えられてしまいます。
また、所謂バリデーションチェックは必ず行う必要があります。
ということを考えると、意外とパラメータを外部化するのは面倒な気もします。
ビルド後のYAMLファイル保存先について
Unity 上で開発する分には /Assets/Yaml/ で問題ありませんが、ビルドして書き出すと話が違ってきます。
というのも、Assets 以下のデータは全てまとめられてしまうため、パスで指定しても参照できなくなってしまいます。
そのため、プロジェクト直下に専用のディレクトリを作成し、そこに保存するようにします。
フィールドの ymlPath と Start() 内を修正します。
// YAMLファイル保存パス private const string ymlPath = "Settings/"; void Start () { string basePath = System.IO.Directory.GetCurrentDirectory(); System.IO.Directory.CreateDirectory(basePath + "/Settings"); }
これでビルド後も参照できるようになりました。
ただ、Unity エディタ上からアクセスできなくなるので、開発時は不便な感じです。
このあたりは割り切るしかないのかもしれません。
まとめ
そんなわけで、Unity 上で YAML をあれこれしてみました!
外部ファイルに書いている分、Unity 上で直接設定するより手間ですが、後々の修正や管理がしやすいです。
反面、入力時にエディタ側で補完してくれないので、(定数よりも)キーの指定を間違えやすいです。
余談ですが、Qiita のアドベントカレンダーとして投稿したのは今年が初めてだったりします。
他の投稿を見ると記事に自信がなくなってきますが、来年もネタがあったら書いていきたいです。
明日は @WheetTweet さんです!