Roguera: как препод рогалик на Java делал. Часть 2

Ответ на публикацию @foreignFont
Как говорится, оцените разницу до и после.
Не могу поверить, что это всё я сделал сам и своими руками. https://twitter.com/foreignFont/status/1309163139741749249

Краткий рекап из предыдущей части

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

В этой части мы продолжим рассматривать процесс разработки рогалика и речь пойдёт в том числе о процедурной генерации комнат.

Дисклеймер: описанная здесь версия игры уже является весьма устаревшей и соответственно качество кода также оставляет желать лучшего. Поэтому прежде чем проводить оценочные суждения об навыках автора, лучше ознакомиться с актуальной кодовой базой на github-е проекта.

Недавно я всё таки решил зарелизиться и выпустить для всех версию 0.3.0, так что скачать да поиграть можно вот тут.

Алсо, не знакомые с Java, могут спокойно игнорировать куски кода , так как я постараюсь отдельно описывать работу игровых систем.

Напоминание о чём игра

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

Как написать процедурную генерацию комнат и не сойти с ума

По сути алгоритм представляет собой генерацию итоговой структуры по точкам.

Напомню, что наша карта (комната) хранится в памяти как двумерный массив символов.

public char[][] RoomStructure;

При генерации подземелья (как последовательности комнат) , для каждой из них определяется размер по ширине и высоте:

public static void GenerateDungeon(int DungeonLenght){ random = GetRandom.RNGenerator; RoomSize[] roomSizes = RoomSize.values(); for(byte i = 0; i < DungeonLenght; i++){ byte Height = roomSizes[2].GetHeightY()[random.nextInt(3)]; byte Widght = roomSizes[2].GetWidghtX()[random.nextInt(3)]; if(i == DungeonLenght-1){ Dungeon.Rooms.add(new Room((byte) (i+1), true, Widght, Height)); } else Dungeon.Rooms.add(new Room((byte) (i+1), Widght, Height)); Dungeon.Rooms.get(i).roomSize = roomSizes[2]; MapEditor.FillSpaceWithEmpty(Dungeon.Rooms.get(i).RoomStructure); String message = "Create Room " + i + " Height: " + Height + " Width: " + Widght; Debug.log("GENERATE: Make Dungeon: " + message); } ConnectRooms(); }

Класс RoomSize представляет собой перечисление констант SMALL, middle, BIG, в которых хранятся массивы с числами ширины и высоты:

