見出し画像

【Unity】「ぷるぷる☆ゆっくり☆すいかげーむ」を作ってみた(1)ゲームシステム編

Android版

iOS版

パク…オマージュゲームを作ってみました(今人気のゲームも元ネタがあるみたいだし、ま、多少はね?)。
大前提として、「あのゲームの作り方」チャンネルさんの以下の動画を参考に一度例のゲームっぽいものを作っています。

ただし、この動画ではクリックしたところにフルーツが表れるなど、本家とはかなり違うところもあります。そういうところを修正したり、あとは自分でアレンジをくわえたりして、一本のゲームに仕上げました。

↓制作がほぼ終了してから「Unity幼稚園」さんも、動画も投稿されたようです。


1.オブジェクト(ゆっくり)の挙動を実装

(1)マウス位置にオブジェクトを追従させる

予想はついていたと思いますが、自分のゲームでは果物ではなく、ゆっくりをくっつけて、次の大きさのゆっくりにします(当たり前だよなあ)。

さて、マウスにゆっくりを追従させて落下位置を決める挙動ですが、上の記事を参考にすれば導入はできます。
自分は以下の通り、少し使用しやすく改良しました。
今作の場合は、X座標はゆっくり毎に大きさが異なりますので、制限する幅が変わります。
Y座標は落とす高さが固定ですので、上限下限ともに同じ数値を入力します。
2DゲームなのでZ座標は0で固定です。

using System.Collections;
using UnityEngine;

// マウス位置に追従するオブジェクトにアタッチする(範囲制限つき、2Dプロジェクト用)
// 解説記事 http://negi-lab.blog.jp/MouseFollow2D
public class MouseFollow2D : MonoBehaviour{
    public float xmin;
    public float xmax;
    public float ymin;
    public float ymax;

    // 補間の強さ(0f~1f) 。0なら追従しない。1なら遅れなしに追従する。
    [SerializeField, Range(0f, 1f)] private float followStrength;

    private void Update ()
    {
        // マウス位置をスクリーン座標からワールド座標に変換する
        var targetPos = Camera.main.ScreenToWorldPoint ( Input.mousePosition );

        // X, Y座標の範囲を制限する
        targetPos.x = Mathf.Clamp ( targetPos.x, xmin, xmax );
        targetPos.y = Mathf.Clamp ( targetPos.y, ymin, ymax );
        
        // Z座標を修正する
        targetPos.z = 0f;

        // このスクリプトがアタッチされたゲームオブジェクトを、マウス位置に線形補間で追従させる
        transform.position = Vector3.Lerp ( transform.position, targetPos, followStrength );
    }
}

(2)追従していたオブジェクトを、左クリック(スマホでは指を放す)で落下させる

オブジェクトが物理演算の状態がどうであれ、(1)のコンポーネントでマウスに追従するようになるのですが、物理演算が働いているとマウスに追従している間にも色々な力をため込むらしく、真っすぐ落下してくれません。
なので、ゆっくりはマウスに追従しているときは、Rigidbody2DのisKinematicは始めにfalseにして物理演算をストップし、落とす瞬間にtrueにして物理演算を開始するようにします。

以下が画面上部で落下する「ゆっくり」を生成する「YukkuriManager」スクリプトです。

using System;
using System.Collections;
using UnityEngine;
using Random = UnityEngine.Random;

public enum YukkuriType{
    none,
    れいみゅ,
    まりちゃ,
    ぱちぇ,
    ちぇん,
    わされいむ,
    つむり,
    ありす,
    みょん,
    れいむ,
    まりさ,
    すいか,
    MAX,
}

public class YukkuriManager : MonoBehaviour{

