📹

[Unity] ソウルシリーズのようなロックオンカメラを実装したい

2023/10/22に公開

前回記事
https://zenn.dev/aruk_vs/articles/79e779ae0d4a5b

UnityのCinemachineを用いてフロムソフトウェアのソウルシリーズのようなロックオンカメラを実装します。
と言ってもその大半は以下の記事を参考に作成させて頂いています。
https://raspberly.hateblo.jp/entry/UnityAdventCalendar2022_24

参考記事ではロックオン中でのターゲット切り替えなどいくつかの改善を図っています。

仕様

  • 通常時はFreeLookCameraを使用(作成記事)
  • ロックオンボタンを押すとプレイヤーの周囲をspherecastで検索し、"Enemy"のレイヤーを持ったゲームオブジェクトの内いずれか一つをターゲットに据える
  • ロックオンボタンはinput Actionで管理。キーボードのRキー若しくはゲームパッドの右スティック
  • ターゲットの選別方法は画面中心に近く、プレイヤーとの距離が近いものが優先される
  • プレイヤーとターゲットの間に"Field"レイヤーを持つオブジェクトが存在する場合、ターゲットから除外される("Field"レイヤーは障害物となる)
  • ロックオン時に専用のVirtualCameraに移動。ターゲットを画面中心付近に据える
  • ロックオン中、プレイヤーはRun時を除いてターゲットに向き続ける
  • ロックオン中、マウスや右スティックを左右に入力するとその方向に存在するターゲットにロックオン対象が変更される(その方向にいない場合は変更されない)
  • ロックオン中、ターゲットを中心に一定の範囲から離れると自動的にロックオンが切れる
  • ロックオン→通常時に変更されるとき、カメラはロックオン中の角度を維持する

ゲームオブジェクトの作成

ロックオンに用いるカメラを作成します。hierarchy上でCreate→Cinemachine→VirtualCameraと進み、作成されたロックオンカメラの優先度を0、Follow欄にプレイヤーオブジェクトを選択します。

ターゲット対象となるオブジェクトの子にロックオンカーソルを表示する空オブジェクトを表示し、レイヤーに"Enemy"を付与する。

またロックオンカーソルの表示のためCanvas作成、その子にimageを作成、非アクティブにします。

カメラを制御するScript

ロックオンカメラを制御するScriptを書いていきます。必要なScriptは新しいものを二つ、加えて前回の記事で用いたプレイヤーの移動面等を司るController.cssに変更点を加えたものを用意します。

  • PlayerLockon.cs: ターゲットの検索を含めたロックオン機能を制御する。
  • PlayerCamera.cs: 通常時のカメラとロックオンカメラの切り替えを制御する。
  • Controller.cs: 通常、ロックオン時にプレイヤーの向きの処理を制御する。

PlayerLockon
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

/// <summary>
/// プレイヤーのロックオン機能の制御するクラス
/// </summary>
public class PlayerLockon : MonoBehaviour
{
    [SerializeField] PlayerCamera playerCamera;
    [SerializeField] Transform originTrn;
    [SerializeField] float lockonRange = 20;
    [SerializeField] LayerMask lockonLayers = 0;
    [SerializeField] LayerMask lockonObstacleLayers = 0;
    [SerializeField] GameObject lockonCursor;

    float lockonFactor = 0.3f;
    float lockonThreshold = 0.5f;
    bool lockonInput = false;
    public bool isLockon = false;

    Camera mainCamera;
    Transform cameraTrn;
    GameObject targetObj;

    void Start()
    {
        mainCamera = Camera.main;
        cameraTrn = mainCamera.transform;
    }

