Как написать свою 2048 на Java за 15 минут
В предыдущих статьях этой серии мы уже писали сапёра и змейку на Java, а теперь попробуем написать десктопный клон игры 2048.
91К открытий94К показов
В предыдущих статьях этой серии мы уже писали сапёра и змейку, а теперь попробуем написать десктопный клон игры 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), то переписываем в новый массив лишь их сумму, затем ставим указатель на третье число (второго уже нет).Иначе переписываем только первое, и ставим указатель на второе число.
При этом нам необходимо хранить место в возвращаемом массиве, на которое необходимо произвести запись.
А вот как он выглядит в виде кода:
Наслаждаемся результатом
P.S Ещё раз напомню, что исходники готового проекта доступны на GitHub.
91К открытий94К показов