【Unity】タイピングゲームの制作 (入力とマッチング)

というわけで、仕事繁忙期につき全然更新できていませんが、今回は所謂「タイピングゲーム」の作成に関するお話です!
かなり唐突ですが、作っていた自作ゲームの制作に詰まってしまっているので、気分転換の意味合いが強いです。
Unityの新UIもあまり慣れていなかったので、そういった意味でも勉強にはよさそうですね。

今回はタイピングゲームの基盤となる、文字の入力とそのマッチング処理について考えてみました!

文字入力と判定に必要なもの

作り始める前に、「どんなシステムでどんなクラスが必要か」を考えてみます。
システム開発では超重要なことですが、自分の作るゲームはどれも実践していません。
適当でいいので図にしてみると、あれこれ想像がしやすいです。

こういう「ちょっとした図」を書く時にはペンタブが便利です。
細かい部分直す際に苦労をしなくて済む上、レイヤーコピーで差分修正も楽々です。
紙だと取り置きしにくいので尚更です。

ざっくり且つ適当に書き起こしたメモがこちら!

gameimage

あれこれ考えながら書いたので混沌としていますが、今回のメインは「InputField」と「Matching」になります。
前者がUIの制御、後者が対象となる文字列と入力された文字を比較する処理を担います。

実際の処理の流れは、
「マッチング用クラスのインスタンス生成」→「入力文字を取得して比較」→「比較結果に応じた各種処理」
です。
ひとまずこれを目指してコーディングします!

入力欄の作成

当然ですが、プレイヤーが文字を入力する部分が必要です。
その他、日本語名と入力すべきローマ字が表示されている場合が多いです。

shot2ss20170219225624970

uGUIにおける入力欄は「InputField」を使うようです。
その他2つは「Text」で問題ないでしょう。
フォント設定やセンタリング等もお好みで大丈夫ですが、「ContentType」は「Alphanumeric」に設定します。
Alphanumeric にすることで、半角英数字しか入力できなくなります。

shot2ss20170219225706361

後々のことを考えて、1つ空オブジェクトを作成し、その子となるように各種UIを設定します。
スクリプトでの制御も親オブジェクトで行います。

shot2ss20170219225747481

親の空オブジェクトはデフォルト Transform が設定されていますが、UI上で動かすので RectTransform に入れ替えておきます。
「Add Component」から「Rect Transform」を選べば切り替わります。
ここまで出来たらプレハブ化しておきます。

スクリプトによるマッチング処理

ひとまずスクリプトを記載します。
「TargetWord」が先程のUIの親に設定するスクリプトで、「WordMatcher」は TargetWord クラスから生成されるマッチング用クラスです。

【TargetWord】

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

public class TargetWord : MonoBehaviour {

    private WordMatcher matcher;
    private InputField input_field;

    void Start () {
        input_field = this.GetComponentInChildren<InputField>();

        string target_word = "goma";
        matcher = new WordMatcher(target_word);
    }

    /// <summary>
    /// InputFiled変更時のコールバック関数
    /// </summary>
    public void onInput(string text) {
        if (text.Length == 0) {
            return;
        }

        string input_char = text.Substring(text.Length - 1, 1);
        bool result = matcher.matching(input_char);

        // 入力内容と一致しなかった場合は戻す
        if (!result) {
            input_field.text = matcher.getInputtedString();
        }
    }
}

【WordMatcher】

using System.Collections;

/// <summary>
/// 入力対象文字列と入力文字を比較するクラス
/// </summary>
public class WordMatcher {

    // 入力対象の文字列
    private string target_string;

    // 現在の入力文字位置
    private int current_position;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public WordMatcher(string target_word) {
        this.target_string = target_word;
        current_position = 0;
    }

    /// <summary>
    /// 指定された文字と現在位置の文字を比較する
    /// </summary>
    public bool matching(string input_char) {
        if (getCurrentChar() == input_char) {
            current_position += 1;
            return true;
        }
        return false;
    }

    /// <summary>
    /// 現在入力済みの文字列を返す
    /// </summary>
    public string getInputtedString() {
        return (target_string.Substring(0, current_position));
    }

    private string getCurrentChar() {
        return (target_string.Substring(current_position, 1));
    }
}

処理は TargetWord クラスの Start() から始まります。
WordMatcher クラスをインスタンス化し、入力対象となる文字列を渡します。(今回は決め打ちです)

次に、入力欄(InputField)に文字が入力されたら処理を行います。
「Inputであれこれ制御すれば・・・」とか深読みしましたが、最近は「UnityEvent」という便利なものがあるようです!

