Phaser 3 FTL клон

Aleks
The Rollins Scopes School
11 min readJul 19, 2020

Предисловие, или кто я

  1. Меня зовут Андрей, мне 25 лет. Работаю я геодезистом на стройке. Образование имею как среднее специальное, так и верхнее (промышленное и гражданское строительство (инженер-строитель)).
  2. Программированием я начал заниматься в ноябре 2019 года (т.е. на данный момент 9 месяцев с первого console.log). До RS-School занимался по “Современному учебнику JavaScript”.
  3. О школе узнал случайно. Я из тех людей кто всегда считал программирование для ну ооооочень умных людей, и мне со своими способностями там делать нечего. Я даже не знал о крупных IT компаниях проводящих обучение. В общем, от знакомой я узнал о EPAM, и о том что у них есть обучающие курсы, так я попал в RS-School занимался (благополучно опоздав на первые 2 таска, но тем не менее доучившись).
  4. Об обучении. На данный момент есть огромное количество различного обучающего контента, не говоря уже о платных курсах, но ключевая проблема ИМХО это конкретное ТЗ. И школа его дает, у вас на руках есть достаточно конкретная задача, разбитая на пункты, что сильно упрощает ведение работы (слона надо есть по частям). Да, помимо заданий еще проходят онлайн лекции по разным темам, но я их доблестно игнорировал (все таки лекторы программисты, а не ораторы, и опять же формат стрима мне в этом плане кажеться не столь удачным. Конечно можно задать интересующий вопрос лектору, но все же приятнее смотреть видео без отвлечение на чат лектором.)). Так же регулярно проводятся тесты знаний, на начальном этапе обучения (да и в принципе сейчас) это позволяет обнаружить пробелы в знаниях и понять что ты не совсем “лох чилийский”, а что то да знаешь. Сильно помогает чат(я в основном зависал в Discord, но так же есть Telegram). Помогает тем что можно как попросить помощи, (“гуглинг” не всегда спасает или дает не столь быстрый результат) так и самому помочь. Мне помощь другим позволяет чувствовать себя более уверенно, да и разобраться в чужом коде тоже не тривиальная задача, как и возможность учиться на чужих ошибках. Ну и не стоит забывать о менторе. В определенный момент студент становиться падаваном и ему выдают ментора (см. джедай). С этого момента твои знания и код оценивает живой, настоящий программист, от которого ты узнаешь: что такое хорошо, а что такое плохо.
  5. За все время обучения я сделал:

Статья про PS-School если не знаете что это за зверь

Немного о подготовительном этапе

Помимо меня над заданием трудился Игорь (Geralld), задание же все таки групповое. В помощь был выдан ментор - Станислав Кирсанов. В Discord был создан чат для общения, ну и конечно же велась доска в Trello. Первое время созванивались часто, дальше реже, но тут скорее сила обстоятельств, ИРЛ жизнь берет свое.

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

Как вы можете видеть, решено было сконцентрироваться на стадии боя (именно там в оригинале используется бОльшая часть разнообразных механик). Возможно, будь команда из большего количества людей, то получилось бы реализовать и другие стадии игры. На данный же момент у нас отсутствует финальный бос, адекватная статистика и магазин (как и прокачка корабля). Но это не сложно реализовать на уже имеющемся “фундаменте”.

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

  • Системы у корабля разные, т.е. эффект от работы и взаимодействия разный, но глобально система умеет изменять количество энергии в себе (в зависимости от уровня системы, и наличия энергии в реакторе), получать урон и ремонтировать себя.
  • Члены команды выходят довольно однотипными, у них лишь отличаются спрайты, и некоторые величины (здоровье, урон, скорость) обусловленные в первую очередь их расой.

Из того что не успели реализовать:

  • Бой команды (т.е. сражения между человечками). Примитивно его можно ввести и сейчас, но для того чтобы сделать качественно и на уровне нужно просчитывать положение дерущихся человечков, включать подходящую анимацию, а так же создавать выстрел отправлять его во врага, фиксировать коллизии. В общем, много микро задач, а времени мало ХД.
  • Воздух на корабле. В оригинале система жизнеобеспечения постоянно пополняет счетчик воздуха, и если её отключить/сломать, то уровень кислорода будет падать и при падении ниже 10% экипаж начнет задыхаться.
  • Двери же позволяют производить микроменеджмент с кислородом устраивая локальные разгерметизации, которые помогают потушить пожар или наносят урон абордажной команде.

