Сапёр на HTML5 с помощью Phaser №3:
Нумерация тайлов

Последнее обновление: 15.05.2017 г.
Публикация: 15.05.2017 г.
Информация

Это руководство в нескольких частях описывает воссоздание классической логической игры Сапёр на HTML5 с помощью бесплатного фреймворка Phaser версии 2.4.6.

Во второй части данного руководства: Сапёр на HTML5 с помощью Phaser №2: Интерактивность тайлов был описан процесс придания тайлам интерактивности и размещения мин на игровом поле. Здесь рассматривается вопрос добавления чисел в тайлы, другими словами, их нумерация, для определения количества мин, которое их окружает и реализации раскрытия пустых тайлов.

1. Процесс нумерации тайлов

Вот как нумерация тайлов работает визуально:

1) Мина размещается случайным образом на игровом поле. Все тайлы, окружающие мину получают число.

Сапёр: Нумерация тайлов — шаг 1

2) Добавляется другая мина и окружающим её тайлам также присваиваются числа. Если тайл уже имеет число, то оно увеличивается на 1.

Сапёр: Нумерация тайлов — шаг 2

3) Этот процесс повторяется до тех пор, пока все мины не будут размещены на игровом поле.

Сапёр: Нумерация тайлов — шаг 3

2. Обновление нумерации тайлов

Теперь, когда ты понял, как работает нумерация тайлов, переходи к кодированию. В файле board.js, после функции setMines добавь функцию updateSurroundingTiles:

            tile.setValue(tile.states.MINE);
            updateSurroundingTiles(tile);
        }
    };

    var updateSurroundingTiles = function(tile) {

    };

Также сразу добавь её вызов в фунцию setMines (строка 42). Ей нужен только 1 параметр — tile. Это ссылка на тайл, который содержит в себе мину.

Затем объяви следующие переменные:

	var updateSurroundingTiles = function(tile) {
        var targetTile;
        var column;
        var row;
    };

Предназначение каждой переменной (свойства):

  • targetTile — используется для обозначения выбранного тайла,
  • column — столбец, в котором находится выбранный тайл в массиве board,
  • row — строка, в которой находится выбранный тайл в массиве board.

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

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

Сапёр: Последовательность обновления группы тайлов

Для того чтобы последовательно обратиться к окружающим тайлам, нужно вычислить положение каждого относительно тайла с миной внутри массива board. Это можно сделать с помощью следующей схемы при условии, что x и y на ней являются свойствами тайла с миной.

Сапёр: Схема определения положения окружающих тайлов

Например, мина находится в тайле со свойствами: х = 3 и у = 5. Тогда используя изображение выше, свойства верхнего левого тайла будут: х = 2 и у = 4.

Теперь посмотри, как это будет реализовано. Добавь следующий код:

        var row;

        for (var y = -1; y <= 1; y++) {
            for (var x = -1; x <= 1; x++) {

            }
        }

В строках 54-55 есть 2 цикла for, которые будут обходить каждый из окружающих тайлов согласно упомянутой ранее последовательности.

Единственная проблема здесь в том, что циклы for также обходят и центральный тайл, где размещена мина. Его нужно пропустить, когда цикл дойдёт до него, это происходит, когда оба свойства: х и у равны 0.

Добавь код, который учтёт это:

        for (var y = -1; y <= 1; y++) {
            for (var x = -1; x <= 1; x++) {
                if (!x && !y) {
                    continue;
                }
            }
        }

В строке 56 используется символ ! перед переменными: х и у. Как правило, при использовании условной инструкции, проверяется, является ли значение true. Поставив ! перед переменной или свойством, проверяется, является ли значение false.

По умолчанию, когда JavaScript проверяет значение числа и происходит логическое преобразование, то 0 в логическом контексте является false, а все остальные числа (в том числе отрицательные и числа с плавающей точкой) являются true. Так x и y здесь со значениями равными 0 будут преобразованы в false.

Также тут есть двойной амперсанд — символ &&, который является логическим оператором AND. Он проверяет оба условия: и . Когда они выполняются, оператор continue в строке 57 пропускает оставшуюся часть текущего цикла и приступает к выполнению следующей итерации, где значение х равно 1.

