はるきちの雑記

対戦よろしくおねがいします

【Unity】敵をロックオンする機能を実装するまでの記録

前に3D半裸男を遊んでもらったときに、
「ロックオン機能が欲しい」
「敵のHPを表示させたら?」

みたいなアイデアをいただきました、ありがとうございます。ぜひ実装させていただきます。

実現したい機能としては、「敵をロックオンしてる間、カメラがその敵をずっと注視してHPバーを表示させる」みたいなものを実装させていこうと思います。
大まかな考え方の流れとしては、

  1. プレイヤーを中心に敵を探知する範囲を作成
  2. 敵がその範囲内に入っていたらロックオン対象リストに入れる
  3. 指定のキーを押すとリストの中からロックオンする敵を選ぶ
  4. カメラがロックオン対象の敵を注視する
  5. 注視している敵のHPを表示させる

みたいな感じでいこうかなーと思います。

1.プレイヤーを中心に敵を探知する範囲を作成
まずプレイヤーでもある半裸男にSphere Collierを追加します。

ここでは、追加したColliderのOnTriggerにチェックを入れてます。
そうすることで敵が範囲に触れたかどうかを判断したいと思います。

試しに範囲に触れたオブジェクト内に敵がいるかを判断するコードを書いてみました。

    private void OnTriggerEnter(Collider other)
    {
        if (other.gameObject.layer == 15)
        {
            Debug.Log("敵がいる");
        }
    }

上記のコードを半裸男にアタッチして、動作の確認をしてみました。

すこし見にくいんですけど、半裸男を中心に広がっている大きな円に白い四角のオブジェクトが触れると、画面下部に「敵がいる」と表示されています。
ここまでは想像通りに行ってるので、次は敵をロックオン対象に入れる処理を考えていこうと思います。

2.敵が範囲内に入っていたらロックオン対象リストに入れる
次に、ロックオン範囲内に敵が入っていたら、リストに追加する処理を実装しようと思います。
ぶっちゃけ自分は世に出回っているゲームのロックオン機能を隅々まで理解しているわけではないので、大まかな仕様としては、

 ・範囲外に出た敵はロックオン不可(ロックオン中の場合はロックオンが勝手に外れる)
 ・範囲内にいる間はロックオンの対象にする
 ・敵が複数いる場合、指定のキー操作でロックオン対象を切り替え可能

上記3点を基本として実装を進めていこうと思います。
敵が範囲内に入ったかの判断は少し前に確認できたので、次は範囲外に出たかを確認する処理を追加します。
とりあえずで書いたコードはこんな感じ

    private void OnTriggerExit(Collider other)
    {
        if(other.gameObject.layer == 15)
        {
            Debug.Log("敵が範囲外に出た");
        }
    }

それではまた実際に動かしてみます。

これまたちょっと見にくいんですけど、部屋の隅にいる四角いオブジェクトに一定範囲近づくとログが一行表示され、一定範囲離れるとまたログが一行追加で表示されていると思います。
なので、このままリストに追加・削除する処理を以下のように書きます

    private void OnTriggerEnter(Collider other)
    {
        // 範囲に触れたオブジェクトが敵の場合
        if (other.gameObject.layer == 15)
        {
            //Debug.Log("敵がいる");
            // ロックオン対象リストに追加
            targetList.Add(other.gameObject);
        }
    }

    private void OnTriggerExit(Collider other)
    {
        //範囲から出たオブジェクトが敵の場合
        if(other.gameObject.layer == 15)
        {
            //Debug.Log("敵が範囲外に出た");
            // ロックオン対象リストから削除
            targetList.Remove(other.gameObject);
        }
    }

これで範囲内・範囲外のロックオン対象リストの編集ができるようになりました。


