ゴマちゃんフロンティア

アザラシが大好きなエンジニアの開発日記です

【Unity】自キャラに一番近いオブジェクトを対象とするロックオン方式の実装

time 2018/03/11

sync 更新履歴
2019/11/14
レイヤーマスクによる判定のコードを修正しました。

というわけで、人生26年目の目前で花粉症と診断されてしまったりべるんです。
今までニュースの「花粉が非常に多いため~」という話は完全スルーでしたが、いきなり敏感に聞くようになってしまいました。国民病と言われるのも納得ですね。

ちょっとした前置きを挟んだ後で、今回は「3Dゲームにおけるロックオン」に関するお話です。

話としては以下の記事の続きになります。

【Unity】3Dのカメラ視点移動とロックオン機能について

超簡易的なロックオン機能を作ってみたので、それをもうちょっとマシな形に直したいという趣旨です。
ロックオン可能なゲームでは、ロックオン対象の決定を複数の方式から選択できることがありますが、今回はシンプルに「最も近いオブジェクトをロックオンする」方式について考えてみました。

自キャラに一番近いオブジェクトの取得

対象を注視し続ける処理は前回と変わらないので、ロックオン対象を決定・取得する関数のみをご紹介します。

protected GameObject GetTargetClosestPlayer()
{
    float search_radius = 10f;

    var hits = Physics.SphereCastAll(
        player.transform.position,
        search_radius,
        player.transform.forward,
        0.01f,
    ).Select(h => h.transform.gameObject).ToList();

    if (0 < hits.Count()) {
        float min_target_distance = float.MaxValue;
        GameObject target = null;

        foreach (var hit in hits) {
            float target_distance = Vector3.Distance(player.transform.position, hit.transform.position);

            if (target_distance < min_target_distance) {
                min_target_distance = target_distance;
                target = hit.transform.gameObject;
            }
        }

        return target;
    } else {
        return null;
    }
}

playerは自キャラのGameObject型の参照で、Start()時に取得しています。
ロックオンボタンを押した時に超短距離のSphereCastAll()を放ち、周囲のオブジェクトを取得します。
返り値をforeachで回して各々の距離を測ります。短距離のキャストのためかRaycastHit.distanceは役に立たないので、Select()GameObject型のListに直した上でVector3.Distance()を使用しました。
最短距離のオブジェクトが見つかったらhit.transform.gameObjectの参照を取っておくことも忘れないようにしましょう。

「画面に表示されている一番近い対象」に限定

上の実装では画面外にいる対象までロックオンしてしまいます。そこでロックオン対象を「画面に表示されているオブジェクト」に限定します。

protected GameObject GetTargetClosestPlayer()
{
    float search_radius = 10f;

    var hits = Physics.SphereCastAll(
        player.transform.position,
        search_radius,
        player.transform.forward,
        0.01f,
    ).Select(h => h.transform.gameObject).ToList();

    hits = FilterTargetObject(hits);

    if (0 < hits.Count()) {
        float min_target_distance = float.MaxValue;
        GameObject target = null;

        foreach (var hit in hits) {
            float target_distance = Vector3.Distance(player.transform.position, hit.transform.position);

            if (target_distance < min_target_distance) {
                min_target_distance = target_distance;
                target = hit.transform.gameObject;
            }
        }

        return target;
    } else {
        return null;
    }
}

protected List<GameObject> FilterTargetObject(List<GameObject> hits)
{
    return hits.Where(h => {
        Vector3 screenPoint = Camera.main.WorldToViewportPoint(h.transform.position);
        return screenPoint.x > 0 && screenPoint.x < 1 && screenPoint.y > 0 && screenPoint.y < 1;
    }).ToList();
}

FilterTargetObject()という関数内でターゲット対象を絞ります。
WorldToViewportPoint()でビューポートに変換した場合、画面左下が0:0、右上が1:1として正規化されるそうです。
https://docs.unity3d.com/ja/2017.3/ScriptReference/Camera.WorldToViewportPoint.html

ということで、その範囲からXY軸が出ていないかをWhere()内から返すことで、はみ出していないオブジェクトのListが取得できます。

ロックオン対象とするオブジェクトの限定

これまでの処理は(画面外以外で)ロックオン対象を限定していないため、どんなオブジェクトでもロックオン対象としてしまいます。実体のあるオブジェクトならまだしも、音やエフェクト用のオブジェクトをロックオンされても困りますよね。
ロックオン対象を限定する方法を考えてみると、以下のような方法が思いつきました。

共通のタグを付ける

ロックオン対象に共通するタグを付け、その文字列を比較して限定する方法です。シンプルで分かりやすい方法ですね。

protected List<GameObject> FilterTargetObject(List<GameObject> hits)
{
    return hits
        .Where(h => {
            Vector3 screenPoint = Camera.main.WorldToViewportPoint(h.transform.position);
            return screenPoint.x > 0 && screenPoint.x < 1 && screenPoint.y > 0 && screenPoint.y < 1;
        })
        .Where(h => h.tag == "Enemy")
        .ToList();
}

ただ、ロックオン対象の選別のためだけにタグを参照するのも微妙な気がします。現行のUnity(2017)では複数のタグが設定できないのが厳しい感じですね。

インタフェースを実装する

特定のインタフェースを実装し、オブジェクトがそのインタフェースを実装しているかどうかで判断する方法です。

protected List<GameObject> FilterTargetObject(List<GameObject> hits)
{
    return hits
        .Where(h => {
            Vector3 screenPoint = Camera.main.WorldToViewportPoint(h.transform.position);
            return screenPoint.x > 0 && screenPoint.x < 1 && screenPoint.y > 0 && screenPoint.y < 1;
        })
        .Where(h => {
            foreach (var component in h.GetComponents<Component>()) {
                if (component is IEnemyCharacter) {
                    return true;
                }
            }
            return false;
        })
        .ToList();
}

いざ作ってみるとWhere()内でforeachな上にGetComponents()と、少し処理が冗長な気が否めません。
また、ターゲットとしたいオブジェクトに「インタフェースを実装したクラス」を作る必要があることです。あまりないケースとは思いますが、スクリプトを設定しないターゲットをロックオン対象にしたい場合は不便かもしれません。

レイヤーマスクで判定する

SphereCastAll()のオーバーロードでレイヤーマスクを指定できるものがあります。レイヤーを追加し、ロックオン対象となるオブジェクトのみに該当レイヤーを設定します。
この場合、スクリプトの修正はSphereCastAll()の引数のみになります。

var hits = Physics.SphereCastAll(
    player.transform.position,
    search_radius,
    player.transform.forward,
    0.01f,
    LayerMask.GetMask("Enemy")
).Select(h => h.transform.gameObject).ToList();

LayerMask.GetMask()でターゲット対象のレイヤー名を指定しましょう。
難点はレイヤーの追加と設定が面倒なことくらいでしょうか。複数のレイヤーを指定することもできるので、この中では最も柔軟な方法かと思います。

コメント

  • 初めまして、こちらの記事を参考にさせていただいているのですが、

    【レイヤーマスクで判定する】の部分の”LayerMask.NameToLayer(“Enemy”)”に関してなのですが、LayerMask.NameToLayerはレイヤーマスクの番号を返す関数なので、正しくはLayerMask.GetMaskでターゲットのレイヤーを指定する必要があります。
    (コピペで試したところhitsがNullでした)

    monaca  2019年11月14日 2:31 PM

    • monacaさん

      ご指摘ありがとうございます。記事内容を修正しました。
      仮に`NameToLayer()`を使う場合はビットシフトが必要ですね。

      riberunn  2019年11月14日 11:51 PM

down

コメントする