Unityでクイックス風ゲームを作る方法 – 初心者向けガイドUnityで懐かしのクイックスを作ってみよう!

2024年10月3日

こんにちは!ジェイです。Unityでクイックス風のゲームを作るためのステップバイステップのガイドです。

このガイドでは、プレイヤーの移動、グリッドの操作、領域の塗りつぶしなど、基本的なゲームメカニクスを実装します。

unityroomにアップしてるのでぜひ遊んでください!

初心者でも理解しやすいように、必要なスクリプトの作成とUnityエディタでの設定を詳細に説明します。簡単なサンプルコードとその解説を通じて、基本的なゲームロジックを学び、楽しくゲーム開発に取り組みましょう。

これから解説するUnityのサンプルプロジェクトはBoothで販売してます。

Unityでクイックス風ゲームを作る方法

1. プロジェクトの設定

  • Unity Hubを開き、新しいプロジェクトを作成します。テンプレートとして2Dを選択し、プロジェクト名と保存場所を指定します。

2. スクリプトの作成

まず、必要なスクリプトを作成します。以下のスクリプトをプロジェクトに追加してください。

各グリッドセルの情報を保存しておくスクリプトです。Unityではy軸が増えると上にいくために、普通の感覚で2次元配列を定義して、そこから参照するとずれてしまう恐れがあるので、オブジェクト1つ1つに情報をもたせられるようにします。

GridInfo.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GridInfo : MonoBehaviour
{
    public char gridValue;
    public void SetValue(char value)
    {
        gridValue = value;
    }
    public char GetValue() { return gridValue; }
}

そして、このGridInfoの情報を用いてゲーム全体を管理するのがGridController.csです。重要な処理は、InitializeGridでグリッドを生成しているところとPlacePlayerでプレイヤーを生成しているところです。

グリッドを生成しているところで、それぞれのグリッドの種類を定義しています。

  • 「=」は外壁でこれより外に行くことはできない
  • 「#」は緑でスペースを押さずにプレイヤーが動くことができる
  • 「+」はスペースを押して空白をプレイヤーが進むとこの「+」になる
  • 「*」はプレイヤーが陣地を囲って占領したグリッド
  • 「’ '」は空白でまだ何も設定されていないグリッド

グリッド1つずつに以上の種類の記号を付与することで、ゲームでの処理を行っていまする

GridController.cs

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;

public class GridController : MonoBehaviour
{
    public int maxX = 48;
    public int maxY = 27;
    public GameObject cellPrefab;
    public GameObject playerPrefab;
    private GameObject[,] gridObjects;
    public Vector2 targetResolution = new Vector2(960, 540); // 目標解像度
    public float cellWidth;
    public float cellHeight;

    private void Awake()
    {
        // セルの幅と高さを計算
        cellWidth = targetResolution.x / maxX;
        cellHeight = targetResolution.y / maxY;
    }
    void Start()
    {
        Initialize();
    }
    public void Initialize()
    {
        gridObjects = new GameObject[maxX, maxY];
        InitializeGrid();
        PlacePlayer();
    }
    void InitializeGrid()
    {
        for (int y = 0; y < maxY; y++)
        {
            for (int x = 0; x < maxX; x++)
            {
                GameObject obj = Instantiate(cellPrefab, new Vector3(x*cellWidth, y*cellHeight, 0), Quaternion.identity);
                obj.transform.localScale = new Vector3(cellWidth, cellHeight, 1); // スケールを調整
                gridObjects[x, y] = obj;
                GridInfo gi = gridObjects[x, y].AddComponent<GridInfo>();

                if (x < 2 || x > maxX - 4 || y < 3 || y > maxY - 3)
                {// 1番外側の外壁を設定する(赤)
                    gi.SetValue('=');
                }
                else if (x == 2 || x == maxX - 4 || y == 3 || y == maxY - 3)
                {// プレイヤーが普通に通過できる道を設定する(緑)
                    gi.SetValue('#');
                }
                else
                {// 真ん中の何もないところ
                    gi.SetValue(' ');
                }
                UpdateCellColor(x, y);
            }
        }
        PrintCell();
    }

