第5章 プレイヤーの開発

本章では、プレイヤーキャラクター(以降、プレイヤー)を実装するための仕様と、ゲームシステムの利用方法を解説します。本章の内容をもとに、実装してみてください。

5.1 プレイヤーの機能

ゲームシステムのときと同様に、サムネイル(図5.1)や、ゲーム画面を見ながら、詳細を検討します。

ゲーム画面のサムネイル

図5.1: ゲーム画面のサムネイル

ゲーム画面のモックアップ

図5.2: ゲーム画面のモックアップ

画面イメージを眺めながら、ゲームが動いている様子を想像して、プレイヤーの機能を具体化していきます。ゲーム画面が表示される演出から想像をはじめて、操作した時の反応、他のキャラクターと衝突したときに起きることなど、プレイヤーに関連する動作をすべて書き出します。

  • ゲームが開始したら、既定のスタート地点に配置
  • スタートのカウントダウン中は、操作不可
  • カウントダウンが終わったら、操作できるようになる
  • キー入力、マウス移動、ゲームパッドで操作した方向へ移動
  • マウス操作は、移動量に応じた速度で移動。速度には、上限を設定する
  • アナログスティックは、スティックを倒す量に応じて、速度を変える。最大に倒したときに、マウスの最高速度
  • キーやD-padでの移動は、常に既定の速度で移動
    • 企画構想時点では考えていなかったが、これだとマウスやアナログスティックに対して、制約が大きい。試遊した上で、必要であれば、ShiftキーやBボタンを押しながら移動すると、最高速度で移動できる機能を検討する
  • 岩や木箱があると、先に進めなくなる。押すこともできない
  • 爆弾に触れたら
    • 爆弾が爆発
    • ゲームオーバーへ遷移
    • 操作を停止
    • コンティニューが選ばれたら、スタート地点へ戻す
    • タイトルへ戻るなら、何もせずにシーンが切り替わるのを待つ
  • コインに触れたら、
    • コインの基本点に、残りの秒数をかけた点数を獲得する
    • コインをすべて取っていたら、クリアへ遷移
    • 操作を停止して、シーンが切り替わるまで何もしない

これらが実装できれば、プレイヤーは完成します。

5.2 プレイヤーの実装方法の検討

今回のようなシンプルなプレイヤーなら、1つのクラスにすべての機能を実装しても、問題は起きないでしょう。しかし、本企画の目的は、設計を学ぶことです。設計の経験を積むために、SOLID原則に基づいて、複数のクラスとインターフェースを定義して、実装します。

5.2.1 SOLID原則

SOLID原則は、次のような5つの原則です。

  • 単一責任の原則
    • あるオブジェクトの変更理由が、複数あってはならない
  • オープン・クローズドの原則
    • 既存のコードは修正せずに(クローズド)、機能を拡張可能できる(オープン)ようにする
  • リスコフの置換原則
    • オブジェクトのインスタンスを入れ替えるだけで、分岐文を必要とせずに、ポリモーフィズムが機能する作りにする
  • インターフェース分離の原則
    • インターフェースを利用するときに不要な定義を含まないように、インターフェースを分離する
  • 依存関係逆転の原則
    • ソフトウェア階層の上下で考えるなら、上下を逆にして、下位レベルから上位レベルに依存させる
    • 抽象と具象で考えるなら、抽象から具象を逆にして、具象から抽象に依存させる

これらを念頭に、プレイヤーを実装するためのクラスやインターフェースを検討します。

5.2.2 プレイヤーの状態の管理

先に挙げたプレイヤーの機能を実装するクラスを、単一責任の原則に基づいて検討します。

ゲームの進行に応じて、スタート地点に戻ったり、操作の禁止や許可をしたりする必要があります。プレイヤーを管理するためのPlayerクラスを定義します。状態管理の他、ゲームシステムとのやりとりもプレイヤーの管理といえるので、このクラスに担当させます。

各状態については、Playerクラスに実装するか、状態ごとにクラスを用意するか、迷うところです。状態が少なく、実装する処理も小さいため、状態ごとにクラスを用意するのは、やりすぎな可能性があります。のちほど、改めて検討します。

5.2.3 操作と移動

操作は、複数のコントローラーに対応させる予定でした。あとで対応コントローラーを増やすことも考えられます。これらの処理を1つのクラスで実装すると、対応コントローラーを増やすときに、クラスの修正が必要になります。これは、SOLID原則のオープン・クローズドの原則違反です。また、入力と移動といった複数の理由で、クラスの修正が発生するので、単一責任の原則にも違反します。

以上から、各操作ごとに読み取りクラスを用意します。また、移動は、操作と別の移動クラスを定義して、そこに実装します。

アニメをさせるなら、アニメを管理する場所の検討が必要です。アニメを統括するクラスを作るか、状態に応じて変化させるならPlayer、移動に関するアニメは移動クラスに実装してもよいでしょう。

今回は、移動する方向を向かせるだけです。移動に関する処理なので、移動クラスに担当させればよいでしょう。

岩や木箱で停止する機能は、移動の実装時に、改めて検討します。

5.2.4 爆弾との接触

プレイヤーと爆弾が接触したことは、両者とも知ることができます。それぞれの実装方法を具体的に考えてみましょう。

プレイヤーは、爆弾以外に、コインとも接触します。プレイヤーに実装する場合、衝突した相手を判定して、処理を分割する必要がありそうです。一方の爆弾は、接触相手がプレイヤーなら爆発します。それ以外の処理はありません。爆弾の方が、検出がシンプルに実装できます。

ゲームオーバーの要求には、第4章で取り上げたIGameOverEmitterインターフェースを使います。プレイヤーは1つしかいないので、ゲームシステムからIGameOverEmitterを設定するのが簡単です。ただし、プレイヤーが爆弾に触ったことを知る必要があります。爆弾に実装すれば、爆弾のみで処理を完了できます。気になるのは、爆弾はステージ中に複数あるので、すべての爆弾にIGameOverEmitterの設定が必要なことです。初期化の処理時間やメモリの使用量が、やや増えます。

