Сапёр на HTML5 с помощью Phaser №2:
Интерактивность тайлов

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

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

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

1. Новые свойства тайла

Добавь в файл tile.js следующий код:

    this.column         = column;
    this.row            = row;
    this.x              = column * gameProperties.tileWidth;
    this.y              = row * gameProperties.tileHeight;
    
    var tile            = this;
    var currentState    = states.DEFAULT;
    var currentValue    = states.ZERO;

    var sprite = game.add.sprite
    (
        this.x,
        this.y,
        graphicAssets.tiles.name,
        currentState,
        group
    );

Обрати внимание на то, как используется ключевое слово this в строках 20-23 по сравнению со следующими строками, которые используют ключевое слово var. Разница здесь в объявлении общедоступных и закрытых элементов объекта Tile.


2. Общедоступные или публичные элементы

При объявлении свойства или функции в качестве общедоступного (публичного) элемента, он становится доступным другим объектам и функциям для получения, добавления, изменения или удаления. Существует 2 распространённых метода объявления общедоступного элемента. До сих пор использовался лишь метод, когда новые элементы добавляются через prototype объекта следующим образом:

gameState.prototype = {
    
    init: function() {

    },
    
    preload: function() {

    },

Этот вариант использовался в файле шаблона игрового проекта game.js в 1-ой части данного руководства.

Конечно, можно добавить новые элементы в prototype и чуть иначе:

gameState.prototype.init = function() {
 
};
    
gameState.prototype.preload = function() {

};

Второй метод, используемый для добавления общедоступных элементов, объявление их с помощью ключевого слова this. Так строки 20-23 в файле tile.js являются публичными элементами, которые могут быть доступны другим объектам или функциям.

Предназначение каждого общедоступного свойства:

  • this.column — столбец (вертикаль), в котором находится тайл,
  • this.row — ряд или строка (горизонталь), в которой находится тайл,
  • this.x — координата x тайла относительно объекта группы,
  • this.y — координата у тайла относительно объекта группы.

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

  • обновления нумерации тайла на основании количества мин, окружающих его,
  • выявления окружающих пустых тайлов.

3. Закрытые или приватные элементы

В отличие от других языков программирования, JavaScript не имеет private декларации. Это не означает, что нельзя скрыть свойства или держать их заключёнными внутри объекта.

Как и объявление общедоступных (публичных) элементов внутри объекта, элемент может быть объявлен как закрытый (приватный) с помощью параметров функции-конструктора:

var Tile = function(column, row, group) {

Здесь параметры: column, row, group являются закрытыми и доступны только внутри самой функции-конструктора.

Вторым способом объявления закрытого элемента (в данном случае свойства) является путь добавления ключевого слова var перед ним, как в строке 25:

    var tile = this;

Можешь также использовать третий способ — неявное объявление свойства закрытым без ключевого слова var:

    tile = this;

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

Для чего элементы объявляются закрытыми (приватными)?

  • Инкапсуляция кода для сохранности или обеспечения ограниченного доступа к элементам объекта.
  • Предотвращение переопределения свойств с одинаковыми именами и сопутствующей путаницы.
  • Облегчение в управлении кодом и его отладке. При возникновении ошибки или когда что-то перестаёт работать, легче проверить закрытые элементы из-за их инкапсулируемости внутри объекта.

4. Функции ввода с помощью мыши

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

    var sprite = game.add.sprite
    (
        this.x,
        this.y,
        graphicAssets.tiles.name,
        currentState,
        group
    );
    
    var init = function() {

    };
    
    var rollOver = function() {

    };
    
    var rollOut = function() {

    };
    
    var click = function() {

    };
    
    this.reveal = function() {

    };

Функция init (строки 38-40) будет использоваться для настройки обработчиков событий мыши:

  • появление курсора над тайлом,
  • исчезновение курсора над тайлом,
  • клик по тайлу.

Каждое из вышеуказанных событий имеет функцию с соответствующим названием (строки 42-52).

Посмотри на функцию reveal (строки 54-56), обрати внимание, она отличается от других функций следующим:

  • Она объявлена как общедоступный элемент с использованием ключевого слова this.
  • Как думаешь, зачем понадобилась отдельная функция для раскрытия тайла? Почему бы просто не поставить этот код в функцию мыши click и всё?

Чтобы понять это, рассмотрим подробнее, как раскрывается тайл.

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

Таким образом, существует 2 способа раскрытия тайлов:

  1. При нажатии на сам тайл.
  2. Пустой тайл раскрывает прилегающие к нему тайлы.

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

1) Игрок нажимает на пустой тайл. Красные стрелки показывают окружающие тайлы, которые будут раскрыты.

Сапёр: Раскрытие тайлов — шаг 1

2) Далее, будут раскрыты тайлы, которые расположены вокруг других пустых тайлов, кроме тайлов с числами.