    void PrintCell()
    {
        for(int y = 0; y < maxY; y++)
        {
            string buf = string.Empty;
            for(int x = 0; x < maxX; x++)
            {
                buf += GetCellValue(x, y);
            }
            Debug.Log(buf);
        }
    }
    void PlacePlayer()
    {
        // グリッドの左下から緑色のセルを探してプレイヤーを配置
        for (int y = 0; y < maxY; y++)
        {
            for (int x = 0; x < maxX; x++)
            {
                if (GetCellValue(x, y) == '#')
                {
                    GameObject player = Instantiate(playerPrefab);
                    PlayerController pc = player.GetComponent<PlayerController>();
                    pc.CX = x;
                    pc.CY = y;
                    pc.cellHeight = cellHeight;
                    pc.cellWidth = cellWidth;
                    player.transform.localScale = new Vector3(cellWidth*5, cellHeight*5, 1); // スケールを調整
                    return; // プレイヤーを1つ配置したら終了
                }
            }
        }
    }

    public void UpdateCell(int x, int y, char value)
    {
        SetCellValue(x, y, value);
        UpdateCellColor(x, y);
    }

    void UpdateCellColor(int x, int y)
    {
        SpriteRenderer renderer = gridObjects[x, y].GetComponent<SpriteRenderer>();
        char value = GetCellValue(x, y);
        switch (value)
        {
            case '#':
                renderer.color = Color.green;
                break;
            case '+':
                renderer.color = Color.blue;
                break;
            case '*':
                renderer.color = Color.yellow;
                renderer.sortingLayerName = "Background";
                break;
            case ' ':
                renderer.color = Color.white;
                break;
            case '=':
                renderer.color = Color.red;
                renderer.sortingLayerName = "Background";
                break;
        }
    }
}

