Roguera: как препод рогалик на Java делал. Часть 3
Финальная часть о том, как препод сделал игру и выучил Java. С кучей подробностей, кода и трижды украденной БД.
Краткий рекап из предыдущей части
Я преподаватель Java в одном из московских вузов. Так сложилось, что этот проект послужил эдакой учебной площадкой для прокачки собственных навыков программирования на достаточно высоком уровне. В первых двух частях дневника я описал разработку игры: строение игрового поля, генерация комнат, способ рендеринга, движение и другие мелочи.
В этой заключительной части я расскажу, как переосмыслил почти всю архитектуру игры и видоизменил многие её аспекты. Разумеется, без вставок кода не обойдётся.
В этот раз никакого дисклеймера про устаревший код; конечно же, репозиторий относительно актуальный (для мая 2022 года), поэтому буду рад конструктивному код ревью от шарящих.
Помимо этого, в предыдущей статье я приводил алгоритм генерации карты и упоминал, что по ней также готовится статья. Так вот, она успешно опубликована внутри универского журнала, приглашаю к ознакомлению:
Кстати! Я всё-таки решил зарелизиться и выпустить для всех версию 0.3.0, так что скачать да поиграть можно вот тут. Помимо этого, я поднял таблицу очков на отдельном сайте: roguera.tms-studio.ru.
Напоминание о чём игра
Roguera — это классический псевдографический рогалик с основными жанровыми особенностями:
- процедурная генерация уровней/монстров/предметов;
- перманентная смерть (при выходе из игры можно сохраниться);
- текстовый лог, описывающий происходящее;
- примитивные эффекты через ASCII цвет.
Также присутствуют очки за прохождение и таблица рекордов со статистикой, которая крутится здесь.
Структура игровой карты
В первую очередь, поводом для пересмотра архитектуры игры была игровая карта. Напомню, что в первой версии карта представляла собой двумерный MxN массив символов, над которым производились все операции типа движения героя, размещения предметов и т.д. По многим причинам, это было весьма неудобно, в частности:
Нельзя было в одной ячейке одновременно держать несколько объектов - для этого пришлось бы делать отдельный стек объектов;
Сложно управлять тем, какой именно объект находится в данной ячейке - только через сравнение символов (а из-за двумерности массива сложность каждого алгоритма сравнения была O(n^2));
• Усложнение кодовой базы из-за необходимости как-то манипулировать массивом – в связи с этим было очень больно расширять функциональность;
- Ну и, в целом, это совсем не ООП стиль.
На смену этому подходу нужно было что-то попроще и более функциональное. Так я придумал следующую концепцию.
Вместо того, чтобы каждую клетку описывать как ячейку с символом в массиве, я создал отдельный класс Cell, который, соответственно, описывает клетку как отдельную структуру данных. Клетка в себе хранит:
- Позицию по X;Y внутри структуры Room (комнаты), эта же позиция нужна для рендера содержимого клетки в соответствующем месте окна терминала.
Коллекцию игровых объектов, которые в этой клетке находятся ; при этом их отрисовка осуществляется так, что виден лишь тот объект, который последним положен в набор.
- Булевый флаг "Это стена?" - не могу сказать, что нельзя без него было обойтись, тем не менее, это упрощало проверку клетки на наличие стен, углов и прочих структурных элементов комнаты, т.к вместо поиска внутри коллекции, можно сразу отметить клетку как стену и проверять только этот флаг.
Методы по типу «найти в клетке, положить в клетку, убрать с клетки» и т.п.
Ссылку на комнату, которой эта клетка принадлежит.
Приведу код этого класса ниже:
P.S. Предложение разработчикам сайта - вместо тёмной темы сделать всё-таки сворачиваемые блоки, чтобы не пугать читателей длинным кодом.
Далее, эти клетки хранятся в другой коллекции в классе Room (комната) . Их количество зависит от размеров комнаты и равно её площади (X * Y для тех, кто забыл). Допустим, на комнату размером 10 столбцов и 10 строк - у нас всё же текстовый интерфейс - приходится 100 клеток. К счастью, на память это не сильно влияет: при анализе профилировщиком размер одной клетки равен 45 байтам.
Комнаты хранятся внутри коллекции в классе Floor (этаж), а этажи хранятся в классе Dungeon (подземелье). Получается следующая матрёшка:
Благодаря такой структуре стало возможным гибкое манипулирование игровой картой и добавлять на неё разные фишки по типу тумана и точного позиционирования объектов (с помощью набора точек, которые находятся внутри заданного периметра).
Кстати, про туман. Его особенность в том, что функция его раскрытия игроком является рекурсией, так как во время раскрытия определяется, насколько глубоко игрок может увидеть при проходе сквозь него.
За игнорирование исключения и "__" в названии переменной меня могут заминусить. Как бы я сделал сейчас: воспользовался классом Optional и через метод ifPresentOrElse() взаимодействовал с содержимым клетки, если оно присутствует в контейнере.
Оконный интерфейс
Частично об этом я рассказывал в предыдущей части дневников.
Lanterna – замечательный фреймворк, который легко использовать "неправильно" или, по крайней мере, не пользоваться хорошими возможностями. Дело в том, что он позволяет отрисовывать хорошие GUI элементы а-ля дизайн из MS-DOS программ, однако для использования приходилось бы переключать экран игры на отдельный слой и перерисовывать всё обратно. Это, на мой взгляд, было не лучшим решением, поэтому я принял решение собрать своё подобие оконного интерфейса (заодно и разобраться, как оно в теории работает).
Идея была такая: окно рисуется поверх элементов в середине экрана, и контекст управления переключается на него. После закрытия окна программа перерисовывает затронутые элементы и возвращает контекст управления фигуркой персонажа.
У меня получилась такая иерархия классов:
IViewBlock – это общий для всех окон интерфейс, который описывает три метода: Init(), Draw(), Reset():
Init - отвечает за первичную инициализацию визуального объекта на экране.
Draw - рендеринг текущего содержимого окна.
Reset - очистка или сброс к первоначальному виду.
За реализацию отвечает абстрактный класс Window, который скрывает под капотом общую логику отрисовки всех окон, а реализацию содержимого оставляет на классы, его расширяющие.
Собственно, за вызов рендеринга отвечает класс Draw:
Например, в метод call передаётся любой объект, реализующий интерфейс IViewBlock, то есть, или окно, или часть интерфейса (пакет UI).
Как выглядит работа с окном инвентаря:
Окна располагаются на верхнем слое отрисовки экрана. На нижнем расположены непосредственно элементы интерфейса, которые распределены по специальной сетке. Она делит экран на три блока: характеристики персонажа, надетая экипировка + слоты быстрого доступа и текстовый лог происходящего.
За отрисовку и логику каждого блока отвечает свой класс из пакета com.roguera.view.ui.
Отдельно хочу отметить логику блока с логами: помимо того, что в нём реализованы переносы текста (ох, и намучился с ними), так и при изменении размера окна лог достаточно корректно перерисовывается с учётом новых значений ширины.
ИИ противников
Ещё одна новинка - это обновлённая логика поиска пути у мобов. В этот раз я замахнулся на реализации алгоритма А*. Если коротко, то мобы находят кратчайший путь к игроку и идут по тому набору позиций, который вернул алгоритм. Вот как это выглядит:
Препятствия так же учитываются:
В остальном, враги так же наносят урон при "касании" с игроком (и наоборот).
Очки рекордов
Ещё одна фишка в новой версии – онлайн-таблица рекордов, которая крутится как веб-приложение на Spring Framework. При этом сохранение очков сделано хитрым образом, чтобы исключить возможность сейвскама или оффлайн-абьюза. Опасно делиться тонкостями безопасности ; тем не менее, в игру также встроена простенькая защита от CheatEngine за счёт проверки хэша очков при каждом изменении переменной. А верификация игровой сессии осуществляется по отслеживанию таймаута пинга от клиента.
Я не слишком боюсь, что кто-то по серьёзке наспамит 99999999 очками в таблице – гораздо интереснее, как ему это удастся (и он честно поделится со мной найденной дырой. Ведь поделится?).
Про разработку сервера на Spring рассказывать опять же особо нечего, так как это достаточно банальная настройка API end-point-ов, микро БД на две таблицы и одно представление, а про систему безопасности я упомянул.
Помимо защиты от сейвскама, у игрока на клиенте хранится персональный ключ аутентификации, который позволяет начать новую игру с тем же именем персонажа.
Discord Rich Presence
Последняя интересная фишка, которую было приятно реализовывать - это интеграция с дискордом. DRP - это API дискорда, которая позволяет отображать различную информацию о том, что конкретно делает пользователь в игре: на каком он уровне, его текущий счёт, время игры и так далее. Для Java существует отдельная библиотека-обёртка, с которой было достаточно удобно работать. В целом, оказывается, что сама интеграция с DRP простая и удивительно, что её не добавляют в каждую ПК игру (например, я был бы рад увидеть эту фичу в Геншине).
Как это выглядит (в качестве арта - название игры на фоне моего кота):
Эта статья выходит сильно позже выхода крайней версии игры. Основная цель разработки - изучение языка Java - достигнута, даже с избытком. Помимо прокаченных навыков программирования, есть ещё и опыт минимум одного проекта, доведённого до рабочего состояния. Конечно, спустя два года разработки, смотришь на весь этот код и понимаешь, как много в нём наивного, неправильного – но он, блин, работает!
Я всегда своим студентам рекомендую при изучении любой новой технологии\языка создавать свой небольшой проект, на разработке которого можно практиковаться. Конечно, не стоит тратить полтора-два года, как я. Пусть это будет что-то небольшое, что можно закончить делать в разумный срок. В принципе, игра – это очень хороший пример, потому что при её разработке затрагиваются чуть ли не все аспекты языка (правда, не уверен, что функциональные ЯП можно учить таким же образом).
Если говорить о том, что было с игрой всё это время – в неё не поиграло какое-то весомое количество игроков (даже полусотни не наберётся), а база данных очков умудрилась трижды быть украденной (кто? зачем?) из-за дефолтного пароля на хосте. Из хорошего - студентам нравится, некоторые просят ссылку поиграть.
Спасибо, что дочитали до конца, буду очень рад комментариям, хорошего дня!