【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とのハイブリッドはほぼできなくなりました。
いっそ開き直ってゲームにすることを優先し、次作るゲームに繋げるというのもありかなーと思い始めていたりします。
なので気にせずやっていこうと思います!

【開発メモ】「オブジェクトを掴む・投げる」アクションの修正

というわけで、今回は「オブジェクトを掴む・投げる」というアクションの修正になります!
「修正」ということで、既にそれっぽいアクションは実装してあります。

shot2ss20160802234413497

流れとしては以下のようになっております。
1.キー入力で掴むアクションを行い、手に掴み専用の判定を出す
2.掴み判定に当たったオブジェクトを持ち上げる
3.再度キー入力でオブジェクトを投げる

これがゲーム的にはかなりの欠陥品で、
・掴み判定に当たらないと持てない
→目の前のオブジェクトでもすぐに持てない
・掴む動作自体がモッサリなので、掴み判定と相まってテンポが悪い
・持ち上げた際のオブジェクトの位置が掴んだ位置に依存
→判定の出始めや終わり際で持つと持ち上げた際にズレまくる
・一度に複数のものを掴めてしまう
・持ち上げた状態で敵を押すと楽々場外KOが可能

などなど、実用に耐えうる代物ではありません。
特に致命的なのがテンポの悪さなので、今回はそこに重点を置いて修正を考えてみます!
アクションゲームらしく、キー連打で次々と投げまくれるようなものがいいですね!

様々なクラスやオブジェクトで処理している関係上、ソースを全て書くと長くなってしまうので、最低限のものだけ載せます!

オブジェクトを掴む処理の変更

以下のようなシンプルなものに変えてみます。
・キー入力時に前方に掴むことができるオブジェクトがあるか確認する
・掴めるオブジェクトがある場合は持ち上げる

処理としては、キー入力時にプレイヤー前方に短く RayCast を行い、当たったオブジェクトを判定、掴める場合は特定の位置に移動させながらプレイヤーの子オブジェクトにします。

上記修正から、掴み判定という概念を削除します。
モッサリ感解消の他、複数オブジェクトを一度に持ち上げてしまう問題も解決できます。

