Крестики-нолики на Unity [Часть 1]

Итак, я решился начать новую рубрику «Учимся вместе». И это первая серия статей в ней. Суть рубрики заключается в том, что я делаю какой-нибудь учебный проект, например, на Unity. По итогу, или прямо в процессе описываю, как я это делаю, какими путями иду, с какими трудностями сталкиваюсь, и как их решаю или не решаю. В общем – делюсь опытом.

Сразу замечу, что у меня уже есть определенный багаж знаний в программировании на C#, 3d-моделировании т.п., так что совсем уж азы и элементарные вещи я, наверное, писать в данной рубрике не буду. Разве что, сам буду что-то начинать с азов.

И еще одно, статью я пишу уже после того, как закончил проект, и начал другой. Вовремя разработки, я ничего не конспектировал, поэтому за точный порядок действий не ручаюсь. Да и некоторые моменты мог забыть.

Короче, хватит воды, перейдем к делу.

Проблема №1: идея.

Хотя нет, еще немного воды. В принципе, эту часть статьи можно безболезненно скипнуть, но глянуть ссылку в конце всё же рекомендую. Любопытные могут читать дальше.

Я долго не мог определиться, между Unity и UE4, каждый ковырял по очереди, но знакомство с C#, и отсутствие каких-либо знаний в С++, помогли-таки сделать выбор. Вот я наконец решил плотно изучать Unity. Почитал документацию (кстати, наличие частично переведенной документации, тоже сыграли роль при выборе Unity), посмотрел туторы, даже сделал что-то по ним, а что дальше? Нужно сделать что-то своё, но идей нет. Ну то есть они есть, но слишком масштабные, неподъёмные для одного человека, тем более полного чайника.

Пропрокрастинировав с неделю, или даже две, я решил делать какой-нибудь рогалик. В общем, вся идея так и формулировалась «какой-нибудь рогалик». Не было никакого видения игры, непонятно с чего начать, чтоб начать. Полез я в гугл, и начал искать что-нибудь по рогаликам (нашел к слову, пару хороших рецептов), и тут я случайно наткнулся на одну статью*. Оглядываясь назад, могу сказать, что она сэкономила мне много времени и нервов, которые я потратил бы на «какой-нибудь рогалик», и скорее всего ничего бы из этого не получилось. Это меня сильно бы демотивировало, и я бы опять на полгода залип в какой-нибудь диабле, или PoE. Не то, чтобы я отказался от идеи сделать рогалик, просто это была очень плохая идея, для самого первого собственного проекта.

Почитав статью, я на несколько шагов вперед запланировал, какие игры я буду делать. Выбор первой игры пал на «Крестики-Нолики». О ней и пойдет речь.

*Спасительная статья – https://habrahabr.ru/post/160547/

Подготовительный этап.

Начать я решил с подготовки всех необходимых графических ассетов, чтоб потом не отвлекаться на это. Сначала я все хотел просто нагуглить, но в процессе понял, что нарисовать будет проще, быстрее, и легче всё выдержать в одном стиле, хотя я и не ставил себе задачи – сделать красиво. В итоге, все спрайты были нарисованы мной сразу, кроме разве что тетрадного листа, его я нашел в Интернете. Набросал на основе их интерфейс игры в ФШ. Позже, когда игра уже была почти закончена, были дорисованы надписи для кнопки, и надписи: «Вы выиграли», «Вы проиграли» и «Ничья». Так как поначалу, я думал их сделать в виде текста, но потом понял, что у меня нет подходящего шрифта, и они будут выбиваться из общей стилистики. А делать специальный шрифт, для 4х надписей, мне было лениво.

За этим делом прошло полдня (да, я медленный), начинать кодить уже не хотелось, поэтому я решил закончить подготовительный этап полностью. Найти звуки, нарезать если надо. Создать все необходимые папки в проекте, импортнуть туда ассеты. Создать префабы, и организовать сцену. Настроить проект.

Так выглядят мои спрайты, и структура папок.

