Как написать свою 2048 на Java за 15 минут
В предыдущих статьях этой серии мы уже писали сапёра и змейку, а теперь попробуем написать десктопный клон игры 2048.
Нам, как обычно, понадобятся:
- 15 минут свободного времени;
- Настроенная рабочая среда, т.е. JDK и IDE (например, Eclipse);
- Библиотека LWJGL (версии 2.x.x) для работы с графикой (опционально). Обратите внимание, что для LWJGL версий выше 3 потребуется написать код, отличающийся от того, что приведён в статье;
- Спрайты, т.е. картинки плиток всех возможных состояний (пустая, и со степенями двойки до 2048). Можно нарисовать самому, или скачать использовавшиеся при написании статьи.
FAQ по опыту предыдущих статей
В: Вы что! Это большой проект, за 15 минут такое нельзя накодить!
О: Разумеется, чтобы придумать всё это и написать, у меня ушёл целый вечер и даже немножко ночи. Но прочитать статью и, скопировав из неё код, запустить работающую игру (при этом понимая, что происходит) вполне реально и за 15 минут.
В: Зачем тянуть ради такого простого проекта LWJGL? Гвозди микроскопом!
О: Вся работа с отображением вынесена в два с половиной метода в 2 интерфейса. Вы можете реализовать их на чём хотите, мне же удобнее на LWJGL.
В: А почему код выкладываете архивом? На гитхаб нужно!
В: У меня не получается подключить твою эту 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.
Хинт для программистов: если зарегистрируетесь на соревнования Huawei Honor Cup, бесплатно получите доступ к онлайн-школе для участников. Можно прокачаться по разным навыкам и выиграть призы в самом соревновании.
Перейти к регистрации
Источник статьи: http://tproger.ru/articles/java-15-mins-2048/
Делаем игру 2048 на AngularJS
Наверное, вам, как и многим коллегам, пришлась по вкусу игра «2048», в которой необходимо достичь плитки с числом 2048, собирая вместе плитки с одинаковыми числами.
В этой статье мы вместе построим клон этой игры при помощи фреймворка AngularJS. По ссылке можно посмотреть демонстрацию конечного результата.
Первые шаги: планируем приложение
Первый шаг – разработка высокоуровневого дизайна приложения. Мы делаем это для любых приложений, неважно, клонируем ли существующее приложение или пишем своё с нуля.
В игре есть игровое поле с набором клеток. Каждая из клеток – место для расположения плитки с числом. Этим можно воспользоваться и переложить ответственность за размещение плиток на CSS3, а не делать это в скрипте. Когда плитка находится на игровом поле, мы просто должны убедиться, что она находится на нужном месте.
Использование CSS3 позволяет нам оставить анимацию для CSS, и использовать поведение AngularJS по умолчанию для отслеживания состояния доски, плиток и логики игры. Так как у нас одна страница, нам понадобится один контроллер.
Так как у нас одно игровое поле, вся логика сетки будет хранится в одном экземпляре сервиса GridService. Сервисы – это синглтоны, поэтому в них удобно хранить сетку. GridService будет размещать плитки, двигать их, отслеживать состояние сетки.
Игровая логика будет хранится и обрабатываться в другом сервисе под названием GameManager. Он будет в ответе за управление состоянием игры, обработку ходов и хранение очков (текущего достижения и таблицы рекордов).
И наконец нам понадобится компонент для обслуживания клавиатуры. Это будет сервис KeyboardService. В этой статье мы реализуем работу десктопного приложения, но его можно будет легко переделать для сенсорных экранов.
Построение приложения
Создадим простое приложение (мы использовали генератор приоложений yeoman, но это необязательно). Создадим каталог приложения, в котором оно будет хранится. Каталог test/ будет находится рядом с каталогом app/.
Следующие инструкции относятся к настройке проекта через yeoman. Если вам удобнее делать это вручную, их можно пропустить.
Сначала убедимся, что yeoman установлен. Для этого должны быть установлены NodeJS и npm. После этого нужно установить утилиту yeoman под названием yo, и генератор для angular (который будет использован утилитой для создания приложения):
После этого можно создавать приложение через утилиту yo:
Нужно будет утвердительно ответить на все вопросы, кроме «select the angular-cookies as a dependency», поскольку нам они не нужны.
Наш модуль angular
Создадим файл приложения scripts/app.js. Начнём приложение:
Модульная структура
Рекомендуется строить структуру приложения по функционалу, а не по типам. То есть, не разделять компоненты, как принято, по контроллерам, сервисам, директивам. К примеру, в нашем приложении мы определим модуль Game и модуль Keyboard.
Модульная структура даёт нам чёткое разделение ответственности, которое совпадает с файловой структурой. Это помогает не только строить большие и сложные приложения, но и делиться функционалом внутри самого приложения. Позже мы настроим окружение для тестирования, совпадающее со структурой каталога.
Проще всего начать приложение с Вида. В данном случае у нас есть только один вид/шаблон. Поэтому мы создадим единственный
После настройки файла app/index.html все остальные изменения внешнего вида мы будем проводить в файле app/views/main.html. Изменять index.html придётся только при импорте нового ресурса в приложение.
В файл app/views/main.html поместим все виды, относящиеся к игре. Через синтаксис controllerAs можно задать, где будут находится данные по нашему $scope, и какой контроллер отвечает за какой из компонентов.
Синтаксис controllerAs – это новинка версии 1.2. С его помощью проще работать со многими контроллерами на странице. В нашем виде, как минимум необходимо задать несколько вещей:
1. Статический заголовок игры
2. Текущий счёт и таблицу рекордов
3. Игровое поле
Обратите внимание, что мы упоминаем GameController вместе с currentScore и highScore. Синтаксис controllerAs позволяет упоминать тот конкретный контроллер, который нам нужен.
GameController
Определив структуру приложения, можно создать GameController для хранения величин, которые появятся в Виде. Внутри app/scripts/app.js создадим контроллер на главном модуле twentyfourtyeightApp:
В Виде мы упоминали объект game, который будет управляться контроллером GameController. Этот игровой объект будет создан в новом модуле, и будет содержать все необходимые ссылки на игру. Но пока его нет, приложение не запустится. В контроллере мы можем добавить зависимость от GameManager:
Помните, что мы создаём зависимость уровня модулей, поэтому, чтобы убедиться, что она загрузится приложением, её надо внести в список зависимостей модуля Angular. Чтобы сделать модуль Game зависимостью для twentyfourtyeightApp, его надо упомянуть в массиве, в котором мы определяем модуль.
Целиком файл app/scripts/app.js выглядит так:
Мы почти подключили Вид, теперь можем заняться и логикой игры. Создадим модуль игры как app/scripts/game/game.js:
Лучше создавать модуль в своём собственном каталоге с именем, совпадающим с именем модуля. Наш модуль Game обеспечивает один основной компонент: GameManager.
GameManager будет отвечать за хранение состояния игры, возможные ходы, текущий счёт, определение окончания игры и её результат. Я рекомендую test driven development, когда сначала пишется заглушка для нужного модуля, затем пишется тест для него, и потом уже заполняются пустые места.
В целях обучения мы подробно пройдём этот путь для текущего модуля, а в дальнейшем я просто упомяну, для каких компонентов необходимо написать тесты.
1. Создание игры
2. Обновление счёта
3. Отслеживание, не закончилась ли игра
Основная структура сервиса, для которой можно будет написать тесты, будет такой:
Test Driven Development (TDD)
Перед созданием тестов необходимо установить модуль karma. Это окружение для запуска тестов, позволяющее автоматизировать тесты фронтенда, запуская их из терминала и кода.
Если вы создавали предложение через yeoman, то следующую часть можно пропустить.
Для использования karma нужно создать файл с настройками. Основная часть процесса настройки – загрузить все файлы, которые нужно тестировать.
Для создания файла конфигурации нужно выполнить команду
Затем отредактировать массив файлов и включить опцию autoWatch:
Тесты будут храниться в каталоге test/unit. Для прогона тестов мы воспользуемся командой:
Пишем первые тесты
Напишем тест проверки на оставшиеся ходы. В новом файле test/unit/game/game_spec.js запишем:
Как и для других юнит-тестов, нам надо создать экземпляр GameManager. Обычным способом для тестов является иньекция его в тест:
С этим экземпляром gameManager можно настроить ожидания нашей функции movesAvailable(). Определим её как метод, который проверяет, есть ли ещё пустые квадратики, а также возможны ли ещё какие-либо объединения плиток. Поскольку это является условием для окончания игры, мы оставим этот метод в GameManager, а все подробности реализуем в GridService, который мы создадим чуть позже.
Если на доске остались ходы, это значит, что:
1. Есть пустые места
2. Есть возможности для объединения плиток
Идея в том, чтобы писать тесты этих условий так, что мы создаём необходимое условие и проверяем реакцию кода на него. Поскольку мы будем полагаться на отчёты о состоянии игрового поля только от GridService, мы сымитируем состояние, и проверим, что логика GameManager работает правильно.
Имитация GridService
Для этого мы переопределим поведение Angular по умолчанию и вместо настоящего сервиса подсунем имитацию, что позволит нам контролировать условия. Для этого мы создадим ложный объект с имитирующими методами, и затем скажем Angular, что этот объект – настоящий, подменив его в сервисе $provide.
Теперь поддельный _gridService можно использовать, чтобы задать нужные условия. Убедимся, что функция movesAvailable() выдаёт true, когда есть свободные ячейки. Сымитируем метод anyCellsAvailable() (который ещё не написан) в сервисе GridService. Метод должен сообщать о свободных ячейках в GridService.
Фундамент заложен, теперь можно заняться вторым условием. Если есть возможные плитки для слияния, то нам надо убедиться, что функция movesAvailable() возвращает true. Надо убедиться и в обратном — что невозможно сделать ход, если нет свободных ячеек и подходящих плиток.
Два другие теста, подтверждающие это:
Теперь можно писать тесты, несмотря на то, что основное поведение приложения ещё не реализовано.
Вернёмся к GameManager
Теперь нам надо реализовать функцию movesAvailable(). Мы уже можем протестировать работу кода, и уже определили те условия, при которых он работает, поэтому написать функцию довольно легко:
Строим игровую сетку
После создания GameManager нам нужно создать GridService, который будет обрабатывать все состояния игрового поля. Мы хотели делать это с помощью двух массивов – основной сетки и плиток. Зададим GridService две локальные переменные в файле app/scripts/grid/grid.js:
Для старта игры нужно будет заполнить эти массивы нулями. Сетка статичная, а для заполнения её плитками используются только элементы DOM. Массив плиток, наоборот, динамический.
В файле app/views/main.html нужно отобразить решётку. Логично будет расположить её в отдельной директиве. Использование директивы позволяет не раздувать главный шаблон и инкапсулировать функциональность.
В файле app/index.html расположим директиву сетки и передадим ей экземпляр GameManager через контроллер:
Эта директива будет расположена в модуле Grid, поэтому создадим для неё файл app/scripts/grid/grid_directive.js.
Директива должна содержать экземпляр GameManager (или, как минимум, модель, содержащую массивы сетки и плиток), это будет обязательным условием. Кромет ого, директива не должна обращаться к остальной части страницы или к экземпляру GameManager на странице, поэтому мы создадим её в изолированной области видимости.
Основная задача директивы – настроить вид сетки, поэтому настраиваемая логика в ней нам не нужна.
grid.html
В шаблоне директивы мы запустим два ngRepeat для показа сетки и плиток, и для отслеживания их через $index.
Первый ng-repeat понятен – он проходит по массиву сетки и рисует пустые div класса grid-cell.
Второй ng-repeat создаёт вложенные директивы для каждого из элементов-плиток tile. Директивы tile отвечают за визуальное создание плиток. Мы к ним скоро вернёмся.
Внимательный читатель воскликнет, что мы используем одномерный массив для вывода двумерной сетки. Молодец! И действительно, когда мы отрендерим наш вид, мы получим столбец плиток, а не решётку. Чтобы превратить их в решётку, нам понадобится CSS.
Входит SCSS
Для проекта мы будем использовать модерновый вариант SASS: scss. Это более мощный вариант стилей, к тому же мы сделаем наши CSS динамичными. Основная часть визуальной работы ляжет на CSS, включая анимации, раскладку и визуальные элементы (цвета и т.п.)
Для создания двумерной доски мы используем ключевое слово CSS3 transform.
CSS3 transform property
Свойство CSS3 transform – это свойство, позволяющее изменять элемент в 2D или 3D. Его можно двигать, корёжить, вращать, масштабировать и т.д. (и анимировать всё это дело). Мы можем просто разместить плитку на доске и применить к ней нужную трансформацию.
К примеру, у нас есть квадрат ширины 40px и высоты 40px.
Применяя свойство transform translateX(300px) мы передвинем его на 300px вправо.
Свойство translate позволяет передвигать плитки по доске просто применяя к ним стили. Но как построить динамические классы в соответствии с расположением клеток на сетке, когда мы меняем положение элементов на странице?
И тут на белом коне въезжает SCSS. Зададим несколько переменных (сколько плиток в ряд, и т.д.) и построим наш SCSS вокруг них. Вот, какие переменные нам нужны:
С их помощью мы даём SCSS возможность динамически рассчитывать позицию элемента. Сперва рассчитаем размер, что довольно тривиально:
Теперь настроим контейнер #game, задав ему нужную высоту и ширину. Также мы убедимся, что сможем задавать абсолютные позиции элементов внутри него. Элементы .grid-container и .tile-container уютно разместятся внутри объекта #game.
Здесь – только цитаты из scss, остальное можно найти в нашем проекте на github (ссылка в конце статьи).
Чтобы .tile-container расположился над .grid-container, необходимо задать z-index выше, чем у .tile-container. Если этого не сделать, они будут сидеть на одном уровне и выглядеть это будет криво
Теперь можно динамически позиционировать плитки. Нам нужно назначить класс .position-
Вычисления отступов начинаются с 1, а не с 0 из-за ограничений SASS. Мы просто вычитаем 1 из индекса. Динамически раздав всем позиции, мы можем выводить плитки на экран.
Раскрашиваем различные плитки
Цвета плиток зависят от содержащегося в них значения, это сделано для удобства игрока. Мы сделаем это в цикле, похожем на тот, что раздавал плиткам позиции, только теперь он будет назначать им цвета. Для этого создадим массив SCSS:
Теперь мы пройдём по цветам и создадим для каждого класс – для плитки 2 класс будет .tile-2, и так далее. Вместо того, чтобы захардкодить эти классы, мы воспользуемся магией SCSS:
Конечно, нужно задать миксин power():
Директива Tile
Т.к. директива – это контейнер для Вида, работы нужно немного. Надо будет настроить доступ к ячейке, которую она должна выводить.
Интересная часть директивы tile – как мы динамически располагаем сетку. Это делается в шаблоне через переменную ngModel, находящуюся в изолированной области видимости. Как видно выше, она ссылается на объект tile из массива tiles:
Вот и почти всё готово для вывода на страницу. Все плитки с координатами x и y автоматом получат классы .position-#
TileModel
Создадим не просто какой-то тупой объект, а вовсе даже умный, содержащий не только данные, но и функциональность.
Чтобы иметь возможность использовать инъекцию зависимостей Angular, мы создадим сервис, содержащий модель данных. Сервис TileModel будет в модуле Grid, так как он нужен только для низкого уровня работы игрового поля.
Через метод .factory мы можем создать функцию, которую мы назначим фабрике. В отличие от функции service(), которая предполагает, что используемая функция определяет сервис в конструкторе этого сервиса, метод factory() назначает сервисный объект возвращаемым значением функции. Поэтому через метод factory() мы можем назначить объект как сервис, чтобы вставлять его в наши приложения.
В файле app/scripts/grid/grid.js создадим фабрику TileModel:
Теперь мы можем где угодно сделать инъекцию TileModel и использовать её, словно глобальный объект. Удобство и комфорт.
Не забудьте написать тесты для TileModel.
Наша первая сетка
Теперь у нас есть TileModel, и мы можем размещать её экземпляры в массиве плиток, после чего они волшебным образом появятся на нужных местах решётки.
Игровое поле готово для игры
Теперь создадим функциональность игрового поля в GridService. При первой загрузке страницы необходимо создать пустое игровое поля, и делать это после каждого нажатия кнопок «Новая игра» и «Попробовать снова». Для очистки поля используется функция buildEmptyGameBoard() в нашем GridService. Этот метод будет заполнять нулями сетку и массив плиток.
Перед кодом давайте напишем тест проверки функции buildEmptyGameBoard().
Сама функция будет небольшой, а располагаться будет в app/scripts/grid/grid.js
Код использует несколько вспомогательных методов. Вот ещё несколько таких функций:
Что это значит
А что это за функции this._positionToCoordinates() и this._coordinatesToPosition()?
Вспомните, что мы обсуждали использование одномерного массива для раскладки нашей сетки. И с точки зрения быстродействия, и с точки зрения анимации этот вариант предпочтительнее.
Многомерный массив в одном измерении
Как можно представить многомерный массив в одном измерении? Посмотрим на сетку, представляющую игровое поле без цветов, где значениями ячеек являются их координаты. Двумерный массив в коде представляется как массив массивов.
Однако, поле можно представить и в одном измерении. Ячейка 0,0 находится в одномерном массиве по адресу 0. Следующая, 1,0, добавляет 1 к позиции. И так далее.
Экстраполируя систему, мы можем видеть, что уравнение, описывающее связь между двумя системами координат, выглядит так:
i – индекс ячейки, x и y – координаты на двумерном игровом поле, n – количество ячеек в строке/столбце. Две вспомогательные функции занимаются преобразованием этих координат. Концептуально легче иметь дело с x и y, но в смысле реализации лучше использовать одномерный массив.
Начальная позиция
В начале игры необходимо расположить некоторые стартовые плитки. Мы сделаем это случайным образом.
Построение начальной позиции простое, оно вызывает только функцию randomlyInsertNewTile() для тех плиток, которые мы желаем разместить. Функция предполагает, что мы знаем обо всех возможных местах положения для плиток. Она просто проходит по массиву и ведёт учёт тех мест, где ещё нет плитки.
Мы можем просто выбирать случайные координаты из массива. Этим занимается функция randomAvailableCell(). Вот один из возможных вариантов реализации:
После этого мы создаём экземпляр TileModel и вставляем его в массив this.tiles.
Теперь, благодаря Angular, наши плитки волшебным образом появятся на игровом поле.
Не забудьте написать тесты для проверки функциональности.
Взаимодействие с клавиатурой
В нашем проекте мы будем работать с клавиатурой и не коснёмся сенсорных экранов (каламбур). Но реализовать управление касаниями несложно.
Игра управляется стрелками, или нажатиями w, s, d, a. Нам нужно, чтобы пользователь, находясь на странице, мог управлять игрой, без необходимости ставить фокус на каком-то элементе. Для этого необходимо назначить отслеживание событий на document. В Angular это будет сервис $document.
Использование сервиса позволит нам создать настраиваемые события, которые происходят по нажатию кнопок, которые мы вставим в объекты Angular.
Для начала создадим новый модуль Keyboard в файле app/scripts/keyboard/keyboard.js
Ну и как обычно, надо сообщить Angular, что новый модуль будет одной из зависимостей нашего приложения:
Теперь займёмся обработчиками событий.
Функция init() обеспечит запуск KeyboardService, который будет отслеживать события клавиатуры и отфильтровывать ненужные. А для всех нужных мы отменим действие по умолчанию и передадим их в keyEventHandlers.
Как узнать нужные события? В нашем случае можно просто перебрать все клавиши управления. Когда нажаты стрелочки, документ получить событие с кодом стрелки. Мы создадим карту событий и будем проверять её:
Каждый раз, когда клавиша из keyboardMap запускает событие, KeyboardService будет выполнять функцию this._handleKeyEvent. Она вызывает обработчик, зарегистрированный на эту кнопку, проходя в цикле все обработчики.
А с другой стороны нужно просто передать функцию-обработчик в список обработчиков.
Использование сервиса Keyboard
Теперь нам нужно запустить отслеживание клавиатурных нажатий после запуска игры. Так как мы построили его в виде сервиса, нужно сделать это внутри основного контроллера.
Сначала надо вызвать init(), чтобы запустить отслеживание событий. Потом зарегистрировать хендлер функции, чтобы вызывать GameManager, который будет вызывать move().
В GameController мы добавим функции newGame() и tartGame(). newGame() вызывает игровой сервис, создаёт новую игру и запускает обработчик клавиатуры. Создадим инъекцию модуля Keyboard как новую модульную зависимость для приложения:
Теперь можно вставить KeyboardService в GameController и запустить взаимодействие с пользователем. Сначала, метод newGame():
Пока мы не определили метод newGame() из GameManager, этим мы займёмся чуть позже.
После создании новой игры мы вызовем startGame(). Она настроит сервис обработчика клавиатуры:
Нажмите кнопку start
Много же нам потребовалось работы для запуска игры! Последний метод, который надо сделать — newGame() из GameManager, который будет:
1. строить пустое игровое поле
2. назначать стартовые расположения
3. инициализировать игру
Логика внутренностей GridService уже готова, осталось всё только подключить. В файле app/scripts/game/game.js добавим новую функцию newGame(). Она будет возвращать игру к первоначальному состоянию:
Загрузив это в браузере, мы получим работающую сетку. Но поскольку мы пока не сделали функциональность ходов, ничего интересного не происходит.
Основной игровой цикл
Теперь перейдём к основной функциональности игры. После нажатия управляющих клавиш нужно вызвать функцию move() сервиса GridService (мы построим этот вызов внутри GameController).
Для этого нужно определить ограничения игры. То есть то, как игра реагирует на любой ход. Для каждого хода нужно:
1. Определить вектор движения, заданный клавишей
2. Найти все наидальнейшие локации для каждой плитки. В это же время получить плитку из следующей локации, чтобы понять, можно ли будет объединять плитки.
3. Для каждой плитки надо определить, есть ли для неё плитка следующего номинала
а) если нет, тогда мы просто перемещаем плитку на самую дальнюю позицию
б) если есть, и
в) значения плиток различаются, то мы перемещаем плитку на самую дальнюю позицию
г) значения плиток одинаковые, мы можем объединять плитки
— если эта плитка только что была объединена с какой-то ещё, тогда мы её пропускаем
— если она на этом ходу ещё не объединялась, то мы делаем объединение
Таперича можно наметить и стратегию построения функции move()
Если игра окончилась, а мы застряли в игровом цикле – мы просто возвращаемся из него и идём дальше.
Затем нам надо пройти по сетке и найти все возможные позицию. Т.к. это обязанность сетки – знать, где у неё есть свободные места, мы создадим в GridService функцию, которая будет искать возможные пути для перемещения плиток.
Нам нужно определить вектор, обозначенный нажатием клавиши. К примеру, нажатие «вправо» перемещает плитки по увеличению координаты х. По нажатию «вверх» плитки перемещаются по уменьшению y.
Разметить вектора можно так:
Теперь можно просто пройти циклом по возможным позициям, используя vectors для определения того, как именно совершается этот проход.
Теперь с помощью traversalDirections() мы можем определять возможные перемещения внутри функции move(). В GameManager мы таким образом будем обходить сетку.
Теперь внутри цикла position мы пройдём все возможные позиции и поищем, нет ли на них плиток. Затем перейдём ко второй части функциональности, и найдём все самые отдалённые возможные позиции для плиток.
Если плитка найдена, вычисляем самую дальнюю позицию возможного перемещения. Для этого мы проходим по всем прилегающим позициям, и проверяем, находится ли следующая клетка внутри сетки, и пустая ли она. Если да, то проверяем следующую клетку, и т.д. В противном случае мы либо наткнулись на границу сети, или нашли следующую позицию. Сохраняем следующую позицию как newPosition и переходим к следующей клетке.
Теперь, когда мы можем подсчитать возможные позиции для перемещения плиток, можно проверять на возможные объединения плиток. Объединение – это столкновение двух плиток одинакового номинала. Мы проверяем, есть ли на позиции плитка этого же номинала, которая не была уже ранее объединена с какой-либо другой.
Теперь, если следующая позиция не удовлетворяет условиям, мы просто перемещаем плитку из текущей.
Перемещение плитки
Вы могли догадаться, что метод moveTile() лучше всего определять внутри GridService. Перемещение плитки – это просто изменение её положения в массиве и обновление TileModel. Когда мы передвигаем плитку в массиве, мы обновляем массив GridService (информация на бэкенде). Положение плитки в массиве не привязано к положению на сетке.
Когда мы обновляем позицию в TileModel, мы обновляем координаты на фронтенде, с тем, чтобы CSS правильно расположили плитку на экране.
Итого, для отслеживания плиток на бэкенде нам надо обновлять массив this.tiles в GridService и обновлять позицию объекта плитки.
Потому moveTile() становится простой двухходовой операцией:
Теперь надо определить метод tile.updatePosition(), который обновляет координаты x и y модели.
Внутри GridService можно просто вызвать .moveTile() для обновления позиции как в массиве GridService.tiles, так и позиции самой плитки.
Объединение плиток
Объединение происходит поэтапно:
1. Добавляем новую плитку на месте назначения, с новым номиналом
2. Удаляем старую плитку
3. Обновляем таблицу очков
4. Проверяем, не достигнут ли выигрыш
Поскольку мы поддерживаем только однократное объединение, нам надо хранить список плиток, которые уже были объединены. Для этого мы взводим флаг .merged.
Мы упомянули две ещё не определённые функции. Метод GridService.newTile() создаёт новый объект TileModel.
Мы скоро вернёмся к методу self.updateScore(). А пока скажем, что он обновляет таблицу очков.
После перемещения
Новые плитки добавляются только после допустимого перемещения.
После перемещения всех плиток надо проверить, не выиграли ли мы. Если игра окончена, то мы взводим флаг self.win. Осталось проверить, было ли какое-либо движение на игровом поле. Если да, то мы:
1. Добавим новые плитки на поле
2. Проверим, не пора ли показать экран с надписью «игра окончена»
Обновление плиток
После совершения хода нужно очистить все флажки объединения у плиток. Для этого мы в начале каждого хода вызываем
Метод prepareTiles() в GridService проходит по всем плиткам и обнуляет их статус.
Подсчёт очков
1. Текущего счёта игры
2. Таблицу рекордов
currentScore — обычная временная переменная, содержащая текущий счёт для игры. А переменная highScore должна сохраняться между играми. Этого можно достичь через локаьное хранилище, куки, или через комбинацию этих методов. Но мы остановимся на использовании куков. А для этого проще всего воспользоваться модулем angular-cookies .
Его можно скачать с angularjs.org или установить через менеджер модулей типа bower:
Как обычно, на него надо сослаться из index.html и включить его в зависимости как ngCookies. Запишем в app/index.html:
Теперь, чтобы добавить модуль ngCookies в подгрузку:
После этого можно делать инъекцию сервиса $cookieStore в сервис GameManager. И у нас появится возможность ставить и считывать куки в браузере. Например, для извлечения последнего достижения пользователя используется следующая функция:
Возвращаясь к методу updateScore() из класса GameManager, мы обновим текущий счёт. Если счёт выше, чем предыдущий счёт из таблицы рекордов, мы запишем куку для этой таблицы на будущее.
Проблемы с track by
Теперь, когда плитки возникают на нашем экране, у нас происходит какое-то непредсказуемое поведение плиток, которые то дублируются, то появляются в неожиданных местах. А всё оттого, что Angular знает, какие плитки содержатся в массиве плиток, на основании уникального идентификатора. Мы задаём его в Виде как $index в массиве плиток. А поскольку мы перемещаем плитки по массиву, $index уже не справляется с отслеживанием плиток. Нам нужна другая система для их отслеживания.
Вместо хранения места плитки в массиве, мы будем остлеживать их через уникальные uuid. Создание такого идентификатора гарантирует их уникальность внутри angular. Эту систему легко реализовать внутри TileModel при создании нового экземпляра. Неважно, каким образом будут создаваться уникальные идентификаторы, пока они действительно уникальны.
Задав вопрос на StackOverflow, мы познакомились с rfc4122 и универсальными уникальными идентификаторами, создание которых мы поместили в метод next():
Для использования фабрики мы совершаем инъекцию и вызываем GenerateUniqueId.next():
И теперь мы предлагаем Angular отслеживать плитку по её уникальному id:
Есть только одна проблемка. Так как мы заполнили массив нулями, Angular будет отслеживать эти нулевые объекты. А поскольку у них нет уникальных id, браузер выдаст ошибку. Поэтому мы должны использовать встроенный инструмент angular, чтобы отслеживать объекты либо по id, либо по позиции в массиве. Вид grid_directive можно поменять, приспособив его к нулевым объектам, следующим образом:
Победили? Конец игры
В оригинале при проигрыше в игре 2048 экран уезжает и предлагает начать заново. Мы можем воссоздать этот клёвый эффект через простые техники Angular. Создадим объект div, содержащий наш экран «игра окончена» с абсолютным расположением поверх игрового поля, и будем показывать его с сообщением, зависящим от того, выиграли мы или проиграли.
Некоторые выдержки из CSS для абсолютного позиционирования элемента (полные стили есть на github):
В случае выигрыша делаем то же самое, но только создаём элемент .game-overlay.
Анимация
Одной из приятных особенностей оригинальной игры 2048 была анимация плиток, которые наезжали друг на друга и анимация экрана, который появлялся по окончанию игры. Мы можем достичь этих эффектов через CSS.
Мы проектировали игру таким образом, что эти эффекты будет легко реализовать, практически без использования JS.
Анимируем позиционирование через CSS
Поскольку мы размещаем плитки через класс position-[x]-[y], то при назначении новой позиции к элементу DOM будет добавлен класс position-[newX]-[newY] и удалён класс position-[oldX]-[oldY]. Поэтому перемещение можно задать при помощи transition через класс .tile CSS. Соответствующий SCSS выглядит так:
После этого плитки будут красиво скользить с одного места на другое. Реально просто.
Анимация экрана об окончании игры
Более модной анимации можно добиться при помощи модуля ngAnimate, который работает без дополнительных настроек.
Как обычно, ставим ссылку на модуль из HTML, чтобы браузер мог подгрузить его. Добавим в index.html следующее:
И, как обычно, необходимо добавить зависимость от модуля. Добавим в массив зависимостей в файле app/app.js:
ngAnimate
Не вдаваясь в детали работы модуля, рассмотрим некоторые приёмы его использования.
При включённом в зависимости модуле ngAnimate, каждый раз при добавлении нового объекта ему назначается CSS-класс. Эти классы можно использовать для назначения анимаций разным игровым компонентам.
Directive | Added class | Leaving class |
ng-repeat | ng-enter | ng-leave |
ng-if | ng-enter | ng-leave |
ng-class | [className]-add | [className]-remove |
При добавлении элемента в область видимости ng-repeat ему будет назначен класс ng-enter. Затем при добавлении его в Вид будет добавлен класс ng-enter-active. Это позволит нам определять анимации в классе ng-enter, и присваивать анимациям стиль в классе ng-enter-active. Так же это работает и в случае класса ng-leave, когда элементы удаляются из итератора ng-repeat.
Когда новый CSS-класс добавляется (или удаляется) из элемента DOM, соответствующие классы [classname]-add и [classname]-add-active добавляются также.
Анимация экрана об окончании игры
Анимировать экраны мы будем при помощи класса ng-enter. Помните, что класс .game-overlay скрывается и появляется при использовании директивы ng-if. Когда условия ng-if меняются, ngAnimate добавит .ng-enter и .ng-enter-active в случае, когда результат выражения true (или .ng-leave и .ng-leave-active при удалении элемента).
Анимацию мы настроим в классе .ng-enter, а запустим в классе .ng-enter-active class. SCSS:
Изменение размеров игрового поля
Допустим, нам захочется поменять размер игрового поля. Вместо оригинального 4х4 мы захотим сделать 3х3 или 6х6. Это легко сделать без изменения большей части кода.
Поле создаётся через SCSS, а сетка обрабатывается в GridService. В этих двух местах и надо будет вносить изменения.
Динамический CSS
На самом деле мы не будем создавать динамический CSS, но мы можем создать больше CSS, чем нам понадобится. Вместо создания одного тега #game мы можем динамически создать тэг элемента, через который сетка будет настраиваться на лету. Иначе говоря, мы создадим вариант игрового поля 3х3, находящийся внутри элемента с id #game-3, и вариант 6х6 внутри элемента с id #game-6
Мы сделаем mixin из уже динамического SCSS – просто найдём тег #game и завернём его в mixin. Например:
Теперь можно включать mixin для динамического создания таблицы стилей, содержащей разные версии игровой доски, каждая из которых будет изолирована через #game-[n] tag.
Чтобы построить несколько вариантов игровых полей, мы просто проходим циклом по игровым полям, которые нам нужны, и вызываем mixin.
Динамический сервис GridService
Имея несколько игровых полей, необходимо переделать GridService, чтобы можно было задавать размер игрового поля при старте. Это просто.
Сперва нам надо поменять логику так, чтобы GridService стал провайдером, а не прямым сервисом. Кратко говоря, провайдер отличается от сервиса тем, что его можно настраивать перед стартом. А ещё нам нужно поменять функцию конструктора, чтобы она была задана как метод $get провайдера:
Методы, не заданные внутри $get, доступны через функцию .config(). Всё, что внутри $get(), доступно в работающем приложении, но не в .config().
Вот и всё, что нужно для возможности динамически задавать размер игрового поля. Если, скажем, нам надо сделать поле 6х6 вместо 4х4, то в функции .config() мы можем вызвать GridServiceProvider и поменять там размер:
При создании провайдера Angular автоматически создаёт модуль config-time, который можно ввести под именем [serviceName]Provider.
Исходники
Полный исходный код доступен на Github d.pr/pNtX. Для локального построения исходников клонируйте их и запустите:
А вот как можно легко получить свежую версию node:
Редакторский дайджест
Присылаем лучшие статьи раз в месяц
Скоро на этот адрес придет письмо. Подтвердите подписку, если всё в силе.
Похожие публикации
Игра 2048 в Wolfram Mathematica
Простая стратегия игры 2048
Курсы
AdBlock похитил этот баннер, но баннеры не зубы — отрастут
Комментарии 15
Оу, спасибо! Обожаю 2048 и её вариации.
В принципе, если хорошо играть в классическую 2048, то объёмная версия даже проще — подтягивать циферки можно с дополнительных направлений.
Зачем ждать, когда можно играть?
А когда захочется еще больше размерностей:
В целом. Если такую игру с помощью angular нельзя сделать проще то значит в angular.
Но как я уже написал выше, у меня сомнения на счёт классов, а значит SASS можно было бы в принципе убрать. JS при этом не сильно бы раздулся.
Сейчас глянул код детальнее и понял, что попытка для игры использовать абстракции angular вот главная проблема. К примеру GenerateUniqueId это уже решение проблем которые сами и создали.
Кроме того, я бы в принципе не разделял бы ячейки и плитки. Если в ячейке 0 значит она пустая, и отказался бы от логики «перемещения плиток» (не для эффекта конечно).
Но это всё поверхностно, единственное в чём я теперь уверен это то, что можно было бы сделать игру гораздо проще.
Вы правы, эту игру действительно можно сделать проще используя angular.
Во-первых, автор оригинала построил очень странную ООП систему.
Я все никак не пойму, почему вдруг GameManager ответствует за move(), когда эта обязанность ложится на саму плитку.
Сама игра, то есть Game не должна быть синглтоном, в отличае, например, от ее настроек, так как кнопка New Game сама по себе подсказывает о создании нового инстанса игры, чистого, по существующему конфигу.
Информация о Score и high score должна лежать в отдельном объекте score, который может быть синглтоном из-за своей специфики.
Хранение в объекте грида массива с плитками вообще кажется мне диким, для этого используют репозитории, и да Вы правы, можно было бы не разделять ячейки и плитки в два разных массива, а используя один репозиторий иметь коллекцию данных в двумерном массиве.
Ну и в конце концов при перемещении плитки ее можно было бы мержить с пустой ячейкой, это легче спроектировать и поддерживать, чем разработать логику проверок и логику слияния при не пустом значении.
И так далее.
Что же касается GenerateUniqueId — это нормальная практика в системах где для некого объекта необходимо иметь один и только один глобальный идентификатор, обозначающий не только его номер в БД / Порядковый номер / Номер элемента в порядке появления, но и саму принадлежность, для избежания пересечений с другими идентификаторами других объектов системы. Подробнее тут — ru.wikipedia.org/wiki/GUID. Собственно, из за этой заметки, и заметке о создании фабрик/провайдеров и их отличие от обычных service, статья и попала ко мне в избранное. Остальное является, как мне кажется, пробой пера автора оригинальной статьи.
Так что ангуляр тут совершенно не причем, а вот у автора есть определенный ряд проблем с ООП и MVC.
Источник статьи: http://habr.com/ru/post/249371/