    [Header("生成するゆっくりの種類")][SerializeField] private GameObject[] YukkuriPrefabs;
    [Header("NEXTゆっくりを指す矢印")][SerializeField] private GameObject[] nextArrow;
    [SerializeField]private float _y;//ゆっくりの初期位置y
    [SerializeField]private float _z;//ゆっくりの初期位置z
    [Header("次のゆっくりがつくられるまでの時間")] [SerializeField] private float intervalTime;//間隔を開けないと連射出来て負荷がかかる
    public int nextYukkuriIndex;
    private bool canFall;//ゆっくりを落としていい状態か
    private bool yukkuriOn;//落とすゆっくりが表示されているか。
    public Transform yukkuriFolder;//ゆっくりを格納するフォルダ
    private GameObject createdYukkuri;
    private MouseFollow2D _mouseFollow2D;//ゆっくりのマウス追従スクリプト
    private Rigidbody2D _rigidbody2D;
    [SerializeField] private GameObject dashLine;//落下線
    private MouseFollow2D _dashLineMouseFollow2D;//落下線のマウス追従スクリプト
    [NonSerialized] public bool isGameOver;//ゲームオーバー処理中
    private Yukkuri yukkuri;
    [Header("タッチするとゆっくりが落ちる上限位置")][SerializeField] private float touchUpperLimit;

    public static YukkuriManager i; //どこからでもアクセスできるようにする
    void Awake(){
        CheckInstance();
    }

    void CheckInstance(){
        if (i == null){
            i = this;
        } else{
            Destroy(gameObject);
        }
    }

    // Start is called before the first frame update
    void Start(){
        dashLine.SetActive(false);//落下線を非表示
        ChoiceNextYukkuri();//次のゆっくりを抽選
        isGameOver = false;//ゲームオーバー中ではない
    }
    
    //次に引くゆっくりを抽選
    private void ChoiceNextYukkuri(){
        //ゆっくりをランダムに抽選
        nextYukkuriIndex = Random.Range(0,YukkuriPrefabs.Length);
        //次に出現するゆっくりを指す矢印を表示
        nextArrow[nextYukkuriIndex].SetActive(true);
    }
    
    void Update(){
        //ゲームオーバーまたはタイム中はリターン
        if (isGameOver||Time.timeScale==0){
            return;
        }
        
        //ゆっくりがいなければ生成
        if(!yukkuriOn){
            StartCoroutine("CreateYukkuri");
        }
        
        //マウスが押された位置を検知 y=1450くらいが上限
        //Y座標が高い位置でのタッチも有効にしてしまうと、タイム解除(ボタンタッチ)と同時にゆっくりが落下してしまう。
        Vector3 mousePosition = Input.mousePosition;
        
        //ゆっくりがいれば生成しない
        //生成したゆっくりは指の位置のX座標に追従
        //指を離すと落下
#if UNITY_ANDROID || UNITY_IOS
        // AndroidとiOSの共通コード
        if (canFall && Input.GetMouseButtonUp(0)&& mousePosition.y <touchUpperLimit){
            FallYukkuri();
        } 
#elif UNITY_EDITOR || UNITY_WEBGL
    // EditorとWebGLの共通コード
        //ゆっくりがいれば生成しない
        //生成したゆっくりはマウスのX座標に追従
        //マウスの左クリックで落下
        if (canFall && Input.GetMouseButtonDown(0)&& mousePosition.y <touchUpperLimit){
            FallYukkuri();
        }
#endif
    }

    void FallYukkuri(){
        //生成したゆっくりのコンポーネント取得
        yukkuri = createdYukkuri.GetComponent<Yukkuri>();
        yukkuri.Speak();
        yukkuri.tag = "Yukkuri";//ゆっくりのタグをBeforeFallからYukkuriに変更
        _mouseFollow2D.enabled = false;//マウスの追従をやめる
        yukkuriOn = false;//落とせるゆっくりがいるか→いない
        canFall = false;//ゆっくりを落とせるか→落とせない
        dashLine.SetActive(false);//落下ラインを非表示に
        _rigidbody2D.isKinematic = false;//RigidBody2dをキネマティックをfalseにし、物理演算をオンにする
    }