プレイヤーの移動処理で詳しくコメントを書いたのでそこの動作はわかると思います。気をつける点は、実際の座標で当たり判定などはしておらず、グリッド1つ分の情報を見て、どんな処理をするか判断してるところです。グリッド1マス分がCX,CYです。そして、その計算が終わった後にtransform.posisionをUpdatePlayerPositionで更新してるところは注意しましょう。

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    public GridController gridController; // GridControllerへの参照を保持

    public float moveInterval = 0.2f; // 移動間隔(秒単位)
    private float moveTimer = 0f; // タイマー

    public int CX;
    public int CY;
    public float cellHeight;
    public float cellWidth;
    private bool isMoving = false;
    public AudioSource audioSource;
    public AudioClip audioClip;
    public float repeatRate = 0.5f; // 何秒ごとに音を鳴らすか
    private float nextPlayTime = 0f;
    void Start()
    {
        gridController = FindObjectOfType<GridController>();
        UpdatePlayerPosition();
    }

    void Update()
    {
        moveTimer += Time.deltaTime;
        if (moveTimer >= moveInterval)
        {
            HandleInput();
            moveTimer = 0f;
        }
    }
    public void Death()
    {
        gridController.Death(gameObject);
    }
    void HandleInput()
    {
        int vx = 0, vy = 0;
        if (Input.GetKey(KeyCode.LeftArrow)) vx = -1;
        else if (Input.GetKey(KeyCode.RightArrow)) vx = 1;
        else if (Input.GetKey(KeyCode.UpArrow)) vy = 1;  // UnityのY座標は上方向が正
        else if (Input.GetKey(KeyCode.DownArrow)) vy = -1;
        if (vx == 0 && vy == 0) return;
        // 移動先がグリッドの範囲内であることを確認
        if (CX + vx * 2 >= 0 && CX + vx * 2 < gridController.maxX && CY + vy * 2 >= 0 && CY + vy * 2 < gridController.maxY)
        {
            char c1 = gridController.GetCellValue(CX + vx, CY + vy);
            char c2 = gridController.GetCellValue(CX + vx * 2, CY + vy * 2);
            if (Input.GetKey(KeyCode.Space))
            {
                // 1つ先のセルが空で、
                // 2つ先のセルも空のときには、線を引く
                if (c1 == ' ' && c2 == ' ')
                {
                    gridController.UpdateCell(CX + vx, CY + vy, '+');
                    gridController.UpdateCell(CX + vx * 2, CY + vy * 2, '+');
                    // 音を一定の時間の感覚で鳴らす
                    if (Time.time >= nextPlayTime)
                    {                      
                        audioSource.PlayOneShot(audioClip);
                        nextPlayTime = Time.time + repeatRate;
                    }
                }
                // 1つ先のセルが空で、
                // 2つ先のセルが古い線のときには、領域を閉じる
                else if (c1 == ' ' && c2 == '#')
                {
                    // 1つ先のセルを線にする
                    gridController.UpdateCell(CX + vx, CY + vy, '+');
                    Enemy enemy = FindObjectOfType<Enemy>();
                    
                    // 敵がいる領域を[%]で塗りつぶす
                    Paint(enemy.CX, enemy.CY);
                    // 領域の塗りつぶしを反転させることによって、
                    // 敵がいない領域を塗りつぶす
                    // また、新しい線を古い線にする
                    for (int y = 3; y < gridController.maxY - 3; y++)
                    {
                        for (int x = 3; x < gridController.maxX - 3; x++)
                        {
                            switch (gridController.GetCellValue(x, y))
                            {
                                // 敵がいない領域(空のセル)は
                                // 塗りつぶす(*)
                                case ' ':
                                    gridController.UpdateCell(x, y, '*');
                                    break;

                                // 敵がいる領域(%)は空にする
                                case '%':
                                    gridController.UpdateCell(x, y, ' ');
                                    break;

                                // 新しい線(+)は古い線(*)にする
                                case '+':
                                    gridController.UpdateCell(x, y, '#');
                                    break;
                            }
                        }
                    }
                    // 周囲に空き領域がない古い線を塗りつぶす
                    for (int y = 2; y < gridController.maxY - 2; y++)
                    {
                        for (int x = 2; x < gridController.maxX - 2; x++)
                        {
                            // 古い線に対する処理
                            if (gridController.GetCellValue(x, y) == '#')
                            {
                                // 線の周囲(8方向)にある空のセルを数える
                                int count = 0;

                                for (int i = -1; i <= 1; i++)
                                {
                                    for (int j = -1; j <= 1; j++)
                                    {
                                        if (gridController.GetCellValue(x + i, y + j) == ' ')
                                        {
                                            count++;
                                        }
                                    }
                                }

                                // 空のセルがまったくないときには、
                                // 古い線を塗りつぶす
                                if (count == 0)
                                {
                                    gridController.UpdateCell(x, y, '*');
                                }
                            }
                        }
                    }
                }
                else
                {
                    vx = vy = 0;
                }
            }
            else
            {
                // 1つ先のセルと2つ先のセルが、
                // ともに古い線であるとき以外には、
                // カーソルを移動させない
                if (c1 != '#' || c2 != '#')
                {
                    vx = vy = 0;
                }
            }
            if (vx != 0 || vy != 0)
            {
                // カーソルのセル座標を更新する
                // 線同士が隣接しないようにするために、
                // セルを二つずつ移動する
                StartCoroutine(MoveToPosition(new Vector3(CX + vx * 2, CY + vy * 2, 0)));
                CX += vx * 2;
                CY += vy * 2;
            }
        }
        //ここの処理のせいでかなり遅くなるので改良すること
        int ysize = gridController.maxY - 6;
        int xsize = gridController.maxX - 6;
        int cell_cnt = 0;
        //int empty_cell_cnt = 0;
        for (int y = 3; y < gridController.maxY - 3; y++)
        {
            for (int x = 3; x < gridController.maxX - 3; x++)
            {
                if (gridController.GetCellValue(x, y) == '*')
                {
                    cell_cnt++;
                }
                //if (gridController.GetCellValue(x, y) == ' ')
                //{
                //    empty_cell_cnt++;
                //}
            }
        }

        float temp = (float)cell_cnt / 4183.0f * 100.0f;
        gridController.parsetText.text = $"{Mathf.Ceil(temp)}%";
        if (temp > 75.0f)
        {
            gridController.parsetText.text = "75%";
            gridController.StageClear(gameObject);
        }
    }
    IEnumerator MoveToPosition(Vector3 targetPosition)
    {
        isMoving = true;
        Vector3 startPosition = transform.position;
        Vector3 endPosition = new Vector3(targetPosition.x * cellWidth, targetPosition.y * cellHeight, 0);
        float elapsedTime = 0;

        while (elapsedTime < moveInterval)
        {
            transform.position = Vector3.Lerp(startPosition, endPosition, (elapsedTime / moveInterval));
            elapsedTime += Time.deltaTime;
            yield return null;
        }

        transform.position = endPosition;
        isMoving = false;
    }
    void UpdatePlayerPosition()
    {
        transform.position = new Vector3(CX * cellWidth, CY * cellHeight, 0);
    }

    void Paint(int x, int y)
    {
        // 現在のセルが空ならば塗りつぶす
        if (gridController.GetCellValue(x, y) == ' ')
        {
            int lx, rx;
            // 空のセルが続く限り、
            // 現在のセルから左へ移動する
            for (lx = x; gridController.GetCellValue(lx - 1, y) == ' '; lx--) ;

            // 空のセルが続く限り、
            // 現在のセルから右へ移動する
            for (rx = x; gridController.GetCellValue(rx + 1, y) == ' '; rx++) ;

            // 空のセルを[%]で塗りつぶす
            for (x = lx; x <= rx; x++)
            {
                gridController.UpdateCell(x, y, '%');
            }

            // 塗りつぶしたセルの上下のセルについて、
            // 塗りつぶしの処理を再帰的に行う
            for (x = lx; x <= rx; x++)
            {
                Paint(x, y - 1);
                Paint(x, y + 1);
            }

        }
    }
}

