Добрый день, уважаемые коллеги!
Меня зовут Александр, я разработчик HTML5 игр.
В одной из компаний, куда я отправлял свое резюме, мне предложили выполнить тестовое задание. Я согласился и, спустя 1 день, отправил в качестве результата разработанную по ТЗ HTML5 игру.
Поскольку я занимаюсь обучением программированию игр, а также для более эффективного использования своего кода, я решил, что будет полезно написать обучающую статью по выполненному проекту. И раз выполненное тестовое получило положительную оценку и привело к приглашению на собеседование, вероятно мое решение имеет право на существование и, возможно, поможет кому-либо в будущем.
Данная статья даст представление об объеме работ, достаточном для успешного выполнения среднестатистического тестового задания на позицию HTML5 разработчика. Материал также может быть интересен всем, кто хочет познакомиться с фреймворком Phaser. А если вы уже работаете с Phaser и пишете на JS — посмотрите, как разработать проект на TypeScript.
Итак, под катом много кода на TypeScript!
Введение
Приведем краткую постановку задачи.
- Мы разработаем простую HTML5 игру — классического сапера.
- В качестве основных инструментов будем использовать phaser 3, typescript и webpack.
- Игра будет предназначена для десктопа и запускаться в браузере.
Укажем ссылки на итоговый проект.
И напомним механику сапера, если вдруг кто забыл правила игры. Но так как это маловероятный кейс, правила размещены под спойлером :)
При клике левой кнопкой мыши по закрытой ячейке, она открывается. Если в открытой ячейке находилась бомба, то игра завершается поражением.
Если в ячейке не было бомбы, то внутри нее отображается число, обозначающее количество бомб, которые находятся в соседних ячейках по отношению к текущей открытой. Если рядом нет ни одной бомбы, то ячейка выглядит пустой.
Клик правой кнопкой мыши по закрытой клетке устанавливает на ней флаг. Задача игрока состоит в том, чтобы расставить все доступные ему флаги так, чтобы они отмечали все заминированные ячейки. После расстановки всех флагов игрок нажимает левую кнопку мыши на одной из открытых ячеек, чтобы проверить, выиграл ли он.
Далее мы переходим непосредственно к самому руководству. Весь материал разбит на небольшие шаги, каждый из которых описывает реализацию конкретной задачи за короткое время. Так, выполняя маленькие цели шаг за шагом, в итоге мы создадим полноценную игру. Воспользуйтесь оглавлением, если решите быстро перейти к определенному шагу.
1.1 Шаблон проекта
1.2 Конфигурация сборки
1.3 Установка модулей
1.4 Подготовка ассетов
2. Создание сцен
2.1 Точка входа
2.2 Стартовая сцена
2.3 Тексты стартовой сцены
2.4 Переход на игровой уровень
2.5 Сцена уровня
2.6 Установка сцен в точке входа
3. Игровые объекты
3.1 Игровая доска
3.2 Модель ячейки
3.3 Представление ячейки
3.4 Создание спрайта в классе представления
3.5 Позиционирование спрайта
3.6 Создание экземпляра FieldView
3.7 Отображение полей доски.
3.8 Создание бомб
3.9 Установка значений
4. Обработка событий ввода
4.1 Отслеживание событий нажатия кнопок мыши
4.2. Обработка клика левой кнопки мыши
4.4 Обработка правого клика
4.5 Объект GameSceneView
5. Анимации
5.1 Анимация заполнения доски
5.2 Анимации переворота клетки
1. Подготовка
1.1 Шаблон проекта
Скачаем дефолтный шаблон проекта phaser. Это рекомендованный шаблон от автора фреймворка и он предлагает нам следующую структуру директорий:
index.html | HTML страница, запускающая игру | |
webpack/ | base.js | конфиг сборки для тестового окружения |
prod.js | конфиг сборки для продакшена | |
src/ | assets/ | игровые ассеты (спрайты, звуки, шрифты) |
index.js | точка входа |
Для нашего проекта текущий 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
.
- Собрать проект для отладки и открыть в браузере через локальный сервер
npm start
- Запустить сборку для прода и поместить релизный билд в папку dist/
npm run build
1.4 Подготовка ассетов
Все ассеты для данной игры честно скачаны с OpenGameArt (версия 61x61) и имеют самую дружелюбную из лицензий под названием Feel free to use, о чем заботливо нам сообщает страница с паком). Кстати, представленный в статье код имеет такую-же лицензию! ;)
Из скачанного набора я удалил изображение часов, а остальные файлы переименовал так, чтобы получить удобные для использования имена фреймов. Список названий и соответствующие файлы отображены на скрине ниже.
Из полученных спрайтов создадим атлас формата Phaser JSONArray
в программе TexturePacker (фришной версии более чем достаточно, работу ведь я еще не получил) и поместим сгенерированные spritesheet.png
и spritesheet.json
файлы в директорию проекта src/assets/
.
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 шага:
- получить и
png
иjson
файлы атласа, используяrequire
:// StartScene.ts const spritesheetPng = require("./../../assets/spritesheet.png"); const spritesheetJson = require("./../../assets/spritesheet.json"); // ...
- загрузить их в методе
preload
стартовой сцены:// StartScene.ts // ... public preload(): void { this.load.atlas("spritesheet", spritesheetPng, spritesheetJson); } // ...
2.3 Тексты стартовой сцены
В стартовой сцене осталось сделать 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
};
}
//...
Таким образом, мы:
- Получили общую ширину экрана в
this._scene.cameras.main.width
. - Получили общую ширину доски умножив число ячеек на ширину одной ячейки:
this._board.cols * this.width
. - Отняв ширину доски из ширины экрана мы получили место на экране, не занятое доской.
- Разделив полученное число на 2 мы получили величину отступа слева и справа от доски.
- Смещая каждую ячейку на величину этого отступа мы гарантируем выравнивание всей доски по оси
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
, которое будет регулировать содержимое ячейки. Примем следующие соглашения.
_value === 0 |
ячейка пуста и в ней нет ни мин, ни значений |
_value === -1 |
в ячейке находится мина |
_value > 0 |
в ячейке находится число мин, расположенных рядом с текущей ячейкой |
Следуя этим правилам разработаем в классе 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. Обработка клика левой кнопки мыши
Приступим к реализации обработки событий клика мыши. И начнем с открытия ячеек. Открывать ячейки следует при нажатии левой кнопки. И прежде, чем мы приступим к программированию, давайте озвучим условия, которые необходимо выполнить:
- при клике по закрытой ячейке ее следует открыть
- если в открытой ячейке мина — игра проиграна
- если в открытой ячейке нет ни мины, ни значения, значит мин нет и в соседних ячейках, в таком случае требуется открыть все соседние ячейки и продолжать делать так до тех пор, пока в открываемой ячейке не появится значение
- при клике по открытой ячейке следует проверить, правильно ли расставлены все флаги и если это так, тогда завершаем игру победой
И теперь для упрощения понимания требуемого функционала переведем озвученную выше логику в псевдокод:
если ячейка закрыта
открыть ее
если она заминирована
игра проиграна
если она пуста
открыть соседей
если ячейка открыта
если вся доска помечена флагами корректно
игра выиграна
Теперь у нас есть понимание того, что нужно запрограммировать. Реализуем метод _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
, который теперь нужно реализовать. Сперва опишем все возможные значения, какие может принимать фрейм ячейки.
Фрейм | Условие |
closed |
Поле закрыто |
flag |
Поле отмечено флагом |
empty |
Поле открыто, не заминировано и не заполнено значением |
exploded
|
поле открыто, заминировано и взорвано |
mined
|
поле открыто, заминировано, но не взорвано |
1...9
|
поле открыто и выводит значение от 1 до 9, обозначающее число бомб рядом с данным полем |
Мы получили описание всех состояний и уже имеем все методы модели, благодаря которым эти состояния можно получить. Заведем небольшой конфиг в начале файла:
// 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();
}
Таким образом простым перебором в цикле проходим все ключи объекта конфига и вызываем каждый колбек по очереди. Та функция, которая первой вернет нам true
, укажет, что ключ key
на текущей итерации и есть правильный фрейм для актуального состояния модели.
Если ни один ключ не подходит, то за дефолтное состояние будем считать открытое поле со значением _value
, так как именно это состояние в конфиге States
мы не задали.
Теперь мы можем полноценно протестировать левый клик по полям доски и проверить, как открываются ячейки и что в них отображается после открытия.
4.4 Обработка правого клика
Как и в случае с созданием обработчика левого клика, сперва четко определим ожидаемый функционал. По нажатию правой кнопки мыши мы должны помечать выбранную ячейку флажком. Но здесь есть определенные условия.
- Пометить флагом можно только закрытое поле, которое не отмечено в данный момент
- Если поле отмечено, то повторный клик правой кнопкой должен снимать с поля флаг
- При установке/снятии флага необходимо обновлять число доступных флагов на уровне и выводить на экране текст с актуальным количеством
Переводя эти условия в псевдокод мы получим следующие строчки комментариев:
если ячейка закрыта и есть свободные флаги
добавляем флаг в ячейку
если ячейка закрыта и флаг уже есть
удаляем флаг из ячейки
обновляем число доступных флагов и выводим их на экран
Теперь можем перевести этот алгоритм в вызовы нужных нам методов, даже если они еще не разработаны:
// GameScene.ts
private _flags: number = 0;
//...
private _onFieldClickRight(field: Field): void {
if (field.closed && this._flags > 0) { // если ячейка закрыта и есть свободные флаги
field.addFlag(); // добавляем флаг в ячейку
} else if (field.marked) { // если флаг уже есть
field.removeFlag(); // удаляем флаг
}
this._flags = Bombs - this._board.countMarked;
}
//...
public create(): void {
this._flags = Bombs;
//...
}
//...
Здесь мы также завели новое поле _flags
, которое на старте игрового уровня равно числу бомб на доске, так как в начале игры еще ни один флаг не выставлен. Это поле вынуждено обновляться при каждом правом клике, так как в таком случае флаг либо добавляется, либо убирается с доски. Добавим в класс Board
геттер countMarked
:
// Board.ts
//...
public get countMarked(): number {
return this._fields.filter(field => field.marked).length;
}
//...
Установка и снятие флага — это изменение состояния модели Field
, поэтому мы реализуем данные методы в соответствующем классе аналогично методу open
:
// Field.ts
//...
public addFlag(): void {
this._setState(States.Marked);
}
public removeFlag(): void {
this._setState(States.Closed);
}
//...
Напомню, что _setState
запустит событие change
, которое отслеживается во вюьшке и, таким образом, спрайт и в этот раз перерисуется автоматически при изменении модели.
При тестировании разработанного функционала вы непременно обнаружите, что каждый раз при нажатии правой кнопки мыши открывается контекстное меню. Добавим код, отключающий это поведение, в конструктор игровой сцены:
// GameScene.ts
//...
constructor() {
super('Game');
// отключить контекстное меню по клику правой кнопкой
document.querySelector("canvas").oncontextmenu = e => e.preventDefault();
}
//...
4.5 Объект GameSceneView
Для отображение UI на игровой сцене мы создадим класс GameSceneView
и поместим его в src/scripts/views/GameSceneView.ts
.
В данном случае мы поступим отличным от создании FieldView
образом и не станем делать этот класс префабом и наследником GameObjects
.
В данном случае от представление сцены нам требуется вывод следующих элементов:
- текст в числом флагов
- кнопка выхода
- сообщение со статусом завершения игры (победа/поражение)
Сделаем каждый элемент UI сделать отдельным полем в классе GameSceneView
.
Подготовим заглушку.
enum Styles {
Color = '#008080',
Font = 'Arial'
}
enum Texts {
Flags = 'FLAGS: ',
Exit = 'EXIT',
Success = 'YOU WIN!',
Failure = 'YOU LOOSE'
};
export class GameSceneView {
private _scene: Phaser.Scene = null;
private _style: {font: string, fill: string};
constructor(scene: Phaser.Scene) {
this._scene = scene;
this._style = {font: `28px ${Styles.Font}`, fill: Styles.Color};
this._create();
}
private _create(): void {
}
public render() {
}
}
Добавим текст с числом флагов.
// GameSceneView.ts
//...
private _txtFlags: Phaser.GameObjects.Text = null;
//...
private _createTxtFlags(): void {
this._txtFlags = this._scene.add.text(
50,
50,
Texts.Flags,
this._style
).setOrigin(0, 1);
}
//...
Данный код поместит нужный нам текст в позицию с отступом 50px от верхнего и левой стороны и установит ему заданный стиль. Кроме того, метод setOrigin
установил pivot point текста в координаты (0, 1). Это значит, что текст будет выравниваться относительно своей левой границы.
Добавим статусное сообщение.
// GameSceneView.ts
//...
private _txtStatus: Phaser.GameObjects.Text = null;
//...
private _createTxtStatus(): void {
this._txtStatus = this._scene.add.text(
this._scene.cameras.main.centerX,
50,
Texts.Success,
this._style
).setOrigin(0.5, 1);
this._txtStatus.visible = false;
}
//...
Помещаем текст статуса в центр экрана и выравниваем относительно середины строки при помощи вызова setOrigin
с параметром 0.5 для координаты x. Кроме того, по умолчанию данный текст нужно скрыть, так как показывать мы его будем только по факту завершения игры.
Создаем кнопку выхода, которая по своей сути также является текстовым объектом.
// GameSceneView.ts
//...
private _btnExit: Phaser.GameObjects.Text = null;
//...
private _createBtnExit(): void {
this._btnExit = this._scene.add.text(
this._scene.cameras.main.width - 50,
50,
Texts.Exit,
this._style
).setOrigin(1);
this._btnExit.setInteractive();
this._btnExit.once('pointerdown', () => {
this._scene.scene.start('Start');
});
}
//...
Помещаем кнопку в правый верхний угол экрана и снова используем setOrigin
, чтобы выровнять текст на этот раз относительно его правого края. Делаем кнопку интерактивной и добавляем колбек на событие клика, который отправляет игрока на стартовую сцену. Таким образом даем игроку возможность выйти с уровня в любой момент.
Остается разработать метод render
для корректного обновления всех элементов UI и добавить вызовы всех созданных методов в _create
.
// GameSceneView.ts
//...
private _create(): void {
this._createTxtFlags();
this._createTxtStatus();
this._createBtnExit();
}
public render(data: {flags?: number, status?: boolean}) {
if (typeof data.flags !== 'undefined') {
this._txtFlags.text = Texts.Flags + data.flags.toString();
}
if (typeof data.status !== 'undefined') {
this._txtStatus.text = data.status ? Texts.Success : Texts.Failure;
this._txtStatus.visible = true;
}
}
//...
В зависимости от переданного в параметре свойства обновляем UI, выводя нужные изменения.
Создадим представление в игровой сцены в классе GameScene и пропишем вызов метода _render во везде, где это требуется по смыслу:
// GameScene.ts
//...
import { GameSceneView } from "../views/GameSceneView";
//...
export class GameScene extends Phaser.Scene {
private _view: GameSceneView = null;
//...
private _onGameOver(status: boolean) {
//...
this._view.render({status});
}
//...
private _onFieldClickRight(field: Field): void {
//...
this._flags = Bombs - this._board.countMarked;
this._view.render({flags: this._flags});
}
//...
public create(): void {
//...
this._view = new GameSceneView(this);
this._view.render({flags: this._flags});
}
//...
}
5. Анимации
Какой же фан от создания игры, даже такой простой как наша, если в ней нет анимаций?! Тем более, раз уж мы начали изучать phaser, давайте познакомимся с самыми базовыми возможностями анимаций и рассмотрим функционал твинов. Твины здесь реализованы в самом фреймворке и никаких сторонних библиотек подключать не требуется.
Добавим в игру 2 анимации: заполнение доски ячейками на старте и переворот ячейки при открытии. Начнем с первой из перечисленных.
5.1 Анимация заполнения доски
Сделаем так, чтобы все ячейки доски прилетали на свое место из левого верхнего края экрана. При запуске игрового уровня нам нужно сместить все ячейки за левый верхний угол экрана и для каждой ячейки запустить анимацию движения до соответствующих ей координат.
В классе FiledView
добавим в конец методы _create
вызов _animateShow
:
// FieldView.ts
//...
private _create(): void {
//...
this._animateShow();
}
//...
B реализуем нужный нам новый метод. В нем, как мы и договорились выше, необходимо выполнить 2 вещи:
- сместить ячейку за левый верхний угол так, чтобы ее не было видно на экране
- запустить твин движения до нужных координат с правильной задержкой
// FieldView.ts
//...
private _animateShow(): Promise<void> {
this.x = -this.width;
this.y = -this.height;
const delay = this._model.row * 50 + this._model.col * 10;
return this._moveTo(this._position, delay);
}
//...
Так как левый верхний угол канваса имеет координаты (0, 0), то если мы зададим ячейке координаты, равные ее отрицательным значениям ширины и высоты, это поместит ячейку за левый верхний угол и скроет с экрана. Таким образом мы выполнили нашу первую задачу.
Вторую цель вы достигнем вызовом метода _moveTo
.
// FieldView.ts
//...
private _moveTo(position: Vec2, delay: number): Promise<void> {
return new Promise(resolve => {
this.scene.tweens.add({
targets: this,
x: position.x,
y: position.y,
duration: 600,
ease: 'Elastic',
easeParams: [1, 1],
delay,
onComplete: () => {
resolve();
}
});
});
}
//...
Для создания анимации мы используем свойство сцены tweens
. В его метод add
мы передаем конфигурационный объект с настройками:
- Свойство
targets
здесь должно содержать в качестве значения те игровые объекты, к которым требуется применить эффекты анимации. В нашем случае это ссылкаthis
на текущий объект, так как он является префабом спрайта. - Вторым и третьим параметром мы передаем координаты точки назначения.
- Свойство
duration
отвечает за длительность анимации, в нашем случае — 600мс. - Параметры
ease
иeaseParams
устанавливают easing функцию. - В поле delay подставляем значение из второго аргумента, которое генерируется для каждой отдельно взятой ячейки с учетом ее позиции на доске. Это сделано для того, чтобы ячейки не вылетали одновременно. Вместо этого каждая ячейка будет появляться с небольшой задержкой относительно предыдущей.
- Наконец, в свойство
onComplete
мы помещаем колбек, который будет вызван по окончании действия твина.
Разумно обернуть твин в промис, чтобы в дальнейшем иметь возможность красиво состыковать разные анимации, поэтому в колбек мы поместим вызов функции resolve
, обозначающий успешное выполнение анимации.
5.2 Анимации переворота клетки
Будет здорово, если при открытии ячейки воспроизводился эффект ее разворота обратной стороной. Как мы сможем этого добиться?
Открытие ячейки в настоящее время осуществляется сменой фрейма при вызове метода _render
во вьюшке. Если мы проверим состояние модели в этом методе, мы поймем, была ли ячейка открыта. В случае, если клетка была открыта, запустим анимацию вместо моментального отображения нового фрейма разворота.
// FieldView.ts
//...
private _onStateChange(): void {
if (this._model.opened) {
this._animateFlip();
} else {
this._render();
}
}
//...
Для получения нужного эффекта воспользуемся трансформацией спрайта через свойство scale
. Если мы будем с течением времени скейлить спрайт по оси x
до нуля, в конце концов он сожмется, соединив левую и правую стороны. И наоборот, если скейлить спрайт по оси x
от нуля до его полной ширины, мы его растянем до его полного размера. Реализуем эту логику в методе _animateFlip
.
// FieldView.ts
//...
private _animateFlip(): void {
this._scaleXTo(0).then(() => {
this._render();
this._scaleXTo(1);
})
}
//...
По аналогии с методом _moveTo
реализуем _scaleTo
:
// FieldView.ts
//...
private _scaleXTo(scaleX: number): Promise<void> {
return new Promise(resolve => {
this.scene.tweens.add({
targets: this,
scaleX,
ease: 'Elastic.easeInOut',
easeParams: [1, 1],
duration: 150,
onComplete: () => {
resolve()
}
});
});
}
//...
В данном методе в качестве параметра мы принимаем значение скейла, которое будем использовать для изменения размера спрайта в обе стороны и передаем его вторым параметром в конфигурационный объект анимации. Все остальные параметры конфигурации нам уже знакомы по предыдущей анимации.
Теперь запустим проект для тестирования и после отладки будем считать нашу игру завершенной, а тестовое задание выполненным! :)
Искренне благодарю всех за то, что добрались вместе со мной до этого момента!
Заключение
Коллеги, я буду очень рад, если вам пригодится материал, изложенный в статье и вы сможете использовать те или иные описанные подходы в своих собственных проектах. Вы всегда можете обратиться ко мне с любым вопросом как по данной статье, так и по программированию на phaser или работе в геймдеве в целом. Я приветствую общение и буду рад приобрести новые знакомства и обменяться опытом!
А у меня к вам есть вопрос уже сейчас. Так как я занимаюсь созданием видео уроков по разработке игр, у меня естественным образом накопился десяток подобных небольших игр. Каждая игра раскрывает фреймворк по своему. Например, в данный игре мы коснулись темы твинов, но еще есть множество других возможностей, например физика, tilemap, spine и др.
В связи с этим вопрос, пришлась ли данная статья вам по душе и, если да, то будет ли вам интересно и дальше читать статьи, подобные этой, но про другие небольшие классические игры? Если ответ положительный, я с радостью переведу материалы своих видео уроков в текстовый формат и продолжу с течением времени публиковать и новые руководства, но уже по другим играм. Привожу соответствующий опрос.
Всех благодарю за внимание! Буду рад обратной связи и до встречи!
Автор: Александр Болотников