shot2ss20170219225843384

「コールバック先オブジェクト」「スクリプトの関数名」を選択するだけで、入力時に自動的に呼び出されます。
いちいち自分で「入力されたら~」という処理を実装しなくても良い上、入力された文字も送られてきます。
Unity4.1 の OnGUI() で頑張っていた人としては感涙ものです。

shot2ss20170219225926120

注意点として、関数名選択時に「Dynamic String」の方を選ぶ必要があります。
「Static Parameter」では動的に取得してくれず、引数が空っぽになってしまいます。

入力時に onInput() が呼ばれます。
先程「入力された文字が送られてくる」と書きましたが、実際は「現在 InputField に入力されている内容全て」が送られてきます。
なので入力された文字=最も右側の文字のみを取得する処理を入れます。

 
    string input_char = text.Substring(text.Length - 1, 1);
    bool result = matcher.matching(input_char);

String.Substring() の第1引数で開始位置を指定すればOKです。
文字列のインデックスは0から始まるので、text.Length で取得した文字数から1を引いた数値を指定します。

あとはマッチング処理です。
といっても「入力した文字を取得する」が出来ているので、入力対象の文字と1文字ずつ比較するだけです。

 
    /// <summary>
    /// 指定された文字と現在位置の文字を比較する
    /// </summary>
    public bool matching(string input_char) {
        if (getCurrentChar() == input_char) {
            current_position += 1;
            return true;
        }
        return false;
    }

    private string getCurrentChar() {
        return (target_string.Substring(current_position, 1));
    }

アプローチとしては「入力した分だけ入力対象文字を削除する」か「入力された位置を変数で管理する」が浮かびました。
どちらでもいける気がしますが、自分は後者で実装しています。
getCurrentChar() で現在位置の文字を取得、入力された文字と比較し、一致したら位置を1つずらします。
一致しなかったら false を返し、TargetWord クラス側で入力された文字を元通しに戻しています。

ただし、現状では input_field.text を再設定した際、再度 onInput() が呼ばれてしまいます。
どうやらキーボード入力限定ではなく、単に input_field.text に変更があったかどうかだけ見ているようです。
回避方法が分からないので、2回通っても問題ないような処理にしてあります。

shot2ss20170219230035815

実行して挙動を確認しておきましょう。
入力すべき文字以外が来ると元通しになるため、見かけ上は入力されていないように見えます。

まとめ

そんなわけで、タイピングゲームの基盤となる部分を作ってみました!
次は「ワードを打ち終えたら次のワードの出現」といった部分でしょうか。
言葉をどこから取得するかも考える必要があるので、しっかり構想が決まってから手を出してみます。

【開発メモ】敵キャラクターとロジックの作成に関する考察 その2

というわけで、前回に引き続き、敵キャラクターとロジックに関するお話です!
出張が多かったので開発が滞っていますが、やっとロジッククラスが作成できたので記載します!

shot2ss20170205204248559

前回の記事はこちら!

【開発メモ】敵キャラクター作成とロジックに関する考察

今回は上記の記事内で考察した中の、移動ロジックと攻撃ロジックについて考えてみます。

ロジッククラス作成の準備

ロジッククラス作成の前に下準備をしておきます。
まずはロジッククラスを共通化させるためのインタフェースを定義します。

【IEnemyMoveLogic】

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public interface IEnemyMoveLogic {
    /// <summary>
    /// 移動する速度を取得する
    /// </summary>
    Vector3 getMoveVelocity();
}

【IEnemyAttackLogic】

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public interface IEnemyAttackLogic {

    /// <summary>
    /// 攻撃を行うか判断する
    /// </summary>
    bool attackDetermine();

    /// <summary>
    /// 攻撃判定を取得する
    /// </summary>
    GameObject getAttackHitObject();

    /// <summary>
    /// 攻撃の種類を取得する
    /// </summary>
    int getAttackType();

    /// <summary>
    /// 攻撃時に再生するモーションを取得する
    /// </summary>
    string getAttackMotion();
}

次に各ロジッククラスのスーパークラスとなる抽象クラスを作成します。
以下は大元となる「AbstractLogic」クラスです。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic {
    public abstract class AbstractLogic : MonoBehaviour {

        protected Enemy enemy;

        protected virtual void Start () {
            enemy = this.GetComponent<Enemy>();
        }
    }
}

更に、移動・攻撃ロジック用に AbstractLogic を継承した抽象クラスを作成します。
共通処理として、Enemy クラスにロジックを設定します。