Затем, если мина находится на краю игрового поля, то нужно пропустить тайлы, которые находятся за его пределами. Чтобы сделать это, добавь следующий код:

                if (!x && !y) {
                    continue;
                }

                column = tile.column + x;
                row = tile.row + y;

                if (row < 0 || row >= rows || column < 0 || column >= columns) {
                    continue;
                }

Строки 60-61 указывают на столбец и строку выбранного в данный момент тайла.

Строка 63 содержит 4 условия, в которых проверяется, находится ли выбранный столбец или строка вне игрового поля. Двойная труба — символ || является логическим оператором OR. До тех пор, пока хотя бы одно из условий равно true, блок кода в строке 64 будет работать.

Теперь нужно извлечь тайл из массива board и проверить, содержит ли он мину:

                if (row < 0 || row >= rows || column < 0 || column >= columns) {
                    continue;
                }

                targetTile = board[row][column];

                if (targetTile.getValue() == tile.states.MINE) {
                    continue;
                }

В строке 67 определяется выбранный тайл.

В строке 69 идёт проверка, содержит ли тайл мину. Если да, то нужно пропустить текущую итерацию.

Наконец, после того, как прошли все проверки состояния, нужно обновить выбранный тайл и увеличить его значение на единицу. Последняя строка кода в функции updateSurroundingTiles делает это:

                if (targetTile.getValue() == tile.states.MINE) {
                    continue;
                }

                targetTile.setValue(targetTile.getValue() + 1);
            }
        }
    };

Строка 73 вызывает функцию setValue из targetTile, чтобы установить новое значение, которое является суммой текущего значения тайла и единицы. Текущее значение приходит из самого тайла с помощью функции getValue. Таким образом, число в тайле увеличивается, в зависимости от количества мин окружающих его.

Хорошо, проверь, что у тебя получилось на данный момент, результат должен быть следующим:

Нажми здесь, чтобы загрузить исходный код проекта до этого момента.

3. Раскрытие всех тайлов

Открой файл tile.js и добавь после функции getValue функцию, которая будет проверять, раскрыт ли тайл на данный момент:

    this.getValue = function() {
        return currentValue;
    };

    this.isRevealed = function() {
        return (sprite.animations.frame == currentValue);
    };

Функция isRevealed сравнивает номер текущего кадра анимации со свойством тайла currentValue. Если оба значения совпадают, значит тайл уже раскрыт.

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

Варианты, которые могут произойти при раскрытии тайла:

  1. Если тайл содержит мину, раскрывается всё игровое поле и выводится сообщение об окончании игры.
  2. Если тайл пуст, то раскрываются окружающие его тайлы.

Также нужен способ для тайла, чтобы общаться с другими объектами. Добавь ещё одно публичное свойство:

    this.x           = column * gameProperties.tileWidth;
    this.y           = row * gameProperties.tileHeight;
    this.onRevealed  = new Phaser.Signal();

    var tile         = this;
    var currentState = this.states.DEFAULT;
    var currentValue = this.states.ZERO;

В строке 24 свойство onRevealed будет содержать новый объект Phaser.Signal. Он является механизмом диспетчеризации событий, который поддерживает вещание на нескольких слушателей и транслирует им события. Слушатель — это функция, которая срабатывает только тогда, когда Signal передаёт событие.

Код добавления слушателя выглядит примерно следующим образом:

game.input.onDown.add(callback, context);

или так:

game.time.events.add(duration, callback, context);

Эти примеры выше помогут тебе получить представление, о чём идёт речь.

Фреймворк Phaser уже имеет свой собственный встроенный объект Signal для общих особенностей игры. Использование в проекте дополнительного объекта Phaser.Signal большая редкость. Эта возможность применяется лишь при создании большой игры или когда необходимо получить более точный контроль над её событиями.

Здесь создаётся собственный пользовательский объект Signal, который будет транслировать событие только тогда, когда тайл раскрывается. Вот как это делается:

    this.reveal = function() {
        sprite.animations.frame = currentValue;
        sprite.inputEnabled = false;
        tile.onRevealed.dispatch(tile);
    };

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

Но в данном случае необходимо отправить объект Tile всем слушателям.

Теперь вернись к файлу board.js.

После объявления функции updateSurroundingTiles добавь следующие строки:

    var onReveal = function(tile) {

    };

    var revealAll = function() {

    };

    init();