3. Unityエディタでの設定

  1. プロジェクトウィンドウで「Assets」フォルダを右クリックし、「Create > Folder」を選択して「Scripts」フォルダを作成します。
  2. 「Scripts」フォルダ内で右クリックし、「Create > C# Script」を選択し、上記のスクリプトをそれぞれ作成します。
  3. Hierarchyウィンドウで右クリックし、「Create Empty」を選択して空のGameObjectを作成し、名前をGameControllerに変更します。
  4. GameControllerオブジェクトを選択し、Inspectorウィンドウで「Add Component」ボタンをクリックしてGridControllerスクリプトをアタッチします。
  5. 同様にして空のGameObjectを作成し、Playerに名前を変更し、PlayerControllerスクリプトをアタッチします。
  6. 「Prefabs」フォルダを作成し、セルやプレイヤーのプレハブを設定します。

4. ゲームの実行とテスト

  1. Unityエディタ上部にある再生ボタンをクリックしてゲームを実行します。
  2. 矢印キーとスペースキーを使用して、プレイヤーがセルを移動し、線を引き、領域を塗りつぶす動作を確認します。

まとめ

ここまでで最低限のクイックスを動かす説明をしてきました。UnityではDXライブラリとは違い座標を直接して描画するわけではないので、グリッドの情報を持たせる配列だけ単体で作るよりも、実際に生成したGameObjectそのものに情報を持たせる方法が適切であることがわかります。

最終的なプロジェクトはGitHubに上げておいたのでよろしければ参考にしてください。