Сапёр на HTML5 с помощью Phaser №4:
Маркировка тайлов
и пользовательский интерфейс

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

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


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

1. Маркировка тайлов

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

Сапёр: контекстное меню элемента Canvas
Внимание

Другая проблема состоит в том, что для игры в Сапёра на планшете требуется адаптация его интерфейса.

Чтобы это исправить, необходимо отменить действие события по умолчанию с помощью метода preventDefault. Открой файл game.js и добавь в функцию preload следующий код:

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

Теперь следует доработать объект Tile. Открой файл tile.js и добавь после функции this.reveal следующую функцию:

    this.flag = function() {
 
    };

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

    this.flag = function() {
        switch (currentState) {
            case tile.states.DEFAULT:
                currentState = tile.states.FLAG;
                break;

            case tile.states.FLAG:
                currentState = tile.states.UNKNOWN;
                break;

            case tile.states.UNKNOWN:
                currentState = tile.states.DEFAULT;
                break;
        }

        sprite.animations.frame = currentState;
    };

В начале игры все тайлы имеют состояние по умолчанию. При первом запуске функции flag для каждого тайла проверяется является ли его текущее состояние состоянием по умолчанию. Если это так, то свойству currentState назначается состояние с флагом. При втором вызове функции flag для тайла, свойство currentState получает состояние с вопросом.

Существует всего 3 состояния, которые будут сменять друг друга при вызове функции flag:

Сапёр: 3 состояния нераскрытого тайла

3 состояния нераскрытого тайла:

  • С голубым фоном — состояние по умолчанию.
  • С жёлтым фоном — состояние с флагом.
  • С зелёным фоном — состояние с вопросом.

В строке 84, текущий кадр анимации получает значение currentState для отображения.

Далее обнови функцию click, чтобы можно было маркировать тайл:

    var click = function() {
        if (game.input.activePointer.rightButton.isDown) {
            tile.flag();
        } else if (currentState == tile.states.DEFAULT) {
            tile.reveal();
        }
    };

В строке 60, проверяется, нажата ли правая кнопка мыши в настоящее время. Если да, то вызывается функция flag.

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

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

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

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


2. Пользовательский интерфейс

Для реализации пользовательского интерфейса тебе нужно создать 2 счётчика:

  • Счётчик времени (таймер) — отслеживает сколько секунд прошло с начала игры.
  • Счётчик мин — отслеживает сколько осталось непромаркированных мин на игровом поле.

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

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

var fontStyles = {
    counterFontStyle: {font: '20px Arial', fill: '#FFFFFF'}
};

Текстовые объекты будут использовать белый цвет текста и шрифт Arial размером 20 пикселей.


2.1. Таймер

Создай новый файл с именем timer.js и добавь в него следующую функцию-конструктор:

var Timer = function(x, y) {

};

Функция имеет 2 аргумента, которые будут позиционировать объект Timer по осям X и Y в игровом мире.

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

var Timer = function(x, y) {

    var count = 0;
    var tf_counter = game.add.text(x, y, 'Время: ' + count, fontStyles.counterFontStyle);

    tf_counter.anchor.set(0, 0.5);

    var timer = game.time.events;
};

В строке 3, устанавливается значение счётчика по умолчанию, которое и получает объект Timer при создании.

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

  • х — горизонтальная координата текстового объекта,
  • y — вертикальная координата текстового объекта,
  • text — текст по умолчанию для отображения (Время: 0),
  • fontStyles — используемый по умолчанию стиль шрифта, который был объявлен ранее в файле game.js.

В строке 6 устанавливается опорная точка для выравнивания текстового объекта слева посередине.

В строке 8 используется встроенный объект Phaser.Time для управления событием таймера.

Теперь добавь некоторые функции: запуска, остановки и обновления объекта Timer:

    var timer = game.time.events;

    this.start = function() {
        timer.loop(1000, update, this);
        timer.start();
    };

    this.stop = function() {
        timer.stop();
    };

    var update = function() {
        count++;
        tf_counter.text = 'Время: ' + count;
    };
};

В строке 11 функция start создаёт циклическое событие таймера с использованием следующих аргументов:

  • duration — длительность цикла в милисекундах (значение 1000 равно 1 секунде),
  • callback — функция, которая вызывается в конце каждого цикла,
  • context — контекст, в котором существует объект Timer.

Функция stop говорит сама за себя. Одна строка кода, чтобы остановить таймер.

Последняя функция update вызывается в конце каждого цикла.

В строке 20 увеличивается значение счётчика на 1, а затем обновляется текстовый объект, чтобы отобразить новое значение.

Когда начинает работать таймер? В момент первого нажатия на тайл:

  • когда тайл раскрыт,
  • когда тайл маркирован.

