Пирамидки на Unity

Unity популярная среда разработки игр, позволяющая даже одному индивидуальному разработчику реализовать простую игру или сделать прототип игры посложнее. Изучая Unity, достаточно быстро возникает желание сделать свою игру. При этом сразу возникает море вопросов, как сделать то или это. И чем дольше Вы читаете книги или статьи, смотрите ролики, повторяете за спикерами на интенсивах, тем больше Вы узнаёте возможности среды, тем амбициознее видится Ваш проект и, увы, он всё дальше от возможного завершения. Но очень полезно сделать может быть очень простую игру, но от начала и до конца. Получить запускаемый exe файл игры с парой кнопок, звуками и перезагрузкой уровней. Давайте сделаем такую игру — ‘Пирамидки’ на Unity.

Содержание

Мой замысел

Думаю, что все знают детскую игрушку ‘Пирамидка’. Ребёнок нанизывает кольца на стержень. Чем выше кольцо, тем меньше его диаметр. Повторить эту идею в Unity можно, но это будет очень простая игра. Есть игры про сортировку разноцветных шариков или переливание разноцветных жидкостей в пробирках. Попробуем совместить эти две механики: будем собирать 5 разноцветных пирамидок по 6 колец на 6 стержнях. Уровень будет единственный. При желании можно постепенно усложнять игру, увеличивая число стержней и колец в пирамидках. Начать можно с 3 стержней и 2 пирамидок по 3 кольца для обучения. Дальше увеличивать количество всего в уровнях. Как вариант, можно раскрасить кольца одного размера каждое в свой цвет и собирать пирамидки только по уменьшению размера колец. Так будет проще играть. Реализуем первый вариант: 5 пирамидок по 6 колец на 6 стержнях.

Создание проекта и сцены игры Пирамидки на Unity

Запускаем среду разработки Unity и создаём новый проект 3d игры с именем RingsOnRods, например. Смотри рис. 1.

рис. 1 Создание проекта игры.

На экране пустая сцена. Из простых примитивов (куб и цилиндр) сконструируем сцену для игры. Добавим куб, растянем его по X до 11 и по Z до 5, чуть опустим по Y на 0.5. Можно всё это выставить в инспекторе и получим площадку-основание (Transform Scale и Position). Из второго куба сделаем заднюю стену: растянем по X до 11 и по Y до 10, отдалим по Z на 2 и поднимем по Y на 4. Из третьего куба сделаем левую стену: растянем по Z до 5 и по Y до 10, сдвинем влево по X на 5.5 и поднимем по Y на 4. Эту стенку дублируем и координату X делаем отрицательной, в результате справа тоже стенка. Далее добавим цилиндр и выставим его параметры в инспекторе — рис. 2.

рис. 2 Параметры стержня.

Дублируем первый стержень 5 раз и расставим через 1.5 единицы по X левее эти 5 стержней. Добавим ко всем элементам Box Collider. Для стержней определим новый Tag — rod, а для будущих колец — tor. Кликнем по стрелке в поле Tag, затем по нижней строке Add Tag… , далее + и вписываем наше название тега, для второго тега повторяем всё ещё раз. Добавленный тег rod устанавливаем для всех стержней.

На всякий случай я собрал большинство Asset-ов, которые использовал, и 2 скрипта в архив. Его можно скачать по ссылке :

Камера и освещение

Давайте настроим камеру так, чтобы все стержни были видны. Переместим источник освещения и направим на стержни. Для этого выбираем камеру или источник света, ставим режим перемещения — стрелки и за разноцветные стрелки перетаскиваем наши объекты как нам надо. Камеру делаем ортогональной и параметром Size определяем, что она будет показывать. На 10-20 градусов наклоним её сверху вниз. Должно получиться что-то похожее на рис. 3.

рис. 3 Игровое поле.

Вы можете всё расставить и настроить так как Вы видите эту сцену. При ортогональной камере левая и правая стенки видны будут только своими торцами. Чтобы их было видно, нужно их немного повернуть по оси Y. Но, поэкспериментировав, я решил их просто не показывать. А если камера будет в режиме перспективы, то кольца будут не естественными к краям сцены — слегка повернутыми и с искаженными размерами. Кольца разного размера в середине и с краю будут казаться одинаковыми, что будет сбивать во время игры.

Нам нужно кольцо

Для заготовки кольца я использовал программу Blender. В ней я добавил на пустой сцене Mesh (фигуру) Tor (кольцо). Увеличил радиус, определяющий толщину кольца, с 0.25 до 0.5 и число рёбер с 12 до 24, чтобы кольцо было похоже на кольцо от детской пирамидки. Сохранил проект и экспортировал кольцо в формате FBX. Можно поискать Asset для кольца в Asset Store.