以上のように、接触したら何かをするというゲームでよくある処理でも、実装方法はいくつも考えられます。思いついた方法をいきなり実装しようとせず、いくつか案を出して、メリットとデメリットを比較して選ぶとよいでしょう。

相手の種類を知るには、タグが使えます。プレイヤーのゲームオブジェクトに、Playerタグを設定しておくとよいでしょう。爆弾にも、必要ならば何らかのタグを設定します。

5.2.5 コインとの接触

プレイヤーが、コインと接触したことを検出する処理は、爆弾と同じ理由で、コイン側に実装する方がシンプルです。

コインを取ったら、第4章で決めたように、IGetCoinEmitterで受け取ったメソッドを呼び出します。コインの基本点を引数で渡すようにすれば、加点とコインを減らす処理をまとめて実行できます。プレイヤーとコインのどちらに実装するかは、爆弾のゲームオーバー報告と同様の判断が必要です。どちらでも構いませんが、混乱を避けるために、爆弾とコインで統一するのがよいでしょう。

IGetCoinEmitterをコインに実装するなら、プレイヤーには、コイン向けの実装は不要です。タグがPlayerになっていれば、問題ありません。

5.2.6 プレイヤーの実装方法のまとめ

プレイヤーの機能で挙げた項目について、実装方法を検討しました。プレイヤーは、次のような構成にします。

  • Playerクラスで、プレイヤーの状態を管理
  • 対応コントローラーごとに、入力を読み取って、移動量を返すクラスを用意
  • 移動を担当するクラスで、移動量を受け取って、キャラクターの向きの制御と移動
  • 爆弾とコインのために、プレイヤーのゲームオブジェクトのタグを、Playerに設定
  • IGameOverEmitterをプレイヤーに実装するなら、Playerクラスに実装
  • IGetCoinEmitterをプレイヤーに実装するなら、Playerクラスに実装

以上を実装する手順を検討します。これらは、同時に開発できます。入力と移動がなくても、プレイヤーの状態管理クラスは作れます。移動するタイミングになったら、Debug.Logで「移動開始」などと表示すれば、状態が正しく変わっていることは確認できます。デバッグキーで、コインの取得やゲームオーバーの動作を試すこともできます。

コントローラーの入力の開発では、移動量をDebug.Logなどで出力すればよいでしょう。オブジェクトの移動は、完成してからまとめられます。

移動クラスも同様です。入力クラスがなくても、仮の入力クラスを作成したり、テストランナーで入力を渡せば、開発できます。

すべてが揃ったら、プレイヤーの進行管理クラスに組み込んで完成です。クラスを分割することで、チーム開発がやりやすくなります。

5.3 実装手順の決め方

プレイヤーの構造が決まりました。手順を決めて、実装を進めていきましょう。

手順は、出力>入力>処理の順がオススメです。また、簡単なものから実装するのがコツです。不明なことが多いと、同じ内容でも実装は難しく感じます。パズルを組み立てるときと同じです。

プレイヤーの管理クラス、入力を読み取るクラス、移動クラスは、同時に開発できます。担当を決めて、一斉に実装してみるのもよいでしょう。

5.4 プレイヤー管理の実装

プレイヤーの進行管理を実装するための解説をします。まずは、必要な機能を整理します。

  • クラス名は、Playerとする
  • MonoBehaviourを継承
  • IGameStateListenerを実装する
  • Awakeで、現在の座標と方向を保存する
  • 次の状態を管理する
    • Play
      • 入力と移動を実行する
    • GameOver
      • 移動を停止する
    • Clear
      • 移動を停止する
    • Reset
      • Awakeで保存した座標と向きを設定する
      • 移動を停止する

これらのひな形となるPlayer.csが、Assets/Yoketoru/Scripts/Game/Playerフォルダーに作成済みです。ゲームの状態変更を受け取るように、実装されています。Playして、ゲームを開始すると、操作ができるようになるタイミングで「操作と移動開始」とログに表示されます(図5.3)。

ゲームが開始したことを示すログ

図5.3: ゲームが開始したことを示すログ

操作や移動は、他の人が開発することを前提として話しを進めます。挙げた機能のうち、「クラス名は、Playerとする」「MonoBehaviourを継承」「IGameStateListenerを実装する」は、ひな形で完了しています。状態の管理も、enumで定義して、状態を簡易的に管理するSimpleStateを使って、実装済みです。移動を停止するのは、操作と移動をしなければいいので、現時点ですでにできています。

残っているのは、Awake時に座標と回転を記録しておくことと、Resetの初期化時に、記録していた座標と回転を復帰することだけです。

5.4.1 プレイヤー管理の実装手順

残りの作業の実装手順を検討します。実装したときに、結果が分かるように実装を進めるのがコツです。Resetの初期化時に、記録していた座標と回転を復帰したことを確認するには、初期座標から動いている必要があります。まず、そこから作ります。

  1. ゲームが開始したら、プレイヤーの座標と向きを変更する
  2. Awakeで、現在の座標と回転を記録する
  3. Resetの初期化時に、記録していた座標と回転を復帰する

以上の順で作業を進めれば、スムーズに完成できるでしょう。

5.4.2 ゲームが開始したら、プレイヤーの座標と向きを変更する

開発用のブランチを作成して、そこで作業するのがよいマナーです。次の手順で、開発用のブランチを作成します。操作は、慣れているものを使ってください。ここでは、GitHub Desktopの操作を示します。

  1. 変更があれば、コミットするか、Discardする
  2. Current branchをクリックして、Filter欄にplayer-stateと入力して、Create new branchをクリックする
  3. Create branch based on...は、masterを選んで、Create branchをクリックする

以上で、プレイヤー管理スクリプトを開発するためのブランチが作成されて、切り替わります。

Playをしたら、プレイヤーの座標と回転を強制的に変えるコードを実装します。

  • ProjectウィンドウのAssets/Yoketoru/Scripts/Game/Playerフォルダーを開いて、Playerスクリプトをダブルクリックして開く
  • InitState()メソッド内のcase State.Playを、リスト5.1のように修正する

