Пишем Сапёр на Unity. Обработка конца игры

В заключительной части нашей серии мы будем доводить уже почти готовую игру до ума. К концу этого урока ваш Сапёр будет вполне играбельным: игрок сможет и проиграть уровень, и выиграть его.

По результатам прошлых двух частей мы имеем само клеточное поле со случайно расположенными минами в нем. Более того, мы добавили эффект подсвечивания клетки, когда игрок проводит мышью над объектом. И, разумеется, можно ставить и убирать флажки.

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

  1. Пишем Сапёр на Unity. Настройка.
  2. Пишем Сапёр на Unity. Взаимодействие.
  3. Пишем Сапёр на Unity. Обработка конца игры.

Раскрытие одной клетки

Мы уже добавили возможность размещать флаги на клетки при нажатии на правую кнопку мыши. Теперь добавим в ту же функцию OnMouseOver() обработку нажатия левой кнопки:

function OnMouseOver()
{
    if(state == "idle")
    {
        renderer.material = materialLightup;
     
        if (Input.GetMouseButtonDown(0))
            UncoverTile();
     
        if (Input.GetMouseButtonDown(1))
            SetFlag();
    }
    else if(state == "flagged")
    {
        renderer.material = materialLightup;
     
        if (Input.GetMouseButtonDown(1))
            SetFlag();
    }
}

При нажатии на правую кнопку мыши будет вызван метод UncoverTile(). Давайте определим эту функцию:

function UncoverTile()
{
    if(!isMined)
    {
        state = "uncovered";
        displayText.renderer.enabled = true;
        renderer.material = materialUncovered;
    }
    else
        Explode();
}

Как вы видите, нам требуется еще один материал для клетки, ответственный за раскрытую клетку. Объявим переменную:

public var materialUncovered: Material;

Создайте еще один материал, но другого цвета, чтобы игрок мог отличить раскрытые клетки от нераскрытых. Мы будем использовать зеленый цвет, а вы можете выбрать любой другой (кроме красного, поскольку он будет отвечать за клетку с миной).

Когда мы вызываем метод UncoverTile(), происходит следующее:

  1. Проверяется, не скрыта ли мина в клетке, на которую нажали.
  2. Если нет, то состояние клетки переводится в uncovered, отображается текст, показывающий количество мин вокруг, и задается новый материал.
  3. Впоследствии мы не сможем нажать на раскрытую клетку. Также она не будет подсвечиваться.

Прежде чем проверить работу метода, нам понадобится немного изменить функцию OnMouseExit(), поскольку переводить на изначальный материал нужно клетки с состояниями idle и flagged:

function OnMouseExit()
{
    if(state == "idle" || state == "flagged")
        renderer.material = materialIdle;
}

Таким образом, цвет клетки вернется в исходное состояние только в том случае, если клетка осталась нераскрытой.

Нажмите кнопку Play и проверьте работоспособность написанного нами кода. Кстати, клетки с минами пока не раскроются.

ms_03_01

Раскрытие всех безопасных клеток

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

Рассмотрим это игровое поле:

diagram_01

На самом деле в процессе игры вы не будете видеть ни числа, ни мины. Поле будет выглядеть вот так:

diagram_02

Когда вы кликнете на клетку, количество мин вокруг которой равно нулю, все соседние клетки тоже раскрываются.

diagram_03

Эти новые клетки снова должны раскрыть своих соседей, если число мин вокруг них равно нулю.

diagram_04

Это все происходит до тех пор, пока мы не встретим клетки с ненулевыми количествами мин.

diagram_05

Итак, чтобы реализовать все это, нам потребуется еще две функции: UncoverAdjacentTiles() и UncoverTileExternal().

private function UncoverAdjacentTiles()
{
    for(var currentTile: Tile in adjacentTiles)
    {
        //раскрываем всех безопасных соседей (количество мин вокруг которых равно 0)
        if(!currentTile.isMined && currentTile.state == "idle" && currentTile.adjacentMines == 0)
            currentTile.UncoverTile();
         
        //раскрываем всех небезопасных соседей и останавливаемся
        else if(!currentTile.isMined && currentTile.state == "idle" && currentTile.adjacentMines > 0)
            currentTile.UncoverTileExternal();
    }
}
 
public function UncoverTileExternal()
{
    state = "uncovered";
    displayText.renderer.enabled = true;
    renderer.material = materialUncovered;
}

