Pull to refresh

Делаем игру 2048 на AngularJS

Reading time32 min
Views49K
Original author: ng-newsletter
Наверное, вам, как и многим коллегам, пришлась по вкусу игра «2048», в которой необходимо достичь плитки с числом 2048, собирая вместе плитки с одинаковыми числами.

В этой статье мы вместе построим клон этой игры при помощи фреймворка AngularJS. По ссылке можно посмотреть демонстрацию конечного результата.


Первые шаги: планируем приложение


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):

<!-- index.html --> <doctype html> <html> <head> <title>2048</title> <link rel="stylesheet" href="styles/main.css"> </head> <body ng-app="twentyfourtyeightApp" <!-- header --> <div class="container" ng-include="'views/main.html'"></div> <!-- script tags --> <script src="bower_components/angular/angular.js"></script> <script src="scripts/app.js"></script> </body> </html>


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

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


<!-- app/views/main.html -->
<div id="content" ng-controller='GameController as ctrl'>
  <!—Теперь переменная: ctrl ссылается на GameController -->
</div>


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

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

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


<!—заголовок внутри app/views/main.html -->
<div id="content" ng-controller='GameController as ctrl'>
  <div id="heading" class="row">
    <h1 class="title">ng-2048</h1>
    <div class="scores-container">
      <div class="score-container">{{ ctrl.game.currentScore }}</div>
      <div class="best-container">{{ ctrl.game.highScore }}</div>
    </div>
  </div>
  <!-- ... -->
</div>


Обратите внимание, что мы упоминаем 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 через контроллер:

  <!-- instructions -->
  <div id="game-container">
    <div grid ng-model='ctrl.game' class="row"></div>
    <!-- ... -->


Эта директива будет расположена в модуле 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.


<div id="game">
  <div class="grid-container">
    <div class="grid-cell" 
      ng-repeat="cell in ngModel.grid track by $index">
      </div>
  </div>
  <div class="tile-container">
    <div tile 
      ng-model='tile'
      ng-repeat='tile in ngModel.tiles track by $index'>
    </div>
</div>
</div>


Первый 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.

.box { width:40px; height:40px; background-color: blue; }


Применяя свойство transform translateX(300px) мы передвинем его на 300px вправо.

.box.transformed { -webkit-transform: translateX(300px); transform: translateX(300px); }


Свойство translate позволяет передвигать плитки по доске просто применяя к ним стили. Но как построить динамические классы в соответствии с расположением клеток на сетке, когда мы меняем положение элементов на странице?

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

$width: 400px;          // Ширина доски
$tile-count: 4;         // Количество плиток по горизонтали и вертикали
$tile-padding: 15px;    // Отступы между ними


С их помощью мы даём 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:


<div ng-if='ngModel' class="tile position-{{ ngModel.x }}-{{ ngModel.y }} tile-{{ ngModel.value }}">
  <div class="tile-inner">
    {{ ngModel.value }}
  </div>
</div>


Вот и почти всё готово для вывода на страницу. Все плитки с координатами 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 выглядит так:

<!-- body -->
  <script src="scripts/app.js"></script>
  <script src="scripts/grid/grid.js"></script>
  <script src="scripts/grid/grid_directive.js"></script>
  <script src="scripts/grid/tile_directive.js"></script>
  <script src="scripts/keyboard/keyboard.js"></script>
  <script src="scripts/game/game.js"></script>
</body>
</html>


Ну и как обычно, надо сообщить 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 (информация на бэкенде). Положение плитки в массиве не привязано к положению на сетке.

Когда мы обновляем позицию в TileModel, мы обновляем координаты на фронтенде, с тем, чтобы CSS правильно расположили плитку на экране.

Итого, для отслеживания плиток на бэкенде нам надо обновлять массив this.tiles в GridService и обновлять позицию объекта плитки.

Потому moveTile() становится простой двухходовой операцией:

// GridService
// ...
this.moveTile = function(tile, newPosition) {
  var oldPos = {
    x: tile.x,
    y: tile.y
  };

  // Обновить позицию в массиве
  this.setCellAt(oldPos, null);
  this.setCellAt(newPosition, tile);
  // Обновить модель плитки
  tile.updatePosition(newPosition);
};


Теперь надо определить метод tile.updatePosition(), который обновляет координаты x и y модели.

.factory('TileModel', function() {
  // ...

  Tile.prototype.updatePosition = function(newPos) {
    this.x = newPos.x;
    this.y = newPos.y;
  };
  // ...
});


