КРЕСТИКИ-НОЛИКИ НА UNITY [ЧАСТЬ 2]

Вот я наконец и взялся за вторую часть статьи. Чертова прокрастинация одолела. Спасибо человеку, «пнувшему» меня в комментариях к предыдущей части, кто знает, сколько бы я еще откладывал это дело на потом. Тут кстати, находится первая часть .

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

Проверка на победу.

Теперь, после реализации ходов, наших и ИИ, нам нужно реализовать проверку на победу одной из сторон. Ничья, изначально в моей игре не учитывалась, я как-то напрочь забыл о таком развитии событий в крестиках-ноликах.

Идея, как сделать такую проверку, родилась практически сразу. Каждый раз, когда совершается ход, мы должны «прошагать по полю» во все стороны (вверх, вниз, вправо, влево, и также по диагоналям), и сравнить спрайты в клетках на совпадение со спрайтом последнего ходившего. При наличии 2х совпадений, мы имеем его победу. Звучит проще простого, но реализовать это кодом, для меня было не просто. Поначалу код вышел громоздким, с кучей if-else’ов, потом его удалось подсократить.

Проверку я разделил на 2 метода, в первом мы задаем вектор для «шагания», и в зависимости от результатов «шагания» отрисовываем линию перечеркивающую ряд, выводим нужное сообщение (победа, или проигрыш). Во-втором, мы собственно и шагаем. Методы я поместил в классе GamePlayController.

Вот код первого метода:

public bool CheckWin(Transform callerCell, string callerFigure)
    {
        bool isWin = false;
        if (callerCell)
        {   //Если в параметрах не null, чекаем на победу....
            Vector2 currenPos = new Vector2();
            currenPos.x = callerCell.GetComponent<CellClass>().posColl;
            currenPos.y = callerCell.GetComponent<CellClass>().posCell;
 
            if (StepByField(new Vector2(0, -1), currenPos) == 2) //Вверх             x - колонка / у - строка
            {
                DrawLine(sprites[(int) currenPos.x, (int) currenPos.y].position, "vertical");
                StartCoroutine(ShowMessage(callerFigure));
                isWin = true;
            }
            else if (StepByField(new Vector2(1, -1), currenPos) == 2) //Вверх-вправо
            {
                DrawLine(sprites[(int) currenPos.x, (int) currenPos.y].position, "left_diagonal");
                StartCoroutine(ShowMessage(callerFigure));
                isWin = true;
            }
            else if (StepByField(new Vector2(1, 0), currenPos) == 2) //Вправо
            {
                DrawLine(sprites[(int) currenPos.x, (int) currenPos.y].position, "horizontal");
                StartCoroutine(ShowMessage(callerFigure));
                isWin = true;
            }
            else if (StepByField(new Vector2(1, 1), currenPos) == 2) //Вниз-вправо
            {
                DrawLine(sprites[(int) currenPos.x, (int) currenPos.y].position, "right_diagonal");
                StartCoroutine(ShowMessage(callerFigure));
                isWin = true;
            }
        }
        else // .... в противном случае, передаем null дальше, для вывода сообщение о ничьей
            StartCoroutine(ShowMessage(null));
 
        return isWin;
    }

Метод возвращает true или false, в зависимости от того, есть ли победа. Принимает две переменные: callerCell – объект-ячейка в которую был сделан ход; callerFigure – строковая переменная, содержащая имя, кем именно сделал ход. Объявляем временную переменную currenPos для хранения позиции ячейки, куда был произведен ход, от нее и будем шагать. Смысл этой переменной только в том, чтобы сократить запись вызова метода «шагания».

Теперь в ряде условий if-else вызываем метод шагания StepByField, передавая ему направление шагания в виде вектора, и позицию, от которой необходимо производить шаги. Метод вернет количество найденных совпадений. При двух совпадениях, мы имеем победу, внутри if отрисовываем линию перечеркивающую ряд, выводим нужное сообщение (о реализации этих методов потом), устанавливаем флаг isWin в true, который и возвращаем.

