Робимо гру 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.

Модульна структура дає нам чітке розділення відповідальності, яке збігається з файловою структурою. Це допомагає не тільки будувати великі й складні додатки, але і ділитися функціоналом всередині самого додатка. Пізніше ми налаштуємо оточення для тестування, збігається зі структурою каталогу.

Вигляд
Простіше за все почати додаток з Виду. В даному випадку у нас є тільки один вид/шаблон. Тому ми створимо єдиний
<div>
, що містить усі додаток.

В файл 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.рекорд }}</div>
</div>
</div>
<!-- ... -->
</div>


Зверніть увагу, що ми згадуємо GameController разом з currentScore і рекорд. Синтаксис 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;
});


Ігри

Ми підключили Вигляд, тепер можемо зайнятися і логікою гри. Створимо модуль гри як 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 cells are 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 by track $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.рекорд = 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.об'єднані = [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 — звичайна тимчасова змінна, що містить поточний рахунок для гри. А мінлива рекорд повинна зберігатися між іграми. Цього можна досягти через локаьное сховище, куки, або через комбінацію цих методів. Але ми зупинимося на використання куків. А для цього найпростіше скористатися модулем angular-cookies.

Його можна завантажити з angularjs.org або встановити через менеджер модулів типу bower:

$ bower install --save angular-cookies


Як зазвичай, на нього треба послатися з index.html і включити його в залежності як ngCookies. Запишемо в app/index.html:

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


Тепер, щоб додати модуль ngCookies в підвантаження:

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


Після цього можна робити ін'єкцію сервісу $cookieStore в сервіс GameManager. І у нас з'явиться можливість ставити і зчитувати куки в браузері. Наприклад, для вилучення останнього досягнення користувача використовується наступна функція:

this.getHighScore = function() { 
return parseInt($cookieStore.get('рекорд')) || 0; 
}


Повертаючись до методу updateScore() з класу GameManager, ми оновимо поточний рахунок. Якщо рахунок вище, ніж попередній рахунок з таблиці рекордів, ми запишемо куку для цієї таблиці на майбутнє.

this.updateScore = function(newScore){
this.currentScore = newScore;
if (this.currentScore > this.getHighScore()) {
this.рекорд = newScore;
// Set on the cookie
$cookieStore.put('рекорд', 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 by track $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 = 'хххххххх-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function© {
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.об'єднані = null;
};
// ...
});


І тепер ми пропонуємо Angular відстежувати плитку за її унікальному id:

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


Є тільки одна проблемка. Так як ми заповнили масив нулями, Angular буде відслідковувати ці нульові об'єкти. А оскільки у них немає унікальних id, браузер видасть помилку. Тому ми повинні використовувати вбудований інструмент angular, щоб відстежувати об'єкти або по id, або за позиції в масиві. Вид grid_directive можна поміняти, пристосувавши його до нульових об'єктів, наступним чином:

<!-- ... -->
<div tile 
ng-model='tile'
ng-repeat='tile in ngModel.tiles by track $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-ad додаються.

Анімація екрану про закінчення гри
Анімувати екрани ми будемо за допомогою класу 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


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.