В строке 76 функции onReveal нужно написать код слушателя, который срабатывает, когда свойство onRevealed отправляет событие из объекта Tile. Функция-слушатель принимает 1 параметр — это объект Tile.

В строке 80 функции revealAll с помощью цикла будет происходить обход массива board и раскрытие всех тайлов.

Реализация функции-слушателя будет следующей:

    var onReveal = function(tile) {
        if (tile.getValue() == tile.states.MINE) {
            revealAll();
        }
    };

В строках 76-78 проверяется значение полученного тайла. Если оно равно значению мины, то вызывается функция revealAll для раскрытия всех тайлов.

Теперь добавь следующий код:

    var revealAll = function() {
        for (var y = 0; y < rows; y++) {
            for (var x = 0; x < columns; x++) {
                var tile = board[y][x];

                if (tile.isRevealed()) {
                    continue;
                }

                tile.onRevealed.remove(onReveal, this);
                tile.reveal();
            }
        }
    };

В строках 82-83 используется тот же код, что и в функции init при создании игрового поля с помощью массива board.

Далее, в строках 86-88, проверяется, раскрыт ли уже текущий тайл. Если это так, то нет нужды раскрывать его снова.

Затем в строке 90, необходимо удалить слушатель перед вызовом функции reveal объекта Tile. Эта строка имеет решающее значение. Её отсутствие приведёт игру к сбою, так как каждый тайл будет с помощью свойства onRevealed отправлять событие слушателю вновь и игра войдёт в бесконечный цикл.

Теперь в верхней части файла board.js добавь следующую строку:

    for (var x = 0; x < columns; x++) {
        var tile = new Tile(x, y, group);
        tile.onRevealed.add(onReveal, this);
        row.push(tile);
    }

Здесь задаётся слушатель onReveal на Signal событие.

Воссоздание игры продолжается и сейчас результат должен быть следующим:

Нажми здесь, чтобы загрузить исходный код проекта до этого момента.


4. Раскрытие пустых тайлов

Теперь в игре надо реализовать следующие возможности: раскрытие всех тайлов на игровом поле и раскрытие только пустых тайлов. При нажатии на пустой тайл нужно раскрывать все окружающие тайлы. Если окружающий тайл тоже пуст, то необходимо будет раскрыть и тайлы, расположенные вокруг него. Этот процесс продолжается до тех пор, пока не будут обнаружены пронумерованные тайлы или не будет достигнут край игрового поля.

В файле board.js добавь ещё 2 функции:

    var getSurroundingTiles = function(tile) {

    };

    var revealEmptyTiles = function(tile) {

    };

    init();

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

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

Функция getSurroundingTiles будет использоваться для получения всех окружающих тайлов. Вот код, который необходимо добавить:

    var getSurroundingTiles = function(tile) {
        var tileList = [];
        var targetTile;
        var column;
        var row;

        for (var y = -1; y <= 1; y++) {
            for (var x = -1; x <= 1; x++) {
                if (!x && !y) {
                    continue;
                }

                column = tile.column + x;
                row = tile.row + y;

                if (row < 0 || row >= rows || column < 0 || column >= columns) {
                    continue;
                }

                targetTile = board[row][column];
                tileList.push(targetTile);
            }
        }

        return tileList;
    };

Сравни строки 98-121 со строками 48-73 в функции updateSurroundingTiles — они практически идентичны.

Единственное отличие в том, что все окружающие тайлы будут помещены в массив с именем tileList, который затем и возвращает функция getSurroundingTiles.

Обнови функцию updateSurroundingTiles, чтобы она использовала функцию getSurroundingTiles.

    var updateSurroundingTiles = function(tile) {
        var targetTile;
        var surroundingTiles = getSurroundingTiles(tile);

        for (var i = 0; i < surroundingTiles.length; i++) {
            targetTile = surroundingTiles[i];

            if (targetTile.getValue() == tile.states.MINE) {
                continue;
            }

            targetTile.setValue(targetTile.getValue() + 1);
        }
    };

Единственное, что осталось от прежней версии кода, это переменная targetTile в строке 48 и код в строках 53-59. Остальная часть кода была удалена и заменена строками 49-52.

В строке 49 вызывается функция getSurroundingTiles для получения списка окружающих тайлов. Затем, в строке 51, выполняется обход полученного списка с помощью цикла for. Тайлы с минами пропускаются, а остальные получают или обновляют свои номера.