    void Update()
    {
        if (lockonInput)
        {
            //Debug.Log("isLockon: " + isLockon);
            // すでにロックオン済みなら解除する
            if (isLockon)
            {
                isLockon = false;
                lockonCursor.SetActive(false);
                lockonInput = false;
                playerCamera.InactiveLockonCamera();
                targetObj = null;
                return;
            }

            // ロックオン対象の検索、いるならロックオン、いないならカメラ角度をリセット
            targetObj = GetLockonTarget();
            //Debug.Log("tagetObj: " + targetObj);
            if (targetObj)
            {
                isLockon = true;
                playerCamera.ActiveLockonCamera(targetObj);
                lockonCursor.SetActive(true);
            }
            else
            {
                playerCamera.ResetFreeLookCamera();
            }
            lockonInput = false;
        }

        // ロックオンカーソル
        if (isLockon)
        {
            lockonCursor.transform.position = mainCamera.WorldToScreenPoint(targetObj.transform.position);
            float lookAtDistance = Vector3.Distance(playerCamera.GetLookAtTransform().position, originTrn.position);
            if (lookAtDistance > lockonRange)
            {
                isLockon = false;
                lockonCursor.SetActive(false);
                lockonInput = false;
                playerCamera.InactiveLockonCamera();
                targetObj = null;
                return;
            }
        }
    }

    public void OnLockon(InputAction.CallbackContext context)
    {
        switch (context.phase)
        {
            case InputActionPhase.Performed:
                // ボタンが押された時の処理
                lockonInput = true;
                //Debug.Log("lockonInput: " + lockonInput);
                break;

            case InputActionPhase.Canceled:
                // ボタンが離された時の処理
                break;
        }
    }

    /// <summary>
    /// ロックオン対象の計算処理を行い取得する
    /// 計算は3つの工程に分かれる
    /// </summary>
    /// <returns></returns>
    GameObject GetLockonTarget()
    {
        // 1. SphereCastAllを使ってPlayer周辺のEnemyを取得しListに格納
        RaycastHit[] hits = Physics.SphereCastAll(originTrn.position, lockonRange, Vector3.up, 0, lockonLayers);

        if (hits?.Length == 0)
        {
            // 範囲内にターゲットなし
            return null;
        }

        // 2. 1のリスト全てにrayを飛ばし射線が通るものだけをList化
        List<GameObject> hitObjects = makeListRaycastHit(hits);
        if (hitObjects?.Count == 0)
        {
            // 射線が通ったターゲットなし
            return null;
        }

        // 3. 2のリスト全てのベクトルとカメラのベクトルを比較し、画面中央に一番近いものを探す
        var tumpleData = GetOptimalEnemy(hitObjects);

        float degreemum = tumpleData.Item1;
        GameObject target = tumpleData.Item2;

        //// 求めた一番小さい値が一定値より小さい場合、ターゲッティングをオンにします
        if (Mathf.Abs(degreemum) <= lockonThreshold)
        {
            return target;
        }
        return null;
    }

    bool frag = true;
    public void OnCameraXY(InputAction.CallbackContext context)
    {
        var inputValue = context.ReadValue<Vector2>();
        if (isLockon)
        {
            if (frag)
            {
                if (inputValue.x > 0.95f)
                {
                    Debug.Log("right: " + inputValue.x);
                    frag = false;
                    GameObject rightEnemy = GetLockonTargetLeftOrRight("right");
                    if (rightEnemy != null)
                    {
                        targetObj = rightEnemy;
                        playerCamera.ActiveLockonCamera(targetObj);
                        lockonCursor.SetActive(true);
                    }

                }
                else if (inputValue.x < -0.95f)
                {
                    Debug.Log("left: " + inputValue.x);
                    frag = false;
                    GameObject leftEnemy = GetLockonTargetLeftOrRight("left");
                    if (leftEnemy != null)
                    {
                        targetObj = leftEnemy;
                        playerCamera.ActiveLockonCamera(targetObj);
                        lockonCursor.SetActive(true);
                    }
                }
            }
        }
        if (context.canceled)
        {
            frag = true;
        }
    }

    // 2. 1のリスト全てにrayを飛ばし射線が通るものだけをList化
    // Raycastの発射位置によっては自モデルに当たって遮蔽物扱いされる場合がある
    private List<GameObject> makeListRaycastHit(RaycastHit[] hits)
    {
        List<GameObject> hitObjects = new List<GameObject>();
        RaycastHit hit;
        for (var i = 0; i < hits.Length; i++)
        {
            var direction = hits[i].collider.gameObject.transform.position - (originTrn.position);

            if (Physics.Raycast(originTrn.position, direction, out hit, lockonRange, lockonObstacleLayers))
            {
                if (hit.collider.gameObject == hits[i].collider.gameObject)
                {
                    hitObjects.Add(hit.collider.gameObject);
                }
            }
            //Debug.DrawRay(originTrn.position, direction * lockonRange, Color.red);
        }
        return hitObjects;
    }