3.指定のキーを押すとリストの中からロックオン対象を選ぶ
この調子でRキーを押すとロックオン開始、Qキーでターゲット切り替え処理を書いていきます。

   // ロックオン中フラグがfalseの状態でRキーを押した場合
        if (Input.GetKeyDown(KeyCode.R) && !IsLockOnNow && targetList.Count > 0)
        {        
            targetIndex = 0;
            // ロックオン中フラグをtrueにする
            IsLockOnNow = true;
            // リストのインデックスが0のオブジェクトを代入する
            targetObject = targetList[targetIndex];
            Debug.Log("ロックオン開始");
        }

        // ロックオン中フラグがtrueの状態
        // かつロックオン対象リストの数が2以上の状態でQキーを押した場合
        if(Input.GetKeyDown(KeyCode.Q) && IsLockOnNow && targetList.Count > 1)
        {
            // 現在ロックオンしてるオブジェクトのインデックスを取得
            int targetListNum = targetList.IndexOf(targetObject);
            // リストの最後のオブジェクトを取得する
            GameObject lastObject = targetList.Last();
            // 最後のオブジェクトのインデックスを取得
            int lastListNum = targetList.IndexOf(lastObject);

            // ロックオンしてるオブジェクトと最後のオブジェクトのインデックスが一致してる場合
            if(targetListNum == lastListNum)
            {
                targetIndex = 0;
                // インデックスが0のオブジェクトを代入する
                targetObject = targetList[targetIndex];
                Debug.Log(targetObject);
            }
            // ロックオンしてるオブジェクトと最後のオブジェクトのインデックスが一致してない場合
            else
            {
                // インデックス指定変数を+1する
                targetIndex = targetIndex + 1;
                // 次のインデックスにあるオブジェクトを代入する
                targetObject = targetList[targetIndex];
                Debug.Log(targetObject);
            }
        }

        // ロックオン中フラグがtrueの状態でFキーを押下した場合
        if(Input.GetKeyDown(KeyCode.F) && IsLockOnNow)
        {
            IsLockOnNow = false;
        }

急に論文並みの長文になってしまった。
上記のコードは敵が範囲外に出た場合を考慮してないので、次のような処理も追加します。

    private void OnTriggerExit(Collider other)
    {
        // 範囲から出たオブジェクトが敵の場合
        if(other.gameObject.layer == 15)
        {
            // ロックオン対象リストから削除
            targetList.Remove(other.gameObject);

            // ロックオン中の場合
            if (IsLockOnNow)
            {
                // ターゲット中の敵と範囲から出た敵の名前が同じ場合
                if(other.gameObject.name == targetObject.name)
                {
                    // ロックオン中フラグをfalseにする
                    IsLockOnNow = false;
                    Debug.Log("ロックオン終了");
                }
            }
        }
    }

先ほどの範囲外に出た際の処理に、ロックオン中の時の処理を追加しました。
IsLockOnNowとtargetObjectはカメラ制御で使う予定です。
いったんこれでロックオン対象リストは作成完了とします。

4.カメラがロックオン対象の敵を注視する
次にカメラ制御の実装に取り掛かろうと思います。
この記事にはある程度形になったものだけ載せますが、裏ではかなりうんうん唸りながら実装しました。


今回の機能追加にて、実現させたいカメラの動きとしては、
下の画像のように敵とプレイヤーの延長線上にカメラを置いて、敵もプレイヤーもカメラに映るようにします。

なので、図でいう①と②の位置がわかればカメラの位置もわかるのでは?ということです。

