[ libGDX ] Пишем полноценную игру под Android. Часть 2

  • Tutorial
Здравствуйте! Не прошло и суток с момента публикации первой части статьи, а я не могу спать, так как есть незаконченное дело и нужно дописать статью. Приступим.

Оговорюсь еще раз. Я шибкий не знаток Java и поэтому следующий далее код, может смутить многих, но игру я написал меньше, чем за неделю и работал скорее на результат, чем на красоту и порядочность кода. Надеюсь, в комментариях найдется тот, кто поможет сделать код и структуру проекта, если не совершенными, то хотя бы привести к хорошему виду и дать возможность мне и остальным стать более хорошими программистами. Ладно, хватит лирики, продолжим наш «хардкор».
Создадим новый package и назовем его objects. В нем создадим класс фона, а в него добавим следующий код:

Файл BackgroundActor.java

package ru.habrahabr.songs_of_the_space.objects;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.graphics.g2d.Sprite;
import com.badlogic.gdx.scenes.scene2d.Actor;

public class BackgroundActor extends Actor {
    private Texture backgroundTexture;
    private Sprite backgroundSprite;

    public BackgroundActor() {
        backgroundTexture = new Texture("images/sky.jpg");
        backgroundSprite = new Sprite(backgroundTexture);
        backgroundSprite.setSize(Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
    }

    @Override
    public void draw(Batch batch, float alpha) {
        backgroundSprite.draw(batch);
    }
}


Ничего сложного. Это «актер», который устанавливается по размеру экрана пользователя и делает нашу игру более похожей на звездное небо. Примерно так это должно выглядеть:
Главный экран игры
image


Теперь добавим его в MyGame.java и сделаем его доступным извне, для того, чтобы не создавать его на каждом следующем экране. Это избавит нас от мерцания.

Файл MyGame.java

    // Перед методом create()
    public BackgroundActor background;

    @Override
    public void create() {
        ...
        
        background = new BackgroundActor();
        background.setPosition(0, 0);
        
        ...
    }


Далее, мы должны в каждом новой экране добавлять его на сцену:

stage.addActor(game.background);


Теперь, также в пакете objects создадим класс ноты. Он будет хранить все наши ноты в нужной нам последовательности.

Файл Note.java

package ru.habrahabr.songs_of_the_space.objects;

public class Note {
    private String note;
    private float delay;
    private Star star;

    // Устанавливаем ноты. Ноты будем брать из xml файла уровня.
    public void setNote(String note) {
        this.note = note;
    }

    public String getNote() {
        return this.note;
    }

    // Устанавливаем задержку для ноты, чтобы можно было создавать мелодии разной сложности
    public void setDelay(String delay) {
        this.delay = Float.parseFloat(delay);
    }

    public float getDelay() {
        return this.delay;
    }

    // Наша красавица -- звезда
    public void setStar(Star star) {
        this.star = star;
    }

    public Star getStar() {
        return this.star;
    }
}


Теперь, когда мы создали ноту, нам нужно создать звезду, которая будет нашим основным актером в нашей космической сцене. Она будет мерцать и петь свою чудную мелодию для будущих пользователей.
Перед тем, как продолжить немного поясню, зачем нам нужен отдельный класс для ноты и для звезды. Мелодия может повторять свои ноты, а каждая звезда должна быть в единственном экземпляре. Когда я только продумывал идею игры, я как раз хранил каждую ноту внутри звезды. В итоге, либо мелодия была слишком простой, либо звезд на небе становилось слишком много и было сложно пройти уровень даже с восемью повторяющимися нотами.
Итак, создаем звезду.

Файл Star.java

package ru.habrahabr.songs_of_the_space.objects;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.graphics.g2d.Sprite;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.Touchable;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;

public class Star extends Actor {
    
    // Звук, если пользователь ошибся
    private Sound sound, wrong;

    // Ноты в строковом представлении
    private String note;

    // Изображение звезды
    private Sprite img;
    private Texture img_texture;
    
    // Наш уровень. Он будет говорить, где должна находиться звезда
    private Level level;
    
    public Star(String str_img, String str_sound) {
        img_texture = new Texture("images/stars/" + str_img + ".png");
        img_texture.setFilter(TextureFilter.Linear, TextureFilter.Linear);
        img = new Sprite(img_texture);

        // Это я сделал для того, чтобы размер звезды менялся в зависимости от экрана пользователя

        img.setSize(Gdx.graphics.getHeight() * 15 / 100, Gdx.graphics.getHeight() * 15 / 100);
        this.note = str_sound;
        this.sound = Gdx.audio.newSound(Gdx.files.internal("sounds/bells/" + str_sound + ".mp3"));
        this.wrong = Gdx.audio.newSound(Gdx.files.internal("sounds/bells/wrong.mp3"));

        // Слушает события касания пользователя и играет соответствующую ноту, а также создает эффект мерцания за счет увеличения звезды в размерах
        addListener(new ClickListener() {
            @Override
            public boolean touchDown(InputEvent event, float x, float y, int pointer, int button) {
                img.setScale(1.2f);
                if (note.equals(level.getCurrentNoteStr())) {
                    level.setCurrentNote();
                    Gdx.input.vibrate(25); // Дадим пользователю понять, что он нажал немного вибрируя в момент касания
                    getSound().play();
                } else {

                    // Если юзер ошибся, то начинаем сначала. Проигрываем первые четыре ноты и играем их. А также сильнее вибрируем, чтобы оповестить его об ошибке.

                    level.setCurrentNote(0);
                    level.setEndNote(true);
                    level.setPlayMusic();
                    getWrongSound().play();
                    Gdx.input.vibrate(80);
                }
                return true;
            }
            
            @Override
            public void touchUp(InputEvent event, float x, float y, int pointer, int button) {
                img.setScale(1.0f); // Как только пользователь отпустил нашу звезду, делаем ее размер таким же, каким он был
            }
        });
        setTouchable(Touchable.enabled); // Делаем нашу звезду активной для касания
    }
    
