Unityで簡単な2D脱出ゲームを作ってウェブサイトで公開してみよう 〜アイテム取得と選択〜

この記事はだいぶ前に書かれたものなので情報が古いかもしれません
取得と選択

この記事を三行にまとめると

基本的にアクティブ状態をどう操作するかってだけの話
動画の方でも海馬がずっと俺のターンとか言ってますが
そんな優しい世界も悪くないだろう?
今回はアイテムの取得と取得したアイテムを選択できるところまでやります。実際にアイテムを使用するのは次回ということで。前回もちょろっと言いましたが今回と次回は脱出ゲーム作りの一つの山場だと思うんで、いつものごとく長くなるとは思いますがおつき合いいただければと。

とはいえ、基本的にアイテムの取得も選択もアクティブ状態をどう操作するかってだけの話なんでね。まあ、楽な気持ちで行きましょう。

動画はこちらです。





アイテムの取得

まずはアイテムを取得する方を実装します。

本作ではアイテム取得時に「クリックしたアイテムの非表示」「一覧に取得したアイテムを表示」「アイテムの詳細画面を表示」の3つの動作が発生します。やることは3つともアクティブ状態を切り替えるだけです。

インスペクターのEvent Triggerだけでもこれらの動きを実装することは可能なんですが、今回は後のためにこの3つの動作をスクリプトから実行します。

いつも通り先にコードから見てみましょう。

public class Tongs : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }
}

動画ではトングを取得する例として書いてますが、他のアイテムもやることは一緒です。

get変数はゲーム画面内にある方のアイテムです。アイテム取得用のGetItem関数が発動するとこいつは非アクティブになり、画面内から消えます。

item変数は一覧の方のアイテムです。getとは反対にこいつはGetItem関数が発動するとアクティブになり、一覧画面に現れます。

hint変数はアイテム詳細画面を開くためのスクリプト用の変数です。アイテム詳細画面はヒエラルキーだとこんな感じになっています。

詳細画面のアイテム一覧(の一部)

Hintオブジェクトは画面をまとめて扱うための空オブジェクトで、OverlayやBackgroundがアイテム詳細画面の背景、TongsやChair、Cutterなどがその画面内に表示するためのアイテムオブジェクトです。それぞれの中にはアイテムの画像オブジェクト(Item)とアイテム名のテキストオブジェクト(Name)が入っています。Closeは閉じるボタン。

Hintオブジェクトのコンポーネントにはヒント画面を開く時に作ったDialogスクリプトを追加してあります。なのでhint変数にHintオブジェクトをセットすればDialogスクリプトのOpenDialog関数が使用できます。

ちなみにDialogスクリプトの中身はこうなっています。

public class Dialog : MonoBehaviour {
  public GameObject dialog;
  private GameObject current;

  public void OpenDialog(GameObject detail) {
    current = detail;
    ChangeActive(true);
  }

  public void CloseDialog() {
    ChangeActive(false);
  }

  private void ChangeActive(bool active) {
    dialog.SetActive(active);
    current.SetActive(active);  
  }
}

OpenDialog関数には画面内に表示するためのオブジェクトを渡すための引数が用意されていまが、ここには上記のヒエラルキーで言うところのTongsやChairなどを渡すことになります。なのでTongsスクリプトの中のdetail関数にセットするのもこのTongsやChairになります。

これでアイテムの取得は完成です。まとめるとこんな感じですね。

public class Tongs : MonoBehaviour {
  public GameObject get; //ゲーム画面内のアイテム
  public GameObject item; //一覧のアイテム
  public Dialog hint; //Dialogスクリプト
  public GameObject detail; //詳細画面に表示するアイテム

  public void GetItem() {
    get.SetActive(false); //画面内のアイテムの非表示
    item.SetActive(true); //一覧のアイテムの表示
    hint.OpenDialog(detail); //詳細画面の表示
  }
}



アイテムの選択

続いて取得したアイテムを選択する動きを実装してみましょう。先ほどのTongsスクリプトの中に「SelectItem」という関数を作ってみます。

public class Tongs : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;
  public GameObject selected;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }

  public void SelectItem() {
    if(item.activeSelf) {
      selected.SetActive(true);
    }
  }
}