И немного изменим метод UncoverTile():

function UncoverTile()
{
    if(!isMined)
    {
        state = "uncovered";
        displayText.renderer.enabled = true;
        renderer.material = materialUncovered;
         
        if(adjacentMines == 0)
            UncoverAdjacentTiles();
    }
}

Когда мы раскрываем клетку и мин рядом нет, то мы вызываем функцию UncoverAdjacentTiles(), которая проверяет каждого соседа. Если сосед безопасный, то клетка раскрывается и инициирует новый ряд проверок уже его соседей. Если небезопасный — раскрываем его самого и останавливаемся.

А теперь попробуйте. Чтобы получить достаточно хороший шанс на появление большого пустого пространства, достаточно создать поле 9×9 с 10-ю минами. Но помните, мины еще никак не отображаются, и при клике на клетку с миной ничего не произойдет.

ms_03_02

Обнаружение мин

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

public var materialDetonated: Material;

А также добавим пару функций для обработки нажатия на мину:

function Explode()
{
    state = "detonated";
    renderer.material = materialDetonated;
     
    for (var currentTile: Tile in Grid.tilesMined)
        currentTile.ExplodeExternal();
}
 
function ExplodeExternal()
{
    state = "detonated";
    renderer.material = materialDetonated;
}

Мы используем эти методы в функции UncoverTile():

function UncoverTile()
{
    if(!isMined)
    {
        state = "uncovered";
        displayText.renderer.enabled = true;
        renderer.material = materialUncovered;
         
        if(adjacentMines == 0)
            UncoverAdjacentTiles();
    }
    else
        Explode();
}

И все! Если нажатая клетка содержала мину, то она меняет свой материал и свое состояние, а затем отправляет всем остальным клеткам с минами команду раскрыться.

ms_03_03

Обработка победы

Игрок выиграет партию, если все клетки с минами будут отмечены правильно. Но если к этому моменту есть другие нераскрытые клетки (которые, разумеется, не будут содержать никаких мин), их следует открыть. Как мы будем отслеживать это?

Давайте добавим в сценарий Grid новую переменную state, которая и будет нам давать информацию о том, в каком состоянии находится игра.

static var state: String = "inGame";

Раз уж мы создали переменную состояния игры, то давайте выведем ее игроку — сделаем простой графический интерфейс. В Unity это делается очень просто — достаточно определить метод OnGUI:

function OnGUI()
{
    GUI.Box(Rect(10,10,100,50), state);
}

Эта информация укажет нам, в каком состоянии мы находимся (inGame, gameOver, gameWon).

Также мы можем изменить методы OnMouseOver() и OnMouseExit() для того, чтобы давать возможность игроку управлять игрой только тогда, когда он в состоянии inGame:

function OnMouseOver()
{
    if(Grid.state == "inGame")
    {
        if(state == "idle")
        {
            renderer.material = materialLightup;
             
            if (Input.GetMouseButtonDown(0))
                UncoverTile();
             
            if (Input.GetMouseButtonDown(1))
                SetFlag();
        }
        else if(state == "flagged")
        {
            renderer.material = materialLightup;
         
            if (Input.GetMouseButtonDown(1))
                SetFlag();
        }
    }
}
 
function OnMouseExit()
{
    if(Grid.state == "inGame")
    {
        if(state == "idle" || state == "flagged")
            renderer.material = materialIdle;
    }
}

Есть два способа определения победы:

  1. проверить количество правильно отмеченных мин;
  2. проверить количество раскрытых клеток без мин.

В любом случае нам понадобятся (в сценарии Grid) следующие переменные:

static var minesMarkedCorrectly: int = 0;
static var tilesUncovered: int = 0;
static var minesRemaining: int = 0;

Не забудем инициализировать их в методе Start():

function Start()
{
    CreateTiles();
     
    minesRemaining = numberOfMines;
    minesMarkedCorrectly = 0;
    tilesUncovered = 0;
     
    state = "inGame";
}

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

В методе Update() (который вызывается каждый кадр игры) мы будем проверять условие победы:

function Update()
{
    if(state == "inGame")
    {
        if((minesRemaining == 0 && minesMarkedCorrectly == numberOfMines) || (tilesUncovered == numberOfTiles - numberOfMines))
            FinishGame();
    }
}

