Наконец-то я закончил свою первую игру "Война Смайликов: Вторжение". Началось все в ноябре прошлого года, когда из-за очередного кризиса, закрылась контора, где я работал. Появилось свободное время и я решил попробовать написать игру под андроид, не имея смартфона, не зная java и отсутствии какого-либо опыта в разработке игр и знаний OpenGL.
Начал я с выбора игрового движка – в первую очередь он должен поддерживать iOS и Android, а также быть бесплатным и желательно легким в плане библиотек. Изучая интернет, остановился на libGDX, о чем не жалею.
Вторым шагом стал выбор IDE. Выбирал между Eclipse и IntelliJ IDEA. Начитавшись хвалебных отзывов о последней, остановился на IDEA. В принципе доволен – мощная среда разработки, автоматические переменные, интеллектуальные подсказки (которые не всегда четко срабатывают), всевозможные настройки и плагины, куча горячих клавиш и много другого.
Единственное, что я не поборол в IDEA, так это когда при вставке строки, заканчивающей кавычкой, она автоматом делает отступ следующей под ней строки.
Подготовив среду разработки, начал изучение с туториала libGDX «Поймай капельку» — простая игра, которое дает лишь начальное представление об архитектуре игры в libGDX. Единственное, таймер, используемый в примере – точный, но imho неудобный, помимо того, что 1 сек. = 1 млд., так еще постоянно идет вызов функции, возвращающей системное время:
long lastDropTime;
public void render(float delta) {
if (TimeUtils.nanoTime() - lastDropTime > 1000000000){
spawnRaindrop(); // добавление капли
lastDropTime = TimeUtils.nanoTime();
}
}
Вот более удачный пример реализации таймера, найденным на stackoverflow.com, которым пользуюсь до сих пор:
float lastDropTime = 0;
public void render(float delta) {
lastDropTime -= delta;
if (lastDropTime < 0){
spawnRaindrop(); // добавление капли
lastDropTime = 1;
}
}
Можно еще задать время в условии, дельту прибавлять, а lastDropTime сбрасывать на ноль при выполнении условия — но это кому как удобней.
Наигравшись с "ловлей капельки" по образу и подобию написал первую игру, которая выглядела так:
Игра хорошо работала на десктопе, но на смартфоне друга (Samsung Galaxy S3), изменилось масштабирование и сбилось позиционирование подсветки – все предсказуемо и поправимо, но хуже всего, это отсутствие нормальной поддержки коллизий, из-за чего я стал изучать Box2d.
Нужно сказать, что до этого я разработкой игр не занимался и сильно удивился, увидев какое количество расчетов делает элементарная игра, а познакомившись с Box2d, почему-то решил, что даже средненький смартфон не успеет все просчитать и начнет проседать FPS. Для проверки, решил написать игру, в которой будет много объектов и коллизий и к новому году получилось следующее:
Надо сказать, что художество — отдельная песня и даже на примитивную графику уходит очень много времени, а результат все равно оставляет желать лучшего.
Графику, хотел сначала рисовать в blender’e, который немного знал, но так как дело продвигалось очень медленно, то пришлось изучать бесплатный векторный редактор Inkscape, в котором все и нарисовал. В игре, из 3D графики, остался только патрон на бонусе, увеличивающем скорострельность.
Из бесплатных же ресурсов, особо выбрать и нечего, так как там в основном пиксельная графика.
На смартфоне игрушка пошла плавненько, без всяких признаков пропуска кадров, но друг пожаловался, что она слишком сложная и не хватает свободного перемещения танка по экрану.
С этим нельзя было не согласиться и в конце января — начале февраля появился окончательный вариант игры с несколько раз перерисованными и анимированными врагами-смайликами, также добавлен новый фон:
Оставалось доделать UI, прикрутить к google и настроить баланс. Баланс игры для меня был самым сложным этапом, на который ушло больше месяца, т.к. при любом изменении (а настроек очень много) приходилось проходить её снова и снова, а чем дольше в нее играешь подряд — больше устаешь и сложнее становится.
public class Settings {
final public static int WIDTH = 90;
final public static int HEIGHT = 160;
final public static short F_NULL = 0;
final public static short F_HERO = 1;
final public static short F_WALLS = 2;
final public static short F_HERO_BULLET = 4;
final public static short F_ENEMY = 8;
final public static short F_ENEMY_BULLET = 16;
final public static short F_MONEY = 32;
final public static short F_DEFEND = 64;
final public static short F_ENEMY_STATIC = 128;
final public static short M_NULL = -1;
final public static short M_HERO = F_WALLS | F_ENEMY | F_ENEMY_BULLET | F_MONEY | F_ENEMY_STATIC;
final public static short M_WALLS = F_HERO | F_ENEMY;
final public static short M_HERO_BULLET = F_ENEMY | F_ENEMY_STATIC;
final public static short M_ENEMY = F_WALLS | F_HERO | F_HERO_BULLET | F_DEFEND | F_ENEMY_STATIC;
final public static short M_ENEMY_BULLET = F_HERO | F_DEFEND;
final public static short M_MONEY = F_HERO;
final public static short M_DEFEND = F_WALLS | F_ENEMY | F_ENEMY_BULLET | F_ENEMY_STATIC;
// final public static short M_ENEMY_STATIC = F_ENEMY | F_ENEMY_STATIC | F_HERO | F_HERO_BULLET | F_DEFEND;
public int NUM_LEVEL = -1;
public int E_TYPE = 0; // 0,2,5 - квадрат, 1,3,4 - круг 6 - hex, 7 - asteroid, 8 - triangle
public int E_COUNT = 30;
public int E_CICLE = 1; // количество циклов для запуска врагов
public int E_MAX_COUNT = 5;
public float E_FREQ = 0.5f;
public float E_WIDTH = 6;
public float E_HEIGHT = 6;
public float E_DENSITY = 0.03f;
public float E_RESTITUTION = 0.9f;
public float E_HEALTH = 10;
public float E_START_X;
public float E_START_Y;
public float E_SPEED_X;
public float E_SPEED_Y;
public float E_RADIUS_X; // для статик врагов
public float E_RADIUS_Y;
public int E_STATIC_TYPE; // тип движения: 0 - по кругу, 1 - синусоиде, 2 - выехать
public float E_CHANCE_BOMB = 0.8f;
public int E_TYPE_BOMB = 0; // -1 - случайная, 0 - простая, 1 - одновременно по окружности, 2 - направленная, 3 - одновременно направленная часть окружности
public int E_MANY_BOMB = 0; // очередь бомб: -1 - от 0 до 3
public int[] E_BONUSES;
// public int E_CHANCE_BONUS = 50;
public int E_LEVEL_HARD;
// public int E_MAX_WEAPON = 1; // количество оружия за уровень
public float E_DELAY; // > 0 через время, 0 - ждать пока есть враги, -1 - стоп
static public Settings getLevel(int nLevel){
Levels levels = new Levels();
return levels.GetLevel(nLevel);
}
}
В классе уровней Levels, идет простой switch-case, где присваиваются определенные значения, которые после этого доступны в группе врагов, диспетчере врагов и в каждом враге.
Для начала сама структура игры:
В главном классе хранятся сами ресурсы, где и идет их загрузка, а также общие статические переменные и переключение экранов:
// 0 - main, 1 - level, 2 - game
public void setMyScreen(int nType, int nLevel){
if(stage != null) stage.clear();
if(world != null) ClearBodies();
if(UIMainMenu != null) UIMainMenu.dispose();
if(UILevelSelect != null) UILevelSelect.dispose();
if(WarOfSmilesGame != null) WarOfSmilesGame.dispose();
System.gc();
if(nType == 0){
UIMainMenu = new UIMainMenu();
setScreen(UIMainMenu);
Gdx.input.setInputProcessor(stage);
}else if(nType == 1){
UILevelSelect = new UILevelSelect();
setScreen(UILevelSelect);
Gdx.input.setCatchBackKey(true);
Gdx.input.setInputProcessor(inputMultiplexer);
}else if(nType == 2){
stageGame.clear();
WarOfSmilesGame = new WarOfSmilesGame(nLevel);
setScreen(WarOfSmilesGame);
Gdx.input.setCatchBackKey(true);
Gdx.input.setInputProcessor(inputProcessorHero);
}
}
Класс EnemyGroup управляет монетами, бонусами, которые появляются на месте убитых врагов. Также он управляет диспетчерами врагов, которых может быть несколько, а каждый диспетчер врагов, управляет одним любым типом врага.
Все повторяющиеся объекты, начиная от диспетчера и заканчивая бомбами и пулями, хранятся в пулах.
Класс главного меню на scene2d.ui.*
public class UIMainMenu extends ScreenAdapter {
@Override
public void render(float delta) {
WarOfSmiles.stage.draw();
super.render(delta);
}
@Override
public void resize(int width, int height) {
WarOfSmiles.fitViewport.update(width, height, true);
WarOfSmiles.camera.update();
super.resize(width, height);
}
public UIMainMenu() {
if(WarOfSmiles.bPlayMusic) {
if(!WarOfSmiles.music.isPlaying()) {
WarOfSmiles.music.play();
}
}
Table tableMain, tableMenu;
Label title;
TextButton button;
TextButton.TextButtonStyle buttonStyle, buttonStyleAds;
float pad = WarOfSmiles.screen_height / 48;
float button_with = WarOfSmiles.screen_width * 0.1f * 5.5f;
float button_height = button_with / 3.1848f;
tableMain = new Table().top().background(new TextureRegionDrawable(WarOfSmiles.background));
//tableMain.setDebug(true);
tableMain.setFillParent(true);
tableMain.row().align(Align.left).width(WarOfSmiles.screen_width);
//********************* TITLE ***************************
Label.LabelStyle labelStyle = new Label.LabelStyle(WarOfSmiles.bitmapFontBig, null);
title = new Label(WarOfSmiles.Bundle.get("game"), labelStyle);
Container containerTitle = new Container(title);
containerTitle.align(Align.left).padLeft(pad);
//********************* SMILE ***************************
float height_title = title.getHeight();
Image image = new Image(WarOfSmiles.assetManager.get("enemy/enemies.atlas", TextureAtlas.class).findRegion("circle_yellow"));
image.setOrigin((int)(height_title * 0.65f), (int)(height_title * 0.65f));
image.setRotation(-20);
Container containerImage = new Container(image);
containerImage.align(Align.right).size(height_title * 1.3f).padRight(pad).padTop(pad);
tableMain.add(new Stack(containerImage, containerTitle));
tableMain.row();
//********************* MAIN FON *************************
tableMenu = new Table().background(new TextureRegionDrawable(WarOfSmiles.fon_main)).top();
//tableMenu.setDebug(true);
tableMain.add(tableMenu).width(WarOfSmiles.screen_width - pad * 4).height(WarOfSmiles.screen_height - title.getHeight() - pad * 6);
tableMenu.row().padTop(pad * 4);
//********************* BUTTON ***************************
buttonStyle = new TextButton.TextButtonStyle();
buttonStyle.font = WarOfSmiles.bitmapFontNormal;
buttonStyle.pressedOffsetY -=2;
buttonStyle.up = new TextureRegionDrawable(WarOfSmiles.assetManager.get("ui/ui.atlas", TextureAtlas.class).findRegion("button"));
buttonStyle.down = new TextureRegionDrawable(WarOfSmiles.assetManager.get("ui/ui.atlas", TextureAtlas.class).findRegion("button_down"));
button = new TextButton(WarOfSmiles.Bundle.get("play"), buttonStyle);
tableMenu.add(button).width(button_with).height(button_height);
button.addListener(new ChangeListener() {
public void changed(ChangeEvent event, Actor actor) {
if(WarOfSmiles.bPlaySound) WarOfSmiles.click_snd.play(0.5f);
WarOfSmiles.nADS = 0;
WarOfSmiles.WarOfSmiles.setMyScreen(1, 0);
}
});
tableMenu.row();
button = new TextButton(WarOfSmiles.Bundle.get("scores"), buttonStyle);
tableMenu.add(button).width(button_with).height(button_height);
button.addListener(new ChangeListener() {
public void changed(ChangeEvent event, Actor actor) {
if(WarOfSmiles.bPlaySound) WarOfSmiles.click_snd.play(0.5f);
WarOfSmiles.myRequestHandler.getLeaderboardGPGS();
}
});
tableMenu.row();
buttonStyleAds = new TextButton.TextButtonStyle(buttonStyle);
buttonStyleAds.font = WarOfSmiles.bitmapFontSmall;
button = new TextButton(WarOfSmiles.Bundle.get("ads"), buttonStyleAds);
tableMenu.add(button).width(button_with).height(button_height);
button.addListener(new ChangeListener() {
public void changed(ChangeEvent event, Actor actor) {
if(WarOfSmiles.bPlaySound) WarOfSmiles.click_snd.play(0.5f);
WarOfSmiles.WarOfSmiles.setMyScreen(0, 0);
}
});
tableMenu.row();
// ************** SOUND, MUSIC *******************
Table table = new Table();
TextButton.TextButtonStyle buttonStyleSound = new TextButton.TextButtonStyle(
new TextureRegionDrawable(WarOfSmiles.assetManager.get("ui/ui.atlas", TextureAtlas.class).findRegion("sound")),
null,
new TextureRegionDrawable(WarOfSmiles.assetManager.get("ui/ui.atlas", TextureAtlas.class).findRegion("sound_off")),
WarOfSmiles.bitmapFontNormal);
final TextButton buttonSound = new TextButton("", buttonStyleSound);
table.add(buttonSound).width(button_with * 0.3f).height(button_height);
buttonSound.setChecked(!WarOfSmiles.bPlaySound);
buttonSound.addListener(new ChangeListener() {
public void changed(ChangeEvent event, Actor actor) {
WarOfSmiles.bPlaySound = !buttonSound.isChecked();
WarOfSmiles.prefs.putBoolean("bPlaySound", WarOfSmiles.bPlaySound);
WarOfSmiles.prefs.flush();
if(WarOfSmiles.bPlaySound) WarOfSmiles.click_snd.play(0.5f);
}
});
TextButton.TextButtonStyle buttonStyleMusic = new TextButton.TextButtonStyle(
new TextureRegionDrawable(WarOfSmiles.assetManager.get("ui/ui.atlas", TextureAtlas.class).findRegion("music")),
null,
new TextureRegionDrawable(WarOfSmiles.assetManager.get("ui/ui.atlas", TextureAtlas.class).findRegion("music_off")),
WarOfSmiles.bitmapFontNormal);
final TextButton buttonMusic = new TextButton("", buttonStyleMusic);
table.add(buttonMusic).width(button_with * 0.3f).height(button_height);
buttonMusic.setChecked(!WarOfSmiles.bPlayMusic);
buttonMusic.addListener(new ChangeListener() {
public void changed(ChangeEvent event, Actor actor) {
if(WarOfSmiles.bPlaySound) WarOfSmiles.click_snd.play(0.5f);
WarOfSmiles.bPlayMusic = !buttonMusic.isChecked();
WarOfSmiles.prefs.putBoolean("bPlayMusic", WarOfSmiles.bPlayMusic);
WarOfSmiles.prefs.flush();
if(WarOfSmiles.bPlayMusic) {
WarOfSmiles.music.play();
}else{
WarOfSmiles.music.stop();
}
}
});
tableMenu.add(table);
//*************** EXIT ***********************
tableMenu.row();
button = new TextButton(WarOfSmiles.Bundle.get("exit"), buttonStyle);
tableMenu.add(button).width(button_with).height(button_height);
button.addListener(new ChangeListener() {
public void changed(ChangeEvent event, Actor actor) {
if(WarOfSmiles.bPlaySound) WarOfSmiles.click_snd.play(0.5f);
Gdx.app.exit();
}
});
WarOfSmiles.stage.addActor(tableMain);
}
}
В целом игра получилась вполне играбельной, если особенно не увлекаться, а проходить по уровню в свободное время – нормальная такая убивалка времени, хоть другу и показалась сложноватой.
Сам проходил её не раз – во многом зависит от выпадения бонусов, потому как если начнут сыпаться только хорошие бонусы, то время прохождения сокращается в разы.
Перед публикацией, прикрутил таблицу рекордов от Google Play и отключение рекламы за деньги – больше для того, чтобы самому разобраться с монетизацией, т.к. реклама в игре показывается только при прохождении уровня (а их там всего 9 – и то, баннерная, которая не мешает и автоматически закрывается). И при выходе из игры, один раз показывается полноэкранная реклама, если вы проиграли хоть одну волну.
Да, смартфон все-таки пришлось купить после публикации для проверки Google Play сервисов, да и игра на смартфоне, все-таки отличается от игры на эмуляторе, плюс некоторые мелкие баги эмулятор не замечает.
Доступна на 2 языках – русском и английском. Бесплатная музыка взята с audionautix.com, занимает дополнительно 15 Мб., наверное можно было и без нее обойтись.
На все про все, потрачено около 5 месяцев работы, включая графику, поиск музыки и создание звуковых эффектов при помощи программки Bfxr.
В Google play игра за неделю скачана всего 2 раза, поэтому ни какой статистики привести не могу.
Вот собственно и все. Буду рад отзывам, пожеланиям и конструктивной критике. Удачи!