Как написать 2048 на java

В предыдущих статьях этой серии мы уже писали сапёра и змейку, а теперь попробуем написать десктопный клон игры 2048.

Нам, как обычно, понадобятся:

  • 15 минут свободного времени;
  • Настроенная рабочая среда, т.е. JDK и IDE (например, Eclipse);
  • Библиотека LWJGL (версии 2.x.x) для работы с графикой (опционально). Обратите внимание, что для LWJGL версий выше 3 потребуется написать код, отличающийся от того, что приведён в статье;
  • Спрайты, т.е. картинки плиток всех возможных состояний (пустая, и со степенями двойки до 2048). Можно нарисовать самому, или скачать использовавшиеся при написании статьи.

FAQ по опыту предыдущих статей

В: Вы что! Это большой проект, за 15 минут такое нельзя накодить!

О: Разумеется, чтобы придумать всё это и написать, у меня ушёл целый вечер и даже немножко ночи. Но прочитать статью и, скопировав из неё код, запустить работающую игру (при этом понимая, что происходит) вполне реально и за 15 минут.

В: Зачем тянуть ради такого простого проекта LWJGL? Гвозди микроскопом!

О: Вся работа с отображением вынесена в два с половиной метода  в 2 интерфейса. Вы можете реализовать их на чём хотите, мне же удобнее на LWJGL.

В: А почему код выкладываете архивом? На гитхаб нужно!

О: Да, теперь весь проект выложен на GitHub.

В: У меня не получается подключить твою эту LWJGL! Что делать?

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

Во-первых, выше я дал ссылку на папку с библиотеками на GitHub, которые использую я, чтобы не было путаницы с версиями и вопросов, где найти что-то. Папку из архива требуется поместить в папку проекта и подключить через вашу IDE.

Во-вторых, у многих пользователей InteliJ IDEA возникли проблемы как раз с их подключением. Я нашёл в сети следующий видеогайд:

После того, как я сделал всё в точности по нему, у меня библиотеки подключились корректно и всё заработало.

В: А почему на Java?

О: На чём бы я не написал, можно было бы спросить «Почему именно на X?». Если в комментариях будет реально много желающих увидеть код на каком-то другом языке, я перепишу игру на нём и выложу (только не Brainfuck, пожалуйста).

С чего начать?

Начать стоит с главного управляющего класса, который в нашем проекте находится выше остальных по уровню абстракции. Вообще отличный совет — в начале работы всегда пишите код вида if(getKeyPressed()) doSomething(), так вы быстро определите фронт работ.

 /**
     * Точка входа. Содержит все необходимые действия для одного игрового цикла.
     */
    public static void main(String[] args) {
        initFields();
        createInitialCells();

        while(!endOfGame){

            input();
            logic();

            graphicsModule.draw(gameField);
        }

        graphicsModule.destroy();

    }

Это наш main(). Что тут происходит, понять несложно — мы инициализируем поля, потом создаём первые две ячейки и, пока игра не закончится, осуществляем по очереди: ввод пользовательских данных (input()), основные игровые действия (logic()) и вызов метода отрисовки у графического модуля (graphicsModule.draw()), в который передаём текущее игровое поле (gameField).

Так как пока мы не знаем, какие поля инициировать, постараемся написать createInitialCells(). Но так как создавать клетки нам пока просто-напросто не в чем, то создадим класс игрового поля.

Создаём игровое поле

Всё наше поле — матрица чисел и методы, позволяющие их изменять (геттеры и сеттеры). Договоримся только, что пустую ячейку мы будем обозначать числом 0. Выглядеть этот класс будет так:

public class GameField {
    /**
     * Состояние всех ячеек поля.
     */
    private int[][] theField;

    /**
     * Инициализирует поле и заполняет его нулями
     */
    public GameField(){
        theField = new int[COUNT_CELLS_X][Constants.COUNT_CELLS_Y];

        for(int i=0; i<theField.length;i++){
            for(int j=0; j<theField[i].length; j++){
                theField[i][j]=0;
            }
        }
    }

    /**
     * Возвращает состояние ячейки поля по координатам
     *
     * @param x Координата ячейки X
     * @param y Координата ячейки Y
     * @return Состояние выбранной ячейки
     */
    public int getState(int x, int y){
        return theField[x][y];
    }

    /**
     * Изменяет состояние ячейки поля по координатам
     *
     * @param x Координата ячейки X
     * @param y Координата ячейки Y
     * @param state Новое состояние для этой ячейки
     */
    public void setState(int x, int y, int state){
        //TODO check input maybe?

        theField[x][y] = state;
    }

    /**
     * Изменяет столбец под номером i
     *
     * @param i Номер изменяемого столбца
     * @param newColumn Массив новых состояний ячеек столбца
     */
    public void setColumn(int i, int[] newColumn) {
        theField[i] = newColumn;
    }

    /**
     * Возвращает массив состояний ячеек столбца под номером i
     *
     * @param i Номер запрашиваемого столбца
     * @return Массив состояний ячеек столбца
     */
    public int[] getColumn(int i) {
        return theField[i];
    }

    /**
     * Изменяет строку под номером i
     *
     * @param i Номер изменяемой строки
     * @param newLine Массив новых состояний ячеек строки
     */
    public void setLine(int i, int[] newLine) {
        for(int j = 0; j< COUNT_CELLS_X; j++){
            theField[j][i] = newLine[j];
        }
    }

    /**
     * Возвращает массив состояний ячеек строки под номером i
     *
     * @param i Номер запрашиваемой строки
     * @return Массив состояний ячеек строки
     */
    public int[] getLine(int i) {
        int[] ret = new int[COUNT_CELLS_X];

        for(int j = 0; j< COUNT_CELLS_X; j++){
            ret[j] = theField[j][i];
        }

        return ret;
    }

}

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

Создаём в поле первые две ячейки

Совсем очевидно, что нам нужно просто вызвать два раза метод создания одной ячейки.

/**
     * Создаёт на поле начальные ячейки
     */
    private static void createInitialCells() {
        for(int i = 0; i < COUNT_INITITAL_CELLS; i++){
            generateNewCell();
        }
    }

Заметьте, я не пишу вызов одного метода два раза. Для программистов существует одна максима: «Существует только два числа: один и много». Чаще всего, если что-то нужно сделать 2 раза, то со временем может возникнуть задача сделать это и 3, и 4 и куда больше раз. Например, если вы решите сделать поле не 4х4, а 10х10, то разумно будет создавать не 2, а 10 ячеек.

Вы могли заметить, что в коде использована константа COUNT_INITIAL_CELLS. Все константы удобно определять в классе с public static final полями. Полный список констант, который нам потребуется в ходе разработки, можно посмотреть в классе Constants на GitHub.

Теперь постараемся решить вопрос — как в матрице создать ячейку вместо одного из нулей? Я решил пойти по такому пути: мы выбираем случайные координаты, и если там находится пустая ячейка, то создаём новую плитку там. Если там уже есть плитка с числом, то пытаемся создать в следующей клетке (двигаемся вправо и вниз). Обратите внимание, что после хода не может не быть пустых клеток, т.к. ход считается сделанным, когда клетки либо переместились (т.е. освободили какое-то место), либо соединились (т.е. клеток стало меньше, и место снова высвободилось).

private static void generateNewCell() {
        int state = (new Random().nextInt(100) <= Constants.CHANCE_OF_LUCKY_SPAWN)
                ? LUCKY_INITIAL_CELL_STATE
                : INITIAL_CELL_STATE;

        int randomX, randomY;

        randomX = new Random().nextInt(Constants.COUNT_CELLS_X);
        int currentX = randomX;

        randomY = new Random().nextInt(Constants.COUNT_CELLS_Y);
        int currentY = randomY;



        boolean placed = false;
        while(!placed){
            if(gameField.getState(currentX, currentY) == 0) {
                gameField.setState(currentX, currentY, state);
                placed = true;
            }else{
                if(currentX+1 < Constants.COUNT_CELLS_X) {
                    currentX++;
                }else{
                    currentX = 0;
                    if(currentY+1 < Constants.COUNT_CELLS_Y) {
                        currentY++;
                    }else{
                        currentY = 0;
                    }
                }

                if ((currentX == randomX) && (currentY==randomY) ) {  //No place -> Something went wrong
                    ErrorCatcher.cellCreationFailure();
                }
            }
        }

        score += state;
    }

Немного более затратен по времени и памяти другой метод, который тоже имеет право на жизнь. Мы складываем в какую-либо коллекцию (например, ArrayList) координаты всех ячеек с нулевым значением (простым перебором). Затем делаем new Random().nextInt(X), где X — размер это коллекции, и создаём ячейку по координатам, указанным в члене коллекции с номером, соответствующем результату.