Когда таймер заканчивает работу?

  • когда раскрыта мина,
  • когда раскрыты все тайлы.

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

    this.onRevealed = new Phaser.Signal();
    this.onFlagged = new Phaser.Signal();

Строка 25 добавляет ещё один объект сигнала, через который слушателю будет отправляться событие, когда тайл будет маркирован.

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

        tile.onFlagged.dispatch(tile);
        sprite.animations.frame = currentState;

Это позволит транслировать событие, которое сможет получить функция-слушатель в объекте Board.

Теперь давай посмотрим на файл board.js. Объект Board управляет всеми отдельными тайлами, также он будет отвечать за диспетчеризацию событий для запуска и остановки таймера.

Добавь следующее в начале функции-конструктора:

var Board = function(columns, rows, mines) {
    
    this.onTileClicked = new Phaser.Signal();
    this.onEndGame = new Phaser.Signal();
    
    var self = this;

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

В строках 3-4 были добавлены 2 объекта сигнала, которые будут отправлять события, когда происходит нажатие на тайл и когда игра заканчивается.

В строке 6 создаётся ссылка self на сам объект Board, которая пригодится позже.

Теперь добавь следующую строку в функцию onReveal для транслирования события сигналом onTileClicked:

    var onReveal = function(tile) {
        self.onTileClicked.dispatch();
        switch (tile.getValue()) {

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

    };

    var onFlag = function(tile) {
        self.onTileClicked.dispatch();
    };

    var revealAll = function() {

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

    var revealAll = function() {
        self.onEndGame.dispatch();
        for (var y = 0; y < rows; y++) {

Хорошо, ранее уже был добавлен слушатель onReveal для проверки раскрытия тайла. Теперь нужно добавить слушатель для проверки маркировки тайла:

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

В строке 18 задаётся слушатель onFlag на событие сигнала onFlagged в объекте Tile. Всякий раз, когда тайл будет промаркирован вызывается функция onFlag, которую только что добавили к объекту Board.

Теперь вернись к файлу game.js, чтобы добавить объект Timer. Добавь в объект gameState следующее:

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

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

    },

    initUI: function() {
        var top = this.boardTop - 20;
        var left = this.boardLeft;

        this.timer = new Timer(left, top);
    }
};

Функция initUI будет использоваться для добавления объекта Timer на игровое поле. Переменные top и left являются координатами, в которых таймер будет размещён относительно координат игрового поля. В строке 78 показан новый объект Timer, который создаётся и отображается в игровом мире.

Тебе понадобятся ещё 2 функции, чтобы запустить и остановить таймер. Добавь их после функции initUI:

    },

    startGame: function() {
        this.timer.start();
    },

    endGame: function() {
        this.timer.stop();
    }
};

Затем добавь слушателей для событий сигналов onTileClicked и onEndGame для объекта Board:

    initBoard: function() {
        this.board = new Board
        (
            gameProperties.boardWidth,
            gameProperties.boardHeight,
            gameProperties.totalMines
        );
        this.board.moveTo(this.boardLeft, this.boardTop);
        this.board.onTileClicked.addOnce(this.startGame, this);
        this.board.onEndGame.addOnce(this.endGame, this);
    },

Обрати внимание, что в строках 72 и 73 используется функция addOnce вместо обычной функции add для присоединения слушателей. С помощью addOnce оба слушателя будут получать событие только один раз. Конечно, это поведение нужно будет изменить, если ты захочешь позже использовать функцию паузы и возобновления.

В создание игрового состояния добавь вызов функции initUI:

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

И последнее, прежде чем ты сможешь проверить свою игру.

Открой файл index.html и добавь следующую строку:

    <script src="js/board.js"></script>
    <script src="js/timer.js"></script>
</head>

Без этой строки таймер не появится, так как код для него находится в файле timer.js.

Вот что сделано до сих пор:

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


2.2. Счётчик мин

Для счётчика мин также необходимо создать новый файл с именем counter.js. Начни с функции-конструктора Counter:

var Counter = function(x, y, defaultValue) {

};

Вот параметры этой функции:

  • x — горизонтальная координата объекта счётчика,
  • y — вертикальная координата объекта счётчика,
  • defaultValue — отображается начальное значение.

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

var Counter = function(x, y, defaultValue) {

    var currentValue = defaultValue;
    var tf_counter = game.add.text(x, y, 'Мины: ' + defaultValue, fontStyles.counterFontStyle);

    tf_counter.anchor.set(1, 0.5);
};

Строка 3 будет отслеживать значение счётчика по умолчанию.

Строка 4 создаёт новый текстовый объект в координатах x и y, добавляет строку по умолчанию и использует стиль шрифта, который был установлен ранее в файле game.js.

В строке 6 устанавливается опорная точка для выравнивания текстового объекта справа посередине.

Теперь добавь публичную функцию update:

    tf_counter.anchor.set(1, 0.5);

    this.update = function(value) {
        tf_counter.text = 'Мины: ' + (currentValue - value);
    };
};