    // 3. 2のリスト全てのベクトルとカメラのベクトルを比較し、画面中央に一番近いものを探す
    // degreep: カメラの前方ベクトルX,Z成分からなる角度
    private (float, GameObject) GetOptimalEnemy(List<GameObject> hitObjects)
    {
        float degreep = Mathf.Atan2(cameraTrn.forward.x, cameraTrn.forward.z);
        float degreemum = Mathf.PI * 2;
        GameObject target = null;

        foreach (var enemy in hitObjects)
        {
            // pos: 敵からカメラへ向けたベクトル
            // pos2: カメラから敵に向けたベクトル(水平方向に制限して正規化)
            Vector3 pos = cameraTrn.position - enemy.transform.position;
            Vector3 pos2 = enemy.transform.position - cameraTrn.position;
            pos2.y = 0.0f;
            pos2.Normalize();

            // degree: pos2のX,Z成分からなる角度. カメラの前方からどれだけ回転しているか
            float degree = Mathf.Atan2(pos2.x, pos2.z);
            // degreeを-180°~180°に正規化
            degree = degreeNormalize(degree, degreep);

            // pos.magnitude: 敵とカメラの距離
            // pos.magnitudeに応じて角度に重みをかけ、距離が近いほど角度の重みが大きく選好される
            degree = degree + degree * (pos.magnitude / 500) * lockonFactor;
            // Mathf.Abs(degreemum): 以前に記録された最小角度差の絶対値
            // Mathf.Abs(degree): 現在の角度差の絶対値
            if (Mathf.Abs(degreemum) >= Mathf.Abs(degree))
            {
                degreemum = degree;
                target = enemy;
            }
        }
        return (degreemum, target);
    }

    // degreeを-180°~180°に正規化
    private float degreeNormalize(float degree, float degreep)
    {
        if (Mathf.PI <= (degreep - degree))
        {
            // degreep (カメラの前方ベクトル) とdegree (カメラから敵へのベクトル) との角度差が180°以上
            // degreeから360°引いて正規化(-180から180に制限)
            degree = degreep - degree - Mathf.PI * 2;
        }
        else if (-Mathf.PI >= (degreep - degree))
        {
            // degreep (カメラの前方ベクトル) とdegree (カメラから敵へのベクトル) との角度差が-180°以下
            // degreeから360°足して正規化(-180から180に制限)
            degree = degreep - degree + Mathf.PI * 2;
        }
        else
        {
            // そのままdegreeを使用
            degree = degreep - degree;
        }
        return degree;
    }
    
    // マウス、右スティック入力時の処理
    private GameObject GetLockonTargetLeftOrRight(string direction)
    {
        float degreemum;
        GameObject target;
        //GameObject current = null;
        // 1. SphereCastAllを使ってPlayer周辺のEnemyを取得しListに格納
        RaycastHit[] hits = Physics.SphereCastAll(originTrn.position, lockonRange, Vector3.up, 0, lockonLayers);
        // 2. 1のリスト全てにrayを飛ばし射線が通るものだけをList化
        List<GameObject> hitObjects = makeListRaycastHit(hits);
        // 3. 2のリスト全てのベクトルとカメラのベクトルを比較し、画面中央に一番近いものを探す
        if (direction.Equals("left"))
        {
	    // 左入力時
            var tumpleData = GetEnemyLeftOrRight(hitObjects, "left");
            degreemum = tumpleData.Item1;
            target = tumpleData.Item2;
        }
        else
        {
	    // 右入力時
            var tumpleData = GetEnemyLeftOrRight(hitObjects, "right");
            degreemum = tumpleData.Item1;
            target = tumpleData.Item2;
        }
        return target;
    }