На этом этапе возникла вторая проблема, я никак не мог задать окну игры, нужный мне размер. При попытке задать размер в  Project Settings> Player> Resolution  ничего не происходило, игра запускалась с размером окна, с которым она запускалась до этого. Что я имею ввиду? Если сбилдить игру с галочкой Resizable Window, запустить игру и отмасштабировать окно вручную. После чего сбилдить игру уже без галочки, то игра запустится с тем размером, который мы выставили вручную, при прошлом запуске, а не с прописанным в Resolution. Как будто, где-то в системе это запоминается. Я так и не смог найти, как это пофиксить, без скриптинга.

В итоге, размер окна, задается принудительно, скриптом. Это был первый скрипт в проекте, и выглядит он так:

public class SizeWindow : MonoBehaviour
{
    public int width = 314;
    public int height = 401;
    
    void Awake ()
    {
        Screen.SetResolution(width, height, false);
    }
}

Скрипт я повесил на камеру, хотя это не важно, его можно повесить на любой объект, постоянно присутствующий в сцене. Но это решение, не разрешает проблему полностью, так как, скрипт отрабатывает после загрузки камеры, соответственно и размер задается тогда же. А пока игра запускается, и проигрывается заставка Unity, размер окна может быть каким угодно.

Засеваем поле.

Поле представляло из себя префаб с вложенным спрайтом решетки, и объектом-пустышки для будущих ячеек (понятно будет чуть позже). Дальше ход мысли был прост, нужно было при клике на клетке, отрисовывать спрайт крестика. Как это сделать, я не представлял, поэтому полез в гугл. Как выяснилось, самый простой способ, это реализовать через рейкастинг (Raycast). Суть которого заключается в том, что из камеры (в нашем случае) в сторону указателя мыши, бьет невидимый луч. Если луч на своем пути, сталкивается с коллайдером, он возвращает объект с координатами и объектом столкновения. Подробнее можно почитать тут – https://docs.unity3d.com/ru/current/Manual/CameraRays.html.

Следовательно, для начала, нам нужно в каждую клетку игрового поля, поместить объект с коллайдером, который будет «ловить клики». Я назвал этот объект – cell (ячейка), объект состоит из Box Collider 2d с размерами клетки поля (в моем случае, это 0.85), Sprite Renderer с пустым свойством Sprite, и скриптом Cell Class. В скрипте всего два поля, описывающих позицию объекта на поле-решетке:

public class CellClass : MonoBehaviour
{
    public int posCell, posColl;
}

Теперь пришло время разместить наши объекты ячейки на сцене, подогнав их под спрайт решетки. В принципе, это можно было сделать вручную, разкопировав объекты, и отпозиционировав их в редакторе Unity. Но наша цель научится писать игры, а не копипастить, и подгонять объекты на глазок. Поэтому, я решил сделать инициализацию поля в скрипте.
Создав пустой объект GamePlay в сцене, я повесил на него скрипт GamePlayController, он как понятно из названия, и будет контролировать игровой процесс. Хотя стоит отметить, что я совершенно не продумывал архитектуру, структуру, и т.д., поэтому некоторые моменты выглядят странно и нелогично. Для начала, в скрипт я добавил поля:

    public Transform cell;                  //Префаб-триггер
    public Transform cellsParent;           //Объект-папка для ячеек
    private Transform[,] sprites;          //Массив ячеек поля

В первые два поля мы через инспектор линкуем префаб ячейки и объект-пустышку, внутрь которого мы будем помещать инстансы наших ячеек, для порядка в окне иерархии. Третье поле, это двумерный массив, где будут храниться наши cell’сы, это как бы представление нашего игрового поля в коде.

Так же я добавил еще 2 технических поля:

    private float x = -0.85f;
    private float y = 0.75f;

Это координаты 1й ячейки (верхняя левая). Забегая наперед отмечу, что размер ячейки 0.85 на 0.85. Почему получились такие странные цифры? Ну так вышло. Сейчас, бы я конечно сделал бы по-другому, ну да ладно.

