ゴマちゃんフロンティアV2へようこそ!

本ブログはゲーム統合開発環境「Unity」を使用したゲーム開発の日記です!
ときどきイラストを描いたり、写真を交えた日記も書いています!

【開発環境】

OS Windows7 SP1 64bit
CPU Intel(R) Core(TM) i7-4770 CPU @ 3.40GHz
メモリ 12GB
グラボ GeForce GTX 645
使用ソフト Microsoft Visual Studio(C#)
Blender
GIMP

旧ブログの記事について

本ブログは2015年9月にFC2ブログから移転しております。
過去記事につきまして、「開発方針が変わっている」「Unityの仕様が現状と異なる」等、そのままでは不適切と判断しました。
なので、知識・技術面で重要そうな記事のみをピックアップし、再編集して新ブログに追加している段階です。
Googleのインデックスの関係上、検索内容とブログ内容に差異が発生する可能性がありますので、ご了承ください。
また「前のブログのこの記事読みたい!」というものがありましたら、ご連絡頂ければ追加致します!

お問い合わせフォームはこちら

【Twitter】ランパの台詞botを作成してみる その1

【2017/01/16】
サンプルソースを修正しました。

というわけで、今回はタイトル通り、Twitterのbot作成に挑戦してみます!
作るのは伝説のスタフィーに登場するキャラクター「ランパ」の台詞集です!

ranpa31

上のような、宇宙服を着たウサギのようなキャラです。
(自分が描いたイラストなので実物とは異なります)
「ランパ星」というまんまな星の王子で、いろいろな動物に変身することができます。
「キャラクターとしての魅力は?」と言われれば・・・回答が難しいところですが、自分は「見た目」と「至って普通な性格」と返します。
主人公であるスタフィーとの変身形態の1つにアザラシがあるので、むしろそっちに惹かれたりしていましたが・・・。

DSC_0278

そんなランパの台詞集、誰も作っていなかったので、ネタとしてのポストは空いています。
前からbotを作ってみたかったのもあるので、どんな感じなのかやってみました!

Twitterアカウントの作成

まずはbot用のTwitterアカウントを作成します。
マイアカウントでログインしっぱなしなので一度ログアウトし、新しくアカウントを作成します。

新規アカウントのためにはメールアドレスが必要ですが、bot専用のアカウントを作るのも嫌なので、Gmailのエイリアス機能を使用します。
[アドレス]+[任意文字列]@gmail.com でメールを自動的に転送してくれるらしいです。
細かいことはぐぐればたくさんでるので割愛します。

shot2ss20161223224502738

「ランパ」とだけ打つと、歯の矯正手術とかそっち系のばかりヒットするため、頭にスタフィーと入れておきます。
ちなみにランパのスペルは「Rampa」らしいです。
海外版では「bunston」と全く別名だったりしますが、とりあえずランパで行きます!

shot2ss20161223224725777

電話番号認証の後、ユーザIDを入力します。
まあ日本の方ならアカウント名見れば分かると思うので、シンプルにいきます。

あとはおすすめされる通知設定やフォローを全て拒否して完了です。

「Twitter Developers」でアプリケーションの作成

Twitter連携用のアプリケーションを作成します。

https://dev.twitter.com/

サイト上の「My Apps」をクリックし、次画面の右上からログインしましょう。

shot2ss20161223231237770

アプリ名、アプリの詳細、サイトURLを入力します。
割と適当でも大丈夫です。
サイトを持っていない場合は適当なURL(example.comとか)でも良いらしいです。

作成時に電話番号認証が行われていない場合、エラーとなって先に進めません。
しかも電話番号はアカウント間で共有することが出来ないそうです。
面倒ですが、メインアカウントの電話番号で一時的に認証してしまいました。

完了後にアプリケーションの詳細画面が表示されるので、「Application Settings」のあたりを撮っておきます。
上タブの「Keys and Access Tokens」の情報と、画面下の「Create My Access Token」クリック後の情報もメモしておきます。

実行用PHPファイルの作成と自動実行設定

GitHub上で公開されている「TwitterOauth」というPHPで作るのが一般的らしいです。
ほぼ他の参考サイトのコピペです。

<?php
require_once("twitteroauth-master/autoload.php");
require_once('twitteroauth-master/src/TwitterOAuth.php');

use Abraham\TwitterOAuth\TwitterOAuth;

$customer_key = "customer_key";
$customer_secret = "customer_secret";
$access_token = "access_token";
$access_token_secret = "access_token_secret";

$filelist = file('rampa_serifu.txt');
if(shuffle($filelist) ){
    $message = $filelist[0];
}

$connection = new TwitterOAuth($customer_key, $customer_secret, $access_token, $access_token_secret);
$connection->post("statuses/update", array("status" => $message));
?>

file() でテキストファイルの中身を取得し、無作為に1行抽出しているようです。
TwitterOAuth のパスや各種キーは環境に応じて設定して下さい。

実際は後述の台詞集入力で手間取っているため、まだ動かしていません。
またツイート時に「どこで発言したか」も入れたいので、単純にテキストファイルから取ってくるだけではきついかなーとも思います。
行く行くは正規表現であれこれして出力する感じでしょうか。

台詞集の入力

あとはランパの台詞を読み込ませるだけなのですが・・・。
ランパが登場するのは「伝説のスタフィー たいけつ!ダイール海賊団」という作品になりますが、もう発売から10年近く経つゲーム故、台詞をまとめてあるサイトなんてどこにもありません。
もちろんROM解析する知識・技術もなければ、それを行うための機材もありません。

ただ、ソフト自体は今でもとってあります。DSもあります。
つまりこういうことです!

DSC_0277

素でゲームを最初からエンディングまでプレイし、ランパの発言を残すことなく記録していくしかないでしょう。
クリアデータがありますが、一度クリアしたステージのストーリーを再度見ることは出来ません。
また、マリオやカービィと違ってステージ中にも会話が入るので、そういった意味でもプレイしないと厳しいです。

必要なのは「ランパが落ちてくるところからダイール撃破まで」のステージ1~8で、9~10は(確か)一言も喋らないので除きます。
隠しステージの攻略が必要かどうかは微妙なところです。
(うる覚えですが、ドランパに関することでランパが何か喋った気がします)
その他サブ要素(今日のゲストとメモ)で幾つか台詞があった気がしますが、既存のクリアデータで回収できます。

・・・と思いきや、セーブ中にソフトの接触不良→再起動時にデータ真っ白というアクシデント!
サブ要素をメモった後だったのが幸いですが、(ほぼ)コンプリートしたデータが消し飛んでしまいました。
元々最初からやり直す予定だったので、新規で始めますが・・・。

DSC_0279

DSC_0280

何かステージがところどころおかしいです。
水中なのに地上扱いになっていたり、壁のブロックが消えていたりします。
使用している3DSのカートリッジスロットの認識が悪いので、本体を入れ替えてからプレイしないと危険な気がします・・・。
ちょっとやり方を考える必要がありそうです。

というトラブルがあったことを踏まえると、実際の完成はかなり先の話になってしまいそうです。
こういう「台詞bot」って割とよく見かけますが、どうやって作っているのでしょうか。
やはり気合で入力するしかないのでしょうか・・・。

一気にやるのは無理があるので、暇なときにやっていくようにします。
ゲーム的な難易度は低めなので、「詰まる」という意味での障害はないと思われます。

まとめ

というわけで、Twitterでランパの台詞botを「形だけ」作ってみました!
botだけなら自作でも簡単に作れてしまうようです。
むしろゲーム内のランパの台詞をかき集める作業の方が大変かもしれません・・・。
気が向いたらときに少しずつ作っていくしかなさそうです。

余談

海外では「starfy wiki」というシリーズ全体のwikiがあります。
スタフィーシリーズは5作目まで海外で発売されていなかったにも関わらずです。
過去シリーズはおろか、過去に発売されたグッズやROM内の没データまで、内容量も凄まじいです。
英語のサイトですが、スタフィー好きな方は読んでみると面白いかもしれません。

http://starfywiki.org/wiki/Main_Page

【Unity】横スクロールアクションのカメラワーク制御について

というわけで、今更ながらあけましておめでとうございます!
今年も「ゴマちゃんフロンティア」をよろしくお願いします!

例年であれば「あけおめ」専用の記事を書いているのですが、毎年振り返ってもロクなこと語っていないので、今年はやめておこうと思います。
「ゲーム開発の目標!」なんて守れたことはほとんどないので(ぁ

shot2ss20170106203043668

新年1発目は「横スクロールアクションにおけるカメラワークの制御」になります!
ゲームにおいてカメラワークは超重要な要素ですが、自分なりに作った仕組みはどれも欠陥品で、使い物になりませんでした。
他サイト様を参考にしつつ、やっとそれなりのものが出来たのでメモしておきます。

カメラの描画範囲に応じた制御

カメラワークを制御する目的の1つに、「カメラの描画範囲がステージの範囲を超えないこと」があります。
・・・何を言っているかよく分からないと思うので、以下のgifアニメーションをご参照下さい。

20170106_01

20170106_02

上はカメラとプレイヤー位置(用のオブジェクト)を親子関係でくっ付けているだけなので、カメラ移動は完全にプレイヤーの移動に依存します。例えば、「ステージが横スクロールのみで縦スクロールさせたくない!」という場合でも、プレイヤーがジャンプすればカメラが上下に動いてしまう状態です。
また、カメラから見たプレイヤーの位置は常に画面の中央です。ステージ端まで行ってもプレイヤーが中央にいるため、画面の半分近くを壁(ステージ外の空間)が占領してしまいます。

下は本記事の趣旨である「カメラ移動範囲」の制御を取り入れたものです。
こちらもカメラとプレイヤー位置を親子関係でくっ付けていますが、ステージの各セクションごとに決められた範囲(以下セクション範囲と呼称します)に応じて、カメラの描画範囲を超えないようにカメラ移動を制限します。
gifアニメーションでは少し分かりにくいですが、これならステージの範囲を超えればカメラが上下せず、画面端まで行けばカメラ移動も止まります。

実装方法ですが、基本的には下記サイトの方法で設定しています。
非常に参考になりましたorz
http://pokelabo.co.jp/creative-blog/?p=340

shot2ss20170106202106038

オブジェクトとしては、CameraController と Section、SectionArea があります。
CameraController の下に mainCamera が存在し、Section はステージに配置したオブジェクトの親にしています。
わざわざ CameraController の子にしているのは、プレイヤー位置から見た相対的なカメラ位置・回転を変更できるようにしたかったためです。
SectionArea はセクションの範囲の基準点となる空オブジェクトです。
それぞれのオブジェクトにスクリプトを設定します。

【CameraController】

using UnityEngine;
using System.Collections;

/// <summary>
/// ゲーム画面上のメインカメラの制御を行うクラス
/// </summary>

public class CameraController : MonoBehaviour {

    // カメラの幅と高さ
    private float cameraRangeWidth, cameraRangeHeight;

    // 各オブジェクトとコンポーネント
    private GameObject mainCamera;
    private GameObject player;

    // セクション範囲定義用
    private Rect SectionRect;
    private Vector3 top_left, bottom_left, top_right, bottom_right;

    void Start() {
        // パラメータに値を設定
        player = GameObject.FindGameObjectWithTag("Player");
        mainCamera = GameObject.FindGameObjectWithTag("MainCamera");
    }

    void Update() {
        // プレイヤーキャラの位置に追従させる
        transform.position = player.transform.position;

        Vector3 newPosition;

        // カメラ描画範囲の上下左右を取得
        float distance = Vector3.Distance(mainCamera.transform.position, player.transform.position);
        bottom_left = mainCamera.GetComponent<Camera>().ViewportToWorldPoint(new Vector3(0, 0, distance));
        top_right = mainCamera.GetComponent<Camera>().ViewportToWorldPoint(new Vector3(1, 1, distance));
        top_left = new Vector3(bottom_left.x, top_right.y, bottom_left.z);
        bottom_right = new Vector3(top_right.x, bottom_left.y, top_right.z);

        cameraRangeWidth = Vector3.Distance(bottom_left, bottom_right);
        cameraRangeHeight = Vector3.Distance(bottom_left, top_left);

        // カメラ位置をセクション範囲内に収める
        float newX = Mathf.Clamp(newPosition.x, SectionRect.xMin + cameraRangeWidth/2, SectionRect.xMax-cameraRangeWidth/2);
        float newY = Mathf.Clamp(newPosition.y, SectionRect.yMin + cameraRangeHeight/2, SectionRect.yMax - cameraRangeHeight/2);

        transform.position = new Vector3(newX, newY, newPosition.z);
    }

    void OnDrawGizmos()
    {
        // カメラ描画範囲を表示
        Gizmos.color = Color.green;
        Gizmos.DrawLine(bottom_left, top_left);
        Gizmos.DrawLine(top_left, top_right);
        Gizmos.DrawLine(top_right, bottom_right);
        Gizmos.DrawLine(bottom_right, bottom_left);
    }
}

【SectionController】

using UnityEngine;
using System.Collections;

public class SectionController : MonoBehaviour {

    // セクションのカメラ範囲制御
    public Transform SectionArea;
    public float rect_width, rect_height, collider_depth;

    private Rect SectionRect;

    // マネージャ
    private GlobalManager globalManager;

    void Start () {
        // マネージャ取得
        globalManager = GlobalManager.getInstance();

        // セクション範囲定義
        SectionRect = new Rect(SectionArea.position.x, SectionArea.position.y, rect_width, rect_height);

        // セクション判定用オブジェクトに範囲を設定
        SectionArea.GetComponent<SectionArea>().setSectionRect(SectionRect);

        // CameraControllerにセクション範囲を渡すための判定定義
        SectionArea.transform.position = new Vector3(SectionRect.center.x, SectionRect.center.y, transform.position.z);
        BoxCollider boxCollider = SectionArea.GetComponent<BoxCollider>();
        boxCollider.size = new Vector3(SectionRect.width, SectionRect.height, collider_depth);
    }

    void OnDrawGizmos()
    {
        if (globalManager) {
            // セクション範囲を描画
            float base_depth = globalManager.stageManager.baseDepth;

            Vector3 lower_left = new Vector3 (SectionRect.xMin, SectionRect.yMax, base_depth);
            Vector3 upper_left = new Vector3 (SectionRect.xMin, SectionRect.yMin, base_depth);
            Vector3 lower_right = new Vector3 (SectionRect.xMax, SectionRect.yMax, base_depth);
            Vector3 upper_right = new Vector3 (SectionRect.xMax, SectionRect.yMin, base_depth);

            Gizmos.color = Color.red;
            Gizmos.DrawLine(lower_left, upper_left);
            Gizmos.DrawLine(upper_left, upper_right);
            Gizmos.DrawLine(upper_right, lower_right);
            Gizmos.DrawLine(lower_right, lower_left);
        }
    }
}

globalManager など、多少触れていない要素が含まれていますが、ただ他のコンポーネントから値を取っているだけなので適当に流して下さい。

CameraController の Mathf.Clamp() がミソのようです。
これで CameraController のX軸Y軸がセクション範囲から出ないようにコントロールしています。
計算式の意味は参考サイトの持ってきただけなので、自分でもよく分かっていませんが・・・。

shot2ss20170106202358374

SectionController の boxCollider は後述する「セクション移動時のエリア再設定」用です。
セクション範囲の大きさをインスペクターから rect_width, rect_height, collider_depth に設定します。
その際、SectionArea の位置を左下とした値に設定する必要があります。
また、こちらは動的に動かしたりはしないので、本当に定義するだけです。

20170106_04

この状態で実行しシーンビューに切り替えると、セクション範囲が赤枠で、カメラ範囲が緑枠で表示されます。
上のgifアニメーションは mainCamera を選択している状態です。緑枠が赤枠を超えた場合、mainCamera の位置が動いていないのが分かると思います。

セクション移動時のエリア再設定

実際のゲームで考えてみると、ステージ全体を通して1セクションで出来ていることはなかなかないです。
「右に進んだら上に行って、今度は下ってまた右に~」なんてことが多いのではないでしょうか。
途中で特殊なエリア (中ボス部屋とか) を挟んだりする場合もあります。

なので、セクションを複数定義した際に CameraController のセクション範囲を再定義できるようにしておきます。
また、各セクションの子オブジェクトにセクション範囲を定義したオブジェクトを作ります。
付けるスクリプトは以下になります。

using UnityEngine;
using System.Collections;

public class SectionArea : MonoBehaviour {

    private Rect SectionRect;
    private CameraController cameraController;

    /// <summary>
    /// セクション範囲を設定する
    /// </summary>
    /// <param name="rect"></param>
    public void setSectionRect(Rect rect) {
        this.SectionRect = rect;
        cameraController = GameObject.FindGameObjectWithTag("CameraController").GetComponent<CameraController>();
    }

    void OnTriggerEnter(Collider c) {
        if (TagUtility.getParentTagName(c.gameObject.tag) == "Player") {
            cameraController.setSectionRect(SectionRect);
        }
    }
}

OnTriggerEnter() 時に CameraController にセクション範囲を渡すので、胴スクリプトにセッタを作ります。
至って普通のセッタです。

public void setSectionRect(Rect SectionRect) {
    this.SectionRect = SectionRect;
}

あとはプレイヤーがセクション範囲に触れれば勝手にセットされます。

セクション移動時のカメラワーク

一通りの実装はできましたが、セクションからセクションへ移動した際のカメラワークが微妙で、カメラが一瞬で移動するので視覚的にはよろしくありません。
こういった処理では iTween を使用するのがベターですが、「移動する先」が分からないと処理しようがないです。
現状でも一瞬で移動するのは、Update() の「セクション範囲にカメラを収める」ための処理で勝手に動いている感じです。

ということで、setSectionRect() 実行時に移動先の地点を割り出すための再計算を行い、 iTween による移動処理を加えます。
再計算といってもやっていることは Update() 内と変わりません。
ついでに移動用に関数として、moveCameraPosition() も作っておきます。

public void setSectionRect(Rect SectionRect) {
    // 移動後のカメラ位置取得のため、カメラ移動範囲の再計算
    float newX = Mathf.Clamp(mainCamera.transform.position.x, SectionRect.xMin + cameraRangeWidth/2, SectionRect.xMax-cameraRangeWidth/2);
    float newY = Mathf.Clamp(mainCamera.transform.position.y, SectionRect.yMin + cameraRangeHeight/2, SectionRect.yMax - cameraRangeHeight/2);
    Vector3 newPos = new Vector3(newX, newY, mainCamera.transform.position.z);

    moveCameraPosition(mainCamera.transform.position, newPos);
    this.SectionRect = SectionRect;
}

public void moveCameraPosition(Vector3 oldPos, Vector3 newPos, float time = 1f) {
    iTween.MoveTo(mainCamera, iTween.Hash(
        "position", newPos,
        "time", time
    ));
}

20170106_03

iTween.MoveTo() により、適用前よりもなめらかにカメラが移動してセクション再設定が行われます。
なかなか良い感じですね!

まとめ

そんなわけで、横スクロールアクションにおけるカメラワークについて考えてみました!
やっとまともなカメラワークが実現できたので一安心です。

ただ、実装が完全に横スクロール向けのカメラワークなので、3Dとのハイブリッドはほぼできなくなりました。
いっそ開き直ってゲームにすることを優先し、次作るゲームに繋げるというのもありかなーと思い始めていたりします。
なので気にせずやっていこうと思います!

【Unity】横スクロールアクション作成のためのオブジェクト移動制御

というわけで、年越しを前にして風邪が全然治らないりべるんです。
ブログも2週間近く放置していたので、年越し前に何か書いておこうと思います!

shot2ss20161230224759374

今回はUnityで横スクロールアクションを作る際の移動制御についてになります。
最近のゲームでありがちな、「3Dで作られているけど中身は2D」な感じです。
故にUnityの2D機能とは趣旨が異なるためご注意下さい。

こんな記事を書いた理由は他でもなく、自作ゲームを3Dで作ることに挫折しかけているためです。
ステージ作成が2Dの方がやりやすいのもありますが、カメラアングルの問題を上手く解決できません。
そのあたりのセオリーが分かるまで、ステージは2D移動をベースにして作ろうと思っています。

プレイヤー移動を2Dに限定

前に記事で紹介した、「CharacterMotor使用時の移動を2Dに限定する」でプレイヤー移動を2D方向に限定できます。

【Unity】CharacterMotor使用時のキャラクター移動を2Dに限定する

ただしこれだけでは完全ではなく、Unity側の物理演算の結果によってZ軸に移動する可能性があります。
適球状のオブジェクトを配置し、横から突っ込むと分かりやすいです。
上の対応は「プレイヤー側の入力で」Z軸に移動しないだけなので・・・。

スマートに解決する方法が分からなかったので、Update() 内で強引にZ軸を制御しました。
RigidBody があれば FreezePosition を指定することで解決しますが、CharacterController を使用している関係で使用できません。

void Update () {
    transform.position = new Vector3(transform.position.x, transform.position.y, globalManager.stageManager.baseDepth);
}

スタックオーバーフローには「Z軸を0にしろ(意訳)」的なものがあったりします。
が、自分はZ軸が0になるようにステージを作っていなかったので、真に受けて設定すると異次元にぶっ飛んでしまいました。

ということで、ステージ用のマネージャクラスに「現在ベースとなっているZ軸」を持たせることにしました。
ステージ進行に合わせて変化させることも可能で、その値でプレイヤーのZ軸を固定します。
マネージャクラスについては趣旨から外れる上に我流なので割愛しますが、 単に Player クラスのフィールドに持たせても問題ありません。
その場合、Start() 内で transform.position.z を元に初期化すればOKです。

20161230_01

ダッシュ時に挙動が怪しくなる時がありますが、概ね固定されるみたいです。

オブジェクト・敵キャラクター移動を2Dに限定

RigidBody の「FreezePosition」でZを指定してあげるだけでOKです。
あまりにも簡単すぎてプレイヤー制御の苦労が嘘のようです。

shot2ss20161230230000329

・・・本当にこれだけでOKなので、あまり書くことはないです!
「RigidBody万歳!」といったところでしょうか。

強いて言えば、敵キャラクターのロジックが3D用になっているので、それを修正します。
プレイヤーと同じように左右のみ瞬時で振り向くようにします。
Slerp() で補完していたのを LookRotation() に置き換えるだけでOKです。
その際、Y軸とZ軸を0に設定して振り向かせます。

void Update() {
    if (lookTarget != null) {
        Vector3 angle = lookTarget.position - transform.position;
        angle.y = 0;
        angle.z = 0;
        transform.rotation = Quaternion.LookRotation(angle);
    }
}

lookTarget にはプレイヤーの Transform を入れます。
実際は別オブジェクトに判定を持たせ、OnTriggerEnter() でセットしています。
(これまた趣旨と外れるので割愛します)

まとめ

かなりざっくりですが、横スクロールアクションにおける移動制御について考えてみました!
自分が作りたいゲームを想像すると、何かと2Dっぽい部分が多い気がするので、とりあえずこれで行ってみようと思います!

2016年もあと24時間ちょっとです。今年も早かったですね。
来年も「ゴマちゃんフロンティア」をよろしくお願いします!

【Unity】StandardAssetsの「Water4」で水中から水面上をレンダリングする方法

※本記事は「Unity5.4.1」時点の内容です。

というわけで、今回は Unity のシェーダーに関するお話です。
StandardAssets の Environment に「Water4」という水面シェーダーがあります。

shot2ss20161218223706980

Unity4 まではPro専用でしたが、Unity5 から無料でも使えるようになりました。
これによって水面のレンダリングがすごく楽に出来ます。

shot2ss20161218224017428

が、カメラが水中に入っている場合、水面上が見えなくなってしまいます。
ステージに陸上のエリアと水中エリアがある場合、かなり致命的な問題です。

適当に試行錯誤したところ、Water4 のシェーダーを修正すれば(とりあえずは)直るようです。
自分はシンプルの方を使っているので、「FXWater4Simple.shader」を修正します。
215行目付近の edgeBlendFactors が書かれている行をコメントアウトします。

baseColor = lerp (lerp (rtRefractions, baseColor, baseColor.a), reflectionColor, refl2Refr);
baseColor = baseColor + spec * _SpecularColor;

// baseColor.a = edgeBlendFactors.x; // ←この行をコメントアウト
UNITY_APPLY_FOG(i.fogCoord, baseColor);
return baseColor;

水色なので分かりにくいですが、 水面上が表示されるようになりました!

shot2ss20161218225529184

少し上に「half4 edgeBlendFactors = half4 (1.0, 0.0, 0.0, 0.0);」という宣言があるので、水面の少し上を baseColor のアルファ値に設定しているからかなーと察しています。
とはいえ、自分はシェーダーがほとんど分からないので、このアプローチがよろしいかは微妙です。
修正する場合は自己責任でお願いします!

【Unity】メッセージやパラメータをYAMLファイルで定義・参照する

この記事は「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 ファイルの読み込みに関しては、以前投稿した記事で紹介しております。

【Unity】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」を設定した場合の出力です。

shot2ss20161205003818664

後は 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」です。

shot2ss20161205003840118

言語を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 さんです!