Qook: Портировать старую игрушку на Android и поделиться ей с миром

в 11:46, , рубрики: java, портирование приложений, разработка игр, Разработка под android, старые игры, метки:

КДПВ

На самом деле, я очень люблю логические игрушки. Не, «три в ряд», «найди похожий» и прочие «покорми собачку» меня мало интересуют. А вот по-настоящему сложная штуковина может спокойно утянуть на пару недель. Примерно так и случилось со мной в 2004-м году, когда ко мне в руки попал новенький мобильник от Sony. Способность этого T68I отлично звонить, показывать цветные картинки и, по слухам, даже отсылать свои контакты по BT прошли мимо меня незамеченными. А вот Q – нет. И сколько часов я просидел за маленьким дисплеем, судорожно гоняя шарики туда-сюда я уже и не помню. Зато, прекрасно помню, что, идея написать порт этой игры для какой-нибудь из современных платформ меня не отпускала со времен своего самого первого Hello World. Правда, все пои попытки склепать хоть какой-то игровой движок в те старые-добрые времена разбивались о… в общем обо что-то они разбивались. Зато теперь я давно и прочно пишу на Java, а с некоторых (совсем недавних) пор еще и для Android, так что идея порта игрушки наконец-то нашла возможность быть реализованной. Хотите посмотреть, что оно есть и как оно получилось? Тогда – под кат.

А в чем смысл игры?

Q – очень сложная логическая игра, смысл которой в том, чтобы закатить все цветные шарики на игровом поле в лунки того же цвета. Почти как в биллиарде, ага. И совсем как в биллиарде, шарики двигаются только по прямой и только до первого препятствия, которым может быть либо неподвижный кирпич, либо другой шарик. При этом все уровни построены так, чтобы исключить самые простые решения – в этом-то и прелесть.

Все равно не поняли? Ладно, вот вам картинка первого уровня. Придумайте, кстати, на досуге как этот самый уровень пройти.

Qook: Портировать старую игрушку на Android и поделиться ей с миром - 2

Так лучше? Тогда давайте еще поймем, как бы это написать

На чем пишем?

Первым желанием, которое пришло мне в голову, было использовать какой-нибудь физический движок. Ну, Unity, например. Правда, после того как я посмотрел, сколько весят такие игрушки и сколько они жрут батарейки – идея использовать целый движок только для того, чтобы красиво катать шарики по полю умерла незамедлительно. Зато появилась идея написать свой собственный маленький движок специально для этой игры, тем более, что именно эта часть у меня и не получалась в детстве. Так что будем изобретать свой велосипед: с шариками и низким энергопотреблением. Кстати, изобретать его мы будем на Java, раз уж Android. Поехали?

Выделяем игровые элементы

Это первое, что нужно сделать при написании кода чего бы то ни было. Давайте посмотрим, что у нас есть… так, посмотрим на картинку еще раз…

Ага! На поле у нас есть… элементы! Логично?

public abstract class Item implements Serializable {
    private Color color;

    public Item(Color color) {
        setColor(color);
    }

    public Color getColor() {
        return color;
    }

    private void setColor(Color color) {
        this.color = color;
    }

    @Override
    public String toString() {
        return "Item{" +
                "color=" + color +
                '}';
    }
}

Что, где координаты? А откуда шарик знает о том, где он там находится? Не его дело.

Теперь спустимся поглубже и посмотрим какие именно элементы у нас тут есть

Блок. С ним все просто: он квадратный, серый и никуда не двигается. Ни дать, ни взять – экономика какой-нибудь не сильно развитой страны. Правда, надо не забыть, что блок – это элемент.

public class Block extends Item {
    public Block() {
        super(Color.GRAY);
    }
}

Шарик. С шариком немного сложнее: он круглый, разноцветный и все время куда-то катается. И тоже элемент.

public class Ball extends Item {
    public Ball(Color color) {
        super(color);
    }
}

Дырка. Ну, или луза – как вам больше нравится. Она у нас что-то среднее между шариком и блоком: вроде бы квадратная и неподвижная, но тоже разноцветная.

public class Hole extends Item {
    public Hole(Color color) {
        super(color);
    }
}

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

Пишем уровень

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

public class Level implements Serializable {
    private Item[][] field;
    private int ballsCount;

    public Level(Item[][] field) {
        this.field = field;
    }
}

Ну вот, начало хорошее. У нас есть какой-то уровень, который хранит в себе какой-то двумерный массив из элементов. Поскольку все шарики-блоки-дырки у нас и есть элементы, так можно. Вторая переменная нам нужна для того, чтобы не подсчитывать количество оставшихся на поле шариков каждый раз. Впрочем, один раз это дело посчитать по-честному таки придется

private int countBallsOnLevel(Item[][] field) {
    int ballsCount = 0;

    for (Item[] aField : field) {
        for (int j = 0; j < field[0].length; j++) {
            if (aField[j] != null && aField[j].getClass().equals(Ball.class)) {
                ballsCount++;
            }
        }
    }

    return ballsCount;
}

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

