Сапёр на HTML5 с помощью Phaser №1:
Обзор проекта и его настройка

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

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

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

Phaser — это фреймворк для разработки мобильных и десктопных HTML5 игр, базирующийся на библиотеке PIXI.js. Поддерживает рендеринг в Canvas и WebGL, анимированные спрайты, частицы, аудио, разные способы ввода и физику объектов. Phaser также позволяет использовать для разработки язык программирования TypeScript. Распространяется с открытым исходным кодом по лицензии MIT. Создан Ричардом Дейви.

1. Обзор игрового процесса

Моё первое знакомство с игрой Сапёр произошло ещё во времена Windows 3.1, тогда она выглядела примерно так:

Сапёр: Вид игры под Windows 3.1

Скриншот выше содержит игровое поле размером для начинающих. Это 8x8 тайлов и 10 мин. Существует также игровое поле средней сложности: 16x16 тайлов и 40 мин и поле для экспертов: 30x16 тайлов и 99 мин. Каждый раз, когда начинается игра, генерируется новое поле и мины располагаются случайным образом.

Заметка

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

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

Сапёр: Часть игрового поля

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

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

Так что же означают цифры? Посмотри на скриншот выше и обрати внимание на уже открытые тайлы.

Цифры показывают, сколько мин фактически соприкасается с пронумерованным тайлом. Посмотри на скриншот ниже:

Сапёр: Принцип нумерации тайлов

Обрати внимание на выделенный тайл с номером 3. Это число указывает на то, что существует ровно 3 мины вокруг этого тайла. Я также пометил, где расположены эти 3 мины. Тайл рядом с выделенным даёт дополнительные подсказки и говорит, что рядом с ним находятся лишь 2 мины.

Далее обрати внимание на выделенный тайл с номером 1. Известно, что рядом с ним может быть лишь 1 мина, как ты думаешь, где она находится?

Вот ответ:

Сапёр: Принцип расположения мин

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

Хорошо, вот так вкратце работает механика нумерации в этой игре.


2. Обзор руководства

Ниже представлено краткое содержание данного руководства:

  1. Настройка проекта.
  2. Создание игрового поля.
  3. Размещение мин.
  4. Нумерация тайлов.
  5. Создание кликабельных тайлов.
  6. Раскрытие пустых тайлов.
  7. Победа и поражение.
  8. Пользовательский интерфейс.

3. Файлы проекта

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

  • index.html — HTML-страница с элементом Canvas (холст), в котором будет отображаться игра,
  • /js/phaser.min.js — фреймворк Phaser (версия 2.4.4),
  • /js/game.js — исходники игры,
  • /assets/tiles.png — ресурсы игры.

Ниже представлен листинг кода файла game.js — базового шаблона игрового проекта.

var gameProperties = {
    screenWidth: 640,
    screenHeight: 480
};

var states = {
    game: 'game'
};

var gameState = function(game) {
    
};

gameState.prototype = {
    
    preload: function() {
        
    },
    
    create: function() {
        
    },

    update: function() {
        
    }
};

var game = new Phaser.Game
(
    gameProperties.screenWidth,
    gameProperties.screenHeight,
    Phaser.AUTO,
    'wrapper'
);
game.state.add(states.game, gameState);
game.state.start(states.game);
Заметка

Если ты не знаешь, как создать и настроить проект для Phaser, ознакомься с этим кратким руководством: Шаблон игрового проекта для фреймворка Phaser на HTML5.


4. Особенности игры

Перед погружением в код давай рассмотрим функции, которые должны быть в игре:

  1. Создание нового игрового поля.
  2. Случайное распределение мин.
  3. Добавление номеров к окружающим мины тайлам.
  4. Щелчок левой кнопкой мыши для открытия тайла.
  5. Раскрытие нескольких тайлов, если выбранный тайл нажат и окружающие тайлы пусты.
  6. Щелчок правой кнопкой мыши для пометки тайла флагом и его снятие при повторном клике.
  7. Проверка раскрытия всех тайлов для определения факта победы.
  8. Отображение всех скрытых мин, когда игра заканчивается.
  9. Реализация таймера для определения, сколько прошло времени с начала игры.
  10. Счётчик мин для учёта помеченных мин.

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


5. Определение свойств игры

Открой файл game.js.

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

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

