Здравствуй! Во второй части статью я продолжу рассказ о том, как я писал клон игры Pacman. Первую часть можно почитать здесь.
С момента, когда я последний раз работал над пакманом прошло порядка трех недель. Прошла большая часть сессии, стало немного больше времени и я решил продолжить. В этот момент появилось желание доделать игру до состояния, когда ее можно будет выложить в Google Play Market, хотя в самом начале разработки я об этом даже не помышлял. Кроме того, доделывание до играбельного состояния – неплохая тренировка. Где-то я слышал, что игры (да и вообще приложения) стоит доделывать.
Напомню, что разработка игры велась с использованием Android NDK (С++) и OpenGL ES 2.0.
Для начала я составил список того, что, как я считал, необходимо для окончания работы над игрой:
- Бонусы
- Вывод текста
- Музыка и звуки
- Перманентное сохранение данных
- Более красивая анимация и дизайн
Теперь подробнее, по пунктам:
Бонусы
Бонусы в игре нужны для разнообразия. Чтобы не тратить на них много времени, я ввел новый абстрактный класс Bonus
, от которого тут же унаследовал LifeBonus
. Как нетрудно догадаться, LifeBonus
дает игру одну жизнь. Надо сказать, бонусы весьма органично вписались в уже существующую иерархию:
На этом я пока остановился. Создать другие бонусы крайне легко, стоит лишь унаследовать их от Bonus’a.
В связи с бонусами стоит упомянуть класс Statistics
. Этот класс нужен для сбора различной статистики, такой как вход/выход/пауза уровня, подсчет набранных очков и времени внутри уровня. Вся эта статистика собирается и может быть использована для создания таблицы достижений или даже сетевых таблиц рекордов. Внутри класс Statistics
реализован в виде детерминированного конечного автомата.
Вывод текста
Сначала я хотел обойтись без текстовой информации вовсе, потому что (ИМХО) встраивание текста влечет за собой костыли. Оказалось, что обходиться без текста сложно, проще было реализовать его вывод.
Для вывода текста я воспользовался простым приемом: графическое представление символов моноширинного шрифта берется прямоугольниками из текстуры примерно такого вида, как на рисунке.
Первый символ – пробел, остальные идут подряд. Разлиновка на рисунке нужна лишь для удобства (видно базовую линию и то, что все символы выравнены). В приложении текстура такая же, но с прозрачным фоном. Правильнее было бы рендерить шрифт в текстуру на этапе выполнения, а не хранить статичную текстуру, но это только добавило бы сложности, т.к. непонятно, как выравнивать символы в прямоугольниках.
Для вывода текста разработан специальный элемент GUI — Label
, наследник Control
’a. Он используется в заголовке окна игры для вывода игровой статистики, в меню Win/GameOver для оповещения игрока о выигрыше или проигрыше соответственно.
Звук
Редкая игра обходится без звука (пожалуй, я с ходу не смогу назвать таких игр). Поэтому я решил добавить фоновую музыку и игровые звуки в свою игру тоже.
Техническая часть
До этого у меня не было опыта работы со звуком. Здесь есть как минимум 3 варианта:
- Использовать jni и проигрывать звуки, используя API, предоставляемые Android SDK
- Использовать OpenSL ES
- Использовать OpenAL
Первый вариант я отбросил сразу, поскольку посчитал, что это не совсем изящное решение. Выбор из двух оставшихся был сделан в пользу OpenSL ES (об этом я написал статью, заработав тем самым инвайт сюда).
Для работы с музыкой разработан класс Audio
, который имеет набор статических методов для включения той или иной фоновой музыки, быстрого проигрывания звуков и управления слышимостью музыки и звуков (по отдельности друг от друга).
Пользователь осуществляет управление из главного меню игры, в котором для этого есть подобия кнопок с состояниями – CheckBox
, который унаследован от Control
’a.
Композиторская часть
Сначала я хотел выбрать музыку и звуки из имеющихся в открытом доступе на огромном количестве музыкальных сайтов. Но эта затея провалилась, поскольку подобрать музыку оказалось проблематично для меня.
К счастью, ко мне на помощь пришел мой друг-музыкант Тимур Рамазанов, который согласился написать для меня треки. Лично мне музыка кажется очень подходящей к дизайну и настроению игры. Те, кому интересны другие его работы, могут ознакомиться с ними вконтакте или на soundcloud
Фоновая музыка разделена на две части: игровая и в меню. Она зациклена и сохранена в формате ogg. Игровые звуки сохранены в формате wav.
Сохранение информации
В процессе игры различная информация должна быть сохранена перманентно. Это, например, рекорды игрока или его настройки звука.
Для этого написана обертка над android.content.SharedPreferences. Обращение к обертке происходит через jni.
public class StoreManager {
public static final String PACMAN_PREFERENCES = "com_zagayevskiy_pacman_store";
private Context context;
/*Сохраним ссылку на контекст*/
public StoreManager(Context _context){
context = _context;
}
/*Методы для сохранения и загрузки целых чисел и булевых величин. При желании можно расширить и другими типами*/
public void saveBoolean(String key, boolean value){
SharedPreferences sp = context.getSharedPreferences(PACMAN_PREFERENCES, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sp.edit();
editor.putBoolean(key, value);
editor.commit();
}
public boolean loadBoolean(String key, boolean defValue){
SharedPreferences sp = context.getSharedPreferences(PACMAN_PREFERENCES, Context.MODE_PRIVATE);
return sp.getBoolean(key, defValue);
}
public void saveInt(String key, int value){
SharedPreferences sp = context.getSharedPreferences(PACMAN_PREFERENCES, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sp.edit();
editor.putInt(key, value);
editor.commit();
}
public int loadInt(String key, int defValue){
SharedPreferences sp = context.getSharedPreferences(PACMAN_PREFERENCES, Context.MODE_PRIVATE);
return sp.getInt(key, defValue);
}
}
#include <stdlib.h>
#include <stdio.h>
#include <jni.h>
class Store {
public:
static void init(JNIEnv* env, jobject _storeManager);
static void saveBool(const char* name, bool value);
static bool loadBool(const char* name, bool defValue);
static void saveInt(const char* name, int value);
static int loadInt(const char* name, int defValue);
private:
static JavaVM* javaVM;
static jobject storeManager;
static jclass storeManagerClass;
static jmethodID saveBoolId;
static jmethodID loadBoolId;
static jmethodID saveIntId;
static jmethodID loadIntId;
static JNIEnv* getJNIEnv(JavaVM* jvm);
};
Store.cpp:
/*env и _storeManager передаются при инициализации нативной библиотеки*/
void Store::init(JNIEnv* env, jobject _storeManager){
/*Сохраним ссылку на Java-машину, понадобится позже*/
if(env->GetJavaVM(&javaVM) != JNI_OK){
LOGE("Can not Get JVM");
return;
}
storeManager = env->NewGlobalRef(_storeManager);
if(!storeManager){
LOGE("Can not create NewGlobalRef on storeManager");
return;
}
storeManagerClass = env->GetObjectClass(storeManager);
if(!storeManagerClass){
LOGE("Can not get StoreManager class");
return;
}
saveBoolId = env->GetMethodID(storeManagerClass, "saveBoolean", "(Ljava/lang/String;Z)V");
if(!saveBoolId){
LOGE("Can not find method saveBoolean");
return;
}
/*Аналогично для остальных методов*/
}
}
void Store::saveBool(const char* name, bool value){
LOGI("Store::saveBool(%s, %d)", name, value);
JNIEnv* env = getJNIEnv(javaVM);
if(!env){
LOGE("Can not getJNIEnv");
return;
}
jstring key = env->NewStringUTF(name);
if(!key){
LOGE("Can not create NewStringUTF");
}
env->CallVoidMethod(storeManager, saveBoolId, key, value);
}
bool Store::loadBool(const char* name, bool defValue){
LOGI("Store::loadBool(%s, %d)", name, defValue);
JNIEnv* env = getJNIEnv(javaVM);
if(!env){
LOGE("Can not getJNIEnv");
return defValue;
}
jstring key = env->NewStringUTF(name);
if(!key){
LOGE("Can not create NewStringUTF");
}
return env->CallBooleanMethod(storeManager, loadBoolId, key, defValue);
}
/*Аналогично реализуются оставшиеся два метода load/saveInt()*/
/*Получаем указатель на JNIEnv для текущего потока, используя ссылку на Java-машину*/
JNIEnv* Store::getJNIEnv(JavaVM* jvm){
JavaVMAttachArgs args;
args.version = JNI_VERSION_1_6;
args.name = "PacmanNativeThread";
args.group = NULL;
JNIEnv* result;
if(jvm->AttachCurrentThread(&result, &args) != JNI_OK){
result = NULL;
}
return result;
}
Более красивая анимация и дизайн
Первоначально анимировался у меня только Pacman. Хотелось сделать анимацию более красивой (а не в 4 кадра), и сделать анимацию для бонусов и врагов. Все это в одном стиле.
В какой-то момент возникла идея сделать Pacman’a в виде огненного шара, а его врагов – в виде капель воды.
Самый идеальный вариант для меня был – сделать красивую покадровую анимацию. Проблем в программном плане это не представляет, но зато есть проблема рисования кадров. Я столкнулся с проблемой поиска дизайнера и объяснения, что именно я хочу. Эту проблему я не решил. Потом некоторое время подумал и решил сделать полностью программную анимацию. А у дизайнера заказал только тайлы разных размеров, что обошлось мне в $50.
Программная анимация
Для того, чтобы сделать анимацию удобной в использовании, я реализовал два класса-наследника уже упоминавшегося выше IRenderable
: Plume
для анимации «шлейфа» и Pulsation
для «пульcаций».
На скриншоте шлейфы различной длины имеют персонажи – Pacman и монстры, а пульсация – это точка большего размера в центре сердца. Так показана на карте дополнительная жизнь.
Идея обоих классов основана на эффекте «кисти». На каждом шаге объект класса Plume
получает координаты анимируемого объекта и запоминает (или не запоминает, в зависимости от желаемой длины шлейфа – чем чаще запоминания, тем короче шлейф) их в контейнер-очередь. Затем, используя уже запомненные координаты, рисуются круги с помощью текстуры, аналогичной представленной ниже.
Зелено-черный градиент соответствует градиенту альфа-канала. Зеленый — полная непрозрачность, черный – полная прозрачность. Эта текстура генерируется при инициализации игры при помощи фрагментного шейдера и рендера в текстуру.
Чем старше координаты, тем меньший радиус рисуемого круга. Круги рисуются с наложением текстуры, указанной при создании объекта-шлейфа. Текстурные координаты при этом смещаются в зависимости от рисуемых координат и, дополнительно, по формуле спирали Архимеда (для того, чтобы при остановке персонажей анимация не застывала).
Для анимации Pacman’a и монстров используются шлейфы разной длины, с разными текстурами. Дополнительное требование к текстурам воды и пламени — они должны быть «зациклены», т.е. не должно быть видно стыков. Сам Pacman так же использует покадровую анимацию движения челюстей.
Аналогичным образом реализована пульсация, в которой градиентные круги различных размеров просто сменяют друг друга с определенной частотой.
Название и иконка приложения
При выборе названия хотелось обыграть то, что игра – клон Pacman’a, причем Pacman – огненный. При этом надо было не обидеть Namco. Были различные варианты: Fireman, Fire Man, Pyro Man, Pacman: Jaws of Fire. В итоге я остановился на Pyroman: Jaws of Fire. А отсылку к игре Pac-Man оставил в описании.
Иконку приложения нарисовал в фотошопе, обыграв огненность Pacman’a. Получилось похоже на золотую рыбку и, по-моему, забавно=)
Так же хотелось рассказать об участии в прошедшем конкурсе The Tactrick Android Developer Cup, в номинации «Games». Но, по сути, рассказать нечего, так как конкурс кончился внезапно — вывешиванием плашек «WINNERS» победителям и письмом «Спасибо за участие» остальным. Я не претендовал на какие-либо призовые места, но интересно было, на каком месте в зачете буду. Пусть 66 из 66, но будет понятно, что как-то программы оценивали.
Игра доступна на github.
Благодарности
Хочу сказать спасибо моей девушке Юле, за понимание и поддержку. В её честь нарисован первый уровень
Так же хочу поблагодарить моего гуру и наставника — Булата Танирбергена за дружескую поддержку и убеждение, что всё в моих силах
Рамазанову Тимуру за треки к игре — спасибо.
Отдельные благодарности компании ZeptoLab, благодаря которой я теперь достаточно хорошо знаю Android NDK, прочитал книгу Сильвена Ретабоуила о NDK и книгу Стефана Дьюхерста «Скользкие места С++», таким образом подняв свой программистский уровень.
Автор: zagayevskiy