
Unity初心者がスイカ風の落ちものゲームを作ってみた
前回のUnity入門から空いてしまったが、今回はUnity開発に少し慣れてきて、ちゃんとしたゲームを作ってみたいと思って、スイカ風の落ちものゲームの開発に挑戦してみた!
どんなゲームを作る?
Unity初心者がゲームを作るとしたらどんなゲームがいいか?を考えた時に、3Dよりも2Dゲームの方が取っ付きやすく、スイカ風の落ちものゲームはYouTubeに解説動画があったり、実装記事もいくつかあったので、今回はそれらを参考にして具体的な実装イメージを膨らませた。
テーマを練るために、まずスイカゲームのパロディとしてどんなゲームがあるのかを調べてみたが、ボカロや惑星、海の生き物など、いろんなバリエーションがあってそれぞれが個性的で面白そうだった。
干支や星座、コーヒー、アニマル(海 or 陸)、恐竜などいろいろ考えたが、ある程度ドロップするものにバリエーションがあって進化できる「寿司」に決めて、人気の寿司ネタランキングから上位のネタをドロップさせる方向にした。

デザイン
テーマが「寿司」と決まったので、次はFigmaを使ってレイアウトを組んで、必要な画像アセットを書き出していく。
まずは寿司ネタの画像だが、こちらの人気ランキングをもとに、いらすとやの「お寿司」カテゴリーで該当の寿司ネタの画像を探して、円形の背景と枠線を敷いて寿司ネタの画像を作成。
タイトル画面では、タイトルとStartボタンを囲むように寿司ネタを配置。メインのゲーム画面では、寿司屋の暖簾をイメージしたものを背景にして、左側に寿司メニューをイメージしたスコアボードを、右側に寿司皿をイメージしたNextを配置。かわいさを加えるためにかっぱに寿司をドロップさせ、進化を示す表示も寿司レーンをイメージしたものにしてみた。ゲームオーバーの表示は、メインのゲーム画面にオーバーレイで表示するようにし、タイトル画面に戻る、リトライできるようにした。

実装
デザインも決まったので、いよいよゲームを実装していく!
各画面とシーン遷移
今回はタイトル画面とメインのゲーム画面の構成で、ゲームオーバーはメイン画面にオーバーレイで表示する。タイトル画面とメイン画面間のシーン遷移は下記のようなスクリプトを作成して、Buttonにコンポーネント追加して遷移先のSceneNameを指定するだけで、ボタン押下時にシーン遷移できるようにした。

public class ChangeScene : MonoBehaviour
{
[SerializeField] private string sceneName;
public void OnClick()
{
SceneManager.LoadScene(sceneName);
}
}
ゲームオーバーのオーバーレイは、メイン画面の他のUIやGameObjectsとは別のCanvasに追加して、Plane DistanceをよりMainCameraに近くなるように調整した。

移動とドロップ
今回はかっぱを左右に移動させて寿司ネタをドロップさせるので、かっぱに「入力に応じて移動する」と「入力時に寿司ネタを生成させる」処理が必要となる。それぞれの機能を持つMove, Dropスクリプトを作成してかっぱに付与した。
MoveではUpdate()でマウス位置を取得してワールド座標系に変換した後に、y座標を固定しつつx座標が閾値(ボックスの左右)を超えないように制限した上で、transform.positionを更新している。
public class Move : MonoBehaviour
{
[SerializeField] Camera camera;
private Vector2 mousePos;
private Vector3 objPos;
private readonly float minMaxX = 1.8f;
// Update is called once per frame
void Update()
{
mousePos = Input.mousePosition;
objPos = camera.ScreenToWorldPoint(new Vector3(mousePos.x, mousePos.y, 10));
objPos.y = transform.position.y;
if (objPos.x < -minMaxX)
{
objPos.x = -minMaxX;
} else if (objPos.x > minMaxX)
{
objPos.x = minMaxX;
}
transform.position = objPos;
}
}