this.ballsCount = countBallsOnLevel(field);

Так, уровень у нас готов. Теперь по плану самое интересное

Пишем движок

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

private Level level;
private int ballsCount;

public Field(Level level) {
    this.level = level;
    this.ballsCount = level.getBallsCount();
}

Отлично. Теперь ненадолго отвлечемся от движка и напишем небольшой enum

public enum Direction {
    LEFT,
    RIGHT,
    UP,
    DOWN,
    NOWHERE
}

Ага, направление перемещения шарика. Теперь отвлечемся еще раз и напишем совсем маленький классик, который будет хранить в себе координаты нужного элемента на поле. Зачем? А чтобы потом писать меньше

public class Coordinates {
    private int horizontal;
    private int vertical;

    public Coordinates(int horizontal, int vertical) {
        this.horizontal = horizontal;
        this.vertical = vertical;
    }
}

Ура, наконец-то можно вернуться обратно к движку и продолжить наш непосильный труд.

Первое, что хочется сделать – так это научить наше поле перемещать шарики.

private Coordinates moveRight(int xCoord, int yCoord) {
    try {
        while (level.getField()[yCoord][xCoord + 1] == null) {
            level.getField()[yCoord][xCoord + 1] = level.getField()[yCoord][xCoord];
            level.getField()[yCoord][xCoord++] = null;
        }
    } catch (ArrayIndexOutOfBoundsException ex) {
    }

    return new Coordinates(xCoord, yCoord);
}

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

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

private Coordinates moveItem(Coordinates coordinates, Direction direction) {

    int horizontal = coordinates.getHorizontal();
    int vertical = coordinates.getVertical();

    if (direction.equals(Direction.NOWHERE) || level.getField()[vertical][horizontal] == null) {
        return null;
    }

    Class clazz = level.getField()[vertical][horizontal].getClass();
    if (!clazz.equals(Ball.class)) {
        return null;
    }

    switch (direction) {
        case RIGHT:
            return moveRight(horizontal, vertical);

        case LEFT:
            return moveLeft(horizontal, vertical);

        case UP:
            return moveUp(horizontal, vertical);

        case DOWN:
            return moveDown(horizontal, vertical);
    }

    return null;
}

Ну вот и наши координаты пригодились. Я же говорил, что так меньше писать.

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

private boolean acceptRight(Coordinates coordinates) {
    try {
        int horizontal = coordinates.getHorizontal();
        int vertical = coordinates.getVertical();

        Item upItem = level.getField()[vertical][horizontal + 1];
        Item item = level.getField()[vertical][horizontal];

        if (upItem == null || !upItem.getClass().equals(Hole.class) || !(upItem.getColor().equals(item.getColor()))) {
            return false;
        }

        level.getField()[vertical][horizontal] = null;
    } catch (ArrayIndexOutOfBoundsException ex) {
    }

    return true;
}

И точно такая же обертка уровнем выше

private boolean acceptHole(Coordinates coordinates, Direction direction) {
    boolean isAccepted = false;
    switch (direction) {
        case UP:
            isAccepted = acceptUp(coordinates);
            break;

        case DOWN:
            isAccepted = acceptDown(coordinates);
            break;

        case RIGHT:
            isAccepted = acceptRight(coordinates);
            break;

        case LEFT:
            isAccepted = acceptLeft(coordinates);
            break;
    }

    if (!isAccepted) {
        return false;
    }

    catchBall();

    return checkWin();

}

После того, как шарик получилось скушать, нужно еще пересчитать количество оставшихся. Нет, там не O(N).

private void catchBall() {
    ballsCount--;
}

Почему? Потому что за один ход мы можем перемещать только один шарик, а значит – и закатить больше у нас не получится. Проверка на то, что уровень окончен делается не сложнее

private boolean checkWin() {
    return ballsCount == 0;
}

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

public boolean makeTurn(Coordinates coordinates, Direction direction) {
    Coordinates newCoordinates = moveItem(coordinates, direction);
    return newCoordinates != null && acceptHole(newCoordinates, direction);
}

Ничего нового: приняли координаты с направлением, если получилось, переместили шарик на новое место и загнали его в дырку, если она там нашлась. Если нашлась – вернули true.

Ну вот и весь движок. И стоило из-за этого цеплять сюда какой-то unity?

Надо теперь только научить телефон все это дело показывать на экране.

Пишем свою вьюшку

Основной элемент интерфейса приложения для Android – View. Вьюшка, то есть. Это и кнопочка, и поле для ввода и… наше игровое поле. Правда, странно надеяться, что за нас его уже кто-то написал. Так что придется сделать это самим. Для этого мы создадим целый класс и отнаследуем его от встроенного View андройда, чтобы получить доступ к его жизненному циклу, возможности размещать это дело на экране и еще много чему

