Skip to content

第02回 カメラ操作とコンポーネント分割

前回: 第01回 Input Systemとキャラクター操作 / 次回: 第03回 衝突判定とダメージ

前回の振り返り

プレイヤーの移動を実装

  • Input Systemを使い、入力をデバイス単位ではなく 移動や振り向きなどの Action 単位で読み取るようにしました
  • 2D入力の MoveVector2 で取得し、 X-Z平面の Vector3 に変換して移動させました
  • SerializeField を用いて、コード変更無しで 動作パラメーターを調整できるようにしました
  • ここまでの処理はすべて PlayerController 1つに集めていましたが 今回は機能ごとに分割して、より柔軟な設計にしていきます

今回のテーマ

カメラ基準の移動と 機能分割

入力・移動・カメラを 役割で分けて連携

今回の到達目標

コンポーネント志向を意識してカメラを実装する

画像

  • 操作対象に「カメラ」を追加し カメラの向きに合わせて移動方向を加工する
  • 注視対象・オフセット・左右と上下の回転を 扱うTPS型のカメラを作る
  • 入力・移動・カメラの機能を別々の コンポーネントに分割し、拡張可能な 設計手法を理解する

カメラを操作する

動かす対象が「プレイヤー」だけではない場合を考える

  • 前回はプレイヤーを動かすことだけを考え PlayerControllerに入力と移動の処理を書きました
  • 今回はそこに「カメラの操作」が加わります カメラは右スティックやマウスで操作可能とします
  • つまり同時に2つの対象を入力で制御することになります この場合、入力の受付はどこで行うべきでしょうか?

今回のカメラ

TPSと同じ作りにする

  • カメラは 注視ターゲット を中心に動きます
  • ターゲットの周りをぐるりと回る、肩越し視点のカメラです
  • ターゲットからの オフセット(距離と高さ)で位置を決めます
  • カメラ自身の 左右と上下の回転 で向きを変えます _yaw(Y軸まわり)が左右、_pitch(X軸まわり)が上下です

カメラ位置の決め方

ターゲットを中心に回す

  • 上下左右の回転軸での回転は Quaternion.Euler() で求めます
  • カメラ位置は target.position + 回転 * offset で決めます
  • 回転をオフセットに掛けることで、ターゲットの周りを公転します
  • _pitch は上下に動きすぎないよう範囲を制限します
  • プレイヤーの移動結果に追従するため LateUpdate で処理します Update で処理すると、プレイヤーの移動前の位置を基準に カメラが動いてしまう場合があります

プレイヤーとカメラの関係

プレイヤーがカメラを操作するのも悪くはないが

  • 多くの場合、ゲーム内でカメラはプレイヤーを追いかけるように動きます プレイヤーも、カメラの向きを基準に移動方向を決定します
  • こうして思えば、プレイヤーがカメラを操作しても問題はなさそうです しかし、プレイヤーのコードにカメラの操作を直接書いてしまうと プレイヤーとカメラが密に結合してしまいます
  • カメラには演出を扱う、レーダー視点を扱うなど、他の役割もあるかもしれません プレイヤーに直接紐づけるには、一考の余地があります

入力、プレイヤー、カメラを一つにまとめても

特に問題はない、だが潜在的な問題を孕む

  • 入力・プレイヤー移動・カメラの追従と回転を1つのクラスに書いても 十分見通しよく書きあげることは可能です
  • ですが、このために用意されたクラスは 入力・プレイヤー移動・カメラ追従と回転のすべてを同時に扱うことになります これらは本来、別々の役割を持つ機能です
  • カメラとプレイヤーの関係が密になりすぎると、修正や調整の際に 変更しなければならないコードの境界が曖昧になり、 またカメラだけ、プレイヤーだけの再利用やテストが難しくなります

「単一責任の原則」

機能別でコンポーネントを分割する

  • ここでは、以下のような役割でクラスを分けてみましょう
  • PlayerController … 入力の受け付けと解釈を行う司令塔
  • PlayerMove … 受け取った方向で移動と振り向きを行う
  • CameraMove … 注視対象を追従し、カメラの向きを操作する
  • 求められている機能に対してクラス数が多すぎるように感じるかもしれませんが 役割ごとに分けることで、各クラスのコードはシンプルになり、 テストや調整もやりやすくなります