Импортируем кольцо в формате FBX в наш проект и добавим его на сцену. Из него нужно сделать Prefab (заготовку) для программного создания набора колец. Чтобы кольца были разноцветными, создадим материалы разных цветов. У меня 5 (6) цветов для колец, 3 цвета для стержней, пола и стен. Перетаскивая материалы разных цветов на стержни, стены и кольцо назначим их. Наши элементы будут покрашены в созданные цвета. Для кольца в Mesh Renderer в разделе Materials добавим ещё 4 (5) материалов нажимая на + и в поля Element 1, 2 … перетащим материалы других цветов. Красная стрелка на рис. 4 ниже :

рис. 4 Заполнение массива материалов.

Осталось добавить кольцу коллайдер. Выберу самый простой — Box Collider. Он нужен будет чтобы можно было выбрать конкретное кольцо или столбик. Установим кольцу ранее созданный тег — tor. Создадим папку Prefabs внутри Assets проекта и перетащим в неё созданное кольцо. В перечне объектов сцены имя кольца стало голубым — теперь у нас есть префаб tor. Из сцены его удалим, т.к. набор колец будем создавать программно.

Случайная генерация набора колец

Пора переходить к программированию. Создадим в сцене пустой объект с именем GameManager. Добавим к нему в инспекторе новый Script с таким же именем. Скрипты будем хранить в отдельной папке с именем Scripts, которую создадим в нашем проекте. Также и кольцу добавим свой скрипт с именем Tor.cs (внутри префаба по кнопке Add Component). Оба скрипта перенесём в ранее созданную папку Scripts. Для редактирования кода я использую Microsoft Visual Studio 2019. Запустим её и откроем проект (файл с расширением sln). Внутри найдите и откройте файл GameManager.cs. Сразу добавим несколько переменных, об использовании которых я буду рассказывать далее.

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

public class GameManager : MonoBehaviour
{
    [SerializeField] GameObject prefabTor;
    [SerializeField] int modeGame = 5;
    [SerializeField] float moveSpeed = 5;
    [SerializeField] Camera _camera;
    [SerializeField] TextMeshProUGUI score;
    [SerializeField] GameObject panel;
    public Texture2D cursorTexture;

    public AudioClip tor_UP;
    public AudioClip tor_DOWN;
    public AudioClip game_WIN;
    [SerializeField] private AudioSource sound_eff_Source;


    private GameObject[] arrTors = null;
    private int[,] posTors = null;
    private GameObject curRing = null;
    private int[,] posWin = null;
    private int n_step;

Атрибут SerializeField позволяет отображать private (закрытые) переменные в инспекторе Unity. Там можно соотнести конкретные объекты сцены или проекта с переменными внутри класса, а также менять значения переменных не изменяя кода скриптов. Эту возможность разработчики предоставляют гейм дизайнерам для настройки, например, баланса игры. Перейдя в Unity переменной prefabTor типа GameObject назначим префаб кольца перетащив его в ячейку рядом с именем переменной. Красная стрелка на рис. 5 ниже :

рис. 5 Назначаем tor1 в качестве префаба класса GameManager.cs

В массиве arrTors будем хранить все кольца. В массиве posTors будем фиксировать перемещения колец по стержням. Переменная curRing используется для обращения к выбранному кольцу. Создание массива колец разного размера и цвета вынесем в отдельную функцию :

void GeneratePosTors()
{
    if (modeGame == 5)
    {
        posTors = new int[6, 6];
        arrTors = new GameObject[30];
        int i;
        for (i = 0; i < 30; i++)
        {
            arrTors[i] = Instantiate(prefabTor, Vector3.zero, Quaternion.identity);
            arrTors[i].GetComponent<Tor>().TorID = i;
            arrTors[i].GetComponent<Tor>().Speed = moveSpeed;
            MeshRenderer mr = arrTors[i].GetComponent<MeshRenderer>();
            Material[] mat = new Material[1];
            mat[0] = mr.materials[i / 6];
            mr.materials = mat;
            arrTors[i].transform.Rotate(90f, 0, 0);
            arrTors[i].transform.localScale = new Vector3((float)(20 * (0.8 + 0.31 * (i % 6))), (float)(20 * (0.8 + 0.31 * (i % 6))), 55.0f);
        }
        Shuffle(arrTors);
        for (i = 0; i < 30; i++)
        {
            posTors[i / 6, i % 6] = arrTors[i].GetComponent<Tor>().TorID;   // y, x
            arrTors[i].transform.position = new Vector3((float)(-3.75 + 1.5 * (i % 6)), 0.25f + 0.5f * (i / 6), 0);
        }
        for (i = 0; i < 6; i++) posTors[5, i] = -1;
    }
}

Режим игры ( 5 ) реализуем только один — 5 пирамидок по 6 колец на 6 стержнях. Остальные можно реализовать с уменьшением или увеличением числа предметов для упрощения или увеличения сложности игры. Создадим массивы для позиций и колец. Массив колец наполним 30 кольцами в цикле. Каждому кольцу присвоим идентификатор, скорость перемещения и материал с цветом. Rotate(90f, 0, 0) — борьба с последствиями импорта из Blender. Разные размеры кольцам зададим через присваивание Vector3 в localScale. Далее перемешаем кольца и зададим им позиции на стержнях. Верхний 6-й слой — пустой (-1), нижнее 5 содержат идентификаторы колец в posTors.

void Shuffle(GameObject[] deck)
{
    for (int i = 0; i < deck.Length; i++)
    {
        GameObject temp = deck[i];
        int randomIndex = Random.Range(0, deck.Length);
        deck[i] = deck[randomIndex];
        deck[randomIndex] = temp;
    }
}

На сцене при запуске игры появятся 30 разноцветных колец разных размеров, распределенные в случайном порядке на 6 стержнях. При каждом запуске расстановка будет случайной и другой.

Перемещение кольца

Главная механика игры состоит в перекладывании колец с одного стержня на другой и выстраивании их в определенном порядке. В MVS 2019 откроем файл Tor.cs. Добавим два публичных свойства — TorID и Speed и три внутренних переменные — режим перемещения и целевые точки.

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

public class Tor : MonoBehaviour
{
    public int TorID { get; set; }
    public float Speed { get; set; }