一方、Dropではドロップすべき寿司ネタのGameObjectの配列を持ち、マウスのクリック時に同じposition, rotationでInstantiateすれば、Rigidbody 2Dを付与された寿司ネタが自動で落下していく。また、次にどの寿司ネタをドロップすべきかのロジックをDropに持たせることもできるが、Next表示でも同様のロジックが必要になるので、寿司ネタの画像配列を持つNextスクリプトを追加して、Change()で次の寿司ネタ画像をランダムで更新する。そして、DropでNextのコンポーネントを取得して、表示中の次の寿司ネタのindexをもとにInstantiateし、Change()で次の寿司ネタを更新すればよい。
public class Drop : MonoBehaviour
{
[SerializeField] private GameObject[] items;
private bool isMouseButtonDown;
private GameObject current;
private void Update()
{
if (CanDropNext() && IsClickedDownUp()) {
var next = GameObject.Find("NextSushi").GetComponent<Next>();
current = Instantiate(items[next.index], transform.position, transform.rotation);
next.Change();
isMouseButtonDown = false;
}
}
private bool CanDropNext()
{
if (current)
{
// ドロップした寿司ネタが衝突するまで、次の寿司ネタをドロップさせない
var collide = current.GetComponent<Collide>();
return collide && collide.isCollided;
}
return true;
}
private bool IsClickedDownUp()
{
if (Input.GetMouseButtonDown(0))
{
isMouseButtonDown = true;
return false;
}
if (isMouseButtonDown && Input.GetMouseButtonUp(0))
{
// MouseButtonDown中にButtonUpするとクリックしたと判断する
isMouseButtonDown = false;
return true;
}
return false;
}
}
public class Next : MonoBehaviour
{
[SerializeField] Sprite[] images;
public int index;
void Start()
{
Change();
}
public void Change() {
index = Random.Range(0, images.Length);
GetComponent<Image>().sprite = images[index];
}
}

衝突時に進化させる
ゲームの肝でもあるが、ドロップした寿司ネタが同じ寿司ネタと衝突した時に、次の寿司ネタに進化させる必要がある。衝突時の処理をCollideスクリプトで記述し、それぞれの寿司ネタのPrefabに追加しておく。
衝突検知自体はOnCollisionEnter2Dでできるが、同じ寿司ネタかどうかの判定では、自身と衝突した相手のgameObject.nameが一致するかどうかをチェックし、一致した場合に衝突した相手のGameObjectを破棄し、予め設定した進化先のGameObjectを生成する。
注意点として、衝突して進化先を生成する前に、衝突相手が持つ進化先をnullにしないと、同時に同じ進化先を生成してしまい、さらにその場でどんどん衝突して生成してしまい、寿司ネタが増殖してしまう。
public class Collide : MonoBehaviour
{
[SerializeField] GameObject evolution;
private void OnCollisionEnter2D(Collision2D collision) {
if (gameObject.name == collision.gameObject.name)
{
Destroy(collision.gameObject);
collision.gameObject.GetComponent<Collide>().evolution = null;
if (evolution)
{
Instantiate(evolution, transform.position, transform.rotation);
}
}
}
}

スコア計算
同じ寿司ネタが衝突して進化する時、スコアを加算していく必要があり、今回はスコアボードに表示されるスコアを更新するScoreManagerを用意して、必要なコンポーネントに紐づけて呼び出すようにした。
TextMeshProUGUIのスコアとベストスコアを取得して、int型に変換してからスコアを加算したり、PlayerPrefsに保存されたベストスコアよりも大きい場合に更新を行う。ちなみに、それぞれの寿司ネタで何点を加算するかはこちらの記事を参考にした。
public class ScoreManager : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI scoreText;
[SerializeField] private TextMeshProUGUI bestScoreText;
private void Start()
{
loadBestScore();
}
public void loadBestScore()
{
int bestScore = PlayerPrefs.GetInt("BestScore");
bestScoreText.text = bestScore.ToString();
}
public void UpdateBestScore()
{
int score = int.Parse(scoreText.text);
if (score > PlayerPrefs.GetInt("BestScore"))
{
PlayerPrefs.SetInt("BestScore", score);
bestScoreText.text = score.ToString();
}
}
public void AddScore(int score)
{
int currentScore = int.Parse(scoreText.text);
scoreText.text = (currentScore + score).ToString();
}
public void ClearScore()
{
scoreText.text = "0";
}
}
ゲームオーバー
先述の通りゲームオーバーはメイン画面にオーバーレイで表示していて、ゲームオーバー時に対象CanvasのPanelをactiveに変更すればよい。
重要なのがどのような条件を満たせばゲームオーバーとするかだが、これが地味に悩ましかった。単純に考えれば「寿司ネタがボックスから溢れたら」だが、「溢れる = 最後にドロップした寿司ネタの中心点があるボーダーを超えたら」にしようとすると、ドロップした瞬間に既にボーダーを超えているのでタイマーが必要になってしまう。
こちらの記事にある方法も考えたが、やはりタイマーが必要なのと積み上がり方によってはちょうどボーダーにかかった状態でゲームオーバーのケースもありうるので、最終的には別の記事を参考に「寿司ネタが一定時間ボーダーに触れていたらゲームオーバーとする」方向にした。
まず「一定時間触れたらゲームオーバー」となるGameObjectをボックスのやや上に配置し、Box Collider 2Dを追加してIs Triggerにチェックを入れる。そして、下記のようにスクリプトでstayTimeを持たせ、OnTriggerStay2DでstayTimeを加算し一定時間を超えたらゲームオーバーを表示させる。普通に落下していくものに関しては、OnTriggerExit2DでstayTimeをクリアしているので問題ない。また、ゲームオーバーに表示されているBack, Retryボタンを押した時は、タイトル画面にシーン遷移したり、ゲームオーバーを非表示にしている。
public class Border : MonoBehaviour
{
private float stayTime;
private readonly float stayTimeThreshould = 2.0f;
private GameManager gameManager;
private ScoreManager scoreManager;
private void Start()
{
gameManager = GameObject.Find("GameManager").GetComponent<GameManager>();
scoreManager = GameObject.Find("ScoreManager").GetComponent<ScoreManager>();
}
private void OnTriggerStay2D(Collider2D collision)
{
if (collision.CompareTag("sushi"))
{
stayTime += Time.deltaTime;
if (stayTime > stayTimeThreshould && !gameManager.IsShowingGameOver())
{
gameManager.ShowGameOver();
scoreManager.UpdateBestScore();
}
}
}
private void OnTriggerExit2D(Collider2D collision)
{
if (collision.CompareTag("sushi"))
{
stayTime = 0;
}
}
}
public class GameManager : MonoBehaviour
{
[SerializeField] private GameObject gameOver;
public void ShowGameOver()
{
gameOver.SetActive(true);
}
public void HideGameOver()
{
gameOver.SetActive(false);
}
public bool IsShowingGameOver()
{
return gameOver.activeSelf;
}
}