コンポーネント志向

信頼できる小さな部品を組み合わせて大きな構造物を作る

  • 機能ごとに小さな部品(コンポーネント)に分けることで 各部品を単独で動かすことができるようにすれば テストや機能検証は部品単位で行うことができ、実装もシンプルになります
  • 信頼できる機能部品同士を接続することで、より複雑なソフトウエアを構築する 考え方を コンポーネント志向 と呼びます
  • 独立性の高いコンポーネントは再利用性も高くなります Unity の MonoBehaviour は、まさにこの考え方で設計されています

接続の全体像

入力はPlayerControllerに集約する

画像
コンポーネントの接続の様子

  • PlayerControllerMoveLook の入力をまとめて受け取ります
  • 解釈した結果を、それぞれの担当に渡します
text
[入力] Move / Look


PlayerController(受付・解釈)
   ├─ 回転入力   ─> CameraMove.Look(追従・向きの操作)
   ├─ 水平な向き <─ CameraMove.PlanarForward
   │               CameraMove.PlanarRight
   └─ 移動方向   ─> PlayerMove.Move(移動・振り向き)

PlayerMove

移動と方向の制御だけを担当する

  • PlayerMove はプレイヤーが 「どの方向に向かって動くか」だけを 扱うクラスとします
  • 入力やカメラのことは一切知りませんので 移動のロジックを単独でテストしたり、 演出でのプレイヤーの自動操縦などにも 利用できるようになります
cs
using UnityEngine;

public class PlayerMove : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float rotationSpeed = 720f;

    public void Move(Vector3 direction)
    {
        transform.position +=
            direction * moveSpeed * Time.deltaTime;

        if (direction == Vector3.zero)
            return;

        var targetRotation =
            Quaternion.LookRotation(direction);

        transform.rotation = Quaternion.RotateTowards(
            transform.rotation,
            targetRotation,
            rotationSpeed * Time.deltaTime
        );
    }
}

CameraMove の追加

注視対象を追従し カメラの向きを操作する

  • PlayerMove 同様に、カメラを動かす CameraMove が必要です
  • CameraMove は 「カメラが何を追い、どこを向くか」だけを 扱うクラスとします
  • 入力の読み取りやプレイヤー移動は行いません これにより、カメラの追従と回転だけを 単独で調整しやすくなります
cs
using UnityEngine;

public class CameraMove : MonoBehaviour
{
    [SerializeField] private Transform target;
    [SerializeField] private Vector3 offset = new Vector3(0f, 1.5f, -6f);
    [SerializeField] private float lookSpeed = 120f;
    [SerializeField] private float minPitch = 10f;
    [SerializeField] private float maxPitch = 75f;

    private float _yaw;
    private float _pitch = 60f;

    private Quaternion CameraRotation =>
        Quaternion.Euler(_pitch, _yaw, 0f);

    // PlayerControllerから回転入力を受け取る
    public void Look(Vector2 lookInput)
    {
        _yaw += lookInput.x * lookSpeed * Time.deltaTime;
        _pitch -= lookInput.y * lookSpeed * Time.deltaTime;
        _pitch = Mathf.Clamp(_pitch, minPitch, maxPitch);
    }

    private void LateUpdate()
    {
        transform.rotation = CameraRotation;
        transform.position =
            target.position + transform.rotation * offset;
    }
}

CameraMove をメインカメラにアタッチ

カメラをプレイヤーに追従させる

画像
メインカメラに CameraMove を追加した様子

  • CameraMove が書きあがったら Main Camera に追加します
  • CameraMove の target に Player の Transform を設定します
  • 以降、カメラはプレイヤーに追従するように なります

プレイヤーをカメラ基準で動かしたい

「上入力=画面の奥」にするためには

  • プレイヤーは画面上の前後左右を基準に操作方向を入力します
  • しかし入力値をそのままワールド座標の移動方向として使うと 画面の向きと移動方向が一致しないことになってしまいます
  • そのため入力方向を、カメラの向きを基準にした 実際の移動方向へ変換する必要があります
  • この変換のために必要な情報はカメラが持っていることになります ここではカメラに「前方向」と「右方向」を提供してもらうことにします