Реализованы основные системы участвующие в бою:

  • Оружейная система. Она назначает цель для отдельной пушки, активирует и отключает оружие, а также создает экземпляры класса Weapon — непосредственно оружие.
  • Щиты. За каждые 2 энергии корабль получает слой щита, 1 слой разрушается при попадании и переходит в стадию перезарядки (2 секунды).

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

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

Запись геймплея

Немного скриншотов

Основное меню
Основное меню
Краткое руководство
Спокойная стартовая сцена
Бой не заставляет долго ждать
Враг повержен
Надо бы залатать повреждения полученные в бою

Реализация

Игра сделана с использованием движка Phaser3 который использует canvas и WebGL. Мы использовали ES6 синтаксис классов и TypeScript (имхо я его плохо применял, но это мое первое знакомство с ним).

структура проекта
  • В папке assets храним все аудио файлы, картинки, карты тайлов, шрифты и т.п.
  • в build храниться сжатый js файл.
  • в src лежат модули игры.
  • game берет config файл из констант, добавляет туда сцены, и создает игру.

Основной config файл игры, здесь мы задаем размеры экрана, и подключаем плагины(Использовали matterCollision который упрощает работу с физикой материи и rexUI для создания интерфейса (позволяет создавать popup, кнопки, диалоговые окна и т.п. )).

Конфиг файл
файл игры

Как вы видите в нашей игре всего 10 сцен. Сцена обычно предстставляет собой 1 экран. Но не всегда. К примеру Preload существует только для того что бы загрузить пару картинок которые используются для отображения процесса загрузки в сцене Boot (вместо картинки можно просто нарисовать прямоугольник и текст но так симпатичнее).

Ну и наверное стоит объяснить конструкцию Сцены в Phaser.

Сцена это класс который имеет множество свойств и методов но основные 3:

preload() — метод отрабатывает первым, в нем обычно грузят картинки, музыку и т.п. материалы которые после этого можно легко достать из кэша.

create() — метод в котором создают игровые объекты, окружение, и т.п. используя материалы загруженные в preload методе.

update() — метод который регулярно вызывается (зависит от настроек, но у нас это примерно 60 вызовов в секунды (привет 60fps)). Используется, например, для динамического обновления интерфейса. Из-за частоты запуска крайне не рекомендуется делать в нем сложные вычисления.

так же важно передать в конструктор класса (в супер) ключ — имя которое Phaser использует для обозначения сцены.

Boot сцена

В Boot сцене грузиться большое количество разных файлов (порядка 300) поэтому что бы избежать дублирования this.load.image(‘imageName’, ‘assets/asset/imagelink’) используем цикл.

Здесь же создается полоска отображения хода загрузки благодаря событию “progress” — this.load.on(‘progress’, this.progressHandler, this);

Для запуска другой сцены используем this.scene.start(‘sceneKey’)(sceneKey — ключ сцены переданный в конструктор). При этом текущая сцена останавливается(не отображается и в целом не работает).

Для запуска сцены параллельно текущей используется this.scene.run(‘sceneKey’). К примеру таким образом мы добавляем врага или интерфейс игры к основной игровой сцене. используя же this.scene.stop(‘sceneKey’). можно остановить сцену (без аргументов остановит текущую).

Создание корабля

В основную (и боевую) сцены мы добавляем свойство ship с классом корабль. Класс берет данные из объекта и по ним создает соответсвующее изображение корабля, системы и субсистемы, а также команду. Для того чтобы реализовать сохранение и загрузку создан класс Save от которого наследуются корабль, системы и субсистемы. Просто взять и сделать JSON из объекта корабля не выйдет т.к. из-за ссылок на сцену имеет цикличную структуру (этим в принципе грешат все Игровые объекты создаваемые Phaser, в большинстве случаев это даже удобно т.к. позволяет обратиться к методам сцены но тут вышло боком)

В методе create происходит большая часть магии по созданию корабля: создание тайловой карты (5 слоев тайлов); перерасчет размера корабля (т.к. в размер тайловой карты входят так же пустые тайлы, а нам их не надо учитывать, чтобы рисовать эллипс щита верных размеров); создание комнат (нужно для отображения выделения комнаты и ведения огня по комнатам, а не пустым тайлам/стенам. Также комнаты обрабатывают клик для отправки члена команды, или позиционируют прицел на вражеском корабле); создание систем, подсистем, и команды (по сути просто инициализация классов по взятым из объекта или карты тайлов свойств).