Мы закончим игру, задав переменной state значение gameWon, раскрыв все нераскрытые клетки и отметив все неотмеченные мины.

function FinishGame()
{
    state = "gameWon";
     
    //раскрываем все нераскрытые клетки
    for(var currentTile: Tile in tilesAll)     
        if(currentTile.state == "idle" && !currentTile.isMined)
            currentTile.UncoverTileExternal();
     
    //отмечаем все оставшиеся мины, если все клетки без мин уже раскрыты
    for(var currentTile: Tile in Grid.tilesMined)    
        if(currentTile.state != "flagged")
            currentTile.SetFlag();
}

При проверке победы в методе Update() мы использовали переменную tilesUncovered, которую только задали, но нигде не меняли. На самом деле каждый раз, когда игрок правильно раскрывает клетку, значение этой переменной следует увеличить на единицу. Как в методе UncoverTile()

function UncoverTile()
{
    if(!isMined)
    {
        state = "uncovered";
        displayText.renderer.enabled = true;
        renderer.material = materialUncovered;
         
        Grid.tilesUncovered += 1;
         
        if(adjacentMines == 0)
            UncoverAdjacentTiles();
    }
    else
        Explode();
}

… так и в методе UncoverTileExternal():

function UncoverTileExternal()
{
    state = "uncovered";
    displayText.renderer.enabled = true;
    renderer.material = materialUncovered;
    Grid.tilesUncovered += 1;
}

А также не забудем изменять значения переменных minesMarkedCorrectly и minesRemaining в методе SetFlag():

function SetFlag()
{
    if(state == "idle")
    {
        state = "flagged";
        displayFlag.renderer.enabled = true;
        Grid.minesRemaining -= 1;
        if(isMined)
            Grid.minesMarkedCorrectly += 1;
    }
    else if(state == "flagged")
    {
        state = "idle";
        displayFlag.renderer.enabled = false;
        Grid.minesRemaining += 1;
        if(isMined)
            Grid.minesMarkedCorrectly -= 1;
    }
}

Обработка проигрыша

А здесь уже все просто. Игрок проиграет, как только раскроет клетку с миной. А за это отвечает метод Explode(). Осталось добавить туда одну строчку:

Grid.state = "gameOver";

После этого все клетки станут не кликабельными, что и требуется.

Улучшенный интерфейс

Чуть выше мы использовали встроенный в Unity графический интерфейс для того, чтобы выводить некоторую полезную информацию. Давайте обновим метод OnGUI(), чтобы выводимые данные были более информативными.

function OnGUI()
{
    if(state == "inGame")
    {
    }
    else if(state == "gameOver")
    {
     
    }
    else if(state == "gameWon")
    {
     
    }
}

В зависимости от состояния игры мы будем показывать различную информацию. Например, выводить «You lose!», когда игрок проиграет.

function OnGUI()
{
    if(state == "inGame")
    {
    }
    else if(state == "gameOver")
    {
        GUI.Box(Rect(10,10,200,50), "You lose");
    }
    else if(state == "gameWon")
    {
        GUI.Box(Rect(10,10,200,50), "You rock!");
    }
}

Во время игры будет очень актуальной информация о том, сколько еще мин нужно отметить. А также добавим кнопку перезапуска уровня.

function OnGUI()
{
    if(state == "inGame")
    {
        GUI.Box(Rect(10,10,200,50), "Mines left: " + minesRemaining);
    }
    else if(state == "gameOver")
    {
        GUI.Box(Rect(10,10,200,50), "You lose");
     
        if(GUI.Button(Rect(10,70,200,50), "Restart"))
            Restart();
    }
    else if(state == "gameWon")
    {
        GUI.Box(Rect(10,10,200,50), "You rock!");
     
        if(GUI.Button(Rect(10,70,200,50), "Restart"))
            Restart();
    }
}
 
function Restart()
{
    state = "loading";
    Application.LoadLevel(Application.loadedLevel);
}

И на этом все. В окончательную игру вы сможете поиграть здесь.

Вывод

Итак, мы создали простую игру-головоломку на Unity. Но самое главное в том, что вы можете использовать наработки этой серии уроков и написать что-то свое, например, морской бой. А почему бы и нет?

Перевод статьи «Build a Grid-Based Puzzle Game Like Minesweeper in Unity: Winning»