移動に使う向きを取り出す

カメラの姿勢から水平な軸を作る

  • CameraMoveは _yaw_pitch から、現在の回転を作ります
  • その回転を Vector3.forwardVector3.right に掛けることで カメラ基準の前方向と右方向を作成できます
  • ですが、カメラが上下方向に傾いている場合、 得られたベクトルは地面に対して斜めになってしまいます
  • プレイヤーの移動はX-Z平面(水平面)に限定したいので ベクトルから上下成分を取り除かなければなりません
  • 取り除いた後は長さが変わるため、長さを1に戻して正規化します これでカメラの向きを基準にした水平な前方向と右方向が得られます

CameraMove に水平軸を求めさせる

y成分を0にして正規化する

  • カメラは「移動の基準になる水平な向き」を 外へ提供することにします
  • 今回の地面はXZ平面で水平なので y = 0f で水平面に投影したのち normalized で長さを1に正規化します
  • ※今回は CameraMove が地面に対して 水平な前方向・右方向を提供していますが 計算処理を別の場所に移してもよいでしょう
cs
// CameraMove に追加する

public Vector3 PlanarForward
{
    get
    {
        var forward = CameraRotation * Vector3.forward;
        forward.y = 0f;
        return forward.normalized;
    }
}

public Vector3 PlanarRight
{
    get
    {
        var right = CameraRotation * Vector3.right;
        right.y = 0f;
        return right.normalized;
    }
}

01から変更されたPlayerController

cs
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [SerializeField] private PlayerMove playerMove;
    [SerializeField] private CameraMove cameraMove;

    private InputSystem_Actions _inputActions;

    private void Awake()
    {
        _inputActions = new InputSystem_Actions();
    }

    private void OnEnable()
    {
        _inputActions.Player.Enable();
    }

    private void OnDisable()
    {
        _inputActions.Player.Disable();
    }
cs
    private void Update()
    {
        var moveInput =
            _inputActions.Player.Move.ReadValue<Vector2>();
        var lookInput =
            _inputActions.Player.Look.ReadValue<Vector2>();

        // カメラを回す
        cameraMove.Look(lookInput);

        // カメラの向きを基準に移動方向を作る
        var forward = cameraMove.PlanarForward;
        var right = cameraMove.PlanarRight;
        var direction =
            forward * moveInput.y + right * moveInput.x;
        direction =
            Vector3.ClampMagnitude(direction, 1f);

        // 実際の移動はPlayerMoveに任せる
        playerMove.Move(direction);
    }
}

今回追加されたPlayerMove

移動と振り向きを担当する

  • 入力やカメラから切り離され 指定された方向へプレイヤーを動かすこと だけを担当します
  • 代わりに速度や振り向きなどは PlayerMove の 担当になります
  • 今後アニメーションの制御などが追加される 場合は、ここに追加されることになるでしょう
cs
using UnityEngine;

public class PlayerMove : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float rotationSpeed = 720f;

    public void Move(Vector3 direction)
    {
        transform.position +=
            direction * moveSpeed * Time.deltaTime;

        if (direction == Vector3.zero)
            return;

        var targetRotation =
            Quaternion.LookRotation(direction);

        transform.rotation = Quaternion.RotateTowards(
            transform.rotation,
            targetRotation,
            rotationSpeed * Time.deltaTime
        );
    }
}

今回追加されたCameraMove

cs
using UnityEngine;

public class CameraMove : MonoBehaviour
{
    [SerializeField] private Transform target;
    [SerializeField] private Vector3 offset =
        new Vector3(0f, 1.5f, -6f);
    [SerializeField] private float lookSpeed = 120f;
    [SerializeField] private float minPitch = 10f;
    [SerializeField] private float maxPitch = 75f;

    private float _yaw;
    private float _pitch = 60f;

    private Quaternion CameraRotation =>
        Quaternion.Euler(_pitch, _yaw, 0f);