А также несколько методов для обработки получения урона/починки, увеличения и уменьшения денег (в данном случае scrap); обработчик уворота (если по кораблю произошел промах, то члены команды на мостике и у двигателя получают опыт, а игрок видит сообщение о промахе).

Создание систем/подсистем

Для создания систем (подсистем) перебираем в цикле соответсвующее значения объекта с данными для чего используем Object.entries (не обращайте внимание на <any> это результат моей войны с TypeScript)

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

System наследуется от родительского класса SubSystem и имплементирует методы по изменению энергии (работа системы зависит от наличия энергии и её количества). Энергия берется из реактора (тоже системы):

Таким образом в игре есть 3 субсистемы и 10 систем:

Создание Экипажа

Каждый член экипажа представляет собой экземпляр класса Crew отнаследованный от класса Sprite (класса движка Phaser). Именно таким образом советуют добавлять и реализовывать игровые сущности в официальной документации. Итак, член команды имеет имя (берется случайное из массива с 1000 имен), здоровье, скорость ремонта, ходьбы и умения (создаются отдельным классом). А также методы для поиска пути, ремонта, работы в системах. При создании экземпляра класса происходит объединение с объектом расы (там указаны специфичные свойства или нестандартные показатели урона/починки/скорости). Также генерируется сразу несколько наборов текстур (для выделенного/невыделенного состояния, и для вражеского экипажа, по сути отличие только в обводке). scene.add.existing(this) — добавляет объект в игру (может использоваться не только для спрайтов, но и для любых других отнаследованных от Phaser классов)

Pathfinding

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

Метод moveCharacter получает массив с координаторами тайлов пути до цели, они обрабатываются чтобы получить необходимое направление движения (нужно для проигрывания соответствующей анимации). this.scene.tweens.timeline отработает перемещение персонажа по координатами и проигрывание анимации.

Физика материи

Достаточно сложный момент при создании игры: проверка столкновения выстрела и щита. Мы оказались перед выбором что использовать:

1. Метод эллипса contains(x, y) (х и у координаты) т.е. просто регулярно проверять находятся ли координаты выстрела внутри эллипса.

2. Использовать аркадную физику и фиксировать столкновения this.physics.add.overlap(shot, shield, overlapHandler, null, this); И это казалось хорошим вариантом, но проблема была в том, что аркадная физика умеет работать только с прямоугольниками и кругами, а щит имеет форму эллипса. Был вариант сделать 3 круга и таким образом симулировать эллипс (физическое тело обычно не отображается, так что игрок не заметил бы подвох).

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

Для создания щита нам нужны координаты центра эллипса и его ширина/высота, эти данные получаем исходя из размеров корабля. Дальше создаем эллипс (ellipse), он сразу будет отрисован в сцене. Потом создаем объект геометрии (ellipseGeom); используя его методы получаем координаты точек эллипса, которые преобразуем в строковый формат и используем при создании физического тела. this.body.setSensor(true) позволяет фиксировать столкновение без отскоков и т.п. эффектов физики.

Звездная карта

Карта в FTL представляет из себя неориентированный граф, где звезды — это вершины, а переходы между звездами — ребра.

Нам не пришлось решать как-либо классических задач из теории графов (например, Задача коммивояжёра). Необходимо было имплементировать хранение вершин и ребер, возможность работы с ними, а также генерацию самого графа исходя из определенных параметров.

Как результат был реализован класс StarMap, который содержал в себе 2 основных класса для работы с графом: class Node и class Graph.

Класс Node описывает вершину графа и содержит дополнительные флаги для определения является ли вершина текущим положением корабля, например, доступна ли для прыжка, является ли выходом, посещалась ли ранее и т.д.

Класс Graph описывает звездную карту с точки зрения графа и состоит из вершин и ребер. Ребра были реализованы при помощи Map<Node, Node[]>, где ключ является вершиной (звездой), а значение — массивом вершин к которым есть доступ у текущей вершины.

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

Для этого на первом этапе звезды разбиваются на группы, внутри которых генерируются ребра:

Вторым этапом является генерация ребер между группами:

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

Geralld О Звездной карте

Поговорю про камеру, взаимодействие между сценами и Rex UI плагин

Мое небольшое пособие по созданию карты тайлов и работе с Tiled

PS

На данный момент я закончил обучение и ищу где бы применить полученные навыки, если я могу быть вам интересен в качестве сотрудника, то обращайтесь (mail: a12344@mail.ru, tel: +375336443245).

--

--