【AbstractMoveLogic】

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic.Move {
    public abstract class AbstractMoveLogic : AbstractLogic, IEnemyMoveLogic {
        public float move_speed;

        protected override void Start () {
            base.Start();

            enemy.setMoveLogic(this);
        }

        public abstract Vector3 getMoveVelocity();
    }
}

【AbstractAttackLogic】

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic.Attack {
    public abstract class AbstractAttackLogic : AbstractLogic, IEnemyAttackLogic {
        public const int CLOSE_RANGE_HIT_TYPE = 1;
        public const int RANGE_HIT_TYPE = 2;

        public GameObject attackHitObject;
        public int attack_type;
        public string attack_motion;

        protected override void Start () {
            base.Start();

            enemy.addAttackLogic(this);
        }

        public abstract bool attackDetermine();
        public abstract GameObject getAttackHitObject();
        public abstract int getAttackType();
        public abstract string getAttackMotion();
    }
}

移動用クラスは IEnemyMoveLogic、攻撃用クラスは IEnemyAttackLogic を実装させます。
これらのインタフェースを介した実装は、「特定のロジックに依存させない」ために重要です。

ロジッククラスの作成

いきなり複雑なロジックは厳しいので、今回は簡単な移動クラスと攻撃クラスを作成してみます。

以下は移動ロジックの、「自分のいる地点から左右に往復移動」のクラスです。
(横スクロール型のアクションで使用することを想定しています)

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic.Move {
    public class RoundTripMoveLogic : AbstractMoveLogic {

        // 基準点から往復するまでの距離
        public float repeat_dist;

        private Vector3 defaultPosition;

        protected override void Start () {
            base.Start();

            defaultPosition = enemy.transform.position;
        }

        public override Vector3 getMoveVelocity() {
            // 振り向き処理
            if (repeat_dist < Mathf.Abs(enemy.transform.position.x - defaultPosition.x)) {
                Vector3 angle = new Vector3(defaultPosition.x, enemy.transform.position.y, defaultPosition.z);
                enemy.lockRotate(angle);
            }

            float forward_x = enemy.transform.forward.x * move_speed;
            Vector3 newPosition = new Vector3(forward_x, enemy.rb.velocity.y, enemy.rb.velocity.z);

            return newPosition;
        }
    }
}

ロジックに関する深い説明は割愛しますが、「往復する距離まで移動したら逆側に振り向かせる」こと、「敵キャラクターは Rigidbody で動かしているので、Rigidbody.velocity を変更する」ことに注意します。

次は攻撃ロジックの、「一定間隔で攻撃」のクラスです。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic.Attack {

    public class AttackIntervalLogic : AbstractAttackLogic {

        // 攻撃を行う間隔 (フレーム)
        public int attack_interval;

        private int attack_count;

        protected override void Start () {
            base.Start();

            enemy.addAttackLogic(this);
            attack_count = 0;
        }

        public override bool attackDetermine() {
            if (attack_interval < attack_count) {
                attack_count = 0;
                return true;
            }
            attack_count += 1;
            return false;
        }

        public override GameObject getAttackHitObject() {
            return this.attackHitObject;
        }

        public override int getAttackType() {
            return this.attack_type;
        }

        public override string getAttackMotion() {
            return this.attack_motion;
        }
    }
}

主に使用するのは attackDetermine() で、これを Enemy クラスの Update() 内で呼び出し、攻撃を行うかの判定をしています。
その他、インタフェースで定義された関数を実装していますが、ロジック上で必要なパラメータを取得するためのものです。
このあたりはゲームによって実装が異なると思います。

共通して気を付けることは、「ロジッククラスから敵キャラクターを直接操作しない」ことです。
複数のロジッククラスからあれこれ敵キャラクターを操作すると訳が分からなくなります。
ロジッククラスは条件判定後に結果を Enemy クラスへ返したり、Enemy クラスの関数を呼び出して間接的に操作したりします。

敵キャラクター共通のクラス作成

敵キャラクター共通のスーパークラス「Enemy」を作成します。
既にダメージ処理等が入ってしまっていて長いので、ロジックに関する部分のみ紹介します。

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

[RequireComponent (typeof (Rigidbody))]

public abstract class Enemy : MonoBehaviour, ICharacter {

    protected bool isAttack;

    // 攻撃判定の出現位置
    public GameObject attackHitOffset;
    public GameObject bulletHitOffset;

    protected Animator animator;
    protected AnimatorStateInfo stateInfo;