    IEnumerator CreateYukkuri(){
        yukkuriOn = true;
        yield return new WaitForSeconds(intervalTime);//生成依頼が来てから、intervalTime分待つ
        
        // マウス位置をスクリーン座標からワールド座標に変換する
        var targetPos = Camera.main.ScreenToWorldPoint ( Input.mousePosition );
        var position = new Vector3(targetPos.x, _y, _z);
        //ゆっくりを生成する
        createdYukkuri = Instantiate(YukkuriPrefabs[nextYukkuriIndex], position, Quaternion.identity,yukkuriFolder);
        //ランダムで角度をつける
        createdYukkuri.transform.Rotate(new Vector3(0,0,Random.Range(-30,30)));
        //生成したゆっくりのタグをBeforeFallにする
        createdYukkuri.tag = "BeforeFall";
        //生成したゆっくりのMouseFollow2Dコンポーネントを取得
        _mouseFollow2D = createdYukkuri.GetComponent<MouseFollow2D>();
        _mouseFollow2D.enabled = true;
        //生成したゆっくりのRigidbody2Dコンポーネントを取得
        _rigidbody2D = createdYukkuri.GetComponent<Rigidbody2D>();
        //キネマティックをオンにし、物理演算を無効にする
        _rigidbody2D.isKinematic = true;
        //落下線を表示
        dashLine.SetActive(true);
        //落下線ののMouseFollow2Dコンポーネントを取得
        _dashLineMouseFollow2D = dashLine.GetComponent<MouseFollow2D>();
        //落下線のX軸の稼働範囲を、ゆっくりと合わせる(ゆっくり毎にXの稼働範囲が違うため)
        _dashLineMouseFollow2D.xmin = _mouseFollow2D.xmin;
        _dashLineMouseFollow2D.xmax = _mouseFollow2D.xmax;
        //落とせる状態に
        canFall = true;
        //次のゆっくりを示す矢印を全て非表示にする
        foreach (var t in nextArrow){
            t.SetActive(false);
        }
        //次のゆっくりを抽選
        ChoiceNextYukkuri();
        yield return null;
    }
}

(3)一定以上の速度で、オブジェクトにぶつかると音声を鳴らす

ゆっくりが一定以上のスピードでオブジェクトにぶつかった時に「ぷよん」という効果音を鳴らします。
以下が、その処理を行っているYukkuriコンポーネントです。

using UnityEngine;

public class Yukkuri : MonoBehaviour{
    [SerializeField] private YukkuriType yukkuriType = YukkuriType.none;//ゆっくりの種類
    [SerializeField] private GameObject nextYukkuriPrefab;//ゆっくりが成長したら何になるか
    [SerializeField] private GameObject bombEffect;//成長時のエフェクト
    public int mySerialNumber;//通し番号
    private static int totalSerialNumber = 0;
    private GameObject _yukkuriFolder;
    private Transform yukkuriFolder;//成長したゆっくりが格納されるフォルダ
    [SerializeField]private float collisionspeed;//ぶつかった時のスピードがこれ以上だと効果音が出る
    [SerializeField] private AudioClip[] voices;//ゆっくりのボイス
    [SerializeField] private AudioClip[] collisionSe;//ぶつかったときの効果音
    private bool soundOn;//サウンド再生中
    [SerializeField] private AudioClip ShinkaSe;//成長するときの効果音
    [Header("エフェクトが前にでないようにZ座標を調整")][SerializeField] private float bombZ;

    private void Awake(){
        //生成直後はマウス追従はしないようにする
        if (GetComponent<MouseFollow2D>()){
            GetComponent<MouseFollow2D>().enabled = false;
        }
    }

    // Start is called before the first frame update
    void Start(){
        totalSerialNumber++;
        mySerialNumber = totalSerialNumber;
        _yukkuriFolder = GameObject.FindWithTag("Folder");//格納フォルダをタグで検索
        yukkuriFolder =  _yukkuriFolder.transform;//格納フォルダのトランスフォームを取得
    }

    public void Speak(){
        SoundManager.instance.RandomVoice(voices);
    }