    private (float, GameObject) GetEnemyLeftOrRight(List<GameObject> hitObjects, string direction)
    {
        float degreep = Mathf.Atan2(cameraTrn.forward.x, cameraTrn.forward.z);
        float degreemum = Mathf.PI * 2;
        GameObject target = null;
        //Vector3 currentLookAt = playerCamera.GetLookAtPosition();

        foreach (var enemy in hitObjects)
        {
            if (enemy == targetObj)
            {
                continue;
            }
            // pos: 敵からカメラへ向けたベクトル
            // pos2: カメラから敵に向けたベクトル(水平方向に制限して正規化)
            Vector3 pos = cameraTrn.position - enemy.transform.position;
            Vector3 pos2 = enemy.transform.position - cameraTrn.position;
            pos2.y = 0.0f;
            pos2.Normalize();

            // degree: pos2のX,Z成分からなる角度. カメラの前方からどれだけ回転しているか
            float degree = Mathf.Atan2(pos2.x, pos2.z);
            // degreeを-180°~180°に正規化
            degree = degreeNormalize(degree, degreep);
            if (direction.Equals("left"))
            {
                if (degree < 0)
                {
		    // enemyが画面中央より右側にいる場合候補から外す
                    continue;
                }
            }
            else
            {
                if (degree > 0)
                {
		    // enemyが画面中央より左側にいる場合候補から外す
                    continue;
                }
            }
            // pos.magnitude: 敵とカメラの距離
            // pos.magnitudeに応じて角度に重みをかけ、距離が近いほど角度の重みが大きく選好される
            degree = degree + degree * (pos.magnitude / 500) * lockonFactor;
            // Mathf.Abs(degreemum): 以前に記録された最小角度差の絶対値
            // Mathf.Abs(degree): 現在の角度差の絶対値
            if (Mathf.Abs(degreemum) >= Mathf.Abs(degree))
            {
                degreemum = degree;
                target = enemy;
            }
        }
        return (degreemum, target);
    }

    public Transform GetLockonCameraLookAtTransform()
    {
        return playerCamera.GetLookAtTransform();
    }

}
PlayerCamera
using UnityEngine;
using Cinemachine;

/// <summary>
/// CinemachineVirtualCameraを制御するクラス
/// </summary>
public class PlayerCamera : MonoBehaviour
{
    [SerializeField] Camera mainCamera;
    [SerializeField] CinemachineFreeLook freeLookCamera;
    [SerializeField] CinemachineVirtualCamera lockonCameral;
    readonly int LockonCameraActivePriority = 11;
    readonly int LockonCameraInactivePriority = 0;

    public void Update() { }

    /// <summary>
    /// カメラの角度をプレイヤーを基準にリセット
    /// </summary>
    public void ResetFreeLookCamera()
    {
        // 未実装
    }


    /// <summary>
    /// ロックオン時のVirtualCamera切り替え
    /// </summary>
    /// <param name="target"></param>
    public void ActiveLockonCamera(GameObject target)
    {
        lockonCameral.Priority = LockonCameraActivePriority;
        lockonCameral.LookAt = target.transform;
    }


    /// <summary>
    /// ロックオン解除時のVirtualCamera切り替え
    /// </summary>
    public void InactiveLockonCamera()
    {
        lockonCameral.Priority = LockonCameraInactivePriority;
        lockonCameral.LookAt = null;
    }

    public Transform GetLookAtTransform()
    {
        return lockonCameral.LookAt.transform;
    }
}
Controller
using Mebiustos.MMD4MecanimFaciem;
using UnityEngine;
using UnityEngine.InputSystem;

public class Controller : MonoBehaviour
{
    private float horizontalInput, verticalInput;
    [SerializeField] float moveSpeed = 2f;
    private float currentSpeed = 5f;
    [SerializeField] float walkSpeed = 2f;
    [SerializeField] float jogSpeed = 5f;
    [SerializeField] float runSpeed = 10f;
    private Rigidbody playerRb;

    public Animator _animator;
    private bool walkInput = false;
    private bool runOn = false;
    private bool jogOn = true;
    private bool landingOn = false;
    private bool flying = false;
    public bool onGround = true;
    private const float RotateSpeed = 900f;
    private const float RotateSpeedLockon = 500f;
    public GroundCheck3D rightGround, leftGround;