    public Rigidbody rb;
    protected Collider bodyCollider;

    // ロジック制御クラス
    protected IEnemyMoveLogic moveLogic;
    protected List<IEnemyAttackLogic> attackLogics;

    protected virtual void Awake() {
        // ロジック初期化
        attackLogics = new List<IEnemyAttackLogic>();
    }

    protected virtual void Start() {
        isAttack = false;

        // 各コンポーネント取得
        animator = this.GetComponent<Animator>();
        rb = this.GetComponent<Rigidbody>();
        bodyCollider = this.GetComponent<Collider>();

    }

    /// <summary>
    /// 毎フレーム実行する処理
    /// </summary>
    protected virtual void Update() {
        if (animator && canAction) {
            stateInfo = animator.GetCurrentAnimatorStateInfo(0);

            // 移動ロジック
            if (moveLogic != null && !isAttack) {
                movement(moveLogic.getMoveVelocity());
            }

            // 攻撃ロジック
            foreach (IEnemyAttackLogic logic in attackLogics) {
                if (logic.attackDetermine()) {
                    GameObject attackHitObject = logic.getAttackHitObject();
                    string attack_motion = logic.getAttackMotion();

                    switch (logic.getAttackType()) {
                        case EnemyLogic.Attack.AbstractAttackLogic.CLOSE_RANGE_HIT_TYPE:
                            // 近距離攻撃処理
                            break;
                        case EnemyLogic.Attack.AbstractAttackLogic.RANGE_HIT_TYPE:
                            // 遠距離攻撃処理
                            break;
                    }
                }
            }
        }
    }

    /// <summary>
    /// 移動処理を行う
    /// </summary>
    public void movement(Vector3 velocity) {
        rb.velocity = velocity;
    }

    /// <summary>
    /// 指定した座標へ振り向かせる
    /// </summary>
    /// <param name="angle"></param>
    public void lockRotate(Vector3 angle) {
        transform.LookAt(angle);
    }

    public void setMoveLogic(IEnemyMoveLogic moveLogic) {
        this.moveLogic = moveLogic;
    }

    public void addAttackLogic(IEnemyAttackLogic attackLogic) {
        this.attackLogics.Add(attackLogic);
    }
}

今までは Update() 内にごりごりロジックを書いていましたが、今回は各ロジッククラスを制御する命令のみに留めます。

キモとなるのは、ロジッククラスの型をインタフェースにしておくことです。
インタフェースに記載された関数の実装が約束されるため、どのロジッククラスにも依存せず共通化できること、Enemy クラスからロジッククラスを見た際に最小限の機能のみを提供するためです。

攻撃クラスのみ List<> で管理しています。
例えば「近距離攻撃と遠距離攻撃を行うキャラクター」や「2種類の近距離攻撃を持つキャラクター」を作成する際、複数のロジックを作成して対応させるためです。
また List<> ならロジックが1つでも複数あっても対応でき、特定のロジックのみを無効化しても問題なく動きます。

実際に使用する際はキャラクターごとに専用のサブクラスを作成し、実際の敵キャラクター用ゲームオブジェクトのコンポーネントとして設定します。
中身はキャラクター固有の特殊能力がある場合に実装し、特にない場合はスーパークラスの関数を呼び出すだけです。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Raidran : Enemy {

    protected override void Start () {
        base.Start();
    }

    protected override void Update() {
        base.Update();
    }
}

今回の敵キャラクター「レイドラン」は特に考えていないため、何も考えずに継承しているだけです。

敵キャラクターへの設定とプレハブ化

上で作った移動ロジックと攻撃ロジックを敵キャラクターのオブジェクトに設定し、その動作を確認してみます。

20170205_01

うーん・・・左右を往復するだけの雑魚っぽいにはなったでしょうか。
本ゲームにはアクションでよくある「接触被ダメージ」がないので微妙かもしれませんが・・・。

ちなみに、Unityのベストプラクティス的にはオブジェクトの種別ごとにプレハブを作るのが良いらしいです。
動的にコンポーネントを足すのもアレなので、ロジックを設定したキャラクター毎にプレハブ化します。
プレハブが多くなって管理が面倒ですが、動的にインスタンスすることを考えればこちらの方が良いです。

まとめ

そんなわけで、ロジック系クラス作成の基盤と簡単なロジックの作成をしてみました!
敵キャラクター制御が使いまわせるようになり、コンポーネント毎に付け替えできるのでいい感じです!
索敵やジャンプのロジックも作れば、更に幅広いアクションを取らせることができそうですね。

