読者です 読者をやめる 読者になる 読者になる

テラシュールブログ

旧テラシュールウェアブログUnity記事。主にUnityのTipsやAR・VR、ニコニコ動画についてのメモを残します。

【Unity】Flappy Birdライクなミニゲームを作成した際のメモ

[Unity]Flappy Birdライクなゲーム試作で紹介したFlappy Birdライクなゲームを作成したときの手順を紹介します。
tsubakit1.hateblo.jp


分からない事があれば、このブログか@tsubaki_t1に連絡くれると
超喜びます(フィードバックほすぃ)。

このプロジェクトのソースコードここで公開してます。

イメージをまとめる

まず自分が作るものをイメージする。今回の場合はFlappy Bird
イメージしたら要素を分解して羅列してみる。

イメージラフ
  1.  魚が右(固定)に移動
  2. 魚はジャンプする
  3. 土管の隙間をくぐるとスコア
  4. 土管の隙間はランダムな位置にあった気がする
  5. 土管や地面に触るとゲームオーバー
完璧な模倣であればもう少し項目が増えるが、面倒なので無視。面白さを確認するだけならこの程度で十分なので、この要素をさらに分解してみる。

  1. 画像を表示
  2. 魚がジャンプで上下に移動
  3. 魚(もしくは土管)が右に移動
  4. 土管を無限に生成
  5. 生成時に座標を調整
  6. 土管との魚は接触する
  7. 土管の間を通過する判定
  8. スコアの加算制御
  9. スコアの表示
  10. ゲームオーバー時操作が効かなくなる

絵作り

まずは自分のイメージしている絵を作ってみる。一旦絵を作り動かしてみるのがこの手の簡単なゲームを作る場合は一番わかりやすい。

今回はAngry Chicken Learnより素材を拝借した。魚でないのが残念だが、魚の画像がないので仕方がない。パブリックドメインなので、使用も問題ないし。

投入した画像がSpriteになっていなかったのでTextureTypeからSpriteに変更。確か2Dモードで実行しておけば自動でなるのだが、何故か3Dモードでプロジェクトを作ってたらしい。確認するとCameraのProjectionもPerspective(3Dモード)だったのでOrthographic(2Dモード)に変更しておく。この辺りはプロジェクト作る時に2Dモード設定してれば治るハズなのだが。

スプライト変更

後はPlayer_Fly0(鳥の画像)とboad(板の画像)をシーンに適当に配置して絵作り。適当に並べて大体イメージ通りの画像が出来た。
画像配置

作ったら実際に(手動やAnimation)で動かしてみて調整する。動かすことで実際の動作イメージを掴んだり、新しいアイディアを見つけたり、問題点を発見出来る。

今回の場合は板の隙間が上下しても他の場所に隙間が発生しないことが条件になっているので、そのあたりも確認。動かしているうちに両方選択するのが面倒になってきたので、板オブジェクトの中に板画像を配置する事にした。これで板オブジェクトを動かせば板画像2枚とも動かせる。

位置調整

鳥をジャンプさせる

とりあえず配置したので鳥をジャンプさせる事にする。記憶では割と機敏に上下していた気がするので、大きさをかなり小さくして調整(ボックス=1m。小さい方が機敏に動き、大きいと遅い)。いくらなんでも鳥が1mなのはおかしいので20cmくらいを目指す、面倒くさいので適当なGameObject(命名root)に入れてをrootのscale一気に0.2にした。これで板の画像も一緒に小さく出来る。

後はジャンプ処理の実装。
ジャンプ処理は

  • 落下
  • 入力を受け付ける
  • ジャンプ
の3つに分けられる。落下については面倒なのでrigidbody2dを使う事にする。そうすればジャンプ処理はrigidbody2d.velocityを弄るだけで済む。最終的にrigidbody2dを使うにせよ使わないにせよ、とりあえず形になるのでこれは割と好き。

InspectorのAdd Componentボタンの検索ボタンにrigidbodyと打ってrigidbody2dを選択。最近コンポーネントD&Dしたりメニューから選択するよりもコッチのほうが楽な事に気づいた。追加したら落下するか確認してみる、うんOK。

rigidbody2d追加

入力を受けたらジャンプする機能を追加する。PlayerController.csとか適当なC#スクリプトを作って適当な場所に配置。タッチされたらフラグを立てる処理を追加する。これでボタンが押されているタイミングのみisJumpRequestがtrueになる。(※1)

private bool isJumpRequest;
void Update ()
{
if( Input.GetMouseButtonDown (0) ){
isJumpRequest = true;
}
}

Updateの下の方に以下の処理を追加しておく。これでisJumpRequestがtrueなら(ボタンが押された瞬間なら)ジャンプするようになる。