public enum RoomSize { SMALL{ @Override public byte[] GetWidghtX() { return new byte[]{ 3, 5, 10 }; } @Override public byte[] GetHeightY() { return new byte[] { 3, 5, 10 }; } },

Данные числа из массивов выбираются случайным образом.

Итак, структура комнаты передаётся в метод класса PGP.

Работу этого алгоритма иллюстрирует гифка:

В начале на координаты 0;0 и 0;2 ставятся точки, далее случайным образом выбирается направление и координаты для следующей точки (х1;y1), после чего ищется точка их пересечения, описанная через две системы уравнений:

В случае, если координаты равны, то выбирается первая систем
В случае, если координаты равны, то выбирается первая систем

В итоге получаются координаты точки пересечения между (x0;y0) и (x1;y1), называемой (xCP; yCP) . После чего x1 и y1 становятся нулевыми координатами и алгоритм повторяется пока мы не дойдём либо до нижней точки комнаты по y, либо до середины комнаты по x.

Далее определяем точку, в которой разместим дверь в следующую локацию и после этого идём в "обратном" направлении. Когда достигнем верхней границы — соединяем с точкой 0;0.

После чего мы пробегаем по точкам и соединяем их линиями и устанавливаем углы (это отдельная боль и говнокод). В конце работы алгоритма возвращается готовый двумерный символьный массив — наша карта.

В коде это выглядит примерно так:

PGP pgp = new PGP(); char[][] CurrentRoom = room.RoomStructure; CurrentRoom = pgp.GenerateRoom(CurrentRoom); room.RoomStructure = CurrentRoom;

Метод. GenerateRoom() отвечает за запуск генерации структуры.

Для справки, алгоритм представляет собой сочетание методов походки пьяницы и клеточного автомата

Минусы такого решения: весьма костыльно, сложно делить комнаты на части, не совсем «естественная» генерация.

Почему не использовал готовое? Захотелось самому поломать голову и разработать собственное решение, чтобы понять как оно работает изнутри.

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

Поиск пути это who? Как я научил мобов ходить по линеечке за игроком.

Тут всё просто, мне было весьма проблематично делать на той архитектуре реализацию алгоритма А*, поэтому всю логику движения я уместил в одно простое правило:

Если напротив моба (по x или y) есть игрок, то он будет идти к нему пока тот не сдвинется с этой линии.

Сканирование зоны происходит в этом куске кода:

boolean ScanZone(){ int y = mob.getMobPosY(); int x = mob.getMobPosX(); for(int Zone = 1; Zone <= mob.ScanZone; Zone++) { try{ if (mob.ScanForPlayer(MapEditor.getFromCell(y+Zone, x))){ mob.Destination.setPosition(y+Zone, x); return true; } if (mob.ScanForPlayer(MapEditor.getFromCell(y-Zone, x))){ mob.Destination.setPosition(y-Zone, x); return true; } if (mob.ScanForPlayer(MapEditor.getFromCell(y, x+Zone))){ mob.Destination.setPosition(y, x+Zone); return true; } if (mob.ScanForPlayer(MapEditor.getFromCell(y, x-Zone))){ mob.Destination.setPosition(y, x-Zone); return true; } } catch (ArrayIndexOutOfBoundsException e){ continue; } if(isInterrupted()){ System.out.println(Colors.RED_BRIGHT + "Interrupted"); break; } } return false; }

Само сражение не особо притязательно — это обмен повреждениями. У игрока — от надетой экипировки, а у моба от внутренних характеристик.

Оконная система и инвентарь

Вот тут было наибольшее количество костылей. Вообще, реализовывать с нуля отдельный оконный слой — это та ещё попаболь. Да, фреймворк Lanterna позволял использовать встроенные GUI решения, но это означало каждый рисовать целиком отдельный экран с тем же инвентарём, а мне хотелось сделать просто поверх интерфейса.

Собственно, реализация окна инвентаря выглядела вот таким вот образом.

Сама по себе оконная система имела следующую иерархию

Во главе всего были абстрактные классы Window и Menu.
Во главе всего были абстрактные классы Window и Menu.

У инвентаря имелось даже подобие контекстного меню! Предметы можно экипировать/использовать и выкидывать.

Пример логики экипировки.

MenuContextElements.add(new Element("Equip", "Equip", new Position(InnerX+1, InnerY+5),()->{ Item item = Player.Inventory.get(MenuCursor.IndexOfElement); Debug.log("INVENTORY: Equipping item " + Player.Inventory.get(MenuCursor.IndexOfElement).name); InventoryController.EquipItem((Equipment) item, InventoryController.getPlace(item)); }));

Для взаимодействия с элементами окна были реализованы классы Element и CursorUI. Первый содержал в себе конкретный элемент меню. Им мог быть предмет инвентаря (в виде иконки) или просто действие (как в контексте). А курсор...собственно просто курсор, который отображался в виде символа указателя и «выбирал» нужный элемент.

Спаси и загрузи

Редко какая игра бывает без реализации системы save/load. Roguera — не исключение. По своей сути сохранение — это запись текущего состояния программы, то есть её данных. Для этого предусмотрен механизм сериализации данных. Загрузка — обратный процесс — десериализация.

Собственно, для сохранения игровых данных я использую специальный класс-контейнер PlayerContainer.

private static PlayerContainer GetPlayerContainer(){ return new PlayerContainer( Player.nickName, Player.GetPlayerPosition(), Player.HP, Player.MP, Player.Level, Player.CurrentRoom, Player.Money, Player.attempt, Player.RandomSeed, Player.XP, Player.ATK, Player.DEF, Player.DEX, Player.ReqXPForNextLevel, Player.Inventory, Player.Equip, Player.playerStatistics ); }

В него, в частности, входят: текущая комната, текущий инвентарь, экипировка и конкретная позиция на карте. Дальше происходит некоторая магия и на диске появляется файл имяигрока. sav.

Этот файл хранит в себе байтовое представление данных из нашего контейнера. Мы можем попробовать прочитать их через HEX редактор, однако мало что сможем извлечь оттуда. Если мы откроем несколько таких сериализованных файлов в Sublime Text, то обнаружим, что все байтовые последовательности начинаются с сигнатуры aced (и ещё доп. чтение).

Оно обозначает, что файл хранит данные java-объекта. К слову, практически все файлы, в том числе и исполняемые, имеют такие вот «магические» сигнатуры, которые определяют то, как их надо обрабатывать. Например у .exe файлов первые два байта имеют значение MZ (4D 5A в hex формате) .

Так, сохранились мы и чо?

В отличии от сохранения, загрузка игры это процесс, который подразумевает восстановление состояния игры по тем данным, которые получены из файла. Игра должна перегенерировать подземелье, разместить в нём сохранённую комнату и соединить её с остальными комнатами. А, ещё необходимо запустить в отдельных потоках «интеллект» мобов. Эти требования влекут за собой сложность системы в отладке и полировки до рабочего вида.

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

И далее по мелочи

В конце предыдущей части я упоминал про логи, псевдо-SDK и игру со шрифтами. Если коротко, то

Игры шрифтов

Олдскульной игре — олдскульный шрифт — подумал я и погрузился в поиски более менее адекватного DOS-овского стиля. Выбор мой пал на такой замечательный комплект гарнитуры IBM VGA 9x16

Пример шифра аля французские булки
Пример шифра аля французские булки

Кириллица, разумеется, шла в комплекте, однако некоторые символы, которые я использовал в стандартном шрифте, отсутствовали, пришлось использовать для оружия всего два-три символа.

Чтобы сделать вывод текста цВеТаСтЫм, я использовал ESC-строки, благо для Unicode они так же работали. Для них я выделил специальный класс Colors, который содержит множество констант для цвета, например:

public static final String RED_BRIGHT = "\u001b[38;5;9m"; public static final String GREEN_BRIGHT = "\u001b[38;5;10m"; public static final String BLUE_BRIGHT = "\u001b[38;5;12m"; public static final String MAGENTA = "\u001b[38;5;90m"; public static final String CYAN = "\u001b[38;5;50m";

Логи

(да-да, в Java по умолчанию есть класс логирования, об этом я узнал потом)

Реализовал через класс Debug, который занимался выводом происходящего в коде и сохранял весь лог в текстовый файл, вычищая некоторые текстовые данные от ESC-строк и другой шелухи, которая красиво выводит текст в игре.

public static void log(String message){ StringBuilder sb = new StringBuilder().append("[").append(Calendar.getInstance().getTime()).append("]//"); message = message.replaceAll("\u001B",""); message = message.replaceAll("\\[+\\d{2}\\;+\\d\\;+\\d*m+", ""); message = message.replaceAll("\\[+[0]m+",""); sb.append(message).append('\n'); logHistory.add(sb); }

Псевдо-SDK

Ничего особенного не вышло. Идея была в создании отдельного инструмента для добавления новых оконных меню. Выглядело это как-то так:

#gamedev
Прошло уже больше месяца с последней новсти. Я переписал алгоритм генерации помещений и добавил рекурсивное деление на части, так что в одной комнате могут быть несколько поменьбше. И ещё я упоролся и пишу редактор UI меню для игровых окон. Разумеется в том же стиле.
Roguera: как препод рогалик на Java делал. Часть 2
Суровый DOS-like интерфейс

Впрочем, от такого решения я отказался, так как это оказалось слишком громоздким и сложным в реализации на тот момент.

В следующей серии

В третьей, заключительной части, я расскажу о глобальной переделке архитектуры всего проекта, включая новый интерфейс, поиск пути, сетевые фичи и даже связкой с Discord Rich Presence! А так же последние приготовления к релизу.

4444
16 комментариев

А зачем код под спойлер прятать? 😅

2
Ответить

Чтобы не пугать людей ?

6
Ответить

Чтоб не отпугивал при чтении
А то там такие отраслевые стандарты, что лучше не палить.

1
Ответить

Забавная игра слов. "take a small byte of @" — потому что главный герой электронный и состоит из байтов?

1
Ответить

Совсем об этом не думал, когда писал, но считай, что канон!

Ответить

Lenght, Widght? :D Где проверка орфографии в вашем блокноте?)

И в Джаве же принято имена методов и переменных с маленькой буквы начинать — naming convention, все дела. Тем более студентов же учите :)

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

Ответить

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

Ответить