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

Ответ на публикацию @foreignFont

#gamedev #indiedev #roguelike

Итак, спустя джва года разработки я решил, что пора был перестать "вечно" пилить один проект и нужно идти дальше Поэтому наконец-то выпускаю в релиз свой проект под версией 0.3.0.

Скачать, да поиграть: https://github.com/Kseoni4/Roguera/releases/tag/v0.3.0 https://twitter.com/foreignFont/status/1437460382273114115

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

Финальная часть о том, как препод сделал игру и выучил Java. С кучей подробностей, кода и трижды украденной БД.

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

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

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

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

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

Кстати! Я всё-таки решил зарелизиться и выпустить для всех версию 0.3.0, так что скачать да поиграть можно вот тут. Помимо этого, я поднял таблицу очков на отдельном сайте: roguera.tms-studio.ru.

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

Roguera — это классический псевдографический рогалик с основными жанровыми особенностями:

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

Также присутствуют очки за прохождение и таблица рекордов со статистикой, которая крутится здесь.

Структура игровой карты

В первую очередь, поводом для пересмотра архитектуры игры была игровая карта. Напомню, что в первой версии карта представляла собой двумерный MxN массив символов, над которым производились все операции типа движения героя, размещения предметов и т.д. По многим причинам, это было весьма неудобно, в частности:

  • Нельзя было в одной ячейке одновременно держать несколько объектов - для этого пришлось бы делать отдельный стек объектов;

  • Сложно управлять тем, какой именно объект находится в данной ячейке - только через сравнение символов (а из-за двумерности массива сложность каждого алгоритма сравнения была O(n^2));

  • • Усложнение кодовой базы из-за необходимости как-то манипулировать массивом – в связи с этим было очень больно расширять функциональность;

  • Ну и, в целом, это совсем не ООП стиль.

На смену этому подходу нужно было что-то попроще и более функциональное. Так я придумал следующую концепцию.

Вместо того, чтобы каждую клетку описывать как ячейку с символом в массиве, я создал отдельный класс Cell, который, соответственно, описывает клетку как отдельную структуру данных. Клетка в себе хранит:

  1. Позицию по X;Y внутри структуры Room (комнаты), эта же позиция нужна для рендера содержимого клетки в соответствующем месте окна терминала.
  2. Коллекцию игровых объектов, которые в этой клетке находятся ; при этом их отрисовка осуществляется так, что виден лишь тот объект, который последним положен в набор.

  3. Булевый флаг "Это стена?" - не могу сказать, что нельзя без него было обойтись, тем не менее, это упрощало проверку клетки на наличие стен, углов и прочих структурных элементов комнаты, т.к вместо поиска внутри коллекции, можно сразу отметить клетку как стену и проверять только этот флаг.
  4. Методы по типу «найти в клетке, положить в клетку, убрать с клетки» и т.п.

  5. Ссылку на комнату, которой эта клетка принадлежит.

Приведу код этого класса ниже:

public class Cell implements Serializable { private Room linkedRoom; public Position position; public ArrayList<GameObject> gameObjects; boolean isWall = false; private int lastObjectIndex; public static final Cell EMPTY_CELL = new Cell(new Position(0,0)); public boolean isEmpty(){ return this.gameObjects.isEmpty() && !isWall; } public GameObject getFromCell(){ try { if (!isEmpty()) { Optional<GameObject> gameObject; gameObject = Optional.ofNullable(this.gameObjects.get(lastObjectIndex)); return gameObject.orElse(EditorEntity.EMPTY_CELL); } else { return EditorEntity.EMPTY_CELL; } } catch (IndexOutOfBoundsException e){ Debug.toLog(Colors.RED_BRIGHT+"[ERROR][CELL|"+position.toString()+"|] Error out of bounds, return empty"); return EditorEntity.EMPTY_CELL; } } public void removeFromCell() { if(lastObjectIndex < 0) lastObjectIndex = 0; removeFromCell(gameObjects.get(lastObjectIndex)); } public void removeFromCell(GameObject gameObject){ this.gameObjects.remove(gameObject); this.linkedRoom.gameObjects.remove(gameObject); UpdateLastItemCounter(); } public GameObject getAndRemoveFromCell(){ GameObject gatedObject = gameObjects.get(lastObjectIndex); removeFromCell(); return gatedObject; } public void clear(){ gameObjects.clear(); UpdateLastItemCounter(); } public boolean isWall(){ return this.isWall; } public void setWall(){ this.isWall = true; } public void unsetWall(){this.isWall = false;} public void putIntoCell(GameObject gameObject){ if(gameObject instanceof Border) setWall(); gameObject.placeObject(this); linkedRoom.gameObjects.add(gameObject); UpdateLastItemCounter(); } private void UpdateLastItemCounter(){ int size = gameObjects.size(); if(size > 0) lastObjectIndex = gameObjects.size()-1; else lastObjectIndex = 0; } public Cell(Position position){ this.position = position; gameObjects = new ArrayList<>(); } public Cell(Position position, Room room){ this(position); this.linkedRoom = room; } public Cell(Room room){ this(new Position(), room); } public Cell[] getCellsAround(){ Cell[] cells = new Cell[8]; int i = 0; for(Position direction : Position.AroundPositions){ cells[i] = Dungeon.getCurrentRoom().getCell(this.position.getRelative(direction)); i++; } return cells; } }