実際のカメラを制御するコードはこちら

        if (TargetLockOn.IsLockOnNow)
        {
            GameObject _targetObject = TargetLockOn.targetObject;
            // ロックオン対象からプレイヤーへのベクトルを算出
            Vector3 vectorTarget = _targetObject.transform.position - player.transform.position;
            // ロックオン対象へのベクトルを前方とするクォータニオンを取得
            Quaternion rotationTarget = Quaternion.LookRotation(vectorTarget);
            // ロックオン中のカメラの位置を指定
            Vector3 cameraPosition = rotationTarget * new Vector3(0, 2, -2);
            cameraObject.transform.position = player.transform.position + cameraPosition;

            // カメラが上下に回転しないようにする
            vectorTarget.y = 0f;
            // ロックオン対象に向くようなクォータニオンを取得
            Quaternion cameraRotation = Quaternion.LookRotation(vectorTarget);
            // カメラを回転させる
            cameraObject.transform.rotation = cameraRotation;
        }
        else
        {
            // カメラのy方向の高さを1.8に合わせる
            cameraObject.transform.position = new Vector3(cameraObject.transform.position.x, 1.8f, cameraObject.transform.position.z);
            // マウスの現在位置の座標を、移動前のマウスの座標で引いた値を代入
            float xRotate = xRotateParam * Input.GetAxis("Mouse X");
            // カメラをプレイヤーの位置座標を中心にして回転させる
            cameraObject.transform.RotateAround(player.transform.position, Vector3.up, xRotate);
        }

画像の①の部分はここで求めています。

            // ロックオン対象からプレイヤーへのベクトルを算出
            Vector3 vectorTarget = _targetObject.transform.position - player.transform.position;

②の部分はここ

            // ロックオン対象へのベクトルを前方とするクォータニオンを取得
            Quaternion rotationTarget = Quaternion.LookRotation(vectorTarget);
            // ロックオン中のカメラの位置を指定
            Vector3 cameraPosition = rotationTarget * new Vector3(0, 2, -2);

プレイヤーと敵のベクトルをもとにQuaternionを取得し、高さと後ろの距離をnew Vector3で指定することで、カメラの位置を決めています。
カメラの位置が決まったら、あとはロックオン対象の方にカメラが向くようすれば完成です。

ここまでの実装が完了したものがこれ

ロックオンが始まると、敵と半裸男の延長線上にカメラが常にいることが確認できますね。
しかも、ロックオン対象の切り替えもうまく行ってる様子です。
この調子で、最後はHPバーの表示をしましょう。


5.注視している敵のHPを表示させる
最後に敵キャラにHPバーをつけて、それをロックオン中は表示するようにします。
まずは敵の子要素にUI > スライダーを追加

Canvasを「EnemyHPUI」
Sliderを「EnemyHP」
命名
「Handle Slide Area」はいらないので非表示

EnemyHPUIのレンダーモードを「ワールド空間」に設定

これで上にある「Rect Transform」タブの中身がいじれるので、スライダーが敵の頭上に来るようEnemyHPUIの位置とスケールを調整する。
次にEnemyHPの子要素としてある「Background」、「Fill Area」、「Fill」の赤枠内を「0」にする。

そうすることでスライダーが左から右まで正常に表示されるようになる。
次に「Background」と「Fill」のソース画像を「None(なし)」に変更し、色をそれぞれ適当に決める

自分は「Background」の色を黒、「Fill」の色を赤に設定しました。
ここまでの設定が完了すれば、EnemyHPのインスペクター内にあるSliderの「Value」をいじればスライダーが下記の動画のように動作するはず

次にスクリプトからEnemyHPの制御をする。
まずはHPの変化させる関数。

    public void EnemyHPBarChange(float enemyHP)
    {
        enemyHPBar.value = enemyHP;
    }

これを敵のHPを計算してるクラスから呼び出し、そのときの引数をそのままHPバーに反映させてます。
ちなみに上のを呼び出す処理はこっち

    [SerializeField] private GameObject enemyObject;
    private EnemyHPUI eHPUI = new EnemyHPUI();

    void Start()
    {
        eHPUI = enemyObject.GetComponent<EnemyHPUI>();
    }

    private void OnCollisionEnter(Collision other)
    {
        // プレイヤーの出す弾に当たった場合
        if(other.gameObject.tag == "Bullet")
        {
            // 弾の威力だけ敵のHPを減らす
            enemyLife = enemyLife - bMove.bulletDamege;
            eHPUI.EnemyHPBarChange(enemyLife);
        }
    }