ただやっていて思うのは、かなり手探りで自分なりの設計をしているということです。
このあたりのベストプラクティスとかってあるのでしょうか。
ゲームによって異なるとは思いますが、他の方がどのように実装しているのか気になります。
そのあたりのお話をUnity道場等でやっていたらぜひ参加したいところです。

【開発メモ】敵キャラクター作成とロジックに関する考察

というわけで、最近 Twitter の bot 制作の話ばかりでしたが、今回はゲーム開発のお話です!

テーマは「敵キャラクターとロジックに関するお話」です!
定期的にこんな話は載せていますが、どれも中途半端な実装とロジックなので、もう一度ベストな形を考え直す意味合いです。特に敵キャラクターのロジック部分はしっかり考える必要があります。

結論から言うと、「移動や攻撃などの役割毎にコンポーネントとして分解する」ようにするのが目的です。
なるべくロジックを分解し、パーツとして組み合わせることができるうな形が理想です。

敵キャラクターの作成

自作ゲームである以上、敵キャラクターも全て自分で考えます。
ロジックどうこうの前に、敵キャラクターを用意しないと話になりません。

とはいえ、今まともな敵と言えるものは「バブぴょん」くらいです。
開発初期の方に作ったので設定も何もなく、ロジックもかなり適当です。

shot2ss20170122094910458

敵キャラクターを作っていく場合、相応に種類が必要になるので、ある程度設定を考え、一貫性を持たせたほうがモデリングやモーション入れもラクです。
今考えている設定に「共通で結晶や角がある」「雷と氷を放つ」があるので、とりあえずそれを盛り込んで作りました。
前者は角ばっている方がモデリングが楽だからで、後者の「雷と氷」というのは、「海(≒水)を脅かす属性」として、一番メジャーで妥当かと思ったためです。

そんなわけで、ざっくりと2匹作ってみました!
上記のバブぴょんは「敵っぽくなさ」に定評がありましたが、今回のはそれなりに敵っぽくなっております!

shot2ss20170122093840735

1体は二足歩行の至ってシンプルな見た目です。
槍状の結晶による突きと角からのビームを放ち、敵軍団の主力となるような敵を目指します。
暫定的な名前として「レイドラン」と付けています。

shot2ss20170122093938413

もう1体は地を這う幽霊っぽい見た目です。
動作は遅めですが、両手で薙ぎ払う攻撃と防御を得意とする敵を目指します。
幽霊っぽく、目はパーティクルで表現しています。

ロジック部分の設計について

敵キャラクターが取る行動について、役割ごとにざっくりと洗い出してみます。

【索敵】
・索敵をしない (プレイヤーの行動に影響されない)
・前方を索敵
・周囲を索敵

【移動】
・移動しない
・常に前進
・一定の範囲を往復
・プレイヤーを追跡

【ジャンプ】
・ジャンプしない
・一定間隔でジャンプ
・攻撃時のみジャンプ
・常に浮いている

【攻撃】
・攻撃しない
・常に一定間隔で攻撃
・プレイヤーが索敵範囲内にいる場合に攻撃

概ねこんな感じでしょうか。
このロジック毎にクラスを作成し、敵キャラクターにコンポーネントとして追加するだけで設定が可能な状態を目指します。キャラクターの固有クラスでロジックを作成すると使い回しがきつくなる上、クラスの役割が肥大化するので避けたいところです。
その分クラスが多くなりますが、命名規則を決めたり(頭文字をAttack~、Move~にするなど)、名前空間を指定するなどでカバーします。

この他、キャラ固有の特殊能力などは個別のサブクラスに実装してしまいます。
また、各ステージのボスは専用のロジックを作成する予定です。

あくまで自己流の設計なので、これがベストとは言えないと思いますが、そこまでたくさんキャラクター(とロジック)作るアイデアも時間もないので、これで行ってみます!

まとめ

そんなわけで、敵キャラクターとロジックについて考えてみました!
実装の際のポイントは
・役割とそのロジック毎にクラスを作成
・コンポーネントとして付け替え/設定ができるように
・命名規則と名前空間をしっかり決めておく

あたりでしょうか!

次回から作っていくつもりですが、最近出張やら外泊やらが多いので、そこそこ先の話になってしまうかもしれません。
妥協するところは妥協しないといつまで経っても完成しないので、そのあたりの線引きも大切ですね。

【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時間ちょっとです。今年も早かったですね。
来年も「ゴマちゃんフロンティア」をよろしくお願いします!