public float power = 2;

void FixedUpdate ()
{
if (isJumpRequest) {
isJumpRequest = false;
rigidbody2D.velocity = Vector3.up * power;
}
}

今作ったスクリプトを鳥に作成したスクリプト(PlayerController.cs)をアタッチして、動作を確認。
割と上手い感じに飛んでくれる。

飛行鳥

(※1)rigidbodyはUpdateと異なるタイミングで動作しているため、Updateにrigidbody2dの処理を記述すると稀に失敗する。そのため、Updateではフラグ立てのみを行う。

右に移動する処理

右に移動する処理だが、板自体を左に動かすことにした。逆に鳥を動かしても良かったのだが、無限に移動するのも気持ち悪い話なので(※2)。背景の多重スクロールもコッチのほうがやりやすいって話もある。

板オブジェクトにbox collider2dを追加して当たり判定を追加し、鳥との判定を確保。動かすために板オブジェクトにrigidbody2dを追加する(※3)。これで上下のコライダーが板オブジェクト一つとして計算される。

「板」オブジェクトに左に移動する処理を記述する。MoveBoad.csを用意して、中に記述を追加。


    public float speed = 1;
    void FixedUpdate ()
    {
        rigidbody2D.velocity = -Vector2.right * speed;
    }

ただココで一つ問題が発生する。どうもrigidbody2dはFixedUpdateのタイミングでvelocityを上書きすれば挙動をハック出来るといった訳では無いらしく、鳥と衝突した場合板が転がってしまう。その対策にrigidbody2dに「gravity scaleを0」「Massを1000くらい(とにかく大きい数)」「FixedAngleにチェック」を設定した。

gravity scale

(※2)実際はrigidbodyの数的に地面より背景動かした方が効率的だが、一定距離でプレイヤーの位置を初期化するのが面倒だったので背景を移動する方を選択。

(※3)別に要らないように思うかもしれないが、rigidbodyがない場合にコライダーを動かすと毎回計算が走るらしく、場合によっては致命的なスパイクを呼ぶ。

板を無限に出現させる

板を定期的に配置する処理を追加する。方法は単純に一定時間ごとにInstantate(生成)し範囲外に移動したら削除する。

まずは一定時間ごとにオブジェクトを配置する処理を記述する。BoadManager.csを用意し、処理を記述。単純にInstantateを書かないのは、この処理が拡張される事が多いため(※4)。

ここで指定するboadObjectにはプレハブ化した板オブジェクトを配置する。


public GameObject boadObject;

    void LocalInstantate ()
    {
        // オブジェクトの生成して子オブジェクトに登録
        GameObject obj = (GameObject)GameObject.Instantiate(boadObject) ;
        obj.transform.parent = transform;
       
        // 座標を設定.
        float y = Random.Range (3f, 10f);
        obj.transform.localPosition = new Vector3(0, y, 0);
    }

この際ミスで親オブジェクトがscaleを変更していた事を忘れてそのままプレハブ化してしまったため、生成時にものすごい大きな板オブジェクトを出現させてしまった(※5)。これはプレハブ化するオブジェクトを一旦root直下に移動させてからApplyすることで問題を回避した。

このコードで板オブジェクトを生成する場所は、BoadManagerをアタッチしたオブジェクトの上3~10くらいの位置。この3〜10の値は絵作り中に板オブジェクトを上下に動かして画面内に収まる位置で決めた。

スクリーンショット 2014-03-16 1.22.23
それとオブジェクトが一定時間ごとに出現する処理も追加する。一定時間ごとに出現させる処理はInvokeRepeatingやコルーチン等々色々とあるが、今回はUpdateとTime.timeSinceLevelLoadを利用して実装する。

作業が完了したら適当なゲームオブジェクト(命名:板マネージャー)を作ってBoadManagerを登録し、板プレハブをboadObjectに登録。再生すればゲーム板オブジェクトが定期的に流れるようになる。


    float nextSpawnTime = 0;
    float interval = 2;
    void Update()
    {
        if( nextSpawnTime < Time.timeSinceLevelLoad)
        {
            nextSpawnTime = Time.timeSinceLevelLoad + interval;
            LocalInstantate();
        }
    }

画面外に出たら削除する処理も作成する。画面外判定もOnBecameInvisibleや位置判定等々色々とあるが、今回は移動する物にRendererが無いのでOnTriggerExit2Dを使用して判定する。

先ほど作成した板の管理オブジェクトにboxcollider2dを追加し「isTrigger」にチェック。boxcollider2dのcenterやsizeを使い画面外となる位置に判定を配置。

画面外判定

