Как я писал Pacman’a и что из этого получилось. Часть 2


    Здравствуй, хабр! Во второй части статьи я продолжу рассказ о том, как я писал клон игры 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);
    	}
    }
    


    С++ код для обращения к StoreManager через jni
    Store.h:
    #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 и книгу Стефана Дьюхерста «Скользкие места С++», таким образом подняв свой программистский уровень.
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 19

      +24
      Я знаю, что вы сейчас сидите и обновляете каждую минуту комментарии, потому без зазрения совести поставлю заслуженный плюс за саморазвитие и желание идти до конца. Так держать!
      • UFO just landed and posted this here
        • UFO just landed and posted this here
            +1
            На гитхабе есть ссылка для скачивания;-)
            • UFO just landed and posted this here
                0
                Если в статью вставить ссылку, то отправят в read-only. В прошлой статье она была, вставил по неопытности
                  0
                  Не совсем понятно почему — проект же опенсоурсный.
                  Чем мешает прямая ссылка на apk?
                    0
                    Ссылка была на Google Play.
        +1
        Восхищаюсь такими людьми. Сразу в голову лезет " я бы не смог ". Ну конечно смог бы, но это не важно т.к. не стал бы. Но такие статьи вдохновляют. Пойду на праздниках тоже что-нибудь покодю обязательно. Спасибо!
          +2
          Я рад, что кого-то вдохновил, значит время на статью потрачено не зря. Спасибо за отзыв!
          0
          лого — ок)
            –1
            У вас есть стейт машина? А сколько у нее состояний, для нее они абстракты или типизированы?
              0
              Машиной состояний (StateMachine) я назвал абстрактный ДКА. Остальные классы, реализующие этот интерфейс, внутри устроены как ДКА, с разными количествами состояний — у каждого из них состояния свои.
              0
              Всё же, лучше было бы OpenAL использовать. Он и красивее, и вроде как нету недостатков некоторых, которые есть в OpenSL ES.
              Вот только при работе с OpenAL мы столкнулись с тем, что на некоторых устройствах бывают задержки при воспроизведении )=
              В OpenSL ES, как я понимаю, такой проблемы не встретили?
                0
                Да, теперь уже есть такие мысли, что лучше было OpenAL использовать — он и поддерживается многими платформами, можно рассчитывать на кроссплатформенность реализации.
                Единственная проблема была в задержке при зацикливании музыки. Так и не победил это. Тестировал на десятке устройств, примерно.
                0
                По прошествии времени, вынужден признать, что OpenSL на Android лучше, чем OpenAL.

                В нашей новой игре очень многое зависит от звуков. Их много и часто необходимо их проигрывать. С OpenAL были частые задержки перед проигрыванием, порой до 0.5 секунд.

                С OpenSL всё работает куда лучше. Пришлось всё с OpenAL переписывать на OpenSL, но оно того стоило)
                  0
                  А с проблемой задержки при зацикливании треков решили что-нибудь? Или такого в проекте нет?
                    0
                    Пока без зацикливания решили делать. Но есть огромная проблема по сравнению с OpenAL.
                    Если в OpenAL pause, stop и прочие методы вешаются на сорс, то в OpenSL они вешаются на плейер.

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

                    Динамически поменять формат плейера тоже нельзя. Приходится создавать плейер на каждый формат.

                    А если в игре 50+ звуков, многие из которых могут играть параллельно, надо около 5-8 плейеров. Если ещё брать в расчёт различность форматов (3-4 формата), то надо перемножить… Итого надо более 20 плейеров.

                    А их число ограничено. Если превысите максимально возможное число, приложение упадёт.

                Only users with full accounts can post comments. Log in, please.