У вас может возникнуть вопрос, а почему вся эта конструкция вложена в условие if (callerCell), и почему там может вдруг оказаться null? Как я уже сказал, изначально в моей игре не учитывалась ничья, и это условие, нужно именно для того, чтоб ее учесть. Условие ничьей проверяется в другом месте. Именно там, обнаружив ничью, мы вызываем метод CheckWin, передавая первым параметром null. Вероятно, уже кто-то догадался, где происходит эта проверка. Нет? Ну хорошо. Игрок у нас всегда делает ход первым, а ничья может случиться только после хода противника, именно он может заполнить последнюю клетку. Вот в классе Enemy мы и делаем эту проверку. Если CheckWin вернет false, а количество свободных ячеек равно нулю, значит у нас ничья. Коряво, согласен. Но я это дописывал уже в самом конце, когда всё было готово, и я дал потестировать игру знакомым, которые мне и сообщили, что я кое-что забыл. А переписывать много уже не хотелось.

Теперь о методе шагания:

int StepByField(Vector2 vector, Vector2 currentPosition)
    {   
        int coincide = 0;
        
        Vector2 step = currentPosition + vector;
        string figure = sprites[(int)currentPosition.x, (int)currentPosition.y].GetComponent<SpriteRenderer>().sprite.name;
 
        int stop = 0;
        while (stop < 2)
        {
            //  Блок проверок:                
            // -Проверка выхода за пределы массива
            // -Проверка на наличии спрайта в "ячейке"
            // -Проверка соответствует ли следующий спрайт, "походившему"
            if ((step.x > -1 && step.y > -1 && step.x < 3 && step.y < 3)                                           
                && (sprites[(int) step.x, (int) step.y].GetComponent<SpriteRenderer>().sprite)                     
                && sprites[(int)step.x, (int)step.y].GetComponent<SpriteRenderer>().sprite.name == figure)         
                                                                                                                   
            {
                coincide++;
                step += vector;
            }
            else
            {
                vector *= -1;
                step = currentPosition + vector;
                stop++;
            }
        }
        return coincide;
    }

Создаем переменную для подсчета совпадений. Создаем переменную step, которая является позицией следующей сравниваемой ячейки (будем в дальнейшем называть ее «курсором»). Извлекаем имя спрайта изначальной позиции в переменную figure. Цикл проверки устроен так, что мы проверяем не только в заданном вектором направлении, но и заодно в противоположном, это позволило сократить количество if-else блоков в методе CheckWin с 8-ми до 4-х. Далее в условии if мы делаем ряд проверок: не вышел ли «курсор» за пределы поля; есть ли вообще спрайт в ячейке под «курсором»; соответствует ли имя спрайта под «курсором», спрайту походившего. Если все совпадает, увеличиваем счетчик совпадений, и перемещаем «курсор» дальше по вектору.

Если мы вышли за пределы поля, или наткнулись на несовпадение; реверсируем вектор; сдвигаем «курсор» в новом направлении от изначальной позиции; увеличиваем переменную stop (мол одно направление мы проверили, следующее увеличения переменной завершит цикл).

В конце, возвращаем количество найденных совпадений.

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

Отрисовка перечеркивания ряда

Все в том же классе GamePlayController, пишем метод DrawLine:

void DrawLine(Vector2 position, string orientation)
    {
        Vector3 posLine = position;
        Transform tr;
        switch (orientation)
        {
            case "vertical":
                posLine.y = 0; // Центрируем линию по вертикали
                Instantiate(line, posLine, Quaternion.identity);
                break;
            case "horizontal":
                posLine.x = 0; // Центрируем линию по горизонтали
                Instantiate(line, posLine, Quaternion.Euler(0, 0, 90));
                break;
            case "left_diagonal":
                // Диагональ всегда проходит через центр, поэтому инстанцируем ее в нулевых координатах
                // После инстанцирования, не много растягиваем линию
                tr = (Transform)Instantiate(line, new Vector3(0, 0, 0), Quaternion.Euler(0,0,-45));
                tr.localScale += new Vector3(0, 0.3f, 0);
                break;
            case "right_diagonal":
                tr = (Transform)Instantiate(line, new Vector3(0, 0, 0), Quaternion.Euler(0, 0, 45));
                tr.localScale += new Vector3(0, 0.3f, 0);
                break;
        }
    }

Метод принимает два параметра: позицию победного хода; и строковую информацию о том, как должна быть сориентирована линия, что определяется в зависимости от того, в каком из блоков if-else метода CheckWin найдены совпадения. В блоке switch мы инстанцируем линию line, и в зависимости от требуемой ориентации, центрируем ее по вертикали/горизонтали, и задаем нужный поворот (с помощью Quaternion.Euler). Если требуется диагональная линия, я ее еще и растягиваю слегка (задаю tr.localScale), так выглядит красивее.

