ゲーム開発で色々なミニゲームを作り、
Unityに慣れてきたら、
パズルゲーム制作にチャレンジしてみましょう!
パズルゲームは、ストーリーもないので、
ルールさえわかれば、サクッと遊べる有用なジャンルです。
(旅先でオセロやトランプをする感覚)
トランプなどのカードゲームを使って、
自分オリジナルのルールで遊んた経験のある方にとっては
Unityでパズルゲームを自作できるようになると、
遊びの創造が無限に広がるでしょう~^^
今回は、その入門として最適なパズルゲームを例に作り方を紹介します。
C#スクリプトだけ、プログラミング初心者にはとっつきにくいかもですが、頑張りましょう!
↓このような三目並べゲーム(◯×風ゲーム)を作ります
基本は知ってる!オンライン対戦ボードゲームを作りたいならコレ↓
1.オブジェクトの準備
大前提として、
Unityの2Dゲームプロジェクトを選択して、起動します。
シーンにて
2DオブジェクトのSpriteのSqureを作成します。
Positionはx=-1,y=1、Scaleはxもyも0.8にします。
そのSqureに「Box Collider 2D」コンポーネントをアタッチします。
これがボードゲームの1マスに相当します。
これをCtrl+Dで9個に複製し、並べます。
図のように、-1、0、1というPositionの間隔で並べます。
x,yの座標でいえば、
(x,y)=(-1,1), (-1,0), (-1,-1), (0,1), (0,0), (0,-1), (1,1), (1,0), (1,-1)
の9個です。
scaleを0.8にしているので、ちょうど間にラインが入って、
わざわざラインを作る必要もありません^^
次は、ゲームとして最低限必要なUIを配置していきます。
ゲーム制作でよく使われるTextMeshProを作成します。
名前はお好みでつけ、ポジションはだいたい中央上くらいにします。
次に、Imageを作成します。
RectTransformの左の方の四角いアイコンをクリックして、
青矢印が広がったアイコン(全画面にストレッチ)を選択します。
その上で、Left、Top、Right、Bottomを0にすると、画面全体にImageが広がります。
Imageのcolorは、透明度のある黒に設定します。
次に、Buttonを作成します。
ドラッグ&ドロップで、先ほどのImageの配下に移動させましょう。
Buttonの配下のTextの内容は、「Retry」としておきます。
(つまり、これはゲームをリトライするためのButtonです^^)
UIのオブジェクト作成は以上です。
次にボードゲームで扱うコマを作成します。
SpriteのCircleとDiamondの2種類を作成します。
CircleのScaleはxとyを0.8に、
DiamondのScaleはxを0.8、yを1.6にすると、イイ感じに1マスに収まるようになります。
またOrder In Layerを1にして、
必ずマスよりも前面に来るようにしておきます。
その2つをAssetsフォルダに(整理したい人はPrefabsフォルダを作って)、
ドラッグ&ドロップしてプレファブ化します。
※プレファブ化すると、いくつもシーンに生成することができるオブジェクトになります。
一度プレファブ化したら、シーン上にはその2つは要らないので、削除します。
ゲームに必要なオブジェクトはこれで全てです(少ない!)
これらの少ない素材をスクリプトでゲームにしていきます。
2.スクリプトでゲームを動かす
Assetsフォルダで、C#Scriptを作成し、GameManagerと名付けます。
それをダブルクリックして、VS codeなどで、以下のように編集していきます。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using TMPro;
public class GameManager : MonoBehaviour
{
public GameObject prefabCircle;
public GameObject prefabDiamond;
public GameObject resultObj;
public TextMeshProUGUI infoTMP;
int nowPlayer;
int[] board =
{
-1,-1,-1,
-1,-1,-1,
-1,-1,-1,
}; //9個のint型の1次元配列
bool win;
bool draw;
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
if(win||draw)return;
infoTMP.text = (nowPlayer +1) + "P's Turn";
bool next = false; //手番終了や勝敗判定開始のためのフラグ
if(Input.GetMouseButtonUp(0)) //クリック離したとき
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);//Ray(光線)にメインカメラ上でタップした位置情報と方向を代入
RaycastHit2D hit = Physics2D.Raycast(ray.origin, ray.direction); //hitにRayがコライダーに当たった情報を代入
if(null !=hit.collider)
{
Vector3 pos = hit.collider.gameObject.transform.position; //当たったオブジェクトのポジ(座標)を取得
int x = (int)pos.x + 1; //取得した座標から配列の要素番号に戻す1次処理、(-1,-1)なら(0,0)へ
int y = (int)pos.y + 1;
int idx = x + y * 3; //配列の要素番号にする2次処理
if( -1 == board[idx]) // その要素(マス)にまだプレファブが置かれていない(=-1)場合
{
GameObject prefab = prefabCircle;
if(1 == nowPlayer) prefab = prefabDiamond; //プレイヤー番号が1ならDiamondのプレファブを置く
Instantiate(prefab, pos, Quaternion.identity);
board[idx] = nowPlayer; //要素の値に今のプレイヤー番号を入れて-1(空)じゃないようにする
next = true;//勝敗チェック可能
}
}
}
if(next)//勝敗チェック開始
{
win = false;
List<int[]> lines = new List<int[]>(); //配列のリストに勝ちパターンを格納
lines.Add(new int[] {0, 1, 2});
lines.Add(new int[] {3, 4, 5});
lines.Add(new int[] {6, 7, 8});
lines.Add(new int[] {0, 3, 6});
lines.Add(new int[] {1, 4, 7});
lines.Add(new int[] {2, 5, 8});
lines.Add(new int[] {0, 4, 8});
lines.Add(new int[] {2, 4, 6});
foreach (int[] v in lines)//長さが決まっている配列の全要素を処理するならforeach
{
bool issame = true;//勝ちパターンと同じかどうかのフラグ、はじめはtrueに
for(int i = 0; i < v.Length; i++) //v(int配列)の長さは3
{
int idx0 = v[0];//int配列の一番初めの要素の値をidx0に代入
int idx1 = v[i];//他の要素の値をidx1に代入して検証していく
if(0 > board[idx1]|| board[idx0] != board[idx1])
{
issame = false; //1要素でも勝ちパターンでなければfalseへ
}
}
if(issame) //勝ちパターンであるなら
{
win = true;
}
}
if(win)
{
resultObj.SetActive(true);//結果UI表示
infoTMP.text = (nowPlayer +1) + "P Win!";//テキスト表示変更
}
else //勝ちでなかった場合
{
//引き分け
int cnt = 0; //空いてる場所を数える用
for (int i = 0; i < board.Length; i++)
{
if(-1 == board[i]) //デフォルトでboardの要素はすべて-1なので
{
cnt++; //数えていく
}
}
if(0== cnt) //空いてる場所がない場合
{
resultObj.SetActive(true);
infoTMP.text = "Draw!";
draw = true;
}
//手番を回す
nowPlayer++;
if(2 <= nowPlayer)
{
nowPlayer = 0; //Player番号は0か1の繰り返し
}
}
}
}
public void OnClickRetry()
{
SceneManager.LoadScene("SampleScene");
}
}
スクリプトの内容は基本的なことですが、
C#というプログラミング言語の初心者には、複雑に感じるかもしれません。
それぞれの意味は、「//~」でコメント書きしているので、参考ください。
まずボードゲームを作る際は、必ずと言ってよいほど、1次元や2次元の配列を宣言して使います。
上のスクリプトでは、3行3列でたったの9マスなので、1次元配列を使った管理方法です。
今回は、シーンにすでにつくられたボードに駒を置いていくだけなので、
Start関数で、特別に何か処理することはありません。
Update関数のみでゲームは進行します。
ここに焦点を当てて、少し解説します。
画面クリックでオブジェクトを扱うなら"Rayを飛ばす"
下の文は、テンプレといってよいほど、ボードゲーム作成では頻出です。
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
カメラからみてクリックした方向に光線を飛ばす処理です。
光線が当たった判定hitを情報に、
そこにあるゲームオブジェクトのポジションをVector3 posで取得します。
ボードゲームでなければ、そこにそのままプレファブ生成することもありますが、
ボードゲームでは1マスに収まるようにプレファブを置かねばなりません。
(同じマスに置けないなどがあるため、ボードゲームでは配列を扱う宿命にあるといえます)
オブジェクトポジションから配列の要素番号を算出する
はじめにオブジェクトを-1,0,1を使ったキリの良いx,y座標で並べています。
なので、光線衝突で取得したゲームオブジェクトのポジションは、
-1,0,1の数値で構成される座標となります。
例えば、
当たったオブジェクトのポジション(x,y)座標が(-1,-1)は
画面でいうと下段左のSqure、
その隣の下段中央のSqureの座標は(0,-1)、
その右のSqureの座標は(1,-1)です。
9つのSqureを、下段から左、中央、右にかけて数えていくと、
その番号と配列の要素番号を対応させられれば、
ボードゲームのマス目を、1次元配列によって管理できるという仕組みです。
(こんがらがってきたら下の図を見ましょう^^;)
取得した座標(x,y)、それぞれにいったん+1して
「x+y*3」という式に入れれば、
配列の要素番号に対応する数値(整数)となります。
スクリプトでは、そのように計算し、idxという変数に代入しています。
これで配列の要素番号からマス目の座標が紐づくので、
マス目の状況を配列で管理できるようになります。
配列のint型の値によってプレファブを置けるか判定
スクリプトでは、配列の全要素の「値」を、デフォルトで-1に宣言しています。
Playerがプレファブを置くことで、この値を変更させていきます。
値はそのPlayer番号の、0または1とします。
もし、そのマス目(その配列要素)の値が-1なら、まだプレファブが置かれていない、つまり、置けるということです。
配列とは、0から始まる配列の要素番号とそれに値(intやstringなど)を格納することができます。
今回はint型の配列、要素数は9、値は全て-1なので、正確に宣言すると
int[] board = new int[9]{-1, -1, -1, -1, -1, -1, -1, -1, -1}
となります。
勝敗チェック
このゲームは自分のコマが3つ並べば、勝利です。
3つ並ぶパターンは数えられるくらいなので(縦3横3斜2の8つ)、
それらパターンをListに格納しておき、
毎ターン、ボードの状況がそれと同じかどうかで判定します。
ここで、二重ループを使います。
(ここから複雑になってきます!)
3つ並ぶパターン(int型配列)を要素に持つList(List<int[]> lines)をforeachで回し、
さらに、そのパターン(int[] v)の1要素ずつををforでチェックします。
※foreachはListの要素全てを処理、forは指定した回数を処理する
特に、forでは1要素目と2要素目、3要素目を比較して、boardの配列において、それらが示すマス目が同じ値(コマをおいて変化する数(0または1))かどうかチェックします。
「同じでない、もしくは、まだコマが置かれていなくて値が-1」なら、
issameという差し当たりのboolはfalseとなり、
フラグwinがtrueになることはありません。
引き分けの判定
毎ターン、フラグwinがtrueにならなければ、ゲームは引き分けか続行かを判定していきます。
このゲームでは、すべてのマスにコマを置いても、勝敗が付かなければ、引き分けです。
なので、board配列(の値に)に空きマス(-1)があるかないかで決めます。
空きマスチェックはforでboard配列の全要素を(board.Lengthで)調べます。
その結果、空きマスの数cntが0なら、結果UIと、TextMeshProの内容を”引き分け(DRAW)”を表示します。
なお、これらの処理をするUpdate関数の最後に手番を回します。
”勝ち”または”引き分け”にならなければ、手番が回る仕組みです。
3.スクリプトとオブジェクトの紐づけ
GameManager.csができたら、空のゲームオブジェクトにアタッチして、プレファブやUIのオブジェクトを紐づけます。
また、RetryボタンのOnClickにGameManagerのOnClickretry関数を紐づけます。
これでゲームが完成です!
パズルゲームはスクリプトの設計作業がほとんどで、
製作者のC#の理解と使いこなすスキルが求められます。
特に、配列やループ処理についての理解が必要です。
自分でアレンジしてみて、
オリジナルゲームを作ってみると理解が深まるでしょう~。
そして、実はここからが本題です。
実際に色々なパズルゲームを作っていくなら、
2次元配列を使って作成する方法が王道です。
(マス目ももっと多いはずなので)
なので、同ゲームを
2次元配列を使って作成するスクリプトバージョンを
次の項で紹介します。
配列の理解さえあれば、むしろこっちのほうが簡単です。
4.2次元配列 ver.
先ほど作ったオブジェクトはそのままで、GameManager.csの内容を変更します。
2パターンを比べたい場合、違う名前のスクリプト(GM.csなど)で作成しましょう。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using TMPro;
public class GM : MonoBehaviour
{
public GameObject prefabCircle;
public GameObject prefabDiamond;
public GameObject resultObj;
public TextMeshProUGUI infoTMP;
int nowPlayer;
int[,] board = new int[3, 3];//3*3のint型2次元配列を定義
bool win;
bool draw;
// Start is called before the first frame update
void Start()
{
//2次元配列boardの全ての値を初期化(-1に)
for (int i = 0; i < 3;i++)
{
for (int j = 0; j < 3;j++)
{
board[i, j] = -1;
}
}
}
// Update is called once per frame
void Update()
{
if(win||draw)return;
infoTMP.text = (nowPlayer +1) + "P's Turn";
bool next = false;
if(Input.GetMouseButtonUp(0))
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit2D hit = Physics2D.Raycast(ray.origin, ray.direction);
if(null !=hit.collider)
{
Vector3 pos = hit.collider.gameObject.transform.position;
int x = (int)pos.x + 1;
int y = (int)pos.y + 1;
if( -1 == board[y,x])
{
GameObject prefab = prefabCircle;
if(1 == nowPlayer) prefab = prefabDiamond;
Instantiate(prefab, pos, Quaternion.identity);//ここまでは1次元配列ver.と同じ記載
board[y,x] = nowPlayer; //2次元配列なので[x,y]にプレイヤー番号を入れる
next = true;
}
}
}
if(next)//勝敗チェック開始
{
win = false;
int prefabNum;//プレファブが並ぶ数をカウントする変数
//横並びの調査
for (int i = 0; i < 3; i++)
{
prefabNum = 0;//初期化
for (int j = 0; j < 3; j++)
{
if (board[i, j] == -1 || board[i, j] != nowPlayer)//空きマスまた相手のコマがある場合
{
prefabNum = 0;//並びをリセット
}
else
{
prefabNum++;//自分のコマならカウント
}
if (prefabNum == 3)//3つ揃ったら勝ち
{
resultObj.SetActive(true);
infoTMP.text = (nowPlayer +1) + "P Win!";
win = true;
}
}
}
//縦並びの調査
for (int i = 0; i < 3; i++)
{
prefabNum = 0;
for (int j = 0; j < 3; j++)
{
if (board[j, i] == -1 || board[j, i] != nowPlayer)
{
prefabNum = 0;
}
else
{
prefabNum++;
}
if (prefabNum == 3)
{
resultObj.SetActive(true);
infoTMP.text = (nowPlayer +1) + "P Win!";
win = true;
}
}
}
//斜め並び(右上がり)の調査
for (int i = 0; i < 3; i++)
{
prefabNum = 0;
int up = 0;//上ずれ用
for (int j = i; j < 3; j++)
{
if (board[j, up] == -1 || board[j, up] != nowPlayer)
{
prefabNum = 0;
}
else
{
prefabNum++;
}
if (prefabNum == 3)
{
resultObj.SetActive(true);
infoTMP.text = (nowPlayer +1) + "P Win!";
win = true;
}
up++;
}
}
//斜め並び(右下がり)の調査
for (int i = 0; i < 3; i++)
{
int down = 2;//下ずれ用
prefabNum = 0;
for (int j = i; j < 3; j++)
{
if (board[j, down] == -1 || board[j, down] != nowPlayer)
{
prefabNum = 0;
}
else
{
prefabNum++;
}
if (prefabNum == 3)
{
resultObj.SetActive(true);
infoTMP.text = (nowPlayer +1) + "P Win!";
win = true;
}
down--;
}
}
if(!win)
{
//引き分け
int cnt = 0; //空いてる場所を数える用
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
if (board[i, j] == -1) //空きマスがあったら
{
cnt++;//カウントする
}
}
}
if(0== cnt) //空きマスがない場合
{
resultObj.SetActive(true);
infoTMP.text = "Draw!";//引き分け処理
draw = true;
}
nowPlayer++;
if(2 <= nowPlayer)
{
nowPlayer = 0;
}
}
}
}
public void OnClickRetry()
{
SceneManager.LoadScene("SampleScene");
}
public void OnClickDebug()//デバッグ用(各配列の値を出力する)
{
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
Debug.Log("(i,j) = (" + i + "," + j + ") = " + board[i, j]);
}
}
}
}
このようなスクリプトになります。
2次元配列の場合、すべての値を-1に初期化したい場合は、
Start関数で、for文などのループで処理する方が楽です。
また、勝敗判定は、
勝ちパターンと比較するのでなく、連続するコマを検出する方法をとっています。
こちらのほうが勝ちパターンがたくさんあるようなゲームには適しているので、ご参考ください。
勝敗判定の仕方やマス目にコマを置けるかどうかの処理が、パズルゲームの種類によって様々です。
勉強したい方は、そこに注目して勉強するのが良いと思います。
また、おまけとして、デバッグ用の関数(OnClickDebug())も追加しておきました。
何かおかしい時は、ボタンなどで呼び出して確認してみると良いでしょう。
作ったボードゲームをオンライン対戦できるようにしたいなら↓