Реализуем пользовательский ввод

Следующим по очереди у нас идёт метод input(). Займёмся им.

private static void input() {
        keyboardModule.update();
        
        /* Определяем направление, в котором нужно будет произвести сдвиг */
        direction = keyboardModule.lastDirectionKeyPressed();

        endOfGame = endOfGame || graphicsModule.isCloseRequested() || keyboardModule.wasEscPressed();
}

Отсюда нам нужно запомнить только, какие интерфейсы (графический и клавиатурный модули) нам нужно создать и какие методы в них определить. Если не запомнили — не волнуйтесь, ворнинги вашей IDE особо забыть не дадут.

Интерфейсы для клавиатурного и графического модулей

Так как многим не нравится, что я пишу эти модули на LWJGL, я решил в статье уделить время только интерфейсам этих классов. Каждый может написать их с помощью той GUI-библиотеки, которая ему нравится (или вообще сделать консольный вариант). Я же по старинке реализовал их на LWJGL, код можно посмотреть здесь в папках graphics/lwjglmodule и keyboard/lwjglmodule.

Интерфейсы же, после добавления в них всех упомянутых выше методов, будут выглядеть следующим образом:

Графический модуль

public interface GraphicsModule {

    /**
     * Отрисовывает переданное игровое поле
     *
     * @param field Игровое поле, которое необходимо отрисовать
     */
    void draw(GameField field);

    /**
     * @return Возвращает true, если в окне нажат "крестик"
     */
    boolean isCloseRequested();

    /**
     * Заключительные действия, на случай, если модулю нужно подчистить за собой.
     */
    void destroy();
}

Клавиатурный модуль

public interface KeyboardHandleModule {

    /**
     * Считывание последних данных из стека событий, если можулю это необходимо
     */
    void update();

    /**
     * @return Возвращает направление последней нажатой "стрелочки",
     * либо AWAITING, если не было нажато ни одной
     */
    ru.tproger.main.Direction lastDirectionKeyPressed();

    /**
     * @return Возвращает информацию о том, был ли нажат ESCAPE за последнюю итерацию
     */
    boolean wasEscPressed();

}

Метод логики

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

private static void logic() {
        if(direction!=Direction.AWAITING){
            if(shift(direction)) generateNewCell();

            direction=Direction.AWAITING;
        }
    }

Вы могли заметить, что мы часто используем enum Direction для определения направления. Т.к. его используют различные классы, он вынесен в отдельный файл и выглядит так:

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

Давай уже серьёзно. Как нам сдвинуть это чёртово поле?

Самое ядро нашего кода! Вот самое-самое. К слову, спорный вопрос, куда поместить этот метод — в Main.java или в GameField.java? Я выбрал первое, но это решение нельзя назвать слишком обдуманным. Жду ваше мнение в комментариях.

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

/**
     * Изменяет gameField, сдвигая все ячейки в указанном направлении,
     * вызывая shiftRow() для каждой строки/столбца (в зависимости от направления)
     *
     * @param direction Направление, в котором необходимо совершить сдвиг
     * @return Возвращает true, если сдвиг прошёл успешно (поле изменилось)
     */
    private static boolean shift(Direction direction) {
        boolean ret = false;

        switch(direction) {
            case UP:
            case DOWN:

                /*По очереди сдвигаем числа всех столбцов в нужном направлении*/
                for(int i = 0; i< Constants.COUNT_CELLS_X; i++){
                    /*Запрашиваем очередной столбец*/
                    int[] arg =  gameField.getColumn(i);

                    /*В зависимости от направления сдвига, меняем или не меняем порядок чисел на противоположный*/
                    if(direction==Direction.UP){
                        int[] tmp = new int[arg.length];
                        for(int e = 0; e < tmp.length; e++){
                            tmp[e] = arg[tmp.length-e-1];
                        }
                        arg = tmp;
                    }

                    /*Пытаемся сдвинуть числа в этом столбце*/
                    ShiftRowResult result = shiftRow (arg);

                    /*Возвращаем линию в исходный порядок*/
                    if(direction==Direction.UP){
                        int[] tmp = new int[result.shiftedRow.length];
                        for(int e = 0; e < tmp.length; e++){
                            tmp[e] = result.shiftedRow[tmp.length-e-1];
                        }
                        result.shiftedRow = tmp;
                    }

                    /*Записываем изменённый столбец*/
                    gameField.setColumn(i, result.shiftedRow);

                    /*Если хоть одна линия была изменена, значит было изменено всё поле*/
                    ret = ret || result.didAnythingMove;
                }
                break;
            case LEFT:
            case RIGHT:

                /*По очереди сдвигаем числа всех строк в нужном направлении*/
                for(int i = 0; i< Constants.COUNT_CELLS_Y; i++){
                    /*Запрашиваем очередную строку*/
                    int[] arg = gameField.getLine(i);

                    /*В зависимости от направления сдвига, меняем или не меняем порядок чисел на противоположный*/
                    if(direction==Direction.RIGHT){
                        int[] tmp = new int[arg.length];
                        for(int e = 0; e < tmp.length; e++){
                            tmp[e] = arg[tmp.length-e-1];
                        }
                        arg = tmp;
                    }

                    /*Пытаемся сдвинуть числа в этом столбце*/
                    ShiftRowResult result = shiftRow (arg);

                    /*Возвращаем линию в исходный порядок*/
                    if(direction==Direction.RIGHT){
                        int[] tmp = new int[result.shiftedRow.length];
                        for(int e = 0; e < tmp.length; e++){
                            tmp[e] = result.shiftedRow[tmp.length-e-1];
                        }
                        result.shiftedRow = tmp;
                    }

                    /*Записываем изменённую строку*/
                    gameField.setLine(i, result.shiftedRow);

                     /*Если хоть одна линия была изменена, значит было изменено всё поле*/
                    ret = ret || result.didAnythingMove;
                }

                break;
            default:
                ErrorCatcher.shiftFailureWrongParam();
                break;
        }

        return ret;
    }

Так как этот магический метод с алгоритмом должен будет по сути вернуть два объекта (новую линию и boolean, который будет говорить о наличии изменений в ней), создадим в начале класса Main для такого результата обёртку:

/**
     * Результат работы метода сдвига shiftRow().
     * Содержит изменённую строку и информацию о том, эквивалентна ли она начальной.
     */
    private static class ShiftRowResult{
        boolean didAnythingMove;
        int[] shiftedRow;
    }

Можно, конечно. просто возвращать линию, а затем сравнивать её (не забываем, что это нужно делать через метод equals(), а не через ==), но на это будет уходит больше времени (из-за сравнение каждого элемента массива), но меньше памяти (на один boolean).

Самое сердце программы. Метод shiftRow()

Если подумать, то вам предстоит решить задачку — как за наименьшее (линейно зависящее от количества поступающих данных) время произвести с рядом чисел следующие последовательные операции: (1) если в ряде есть нули, их необходимо удалить, (2) если любые два соседних числа равны, то вместо них должно остаться одно число, равное сумме двух равных чисел. И (3) — если число получено через пункт (2), оно не может совмещаться с другими числами.

Если представить себе алгоритм таким образом, то придумать линейное решение будет гораздо легче. Вот какой алгоритм должен получиться:

  • Выкидываем все нули — проходимся по всему массиву и копируем элемент в новый массив, только если он не равен нулю. Если вы попробуете удалять эти нули из середины того же массива, алгоритм будет работать за O(n^2).
  • Рассмотрим (поставим указатель на) первое число получившегося массива без нулей.
    1. Если с ним можно совместить следующее за ним число (наш указатель +1), то переписываем в новый массив лишь их сумму, затем ставим указатель на третье число (второго уже нет).
    2. Иначе переписываем только первое, и ставим указатель на второе число.

При этом нам необходимо хранить место в возвращаемом массиве, на которое необходимо произвести запись.

А вот как он выглядит в виде кода:

private static ShiftRowResult shiftRow (int[] oldRow) {
        ShiftRowResult ret = new ShiftRowResult();

        int[] oldRowWithoutZeroes = new int[oldRow.length];
        {
            int q = 0;

            for (int i = 0; i < oldRow.length; i++) {
                if(oldRow[i] != 0){
                    if(q != i){
                        /*
                         * Это значит, что мы передвинули ячейку
                         * на место какого-то нуля (пустой плитки)
                         */
                        ret.didAnythingMove = true;
                    }

                    oldRowWithoutZeroes[q] = oldRow[i];
                    q++;
                }
            }

        }

        ret.shiftedRow = new int[oldRowWithoutZeroes.length];

        {
            int q = 0;

            {
                int i = 0;


                while (i < oldRowWithoutZeroes.length) {
                    if((i+1 < oldRowWithoutZeroes.length) && (oldRowWithoutZeroes[i] == oldRowWithoutZeroes[i + 1])
                            && oldRowWithoutZeroes[i]!=0) { {
                        ret.didAnythingMove = true;
                        ret.shiftedRow[q] = oldRowWithoutZeroes[i] * 2;
                        i++;
                    } else {
                        ret.shiftedRow[q] = oldRowWithoutZeroes[i];
                    }

                    q++;
                    i++;
                }

            }
        }

Наслаждаемся результатом

Работающая программа

Работающая программа

P.S. Ещё раз напомню, что исходники готового проекта доступны на GitHub.

Код 2048 был написан не мной, это был проект, найденный на платформе проектов Niuke.

Ссылка на код проекта (код размещен в конце статьи)

https://git.nowcoder.com/11000160/2048-java/blob/master/Game2048.java

Записанные визуализации:

Анализ игровой логики

Все играли в 2048, поэтому я не буду рассказывать об этом. Если вы еще не играли в нее, можете пойти поиграть, просто поговорите о логике игры.

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

2. Каждый раз получается числовой блок 2 или 4, и сгенерированная позиция появляется случайным образом в пустой позиции.

3. В начале игры случайным образом появляются два числовых блока.

4. Каждый раз, когда передается направление движения (вверх, вниз, влево и вправо), все цифровые блоки будут двигаться в этом направлении до границы, и те же два цифровых блока будут объединены в сумму двух чисел, чтобы стать один цифровой блок.

5. Если цифровой блок не смещается после перемещения, движение недействительно и новый цифровой блок не создается.

6. Если в сцене нет места и она не может двигаться, игра завершится ошибкой.

7. Счет игры — это наибольшее число в сцене. Игра заканчивается, когда число составляет 2048.

Анализ кода

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

Простая интеллектуальная карта

Текстовая версия

Game2048

основной метод

SwingUtilities.invokeLater(() -> {

JFrame f = new JFrame();

f.setDefaultCloseOperation (JFrame.EXIT_ON_CLOSE может закрыть окно);

f.setTitle («2048»); Установить заголовок окна

f.setResizable (true); Панель можно настраивать

f.add (new Game2048 (), BorderLayout.CENTER); поместить игру в панель

f.pack();

f.setLocationRelativeTo(null);

f.setVisible (true); видимый

});

класс Плитка

Цифровой блок

частное логическое слияние; следует ли слить

частное значение int; значение

Метод строительства

  • Tile(int val) {
    • value = val;

получить и установить методы

  • getValue()
  • setMerged(boolean m)

canMergeWith (Другой фрагмент) определяет, можно ли объединить два блока

  • return !merged && other != null && !other.merged && value == other.getValue();
  • Значение равно, другой блок не пуст, и ход не объединяется с другим блоком, все можно объединить

int mergeWith (Другой фрагмент) {Метод слияния

  • if (canMergeWith(other)) {
    • значение * = 2; комбинированное значение удваивается
    • merged = true;
    • return value;
  • return -1;

moveAvailable определяет, можно ли его переместить

clearMerged прозрачная панель

Обойти все сетки, установить false

переменная

Статический член состояния

  • начало, победа, работа, завершение игры

colorTable библиотека цветов

  • new Color(0x701710), new Color(0xFFE4C3), new Color(0xfff4d3),
  • new Color(0xffdac3), new Color(0xe7b08e), new Color(0xe7bf8e),
  • new Color(0xffc4c3), new Color(0xE7948e), new Color(0xbe7e56),
  • new Color(0xbe5e56), new Color(0x9c3931), new Color(0x701710)};

final static int target = 2048; целевой балл

static int high; высота игры

static int score; игровой счет

private Color gridColor = new Color (0xBBADA0); цвет сетки

частный цвет emptyColor = новый цвет (0xCDC1B4); пустой цвет

частный цвет startColor = новый цвет (0xFFEBCD); начальный цвет

private Random rand = new Random (); случайное число

Частные плитки; массив цифровых блоков хранит все цифровые блоки

private int side = 4; размер сетки 4 * 4

частное состояние gamestate = State.start; состояние игры

private boolean checkAvailableMoves; может перемещать состояние

Метод строительства Game2048

setPreferredSize(new Dimension(900, 700));

setBackground(new Color(0xFAF8EF));

setFont(new Font(«SansSerif», Font.BOLD, 48));

setFocusable(true);

Мышь монитора

Клавиатура монитора

paintComponent компонент краски

Инициализировать рисунок

setRenderingHint сглаживание

startGame начать игру

if (gamestate != State.running) {

score = 0;

highest = 0;

gamestate = State.running;

tiles = new Tileside;

addRandomTile();

addRandomTile();

}

панель рисования drawGrid

g.setColor (gridColor); установить цвет

g.fillRoundRect (200, 100, 499, 499, 15, 15); заполнить фон

Определить состояние игры

drawTile рисует цифровые блоки

fillRoundRect рисует значение в строке r и столбце c двухмерной панели

addRandomTile случайным образом генерирует цифровые блоки

Случайным образом найдите пустые позиции, чтобы добавить 4 или 2

переместить, чтобы переместить цифровой блок

Двигайтесь по направлению

Пост-обработка движения

  • if (moved) {
  • if (highest < target) {
  • clearMerged();
  • addRandomTile();
  • if (!movesAvailable()) {
  • gamestate = State.over;
  • }
  • } else if (highest == target)
  • gamestate = State.won;
  • }

Серия мобильных методов

boolean moveUp() {

  • return move(0, -1, 0);

boolean moveDown() {

  • return move(side * side — 1, 1, 0);

boolean moveLeft() {

  • return move(0, 0, -1);

boolean moveRight() {

  • return move(side * side — 1, 0, 1);

Код

package com.hanxu51.game2048;import java.awt.*;import java.awt.event.*;import java.util.Random;import javax.swing.*;public class Game2048 extends JPanel {    enum State {        start, won, running, over    }    final Color[] colorTable = {            new Color(0x701710), new Color(0xFFE4C3), new Color(0xfff4d3),            new Color(0xffdac3), new Color(0xe7b08e), new Color(0xe7bf8e),            new Color(0xffc4c3), new Color(0xE7948e), new Color(0xbe7e56),            new Color(0xbe5e56), new Color(0x9c3931), new Color(0x701710)};    final static int target = 2048;    static int highest;    static int score;    private Color gridColor = new Color(0xBBADA0);    private Color emptyColor = new Color(0xCDC1B4);    private Color startColor = new Color(0xFFEBCD);    private Random rand = new Random();    private Tile[][] tiles;    private int side = 4;    private State gamestate = State.start;    private boolean checkingAvailableMoves;    public Game2048() {        setPreferredSize(new Dimension(900, 700));        setBackground(new Color(0xFAF8EF));        setFont(new Font("SansSerif", Font.BOLD, 48));        setFocusable(true);        addMouseListener(new MouseAdapter() {            @Override            public void mousePressed(MouseEvent e) {                startGame();                repaint();            }        });        addKeyListener(new KeyAdapter() {            @Override            public void keyPressed(KeyEvent e) {                switch (e.getKeyCode()) {                    case KeyEvent.VK_UP:                        moveUp();                        break;                    case KeyEvent.VK_DOWN:                        moveDown();                        break;                    case KeyEvent.VK_LEFT:                        moveLeft();                        break;                    case KeyEvent.VK_RIGHT:                        moveRight();                        break;                }                repaint();            }        });    }    @Override    public void paintComponent(Graphics gg) {        super.paintComponent(gg);        Graphics2D g = (Graphics2D) gg;        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,                RenderingHints.VALUE_ANTIALIAS_ON);        drawGrid(g);    }    void startGame() {        if (gamestate != State.running) {            score = 0;            highest = 0;            gamestate = State.running;            tiles = new Tile[side][side];            addRandomTile();            addRandomTile();        }    }    void drawGrid(Graphics2D g) {        g.setColor(gridColor);        g.fillRoundRect(200, 100, 499, 499, 15, 15);        if (gamestate == State.running) {            for (int r = 0; r < side; r++) {                for (int c = 0; c < side; c++) {                    if (tiles[r][c] == null) {                        g.setColor(emptyColor);                        g.fillRoundRect(215 + c * 121, 115 + r * 121, 106, 106, 7, 7);                    } else {                        drawTile(g, r, c);                    }                }            }        } else {            g.setColor(startColor);            g.fillRoundRect(215, 115, 469, 469, 7, 7);            g.setColor(gridColor.darker());            g.setFont(new Font("SansSerif", Font.BOLD, 128));            g.drawString("2048", 310, 270);            g.setFont(new Font("SansSerif", Font.BOLD, 20));            if (gamestate == State.won) {                g.drawString("you made it!", 390, 350);            } else if (gamestate == State.over)                g.drawString("game over", 400, 350);            g.setColor(gridColor);            g.drawString("click to start a new game", 330, 470);            g.drawString("(use arrow keys to move tiles)", 310, 530);        }    }    void drawTile(Graphics2D g, int r, int c) {        int value = tiles[r][c].getValue();        g.setColor(colorTable[(int) (Math.log(value) / Math.log(2)) + 1]);        g.fillRoundRect(215 + c * 121, 115 + r * 121, 106, 106, 7, 7);        String s = String.valueOf(value);        g.setColor(value < 128 ? colorTable[0] : colorTable[1]);        FontMetrics fm = g.getFontMetrics();        int asc = fm.getAscent();        int dec = fm.getDescent();        int x = 215 + c * 121 + (106 - fm.stringWidth(s)) / 2;        int y = 115 + r * 121 + (asc + (106 - (asc + dec)) / 2);        g.drawString(s, x, y);    }    private void addRandomTile() {        int pos = rand.nextInt(side * side);        int row, col;        do {            pos = (pos + 1) % (side * side);            row = pos / side;            col = pos % side;        } while (tiles[row][col] != null);        int val = rand.nextInt(10) == 0 ? 4 : 2;        tiles[row][col] = new Tile(val);    }    private boolean move(int countDownFrom, int yIncr, int xIncr) {        boolean moved = false;        for (int i = 0; i < side * side; i++) {            int j = Math.abs(countDownFrom - i);            int r = j / side;            int c = j % side;            if (tiles[r][c] == null)                continue;            int nextR = r + yIncr;            int nextC = c + xIncr;            while (nextR >= 0 && nextR < side && nextC >= 0 && nextC < side) {                Tile next = tiles[nextR][nextC];                Tile curr = tiles[r][c];                if (next == null) {                    if (checkingAvailableMoves)                        return true;                    tiles[nextR][nextC] = curr;                    tiles[r][c] = null;                    r = nextR;                    c = nextC;                    nextR += yIncr;                    nextC += xIncr;                    moved = true;                } else if (next.canMergeWith(curr)) {                    if (checkingAvailableMoves)                        return true;                    int value = next.mergeWith(curr);                    if (value > highest)                        highest = value;                    score += value;                    tiles[r][c] = null;                    moved = true;                    break;                } else                    break;            }        }        if (moved) {            if (highest < target) {                clearMerged();                addRandomTile();                if (!movesAvailable()) {                    gamestate = State.over;                }            } else if (highest == target)                gamestate = State.won;        }        return moved;    }    boolean moveUp() {        return move(0, -1, 0);    }    boolean moveDown() {        return move(side * side - 1, 1, 0);    }    boolean moveLeft() {        return move(0, 0, -1);    }    boolean moveRight() {        return move(side * side - 1, 0, 1);    }    void clearMerged() {        for (Tile[] row : tiles)            for (Tile tile : row)                if (tile != null)                    tile.setMerged(false);    }    boolean movesAvailable() {        checkingAvailableMoves = true;        boolean hasMoves = moveUp() || moveDown() || moveLeft() || moveRight();        checkingAvailableMoves = false;        return hasMoves;    }    public static void main(String[] args) {        SwingUtilities.invokeLater(() -> {            JFrame f = new JFrame();            f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);            f.setTitle("2048");            f.setResizable(true);            f.add(new Game2048(), BorderLayout.CENTER);            f.pack();            f.setLocationRelativeTo(null);            f.setVisible(true);        });    }}class Tile {    private boolean merged;    private int value;    Tile(int val) {        value = val;    }    int getValue() {        return value;    }    void setMerged(boolean m) {        merged = m;    }    boolean canMergeWith(Tile other) {        return !merged && other != null && !other.merged && value == other.getValue();    }    int mergeWith(Tile other) {        if (canMergeWith(other)) {            value *= 2;            merged = true;            return value;        }        return -1;    }}

Introduction: Program Your Own 2048 Game W/Java!

I love the game 2048. And so I decided to program my own version.

It’s very similar to the actual game, but programming it myself gives me the freedom to change whatever I want whenever I want to. If I want a 5×5 game instead of the typical 4×4, a simple change using the ‘Board’ constructer will allow me to do so. Say I want to make the game more difficult, adding pieces at positions that will make it most complex for the player rather than at random. Using a simple algorithm, I can do just that. While I won’t cover all these modifications in this Instructable, I do plan on adding more as I go.

For now, however, we’ll program your typical game of 2048.

Let’s get started!

(A side note: This Instructable requires moderate knowledge of programming — specifically with Java)

Step 1: Materials

You won’t need much for this project as it is just a programming walkthrough.

Materials:

  • Laptop
  • Eclipse (or any IDE of your choice)

Yup. That’s it.

Step 2: Get to Know the Program — Board

I uploaded all my code onto GitHub — check it out here: https://github.com/patturtestsite/2048

I divided the game up into 3 classes: Board, Tile and Game.

Board:

Description: The Board class deals with the gameboard, setting up an array of ‘Tile’ elements, getting the current score and the highest tile, and putting the array in a string (to be used later in ‘Game’). Most of the logic is also here, the class providing methods for spawning 2’s and 4’s at random locations, moving up, down, left and right, and letting players know when the game is over.

Constructors:

/* Default constructor for the Board — sets up a 4×4 matrix */

public Board() {…}

/* Constructor for the Board — sets up a matrix with specified grid size */

public Board (int grids) {…}

Methods:

/* Getter method that returns the board */

public Tile[][] getBoard() {…}

/* Getter method that returns the score */

public int getScore() {…}

/* Finds the highest tile on the board and returns it */

public int getHighTile() {…}

/* Prints out the board onto the console — for testing purposes */

public void print() {…}

/* Returns the board as a String — used in the GUI */

public String toString() {…}

/* Spawns a 2 (or 4) at an empty space very time a move is made */

public void spawn() {…}

/* Checks to see if the board is completely blacked out and if it is, it will nudge the players to restart */

public boolean blackOut() {…}

/* Checks to see if the game is over — when the board is blacked out and none of the tiles can combine */

public boolean gameOver() {…}

/* Called when ‘w’ or up arrow is pressed — calls ‘verticalMove’ for every tile on the board with parameter ‘up’ */

public void up() {…}

/* Called when ‘s’ or down arrow is pressed — calls ‘verticalMove’ for every tile on the board with parameter ‘down’ */
public void down() {…}

/* Called when ‘d’ or right arrow is pressed — calls ‘horizontalMove’ for every tile on the board with parameter ‘right’ */
public void right() {…}

/* Called when ‘a’ or left arrow is pressed — calls ‘horizontalMove’ for every tile on the board with parameter ‘left’ */

public void left() {…}

/* Compares two tile’s values together and if they are the same or if one is equal to 0 (plain tile) — their values are added (provided that the tiles we are comparing are two different tiles and they are moving towards the appropriate direction) — recursively moves through the row */

public void horizontalMove (int row, int col, String direction) {…}

/* Compares two tile’s values together and if they are the same or if one is equal to 0 (plain tile) — their values are added (provided that the tiles we are comparing are two different tiles and they are moving towards the appropriate direction) — recursively moves through the column */

public void verticalMove (int row, int col, String direction) {…}

Yeah, that’s a lot of methods — but don’t worry, most are extremely easy to understand. On top of that, the ‘Board’ class is the most complex, so everything after this will be relatively simple.

Step 3: Get to Know the Program — Tile

Tile:

Description: The Tile class deals with the individual tiles, and is the smallest of all the classes. Each tile has an integer value and a color. It has two constructors that create Tiles of value 0 (default) or value #. The methods are mostly self explanatory, with ‘getter’ and ‘setter’ methods making up a bulk of the total.

Constructors:

/* Constructs a basic tile with a value of 0 */

public Tile() {…}

/* Constructs a tile with a value of number */

public Tile (int number) {…}

Methods:

/* Gets the tile’s value */

public int getValue() {…}

/* Sets the tile’s value — used when adding two tiles together */

public void setValue (int value) {…}

/* Represents the tile as a String — used in the GUI */

public String toString() {…}

/* Sets the tile’s color based on its value */

public void setColor() {…}

/* Gets the tile’s color */

public void getColor() {…}

Step 4: Get to Know the Program — Game

Game

Description: The Game Class houses the main method, most of the GUI methods and the Key interactions. It takes both the Tile and Board classes, and enables them to work together.

Constructors:

None

Methods:

/* sets up the GUI with appropriate sizes and adds a Key Listener */

public static void setUpGUI() {…}

/* Checks to see whether wasd or arrow keys are pressed and performs the appropriate actions — updates the JFrame with every move */

public void keyPressed (KeyEvent e) {…}

/* Paints the GUI with a series of strings, the board, the tiles and ensures they are repainted when the game is over */

public void paint (Graphics g) {…}

/* draws an individual tile — called from the paint method */

public void drawTiles (Graphics g, Tile tile, int x, int y) {…}

/* Main method — sets up the GUI and starts the game */

public static void main(String[] args) {…}

Step 5: Important Methods — Movement

The movement methods are the most important to understand, but the good news is once you understand the vertical movements, you can apply that understanding to the horizontal movements. In fact, the three vertical movement methods are the exact same as the three horizontal method movements, except one moves across rows and the other across columns. For that reason, let’s focus on just the vertical movement methods.

private void verticalMove( int row, int col, String direction )
    {
        Tile initial = board[border][col];
        Tile compare = board[row][col];
        if ( initial.getValue() == 0 || initial.getValue() == compare.getValue() )
        {
            if ( row > border || ( direction.equals( "down" ) && ( row < border ) ) )
            {
                int addScore = initial.getValue() + compare.getValue();
                if ( initial.getValue() != 0 )
                {
                    score += addScore;
                }
                initial.setValue( addScore );
                compare.setValue( 0 );
            }
        }
        else
        {
            if ( direction.equals( "down" ) )
            {
                border--;
            }
            else
            {
                border++;
            }
            verticalMove( row, col, direction );
        }
    }

The above method, verticalMove, is called by the ‘up’ and ‘down’ methods. Let’s take a look at the ‘up’ method.

public void up()
    {
        for ( int i = 0; i < grids; i++ )
        {
            border = 0;
            for ( int j = 0; j < grids; j++ )
            {
                if ( board[j][i].getValue() != 0 )
                {
                    if ( border <= j )
                    {
                        verticalMove( j, i, "up" );
                    }
                }
            }
        }
    }

This method goes through the entire board and calls verticalMove for every tile with the parameter «up». verticalMove then compares the tile at position ‘j’ and ‘i’ with the tile at position ‘border’ and ‘i’. If the two are equal, they are combined. If they are not, the border tile is increased by 1 (as the parameter in place is ‘up’), and verticalMove is called again.

Check out the comments on my GitHub for additional information.

If we want to apply this to a horizontal movement, we simply need to change our perspective from columns to rows.

Step 6: Important Methods — Game Over

The Game Over method uses several if statements to check whether or not the game is over. Because 2048 has several circumstances that make it seem as though the game is over, but it may really not be over, I had to make sure everything was covered, and if/else statements seemed to be the easiest way to do that. As a result, the method is extremely long, but relatively basic to understand.

public boolean gameOver()
    {
        int count = 0;
        for ( int i = 0; i < board.length; i++ )
        {
            for ( int j = 0; j < board[i].length; j++ )
            {
                if ( board[i][j].getValue() > 0 )
                {
                    if ( i == 0 && j == 0 )
                    {
                        if ( board[i][j].getValue() != board[i + 1][j].getValue()
                            && board[i][j].getValue() != board[i][j + 1].getValue() )
                        {
                            count++;
                        }
                    }
                    else if ( i == 0 && j == 3 )
                    {
                        if ( board[i][j].getValue() != board[i + 1][j].getValue()
                            && board[i][j].getValue() != board[i][j - 1].getValue() )
                        {
                            count++;
                        }
                    }
                    else if ( i == 3 && j == 3 )
                    {
                        if ( board[i][j].getValue() != board[i - 1][j].getValue()
                            && board[i][j].getValue() != board[i][j - 1].getValue() )
                        {
                            count++;
                        }
                    }
                    else if ( i == 3 && j == 0 )
                    {
                        if ( board[i][j].getValue() != board[i - 1][j].getValue()
                            && board[i][j].getValue() != board[i][j + 1].getValue() )
                        {
                            count++;
                        }
                    }
                    else if ( i == 0 && ( j == 1 || j == 2 ) )
                    {
                        if ( board[i][j].getValue() != board[i + 1][j].getValue()
                            && board[i][j].getValue() != board[i][j + 1].getValue()
                            && board[i][j].getValue() != board[i][j - 1].getValue() )
                        {
                            count++;
                        }
                    }
                    else if ( i == 3 && ( j == 1 || j == 2 ) )
                    {
                        if ( board[i][j].getValue() != board[i - 1][j].getValue()
                            && board[i][j].getValue() != board[i][j + 1].getValue()
                            && board[i][j].getValue() != board[i][j - 1].getValue() )
                        {
                            count++;
                        }
                    }
                    else if ( j == 0 && ( i == 1 || i == 2 ) )
                    {
                        if ( board[i][j].getValue() != board[i][j + 1].getValue()
                            && board[i][j].getValue() != board[i - 1][j].getValue()
                            && board[i][j].getValue() != board[i + 1][j].getValue() )
                        {
                            count++;
                        }
                    }
                    else if ( j == 3 && ( i == 1 || i == 2 ) )
                    {
                        if ( board[i][j].getValue() != board[i][j - 1].getValue()
                            && board[i][j].getValue() != board[i - 1][j].getValue()
                            && board[i][j].getValue() != board[i + 1][j].getValue() )
                        {
                            count++;
                        }
                    }
                    else
                    {
                        if ( board[i][j].getValue() != board[i][j - 1].getValue()
                            && board[i][j].getValue() != board[i][j + 1].getValue()
                            && board[i][j].getValue() != board[i - 1][j].getValue()
                            && board[i][j].getValue() != board[i + 1][j].getValue() )
                        {
                            count++;
                        }
                    }
                }
            }
        }
        if ( count == 16 )
        {
            return true;
        }
        return false;
    }

Essentially the method begins with a count that is equal to 0, and for every immovable/not-combine-able tile, one is added. If the count equals 16 by the end (the total number of tiles on the board), then that means the game is over, as not a single tile is able to move.

Step 7: Important Methods — Key Presses

The Key Press methods are imported over from keyListener. I did not use keyReleased or keyTyped, as I found keyPressed to be the most convenient for my purposes. I also had to import KeyEvent in order to use it as a parameter. While most of this can be found online and is pretty self-explanatory for someone with programming knowledge, I thought I’d go ahead and explain it a little more here.

public void keyPressed( KeyEvent e )
    {
        if ( e.getKeyChar() == 'w' || e.getKeyCode() == KeyEvent.VK_UP )
        {
            game.up();
            game.spawn();
            gameBoard = game.toString();
            frame.repaint();
        }
        else if ( e.getKeyChar() == 's' || e.getKeyCode() == KeyEvent.VK_DOWN )
        {
            game.down();
            game.spawn();
            gameBoard = game.toString();
            frame.repaint();
        }
        else if ( e.getKeyChar() == 'a' || e.getKeyCode() == KeyEvent.VK_LEFT )
        {
            game.left();
            game.spawn();
            gameBoard = game.toString();
            frame.repaint();
        }
        else if ( e.getKeyChar() == 'd' || e.getKeyCode() == KeyEvent.VK_RIGHT )
        {
            game.right();
            game.spawn();
            gameBoard = game.toString();
            frame.repaint();
        }
        else if ( e.getKeyCode() == KeyEvent.VK_ENTER )
        {
            game = new Board();
            game.spawn();
            game.spawn();
            frame.repaint();
        }
    }

Essentially, this method checks whether the keyEvent is a w, a, s, or d — or an up, down, left or right key. As with most video games, ‘w’ and up serve the same purpose, ‘s’ and down serve the same purpose, ‘a’ and left serve the same purpose, and ‘d’ and right serve the same purpose. The Enter key is intended to start the game and as such, everytime it is pressed, a new Board is painted and two numbers are randomly spawned. If any of the other keys are pressed, the game is moved up, down, left or right based on which one is pressed and the frame is repainted.

Step 8: You’re Done!

Hopefully with a better understanding of the classes, their constructors, and their methods, you are able to both recreate the game and play around with the code, modifying it to your liking. If you have any questions, definitely let me know in those comments below.

Be the First to Share

Recommendations

2048

Introduction

A while back, I came across a game called 2048. The game was created by Gabriele Cirulli. It’s an interesting variation on a 4 x 4 sliding puzzle.

If you’re not familiar with Java Swing, Oracle has an excellent tutorial to get you started, Creating a GUI With JFC/Swing. Skip the Netbeans section.

Here’s a screenshot of my Java Swing version of 2048. It will help you understand the rules.

2048 GUI

The 2048 game starts with an empty 4 x 4 grid. Two tiles are placed on the grid at random locations. The tiles will either have a 2 value or a 4 value. There’s a 90% chance that a new tile will have the 2 value and a 10% chance that a new tile will have the 4 value.

You use the arrow keys, or the WASD keys if you’re left handed, to slide the tiles up, left, down, or right. All of the tiles move as far as they can in that direction.

When two tiles of the same value are next to each other, and you press the arrow key in that direction, they combine to form a tile with the next highest power of 2. For example, when two 2 tiles are in the top row and you press the right or the left arrow key, they combine to form a 4 tile. You can see in the screenshot that there are tiles with the value 2, 4, 8, and 16. Two 2 tiles combine to form a 4 tile. Two 4 tiles combine to form an 8 tile. Two 8 tiles combine to form a 16 tile. And so on.

One new 2 or 4 tile is placed in a random empty location each time the tiles are moved and / or combined.

The object of the game is to get a 2048 tile. Failing that, the object is to get as high a score as possible. You can see in the screenshot that the highest tile I’ve made is a 128 tile. The game ends when no more new tiles can be placed on the grid and no tiles can be combined.

You can find 2048 strategy tips in various places on the Internet. The main idea is to keep the highest value tiles in one of the four corners. There’s some luck involved, as the new tiles appear in random locations.

Explanation

I used a model / view / controller pattern (MVC) when putting this Java Swing application together. I have 2 model classes, 5 view classes, and 5 controller classes. The MVC pattern separates the concerns and allows me to focus on one part of the Java Swing application at a time.

Let’s look first at the main application class, Game2048. Game2048 is short and to the point. This class does 3 things.

  1. Starts the Java Swing application on the Event Dispatch thread (EDT).
  2. Creates an instance of the game model.
  3. Creates an instance of the game JFrame.

Every Java Swing application has to do these 3 things. This type of class is how I start every Java Swing application.

Model

Next, let’s look at the main model class, Game2048Model. This class keeps the score, lets the rest of the game classes know whether or not the arrow keys are active, and maintains the 4 x 4 game grid. This class also draws the game grid. I know, I said earlier that I separate the model from the view. The reason that the drawing code is included in the model is that it’s easier for Java objects to draw themselves. While the drawing code is included in the model, it’s executed as a part of the view.

The code to move the tiles is a part of the model. It took me a couple of days to get the code correct for moving the tiles. I can’t recall the last time I had to use a do while loop structure.

This class has a DEBUG boolean. Setting this boolean to true activates a couple of System.out.println statements that helped me to debug the tile move code. This is one way to debug complicated logic without having to step through the code with a debugger.

Next, let’s look at the Cell model class. This class maintains the value. If it wasn’t for the drawing code, this class could be replaced by an int. The cellLocation Point and the drawing code make up the remainder of the class. The most interesting code is in the createImage method, where we attempt to center the text containing the value of the cell.

Coming up with a sequence of colors was interesting. My idea was to make the yellow color deeper and richer as the tile values increased. Unfortunately, the RGB color spectrum isn’t large enough to make 10 different distinct yellow colors just by varying the hue.

View

Let’s look at the view classes. The first is the Game2048Frame class. We get the high scores from a properties file in the constructor. Later, we’ll see the HighScoreProperties class that saves and loads the high scores.

The createPartControl method uses a JFrame to create the game window. Notice that we use a JFrame. We do not extend a JFrame. The only time you should extend a Swing component, or any other Java class, is when you want to override one or more of the class methods.

We have a WindowAdapter in the createPartControl method to listen for when the game window closes. This is so we can get the high score and write it to the properties file before we destroy the window and exit the game.

We create 3 JPanels for the game, the grid panel, the score panel, and the control panel. The score and control panels are placed in a side JPanel. The grid panel and side panel are placed in a main JPanel. The main JPanel is placed in the JFrame. You will save yourself a lot of grief if you always have a main JPanel to put the rest of your Swing components in. JFrames were not designed to hold lots of Swing components.

We define the key bindings in the JFrame class, even though they are attached to the grid panel. I could have attached these key bindings to any of the Swing components in the game. The grid panel seemed the most logical. As soon as you click on the Start Game button in the control panel, the grid panel loses focus. That’s why I have key bindings defined as WHEN_IN_FOCUSED_WINDOW.

A JPanel has WHEN_FOCUSED key bindings defined for the left and right arrow keys. I have no idea what action is defined, but it’s not my action. That’s why I defined WHEN_FOCUSED key bindings as well for the arrow keys.

Towards the bottom, there are 2 convenience methods that allow me to repaint the grid panel and update the score panel. These methods are called by the controller classes. Having these methods in the Game2048Frame class allows me to pass just the instance of the JFrame class to the controller classes. The controller classes don’t have to know the internals of the view classes.

Next, we’ll look at the grid panel class, GridPanel. Since most of the drawing code is included in the model, there’s not much code here. We extend JPanel because we want to override the JPanel paintComponent method. If the game is over, we draw a game over image over the grid panel.

Next, we’ll look at the GameOverImage class. The most interesting thing here is that we have an alpha component to the image to make the majority of the image transparent. The “Game Over” text is opaque. There is also code to center the text, which is similar to the code in the Cell class to center the value text.

Next, we’ll look at the score panel class, ScorePanel. This class uses a GridBagLayout to lay out the labels and fields in a grid. The addComponent method creates a GridBagConstraints for each Swing component.

The updatePartControl method updates the score fields.

These are the relationships between the model, view, and controller in the MVC pattern:

  1. The view may get values from the model.
  2. The view may not update values in the model.
  3. The controller will update values in the model and may update the view.

Next, we’ll look at the control panel class, ControlPanel. This class uses a GridBagLayout to lay out the button in a grid. The GridBagLayout is overkill for one button, but is useful for laying out a column of buttons. The GridBagLayout ensures that all of the buttons are the same width. The addComponent method creates a GridBagConstraints for each Swing component.

Clicking on the “Start Game” button triggers the StartGameActionListener.

Controller

We’ll look at the StartGameActionListener class now. Most of the code resides in the model and view classes. The actionPerformed method performs the necessary model and view methods to start the game. Since this code is executed quickly, we perform it inline. If the code took a long time to execute, we wouldn’t want to tie up the EDT. We execute long running code in a separate thread.

Next, we’ll look at the other action classes. These classes are triggered by one of the arrow keys.

We’ll look at the UpArrowAction class. The actionPerformed method moves the cells up. If any cells move, then the game over test is performed. If the game is over, the arrow keys are logically disabled. The arrow keys still trigger the actions, but since the actions check if the arrow keys are enabled, nothing happens.

If any cells move, and the game is not over, a new 2 or 4 value cell is placed in a random location on the grid. The view is updated.

Since this actionPerformed code is executed quickly, we perform it inline. If the code took a long time to execute, we wouldn’t want to tie up the EDT. We execute long running code in a separate thread.

The LeftArrowAction, DownArrowAction, and RightArrowAction classes are very similar. The only difference is which move method in the model is performed. They are presented now without additional comments.

Finally, we have the HighScoreProperties class. I put the high score in a properties file so that I could save it. Yes, I could go into the properties file and change the high score manually. If you’re writing a game for other people to play, you’ll need to obfuscate the values so they cant be easily changed. One way to do this would be to add a check digit to the values.

I wrote the properties file to the same directory as my Java project. You should change the file name to point to a user directory.

The code of 2048 was not written by me, it was a project found from the project platform of Niuke.com

Project code link (code is placed at the end of the article)

Recorded renderings:

Game logic analysis

Everyone has played 2048, so I won’t introduce it. If you haven’t played, you can go play it and talk about the logic of the game.

1. By moving up, down, left, and right, the adjacent same elements are merged, and then the numbers are added to get the result of 2048.

2. The number block obtained every time is 2 or 4, and the generated position appears randomly in the blank position.

3. Two number blocks appear randomly at the beginning of the game

4. Each time a moving direction (up, down, left, and right) is passed in, all digital blocks move in this direction until the boundary, and the same two digital blocks will be merged into the sum of two numbers and become one digital block.

5. If no digital block is displaced after the movement, the movement is invalid and no new digital block will be generated.

6. When the scene has no space and cannot be moved, the game will fail.

7. The game score is the largest number in the scene. The game ends when the number is 2048.

Code analysis

I feel that the code is already very concise. This game can be written in 200 lines.

Simple mind map

Text version

Game2048

main method

SwingUtilities.invokeLater(() -> {

JFrame f = new JFrame();

f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE can close the window);

f.setTitle(«2048»); Set the window title

f.setResizable(true); The panel can be adjusted

f.add(new Game2048(), BorderLayout.CENTER); put the game into the panel

f.pack();

f.setLocationRelativeTo(null);

f.setVisible(true); visible

});

class Tile

Digital block

private boolean merged; whether to merge

private int value; value

Construction method

  • Tile(int val) {

    • value = val;

get and set methods

  • getValue()

  • setMerged(boolean m)

canMergeWith(Tile other) determines whether two blocks can be merged

  • return !merged && other != null && !other.merged && value == other.getValue();

  • The value is equal, the other block is not empty, and the move is not merged with the other block, all can be merged

int mergeWith(Tile other) {Merge method

  • if (canMergeWith(other)) {

    • value *= 2; the combined value is doubled

    • merged = true;

    • return value;

  • return -1;

movesAvailable determines whether it can be moved

clearMerged clear panel

Traverse all grids, set false

variable

State static member

  • start, won, running, over game state

colorTable color library

  • new Color(0x701710), new Color(0xFFE4C3), new Color(0xfff4d3),

  • new Color(0xffdac3), new Color(0xe7b08e), new Color(0xe7bf8e),

  • new Color(0xffc4c3), new Color(0xE7948e), new Color(0xbe7e56),

  • new Color(0xbe5e56), new Color(0x9c3931), new Color(0x701710)};

final static int target = 2048; target score

static int highest; game height

static int score; game score

private Color gridColor = new Color(0xBBADA0); grid color

private Color emptyColor = new Color(0xCDC1B4); empty color

private Color startColor = new Color(0xFFEBCD); start color

private Random rand = new Random(); random number

Private Tile tiles; digital block array stores all digital blocks

private int side = 4; grid size 4*4

private State gamestate = State.start; game state

private boolean checkingAvailableMoves; can move state

Game2048 construction method

setPreferredSize(new Dimension(900, 700));

setBackground(new Color(0xFAF8EF));

setFont(new Font(«SansSerif», Font.BOLD, 48));

setFocusable(true);

Monitor mouse

Monitor keyboard

paintComponent paint component

Initialize drawing

setRenderingHint anti-aliasing

startGame start the game

if (gamestate != State.running) {

score = 0;

highest = 0;

gamestate = State.running;

tiles = new Tileside;

addRandomTile();

addRandomTile();

}

drawGrid draw panel

g.setColor(gridColor); set the color

g.fillRoundRect(200, 100, 499, 499, 15, 15); fill the background

Determine the state of the game

drawTile draws digital blocks

fillRoundRect draws value in the r row and c column of the two-dimensional panel

addRandomTile randomly generates digital blocks

Randomly find empty positions to add 4 or 2

move to move the digital block

Move according to direction

Post-movement processing

  • if (moved) {

  • if (highest < target) {

  • clearMerged();

  • addRandomTile();

  • if (!movesAvailable()) {

  • gamestate = State.over;

  • }

  • } else if (highest == target)

  • gamestate = State.won;

  • }

A series of mobile methods

boolean moveUp() {

  • return move(0, -1, 0);

boolean moveDown() {

  • return move(side * side — 1, 1, 0);

boolean moveLeft() {

  • return move(0, 0, -1);

boolean moveRight() {

  • return move(side * side — 1, 0, 1);

package com.hanxu51.game2048;
import java.awt.*;
import java.awt.event.*;
import java.util.Random;
import javax.swing.*;
public class Game2048 extends JPanel {
    enum State {
        start, won, running, over
    }
    final Color[] colorTable = {
            new Color(0x701710), new Color(0xFFE4C3), new Color(0xfff4d3),
            new Color(0xffdac3), new Color(0xe7b08e), new Color(0xe7bf8e),
            new Color(0xffc4c3), new Color(0xE7948e), new Color(0xbe7e56),
            new Color(0xbe5e56), new Color(0x9c3931), new Color(0x701710)};
    final static int target = 2048;
    static int highest;
    static int score;
    private Color gridColor = new Color(0xBBADA0);
    private Color emptyColor = new Color(0xCDC1B4);
    private Color startColor = new Color(0xFFEBCD);
    private Random rand = new Random();
    private Tile[][] tiles;
    private int side = 4;
    private State gamestate = State.start;
    private boolean checkingAvailableMoves;
    public Game2048() {
        setPreferredSize(new Dimension(900, 700));
        setBackground(new Color(0xFAF8EF));
        setFont(new Font("SansSerif", Font.BOLD, 48));
        setFocusable(true);
        addMouseListener(new MouseAdapter() {
            @Override
            public void mousePressed(MouseEvent e) {
                startGame();
                repaint();
            }
        });
        addKeyListener(new KeyAdapter() {
            @Override
            public void keyPressed(KeyEvent e) {
                switch (e.getKeyCode()) {
                    case KeyEvent.VK_UP:
                        moveUp();
                        break;
                    case KeyEvent.VK_DOWN:
                        moveDown();
                        break;
                    case KeyEvent.VK_LEFT:
                        moveLeft();
                        break;
                    case KeyEvent.VK_RIGHT:
                        moveRight();
                        break;
                }
                repaint();
            }
        });
    }
    @Override
    public void paintComponent(Graphics gg) {
        super.paintComponent(gg);
        Graphics2D g = (Graphics2D) gg;
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                RenderingHints.VALUE_ANTIALIAS_ON);
        drawGrid(g);
    }
    void startGame() {
        if (gamestate != State.running) {
            score = 0;
            highest = 0;
            gamestate = State.running;
            tiles = new Tile[side][side];
            addRandomTile();
            addRandomTile();
        }
    }
    void drawGrid(Graphics2D g) {
        g.setColor(gridColor);
        g.fillRoundRect(200, 100, 499, 499, 15, 15);
        if (gamestate == State.running) {
            for (int r = 0; r < side; r++) {
                for (int c = 0; c < side; c++) {
                    if (tiles[r][c] == null) {
                        g.setColor(emptyColor);
                        g.fillRoundRect(215 + c * 121, 115 + r * 121, 106, 106, 7, 7);
                    } else {
                        drawTile(g, r, c);
                    }
                }
            }
        } else {
            g.setColor(startColor);
            g.fillRoundRect(215, 115, 469, 469, 7, 7);
            g.setColor(gridColor.darker());
            g.setFont(new Font("SansSerif", Font.BOLD, 128));
            g.drawString("2048", 310, 270);
            g.setFont(new Font("SansSerif", Font.BOLD, 20));
            if (gamestate == State.won) {
                g.drawString("you made it!", 390, 350);
            } else if (gamestate == State.over)
                g.drawString("game over", 400, 350);
            g.setColor(gridColor);
            g.drawString("click to start a new game", 330, 470);
            g.drawString("(use arrow keys to move tiles)", 310, 530);
        }
    }
    void drawTile(Graphics2D g, int r, int c) {
        int value = tiles[r][c].getValue();
        g.setColor(colorTable[(int) (Math.log(value) / Math.log(2)) + 1]);
        g.fillRoundRect(215 + c * 121, 115 + r * 121, 106, 106, 7, 7);
        String s = String.valueOf(value);
        g.setColor(value < 128 ? colorTable[0] : colorTable[1]);
        FontMetrics fm = g.getFontMetrics();
        int asc = fm.getAscent();
        int dec = fm.getDescent();
        int x = 215 + c * 121 + (106 - fm.stringWidth(s)) / 2;
        int y = 115 + r * 121 + (asc + (106 - (asc + dec)) / 2);
        g.drawString(s, x, y);
    }
    private void addRandomTile() {
        int pos = rand.nextInt(side * side);
        int row, col;
        do {
            pos = (pos + 1) % (side * side);
            row = pos / side;
            col = pos % side;
        } while (tiles[row][col] != null);
        int val = rand.nextInt(10) == 0 ? 4 : 2;
        tiles[row][col] = new Tile(val);
    }
    private boolean move(int countDownFrom, int yIncr, int xIncr) {
        boolean moved = false;
        for (int i = 0; i < side * side; i++) {
            int j = Math.abs(countDownFrom - i);
            int r = j / side;
            int c = j % side;
            if (tiles[r][c] == null)
                continue;
            int nextR = r + yIncr;
            int nextC = c + xIncr;
            while (nextR >= 0 && nextR < side && nextC >= 0 && nextC < side) {
                Tile next = tiles[nextR][nextC];
                Tile curr = tiles[r][c];
                if (next == null) {
                    if (checkingAvailableMoves)
                        return true;
                    tiles[nextR][nextC] = curr;
                    tiles[r][c] = null;
                    r = nextR;
                    c = nextC;
                    nextR += yIncr;
                    nextC += xIncr;
                    moved = true;
                } else if (next.canMergeWith(curr)) {
                    if (checkingAvailableMoves)
                        return true;
                    int value = next.mergeWith(curr);
                    if (value > highest)
                        highest = value;
                    score += value;
                    tiles[r][c] = null;
                    moved = true;
                    break;
                } else
                    break;
            }
        }
        if (moved) {
            if (highest < target) {
                clearMerged();
                addRandomTile();
                if (!movesAvailable()) {
                    gamestate = State.over;
                }
            } else if (highest == target)
                gamestate = State.won;
        }
        return moved;
    }
    boolean moveUp() {
        return move(0, -1, 0);
    }
    boolean moveDown() {
        return move(side * side - 1, 1, 0);
    }
    boolean moveLeft() {
        return move(0, 0, -1);
    }
    boolean moveRight() {
        return move(side * side - 1, 0, 1);
    }
    void clearMerged() {
        for (Tile[] row : tiles)
            for (Tile tile : row)
                if (tile != null)
                    tile.setMerged(false);
    }
    boolean movesAvailable() {
        checkingAvailableMoves = true;
        boolean hasMoves = moveUp() || moveDown() || moveLeft() || moveRight();
        checkingAvailableMoves = false;
        return hasMoves;
    }
    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame f = new JFrame();
            f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            f.setTitle("2048");
            f.setResizable(true);
            f.add(new Game2048(), BorderLayout.CENTER);
            f.pack();
            f.setLocationRelativeTo(null);
            f.setVisible(true);
        });
    }
}
class Tile {
    private boolean merged;
    private int value;
    Tile(int val) {
        value = val;
    }
    int getValue() {
        return value;
    }
    void setMerged(boolean m) {
        merged = m;
    }
    boolean canMergeWith(Tile other) {
        return !merged && other != null && !other.merged && value == other.getValue();
    }
    int mergeWith(Tile other) {
        if (canMergeWith(other)) {
            value *= 2;
            merged = true;
            return value;
        }
        return -1;
    }
}

I created a 2048 game clone in Java. However, I am only writing the fundamental algorithm for the game such as the movement, the losing condition, and the algorithm to generate a new tile. This algorithm should be generic enough to work not only on 4 x 4 grid, but also on any size of square grid.

Any review / feedback would be greatly appreciated!

Tile Class

/**
 * Class of individual tile unit in 2048 game. Value of zero implies an empty tile.
 * 
 * @author dkurniawan
 */
public class Tile {

    private int value;

    /**
     * Instantiate tile with a value of zero (empty). 
     */
    public Tile(){
        this(0);
    }

    /**
     * Instantiate tile with a specific value.
     * 
     * @param value
     */
    public Tile(int value){
        this.value = value;
    }


    public void setValue(int value){    
        this.value = value; 
    }

    public int getValue(){
        return value;
    }

    /**
     * Two tiles are the same if they have the same value. (Useful for merging tile)
     * 
     * @param tile
     * @return true if two tiles are equal, false otherwise
     */
    public boolean equals(Tile tile){
        return tile.getValue() == this.getValue();  
    }

    /**
     * Add the value of this tile by the value of the tile in the parameter.
     * 
     * @param tile
     */
    public void merge(Tile tile){
        this.setValue(value + tile.getValue());
    }

    /**
     * Set the value to zero. In other words, delete / empty the tile.
     * 
     */
    public void clear(){
        this.setValue(0);
    }

    public String toString(){
        return (Integer.toString(this.getValue()));
    }

}

Grid Class (The main algorithm is in this class)

To swipe the tiles in 2048, the game calls the move method (with Direction enum as a parameter)

import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
 * The main game algorithm. The grid contains n x n tiles. Tiles with a value of zero implies an empty tile. 
 * The algorithm operate by passing value to other grid (without moving the object itself. 
 * 
 * @author dkurniawan
 *
 */
public class Grid {


    //size of the grid
    private static final int SIZE = 4;

    private Tile[][] tiles = new Tile[SIZE][SIZE];

    /**
     * Instantiate n x n grid with all zero values (grid with empty tile).
     */
    public Grid(){

        for (int i = 0; i < tiles[0].length; i++){
            for (int j = 0; j < tiles.length; j++){
                tiles[i][j] = new Tile();
            }

        }
    }

    /**
     * Generate a tile with a random value of 2 or 4 in a random position. 
     * 
     * @return true if successfully placed a new tile, false if there is no empty tile left.
     * 
     */
    public boolean generateNewTile(){

        if (!(hasEmptyTile())){
            return false;
        }

        Random random = new Random();

        //iterate until an empty tile if found
        while (true){

            int x = random.nextInt(SIZE);
            int y = random.nextInt(SIZE);

            if (tiles[x][y].getValue() == 0){

                tiles[x][y].setValue(getNewTileValue());
                return true;

            }

        }

    }

    //get tile value of either 2 or 4   
    private int getNewTileValue(){

        Random random = new Random();

        int rng = random.nextInt(2) + 1;

        return (rng * 2);

    }

    /**
     * 2048 movement algorithm. The main idea of the algorithm is to create a group / set of tile according to the direction chosen.
     * For example, if the user want to move the tile to the right, then the group will be the rows of tile. As a result,
     * each row will have the same movement algorithm. These rows will be sent to a general method.
     *  
     * @param direction Determine which direction the player want to slide the tile.
     */
    public void move(Direction direction){

        for (int i = 0; i < SIZE; i++){

            //group of tile
            List<Tile> tileSet = new ArrayList<Tile>();

            for (int j = 0; j < SIZE; j++){

                switch(direction){

                case LEFT: tileSet.add(tiles[i][j]); break;
                case RIGHT: tileSet.add(tiles[i][SIZE - j - 1]); break;
                case UP: tileSet.add(tiles[j][i]); break;
                case DOWN: tileSet.add(tiles[SIZE - j - 1][i]); break;
                default: break;

                }

            }

            if (!(isEmptyTile(tileSet))){
                slide(tileSet); //main tile group algorithm
            }


        }

    }

    private boolean isEmptyTile(List<Tile> tileSet) {

        for (Tile tile: tileSet){

            if (tile.getValue() != 0){
                return false;
            }

        }

        return true;

    }

    //main tile group algorithm
    private void slide(List<Tile> tileSet){

        slideToEdge(tileSet);
        mergeTile(tileSet);

    }

    //slide all tile into the edge, in case there is a zero in between
    private void slideToEdge(List<Tile> tileSet){

        for (int i = 0; i < tileSet.size(); i++){

            if (remainingIsZero(tileSet, i)){
                return;
            }

            while (tileSet.get(i).getValue() == 0){

                slideTo(tileSet, i);

            }

        }

    }

    private boolean remainingIsZero(List<Tile> tileSet, int i) {

        List<Tile> remainingTile = new ArrayList<Tile>();

        for (int j = i; j < tileSet.size(); j++){
            remainingTile.add(tileSet.get(j));
        }

        return (isEmptyTile(remainingTile));

    }

    private void slideTo(List<Tile> tileSet, int index){

        for (int j = index; j < tileSet.size() - 1; j++){

            tileSet.get(j).setValue(tileSet.get(j + 1).getValue());

        }

        tileSet.get(tileSet.size() - 1).clear();

    }

    //Merge tile, if tile in the direction has the same value.
    private void mergeTile(List<Tile> tileSet){

        for (int i = 0; i < tileSet.size() - 1; i++){

            if (tileSet.get(i).equals(tileSet.get(i + 1))){
                tileSet.get(i).merge(tileSet.get(i + 1));
                tileSet.get(i + 1).clear();
                slideTo(tileSet, i + 1);

            }

        }

    }

    /**
     * Check for losing condition. Losing implies no possible move can be made to change the tile.
     * 
     * @return true, if no possible move left
     */
    public boolean noPossibleMove(){    

        if (hasEmptyTile()){
            return false;
        }

        return !(hasEqualNeighbour());

    }

    private boolean hasEmptyTile(){

        for (int i = 0; i < SIZE; i++){
            for (int j = 0; j < SIZE; j++){

                if (tiles[i][j].getValue() == 0){
                    return true;
                }
            }
        }

        return false;

    }

    private boolean hasEqualNeighbour() {

        for (int i = 0; i < SIZE; i++){
            for (int j = 0; j < SIZE; j++){

                //check the tile in the right of the chosen tile. Ignore last column.
                if (j < SIZE - 1){

                    if (tiles[i][j].equals(tiles[i][j + 1])){
                        return true;
                    }

                }

                //check the tile below the chosen tile. Ignore last row.
                if (i < SIZE - 1){

                    if (tiles[i][j].equals(tiles[i + 1][j])){
                        return true;
                    }

                }



            }
        }

        return false;
    }

    public String toString(){

        StringBuilder sb = new StringBuilder();

        for (Tile[] tileRow: tiles){
            for (Tile tile: tileRow){
                sb.append(tile);
                sb.append(" ");
            }
            sb.append("n");
        }

        return sb.toString();

    }

}

Direction Enum

public enum Direction {

    UP, DOWN, LEFT, RIGHT;

}

Понравилась статья? Поделить с друзьями:
  • Как написать 2019 год римскими цифрами
  • Как написать 2016 римскими цифрами
  • Как написать 2015 на английском
  • Как написать 2014 на английском
  • Как написать 2011 римскими цифрами