Теперь можно писать метод инициализации поля:

void InitField()
    {
        int arrColl = 0, arrCell = 0;
        sprites = new Transform[3, 3];
        for (int i = 1; i <= 9; i++)
        {
            Transform tempObj = (Transform)Instantiate(cell, new Vector3(x, y, 0), Quaternion.identity);
            sprites[arrColl, arrCell] = tempObj;
            tempObj.transform.SetParent(cellsParent.transform);
            tempObj.GetComponent<CellClass>().posCell = arrCell;
            tempObj.GetComponent<CellClass>().posColl = arrColl;
            
            arrColl++;
            x += 0.85f;
 
            //Смещаемся на строку вниз, сбрасываем колонку
            if (i % 3 == 0)
            {
                arrColl = 0;
                arrCell++;
                x = -0.85f;
                y -= 0.85f;
            }
        }
    }

В общем, код думаю понятен. Если коротко. Инициализируем массив sprites. И в цикле на 9 проходов (по количеству ячеек), инстанцируем объект cell, с координатами, которые мы задали выше. Instantiate возвращает объект GameObject, тут же для удобства, приводим его к Transform, и записываем ссылку во временную переменную. Далее помещаем его в массив с индексами arrColl, arrCell, и помещаем его в объект-пустышку cellParent (для порядка, помните?). Далее в компонент CellClass прописываем нашу позицию в массиве. Сдвигаемся по массиву и в игровом пространстве вправо (увеличиваем индекс arrColl и координату x). Дальше делаем проверку, делится ли без остатка наш счетчик цикла на 3, если да, значит мы достигли конца строки массива, и нам нужно сместиться вниз и в начало строки. Вот и вся инициализация поля.

Осталось вызвать наш метод:

void Awake ()
{
InitField();
}

При запуске игры, на экране вы ничего не увидите, т.к. на ячейки не назначено никаких спрайтов. Но в иерархии вы заметите созданные ячейки.

Рисуем крестик.

Создаем еще один объект-пустышку Player, и вешаем на него скрипт Player. Пишем метод, рисующий крестик по нажатию на клетки решетки:

void ClickPlayer()
    {
        Vector2 clickPoint = Camera.main.ScreenToWorldPoint(Input.mousePosition);
        RaycastHit2D hit = Physics2D.Raycast(clickPoint, Vector2.zero);
 
        if (hit.collider)
        {
            Transform hitCell = hit.transform;
 
            if (!hitCell.GetComponent<SpriteRenderer>().sprite)
            {
                hitCell.GetComponent<SpriteRenderer>().sprite = cross;
                GetComponent<AudioSource>().Play();
            }
        }
    }

Получаем координаты места, где был произведен клик. Посылаем луч в эту точку (рейкаст), и записываем возвращаемый объект в переменную hit. Далее проверяем, есть ли в нем коллайдер и, если да, записываем объект с коллайдером (это наш cell, других коллайдеров у нас просто не будет) в переменную hitCell. Теперь нужно проверить, занята ли клетка, кликали ли мы или противник по ней. Для этого, в компоненте SpriteRenderer нужно проверить поле sprite, если там null, значит клетка пустая, в противном случае там будет спрайт крестика или нолика.

В случае если клетка пустая, нам нужно поместить в свойство sprite компонента SpriteRenderer, наш крестик. Сделать это можно по-разному, я выбрал следующий способ. Создал в классе поле cross типа Sprite:

public Sprite cross;

и в инспекторе прилинковал к нему спрайт. И теперь, в методе присвоил его значение свойству sprite, компонента SpriteRenderer.

Ну и сразу, не отходя от кассы, повесил на Player’а компонент AudioSource, и закинул в него заготовленный трек. А в методе, вызвал на нем метод Play().

Осталось в методе Update() вызвать наш метод при нажатии ЛКМ:

void Update()
    {
        if (Input.GetMouseButtonDown(0))
            ClickPlayer();
    }