    public void Look(Vector2 lookInput)
    {
        _yaw += lookInput.x * lookSpeed * Time.deltaTime;
        _pitch -= lookInput.y * lookSpeed * Time.deltaTime;
        _pitch = Mathf.Clamp(_pitch, minPitch, maxPitch);
    }
cs
    public Vector3 PlanarForward
    {
        get
        {
            var forward = CameraRotation * Vector3.forward;
            forward.y = 0f;
            return forward.normalized;
        }
    }
    public Vector3 PlanarRight
    {
        get
        {
            var right = CameraRotation * Vector3.right;
            right.y = 0f;
            return right.normalized;
        }
    }

    private void LateUpdate()
    {
        transform.rotation = CameraRotation;
        transform.position =
            target.position + transform.rotation * offset;
    }
}

密結合を避ける

疎結合にするメリット

  • PlayerMove は入力やカメラの処理から切り離されています 単独で動作させられますので、イベントシーンでの自動操縦などにも利用できます
  • CameraMove も、カメラの回転や移動の操作を提供しているだけです 入力の対応は PlayerController の仕事なので、 操作方法が変更されてもカメラのコードは変えずに済みます
  • カメラの実装を差し替えてもプレイヤーの移動側のコードは変えずに済みますし プレイヤーの操作方法を変えてもカメラのコードは変えずに済みます
  • このように、互いの部品のつながりが疎であれば、各部品を単独で 運用することができ、テストも実装もしやすくなります

参照の解決

動的な解決と静的な解決

  • 参照の解決方法には、コード上で動的に解決する方法と インスペクターで静的に割り当てる方法があります
  • 動的な解決は、GetComponent() などでコード上で参照を解決する方法です コードを見ただけで必要なコンポーネントがわかりますが オブジェクトの階層構造の変更に弱く、目的となるコンポーネントを 確実に取得できない場合があるため、利用には注意が必要です
  • 静的な解決は、SerializeField を用いてインスペクターから指定する方法です 確実に参照を指定できますが、なんでも割り当てられる分どこに依存しているか コード上からはわからず、プログラムも混乱しやすくなります

GetComponent() の利用

RequireComponent を併用する

  • GetComponent() は、同じ GameObject の コンポーネントをコードから取得する関数です
  • 取得するコンポーネントは同じ GameObject に 存在する必要があります
  • RequireComponent 属性を併用すると対象の コンポーネントを Unity が自動で追加します
  • 取得の失敗を考慮せずに済むようになります ので、併用を強くおすすめします
cs
// 必要なコンポーネントをコード上で明示する
[RequireComponent(typeof(PlayerMove))]
public class PlayerController : MonoBehaviour
{
    private PlayerMove _playerMove;

    private void Awake()
    {
        _playerMove = GetComponent<PlayerMove>();
    }

    private void Update()
    {
        // 中略

        // 取得したコンポーネントの機能を利用する
        _playerMove.Move(direction);
    }
}

親子関係を辿る GetComponent

階層構造を利用して参照を解決する

  • 親子関係を辿ってコンポーネントを取得する関数も存在します
  • GetComponentInParent() は親子関係を上に辿り、 GetComponentInChildren() は親子関係を下に辿ってコンポーネントを取得します
  • これらの関数は階層構造を利用して参照を解決することができますが 最初に見つけたコンポーネントを返すため、階層構造の変更によって 結果が変わってしまう可能性があります
  • 特定のオブジェクトのコンポーネントを狙い撃ちで取得したい場合は SerializeField でインスペクターから割り当てる方が安全です

複数のコンポーネントの同時取得

GetComponents 系関数の利用

  • GetComponents()GetComponentsInParent()GetComponentsInChildren() は 複数のコンポーネントを同時に取得することができます
  • 対象となる型のコンポーネントをすべて取得できるため、 例えばUI部品のすべてのテキストを取得して 一括で変更するなどの用途に利用できます
  • 特定のコンポーネントを取得する目的で利用する関数ではありませんが GetComponentsInChildren() は配下のコンポーネントをまとめて 制御したい場合特に便利です

SerializeField の使い過ぎ問題

