少し前にスネークゲームの実行ファイルをQRコードで記した(つまり3KBに収めた)人が現れ,少し話題になったかと思います.
そのスネークゲームをUnity作ったので,自分が作成した方法について記します.
作成したプロジェクトは整い次第GitHubの方で公開します.
スネークゲームとは
そもそもスネークゲームはどんなゲームなのか
最初に簡単に説明します
ルール
- 移動 : 上下左右の2次的
- エサ : 食べるとスコアが上がり,体が1つ長くなる
- 頭が自分の体,若しくは壁に当たると終了
今回はこれに加えて以下のルールも追加します.
- 制限時間(カウントダウン)
一応カウントが0になったらゲームクリア,体・壁に当たったらゲームオーバーとしますが変わるのはUI(もしかしたらアニメーションを追加するかも?)だけであまり違いはないです.
なんで作ろうと思ったか
そもそも何で作ろうと思ったかを書きます.
興味のない方は次の章へ飛んでください
まず,このゲームは大学の学園祭で展示する用に作っています.
内容はWii Partyのようにミニゲームがいくつか包含されているもので,スネークゲームはその内の1つという訳です.
ただ,最初に書いた通り話題で上がって自分でも作ってみたいと思った節もあります^^
プログラムなど
プログラムの構成
今回は以下の3つのスクリプトで作成しています.
- 移動用
- タイマー
- スネークゲーム本体
今回は色々なミニゲームの一つとしてスネークゲームを作っているので,移動用のスクリプトとタイマースクリプトは他で使い回すために分けて記述します.
using UnityEngine;
public class Move : MonoBehaviour
{
public Vector3 direction;
private float x;
private float z;
void Start()
{
}
void Update()
{
x = Input.GetAxis("Horizontal");
z = (x != 0.0f ) ? 0 : Input.GetAxis("Vertical");
direction = new Vector3(x, 0.0f, z);
direction = direction.normalized;
}
}
using UnityEngine;
using TMPro;
public class TimeScript : MonoBehaviour
{
// カウントダウンの秒数
[SerializeField]
private float time = 60.0f;
// カウント表示用のUI
private TextMeshProUGUI timeText;
// カウントが終わっているかどうかの判定
public bool isFinish;
void Start()
{
timeText = gameObject.GetComponent<TextMeshProUGUI>();
timeText.text = time.ToString("F1");
isFinish = false;
}
void Update()
{
if (time > 0.0f && !isFinish)
{
time -= Time.deltaTime;
timeText.text = time.ToString("F1");
}
else if(!isFinish)
{
timeText.text = "0.0";
isFinish = true;
}
}
}
Move.csではx方向若しくはz方向へのみ動けるようにしていて,normlizedで1.0fか-1.0fに正規化しています.
Timer.csではカウントが0未満になったら0.0になるようにしています.
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using TMPro;
public class Snake : MonoBehaviour
{
PlayerMoveScript move;
TimeScript timeScript;
[SerializeField]
GameObject manager;
[SerializeField]
GameObject timer;
[SerializeField, Header("餌")]
GameObject feed;
[SerializeField]
GameObject body;
[Header("ゲーム終了後のUI")]
[SerializeField]
GameObject gameClearUI;
[SerializeField]
GameObject gameOverUI;
[SerializeField]
TextMeshProUGUI scoreText;
private List<Transform> bodyparts;
// 動く方向
private Vector3 moveDir;
private Vector3 beforePos;
// エサが生成される最大値と最小値を格納
private Vector2 xRange;
private Vector2 zRange;
private int score;
float lastTime;
float waitTime;
float gridSize;
// フィールド上の座標の最大値と最小値
private float xMax;
private float xMin;
private float zMax;
private float zMin;
// ゲーム中かの判定
private bool isMove;
// 失敗したかどうかの判定
private bool isfaild;
void Start()
{
move = manager.GetComponent<PlayerMoveScript>();
timeScript = timer.GetComponent<TimeScript>();
scoreText.text = "Score : 0";
bodyparts = new List<Transform>();
beforePos = new Vector3(0.0f, 0.0f, 0.0f);
xRange = new Vector2(-8.0f, 8.0f);
zRange = new Vector2(-4.5f, 4.5f);
score = 0;
lastTime = 0.0f;
waitTime = 0.25f;
gridSize = 1.8f;
xMax = gridSize * 5.0f;
xMin = -1.0f * xMax;
zMax = gridSize * 3.0f;
zMin = -1.0f * zMax;
moveDir = new Vector3(gridSize, 0.0f, 0.0f);
isMove = true;
isfaild = false;
// エサを生成
StartCoroutine(feedApp());
}
void Update()
{
Vector3 nextPos;
if (!timeScript.isFinish)
{
Vector3 nowPos = transform.position;
// 入力があった時 次の移動場所に体があるかの判定を行う
// 1個前の場所(bodyparts[0])の場所には移動できないようにする
if (move.direction.magnitude != 0)
{
Vector3 befoDir = moveDir;
moveDir = move.direction * gridSize;
if (bodyparts.Count != 0)
{
nextPos = nowPos + moveDir;
if (nextPos.x > xMax) nextPos.x = xMin;
if (nextPos.x < xMin) nextPos.x = xMax;
if (nextPos.z > zMax) nextPos.z = zMin;
if (nextPos.z < zMin) nextPos.z = zMax;
if (nextPos == bodyparts[0].position)
{
moveDir = befoDir;
}
}
}
if (lastTime > waitTime)
{
beforePos = transform.position;
nowPos += moveDir;
if (nowPos.x > xMax) nowPos.x = xMin;
if (nowPos.x < xMin) nowPos.x = xMax;
if (nowPos.z > zMax) nowPos.z = zMin;
if (nowPos.z < zMin) nowPos.z = zMax;
judgeCollision(nowPos);
if (isMove)
{
transform.position = nowPos;
Movebody();
}
lastTime = 0.0f;
}
lastTime += Time.deltaTime;
}
else if (!isfaild && timeScript.isFinish)
{
StartCoroutine(GameClear());
}
}
// 移動先に体があるかどうかの判定
private void judgeCollision(Vector3 dir)
{
Transform headTf = transform;
foreach (Transform body in bodyparts)
{
if (body.position == dir)
{
StartCoroutine(GameOver());
}
}
}
// 餌を生成するメソッド
private IEnumerator feedApp()
{
Vector3 spawnPos;
bool isOccupied; // 出現マスに頭,体があるかどうかの判定
while (isMove)
{
do
{
isOccupied = false;
float randomX = Random.Range(xRange.x / gridSize, xRange.y / gridSize);
float randomZ = Random.Range(zRange.x / gridSize, zRange.y / gridSize);
float gridX = Mathf.Round(randomX) * gridSize;
float gridZ = Mathf.Round(randomZ) * gridSize;
spawnPos = new Vector3(gridX, 0.6f, gridZ);
if (Mathf.Approximately(transform.position.x, spawnPos.x) &&
Mathf.Approximately(transform.position.z, spawnPos.z))
{
isOccupied = true;
}
else
{
foreach (Transform part in bodyparts)
{
if (Mathf.Approximately(part.position.x, spawnPos.x) &&
Mathf.Approximately(part.position.z, spawnPos.z))
{
isOccupied = true;
break;
}
}
}
if (!isOccupied)
{
GameObject[] feeds = GameObject.FindGameObjectsWithTag("Feed");
foreach (GameObject feedObj in feeds)
{
if (Mathf.Approximately(feedObj.transform.position.x, spawnPos.x) &&
Mathf.Approximately(feedObj.transform.position.z, spawnPos.z))
{
isOccupied = true;
break;
}
}
}
} while (isOccupied);
Instantiate(feed, spawnPos, Quaternion.identity);
yield return new WaitForSeconds(2.5f);
}
}
private void Movebody()
{
Vector3 targetPos = beforePos;
foreach (Transform tf in bodyparts)
{
Vector3 tmp = tf.position;
tf.position = targetPos;
targetPos = tmp;
}
}
private void Grow()
{
Vector3 spawnPos;
if (bodyparts.Count > 0)
{
spawnPos = bodyparts[bodyparts.Count - 1].position;
}
else
{
spawnPos = beforePos;
}
GameObject newBodyPart = Instantiate(body, spawnPos, Quaternion.identity);
bodyparts.Add(newBodyPart.transform);
}
private IEnumerator GameClear()
{
isMove = false;
yield return new WaitForSeconds(1.5f);
gameClearUI.SetActive(true);
}
private IEnumerator GameOver()
{
isMove = false;
isfaild = true;
timeScript.isFinish = true;
yield return new WaitForSeconds(1.0f);
gameOverUI.SetActive(true);
Debug.Log("Stop");
}
private void OnTriggerEnter(Collider other)
{
if (other.gameObject.tag == "Feed")
{
Collider col = GetComponent<Collider>();
col.enabled = false;
score += 10;
scoreText.text = "Score : " + score.ToString();
Destroy(other.gameObject);
Grow();
col.enabled = true;
}
}
}
Move.csの説明
Move.csの一部を独断と偏見で選んで説明します.
説明にない場所はぜひコメントで質問してください.
今回は移動の処理は全てUpdate内に書いて,当たり判定などをメソッドにまとめています.
Transform型のリストbodypartsに生成された体の場所を格納することで頭を追従して動くようにしています.
if (move.direction.magnitude != 0)
{
Vector3 befoDir = moveDir;
moveDir = move.direction * gridSize;
if (bodyparts.Count != 0)
{
nextPos = nowPos + moveDir;
if (nextPos.x > xMax) nextPos.x = xMin;
if (nextPos.x < xMin) nextPos.x = xMax;
if (nextPos.z > zMax) nextPos.z = zMin;
if (nextPos.z < zMin) nextPos.z = zMax;
if (nextPos == bodyparts[0].position)
{
moveDir = befoDir;
}
}
}
Move.csで入力を受け付けたかどうかを毎回判定します.moveDirが移動量でdirection(x若しくはzが1.0f)にgridSizeをかけて1マスずつ移動するようにしています.
この時,bodypartsが1つ以上あればbodyparts0がある方向へは動かないようにしている = 1回前の移動で居た場所に戻らないようにしている
と言った感じです.
if (lastTime > waitTime)
{
beforePos = transform.position;
nowPos += moveDir;
if (nowPos.x > xMax) nowPos.x = xMin;
if (nowPos.x < xMin) nowPos.x = xMax;
if (nowPos.z > zMax) nowPos.z = zMin;
if (nowPos.z < zMin) nowPos.z = zMax;
judgeCollision(nowPos);
if (isMove)
{
transform.position = nowPos;
Movebody();
}
lastTime = 0.0f;
}
// 移動先に体があるかどうかの判定
private void judgeCollision(Vector3 dir)
{
Transform headTf = transform;
foreach (Transform body in bodyparts)
{
if (body.position == dir)
{
StartCoroutine(GameOver());
}
}
}
waitTime秒毎に移動するようにしています.
キー入力は方向を決めるだけで移動は自動で行われます.
lastTimeに毎フレームTine.deltatimeを足していってwaitTime以上経ったらその時点で入力されている方向moveDirへ移動をします.
lastTimeを0.0fにリセットするのをお忘れなく!
しかし,移動する前に移動先に体や壁(まだ壁の実装はできていない)があるかの判定をjudgeCollisionメソッドで行います.
UnityにはOnCollisionEnter系の自動で衝突判定を行ってくれるメソッドがありますが,これを使用するとUpdateとの実行順序の関係で 移動 -> 衝突判定 となり頭が体にめり込んでしまいます.
追々アニメーションを追加したいので,めり込んでは困るということで衝突判定はこのタイミングで行い 衝突判定 -> 移動 の順番で実行さえるようにしました.
おわりに
制作難度も高くなく,サクッと気軽に作れるゲームでした.
それゆえに拡張性が高く個性の出し方もいろいろありそうです.
ほかにもミニゲームを作った際はこのように残していこうと思います.
それではまた,他の記事で