    private void OnCollisionEnter2D(Collision2D other){
        soundOn = false;
        // 衝突したオブジェクトの速度を計算
        Vector2 relativeVelocity = other.relativeVelocity;
        var speed = relativeVelocity.magnitude;
        //Debug.Log(speed);//デバッグでスピードを測定
        if (speed > collisionspeed){
           soundOn = true;//スピードが規定値を越えるとサウンドを再生中ブール値をOnにする
        }
        
        if (other.gameObject.TryGetComponent(out Yukkuri otherYukkuri)){
            if (otherYukkuri.yukkuriType != yukkuriType){
                if (soundOn){
                    //ブール値がOnかつ、ぶつかった相手が同じゆっくりでなければ衝突音を出す。
                    SoundManager.instance.RandomSe(collisionSe);
                }
                return;
            }
            if (!otherYukkuri.CompareTag("Yukkuri")){
                if (soundOn){
                    //ブール値がOnかつ、ぶつかった相手がゆっくり以外なら衝突音をだす。
                    SoundManager.instance.RandomSe(collisionSe);
                }
                return;
            }
            //同じ種類のゆっくりとぶつかった時
            if (otherYukkuri.mySerialNumber < mySerialNumber){
                if (nextYukkuriPrefab != null){
                    //2つのゆっくりの中央をとる
                    Vector3 centerPosition = (transform.position + other.transform.position) / 2;
                    //2つの回転具合の平均をとる
                    Quaternion rotation = Quaternion.Lerp(transform.rotation, other.transform.rotation, 0.5f);
                    //成長時SEをだす
                    SoundManager.instance.PlaySe(ShinkaSe);
                    //ゆっくりのインスタンスを生成
                    Instantiate(nextYukkuriPrefab, centerPosition, rotation, yukkuriFolder);
                    //進化用エフェクトを生成、Z位置を調整し、UIより前に出ないようにする
                    var bombPosition = new Vector3(centerPosition.x, centerPosition.y, bombZ);
                    Instantiate(bombEffect,bombPosition,transform.rotation);
                    //生成前のゆっくりのタイプによって加算点数変更
                    if (yukkuriType == YukkuriType.れいみゅ){
                        GameManager.i.score += 1;
                    }else if (yukkuriType == YukkuriType.まりちゃ){
                        GameManager.i.score += 3;
                    }else if (yukkuriType == YukkuriType.ぱちぇ){
                        GameManager.i.score += 6;
                    }else if (yukkuriType == YukkuriType.ちぇん){
                        GameManager.i.score += 10;
                    }else if (yukkuriType == YukkuriType.わされいむ){
                        GameManager.i.score += 15;
                    }else if (yukkuriType == YukkuriType.つむり){
                        GameManager.i.score += 21;
                    }else if (yukkuriType == YukkuriType.ありす){
                        GameManager.i.score += 28;
                    }else if (yukkuriType == YukkuriType.みょん){
                        GameManager.i.score += 36;
                    }else if (yukkuriType == YukkuriType.れいむ){
                        GameManager.i.score += 45;
                    }else if (yukkuriType == YukkuriType.まりさ){
                        GameManager.i.score += 55;
                    }else if (yukkuriType == YukkuriType.すいか){
                        GameManager.i.score += 100;
                    }
                    //スコア表示を更新
                    GameManager.i.ScorePlus();
                }
            }
            //ゲームオブジェクトを破棄
            Destroy(gameObject);
            return;//衝突音が鳴らないようにここでリターン
        }
        if (soundOn){
            //ブール値がオンなら衝突音再生
            SoundManager.instance.RandomSe(collisionSe);
        }
    }
}

以下が上の記事を参考に書いた部分です。
デバッグでゆっくりの移動スピードを計測し、collisionspeedを決定、speedがcollisionspeedを超えた速度でゆっくりが他オブジェクトにぶつかった時に衝突音を出しています。

     // 衝突したオブジェクトの速度を計算
        Vector2 relativeVelocity = other.relativeVelocity;
        var speed = relativeVelocity.magnitude;
        //Debug.Log(speed);//デバッグでスピードを測定
        if (speed > collisionspeed){
           soundOn = true;//スピードが規定値を越えるとサウンドを再生中ブール値をOnにする
        }

ただし、同じゆっくりにぶつかって成長するときは、そちらの効果音を優先したいので、if文で処理をいろいろ分けています。

(4)オブジェクト生成時にパーティクルを生成する