Осталось добавить в класс поле line:

public Transform line;

и в инспекторе прилинковать к нему спрайт линии.

Оговорюсь, что у меня спрайт вертикальной линии, и повороты заданы основываясь на этой начальной ориентации. Если у вас линия будет горизонтальной, то естественно повороты будут не корректными, вам придется отредактировать спрайт, либо сами повороты в коде.

Вывод сообщения.

Вывод сообщения, я решил реализовать через UI. Можно было бы конечно, и просто спрайтами, как все остальное, но я хотел познакомиться с UI, поэтому вот так. Итак, кликнув в иерархии ПКМ я создал канвас, назвал его GUI, настройки оставил дефолтными. Он будет содержать в себе весь мой интерфейс. Внутри него, я создал еще один канвас, так же с дефолтными настройками, и назвал его MessageCanvas. В нем я создал 3 Image’а, назвав: Win, Fail и Standoff. В поле Source Image, компонента Image, я поместил спрайты надписей о победе, проигрыше и ничьей соответственно, заготовленных в фотошопе. Также на каждый Image я повесил компонент Audio Source, с соответствующими звуками.

Далее в классе GamePlayController, я создал поля:

public Image win, fail, standoff;

*чтоб работать с Image, надо подключить неймспейс

using UnityEngine.UI;

и прилинковал в инспекторе к ним Image’ы (просто перетащив их из окна иерархии, в соответствующие поля в инспекторе). В завершение нужно в каждом Image, снять галочку рядом с компонентом Image, чтобы по умолчанию картинка с надписью не отображалась. Подготовительный этап окончен, теперь можно писать метод, активирующий ту, или иную надпись.

IEnumerator ShowMessage(string winner)
    {
        yield return new WaitForSeconds(1.0f);
 
        GameObject.Find("field_spr").GetComponent<SpriteRenderer>().enabled = false;
        Destroy(GameObject.Find("line(Clone)"));
        foreach (Transform sprite in sprites)
            sprite.GetComponent<SpriteRenderer>().sprite = null;
 
        if (winner != null)
        {   //Есть победитель
            if (GameObject.Find("Player").GetComponent<Player>().namePlayer == winner)
            {
                win.enabled = true;
                win.GetComponent<AudioSource>().Play();
            }
            else
            {
                fail.enabled = true;
                fail.GetComponent<AudioSource>().Play();
            }
        }
        else
        {   //Ничья
            standoff.enabled = true;
            standoff.GetComponent<AudioSource>().Play();
        }
    }

Метод будет запускаться в coroutine (для задержки), поэтому используем IEnumerator в сигнатуре. На вход передается строка с именем победителя, или null в случае ничьей. Делаем задержку на 1 сек. Ищем в сцене объект со спрайтом решетки (у меня он имеет имя «field_spr», и скрываем его. Удаляем со сцены линию. Далее пробегаемся по массиву с ячейками, и «обнуляем» их. Таким образом, мы полностью очистили экран от всего не нужного.

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

Теперь нам нужно добавить код в классы Player и Enemy, вызывающий проверку после хода. Начнем с Player’а. Для начала, добавим в класс два новых свойства:

public string namePlayer = "Cross";
private bool enable = true;

Если с первым все очевидно, то второе понадобится для отключения плеера, после конца игры, иначе мы сможем продолжить ставить крестики, и после вывода сообщения.

Добавляем enable в условие вызова метода ClickPlayer:

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

Сам же метод ClickPlayer модифицируем, чтоб он выглядел так:

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 && !Turn.turn && !Turn.Pause)
            {
                hitCell.GetComponent<SpriteRenderer>().sprite = cross;
                GetComponent<AudioSource>().Play();
 
                if (!GameObject.Find("GamePlay").GetComponent<GamePlayController>().CheckWin(hitCell, namePlayer))
                {
                    Turn.turn = true;
                    StartCoroutine(Turn.SetPouse());
                }
                else // Если победа, отключаем плеер
                    enable = false;
            }
        }
    }

Как видите, теперь мы передаем ход, и вызываем паузу, только в том случае, если метод CheckWin вернет ложь. В противном случае, мы отключаем Player’а.

Теперь внесем изменения в Enemy. Так же, сначала добавляем два свойства:

public string namePlayer = "Round";
bool enable = true;

после чего, добавляем enable в условие вызова метода TurnEnemy, в Udate() (аналогично Player’у).