語っていてもしょうがないので、レッツコーディング(`・ω・´)
Player クラスの Update() 内に処理を追加します。

if (Input.GetButtonDown("Grab") && !stateInfo.IsTag("Throw")) {
    Ray grabRay = new Ray(transform.position, transform.forward);
    if (Physics.Raycast(grabRay, out hit, 3f)) {
        if (hit.collider.tag == "ThrowableObject") {
            StartCoroutine("setThrowingTarget", hit.collider.gameObject);
        }
    }
}

1つ目のif文で「ボタン入力時」且つ「掴み&投げ系のモーションではないこと」を判定します。
予め掴み&投げ系のステートに「Throw」というタグを割り当てておく必要があります。
また、Raycast() の際は maxDistance を指定しないとあらぬところまで届いてしまうので注意します。

if文が通ったら setThrowingTarget() であれこれ処理を行います。
詳しい処理は割愛させて頂きますが、モーション切り替えや持ち上げたオブジェクトの親子設定などを行っています。

前の仕様では敵に掴み判定を当てると専用の掴み攻撃が発動しましたが、システム的に面倒な上にゲーム的にもどうでもいい機能だったため、こちらも無くしてしまいました。

連続で入力した際の挙動の調整

掴む際のプロセスが短くなりましたが、そこから投げるまでの過程はまだテンポが悪いです。
モーション的には「掴む→持ち上げて保持→投げる」となっており、各モーションが完全に終了するまで遷移しないことが問題のようです。
そこで即投げする場合は「持ち上げて保持」のモーションを経由せず、掴みから直接投げるモーションへ遷移できるようにしてみます。

shot2ss20160802234236073

上は AnimatorController の掴む&投げるアクション用のサブステートマシン内です。

制御用にbool型パラメータとして Hold を作ってあります。
trueになった際に Grab→Hold と遷移し、falseになった際には Hold→Throw と遷移します。
上記は何れも ExitTime にチェックを入れているので、モーションが終わるまでは遷移しません。

ここに Grab から Throw へ遷移を追加し、直接遷移できるようにします。
遷移条件は Hold→Throw と同じで、Hold パラメータが false になったときです。

ということで、Grab ステートに以下のスクリプトを設定します。

using UnityEngine;
using System.Collections;

namespace StateController.PlayerAnimator {
    public class Grab : StateMachineBehaviour {
        public override void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
            if (Input.GetButtonDown("Grab")) {
                animator.SetBool("Hold", false);
            }
        }
    }
}

超シンプルに、掴みモーション中に入力があった場合はそのまま Hold を false にしてしまいます。
この状態で Grab→Throw への遷移で ExitTime のチェックを外せば、入力された段階でそのまま投げるモーションに遷移してくれます。

まとめ

そんなわけで、オブジェクトを掴む・投げる処理に修正を入れてみました!
システムとして要るかどうかはともかく、アクションゲームでは割とありそうなものなので、ひとまず使えるレベルのものにしておきたいです。

最近更新していなかった故に急いで書いたのでかなりざっくりです。
分かりにくいところがあればコメント等頂ければ頑張って書きます!

【開発メモ】体力0でミスとなった場合の処理の実装

というわけで、最近Googleアナリティクスを見ていると、「モグフィー」という検索ワードで来る方がいるようです。
自分としてはブログ3キャラの内の1匹に適当に付けた名前なのですが、どうやら離乳食の製品名と被ってしまっていたようです。
半分検索妨害のような感じですが、今更被ったからと名前を変えるのもアレなので、このまま行かせていただきます!

今回は「体力がなくなりミスとなった時」の処理について考えてみます。
前回の「落下時のダメージ処理」に続き、ゲームとしての体裁を保つためには必須の処理ですね。

shot2ss20160723090956792

要するに「体力なくなって死んだとき」の処理ですが、直接死亡と書くのはこの手のゲームでは避けたいので、ミスと表記することにします。
「倒れた」とか「ちからつきた」とかそんなニュアンスです。
最近のRPGでは「戦闘不能」とか「気絶」とかで、死亡扱いしているのはドラクエや硬派なゲームだけな気がします。

前回の記事はこちらになります。

【開発メモ】ステージ外へ落下した際の処理について

ミスとなった場合の処理

ゲームとしてはシンプルにゲームオーバーにします。
但し無限にコンティニューが可能で、コンティニューした場合は体力を全回復させて復帰させます。

コンティニューをしなかった場合はタイトルにでも飛ばせば良いでしょう。
まだまともなタイトル画面は作っていませんが・・・。
ちなみに Unity5.3 からシーン遷移が Application 系から SceneManager を使ったものに変わっているようなので注意します。

キャラクター的にはフラフラと倒れるようなアニメーションをさせればそれっぽくなりそうです。
また当たり前ですが、体力が0になった段階で操作できなくする必要があります。
とりあえず CharacterControoler の canControl をfalseに設定して動けないようにはしておけば、最低限の体裁は整えられそうな感じ。

ゲームオーバー時のUI作成

まずゲームオーバー時のロゴを作ってしまいます。
GIMP で適当にやります。

20160723_gameover

ゲーム開発ってデザインセンスも求められるわけですよ。
自分の水準以下のデザインセンスでこの先ゲームを作っていけるのか心配になってきます。
そんなクオリティです。
まあキャラクターデザインとかも割と適当なので、今に始まったことではありませんが。

shot2ss20160723091447812

最低限必要なものはゲームオーバーのロゴ、コンティニューボタンとENDボタンです。
それだけでは不親切なので、コンティニューボタンとENDボタンの説明文なんかも欲しいところです。
ロゴはただの画像なので配置するだけですが、コンティニューボタンとENDボタンは押下時にイベントを走らせる必要があります。
なので UI の Button として作成します。

shot2ss20160723091050222

ゲームオーバー専用の UI としてまとめて管理したいので、上のように空オブジェクトの子にしました。
その場合、親となる空オブジェクトも Transform から RectTransform にしておいた方が良いようです。

shot2ss20160723092244763

uGUI であればクリック時の処理や各種イベント処理が簡単に登録できます。
UI 上から実行したいスクリプトのオブジェクトとメソッドを選択するだけ!
OnGUI() で頑張っていた4.1の頃と比べると便利になったものです。

ボタンが反応しない場合、ヒエラルキーに「EventSystem」が存在するか確認します。
ない場合、メニューバーから「GameObject→UI→EventSystem」で追加します。

shot2ss20160723091142622

そのまま実行すると例外が発生し、「Input Button Submit is not setup.」とか言われます。
Standalone Input Module の各項目に対応する入力が InputManager 上に存在する必要があります。
上記エラーの場合、InputManager で新しく「Submit」を作成するか、モジュールの項目名を InputManager に存在する入力の何れかに変更して対応します。

ゲームオーバー時のスクリプト処理

ゲームオーバー時の処理の UI 制御をまとめたスクリプトを作成します。

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using System;
using System.Collections;

/// <summary>
/// ゲームオーバー時のUIを制御するクラス
/// </summary>

public class GameOverUI : MonoBehaviour {

    private Text description;
    private StageManager stageManager;

    void Start () {
        stageManager = GameObject.Find("StageManager").GetComponent<StageManager>();
        description = transform.FindChild("MenuDescription").GetComponent<Text>();
    }

    /// <summary>
    /// コンティニューボタンを押下した際の処理
    /// </summary>
    /// <param name="continueButton"></param>
    public void OnClickContinue(GameObject continueButton) {
        stageManager.toContinue();
        Destroy(this.gameObject);
    }

    /// <summary>
    /// ENDボタンを押下した際の処理
    /// </summary>
    public void OnClickEnd() {
        stageManager.toEnd();
    }

    /// <summary>
    /// コンティニューボタンにカーソルを合わせた際の処理
    /// </summary>
    public void OnPointerEnterContinue() {
        String message = YamlUtility.getMessageValue("gameover.continue");
        description.text = message;
    }

    /// <summary>
    /// ENDボタンにカーソルを合わせた際の処理
    /// </summary>
    public void OnPointerEnterEnd() {
        String message = YamlUtility.getMessageValue("gameover.end");
        description.text = message;
    }

    /// <summary>
    /// コンティニューボタンからカーソルを離した際の処理
    /// </summary>
    public void OnPointerExitContinue() {
        description.text = null;
    }

    /// <summary>
    /// ENDボタンからカーソルを離した際の処理
    /// </summary>
    public void OnPointerExitEnd() {
        description.text = null;
    }
}

どうしても関数が多くなってしまうので、コメントをしっかり書いて分かるようにしておきます。
Visual Studio ならスラッシュ3回入力で自動的にsummaryが入ってくれるのでラクラクです。

メッセージにある Yaml 系に関しては以下の記事をご参照下さい。

【Unity】YAMLファイルの読み込みと表示用メッセージの管理

UI 制御とは別で、ゲームオーバー時やコンティニュー時の処理を行うマネージャクラスを作成します。
ステージ上のゲーム進行を担うので、「StageManager」と命名してみました。
GameOverUI から呼ばれた処理はこちらで行います。

using UnityEngine;
using UnityEngine.SceneManagement;
using System.Collections;

/// <summary>
/// ステージの進行・状態を管理するマネージャクラス
/// </summary>

public class StageManager : MonoBehaviour {

    public GameObject playerPrefab;
    public Transform checkPoint;
    public GameObject gameOverUI;
    private GameObject canvas;

    private GameObject playerObject;
    private Player player;

    void Awake() {
        playerObject = Instantiate(playerPrefab, checkPoint.position, Quaternion.identity) as GameObject;
        player = playerObject.GetComponent<Player>();
    }

    void Start () {
        canvas = GameObject.Find("Canvas");
    }

    public void toGameOver() {
        GameObject gameOver = Instantiate(gameOverUI) as GameObject;
        gameOver.transform.SetParent(canvas.transform);
        gameOver.GetComponent<RectTransform>().localPosition = Vector3.zero;
    }

    public void toContinue () {
        player.initToContinue(checkPoint.position);
    }

    public void toEnd() {
        // タイトル画面に戻す
        SceneManager.LoadScene("title");
    }
}

プレイヤーキャラの処理は Player クラスに initToContinue() メソッドを作っておき、それを呼んで処理させます。
いろいろ処理が絡み合っているので割愛しますが、スタート地点への移動や無敵判定の解除、パラメータ類のリセットを行っています。

あとは死んで動作チェック!
プレイヤーの体力100に対し、接触時の被ダメを1000に設定した無慈悲なウニに突撃してテストします。

20160723_01

死亡するとゲームオーバー画面が表示されること、プレイヤーが動かせなくなること、コンティニュー押下時にスタート地点へ戻されることを確認します。
GIFアニメーションにこれら全て入れるのは難しかったので、画像的にはぶっ倒れるマリンパだけですが・・・。
とりあえず動作に問題はなさそうです。

チェックポイントの作成

基本的に復帰地点はスタート地点ですが、チェックポイントに触れていればそこから再開にします。
これまたアクションゲームではよくあるシステムです。
中間ポイントのようなものを設けるのが手っ取り早いですが、どこまで進むとチェックポイントを通ったか分かりにくいので、私的には好きな形式ではないです。

となれば、チェックポイントと分かるようなオブジェクトが必要ですね!
これまた適当に作ります。

shot2ss20160723091606072

貝殻!
某ヒトデのマリンアクションもチェックポイントは貝殻だった気がします。
マーメイドとかそれに類するものは入っていないので問題ないかと思われます!
触れたのか分かりやすくするため、アニメーションで貝殻を開くようにしておきます。

で、貝殻にチェックポイント用のスクリプトを追加します。

using UnityEngine;
using System.Collections;

public class CheckPoint : MonoBehaviour {

    public Transform returnPoint;

    private StageManager stageManager;
    private Animator animator;

    void Start () {
        stageManager = GameObject.Find("StageManager").GetComponent<StageManager>();
        animator = this.GetComponent<Animator>();
    }

    void OnTriggerEnter(Collider c) {
        if (c.gameObject.tag == "Player") {
            animator.SetTrigger("open");
            stageManager.checkPoint = returnPoint;
        }
    }
}

貝殻に触れるとパカっと開き、コンティニュー時の復帰地点を触れた貝殻にします。
貝殻自体を復帰地点に登録すると地形にめり込んでしまうので、復帰地点用の空オブジェクトを指定し、そこに戻すようにしています。
見た目が猛烈に地味なので、そのうちエフェクトとか付けます!

まとめ

そんなわけで、ミス時の処理とゲームオーバー処理について作ってみました!
これで最低限のゲームとしてのルールはできたと思われます。
この状態でステージ上に敵やトラップを配置すればそれっぽくはなるので、そこまで作ってしまいたいです。

にしても uGUI は使いやすいですね!
特にイベント系の処理が簡単に実装できるようになったので、メニュー画面などの複雑なUIの作成も捗りそうです。

【開発メモ】ステージ外へ落下した際の処理について

というわけで、今回はタイトル通り、「ステージ外へ落下した際の処理」について考えてみます。

shot2ss20160713230800516

この処理がないと落下しても亜空間を移動できたり、落下後に復帰できなくなってしまいます。
ゲームとしての体裁を保つためには必須の処理ですが、全然作っていなかったので実装してしまおうと思います!

実際の処理はゲーム内容によって大きく異なる部分かと思われます。
落下時は即死するのか、ダメージを受けるだけなのか、その場合どこに戻されるのか・・・とかですね。
まあ見ての通り堅苦しいゲームではないので、ペナルティは軽めにする予定です。

判定用オブジェクト作成とスクリプト設定

「落下時は一定ダメージを受けて特定の地点へ移動」 で実装してみます。
残機というシステムを入れるつもりがないこと、その上で落下=即死では難易度が高くなると感じたためです。

shot2ss20160713231310804

まず、「触れると落下した扱いになる」判定を作ります。
落下判定をする場所に配置する他、ステージ全体の下に大き目の判定で配置し、イレギュラーな挙動をしてしまった場合でも落下扱いにして戻ってこれるようにします。

次に「落下後に戻される場所」として空のオブジェクトを作成し、上で作成した判定の子オブジェクトにします。
子オブジェクトにしなくても問題はありませんが、セットで管理しやすくなるのでオススメです。
戻ってくる位置として使うだけなので、Transform 以外のコンポーネントは不要です。

その後、判定を作ったオブジェクト(=親オブジェクト)にスクリプトを設定します。

using UnityEngine;
using System.Collections;

public class DamageReturnArea : MonoBehaviour {

    public float power;

    private Transform returnPoint;

    void Start() {
        returnPoint = transform.FindChild("ReturnPoint");
    }

    private void OnTriggerEnter(Collider c) {
        string tag = TagUtility.getParentTagName(c.gameObject);

        if (tag == "Player") {
            c.GetComponent<Player>().forceDownDamage(this);
            StartCoroutine("returnCharacter", c.gameObject);
        }
    }

    private IEnumerator returnCharacter(GameObject character) {
        yield return new WaitForSeconds(1f);

        character.transform.position = returnPoint.position;
    }
}

TagUtility に関しては以下の記事をご参照下さい。

【Unity】タグの階層表示と判定方法について

判定に入ったのがプレイヤーキャラだった場合は StartCoroutine() を実行し、一定時間後に returnPoint に戻します。
ダメージ処理は forceDownDamage() から Player クラスで行っています。
Player クラスまで載せると長い上に他と絡み合っていて面倒なので、ここでは割愛します。

ちなみにエリアとして作った関係上、マグマや針びっしりの床などでも同様の処理ができます。
一般的なアクションゲームでよくある即死トラップ系はステージ外落下と同じ扱いにしてしまう予定です。

落下時のカメラワークについて

現状のカメラアングルはプレイヤーの位置に合わせて追従します。
mainCamera をプレイヤーと同位置に追従させているオブジェクトの子オブジェクトとして設定し、位置・角度を調整しています。
そのスクリプトは以下の記事に載せてあります。

【Unity】「ProBuilder Basic」を使ったシンプルなステージ作成

ただし、落下時にカメラに対して何も処理をしていないため、落下後も追従しっぱなしです。
この手のゲームでステージ外落下後もプレイヤーを追従するカメラなんてほとんどないでしょう。

そんなわけで、落下判定に触れた際はカメラをプレイヤーを追従しないようにします。
今は CameraController というスクリプトからカメラを制御しており、上記の追従もここで行っているので、フラグを持たせて切り替えてしまいます。
上で載せたスクリプトを少し修正します。

using UnityEngine;
using System.Collections;

public class DamageReturnArea : MonoBehaviour {

    public float power;

    private Transform returnPoint;
    private CameraController cameraController;

    void Start() {
        returnPoint = transform.FindChild("ReturnPoint");
    }

    void Update() {
        cameraController = GameObject.FindGameObjectWithTag("PlayerManager").GetComponent<CameraController>();
    }

    private void OnTriggerEnter(Collider c) {
        string tag = TagUtility.getParentTagName(c.gameObject);

        if (tag == "Player") {
            c.GetComponent<Player>().forceDownDamage(this);
            StartCoroutine("returnCharacter", c.gameObject);

            // カメラアングルを固定
            cameraController.fixCamera = true;
        }
    }

    private IEnumerator returnCharacter(GameObject character) {
        yield return new WaitForSeconds(1f);

        cameraController.fixCamera = false;
        character.transform.position = returnPoint.position;
    }
}

カメラの方はフィールドに fixCamera を追加し、「transform.position = player.transform.position」の前に if 文を追加して制御します。
以下は CameraController の Update() 内です。

void Update() {
    // キャラクターを見失った場合再取得
    if (player == null) {
        player = GameObject.FindGameObjectWithTag("Player");
    }

    if (!fixCamera) {
        transform.position = player.transform.position;
    }
}

最終的にはこんな感じになりました!

20160714_01

よくあるアクションゲームのようなカメラワークになった気がします。
とりあえず十分な出来です。

まとめ

そんなわけで、落下時の処理について考えてみました!
実装の雰囲気だけ見ると、「メトロイドプライム」シリーズや、「伝説のスタフィー」シリーズの処理に似ています。
(後者はエリアの初めまで戻るので、そこまで似ているわけでもないかも)
まあ最近のゲームはダークソウルとか硬派なものを除くと、どれもこんな感じだったりしますが。

今日のイラスト

久しぶりにランパを描いてみました。
イラストを描くこと自体が数ヶ月ぶりだったりします。
何かバランスが変ですが、パッと見の出来はまあまあな感じ。

ranpa30

そのうちTwitterで「ランパの台詞集bot」みたいなものを作ろうかと考えていたりします。
いろいろと下準備が必要になりそうなので、暇なときにやっていく感じ・・・でしょうか。

【Unity】YAMLファイルの読み込みと表示用メッセージの管理

※UnityYamlMerge の記事ではありませんのでご了承下さい

というわけで、今回は「Unity で YAML ファイルを読み込んでメッセージ(文字列)を取得したい」というお話です。
記事的には前回の「メッセージを表示する看板」の続きです。

【Unity】テキストをUGUIで表示する看板の作成

要するに「画面に表示するメッセージをファイルで管理したい!」といった趣旨です。
例えば看板を複数設置してそれぞれにメッセージを設定した場合、後から修正するには「ヒエラルキーから選択→メッセージを修正」という感じになると思います。
看板の数が少なければ問題ありませんが、多くなってくると大変な作業です。

そこでメッセージを別ファイルで key と value で管理し、看板からは key を指定するようにすれば、誤字脱字があったとしてもファイルの value だけを修正すれば済みますね。
そんなメッセージ管理の実現に YAML ファイルを使ってみました!

C# の定数でも出来そうですが、YAML はインデントを入れることでネストさせることができるので管理しやすいです。
いちいち「public static const ~」と定義するのも面倒なので・・・。
XML や JSON を使う手もありますが、仕事で使って慣れている YAML が一番行けそうだったので決定しました。

ライブラリの導入

C# で Yaml を扱うにはライブラリが必要らしいです。
ぐぐった感じでは以下の3つが候補になりました。

・YamlDotNet
・YamlDotNet for Unity
・YamlSerializer

結論から言うと「YamlDotNet for Unity」にしました!
インポート時のファイル量が多めですが我慢します。
YamlDotNet は Android や iOS で使えないらしいので(一応)見送り、Serlalizer は使い方がよく分からない上、数年前で更新が止まっていたので見送りました。
YamlDotNet で妥協するのであれば、dll ファイルを Assets/Plugins に突っ込むだけで使えるのでお手軽です。

YAMLファイルの作成

メッセージを定義した YAML ファイルを作成します。
以下は作成した message.yml ファイルです。

# 操作

control:
  jump_input: スペースキーを押すとジャンプします
  throw_action: |-
    アクションキーを押すと物を持ち上げます
    再度押すと投げます

# キャラクター
character:
  marinpa:
    attack: 攻撃ボタンを連打することで、剣で連続攻撃を行います
  mogffy:
    attack: 攻撃ボタンを連打することで、槍で連続攻撃を行います
  shilriss:
    attack: 攻撃ボタンを連打することで光弾を連続発射します

# ステージ
stage:
  break_block: ブロックは攻撃することで破壊できます

注意すべきポイントをいくつか書いておきます。
・セミコロン後の半角スペースは必須
・ネストさせる場合は半角スペースで行い、タブは使わない
・行中に # を入れた場合、その以降はコメント扱いになる

これらを間違えると漏れなく構文エラーとなるので注意しましょう。
特に「セミコロン後の半角スペース」が慣れるまでは曲者です。

ちなみに上のようなセミコロンで区切る方法は「マッピング」と呼ばれています。
ダッシュ(-)を入れる方法は「シーケンス」と呼ばれるそうです。
シーケンスが配列、マッピングが連想配列のようなものでしょうか。

今回の看板に限らず、メッセージ的なものは全て外部ファイルに出してしまいたいです。
直書きしてしまうと修正が非常に面倒なので。

YAMLファイルの読み込み

上で作った YAML ファイルをスクリプトから読み込みます。
看板以外でも使いそうなので、パースする処理をユーティリティ化してみました。

using System.IO;
using YamlDotNet;
using YamlDotNet.RepresentationModel;

public static class YamlUtility {

    private static string ymlPath = "Assets/GUI/";

    public static string getMessageValue(string key) {
        // YAMLファイルのロード
        StreamReader inputFile = new StreamReader(ymlPath + "message.yml", System.Text.Encoding.UTF8);
        YamlStream yaml = new YamlStream();
        yaml.Load(inputFile);

        // キーをドットで分割
        string[] keys = key.Split('.');

        // キーの配列数(=ネストレベル)取得
        int keyCount = keys.Length;

        // ルートのマッピング取得
        YamlMappingNode mapping = (YamlMappingNode)yaml.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();
    }
}

単にキーから値を取得するだけであれば、YamlDotNet のドキュメント通りでいけます。
が、ネストされた YAML ファイルの特定のキーから値を取得する方法が分かりませんでした。
mapping.RootNode ではネスト最上位の要素しか取得できず、mapping.AllNodes はネストに関係なく全ての要素を取得してしまいます。
てっきり「getValue(string key)」的な関数があるかと思いきや、そんな楽な話ではなかったようです。

悩んでもぐぐっても分からなかったので、結局自分で実装してしまいました。
Symfony2 のようにネストをドット(.)で区切って指定し、区切られた回数だけ for でループさせます。
区切った要素分だけ mapping.Children で YamlMappingNode を再設定し、最後の要素になったら YamlScalarNodeを取得します。
YamlScalarNode の ToString() で値を返して終わりです。
Symfony2 のYAMLパーサも正規表現を使って強引にパースしていたりするので、そこまでこだわらなくていい部分かもしれません。

看板のスクリプトから YamlUtility の getMessageValue() を呼び出します。
インスペクターからキー値を指定し、対応するメッセージを取得するようにします。

UI関係のソースの意味は前回の記事をご参照下さい。
Start() 内で YAML からメッセージを取得するよう変更してあります。

using UnityEngine;
using UnityEngine.UI;
using System.Collections;

public class Signboard : MonoBehaviour {

    public string messageKey;
    public GameObject messagePref;

    private GameObject canvas;
    private GameObject messageUI;

    private float fadein_x = 100;
    private float fadeout_x = 300;
    private string messageValue;

    void Start () {
        canvas = GameObject.Find("Canvas");
        messageValue = YamlUtility.getMessageValue(messageKey);
    }

    public void OnTriggerEnter(Collider c) {
        if (TagUtility.getParentTagName(c.gameObject) == "Player") {
            if (!messageUI) {
                // メッセージ表示用Ui生成
                messageUI = Instantiate(messagePref) as GameObject;
                messageUI.transform.SetParent(canvas.transform, false);

                // メッセージ設定
                Text messageUIText = messageUI.transform.FindChild("SignboardMessageText").GetComponent<Text>();
                messageUIText.text = messageValue;

                // メッセージを画面内へ移動
                iTween.MoveFrom(messageUI, iTween.Hash(
                    "position", messageUI.transform.position + new Vector3(fadein_x, 0, 0),
                    "time", 1
                ));
            }
        }
    }

    public IEnumerator OnTriggerExit(Collider c) {
        if (TagUtility.getParentTagName(c.gameObject) == "Player") {
            if (messageUI) {
                // メッセージを画面外へ移動
                iTween.MoveTo(messageUI, iTween.Hash(
                    "position", messageUI.transform.position + new Vector3(fadeout_x, 0, 0),
                    "time", 1
                ));

                yield return new WaitForSeconds(0.5f);

                // メッセージ削除
                Destroy(messageUI);
            }
        }
    }
}

あとは実行するだけ!
message.yml のキー値を入力し、看板に近づいてメッセージを表示させます。

shot2ss20160628225235438

見事な文字化け!
上で作った message.yml の文字コードが「SJIS」になっていたようです。
IT系の仕事に就いているとは思えない致命的なミスに頭を抱えつつ、UTF-8 のBOMなしで保存し直します。

shot2ss20160628225317549

今度はしっかり日本語で表示されました!
上は messageKey に control.jump_input を指定した場合になります。

shot2ss20160628230733166

|- で改行した場合も問題なさそうです。

まとめ

そんなわけで、YAML からメッセージを取得し、UI に表示させてみました!
頻繁に修正する可能性のある定義だったり、ユーザーが設定するパラメータなどは YAML で扱うのが良いかもしれません。
今回試してみてなかなかいい感じなので、メッセージに限らず有効活用していきたいところです。