    public void setLevel(Level level) {
        this.level = level;
    }
    
     // Устанавливаем позицию изображения равной позиции актера, и делаем размеры актера равными размеру звезды
    @Override
    public void setBounds(float x, float y, float width, float height) {
        super.setBounds(x, y, this.img.getWidth(), this.img.getHeight());
        this.img.setPosition(x, y);
    }
    
    // В каждый момент исполнения, немного крутим нашу звезду. Пусть потанцует.
    @Override
    public void act(float delta) {
        img.rotate(0.05f);
    }
    
    // Рисуем звезду на сцене
    @Override
    public void draw(Batch batch, float alpha) {
        this.img.draw(batch);
    }
    
    public Sound getSound() {
        return this.sound;
    }
    
    public Sound getWrongSound() {
        return this.wrong;
    }
    
    public String getNote() {
        return this.note;
    }
    
    public Sprite getImg() {
        return this.img;
    }
}


Теперь создадим наш класс уровня. Он будет отвечать за создания всех актрис и актеров, а также играть мелодию и поздравлять в победой. Я добавил его в пакет objects, но он лучше подходит как менеджер, поэтому можете перенести его туда самостоятельно.

Файл Level.java

package ru.habrahabr.songs_of_the_space.objects;

import java.util.HashMap;
import java.util.Map;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.scenes.scene2d.Touchable;
import com.badlogic.gdx.utils.Array;

public class Level {
    
    private XMLparse xml_parse;
    private Array<Note> notes = new Array<Note>();
    private Array<Star> stars = new Array<Star>();
    private Map<String, Array<String>> starsPos = new HashMap<String, Array<String>>();
    
    private int currentNote;
    private int endNote;
    
    private float delay;
    private boolean playMusic;
    
    private boolean win;
    
    private final Sound winner = Gdx.audio.newSound(Gdx.files.internal("sounds/win.mp3")); // Победный звук аплодисментов
    
    public Level(String level) {
        xml_parse = new XMLparse();
        Array<Star> xml_stars = xml_parse.XMLparseStars(); // парсим звезды из всего списка имеющихся
        notes = xml_parse.XMLparseNotes(level); // парсим ноты для уровня
        starsPos = xml_parse.getPos(level); // позиции звезд в текущем уровне
        endNote = 3;
        delay = 0;
        this.win = false;
        
        setPlayMusic();
        
        for (Note n : this.notes) {
            for (Star s : xml_stars) {
                if (n.getNote().equals(s.getNote()) && !this.stars.contains(s, true)) { // Поскольку в одном xml у нас хранятся все возможные варианты звезд, этот код отсеит лишние
                    this.stars.add(s);
                }
                if (n.getNote().equals(s.getNote())) n.setStar(s); // А здесь мы устанавливаем для каждой ноты свою звезду
            }
        }

        for (Star s : this.stars) {
            s.setLevel(this);
            s.setBounds(

                // Это нужно для того, чтобы позицию звезды можно было описать в процентом от размера экрана пользователя отношении (так как скопления наших звезд будут стараться походить на настоящие созвездия реального космоса)

                Gdx.graphics.getWidth() * Float.parseFloat(starsPos.get(s.getNote()).get(0)) / 100,
                Gdx.graphics.getHeight() * Float.parseFloat(starsPos.get(s.getNote()).get(1)) / 100 - s.getImg().getHeight() / 2,
                s.getImg().getWidth(),
                s.getImg().getHeight()
            );
        }
    }
    
    public boolean isWin() {
        return this.win;
    }

    // Устанавливаем последнюю ноту
    
    public void setEndNote() {
        if (this.endNote < this.notes.size - 1) {
            this.endNote += 4;
        }
    }
    
    // Переопределяем метод для того, чтобы в случае, когда пользователь ошибся, сделать последней четвертую ноту.
    // Можно было обойтись и одним методом, но мне так понравилось больше. Переопределяй! Властвуй!

    public void setEndNote(boolean begin) {
        if (begin) {
            this.endNote = 3;
        }
    }
    
    public void setCurrentNote(int note) {
        this.currentNote = note;
    }

    // Устанавливаем текущую ноту
    