ちゃんとした原因はわからないけど、他のクラスの関数を呼び出すにはGetComponentしないとnullreferenceexceptionが出てしまったので、上記のようにコードを書いてます。

次にHPバーの表示の有無に関するコード

        // HPバーを常にカメラに向ける
        enemyUI.transform.LookAt(Camera.main.transform.position);
        if(enemyObject != null)
        {
            // ロックオン中でロックオンリストが1以上の場合
            if (TargetLockOn.IsLockOnNow && TargetLockOn.targetList.Count > 0)
            {
                // ロックオンしてる敵の名前が一致している場合
                if (TargetLockOn.targetObject.name == enemyObject.name)
                {
                    // HPバーを表示
                    enemyUI.SetActive(true);
                }
                // ロックオンしてる敵の名前が一致してない場合
                else if (TargetLockOn.targetObject.name != enemyObject.name)
                {
                    // HPバーを非表示
                    enemyUI.SetActive(false);
                }
            }
            else
            {
                // HPバーを非表示
                enemyUI.SetActive(false);
            }
        }

ひとことで言うと、ロックオン中ならHPバーを表示させる処理です。

あとHPバーとは関係ないんですけど、ロックオン中に敵が倒された場合のロックオン対象リストの削除に関するコードを載せておきます。

        List<GameObject> _targetList = targetList;
        // 順番にロックオンリストのオブジェクトを取得
        foreach (GameObject _gameObject in _targetList)
        {
            // リスト内のオブジェクトがnullではない場合
            if (_gameObject != null)
            {
                // リスト内のオブジェクトがactiveではない場合
                if (!_gameObject.activeInHierarchy)
                {
                    // Remove用変数にactiveではないオブジェクトを代入する
                    removeObject = _gameObject;
                    //_targetList.Remove(_gameObject);

                    // ロックオン中の場合
                    if (IsLockOnNow)
                    {
                        // ロックオン中のオブジェクトと名前が一致する場合
                        if (_gameObject.name == targetObject.name)
                        {
                            // ロックオンをやめる
                            IsLockOnNow = false;
                        }
                    }
                }
            }
            // リスト内のオブジェクトがnullの場合
            else
            {
                // Remove用変数にnullのオブジェクトを代入する
                removeObject = _gameObject;
                //_targetList.Remove(_gameObject);
            }
        }

        // removeObjectに入っているオブジェクトをリストから消す
        _targetList.Remove(removeObject);
        targetList = _targetList;

なんだこのif文の数は

ここまでごちゃごちゃしたコードになった原因としては、foreach文の中でリストの削除を行うと「InvalidOperationException」というエラーが起きたので、その対策になります。
InvalidOperationExceptionとは、「foreach文の中でリストの追加・削除をするな」とunityから怒られてしまっている状態です。
なので、一時的に削除対象になったオブジェクトを指定の変数の中に入れて、foreachの処理が終わったらRemoveで消すようにしました。

でもこれって削除対象が2つ以上発生するとremoveObject内の値が上書きされる気がするんだけど、大丈夫なんですかね??
もっと上手な方法がありそうだけど、テストプレイしてみた感じ特に不具合とかなかったんでこれでいきます。

長くなってしまったが、ここまでの実装が完了した様子がこれです

完成です。長かった~~~

まとめ
クソ疲れました。
今回作ったロックオン機能もある程度形にはなったと思うので、またほかのゲームとかにも使いまわせるのではと思います。
ただ今回作ったのも結構粗削りな部分もあるので、もっとスマートに実装できるのかな~と思います。
もしこのロックオン機能をアレンジするなら
 ・ロックオン開始時、一番近い敵をロックオンする
 ・現在ロックオンしてる敵がいなくなったら、次の敵を自動的にロックオンする
 ・ロックオンしてる敵が壁に隠れたら、ロックオンをやめる
 (もしくは敵のシルエットを表示させる)
など、いろいろ手の施しようがありそう

とりあえずこれでロックオン機能の実装を完了させたいと思います。