「あのゲーム」の「オマージュ」を作った話
こんにちは、muchanです。
総コンのAdvent Calendar 2023 の18日目の記事を担当します。
前回は自分の就活のことについて書きましたが、今回はより総合コンテンツ制作サークルらしく、制作物について書いていこうと思います。
ゲームとゲーム実況
突然ですが、皆さんは「今年ゲーム実況界隈で流行ったゲーム」と言われて何を思い浮かべますか?
ティアキン、ホグワーツレガシー、ff16、ピクミン4、アーマードコア6、人生ゲーム、桃鉄ワールド、、、
などなど、今年発売したゲームの中で覚えている範囲かつ自分の感覚で「実況の力で売り上げを伸ばしていそう」なゲームを挙げてみました。皆さんの思い浮かべたゲーム、もしくは実況を見て買ったゲームはありましたでしょうか。今の時代、「ゲーム実況を見てほしくなる」という事象は少なくないと思います。
そんな中、今年発売ではないゲームが、配信を皮切りに大流行しましたよね。そうです「スイカゲーム」です。スイカゲームとは、2021年末に発売されたパズルゲームで、その気軽さと中毒性から2023年に大流行したゲームです。
今回はそんなスイカゲームのシステムを拝借(オマージュ)したゲームを制作したので、それについてお話していきます。
流石にスイカを作るゲームだとアレかなと思ったので、ハープ(楽器)を作るゲームにしました。
一応宣言しておくんですが、販売等の予定はなく、ご本家様をリスペクトしております。
楽器ゲーム
完成図
まず最初にどんなもんを作ったのか見せちゃおうと思います。いや、魅せちゃおうと思います。興味を持ってくれたならこの後も是非読んでください💛
こんな感じです。ご本家様と違う点は、デバッグや卒論の検証のために、BestScoreの記憶を消すボタンがあるくらいですかね。あとシンカの輪が無いのも違う点でした。
そのため、今回の記事用に急造しました。
本家のシンカの輪の朧げな記憶を頼りにパワーポイントでパパっと作ってみましたが、全然違う。本家のUI上手く出来過ぎてませんか。私の作ったものと並べてみたんですが、涙が出ました。皆さんも是非見比べてみてください。
制作環境
今回の楽器ゲームの制作にあたって、使用したツール等の紹介です。
デザインツールは楽器を描くのに使っています。
ゲームエンジン:Unity 2021.3.30f1(訳あってUnity3Dで制作)
デザインツール:ibisPaint
テキストエディタ:Visual Studio 2019
ゲーム概要
今更スイカゲームのゲームルールを説明するのもおこがましいのですが、作ったゲームがどういうゲームかというと
楽器を上から箱の中に落とす
同じ楽器が触れたらシンカの輪に従って、一つ上の楽器に進化する
楽器が進化した際、種類ごとに設定された得点を獲得する
一番上の進化であるハープ同士が触れると、得点が加算されて消える
設定したゲームオーバーラインを超えるとゲームオーバー
ゲームオーバー時に表示されるRetryボタンでまたプレイできる
ザックリ言うとこんな感じです。これだけ超単純で中毒性あったらプレイしちゃいますよねぇ。
実装
思い返してみると、こういう制作物に対しての記事を書くのは初めてなので、書き方が分かってないのですが、「読んだら同じものを作ることが出来る技術的記事」というよりも「読んだらなんかわからんけど凄そう」くらいを目指して書いていこうと思います。
コードの説明についてですが、説明箇所のコードをコピペし、それに説明を付ける形で行うつもりです。一部説明自体には不要な行等も貼るかもしれませんがご了承ください。意味不明な行に関しては、記事の最後にコードを貼ろうと思うので、そこで答え合わせしていただけると幸いです。
基本ルール
まずゲームの根幹である、「楽器を落としたら枠の中に残り、同じ楽器と触れたら進化する」というところを作りました。
とにもかくにもステージがないとどうしようもないですよね。というわけでこんな感じの蓋の開いた箱型のステージを作成します。
余談ですが、Unity3Dを使用して作成したので、ついつい3D用のコライダーを付けてしまい、楽器が奈落に落ちるということが多発しました。もし同じ環境でゲームを作成する人は2D用のコライダーを使いましょう。
次に楽器合体のルールです。
スクリプトはプレイヤーの物(例:Player.cs)と楽器の物(例:Gakki.cs)を使用します。
まず、自分が何の楽器なのか自覚を持ってもらわないとどうしようもないのでGakki.cs側で
// 自分が何の楽器かを認識してもらう
public enum TYPE
{
kasutanetto,
ha_monika,
toraianguru,
tanbarin,
sinbaru,
taiko,
horun,
ha_pu,
}
public TYPE type = TYPE.kasutanetto;
//自分の進化先の楽器を持っておく
[SerializeField] GameObject nextGakkis;
//落とした楽器に通し番号を振る(この数値の大小を比較して処理を行う)
public int gakkisNum;
こんな感じで楽器タイプや通し番号を設定します。
次に楽器が触れたときの処理を
// 楽器同士がぶつかったときの処理
// コライダーがついてるものにぶつかったときに自動で呼ばれる関数
private void OnCollisionEnter2D(Collision2D collision)
{
//楽器に当たったとき、自分と同じかどうかを確認する
if (collision.gameObject.tag == "Gakki")
{
//自分と同じ楽器なら
TYPE colType = collision.gameObject.GetComponent<Gakkis>().type;
int colNum = collision.gameObject.GetComponent<Gakkis>().gakkisNum;
if (type == colType)
{
//ぶつかった二つの楽器がハープの場合
if(type == TYPE.ha_pu)
{
//合体前の楽器を消す
Destroy(collision.gameObject);
Destroy(gameObject);
playerscript.score += 3000;
}
else
{
//ハープ以外の果物がぶつかった場合
//通し番号が大きい楽器に処理をしてもらう
if (gakkisNum > colNum)
{
//楽器同士を合体させて、1段階上の物を生成する
GameObject gakkis = Instantiate(nextGakkis, transform.position, transform.rotation);
switch (type)
{
//ハーモニカの大きさ
case TYPE.kasutanetto:
gakkis.transform.localScale = new Vector3(1.75f, 1.75f, 1.75f);
break;
//トライアングルの大きさ
case TYPE.ha_monika:
gakkis.transform.localScale = new Vector3(2.6f, 2.6f, 2.6f);
break;
//タンバリンの大きさ
case TYPE.toraianguru:
gakkis.transform.localScale = new Vector3(2.3f, 2.3f, 2.3f);
break;
//シンバルの大きさ
case TYPE.tanbarin:
gakkis.transform.localScale = new Vector3(4.0f, 4.0f, 4.0f);
break;
//太鼓の大きさ
case TYPE.sinbaru:
gakkis.transform.localScale = new Vector3(4.5f, 4.5f, 4.5f);
break;
//ホルンの大きさ
case TYPE.taiko:
gakkis.transform.localScale = new Vector3(5.5f, 5.5f, 5.5f);
break;
//ハープの大きさ
case TYPE.horun:
gakkis.transform.localScale = new Vector3(5.25f, 5.25f, 5.25f);
break;
}
gakkis.GetComponent<Rigidbody2D>().gravityScale = 15;
gakkis.GetComponent<Gakkis>().gakkisNum = gakkisNum;
// スコアの追加
playerscript.score += CalculateScore(type);
//合体前の楽器を消す
Destroy(collision.gameObject);
Destroy(gameObject);
}
}
}
}
このように設定します。
各楽器の大きさの設定のコードは、もっときれいに書くこともできると思いますが、ゲームをプレイしながら大きさ調整をするタイミングも多いと思い、それぞれの数値を変えるだけで反映されるこの形を選びました。
また通し番号を付けないと、同じ楽器同士が触れた際の挙動としてどっちが進化すればいいのか分からず、進化後の楽器が二つ出来るのでこの方式を採用しました。
楽器側の準備は整ったので、次にPlayer.csです。
//移動に必要な物
float x;
float speed = 60;
[SerializeField] GameObject[] gakki;
//[SerializeField] Transform Player;
[SerializeField] Transform spawnPoint;
GameObject gakkiObj;
bool isFall;
bool isCooldown; // 新しい楽器を生成するまでのクールダウン用フラグ
bool FirstPlay;
int num = 0;
//次の楽器
[SerializeField] Image nextGakkis;
int nextGakkisNum;
// ここで画像の配列を定義する
[SerializeField] Sprite[] gakkiSprites;
左右に移動できるように設定し、また右上に表示させていた次の楽器のために、画像を入れるところを用意します。
そしてようやく、楽器を落とす関数の登場です。
IEnumerator GakkiDown()
{
//関数にクールダウンを与える
isCooldown = true;
//楽器に重力を与える
gakkiObj.GetComponent<Rigidbody2D>().gravityScale = 15;
//プレイヤーの子要素から外す
gakkiObj.transform.SetParent(null);
//少し時間を空ける
yield return new WaitForSeconds(1.0f);
//次の楽器をセット
gakkiObj = Instantiate(gakki[nextGakkisNum], spawnPoint.position, Quaternion.identity, this.transform);
gakkiObj.GetComponent<Gakkis>().gakkisNum = num;
num++;
//次の楽器をランダムに選択
int r = Random.Range(0, gakki.Length);
nextGakkisNum = r;
nextGakkis.sprite = gakkiSprites[nextGakkisNum];
isCooldown = false; //クールダウンを解除する
yield return null;
isFall = false;
}
クールダウンを実装しないとスペースキー(楽器を落とすボタン)を連打できてしまったため実装しました。また、楽器をプレイヤーの子要素とすることで、プレイヤーが左右移動するのに付随する形で楽器が移動していましたが、これを解除しないとスペースキーを押した後も一緒に動いてしまうので注意です。
これで「楽器を落としたら枠の中に残り、同じ楽器と触れたら進化する」というのは達成できたと思います。
ゲームオーバー
次に、「設定したゲームオーバーラインを超えるとゲームオーバー」というのを作っていきます。
まずはPlaneオブジェクトを用意し、コライダーを付け、透明なマテリアルを付けます。コライダーは「IsTrigger」にチェックを入れることを忘れずに。またマテリアルの「Rendering Mode」をFadeにすると透明になります。
次にスクリプトで、これに触れたらゲームオーバー時に出てくるUIが表示されるようにします。
using UnityEngine.SceneManagement;
using UnityEngine;
public class GameOverLine : MonoBehaviour
{
private float stayTime;
[SerializeField] GameObject GameOverUI;
public bool isPlay;
private void Start()
{
GameObject obj = GameObject.Find("Player");
isPlay = true;
}
//オブジェクトと接触し続けるとき
private void OnTriggerStay2D(Collider2D collision)
{
//もしGakkiタグのオブジェクトと触れたら
if (collision.CompareTag("Gakki"))
{
//接触時間をカウント
stayTime += Time.deltaTime;
//もし一定時間触れあっていたら
if (stayTime > 4.0f)
{
//ゲームオーバーUIを表示する
GameOverUI.SetActive(true);
playerscript.GameOverUI = GameObject.Find("GameOverUI");
}
}
}
//オブジェクトと接触が終わったとき
private void OnTriggerExit2D(Collider2D collision)
{
//離れたオブジェクトがGakkiタグなら
if (collision.CompareTag("Gakki"))
{
//カウントを0に戻す
stayTime = 0;
}
}
ほぼ全部貼りましたが、楽器のいずれかがゲームオーバーラインに一定時間触れたらゲームオーバーって感じです。
スコア加算・ベストスコア
最後にスコアについてです。
始めは「Gakki.cs側で設定したスコアを、Gakki.cs側で合計して算出」しようとしてたんですが、どうにもうまくいきませんでした。
これは、楽器が合体するとDestroyされる設定にしていたせいで、合体時に合計スコアを放棄していたためでした。そのため、Player.cs側でスコアを合計し、それを表示する形に変更し実装しました。
...
//合体時の処理
// スコアの追加
playerscript.score += CalculateScore(type);
// スコアの計算メソッドを追加
int CalculateScore(TYPE gakkiType)
{
switch (gakkiType)
{
case TYPE.kasutanetto: return 5;
case TYPE.ha_monika: return 10;
case TYPE.toraianguru: return 20;
case TYPE.tanbarin: return 50;
case TYPE.sinbaru: return 150;
case TYPE.taiko: return 500;
case TYPE.horun: return 1200;
default: return 0;
}
}
上記はGakki.cs側の処理です。こちらには、それぞれの楽器が持つ得点を覚えさえ、その得点を合体時にPlayer.csに送っているだけです。
//Update内
//表示するスコアを毎フレーム更新する
scoreText = GameObject.Find("ScoreNumber").GetComponent<Text>();
scoreText.text = score.ToString();
//ベストスコアの設定
if (BestScore <= score)
{
BestScore = score;
//表示するベストスコアを毎フレーム更新する
BestscoreText = GameObject.Find("BestScoreNumber").GetComponent<Text>();
BestscoreText.text = BestScore.ToString();
//"SCORE"をキーとして、ハイスコアを保存・ディスクへの書き込み
PlayerPrefs.SetInt("SCORE", BestScore);
PlayerPrefs.Save();
}
上記はPlayer.cs内の処理です。Gakki.csから受け取ったスコアを加算し、ベストスコアを超えたら、その数値をベストスコアとするという単純明快なものとなっています。ちなみに最後の方のディスクへの書き込みをすることで、ゲーム終了後もベストスコアを覚えておいてくれます。
最後に
最後になりますが、コードをペタッと貼って実装関係は終わろうと思います。使用したコードは「Player.cs」「Gakki.cs」「GameOverLine.cs」の3つです
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
// 左右に移動する、楽器を落とす
public class Player : MonoBehaviour
{
//移動に必要な物
float x;
float speed = 60;
[SerializeField] GameObject[] gakki;
//[SerializeField] Transform Player;
[SerializeField] Transform spawnPoint;
[SerializeField] GameObject Record;
GameObject gakkiObj;
bool isFall;
bool isCooldown; // 新しい楽器を生成するまでのクールダウン用フラグ
bool FirstPlay;
int num = 0;
//次の楽器
[SerializeField] Image nextGakkis;
int nextGakkisNum;
// ここで画像の配列を定義する
[SerializeField] Sprite[] gakkiSprites;
//score・GameOverScoreの初期化
public int score = 0;
public Text scoreText, GameOverScoreText;
//BestScoreの初期化
public int BestScore = 0;
public Text BestscoreText;
public GameObject GameOverUI;
private void Start()
{
// 楽器をSpawnPointの位置に生成する
gakkiObj = Instantiate(gakki[0], spawnPoint.position, Quaternion.identity, this.transform);
gakkiObj.GetComponent<Gakkis>().gakkisNum = num;
num++;
//次の楽器の画像を表示
int r = Random.Range(0, gakki.Length);
nextGakkisNum = r;
nextGakkis.sprite = gakkiSprites[nextGakkisNum];
//初期スコアの表示
scoreText = GameObject.Find("ScoreNumber").GetComponent<Text>();
scoreText.text = score.ToString();
//ベストスコアをBestScoreに保存し、ゲーム終了後も維持する
BestScore = PlayerPrefs.GetInt("SCORE", 0);
//初期ベストスコアの表示
BestscoreText = GameObject.Find("BestScoreNumber").GetComponent<Text>();
BestscoreText.text = BestScore.ToString();
//GameOverLineオブジェクトのGameOverLineスクリプトを取得
GameObject gameover = GameObject.Find("GameOverLine");
gameoverscript = gameover.GetComponent<GameOverLine>();
//初回プレイかどうかの判定
if(BestScore == 0)
{
FirstPlay = true;
}
else
{
FirstPlay = false;
}
}
void Update()
{
x = Input.GetAxisRaw("Horizontal");
//スペースキーを押したときに楽器を落とす
if (Input.GetKeyDown(KeyCode.Space) && GameOverUI == null)
{
if (!isFall && !isCooldown)
{
isFall = true;
StartCoroutine(GakkiDown());
}
}
// 右に移動
if (x > 0 && GameOverUI == null)
{
transform.position += new Vector3(speed, 0, 0) * Time.deltaTime;
}
// 左に移動
else if (x < 0 && GameOverUI == null)
{
transform.position += new Vector3(-speed, 0, 0) * Time.deltaTime;
}
// プレイヤーのposxが450<=x<=550の範囲でのみ動くように制限
float clampedX = Mathf.Clamp(transform.position.x, 450f, 550f);
transform.position = new Vector3(clampedX, transform.position.y, transform.position.z);
//表示するスコアを毎フレーム更新する
scoreText = GameObject.Find("ScoreNumber").GetComponent<Text>();
scoreText.text = score.ToString();
//ベストスコアの設定
if (BestScore <= score)
{
BestScore = score;
//表示するベストスコアを毎フレーム更新する
BestscoreText = GameObject.Find("BestScoreNumber").GetComponent<Text>();
BestscoreText.text = BestScore.ToString();
//"SCORE"をキーとして、ハイスコアを保存・ディスクへの書き込み
PlayerPrefs.SetInt("SCORE", BestScore);
PlayerPrefs.Save();
}
//もしGameOverUIが存在し、アクティブならスコアを表示する
if (GameOverUI != null && GameOverUI.activeSelf)
{
//GameOverUIを取得
GameOverScoreText = GameObject.Find("GameOverScoreNumber").GetComponent<Text>();
GameOverScoreText.text = score.ToString();
}
}
IEnumerator GakkiDown()
{
//関数にクールダウンを与える
isCooldown = true;
//楽器に重力を与える
gakkiObj.GetComponent<Rigidbody2D>().gravityScale = 15;
//プレイヤーの子要素から外す
gakkiObj.transform.SetParent(null);
//少し時間を空ける
yield return new WaitForSeconds(1.0f);
//次の楽器をセット
gakkiObj = Instantiate(gakki[nextGakkisNum], spawnPoint.position, Quaternion.identity, this.transform);
gakkiObj.GetComponent<Gakkis>().gakkisNum = num;
num++;
//次の楽器をランダムに選択
int r = Random.Range(0, gakki.Length);
nextGakkisNum = r;
nextGakkis.sprite = gakkiSprites[nextGakkisNum];
isCooldown = false; //クールダウンを解除する
yield return null;
isFall = false;
}
public void BestScoreDelete()
{
//ハイスコアを削除し、その後シーンを初期化する
PlayerPrefs.DeleteAll();
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class Gakkis : MonoBehaviour
{
// 自分が何の楽器かを認識してもらう
public enum TYPE
{
kasutanetto,
ha_monika,
toraianguru,
tanbarin,
sinbaru,
taiko,
horun,
ha_pu,
}
public TYPE type = TYPE.kasutanetto;
//スコア表示のため、GakkiPlayerを入れるあだ名をつける
GakkiPlayer playerscript;
//自分の進化先の楽器を持っておく
[SerializeField] GameObject nextGakkis;
//落とした楽器に通し番号を振る(この数値の大小を比較して処理を行う)
public int gakkisNum;
private void Start()
{
//Playerオブジェクトの「GakkiPlyer」スクリプトを取得
GameObject obj = GameObject.Find("Player");
playerscript = obj.GetComponent<GakkiPlayer>();
}
// 楽器同士がぶつかったときの処理
// コライダーがついてるものにぶつかったときに自動で呼ばれる関数
private void OnCollisionEnter2D(Collision2D collision)
{
//楽器に当たったとき、自分と同じかどうかを確認する
if (collision.gameObject.tag == "Gakki")
{
//自分と同じ楽器なら
TYPE colType = collision.gameObject.GetComponent<Gakkis>().type;
int colNum = collision.gameObject.GetComponent<Gakkis>().gakkisNum;
if (type == colType)
{
//ぶつかった二つの楽器がハープの場合
if(type == TYPE.ha_pu)
{
//合体前の楽器を消す
Destroy(collision.gameObject);
Destroy(gameObject);
playerscript.score += 3000;
}
else
{
//ハープ以外の果物がぶつかった場合
//通し番号が大きい楽器に処理をしてもらう
if (gakkisNum > colNum)
{
//楽器同士を合体させて、1段階上の物を生成する
GameObject gakkis = Instantiate(nextGakkis, transform.position, transform.rotation);
switch (type)
{
//ハーモニカの大きさ
case TYPE.kasutanetto:
gakkis.transform.localScale = new Vector3(1.75f, 1.75f, 1.75f);
break;
//トライアングルの大きさ
case TYPE.ha_monika:
gakkis.transform.localScale = new Vector3(2.6f, 2.6f, 2.6f);
break;
//タンバリンの大きさ
case TYPE.toraianguru:
gakkis.transform.localScale = new Vector3(2.3f, 2.3f, 2.3f);
break;
//シンバルの大きさ
case TYPE.tanbarin:
gakkis.transform.localScale = new Vector3(4.0f, 4.0f, 4.0f);
break;
//太鼓の大きさ
case TYPE.sinbaru:
gakkis.transform.localScale = new Vector3(4.5f, 4.5f, 4.5f);
break;
//ホルンの大きさ
case TYPE.taiko:
gakkis.transform.localScale = new Vector3(5.5f, 5.5f, 5.5f);
break;
//ハープの大きさ
case TYPE.horun:
gakkis.transform.localScale = new Vector3(5.25f, 5.25f, 5.25f);
break;
}
gakkis.GetComponent<Rigidbody2D>().gravityScale = 15;
gakkis.GetComponent<Gakkis>().gakkisNum = gakkisNum;
// スコアの追加
playerscript.score += CalculateScore(type);
//合体前の楽器を消す
Destroy(collision.gameObject);
Destroy(gameObject);
}
}
}
}
}
// スコアの計算メソッドを追加
int CalculateScore(TYPE gakkiType)
{
switch (gakkiType)
{
case TYPE.kasutanetto: return 5;
case TYPE.ha_monika: return 10;
case TYPE.toraianguru: return 20;
case TYPE.tanbarin: return 50;
case TYPE.sinbaru: return 150;
case TYPE.taiko: return 500;
case TYPE.horun: return 1200;
default: return 0;
}
}
}
using UnityEngine.SceneManagement;
using UnityEngine;
public class GameOverLine : MonoBehaviour
{
private float stayTime;
[SerializeField] GameObject GameOverUI;
public bool isPlay;
//スコア表示のため、GakkiPlayerを入れるあだ名をつける
GakkiPlayer playerscript;
private void Start()
{
GameObject obj = GameObject.Find("Player");
playerscript = obj.GetComponent<GakkiPlayer>();
isPlay = true;
}
//オブジェクトと接触し続けるとき
private void OnTriggerStay2D(Collider2D collision)
{
//もしGakkiタグのオブジェクトと触れたら
if (collision.CompareTag("Gakki"))
{
//接触時間をカウント
stayTime += Time.deltaTime;
//もし一定時間触れあっていたら
if (stayTime > 4.0f)
{
//ゲームオーバーUIを表示する
GameOverUI.SetActive(true);
playerscript.GameOverUI = GameObject.Find("GameOverUI");
}
}
}
//オブジェクトと接触が終わったとき
private void OnTriggerExit2D(Collider2D collision)
{
//離れたオブジェクトがGakkiタグなら
if (collision.CompareTag("Gakki"))
{
//カウントを0に戻す
stayTime = 0;
}
}
//Retryボタンを押したらシーンを初期化する
public void Retry()
{
isPlay = false;
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
}
まとめ
ここまで自分の制作物の制作手順を振り返りながら記事を書いてきました。
コードが文字数を稼いでいるとはいえ、気づけは20000字を超える冗長な記事になりました。
当初はこんなつもりではなく、簡潔かつ超面白い記事にする予定だったのですが、不甲斐ないばかりです。
ただ、今回オマージュを制作してみて、改めてスイカゲームのすごさを実感しました。なぜなら楽器ゲームでも十分おもろいからです。
また記事を書く前は書くことが思いつかなかったので、おまけで「僕の理想の「僕の部屋」」というコラムでも書くかなと思っていたんですが、流石に重すぎるのでまたの機会にします。書いた際は総コンのrandomにでも投げるので、ぜひ読んでね。
この記事が気に入ったらサポートをしてみませんか?