    public void setCurrentNote() {
        if (this.currentNote < this.notes.size - 1) {
            this.currentNote++;
            if (currentNote - 1 == endNote) {
                currentNote = 0;
                setEndNote(); // Увеличиваем значение на 4 для последней ноты
                setPlayMusic(); // Играем мелодию с большим количеством нот
            }
        } else {

            // Если пользователь отыграл все ноты, играем победные аплодисменты

            this.endNote = notes.size - 1;
            this.currentNote = 0;
            this.win = true;
            this.winner.play();
        }
    }
    
    public int getCurrentNote() {
        return this.currentNote;
    }
    
    public String getCurrentNoteStr() {
        return this.notes.get(this.currentNote).getNote();
    }
    
    public Array<Note> getNotes() {
        return this.notes;
    }
    
    public Array<Star> getStars() {
        return this.stars;
    }
    
    
    public void setPlayMusic() {
        if (playMusic) {
            playMusic = false;
        } else {
            playMusic = true;
        }
    }
    
    // Играем наши ноты для пользователя

    public void playStars() {
        if (playMusic) {
            for (Star s : stars) {
                s.setTouchable(Touchable.disabled); // Не даем пользователю трогать наши звезды, пока играет мелодия
            }
            if (getCurrentNote() < notes.size) {
                if (getCurrentNote() <= endNote) {
                    Note note = notes.get(getCurrentNote());
                    
                    delay += note.getDelay(); // delay позволяет создавать задержку по времени между проигрыванием нот
                    
                    if (delay >= 0.9f) note.getStar().getImg().setScale(1.2f); // Увеличиваем активную в данный момент звезду для того, чтобы создать эффект мерцания
                    
                    if (delay >= 1.0f) {
                        delay = 0;
                        setCurrentNote(currentNote + 1);
                        note.getStar().getSound().play();
                        note.getStar().getImg().setScale(1f);
                    }
                } else {
                    setPlayMusic();
                    setCurrentNote(0);
                }
            } else {
                delay = 0;
                setCurrentNote(0);
                setPlayMusic();
            }
        } else {
            for (Star s : stars) {
                s.setTouchable(Touchable.enabled); // Делаем все наши звезды активными для касания
            }
        }
    }
}


Надеюсь, все понятно. Старался максимально комментировать код. Единственное, что может вызвать вопросы — это delay. Поясню немного. Метод playStars() будет вызываться в методе render() класса PlayScreen.java. Поскольку, он выполняется в потоке, каждый раз при совпадении всех условий, delay будет увеличиваться на заданное количество. Таким образом, будет имитироваться задержка в игре нот. Это лучше увидеть в коде. Давайте, наконец наполним наш класс PlayScreen.java. Поскольку, там много кода, я решил его спрятать под спойлер.

Файл PlayScreen.java
package ru.habrahabr.songs_of_the_space.managers;

import ru.habrahabr.songs_of_the_space.MyGame;
import ru.habrahabr.songs_of_the_space.objects.GamePreferences;
import ru.habrahabr.songs_of_the_space.objects.Level;
import ru.habrahabr.songs_of_the_space.objects.PlayStage;
import ru.habrahabr.songs_of_the_space.objects.PlayStage.OnHardKeyListener;
import ru.habrahabr.songs_of_the_space.objects.Star;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.Touchable;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

public class PlayScreen implements Screen {
    
    final MyGame game;
    
    private GamePreferences pref;
    
    private Level level;
    private String sL, nL;
    private Array<Star> stars;

    private PlayStage stage;
    private Table table, table2;
    