public class FieldView extends View {
    private final double ROUND_RECT_SIZE = 0.15;
    private final int PADDING_DIVIDER = 4;
    int paddingSize = 0;
    private int elementSize;
    private Field field;
    private Size fieldSize;
    private Size maxViewSize;

    public FieldView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
}

Зачем тут нужны константы, мы разберемся потом, а пока подумаем о том, какого размера должна быть вьюшка. Понятно, что она должна занимать как можно больше места на экране, но не вылезать за его пределы. И понятно, что размер элементов должен быть пропорционален размеру самой вьюхи. При этом задать что-то константно мы не можем – не писать же свою вьюшку под пару тысяч различных телефонов. Зато мы можем что-нибудь сделать с вьюшкой при размещении ее на экране. Поскольку в XML-разметке она у нас будет иметь размерности math_parent, то этот самый размер мы сможем определить runtime.

public Size countFieldSize() {
    if (maxViewSize == null) {
        maxViewSize = new Size(this.getWidth(), this.getHeight());
    }

    int horizontalElementsNum = field.getField()[0].length;
    int verticalElementsNum = field.getField().length;

    int maxHorizontalElSize = maxViewSize.getWidth() / horizontalElementsNum;
    int maxVerticalElSize = maxViewSize.getHeight() / verticalElementsNum;

    this.elementSize = (maxHorizontalElSize < maxVerticalElSize) ? maxHorizontalElSize : maxVerticalElSize;

    int newWidth = this.elementSize * horizontalElementsNum;
    int newHeight = this.elementSize * verticalElementsNum;

    return new Size(newWidth, newHeight);
}

Size у нас это примерно то же, что и координаты, только нужен для хранения размеров по Ox и Oy. Алгоритм простой: посмотрели, не определил ли эти размеры кто-нибудь до нас, получили высоту и ширину в пикселях, прикинули сколько будет занимать один элемент по горизонтали и по вертикали, выбрали меньший, да и пересчитали размер самой вьюхи домножив размер элемента на их количество по строке и столбцу.

А, ну и не забыть вызвать это дело:

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    Size countedFieldSize = countFieldSize();
    if (fieldSize == null || !fieldSize.equals(countedFieldSize)) {
        this.fieldSize = countedFieldSize;
        setFieldSize(this.fieldSize);
        paddingSize = (int) (Math.sqrt(elementSize) / PADDING_DIVIDER);
    }

}

Что делает setFieldSize? Да пожалуйста!

public void setFieldSize(Size size) {
    LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(size.getWidth(), size.getHeight());
    params.gravity = Gravity.CENTER_HORIZONTAL;
    this.setLayoutParams(params);
}

Взяли вьюшку, да и прицепили к ней размеры. А что вы хотели?

Так, с размерами мы определились. Теперь нам надо как-то отрисовать игровое поле. Это не сложно и делается в onDraw. Правда, прежде чем что-то рисовать надо бы где-то найти сами игровые элементы.

Рисуем

Первое, что мне пришло в голову – завести целую кучу файлов разметки в drawable и подсовывать их на canvas по координатам. К великому несчастью, эта гениальная идея сломалась о невозможность задавать относительные размеры элементов. То есть, я могу сделать у блока скругленные углы и задать их в dp. И они на самом деле будут скругленными. Проблема только в том, что размер элемента у нас изменяется в зависимости от количества этих самых элементов на поле. И если поле у нас 6*6 (минимальный размер в игре), блоки будут квадратными со слегка скругленными углами. А если поле у нас аж 13*13 (максимальный размер) – это будут слегка квадратные шарики. Некрасиво.
Однако, сама идея рисовать на canvas готовыми элементами мне нравится больше, чем заморачиваться с какой-то низкоуровневой рисовкой, вроде drawRect. Давайте наделаем кучу элементов?

Генерацией Drawable у нас будет заниматься отдельный метод (хотя, мне почему-то хотелось вынести это в отдельную фабрику) selectDrawable, который принимает экземпляр элемента, выясняет кто он такой и делает для него drawable. Например, блок будет рисоваться примерно так:

Class clazz = item.getClass();
Color color = item.getColor();

if (clazz.equals(Block.class)) {
    GradientDrawable bgShape = new GradientDrawable();
    bgShape.setColor(ContextCompat.getColor(getContext(), R.color.gray));
    bgShape.setCornerRadius((float) (elementSize * ROUND_RECT_SIZE));
    return bgShape;
}

Ну вот и константы пригодились. Теперь радиус скругления у нас зависит от размера самого элемента. Как раз то, чего мы и добивались.

Теперь посмотрим на то, как строится drawable для шарика, который у нас разноцветный:

if (clazz.equals(Ball.class)) {
    GradientDrawable bgShape = new GradientDrawable();
    bgShape.setColor(ContextCompat.getColor(getContext(), R.color.gray));
    bgShape.setCornerRadius(elementSize);

    switch (color) {
        case GREEN:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.green));
            return bgShape;

        case RED:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.red));
            return bgShape;

        case BLUE:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.blue));
            return bgShape;

        case YELLOW:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.yellow));
            return bgShape;

        case PURPLE:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.purple));
            return bgShape;

        case CYAN:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.cyan));
            return bgShape;

    }
}