var gameProperties = {
    screenWidth: 640,
    screenHeight: 480,

    tileWidth: 32,
    tileHeight: 32,

    boardWidth: 9,
    boardHeight: 9,

    totalMines: 10
};

На данный момент существует 4 набора свойств:

  • ширина и высота экрана,
  • ширина и высота тайла,
  • ширина и высота игрового поля,
  • количество мин.

6. Загрузка ресурсов

После объекта states (состояния) объяви новый объект под названием graphicsAssets (графические ресурсы) и добавь следующий код:

var states = {
    game: 'game'
};

var graphicAssets = {
    tiles: {URL: 'assets/tiles.png', name: 'tiles', frames: 14}
};

Обрати внимание, есть только один графический ресурс для всей игры, я объединил всё в одном спрайт-листе. Это выглядит следующим образом:

Сапёр: Графический ресурс для всей игры
Заметка

Спрайт-лист состоит из кадров, которые также можно назвать тайлами, так как в дальнейшем именно из них формируется игровое поле. Тайлы — небольшие изображения одинаковых размеров, которые и служат фрагментами большой картины. Подробнее о тайловой графике.

Предназначение тайлов:

  • С белым фоном — это раскрывающийся тайл с числом или без него. Тайл с числом будет использоваться только тогда, когда открытый тайл соприкасается с миной.
  • С голубым фоном — будет отображаться по умолчанию в каждом тайле в начале игры.
  • С жёлтым фоном — будет использоваться в 2-х случаях: для пометки тайла флагом с возможной миной или если допущена ошибка при пометке тайла флагом, а позже игрок нажмёт на тайл с миной, то такой тайл будет раскрыт.
  • С зелёным фоном — будет использоваться для пометки тайла, содержимое которого игроку неизвестно, с помощью щелчка правой кнопки мыши на тайле помеченным флагом.
  • С красным фоном — будет отображаться только тогда, когда был щелчок по тайлу с миной.

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

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

Сапёр: Нумерация кадров на спрайт-листе

Обрати внимание на то, что нумерация кадров начинается с 0 и всего получается 14 кадров, последний кадр будет под номером 13.

Теперь необходимо организовать предзагрузку тайловой графики. Добавь в функцию preload (предварительная загрузка) следующий код:

    preload: function() {
        game.load.spritesheet
        (
            graphicAssets.tiles.name,
            graphicAssets.tiles.URL,
            gameProperties.tileWidth,
            gameProperties.tileHeight,
            graphicAssets.tiles.frames
        );
    }

Для загрузки спрайт-листа, основанного на ресурсе с тайлами нужно 5 аргументов:

  • key — уникальный ключ ресурса или имя для манипуляции этим графическим объектом в дальнейшем,
  • url — url файла,
  • frameWidth — ширина кадра в пикселях,
  • frameHeight — высота одного кадра в спрайт-листе в пикселях,
  • frameMax — количество кадров в спрайт-листе (необязательный аргумент).
Заметка

Здесь используется аргумент frameMax, так как нужно только 14 кадров, а не всё изображение. Если бы использовалось всё изображение целиком — 15 кадров, то можно было бы не указывать последний аргумент.

Теперь, когда настроена основа игрового проекта, необходимо создать игровое поле.

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

7. Настройка игрового поля

Во-первых, добавь к объекту gameState следующий код:

var gameState = function(game) {
    this.boardTop;
    this.boardLeft;
    this.board;
};

Затем добавь ещё одну зарезервированную функцию, называемую init к нашему прототипу gameState, которая будет исполняться каждый раз, когда запускается игровое состояние gameState. Функция инициализации в основном используется для сброса всех переменных и запускается непосредственно перед вызовом функции create.