Метод TurnEnemy модифицируем следующим образом:

void TurnEnemy()
    {
        while (enable && 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();
                Transform hitCell = freeCells[randomIndex];
 
                if (!GameObject.Find("GamePlay").GetComponent<GamePlayController>().CheckWin(hitCell, namePlayer))
                {
                    Turn.turn = false;
                    StartCoroutine(Turn.SetPouse());
                }
                else  // Если победа, отключаем ИИ
                    enable = false;
 
            freeCells.RemoveAt(randomIndex);
 
            // Отключаем ИИ если все ячейки зарисованы и при этом нет победителя т.е. ничья (иначе происходит ошибка выхода за пределы массива)
            if (freeCells.Count < 1)
            {
                enable = false;
                // Посылаем null'ы давая знать, что ничья
                GameObject.Find("GamePlay").GetComponent<GamePlayController>().CheckWin(null, null);
            }
        }
    }

Добавляем enable в условие выхода из цикла while так как, отключение ИИ будет происходить внутри цикла в случае победы/ничьей. Ничья происходит, если коллекция freeCells не имеет элементов т.е. ставить нолик больше некуда. Все остальное тут по аналогии с Player’ом, только в метод CheckWin мы передаем ячейку, выбранную рандомом, а не рейкастом.

Последний штрих.

Нашей игре, не хватает только одной функции – рестарта игры. Для этого я добавил в GUI кнопку, назвал ее ReStartButton. В поле Sourse Image я закинул спрайт кнопки, а вложенный элемент Text заменил на Image, со своим спрайтом надписи на кнопке. Почему спрайт вместо текста? Просто, чтоб сохранить стиль интерфейса, для красоты короче. Так же я повесил звук на кнопку.

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

public void Restart()
    {
        Turn.turn = false;
        GameObject.Find("field_spr").GetComponent<SpriteRenderer>().enabled = true;
        GameObject.Find("Enemy").GetComponent<Enemy>().ReInitEnemy();
        GameObject.Find("Player").GetComponent<Player>().ReinitPlayer();
 
        if (!win.enabled && !fail.enabled && !standoff.enabled)
        {
            foreach (Transform sprite in sprites)
                sprite.GetComponent<SpriteRenderer>().sprite = null;
        }
 
        win.enabled = false;
        fail.enabled = false;
        standoff.enabled = false;
    }

В методе мы просто возвращаем состояние игры в стартовое.

Передаем ход игроку. Отображаем решетку поля. Вызываем методы ReInitEnemy() и ReInitPlayer() в Enemy и Player соответственно. Реализация методов выглядит так:

public void ReInitEnemy()
    {
        InitArrayCells();
        enable = true;
    }

public void ReinitPlayer()
    {
        enable = true;
    }

Думаю, код не нуждается в объяснении.

Дальше мы проверяем, если все три сообщения скрыты, значит метод вызван в процессе игры, и мы должны очистить поле от поставленных крестиков/ноликов. Как вы помните, если игра окончится победой/поражением/ничьей, то такая очистка произойдет при выводе сообщения, и повторно очищать нет необходимости.

И наконец, скрываем все сообщения.

Осталось повесить на кнопку, обработчик событий, который будет отрабатывать при клике на кнопке, и вызывать метод рестарта. Тут все оказалось не очевидно, и пришлось лезть в мануал по юнити, и смотреть видеотутор.

В общем, я пошел таким путем. Создал скрипт ButtonScript, и повесил его на кнопку. В скрипте написал такой метод:

public void RestartButton_OnClick()
    {
        GameObject.Find("GamePlay").GetComponent<GamePlayController>().Restart();
        GetComponent<AudioSource>().Play();
    }

Теперь, чтобы этот метод отработал при клике, нужно проделать следующее:

  • Добавить на кнопку компонент Event Trigger;
  • В нем нажать кнопку Add New Event Type и выбрать PointerClick;
  • В появившейся вкладке Pointer Click нажать плюсик, и перетащить нашу кнопку из иерахии в поле;
  • В выпадающем списке (который справа) найти ButtonScript, а в нем наш метод RestartButton_OnClick().

Теперь, наша кнопка реагирует на клик.

Если у вас по какой-то причине не работает, возможно вы удалили из сцены объект EventSystem (он создается автоматически при первоначальном создании любого объекта UI, и нужен для функционирования событий в UI). Если это так, то просто пересоздайте его, ПКМ в иерархии UI> EventSystem.