Да ненамного сложнее. Сначала нарисовали шарик, а потом полили его нужной краской. Зачем здесь switch и почему нельзя просто задать цвет тем, что мы достали из шарика?
Потому что это разные цвета. Цвет, который хранится в элементе, это обычный enum, который из Java, а то, что принимает drawable в качестве цвета – нормальный android-ресурс с нормальным строковым значением. Например, вот вам красненький:

<color name="red">#D81B60</color>

Склеивать одно с другим плохая идея, потому что когда-нибудь мне придет в голову, что красный – недостаточно синий и вообще пора поиграться со шрифтами и придется все это дело переписывать вместо того, чтобы просто исправить ресурсный файл.

Ну и на закуску – строим drawable из дырки:

if (clazz.equals(Hole.class)) {
    GradientDrawable bgShape = new GradientDrawable();
    bgShape.setCornerRadius((float) (elementSize * ROUND_RECT_SIZE));

    switch (color) {
        case GREEN:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.green));
            return bgShape;

        case RED:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.red));
            return bgShape;

        case BLUE:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.blue));
            return bgShape;

        case YELLOW:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.yellow));
            return bgShape;

        case PURPLE:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.purple));
            return bgShape;

        case CYAN:
            bgShape.setColor(ContextCompat.getColor(getContext(), R.color.cyan));
            return bgShape;
    }
}

Опять ничего нового: нарисовали дырку, покрасили и отдали ее просителю

Так, ничего не забыли? Хм… Дырки, шарики, блоки… А пустое место? Что, например, будет, если в массиве встретился null?

if (item == null) {
    GradientDrawable bgShape = new GradientDrawable();
    bgShape.setColor(ContextCompat.getColor(getContext(), android.R.color.transparent));
    bgShape.setCornerRadius((float) (elementSize * ROUND_RECT_SIZE));
    return bgShape;
}

Да ничего нового не будет, потому что это точно такой же красивый скругленный квадратик. Жалко, только что невидимый.

Готово, элементы мы строить умеем. На чем мы там остановились? А… да! На том, чтобы их отрисовать

@Override
protected void onDraw(Canvas canvas) {
    if (field == null) {
        return;
    }

    for (int i = 0; i < field.getField().length; i++) {
        for (int j = 0; j < field.getField()[0].length; j++) {
            Drawable d = selectDrawable(field.getField()[i][j]);
            d.setBounds(j * elementSize + paddingSize, i * elementSize + paddingSize, (j + 1) * elementSize - paddingSize, (i + 1) * elementSize - paddingSize);
            d.draw(canvas);
        }
    }
}

Ну вот. Прошлись по всему полю, для каждого элемента найдем его графическое представление, установили ему размеры и отступы друг от друга и отрисовали его на холсте. Кстати, интересно, что здесь именно drawable рисует на холсте, а не холст рисует на себе drawable. Для того, чтобы это сделать, пришлось бы каждый раз конвертить drawable в bitmap, а это долго.

Давайте посмотрим на то, что получилось? Для этого напишем какой-нибудь тестовый уровень, где элементы заданы прямо в конструкторе (уберем-уберем, не переживайте)

А вот так писать лучше не надо

public class Level {
    private Item[][] field;

    public Item[][] getField() {
        return field;
    }

    public Level() {
        field = new Item[6][6];
        field[0][0] = new Block();
        field[0][1] = new Block();
        field[0][2] = new Hole(Color.RED);
        field[0][3] = new Block();
        field[0][4] = new Block();
        field[0][5] = new Block();

        field[1][0] = new Block();
        field[1][1] = new Ball(Color.RED);
        field[1][2] = new Ball(Color.GREEN);
        field[1][3] = new Ball(Color.YELLOW);
        field[1][4] = new Ball(Color.CYAN);
        field[1][5] = new Block();

        field[2][0] = new Block();
        field[2][1] = new Hole(Color.GREEN);
        field[2][2] = new Hole(Color.YELLOW);
        field[2][3] = new Hole(Color.PURPLE);
        field[2][4] = new Hole(Color.CYAN);
        field[2][5] = new Hole(Color.BLUE);

        field[3][0] = new Block();
        field[3][1] = new Ball(Color.PURPLE);
        field[3][5] = new Block();

        field[4][0] = new Block();
        field[4][1] = new Block();
        field[4][3] = new Ball(Color.BLUE);
        field[4][5] = new Block();

        field[5][1] = new Block();
        field[5][2] = new Block();
        field[5][3] = new Block();
        field[5][4] = new Block();
    }
}

А теперь прицепим нашу вьюшку к какому-нибудь активити и запустим это дело

Qook: Портировать старую игрушку на Android и поделиться ей с миром - 3