    private int modeMove = 0;
    private Vector3 target, target_down;

    // Start is called before the first frame update
    void Start()
    {        
    }

    // Update is called once per frame
    void Update()
    {
        if (modeMove > 0)
        {
            transform.position = Vector3.MoveTowards(transform.position, target, Speed * Time.deltaTime);
            if (transform.position == target)
            {
                if (modeMove == 1) modeMove = 0;
                if (modeMove == 2)
                {
                    if (target != target_down) target = target_down;
                    else modeMove = 0;
                }
            }
        }
    }

    public void RingUp(Vector3 tg)
    {
        modeMove = 1;
        target = tg;
    }

    public void RingMove(Vector3 tg, Vector3 tg_down)
    {
        modeMove = 2;
        target = tg;
        target_down = tg_down;
    }
}

Через две общедоступные функции RingUp и RingMove будем из класса GameManager устанавливать режим и точки перемещения. Само перемещение происходит в функции Update. За счёт функции Vector3.MoveTowards устанавливается новая позиция кольца для каждого нового кадра. Когда кольцо достигает целевой точки, обнуляем режим перемещения и кольцо останавливается.

Проверка завершения игры

Возвращаемся в файл и класс GameManager.cs. В функции Start заполним в правильном порядке значениями идентификаторов колец эталонный массив posWin. В нижнем ряду самые большие кольца с идентификаторами, кратными 5. Выше кольца уменьшающегося размера с уменьшающимися на 1 идентификаторами.

posWin = new int[6, 5];
for (int i = 0; i < 6; i++)
{
    for (int j = 0; j < 5; j++)
    {
        posWin[i, j] = (5 - i) + j * 6;
    }
}

Сравнивать текущее положение колец с эталонным будем в функции isWin. В первом цикле проверяем расположение всех больших колец в нижнем уровне и пустой столбик ( там -1 ). При любом несовпадении возвращаем false.

bool isWin()
{
    n_step++;
    score.text = "Ходы : " + n_step.ToString();
    int wn = 0, i, j, col = -1;
    for (i = 0; i < 6; i++)
    {
        if (((posTors[0, i] + 1) % 6) != 0) return false;
    }
    for (i = 0; i < 6; i++)
    {
        col = -1;
        if (posTors[0, i] == -1) continue;
        // ищем столбик с кратным 5 основанием
        for (j = 0; j < 5; j++) if (posTors[0, i] == posWin[0, j]) {col = j;break;}   
        if (col == -1) continue;
        for (j = 1; j < 6; j++) 
        {   // проверяем правильность сборки столбика
            if (posTors[j, i] != posWin[j, col]) return false;
        }
        wn |= 1 << col;
    }
    if (wn != 0x1f) return false;
    print("WIN");
    return true;
}

Во втором цикле по нижнему кольцу определяем эталонный столбик, с которым и будем сравнивать очередной столбик. При несовпадении — return false. Итерация цикла дошла до конца — в wn установим в 1 бит с номером проверенного столбца. Проверка wn != 0x1f лишняя, т.к. при любом несовпадении из функции возвращается false. Функция вернёт true, если дойдёт до конца, а это значит что все пирамидки правильно собраны.

Основной цикл игры крутится в функции Update класса GameManager. При нажатой левой кнопке мыши методом бросания луча определяем GameObject, по которому кликнул игрок. Если клик попал на кольцо или столбик, что мы определяем по совпадению тегов, начинаем обработывать этот клик. Если ранее кольцо не было выбрано (curRing == null), то по идентификатору кликнутого кольца определяем столбик и верхнее кольцо в нём. Это верхнее кольцо записываем в curRing и даём ему команду ‘переместиться вверх’. В позицию этого кольца записываем -1. Если кольцо уже было выбрано, то определяем столбик и позицию по горизонтали (по идентификаторам кольца или столбика), куда его положить. В эту позицию запишем идентификатор этого кольца, дадим команду ‘переместиться по двум точкам’ ( RingMove ) и обнулим curRing. Тут же делаем проверку на собранность пирамиды и её результат запоминаем в win_yes.

void Update()
{
    bool win_yes = false;
    if (panel.active) return;
    if (Input.GetMouseButtonDown(0))
    { // Реакция на нажатие кнопки мыши.
        Vector3 point = new Vector3(_camera.pixelWidth / 2, _camera.pixelHeight / 2, 0); // Середина экрана — это половина его ширины и высоты.
        Ray ray = _camera.ScreenPointToRay(Input.mousePosition); // Создание в этой точке луча методом ScreenPointToRay().
        RaycastHit hit;            
        if (Physics.Raycast(ray, out hit))
        {// Испущенный луч заполняет информацией переменную, на которую имеется ссылка.
            int col_x, cur_id;
            if (hit.transform.gameObject.CompareTag("tor"))
            {
                print(hit.transform.gameObject.GetComponent<Tor>().TorID);
                if (curRing == null)
                {
                    col_x = (int)((hit.transform.gameObject.transform.position.x + 3.75) / 1.5);
                    for (int i = 5; i >= 0; i--)
                    {
                        cur_id = posTors[i, col_x];
                        if (cur_id != -1) 
                        {
                            foreach (GameObject tor in arrTors)
                            {
                                if (tor.GetComponent<Tor>().TorID == cur_id)
                                {
                                    curRing = tor;
                                    posTors[i, col_x] = -1;
                                    sound_eff_Source.clip = tor_UP;
                                    sound_eff_Source.Play();
                                    break;
                                }                                    
                            }
                            break;
                        }
                    }
                    Vector3 pos = curRing.transform.position;
                    curRing.GetComponent<Tor>().RingUp(new Vector3(pos.x, 3.25f, pos.z));
                }
                else
                {
                    Vector3 rodPos = hit.transform.gameObject.transform.position;
                    col_x = (int)((rodPos.x + 3.75) / 1.5);
                    for (int i = 0; i < 6; i++) 
                    {
                        if (posTors[i, col_x] == -1)
                        {
                            curRing.GetComponent<Tor>().RingMove(new Vector3(rodPos.x, 3.25f, rodPos.z), new Vector3(rodPos.x, 0.25f + 0.5f * i, rodPos.z));
                            posTors[i, col_x] = curRing.GetComponent<Tor>().TorID;
                            curRing = null;win_yes = isWin();
                            sound_eff_Source.clip = tor_DOWN;
                            sound_eff_Source.Play();
                            break;
                        }
                    }                        
                }
            } 
            if (hit.transform.gameObject.CompareTag("rod"))
            {
                if (curRing != null)
                {
                    Vector3 rodPos = hit.transform.gameObject.transform.position;
                    col_x = (int)((rodPos.x + 3.75) / 1.5);
                    for (int i = 0; i < 6; i++)
                    {
                        if (posTors[i, col_x] == -1)
                        {
                            curRing.GetComponent<Tor>().RingMove(new Vector3(rodPos.x, 3.25f, rodPos.z), new Vector3(rodPos.x, 0.25f + 0.5f * i, rodPos.z));
                            posTors[i, col_x] = curRing.GetComponent<Tor>().TorID;
                            curRing = null; win_yes = isWin();
                            sound_eff_Source.clip = tor_DOWN;
                            sound_eff_Source.Play();
                            break;
                        }
                    }
                }
            }
        }
    }
    if (win_yes)
    { 
        print("WIN");
        panel.SetActive(true);
        sound_eff_Source.clip = game_WIN;
        sound_eff_Source.Play();
    }
}

Кольца выбираются и перемещаются между столбиками. Проверка собранности выполняется.

Минимальный интерфейс пользователя

Добавим две кнопки, две панели с текстом и текстовое поле для вывода числа ходов. Расставим их, зададим цвета, размер шрифта и текстовые значения. Я получил вариант как на рис. 6 ниже :

рис. 6 Интерфейс пользователя

Панель Pan_EndGame установим в качестве значения панели класса GameManager и спрячем при старте( SetActive(false) ), а покажем когда всё соберём. Текстовое поле для вывода очков установим в качестве значения поля Score класса GameManager и будем в него выводить число ходов в функции isWin : n_step++; score.text = «Ходы : » + n_step.ToString(); В классе GameManager добавим две функции PressedStart() и PressedExit() для реализации перезагрузки игры и выхода соответственно. Назначим событиям OnClick кнопок Старт и Выход эти функции :

рис. 7 Назначаем цвет при наведении и функцию при клике

Перезагрузка уровня выполняется по клику на кнопке Старт в функции PressedStart(): SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);