Внутри GridService можно просто вызвать .moveTile() для обновления позиции как в массиве GridService.tiles, так и позиции самой плитки.

Объединение плиток

Объединение происходит поэтапно:

1. Добавляем новую плитку на месте назначения, с новым номиналом
2. Удаляем старую плитку
3. Обновляем таблицу очков
4. Проверяем, не достигнут ли выигрыш

// ...
var hasWon = false;
// ...
if (next &&
    next.value === tile.value &&
    !next.merged) {
  // Объединение
  var newValue = tile.value * 2;
  // Создать новую плитку
  var mergedTile = GridService.newTile(tile, newValue);
  mergedTile.merged = [tile, cell.next];

  // Вставить новую плитку
  GridService.insertTile(mergedTile);
  // Удалить старую плитку
  GridService.removeTile(tile);
  // Сместить mergedTile на новую позицию
  GridService.moveTile(merged, next);
  // Обновить таблицу очков
  self.updateScore(self.currentScore + newValue);
  // Проверить на выигрыш
  if (merged.value >= self.winningValue) {
    hasWon = true;
  }
} else {
// ...


Поскольку мы поддерживаем только однократное объединение, нам надо хранить список плиток, которые уже были объединены. Для этого мы взводим флаг .merged.

Мы упомянули две ещё не определённые функции. Метод GridService.newTile() создаёт новый объект TileModel.

// GridService
this.newTile = function(pos, value) {
  return new TileModel(pos, value);
};
// ...


Мы скоро вернёмся к методу self.updateScore(). А пока скажем, что он обновляет таблицу очков.

После перемещения

Новые плитки добавляются только после допустимого перемещения.

var hasMoved = false;
// ...
  hasMoved = true; // переместились с объединением
} else {
  GridService.moveTile(tile, cell.newPosition);
}

if (!GridService.samePositions(originalPos, cell.newPosition)) {
  hasMoved = true;
}
// ...


После перемещения всех плиток надо проверить, не выиграли ли мы. Если игра окончена, то мы взводим флаг self.win. Осталось проверить, было ли какое-либо движение на игровом поле. Если да, то мы:

1. Добавим новые плитки на поле
2. Проверим, не пора ли показать экран с надписью «игра окончена»

if (!GridService.samePositions(originalPos, cell.newPosition)) {
  hasMoved = true;
}

if (hasMoved) {
  GridService.randomlyInsertNewTile();

  if (self.win || !self.movesAvailable()) {
    self.gameOver = true;
  }
}
// ...


Обновление плиток

После совершения хода нужно очистить все флажки объединения у плиток. Для этого мы в начале каждого хода вызываем

GridService.prepareTiles();


Метод prepareTiles() в GridService проходит по всем плиткам и обнуляет их статус.

this.prepareTiles = function() {
  this.forEach(function(x,y,tile) {
    if (tile) {
      tile.reset();
    }
  });
};


Подсчёт очков

Игре нужно вести подсчёт:

1. Текущего счёта игры
2. Таблицу рекордов

currentScore - обычная временная переменная, содержащая текущий счёт для игры. А переменная highScore должна сохраняться между играми. Этого можно достичь через локаьное хранилище, куки, или через комбинацию этих методов. Но мы остановимся на использовании куков. А для этого проще всего воспользоваться модулем angular-cookies .

Его можно скачать с angularjs.org или установить через менеджер модулей типа bower:

$ bower install --save angular-cookies


Как обычно, на него надо сослаться из index.html и включить его в зависимости как ngCookies. Запишем в app/index.html:


<script src="bower_components/angular-cookies/angular-cookies.js"></script>


Теперь, чтобы добавить модуль ngCookies в подгрузку:

angular.module('Game', ['Grid', 'ngCookies'])


После этого можно делать инъекцию сервиса $cookieStore в сервис GameManager. И у нас появится возможность ставить и считывать куки в браузере. Например, для извлечения последнего достижения пользователя используется следующая функция:

this.getHighScore = function() { 
  return parseInt($cookieStore.get('highScore')) || 0; 
}


Возвращаясь к методу updateScore() из класса GameManager, мы обновим текущий счёт. Если счёт выше, чем предыдущий счёт из таблицы рекордов, мы запишем куку для этой таблицы на будущее.