    public PlayScreen(final MyGame gam, String strLevel, String strNextLevel) {
        game = gam;
        this.sL = strLevel;
        this.nL = strNextLevel;
        
        stage = new PlayStage(new ScreenViewport());
        
        stage.addActor(game.background); // Добавляем фон
        
        pref = new GamePreferences();
        
        level = new Level(strLevel);
        stars = level.getStars();
        
        level.setCurrentNote(0);
        
        for (final Star s : stars) {
            stage.addActor(s); // Добавляем всех актрис (звезды) на сцену
        }
        
        LabelStyle labelStyle = new LabelStyle();
        labelStyle.font = game.font;
        
        // Skin для кнопок, которые показываются в случае победы пользователя

        Skin skin = new Skin();
        TextureAtlas buttonAtlas = new TextureAtlas(Gdx.files.internal("images/game/images.pack"));
        skin.addRegions(buttonAtlas);
        TextButtonStyle textButtonStyle = new TextButtonStyle();
        textButtonStyle.font = game.font;
        textButtonStyle.up = skin.getDrawable("button-up");
        textButtonStyle.down = skin.getDrawable("button-down");
        textButtonStyle.checked = skin.getDrawable("button-up");
        
        // Для всех кнопок лучше создать таблицу, так как она хорошо справляется с варавниванием

        table = new Table();
        table.padTop(20);
        table.center().top();
        table.setFillParent(true);

        // label для показа названия созвездия
        
        Label label = new Label(game.langStr.get("Constellation"), labelStyle);
        table.add(label);
        table.row().padBottom(30);
        label = new Label(game.langStr.get("level_" + strLevel), labelStyle);
        table.add(label);
        
        table.setVisible(false);
        
        stage.addActor(table);
        
        table2 = new Table();
        table2.center().bottom();
        table2.setFillParent(true);
        table2.row().colspan(2).padBottom(30);
        label = new Label(game.langStr.get("YouWin"), labelStyle);
        table2.add(label).bottom();
        table2.row().padBottom(20);
        TextButton button = new TextButton(game.langStr.get("Again"), textButtonStyle);

        // Нужно не забыть заставить кнопки прослушивания событих клика (касания)
        
        // Эта кнопка, после нажатия, запустит уровень сначала
        
        button.addListener(new ClickListener() {
            @Override
            public boolean touchDown(InputEvent event, float x, float y, int pointer, int button) {
                Gdx.input.vibrate(20);
                return true;
            };
            @Override
            public void touchUp(InputEvent event, float x, float y, int pointer, int button) {
                game.setScreen(new PlayScreen(game, sL, nL));
                dispose();
            };
        });
        table2.add(button);

        // А эта перенесет пользователя обратно на экран выбора уровня

        button = new TextButton(game.langStr.get("Levels"), textButtonStyle);
        button.addListener(new ClickListener() {
            @Override
            public boolean touchDown(InputEvent event, float x, float y, int pointer, int button) {
                Gdx.input.vibrate(20);
                return true;
            };
            @Override
            public void touchUp(InputEvent event, float x, float y, int pointer, int button) {
                game.setScreen(new LevelScreen(game));
                dispose();
            };
        });
        table2.add(button);
        table2.setVisible(false);
        
        stage.addActor(table2);
        
        Gdx.input.setInputProcessor(stage);
        Gdx.input.setCatchBackKey(true);
        stage.setHardKeyListener(new OnHardKeyListener() {          
            @Override
            public void onHardKey(int keyCode, int state) {
                if (keyCode == Keys.BACK && state == 1){
                    game.setScreen(new LevelScreen(game));    
                }       
            }
        });
    }

    @Override
    public void render(float delta) {

        // Очистка экрана в каждый момент выполнения потока
    
        Gdx.gl.glClearColor(0, 0, 0, 1);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
        
        // Рисуем сцену и вызываем метод act() для дополнительных действий актеров, описанных в одноименном методе каждого (в нашем случае, это вращение звезд)
        
        stage.act(delta);
        stage.draw();
        
        level.playStars();
        
        // Если пользователь выиграл, то показываем ему все наши кнопки, label'ы и прочее

        if (level.isWin()) {
            table.setVisible(true);
            table2.setVisible(true);
            pref.setLevel(nL); // Это для настроек игры. Объяснения ниже.
            for (Star s : stars) {
                s.setTouchable(Touchable.disabled); // Делаем все звезды неактивными для касания после победы пользователя
            }
        }
    }

    @Override
    public void resize(int width, int height) {}

    @Override
    public void show() {}

    @Override
    public void hide() {}

    @Override
    public void pause() {}

    @Override
    public void resume() {}

    // На забываем уничтожить сцену и объект класса MyGame

    @Override
    public void dispose() {
        stage.dispose();
        game.dispose();
    }
}



Наверное, код вызвал несколько вопросов, так как в нем можно заметить новый класс GamePreferences.java. Этот класс позволит нам хранить все настройки игры в удобном формате. Для Android приложения будет создан, так называемый «SharedPreferences». Подробнее здесь. В данном случае, в нем мы будем хранить пройденные пользователем уровни.
Ну что? Давайте теперь создадим и наполним его.

Файл GamePreferences.java

package ru.habrahabr.songs_of_the_space.objects;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Preferences;

public class GamePreferences {
    private Preferences pref;
    
    private static final String PREFS_NAME = "SONGS_OF_THE_SPACE";
    private static final String PREF_LEVEL = "LEVEL_";
    
    public GamePreferences() {
        pref = Gdx.app.getPreferences(PREFS_NAME);
    }
    
    public boolean getLevel(String level) {
        pref.putBoolean(PREF_LEVEL + 1, true);
        pref.flush();
        return pref.getBoolean(PREF_LEVEL + level, false);
    }
 
    public void setLevel(String level) {
        pref.putBoolean(PREF_LEVEL + level, true);
        pref.flush();
    }
}


В нем нет ничего сложного. Не буду дублировать документацию, ссылку на нее я дал ниже. Теперь нам нужно немного обновить наш класс XMLparse.java. Так, мы еще не научили нашу парсить звезды и ноты. Сделаем это.

Файл XMLparse.java
package ru.habrahabr.songs_of_the_space.objects;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import com.badlogic.gdx.Application.ApplicationType;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.files.FileHandle;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.XmlReader;
import com.badlogic.gdx.utils.XmlReader.Element;

public class XMLparse {
    
    private Array<Star> stars = new Array<Star>();
    private Array<Note> notes = new Array<Note>();
    private Map<String, Array<String>> starsPos = new HashMap<String, Array<String>>();

    // В этом методе мы будем парсить наши переводы. А вы как думали? Мы делаем многоязычную игру!
    
    public HashMap<String, String> XMLparseLangs(String lang) {
        HashMap<String, String> langs = new HashMap<String, String>();
        try {
            Element root = new XmlReader().parse(Gdx.files.internal("xml/langs.xml"));
            Array<Element> xml_langs = root.getChildrenByName("lang");
            
            for (Element el : xml_langs) {
                if (el.getAttribute("key").equals(lang)) {
                    Array<Element> xml_strings = el.getChildrenByName("string");
                    for (Element e : xml_strings) {
                        langs.put(e.getAttribute("key"), e.getText());
                    }
                } else if (el.getAttribute("key").equals("en")) {
                    Array<Element> xml_strings = el.getChildrenByName("string");
                    for (Element e : xml_strings) {
                        langs.put(e.getAttribute("key"), e.getText());
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return langs;
    }

    // В этом методе парсим звезды
    
    public Array<Star> XMLparseStars() {
        try {
            Element root = new XmlReader().parse(Gdx.files.internal("xml/stars.xml"));
            Array<Element> xml_stars = root.getChildrenByName("star");
            
            for (Element el : xml_stars) {
                Star star = new Star(
                    el.getAttribute("files"),
                    el.getAttribute("files")
                );
                
                stars.add(star);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return this.stars;
    }

    // В этом парсим уровни
    
    public Array<String> XMLparseLevels() {
        Array<String> levels = new Array<String>();
        Array<Integer> int_levels = new Array<Integer>();
        
        FileHandle dirHandle;
        if (Gdx.app.getType() == ApplicationType.Android) {
            dirHandle = Gdx.files.internal("xml/levels");
        } else {
            
            // Это хак, так как libGDX почему-то не хотел видеть этот файл при тестировании Desktop приложения

            dirHandle = Gdx.files.internal(System.getProperty("user.dir") + "/assets/xml/levels");
        }
        for (FileHandle entry : dirHandle.list()) {
            levels.add(entry.name().split(".xml")[0]);
        }
        
        for (int i = 0; i < levels.size; i++) {
            int_levels.add(Integer.parseInt(levels.get(i)));
        }
        int_levels.sort();
        levels.clear();
        
        for (int i = 0; i < int_levels.size; i++) {
            levels.add(String.valueOf(int_levels.get(i)));
        }
        return levels;
    }

    // Парсим ноты
    
    public Array<Note> XMLparseNotes(String strLevel) {
        try {
            Element root = new XmlReader().parse(Gdx.files.internal("xml/levels/" + strLevel + ".xml")).getChildByName("notes");
            Array<Element> xml_notes = root.getChildrenByName("note");
            
            for (Element el : xml_notes) {
                Note note = new Note();
                
                note.setNote(el.getText());
                note.setDelay(el.getAttribute("delay"));
                
                this.notes.add(note);
            }
            
        } catch (IOException e) {
            e.printStackTrace();
        }
        return this.notes;
    }

    // Парсим позицию для звезд. Знаю знаю, можно было сделать это при парсинге уровня, но мне так легче потом читать этот код, если разбить его по задачам
    
    public Map<String, Array<String>> getPos(String strLevel) {
        try {
            Element root = new XmlReader().parse(Gdx.files.internal("xml/levels/" + strLevel + ".xml")).getChildByName("positions");
            
            Array<Element> xml_pos = root.getChildrenByName("position");
            for (Element el : xml_pos) {
                Array<String> xy = new Array<String>();
                xy.add(el.getAttribute("x"));
                xy.add(el.getAttribute("y"));
                this.starsPos.put(el.getAttribute("note"), xy);
            }
            
        } catch (IOException e) {
            e.printStackTrace();
        }
        return this.starsPos;
    }
}



Осталось немного. Правда. Теперь, раз уж я заикнулся про многоязыковую поддержку, давайте создадим я немного поясню, как это будет. За основу берем локаль пользователя. Для нас она начинается с символом ru, для англичан с en и так далее. Я перевел приложение на два языка, поэтому языковой файл будет таким (и поэтому в коде метода XMLparseLangs немного странное условие):

Файл langs.xml
<?xml version="1.0"?>
<langs>
    <lang key="en">
        <string key="Play">Play</string>
        <string key="Exit">Exit</string>
        <string key="Again">Again</string>
        <string key="Levels">Levels</string>
        <string key="YouWin">You win!</string>
        <string key="Constellation">Constellation</string>
        
        <!-- Levels -->
        <string key="level_1">Canes Venatici</string>
        <string key="level_2">Triangulum</string>
        <string key="level_3">Equuleus</string>
        <string key="level_4">Apus</string>
        <string key="level_5">Sagitta</string>
        <string key="level_6">Musca</string>
        <string key="level_7">Ursa Minor</string>
        <string key="level_8">Orion</string>
        <string key="level_9">Ursa Major</string>
        <string key="level_10">Eridanus</string>
        <string key="level_11">Lacerta</string>
    </lang>
    <lang key="ru">
        <string key="Play">Играть</string>
        <string key="Exit">Выход</string>
        <string key="Again">Повторить</string>
        <string key="Levels">Уровни</string>
        <string key="YouWin">Вы победили!</string>
        <string key="Constellation">Созвездие</string>
        
        <!-- Levels -->
        <string key="level_1">Гончие псы</string>
        <string key="level_2">Треугольник</string>
        <string key="level_3">Малый Конь</string>
        <string key="level_4">Райская Птица</string>
        <string key="level_5">Стрела</string>
        <string key="level_6">Муха</string>
        <string key="level_7">Малая медведица</string>
        <string key="level_8">Орион</string>
        <string key="level_9">Большая медведица</string>
        <string key="level_10">Эридан</string>
        <string key="level_11">Ящерица</string>
    </lang>
</langs>



Как видно, мы берем аттрибут и по нему определяем, что отдавать пользователю. Теперь нужно сделать еще кое-что. Создать XML файлы звезд, нот, уровней. Сделаем это.

Файл stars.xml
<?xml version="1.0"?>
<stars>
    <star files="c5" />
    <star files="c#5" />
    <star files="d5" />
    <star files="d#5" />
    <star files="e5" />
    <star files="f5" />
    <star files="f#5" />
    <star files="g5" />
    <star files="g#5" />
    <star files="a5" />
    <star files="a#5" />
    <star files="b5" />
    
    <star files="c6" />
    <star files="c#6" />
    <star files="d6" />
    <star files="d#6" />
    <star files="e6" />
    <star files="f6" />
    <star files="f#6" />
    <star files="g6" />
    <star files="g#6" />
    <star files="a6" />
    <star files="a#6" />
    <star files="b6" />
</stars>



Если бегло глянуть этот файл, то можно заметить, что немного слукавил, когда сказал, что для каждой ноты будет своя звезда. Я сделал разное представление звезд в разной тональности. Зачем? Для улучшения звучания, так как если взять более-менее интересное созвездие, то можно заметить, то оно состоит, как минимум из 8-9 звезд, а писать мелодию для 8-9 разных нот не очень-то хотелось, вот я и решил немного упростить себе жизнь, добавив еще одну октаву.
Теперь приведу файл(для примера) уровня.

Файл 1.xml
<?xml version="1.0"?>

<level>
    <notes>
        <note delay="0.02f">d5</note>
        <note delay="0.05f">a6</note>
        <note delay="0.05f">d6</note>
        <note delay="0.05f">f#6</note>
        
        <note delay="0.02f">e5</note>
        <note delay="0.05f">a6</note>
        <note delay="0.05f">c#6</note>
        <note delay="0.05f">e6</note>
        
        <note delay="0.02f">d6</note>
        <note delay="0.05f">f#6</note>
        <note delay="0.05f">a6</note>
        <note delay="0.05f">d5</note>
    </notes>
    <positions>
        <position note="d5" x="5" y="35" />
        <position note="a6" x="20" y="43" />
        <position note="d6" x="40" y="50" />
        <position note="f#6" x="55" y="45" />
        <position note="e5" x="67" y="37" />
        <position note="c#6" x="77" y="47" />
        <position note="e6" x="90" y="50" />
    </positions>
</level>



Как видно, сначала мы определяем последовательность нот и их задержку, а затем определяем позицию каждой уникальной ноты в процентом отношении. Кажется это все. Если что-то забыл, жду комментариев. Также, жду критики и советов. Если кому-нибудь будет интересно в следующей статье я могу описать процесс подключения AdMob к нашей игре, рассказать как и откуда я брал звуки для игры, и также рассказать о том, как я выкладывал игру в Google Play. Спасибо за внимание!

Файлы проекта и пример готовой игры.

UPDATE. Я решли не писать новый пост, так как материала на целый пост не набралось, поэтому оставлю здесь некоторые исправления.

Ошибки и исправления
Для начала, хабраюзер zig1375 написал, что было бы неплохо использовать AssetManager. Это действительно справедливое замечание, так как после выбора уровня, игра как бы подвисает на время и в итоге не совсем ясно, то ли она зависла, то ли загружается. Я решил эту проблему следующим образом. Сначала в классе MyGame.java создаем объект типа AssetManager и делаем его публичным. Затем, создаем новый экран, я назвал его LoaderScreen.java и пишем в него что-то вроде этого:

Файл LoaderScreen.java
package ru.habrahabr.songs_of_the_space.managers;

import ru.habrahabr.songs_of_the_space.MyGame;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

public class LoaderScreen implements Screen {

    private MyGame game;

    private Stage stage;
    private Table table;
    private LabelStyle labelStyle;
    private Label label;
	
    public LoaderScreen(MyGame gam) {
        game = gam;
        
        // Загружаем все наши файлы
        game.manager.load("some/sounds", Sound.class);
        game.manager.load("some/textureatlas.pack", TextureAtlas.class);
		
        stage = new Stage(new ScreenViewport());
		
        stage.addActor(game.background);
		
        game.getHandler().showAds(false);

        labelStyle = new LabelStyle();
        labelStyle.font = game.levels;
        table = new Table();
        table.setFillParent(true);
        label = new Label(game.langStr.get("Loading"), labelStyle);
        
        table.add(label);
        stage.addActor(table);
    }

    @Override
    public void render(float delta) {
        Gdx.gl.glClearColor(0, 0, 0, 1);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        stage.act(delta);
        stage.draw();

        if (game.manager.update()) {
            game.setScreen(new MainMenuScreen(game));
            dispose();
        }
    }



Теперь, в классе MyGame.java просто вызываем этот экран вместо экрана MainMenuScreen.java и все. В документации о нем хорошо написано. А нам нужно изменить везде, где мы обращались к файлам на:

game.manager.get("some/file.png", TextureAtlas.class); // Вместо TextureAtlas может быть почти любой класс. Список поддерживаемых классов можно глянуть в документации по ссылке выше

Теперь, когда все готово, перед стартом игры, мы увидим экран:

Экран загрузки
image


Также, вы можете добавить какую-нибудь анимацию, чтобы пользователь точно знал, что игра грузится, а не зависла.

Далее. Теперь замечание от хабраюзера 1nt3g3r. Он написал, что лучше все текстурки упаковать в один файл. Я это сделал с кнопками, но забыл сделать со звездами, а зря. Про упаковку можно почитать в документации к libGDX, а я лишь поясню некоторые плюсы такого решения. Во-первых, после упаковки всех звезд, размер уменьшился на 300 килобайт, что довольно существенно. Во-вторых, я создал один объект атласа и обращался к нему, что по моим наблюдениям немного сказалось на производительности, в лучшую сторону. Как упаковать все ваши файлы? Можно одной командой в терминале. Переходим в каталог с вашим libGDX и выполняем оттуда что-то вроде этого:
java -cp gdx.jar:extensions/gdx-tools/gdx-tools.jar com.badlogic.gdx.tools.texturepacker.TexturePacker inputDir [outputDir] [packFileName]


Помните, что искать region мы будем по имени файла:
starsAtlas = manager.get("images/stars/stars.pack", TextureAtlas.class);
// Находим наше изображение
starsAtlas.findRegion("star1"),


Далее, хабраюзер sperson написал о том, что в libGDX лучше все ресурсы объявлять в json файле и загружать их потом через:
Skin.get(String, Class<?>);

Также, 1nt3g3r написал, что лучше все данные хранить не в xml, а в json. Наверное, они правы, но я пока оставил xml подход. Про json есть немного в документации.

Теперь, поговорим про AdMob. На самом деле, в документации все хорошо описано, поэтому нет смысла «копипастить» ее сюда. Немного расскажу про доход за эти несколько дней после публикации приложения. Я заработал, аж целых 16 центов!

Далее, немного расскажу про публикацию приложения в Google Play. Я сделал перевод названия и описания на английский и китайский. Забавно то, что саму игру писал четыре дня, а выкладывал три, из-за того, что ждал, пока переведут на китайский и долго думал над описанием. На счет китайского я сглупил, ибо моя знакомая, живущая в Китае сказала, что у них там не всегда и не очень хорошо работает Google Play. Может это из-за их файервола, а может это у нее проблемы. В любом случае, из Китая еще никто игру не скачал. Также, время заняла публикация в другие маркеты. О них есть замечательная статья здесь на Хабре.

Теперь о том, где я брал звуки и почему не стал делать фоновую музыку. Звуки я записал с помощью FL Studio. Хорошая программа, крайне простая в использовании. Взял инструмент Sytrus, нашел понравившийся мне звук, немного покрутил крутилки и записал каждую ноту в двух октавах. Потом, правда пришлось немного поработать с каждой нотой уже в Audacity, так как я забыл сделать плавное затухание из-за чего при проигрывании мелодии, ноты накладывались друг на друга, создавая ужасное звучание.
Звук аплодисментов я взял с сайта FreeSound. Полезный ресурс, правда не все звуки качественные, пришлось поискать, а затем немного обработать.
Теперь о том, почему я не стал вставлять фоновую музыку. Какие мелодии ассоциируются у вас с космосом? У меня вообще нет ассоциаций. Я долго что-то пытался сделать, но ничего хорошего не вышло и решил оставить эту затею. Тем более, лично меня раздражают стартовые мелодии в играх.
Share post

Comments 18

    0
    Поиграл в «Музыку космоса». Идея супер, но как Вы будете дальше развивать этот проект?
      0
      Спасибо! Вообще, есть несколько вариантов. Во-первых, нужно добить количество уровней хотя бы до 15. Во-вторых, думаю добавить режим игры со случайным скоплением звезд (и следовательно нот). При этом, количество звезд и нот устанавливает пользователь. Далее, хотелось сделать фон более красивым и «живым». То есть, нужно добавить туманности и всякое прочее космическое. Ну и, поработать с текущими уровнями. Сделать мелодии более яркими и запоминающимися. Как-то так)
      0
      Кстати, хочу выразить благодарность сообществу Хабры за столь радушный прием. Столько плюсов в карму за такое короткое время. А я, если честно, думал, что пост вообще не пройдет даже начальную модерацию) Спасибо Хабр! Следующий пост постараюсь написать завтра, либо сегодня ночью.
        0
        После выбора уровня сильно тормозит, Вы не думали использовать AssetManager?
        Таким образом Вы сможете отобразить, как минимум, прогресс загрузки. А сейчас, кликнув по уровню не понятно что идет загрузка, ощущение что все повисло…
          0
          Да, вы не первый мне это сказали. Это, как я понял из-за AdMob. Также, возможно потому, что я поленился упаковать все изображения звезд. В любом случае, спасибо за наводку. В следующей версии постараюсь это исправить. По правде говоря, игра была написана примерно за 4-5 дней. Вот настолько я торопливый человек — желал скорейшего релиза и написания статьи :)
            0
            Плюс на высоких разрешениях (в частности на SGS4 1920 x 1080) сложно попадать по звездам…

            И спасибо за статьи по libgdx, таких на хабре не так и много.
              0
              У меня, к сожалению, нет под рукой устройства с высоким разрешением. Но тестирование на эмуляторе вроде не вызывало проблем. Наверное, приловчился. По поводу статей, да вы правы. А те что есть, на вес золота. Будем исправлять)
                0
                По LibGDX можете глянуть эти статьи.
                  0
                  Я читал ваши статьи. Хорошо написано. Многое узнал именно из ваших статей. Скажите, в планах есть о чем рассказать? Интересует box2d. Ваши статьи на хабре про него и в блоге я читал. Хотелось бы еще чего-нибудь.
                    0
                    Мог бы, если какие-то конкретные запросы будут. Box2D — слишком объёмен сам по себе )
                      0
                      Вот один вопрос (возможно нубский). Мне показалось, или размеры камеры влияют на поведение объектов. Может я что-то упустил при чтении статей, но когда просто ставишь камеру так:

                      camera = new OrthographicCamera(Gdx.graphics.getWidth(), Gdx.graphics.getHeigth());
                      


                      то, все объекты тяжелеют что-ли. В итоге их вес приходится писать с сотыми (типа так 0.001f). Или я что-то не понимаю. Особенно это заметно, когда перетаскиваешь объект по экрану мышкой. Может поясните? Или это связано больше с тем, что нужно переводить все значения из килограммов (вроде так там они описываются).
                        0
                        Если размер камеры делаете больше, то предметы кажутся тяжелее, а если меньше, то легче, так?
                          0
                          Ну, насколько я помню, так у меня и было. Я уже с пару недель не трогал box2D. А до этого, общался с ним не больше 7 дней) И даже не кажутся, они ведут себя именно так. То есть, его сложнее становится «подтянуть» мышкой и все такое.
                            0
                            Кстати, я все же ошибся. Сейчас проверил, вроде бы одинаково. Однако, все равно хотелось бы больше подробностей, связанных с density, friction, restitution и прочим. Спасибо!
              0
              проглянул код. В глаза бросился фрагмент
              public Star(String str_img, String str_sound) {
                      img_texture = new Texture("images/stars/" + str_img + ".png");
                      img_texture.setFilter(TextureFilter.Linear, TextureFilter.Linear);
                      img = new Sprite(img_texture);
              


              Вы для каждой новой звезды создаете текстуру снова (new Texture()), и по коду не видно, что вы ее уничтожаете. Текстуры в libgdx — unmanaged обьекты, после использования нужно вызывать метод dispose(), иначе будут утечки памяти. Вообще правильный выход — создайте текстурный атлас с помощью GdxTexturePacker, и берите из него регионы текстуры.

              Также я бы на вашем месте использовал Json, а не xml. В libgdx есть прелестные классы для работы с json — сериализация\десереализация обьектов из файлов. К тому же json занимает меньше места, и (не факт) быстрее парсится.

              Это то, что я заметил вкратце. Следует заметить, что я наступал на эти же грабли когда-то :)