Вот и все. Я еще делал простенькую анимацию для кнопки, но описывать, как я это делал не буду, так как сам разобрался поверхностно, и уже довольно смутно помню, что к чему. Если кому-то захочется анимировать кнопку, то вот вам для старта https://unity3d.com/ru/learn/tutorials/modules/beginner/ui/ui-button?playlist=17111

Скачать готовый проект юнити  можно по ссылке.

На этом все, спасибо за внимание.

КРЕСТИКИ-НОЛИКИ НА UNITY [ЧАСТЬ 2]: 5 комментариев

  1. А можешь сделать еще статью, как сделать эти крестики нолики онлайн?
    У меня стоит задача реализовать игру тетрадного типа, но примерно 50х40 клеток + куда сложнее в логике игры, чем в тик так тое.
    Я выбрал для старта именно крестики нолики, т.к. близкий и простой аналог. В целом я сам создал игру (она в разы хуже твоей, сейчас буду начинать сначала и учиться), но проблема встала именно с сетевой игрой. Я даже не понимаю с чего начать.

    Нашел скрипт, который по локальной может подключиться и он подсоединяется, и даже дублирует мои ходы, которые я делаю на сервере, но только 1 крестик и 1 нолик (короче куча багов)

    Думал сейчас разберу скрипт и буду отталкиваться от тех данных, которые там передаются и в скрипте просто создается коннект, его проверка и вывод кнопок для подключения и создания сервера. Там даже данные не передаются и я тупо не понимаю как он вообще что то отображает. Ладно я бы мог подумать, что он с камеры тупо копирует то, что видит на сервере, но он не тупо копирует то, что есть на сервере, а именно последний ход. Нарисую тут схематично, что бы было понятно про что я.

    Пустое поле
    экран сервера
    …|…|…
    …|…|…
    …|…|…

    Пустое поле
    экран того, кто присоединился
    …|…|…
    …|…|…
    …|…|…
    (такой же)

    Первых ход – сервер
    .х|…|…
    …|…|…
    …|…|…
    На клиенте то же самое.
    .х|…|…
    …|…|…
    …|…|…

    С ноликом тоже самое (второй ход)
    Сервер
    .х|…|…
    …|…|.о
    …|…|…
    Клиент
    .х|…|…
    …|…|.о
    …|…|…

    Но потом ставлю крестик и происходит то, о чем говорил
    Сервер
    .х|…|…
    …|…|.о
    …|…|.х
    Клиент
    …|…|…
    …|…|.о
    …|…|.х
    _________________
    При этом то, что я делаю на клиенте, вообще не переносится на сервер и там могу делать эти значки поверх.

    Я понимаю что я данные не где не передаю, но я вообще не понимаю как тут отображается хоть что то. Это меня в замешательство ввело.

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

    1. Здравствуй! Сразу оговорюсь, я сетевой режим не изучал, так чисто теоретически ознакомился, но практически с ним не работал. Мне кажется трабл у тебя возникает, потому что сервер не хранит информацию о предыдущих ходах. И вообще сложно так что-то подсказать, не зная как ты разделил логику, как ты синхронизируешь “поле”, и синхронизируешь ли вообще… В общем копай в этом направлении.

      По поводу стороннего скрипта. Я бы посоветовал тебе пока что, не использовать левые скрипты, по крайней мере, пока не ознакомишься со встроенным инструментарием и не въедешь в тему. В юнити уже есть всё, что необходимо (если ты делаешь на юнити конечно) – погугли на тему “Unity UNet”. По нему уже куча инфы, в том числе и на русском языке. Вот можешь начать с этого – https://youtu.be/AdfUw0Ouqyc там первый час вода и общая теория по сетям, где-то с часа начинается работа с юнетом.

      Что касается статьи от меня. Ты не первый, кто меня просишь, и она есть у меня в планах. Но это работа не на 2 дня, тут проблема даже не в том, чтоб разобраться с юнетом, а в том, чтоб переписать готовую игру под сетевой режим. Мне кажется, что проще будет вообще все с нуля написать, чем приспособить “это” под сеть… В общем, обещать не буду, но посмотрим. Возможно работа с сетевым режимом будет продемонстрирована в рамках другого проекта, если мне будет совсем уж лень переписывать крестики-нолики.

      Надеюсь смог чем-то помочь. Удачи!

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