Прежде чем двигаться дальше, проверь свою игру, работает ли она на данный момент?

Теперь посмотри на следующую блок-схему:

Сапёр: Блок-схема функции revealEmptyTiles

Она описывает алгоритм работы функции revealEmptyTiles:

  1. Клик на тайле — это отправная точка. Указанный тайл помещается в список тайлов tileList.
  2. Запускается основной рекурсивный цикл и работает до тех пор, пока в списке тайлов есть хоть один элемент.
  3. В первом шаге цикла берётся первый элемент из списка тайлов и для него вычисляется список окружающих тайлов.
  4. Запускается второй рекурсивный цикл и работает до тех пор, пока в surroundingTiles есть хоть один элемент.
  5. В первом шаге цикла перемещается первый элемент из списка окружающих тайлов в переменную currentTile.
  6. Если текущий тайл уже раскрыт, то происходит переход к следующей итерации цикла, иначе раскрытие тайла.
  7. Если текущий тайл пуст, то он добавляется в список тайлов tileList.
  8. Удаляется первый элемент из списка тайлов.

Затем весь процесс повторяется до тех пор, пока существует хоть один элемент в списке тайлов.

Теперь добавь код, который реализует указанный алгоритм:

    var revealEmptyTiles = function(tile) {
        var tileList = [tile];
        var surroundingTiles;
        var currentTile;

В строке 111 создаётся массив tileList и в него сохраняется тайл, который является аргументом текущей функции. Потом создаются ещё 2 переменные:

  • surroundingTiles — массив, содержащий список окружающих тайлов,
  • currentTile — текущий выбранный тайл в цикле.

Далее, добавь первый цикл:

        var currentTile;

        while (tileList.length) {
            currentTile = tileList[0];
            surroundingTiles = getSurroundingTiles(currentTile);
        }

В строке 116 выбирается первый элемент из массива tileList. Затем, в строке 117, вычисляются тайлы, окружающие его, а после добавляется второй цикл:

        while (tileList.length) {
            currentTile = tileList[0];
            surroundingTiles = getSurroundingTiles(currentTile);

            while (surroundingTiles.length) {
                currentTile = surroundingTiles.shift();
            }
        }

В строке 120, функция смещения удаляет первый элемент из массива surroundingTiles и присваивает его переменной currentTile. Далее вся работа идёт с ней:

            while (surroundingTiles.length) {
                currentTile = surroundingTiles.shift();

                if (currentTile.isRevealed()) {
                    continue;
                }

                currentTile.onRevealed.remove(onReveal, this);
                currentTile.reveal();

                if (currentTile.getValue() == currentTile.states.ZERO) {
                    tileList.push(currentTile);
                }
            }

            tileList.shift();
        }
    };

В строке 122 проверяется является ли currentTile раскрытым тайлом. Если да, то цикл пропускает текущую итерацию.

Если currentTile ещё не раскрыт, то в строках 126-127, удаляется слушатель onReveal объекта Phaser.Signal и затем осуществляется раскрытие тайла.

В строке 129, проверяется является ли currentTile пустым тайлом. Только пустые тайлы добавляются в массив tileList.

В строке 134, удаляется первый элемент из массива tileList.

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

Также нужно не забыть обновить реализацию функции-слушателя. Код будет следующий:

    var onReveal = function(tile) {
        switch (tile.getValue()) {
            case tile.states.ZERO:
                revealEmptyTiles(tile);
                break;

            case tile.states.MINE:
                revealAll();
                break;
        }
    };

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

Нажми здесь, чтобы загрузить исходный код проекта до этого момента.


5. Резюме

В этой части руководства были исследованы следующие вопросы:

  1. Выбор и обновление нескольких объектов на игровом поле.
  2. Создание и использование пользовательского объекта Signal. Его полезно использовать в проектах для получения большего контроля над событиями или когда фреймворк не имеет встроенных событий для чего-либо.
  3. Незначительный рефакторинг — реструктуризация существующего кода с целью повторного использования.

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

Заметка

О маркировке тайлов, пользовательском интерфейсе и о реализации кнопки повтора ты можешь узнать из четвёртой части данного руководства: Сапёр на HTML5 с помощью Phaser №4: Маркировка тайлов и пользовательский интерфейс.

Контрольные вопросы
Источники и дополнительные материалы