ゴマちゃんフロンティア

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

【Unity】画面中央に一番近いオブジェクトを対象とするロックオン方式の実装

time 2018/03/22

というわけで、今回は3Dゲームにおけるロックオンに関するお話です。
以前「自キャラに最も近いオブジェクトをロックオン」という仕組みを作ったので、次は「画面中央に一番近いオブジェクトをロックオン」する仕組みを考えてみました。

話としては前回の続きになるので、↓の記事を先に閲覧いただくことをおすすめします。

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

画面中央に近いオブジェクトの取得

ロックオン対象取得用の関数を1つ追加し、画面中央に近いオブジェクトを取得する処理を組み立てます。

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

    var hits = Physics.SphereCastAll(
        player.transform.position,
        search_radius,
        player.transform.forward,
        0.01f,
        LayerMask.GetMask("LockonTarget")
    ).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) {
            Vector3 targetScreenPoint = Camera.main.WorldToViewportPoint(hit.transform.position);
            float target_distance = Vector2.Distance(
                new Vector2(0.5f, 0.5f),
                new Vector2(targetScreenPoint.x, targetScreenPoint.y)
            );
            Debug.Log(hit.gameObject + ": " +  target_distance);

            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;
        })
        .Where(h => h.tag == "Enemy")
        .ToList();
}

超短距離にSphereCastすること、フィルター用関数で対象を絞ることは前回と同じです。
Camera.WorldToViewportPoint()を使用し、各オブジェクトの画面上での座標を取得します。値は正規化されているので、XY軸共に0~1の範囲になります。
画面中央は0.5・0.5なので、Vector2.distance()で中央からオブジェクトの座標までの距離を測り、最も距離が離れているオブジェクトを保持を返します。

ロックオン中のカメラの制御は以下の記事で紹介しております。基本的にはTransform.LookAt()を使うだけです。

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

ロックオンカーソルの表示

単に敵の方を向くだけではゲーム的に地味なので、ロックオン中のみカーソルを表示するようにしてみます。
まずはGIMPでそれっぽい画像を作ります。

画像をインポートし、Canvasの子オブジェクトに設定します。ゲーム開始時はロックオンしていないので、非アクティブにしておきましょう。
次にカーソルを制御するためのスクリプトを作成します。

using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// ロックオンカーソルを制御するクラス
/// </summary>
public class LockonCursor : MonoBehaviour
{
    // 自身のRectTransform
    protected RectTransform rectTransform;

    // カーソルのImage
    protected Image image;

    // ロックオン対象のTransform
    protected Transform LockonTarget { get; set; }

    void Start()
    {
        rectTransform = this.GetComponent<RectTransform>();

        image = this.GetComponent<Image>();
        image.enabled = false;
    }

    void Update()
    {
        if (image.enabled) {
            rectTransform.Rotate(0, 0, 1f);

            if (LockonTarget != null) {
                Vector3 targetPoint = Camera.main.WorldToScreenPoint(LockonTarget.position);
                rectTransform.position = targetPoint;
            }
        }
    }

    public void OnLockonStart(Transform target)
    {
        image.enabled = true;
        LockonTarget = target;
    }

    public void OnLockonEnd()
    {
        image.enabled = false;
        LockonTarget = null;
    }
}

「ロックオン開始時」と「ロックオン終了時」で呼び出される関数を定義しておきます。Image.enableを切り替えることでカーソルの表示・非表示を行い、同時にロックオン対象の設定を行います。
Update()で常にZ軸に対して回転を掛けるとちょっと雰囲気がでていい感じです。またターゲットが設定されている場合はWorldToScreenPoint()で画面上の位置を取得し、そのままカーソルの表示位置として設定します。

最後に表示を制御するため、カメラ制御用のスクリプトを修正します。全て載せると長いので、修正部分のStart()Update()のみ記載します。

[SerializeField]
private GameObject lockOnTarget;

protected LockonCursor lockonCursor;

void Start()
{
    player = GameObject.FindGameObjectWithTag("Player");
    lockonCursor = GameObject.FindObjectOfType<LockonCursor>();
}