P.S. Предложение разработчикам сайта - вместо тёмной темы сделать всё-таки сворачиваемые блоки, чтобы не пугать читателей длинным кодом.

Далее, эти клетки хранятся в другой коллекции в классе Room (комната) . Их количество зависит от размеров комнаты и равно её площади (X * Y для тех, кто забыл). Допустим, на комнату размером 10 столбцов и 10 строк - у нас всё же текстовый интерфейс - приходится 100 клеток. К счастью, на память это не сильно влияет: при анализе профилировщиком размер одной клетки равен 45 байтам.

Комнаты хранятся внутри коллекции в классе Floor (этаж), а этажи хранятся в классе Dungeon (подземелье). Получается следующая матрёшка:

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

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

Как тайное становится явным

Кстати, про туман. Его особенность в том, что функция его раскрытия игроком является рекурсией, так как во время раскрытия определяется, насколько глубоко игрок может увидеть при проходе сквозь него.

public void removeFogParts(Cell[] cells, int depth){ for(Cell cell : cells){ int __depth = depth; try { if (Scan.checkFogPart(cell.getFromCell())) { cell.removeFromCell(); } if (__depth > 0) removeFogParts(cell.getCellsAround(), --__depth); } catch (NullPointerException ignored){ } } }

За игнорирование исключения и "__" в названии переменной меня могут заминусить. Как бы я сделал сейчас: воспользовался классом Optional и через метод ifPresentOrElse() взаимодействовал с содержимым клетки, если оно присутствует в контейнере.

Оконный интерфейс

Частично об этом я рассказывал в предыдущей части дневников.

Lanterna – замечательный фреймворк, который легко использовать "неправильно" или, по крайней мере, не пользоваться хорошими возможностями. Дело в том, что он позволяет отрисовывать хорошие GUI элементы а-ля дизайн из MS-DOS программ, однако для использования приходилось бы переключать экран игры на отдельный слой и перерисовывать всё обратно. Это, на мой взгляд, было не лучшим решением, поэтому я принял решение собрать своё подобие оконного интерфейса (заодно и разобраться, как оно в теории работает).

Идея была такая: окно рисуется поверх элементов в середине экрана, и контекст управления переключается на него. После закрытия окна программа перерисовывает затронутые элементы и возвращает контекст управления фигуркой персонажа.

У меня получилась такая иерархия классов:

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

IViewBlock – это общий для всех окон интерфейс, который описывает три метода: Init(), Draw(), Reset():

public interface IViewBlock { IViewBlock[] empty = new IViewBlock[]{}; void Init(); void Draw(); void Reset(); }

Init - отвечает за первичную инициализацию визуального объекта на экране.

Draw - рендеринг текущего содержимого окна.

Reset - очистка или сброс к первоначальному виду.

За реализацию отвечает абстрактный класс Window, который скрывает под капотом общую логику отрисовки всех окон, а реализацию содержимого оставляет на классы, его расширяющие.

Собственно, за вызов рендеринга отвечает класс Draw:

public class Draw { public static int DrawCallCount = 0; public static int DrawResetCount = 0; public static int DrawInitCount = 0; public static void call(IViewBlock viewBlock){ DrawCallCount++; //Debug.toLog("[DRAW_CALL]: "+viewBlock.getClass().getName()); viewBlock.Draw(); flush(); } public static void reset(IViewBlock viewBlock){ DrawResetCount++; viewBlock.Reset(); flush(); } public static void init(IViewBlock viewBlock){ DrawInitCount++; viewBlock.Init(); flush(); } public static void flush(){ try { TerminalView.terminal.flush(); } catch (IOException e) { Debug.toLog(Arrays.toString(e.getStackTrace())); } } public static void clear(){ try{ TerminalView.terminal.clearScreen(); } catch (IOException e){ Debug.toLog(Arrays.toString(e.getStackTrace())); } } }

Например, в метод call передаётся любой объект, реализующий интерфейс IViewBlock, то есть, или окно, или часть интерфейса (пакет UI).

Как выглядит работа с окном инвентаря:

Окна располагаются на верхнем слое отрисовки экрана. На нижнем расположены непосредственно элементы интерфейса, которые распределены по специальной сетке. Она делит экран на три блока: характеристики персонажа, надетая экипировка + слоты быстрого доступа и текстовый лог происходящего.

За отрисовку и логику каждого блока отвечает свой класс из пакета com.roguera.view.ui.

Отдельно хочу отметить логику блока с логами: помимо того, что в нём реализованы переносы текста (ох, и намучился с ними), так и при изменении размера окна лог достаточно корректно перерисовывается с учётом новых значений ширины.

Не обошлось без визуальных багов...

ИИ противников

Ещё одна новинка - это обновлённая логика поиска пути у мобов. В этот раз я замахнулся на реализации алгоритма А*. Если коротко, то мобы находят кратчайший путь к игроку и идут по тому набору позиций, который вернул алгоритм. Вот как это выглядит:

Поход по диагонали, очевидно, более быстрый.

Препятствия так же учитываются:

Небольшая задержка в движении моба происходит из-за перерасчёта пути к игроку после его перемещения.

В остальном, враги так же наносят урон при "касании" с игроком (и наоборот).

Очки рекордов

Ещё одна фишка в новой версии – онлайн-таблица рекордов, которая крутится как веб-приложение на Spring Framework. При этом сохранение очков сделано хитрым образом, чтобы исключить возможность сейвскама или оффлайн-абьюза. Опасно делиться тонкостями безопасности ; тем не менее, в игру также встроена простенькая защита от CheatEngine за счёт проверки хэша очков при каждом изменении переменной. А верификация игровой сессии осуществляется по отслеживанию таймаута пинга от клиента.

Я не слишком боюсь, что кто-то по серьёзке наспамит 99999999 очками в таблице – гораздо интереснее, как ему это удастся (и он честно поделится со мной найденной дырой. Ведь поделится?).

Про разработку сервера на Spring рассказывать опять же особо нечего, так как это достаточно банальная настройка API end-point-ов, микро БД на две таблицы и одно представление, а про систему безопасности я упомянул.

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

Discord Rich Presence

Последняя интересная фишка, которую было приятно реализовывать - это интеграция с дискордом. DRP - это API дискорда, которая позволяет отображать различную информацию о том, что конкретно делает пользователь в игре: на каком он уровне, его текущий счёт, время игры и так далее. Для Java существует отдельная библиотека-обёртка, с которой было достаточно удобно работать. В целом, оказывается, что сама интеграция с DRP простая и удивительно, что её не добавляют в каждую ПК игру (например, я был бы рад увидеть эту фичу в Геншине).

Как это выглядит (в качестве арта - название игры на фоне моего кота):

Эта статья выходит сильно позже выхода крайней версии игры. Основная цель разработки - изучение языка Java - достигнута, даже с избытком. Помимо прокаченных навыков программирования, есть ещё и опыт минимум одного проекта, доведённого до рабочего состояния. Конечно, спустя два года разработки, смотришь на весь этот код и понимаешь, как много в нём наивного, неправильного – но он, блин, работает!

Я всегда своим студентам рекомендую при изучении любой новой технологии\языка создавать свой небольшой проект, на разработке которого можно практиковаться. Конечно, не стоит тратить полтора-два года, как я. Пусть это будет что-то небольшое, что можно закончить делать в разумный срок. В принципе, игра – это очень хороший пример, потому что при её разработке затрагиваются чуть ли не все аспекты языка (правда, не уверен, что функциональные ЯП можно учить таким же образом).

Если говорить о том, что было с игрой всё это время – в неё не поиграло какое-то весомое количество игроков (даже полусотни не наберётся), а база данных очков умудрилась трижды быть украденной (кто? зачем?) из-за дефолтного пароля на хосте. Из хорошего - студентам нравится, некоторые просят ссылку поиграть.

Спасибо, что дочитали до конца, буду очень рад комментариям, хорошего дня!

2929
6 комментариев

Довольно странно выглядит задержка при повторном поиске пути противником (судя по видео) Между ними пара клеток, путь находится за несколько итераций и это обычно занимает микросекунды. Стоит поверить, правильно ли работает алгоритм, вдруг он сканирует всю карту. Хотя и в этом случае задержка большая.

Ответить

Скорее всего там сказывается 400 мс. задержка в методе idle() в контроллере моба. Метод вызывается когда моб ожидает игрока (тот, например, не раскрыл ещё карту) или потерял из "поля зрения" - в смысле ушёл с первоначальной позиции. Провёл замеры - там нулевая задержка в обычном поиске и есть сравнительно небольшая в моменты, когда между мобом и игроком есть препятствие. Если очень заморочиться, то можно и бенчмарки написать с полной статистикой скорости. :)

1
Ответить

Комментарий недоступен

Ответить

NPE в целом не надо try-catch-ить, и вместо ignored я бы сейчас помещал итерируемый объект cell в контейнер Optional и производил все манипуляции только, если объект реально там содержится.

1
Ответить