gameState.prototype = {

    init: function() {

    },

    preload: function() {

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

    init: function() {
        var boardHeight = gameProperties.tileHeight * gameProperties.boardHeight;
        this.boardTop = (gameProperties.screenHeight - (boardHeight)) * 0.5;
        var boardWidth = gameProperties.tileWidth * gameProperties.boardWidth;
        this.boardLeft = (gameProperties.screenWidth - (boardWidth)) * 0.5;
    },

Этот код гарантирует позиционирование игрового поля в центре игрового мира.

Вот как работает расчёт выше:

1. Вычисление разницы между шириной и высотой игрового мира (экрана игры) и игрового поля.

Сапёр: Разница размеров экрана и поля игры

2. Перемещение игрового поля вправо на половину разницы ширин и перемещение вниз на половину разницы высот.

Сапёр: Смещение поля игры относительно экрана

Теперь игровое поле отцентрировано по отношению к игровому миру.


8. Дополнительные JS файлы

Для удобства разработки и управления кодом лучше разделять его на отдельные файлы. Создай 2 новых файла: tile.js и board.js. Оба файла необходимо поместить в папку js. Файл tile.js будет содержать весь код для работы с объектом тайла, а файл board.js будет использоваться для управления игровым полем.

Во-первых, добавь в файл index.html следующие строки:

        <script src="js/phaser.min.js"></script>
        <script src="js/game.js"></script>
        <script src="js/tile.js"></script>
        <script src="js/board.js"></script>

Необходимо подключить оба новых файла, так основной файл game.js получит доступ к исходному коду тайла и игрового поля. Обрати внимание, что порядок подключения файлов не имеет значения, так как JavaScript сначала загружает все файлы перед выполнением какого-либо кода.


9. Файл tile.js

Открой файл tile.js. Во-первых, объяви функцию под названием Tile.

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

};

Обрати внимание на несколько вещей в объявлении этой функции:

  1. Идентификатор или имя начинается с буквы T в верхнем регистре. Такой приём используется для идентификации этой функции в качестве конструктора для дальнейшего вызова с ключевым словом new. Эта функция будет использоваться для создания объекта тайла.
  2. Параметр column будет использоваться для размещения тайла вдоль горизонтальной оси X.
  3. Параметр row будет использоваться для позиционирования тайла вдоль вертикальной оси Y.
  4. Параметр group будет использоваться, чтобы поместить каждый новый тайл в группу, которой можно управлять, то есть управлять одновременно несколькими тайлами.

Теперь добавь объект под названием states (состояния), здесь будут перечислены все возможные состояния тайла.

var Tile = function(column, row, group) {
    
    var states = {
        ZERO       : 0,
        ONE        : 1,
        TWO        : 2,
        THREE      : 3,
        FOUR       : 4,
        FIVE       : 5,
        SIX        : 6,
        SEVEN      : 7,
        EIGHT      : 8,
        DEFAULT    : 9,
        FLAG       : 10,
        WRONG_FLAG : 11,
        UNKNOWN    : 12,
        MINE       : 13
    };

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

Добавь следующий код сразу после объекта states:

        MINE: 13,
    };

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

Эти свойства будут контролировать 2 вещи:

  • Текущее состояние тайла.
  • Значение (состояние), которое получит тайл после своего раскрытия. Это свойство будет сравниваться со свойством currentState для определения корректности отображаемого кадра.

И наконец, нужно добавить спрайт к объекту тайла, который и будет отображать текущий кадр.

    var currentState = states.DEFAULT;
    var currentValue = states.ZERO;
    
    var sprite = game.add.sprite
    (
        column * gameProperties.tileWidth,
        row * gameProperties.tileHeight,
        graphicAssets.tiles.name,
        currentState,
        group
    );

Для создания нового объекта Sprite нужно 5 аргументов:

  • x — координата спрайта по оси X относительно расположения объекта группы спрайта, она вычисляется с помощью аргумента column из функции-конструктора Tile,
  • y — координата спрайта по оси Y относительно расположения объекта группы спрайта, она вычисляется с помощью аргумента column из функции-конструктора Tile,
  • key — уникальный ключ или имя, используемое в качестве текстуры этого спрайта объекта,
  • frame — используется для указания отображаемого кадра из спрайт-листа,
  • group — группа, к которой следует добавить спрайт. Объект группы берётся из функции-конструктора Tile.

10. Файл board.js

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

var Board = function(columns, rows) {
    
};

Создай функцию-конструктор под названием Board, предназначенную для управления игровым полем. Полная аналогия с функцией-конструктором Tile, только здесь 2 аргумента:

  • columns — используется для определения количества тайлов по всей ширине поля,
  • row — используется для определения количества тайлов по всей высоте поля.

Затем объяви следующие свойства:

var Board = function(columns, rows) {
    
    var board = [];
    var group = game.add.group();

Свойство board есть массив, который будет использоваться для хранения списка всех индивидуальных объектов Tile. Массив — это простой список, который может содержать любое количество элементов.

Кроме того, ты можешь создать новый массив таким образом:

    var board = new Array();

Способ создания массива не имеет никакого значения.

Далее объяви свойство group (строка 4), которое будет использоваться для отображения коллекции спрайтов тайлов.

Теперь, чтобы заполнить игровое поле объектами Tile добавь следующий код:

    var board = [];
    var group = game.add.group();

    for (var y = 0; y < rows; y++) {
        var row = [];

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

В строке 6 начинается первый цикл for. Для подсчёта количества рядов (строк) игрового поля используется переменная y и x для подсчёта столбцов. Начиная с 0, свойство y будет увеличиваться на 1, после каждого завершённого цикла. Это будет повторяться до тех пор, пока значение y будет меньше, чем значение параметра rows. Так если значение rows равно 9, то цикл будет повторяться 9 раз — свойство y будет начинаться с 0 и заканчиваться на 8-ми.

В первой строке цикла (строка 7) объявлена переменная row и ей присвоен новый массив.

Затем добавляется второй цикл (строка 9), который будет использоваться для создания индивидуальных объектов Tile.

В 10-ой строке как раз и происходит создание новых объектов Tile. Обрати внимание на 3 аргумента используемых соответственно в функции-конструкторе Tile:

  • x — представляет собой столбец,
  • y — представляет собой ряд (строку),
  • group — представляет собой группу, к которой относится спрайт тайла.

После своего создания новый объект Tile добавляется в массив row (строка 11). После того, как весь ряд тайлов создаётся, цикл завершается и происходит добавление row к объекту игрового поля board.

Когда оба цикла будут выполнены, массив board будет выглядеть следующим образом:

var board = [
    [Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile],
    [Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile],
    [Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile],
    [Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile],
    [Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile],
    [Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile],
    [Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile],
    [Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile],
    [Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile, Tile]
];

А так он будет выглядеть визуально:

Сапёр: Визуальный вид массива игрового поля

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

Поскольку массивы используют индекс на нулевой основе, то первая позиция элемента массива имеет индекс 0.

Смотри, согласно скрину выше:

  • первый индекс ряда (строки) равен 0,
  • 5-ый ряд (строка) имеет индекс 4,
  • колонка 3 имеет индекс 2 и так далее.

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

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

Некоторые игры для примера, которые используют это:

В данном проекте игровое поле, создано начиная с рядов, а не со столбцов. Допустим, необходимо обратиться к объекту Tile в 5-ой колонке и 3-ем ряду. Массив состоит из строк, не столбцов и каждый массив строк состоит из столбцов. Таким образом, столбцы на самом деле в строке и в пределах подмассива каждой строки.

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

Поскольку массивы используют индекс с отчётом от нуля, строка 3 имеет индекс 2, а столбец 5 имеет индекс 4. Поэтому, обращение к правильному Tile будет выглядеть так:

var tile = board[2][4];

Выбранный тайл выделен зелёным цветом:

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

11. Отображение игрового поля

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

    update: function() {
        
    },
    
    initBoard: function() {

    },

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

    initBoard: function() {
        this.board = new Board(gameProperties.boardWidth, gameProperties.boardHeight);
    },

А вызываться эта функция будет из функции create.

    create: function() {
        this.initBoard();
    },

Хорошо, проверь игру на данный момент, она должна выглядеть примерно так:

Всё нормально, только игровое поле находится не в центре игрового мира. Давай сделаем окончательную регулировку.

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

        board.push(row);
    }
    
    this.moveTo = function(x, y) {
        group.x = x;
        group.y = y;
    };

Функция moveTo используется, чтобы переместить всё игровое поле к конкретной координате игрового мира. Чтобы сделать это, установи координаты x и y группы свойств с помощью параметров переданных в эту функцию.

Теперь для вызова функции moveTo в файле game.js добавь следующий код в функцию initBoard:

    initBoard: function() {
        this.board = new Board(gameProperties.boardWidth, gameProperties.boardHeight);
        this.board.moveTo(this.boardLeft, this.boardTop);
    },

Вот то, что наконец получилось:

Хорошая работа.

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

Заметка

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

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