最終更新:
orika_ex_miyako 2023年03月04日(土) 20:18:36履歴
まずは問題点の確認と、改善案の提示を行います。
その後、2回の手順に分けて処理のリファクタリングを行い、抽象化を完成させます。
異なるイベントを発生させる場合、Ray の判定や、OnTriggerEnterメソッドなどの判定内に
以下のようにクラスやタグなどを利用して情報源を特定していく手法があります。
<Ray を利用し、クラスを利用した分岐判定のケース>
// クラス SampleA のアタッチされているゲームオブジェクトと接触した場合
if (hit.collider.TryGetComponent(out SampleA sampleA) == true) {
// SampleA 用の処理
sampleA.SampleMethod();
}
// クラス SampleB のアタッチされているゲームオブジェクトと接触した場合
else if (hit.collider.TryGetComponent(out SampleB sampleB) == true) {
// SampleB 用の処理
sampleB.SampleMethod();
}
// クラス SampleC のアタッチされているゲームオブジェクトと接触した場合
else if (hit.collider.TryGetComponent(out SampleC sampleC) == true) {
// SampleC 用の処理
sampleC.SampleMethod();
}
<OnTriggerEnter メソッド を利用し、タグを利用した分岐判定のケース>
// Enemy のタグと接触した場合
if (collider.CompareTag("Enemy") == true) {
// Enemy 用の処理
collider.gameObject.TryGetComponent(out Enemy enemy){
enemy.SampleMethod();
}
}
// Item のタグと接触した場合
else if (collider.CompareTag("Item") == true) {
// Item 用の処理
collider.gameObject.TryGetComponent(out Item item){
item.SampleMethod();
}
}
// Gem のタグと接触した場合
else if (collider.CompareTag("Gem") == true) {
// Gem 用の処理
collider.gameObject.TryGetComponent(out Gem gem){
gem.SampleMethod();
}
}
どちらのケースの場合も、以下のような問題点があります。
�.ぅ戰鵐�(クラスやタグなど含む)の種類が増減するたびに、このスクリプト内の分岐処理の修正を余儀なくされる �∧�岐の内部には似通ったような処理を書くようになる ��タグの場合、文字列指定であるためソースコードのタイプミスや、ゲームオブジェクトへの設定ミスが発生する
共通する問題点は�,鉢△任后�
機能を追加/削除する度に�,僚だ気�必要になり、その都度、�△僚萢�を追加/削除する、という作業が必要になります。
そのため保守管理が大変になっていることが分かります。
このような場合、処理を抽象化させることにより、実行側は「どのクラス」であるかを知ることなく処理を実行していくことが可能になります。
実装の方法を抽象化することで保守管理を簡素化し、かつ、�,�発生しても�△僚だ気�必要のない処理を構築できます。
インターフェースやクラスの継承を活用して、メソッドの振る舞いを変える手法を指します。
また、具体的な実装の詳細を隠蔽することによって、プログラムの柔軟性と再利用性を高めることを指します。
抽象化によって、クラスやメソッドをより一般的(generally/general)な形で表現することができます。
これにより、コードの理解やメンテナンスが容易になり、より柔軟なロジックを構築できます。
Unity における抽象化の例として、抽象クラスやインターフェースを挙げることができます。
抽象クラスは、クラスの継承を前提に作成する基底クラスです。
具体的な実装を持たず、派生クラスで実装されるメソッドを定義することができます。
一方、インターフェースは、抽象クラスよりもさらに一般的な形であり、実装されるメソッドのみを定義することができます。
これらの抽象化の手法を使用することによって、コードの重複を減らし、機能の変更や追加をより容易に行うことができます。
また、他の開発者が作成したクラスやライブラリを利用する場合にも、抽象化によって、その実装の詳細を知る必要がなく、より簡単に利用することができます。
特定のボタンを押した際に、Ray を投射してゲーム内にあるコライダーを持つオブジェクトを取得し、
そのゲームオブジェクトにアタッチされているクラスを取得することで処理の分岐を実行していく実装例です。
/// <summary>
/// 行動ボタンを押した際の処理
/// </summary>
private IEnumerator Action() {
// Player の位置を起点とし、Player の向いている方向に 1.0f 分だけ Ray を発射し、NPC レイヤーを持つゲームオブジェクトに接触するか判定し、その情報を hit 変数に代入
RaycastHit2D hit = Physics2D.Raycast(rb.position, lookDirection, 1.0f, LayerMask.GetMask(actionlayerMasks));
// Scene ビューにて Ray の可視化
Debug.DrawRay(rb.position, lookDirection, Color.blue, 1.0f);
// Ray によって hit 変数にコライダーを持つゲームオブジェクトの情報が取得出来ていない場合、処理しない
if (!hit.collider) {
yield break;
}
// アクション中の場合、処理しない
if (isActionCheck) {
yield break;
}
// 移動停止
StopPlayer();
isActionCheck = true;
// 御朱印と接触した場合
if (hit.collider.TryGetComponent(out Goshuin gos) == true) {
yield return StartCoroutine(getGenerator.ActivatePlacementGetStampPopUp2(gos.goshuinData));
Destroy(gos.gameObject);
GameData.instance.AddGoshuinInventryData(gos.goshuinData.goshuinName);
isActionCheck = true;
}
// 宝箱と接触した場合
else if (hit.collider.TryGetComponent(out TreasureBox box) == true) {
//Boxシナリオの作成
yield return StartCoroutine(AdvEngineController.instance.JumpScenarioAsync(box.itemData.itemScenario, null));
GameData.instance.AddItemInventryData(box.itemData.itemName);
//箱を開ける時の処理
box.OpenTresureBox();
//アクションチェックboolをtrue
isActionCheck = true;
}
// NPCと接触した場合
else if (hit.collider.TryGetComponent(out NonPlayerCharacter npc) == true) {
// NPCの向きを変更する
npc.LookDirection(GetFaceNPC(facePlayer));
//NPCシナリオの作成
yield return StartCoroutine(AdvEngineController.instance.JumpScenarioAsync(npc.npcData.npcScenario, null));
//アクションチェックboolをtrue
isActionCheck = true;
}
GameData.instance.SaveAllUserDatas();
// 再度移動とアクションを可能にする
NonStopPlayer();
isActionCheck = false;
//UTAGEのフラグがある場合は
if(AdvEngineController.instance.AdvEngine.Param.GetParameterBoolean(npc.utageParamBoolName.ToString()) == true)
{
if (npc.utageParamBoolName.ToString() == "ep5_Satokibi5")
{
Debug.Log("AAA");
Debug.Log(npc == null);
npc.Suspect();
}
}
if(AdvEngineController.instance.AdvEngine.Param.GetParameterBoolean(npc.utageParamBoolName.ToString()) == true)
{
// if 御朱印がNoData出なければ、御朱印を追加する
if (npc.npcData.goshuin != GoshuinName.NoData)
{
if (!GameData.instance.goshuinInventryDatasList.Exists(x => x.goshuinName == npc.npcData.goshuin))
{
getGenerator.ActivatePlacementGetStampPopUp(DataBaseManager.instance.GetGoshuinDataFromName(npc.npcData.goshuin));
GameData.instance.AddGoshuinInventryData(DataBaseManager.instance.GetGoshuinDataFromName(npc.npcData.goshuin).goshuinName);
}
}
}
yield return null;
}
イベントの処理が複数分岐していますので、こちらの一元化を検討します。
先ほど説明があったように処理の抽象化を行い、メソッドの振る舞いを各派生クラス内で実装することにより、
実行する側は「クラスを気にすることなく」、常に1つのメソッドを実行することで処理を実行できるようにします。
具体的には、次回以降の手順で下記のような処理を作ることで、すべてのイベント処理を問題なく実行する抽象化した処理を作成します。
特に重要なのが��と�い任后�
<� ヾ�存の Ray の処理のうち、Ray の結果を受けて処理を先に進めるか、止めるかを判定する機能をメソッド化>
/// <summary>
/// プレイヤーから投射した Ray が、指定した Layer 設定のあるコライダーに接触したか判定
/// 接触しており、かつ、該当するオブジェクトの場合にはアクション成功となり、イベント制御開始
/// </summary>
private void PrepareJedgeAction() {
(bool isReadyAction, GameObject hitObj) item = JedgeAction(lookDirection); // 下記�△鮗孫圓掘¬瓩蠱佑鬟織廛觀燭納�け取る
if (!item.isReadyAction) {
return;
}
// 判定に成功した場合、イベント実行
StartCoroutine(StartEventCoroutine(item.hitObj)); // 下記��
}
<�◆ヾ�存の Ray の処理のうち、Ray の投射とコライダー判定部分を1つのメソッド化。戻り値を利用して判定結果を提供する機能>
/// <summary>
/// Ray を投射して対象のオブジェクトがアクションの対象物か判定
/// </summary>
/// <param name="lookDirection"></param>
/// <returns></returns>
public (bool isReacyAction, GameObject hitObj) JedgeAction(Vector2 lookDirection) {
// Player の位置を起点とし、Player の向いている方向に 1.0f 分だけ Ray を発射し、NPC レイヤーを持つゲームオブジェクトに接触するか判定し、その情報を hit 変数に代入
RaycastHit2D hit = Physics2D.Raycast(transform.position, lookDirection, 1.0f, LayerMask.GetMask(actionlayerMasks));
// Scene ビューにて Ray の可視化
Debug.DrawRay(transform.position, lookDirection, Color.blue, 1.0f);
// Ray によって hit 変数にコライダーを持つゲームオブジェクトの情報が取得出来ていない場合、処理しない
if (!hit.collider) {
return (false, null);
}
return (true, hit.collider.gameObject);
}
<�� 今までのクラスによる分岐処理を実行するメソッドを抽象化することで一元化>
/// <summary>
/// コルーチンによる非同期処理によるイベント処理の制御
/// </summary>
/// <returns></returns>
private IEnumerator StartEventCoroutine(GameObject hitObj) {
// イベント開始。移動とアクション停止。
EnterGameEvent();
// イベント発生
yield return StartCoroutine(PlayActionCoroutine(hitObj)); // ← 下記�ぁ�今までの分岐部分
// イベント終了。再度移動とアクションを可能にする
ExitGameEvent();
}
<�ぁヽ謄ぅ戰鵐判萢�を実行するメソッド内で処理の振る舞いを変える>
/// <summary>
/// コルーチンによる非同期
/// </summary>
/// <returns></returns>
public IEnumerator ExecuteGameEventsCoroutine() {
yield return StartCoroutine(gameEvent.ExecuteEventCoroutine()); // ← ここで各イベントを実行する
Debug.Log("イベント 終了");
}
�い納孫圓気譴織�ラス側では、実装しているメソッドの内容を実行します。
そのため、��や�い任六�前に分岐を作成する必要がなく、決まった処理を行うだけで処理が自動的に振る舞いを変えるように設計しています。
抽象化によって処理がどのように変化しているのかを確認してみてください。
大きく2つあります。
まずは分岐の処理です。
<いままで>
// 御朱印と接触した場合
if (hit.collider.TryGetComponent(out Goshuin gos) == true) {
yield return StartCoroutine(getGenerator.ActivatePlacementGetStampPopUp2(gos.goshuinData));
Destroy(gos.gameObject);
GameData.instance.AddGoshuinInventryData(gos.goshuinData.goshuinName);
//isActionCheck = true;
isSaveOn = true;
}
// 宝箱と接触した場合
else if (hit.collider.TryGetComponent(out TreasureBox box) == true) {
//Boxシナリオの作成
yield return StartCoroutine(AdvEngineController.instance.JumpScenarioAsync(box.itemData.itemScenario, null));
GameData.instance.AddItemInventryData(box.itemData.itemName);
//箱を開ける時の処理
box.OpenTresureBox();
//アクションチェックboolをtrue
//isActionCheck = true;
isSaveOn = true;
}
// NPCと接触した場合
else if (hit.collider.TryGetComponent(out NonPlayerCharacter npc) == true) {
// NPCの向きを変更する
npc.LookDirection(GetFaceNPC(facePlayer));
//NPCシナリオの作成
yield return StartCoroutine(AdvEngineController.instance.JumpScenarioAsync(npc.npcData.npcScenario, null));
//アクションチェックboolをtrue
isActionCheck = true;
}
各クラスごとに if 文による分岐を作成し、該当するクラスで処理を分けています。
この手法はタグも同様ですが、クラスが増減するたびソースコードの修正を行う必要があります。
そのため、保守がしにくく、処理がどんどんと肥大化していく恐れもあります。
<今回の処理>
// イベント発生
yield return StartCoroutine(PlayActionCoroutine(hitObj));
たったの1行です。
処理の抽象化を行うことにより、分岐の必要がなくなります。
つまり、イベントの種類が増減しても、ソースコード自体の修正は一切発生しない設計が実現できます。
もう1つは分岐内で実行されていた各イベントの処理です。
こちらも実行させるための処理は1行です。
/// <summary>
/// コルーチンによる非同期
/// </summary>
/// <returns></returns>
public IEnumerator ExecuteGameEventsCoroutine() {
yield return StartCoroutine(gameEvent.ExecuteEventCoroutine()); // ← ここ
Debug.Log("イベント 終了");
}
各イベント用のクラス(会話イベント、宝箱、御朱印など)内で ExecuteEventCoroutine メソッドを実装し、その中で各イベントの処理を記述しています。
そのため、この一連の処理の流れにおいては、各イベントの中身は一切関係なく、処理を実行する、ということだけで処理が成立するようにしています。
実行されるメソッド側の処理も抽象化してあるため、各クラスにあるメソッドがそれぞれ異なる振る舞いを行うため、
実行側はクラスを気にすることなく、実行する処理は1つに集約することが出来ます。
現在のように分岐を作り、その中に処理を書く、という処理の構成ではなく、
処理自体は1つメソッドを共通で実行し、その実行されたメソッド内で各イベントがそれぞれの処理を実行する、という形で責務を分担しています。
例えば、会話イベントの処理は、個別のクラスを作成し、その中には次のような処理が施されています。
protected override IEnumerator ExecuteEventCoroutine() {
string npcScenario = GetComponent<NonPlayerCharacter>().npcData.npcScenario;
// NPCシナリオの作成
yield return StartCoroutine(AdvEngineController.instance.JumpScenarioAsync(npcScenario, null));
}
いままで NPC 用の分岐内に書かれてた処理です。
これを一連の処理内に書くのではなく、個別のクラスに用意した ExecuteEventCoroutine メソッド内に移動して処理しています。
そしてアイテムの取得の処理も、個別のクラスを作成しメソッドの内容はこのようになります。
public override IEnumerator ExecuteEventCoroutine() {
// すでに所持しているアイテムの場合、セーブ処理はせず、獲得処理もしない
if (GameData.instance.itemInventryDatasList.Exists(x => x.itemName == itemName)) {
yield break;
}
// 今回終了した会話イベント後に紐づいたイベントがあるか確認
if (AdvEngineController.instance.AdvEngine.Param.GetParameterBoolean(utageParamBoolName.ToString())) {
GameData.instance.AddItemInventryData(itemName);
}
}
どちらのクラスにも同じExecuteEventCoroutine メソッドがあります。
異なるクラスであるもののメソッドの名前だけは同じで、でも書いてある処理がそれぞれ異なっていることがおわかりいただけるかと思います。
このようにして、同じメソッドを実行しているにもかからず、実際の処理が変化していくことをメソッドの振る舞いを変えるといいます。
オブジェクト指向型プログラムにおける「多態性(たたいせい:ポリモーフィズム)」の概念を体現する機能となります。
ファミコンや、CDプレイヤーといったガジェットを思い浮かべてみてください。
これらは再生するための本体とソフト(CD)に分かれていると思います。それゆえに汎用性と拡張性があります。
いままでの処理は、これら2つを一緒に1つのソースコード内に書いている形式です。
ファミコンで例えるなら、電源を入れてゲームが動いて電源を落とすまでの処理が書いてあるだけではなく、
どんなゲームが動くかもあらかじめ分岐の部分によって決められてしまっている状態です。
つまり、遊べるゲームが決まっているファミコンの機能をまとめて作っていたイメージです。
これではファミコン本来の「ソフトを指せば、そのソフトが動く」という機能とは異なります。
今回はここをリファクタリングしています。
ファミコンでは、電源を入れればゲームが動くという部分は共通ですが、遊べる内容はソフトによって違うはずです。
それをプログラムで表現するために分岐を作って、どれが動くのかを記載していました。
ただしこのままでは遊べるソフトは決まっていますし、ソフトを増減するたびに処理の修正が必要になります。最初に提起した問題点の部分です。
また、ファミコン自体はどんなソフトを起動しているのかは知りません。刺さっているから起動しているに過ぎないのです。
今回の実装はまさに「電源を入れたら」⇒「刺さっているソフトが自動的に動く」ようにするための処理です。
ファミコン自体は、ソフトが何であるかを知っている必要はなく、それがファミコンのソフトであるかどうかだけ分かればいいのです。
作成した� 銑い琉賚△僚萢�の中には「ソフト側の処理」は書いていません。特に��と�い鮴�非見直してみてください。
ファミコンだけあれば、あとはソフトを買えばゲームが遊べました。その仕組みを作っているのが��〜�い僚萢�です。
ここでは、電源を入れたら、自動的にソフトを読み込む、という機能だけを作っていることになります。
ファミコン本体の役割に相当する機能を作っているイメージです。
このような仕組みにしておけば、処理を修正する必要がありません。
つまり、本体を完成させているので直す場所はない状態にしています。
そしてソフト側(会話イベント、宝箱イベントなど)は、この� 銑い琉賚△僚萢�には含まず、各クラス側に記載する仕組みにしています。
つまり、新しいイベント処理を作るためには、新しいクラスを作成すれば(新しいソフトを買えば)、
あとは、ファミコン側の処理(��と��)がイベント処理を自動的に読み込んでくれるようになっています。
刺さっている各ソフトを実行する部分が�い� ExecuteEventCoroutine メソッドを実行する部分です。
そうすることで、例えば会話イベントであれば ExecuteEventCoroutine メソッド内に会話イベントの処理があるので、会話イベントが発生します。
共通の ExecuteEventCoroutine メソッドを実行し、振る舞いを変える部分=刺さっているソフトを立ち上げて各ゲームを遊ぶ部分に相当します。
ファミコンを使ったイメージ図

この考え方において、プログラムでは抽象化という方法を使うことで再現をすることが出来ます。
以上でこの手順は終了です。
それでは実際に処理のリファクタリングを行いながら、現在の処理を抽象化していきます。
次の手順は 処理の抽象化による実装例�� です。




コメントをかく