後は下の処理をBoadManagerに追加する。OnTriggerEnter2DではなくOnTriggerExit2Dなのは動いていないオブジェクトに反応させないため。



    void OnTriggerExit2D (Collider2D cal)
    {
        Destroy (cal.attachedRigidbody.gameObject);
    }

 板出現

(※4)大抵の場合、この後パラメータ追加やオブジェクトの参照関連の処理が入るため、別メソッドにしたほうが良い

(※5)GameObject.Instantiateは一旦オブジェクトをグローバルスケールに配置するため、親オブジェクトがscaleを変更していた場合、オブジェクトの大きさが変化してしまう。

スコアの追加

板の隙間を抜けるとスコアが加算する仕組みを追加する。

板と板の間にbox collider2Dが付いたオブジェクトを追加して、Is Triggerにチェックを入れる。当然このオブジェクトは板オブジェクトと同じようにうごくように、板オブジェクト下に配置しておく。

スコアの判定設定
スコアの保存場所だが、色々考えた末にGameController(※6)を作り保存する事にする。今回はコンパクトなゲームなのでGameController.csを作成。SingletonMonoBehaviourを継承して何処からでもアクセスできるようにしておく。

スコアはせっかく作ったのでNotificationObjectに登録しておく。スコアやゲームステート、プレイヤー等の情報はNotificationObjectで制御しておくと連携が楽だ。inspectorから確認出来なくなってしまう問題はあるが…


public class GameController : SingletonMonoBehaviour<GameController>
{
    public NotificationObject<int> score = new NotificationObject<int>(0);
   
    void OnDestroy()
    {
        score.Dispose();
    }
}

プレイヤーが通過した際にスコアが発生する仕組みも作成する。AddScore.csを追加して接触時にスコア追加。これを先ほどつくった棒と棒の間の当たり判定に追加しておけば、通過時自動でスコアが加算される。


    void OnTriggerEnter2D(Collider2D cal)
    {
        GameController.Instance.score.Value += 1;
    }

一つ注意なのが、単純に判定だけを取るとプレイヤー以外が通過した場合もカウントされてしまう点だ。対策に通過したものがPlayerタグの場合のみスコアを加算するように記述しておけば間違いは無い。 当然この処理を追加したらプレイヤーはPlayerタグに設定しておく必要がある。なので、上のコードを下のように変更する。


    void OnTriggerEnter2D(Collider2D cal)
    {
        if( cal.CompareTag("Player") ){
            GameController.Instance.score.Value += 1;
        }
    }

プレイヤータグ
(※6)GameControllerはUnityにビルトインされているタグの一つ。多くの場合、ゲームの制御を行う中枢オブジェクトはこのタグを持ち、この名称に近い名称を持つスクリプトを持つ。

スコアの表示

現状ではスコアが見れないのでスコアを表示する。

3DTextで画面の真ん中に数字を配置。 ScoreLabel.csを作成し作成した3DTextにアタッチ。GameController.Instance.scoreと接続し変更通知を受け取る処理を記述する。これで数値が変更した場合のみ数値を変更する処理が走る。

NotificationObjectの特性で、Destroyする際は(例えばシーン切り替えでGameControllerが先に破棄されている可能性も考え)GameController.Instanceを事前にチェック。既に破棄されていた場合はイベントもついでに全て破棄されているので気にせず破棄。

Startでイベント登録前にGameController.Instance.scoreでラベルをアップデートしているのは、値が初期化前・初期化後の両方に対応するため。


    void Start ()
    {
        // 事前に更新.
        UpdateScoreLabel (GameController.Instance.score.Value);
        // 変更通知を登録.
        GameController.Instance.score.changed += UpdateScoreLabel;
    }

    void OnDestroy ()
    {
        // 登録した変更通知を解除.
        if (GameController.Instance != null) {
            GameController.Instance.score.changed -= ChangeScore;
        }
    }

    void ChangeScore (int score){}

ChangeScoreにラベルを更新する処理を更新する。NotificationObjectに登録するイベントは"Change変数名"みたいに名前を統一しておくと良いかも。


    void ChangeScore (int score)
    {
        GetComponent<TextMesh>().text = score.ToString ();
    }

ラベル設定

ゲームオーバーを作る

プレイとゲームオーバーのステートを作る。enumでステートを定義しておいて、その定義をシングルトンのGameControllerに持たせ、変更通知を受けられるようにNotificationObjectで登録する。

GameController.csにステートの定義(GameState)を追加し、NotificationObject<GameState> gameStateを追加する。


    public enum GameState
    {
        Title,
        Play,
        GameOver
    }
    public NotificationObject<GameState> gameState = new NotificationObject<GameState>( GameState.Play );

忘れずにOnDestroyで削除する対象に追加しておく。


    void OnDestroy()
    {
        score.Dispose();
        gameState.Dispose(); //<- 追加.
    }

