Не могу поверить, что это всё я сделал сам и своими руками. https://twitter.com/foreignFont/status/1309163139741749249
Краткий рекап из предыдущей части
Я преподаватель 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
Ничего особенного не вышло. Идея была в создании отдельного инструмента для добавления новых оконных меню. Выглядело это как-то так:
Прошло уже больше месяца с последней новсти. Я переписал алгоритм генерации помещений и добавил рекурсивное деление на части, так что в одной комнате могут быть несколько поменьбше. И ещё я упоролся и пишу редактор UI меню для игровых окон. Разумеется в том же стиле.
Впрочем, от такого решения я отказался, так как это оказалось слишком громоздким и сложным в реализации на тот момент.
В следующей серии
В третьей, заключительной части, я расскажу о глобальной переделке архитектуры всего проекта, включая новый интерфейс, поиск пути, сетевые фичи и даже связкой с Discord Rich Presence! А так же последние приготовления к релизу.
А зачем код под спойлер прятать? 😅
Чтобы не пугать людей ?
Какие все нежные стали. Джавы боятся (¬‿¬ )
Джавы бояться - велосипеды не писать.
Чтоб не отпугивал при чтении
А то там такие отраслевые стандарты, что лучше не палить.
Подсайт же про геймдев, т. е. люди знают, что получат. Мне вот наоборот неудобно каждый раз кликать, чтоб код посмотреть)
Окей, убедил, всё обнажил так сказать
Забавная игра слов. "take a small byte of @" — потому что главный герой электронный и состоит из байтов?
Совсем об этом не думал, когда писал, но считай, что канон!
Lenght, Widght? :D Где проверка орфографии в вашем блокноте?)
И в Джаве же принято имена методов и переменных с маленькой буквы начинать — naming convention, все дела. Тем более студентов же учите :)
В алгоритме генерации комнаты какое-то чересчур странное и сложное описание для такого простого результата. Насколько я понял, просто на каждом шаге для следующей точки случайно изменяем поочерёдно одну из двух координат (с некоторыми ограничениями, чтобы сперва двигаться вправо-вниз, а затем вправо-вверх).
Алгоритм ещё требует, чтобы мы между точками находили пересечение, чтобы их связывать друг с другом.
А описание такое, потому что параллельно готовлю по нему статью на вузовскую конфу, так что наукояз вплёлся случайно)
Так если эти точки-пересечения включить в работу алгоритму "по-очереди меняем одну из координат", то и получается этот алгоритм.
0 - фиксирован (0,0), y_min=0, x_max=6, y_max=6.
1 - фиксирован (0,2), выбираем случайный y от 3 до y_max. Выбрали 4. y_max не достигнут.
2 - (0,4), выбираем случайный х от 1 до x_max. Выбрали 2. floor(x_max/2) не достигнут.
3 - (2,4), выбираем случайный у от 5 до y_max. Выбрали 6. Достигнут y_max, генерируем следующую точку так, чтобы была дверь, а следующий y выбираем меньше текущего.
4 - (2,6), выбираем случайный х от 3+1 до x_max. Выбрали 4. x_max не достигнут.
5 - (4,6), выбираем случайный у от y_min до 5. Выбрали 4. y_min не достигнут.
6 - (4,4), выбираем случайный х от 5 до x_max. Выбрали 6. Достигнут x_max.
7 - (6,4), следующий y изменяем на y нулевой точки.
8 - (6,0). Готово.
Ну ещё забыл добавить, что для выбора второй точки выбираем случайно, какую координату менять — x или у.
так и хочется всё это сконвертить в котлин, уж больно джава эта ваша разлапистая
Заебись оценил разницу, спасибо, папаша.
Разблокал твиттер