本章では、プレイヤーキャラクター(以降、プレイヤー)を実装するための仕様と、ゲームシステムの利用方法を解説します。本章の内容をもとに、実装してみてください。
ゲームシステムのときと同様に、サムネイル(図5.1)や、ゲーム画面を見ながら、詳細を検討します。
図5.1: ゲーム画面のサムネイル
図5.2: ゲーム画面のモックアップ
画面イメージを眺めながら、ゲームが動いている様子を想像して、プレイヤーの機能を具体化していきます。ゲーム画面が表示される演出から想像をはじめて、操作した時の反応、他のキャラクターと衝突したときに起きることなど、プレイヤーに関連する動作をすべて書き出します。
これらが実装できれば、プレイヤーは完成します。
今回のようなシンプルなプレイヤーなら、1つのクラスにすべての機能を実装しても、問題は起きないでしょう。しかし、本企画の目的は、設計を学ぶことです。設計の経験を積むために、SOLID原則に基づいて、複数のクラスとインターフェースを定義して、実装します。
SOLID原則は、次のような5つの原則です。
これらを念頭に、プレイヤーを実装するためのクラスやインターフェースを検討します。
先に挙げたプレイヤーの機能を実装するクラスを、単一責任の原則に基づいて検討します。
ゲームの進行に応じて、スタート地点に戻ったり、操作の禁止や許可をしたりする必要があります。プレイヤーを管理するためのPlayerクラスを定義します。状態管理の他、ゲームシステムとのやりとりもプレイヤーの管理といえるので、このクラスに担当させます。
各状態については、Playerクラスに実装するか、状態ごとにクラスを用意するか、迷うところです。状態が少なく、実装する処理も小さいため、状態ごとにクラスを用意するのは、やりすぎな可能性があります。のちほど、改めて検討します。
操作は、複数のコントローラーに対応させる予定でした。あとで対応コントローラーを増やすことも考えられます。これらの処理を1つのクラスで実装すると、対応コントローラーを増やすときに、クラスの修正が必要になります。これは、SOLID原則のオープン・クローズドの原則違反です。また、入力と移動といった複数の理由で、クラスの修正が発生するので、単一責任の原則にも違反します。
以上から、各操作ごとに読み取りクラスを用意します。また、移動は、操作と別の移動クラスを定義して、そこに実装します。
アニメをさせるなら、アニメを管理する場所の検討が必要です。アニメを統括するクラスを作るか、状態に応じて変化させるならPlayer、移動に関するアニメは移動クラスに実装してもよいでしょう。
今回は、移動する方向を向かせるだけです。移動に関する処理なので、移動クラスに担当させればよいでしょう。
岩や木箱で停止する機能は、移動の実装時に、改めて検討します。
プレイヤーと爆弾が接触したことは、両者とも知ることができます。それぞれの実装方法を具体的に考えてみましょう。
プレイヤーは、爆弾以外に、コインとも接触します。プレイヤーに実装する場合、衝突した相手を判定して、処理を分割する必要がありそうです。一方の爆弾は、接触相手がプレイヤーなら爆発します。それ以外の処理はありません。爆弾の方が、検出がシンプルに実装できます。
ゲームオーバーの要求には、第4章で取り上げたIGameOverEmitterインターフェースを使います。プレイヤーは1つしかいないので、ゲームシステムからIGameOverEmitterを設定するのが簡単です。ただし、プレイヤーが爆弾に触ったことを知る必要があります。爆弾に実装すれば、爆弾のみで処理を完了できます。気になるのは、爆弾はステージ中に複数あるので、すべての爆弾にIGameOverEmitterの設定が必要なことです。初期化の処理時間やメモリの使用量が、やや増えます。
以上のように、接触したら何かをするというゲームでよくある処理でも、実装方法はいくつも考えられます。思いついた方法をいきなり実装しようとせず、いくつか案を出して、メリットとデメリットを比較して選ぶとよいでしょう。
相手の種類を知るには、タグが使えます。プレイヤーのゲームオブジェクトに、Playerタグを設定しておくとよいでしょう。爆弾にも、必要ならば何らかのタグを設定します。
プレイヤーが、コインと接触したことを検出する処理は、爆弾と同じ理由で、コイン側に実装する方がシンプルです。
コインを取ったら、第4章で決めたように、IGetCoinEmitterで受け取ったメソッドを呼び出します。コインの基本点を引数で渡すようにすれば、加点とコインを減らす処理をまとめて実行できます。プレイヤーとコインのどちらに実装するかは、爆弾のゲームオーバー報告と同様の判断が必要です。どちらでも構いませんが、混乱を避けるために、爆弾とコインで統一するのがよいでしょう。
IGetCoinEmitterをコインに実装するなら、プレイヤーには、コイン向けの実装は不要です。タグがPlayerになっていれば、問題ありません。
プレイヤーの機能で挙げた項目について、実装方法を検討しました。プレイヤーは、次のような構成にします。
以上を実装する手順を検討します。これらは、同時に開発できます。入力と移動がなくても、プレイヤーの状態管理クラスは作れます。移動するタイミングになったら、Debug.Logで「移動開始」などと表示すれば、状態が正しく変わっていることは確認できます。デバッグキーで、コインの取得やゲームオーバーの動作を試すこともできます。
コントローラーの入力の開発では、移動量をDebug.Logなどで出力すればよいでしょう。オブジェクトの移動は、完成してからまとめられます。
移動クラスも同様です。入力クラスがなくても、仮の入力クラスを作成したり、テストランナーで入力を渡せば、開発できます。
すべてが揃ったら、プレイヤーの進行管理クラスに組み込んで完成です。クラスを分割することで、チーム開発がやりやすくなります。
プレイヤーの構造が決まりました。手順を決めて、実装を進めていきましょう。
手順は、出力>入力>処理の順がオススメです。また、簡単なものから実装するのがコツです。不明なことが多いと、同じ内容でも実装は難しく感じます。パズルを組み立てるときと同じです。
プレイヤーの管理クラス、入力を読み取るクラス、移動クラスは、同時に開発できます。担当を決めて、一斉に実装してみるのもよいでしょう。
プレイヤーの進行管理を実装するための解説をします。まずは、必要な機能を整理します。
これらのひな形となるPlayer.csが、Assets/Yoketoru/Scripts/Game/Playerフォルダーに作成済みです。ゲームの状態変更を受け取るように、実装されています。Playして、ゲームを開始すると、操作ができるようになるタイミングで「操作と移動開始」とログに表示されます(図5.3)。
図5.3: ゲームが開始したことを示すログ
操作や移動は、他の人が開発することを前提として話しを進めます。挙げた機能のうち、「クラス名は、Playerとする」「MonoBehaviourを継承」「IGameStateListenerを実装する」は、ひな形で完了しています。状態の管理も、enumで定義して、状態を簡易的に管理するSimpleStateを使って、実装済みです。移動を停止するのは、操作と移動をしなければいいので、現時点ですでにできています。
残っているのは、Awake時に座標と回転を記録しておくことと、Resetの初期化時に、記録していた座標と回転を復帰することだけです。
残りの作業の実装手順を検討します。実装したときに、結果が分かるように実装を進めるのがコツです。Resetの初期化時に、記録していた座標と回転を復帰したことを確認するには、初期座標から動いている必要があります。まず、そこから作ります。
以上の順で作業を進めれば、スムーズに完成できるでしょう。
開発用のブランチを作成して、そこで作業するのがよいマナーです。次の手順で、開発用のブランチを作成します。操作は、慣れているものを使ってください。ここでは、GitHub Desktopの操作を示します。
以上で、プレイヤー管理スクリプトを開発するためのブランチが作成されて、切り替わります。
Playをしたら、プレイヤーの座標と回転を強制的に変えるコードを実装します。
リスト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(オー)キーを押すと、デバッグ機能でゲームオーバーになります。ゲームオーバー画面でスペースキーを押してコンティニューすると、カウントダウンがはじまります。この段階では、プレイヤーは右上に向いたままです。これを、最初の座標と向きに戻すのが、ここで実装したい機能です。
次のヒントを参考に、機能を実装してください。変数名は、分かりやすい名前をつけてください。
transform.positionを代入するtransform.Find("Pivot").eulerAnglesを代入するtransform.positionに、座標を記録するための変数の値を代入するtransform.Find("Pivot").eulerAnglesに、回転を記録するための変数の値を代入する実装できたら、Playして、ゲームオーバーからコンティニューしてください。座標と回転が初期状態に戻れば、実装完了です。Playの初期化に実装していた、デバッグ用の移動処理は、消しておくとよいでしょう。
実装が完了したら、コミットとプッシュをしましょう。
あっという間でしたが、Playerクラスの実装はひとまず完了です。単一責任の原則に基づいてクラスを分割したことで、クラスの役割が減って、開発が楽になりました。複雑さが減れば、バグも起きにくくなります。
次の作業は、操作と移動用のクラスが完成してからになります。この作業の担当者は、他の作業を進めることができます。複数のメンバーで、次々に作業を進められるのも、事前に仕様を決めて、クラスを適切に分割することのメリットの1つです。
入力デバイスの値を読み取って、移動量を返す機能を作成します。
企画概要で、次の操作に対応すると書きました。
操作の読み取りクラスを個別に用意すれば、実装をシンプルにできます。対応デバイスを増やすために、新しいクラスを増やせば、既存のクラスの修正は不要です。オープン・クローズドの原則に基づいた設計といえます。
複数の入力を、移動指示にまとめる処理を、Playerクラスに実装するのは、役割が違うと感じます。入力をまとめる役割のクラスを用意した方がよいでしょう。そのクラスのインスタンスを、Playerクラスに持たせて、必要なタイミングで利用します。
入力をまとめるクラス内で、入力デバイスを見分けて制御するのは、オープン・クローズドの原則に違反します。操作を読み取るクラスは、共通するインターフェースを定義して、呼び出し方を統一します。
以上から、次のようなクラスやインターフェースを用意します。
インターフェースはすぐに定義できて、すぐに利用したいので、真っ先に実装します。入力デバイスをまとめるクラスと、入力デバイスごとの読み取りクラスは、同時進行で進められます。
手分けをする前に、インターフェースを手早く実装して、共有します。
今回のプロジェクトは、入力を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: }
インターフェースが定義できたら、入力をまとめるクラスと、入力値を読み取るクラスは、平行して開発できます。
プレイヤー管理の実装と同様に、開発用のブランチを作成してから、実装をはじめましょう。
以上で、開発用のブランチが作成されて、切り替わります。
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: }
動作を確認するための開発用のシーンとスクリプトを用意すれば、他の環境と切り離して、このクラスを開発できます。開発環境を用意する手順です。
リスト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)と表示されれば成功です。コミットして、プッシュしておきましょう。この段階では、入力デバイスを実装していないので、操作をしても、値は変わりません。
続きは、入力デバイスを読み取るクラスの解説の後に用意しています。
キーボードの操作を読み取るクラスを実装します。入力をまとめるクラスと同様の手順で、開発用の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するコードを加えて開発できます。
ここでは、同時に開発している想定で、開発用の環境を用意して、単独で開発を進めます。
リスト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、押されていなければ、通常の速さにすれば、スピードアップ操作ができます。
開発環境の準備方法は、キー入力と同じです。Keyの部分を、GamePadに置き換えて、同様の手順でクラスを作成して、開発環境を準備してください。
以上できたら、中身の実装に取り組みます。
D-padの入力は、キーと同じ設定なので、対応は不要です。このクラスでは、アナログ入力に対応させます。
アナログ入力は、Input.GetAxisで読み取ります。左右をHorizontalAnalog、上下をVerticalAnalogで設定してあります。返すのは、キーと同様に、最新の値でよいでしょう。Updateごとに入力を読み取って、inputValueへ代入します。アナログ入力は、正規化済みの値が得られるので、normalizedは不要です。
アナログ入力は、倒し方で速さを変えられるので、スピードアップボタンへの対応は不要です。もちろん、対応させても構いません。自由に設定してください。
今回、ゲームコントローラーは、Xboxのゲームパッドに限定しています。Windows用やPS用など、さまざまなゲームコントローラーがありますが、これらのスティックやボタンの定義は、残念ながら統一されていません。
今回はテーマ外なので扱いませんが、本気で実装する場合は、標準的なゲームコントローラーのプリセットを用意したり、キー設定機能を用意することになります。
キーやゲームパッドの入力と同様の手順で開発します。KeyやGamePadの部分を、Mouseに差し替えます。
マウスの移動は、Input.GetAixsを使って読み取ります。左右がMouse X、上下がMouse Yです。
マウスの移動量は、前回のUpdateから、今回のUpdateまでの間に移動した量が返されます。1回の物理更新の間に、10回Updateが実行されたなら、10回分の移動量を合計した値が、移動に使いたい量です。キーやゲームパッドとは、違うコードになります。
マウスの移動量は、キーやゲームパッドと違い、-1から1の範囲を越える可能性があります。最高速度のときのマウスの移動量を決めて、読み取ったマウスの移動量を、最高速度のマウスの移動量で割れば、-1から1に対応づけることができます。
このままだと、マウスを最高速度より大きく移動させると、戻り値の大きさが1を越えてしまいます。最高速度を制限したいので、GetValueが返すベクトルの長さが1を超えないように、制限してください。
操作関連のクラスがすべて完成したら、統合します。
1人で作業をしていたら、マージするのが手っ取り早いです。別のアカウントで作業をしていたら、プルリクエストで統合するのが一般的です。しかし、マージやプルリクエストは、GitHubに慣れているメンバーがいないと、コンフリクトが起きたときの対処ができません。今回の内容なら、スクリプトファイルをコピーするだけなので、統合担当者にファイルを渡すような力技で解決できます。
入力をまとめるクラスで統合するのが合理的です。入力をまとめるクラスの担当者に、各入力処理のスクリプトを渡してください。Googleドライブやネットドライブ、USBメモリ、メールに添付するなど、何らかの手段でファイルを渡してください。
入力をまとめるクラスの担当者は、受け取ったファイルを、プロジェクトのScriptsフォルダー内の任意の場所にコピーします。あとは、InputControllerのinputsに、リスト5.8のように入力クラスのインスタンスを登録します。
リスト5.8: すべての入力を登録
IInput[] inputs =
{
new KeyInput(),
new GamePadInput(),
new MouseInput(),
};
DevInputControllerシーンを開いて、Playしてください。キーやゲームパッド、マウスを操作して、ログに期待した数値が表示されれば成功です。
キーやゲームパッドが反応せず、マウスしか操作できないバグが予想されます。その場合、InputControllerのGetValueの実装方法を間違えています。3つのデバイスのうち、どの値を返せばよいかをメンバーで相談して、修正してください。
InputControllerへの統合ができたら、プレイヤーに組み込みましょう。
スクリプトファイルをコピーしても構いませんが、ファイルが複数あるので少々面倒です。そこで、unitypackageを使う方法を紹介します。作業するのは、入力のまとめクラスの担当者がよいでしょう。
図5.6: DevInputControllerシーンの依存ファイルを選ぶ
図5.7: パッケージとしてエクスポート
図5.8: ファイルをエクスポートする
以上で、スクリプトファイルをunitypackageファイルにエクスポートできました。これを、ゲームの開発用ブランチに読み込みます。
図5.9: inputsパッケージをインポートする
以上で、作成したスクリプトを、Playerを開発したプロジェクトに加えられました。あとは、Playerクラスで、InputControllerを利用すれば、入力を読み取れます。
リスト5.9: InputControllerを定義する
InputController inputController = new();
リスト5.10: Play状態なら、Updateごとに入力を更新する
case State.Play:
inputController.Update();
break;
リスト5.11: 入力値を取り出して、ログへ表示する
case State.Play:
var move = inputController.GetValue();
Debug.Log($"{move}");
break;
Projectウィンドウから、GameSystemシーンをダブルクリックして開いて、Playしてください。ゲームを開始して、カウントダウンが終わると、コンソールに入力値が表示されるようになります。キーやマウスを操作して、表示される数値が変わることを確認してください。
これで、プレイヤーを操作するための入力値を取得する処理を、Playerに実装できました。
次のようなクラスを作成して、Playerに組み込みました。
すべての入力デバイス用のクラスは、操作を抽象化したIInputインターフェースを実装しました。これにより、IInputの配列に、入力デバイスを読み取るクラスのインスタンスを列挙すれば、繰り返し文で入力処理を実行できます。拡張も簡単です。
作成したスクリプトをまとめる方法として、ファイルのコピーと、unitypackageへエクスポートする方法を紹介しました。
GitHubのマージやプルリクエストを使うと、ファイルの移動をせずに済みます。ただ、コンフリクトが起きたときの対応が難しいので、ここでは取り上げませんでした。企業でのチーム開発では、欠かせない機能なので、GitHubの公式ドキュメント*1などで、学習することをオススメします。
[*1] GitHub Docs. Hello World: https://docs.github.com/ja/get-started/start-your-journey/hello-world
移動処理の仕様を検討して、実装をします。
移動に関して、思い浮かぶことを書き出して、整理します。
移動のスクリプトに関するものは、1、3、4です。ゲームオブジェクトを移動させるので、MonoBehaviourを継承したクラスに実装するのがよいでしょう。SerializeFieldが使えるので、速度の上限は、Inspectorで設定できるようにします。
Playerクラスから、移動用のクラスを直に参照すると、あとで実装先やクラス名を変更する際に、Playerクラスの修正が必要になります。インターフェースを使えば、実装先などの変更の影響を受けなくなります。Vector2の移動ベクトルを受け取って、速度を設定するメソッドを持たせたインターフェースを定義するのがよいでしょう。
効率よく作業する手順を考えます。まずは、手軽にできるゲームオブジェクトの設定を、Playerプレハブにします。5、6、8、9はこれで完了します。
あとはスクリプトの実装です。先に検討した、Vector2の移動量を受け取って、自動を実行するメソッドを定義したインターフェースを作成します。インターフェースができたら、作成するスクリプトの動作を確認するための環境を作ります。あとは、必要な機能を実装します。引数として1の入力を受け取って、3の設定を用意して、移動します。移動したら、4の向きの設定を実装して完成です。残っている2は、状態管理からの呼び出し方なので、ここでやることはありません。
整理すると、手順は次のとおりです。
ゲームオブジェクトを設定します。Playerプレハブを開いて、設定を確認していきます。
Projectビューから、Assets/Yoketoru/Prefabsを開いて、Playerプレハブをダブルクリックします。作業の間に、他のメンバーがPlayerプレハブを変更しないように、声掛けをしてください。Playerプレハブが、変更される可能性があれば、PlayerプレハブをCtrl+Dキーで複製して、複製したプレハブで作業するのが安全です。
Playerプレハブをダブルクリックして選択したら、Inspectorを確認します。爆弾やアイテムからプレイヤーと認識できるように、TagをPlayerに設定します(図5.10)。
図5.10: Playerタグを設定する
Z方向には動かず、すべての軸で衝突による回転をさせないために、RigidbodyのConstraints(制約)の設定を、図5.11のようにします。すでに設定されていれば、そのままで大丈夫です。
図5.11: Playerの物理制限
岩や木箱にぶつかる機能は、RigidbodyとColliderに任せましょう。Sphere ColliderのIs Triggerが、チェックされていないことを確認します。
図5.12: Sphere Colliderの設定
以上で、書き出した機能のうち、5、6、8、9が設定できました。
プレイヤーの移動を実装します。これまでと同様に、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: }
スクリプトの開発環境を作ります。
Playして、エラーがないことを確認してください。まだ、何も起きません。
DevPlayerを調整します。
リスト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)で囲んでいるのと、同じ効果があります。
これで、開発環境が整いました。
CharacterMoverスクリプトを開いて、Moveメソッドを実装します。
移動ベクトルを受け取って、上限速度に応じて移動させるキャラなら、プレイヤー以外にもこの処理は使えます。そこで、CharacterMoverという名前のクラスにします。
リスト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: }
Playすると、一定時間ごとに変わる移動量が表示されます。先に作成した、開発環境のスクリプトが、一定時間ごとに移動方向を変えて、Moveを呼び出しているからです。動作することが確認できたら、Moveメソッド内のDebug.Logは不要なので、消してください。
Moveメソッドを実装します。一番簡単な方法は、Rigidbodyのvelocityに、速度を設定することです。Rigidbodyのインスタンスは、変数rbに取得済みです。移動の指示は、引数のmoveに渡されるので、これに速度上限のmaxSpeedをかけた値を、rb.velocityに代入すれば完了です。
移動方向にプレイヤーを向かせます。今回は、DevPlayerの子オブジェクトであるPivotを回転させます。親オブジェクトを回転させると、モデルの軸が中心からずれているような場合に調整が難しくなります。子オブジェクトを起点にして回転させると、調整しやすい構造になります。
Pivotのtransformは、transform.Find("Pivot")で得られます。Findは、やや重めの処理なので、StartやAwakeで取得して、変数に保存したものを使うのがよいでしょう。
この処理は、いろいろな考え方があります。
X-Z平面を移動する場合、上方向が変わらないので、transform.forwardに前方のベクトルを代入すれば、前を向かせることができます。今回はX-Y平面を移動するため、上方向が変わってしまいます。そのため、前を向かせるのに工夫が必要です。QuaternionやVectorを調べて、理解を深めるのに役立ててください。
CharacterMoverスクリプトができたら、Playerに統合しましょう。必要なのは、CharacterMoverスクリプトのファイルだけなので、コピーなどで受け渡せばよいでしょう。
Playerの開発担当者は、CharacterMoverスクリプトを受け取ったら、player-stateブランチのプロジェクトに加えます。このクラスは、ゲームオブジェクトにアタッチして使います。ProjectビューのAssets/Yoketoru/Prefabsフォルダー内のPlayerプレハブに、ドラッグ&ドロップでアタッチします。
Playerスクリプトに、移動を呼び出す処理を加えます。
リスト5.15: IMoverの変数を定義
IMover mover;
リスト5.16: Awakeで、IMoverを取得する
void Awake() {
mover = GetComponent<IMover>();
}
リスト5.17: Playerスクリプトに、移動呼び出しを追加
case State.Play:
var move = inputController.GetValue();
mover.Move(move);
break;
Playerの実装は、これで完了です。コミットとプッシュをします。
すべてできたら、player-stateブランチを、masterブランチにマージすれば完成です。GitHub Desktopなどで、次の手順でマージします。
以上で、プレイヤーの開発完了です。
プレイヤーが、ゲームオーバーやクリアしたときに止まらなくなる可能性があります。他にも、想定していない不具合が発生していたら、原因を調べて、修正に取り組んでください。
プレイヤーの機能として、次のものが実装できました。
次のものは、爆弾やコインに実装します。
作業に先立って機能を書き出して、各機能の実装方法を検討しました。作業の効率を考えて、作業手順を決めてから、作業に取り組みました。
スクリプトは、単一責任の原則に基づいて、クラスを分けました。必要な機能と、メソッドの呼び出し方を決めて、インターフェースを用意しました。このような準備をしたことで、プレイヤーという1つのオブジェクトを、複数人で並行して開発できることを示しました。
複数メンバーで開発する際は、masterブランチで作業するのは避けます。作業用のブランチを作ってから作業をして、完了したら、成果をまとめます。
設計には、それなりの労力と時間がかかります。すぐに作業に取りかかれないので、開発効率が悪いと感じることがあります。実際に、ごくシンプルなものを個人で開発するなら、いきなり開発した方が効率的な場合はあります。しかし、複数メンバーで開発する場合や、プロジェクトが大きくなるにしたがって、設計した方がよくなります。作業の洗い出しや、進め方が準備できているので、開発メンバーに適切な作業を割り当てられます。同じ機能を複数メンバーで開発してしまったり、実装方針が食い違っていて、完成したオブジェクトがうまく統合できないような事態を防げます。
個人開発でも、設計してから作業を進めるようにして、積極的に経験を積みましょう。