Наконец-то оно что-то показывает!

А теперь вдохновленные такой красивой картинкой научим нашу вьюшку интерактивности

Гоняем шары

Поскольку движок, который умеет перемещать элементы, у нас уже есть, нам остается только найти способ вызывать соответствующие методы как-то взаимодействуя с вьюхой.

Взаимодействовать с игровым полем можно по-разному. Если пользователей совсем не жалко, можно даже сделать управление таким же, как в оригинальной игре – приделать виртуальный джойстик и нажимать на него до посинения. А можно вспомнить о том, что нативный жест для сенсорного экрана, это все-таки свайп и смахивать шары в нужную сторону. Поняли, что мы собрались сделать? Тогда поехали

Вообще, для Android есть встроенный GestureManager, но то ли я так и не понял, как им пользоваться, то ли на моем тестовом девайсе он работает как попало, но запустить его так, чтобы не было ошибок распознавания нигде моими кривыми ручками почему-то не вышло. Так что сейчас возьмем и напишем свой собственный

Итак, двигаться наши с вами шарики могут ровно в четырех направлениях: вверх, вниз, влево и вправо. Правда, кроме этого они могут вообще никуда не двигаться, но это совсем не интересно. Так что для того, чтобы определять направление движения шарика, распознавать нам нужно всего 4 простых жеста.

Не особо заморачиваясь, начинаем писать еще один метод:

public Direction getSwipeDirection(float downHorizontal, float upHorizontal, float downVertical, float upVertical) {
    float xDistance = Math.abs(upHorizontal - downHorizontal);
    float yDistance = Math.abs(upVertical - downVertical);
    double swipeLength = getSwipeLength(xDistance, yDistance);

    if (swipeLength < elementSize / 2) {
        return Direction.NOWHERE;
    }

    if (xDistance >= yDistance) {
        if (upHorizontal > downHorizontal) {
            return Direction.RIGHT;
        }
        return Direction.LEFT;
    }

    if (yDistance > xDistance) {
        if (upVertical > downVertical) {
            return Direction.DOWN;
        }
        return Direction.UP;
    }

    return Direction.DOWN;
}

Direction – это Enum, который мы описали выше, а все остальное совсем просто: получили 4 координаты (откуда мы их получили пока не важно) и посчитали расстояние по вертикали и горизонтали. Потом вспомнили курс геометрии из средней школы и нашли длину самого свайпа. Если она совсем маленькая, подумаем, что юзер тут ни при чем и ничего делать не будем. Если же свайп был хороший, определим куда он такой хороший был и вернем пользователю направление. Классно? Мне тоже нравится.

Ну, допустим, направление свайпа мы определять с горем пополам научились. А какой из шариков мы, простите, свайпнули? Давайте разбираться.

Так, у нас есть координаты точки касания (еще у нас есть координаты точки отрыва, но что мы будем с ними делать?) и по этим координатам нам нужно найти элемент… Хм.

public Coordinates getElementCoordinates(float horizontal, float vertical) {
    float xElCoordinate = horizontal / elementSize;
    float yElCoordinate = vertical / elementSize;

    return new Coordinates((int) xElCoordinate, (int) yElCoordinate);
}

Ничего необычного. Если все элементы одного и того же размера, размер которого мы знаем (сами определяли), а размер поля мы и без того уже посчитали – остается только взять и поделить. А работать с элементом по его координатам – задача движка

Теперь мы точно знаем, что мы свайпнули и даже догадываемся куда. Остается только передать все это дело движку и пусть себе тарахтит. Вот только это уже не задача вьюшки. Ее дело – показывать, а, обрабатывать какие-то действия надо бы либо во фрагментах, либо в активити. С фрагментами у нас негусто, а вот какое-никакое активити есть. Повесим на вьюшку onTouchLictener.

private OnTouchListener onFieldTouchListener = new OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downHorizontal = event.getX();
                downVertical = event.getY();
                break;

            case MotionEvent.ACTION_UP:
                upHorizontal = event.getX();
                upVertical = event.getY();

                boolean isWin = fieldView.getField().makeTurn(
                        fieldView.getElementCoordinates(downHorizontal, downVertical),
                        fieldView.getSwipeDirection(downHorizontal, upHorizontal, downVertical, upVertical)
                );
}

Ну вот. При касании будем сохранять координаты, при отпускании дисплея будем получать еще пару координат, а потом — собирать все в кучу и передавать вьюшке – пусть сама разбирается. Вьюшка отдаст все это дело дальше, достанет булевый результат, который ни много ни мало, а признак завершения уровня и вернет нам. Остается его только обработать ну и не забыть сказать вьюшке, что надо перерисоваться.

Дописываем в листенер:

fieldView.invalidate();

        if (isWin) {
            animateView(fieldView);

            try {
                levelManager.finishLevel();
                openLevel(levelManager.getCurrentLevelNumber());
              
            } catch (GameException ex) {
                onMenuClick();
            }
        }
}
return true;