PlayerController.csに壁と接触した時にステートをGameOverにする処理を追加。これでプレイヤーが壁と接触したらゲームステートがGameoverへ移行する。


    void OnCollisionEnter2D (Collision2D  cal)
    {
        GameController.Instance.gameState.Value = GameController.GameState.GameOver;
    }

ステートの状態で挙動の変わるオブジェクトにステート変更通知を受ける処理を追加し、各々必要な処理を記述しておく。
プレイヤーの場合はゲームオーバー時に入力を効かなくする。
PlayerController.csに下の処理を記述


    void Start ()
    {
        // 通知の登録(定型文)
        ChangeGameState (GameController.Instance.gameState.Value);
        GameController.Instance.gameState.changed += ChangeGameState;
    }
   
    void OnDestroy ()
    {
        // 通知の解除(定型文)
        if (GameController.Instance != null)
            GameController.Instance.gameState.changed -= ChangeGameState;
    }
   
    void ChangeGameState (GameController.GameState state)
    {
        switch (state) {
        case GameController.GameState.GameOver:
            // ゲームオーバー:updateを停止して入力を受け付けなくする
            enabled = false;
            break;
        }
    }

地面を用意してスクロールする

せっかくなので地面も用意する。スクロールする背景を作るには「スプライトを並べて総移動」と「テクスチャのUVをスクロール」が思いつくが、今回はテクスチャのUV移動を選択。多重スクロールがやりたい。なおスプライトのUV移動は現状出来ないので(※7)、QUADを使用して実現する。

地面

メニュー>Create Other>QUADでQuadを用意して、スクロールさせる画像をD&Dで登録する。今回の場合はImages>Stageの中にあるboxを使用。名前は地面とでもしておく。地面オブジェクトのスケールのxを3にして横に3倍に引き伸ばし、shaderのtilingを3にする。もし1個しか表示されない場合は、テクスチャのwrapModeがRepeatになっていない。

shaderはunlit>Textureを使用。もし透明を使いたいならばunlit>Transparentを使う(※9)

スクロール処理を追加する。これは単純にUVを時間毎に変更を加えたもの。多重スクロールしたい場合は、速度を落とした画像を複数配置すればOK。


    public float speed = 1;

    float current;
    void Update () {
        current += Time.deltaTime * speed;
        renderer.material.SetTextureOffset("_MainTex", new Vector2(current, 0));
    }

スクロール
地面との接触判定を作るため、地面オブジェクトにbox collider2dを付与する。ただしこのままでは流れてくる板オブジェクトに干渉するので、対策に地面と流れてくる板オブジェクトはレイヤーを分けお互い接触しないようにする。

レイヤー分け前
地 面オブジェクトは「ground」、板オブジェクトは「boad」レイヤーに所属させ、メ ニュー>Edit>PorjectSettings>Physics2dよりLayerCollisionMatrixを調整。 groundとboadの列からチェックを外す。もし板の奥に地面が表示される場合、座標のz値をマイナス方向に弄る。

LayerCollisionMatrix.jpg

(※8)やろうと思えばスプライトでもスクロール出来るには出来る。
(※9)パフォーマンスはTexture >>>>> Transparent なので、可能な限りTextureを使うと良い。

背景を多重スクロールさせる

画面の奥行きを作るため多重スクロールさせる。奥のものは遅く、手前の物は高速で動作するので、画面奥にスクロールする板を配置し、スピードを低速に設定する。背景の場合は当たり判定が不要なのでコライダーは外しておく。

多重スクロール

スクロール処理にはspeedを設定していたので、それを変更すればOK。その際、当たり判定は不要なので外しておく。

ゲームオーバーからのリトライ

ゲームオーバー後にゲームをリトライする処理を追加する。これはStateがGameOverの時何らかのアクションで現在のシーンを読み直せば良い。

今回はGUIを選択。適当なスクリプトGUIController.cs)を作成し、下のコードを記述。もうすこしマトモなGUIを組みたい場合、変更通知でリザルト画面をLoadLevelAditiveにしたり、そのタイミングで下から飛び出すアニメーションさせたりする。


void OnGUI ()
    {
        switch (GameController.Instance.gameState.Value) {
        case GameController.GameState.GameOver:
            GUIGameOver ();
            break;
        }
    }

    void GUIGameOver ()
    {
        float x = Screen.width * 0.1f;
        float y = Screen.height * 0.6f;
        if (GUI.Button (new Rect (x, y, Screen.width - (x * 2), Screen.height * 0.3f), "RETRY")) {
                Application.LoadLevel (Application.loadedLevel);
        }
    }


本当は小分けにしようかとおもったけど、
面倒になったので一気に公開。

音とかフェードエフェクトとか向き変えは…反響があれば