リスト5.1: Playがはじまった瞬間に、座標と回転を変える

            case State.Play:
                Debug.Log($"操作と移動開始");

                // TODO: 動作を確認したら、消す
                transform.Find("Pivot").eulerAngles
                    = new Vector3(0, 0, -45);
                transform.Translate(new Vector3(1, 1, 0));
                break;

Playして、ゲームを開始すると、プレイヤーが右上向きに、少し移動します。

動作確認のために、右上へ少し移動

図5.4: 動作確認のために、右上へ少し移動

O(オー)キーを押すと、デバッグ機能でゲームオーバーになります。ゲームオーバー画面でスペースキーを押してコンティニューすると、カウントダウンがはじまります。この段階では、プレイヤーは右上に向いたままです。これを、最初の座標と向きに戻すのが、ここで実装したい機能です。

5.4.3 開始位置に戻す処理の実装

次のヒントを参考に、機能を実装してください。変数名は、分かりやすい名前をつけてください。

  • 座標を記録するための変数を、Vector3型で定義する
  • 回転を記録するための変数を、Vector3型で定義する
  • Awakeを定義して、次の処理を実装する
    • 座標を記録するための変数に、transform.positionを代入する
    • 回転を記録するための変数に、transform.Find("Pivot").eulerAnglesを代入する
  • InitStateメソッドのState.Resetの初期化で、次の処理を実装する
    • transform.positionに、座標を記録するための変数の値を代入する
    • transform.Find("Pivot").eulerAnglesに、回転を記録するための変数の値を代入する

実装できたら、Playして、ゲームオーバーからコンティニューしてください。座標と回転が初期状態に戻れば、実装完了です。Playの初期化に実装していた、デバッグ用の移動処理は、消しておくとよいでしょう。

実装が完了したら、コミットとプッシュをしましょう。

5.4.4 Playerクラスはひとまず完了

あっという間でしたが、Playerクラスの実装はひとまず完了です。単一責任の原則に基づいてクラスを分割したことで、クラスの役割が減って、開発が楽になりました。複雑さが減れば、バグも起きにくくなります。

次の作業は、操作と移動用のクラスが完成してからになります。この作業の担当者は、他の作業を進めることができます。複数のメンバーで、次々に作業を進められるのも、事前に仕様を決めて、クラスを適切に分割することのメリットの1つです。

5.5 操作クラスの実装

入力デバイスの値を読み取って、移動量を返す機能を作成します。

5.5.1 操作の実装方針

企画概要で、次の操作に対応すると書きました。

  • キーボード
  • マウス移動
  • ゲームパッド(Xbox用)

操作の読み取りクラスを個別に用意すれば、実装をシンプルにできます。対応デバイスを増やすために、新しいクラスを増やせば、既存のクラスの修正は不要です。オープン・クローズドの原則に基づいた設計といえます。

複数の入力を、移動指示にまとめる処理を、Playerクラスに実装するのは、役割が違うと感じます。入力をまとめる役割のクラスを用意した方がよいでしょう。そのクラスのインスタンスを、Playerクラスに持たせて、必要なタイミングで利用します。

入力をまとめるクラス内で、入力デバイスを見分けて制御するのは、オープン・クローズドの原則に違反します。操作を読み取るクラスは、共通するインターフェースを定義して、呼び出し方を統一します。

以上から、次のようなクラスやインターフェースを用意します。

  • 入力デバイスをまとめるクラス
  • 入力デバイスの読み取りクラスに実装する共通のインターフェース
  • 入力デバイスごとの読み取りクラス

5.5.2 入力処理の実装手順

インターフェースはすぐに定義できて、すぐに利用したいので、真っ先に実装します。入力デバイスをまとめるクラスと、入力デバイスごとの読み取りクラスは、同時進行で進められます。

5.5.3 入力デバイスのインターフェース

手分けをする前に、インターフェースを手早く実装して、共有します。

今回のプロジェクトは、入力をInput Managerで読み取ることを前提に設定しています。Input Managerは、古くからあるUnityの入力方法です。更新のタイミングが、Updateに限定されているので、読み落としを避けるには、Updateで入力を読み取る必要があります。

一方、移動処理は、物理更新であるFixedUpdateで実行します。こうすることで、実行しているPCの性能や、モニターのリフレッシュレートが違っても、同じ操作感にできます。

問題は、最近のPCは処理が速く、Windowモードでは数百FPSで実行されることです。一方の物理更新は、Unityのデフォルトでは50FPSです。1回の移動処理の間に、Updateが10回ほど呼ばれる可能性があります。その対応が必要です。

Updateのタイミングで、入力デバイスの値を読み取って、変数に保存します。保存の仕方は、デバイスの性質にあわせます。

FixedUpdateのタイミングになったら、保存されている値を取り出して、保存されていた値をリセットします。取り出した値を、移動処理に渡します。

以上をまとめたのが、リスト5.2です。これは、用意済みです。

リスト5.2: IInputインターフェース

 1: using UnityEngine;
 2: 
 3: public interface IInput
 4: {
 5:     /// <summary>
 6:     /// 前回からの移動量を返す。
 7:     /// </summary>
 8:     /// <returns>長さ0-1の範囲のベクトル</returns>
 9:     Vector2 GetValue();
10: 
11:     /// <summary>
12:     /// Updateから呼び出して、入力の値を更新する。
13:     /// </summary>
14:     void Update();
15: }

インターフェースが定義できたら、入力をまとめるクラスと、入力値を読み取るクラスは、平行して開発できます。

5.5.4 入力をまとめるクラスの実装

プレイヤー管理の実装と同様に、開発用のブランチを作成してから、実装をはじめましょう。

  1. 変更があれば、コミットするか、Discardする
  2. Current branchをクリックして、Filter欄にinput-controllerと入力して、Create new branchをクリックする
  3. Create branch based on..は、masterを選んで、Create branchをクリックする