パーティクルの生成は難しくないのですが、3D用のパーティクルを使用したため、UIの前にパーティクルが表示されないようにZ座標を調整しました。
それでも少しは出てしまっているのですが;

   //進化用エフェクトを生成、Z位置を調整し、UIより前に出ないようにする
    var bombPosition = new Vector3(centerPosition.x, centerPosition.y, bombZ);
    Instantiate(bombEffect,bombPosition,transform.rotation);

2.落下位置を表示する点線を実装

(1)点滅させる

点線のping画像を自作し、アルファ値を変更するスクリプトを書きました。

(2)ゆっくりの位置によって移動可能な範囲を変更する

落下位置を表示する点線にもFollowMouse2Dコンポーネントが付いており、ゆっくりと動きを共にします。
しかし、ゆっくり毎にX座標の移動可能範囲が違うため、点線側の移動可能範囲も都度上書きする必要があります。

//落下線を表示
  dashLine.SetActive(true);
//落下線ののMouseFollow2Dコンポーネントを取得
  _dashLineMouseFollow2D = dashLine.GetComponent<MouseFollow2D>();
//落下線のX軸の稼働範囲を、ゆっくりと合わせる(ゆっくり毎にXの稼働範囲が違うため)
  _dashLineMouseFollow2D.xmin = _mouseFollow2D.xmin;
  _dashLineMouseFollow2D.xmax = _mouseFollow2D.xmax;

YukkuriManagerのこの範囲ですね。

3.ゲームオーバーの判定を実装

ゲームオーバー判定ラインに「Yukkuri」タグがついたオブジェクトが、3秒以上触れていたらゲームオーバーという判定をします。以下がGameOverLineコンポーネントです。

using UnityEngine;
using UnityEngine.Playables;

public class GameOverLine : MonoBehaviour{
    private float touchDuration = 0f;//他のオブジェクトが触れている時間
    [SerializeField] private PlayableDirector countDownDirector;//カウントダウンタイムライン
    [SerializeField] private GameObject[] countDownNumber;//タイムラインで表示する数字
    private int touchingCounter;//ラインに触っているオブジェクトの数

    private void Start(){
        touchingCounter = 0;
    }

    //他のオブジェクトがトリガーに触った時に呼ばれる
    private void OnTriggerEnter2D(Collider2D other){
        //触ったら1増える
        touchingCounter++;
    }

    //他のオブジェクトがトリガーに触れている間、この関数が呼ばれ続ける
    private void OnTriggerStay2D(Collider2D other){
        //触り続けているオブジェクトのタグが「Yukkuri」なら
        if (other.CompareTag("Yukkuri")){
            touchDuration += Time.deltaTime;
            //一秒たったら
            if (touchDuration >= 1f){
                PlayCountDown();//タイムライン開始
            }
        }
    }
    
    private void PlayCountDown(){
        countDownDirector.Play();
    }

    // 他のオブジェクトがトリガーから離れた時に呼ばれる
    private void OnTriggerExit2D(Collider2D other){
        //出たら1減る
        touchingCounter--;
        //接触しているオブジェクトが0ならばカウントダウンをリセット
        if (touchingCounter == 0){
            countDownDirector.Stop();
            touchDuration = 0f; //触れている時間をリセット
            for (int i = 0; i < countDownNumber.Length; i++){
                countDownNumber[i].SetActive(false);
            }
        }
    }

    //ゲームオーバー判定、タイムラインのシグナルで呼び出す
    public void GameOver(){
        YukkuriManager.i.isGameOver = true;
        GameManager.i.GameOver();
    }
}

最後までわからなかったのですが、マウスに追従している状態のゆっくりがゲームオーバー判定ラインに触っているという判定をされて、ゲームオーバーになる現象が発生したので、落下する前のゆっくりはタグを「Yukkuri」ではなく、「beforeFall」に変えています。落下時に「Yukkuri」にタグ変更しています。

4.タイムラインで凝ったトランジションを実装

以下の記事ようなやりかたもありますが、自分は今回タイムラインで実装しました。

ゲームオーバー時

いい感じですね。しかも簡単。

