📜 ⬆️ ⬇️

Making the game 2048 on AngularJS

Probably, like many colleagues, you liked the game “2048”, in which you need to reach the tile with the number 2048, bringing together tiles with the same numbers.

In this article, we will build a clone of this game together using the AngularJS framework. Under the link you can see a demonstration of the final result.


First steps: we plan the application


image
')
The first step is to develop a high-level application design. We do this for any applications, no matter whether we are cloning an existing application or writing ours from scratch.

The game has a playing field with a set of cells. Each of the cells is a place to place a tile with a number. This can take advantage of and shift the responsibility for placing tiles on CSS3, rather than doing it in a script. When the tile is on the playing field, we just have to make sure that it is in the right place.

Using CSS3 allows us to leave the animation for CSS, and use the default AngularJS behavior to track the state of the board, tiles, and game logic. Since we have one page, we need one controller.

Since we have one playing field, all the grid logic will be stored in one instance of the GridService service. Services are singletons, so it’s convenient to store a grid in them. GridService will place the tiles, move them, track the status of the grid.

Game logic will be stored and processed in another service called GameManager. He will be responsible for managing the state of the game, processing moves and storing points (current achievement and high score table).

Finally, we need a component for servicing the keyboard. This will be the KeyboardService service. In this article, we will implement the work of the desktop application, but it can be easily redone for touch screens.

Build the application


image

Create a simple application (we used yeoman's jail generator, but this is optional). Create a directory of the application in which it will be stored. The test / directory will be located next to the app / directory.

The following instructions are for setting up a project through yeoman. If you prefer to do it manually, you can skip them.

First, make sure yeoman is installed. For this, NodeJS and npm must be installed. After that, you need to install the yeoman utility called yo, and the generator for angular (which will be used by the utility to create the application):

$ npm install -g yo $ npm install -g generator-angular 


After that, you can create an application using the yo utility:

 $ cd ~/Development && mkdir 2048 $ yo angular twentyfourtyeight 


You will need to answer all questions in the affirmative, except for “select the angular-cookies as a dependency”, since we do not need them.

Our angular module

Create a scripts / app.js application file. Let's start the application:

 angular.module('twentyfourtyeightApp', []) 


Modular structure


image

It is recommended to build the structure of the application by functionality, not by type. That is, not to separate the components, as is customary, by controllers, services, directives. For example, in our application, we define the Game module and the Keyboard module.

The modular structure gives us a clear division of responsibility, which coincides with the file structure. This helps not only to build large and complex applications, but also to share functionality within the application itself. Later we will set up the environment for testing, which coincides with the directory structure.

View

The easiest way to start the application is with the View In this case, we have only one view / pattern. Therefore, we will create a single
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable

, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable
, .

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.imtqy.com/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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .config() GridServiceProvider :

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

Angular config-time, [serviceName]Provider.


ng2048.imtqy.com .


Github d.pr/pNtX . :

$ npm install $ bower install $ grunt serve

node:

$ sudo npm cache clean -f $ sudo npm install -gn $ sudo n stable
 ,   . 

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-repeatdiv 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; } // ... }


, . 44 33 66. .

SCSS, GridService. .

CSS
CSS, CSS, . #game , . , 33, id #game-3, 66 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().

, . , , 66 44, .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 -gn $ sudo n stable

Source: https://habr.com/ru/post/249371/


All Articles