              Если хотите почитать немного о libgdx, можете сделать это здесь (немножко пиара)

              Сейчас я заканчиваю разрабатывать игру-лабиринт (та, что с шариком), если кому-то будет интересно, смогу написать некоторые моменты. В частности, я использовал Box2d, DistanceField шрифты, текстурные атласы, написал редактор уровней (загрузка\выгрузка json), и другое. Пишите, кому нужно, будем разбираться :)
                0
                Я уже понял, что могут быть проблемы с производительностью из-за текстурок. В новой версии исправлю и, вы правы, атлас лучше. Я его использовал для кнопок и прочего, а вот звезды решил таким образом оставить. Ошибся) обязательно почитаю ваш блог. А по поводу вашей игры — хотелось бы увидеть полноценную статью (или две). Вообще, интересно про json. И еще. Что вы можете сказать про TiledMap? Находил чей-то англоязычный блог. Там хвалили, правда для «раннеров» и подобного. Спасибо!
                +1
                TiledMap — использовал, нормально, ничего не скажу) Удобно делать карты, libgdx круто поддерживает. Есть классный редактор — Tiled, пользоваться им неплохо. Как по мне, такие карты хорошо подойдут для стратегий, например, или «рпг» — для вашей игры хватит и простой картинки и рэндомных звезд)

                Статью напишу, как закончу игру)
                  0
                  Только вы учтите, что google может в любой момент удалить ваше приложение с маркета и заблокировать аккаунт, как это случилось с вашим покорным слугой.
                  Не стоит делать бизнес зависимым от корпорации зла.
                  Сотрудничество с гуглем — не надежно.

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