this.updateScore = function(newScore) {
  this.currentScore = newScore;
  if (this.currentScore > this.getHighScore()) {
    this.highScore = newScore;
    // Set on the cookie
    $cookieStore.put('highScore', newScopre);
  }
};


Проблемы с track by

Теперь, когда плитки возникают на нашем экране, у нас происходит какое-то непредсказуемое поведение плиток, которые то дублируются, то появляются в неожиданных местах. А всё оттого, что Angular знает, какие плитки содержатся в массиве плиток, на основании уникального идентификатора. Мы задаём его в Виде как $index в массиве плиток. А поскольку мы перемещаем плитки по массиву, $index уже не справляется с отслеживанием плиток. Нам нужна другая система для их отслеживания.


<div id="game">
  <!-- grid-container -->
  <div class="tile-container">
    <div tile 
      ng-model='tile'
      ng-repeat='tile in ngModel.tiles track by $index'></div>
  </div>
</div>


Вместо хранения места плитки в массиве, мы будем остлеживать их через уникальные uuid. Создание такого идентификатора гарантирует их уникальность внутри angular. Эту систему легко реализовать внутри TileModel при создании нового экземпляра. Неважно, каким образом будут создаваться уникальные идентификаторы, пока они действительно уникальны.

Задав вопрос на StackOverflow, мы познакомились с rfc4122 и универсальными уникальными идентификаторами, создание которых мы поместили в метод next():

.factory('GenerateUniqueId', function() {
  var generateUid = function() {
    // http://www.ietf.org/rfc/rfc4122.txt
    var d = new Date().getTime();
    var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
      var r = (d + Math.random()*16)%16 | 0;
      d = Math.floor(d/16);
      return (c === 'x' ? r : (r&0x7|0x8)).toString(16);
    });
    return uuid;
  };
  return {
    next: function() { return generateUid(); }
  };
})


Для использования фабрики мы совершаем инъекцию и вызываем GenerateUniqueId.next():

// Внутри app/scripts/grid/grid.js
// ...
.factory('TileModel', function(GenerateUniqueId) {
  var Tile = function(pos, val) {
    this.x      = pos.x;
    this.y      = pos.y;
    this.value  = val || 2;
    // Создать уникальный id 
    this.id = GenerateUniqueId.next();
    this.merged = null;
  };
  // ...
});


И теперь мы предлагаем Angular отслеживать плитку по её уникальному id:


<!-- ... -->
<div tile 
      ng-model='tile'
      ng-repeat='tile in ngModel.tiles track by $id(tile.id)'></div>
<!-- ... -->


Есть только одна проблемка. Так как мы заполнили массив нулями, Angular будет отслеживать эти нулевые объекты. А поскольку у них нет уникальных id, браузер выдаст ошибку. Поэтому мы должны использовать встроенный инструмент angular, чтобы отслеживать объекты либо по id, либо по позиции в массиве. Вид grid_directive можно поменять, приспособив его к нулевым объектам, следующим образом:


<!-- ... -->
<div tile 
      ng-model='tile'
      ng-repeat='tile in ngModel.tiles track by $id(tile.id || $index)'></div>
<!-- ... -->


Победили? Конец игры


В оригинале при проигрыше в игре 2048 экран уезжает и предлагает начать заново. Мы можем воссоздать этот клёвый эффект через простые техники Angular. Создадим объект div, содержащий наш экран «игра окончена» с абсолютным расположением поверх игрового поля, и будем показывать его с сообщением, зависящим от того, выиграли мы или проиграли.


<!-- ... -->
<div id="game-container">
  <div grid ng-model='ctrl.game' class="row"></div>
    <div id="game-over" 
        ng-if="ctrl.game.gameOver"
        class="row game-overlay">
      Game over
      <div class="lower">
        <a class="retry-button" ng-click='ctrl.newGame()'>Try again</a>
      </div>
    </div>
  <!-- ... -->


Некоторые выдержки из CSS для абсолютного позиционирования элемента (полные стили есть на github):

.game-overlay {
  width: $width;
  height: $width;
  background-color: rgba(255, 255, 255, 0.47);
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  text-align: center;
  padding-top: 35%;
  overflow: hidden;
  box-sizing: border-box;

  .lower {
    display: block;
    margin-top: 29px;
    font-size: 16px;
  }
}


В случае выигрыша делаем то же самое, но только создаём элемент .game-overlay.

Анимация


Одной из приятных особенностей оригинальной игры 2048 была анимация плиток, которые наезжали друг на друга и анимация экрана, который появлялся по окончанию игры. Мы можем достичь этих эффектов через CSS.

Мы проектировали игру таким образом, что эти эффекты будет легко реализовать, практически без использования JS.

Анимируем позиционирование через CSS

Поскольку мы размещаем плитки через класс position-[x]-[y], то при назначении новой позиции к элементу DOM будет добавлен класс position-[newX]-[newY] и удалён класс position-[oldX]-[oldY]. Поэтому перемещение можно задать при помощи transition через класс .tile CSS. Соответствующий SCSS выглядит так:

.tile {
  @include border-radius($tile-radius);
  @include transition($transition-time ease-in-out);
  -webkit-transition-property: -webkit-transform;
  -moz-transition-property: -moz-transform;
  transition-property: transform;
  z-index: 2;
}


После этого плитки будут красиво скользить с одного места на другое. Реально просто.

Анимация экрана об окончании игры

Более модной анимации можно добиться при помощи модуля ngAnimate, который работает без дополнительных настроек.

$ bower install --save angular-animate


Как обычно, ставим ссылку на модуль из HTML, чтобы браузер мог подгрузить его. Добавим в index.html следующее:



И, как обычно, необходимо добавить зависимость от модуля. Добавим в массив зависимостей в файле app/app.js:

angular .module('twentyfourtyeightApp', ['Game', 'Grid', 'Keyboard', 'ngAnimate', 'ngCookies']) // ...


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:

.game-overlay {
  // ...
  &.ng-enter {
    @include transition(all 1000ms ease-in);
    @include transform(translate(0, 100%));
    opacity: 0;
  }
  &.ng-enter-active {
    @include transform(translate(0, 0));
    opacity: 1;
  }
  // ...
}


Изменение размеров игрового поля

Допустим, нам захочется поменять размер игрового поля. Вместо оригинального 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-board($tile-count: 4) {
  $tile-size: ($width - $tile-padding * ($tile-count + 1)) / $tile-count;
  #game-#{$tile-count} { 
    position: relative;
    padding: $tile-padding;
    cursor: default;
    background: #bbaaa0;
    // ...
}


Теперь можно включать mixin для динамического создания таблицы стилей, содержащей разные версии игровой доски, каждая из которых будет изолирована через #game-[n] tag.

Чтобы построить несколько вариантов игровых полей, мы просто проходим циклом по игровым полям, которые нам нужны, и вызываем mixin.

$min-tile-count: 3;       // минимальное количество плиток
$max-tile-count: 6;       // максимальное количество плиток
@for $i from $min-tile-count through $max-tile-count {
  @include game-board($i);
}


Динамический сервис GridService

Имея несколько игровых полей, необходимо переделать GridService, чтобы можно было задавать размер игрового поля при старте. Это просто.

Сперва нам надо поменять логику так, чтобы GridService стал провайдером, а не прямым сервисом. Кратко говоря, провайдер отличается от сервиса тем, что его можно настраивать перед стартом. А ещё нам нужно поменять функцию конструктора, чтобы она была задана как метод $get провайдера:

.provider('GridService', function() {
  this.size = 4; // Default size
  this.setSize = function(sz) {
    this.size = sz ? sz : 0;
  };

  var service = this;

  this.$get = function(TileModel) {
    // ...


Методы, не заданные внутри $get, доступны через функцию .config(). Всё, что внутри $get(), доступно в работающем приложении, но не в .config().

Вот и всё, что нужно для возможности динамически задавать размер игрового поля. Если, скажем, нам надо сделать поле 6х6 вместо 4х4, то в функции .config() мы можем вызвать GridServiceProvider и поменять там размер:

angular
.module('twentyfourtyeightApp', ['Game', 'Grid', 'Keyboard', 'ngAnimate', 'ngCookies'])
.config(function(GridServiceProvider) {
  GridServiceProvider.setSize(6);
})


При создании провайдера Angular автоматически создаёт модуль config-time, который можно ввести под именем [serviceName]Provider.

Демо


Демка доступна по адресу ng2048.github.io.

Исходники


Полный исходный код доступен на Github d.pr/pNtX. Для локального построения исходников клонируйте их и запустите:

$ npm install 
$ bower install 
$ grunt serve


А вот как можно легко получить свежую версию node:

$ sudo npm cache clean -f 
$ sudo npm install -g n 
$ sudo n stable
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+45
Comments15

Articles