いつもタイムラインについて、わからないことがあると読む記事を貼っておきます。

なお、TimeLine中に再生を開始したBGMをTimeLine終了後もループ再生しておく手段は、シグナル等でスクリプトから再生するか、アクティベーショントラックで、ループかつゲーム開始時に再生にチェックが入ったオーディオソースをアクティブにする方法があります。

5.GameManager(コードのみ)

using System;
using GoogleMobileAds.Api;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.UI;

public class GameManager : MonoBehaviour{
    public static GameManager i; //どこからでもアクセスできるようにする
    public int score;
    private int highScore;
    [SerializeField] private Text resultScore;
    [SerializeField] private Text reslutHighScore;
    [SerializeField] private Text scoreText;//スコアテキスト
    [SerializeField] private Text highScoreText;//ハイスコアテキスト
    [SerializeField] private PlayableDirector playableDirector;
    [SerializeField] private AudioClip[] bgm;
    [NonSerialized]public int playCount;//プレイ回数

    void Awake(){
        CheckInstance();
    }

    void CheckInstance(){
        if (i == null){
            i = this;
        } else{
            Destroy(gameObject);
        }
    }

    public void Start(){
        //iOSでインタースティシャル中に再生しないように
        //iOSでフルスクリーン広告が表示されている間、Unityアプリを一時停止
        MobileAds.SetiOSAppPauseOnBackground(true);
        playCount = ES3.Load<int>("PLAY",0);//プレイ回数をロード
        playCount++;//プレイ回数を1増やす
        ES3.Save<int>("PLAY",playCount);//プレイ回数をセーブ
        score = 0;
        highScore = ES3.Load<int>("HIGHSCORE",0);//ハイスコアをロード
        highScoreText.text = highScore.ToString("d6");;//ハイスコアテキスト更新
        SoundManager.instance.RandomBgm(bgm);//BGMをランダムで再生
    }
    
    public void GameOver(){ 
        //スクリーンショットを撮影
        ScreenshotToSpecifyRange.i.ScreenShot();
        reslutHighScore.text = highScore.ToString("d6");//ハイスコアを6ケタに整形
        if (score > highScore){
            //現在のスコアをハイスコアとして記録
            ES3.Save<int>("HIGHSCORE",score);
            highScoreText.text = score.ToString("d6");//ハイスコアテキスト更新
            reslutHighScore.text = score.ToString("d6");
        }
        resultScore.text = score.ToString("d6");
        //タイムライン再生する
        playableDirector.Play();
    }

    public void ScorePlus(){
        //スコアをスコアテキストに反映、6ケタに整形
        scoreText.text = score.ToString("d6");
    }
}

6.やろうと思ってやらなかったこと

(1)プッシュ通知

そんなに難しくはなさそうでしたが、変に詰まったりしてリリースがこれ以上遅れても嫌なので見送りました。
本当は1週間くらいでリリースするつもりだったのに2週間以上かかるとは…。

(2)ローディング画面

(1)と同じの理由です。

(3)2Dオブジェクトをぷるぷるにする

過去に何回も失敗しているのですが、やっぱり今回も駄目だったよ。今回もダメでした。
上手く挙動しません。ただ、ボーンが長すぎかつ少なすぎなのでは?と思いました。短いボーンを表面に沢山並べればイケそうな気がする―。

(4)UniTaskを活用する

結局コールチン使ってしまいました。キャンセルの処理が面倒くさいんですよね…。

(5)中断機能

このアプリには、プレイ中にアプリを落ちてしまっても、同じ場所から再開できる機能がついています。
ChatGPT(課金済み)に色々聞いて実装できそうだったのですが、時間がかかりそうだったので、今回は止めました。

実装方法は、EasySave3を使用し、親オブジェクトFolder内にある子オブジェクトのenum.typeとtransformの取得をし再開データを作成、再開データがある状態でアプリを立ち上げたら、保存したデータを呼び出して、prefabの生成するというものです。

アプリの終了や一時停止を取得することもできるので、この時に再開用データを作れば、実装できそうですね。

この記事が気に入ったらサポートをしてみませんか?