Pull to refresh

Моя первая игра на libGDX & Box2d


Наконец-то я закончил свою первую игру "Война Смайликов: Вторжение". Началось все в ноябре прошлого года, когда из-за очередного кризиса, закрылась контора, где я работал. Появилось свободное время и я решил попробовать написать игру под андроид, не имея смартфона, не зная 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 сбрасывать на ноль при выполнении условия — но это кому как удобней.


Наигравшись с "ловлей капельки" по образу и подобию написал первую игру, которая выглядела так:


image


Игра хорошо работала на десктопе, но на смартфоне друга (Samsung Galaxy S3), изменилось масштабирование и сбилось позиционирование подсветки – все предсказуемо и поправимо, но хуже всего, это отсутствие нормальной поддержки коллизий, из-за чего я стал изучать Box2d.


Нужно сказать, что до этого я разработкой игр не занимался и сильно удивился, увидев какое количество расчетов делает элементарная игра, а познакомившись с Box2d, почему-то решил, что даже средненький смартфон не успеет все просчитать и начнет проседать FPS. Для проверки, решил написать игру, в которой будет много объектов и коллизий и к новому году получилось следующее:


image


Надо сказать, что художество — отдельная песня и даже на примитивную графику уходит очень много времени, а результат все равно оставляет желать лучшего.


Графику, хотел сначала рисовать в blender’e, который немного знал, но так как дело продвигалось очень медленно, то пришлось изучать бесплатный векторный редактор Inkscape, в котором все и нарисовал. В игре, из 3D графики, остался только патрон на бонусе, увеличивающем скорострельность.


Из бесплатных же ресурсов, особо выбрать и нечего, так как там в основном пиксельная графика.


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


С этим нельзя было не согласиться и в конце января — начале февраля появился окончательный вариант игры с несколько раз перерисованными и анимированными врагами-смайликами, также добавлен новый фон:


image


Оставалось доделать 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, где присваиваются определенные значения, которые после этого доступны в группе врагов, диспетчере врагов и в каждом враге.


Для начала сама структура игры:


image


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


Переключение экранов
// 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.*


image


Класс главного меню
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 раза, поэтому ни какой статистики привести не могу.


Вот собственно и все. Буду рад отзывам, пожеланиям и конструктивной критике. Удачи!

Tags:
Hubs:
You can’t comment this publication because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author’s username will be hidden by an alias.