Проверили и даже что-то сделали. Что именно мы сделали, поймем чуть позже, а пока давайте поиграемся. Уберем из нашего Level все лишнее, оставим только два шарика и попробуем загнать один из них в лузу

Qook: Портировать старую игрушку на Android и поделиться ей с миром - 4

Хе-хе. Оно даже работает. Тогда двигаемся дальше

Ищем уровни

Ну вот. Рабочий движок мы написали, красиво это рисовать научились, теперь осталось только определиться с тем, что именно мы научились рисовать. С уровнями, ага.

Изначально, я не собирался рисовать уровни самостоятельно, потому что наша с вами цель – портировать игру, а не написать новую. Стало быть, надо где-то взять оригинальную Q, хорошенько ее встряхнуть, отцепить от нее трех-четырех юристов Sony (ну, это все-таки ее игрушка) и вуаля – готово.

Вот только поиски оригинальной игрушки ничем не закончились. Того самого T68 давно уж нет, картинок в сети почему-то не оказалось, а найти еще один оригинальный девайс в 2016-м году… проблематично найти, в общем. Трагедия.

Правда, пока я искал оригинал, я совершенно случайно наткнулся на порт этой игрушки под Windows 98. И каким же великим было мое удивление, когда я понял, что уровни к этой игрушке не только содраны с оригинала, но и лежат в обычных txt файлах, где каждый символ обозначает либо блок, либо шарик, либо дырку. Пообщавшись с автором и заручившись его согласием на использование, я со спокойной совестью забрал их себе и даже попробовал понять, что с ними делать.

Просто так, взять и засунуть txt-файлы в игрушку конечно можно, кто бы был против. Но заставить мой движок с ними работать – надо еще постараться. Надо так надо. Постараемся

Пишем Level Manager

Звучит красиво, на практике – еще один класс, который будет делать из txt-файлов нормальные объекты уровней и отдавать их кому скажут. А заодно будет разбираться с тем, какой уровень у нас текущий, сколько их всего есть и прочими прелестями жизни.

Поскольку менеджер у нас хранит в себе какое-то состояние (например, номер текущего уровня), неприятно будет, если в какой-то момент, мы сдуру сделаем себе новый, в котором этого самого состояния еще не будет (особенно неприятно будет старому менеджеру, которого система благополучно пристрелит). Так что сделаем его лучше синглтоном, от греха подальше

public class  LevelManager {
    private static final String LEVELS_FOLDER = "levels";
    private static final String LEVEL_FILE_EXTENSION = ".lev";

    private static final int EMPTY_CELL = 0;
    private static final int BLOCK_CELL = 1;
    private static final int GREEN_BALL_CELL = 2;
    private static final int RED_BALL_CELL = 3;
    private static final int BLUE_BALL_CELL = 4;
    private static final int YELLOW_BALL_CELL = 5;
    private static final int PURPLE_BALL_CELL = 6;
    private static final int CYAN_BALL_CELL = 7;

    private static final int GREEN_HOLE_CELL = 22;
    private static final int RED_HOLE_CELL = 33;
    private static final int BLUE_HOLE_CELL = 44;
    private static final int YELLOW_HOLE_CELL = 55;
    private static final int PURPLE_HOLE_CELL = 66;
    private static final int CYAN_HOLE_CELL = 77;

    private static Context context;
    private static SharedSettingsManager sharedSettingsManager;
    private static LevelManager instance;

    private LevelManager() {
    }

