Roguera: как препод рогалик на Java делал. Часть 2
Краткий рекап из предыдущей части
Я преподаватель Java в одном из московских вузов. Так сложилось, что этот проект послужил эдакой учебной площадкой для прокачки собственных навыков (достаточно хорошо) программирования. В первой части дневника я описал самое начало разработки: строение игрового поля, генерация комнат, способ рендеринга и движение персонажа.
В этой части мы продолжим рассматривать процесс разработки рогалика и речь пойдёт в том числе о процедурной генерации комнат.
Дисклеймер: описанная здесь версия игры уже является весьма устаревшей и соответственно качество кода также оставляет желать лучшего. Поэтому прежде чем проводить оценочные суждения об навыках автора, лучше ознакомиться с актуальной кодовой базой на github-е проекта.
Недавно я всё таки решил зарелизиться и выпустить для всех версию 0.3.0, так что скачать да поиграть можно вот тут.
Алсо, не знакомые с Java, могут спокойно игнорировать куски кода , так как я постараюсь отдельно описывать работу игровых систем.
Напоминание о чём игра
Roguera — это классический псевдографический рогалик, со всеми вытекающими особенностями: процедурная генерация уровней/монстров/предметов, перманентная смерть (при выходе из игры можно сохраниться) , текстовый лог, описывающий происходящее, примитивные эффекты через ASCII цвет. Также присутствуют очки за прохождение и таблица рекордов со статистикой, которая крутится здесь.
Как написать процедурную генерацию комнат и не сойти с ума
По сути алгоритм представляет собой генерацию итоговой структуры по точкам.
Напомню, что наша карта (комната) хранится в памяти как двумерный массив символов.
При генерации подземелья (как последовательности комнат) , для каждой из них определяется размер по ширине и высоте:
Класс RoomSize представляет собой перечисление констант SMALL, middle, BIG, в которых хранятся массивы с числами ширины и высоты:
Данные числа из массивов выбираются случайным образом.
Итак, структура комнаты передаётся в метод класса PGP.
Работу этого алгоритма иллюстрирует гифка:
В начале на координаты 0;0 и 0;2 ставятся точки, далее случайным образом выбирается направление и координаты для следующей точки (х1;y1), после чего ищется точка их пересечения, описанная через две системы уравнений:
В итоге получаются координаты точки пересечения между (x0;y0) и (x1;y1), называемой (xCP; yCP) . После чего x1 и y1 становятся нулевыми координатами и алгоритм повторяется пока мы не дойдём либо до нижней точки комнаты по y, либо до середины комнаты по x.
Далее определяем точку, в которой разместим дверь в следующую локацию и после этого идём в "обратном" направлении. Когда достигнем верхней границы — соединяем с точкой 0;0.
После чего мы пробегаем по точкам и соединяем их линиями и устанавливаем углы (это отдельная боль и говнокод). В конце работы алгоритма возвращается готовый двумерный символьный массив — наша карта.
В коде это выглядит примерно так:
Метод. GenerateRoom() отвечает за запуск генерации структуры.
Для справки, алгоритм представляет собой сочетание методов походки пьяницы и клеточного автомата
Минусы такого решения: весьма костыльно, сложно делить комнаты на части, не совсем «естественная» генерация.
Почему не использовал готовое? Захотелось самому поломать голову и разработать собственное решение, чтобы понять как оно работает изнутри.
На данный момент я разрабатываю вторую версию этого генератора с учётом всех приобретённых навыков. Не факт, что буду внедрять её в ближайшее время, тем не менее, в качестве темы для научной статьи она могла бы подойти.
Поиск пути это who? Как я научил мобов ходить по линеечке за игроком.
Тут всё просто, мне было весьма проблематично делать на той архитектуре реализацию алгоритма А*, поэтому всю логику движения я уместил в одно простое правило:
Если напротив моба (по x или y) есть игрок, то он будет идти к нему пока тот не сдвинется с этой линии.
Сканирование зоны происходит в этом куске кода:
Само сражение не особо притязательно — это обмен повреждениями. У игрока — от надетой экипировки, а у моба от внутренних характеристик.
Оконная система и инвентарь
Вот тут было наибольшее количество костылей. Вообще, реализовывать с нуля отдельный оконный слой — это та ещё попаболь. Да, фреймворк Lanterna позволял использовать встроенные GUI решения, но это означало каждый рисовать целиком отдельный экран с тем же инвентарём, а мне хотелось сделать просто поверх интерфейса.
Собственно, реализация окна инвентаря выглядела вот таким вот образом.
Сама по себе оконная система имела следующую иерархию
У инвентаря имелось даже подобие контекстного меню! Предметы можно экипировать/использовать и выкидывать.
Пример логики экипировки.
Для взаимодействия с элементами окна были реализованы классы Element и CursorUI. Первый содержал в себе конкретный элемент меню. Им мог быть предмет инвентаря (в виде иконки) или просто действие (как в контексте). А курсор...собственно просто курсор, который отображался в виде символа указателя и «выбирал» нужный элемент.
Спаси и загрузи
Редко какая игра бывает без реализации системы save/load. Roguera — не исключение. По своей сути сохранение — это запись текущего состояния программы, то есть её данных. Для этого предусмотрен механизм сериализации данных. Загрузка — обратный процесс — десериализация.
Собственно, для сохранения игровых данных я использую специальный класс-контейнер PlayerContainer.
В него, в частности, входят: текущая комната, текущий инвентарь, экипировка и конкретная позиция на карте. Дальше происходит некоторая магия и на диске появляется файл имяигрока. sav.
Этот файл хранит в себе байтовое представление данных из нашего контейнера. Мы можем попробовать прочитать их через HEX редактор, однако мало что сможем извлечь оттуда. Если мы откроем несколько таких сериализованных файлов в Sublime Text, то обнаружим, что все байтовые последовательности начинаются с сигнатуры aced (и ещё доп. чтение).
Оно обозначает, что файл хранит данные java-объекта. К слову, практически все файлы, в том числе и исполняемые, имеют такие вот «магические» сигнатуры, которые определяют то, как их надо обрабатывать. Например у .exe файлов первые два байта имеют значение MZ (4D 5A в hex формате) .
Так, сохранились мы и чо?
В отличии от сохранения, загрузка игры это процесс, который подразумевает восстановление состояния игры по тем данным, которые получены из файла. Игра должна перегенерировать подземелье, разместить в нём сохранённую комнату и соединить её с остальными комнатами. А, ещё необходимо запустить в отдельных потоках «интеллект» мобов. Эти требования влекут за собой сложность системы в отладке и полировки до рабочего вида.
Не обошлось без «курьёзов» в виде сломанных мобов, застывающих на месте, невозможность дальнейшего прохождения игры, исчезнувший инвентарь и так далее. Но это нормально для разработки такого рода сложных систем.
И далее по мелочи
В конце предыдущей части я упоминал про логи, псевдо-SDK и игру со шрифтами. Если коротко, то
Игры шрифтов
Олдскульной игре — олдскульный шрифт — подумал я и погрузился в поиски более менее адекватного DOS-овского стиля. Выбор мой пал на такой замечательный комплект гарнитуры IBM VGA 9x16
Кириллица, разумеется, шла в комплекте, однако некоторые символы, которые я использовал в стандартном шрифте, отсутствовали, пришлось использовать для оружия всего два-три символа.
Чтобы сделать вывод текста цВеТаСтЫм, я использовал ESC-строки, благо для Unicode они так же работали. Для них я выделил специальный класс Colors, который содержит множество констант для цвета, например:
Логи
(да-да, в Java по умолчанию есть класс логирования, об этом я узнал потом)
Реализовал через класс Debug, который занимался выводом происходящего в коде и сохранял весь лог в текстовый файл, вычищая некоторые текстовые данные от ESC-строк и другой шелухи, которая красиво выводит текст в игре.
Псевдо-SDK
Ничего особенного не вышло. Идея была в создании отдельного инструмента для добавления новых оконных меню. Выглядело это как-то так:
Впрочем, от такого решения я отказался, так как это оказалось слишком громоздким и сложным в реализации на тот момент.
В следующей серии
В третьей, заключительной части, я расскажу о глобальной переделке архитектуры всего проекта, включая новый интерфейс, поиск пути, сетевые фичи и даже связкой с Discord Rich Presence! А так же последние приготовления к релизу.
А зачем код под спойлер прятать? 😅
Чтобы не пугать людей ?
Чтоб не отпугивал при чтении
А то там такие отраслевые стандарты, что лучше не палить.
Забавная игра слов. "take a small byte of @" — потому что главный герой электронный и состоит из байтов?
Совсем об этом не думал, когда писал, но считай, что канон!
Lenght, Widght? :D Где проверка орфографии в вашем блокноте?)
И в Джаве же принято имена методов и переменных с маленькой буквы начинать — naming convention, все дела. Тем более студентов же учите :)
В алгоритме генерации комнаты какое-то чересчур странное и сложное описание для такого простого результата. Насколько я понял, просто на каждом шаге для следующей точки случайно изменяем поочерёдно одну из двух координат (с некоторыми ограничениями, чтобы сперва двигаться вправо-вниз, а затем вправо-вверх).
Алгоритм ещё требует, чтобы мы между точками находили пересечение, чтобы их связывать друг с другом.
А описание такое, потому что параллельно готовлю по нему статью на вузовскую конфу, так что наукояз вплёлся случайно)