Чтобы элементы интерфейса оставались на своих местах при изменении разрешения экрана используем привязку якорями для каждого элемента. Заменим курсор с обычной стрелки на, например, ‘руку’. Скачаем бесплатную картинку в формате png, добавим её в проект и установим значение её Texture Type в Cursor. Перетащим получившийся курсор в поле Cursor Texture объекта GameManager и активируем его в функции Start : Cursor.SetCursor(cursorTexture, Vector2.zero, CursorMode.Auto);

Добавим звуки и музыку фона

Часто в игры играют без звука, чтобы не мешать окружающим. Но без озвучки игра всё-таки неполная. Добавим фоновую музыку и три звука, сопровождающие выбор кольца, перемещение и победу. Элементы управления звуком можно добавить к любому объекту, особенно если нужно чтобы звук исходил из места расположения этого объекта. Нам это не принципиально, поэтому по одному элементу управления звуком добавим к главной камере и к GameManager. Музыку и нужные звуки в формате mp3 можно скачать на сайтах с бесплатными Asset-ами. Эти звуковые файлы перетаскиванием добавляем в проект (правильнее создать папку Sounds и поместить их туда). Для воспроизведения фоновой музыки добавим к камере элемент Audio Source. В качестве AudioClip перетащим в это поле Ваш музыкальный файл и установим галочки в полях Play On Awake и Loop для воспроизведения сразу после загрузки циклически. В классе GameManager есть три поля для звуковых файлов ( AudioClip tor_UP; tor_DOWN; game_WIN ).

