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

#gamedev #indiedev #roguera
Итак, около месяца назад всё таки довёл игру до относительно играбельного билда, прежде чем начал переделывать архитектуру. И вместе с тем записал видео геймплея для будущей статьи на DTF. https://t.co/GB8uOn7Jvp

Хотите узнать, как я до такого докатился?

Вступление, которое можно пропустить

Так уж вышло, что я — преподаватель в одном из московских вузов и мой основной предмет — программирование на Java (официально он называется иначе, но разницы никакой), по которому я, как ни странно, веду практики. В середине сентября, всё таки встал вопрос — как научить студентов языку, когда ты сам практически не шаришь в нём, а из проектного опыта только кликер на C#?..

Уже думал, что опять два семестра гонять балду, а студентам давать незамысловатые задачи, как однажды, во время дрёмы, в голову пришла гениальная и дерзкая мысль:

А что если написать рогалик на джава и обучать студентов по нему?

И вот мы здесь спустя больше полугода с момента начала работы над игрой — 24 сентября 2020 года.

Небольшой дисклеймер

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

А в чём вообще суть?

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

Как всё начиналось

Разумеется, с выбора «графической подсистемы». Мне довольно быстро повезло найти фреймворк Lanterna, который удовлетворял моим нуждам. По сути, это относительно простой эмулятор терминала, который позволял как выводить встроенный псевдографический GUI, аля Norton Commander, так и посимвольный вывод. Поигравшись с гайдами, я начал реализовывать простейший прототип игры: @, которая бегает внутри квадрата из "*".

У меня новая одержимость - лениво делать рогалик на Java. https://t.co/bIeiiwb7GR
Собственно, момент сотворения рогалика…

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

public class R_MoveController { public static void MovePlayer(KeyStroke key) throws IOException { switch (key.getCharacter()) { case 'w' -> Move(R_Player.Pos.x, R_Player.Pos.y + 1); case 'a' -> Move(R_Player.Pos.x - 1, R_Player.Pos.y); case 's' -> Move(R_Player.Pos.x, R_Player.Pos.y - 1); case 'd' -> Move(R_Player.Pos.x + 1, R_Player.Pos.y); } } public static void Move(int x, int y) throws IOException { if(R_Dungeon.CheckWall(R_Dungeon.Dungeon[x][y])){ R_Dungeon.Dungeon[R_Player.Pos.x][R_Player.Pos.y] = ' '; R_Dungeon.Dungeon[x][y] = Player; R_Player.Pos.x = x; R_Player.Pos.y = y; } if(R_Dungeon.CheckExit(R_Dungeon.Dungeon[x][y])) { T_View.NextRoomWindow(T_View.terminal); } } }

Всё достаточно просто: метод MovePlayer(KeyStroke key) отвечает за обработку кнопок WASD и перемещает на одну координату в соответствующую сторону с помощью метода Move(int x, int y). Оный просто заменяет символ @ на пустой и перемещает собачку в нужную точку. Вот так просто и наивно.

Завод костылей

Всего-то около пяти часов работы, чтобы собачка была зелёная и бегала по красивой комнате. https://t.co/NVrl3LdlXk
Так всё выглядело спустя несколько часов. А работал над игрой я тогда достаточно много и изменения шли быстро.

Теперь-то можно рассказать об особенностях устройства игры.

Например, начнём с того, как генерировались комнаты, для этого использовался вот такой метод:

public static char[][] Dungeon = new char[Height][Widght]; //двумерный массив символов для карты? #достаточно public static void GenerateDungeon() { System.out.println("Dungeon length = " + Dungeon.length + " " + Dungeon[0].length); Dungeon[0][0] = Symbols.DOUBLE_LINE_TOP_LEFT_CORNER; Dungeon[Dungeon.length-1][Dungeon[0].length-1] = Symbols.DOUBLE_LINE_BOTTOM_RIGHT_CORNER; Dungeon[0][Dungeon[0].length-1] = Symbols.DOUBLE_LINE_BOTTOM_LEFT_CORNER; Dungeon[Dungeon.length-1][0] = Symbols.DOUBLE_LINE_TOP_RIGHT_CORNER; for (int i = 1; i < Dungeon[0].length-1; i++) { Dungeon[0][i] = Symbols.DOUBLE_LINE_VERTICAL; } for (int i = 1; i < Dungeon.length - 1; i++) { Dungeon[i][Dungeon[0].length - 1] = Symbols.DOUBLE_LINE_HORIZONTAL; } for (int i = 1; i < Dungeon[0].length - 1; i++) { Dungeon[Dungeon.length - 1][i] = Symbols.DOUBLE_LINE_VERTICAL; } for (int i = 1; i < Dungeon.length-1; i++) { Dungeon[i][0] = Symbols.DOUBLE_LINE_HORIZONTAL; } for (int i = 0; i < Dungeon.length; i++) { for (int j = 0; j < Dungeon[0].length; j++) { if (CheckWall(Dungeon[i][j])) { Dungeon[i][j] = EmptyCell; } } } Dungeon[Dungeon.length/2][Dungeon[0].length-1] = Symbols.ARROW_DOWN; }

Для любителей покопаться в легаси коде — ссылка на снапшот файла класса R_Dungeon.java от 26 сентября 2020.

Если вы ничего не поняли, не волнуйтесь, дальше я проиллюстрирую как же выглядело игровое поле. (спойлер, очень неоптимальное решение — это как если бы YandereDev делал рогалик)

Пустые клетки в массиве это буквально символ ' '. 
Пустые клетки в массиве это буквально символ ' '. 

Грубо говоря, у нас есть двумерный массив (аля таблица с столбцами и строками) символов для примера назовём roomStructure[y][x] (да ещё и координаты перепутаны). Строки обозначим как y, а столбцы как x.

Чтобы получить содержимое точки, на иллюстрации выше, где находится собачка, надо адресоваться к нужному элементу в массиве:

x = 1; y = 1; char cell = roomStructure[y][x];

Наш "игрок" находится на координатах 1;1, его то отображение мы и получаем.

Это было безумно неэффективно, так как нам надо ещё и проверять является ли эта клетка игроком, чтобы как-то с ним взаимодействовать, например, правильно отрисовать в консоли:

cell = R_Dungeon.ShowDungeon(i,j).charAt(0); if(cell == R_Player.Player) { DrawPlayer(textGraphics, i, j); } static void DrawPlayer(TextGraphics textGraphics, int i, int j){ textGraphics.setForegroundColor(TextColor.ANSI.GREEN_BRIGHT); textGraphics.putString(i, j, R_Dungeon.ShowDungeon(i, j), SGR.CIRCLED); textGraphics.setForegroundColor(TextColor.ANSI.WHITE); }

Соединение комнат было на тот момент запилено адской непонятной вундервафлей через методы StreamAPI:

public static void ConnectRooms(){ int i = 2; ArrayList<R_Room> Keys = new ArrayList<>(); for (Map.Entry<R_Room, char[][]> r_roomEntry : Rooms.entrySet()) { Keys.add(r_roomEntry.getKey()); } Keys.sort(Comparator.comparingInt(value -> value.NumberOfRoom)); for(R_Room room : Keys){ if(!room.IsEndRoom) { int finalI = i; room.nextRoom = Rooms.keySet() .stream() .filter(r_room -> r_room.NumberOfRoom == finalI) .findFirst() .orElse(null); } i++; } }

Проспойлерю и покажу как этот метод выглядит в текущей версии.

public static void ConnectRooms(){ Dungeon.rooms.sort(Comparator.comparingInt(value -> value.RoomNumber)); Dungeon.rooms.forEach(room -> { if(!room.isEndRoom) room.LinkRooms(Dungeon.rooms.get(room.RoomNumber)); }); } //Класс Room.java ... public void LinkRooms(Room nextRoom){ this.nextRoom = nextRoom; nextRoom.prevRoom = this; LinkDoors(); } ...

Думаю, лаконичность говорит сама за себя.

Собственно рендеринг или вывод на экран терминала здесь был представлен весьма специфично в классе T_View:

while (keyStroke.getKeyType() != KeyType.Escape) { ResetTerminalPosition(); InitNewTextGraphics(textGraphics1, topLeft); DrawInformation(textGraphics1, topLeft); DrawDungeon(textGraphics, cell); terminal.flush(); keyStroke = terminal.readInput(); R_MoveController.MovePlayer(keyStroke); } }

При нажатии на кнопку, сначала игрок «незримо» перемещался, после чего весь экран перерисовывался.

Основной метод здесь, конечно, DrawDungeon(TextGraphics textGraphics, char cell)

static void DrawDungeon(TextGraphics textGraphics, char cell){ for (int i = 0; i < R_Dungeon.CurrentRoom.length; i++) for (int j = 0; j < R_Dungeon.CurrentRoom[0].length; j++) { // System.out.println("i:"+ i + " " + "j:" + j); // System.out.println(R_Dungeon.Dungeon[i][j]); cell = R_Dungeon.ShowDungeon(i,j).charAt(0); if(cell == R_Player.Player) { DrawPlayer(textGraphics, i, j); } else{ textGraphics.putString(i, j, R_Dungeon.ShowDungeon(i, j)); } if (j == R_Dungeon.CurrentRoom[0].length - 1) textGraphics.putString(i, j, "\n", SGR.BOLD); } }

То есть, на каждой клетке мы проверяем:

1. Если есть игрок, рисуем игрока (зелёненьким).

2. Если не игрок, то берём из 2D массива комнаты то, что находится по координатам i, j и отображаем на том же месте на экране (относительно точки 0.0 от левого верхнего угла окна).

Причём мы не просто размещаем символ, мы размещаем строку из одного символа.

Можете сами догадаться, насколько мне было удобно наращивать игру фичами. В конце концов, как правильно сказал Юра turbojedi:

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

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

Вождение костылями по джойстику

Через полторы недели я представил очередной прогресс:

Спустя примерно полторы недели время показать прогресс. На данный момент это - стабильный прототип версии 0.0.1, где можно просто побегать и покэнселить мобов.
Тем не менее, время перелопачивать архитектуру, иначе добавлять новые фичи будет мучительно, как спорить с дурачками. https://t.co/RcC5OEs2t6
Тогда я ещё предполагал, что бои будут пошаговыми.

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

public class R_Mob extends R_Creature { private int Armor; private int Damage; public final int ScanZone = 2; public char MobSymbol; public int MobPosX; public int MobPosY; public R_Mob(String name, char mobSymbol, int HP) { super(name); this.MobSymbol = mobSymbol; setHP(HP); setCreatureType(CreatureType.MOB); } public void setMobPosition(int x, int y){ this.MobPosX = x; this.MobPosY = y; } public int getMobPosX() { return MobPosX; } public int getMobPosY() { return MobPosY; } public boolean ScanForPlayer(char c){ return c == R_Player.Player; } @Override public char getCreatureSymbol() { return this.MobSymbol; } @Override public int getDamage() { return this.Damage; } @Override public int getArmor() { return this.Armor; } }

Сами битвы происходили по jRPG принципу: если в комнате были мобы, то игрок, задев хотя бы одного — бил всех. В данном случае с одного удара, а они даже не могли ответить…

Структура игры (в особенности вывод в консоль), выглядела как один большой референс к треку Оксимирона — Переплетено. Нужно было как-то разделить логику вывода и логику остального безобразия и при этом ничего не сломать. Ах да, следует добавить, что игра на тот момент была всё ещё однопоточной…

Я потратил ещё около десяти часов, чтобы расчистить объектно-ориентированные авгиевы конюшни. Теперь у меня появился класс MapEditor, который представляет собой простейший инструмент для «рисования» линий и прямоугольников. Это можно представить как вождение костылями по джойстику, чтобы использовать кисть. Однако, критичная часть логики не была переписана и необходимо было ломать всё дальше во имя прогресса. А КАДА ИГРА ТО?

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

Хочу кратко рассказать, как устроен рендеринг в игре. Мы нажали на стрелочку - тут же активируется функция drawcall(), которая очищает экран, заново рисует блок информации и рисует карту.
На примере показан процесс отрисовки (я специально немного замедлил для наглядности) https://t.co/0FEgRkeUMK
Чтобы написать такой эффект пришлось сильно повозиться…

Собственно, сам метод:

static void Drawcall() throws IOException { terminal.clearScreen(); ResetTerminalPosition(); InitGraphics(PlayerInfoGraphics, topPlayerInfoLeft, PlayerInfoSize); DrawPlayerInformation(); DrawDungeon(); terminal.flush(); }

Ничего необычного, при каждом движении мы: стираем экран, обновляем позиции у элементов, «рисуем» блок информации и саму комнату, после чего обновляем экран.

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

Играюсь с алгоритмами генерации комнаты https://t.co/iFgD8sKY4o
Представьте, что это зал с колоннами. https://t.co/gclyA41rDb
Roguera: как препод рогалик на Java делал. Часть 1
Многоэтажка…

А вот и метод, который отвечает за генерацию всего безобразия выше.

static char[][] GenerateSubRoom(){ int LengthY, LengthX, LengthXY, PointTopLeftX, PointTopY, PointBottomY, PointTopRightX; LengthY = (int) Math.pow(random.nextInt(1)+2,2); LengthX = LengthY; PointBottomY = LengthY; PointTopY = 0; PointTopRightX = LengthX; PointTopLeftX = 0; LengthXY = LengthY; char[][] BufferSubRoom = new char[LengthY][LengthX]; for(int y = 0; y < BufferSubRoom.length; y++){ BufferSubRoom[y][0] = Symbols.DOUBLE_LINE_VERTICAL; BufferSubRoom[y][LengthX-1] = Symbols.DOUBLE_LINE_VERTICAL; System.out.print(BufferSubRoom[y][0]); } System.out.print('\n'); for(int x = 0; x < BufferSubRoom[0].length; x++){ BufferSubRoom[0][x] = Symbols.DOUBLE_LINE_HORIZONTAL; BufferSubRoom[LengthY-1][x] = Symbols.DOUBLE_LINE_HORIZONTAL; System.out.print(BufferSubRoom[0][x]); } System.out.print('\n'); FillSpaceWithEmpty(BufferSubRoom); BufferSubRoom[PointTopY][PointTopLeftX] = Symbols.DOUBLE_LINE_T_RIGHT; BufferSubRoom[PointBottomY-1][PointTopLeftX] = Symbols.DOUBLE_LINE_T_RIGHT; BufferSubRoom[PointTopY][PointTopRightX-1] = Symbols.DOUBLE_LINE_TOP_RIGHT_CORNER; BufferSubRoom[PointBottomY-1][PointTopRightX-1] = Symbols.DOUBLE_LINE_BOTTOM_RIGHT_CORNER; return BufferSubRoom; } static void PlaceSubRoom(char[][] SubRoom, char[][] CurrentRoom){ int SubRoomLenghtY = SubRoom.length; int SubRoomLenghtX = SubRoom[0].length; int RoomPointY = ((CurrentRoom.length/SubRoomLenghtY)); int RoomPointX = 0; for(int y = 0; y < SubRoomLenghtY; y++){ for(int x = 0; x < SubRoomLenghtX; x++){ System.out.println("x:" + x + " " + "y:" + y); System.out.println("RoomPointX:" + RoomPointX + " " + "RoomPointX + x:" + (RoomPointX+x)); System.out.println("RoomPointY:" + RoomPointY + " " + "RoomPointY + y:" + (RoomPointY+y)); CurrentRoom[RoomPointX+x][RoomPointY+y] = SubRoom[y][x]; } } }

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

И на этом данная часть заканчивается. Мы прошли пока что только пару недель разработки, дальше больше и ещё интереснее!

В следующих частях вас ждут:

  • Как придумать процедурную генерацию по точкам для двухмерного массива и не сойти с ума.
  • Поиск пути это who? Как я научил мобов ходить по линеечке за игроком.
  • Попытка сделать псевдо-SDK.
  • Игра со шрифтами и ASCII кодами.
  • Миллионы строк логов.
  • Дырявый инвентарь.
  • Как я написал свою систему Windows.
  • И конечно же великий переход на новую архитектуру…

Для самых искушённых и любопытных — добро пожаловать в трелло проекта и на гитхаб.

6161
25 комментариев

  как научить студентов языку, когда ты сам практически не шаришь в нём, а из проектного опыта только кликер на C#?..Вот здесь я малость фалломорфировал. Простите, а как вы вообще можете преподавать практически не шаря?

10

Скажем так, есть люди, которые читают лекции, а есть которые ведут практики.
Я пока что во второй категории по данному предмету. И в принципе, всю практику можно свести к обычному наблюдению за тем, чтоб студенты хоть чё-то делали.
Однако лично мне в этом году всё таки захотелось заняться нормально предметом и вывести свои навыки и преподавание на более высокий уровень.

2

80% преподавателей в универах по it специальностям в пост совковых странах  читая этот коммент

То что в Java вы не профессионал, это видно про процедурному стилю (массовые static методы в листингах кода).

То что студентов учите по такому коду - не очень хорошо.

6

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

1

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

Так уж вышло, что я — преподаватель в одном из московских вузов и мой основной предмет — программирование на Java (официально он называется иначе, но разницы никакой), по которому я, как ни странно, веду практики. В середине сентября, всё таки встал вопрос — как научить студентов языку, когда ты сам практически не шаришь в нём, а из проектного опыта только кликер на C#?..классика нашего ит образования, к сожалению.
Блин, скачайте intellij idea, она вам подскажет, как правильно называть переменные, классы и методы, чтобы у джавистов кровь из глаз не шла. А то будет не очень хорошо, если научить студентов неправильному стилю кода. Потом сложно переучивать.
Ну и писать на очень ориентированном на ООП языке в процедурном стиле - ну такое. Главное неокрепшие умы этому не учите. 
Советую все же начать с чего-то попроще и написать идеоматичную программу, а то по коду скорее кажется, что это не Java, а плод плотских утех оригинального C и C#.
Еще если нравится делать игры, то можете попробовать сделать простенький мод для майнкрафта. Там от ООП полностью не отвертитесь, так как привязаны к АПИ жестко (хотя всякое бывает). Заодно появится опыт чтения чужого кода, который поможет понять, как его лучше писать на этом языке. Правда не уверен, что майнкрафт - это прям эталон Java кода, но тем не менее.

5