void Update()
{
    transform.position = player.transform.position;

    if (Input.GetKeyDown(KeyCode.R)) {
        if (lockOnTarget == null) {
            // ロックオンターゲット取得
            GameObject target = SetTargetClosestScreenCenter();

            if (target != null) {
                lockOnTarget = target;
                lockonCursor.OnLockonStart(target.transform);
            } else {
                // 視点リセット
                iTween.RotateTo(gameObject, iTween.Hash(
                    "rotation", player.transform.eulerAngles,
                    "time", 0.5f
                ));
            }
        } else {
            // 既にターゲットが設定されていた場合は解除
            lockOnTarget = null;
            lockonCursor.OnLockonEnd();
        }
    }
}

Start()でカーソルの参照を取得しておき、カーソル制御クラスで定義した関数をロックオン開始時と終了時に呼び出します。
あとはLockonCursor側の関数で表示・非表示が行われます。

実際に動かすと以下のような感じです。

カーソルがチープな感じですが、挙動事態は問題なさそうです。

コメント

  • 敵に表示したカーソルの位置を高くしたいのですが、どうすればいいかわかりません。

    kiti  2019年10月12日 2:30 PM

    • kitiさん

      `rectTransform.position`に代入するところでVector3型を足し、位置を調整するのはどうでしょうか?
      例えばY軸を10ずらす場合、`rectTransform.position = targetPoint + new Vector3(0, 10f, 0);`といった感じです。

      riberunn  2019年10月12日 11:33 PM

  • 10fであんまり変わらなくて100fにしたら敵の真ん中にいったのでどうやら差小さかったので気づいていなかったようです。
    ありがとうございます!
    加えて申し訳ございませんが
    敵から一定の距離を離れるとターゲットの表示を自動的に消すコードが思いつかないです。もし教えていただけると幸いです。

    kiti  2019年10月13日 1:27 AM

    • `Start()`でキャラクターオブジェクトの参照を取得し、`Vector3.Distance()`で`LockonTarget`との距離を算出してみてください。
      その値を`Update()`内のif文の条件に加えてあげれば実現できるかと思います。

      riberunn  2019年10月15日 8:33 AM

  • 初めまして。
    記事を拝見して勉強させて頂いております。
    FPSで実現しようとしているのですが、メインカメラをtransform.LookAtさせると、親であるプレイヤーが追随してこず、同じ量だけ回転させようとしたり、プレイヤーもtransform.LookAtさせようとしたりしているのですが、うまく行きません。
    もし、良い解決案がありましたらご教授頂けませんでしょうか?

    ANGLE  2019年10月22日 12:22 AM

    • ANGLEさん

      単にプレイヤーをターゲット方向へ向けたいだけであれば、`Update()`でプレイヤーオブジェクトの`transoform.rotation`にカメラの`transoform.rotation`を代入してあげるとそれっぽくなるかと思います。
      しかしこれだとカメラを上に向けるとプレイヤーも上を向いてひっくり返ってしまうので、代入前にカメラの`rotation`のX軸とZ軸を0にしてあげてください。
      コードで書くとこんな感じになるかと思います。

      var playerDirection = mainCamera.transform.rotation;
      playerDirection.x = 0;
      playerDirection.z = 0;
      player.transform.rotation = playerDirection;

      riberunn  2019年10月22日 3:34 PM

  • 早速回答頂きありがとうございます!

    ANGLE  2019年10月22日 7:26 PM

  • こちらの記事でも冒頭のPhysics.SphereCastAll内でのLayer指定方法が
    LayerMask.NameToLayerとなっているので、.GetMaskに変更されたほうが良いかと思います。

    それと質問なのですが、Linqは知識が乏しいのでなんとなくでしか理解していないのですが、
    FilterTargetObjectメソッド内の.Where(h => h.tag == “Enemy”)
    の部分はLayerMaskの指定と合わせると

    「サーチ範囲のオブジェクトがLookOnTargetか判定し、その後画面内のTarget
    がEnemyかどうか判定する」といった処理でしょうか?

    monaca  2019年11月15日 1:49 PM

    • monacaさん

      度々ありがとうございます。こちらの記事も修正しました。

      > 「サーチ範囲のオブジェクトがLookOnTargetか判定し、その後画面内のTarget
      がEnemyかどうか判定する」といった処理でしょうか?

      こちらの認識で合っています。
      範囲内のロックオン可能なオブジェクトを拾ってきた後、LINQのWhere()で画面内の敵だけに絞り込むイメージですね。

      riberunn  2019年11月15日 9:05 PM

  • 返答ありがとうございます。
    認識が合っていてよかったです。

    これからも参考にさせていただきます。

    monaca  2019年11月18日 9:10 AM

down

コメントする