Сапёр: Раскрытие тайлов — шаг 2

3) Предыдущий шаг повторяется.

Сапёр: Раскрытие тайлов — шаг 3

4) Процесс заканчивается, когда вокруг пустых тайлов не осталось нераскрытых тайлов.

Сапёр: Раскрытие тайлов — шаг 4

На основании приведённых выше шагов, клик был сделан по 1-му тайлу, но в итоге раскрыто 23 тайла.


5. Добавление ввода с помощью мыши

Чтобы объекты Tile могли взаимодействовать с мышью, в функцию init добавь следующий код:

    var init = function() {
        sprite.inputEnabled = true;
        sprite.input.useHandCursor = true;
        sprite.events.onInputOut.add(rollOut, this);
        sprite.events.onInputOver.add(rollOver, this);
        sprite.events.onInputDown.add(click, this);
    };

Строка 39 является первым шагом для наладки взаимодействия спрайта с мышью. Также здесь ты можешь и отключить взаимодействие с мышью, установив параметру inputEnabled значение false.

Далее, хорошим решением будет изменить курсор мыши с иконки стрелки, используемый по умолчанию, на курсор в виде руки. За это отвечает параметр useHandCursor в строке 40, и как правило, следует включать его для объектов, имеющих определённый способ взаимодействия с мышью. Это помогает улучшить пользовательский опыт на настольном компьютере, так как игрок сразу понимает, что объект является интерактивным.

В строках 41-43 добавляются обработчики событий для 3-х типов взаимодействий с мышью:

  1. onInputOut — обработчик срабатывает, когда курсор появляется над тайлом.
  2. onInputOver — обработчик срабатывает, когда курсор исчезает из области над тайлом.
  3. onInputDown — обработчик срабатывает, когда происходит клик по тайлу.

Каждое из этих событий имеет свою собственную функцию c соответствующим названием, которая вызывается, когда это событие произойдёт.

6. Появление и исчезновение курсора над тайлом

Затем в функцию rollOver добавь следующие строки:

    var rollOver = function() {
        var tween = game.add.tween(sprite);
        tween.to({x: tile.x - 3, y: tile.y - 3}, 100, Phaser.Easing.Exponential.easeOut);
        tween.start();
    };

При наведении курсора мыши на тайл, он должен двигаться немного вверх — это даст понимание игроку, что тайл выделен в данный момент.

Сапёр: Выделение тайла при наведении курсора

Для анимации движения тайла, будет использоваться Tween manager (менеджер анимации движения) фреймворка Phaser. В основном он используется для изменения одного или нескольких свойств объекта в течение определённого периода времени. В данном случае при выделении тайл будет двигаться чуть выше и левее от своего первоначального положения.

Во-первых, создай новый объект Tween (анимацию движения) используя в качестве аргумента объект sprite (строка 47). Обрати внимание, что только один объект может быть передан в качестве аргумента при создании нового объекта Tween.

Далее (строка 48) вызывается функция to. Она берёт текущие значения свойств, которые будут изменяться и обновляет их в течение определённого периода времени, пока те не достигнут конечных значений. У функции to всего 7 аргументов, которые можно использовать, здесь для достижения желаемого эффекта тебе понадобятся только первые 3.