selected変数はアイテムの選択状態を表すオブジェクトです。本作は選択状態のアイテムは赤枠で表示される仕様になっていますが、その赤枠の画像です。SelectItem関数が発動するとこれがアクティブになります。

ただし無条件にアクティブにしてしまうと取得してないアイテムも選択状態になってしまうので、if文を使って「アイテムが取得済みならアクティブにする」という処理にしています。アイテムが取得状態かどうかは一覧のアイテム(item変数)のアクティブ状態で判定できます。

このSelectItem関数を発動させるクリックイベントは一覧のアイテムではなく、各アイテムの枠である白い長方形オブジェクトにつけています。アイテムにつけてしまうとアイテムごとにクリックできる範囲が異なるので微妙かなと思ったのと、たぶん僕がプレイヤーだったら長方形全体がクリックできると思いそうだと思ったのでそうしました。

基本的にUIオブジェクトは重なった状態だと奥の方(ヒエラルキーで上にある方)のイベントが発生しなくなりますが、例外的に親子関係にある場合の親オブジェクトにだけイベントがついている場合は、手前に子オブジェクトがあってもイベントが有効になります。本作のアイテム一覧のヒエラルキーはこうなっています。

アイテム一覧(の一部)

TongsやChair、Cutterが白い長方形オブジェクトでItemがアイテムの画像オブジェクト、Selectedが選択状態用の赤枠の画像オブジェクトです。TongsたちとItem、Selectedは親子関係にあり、SelectItem関数は親のクリックイベントにセットされています。ItemとSelectedには何のイベントもついていません。つまり今言った例外パターンの条件を満たしているので、ItemやSelectedの上にカーソルがある時でもSelectItemを発動させることができます。



選択アイテムの詳細画面を開く

この辺はゲームによって動きが変わると思いますが、本作は選択状態のアイテムをもう一度クリックすると詳細画面が開くようになっています。やり方はいくつかあります。例えばSelectItemと同じようにアイテムが選択状態なら画面を開く関数を作るとか。

public class Tongs : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;
  public GameObject selected;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }

  public void SelectItem() {
    if(item.activeSelf) {
      selected.SetActive(true);
    }
  }

  public void OpenDetail() {
    if(selected.activeSelf) {
      hint.OpenDialog(detail);
    }
  }
}

このOpenDetail関数も長方形オブジェクトのクリックイベントに追加すれば、アイテムだけがアクティブ状態ならアイテムが選択状態になり、選択状態の方もアクティブなら詳細画面が開く動きになります。

でも本作ではSelectItem関数の中にもう一つ分岐の条件を追加して両方の処理を場合分けしています。

public class Tongs : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;
  public GameObject selected;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }

  public void SelectItem() {
    if(selected.activeSelf) {
      hint.OpenDialog(detail);
    else if(item.activeSelf) {
      selected.SetActive(true);
    }
  }
}

これなら選択状態がアクティブなら詳細画面を開き、そうでない場合はアイテムがアクティブならアイテムを選択状態にするという動きを実現できます。どちらも非アクティブなら何も起こりません。

else ifで複数の条件分岐を行う時、条件判定は上から順番に行われる点は気をつけておいた方が良いかもです。上記の場合はまずselectedのアクティブ判定が行われて、結果がfalseだったら次のitemのアクティブ判定を行うという順に処理が走ります。

これを逆に書いた場合。

