{"id":3826,"url":"\/distributions\/3826\/click?bit=1&hash=aa8ae934c4b405b2d10a14343e22e97821fd478e8a4a31defae61d161d040867","title":"\u041e\u0444\u0444\u0435\u0440 \u0434\u043b\u044f \u0434\u0436\u0430\u0432\u0438\u0441\u0442\u0430-\u043c\u0438\u0434\u0434\u043b\u0430 \u0437\u0430 \u043e\u0434\u0438\u043d \u0434\u0435\u043d\u044c","buttonText":"\u0413\u0434\u0435 \u0442\u0430\u043a\u043e\u0435?","imageUuid":"2b70606f-740c-5d85-8a71-8a33c5f66557","isPaidAndBannersEnabled":false}

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.

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

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

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 меню для игровых окон. Разумеется в том же стиле.
Суровый DOS-like интерфейс

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

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

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

0
16 комментариев
Написать комментарий...
Andrey Apanasik

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

Ответить
Развернуть ветку
J. Jан-Батон II

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

Ответить
Развернуть ветку
Andrey Apanasik

Какие все нежные стали. Джавы боятся (¬‿¬ )

Ответить
Развернуть ветку
Николай Ксенофонтов
Автор

Джавы бояться - велосипеды не писать.

Ответить
Развернуть ветку
Николай Ксенофонтов
Автор

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

Ответить
Развернуть ветку
Andrey Apanasik

Подсайт же про геймдев, т. е. люди знают, что получат. Мне вот наоборот неудобно каждый раз кликать, чтоб код посмотреть)

Ответить
Развернуть ветку
Николай Ксенофонтов
Автор

Окей, убедил, всё обнажил так сказать

Ответить
Развернуть ветку
Неопознанный Енот

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

Ответить
Развернуть ветку
Николай Ксенофонтов
Автор

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

Ответить
Развернуть ветку
Bot Useless

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

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

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

Ответить
Развернуть ветку
Николай Ксенофонтов
Автор

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

Ответить
Развернуть ветку
Bot Useless

Так если эти точки-пересечения включить в работу алгоритму "по-очереди меняем одну из координат", то и получается этот алгоритм.
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). Готово.

Ответить
Развернуть ветку
Bot Useless

Ну ещё забыл добавить, что для выбора второй точки выбираем случайно, какую координату менять — x или у.

Ответить
Развернуть ветку
Парламентский браслет

так и хочется всё это сконвертить в котлин, уж больно джава эта ваша разлапистая

Ответить
Развернуть ветку
777yur0k

Заебись оценил разницу, спасибо, папаша.

Ответить
Развернуть ветку
Николай Ксенофонтов
Автор

Разблокал твиттер

Ответить
Развернуть ветку
Читать все 16 комментариев
null