Как написать сапера на Phaser и выполнить тестовое задание HTML5 разработчика

Добрый день, уважаемые коллеги!
Меня зовут Александр, я разработчик HTML5 игр.

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

4tngdnvlxq3izqrpk2nykww2dvs.png

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

Данная статья даст представление об объеме работ, достаточном для успешного выполнения среднестатистического тестового задания на позицию HTML5 разработчика. Материал также может быть интересен всем, кто хочет познакомиться с фреймворком Phaser. А если вы уже работаете с Phaser и пишете на JS — посмотрите, как разработать проект на TypeScript.

Итак, под катом много кода на TypeScript!

Введение


Приведем краткую постановку задачи.

  1. Мы разработаем простую HTML5 игру — классического сапера.
  2. В качестве основных инструментов будем использовать phaser 3, typescript и webpack.
  3. Игра будет предназначена для десктопа и запускаться в браузере.


Укажем ссылки на итоговый проект.
И напомним механику сапера, если вдруг кто забыл правила игры. Но так как это маловероятный кейс, правила размещены под спойлером :)

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

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

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

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


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

Оглавление

1. Подготовка


1.1 Шаблон проекта


Скачаем дефолтный шаблон проекта phaser. Это рекомендованный шаблон от автора фреймворка и он предлагает нам следующую структуру директорий:
Для нашего проекта текущий index.js файл нам не потребуется, поэтому удалим его. Затем создадим директорию /src/scripts/ и поместим в нее пустой файл index.ts. В эту папку мы будем складывать все наши скрипты.
Также стоит иметь ввиду, что при сборке проекта для продакшена, в корне будет создана директория dist, в которую будет помещен релизный билд.

1.2 Конфигурация сборки


Для сборки будем использовать webpack. Так как наш шаблон изначально подготовлен для работы с JavaScript, а мы пишем на TypeScript, нам потребуется внести небольшие изменения в конфиг сборщика.

В файл webpack/base.js добавим ключ entry, обозначающий точку входа при сборке нашего проекта, а также конфигурацию ts-loader, описывающую правила сборки TS скриптов:

// webpack/base.js
//...
module.exports = {
  entry: './src/scripts/index.ts',
  // ...
  resolve: {
    extensions: [ '.ts', '.tsx', '.js' ]
  },
  module: {
    rules: [{
            test: /\.tsx?$/,
            use: 'ts-loader',
            exclude: /node_modules/
      },
//...


Нам также потребуется создать в корне проекта файл tsconfig.json. У меня он имеет следующее содержание:

{
    "compilerOptions": {
      "module": "commonjs",
      "lib": [ "dom", "es5", "es6", "es2015", "es2017", "es2015.promise" ],
      "target": "es5",
      "skipLibCheck": true
    },
    "exclude": ["node_modules", "dist"]
}

1.3 Установка модулей


Устанавливаем все зависимости из package.json и добавляем к ним модули typescript и ts-loader:

npm i
npm i typescript --save-dev
npm i ts-loader --save-dev


Теперь проект готов к началу разработки. В нашем распоряжении есть 2 команды, которые уже определены в свойстве scripts в файле package.json.

  1. Собрать проект для отладки и открыть в браузере через локальный сервер
    npm start
  2. Запустить сборку для прода и поместить релизный билд в папку dist/
    npm run build

1.4 Подготовка ассетов


Все ассеты для данной игры честно скачаны с OpenGameArt (версия 61×61) и имеют самую дружелюбную из лицензий под названием Feel free to use, о чем заботливо нам сообщает страница с паком). Кстати, представленный в статье код имеет такую-же лицензию!;)

Из скачанного набора я удалил изображение часов, а остальные файлы переименовал так, чтобы получить удобные для использования имена фреймов. Список названий и соответствующие файлы отображены на скрине ниже.
Из полученных спрайтов создадим атлас формата Phaser JSONArray в программе TexturePacker (фришной версии более чем достаточно, работу ведь я еще не получил) и поместим сгенерированные spritesheet.png и spritesheet.json файлы в директорию проекта src/assets/

mskiz9zmuidbxk8hhcbksfmny4k.png

2. Создание сцен


2.1 Точка входа


Начнем разработку с создания точки входа, описанной в конфиге webpack.

// src/scripts/index.ts
import * as Phaser from "phaser";

new Phaser.Game({
    type: Phaser.AUTO,
    parent: "minesweeper",
    width: window.innerWidth,
    height: window.innerHeight,
    backgroundColor: "#F0FFFF",
    scene: []
});

Так как игра у нас предназначена для десктопа и будет заполнять весь экран, смело используем всю ширину и высоту браузера для полей width и height.
Поле scene в данный момент является пустым массивом и мы это исправим!

2.2 Стартовая сцена


Создадим класс первой сцены в файле src/scripts/scenes/StartScene.ts:

export class StartScene extends Phaser.Scene {
   constructor() {
       super('Start');
   }
 
   public preload(): void {
   }
 
   public create(): void {
   }
}

Для валидного наследования Phaser.Scene передадим название сцены параметром в конструктор родительского класса.

Эта сцена будет совмещать функционал предзагрузки ресурсов и стартового экрана, приглашающего пользователю в игру.
Обычно в моих проектах игрок проходит две сцены до того, как попадает на стартовую, в таком порядке:

Boot => Preload => Start

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

Все ассеты мы будем загружать в методе preload. Чтобы в дальнейшем иметь возможность работать с созданным атласом нам потребуется выполнить 2 шага:

  1. получить и png и json файлы атласа, используя require:
    // StartScene.ts
    const spritesheetPng = require("./../../assets/spritesheet.png");
    const spritesheetJson = require("./../../assets/spritesheet.json");
    // ...
    

  2. загрузить их в методе preload стартовой сцены:
    // StartScene.ts
    // ...
    public preload(): void {
        this.load.atlas("spritesheet", spritesheetPng, spritesheetJson);
    }
    // ...
    


2.3 Тексты стартовой сцены


В стартовой сцене осталось сделать 2 вещи:

  1. сообщить игроку, как начать игру
  2. запустить игру по инициативе игрока


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

// StartScene.js
enum Texts {
    Title = 'Minesweeper HTML5',
    Message = 'Click anywhere to start'
}

enum Styles {
    Color = '#008080',
    Font = 'Arial'
}
//...


А затем создадим оба текста в виде объектов в метод create. Напомню, что метод create сцен в Phaser будет вызван только после загрузки всех ресурсов в методе preload и нас это вполне устраивает.

// StartScene.js
//...
public create(): void {
    this.add.text(
        this.cameras.main.centerX,
        this.cameras.main.centerY - 100,
        Texts.Title,
        {font: `52px ${Styles.Font}`, fill: Styles.Color})
    .setOrigin(0.5);

    this.add.text(
        this.cameras.main.centerX,
        this.cameras.main.centerY + 100,
        Texts.Message,
        {font: `28px ${Styles.Font}`, fill: Styles.Color})
    .setOrigin(0.5);
}
//...

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

2.4 Переход на игровой уровень


Финальное, что мы сделаем в этой сцене, прежде чем двинемся дальше — это отследим событие нажатия кнопки мыши для запуска игрока в игру:

// StartScene.js
//...
public create(): void {
    //...
    this.input.once('pointerdown', () => {
        this.scene.start('Game');
    });
}
//...

2.5 Сцена уровня


Судя по параметру "Game", передаваемому в метод this.scene.start вы уже догадались, что пришло время создать вторую сцену, которая и будет обрабатывать основную игровую логику. Создадим файл src/scripts/scenes/GameScene.ts:

export class GameScene extends Phaser.Scene {
    constructor() {
        super('Game');
    }

    public create(): void {
    }
}

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

2.6 Установка сцен в точке входа


Теперь, когда созданы обе сцены, добавим их в нашу точку входа src/scripts/index.ts:

//...
import { StartScene } from "./scenes/StartScene";
import { GameScene } from "./scenes/GameScene";
//...
new Phaser.Game({
    // ...
    scene: [StartScene, GameScene]
});

3. Игровые объекты


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

3.1 Игровая доска


Создадим файл src/scripts/models/Board.ts, в который поместим класс Board:

import { Field } from "./Field";

export class Board extends Phaser.Events.EventEmitter {
    private _scene: Phaser.Scene = null;
    private _rows: number = 0;
    private _cols: number = 0;
    private _bombs: number = 0;
    private _fields: Field[] = [];

    constructor(scene: Phaser.Scene, rows: number, cols: number, bombs: number) {
        super();
        this._scene = scene;
        this._rows = rows;
        this._cols = cols;
        this._bombs = bombs;
        this._fields = [];
    }

    public get cols(): number {
        return this._cols;
    }
    
    public get rows(): number {
        return this._rows;
    }
}


Сделаем класс наследником Phaser.Events.EventEmitter для того, чтобы получить доступ к интерфейсу регистрации и вызова событий, который нам потребуется в дальнейшем.

В приватном свойстве _fields будет храниться массив объектов класса Field. Реализуем эту модель позже.

Заводим приватные числовые свойства _rows и _cols для указания количества строк и столбцов игрового поля. Создаем публичные геттеры для чтения _rows и _cols.

Поле _bombs сообщает нам о количестве бомб, которые потребуется сгенерировать для уровня. А в параметр _scene мы передаем ссылку на объект игровой сцены GameScene, в которой мы и создадим экземпляр класс Board.

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

Раз мы определились с интерфейсом создания доски, предлагаю инициализировать ее в сцене уровня, доработав класс GameScene:

 // GameScene.ts
import { Board } from "../models/Board";

const Rows = 8;
const Cols = 8;
const Bombs = 8;

export class GameScene extends Phaser.Scene {
    private _board: Board = null;
    //... 
    public create(): void {
        this._board = new Board(this, Rows, Cols, Bombs);
    }
}

Вынесем параметры доски в константы в начале файла сцены и передадим в конструктор Board при создании экземпляра этого класса.

3.2 Модель ячейки


Доска состоит из ячеек, которые и требуется вывести на экран. Каждую ячейку нужно разместить в соответствующей позиции, определяемой строкой и столбцом.
Ячейки также выделим в отдельную сущность. Создадим файл src/scripts/models/Field.ts в который поместим класс, описывающий ячейку:

import { Board } from "./Board";

export class Field extends Phaser.Events.EventEmitter {
    private _scene: Phaser.Scene = null;
    private _board: Board = null;
    private _row: number = 0;
    private _col: number = 0;

    constructor(scene: Phaser.Scene, board: Board, row: number, col: number) {
        super();
        this._init(scene, board, row, col);
    }

    public get col(): number {
        return this._col;
    }

    public get row(): number {
        return this._row;
    }

    public get board(): Board {
        return this._board;
    }

    private _init(scene: Phaser.Scene, board: Board, row: number, col: number): void {
        this._scene = scene;
        this._board = board;
        this._row = row;
        this._col = col;
    }
}


У каждой ячейки должны быть показатели строки и столбца, в которых она находится. Заводим параметры _board и _scene для установки ссылок на объекты доски и сцены. Реализуем геттеры для чтения полей _row, _col и _board.

3.3 Представление ячейки


Абстрактная ячейка создана и теперь мы хотим ее визуализировать. Чтобы вывести ячейку на экран потребуется создать ее представление. Создадим файл src/scripts/views/FieldView.ts и поместим в него класс вьюшки:

import { Field } from "../models/Field";

export class FieldView extends Phaser.GameObjects.Sprite {
    private _model: Field = null;

    constructor(scene: Phaser.Scene, model: Field) {
        super(scene, 0, 0, 'spritesheet', 'closed');
        this._model = model;
        this._init();
        this._create();
    }

    private _init(): void {
    }

    private _create(): void {
    }
}


Обратите внимание, что данный класс мы сделали наследником Phaser.GameObjects.Sprite. В терминах phaser данный класс стал префабом спрайта. То есть получил функционал игрового объекта спрайта, который мы в дальнейшем расширим собственными методами.

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

  • ссылка на объект сцены (о чем я предупреждал в п. 3.1: phaser требует от нас ссылку на текущую сцену для возможности рендеринга спрайтов)
  • координаты x и y на канвасе
  • строковый ключ, по которому доступен атлас, загруженный нами в методе preload стартовой сцены
  • строковый ключ фрейма в этом атласе, который требуется выбрать для отображения спрайта


Установим ссылку на модель (то есть экземпляр класса Field) в приватное свойство _model.

Мы также предусмотрительно завели 2 пустых на данный момент метода _init и _create, которые реализуем чуть позже.

3.4 Создание спрайта в классе представления


Итак, вьюшка создана, но спрайт она рисовать еще не умеет. Чтобы поместить спрайт с нужным нам фреймом на канвас потребуется доработать наш собственный приватный метод _create:

// FieldView.js
//...
private _create(): void {
    this.scene.add.existing(this); // добавляем созданный объект на канвас
    this.setOrigin(0.5); // устанавливаем pivot point в центр спрайта
}
//...

3.5 Позиционирование спрайта


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

Добавим в класс свойство _position, отвечающие за итоговые координаты ячейки на игровом поле:

// FieldView.ts
//...
interface Vec2 {x: number, y: number};

export class FieldView extends Phaser.GameObjects.Sprite {
    private _position: Vec2 = {x: 0, y: 0};
    //...


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

// FieldView.ts
//...
    private get _offset(): Vec2 {
        return {
            x: (this.scene.cameras.main.width - this._model.board.cols * this.width) / 2,
            y: (this.scene.cameras.main.height - this._model.board.rows * this.height) / 2
        };
    }
//...


Таким образом, мы:

  1. Получили общую ширину экрана в this._scene.cameras.main.width.
  2. Получили общую ширину доски умножив число ячеек на ширину одной ячейки: this._board.cols * this.width.
  3. Отняв ширину доски из ширины экрана мы получили место на экране, не занятое доской.
  4. Разделив полученное число на 2 мы получили величину отступа слева и справа от доски.
  5. Смещая каждую ячейку на величину этого отступа мы гарантируем выравнивание всей доски по оси x.


Абсолютно аналогичные действия проделываем для получения смещения по вертикали.

Остается добавить нужный код в методе _init:

// FieldView.ts
// ...
private _init(): void {
    const offset = this._offset;
    this.x = this._position.x = offset.x + this.width * this._model.col + this.width / 2;
    this.y = this._position.y = offset.y + this.height * this._model.row + this.height / 2;
}
// ...


Свойства this.x, this.y, this.width и this.height здесь — это унаследованные свойства родительского класса Phaser.GameObjects.Sprite. Изменение свойств this.x и this.y приводит к правильному позиционированию спрайта на канвасе.

3.6 Создание экземпляра FieldView


Создадим вьюшку в классе Field:

// Field.ts
// ...
private _view: FieldView = null;

public get view(): FieldView {
    return this._view;
}

private _init(scene: Phaser.Scene, board: Board, row: number, col: number): void {
    //...
    this._view = new FieldView(this._scene, this);
}
// ...

3.7 Отображение полей доски.


Вернемся в класс Board, который по своей сути является коллекцией объектов класса Field и будет заниматься созданием ячеек.
Вынесем код создания доски в отдельный метод _create и вызовем этот метод из конструктора. Зная о том, что в методе _create мы будем создавать не только ячейки, вынесем код создания ячеек в отдельный метод _createFields.

// Board.ts
constructor(scene: Phaser.Scene, rows: number, cols: number, bombs: number) {
    // ...
    this._create();
}

private _create(): void {
    this._createFields();
}

private _createFields(): void {
}


Именно в этом методе мы и создадим нужное число ячеек во вложенном цикле:

// Board.ts
// ...
private _createFields(): void {
    for (let row = 0; row < this._rows; row++) {
        for (let col = 0; col < this._cols; col++) {
            this._fields.push(new Field(this._scene, this, row, col));
        }
    }
}
//...


Самое время в первый раз запустить сборку для отладки командой

npm start


Удостоверимся в том, что в центре экрана мы ожидаемо видим 64 ячейки в 8 строках.

3.8 Создание бомб


Ранее я сообщил, что в методе _create класса Board у нас будет не только создание полей. А что же еще? Здесь будет также и создание бомб, и установка созданным ячейкам значений числа бомб-соседей. Начнем с самих бомб.

Нам требуется разместить N бомб на доске в случайных ячейках. Опишем процесс создания бомб приблизительным алгоритмом:

определить число бомб для генерации
пока не создано требуемое число бомб
    получить рандомное поле
    если полученное поле пустое
        поместить в него бомбу
        уменьшить счетчик бомб


Будем на каждой итерации цикла получать случайную ячейку из свойства this._fields до тех пор, пока мы создадим столько бомб, сколько указано в поле this._bombs,. Если полученная ячейка пуста, то установим в ней бомбу и обновим счетчик бомб, необходимых для генерации.
Для генерации случайного числа используем статический метод Phaser.Math.Between.

// Board.ts
//...
private _createBombs(): void {
    let count = this._bombs; // определить число бомб для генерации

    while (count > 0) { // пока не создано требуемое число бомб
        let field = this._fields[Phaser.Math.Between(0, this._fields.length - 1)]; // получить рандомное поле

        if (field.empty) { // если полученное поле пустое
            field.setBomb(); // поместить в него бомбу
            --count; // уменьшить счетчик бомб
        }
    }
}


Не забудем в файле Board.ts прописать вызов this._createBombs(); в конце метода _create

Как вы уже заметили, чтобы этот код корректно отрабатывался, необходимо доработать класс Field, добавив в него геттер empty и метод setBomb.

Добавим в класс Field приватное поле _value, которое будет регулировать содержимое ячейки. Примем следующие соглашения.


Следуя этим правилам разработаем в классе Field методы, работающие со свойством _value:

// Field.ts
// ...
private _value: number = 0;
// ...
public get value(): number {
    return this._value;
}

public set value(value) {
    this._value = value;
}

public get empty(): boolean {
    return this._value === 0;
}

public get mined(): boolean {
    return this._value === -1;
}

public get filled(): boolean {
    return this._value > 0;
}

public setBomb(): void {
    this._value = -1;
}
// ...

3.9 Установка значений


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

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

для каждого поля на доске
    если в поле есть мина
        для каждой соседней ячейки
            увеличим показатель числа мин рядом


В классе Board создадим новый метод и транслируем указанный псевдокод в реальный код:

// Board.ts
//...
private _createValues() {
    // для каждого поля на доске
    this._fields.forEach(field => {
        // если в поле есть мина
        if (field.mined) {
            // для каждой соседней ячейки
            field.getClosestFields().forEach(item => {
                // увеличим показатель числа мин рядом
                if (item.value >= 0) {
                    ++item.value;
                }
            });
        }
    });
}
//...


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

Как определить соседние ячейки?
Для примера рассмотрим любую клетку доски, находящуюся не на краю, то есть не в крайней строке и не в крайнем столбце. У таких клеток в наличии максимальное число соседей: 1 сверху, 1 снизу, 3 слева и 3 справа (включая ячейки по диагонали).
Таким образом у каждой из соседних ячеек показатели _row и _col не отличаются больше, чем на 1. Это значит, что мы можем заранее указать разницу параметров _row и _col с текущим полем. Добавим константу в начале файла до описания класса:

// Field.ts
const Positions = [
    {row : 0, col : 1}, // справа
    {row : 0, col : -1}, // слева
    {row : 1, col : 0}, // сверху
    {row : 1, col : 1}, // сверху справа
    {row : 1, col : -1}, // сверху слева
    {row : -1, col : 0}, // снизу
    {row : -1, col : 1}, // снизу справа
    {row : -1, col : -1} // снизу слева
];
//...


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

// Field.ts
//...
public getClosestFields(): Field[] {
    let results = [];

    // для каждой возможной соседней позиции
    Positions.forEach(position => {
        // получим клетку в заданной позиции
        let field = this._board.getField(this._row + position.row, this._col + position.col);

        // если такая клетка есть на доске
        if (field) {
            // добавить ее в пул
            results.push(field);
        }
    });

    return results;
};
//...

Не забудем проверить переменную field на каждой итерации, так как не все клетки на доске имеют по 8 соседей. Например, левая верхняя клетка не будет иметь соседей слева от нее и так далее.

Осталось реализовать метод getField и добавить все нужные вызовы в метод _create в классе Board

// Board.ts
//...
public getField(row: number, col: number): Field {
    return this._fields.find(field => field.row === row && field.col === col);
}
//...
private _create(): void {
    this._createFields();
    this._createBombs();
    this._createValues(); 
}
//...


4. Обработка событий ввода


4.1 Отслеживание событий нажатия кнопок мыши


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

Сперва нам этот самый клик необходимо отследить. В классе FieldView добавим в самый конец метода _create следующий код:

// FielView.ts
//...
private _create(): void {
    // ...
    this.setInteractive();
}
//...


В phaser вы можете подписывать на разные события объекты из пространства имен Phaser.GameObjects. В частности мы подпишем на событие клика (pointerdown) сам префаб спрайта, то есть объект класса FieldView, унаследованного от Phaser.GameObjects.Sprite.
Но прежде чем это сделать, мы должны явно обозначить, что спрайт является потенциально интерактивным, то есть по нему вообще нужно слушать пользовательский инпут. Сделать это нужно вызовом метода setInteractive без параметров на самом спрайте, что мы и сделали в примере выше.

Теперь, когда спрайт у нас стал интерактивным, вернемся в класс Board в то место, где создаются новые объекты модели Field, а именно в метод _createFields и зарегистрируем колбек на события инпута для вьюшки:

// Board.ts
//...
private _createFields(): void {
    for (let row = 0; row < this._rows; row++) {
        for (let col = 0; col < this._cols; col++) {
            const field = new Field(this._scene, this, row, col)
            field.view.on('pointerdown', this._onFieldClick.bind(this, field));
            this._fields.push(field);
        }
    }
}
//...


Раз мы установили, что по клику на спрайт мы хотим запускать метод _onFieldClick, то нужно его реализовать. Но саму логику обработки клика мы вынесем из класса Board. Есть мнение, что обрабатывать модель в зависимости от инпута и соответственно изменять ее данные лучше в отдельном контроллере, подобием которого у нас является класс игровой сцены GameScene. Значит, нам нужно пробросить событие клика дальше, из класса Board в саму сцену. Так и поступим:

// Board.ts
//...
private _onFieldClick(field: Field, pointer: Phaser.Input.Pointer): void {
    if (pointer.leftButtonDown()) {
        this.emit(`left-click`, field);
    } else if (pointer.rightButtonDown()) {
        this.emit(`right-click`, field);
    }
}
//...


Здесь мы не просто пробрасываем событие клика, как оно было, но еще и уточняем, какой именно клик это был. Это будет полезно в дальнейшем, когда в классе сцены мы по разному будем обрабатывать каждый вариант. Конечно, можно было бы отправить событие клика, как есть, но мы упростим код сцены, оставив часть логики, касающейся самого события, в классе Field.
Ну, а теперь вернемся в класс игровой сцены GameScene и добавим в конец метода _create код, отслеживающий события клика по ячейкам:

// Board.ts
//...
import { Field } from "../models/Field";
//...
public create(): void {
    this._board = new Board(this, Rows, Cols, Bombs);
    this._board.on('left-click', this._onFieldClickLeft, this);
    this._board.on('right-click', this._onFieldClickRight, this);
}

private _onFieldClickLeft(field: Field): void {
}

private _onFieldClickRight(field: Field): void {
}
//...

4.2. Обработка клика левой кнопки мыши


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

  1. при клике по закрытой ячейке ее следует открыть
  2. если в открытой ячейке мина — игра проиграна
  3. если в открытой ячейке нет ни мины, ни значения, значит мин нет и в соседних ячейках, в таком случае требуется открыть все соседние ячейки и продолжать делать так до тех пор, пока в открываемой ячейке не появится значение
  4. при клике по открытой ячейке следует проверить, правильно ли расставлены все флаги и если это так, тогда завершаем игру победой


И теперь для упрощения понимания требуемого функционала переведем озвученную выше логику в псевдокод:

если ячейка закрыта
       открыть ее
       если она заминирована
           игра проиграна
        если она пуста
            открыть соседей
если ячейка открыта
    если вся доска помечена флагами корректно
        игра выиграна


Теперь у нас есть понимание того, что нужно запрограммировать. Реализуем метод _onFieldClickLeft:

// GameScene.ts
//...
private _onFieldClickLeft(field: Field): void {
    if (field.closed) { // если ячейка закрыта
        field.open(); // открыть ее

        if (field.mined) { // если она заминирована
            field.exploded = true;
            this._onGameOver(false); // игра проиграна
        } else if (field.empty) { // если она пуста
            this._board.openClosestFields(field); // открыть соседей
        }
    } else if (field.opened) { // если ячейка открыта
        if (this._board.completed) { // и вся доска помечена флагами корректно
            this._onGameOver(true); // игра выиграна
        }
    }
}
//...

А дальше, как всегда, доработаем классы Field и Board, реализовав в них те методы, которые мы вызываем в обработчике.

Укажем 3 возможных состояния ячейки в перечислении States, добавим поле _state и реализуем по геттеру для каждого возможного состояния:

// Field.ts
enum States {
   Closed = 'closed',
   Opened = 'opened',
   Marked = 'flag'
};
export class Field extends Phaser.Events.EventEmitter {
    private _state: string = States.Closed;
    //...
    public get marked(): boolean {
        return this._state === States.Marked;
    }

    public get closed(): boolean {
        return this._state === States.Closed;
    }

    public get opened(): boolean {
        return this._state === States.Opened;
    }
 //...
 


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

// Field.ts
//...
public open(): void {
    this._setState(States.Opened);
}

private _setState(state: string): void {
    if (this._state !== state) {
        this._state = state;
        this.emit('change');
    }
}
//...
 


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

Добавим булев флаг _exploded для явного указания именно того объекта Field, который был взорван:

// Field.ts
private _exploded: boolean = false;
//...
public set exploded(exploded: boolean) {
    this._exploded = exploded;
    this.emit('change');
}
public get exploded(): boolean {
    return this._exploded;
}
//...
 


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

открыть соседей:
    для каждой соседней ячейки
        если она закрыта
            открыть ячейку
            если она пуста
                открыть соседей этой ячейки


И на этот раз у нас уже есть все необходимые интерфейсы для полной реализации этого метода:

// Board.ts
//...
public openClosestFields(field: Field): void {
    field.getClosestFields().forEach(item => {// для каждой соседней ячейки
        if (item.closed) {// если она закрыта
            item.open();// открыть ячейку

            if (item.empty) {// если она пуста
                this.openClosestFields(item);// открыть соседей этой ячейки
            }
        }
    });
}
//...

Добавим геттер completed в класс Board для обозначения корректности расстановки флагов на доске. Как мы можем определить что доска успешно разминирована? Число правильно отмеченных полей должно равняться общему число бомб на доске.

// Board.ts
//...
public get completed(): boolean {
    return this._fields.filter(field => field.completed).length === this._bombs;
}
//...


Этот метод фильтрует массив _fields по геттеру completed, который должен обозначать валидность отметки поля. Если длина отфильтрованного массива (в который попадают только правильно отмеченные поля, за что отвечает геттер completed уже у класса Field) равна значению поля _bombs (то есть числу бомб на доске), то возвращаем true, иначе говоря, считаем игру выигранной.
Нам также не помешает возможность одним вызовом открыть всю доску, что нам предстоит сделать при завершении уровня. Эту возможность также добавим в класс Board:

// Board.ts
//...
public open(): void {
    this._fields.forEach(field => field.open());
}
//...


Остается добавить геттер completed в сам класс Field. В каком случае поле будет считаться успешно разминированным? Если оно и заминировано, и отмечено флагом. Оба нужных геттера уже есть и мы можем добавить такой метод:

// Field.ts
//...
public get completed(): boolean {
    return this.marked && this.mined;
}
//...


Для завершения обработки левого клика мыши создадим метод _onGameOver, в котором отключим отслеживание событий доски и покажем игроку всю доску. Позже мы также добавим в него код рендеринга сообщения о статусе завершения уровня на основе параметра status.

// GameScene.ts
//...
private _onGameOver(status: boolean) {
    this._board.off('left-click', this._onFieldClickLeft, this);
    this._board.off('right-click', this._onFieldClickRight, this);
    this._board.open();
}
//...

4.3 Отображение поля


Прежде чем начать обрабатывать клик правой кнопкой научимся перерисовывать только что открытые ячейки.
Ранее в классе Field мы разработали метод _setState, который запускает событие change при изменении состояния модели. Воспользуемся этим и в классе FieldView отследим данное это событие:

// FieldView.ts
//...
private _init(): void {
    //...
    this._model.on('change', this._onStateChange, this);
}
private _onStateChange(): void {
    this._render();
}
private _render(): void {
    this.setFrame(this._frameName);
}
//...

Мы специально сделали промежуточный метод _onStateChange колбеком события изменения модели. В дальнейшем нам потребуется проверять, как именно была изменена модель, чтобы понять, нужно ли выполнять _render.

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

Для получения фрейма в одну строку мы хитро использовали геттер _frameName, который теперь нужно реализовать. Сперва опишем все возможные значения, какие может принимать фрейм ячейки.


Мы получили описание всех состояний и уже имеем все методы модели, благодаря которым эти состояния можно получить. Заведем небольшой конфиг в начале файла:

// FieldView.ts
const States = {
    'closed': field => field.closed,
    'flag': field => field.marked,
    'empty': field => field.opened && !field.mined && !field.filled,
    'exploded': field => field.opened && field.mined && field.exploded,
    'mined': field => field.opened && field.mined && !field.exploded
}
//...


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

// FieldView.ts
//...
private get _frameName(): string {
    for (let key in States) {
        if (States[key](this._model)) {
            return key;
        }
    }

    return this._model.value.toString();
}


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

© Habrahabr.ru