рис. 8 Устанавливаем значения в GameManager

Подобранные mp3 файлы перетащим из папки проекта в эти поля и будем воспроизводить их программно в нужных нам случаях. Для этого к объекту GameManager добавим свой элемент Audio Source. В нём установим галочку в поле Play On Awake и значение Volume в 0.4 чтобы звуки были потише фоновой музыки, но это уже на Ваше усмотрение. Этот элемент Audio Source назначим полю sound_eff_Source в объекте GameManager (перетаскиванием). В нужный момент загружаем соответствующий AudioClip и проигрываем его один раз : sound_eff_Source.clip = tor_UP; sound_eff_Source.Play();

Сборка и тестирование игры

Разработка нашего прототипа закончена. Уже можно в Пирамидки на Unity поиграть. Чтобы сделать запускаемый exe файл перейдём в окно Build Settings, добавим нашу сцену, выходную папку по нажатию кнопки Build. В этой папке и будет наша готовая игра.

рис. 9 Делаем exe файл.

В ролике показан финальный этап игры. Когда все пирамидки собраны, появляется поздравление с победой. Игрок может начать снова по кнопке СТАРТ или закончить и уйти по кнопке ВЫХОД.

В архиве ниже рабочая версия игры. Скачайте его и распакуйте в отдельную папку на компьютере.

Послесловие

Я постарался описать полностью процесс создания простой игры. Результат, конечно, далёк от идеала. Что-то можно улучшить в коде, кольцо сделать более реалистичным, добавить визуальных и звуковых эффектов, а также придумать как разнообразить геймплей. Здесь море возможностей. Но цель ставилась именно описать всю разработку игры от идеи до работающего exe файла. Буду рад, если эта статья кому-то поможет сделать свою игру.

Обновлено: 16.11.2022 — 15:07

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *