Как написать свою 2048 на Java за 15 минут

В предыдущих статьях этой серии мы уже писали сапёра и змейку, а теперь попробуем написать десктопный клон игры 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(), так вы быстро определите фронт работ.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Метод логики

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Пётр Соковых, транслятор двоичного кода в русский язык