以上で、開発用のブランチが作成されて、切り替わります。

Projectウィンドウで、Assets/Yoketoru/Scriptsフォルダー内の任意の場所(GameフォルダーやGame/Playerフォルダーなど)に、新しいC# Scriptを作成します。名前は、InputControllerにします。このクラスは、Playerクラスで生成して呼び出すので、MonoBehaviourを継承する必要はありません。まずは、中身は後回しにして、リスト5.3のように、必要な定義をします。コメントは、省いて構いません。

リスト5.3: InputController.csの雛形

 1: using UnityEngine;
 2: 
 3: /// <summary>
 4: /// 入力デバイスを取りまとめて制御するクラス
 5: /// </summary>
 6: public class InputController
 7: {
 8:     /// <summary>
 9:     /// 対応する入力デバイスのインスタンスを定義
10:     /// </summary>
11:     IInput[] inputs = {
12: 
13:     };
14: 
15:     /// <summary>
16:     /// 更新処理を呼び出す。
17:     /// </summary>
18:     public void Update()
19:     {
20: 
21:     }
22: 
23:     /// <summary>
24:     /// FixedUpdateから呼び出して、入力デバイスから移動量を読み取って、返す。
25:     /// </summary>
26:     /// <returns>移動を指示するVector2の値</returns>
27:     public Vector2 GetValue()
28:     {
29:         return Vector2.zero;
30:     }
31: }

動作を確認するための開発用のシーンとスクリプトを用意すれば、他の環境と切り離して、このクラスを開発できます。開発環境を用意する手順です。

  1. 開発用のアセットを入れておくために、Projectウィンドウで新規フォルダーを作成して、DevInputControllerなどの名前にする
  2. 新規にシーンを作成して、DevInputControllerなどの名前で、DevInputControllerフォルダーに保存する
  3. DevInputControllerフォルダー内に、新しいC# Scriptを作成して、名前をInputControllerBenchにする
  4. DevInputControllerシーンを開く
  5. Create Emptyで、空のゲームオブジェクトを作成する
  6. InputControllerBenchスクリプトを、作成したゲームオブジェクトにアタッチする
  7. InputControllerBenchスクリプトを開く
  8. リスト5.4のコードを入力する

リスト5.4: InputControllerBenchクラス

 1: using UnityEngine;
 2: 
 3: public class InputControllerBench : MonoBehaviour
 4: {
 5:     InputController inputController = new();
 6: 
 7:     void Update()
 8:     {
 9:         inputController.Update();
10:     }
11: 
12:     void FixedUpdate()
13:     {
14:         var move = inputController.GetValue();
15:         Debug.Log($"{move}");
16:     }
17: }

Playすると、InputControllerの処理を呼び出して、読み取った移動量がログに表示されます。InputControllerを実装すれば、動作が確認できます。

InputControllerでやることは簡単です。Updateメソッド内で、inputs配列の数だけループを回して、inputsのUpdateを呼び出します。

GetValueメソッドでは、inputsの数だけループを回して、inputsのGetValueメソッドを呼び出して、値を読み取ります。入力値は、入力デバイスの種類だけ得られます。これを一本化するには、どうしたらよいかを考えて、実装してみてください。

Playして、ログに(0.00, 0.00)と表示されれば成功です。コミットして、プッシュしておきましょう。この段階では、入力デバイスを実装していないので、操作をしても、値は変わりません。

続きは、入力デバイスを読み取るクラスの解説の後に用意しています。

5.5.5 キーの読み取りの実装

キーボードの操作を読み取るクラスを実装します。入力をまとめるクラスと同様の手順で、開発用のkey-inputブランチを、masterブランチから作成してから、作業をはじめます。

開発用のフォルダーに、新規でC# Scriptを作成して、名前をKeyInputにします。このクラスは、ゲームオブジェクトにはアタッチしないので、MonoBehaviourを継承する必要はありません。その代わり、IInputインターフェースを実装します。作成したスクリプトを開いて、リスト5.5のコードを入力します。コメントは省略して構いません。

リスト5.5: KeyInputクラスの雛形

 1: using UnityEngine;
 2: 
 3: /// <summary>
 4: /// キー入力を読み取って、返すクラス。
 5: /// </summary>
 6: public class KeyInput : IInput
 7: {
 8:     /// <summary>
 9:     /// 入力値を記録しておく変数
10:     /// </summary>
11:     Vector2 inputValue;
12: 
13:     public Vector2 GetValue()
14:     {
15:         return inputValue;
16:     }
17: 
18:     public void Update()
19:     {
20:     }
21: }

Updateで読み取った入力の値を保存しておく変数として、inputValueを定義しています。GetValueメソッドとUpdateメソッドは、IInputインターフェースで定義したものです。

中身が空でも、必要なメソッドを定義したクラスを用意すれば、他のクラスから参照できます。入力をまとめるクラスが完成していたら、inputsに、KeyInputクラスをnewするコードを加えて開発できます。

ここでは、同時に開発している想定で、開発用の環境を用意して、単独で開発を進めます。

  1. Projectウィンドウで新規フォルダーを作成して、DevKeyInputなどの名前にする。これに、開発用のアセットを入れる
  2. DevKeyInputフォルダー内に、新規にシーンを作成して、DevKeyInputという名前にする
  3. 作成したDevKeyInputシーンをダブルクリックして開く
  4. DevKeyInputフォルダー内に、新しいC# Scriptを作成して、名前をKeyInputBenchにする
  5. Hierarchyウィンドウの+から、Create Emptyで、空のゲームオブジェクトを作成する
  6. KeyInputBenchスクリプトを、作成したゲームオブジェクトにアタッチする
  7. KeyInputBenchスクリプトを開く
  8. リスト5.6のコードを入力する

リスト5.6: KeyInputBenchクラス

 1: using UnityEngine;
 2: 
 3: public class KeyInputBench : MonoBehaviour
 4: {
 5:     KeyInput keyInput = new();
 6: 
 7:     void Update()
 8:     {
 9:         keyInput.Update();
10:     }
11: 
12:     private void FixedUpdate()
13:     {
14:         Debug.Log($"{keyInput.GetValue()}");
15:     }
16: }