Функция update требует 1 аргумент, значением которого является общее количество тайлов, которые были промаркированы. Когда эта функция вызывается, она обновляет текстовый объект, чтобы показать количество неразминированных мин.

Теперь обнови файл board.js. Добавь ещё один сигнальный объект:

    this.onTileClicked = new Phaser.Signal();
    this.onTileFlagged = new Phaser.Signal();
    this.onEndGame = new Phaser.Signal();

Событие сигнала onTileFlagged будет отправляться каждый раз, когда тайл будет маркирован. Затем событие будет получено счётчиком и текстовый объект обновится.

Также потребуется массив для отслеживания помеченных элементов:

    var self            = this;
    var flaggedTiles    = [];
    var board           = [];

А ещё следует добавить функцию getState в конец файла tile.js для получения текущего состояния тайла.

    };

    this.getState = function() {
        return currentState;
    };

    init();

Теперь можно обновить и функцию onFlag в файле board.js:

    var onFlag = function (tile) {
        self.onTileClicked.dispatch();

        if (tile.getState() == tile.states.FLAG) {
            flaggedTiles.push(tile);
        } else {
            for (var i = 0; i < flaggedTiles.length; i++) {
                if (flaggedTiles[i] == tile) {
                    flaggedTiles.splice(i, 1);
                }
            }
        }

        self.onTileFlagged.dispatch(flaggedTiles.length);
    };

В строках 85-93 происходит проверка маркировки какого-либо тайла. В строке 86, если тайл промаркирован состоянием с флагом, то он переходит в массив flaggedTiles. В противном случае, если тайл имеет состояние с вопросом или состояние по умолчанию, то строки 88-92 удаляют такой тайл из массива flaggedTiles.

В строке 95, после проверки состояния тайла и формирования массива flaggedTiles, общее количество его элементов транслируется сигналом onTileFlagged.

Теперь, после создания объекта Counter и обновления объектов Tile и Board, добавь таймер в файл game.js.

Начни с добавления свойства counter к объекту gameState:

    this.board;
    this.timer;
    this.counter;
};

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

    initUI: function() {
        var top = this.boardTop - 20;
        var left = this.boardLeft;
        var right = left + (gameProperties.boardWidth * gameProperties.tileWidth);

        this.timer = new Timer(left, top);
        this.counter = new Counter(right, top, gameProperties.totalMines);
    },

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

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

    },

    updateMines: function(value) {
        this.counter.update(value);
    }

Значение аргумента принимается от объекта Board в момент маркировки тайла состоянием с флагом. Когда эта функция вызывается, она передаёт значение функции update объекта Counter.

Теперь в функцию initBoard следует добавить функцию-слушателя updateMines к сигналу onTileFlagged:

        this.board.onTileClicked.addOnce(this.startGame, this);
        this.board.onEndGame.addOnce(this.endGame, this);
        this.board.onTileFlagged.add(this.updateMines, this);

Наконец, необходимо обновить файл index.html, чтобы включить новый файл counter.js:

        <script src="js/board.js"></script>
        <script src="js/timer.js"></script>
        <script src="js/counter.js"></script>

Вот что ты должен увидеть сейчас:

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


2.3. Повторная игра

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

Добавь в объект gameState, файла game.js следующую строку:

    this.counter;
    this.tf_replay;
};

Строка 32 будет использоваться для текстового объекта.

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

        this.timer = new Timer(left, top);
        this.counter = new Counter(right, top, gameProperties.totalMines);

        this.tf_replay = game.add.text
        (
            game.stage.width * 0.5,
            top,
            'Повтор?',
            fontStyles.counterFontStyle
        );
        this.tf_replay.anchor.set(0.5, 0.5);
        this.tf_replay.inputEnabled = true;
        this.tf_replay.input.useHandCursor = true;
        this.tf_replay.events.onInputDown.add(this.restartGame, this);
        this.tf_replay.visible = false;

В строках 88-94 создаётся текстовый объект с помощью обычных аргументов:

  • x — горизонтальная координата текстового объекта,
  • y — вертикальная координата текстового объекта,
  • text — текст по умолчанию для отображения (Повтор?),
  • style — используемый по умолчанию стиль шрифта, который также был использован в счётчиках.

В строке 95 устанавливается опорная точка для выравнивания текстового объекта в центре, между счётчиками.

В строках 96-98 к текстовому объекту подключается ввод с помощью мыши, чтобы ты смог кликнуть по нему. Строка 96 имеет решающее значение для обработки текстовым объектом событий мыши.