Рассмотрим их подробнее:

  • properties — это должен быть объект, содержащий свойства анимации движения. В данном случае установлены свойства: х и у со смещением на 3 пикселя влево и 3 пикселя вверх.
  • duration — продолжительность анимации в миллисекундах. В данном случае это 100 миллисекунд (0,1 секунды).
  • ease — это должна быть вспомогательная функция для расчёта изменений. По умолчанию здесь используется функция Phaser.Easing.Default. На самом деле, это ссылка на функцию Phaser.Easing.Linear. Она последовательно (размеренно) изменяет свойства в течение времени. Вместо последовательной анимации движения, далее реализована анимация, которая быстро начинается, но замедляется к концу. Для достижения этого эффекта используется функция Phaser.Easing.Exponential.easeOut. Тип Exponential вычисляет и увеличивает изменяющиеся значения в геометрической прогрессии. Если хочешь получить обратный эффект с анимацией — медленно начать, но ускорить её к концу, используй функцию easeIn.

Далее вызови функцию start для запуска анимации (строка 49).

Для завершения реализации логики выделения тайла в функцию rollOut добавь следующие строки:

    var rollOut = function() {
        var tween = game.add.tween(sprite);
        tween.to({x: tile.x, y: tile.y}, 100, Phaser.Easing.Exponential.easeOut);
        tween.start();
    };

Функция rollOut действует аналогично функции rollOver выше и возвращает тайл в исходное положение. Единственное различие этой функции в том, что она не имеет смещений по координатам х и у.


7. Что скрывается за тайлом?

В функцию click добавь следующий код:

    var click = function() {
        tile.reveal();
    };

До поры до времени здесь будет только одна строка кода, которая вызывает функцию reveal, в ответ на клик по тайлу.

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

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

В строке 63 происходит смена кадра анимации для отображения скрытого состояния тайла. Затем для тайла отключается возможность клика по нему мышкой с помощью свойства спрайта inputEnabled со значением false (строка 64). Таким образом дальнейшее взаимодействие мыши со спрайтом предотвращено и иконка руки больше не появляется при перемещении курсора над тайлом.

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

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


8. Как разместить мины

Перед добавлением какого-либо кода, разберём, как это будет работать.

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

Начни с добавления следующего кода после функции moveTo:

    this.moveTo = function(x, y) {
        group.x = x;
        group.y = y;
    };
    
    var getRandomTile = function() {

    };
    
    var setMines = function() {

    };

9. Выбор случайного тайла

Функция getRandomTile вернёт нам случайно выбранный тайл игрового поля. Добавь следующий код в неё:

    var getRandomTile = function() {
        var randomRow = Math.floor(Math.random() * rows);
        var randomColumn = Math.floor(Math.random() * columns);

        return board[randomRow][randomColumn];
    };

В строках 27-28 выбирается случайная строка и столбец на основе размеров игрового поля, которые приходят из параметров функции-конструктора Board (строка 1).

Функция Math.random возвращает случайное число с плавающей точкой между 0 и 1. Таким образом, умножив его на значения строк и столбцов, получим подходящий диапазон (от 0 до 9).

Теперь, если оставить полученное число, как окончательный результат, это будет проблемой. Функция Math.random возвращает число с плавающей точкой и, следовательно, ты получишь примерно такие числа: 0.71864834 или 6.973395. Это не будет работать, когда дело дойдёт до выбора номера тайла в массиве board. Все элементы массива являются целыми числами, так что необходимо преобразовать эти числа с плавающей точкой в целые числа, чтобы всё заработало.

В обоих случаях используй функцию Math.floor для округления чисел с плавающей точкой в меньшую сторону. Таким образом, число 0.1288 округляется до 0 и 2.9989 до 2. В расчёте случайных чисел результат должен быть от 0 до 9, функция округления в меньшую сторону обеспечит конвертацию значений в целые числа от 0 до 8. Это то, что надо, так как первый элемент массива всегда начинается с индекса 0.

После получения случайной строки и столбца, можно получить и нужный тайл из массива board (строка 30). Результатом вызова функции getRandomTile является случайный тайл.


10. Добавление мин

