[Перевод] Делаем игру 2048 на AngularJS

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

Первые шаги: планируем приложениеimageПервый шаг — разработка высокоуровневого дизайна приложения. Мы делаем это для любых приложений, неважно, клонируем ли существующее приложение или пишем своё с нуля.

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

Использование CSS3 позволяет нам оставить анимацию для CSS, и использовать поведение AngularJS по умолчанию для отслеживания состояния доски, плиток и логики игры. Так как у нас одна страница, нам понадобится один контроллер.

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

Игровая логика будет хранится и обрабатываться в другом сервисе под названием GameManager. Он будет в ответе за управление состоянием игры, обработку ходов и хранение очков (текущего достижения и таблицы рекордов).

И наконец нам понадобится компонент для обслуживания клавиатуры. Это будет сервис KeyboardService. В этой статье мы реализуем работу десктопного приложения, но его можно будет легко переделать для сенсорных экранов.

Построение приложения imageСоздадим простое приложение (мы использовали генератор приоложений yeoman, но это необязательно). Создадим каталог приложения, в котором оно будет хранится. Каталог test/ будет находится рядом с каталогом app/.

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

Сначала убедимся, что yeoman установлен. Для этого должны быть установлены NodeJS и npm. После этого нужно установить утилиту yeoman под названием yo, и генератор для angular (который будет использован утилитой для создания приложения):

$ npm install -g yo $ npm install -g generator-angular После этого можно создавать приложение через утилиту yo:

$ cd ~/Development && mkdir 2048 $ yo angular twentyfourtyeight Нужно будет утвердительно ответить на все вопросы, кроме «select the angular-cookies as a dependency», поскольку нам они не нужны.

Наш модуль angular Создадим файл приложения scripts/app.js. Начнём приложение: angular.module ('twentyfourtyeightApp', []) Модульная структура imageРекомендуется строить структуру приложения по функционалу, а не по типам. То есть, не разделять компоненты, как принято, по контроллерам, сервисам, директивам. К примеру, в нашем приложении мы определим модуль Game и модуль Keyboard.

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

Вид Проще всего начать приложение с Вида. В данном случае у нас есть только один вид/шаблон. Поэтому мы создадим единственный

, содержащий всё приложение.В файл app/index.html file нужно включить все зависимости (и наш файл scripts/app.js и angular.js):

2048

После настройки файла app/index.html все остальные изменения внешнего вида мы будем проводить в файле app/views/main.html. Изменять index.html придётся только при импорте нового ресурса в приложение.

В файл app/views/main.html поместим все виды, относящиеся к игре. Через синтаксис controllerAs можно задать, где будут находится данные по нашему $scope, и какой контроллер отвечает за какой из компонентов.

Синтаксис controllerAs — это новинка версии 1.2. С его помощью проще работать со многими контроллерами на странице. В нашем виде, как минимум необходимо задать несколько вещей:

1. Статический заголовок игры2. Текущий счёт и таблицу рекордов3. Игровое поле

Статический заголовок:

ng-2048

{{ ctrl.game.currentScore }}
{{ ctrl.game.highScore }}
Обратите внимание, что мы упоминаем GameController вместе с currentScore и highScore. Синтаксис controllerAs позволяет упоминать тот конкретный контроллер, который нам нужен.

GameController Определив структуру приложения, можно создать GameController для хранения величин, которые появятся в Виде. Внутри app/scripts/app.js создадим контроллер на главном модуле twentyfourtyeightApp: angular .module ('twentyfourtyeightApp', []) .controller ('GameController', function () { }); В Виде мы упоминали объект game, который будет управляться контроллером GameController. Этот игровой объект будет создан в новом модуле, и будет содержать все необходимые ссылки на игру. Но пока его нет, приложение не запустится. В контроллере мы можем добавить зависимость от GameManager:

.controller ('GameController', function (GameManager) { this.game = GameManager; }); Помните, что мы создаём зависимость уровня модулей, поэтому, чтобы убедиться, что она загрузится приложением, её надо внести в список зависимостей модуля Angular. Чтобы сделать модуль Game зависимостью для twentyfourtyeightApp, его надо упомянуть в массиве, в котором мы определяем модуль.

Целиком файл app/scripts/app.js выглядит так:

angular .module ('twentyfourtyeightApp', ['Game']) .controller ('GameController', function (GameManager) { this.game = GameManager; }); Game Мы почти подключили Вид, теперь можем заняться и логикой игры. Создадим модуль игры как app/scripts/game/game.js: angular.module ('Game', []); Лучше создавать модуль в своём собственном каталоге с именем, совпадающим с именем модуля. Наш модуль Game обеспечивает один основной компонент: GameManager.

GameManager будет отвечать за хранение состояния игры, возможные ходы, текущий счёт, определение окончания игры и её результат. Я рекомендую test driven development, когда сначала пишется заглушка для нужного модуля, затем пишется тест для него, и потом уже заполняются пустые места.

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

Итак, функции GameManager:

1. Создание игры2. Обновление счёта3. Отслеживание, не закончилась ли игра

Основная структура сервиса, для которой можно будет написать тесты, будет такой:

angular.module ('Game', []) .service ('GameManager', function () { // Создать новую игру this.newGame = function () {}; // Обработка хода this.move = function () {}; // Обновление очков this.updateScore = function (newScore) {}; // Остались ли ещё ходы? this.movesAvailable = function () {}; }); Test Driven Development (TDD) imageПеред созданием тестов необходимо установить модуль karma. Это окружение для запуска тестов, позволяющее автоматизировать тесты фронтенда, запуская их из терминала и кода.

Для установки наберите:

$ npm install -g karma Если вы создавали предложение через yeoman, то следующую часть можно пропустить.

Для использования karma нужно создать файл с настройками. Основная часть процесса настройки — загрузить все файлы, которые нужно тестировать.

Для создания файла конфигурации нужно выполнить команду

$ karma init karma.conf.js Затем отредактировать массив файлов и включить опцию autoWatch:

// … files: [ 'app/bower_components/angular/angular.js', 'app/bower_components/angular-mocks/angular-mocks.js', 'app/bower_components/angular-cookies/angular-cookies.js', 'app/scripts/**/*.js', 'test/unit/**/*.js' ], autoWatch: true, // … Тесты будут храниться в каталоге test/unit. Для прогона тестов мы воспользуемся командой:

$ karma start karma.conf.js Пишем первые тесты Напишем тест проверки на оставшиеся ходы. В новом файле test/unit/game/game_spec.js запишем: describe ('Game module', function () { describe ('GameManager', function () { // Инъекция модуля Game в тест beforeEach (module ('Game'));

// Ниже пойдут наши тесты }); }); В этом тесте используется синтаксис Jasmine.jasmine.github.io/2.0/introduction.html

Как и для других юнит-тестов, нам надо создать экземпляр GameManager. Обычным способом для тестов является иньекция его в тест:

// … // Инъекция модуля Game в тест beforeEach (module ('Game'));

var gameManager; // instance of the GameManager beforeEach (inject (function (GameManager) { gameManager = GameManager; });

// … С этим экземпляром gameManager можно настроить ожидания нашей функции movesAvailable (). Определим её как метод, который проверяет, есть ли ещё пустые квадратики, а также возможны ли ещё какие-либо объединения плиток. Поскольку это является условием для окончания игры, мы оставим этот метод в GameManager, а все подробности реализуем в GridService, который мы создадим чуть позже.

Если на доске остались ходы, это значит, что:

1. Есть пустые места2. Есть возможности для объединения плиток

Идея в том, чтобы писать тесты этих условий так, что мы создаём необходимое условие и проверяем реакцию кода на него. Поскольку мы будем полагаться на отчёты о состоянии игрового поля только от GridService, мы сымитируем состояние, и проверим, что логика GameManager работает правильно.

Имитация GridService Для этого мы переопределим поведение Angular по умолчанию и вместо настоящего сервиса подсунем имитацию, что позволит нам контролировать условия. Для этого мы создадим ложный объект с имитирующими методами, и затем скажем Angular, что этот объект — настоящий, подменив его в сервисе $provide. // … var _gridService; beforeEach (module (function ($provide) { _gridService = { anyCellsAvailable: angular.noop, tileMatchesAvailable: angular.noop };

// Подменим настоящий GridService // ненастоящей версией $provide.value ('GridService', _gridService); })); // … Теперь поддельный _gridService можно использовать, чтобы задать нужные условия. Убедимся, что функция movesAvailable () выдаёт true, когда есть свободные ячейки. Сымитируем метод anyCellsAvailable () (который ещё не написан) в сервисе GridService. Метод должен сообщать о свободных ячейках в GridService.

// … describe ('.movesAvailable', function () { it ('should report true if there are cells available', function () { spyOn (_gridService, 'anyCellsAvailable').andReturn (true); expect (gameManager.movesAvailable ()).toBeTruthy (); }); // … Фундамент заложен, теперь можно заняться вторым условием. Если есть возможные плитки для слияния, то нам надо убедиться, что функция movesAvailable () возвращает true. Надо убедиться и в обратном — что невозможно сделать ход, если нет свободных ячеек и подходящих плиток.

Два другие теста, подтверждающие это:

// … it ('должна возвращать true, если есть подходящие ячейки и плитки», function () { spyOn (_gridService, 'anyCellsAvailable').andReturn (false); spyOn (_gridService, 'tileMatchesAvailable').andReturn (true); expect (gameManager.movesAvailable ()).toBeTruthy (); }); it ('должна возвращать false, если нет подходящих ячеек и плиток», function () { spyOn (_gridService, 'anyCellsAvailable').andReturn (false); spyOn (_gridService, 'tileMatchesAvailable').andReturn (false); expect (gameManager.movesAvailable ()).toBeFalsy (); }); // … Теперь можно писать тесты, несмотря на то, что основное поведение приложения ещё не реализовано.

Вернёмся к GameManager Теперь нам надо реализовать функцию movesAvailable (). Мы уже можем протестировать работу кода, и уже определили те условия, при которых он работает, поэтому написать функцию довольно легко: // … this.movesAvailable = function () { return GridService.anyCellsAvailable () || GridService.tileMatchesAvailable (); }; // … Строим игровую сетку После создания GameManager нам нужно создать GridService, который будет обрабатывать все состояния игрового поля. Мы хотели делать это с помощью двух массивов — основной сетки и плиток. Зададим GridService две локальные переменные в файле app/scripts/grid/grid.js: angular.module ('Grid', []) .service ('GridService', function () { this.grid = []; this.tiles = []; // Размер доски this.size = 4; // … }); Для старта игры нужно будет заполнить эти массивы нулями. Сетка статичная, а для заполнения её плитками используются только элементы DOM. Массив плиток, наоборот, динамический.

В файле app/views/main.html нужно отобразить решётку. Логично будет расположить её в отдельной директиве. Использование директивы позволяет не раздувать главный шаблон и инкапсулировать функциональность.

В файле app/index.html расположим директиву сетки и передадим ей экземпляр GameManager через контроллер:

Эта директива будет расположена в модуле Grid, поэтому создадим для неё файл app/scripts/grid/grid_directive.js.

Директива должна содержать экземпляр GameManager (или, как минимум, модель, содержащую массивы сетки и плиток), это будет обязательным условием. Кромет ого, директива не должна обращаться к остальной части страницы или к экземпляру GameManager на странице, поэтому мы создадим её в изолированной области видимости.

angular.module ('Grid') .directive ('grid', function () { return { restrict: 'A', require: 'ngModel', scope: { ngModel: '=' }, templateUrl: 'scripts/grid/grid.html' }; }); Основная задача директивы — настроить вид сетки, поэтому настраиваемая логика в ней нам не нужна.

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. Его можно двигать, корёжить, вращать, масштабировать и т.д. (и анимировать всё это дело). Мы можем просто разместить плитку на доске и применить к ней нужную трансформацию.К примеру, у нас есть квадрат ширины 40 px и высоты 40 px.

.box { width:40 px; height:40 px; background-color: blue; } Применяя свойство transform translateX (300 px) мы передвинем его на 300 px вправо.

.box.transformed { -webkit-transform: translateX (300 px); transform: translateX (300 px); } Свойство translate позволяет передвигать плитки по доске просто применяя к ним стили. Но как построить динамические классы в соответствии с расположением клеток на сетке, когда мы меняем положение элементов на странице?

И тут на белом коне въезжает SCSS. Зададим несколько переменных (сколько плиток в ряд, и т.д.) и построим наш SCSS вокруг них. Вот, какие переменные нам нужны:

$width: 400 px; // Ширина доски $tile-count: 4; // Количество плиток по горизонтали и вертикали $tile-padding: 15 px; // Отступы между ними С их помощью мы даём SCSS возможность динамически рассчитывать позицию элемента. Сперва рассчитаем размер, что довольно тривиально:

$tile-size: ($width — $tile-padding * ($tile-count + 1)) / $tile-count; Теперь настроим контейнер #game, задав ему нужную высоту и ширину. Также мы убедимся, что сможем задавать абсолютные позиции элементов внутри него. Элементы .grid-container и .tile-container уютно разместятся внутри объекта #game.

Здесь — только цитаты из scss, остальное можно найти в нашем проекте на github (ссылка в конце статьи).

#game { position: relative; width: $width; height: $width; // Доска квадратная

.grid-container { position: absolute; // позиционирование absolute z-index: 1; // важно задать z-index для корректной работы слоёв margin: 0 auto; // центрируем

.grid-cell { width: $tile-size; // ширина ячейки height: $tile-size; // высота ячейки margin-bottom: $tile-padding; // отступ снизу margin-right: $tile-padding; // отступ справа // … } } .tile-container { position: absolute; z-index: 2;

.tile { width: $tile-size; // ширина плитки height: $tile-size; // высота плитки // … } } } Чтобы .tile-container расположился над .grid-container, необходимо задать z-index выше, чем у .tile-container. Если этого не сделать, они будут сидеть на одном уровне и выглядеть это будет криво

Теперь можно динамически позиционировать плитки. Нам нужно назначить класс .position-{x}-{y} каждой плитке. Начальной позицией первой плитки будет 0,0.

.tile { // … // Обходим позиции и создаём классы.position-#{x}-#{y}, // располагая таким образом плитки @for $x from 1 through $tile-count { @for $y from 1 through $tile-count { $zeroOffsetX: $x — 1; $zeroOFfsetY: $y — 1; $newX: ($tile-size) * ($zeroOffsetX) + ($tile-padding * $zeroOffsetX); $newY: ($tile-size) * ($zeroOffsetY) + ($tile-padding * $zeroOffsetY);

&.position-#{$zeroOffsetX}-#{$zeroOffsetY} { -webkit-transform: translate ($newX, $newY); transform: translate ($newX, $newY); } } } // … } Вычисления отступов начинаются с 1, а не с 0 из-за ограничений SASS. Мы просто вычитаем 1 из индекса. Динамически раздав всем позиции, мы можем выводить плитки на экран.

image

Раскрашиваем различные плитки Цвета плиток зависят от содержащегося в них значения, это сделано для удобства игрока. Мы сделаем это в цикле, похожем на тот, что раздавал плиткам позиции, только теперь он будет назначать им цвета. Для этого создадим массив SCSS: $colors: #EEE4DA, // 2 #EAE0C8, // 4 #F59563, // 8 #3399ff, // 16 #ffa333, // 32 #cef030, // 64 #E8D8CE, // 128 #990303, // 256 #6BA5DE, // 512 #DCAD60, // 1024 #B60022; // 2048 Теперь мы пройдём по цветам и создадим для каждого класс — для плитки 2 класс будет .tile-2, и так далее. Вместо того, чтобы захардкодить эти классы, мы воспользуемся магией SCSS:

@for $i from 1 through length ($colors) { &.tile-#{power (2, $i)} .tile-inner { background: nth ($colors, $i) } } Конечно, нужно задать миксин power ():

@function power ($x, $n) { $ret: 1;

@if $n >= 0 { @for $i from 1 through $n { $ret: $ret * $x; } } @else { @for $i from $n to 0 { $ret: $ret / $x; } }

@return $ret; } Директива Tile Т.к. директива — это контейнер для Вида, работы нужно немного. Надо будет настроить доступ к ячейке, которую она должна выводить. angular.module ('Grid') .directive ('tile', function () { return { restrict: 'A', scope: { ngModel: '=' }, templateUrl: 'scripts/grid/tile.html' }; }); Интересная часть директивы tile — как мы динамически располагаем сетку. Это делается в шаблоне через переменную ngModel, находящуюся в изолированной области видимости. Как видно выше, она ссылается на объект tile из массива tiles:

{{ ngModel.value }}
Вот и почти всё готово для вывода на страницу. Все плитки с координатами x и y автоматом получат классы .position-#{x}-#{y} и будут правильно расставлены самим браузером. Значит, для объекта tile необходимы x, y и значение для работы директивы. То есть, для каждой плитки нужно создать новый объект.

TileModel Создадим не просто какой-то тупой объект, а вовсе даже умный, содержащий не только данные, но и функциональность.Чтобы иметь возможность использовать инъекцию зависимостей Angular, мы создадим сервис, содержащий модель данных. Сервис TileModel будет в модуле Grid, так как он нужен только для низкого уровня работы игрового поля.

Через метод .factory мы можем создать функцию, которую мы назначим фабрике. В отличие от функции service (), которая предполагает, что используемая функция определяет сервис в конструкторе этого сервиса, метод factory () назначает сервисный объект возвращаемым значением функции. Поэтому через метод factory () мы можем назначить объект как сервис, чтобы вставлять его в наши приложения.

В файле app/scripts/grid/grid.js создадим фабрику TileModel:

angular.module ('Grid') .factory ('TileModel', function () { var Tile = function (pos, val) { this.x = pos.x; this.y = pos.y; this.value = val || 2; };

return Tile; }) // … Теперь мы можем где угодно сделать инъекцию TileModel и использовать её, словно глобальный объект. Удобство и комфорт.

Не забудьте написать тесты для TileModel.

Наша первая сетка Теперь у нас есть TileModel, и мы можем размещать её экземпляры в массиве плиток, после чего они волшебным образом появятся на нужных местах решётки. angular.module ('Grid', []) .factory ('TileModel', function () { // … }) .service ('GridService', function (TileModel) { this.tiles = []; this.tiles.push (new TileModel ({x: 1, y: 1}, 2)); this.tiles.push (new TileModel ({x: 1, y: 2}, 2)); // … }); Игровое поле готово для игры Теперь создадим функциональность игрового поля в GridService. При первой загрузке страницы необходимо создать пустое игровое поля, и делать это после каждого нажатия кнопок «Новая игра» и «Попробовать снова». Для очистки поля используется функция buildEmptyGameBoard () в нашем GridService. Этот метод будет заполнять нулями сетку и массив плиток.Перед кодом давайте напишем тест проверки функции buildEmptyGameBoard ().

// В это время в файле test/unit/grid/grid_spec.js // … describe ('.buildEmptyGameBoard', function () { var nullArr;

beforeEach (function () { nullArr = []; for (var x = 0; x < 16; x++) { nullArr.push(null); } }) it('должен заполнить сетку нулями’, function() { var grid = []; for (var x = 0; x < 16; x++) { grid.push(x); } gridService.grid = grid; gridService.buildEmptyGameBoard(); expect(gridService.grid).toEqual(nullArr); }); it(''должен заполнить плитки нулями’, function() { var tiles = []; for (var x = 0; x < 16; x++) { tiles.push(x); } gridService.tiles = tiles; gridService.buildEmptyGameBoard(); expect(gridService.tiles).toEqual(nullArr); }); }); Сама функция будет небольшой, а располагаться будет в app/scripts/grid/grid.js

.service ('GridService', function (TileModel) { // … this.buildEmptyGameBoard = function () { var self = this; // Initialize our grid for (var x = 0; x < service.size * service.size; x++) { this.grid[x] = null; }

// Инициализация массива кучкой нулевых объектов this.forEach (function (x, y) { self.setCellAt ({x: x, y: y}, null); }); }; // … Код использует несколько вспомогательных методов. Вот ещё несколько таких функций:

// Запускать для каждого элемента массива плиток this.forEach = function (cb) { var totalSize = this.size * this.size; for (var i = 0; i < totalSize; i++) { var pos = this._positionToCoordinates(i); cb(pos.x, pos.y, this.tiles[i]); } };

// Установить ячейку на позиции this.setCellAt = function (pos, tile) { if (this.withinGrid (pos)) { var xPos = this._coordinatesToPosition (pos); this.tiles[xPos] = tile; } };

// Запросить ячейку с позиции this.getCellAt = function (pos) { if (this.withinGrid (pos)) { var x = this._coordinatesToPosition (pos); return this.tiles[x]; } else { return null; } };

// Находится ли позиция в границах нашей сетки this.withinGrid = function (cell) { return cell.x >= 0 && cell.x < this.size && cell.y >= 0 && cell.y < this.size; }; Что это значит А что это за функции this._positionToCoordinates() и this._coordinatesToPosition()?Вспомните, что мы обсуждали использование одномерного массива для раскладки нашей сетки. И с точки зрения быстродействия, и с точки зрения анимации этот вариант предпочтительнее.

Многомерный массив в одном измерении Как можно представить многомерный массив в одном измерении? Посмотрим на сетку, представляющую игровое поле без цветов, где значениями ячеек являются их координаты. Двумерный массив в коде представляется как массив массивов.image

Однако, поле можно представить и в одном измерении. Ячейка 0,0 находится в одномерном массиве по адресу 0. Следующая, 1,0, добавляет 1 к позиции. И так далее.

image

Экстраполируя систему, мы можем видеть, что уравнение, описывающее связь между двумя системами координат, выглядит так:

i = x + ny i — индекс ячейки, x и y — координаты на двумерном игровом поле, n — количество ячеек в строке/столбце. Две вспомогательные функции занимаются преобразованием этих координат. Концептуально легче иметь дело с x и y, но в смысле реализации лучше использовать одномерный массив.

// Преобразование x в x, y this._positionToCoordinates = function (i) { var x = i % service.size, y = (i — x) / service.size; return { x: x, y: y }; };

// Преобразование координат в индекс this._coordinatesToPosition = function (pos) { return (pos.y * service.size) + pos.x; }; Начальная позиция В начале игры необходимо расположить некоторые стартовые плитки. Мы сделаем это случайным образом. .service ('GridService', function (TileModel) { this.startingTileNumber = 2; // … this.buildStartingPosition = function () { for (var x = 0; x < this.startingTileNumber; x++) { this.randomlyInsertNewTile(); } }; // ... Построение начальной позиции простое, оно вызывает только функцию randomlyInsertNewTile() для тех плиток, которые мы желаем разместить. Функция предполагает, что мы знаем обо всех возможных местах положения для плиток. Она просто проходит по массиву и ведёт учёт тех мест, где ещё нет плитки.

.service ('GridService', function (TileModel) { // … // Получить все возможные плитки this.availableCells = function () { var cells = [], self = this;

this.forEach (function (x, y) { var foundTile = self.getCellAt ({x: x, y: y}); if (! foundTile) { cells.push ({x: x, y: y}); } });

return cells; }; // … Мы можем просто выбирать случайные координаты из массива. Этим занимается функция randomAvailableCell (). Вот один из возможных вариантов реализации:

.service ('GridService', function (TileModel) { // … this.randomAvailableCell = function () { var cells = this.availableCells (); if (cells.length > 0) { return cells[Math.floor (Math.random () * cells.length)]; } }; // … После этого мы создаём экземпляр TileModel и вставляем его в массив this.tiles.

.service ('GridService', function (TileModel) { // … this.randomlyInsertNewTile = function () { var cell = this.randomAvailableCell (), tile = new TileModel (cell, 2); this.insertTile (tile); };

// Добавить плитку в массив this.insertTile = function (tile) { var pos = this._coordinatesToPosition (tile); this.tiles[pos] = tile; };

// Убрать плитку из массива this.removeTile = function (pos) { var pos = this._coordinatesToPosition (tile); delete this.tiles[pos]; } // … }); Теперь, благодаря Angular, наши плитки волшебным образом появятся на игровом поле.

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

Взаимодействие с клавиатурой В нашем проекте мы будем работать с клавиатурой и не коснёмся сенсорных экранов (каламбур). Но реализовать управление касаниями несложно.Игра управляется стрелками, или нажатиями w, s, d, a. Нам нужно, чтобы пользователь, находясь на странице, мог управлять игрой, без необходимости ставить фокус на каком-то элементе. Для этого необходимо назначить отслеживание событий на document. В Angular это будет сервис $document.

Использование сервиса позволит нам создать настраиваемые события, которые происходят по нажатию кнопок, которые мы вставим в объекты Angular.

Для начала создадим новый модуль Keyboard в файле app/scripts/keyboard/keyboard.js

// app/scripts/keyboard/keyboard.js angular.module ('Keyboard', []);

Как и для других скриптов, для этого нужно создать ссылку из index.html. Теперь наш список тегов script выглядит так:

Ну и как обычно, надо сообщить Angular, что новый модуль будет одной из зависимостей нашего приложения:

.module ('twentyfourtyeightApp', ['Game', 'Grid', 'Keyboard']) Теперь займёмся обработчиками событий.

// app/scripts/keyboard/keyboard.js angular.module ('Keyboard', []) .service ('KeyboardService', function ($document) {

// Инициализация обработчика нажатий this.init = function () { };

// Привяжем обработчики, которые будут вызваны // когда событие происходит this.keyEventHandlers = []; this.on = function (cb) { }; }); Функция init () обеспечит запуск KeyboardService, который будет отслеживать события клавиатуры и отфильтровывать ненужные. А для всех нужных мы отменим действие по умолчанию и передадим их в keyEventHandlers.

image

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

// app/scripts/keyboard/keyboard.js angular.module ('Keyboard', []) .service ('KeyboardService', function ($document) {

var UP = 'up', RIGHT = 'right', DOWN = 'down', LEFT = 'left';

var keyboardMap = { 37: LEFT, 38: UP, 39: RIGHT, 40: DOWN };

// Инициализация обработчика нажатий this.init = function () { var self = this; this.keyEventHandlers = []; $document.bind ('keydown', function (evt) { var key = keyboardMap[evt.which];

if (key) { // Нажата нужная клавиша evt.preventDefault (); self._handleKeyEvent (key, evt); } }); }; // … }); Каждый раз, когда клавиша из keyboardMap запускает событие, KeyboardService будет выполнять функцию this._handleKeyEvent. Она вызывает обработчик, зарегистрированный на эту кнопку, проходя в цикле все обработчики.

// … this._handleKeyEvent = function (key, evt) { var callbacks = this.keyEventHandlers; if (! callbacks) { return; }

evt.preventDefault (); if (callbacks) { for (var x = 0; x < callbacks.length; x++) { var cb = callbacks[x]; cb(key, evt); } } }; // ... А с другой стороны нужно просто передать функцию-обработчик в список обработчиков.

// … this.on = function (cb) { this.keyEventHandlers.push (cb); }; // … Использование сервиса Keyboard Теперь нам нужно запустить отслеживание клавиатурных нажатий после запуска игры. Так как мы построили его в виде сервиса, нужно сделать это внутри основного контроллера.image

Сначала надо вызвать init (), чтобы запустить отслеживание событий. Потом зарегистрировать хендлер функции, чтобы вызывать GameManager, который будет вызывать move ().

В GameController мы добавим функции newGame () и tartGame (). newGame () вызывает игровой сервис, создаёт новую игру и запускает обработчик клавиатуры. Создадим инъекцию модуля Keyboard как новую модульную зависимость для приложения:

angular.module ('twentyfourtyeightApp', ['Game', 'Keyboard']) // … Теперь можно вставить KeyboardService в GameController и запустить взаимодействие с пользователем. Сначала, метод newGame ():

// … (из предыдущего примера) .controller ('GameController', function (GameManager, KeyboardService) { this.game = GameManager;

// Создать новую игру this.newGame = function () { KeyboardService.init (); this.game.newGame (); this.startGame (); };

// … Пока мы не определили метод newGame () из GameManager, этим мы займёмся чуть позже.

После создании новой игры мы вызовем startGame (). Она настроит сервис обработчика клавиатуры:

.controller ('GameController', function (GameManager, KeyboardService) { // … this.startGame = function () { var self = this; KeyboardService.on (function (key) { self.game.move (key); }); };

// Создать новую игру при загрузке this.newGame (); }); Нажмите кнопку start Много же нам потребовалось работы для запуска игры! Последний метод, который надо сделать — newGame () из GameManager, который будет:1. строить пустое игровое поле2. назначать стартовые расположения3. инициализировать игру

Логика внутренностей GridService уже готова, осталось всё только подключить. В файле app/scripts/game/game.js добавим новую функцию newGame (). Она будет возвращать игру к первоначальному состоянию:

angular.module ('Game', []) .service ('GameManager', function (GridService) { // Создать новую игру this.newGame = function () { GridService.buildEmptyGameBoard (); GridService.buildStartingPosition (); this.reinit (); };

// Вернуть игру к первоначальному состоянию this.reinit = function () { this.gameOver = false; this.win = false; this.currentScore = 0; this.highScore = 0; // вернёмся сюда позже }; }); Загрузив это в браузере, мы получим работающую сетку. Но поскольку мы пока не сделали функциональность ходов, ничего интересного не происходит.

Основной игровой цикл imageТеперь перейдём к основной функциональности игры. После нажатия управляющих клавиш нужно вызвать функцию move () сервиса GridService (мы построим этот вызов внутри GameController).

Для этого нужно определить ограничения игры. То есть то, как игра реагирует на любой ход. Для каждого хода нужно:

1. Определить вектор движения, заданный клавишей2. Найти все наидальнейшие локации для каждой плитки. В это же время получить плитку из следующей локации, чтобы понять, можно ли будет объединять плитки.3. Для каждой плитки надо определить, есть ли для неё плитка следующего номинала

а) если нет, тогда мы просто перемещаем плитку на самую дальнюю позициюб) если есть, ив) значения плиток различаются, то мы перемещаем плитку на самую дальнюю позициюг) значения плиток одинаковые, мы можем объединять плитки— если эта плитка только что была объединена с какой-то ещё, тогда мы её пропускаем— если она на этом ходу ещё не объединялась, то мы делаем объединение

Таперича можно наметить и стратегию построения функции move ()

angular.module ('Game', []) .service ('GameManager', function (GridService) { // … this.move = function (key) { var self = this; // сохраним ссылочку на GameManager // тут определяем ход if (self.win) { return false; } }; // … }); Если игра окончилась, а мы застряли в игровом цикле — мы просто возвращаемся из него и идём дальше.

Затем нам надо пройти по сетке и найти все возможные позицию. Т.к. это обязанность сетки — знать, где у неё есть свободные места, мы создадим в GridService функцию, которая будет искать возможные пути для перемещения плиток.

Нам нужно определить вектор, обозначенный нажатием клавиши. К примеру, нажатие «вправо» перемещает плитки по увеличению координаты х. По нажатию «вверх» плитки перемещаются по уменьшению y.

image

Разметить вектора можно так:

// В `GridService` app/scripts/grid/grid.js var vectors = { 'left': { x: -1, y: 0 }, 'right': { x: 1, y: 0 }, 'up': { x: 0, y: -1 }, 'down': { x: 0, y: 1 } }; Теперь можно просто пройти циклом по возможным позициям, используя vectors для определения того, как именно совершается этот проход.

.service ('GridService', function (TileModel) { // … this.traversalDirections = function (key) { var vector = vectors[key]; var positions = {x: [], y: []}; for (var x = 0; x < this.size; x++) { positions.x.push(x); positions.y.push(x); } // Перестроимся, если идём вправо if (vector.x > 0) { positions.x = positions.x.reverse (); } // Перестроим позиции y, если идём вниз if (vector.y > 0) { positions.y = positions.y.reverse (); } return positions; }; // … Теперь с помощью traversalDirections () мы можем определять возможные перемещения внутри функции move (). В GameManager мы таким образом будем обходить сетку.

// … this.move = function (key) { var self = this; // определяем ход тут if (self.win) { return false; } var positions = GridService.traversalDirections (key);

positions.x.forEach (function (x) { positions.y.forEach (function (y) { // для каждой позиции }); }); }; // … Теперь внутри цикла position мы пройдём все возможные позиции и поищем, нет ли на них плиток. Затем перейдём ко второй части функциональности, и найдём все самые отдалённые возможные позиции для плиток.

// … // для каждой позиции // сохраняем изначальную позицию плитки var originalPosition = {x: x, y: y}; var tile = GridService.getCellAt (originalPosition);

if (tile) { // если здесь есть плитка var cell = GridService.calculateNextPosition (tile, key); // … } image

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

// внутри GridService // … this.calculateNextPosition = function (cell, key) { var vector = vectors[key]; var previous;

do { previous = cell; cell = { x: previous.x + vector.x, y: previous.y + vector.y }; } while (this.withinGrid (cell) && this.cellAvailable (cell));

return { newPosition: previous, next: this.getCellAt (cell) }; }; Теперь, когда мы можем подсчитать возможные позиции для перемещения плиток, можно проверять на возможные объединения плиток. Объединение — это столкновение двух плиток одинакового номинала. Мы проверяем, есть ли на позиции плитка этого же номинала, которая не была уже ранее объединена с какой-либо другой.

// … // для каждой позиции // сохранить изначальную позицию плитки var originalPosition = {x: x, y: y}; var tile = GridService.getCellAt (originalPosition);

if (tile) { // если тут есть плитка var cell = GridService.calculateNextPosition (tile, key), next = cell.next;

if (next && next.value === tile.value && ! next.merged) { // обработка объединения } else { // обработка перемещения } // … } Теперь, если следующая позиция не удовлетворяет условиям, мы просто перемещаем плитку из текущей.

// … if (next && next.value === tile.value && ! next.merged) { // обработка объединения } else { GridService.moveTile (tile, cell.newPosition); } Перемещение плитки Вы могли догадаться, что метод moveTile () лучше всего определять внутри GridService. Перемещение плитки — это просто изменение её положения в массиве и обновление TileModel. Когда мы передвигаем плитку в массиве, мы обновляем массив GridService (информация на бэкенде

© Habrahabr.ru