実は危険な SerializeField

  • Unity では SerializeField を使うことで、コードの変更無しで インスペクターからパラメーターを調整することができます
  • パラメーターに限らず、参照するコンポーネントも SerializeField を使って インスペクターから割り当てることができます
  • ですが自由に割り当てられる分、コンポーネントの動作に必要な参照が何かを コードからは把握できなくなってしまうことがあります
  • 例えば PlayerControllerPlayerMoveCameraMove を参照していますが CameraMove は暗黙のうちに登場しており、コードだけでは所在がわかりません

SerializeField の上手な使い方

参照を Prefab 内で完結させる

  • SerializeField でのオブジェクト参照は、 Prefab 内で完結させる のがひとつの目安になります
  • GetComponent() 系で動的に解決することもできますが、 失敗する可能性や、階層変更の影響を考慮する必要があります
  • Prefab 内で参照が完結していれば参照先をセットで管理できますし、 階層構造の変更などで参照が失われることもありません
  • SerializeField を用いる際は、 参照はなるべく Prefab 内で完結させるのが安全です

参照を用いるコードの注意点

参照先が存在しない場合、取得に失敗する場合を考慮する

  • 参照を用いるコードは、参照先が存在しない場合の挙動を考慮する必要があります
  • GetComponent() で動的に解決する場合は、 目的のコンポーネントが存在しない可能性を考慮する必要があります
  • 目的のコンポーネントがまだ初期化されていない場合も考えなければなりません Awake() より前に呼び出されるコードで GetComponent() を利用する場合は 特に注意が必要です
  • 加えて、GetComponentInParent()GetComponentInChildren() を利用して 解決する場合は、階層の変更によって目的のコンポーネントが取得できなくなったり、 意図しないコンポーネントが取得される可能性も考慮する必要があります

参照の解決方法の選択

動的な解決と静的な解決を適切に使い分ける

  1. 同じオブジェクトに存在するコンポーネントを参照する場合は、 GetComponent()RequireComponent の併用で解決する
  2. 特定の参照が必要な際は SerializeField で静的に解決するが 参照先は可能な限り Prefab 内で完結させる
  3. 参照の解決に階層構造を利用する場合は GetComponentInParent()GetComponentInChildren() を使用するが、失敗や誤取得の可能性を考慮する
  • どの方法が適切かは状況によりますが、上のリストの順に選択するのが おすすめです

実習

カメラ操作を分割設計で実装する

  1. Look Actionを追加する(型は Vector2
  2. PlayerMove を作り、Playerに追加する
  3. CameraMove を作り、Main Cameraに追加する
  4. CameraMovetarget にPlayerのTransformを設定する
  5. PlayerController を更新し、playerMovecameraMove を参照する
  6. Inspectorで playerMovecameraMove を割り当てる
  7. カメラ回転後も入力方向が自然か確認する

よくある確認ポイント

動かないときは順番に見る

  • Look Actionの型が Vector2 になっているか
  • playerMovecameraMove がInspectorで割り当て済みか
  • CameraMovetarget がPlayerになっているか
  • offset が近すぎたり、pitch の範囲が狭すぎたりしないか
  • カメラがプレイヤーの移動後に追従しているか(LateUpdate
  • 移動方向が斜め下を向いていないか(y を0にしたか)

今日のポイント

機能を分けて、つないで、組み上げる

  • 機能を役割ごとのコンポーネントに分ける
  • 入力の受付と解釈は PlayerController に集約する
  • 移動は PlayerMove、カメラは CameraMove が担当する
  • 部品同士は「必要な情報」だけを参照でつなぐ
  • カメラは移動基準になる地面に水平なベクトルを提供する

提出物の要件

動作確認項目

  • WASDまたは左スティックで移動できる
  • マウスまたは右スティックでカメラを操作できる
  • カメラの向きを基準に前後左右が決まる
  • PlayerController / PlayerMove / CameraMove に分割している
  • playerMovecameraMove をInspectorから参照している
  • CameraMove がプレイヤーを追従している

おつかれさまでした!

次回予告 第03回 衝突判定とダメージ

またゲームに 一歩近づいた