Строка 97 изменяет стандартный курсор мыши на курсор руки, когда он находится над текстовым объектом.

В строке 98 добавляется функция слушателя в сигнал onInputDown. Это событие сигнала будет отправлено при клике любой кнопкой мыши на текстовом объекте. Функция-слушатель будет называться restartGame, она ещё не была реализована.

Строка 99 скрывает текстовый объект, установив его свойство visible равным false, таким образом объект не появится на экране до окончания игры.

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

        endGame: function() {
            this.timer.stop();
            this.tf_replay.visible = true;
        },

В строке 108 свойство visible становится равным true — это сделает текстовый объект видимым снова.

Теперь добавь функцию restartGame сразу после функции endGame:

        },

        restartGame: function() {
            game.state.start(states.game);
        },

        updateMines: function(value) {

Функция restartGame всего лишь меняет состояние игры. Вызов функции game.state.start просто выгружает из памяти текущее состояние игры, а затем загружает новое.

Попробуй нажать на мине и запустить игру повторно:

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


2.4. Победа

Осталось реализовать завершение игры при раскрытии всех тайлов.

Сначала добавь следующую строку в объект Board:

    var group = game.add.group();
    var tilesLeft = (columns * rows) - mines;

    var init = function() {

В строке 11 переменная tilesLeft будет отслеживать общее количество тайлов на игровом поле без мин.

Тайл раскрывается когда:

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

Такое поведение раскрытия тайлов реализовано в функциях onReveal и revealEmptyTiles.

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

        switch (tile.getValue()) {
            case tile.states.ZERO:
                revealEmptyTiles(tile);
                break;

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

        tilesLeft--;

        if (!tilesLeft) {
            self.onEndGame.dispatch();
        }
    };

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

                }

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

                if (currentTile.getValue() == currentTile.states.ZERO) {

Также в конец функции revealEmptyTiles добавь такие строки:

            tileList.shift();
        }

        if (!tilesLeft) {
            self.onEndGame.dispatch();
        }
    };

Хорошо, попробуй игру сейчас.

Заметил ли ты что-то необычное после раскрытия всех тайлов?

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

На данный момент не существует способа отключить взаимодействие мыши с тайлом. Это необходимо исправить, добавь публичную функцию в файл tile.js, которая позволит объекту Board управлять кликабельностью тайлов.

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

    this.enable = function(enable) {
        sprite.inputEnabled = enable;
    };

    init();

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

    var flaggedTiles = [];
    var mineList = [];
    var board = [];

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

                tile = getRandomTile();
            }

            mineList.push(tile);
            tile.setValue(tile.states.MINE);

Функция push добавляет новый элемент в конец списка. Поэтому каждый раз при её вызове, очередной тайл добавляется в mineList.

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

    var endGame = function() {
        for (var i = 0; i < mineList.length; i++) {
            var tile = mineList[i];
            tile.enable(false);
        }

        self.onEndGame.dispatch();
    };

    init();

В строке 189 также посылается сигнал о завершении игры. Подобная строка присутствует в 3-х местах по коду выше. Теперь её нужно заменить на вызов добавленной функции endGame. Первое место в функции onReveal:

        if (!tilesLeft) {
            endGame();
        }
    };
Второе место в функции revealAll:
    var revealAll = function() {
        endGame();
        for (var y = 0; y < rows; y++) {
Третье место в функции revealEmptyTiles:
        if (!tilesLeft) {
            endGame();
        }
    };

Ну вот и всё, финальная игра:

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


3. Последние комментарии

Отличная работа! Ты прошёл руководство до конца. Конечно, многое ещё можно улучшить, но уже сейчас у тебя есть хорошая версия игры Сапёр со всеми основными функциями.

Если ты всё же захочешь улучшить её, вот несколько советов:

  1. Добавь отдельное состояние игры с выигрышным или проигрышным сообщением.
  2. Добавь другое состояние кадра анимации для каждого тайла с верной маркировкой мины при завершении игры. Например: версия Windows 8 отображает в этом случае цветок.
  3. Добавь другое состояние кадра анимации для каждого тайла с ложной маркировкой мины при завершении игры. Этот кадр уже включён в файл tiles.png и указан как WRONG_FLAG в файле tile.js.
  4. Добавь звуковые эффекты при взаимодействии с тайлом и при завершении игры.
  5. Добавь уровни сложности: начальный, средний, высокий, а также возможность его настройки.

Хорошо, это всего лишь несколько предложений, которые ты можешь попробовать. Обязательно поделись ссылкой на свой проект, если в нём ты реализовал какие-либо улучшения. Было бы здорово это увидеть!

Итак, в этом руководстве были рассмотрены основы создания простой игры в формате сетки. Это хорошая основа для создания подобных игр, таких как:

Контрольные вопросы