    public static LevelManager build(Context currentContext) {
        context = currentContext;
        sharedSettingsManager = SharedSettingsManager.build(currentContext);

        if (instance == null) {
            instance = new LevelManager();
        }
        return instance;
    }

Что это за куча констант? Это элементы легенды. На самом деле, спертый честно взятый уровень выглядит примерно так

Qook: Портировать старую игрушку на Android и поделиться ей с миром - 5

И каждая циферка что-то да обозначает. А чтобы не лезть каждый раз в справку игры, поназаводим понятных глазу констант и будем работать только с ними. Про sharedSettingsManager, который тут зачем-то есть я вам расскажу в другой раз, а пока давайте научим наш менеджер открывать уровень и строить из него приличный объект

Для начала, попробуем достать сам уровень из файла и как-то его распарсить. Для выдергивания различных циферок и буковок из потока данных у нас есть Scanner, так что его мы на файлик и натравим

private Scanner openLevel(int levelNumber) throws IOException {
    AssetManager assetManager = context.getAssets();
    InputStream inputStream = assetManager.open(
            LEVELS_FOLDER +
                    "/" +
                    String.valueOf(levelNumber) +
                    LEVEL_FILE_EXTENSION);

    BufferedReader bufferedReader =
            new BufferedReader
                    (new InputStreamReader(inputStream));

    return new Scanner(bufferedReader);
}

Да, чуть не забыл. Все уровни у нас хранятся к assets андройда, достать их оттуда не представляет никакого труда, правда, только, если у нас есть контекст. А называются они по своим номерам. Поэтому, нам остается только передать номер требуемого уровня, чтобы получить готовый сканнер, который работает с нужным файликом.

Теперь – будем конвертировать элемент легенды в Item.

private Item convertLegendToItem(int itemLegend) {
    switch (itemLegend) {
        case EMPTY_CELL:
            return null;

        case BLOCK_CELL:
            return new Block();

        case GREEN_BALL_CELL:
            return new Ball(Color.GREEN);

        case RED_BALL_CELL:
            return new Ball(Color.RED);

        case BLUE_BALL_CELL:
            return new Ball(Color.BLUE);

        case YELLOW_BALL_CELL:
            return new Ball(Color.YELLOW);

        case PURPLE_BALL_CELL:
            return new Ball(Color.PURPLE);

        case CYAN_BALL_CELL:
            return new Ball(Color.CYAN);

        case GREEN_HOLE_CELL:
            return new Hole(Color.GREEN);

        case RED_HOLE_CELL:
            return new Hole(Color.RED);

        case BLUE_HOLE_CELL:
            return new Hole(Color.BLUE);

        case YELLOW_HOLE_CELL:
            return new Hole(Color.YELLOW);

        case PURPLE_HOLE_CELL:
            return new Hole(Color.PURPLE);

        case CYAN_HOLE_CELL:
            return new Hole(Color.CYAN);
    }

    return null;
}

Один большой-пребольшой switch и ничего сложного.

Ну и наконец – научимся обрабатывать весь уровень целиком:

public Level getLevel(int levelNumber) throws IOException {
    Scanner scanner = openLevel(levelNumber);

    int levelWidth = scanner.nextInt();
    int levelHeight = scanner.nextInt();

    Item levelMatrix[][] = new Item[levelHeight][levelWidth];

    for (int i = 0; i < levelHeight; i++) {
        for (int j = 0; j < levelWidth; j++) {
            levelMatrix[i][j] = convertLegendToItem(scanner.nextInt());
        }
    }

    Level level = new Level(levelMatrix);
    sharedSettingsManager.setCurrentLevel(levelNumber);
    return level;
}

Взяли номер – вернули уровень. Чудеса. Вот только, кроме открытия уровня, надо еще и «завершать» его в тот момент, когда шариков там не осталось. Определять – остались там шарики или нет это задача движка, а вот обрабатывать внезапно закончившийся уровень будем так

public void finishLevel() {
    sharedSettingsManager.setCurrentLevel(
            sharedSettingsManager.getCurrentLevel() + 1
    );

    if (sharedSettingsManager.getCurrentLevel() > sharedSettingsManager.getMaxLevel()) {
        throw new GameException(GameExceptionCodes.INCORRECT_LEVEL);
    }
}

Ага, сделали отметочку о том, что этот уровень пройден, изменили номер текущего уровня и, если все уровни кончились, плюнули исключение. Мило? Тогда попробуем запустить нашу игрушку на, например, втором уровне

Qook: Портировать старую игрушку на Android и поделиться ей с миром - 6

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

Еще один менеджер

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

Для хранения таких несложных штуковин, в Android есть SharedSettings. Обычно, его используют для хранения настроек, но нам оно тоже сгодится. Так что заведем еще одного менеджера

public static final String LAST_LEVEL = "current_level";
public static final String MAX_LEVEL = "max_level";
public static final String WAS_RAN_BEFORE = "was_ran_before";
private static final String APP_PREFS = "qook_prefs";
public static Context context;
public static SharedSettingsManager instance;
SharedPreferences sharedPreferences;

private SharedSettingsManager() {
    sharedPreferences = context.getSharedPreferences(APP_PREFS, Context.MODE_PRIVATE);
}

Тоже синглтон, ага. Потому что мало ли, где мы там во что вляпаемся, а выяснять – threadsafe ли сами sharedsettings особого желания почему-то нет.

Теперь, научимся выдавать текущий и максимальный уровень.

Раз

public int getMaxLevel() {
    return sharedPreferences.getInt(MAX_LEVEL, 1);
}

И два

public int getCurrentLevel() {
    return sharedPreferences.getInt(LAST_LEVEL, 1);
}

Теперь, попробуем их записывать обратно. Вызывать где-то выше два отдельных метода особого желания нет, поэтому сделаем вот как

private void setMaxLevel(int maxLevel) {
    SharedPreferences.Editor editor = sharedPreferences.edit();
    editor.putInt(MAX_LEVEL, maxLevel);
    editor.apply();
}
public void setCurrentLevel(int currentLevel) {
    SharedPreferences.Editor editor = sharedPreferences.edit();
    editor.putInt(LAST_LEVEL, currentLevel);

    editor.apply();

    if (getMaxLevel() < currentLevel) {
        setMaxLevel(currentLevel);
    }
}

Теперь, по завершении очередного уровня достаточно просто изменить текущий, а бы он последним или нет – менеджер разберется и без нас. Иначе зачем он тут вообще сидит?

Дописываем разметку

Игрушка у нас уже играется, это хорошо. Ничего кроме этого в ней нет – плохо. А ведь так хочется и на кнопочку «сначала» нажать и в менюшку выйти и на каком мы уровне посмотреть. Так что добавим над игровым полем вот такую плашку

Qook: Портировать старую игрушку на Android и поделиться ей с миром - 7

Код у кнопочек проще не бывает, так что рассказывать, как оно работает не хочу. Тем более, что, ничего кроме вызовов менеджеров или перехода в другое активити там нет

А вот как саму разметку имеет смысл показать, там интересно

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/game_activity"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/background"
    android:gravity="center_vertical"
    android:orientation="vertical"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".ui.activities.LevelActivity">


    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/level_counter"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:paddingLeft="16dp"
            android:paddingTop="5dp"
            android:text="01 / 60"
            android:textSize="34sp" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="right"
        android:paddingBottom="10dp">