Playすると、ログに(0.00, 0.00)が表示されます。これで、KeyInputで読み取ったキーの値を確認できるようになりました。KeyInputの中身を実装します。

WASDと矢印キーは、Project SettingsのInput Managerで定義済みです(図5.5)。

左右と上下入力の設定

図5.5: 左右と上下入力の設定

Input.GetAxisRawメソッドで、Horizontalを指定すれば、左右の入力が読めます。上下は、Verticalです。

キーボードの入力は、直近の入力を採用すればよいでしょう。入力を記録しておくための変数inputValueは、定義済みです。Updateメソッドで、inputValue.xに左右の入力、inputValue.yに上下の入力を代入します。

GetValueメソッドは実装済みなので、これで最小限の機能は完成です。Playをして、WASDや矢印キーを押してください。キー操作に応じて、ログに表示される値が変化します。

このままだと、斜めのときにXとYの両方が1になるので、斜め移動が速くなってしまいます。次のコードで、長さを1に正規化できます。

リスト5.7: 入力値を正規化する

        inputValue = 1.0f * inputValue.normalized;

Playして、斜めの値が(0.71, 0.71)のようになれば成功です。

速さが1だと、最高速度で移動します。通常移動のときは、1.0fのところを0.5fにするなどして、速度を落とすとよいでしょう。調整しやすくするために、スピードの値を、定数などで定義することを推奨します。

おまけで、スピードアップ機能を解説します。Input.GetButtonメソッドに、SpeedUpを渡すと、左シフトボタンが押されているかを確認できます。左シフトが押されていたら長さを1、押されていなければ、通常の速さにすれば、スピードアップ操作ができます。

5.5.6 ゲームパッド(Xbox)の読み取りの実装

開発環境の準備方法は、キー入力と同じです。Keyの部分を、GamePadに置き換えて、同様の手順でクラスを作成して、開発環境を準備してください。

  1. masterブランチから、開発用ブランチを作成する
  2. GamePadInputスクリプトを作成して、ひな形を入力
  3. 開発用のフォルダーを作成する
  4. 開発用のシーンを作成する
  5. ベンチ用スクリプトを作成する
  6. 空のゲームオブジェクトを作成して、ベンチ用のスクリプトをアタッチする

以上できたら、中身の実装に取り組みます。

D-padの入力は、キーと同じ設定なので、対応は不要です。このクラスでは、アナログ入力に対応させます。

アナログ入力は、Input.GetAxisで読み取ります。左右をHorizontalAnalog、上下をVerticalAnalogで設定してあります。返すのは、キーと同様に、最新の値でよいでしょう。Updateごとに入力を読み取って、inputValueへ代入します。アナログ入力は、正規化済みの値が得られるので、normalizedは不要です。

アナログ入力は、倒し方で速さを変えられるので、スピードアップボタンへの対応は不要です。もちろん、対応させても構いません。自由に設定してください。

ゲームコントローラーへの対応

今回、ゲームコントローラーは、Xboxのゲームパッドに限定しています。Windows用やPS用など、さまざまなゲームコントローラーがありますが、これらのスティックやボタンの定義は、残念ながら統一されていません。

今回はテーマ外なので扱いませんが、本気で実装する場合は、標準的なゲームコントローラーのプリセットを用意したり、キー設定機能を用意することになります。

5.5.7 マウスの読み取りの実装

キーやゲームパッドの入力と同様の手順で開発します。KeyやGamePadの部分を、Mouseに差し替えます。

マウスの移動は、Input.GetAixsを使って読み取ります。左右がMouse X、上下がMouse Yです。

マウスの移動量は、前回のUpdateから、今回のUpdateまでの間に移動した量が返されます。1回の物理更新の間に、10回Updateが実行されたなら、10回分の移動量を合計した値が、移動に使いたい量です。キーやゲームパッドとは、違うコードになります。

マウスの移動量は、キーやゲームパッドと違い、-1から1の範囲を越える可能性があります。最高速度のときのマウスの移動量を決めて、読み取ったマウスの移動量を、最高速度のマウスの移動量で割れば、-1から1に対応づけることができます。

このままだと、マウスを最高速度より大きく移動させると、戻り値の大きさが1を越えてしまいます。最高速度を制限したいので、GetValueが返すベクトルの長さが1を超えないように、制限してください。

5.5.8 開発したクラスを統合する

操作関連のクラスがすべて完成したら、統合します。

1人で作業をしていたら、マージするのが手っ取り早いです。別のアカウントで作業をしていたら、プルリクエストで統合するのが一般的です。しかし、マージやプルリクエストは、GitHubに慣れているメンバーがいないと、コンフリクトが起きたときの対処ができません。今回の内容なら、スクリプトファイルをコピーするだけなので、統合担当者にファイルを渡すような力技で解決できます。

入力をまとめるクラスで統合するのが合理的です。入力をまとめるクラスの担当者に、各入力処理のスクリプトを渡してください。Googleドライブやネットドライブ、USBメモリ、メールに添付するなど、何らかの手段でファイルを渡してください。

入力をまとめるクラスの担当者は、受け取ったファイルを、プロジェクトのScriptsフォルダー内の任意の場所にコピーします。あとは、InputControllerのinputsに、リスト5.8のように入力クラスのインスタンスを登録します。

リスト5.8: すべての入力を登録

    IInput[] inputs =
    {
        new KeyInput(),
        new GamePadInput(),
        new MouseInput(),
    };

DevInputControllerシーンを開いて、Playしてください。キーやゲームパッド、マウスを操作して、ログに期待した数値が表示されれば成功です。

キーやゲームパッドが反応せず、マウスしか操作できないバグが予想されます。その場合、InputControllerのGetValueの実装方法を間違えています。3つのデバイスのうち、どの値を返せばよいかをメンバーで相談して、修正してください。

