[Из песочницы] Hunt the Wumpus или опыт написания классической игры для Android
Слышали ли вы когда-нибудь про Вампуса? Независимо от ответа — добро пожаловать в его владения!
В этой статье я хочу поведать вам свою историю создания игры под Android. В зависимости от компетенции читателя передаваемые мною опыт, мысли и решения будут более или менее полезными. Однако я надеюсь, что мой рассказ, как минимум, будет небезынтересным.
Содержание
1. Введение
2. Выбор средств
3. Идея проекта
4. I SMELL A WUMPUS
5. Основа основ — структура проекта
6. Генерация лабиринта Вампуса и работа с ним
7. Хранение игровых сообщений и вывод их игроку
8. Первый результат
9. Даже маленькие игры достойны истории
10. Преображение Вампуса и конечный результат
11. Вывод
1. Введение
Содержание
Для начала несколько слов о себе. Программирование, к сожалению, не является основным моим видом деятельности, но я с удовольствием посвящаю ему своё свободное время.
По некоторым причинам я выбрал путь создания мобильных игр. Мои предыдущие проекты для мобильных устройств создавались в среде Qt в связке с языками QML и C++. От этой тройки я получал большое удовольствие, однако, анализирую свои идеи я понял, что в будущем решение некоторых задач известными мне средствами потребует слишком много времени и сил. Поэтому, обдумывая следующий проект, я решил найти новые, более подходящие инструменты для разработки и получить опыт работы с ними.
2. Выбор средств
Содержание
Ранее я уделял внимание лишь Android и в новом проекте я решил сконцентрироваться на этой ОС, познакомиться с «родной» для неё Android Studio, попробовать новый для себя Java (а в будущем, если понравится, ещё и перспективный Kotlin).
Ни AS, ни Java ранее я не использовал и передо мной предстал гигантский фронт работы с множеством новых задач, и я уже готов был ринуться в бой, осталось лишь придумать проект.
3. Идея проекта
Содержание
Известно, что лучше всего обучение происходит на реальных задачах, а в особенности тех, что вызывают интерес. Для меня таким учебным проектом должна была стать игра, для которой я сформировал ряд требований:
- Реиграбельность.
- Простота игровой механики.
- Минимальное использование графики.
- Игровой процесс должен вынуждать игрока размышлять.
- Игровая партия не должна быть продолжительной.
- Быстрая реализация проекта (2 месяца).
- Простота и лёгкость UI.
Перебрав множество вариантов и объективно оценивая свои силы, помня при этом, что сколько бы не закладывал времени и ресурсов вначале, в действительности потребуется много больше, я пришёл к мысли о том, что в качестве обучения лучше всего взять за основу проверенную классику, нежели изобретать что-то своё. Создавать условную змейку мне совершенно не хотелось, и я стал изучать старые игры. Так мною была обнаружена любопытнейшая Hunt the Wumpus (Охота на Вампуса).
4. I SMELL A WUMPUS
Содержание
Охота на Вампуса — классическая текстовая игра, придуманная Gregory Yob в 1972. В том же году в журнальной статье им были даны описание игры и исходный код.
Суть игры в исследовании игроком лабиринта-додекаэдра, являющегося жилищем злобного Вампуса, и попытках угадать, на основе сообщений-индикаторов, выводящихся в игровой лог, что находится в комнатах-вершинах. Помимо самого Вампуса (издаёт неприятный запах) имеются летучие мыши (доносится шум), переносящие игрока в случайную комнату, и ямы (сквозит), попадание в которые приводит к завершению игры. Целью же игры является убийство Вампуса для чего у игрока есть 5 стрел, которые могут пролетать от 1 до 5 комнат за раз (игрок сам решает какую «силу» выстрела сделать). Таким образом, игроку доступно две действия: выстрелить из лука, перейти в комнату. Каков же будет результат зависит от доли везения и степени информированности.
В общем, механика мне понравилась: она простая, но в тоже время с элементами риска. В Google Play про Вампуса интересных игр не было (кроме свежей на тот момент игры по миру Лавкрафта, в которой, как я позже узнал, в основе лежала-таки механика Вампуса. А вот это статья про создание игры на Хабре), поэтому было принято решение взять именно Вампуса за основу. Целью я поставил сохранить классическую игру, но слегка её обновить и добавить новые функции.
5. Основа основ — структура проекта
Содержание
Первым делом я изучил правила классической игры и познакомился с различными реализациями Вампуса. После чего я составил схему с логикой игры:
На первых порах схема была полезна, т.к. позволяла проанализировать механику игры, устранить изъяны и внести что-то своё. Более того эта схема пригодилась, когда впоследствии я работал с художницей, чтобы объяснить суть игры.
Проект я разбил на 4 части, в каждой из которых решались разные задачи. Я приведу лишь некоторые из них.
1. Игровая механика
- В каком виде будет храниться информация о подземелье?
- Какого типа и сколько переменных нужно?
- Написание алгоритмов: формирования подземелья, полёта стрелы, проверки результата стрельбы, полёта стрелы при неправильно набранной последовательности комнат, перемещения игрока, проверки комнаты при перемещении, перемещения игрока летучими мышами и т.д.
- Как выводить информацию в игровой лог и в каком порядке?
- В какой последовательности проводить проверку комнаты?
2. UI
- Какие активити должны быть в приложении? Как должны выглядеть и какие элементы должны быть на них?
- Какие параметры позволить изменять в настройках?
- Нужны ли изображения в игре?
- Какая, в целом, должна быть стилистика приложения (цвета, настроение, стиль сообщений)?
- Какие шрифты использовать?
3. Прочее
- Подключение к Google play services
- Работа с XML файлами
- Какие шрифты использовать?
4. Написание текста для игры
- Игровые сообщения
- Правила
- Описание игры для Google Play
Скорее ненужно, нежели невозможно, описывать всё, поэтому я остановлюсь лишь на некоторых моментах, после чего покажу первый полученный результат.
6. Генерация лабиринта Вампуса и работа с ним
Содержание
Лабиринт Вампуса — додекаэдр, который можно представить в виде матрицы G размерностью 20×20. Вершины пронумеруем от 0 до 19. Если элемент матрицы равен 1 — между вершинами (комнатами) есть проход, иначе — нет.
Так же введём матрицу N размерностью 20×3, хранящую индексы соседей для каждой комнаты. Эта матрица ускорит работу с G.
Матрицы G и N вшиты в код игры и не изменяются (разумеется, хранение G излишне, т.к. можно работать только с N, но сейчас оставим всё так). Эти «истинные» индексы вершин раз и навсегда заданного додекаэдра. Для игрока же формируются «игровые» индексы, являющиеся своего рода маской «истинных», в вектор V размерностью 20 следующим образом:
// обнуляем "игровой" вектор перед игрой
for (byte i = 0; i < 20; i++) {
V[i] = i;
}
// перемешиваем индексы в "игровом" векторе
for (int i = 0; i < 20; i++) {
int tmpRand = random.nextInt(20);
byte tmpVar = V[i];
V[i] = V[tmpRand];
V[tmpRand] = tmpVar;
}
Таким образом, получается следующая картина:
Вектор V формируется каждую новую игру, что даёт игроку «новое» подземелье.
Для установления соответствия между «истинным» и «игровым» индексом комнаты используется метод преобразования indByNmb:
public byte indByNmb(int room) {
byte ind = -1;
for (byte i = 0; i < V.length; i++) {
if (V[i] == room) {
ind = i;
break;
}
}
return ind;
}
На входе метод indByNmb получает «игровой» индекс комнаты room, а на выходе даёт «истинный» ind.
После генерации структуры подземелья размещаем: 2 стаи летучих мышей, 2 ямы, Вампуса и игрока:
byte[] randomRooms = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19};
for (int i = 0; i < 20; i++) {
int tmpRand = random.nextInt(20);
byte tmpVar = randomRooms[i];
randomRooms[i] = randomRooms[tmpRand];
randomRooms[tmpRand] = tmpVar;
}
P = randomRooms[0];
W = randomRooms[1];
Pits[0] = randomRooms[2];
Pits[1] = randomRooms[3];
Bats[0] = randomRooms[4];
Bats[1] = randomRooms[5];
Подобное размещение гарантирует, что в одной комнате не будет двух обитателей, а игрок не будет с самого начала закинут в комнату к Вампусу.
Полная генерация подземелья выглядит следующим образом:
byte[] V = new byte[20]; // "игровой" лабиринт
int P; // Положение игрока,
byte W; // Положение Вампуса
byte[] Bats = new byte[2]; // Комнаты с летучими мышами,
byte[] Pits = new byte[2]; // Комнаты с ямами
public void generateDungeons() {
resetVars(); // этот метод обнуляет все данные
for (int i = 0; i < 20; i++) {
int tmpRand = random.nextInt(20);
byte tmpVar = V[i];
V[i] = V[tmpRand];
V[tmpRand] = tmpVar;
}
byte[] randomRooms = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19};
for (int i = 0; i < 20; i++) {
int tmpRand = random.nextInt(20);
byte tmpVar = randomRooms[i];
randomRooms[i] = randomRooms[tmpRand];
randomRooms[tmpRand] = tmpVar;
}
P = randomRooms[0];
W = randomRooms[1];
Pits[0] = randomRooms[2];
Pits[1] = randomRooms[3];
Bats[0] = randomRooms[4];
Bats[1] = randomRooms[5];
}
Теперь можно реализовывать все алгоритмы игровой механики. Так, например, происходит вывод соседних комнат при попадании игрока в комнату с индексом currentRoom:
public void printNearRooms(byte currentRoom) {
byte ind = indByNmb(currentRoom);
appendText(V[N[ind][0]], V[N[ind][1]], V[N[ind][2]]);
}
На входе метод printNearRooms получает текущий «игровой» индекс комнаты currentRoom.
Рассмотрим механику на примере. Пусть игрок перешёл в новую комнату и появилось сообщение: «Теперь я в комнате 8». Число 8 это «игровой» индекс. «Истинный» же индекс комнаты — 6 (см. скриншоты выше). В коде ведётся работа именно с «истинным» индексом, т.е. 6. Для 6 определяются индексы «истинных» соседей: 2, 5, 7. «Игровыми» же, соответственно, будут: 10, 0, 7. Игроку показываем в логе: «Я могу перейти в комнаты 10, 0, 7».
Таким образом, формируя каждую новую игру вектор V и работая с «истинными» и «игровыми» индексами графа лабиринта, создаётся видимость того, что каждая игра уникальна.
Благодаря функции appendText сообщения выводятся через заданный интервал. С ней мы познакомимся позже.
А вот пример проверки комнаты на близость мышей:
public boolean isBatsNear() {
boolean answer = false;
byte indP = indByNmb(P);
for (int i = 0; i < 3; i++) {
if ((V[N[indP][i]] == Bats[0]) || (V[N[indP][i]] == Bats[1])) {
answer = true;
break;
}
}
return answer;
}
7. Хранение игровых сообщений и вывод их игроку
Содержание
Классическая игра представляла из себя процесс взаимодействия игрока с консолью, т.е. игра была чисто текстовой. Я эту особенность хотел сохранить для чего все игровые сообщения разбил на блоки, например:
- Первое сообщение новой игры.
- Сообщение при перемещении игроком.
- Сообщение при близости ям.
- Сообщение при перемещении Вампуса.
- Сообщение при попадании в яму.
Текст хранится в XML файле. Каждый блок имеет несколько вариантов сообщения в угоду разнообразия геймплея.
Пример блока сообщения, которое выводится при наличии в одной из соседних комнат ямы:
— Чувствую сквозняк\n
— Из соседней комнаты дует\n
— Ощутил дуновение на своём лице\n
— Ногам холодно, сквозит\n
— А здесь сквозит\n
- @string/g_pitsNear_1
- @string/g_pitsNear_2
- @string/g_pitsNear_3
- @string/g_pitsNear_4
- @string/g_pitsNear_5
При такой структуре можно легко редактировать имеющиеся либо добавлять новые сообщения, не затрагивая при этом Java код.
Если, к примеру, при проверке комнаты показанный ранее метод isBatsNear вернул true, то достанем из XML нужный блок сообщений, а затем случайным образом возьмём одно в качестве аргумента для appendText:
if (isBatsNear()) {
String[] g_batsNear = getResources().getStringArray(R.array.g_batsNear);
appendText(g_batsNear[random.nextInt(g_batsNear.length)]);
}
Вывод игровых сообщений производится в консоль, которая является объектом TextView. Давайте посмотрим на метод appendText.
public void appendText(final String str) {
msgBuffer.add(str);
if (!isTimerGameMsgWork) {
mTimerGameMsg.run();
isTimerGameMsgWork = true;
}
}
Когда возникает необходимость вывести игровое сообщение, то вызывается метод appendText, принимающий его в качестве аргумента. В самом методе сначала происходит добавление строки в буфер msgBuffer. Затем следует проверка булевой переменной isTimerGameMsgWork. Она принимает true в случаях, когда запущен таймер mTimerGameMsg. Когда работает этот таймер, то из буфера msgBuffer по принципу FIFO (First In First Out) достаются с заданным интервалом mIntervalGameMsg сообщения и добавляются в игровой лог — txtViewGameLog.
Код вывода сообщений целиком:
ArrayList msgBuffer = new ArrayList<>();
Handler mHandlerGameMsg;
private int mIntervalGameMsg = 1000;
boolean isTimerGameMsgWork = false;
public void appendText(final String str) {
msgBuffer.add(str);
if (!isTimerGameMsgWork) {
mTimerGameMsg.run();
isTimerGameMsgWork = true;
}
}
final Runnable mTimerGameMsg = new Runnable() {
@Override
public void run() {
if (msgBuffer.size() == 0) {
mHandlerGameMsg.removeCallbacks(mTimerGameMsg);
isTimerGameMsgWork = false;
} else {
txtViewGameLog.append(msgBuffer.get(0));
msgBuffer.remove(0);
mHandlerGameMsg.postDelayed(mTimerGameMsg, mIntervalGameMsg);
}
}
};
8. Первый результат
Содержание
Спустя месяц разработки была получена первая играбельная версия игры с полностью реализованным функционалом. Скриншоты прилагаю:
На представленных скриншотах можно увидеть: главное меню, окно с правилами, окно с настройками, игровое окно.
Разумеется, я понимал, что результат вышел совсем неинтересный. Главное же то, что я получил практический опыт по AS и Java, что и было первозадачей.
Игрок, как и в классической игре, должен был взаимодействовать через консоль: вводить номер комнаты для перемещения либо маршрут для полёта стрелы. В настройки я сделал возможным менять размер шрифта и прозрачность подложки. Предполагал, что для каждого игрового окна будет своя картинка-подложка (чтобы немного разнообразить геймплей, ха!).
Далее я планировал заменить имеющиеся картинки (которые я бессовестно взял из интернета) на те, что нарисует художник. Потом я бы выпустил игру в Play market и благополучно забыл бы про неё, применяя полученный опыт уже к новым проектам. И я не мог тогда предположить, как сильно может измениться Вампус…
9. Даже маленькие игры достойны истории
Содержание
Когда человек подходит к работе с душой, то от этого проект только выигрывает. Мне повезло, что художница, Анастасия Фроликова, оказалась именно таким человеком. Т.е. вместо того, чтобы просто нарисовать то, что требовалось мне, она заинтересовалась миром игры и захотела понять, как он устроен. И вдруг оказалось, что никакого мира, по большому счёту, нет! Кто такой этот Вампус? И почему игрок должен его убить? Как выглядят комнаты Вампуса? И прочее, прочее над чем я не думал и что не планировал рассказывать игроку. В результате мы сошлись на том, что даже у такой, казалось бы, маленькой игры должна быть своя история. И она появилась.
Согласно нашей легенде Вампус хоть и древнее, но не злое мифическое существо, любящее подшучивать над людьми. Да, он живет в лабиринте, но этот лабиринт не в виде классического мрачного подземелья, а в виде невообразимого дома, состоящего из нагромождения комнат, содержание которых характеризует Вампуса. Так, например, в одной из комнат расположился кинотеатр, на стенах которого постеры его любимых фильмов, а в другой находится его каморка, где он готовит свои «розыгрыши».
Игрок из безымянного охотника превратился в завсегдатая криптозоологического форума, который хочет доказать существование Вампуса. Мы заменили классические лук и стрелы на фотоаппарат и плёнку, а целью игры стало не убийство Вампуса, а получение его фотографии. К слову, главное меню было переделано под форум, где люди обсуждают Вампуса, а из обсуждения игрок может узнать про него.
Что касаемо остальных аспектов, то они остались практически без изменений: мыши действуют так же, а попадание в яму стало приводить к падению, разбитию камеры и завершению игры (а не смерти игрока).
Ещё момент про камеру. В классической игре игрок мог пустить стрелу на дальность от 1 до 5 комнат и это выглядело логично. У нас же вместо лука камера (фотографирующая от 1 до 3 комнат за раз, но работающая как классическая стрела, поражающая Вампуса). И это… выглядит странно, не находите? Была идея уменьшить дальность камеры до 1 комнаты, чтобы фотографировать можно было только соседнюю, но это, во-первых, усложнит игру, а, во-вторых, могут быть получены такие ситуации, когда игра не может быть выиграна, что неправильно. В общем, это тот момент, который лично мне не даёт покоя до сих пор, а решения я пока не нашёл.
Что касаемо стиля стиля и настроения игры. Практически все игры про Вампуса выполнены в скучных серых тонах, а действия происходят в тёмных локациях подземелий. Поэтому мы решили, что наша игра должна отличаться от всего этого и быть выполнена с юмором и в ярких красках.
10. Преображение Вампуса и конечный результат
Содержание
Дальше нас ждали ещё 2 месяца работы над игрой. Так как у Васпуса 20 комнат, то для каждой был создан свой интерьер. Помимо этого, были нарисованы иконки достижений, иконки в игре, приняты решения по дизайну в целом и UI. Так же был дописан весь игровой текст, дополнен и оптимизирован код, были добавлены новые функции (например, появился блокнот для записей информации по ходу игры). В общем, Вампус подвергся серьёзным изменениям.
Комнаты, например, создавались следующим образом: (скриншот кликабелен):
Комнат 20, все они уникальны, а игрок каждую игру получает «новый» лабиринт. Как сделать так, чтобы каждую новую игру картинки привязывались к новым комнатам? Самое простое, это использовать тот же подход «истинных» и «игровых» индексов:
public void changeImgOfRoom() {
ImageView img = findViewById(R.id.imgRoom);
int ind = indByNmb(P);
String imgName = "room_" + ind;
int id = getResources().getIdentifier(imgName, "drawable", this.getPackageName());
Glide.with(this)
.load(id)
.transition(DrawableTransitionOptions.withCrossFade())
.into(img);
}
Картинки квадратного формата (для уменьшения искажения при просмотрах на разных экранах) хранятся в ресурсах с названиями [room_0; room_1; …, room_19]. И они, фактически, связаны с «истинными» индексами додекаэдра, но для игрока каждую новую игру для одной и той же комнаты будут разные картинки. Зачем это нужно? Для того, чтобы дать возможность в конкретной игровой партии соотнести текстовую информацию с изображением конкретной комнаты («ага, помню, что в комнате Х, которая гостиная, был сквозняк») и чтобы не получалось так, что «а почему у меня всегда в комнате Х одна и также картинка?». Всё для разнообразия и помощи игроку (впрочем, как показал опыт, помощи от визуального запоминания нет, эффективнее работать с текстом).
В конечном счёте мы получили новую версию игры. И, знаете что? Вампус стал чертовски привлекателен, а самое главное, это всё тот же классический Вампус, но в новом уютном доме!
На скриншотах: главное меню, окно с правилами, окно с настройками, игровое окно.
Что касаемо механики, то она лишь слегка изменена и переименована (ну в самом деле, есть ли разница: камера или лук, если делать нужно одно и тоже?). Самое заметное изменение в механике — это упрощение процесса взаимодействия между игроком и игрой — был убран классический ввод номеров комнат при помощи клавиатуры. Теперь для перехода между комнатами нужно выбрать во всплывающем окошке 1 из 3 чисел, а для формирования «маршрута» фотографирования достаточно прокрутить колёса на барабане:
На видео ниже вы можете увидеть конечный результат:
11. Вывод
Содержание
Первая версия игры была получена мною за месяц разработки, выделяя по 1–2 часа времени после работы. При этом ни AS, ни Java не были мне ранее знакомы. Вторая версия игры потребовала ещё 2 месяца. Таким образом, всего 3 месяца неспешной работы.
Конечно, в таком виде игра не для широкого круга, т.к. она может показаться сложной и не захватывающей современному игроку, но важнее, наверное, сохранение духа классических игр, не находите?
Доволен ли я результатом? Однозначно, да. Я получил большой опыт как программирования, так и работы в команде. Мне нравится, как получившаяся механика игры, так и визуальная её составляющая. Есть ли что-то, что бы мне хотелось изменить? Разумеется, нет пределов совершенства и всегда можно что-то добавить/улучшить, но нельзя же этим заниматься вечно!
Интересна ли эта игра? Что ж, тут уж решать не мне. Но пусть Вампусу будет уютно в том доме, что мы для него выстроили с большой любовью и вниманием.
Желаю Вам успехов!
Спасибо за внимание и берегитесь Вампуса!
P.S. Постоянно возникающие задачи и радость от их решения — это именно то, за что я люблю программирование. Надеюсь, что и вы получаете не меньшее удовольствие.