BGMやSEを鳴らす
ここまででゲームの画面遷移、寿司ネタのドロップと進化、ゲームオーバーを実装してきたが、最後の仕上げとしてゲーム全体のBGMや衝突時、ゲームオーバー時のSEを鳴らしてみる。
まずはBGMやSEの音声ファイルだが、今回はこちらのアセットにあるものを利用させていただいた。
BGM-Romance: ゲーム全体のBGM
FREE Casual Game SFX Pack: 寿司ネタが衝突した時やゲームオーバー時のSE
Untiyでは音声データを紐づけたAudioClipをAudioSourceで再生することで音が鳴るので、SoundManagerにAudio Sourceコンポーネントを追加して、AudioClipにダウンロードしたBGMの音声データを紐づける。プレイ開始時に鳴ってほしい、ループで鳴ってほしいので、Play on Awake, Loopにチェックを入れておく。また、シーン遷移してもBGMが鳴ってほしいので、SoundManagerをシングルトンにして、DontDestroyOnLoad(gameObject)でシーン遷移時に破棄されるのを回避できる。
一方、衝突時やゲームオーバー時のSEは、必要な時にAudioClipを切り替えて鳴らしたいので、BGMとは別のAudio Sourceコンポーネントを追加して、SoundManagerのSerializeFieldでAudioClipを紐づけて、適切なタイミングでaudioSource.PlayOneShot(evolutionSound)のように再生する。
public class SoundManager : MonoBehaviour
{
[SerializeField] private AudioSource bgmAudioSource;
[SerializeField] private AudioSource audioSource;
[SerializeField] private AudioClip evolutionSound;
[SerializeField] private AudioClip gameOverSound;
public static SoundManager Instance;
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
public void PlayBgm()
{
bgmAudioSource.Play();
}
public void StopBgm()
{
bgmAudioSource.Stop();
}
public void PlayEvolutionSound()
{
audioSource.PlayOneShot(evolutionSound);
}
public void PlayGameOverSound()
{
audioSource.PlayOneShot(gameOverSound);
}
}
Unityroomで公開
今回はUnityでゲームを作ったのもあって、unityroomというフリーゲーム投稿サイトに投稿してみた。
前提としてWebGLでビルドしている必要があるため、PlatformをWebGLに切り替えてビルドしてアップロードしようとしたら、.gzファイルがない!?となってしまった。調べてみるとPlayer Settings > Publishing Settings > Compression FormatをGzipに設定する必要があるとのことで、再ビルドして無事にアップロードできた!
タイトルや紹介文などゲーム情報を設定して公開!作ったゲームをブラウザで遊べるようになって思わず感動した!
振り返り
今回スイカ風の落ちものゲームを作ってみて、実装面でも色々つまずきながら進めてきたが、意外とテーマ決めや寿司ネタなどの画像アセットの作成やサイズ調整が大変だった。
テーマを寿司ネタに決めるまで色々調べて、どんな寿司ネタをドロップしたら面白いのか、何からドロップしたらいいかで悩んでいた。デザインでも寿司屋の雰囲気のある背景画像やスコア表示、Next表示で試行錯誤して、素人ながらでもFigmaを使ってレイアウトを組んで、画像アセットを書き出した。
実装では解説動画を参考にしてベースを作り、マウス移動の範囲制限やクリックでドロップ、シーン遷移やオーバーレイ表示、各Managerによる状態管理やロジックの分離などを調べながら仕上げていった。
さらなるステップアップとして、スマホの縦画面対応やunityroomのランキング対応、Zenjectによる依存性注入、そしてiOS開発をやっているのもあって、iPhoneやiPad、Apple Vision Pro対応もやってみたい!