Теперь, при клике на клетках у нас рисуются крестики, и даже проигрывается звук при этом.

Ход противника.

Как и в случае с Player’ом, я создал в сцене объект-пустышку Enemy, добавил к нему скрипт Enemy и компонент AudioSource, со звуком хода противника. Теперь нужно подумать, а как собственно противник будет решать, куда ставить нолик.

Как и советовалось в статье, я не стал мудрить с ИИ, а решил пойти легким путем, и реализовать его через рандом, т.е. нолик будет ставиться случайно в свободную клетку. Поразмыслив над задачей, я пришел к такому решению. Создаем список (List) с пустыми ячейками, ячейка для нолика будет выбираться случайно, по индексу списка. После чего, выбранная ячейка удаляется из списка. Примерно поняв, что к чему, я начал писать.

Для начала нам точно понадобятся два поля. Первое для присвоения спрайта нолика, пустой ячейке, по аналогии с Player’ом:

public Sprite round;

Второе, сам список ячеек:

private List<Transform> freeCells;

*если вам вдруг не понятна данная конструкция, то вам пока рановато читать эту статью, да и писать скрипты для Unity в принципе. Почитайте сначала об универсальных шаблонах/типах, они же обобщения, они же дженерики (Generic). Они используются в Unity сплошь и рядом.

Теперь, список нужно инициализировать. Пишем метод:

void InitArrayCells()
    {
        freeCells = new List<Transform>();
        GameObject[] tempArr = GameObject.FindGameObjectsWithTag("cell");
 
        foreach (GameObject obj in tempArr)
            freeCells.Add(obj.GetComponent<Transform>());
    }

Думаю, тут всё понятно без лишних слов, скажу лишь, что чтобы метод заработал, ячейкам нужно задать в редакторе Unity тэг “cell”. Иначе метод FindGameObjectsWithTag ничего не вернет, а так он возвращает массив всех объектов, находящихся в сцене, с указанным тэгом.

Метод InitArrayCells() вызываем в методе Start скрипта.

Пришло время написать метод хода противника. Нет, не пришло. Мы еще не предусмотрели кое-что важное, а именно, нам нужно, чтоб противник ходил в свою очередь. Чтоб он, походив ждал нашего хода, а мы соответственно, не могли ходить, пока не сделает ход ИИ. Иначе он моментально забьет поле нулями, и бог знает какие еще ошибки полезут. Так же желательно, чтоб между ходами была микропауза (замечу, что я не сразу пришел к этому выводу). Дело в том, что, если не делать задержку, ИИ мгновенно ответит на наш ход, и звуки совместятся. А я бы этого не хотел. Так родился еще один класс Turn. Это статический класс, он не «повешен» ни на какой объект, он сам по себе, так сказать. Его задача, обеспечить последовательность ходов, и микропаузу между ними.

public static class Turn
{
    public static bool turn = false;
    static bool pause = false;
 
    public static bool Pause
    {
        get { return pause; }
    }
 
    public static IEnumerator SetPouse()
    {
        pause = true;
        yield return new WaitForSeconds(0.6f);
        pause = false;
    }
}

Поле turn, отвечает за ходы: если true, очередь ходить противника.

Поле pause, отвечает за микропаузу: если true, никто ходить не может.

Метод SetPouse() собственно и устанавливает паузу на 0.6 секунды. Если вам не совсем понятно, как работает, этот метод, почему он имеет такую сигнатуру, ознакомитесь с Coroutines – https://docs.unity3d.com/ru/current/Manual/Coroutines.html.

Вот теперь уже можно начинать писать метод хода противника:

void TurnEnemy()
    {
        while (Turn.turn)
        {
            int randomIndex = (int)Random.Range(0.0f, freeCells.Count - 1);
            if (!freeCells[randomIndex].GetComponent<SpriteRenderer>().sprite)
            {
                freeCells[randomIndex].GetComponent<SpriteRenderer>().sprite = round;
                GetComponent<AudioSource>().Play();
                Turn.turn = false;
                StartCoroutine(Turn.SetPouse());
                
            }
 
            freeCells.RemoveAt(randomIndex);
        }
    }