public class Tongs : MonoBehaviour {
  public void SelectItem() {
    if(item.activeSelf) {
      selected.SetActive(true);
    else if(selected.activeSelf) {
      hint.OpenDialog(detail);
    }
  }
}

これだと先にアイテムがアクティブ状態かどうかの判定が行われます。つまりアイテム取得後は常にここの結果がtrueになるため、次のselectedのアクティブ判定に行くことがありません。したがって詳細画面は永遠に開かれないことになります。動画の方でも海馬がずっと俺のターンとか言ってますが、ずっと選択状態をアクティブにする処理だけが発生し続けます。たとえすでにselectedがアクティブであってもライフが0であっても構わずにアクティブにし続けます。



選択状態の解除

ここまででアイテムを選択状態にすることはできましたが、今のままだと他のアイテムを選択しても選択状態が解除されずにずっと選択状態のまま残ってしまいます。動画の方でも海馬がずっと(ry

動きとしては、SelectItem関数で選択状態にする時に自分以外のアクティブなselectedが非アクティブになればオーケーです。

これに関してもやり方はいろいろ考えられます。現在選択状態のselectedを別の変数に入れておくとか、foreachを使ってまとめて非アクティブにするとか。ただ、前者は今回のパターンだとちょっちめんどいです。本作の場合、各アイテムのスクリプトは独立しています。前にRoomスクリプトを別々のオブジェクトにセットするとnow変数の値が同期されないって話をしましたが、あれと一緒で現在の選択状態を保持する変数を用意した場合、全アイテムスクリプトのその変数の中身を同期させるような処理を書かなきゃいけない。

だから今回の場合はselectedをまとめて扱える配列用の変数を用意して、foreachで毎回自分以外のselectedを非アクティブにする処理にしちゃう方がまだ楽なんですが、これはこれでちょいしんどい。本作は全部で10個のアイテムがあるので、当然ながらselectedも全部で10個ある。ってーことはインスペクターで各アイテムスクリプトの変数に自分以外の9個のselectedをドラッグ&ドロップしなきゃいけないわけで、全部で90回のドラッグ&ドロップが必要になる。少しでもこの手間を減らす方法はないものか。

ということで本作ではタグ機能を使っています。

オブジェクトを選択するとインスペクターの上の方に「Tag」という項目があります。

タグ

これはオブジェクトをタグ付けするための設定項目です。デフォルトでもいくつかのタグが用意されているんですが「Add Tag」で好きな名前のタグを作成できるので、これで共通のタグをつければ複数のオブジェクトをグループ化でき、配列用の変数に一つずつオブジェクトをセットしなくてもforeachでまとめて扱えるようになります。

Unityには「GameObject.FindGameObjectsWithTag」という関数が用意されています。これを使うと指定したタグがつけられたオブジェクトをまとめて取得できるので、これで全部のselectedを取得すればforeachでまとめて非アクティブにできます。

public class Tongs : MonoBehaviour {
  public void SelectItem() {
    if(selected.activeSelf) {
      hint.OpenDialog(detail);
    else if(item.activeSelf) {
      GameObject[] selects = GameObject.FindGameObjectsWithTag("Selected");
      foreach(GameObject select in selects) {
        select.SetActive(false);
      }

      selected.SetActive(true);
    }
  }
}

FindGameObjectsWithTagのかっこの中のSelectedはオブジェクト名ではなくタグ名です。同じ名前にしちゃったんでややこしいですが、ここで指定した名前のタグがつけられたオブジェクトを取ってくるという内容です。

ちなみにFindGameObjectsWithTagで取れるのは現在アクティブな状態のオブジェクトだけです。だからこの処理で取ってきているのは全部のSelectedオブジェクトではなく、現在アクティブなSelectedのみです。結果的に現在選択状態のものだけを非アクティブにするという処理になっているわけですね。



詳細画面を閉じずに他のアイテムを選択した場合

動画で言うと7:30くらいからの部分(コナンくんがやっべーって言ってるとこ)なんですが、現状のDialogスクリプトではCloseDialog関数を実行した時しか詳細部分が非アクティブにならないので、今はアイテム詳細画面を開いた後、そのまま画面を閉じずに別のアイテムを選択して詳細画面を開くと前のアイテムが一緒に表示されてしまいます。

これを防ぐにはCloseDialogを実行しなくても詳細部分を非アクティブにする必要があるので、OpenDialog関数の中で非アクティブになっていない詳細があれば最初に非アクティブにするという処理を追加します。

public class Dialog : MonoBehaviour {
  public GameObject dialog;
  private GameObject current;

  public void OpenDialog(GameObject detail) {
    if(current != null && current.activeSelf) {
      current.SetActive(false);
    }

    current = detail;
    ChangeActive(true);
  }

  public void CloseDialog() {
    ChangeActive(false);
  }

  private void ChangeActive(bool active) {
    dialog.SetActive(active);
    current.SetActive(active);  
  }
}

これでオッケーです。現在表示中の詳細はcurrent変数で持っているので、こいつがアクティブであれば最初に非アクティブにすることで表示の重複が起こらなくなります。ただしactiveSelfやSetActiveは何かしらのオブエジェクトが変数にセットされていないとエラーになります。currentはゲーム開始時には何のオブジェクトもセットされていないので、今回はcurrentがnullかどうかの判定も入れています。

さっきのelse ifと同じで、&&で複数の条件をつないだ場合も判定の順番があります。基本的には左から順に判定されます。数学の式と同じような感覚ですかね。だから「if(current.activeSelf && current != null)」と書くとcurrentがnullの時にエラーになるので、今回の場合は厳密に「current != null」の方を左に書いておく必要があります。






やはり長くなってしまいましたな。すんまそん。

あとついさっき気づいたんだけど、OpenDialog関数で最初に非アクティブにするんだったら、別にCloseDialog関数でcurrentを非アクティブにする必要はないんだね。それに考えたらDialogスクリプトの方もcurrent変数で表示中のオブジェクトを取っておくやり方じゃなくて、詳細オブジェクトをタグ付けしてOpenDialog関数の中でFindGameObjectsWithTagを使って非アクティブにすれば良かったのか。そうすりゃcurrent変数すらもいらなくなるな。どっちが良いかは何とも言えないですが、同じような処理に対してバラバラのコードを書くよりはなるべく同じ処理を書いておいた方が後で見直した時にも分かりやすいし、秩序と美女を愛する僕にも合っている気はします。

何にせよ、これで動画や記事で紹介してるコードにも最適化できてない部分はまだまだあるってことも分かりましたし、僕がそれを偉そうに世界に向けて発信してる男だってこともバレましたので、動画や記事を見てこれしかアイテムの取得や選択を実装する方法がないと思う必要はなく、こんなやり方もあるんだという一つの参考にしてもらえればと思います。Unityに造詣が深い人ならもっと良いやり方を知っているだろうし、むしろもっとこうした方が良いという情報があれば僕も教わりたい。ぺこぱのツッコミの人も言ってましたからね。「知識は水だ。独占してはいけない」って。

逆に「こいつの書くコード全然ダメじゃね? 説明も分かりづれーし」って思っても、あまりマウントは取らないでいただけると助かります。だって涙が出ちゃう。いや、説明が分かりづらいのは改善せにゃいかんし、怒られて当然なのだがね。

まあ僕に限らず、そこにマウンテンがあれば遠慮なく登っても良いですけど、そこにマウントを取れる人がいても一も二もなく噛みつくのは控えた方がよろしいかと。ぺこぱのツッコミの人も言ってましたからね。「間違いはふるさとだ、。誰にでもある」って。だから間違いを見つけてもこれみよがしにマウントを取るんじゃなくて、正しい答えを教え合っていこう。そんな優しい世界も悪くないだろう?

ただでさえ長いのに余計なことを言ってしまいました。えーと、次回は何だっけ。アイテムの使用ですね。これも基本はアクティブ状態の操作です。

それでは次回もよろしくお願いしっだーるた。



本シリーズの記事の一覧はこちら
Unityで簡単な2D脱出ゲームを作ってウェブサイトで公開してみよう 〜エピローグ〜
 もしかしたら何か関連しているかも? 
 みんなからのコメント 
2020年04月18日 11:37:11
sunaga
はじめまして。
 つい最近UnityをダウンロードしてUnityについてもC#についても全くわかっていない状態の者ですが、こちらのサイトと動画を拝見しながら見よう見まねで2D脱出ゲームを作らせていただいています。いくつか上手くいかず解決方法が分からないことがあり、もしよろしければ教えていただきたいと思いコメント致します。
 SelectedのTagづけをし、アイテムの選択マークが複数表示されないようにする操作をやってみたのですがSelectItem関数を変更した後も変更前と変わらず選択マークが複数表示されてしまいどうすれば良いか検討がつきません。
 また、部屋を移動すると、最初の方だけ複数の部屋のオブジェクトが重なって表示されてしまうのですが、そのまま部屋の移動を繰り返していると正常に表示されるようになり、修正したいのですがどうすれば良いかわかりません。
 はじめてのコメントで質問ばかりで大変恐縮ですが、ご返信いただけたら嬉しいです。よろしくお願い致します。
2020年04月18日 12:43:34
まっち~(管理人)
コメントありがとうございます。



>SelectedのTagづけをし、アイテムの選択マークが複数表示されないようにする操作をやってみたのですがSelectItem関数を変更した後も変更前と変わらず選択マークが複数表示されてしまいどうすれば良いか検討がつきません。

差し支えなければ作ったコードを見せていただけると原因の特定がしやすいのですが、現時点で考えられるパターンとしては以下のものが挙げられます。

・タグをつけ忘れているSelectedオブジェクトがある
・GameObject.FindGameObjectsWithTag関数で指定しているタグとSelectedオブジェクトにつけたタグの名前が違う(オブジェクト名もタグ名も両方ともSelectedであればスペルミスだったり先頭の大文字小文字が違っていたりするかもしれません)

GameObject.FindGameObjectsWithTagで指定するのはオブジェクト名のSelectedではなくて、タグ名のSelectedです。「GameObject.FindGameObjectsWithTag("タグ名")」みたいな感じです。今回は両方とも同じにしてしまったので混乱させてしまったらすみません。



>部屋を移動すると、最初の方だけ複数の部屋のオブジェクトが重なって表示されてしまうのですが、そのまま部屋の移動を繰り返していると正常に表示されるようになり、修正したいのですがどうすれば良いかわかりません。

僕が書いたものと同じRoomスクリプトで実装しているという前提でのお話になりますが、Roomスクリプトでは表示したい画面の一覧(本作の場合だとRoom1からRoom4のオブジェクトです)をroomsという配列(複数のオブジェクトを一つの変数で扱える機能です)で管理し、現在表示している画面がその配列の何番目にあるのかをnowという変数で管理しています。配列の先頭は0番目です。

nowの値が0だとスクリプトは配列の0番目に入っている画面を表示していると判断します。本作だと配列の0番目にはRoom1オブジェクトがセットされているので、スタート時にnowが0だとスクリプトはRoom1を表示しているものと見なします。

この時、例えばRoom2の画面の動作テストをしたいみたいな理由でUnity側でRoom2をアクティブにした状態でスタートしても、スクリプトはnowが0である限りは常にRoom1がアクティブだと思っているので、移動ボタンを押した時の動きもRoom1から隣に移動する時の処理を行います。本作だとRoom1から左に移動する時はRoom1を非アクティブにしてRoom4をアクティブにするという動きになりますが、これはスタート時にRoom2がアクティブであっても変わらないため、Room2がアクティブなまま、Room4も同時にアクティブになるみたいな動きになります。そのまま移動を繰り返しているといずれnowの値と表示されている画面が一致するので、そこから先は正常な動きになります。

上記のような作業を行っているのであれば、ちょっとめんどうなのですが、Room1以外(配列の0番目以外)の画面を最初に表示してスタートする時は、スクリプトの方でnowの初期値も変えれば重ならなくなると思います。Room2を初期表示にした時はnowの初期値も1にする、みたいなことです。

説明が分かりづらかったり違う原因だったら申し訳ないです。タグづけの方は何とも言えませんが部屋の移動はたぶんこれで解決すると思うんですけど……どうでしょうか?
2020年04月20日 16:09:54
sunaga
早速ご返信いただきありがとうございます。
 SelectedのTagづけに関してですが、一度Unityを閉じて再度開いたら、特にコードやタグ名を変更していないのですがうまく作動するようになりました。お手数をおかけして丁寧に答えていただいたのに確認不足で申し訳ありません。
 部屋の移動に関してはまさに教えていただいた通りの原因だったようで、Room1だけアクティブにした状態から再生するようにしたら正常に動きました。理由についてもとても分かりやすく教えていただき勉強になりました。ありがとうございます。続きの動画、記事も楽しみです。
2020年04月21日 01:40:20
まっち~(管理人)
なるほど……Selectedの方についてはちょい謎ですが、何にせよ無事に動いて良かったです。また何かあれば気軽にご質問ください。
本シリーズはもうちょい続きますんで、残りも引き続きよろしくお願いします。