    private float start, elapsedTime;
    InputAction move;
    PlayerLockon playerLo;
    void Start()
    {
        playerRb = GetComponent<Rigidbody>();
        playerLo = GetComponent<PlayerLockon>();

        var playerInput = GetComponent<PlayerInput>();
        move = playerInput.actions["Move"];
    }

    void Update(){----}

    void FixedUpdate()
    {
        // カメラの方向から、X-Z平面の単位ベクトルを取得
        Vector3 cameraForward = Vector3.Scale(Camera.main.transform.forward, new Vector3(1, 0, 1)).normalized;
        // 方向キーの入力値とカメラの向きから、移動方向を決定
        Vector3 moveForward = cameraForward * verticalInput + Camera.main.transform.right * horizontalInput;
        if (onGround)
        {
            moveSpeed = currentSpeed;
        }

        // 移動方向にスピードを掛ける。ジャンプや落下がある場合は、別途Y軸方向の速度ベクトルを足す。
        playerRb.velocity = moveForward * moveSpeed + new Vector3(0, playerRb.velocity.y, 0);

        _animator.SetBool("input", walkInput);
        _animator.SetBool("jogOn", jogOn);
        if (onGround)
        {
            _animator.SetBool("runOn", runOn);
        }
        _animator.SetBool("flying", flying);
        _animator.SetBool("onGround", onGround);

        // キャラクターの向きを進行方向に
        if (moveForward != Vector3.zero)
        {
            if (playerLo.isLockon && !runOn)
            {
	        // ロックオンかつ非Run時はターゲットに向き続ける
                Quaternion from = transform.rotation;
                var dir = playerLo.GetLockonCameraLookAtTransform().position - transform.position;
                dir.y = 0;
                Quaternion to = Quaternion.LookRotation(dir);
                transform.rotation = Quaternion.RotateTowards(from, to, RotateSpeedLockon * Time.deltaTime);
            }
            else
            {
                Quaternion from = transform.rotation;
                Quaternion to = Quaternion.LookRotation(moveForward);
                transform.rotation = Quaternion.RotateTowards(from, to, RotateSpeed * Time.deltaTime);
            }
        }

    }

    public void OnMove(InputAction.CallbackContext context) { }

    public void OnJump(InputAction.CallbackContext context){----}

    public void OnJog(InputAction.CallbackContext context){----}

    public void OnRun(InputAction.CallbackContext context){----}

    private void OnCollisionEnter(Collision other){----}

    private void setti(){----}
}

Scriptをプレイヤーに追加

作成したScriptをプレイヤーに追加、変数に必要なオブジェクト等を選択します。

  • PlayerCamera
    • Main Camera: Cinemachine Brainを持つMain Camera
    • Free Look Camera: 通常時のFreeLookCamera
    • Lockon Camera: ロックオン時のVirtualCamera
  • PlayerLockon
    • PlayerCamera: PlayerCameraを持つプレイヤーオブジェクト
    • Orijin Trn: ロックオンレイキャストの発射Transform(画像では専用の物を作ったがプレイヤーオブジェクトならどこでも良い)
    • Lockon Range: ロックオンを行う最大距離
    • Lockon Layers: ロックオン対象のレイヤー
    • Lockon Obstacle Layers: ↑のレイヤーに加え、障害物でロックオンを阻害する場合はそのレイヤーを含める
    • Lockon Cursor: Canvasの子に作成したロックオンカーソル

input Actionにカメラ関係を追加

Rキー、もしくは右スティックを押し込むとロックオンターゲットの検索を行う。
またロックオン中にマウスを左右に動かす若しくは右スティックを左右に倒すとターゲットの切り替えを行う。
前回作成したinputActionアセットを開き、ロックオン用のActionsを追加、bindingする。

プレイヤーオブジェクトの"Player Input"を開き、イベントのLockonに"OnLockon"を選択、Camera XYにロックオン時のターゲット切り替え用メソッド"OnCameraXY"を選択します。

容量の制限で見づらいですが、ロックオンボタンを押すとターゲットの検索を行い、右スティックでターゲットの切り替えが可能になっています。

Discussion