        <ImageButton
            android:id="@+id/back_level_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:src="@drawable/menu_icon" />

        <ImageButton
            android:id="@+id/reset_level_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:src="@drawable/restore_level" />

        <ImageButton
            android:id="@+id/undo_step_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:src="@drawable/undo_step" />

    </LinearLayout>

    </LinearLayout>

    <org.grakovne.qook.ui.views.FieldView
        android:id="@+id/field"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center_horizontal"
        android:background="@drawable/field_ground"
        android:foregroundGravity="center" />

</LinearLayout>

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

Пишем менюшку для уровней

За это дело будет отвечать отдельный экран, который будет выглядеть более, чем скучно: каждый уровень – квадратик, открытые и еще не открытые уровни разных цветов, по еще не открытым нажимать нельзя. Создадим разметочку?

<TextView
    android:id="@+id/title_text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_weight="1"
    android:gravity="center"
    android:paddingBottom="16dp"
    android:text="@string/app_name"
    android:textAllCaps="true"
    android:textSize="48sp"
    android:textStyle="bold" />

<GridView
    android:id="@+id/level_grid"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_weight="9"
    android:numColumns="5">
</GridView>

Заголовочек и небольшая гридвьюшка – ничего нового. Теперь, придумаем как эту вьюшку наполнить чем-то полезным

Для этого нам придется написать адаптер, который будет создавать новую вьюшку, забивать ее данными, навешивать на нее clickListener и запихивать в родителя. Примерно так:

public View getView(int position, View convertView, ViewGroup parent) {
    LayoutInflater vi;
    vi = LayoutInflater.from(getContext());
    @SuppressLint("ViewHolder") View view = vi.inflate(R.layout.level_item, null);

    Integer currentLevelNumber = getItem(position);
    if (currentLevelNumber != null) {
        Button levelButton = (Button) view.findViewById(R.id.level_item_button);
        if (levelButton != null) {

            levelButton.setText(String.valueOf(currentLevelNumber));

            if (position < maxOpenedLevel) {
                levelButton.setBackgroundResource(R.drawable.opened_level_item);
                levelButton.setClickable(true);
                levelButton.setOnClickListener(clickListener);
                levelButton.setId(currentLevelNumber);
            } else {
                levelButton.setBackgroundResource(R.drawable.closed_level_item);
                levelButton.setClickable(false);
            }
        }
    }

    return view;
}

Здорово. Только мы хотим, чтобы все кнопочки уровней были квадратными. Для этого, создадим своего наследника Button и добавим немного магии:

public class LevelButton extends Button {
@Override
    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, widthMeasureSpec);
    }
}

Хе-хе. Какая ширина, такая и высота. Никто и не заметил. Осталось только вызывать это все при создании активити

@Override
public void onResume() {
    super.onResume();
   
    manager = LevelManager.build(getBaseContext());

    View.OnClickListener levelClick = new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            Intent intent = new Intent(getBaseContext(), LevelActivity.class);
            intent.putExtra(DESIRED_LEVEL, v.getId());
            intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
            startActivity(intent);
        }
    };

    LevelGridAdapter adapter = new LevelGridAdapter(this, R.layout.level_item, getListOfLevelNumbers(), manager.getMaximalLevelNumber(), levelClick);
    adapter.setNotifyOnChange(false);
    levelGrid.setAdapter(adapter);
    levelGrid.setVerticalScrollBarEnabled(false);
}

Кликнули по кнопочке – уровень открылся. Красота.

Играемся

После того, как мы дописали к игре основное меню, помощь и, конечно же, «об авторе», самое время попробовать поиграть в то, что у нас там получилось.


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

Ладно, раз это дело работает – выложим это добро в Google Play, глядишь, кому и понравится

Регистрируемся, платим $25 гуглу, ждем чуток времени, создаем проект, заполняем поля для данных и… получаем целую страничку в разделе «Головоломки», а, заодно, вот такую милую плашечку

Автор: GrakovNe

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js