Ранее было отмечено, что при размещении мин нужно будет делать проверку на существование мины в выбранном тайле. На данный момент нет никакой возможности организовать это, так как свойство currentValue в файле tile.js (строка 27) является закрытым. Вернись к файлу tile.js и обнови его.

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

Для доступности свойства тайла currentValue добавь следующие функции в код:

    this.reveal = function() {
        sprite.animations.frame = currentValue;
        sprite.inputEnabled = false;
    };
    
    this.setValue = function(value) {
        currentValue = value;
    };
    
    this.getValue = function() {
        return currentValue;
    };

Функция setValue (строка 67) позволяет изменять currentValue тайла. Позже это свойство будет использоваться для отображения правильного (скрытого) состояния кадра анимации.

В строке 71 функция getValue возвращает значение currentValue.

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

    var setMines = function() {
        var tile = getRandomTile();
        
        for (var i = 0; i < mines; i++) {
            while (tile.getValue() == tile.states.MINE) {
                tile = getRandomTile();
            }
            
            tile.setValue(tile.states.MINE);
        }
    };

В строке 35 случайно выбирается тайл из массива board.

Далее используется цикл, чтобы разместить мины. В строке 37 количество циклов зависит от приходящего параметра mines из функции-конструктора Board в строке 1.

В строке 38 проверяется содержит ли выбранный тайл мину. Если мины не существует, то цикл немедленно заканчивается без выбора другого случайного тайла. А если мина существует, то повторно выбирается другой случайный тайл (строка 39). После того, как тайл без мины выбран, цикл завершается.

Установка значения (состояния) тайла с миной происходит в строке 42.

Теперь, добавь вызов функции setMines до закрытия функции init.

            board.push(row);
        }
        
        setMines();
    };

Проверь свой код.

Работает ли он как ожидалось? Нет?

Если проверишь консоль разработчика в браузере, то увидишь следующее сообщение об ошибке:

Uncaught TypeError: Cannot read property 'ZERO' of undefined > tile.js:27

Вывод отладчиков при ошибках обычно отражает название свойства (ZERO) и его значение (undefined).

Ошибка в файле tile.js (строка 27). Посмотри, где расположено свойство ZERO и какой объект с ним связан.

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

    var sprite = game.add.sprite
    (
        this.x,
        this.y,
        graphicAssets.tiles.name,
        currentState,
        group
    );

Свойство ZERO относится к объекту tile.states. Похоже, что браузер не может найти или идентифицировать этот объект.

Есть 3 возможные причины этой ошибки:

  1. Объект не существует, потому что не был объявлен.
  2. Опечатка в имени объекта.
  3. Объект является закрытым (приватным) элементом и не может быть доступен другим элементам.

Поскольку объект states прикреплён к объекту Tile, то значит и объявлен в файле tile.js (строка 3).

var Tile = function(column, row, group) {

    var states = {
        ZERO       : 0,
        ONE        : 1,

Имя объекта является правильным, то есть не содержит опечаток. Так что остаётся только последняя причина ошибки: объект states является закрытым элементом. Ключевое слово var подтверждает это. Измени строку следующим образом:

var Tile = function(column, row, group) {

    this.states = {
        ZERO       : 0,
        ONE        : 1,

Не спеши проверять итоговый результат вновь, есть ещё одно изменение, которое надо сделать в файле tile.js. Так как доступ к объекту states изменился от закрытого к публичному, то и остальные ссылки на этот объект в пределах функции-конструктора Tile также необходимо изменить. К счастью, тут нужно обновить лишь 26-ую строку. Добавь ключевое слово this, перед объектом states следующим образом:

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

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

Чтобы найти все скрытые мины нужно немного пощёлкать мышкой, но в конечном итоге ты сможешь найти все.

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


11. Резюме

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

  1. Скрытые и публичные элементы объекта и изменение его доступности.
  2. Добавление 3-х различных событий взаимодействия с мышью: наведение, сведение и клик по тайлу.
  3. Использование функций объекта Math для генерации случайных чисел и преобразование их в удобный формат.
  4. Переключение кадра анимации спрайта.
  5. Исправление ошибки неопределённого свойства.
Заметка

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

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