Первый вопрос, почему в while? Дело в том, что ход Player’а не удаляет после себя, занятую клетку из списка freeCells. И, когда великий рандом выдаст именно эту клетку, противник не походит, а ход передастся обратно к нам. Более того, может случится так, что таких не удаленных, но занятых клеток, может оказаться несколько. Поэтому и нужен while, если мы натыкаемся на такую клетку, мы ее удаляем из списка, и повторяем «генерацию» хода. Конечно, эту проблему можно было решить по-другому, как, впрочем, и написать весь проект, но сделано так, как сделано.

Ну, а в while’е все просто. Рандомим индекс в рейндже нашего списка. Проверяем пустой ли спрайт по данному индексу, если нет, удаляем его из списка, и повторяем процедуру. Если пустой, присваиваем ему спрайт нолика, проигрываем звук, передаем ход Player’у, вызываем метод паузы. И в завершении удаляем клетку из списка.

Теперь его нужно вызвать, пишем в методе Update:

if (Turn.turn && !Turn.Pause)
    TurnEnemy();

Ну и конечно, в Player тоже нужно внести небольшие изменения, а то на него не будут распространятся паузы и ходы. Строку, проверяющую занятость клетки, модифицируем так:

if (!hitCell.GetComponent<SpriteRenderer>().sprite
    && !Turn.turn && !Turn.Pause)

А в тело if’а добавляем две строки, после проигрыша звука:

Turn.turn = true;
StartCoroutine(Turn.SetPouse());

Теперь всё работает, как мы и задумали, правда, когда все клетки заполнятся, Unity выдаст ошибку «Argument is out of range». И я думаю понятно почему, список-то пуст, а Enemy продолжает к нему обращаться. Но это не беда, мы пофиксим, это чуть позже. Все равно, мы еще будем вносить изменения в то, что уже написали. Если вы боитесь запутаться, и потеряться со всеми этими правками, модификациями. То не переживайте, в конце я выложу окончательный, рабочий вариант всех скриптов, чтобы вы могли посмотреть, что у меня получилось в результате, после всех правок.

Иерархия объектов в запущенном состоянии игры (это уже законченная игра).

На этом, я закончу первую часть. Она вышла простой, разогревочной. В следующей части, мы будем писать метод, определяющий победу/поражение/ничью. Он дался мне не просто, но я написал его сам, без поисков готового решения в гугле. Ну, а пока всё.

Спасибо за внимание!

Вторая часть

Крестики-нолики на Unity [Часть 1]: 5 комментариев

      1. У меня не получается инициализировать поле, ошибку юнити не выдает сделал вроде все как нужно:создал cell дал ему Box collider 2d, sprite renderer, навесил скрипт с координатами клетки, потом создал GameObjtect навесил скрипт который был у вас, и перетащил клетку из иерархии в первое поле скрипта(public Transform cell; ) а затем создал пустышку cells и из иерархии перетащил во второе поле скрипта(public Transform cellsParent;), умоляю помогите, я весь день просидел в гугле и не смог найти ответ.

        1. Возможно, Вы не вызываете нигде сам метод инициализации InitField. Я видимо забыл в статье про этот момент. Пропишите в скрипте GamePlayController следующий метод:

          void Awake ()
          {
          InitField();
          }

          *О методе Awake и других стандартных методах юнити можете почитать тут – https://docs.unity3d.com/ru/current/Manual/ExecutionOrder.html

          Если это не решит проблему, пишите, будем разбираться.

          P.S. И еще одно, объект Cell нужно сохранить как префаб, после чего из сцены его удалить, а прилинковывать его уже именно как префаб (перетаскивая из папки проекта), а не из иерархии. Если Вы прилинкуете объект из сцены, после чего сам объект удалите, то даже если у вас будет префаб этого объекта, связь разорвется и поле останется пустым.

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