5.5.9 Playerに組み込む

InputControllerへの統合ができたら、プレイヤーに組み込みましょう。

スクリプトファイルをコピーしても構いませんが、ファイルが複数あるので少々面倒です。そこで、unitypackageを使う方法を紹介します。作業するのは、入力のまとめクラスの担当者がよいでしょう。

  • ここまでの作業を、コミットして、プッシュする
  • UnityのProjectウィンドウで、必要なクラスをShiftキーを押しながらクリックして、選択する(図5.6
DevInputControllerシーンの依存ファイルを選ぶ

図5.6: DevInputControllerシーンの依存ファイルを選ぶ

  • 選択したファイルの1つを右クリックして、Export Package...を選ぶ(図5.7
パッケージとしてエクスポート

図5.7: パッケージとしてエクスポート

  • Include dependenciesのチェックを外してから、Exportをクリックする(図5.8
ファイルをエクスポートする

図5.8: ファイルをエクスポートする

  • inputsなどの名前を付けて、デスクトップなどの分かりやすい場所へ保存する

以上で、スクリプトファイルをunitypackageファイルにエクスポートできました。これを、ゲームの開発用ブランチに読み込みます。

  • GitHub Desktopなどで、Playerを開発したplayer-stateブランチへ切り替える
  • デスクトップなどへ保存したinputs.unitypackageファイルをドラッグして、UnityのProjectウィンドウへドロップする
  • Importダイアログが表示されたら、Importボタンをクリックする(図5.9
inputsパッケージをインポートする

図5.9: inputsパッケージをインポートする

以上で、作成したスクリプトを、Playerを開発したプロジェクトに加えられました。あとは、Playerクラスで、InputControllerを利用すれば、入力を読み取れます。

  • UnityのProjectウィンドウから、Playerスクリプトを開く
  • クラス内に、インスタンス変数として、次の定義を加える(リスト5.9

リスト5.9: InputControllerを定義する

    InputController inputController = new();
  • UpdateStateメソッド内のcase State.Playを、リスト5.10のようにする

リスト5.10: Play状態なら、Updateごとに入力を更新する

            case State.Play:
                inputController.Update();
                break;
  • FixedUpdateStateメソッドのcase State.Playを、リスト5.11のようにする

リスト5.11: 入力値を取り出して、ログへ表示する

            case State.Play:
                var move = inputController.GetValue();
                Debug.Log($"{move}");
                break;

Projectウィンドウから、GameSystemシーンをダブルクリックして開いて、Playしてください。ゲームを開始して、カウントダウンが終わると、コンソールに入力値が表示されるようになります。キーやマウスを操作して、表示される数値が変わることを確認してください。

これで、プレイヤーを操作するための入力値を取得する処理を、Playerに実装できました。

5.5.10 操作クラスのまとめ

次のようなクラスを作成して、Playerに組み込みました。

  • 入力をまとめるクラスInputController
  • キーとD-padを読み取るKeyInput
  • ゲームパッドのアナログ入力を読み取るGamePadInput
  • マウスを読み取るMouseInput

すべての入力デバイス用のクラスは、操作を抽象化したIInputインターフェースを実装しました。これにより、IInputの配列に、入力デバイスを読み取るクラスのインスタンスを列挙すれば、繰り返し文で入力処理を実行できます。拡張も簡単です。

作成したスクリプトをまとめる方法として、ファイルのコピーと、unitypackageへエクスポートする方法を紹介しました。

GitHubのマージやプルリクエストを使うと、ファイルの移動をせずに済みます。ただ、コンフリクトが起きたときの対応が難しいので、ここでは取り上げませんでした。企業でのチーム開発では、欠かせない機能なので、GitHubの公式ドキュメント*1などで、学習することをオススメします。

5.6 プレイヤーの移動の実装

移動処理の仕様を検討して、実装をします。

5.6.1 移動の機能の検討

移動に関して、思い浮かぶことを書き出して、整理します。

  1. 入力デバイスから読み取った0~1の大きさのVector2の値で、移動させる
  2. 移動するのは、Play状態のときのみ
  3. 速度には、上限を設定する
  4. 移動する方向を向く
  5. 岩や木箱に移動を遮られる
  6. 岩や木箱は押せない
  7. 爆弾やコインは、接触したことは認識するが、移動は遮られない
  8. Z方向には動かない
  9. すべての軸で、衝突による回転はさせない

移動のスクリプトに関するものは、1、3、4です。ゲームオブジェクトを移動させるので、MonoBehaviourを継承したクラスに実装するのがよいでしょう。SerializeFieldが使えるので、速度の上限は、Inspectorで設定できるようにします。

Playerクラスから、移動用のクラスを直に参照すると、あとで実装先やクラス名を変更する際に、Playerクラスの修正が必要になります。インターフェースを使えば、実装先などの変更の影響を受けなくなります。Vector2の移動ベクトルを受け取って、速度を設定するメソッドを持たせたインターフェースを定義するのがよいでしょう。

5.6.2 移動の実装手順の検討

効率よく作業する手順を考えます。まずは、手軽にできるゲームオブジェクトの設定を、Playerプレハブにします。5、6、8、9はこれで完了します。

あとはスクリプトの実装です。先に検討した、Vector2の移動量を受け取って、自動を実行するメソッドを定義したインターフェースを作成します。インターフェースができたら、作成するスクリプトの動作を確認するための環境を作ります。あとは、必要な機能を実装します。引数として1の入力を受け取って、3の設定を用意して、移動します。移動したら、4の向きの設定を実装して完成です。残っている2は、状態管理からの呼び出し方なので、ここでやることはありません。

整理すると、手順は次のとおりです。

  1. Playerプレハブを設定する
  2. 移動メソッドを持ったインターフェースを定義する
  3. 移動の開発環境を構築する
  4. 移動処理を実装する
  5. 回転処理を実装する

5.6.3 Playerプレハブの設定

ゲームオブジェクトを設定します。Playerプレハブを開いて、設定を確認していきます。

Projectビューから、Assets/Yoketoru/Prefabsを開いて、Playerプレハブをダブルクリックします。作業の間に、他のメンバーがPlayerプレハブを変更しないように、声掛けをしてください。Playerプレハブが、変更される可能性があれば、PlayerプレハブをCtrl+Dキーで複製して、複製したプレハブで作業するのが安全です。

Playerプレハブをダブルクリックして選択したら、Inspectorを確認します。爆弾やアイテムからプレイヤーと認識できるように、TagをPlayerに設定します(図5.10)。

Playerタグを設定する

図5.10: Playerタグを設定する

Z方向には動かず、すべての軸で衝突による回転をさせないために、RigidbodyのConstraints(制約)の設定を、図5.11のようにします。すでに設定されていれば、そのままで大丈夫です。

Playerの物理制限

図5.11: Playerの物理制限

岩や木箱にぶつかる機能は、RigidbodyとColliderに任せましょう。Sphere ColliderのIs Triggerが、チェックされていないことを確認します。

Sphere Colliderの設定

図5.12: Sphere Colliderの設定

以上で、書き出した機能のうち、5、6、8、9が設定できました。

5.6.4 移動メソッドを持ったインターフェースの定義

プレイヤーの移動を実装します。これまでと同様に、masterブランチから、新しくplayer-moveというブランチを作成して、そこで作業をしましょう。今回も、プレイヤーの状態や入力といった他の機能と平行して開発する前提で解説します。

移動用のインターフェースは、Assets/Yoketoru/Scripts/Gameフォルダーに用意してあります。中身は、リスト5.12のとおりです。

リスト5.12: IMoverインターフェース

 1: using UnityEngine;
 2: 
 3: public interface IMover
 4: {
 5:     /// <summary>
 6:     /// 0-1の大きさの移動ベクトルを受け取って、移動させる。
 7:     /// </summary>
 8:     /// <param name="move">0-1の範囲の移動方向ベクトル</param>
 9:     void Move(Vector2 move);
10: }

5.6.5 移動処理の開発環境を作る

スクリプトの開発環境を作ります。

  • Projectウィンドウで、新しいフォルダーを作成して、DevCharacterMoverという名前にする
  • Assets/Yoketoru/Prefabsフォルダー内のPlayerプレハブを選択して、Ctrl+Dキーで複製する
  • 複製してできるPlayer 1プレハブの名前を、DevPlayerに変更する
  • DevPlayerプレハブをドラッグして、DevCharacterMoverフォルダーへドロップして移動する
  • DevCharacterMoverフォルダーに、新規シーンを作成して、名前をDevCharacterMoverにする
  • 作成したDevCharacterMoverシーンをダブルクリックして開く
  • DevPlayerプレハブをドラッグして、Hierarchyウィンドウにドロップする

Playして、エラーがないことを確認してください。まだ、何も起きません。

DevPlayerを調整します。

  • Projectウィンドウで、DevPlayerプレハブを選択する
  • Inspectorウィンドウで、Playerスクリプトの右の3点アイコンをクリックして、Remove Componentを選択して、Playerスクリプトを削除する
  • ProjectウィンドウのDevCharacterMoverフォルダーを右クリックして、Create > C# Scriptを選択して、スクリプトを作成して、名前をCharacterMoverBenchにする
  • 作成したCharacterMoverBenchスクリプトをドラッグして、ProjectウィンドウのDevPlayerプレハブにドロップして、アタッチする
  • CharacterMoverBenchスクリプトを、ダブルクリックして開く
  • リスト5.13のコードを実装する

リスト5.13: CharacterMoverBenchスクリプトの内容

 1: using System.Collections;
 2: using UnityEngine;
 3: 
 4: public class CharacterMoverBench : MonoBehaviour
 5: {
 6:     void Start()
 7:     {
 8:         StartCoroutine(MoveCoroutine());
 9:     }
10: 
11:     IEnumerator MoveCoroutine()
12:     {
13:         Vector2[] moveVectors =
14:         {
15:             Vector2.right,
16:             new Vector2(1,-1).normalized,
17:             Vector2.down,
18:             new Vector2(-1,-1).normalized,
19:             Vector2.left,
20:             new Vector2(-1,1).normalized,
21:             Vector2.up,
22:             new Vector2(1,1).normalized,
23:         };
24: 
25:         var mover = GetComponent<IMover>();
26:         var wait = new WaitForFixedUpdate();
27: 
28:         while(true)
29:         {
30:             for (int i = 0; i < moveVectors.Length;i++)
31:             {
32:                 for (int j = 0; j < 20; j++)
33:                 {
34:                     mover?.Move(moveVectors[i]);
35:                     yield return wait;
36:                 }
37:             }
38:         }
39:     }
40: }

Playして、エラーが出ないことを確認してください。まだ、IMoverインターフェースを実装したクラスを作成していないので、Moveが呼ばれることはありません。

34行目で、moverの後ろに?を書いています。これは、moverがnullなら、この行を実行しないという便利な文法です。if (mover != null)で囲んでいるのと、同じ効果があります。

これで、開発環境が整いました。

5.6.6 移動処理の実装

CharacterMoverスクリプトを開いて、Moveメソッドを実装します。

移動ベクトルを受け取って、上限速度に応じて移動させるキャラなら、プレイヤー以外にもこの処理は使えます。そこで、CharacterMoverという名前のクラスにします。

  • Scriptsフォルダー内の任意の場所(GameフォルダーやGame/Playerフォルダーを推奨)に、新規スクリプトを作成して、CharacterMoverという名前にする
  • ひな形として、リスト5.14を実装する

リスト5.14: CharacterMoverのひな形

 1: using UnityEngine;
 2: 
 3: /// <summary>
 4: /// キャラクターを移動させる。
 5: /// </summary>
 6: public class CharacterMover : MonoBehaviour, IMover
 7: {
 8:     [SerializeField, Tooltip("最高速度")]
 9:     float maxSpeed = 4f;
10: 
11:     Rigidbody rb;
12: 
13:     void Awake()
14:     {
15:         rb = GetComponent<Rigidbody>();
16:     }
17: 
18:     public void Move(Vector2 move)
19:     {
20:         Debug.Log($"{move}");
21:     }
22: }
  • CharacterMoverスクリプトをドラッグして、ProjectウィンドウのDevPlayerプレハブにドロップして、アタッチする

Playすると、一定時間ごとに変わる移動量が表示されます。先に作成した、開発環境のスクリプトが、一定時間ごとに移動方向を変えて、Moveを呼び出しているからです。動作することが確認できたら、Moveメソッド内のDebug.Logは不要なので、消してください。

Moveメソッドを実装します。一番簡単な方法は、Rigidbodyのvelocityに、速度を設定することです。Rigidbodyのインスタンスは、変数rbに取得済みです。移動の指示は、引数のmoveに渡されるので、これに速度上限のmaxSpeedをかけた値を、rb.velocityに代入すれば完了です。

5.6.7 回転処理を実装する

移動方向にプレイヤーを向かせます。今回は、DevPlayerの子オブジェクトであるPivotを回転させます。親オブジェクトを回転させると、モデルの軸が中心からずれているような場合に調整が難しくなります。子オブジェクトを起点にして回転させると、調整しやすい構造になります。

Pivotのtransformは、transform.Find("Pivot")で得られます。Findは、やや重めの処理なので、StartやAwakeで取得して、変数に保存したものを使うのがよいでしょう。

この処理は、いろいろな考え方があります。

  • Vector2.SignedAngleで、上方向から、進行方向の角度を求める。求めた角度を、eulerAngleのZ軸に指定する
  • Quaternion.LookRotationで、移動方向を向くクォータニオンを求めて、rotationに代入する
    • ただし、今回のプロジェクトだと、Pivotの子供のメッシュの向きがrotationに一致しなくなるので、コードに合わせて、最初に向いている方向の修正が必要

X-Z平面を移動する場合、上方向が変わらないので、transform.forwardに前方のベクトルを代入すれば、前を向かせることができます。今回はX-Y平面を移動するため、上方向が変わってしまいます。そのため、前を向かせるのに工夫が必要です。QuaternionやVectorを調べて、理解を深めるのに役立ててください。

5.6.8 移動処理をPlayerへ統合する

CharacterMoverスクリプトができたら、Playerに統合しましょう。必要なのは、CharacterMoverスクリプトのファイルだけなので、コピーなどで受け渡せばよいでしょう。

Playerの開発担当者は、CharacterMoverスクリプトを受け取ったら、player-stateブランチのプロジェクトに加えます。このクラスは、ゲームオブジェクトにアタッチして使います。ProjectビューのAssets/Yoketoru/Prefabsフォルダー内のPlayerプレハブに、ドラッグ&ドロップでアタッチします。

Playerスクリプトに、移動を呼び出す処理を加えます。

  • Playerスクリプトをダブルクリックして開く
  • インスタンス変数として、リスト5.15の定義を追加する

リスト5.15: IMoverの変数を定義

    IMover mover;
  • Awakeメソッドに、GetComponentを追加する(リスト5.16

リスト5.16: Awakeで、IMoverを取得する

    void Awake() {
        mover = GetComponent<IMover>();
    }
  • FixedUpdateStateメソッドのcase State.Playに、移動の呼び出しを加える(リスト5.17

リスト5.17: Playerスクリプトに、移動呼び出しを追加

            case State.Play:
                var move = inputController.GetValue();
                mover.Move(move);
                break;

Playerの実装は、これで完了です。コミットとプッシュをします。

すべてできたら、player-stateブランチを、masterブランチにマージすれば完成です。GitHub Desktopなどで、次の手順でマージします。

  • Current branchをmasterに切り替える
  • Current branchをクリックして、一番下にあるChoose a branch to merge into masterボタンをクリックする
  • player-stateブランチを選択する
  • コンフリクトがないことを確認して、Create a merge commitをクリックする

以上で、プレイヤーの開発完了です。

5.7 不具合対応

プレイヤーが、ゲームオーバーやクリアしたときに止まらなくなる可能性があります。他にも、想定していない不具合が発生していたら、原因を調べて、修正に取り組んでください。

5.8 プレイヤーの実装のまとめ

プレイヤーの機能として、次のものが実装できました。

  • ゲームが開始したら、既定のスタート地点に配置
  • スタートのカウントダウン中は、操作不可
  • カウントダウンが終わったら、操作できるようになる
  • キー入力、マウス移動、ゲームパッドで操作した方向へ移動
  • 岩や木箱があると、先に進めなくなる。押すこともできない

次のものは、爆弾やコインに実装します。

  • 爆弾に触れた
  • コインに触れた

作業に先立って機能を書き出して、各機能の実装方法を検討しました。作業の効率を考えて、作業手順を決めてから、作業に取り組みました。

スクリプトは、単一責任の原則に基づいて、クラスを分けました。必要な機能と、メソッドの呼び出し方を決めて、インターフェースを用意しました。このような準備をしたことで、プレイヤーという1つのオブジェクトを、複数人で並行して開発できることを示しました。

複数メンバーで開発する際は、masterブランチで作業するのは避けます。作業用のブランチを作ってから作業をして、完了したら、成果をまとめます。

設計には、それなりの労力と時間がかかります。すぐに作業に取りかかれないので、開発効率が悪いと感じることがあります。実際に、ごくシンプルなものを個人で開発するなら、いきなり開発した方が効率的な場合はあります。しかし、複数メンバーで開発する場合や、プロジェクトが大きくなるにしたがって、設計した方がよくなります。作業の洗い出しや、進め方が準備できているので、開発メンバーに適切な作業を割り当てられます。同じ機能を複数メンバーで開発してしまったり、実装方針が食い違っていて、完成したオブジェクトがうまく統合できないような事態を防げます。

個人開発でも、設計してから作業を進めるようにして、積極的に経験を積みましょう。