使用 AngularJS 开发 2048 游戏
我频繁地被问及到的一个问题之一,就是什么时候使用Angular框架是一个糟糕的选择。我的默认答复是编写游戏的时候,尽管Angular有它自己的事件循环处理 ($digest循环) ,并且游戏通常需要很多底层DOM操作.如果说有Angular能支持很多类型的游戏,那这个说法可不准确。即使游戏需要大量的DOM操作,这可能会用到angular框架处理静态部分,如记录最高分和游戏菜单。
如果你和我一样迷上流行的2048 游戏. 游戏的目标是用相同的值相加拼出值为2048的方块。
在今天这篇博文中,我们会用AngularJS从头到尾地创建一个副本, 并解释创建app的全过程。由于这个app相对复杂,所以我也打算用这篇文章来描述如何创建复杂的AngularJS应用。
这是我们要创建的 demo .
现在开始吧!
TL;DR: 这个app的源代码也可下载,文章尾部有该app在github上的链接.
第一步:规划app
第一步我们要做的,就是给要创建的app做高层设计。无论是山寨别人的app,还是自己从零做起,这一步都与app的规模无关。
再来看看这个游戏,我们发现在游戏板的顶端有一堆瓦片。每个瓦片自身都可以作为一个位置,用来放置其他有编号的瓦片。我们可以根据这个事实,把任务移动瓦片的任务交给CSS3来处理,而不是依靠JavaScript,它需要知道移动瓦片的位置。当游戏面板上有一个瓦片,我们只需要简单地确保它放在顶部合适的位置即可。
使用CSS3来布局,能带给我们CSS动画效果,同时也默认使用AngularJS行为来跟踪游戏板的状态,瓦片和游戏逻辑。
因为我们只有一个单页面(single page),我们还需要一个单控制器(single controller)来管理页面。
因为应用的生命周期内只有一个游戏板,我们在GridService服务中的一个单一实例里包含所有的网格逻辑。由于服务是单例模式对象,所以这是一个存储网格的恰当位置。我们使用GridService来处理瓦片替换,移动,管理网格。
而把游戏的逻辑和处理放到一个叫做GameManager的服务中。它将负责游戏的状态,处理移动,维护分数(当前分数和最高分)
最后,我们需要一个允许我们管理键盘的组件。我们需要一个叫做KeyboardService的服务。在这篇博文中,实现了应用对桌面的处理,我们也可以复用这个服务来管理触摸操作让它在移动设备上运转。
创建app
为了创建app,我们先创建一个基本的 app (使用 yeoman angular 生成器生成app的结构, 这一步不是必须的. 我们只把它作为切入点,之后就迅速地从它的结构上分开。).创建一个app目录用来放置整个应用。把test/目录作为app/目录的同级目录.
The following instructions are for setting up the project using the yeoman tool. If you prefer to do it manually, you can skip installing the dependencies and move on to the next section.
因为在应用中我们用了yeomanin工具, 我们首先要确保它已经安装好了. Yeoman安装时基于NodeJS和npm.安装NodeJS不是这篇教程所要讲的,但你可以参看NodeJS.org 站点.
装完npm后,我们就可以安装yeoman工具yo和angular生成器(它由yo调用来创建Angular app):
$ npm install -g yo $ npm install -g generator-angular
安装后,我们可以使用yeoman工具生成我们的应用,如下:
$ cd ~/Development && mkdir 2048 $ yo angular twentyfourtyeight
该工具会询问一些请求。我们都选yes即可,除了要选择angular-cookies作为依赖外,我们不需要任何其他的依赖了。
Note that using the Angular generator, it will expect you have the compass gem installed along with a ruby environment. See the complete source for a way to get away without using ruby and compass below.
我们的angular 模块
我们将创建scripts/app.js文件来放置我们的应用。现在就开始创建应用吧:
angular.module('twentyfourtyeightApp', [])
模块结构
布局angular应用使用的结构现在是根据函数推荐的,而不是类型。这就是说,不用把组件分成控制器,服务,指令等,就可以在函数基础上定义我们的模块结构。例如,在应用中定义一个Game模块和一个Keyboard模块。
模块结构清晰地为我们分离出匹配文件结构的职能域。这不仅方便我们创建大型,灵活性强的angular应用,也方便我们共享app中的函数。
最后,我们搭建测试环境适应文件目录结构。
视图
应用中最易切入的地方非视图莫属了。审视视图自身,我们发现只有一个view/template.在这个应用中,不需要多视图,所以我们创建单一的<div>元素,用来放置应用的内容。
在我们的主文件app/index.html中,我们需要包含所有的依赖项(包括angular.js自身和JS文件,即scripts/app.js),如下:
<!-- index.html --> <doctype html> <html> <head> <title>2048</title> <link rel="stylesheet" href="styles/main.css"> </head> <body ng-app="twentyfourtyeightApp" <!-- header --> <div class="container" ng-include="'views/main.html'"></div> <!-- script tags --> <script src="bower_components/angular/angular.js"></script> <script src="scripts/app.js"></script> </body> </html>
Feel free to make a more complex version of the game with multiple views – please leave a comment below if you do. We’d love to see what you create.
有了app/index.html文件集,我们需要在应用视图层面上,详细地处理app/views/main.html中的视图。当需要在应用中导入一个新
资源时,我们需要修改index.html文件。
打开app/views/main.html,我们要替换所有的游戏指定的视图。使用controllerAs语法,我们可以在$scope中清楚地知道我们期待在哪里查询数据,哪个控制器负责哪个组件。
<!-- app/views/main.html --> <div id="content" ng-controller='GameController as ctrl'> <!-- Now the variable: ctrl refers to the GameController --> </div>
ThecontrollerAssyntax is a relatively new syntax that comes with version 1.2. It is useful when dealing with many controllers on the page as it allows us to be specific about the controllers where we expect functions and data to be defined.
在视图中,我们要显示以下一些项目:
-
游戏静态头
-
当前游戏分数和本地用户最高分
-
游戏板
游戏静态头可以这样来完成:
<!-- heading inside app/views/main.html --> <div id="content" ng-controller='GameController as ctrl'> <div id="heading" class="row"> <h1 class="title">ng-2048</h1> <div class="scores-container"> <div class="score-container">{{ ctrl.game.currentScore }}</div> <div class="best-container">{{ ctrl.game.highScore }}</div> </div> </div> <!-- ... --> </div>
注意到,当在视图中引用currentScore和highScroe时,我们也引用了GameController.controllerAs语法使得我们可以显示地引用我们感兴趣的控制器。
GameController
现在我们有了一个合理的项目结构,现在来创建GameController来放置我们要在视图中显示的值。在app/script/app.js中,我们可以在主模块twentyfourtyeight.App中创建控制器:
angular .module('twentyfourtyeightApp', []) .controller('GameController', function() { });
在视图中,我们引用了一个game对象,它将在GameController中设置。该game对象将引用主game对象。我们在一个新模块中创建这个主游戏模块,用来放置游戏中所有的引用。
因为这个模块还没有创建,app不会再浏览器中加载它。在控制器中,我们可以添加GameManager依赖
.controller('GameController', function(GameManager) { this.game = GameManager; });
别忘了,我们正创建一个模块级别的依赖,它是应用中不同的部分,所以要确保它在应用中正确地加载,我们需要将它列为angular模块的一个依赖。为使Game模块成为twentyfourtyeightApp的依赖,我们在定义该模块的数组中列举它。
我们整个的app/script/app.js文件应该看起来像这样:
angular .module('twentyfourtyeightApp', ['Game']) .controller('GameController', function(GameManager) { this.game = GameManager; })
Game
既然我们有视图间部分相互连接了,那么就可以开始编写游戏背后的逻辑了。为创建一个新游戏模块,我们在app/scripts/目录中把我们的模块创建为app/scripts/game/game.js:
angular.module('Game', []);
When building modules, we like to write them in their own directory named after the module. We’ll implement the module initialization in a file by the name of the module. For instance, we’re building a game module, so we’ll build our game module inside theapp/scripts/gamedirectory in a file namedgame.js. This methodology has provided to be scalable and logical in production.
Game模块将提供一个单核心组件:GameManager.
我们将来完成GameManager,使它能处理游戏的状态,用户可以移动的不同方法,记录分数以及决定游戏何时结束和用户是否打破最高分以及用户是否输局了。
开始开发应用时,我们喜欢为我们用到的方法编写stub方法,并编写测试代码然后填入要实现的地方。
For the purposes of this article, we’ll run through this process for this module. When we write the next several modules, we’ll only mention the core components we should be testing.
我们知道GameManager将支持以下特性:
-
建立新游戏
-
处理游戏循环/移动操作
-
更新分数
-
跟踪游戏是否结束
有了这些特性,我们可以创建GameManager服务的基本大纲,我们就可以对它进行测试代码的编写:
angular.module('Game', []) .service('GameManager', function() { // Create a new game this.newGame = function() {}; // Handle the move action this.move = function() {}; // Update the score this.updateScore = function(newScore) {}; // Are there moves left? this.movesAvailable = function() {}; });
基本的功能实现完后,就来编写测试代码,使它定义GameManager需要支持的功能.
测试驱动开发 (TDD)
开始实现测试前,需要使用karma驱动测试。如果你对karma不熟悉,就把它当做一个测试runner,它允许我们在终端和代码中舒适高效地进行前台的自动化测试。
要使用Karma,我们需要确保它已安装正确。使用Karma,我们要依赖NodeJS,因为它可以作为一个npm包。运行以下代码,安装Karma:
$ npm install -g karma
The-gflag tells npm to install the package globally. Without this flag, the package would only be installed locally in the current working directory.
如果你使用了yeoman angular生成器,你可以跳过下一部分。
要使用 karma, 我们需要编写一个配置文件。尽管我们不会深入讨论怎样配置Karma(猛戳这里 ng-book ,查看配置Karma的详细选项), 但是关键的部分还是要知道的,即设置Karma使它在测试中加载所有我们感兴趣的文件。
要创建一个配置文件,我们可以使用karma init命令来创建一个基本的版本.
$ karma init karma.conf.js
该命令会询问一些请求并创建karma.conf.js文件。从这里起,我们将改变两个配置选项:files数组和要打开的autoWatch::
// ... files: [ 'app/bower_components/angular/angular.js', 'app/bower_components/angular-mocks/angular-mocks.js', 'app/bower_components/angular-cookies/angular-cookies.js', 'app/scripts/**/*.js', 'test/unit/**/*.js' ], autoWatch: true, // ...
建立完这个配置文件,我们可以随时运行测试(它写在test/unit/目录下)
为运行测试,我们运行karma start命令,如下所示:
$ karma start karma.conf.js
编写第一份测试
既然karma安装和配置好了,我们就可以开始为GameManager编写基本的测试。因为我们还不知道应用的全部功能,我们只能进行有限的测试
Often times, we find that our API changes as we develop the application, so rather than introduce a lot of work ahead of time that we’ll likely change, we set up our tests to test basic functionality and fill them in deeper as we uncover the eventual API.
第一份测试的较好的备选方案,是它可以告诉我们有没有可能向左移动。为测试是否可以向左移动,我们简单地写一个我们需要调用的stub方法,它测试应用逻辑的行为并返回true/false.
我们将穿件一个文件---test/unit/game/game_spec.js,并开始创建我们的测试上下文:
describe('Game module', function() { describe('GameManager', function() { // Inject the Game module into this test beforeEach(module('Game')); // Our tests will go below here }); });
In this test, we’re using Jasmine syntax.
同其他单元测试一样,我们需要创建GameManager对象的实例。我们可以沿袭常规(当测试服务时),把它注入到我们测试中:
// ... // Inject the Game module into this test beforeEach(module('Game')); var gameManager; // instance of the GameManager beforeEach(inject(function(GameManager) { gameManager = GameManager; }); // ...
有了这个gameManager的实例,我们可以开始编写movesAvailable()期望的功能.
我们将定义movesAvailable()函数,它用来验证是否有剩下可用的方块以及验证有没有可能的合并。因为它是游戏是否结束的条件,我们把这个方法放到GameManager,但是在GridService中实现大部分功能,GridService将在下一步创建。
要看游戏板上是否有方块移动,我们看两个条件:
-
游戏板上有可用的位置
-
有可匹配的位置
有了这两个条件,我们就可以编写测试代码来看是否满足这两个条件。
最基本的想法就是我们写出测试代码,然后满足一个条件,它可以用来观察单元测试在环境下的表现。由于依赖GridService来报告游戏板的条件,所以我们要在GameManager中改变条件来看逻辑是否正确。
Mock the GridService
要mock我们的GridService,我们通过重写默认的Angular行为来“提供”我们的mock后的服务,而不是真正的服务,所以我们可以在服务中建立可控的条件
用mocked方法创建一个fake对象,然后通过$provide服务处理它们并告诉Angular这些fake对象是真正的对象。
// ... var _gridService; beforeEach(module(function($provide) { _gridService = { anyCellsAvailable: angular.noop, tileMatchesAvailable: angular.noop }; // Switch out the real GridService for our // fake version $provide.value('GridService', _gridService); })); // ...
现在我们可以使用这个fake _gridService实例来建立我们的条件。
我们要确保当有可用的方块时,movesAvailable()函数返回true.现在就在GridService中mock anyCellsAvailable()方法。我们希望这个方法在GridService中报告是否有可用方块。
// ... describe('.movesAvailable', function() { it('should report true if there are cells available', function() { spyOn(_gridService, 'anyCellsAvailable').andReturn(true); expect(gameManager.movesAvailable()).toBeTruthy(); }); // ...
既然基本原理弄清楚了,我们就可以设定第二个条件的期望值了。如果有可用的搭配,我们就要确保movesAvailable()函数返回true.同时我们确保对话返回true时,要是没有可用的网格或搭配,就没有可用的移动。
另两个测试确保如下过程:
// ... it('should report true if there are matches available', function() { spyOn(_gridService, 'anyCellsAvailable').andReturn(false); spyOn(_gridService, 'tileMatchesAvailable').andReturn(true); expect(gameManager.movesAvailable()).toBeTruthy(); }); it('should report false if there are no cells nor matches available', function() { spyOn(_gridService, 'anyCellsAvailable').andReturn(false); spyOn(_gridService, 'tileMatchesAvailable').andReturn(false); expect(gameManager.movesAvailable()).toBeFalsy(); }); // ...
我们已经奠定基础了,现在在实现期望的功能前可以编写测试样例了。
Although we aren’t going to continue with TDD in this post, for the sake of overall completion, we suggest you should continue with it. Check out the full source code below for more tests.
回到GameManager
现在我们来实现movesAvailable函数. 我们已经测试代码可以运行, 并且明确了执行的条件, 这个函数实现起来就简单了.
// ... this.movesAvailable = function() { return GridService.anyCellsAvailable() || GridService.tileMatchesAvailable(); }; // ...
打造game grid
GameManager已经准备妥当, 我们接下来就要创建GridService来管理游戏板.
回想一下我们用来描述游戏板的两个数组变量grid和tiles, 我们用这两个局部变量来设置GridService. 在app/scripts/grid/grid.js文件中, service的创建代码如下:
angular.module('Grid', []) .service('GridService', function() { this.grid = []; this.tiles = []; // Size of the board this.size = 4; // ... });
当我们想创建一个新游戏, 数组用null元素初始化. grid数组只包含在游戏板上用来放置方块的固定数量的Dom元素, 因此grid可以理解为静态的.
相比而言, tiles数组用来存放游戏中正在使用的瓦片, 则相对是动态变化的. 下来我们在页面上创建grid, 看看如何通过使用这些变量来控制grid和瓦片的布局.
回到app/views/main.html中,我们需要开始布局网格。因为它是动态的,加上我们要把我们的逻辑处理放在网格内,我们仅仅只要把逻辑放到它自己的指令内。使用指令,将清空主模板和在指令中的封装的功能,同时主控制器也被清空。
在app/index.html中,我们把网格指令放到网格并在控制器中传递GameManager实例:
<!-- instructions --> <div id="game-container"> <div grid ng-model='ctrl.game' class="row"></div> <!-- ... -->
编写这个指令,使它能包含在Grid模块中。在app/scripts/grid/目录下,我们创建一个grid_directives.js文件来放置grid指令。
在grid指令中,由于它的权限有限,不能封装视图,所以我们还需要一些变量。
这个指令需要一个GameManager实例(或者,至少一个包含grid和tiles的模型),这样就可以根据指令的需要完成了一个自定义的指令。另外,我们不希望我们的指令干扰到页面或者页面中的GameManager实例,所以我们需要使用isolate来创建这个之类,用于限制它的使用范围。
深入理解自定义指令可以参考: custom directives ,或者查看 ng-book里面关于指令的内容
angular.module('Grid') .directive('grid', function() { return { restrict: 'A', require: 'ngModel', scope: { ngModel: '=' }, templateUrl: 'scripts/grid/grid.html' }; });
该指令的主要功能是建立网格视图,所以我们不需要在指令里面使用自定义逻辑。
在指令的模板里面,我们使用两次ngRepeat来遍历展示grid和tiles数组,并且分别使用$index来跟踪遍历的结果。
<div id="game"> <div class="grid-container"> <div class="grid-cell" ng-repeat="cell in ngModel.grid track by $index"> </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是一个非常简单的遍历,将ngModel.grid遍历输出到一个class为grid-cell的div里面。
在第二个ng-repeat里面,我们给每一个屏幕上的元素创建一个叫做tile的辅助的指令。这个tile指令将用于给每个tile元素创建直观的页面显示效果。后面我们再来创建这个tile指令...
精明的读者可能会看到,我们只使用了一个一维数组来展示一个二维网格。当我们渲染视图的时候,我们只获取一列tiles,而不是一个格子。为了让他们变成网格,我们需要使用CSS。
Enter SCSS
针对这个项目,我们使用SASS的一个现代变体:scss。scss不仅是一个更强大的CSS,我们将会以动态的方式来构建我们的CSS。
这个app的视觉元素部分将使用CSS完成,包括动画以及布局和视觉元素(瓷砖的颜色等)。
为了可以使用二维数组的方式创建面板,我们需要使用CSS3的transform关键字来将每个瓷砖放置在面板特定的位置上。
CSS3 transform 属性
CSS3 transform 属性向元素应用 2D 或 3D 转换。 该属性允许我们对元素(当然是可以动起来的元素)进行移动、倾斜、旋转、缩放,以及其它更多动作. 使用这个属性,我们可以简单地将方块放到游戏板上,然后给元素应用适当的transform属性。
例如,下面这个示例,我们有一个40px宽和40px高的box类:
.box { width:40px; height:40px; background-color: blue; }
如果我们应用一个translateX(300px)属性,我们将向左移动盒子300px,以下示例证明了这一点:
.box.transformed { -webkit-transform: translateX(300px); transform: translateX(300px); }
使用这个转换属性,我们能够简单地通过给我们的方块应用一个CSS类标记在游戏板上移动它们。现在,微秒的地方就是我们怎样来构建我们动态的类,如此,当我们在页面上定点时,它们使用CSS类来对应一个合适的方格?
这就是SCSS发挥威力的地方。我们将设置一些变量(比如一行我们想要几个方块),并且在这些变量周围构建我们的SCSS,使用一些数学方法来为我们做计算。
让我们看一看这些变量,我们需要正确的计算它们的在游戏板上的位置:
$width: 400px; // The width of the whole board $tile-count: 4; // The number of tiles per row/column $tile-padding: 15px; // The padding between tiles
我们可以让SCSS帮我们动态的计算这三个变量的位置。首先,我们需要计算每一个方块的面积。这对SCSS变量来说是非常容易的:
$tile-size: ($width - $tile-padding * ($tile-count + 1)) / $tile-count;
现在我们可以为#game这个容器设置合适的宽高。同样,我们在#game这个容器上设置位置参数,这样我们就可以在容器中准确的定位到我们的子元素。我们会放置我们的.gird-container和.tile-container到#game这个容器对象中。
我们在这里只包含了与scss相关的部分。剩下的代码可以在文章最后提供的github地址上找到。
#game { position: relative; width: $width; height: $width; // The gameboard is a square .grid-container { position: absolute; // the grid is absolutely positioned z-index: 1; // IMPORTANT to set the z-index for layering margin: 0 auto; // center .grid-cell { width: $tile-size; // set the cell width height: $tile-size; // set the cell height margin-bottom: $tile-padding; // the padding between lower cells margin-right: $tile-padding; // the padding between the right cell // ... } } .tile-container { position: absolute; z-index: 2; .tile { width: $tile-size; // tile width height: $tile-size; // tile height // ... } } }
需要注意的是为了将.tile-container置于.gird-container之上,我们必须为.tile-container设置更高的z-index值。否则,浏览器会将它们置于同等高度,这样看上去就不美观了。
通过这些设置,我们现在可以动态生成这些方块的位置坐标。我们需要的只是一个.position-[x}-{y}标记类,将它附值给一个方块,那样浏览器就知道方块的位置坐标,然后动态的将方块放置到那个位置上去。因为我们要计算与这个格子容器相关的转换属性,我们将用0,0来做为第一个方块的初始位置。
我们将迭代所有的方块,然后基于我们计算的预期偏移值来动态地创建每一个类:
.tile { // ... // Dynamically create .position-#{x}-#{y} classes to mark // where each tile will be placed @for $x from 1 through $tile-count { @for $y from 1 through $tile-count { $zeroOffsetX: $x - 1; $zeroOFfsetY: $y - 1; $newX: ($tile-size) * ($zeroOffsetX) + ($tile-padding * $zeroOffsetX); $newY: ($tile-size) * ($zeroOffsetY) + ($tile-padding * $zeroOffsetY); &.position-#{$zeroOffsetX}-#{$zeroOffsetY} { -webkit-transform: translate($newX, $newY); transform: translate($newX, $newY); } } } // ... }
需要注意我们必须以1为起始值来计算偏移,而不是以前的以0为起始值.这是SASS自身的一个局限。我们通过将索引减1来规避这个问题。
现在我们已经创建了.position-#{x}-#{y}这个CSS标记类,可以将我们的方块布局到屏幕上了。
为不同的方块的着色
注意到每一个方块出现在屏幕上时都有不同的颜色。这些不同的颜色表示每一个方块自己拥有的数值。这是一种简单地方法可以让玩家知道这些方块处在不同的状态之下。使用我们迭代所有方块时相同的手法来创建一个方块的颜色方案。
为了完成颜色方案的创建,我们首先需要创建一个SCSS数组来保存我们将在屏幕上用到的每一种背景颜色。每一种颜色将
$colors: #EEE4DA, // 2 #EAE0C8, // 4 #F59563, // 8 #3399ff, // 16 #ffa333, // 32 #cef030, // 64 #E8D8CE, // 128 #990303, // 256 #6BA5DE, // 512 #DCAD60, // 1024 #B60022; // 2048
利用我们$colors数组变量,我们可以简单地迭代每一种颜色,并且动态地基于这个方块的数值来创建一个类。也就是说,当一个方块的值是2时,我们将增加.tile-2这个CSS类,这个类的背景色是#EEE4DA。我们将使用SCSS技巧来帮助我们处理,而不是为每一个方块进行硬编码。
@for $i from 1 through length($colors) { &.tile-#{power(2, $i)} .tile-inner { background: nth($colors, $i) } }
当然了,我们需要定义power()这个混合函数。它像这样定义:
@function power ($x, $n) { $ret: 1; @if $n >= 0 { @for $i from 1 through $n { $ret: $ret * $x; } } @else { @for $i from $n to 0 { $ret: $ret / $x; } } @return $ret; }
方块指令
因为SASS的不懈的工作,我们可以回到我们的方块指令,根据动态定位来展示每一个方块,并且允许CSS能够以它被设计的方式来工作,然后依序排列这些方块。
因为tile指令是一个自定义视图的容器,所以我们不需要让它有太多的功能。我们需要用到元素负责显示的特性。除此之外,这里没有其它功能需要放进去。下面这段代码说明了一切:
angular.module('Grid') .directive('tile', function() { return { restrict: 'A', scope: { ngModel: '=' }, templateUrl: 'scripts/grid/tile.html' }; });
现在,tile指令有意思的地方在于我们如果动态呈现。使用ngModel这个在其它地方定义的变量,所有这些事都在模板中被搞定了。正好我们前面看到的一样,它引用了我们tiles数组中的方块对象。
<div ng-if='ngModel' class="tile position-{{ ngModel.x }}-{{ ngModel.y }} tile-{{ ngModel.value }}"> <div class="tile-inner"> {{ ngModel.value }} </div> </div>
使用这条基础指令,我们几乎就要把它显示在屏幕上了。所有以x和y为坐标的方块,它们将自动被分配相应的.position-#{x}-#{y}类,并且浏览器也将自动的将它们放置到期望的位置上。
这意味着我们的方块对象将需要一个x,y和一个对指令运行来说可行的值。因此,我们需要为每一个即将布局对屏幕上的方块创建一个新的对象。
TileModel服务
我们将创建一个智能地对象,它包含数据以及功能处理,而不是弄一个不能处理信息的普通对象。
因为我们希望可以利用Angular的依赖注入,我们将新建一个服务来管理我们的数据模型。我们将在Grid模块中创建一个TileModel服务,因为只有在涉及到游戏板时,使用低阶的TileModel才有必要。
使用.factory这个方法,我们可以简单地新建一个函数,将之作为一个工场方法。不像service()这个函数假定我们使用来定义服务的函数就是那个服务的构建函数,factory()方法将函数的返回值作为服务对象。这样,使用factory()方法我们能够将任何对象作为一个服务来注入到我们的Angular应用当中。
在我们的app/scripts/grid/grid.js这个文件中,我们可以创建我们的TileModel工场方法:
angular.module('Grid') .factory('TileModel', function() { var Tile = function(pos, val) { this.x = pos.x; this.y = pos.y; this.value = val || 2; }; return Tile; }) // ...
现在,在我们Angular应用中的任何地方,我们可以注入TileMode服务,并将它作为一个全局对象来使用。相当棒,对不对?
不要忘记给我们放到TileModel里的功能写测试用例。
我们的第一个方格
现在,我们有了TileModel这个服务,可以开始放置TileModel的实例到tiles数组中,之后它们就会神奇的出现在格子中正确的地方。
让我们尝试在GridService服务里边添加一些Tile实例到tiles数组中:
angular.module('Grid', []) .factory('TileModel', function() { // ... }) .service('GridService', function(TileModel) { this.tiles = []; this.tiles.push(new TileModel({x: 1, y: 1}, 2)); this.tiles.push(new TileModel({x: 1, y: 2}, 2)); // ... });
游戏板准备完毕
现在可以放置方块到屏幕上了,我们需要在GridService里创建一个功能,这个功能将会为我们准备好游戏板.当我们第一次加载页面时,我们希望可以创建一个空的游戏板。并且希望当用户在游戏区域点击"New Game"或者"Try again"按钮时触发相同的动作。
为了清空游戏板,我们将在GameService中创建一个新的函数,叫做buildEmptyGameBoard()。这个方法将会负责以空值来填充grid和tiles数组。
在我们写代码前,我们会写测试来确保buildEmptyGameBoard()这个函数的正确性。正如我们在上面谈到的那样,我们不会讨论过程,只关心结果。测试可以像这样:
// In test/unit/grid/grid_spec.js // ... describe('.buildEmptyGameBoard', function() { var nullArr; beforeEach(function() { nullArr = []; for (var x = 0; x < 16; x++) { nullArr.push(null); } }) it('should clear out the grid array with nulls', function() { var grid = []; for (var x = 0; x < 16; x++) { grid.push(x); } gridService.grid = grid; gridService.buildEmptyGameBoard(); expect(gridService.grid).toEqual(nullArr); }); it('should clear out the tiles array with nulls', function() { var tiles = []; for (var x = 0; x < 16; x++) { tiles.push(x); } gridService.tiles = tiles; gridService.buildEmptyGameBoard(); expect(gridService.tiles).toEqual(nullArr); }); });
有了测试,现在可以来实现我们的buildEmptyGameBoard()函数。
这个函数很简单,代码已经充分解释了它的作用。在app/scripts/grid/grid.js里边
.service('GridService', function(TileModel) { // ... this.buildEmptyGameBoard = function() { var self = this; // Initialize our grid for (var x = 0; x < service.size * service.size; x++) { this.grid[x] = null; } // Initialize our tile array // with a bunch of null objects this.forEach(function(x,y) { self.setCellAt({x:x,y:y}, null); }); }; // ...
上面的代码使用了一些功能清晰明了地辅助函数。这里列举了一些我们在整个工程中使用的辅助函数,它们都非常简单明了:
// Run a method for each element in the tiles array this.forEach = function(cb) { var totalSize = this.size * this.size; for (var i = 0; i < totalSize; i++) { var pos = this._positionToCoordinates(i); cb(pos.x, pos.y, this.tiles[i]); } }; // Set a cell at position this.setCellAt = function(pos, tile) { if (this.withinGrid(pos)) { var xPos = this._coordinatesToPosition(pos); this.tiles[xPos] = tile; } }; // Fetch a cell at a given position this.getCellAt = function(pos) { if (this.withinGrid(pos)) { var x = this._coordinatesToPosition(pos); return this.tiles[x]; } else { return null; } }; // A small helper function to determine if a position is // within the boundaries of our grid this.withinGrid = function(cell) { return cell.x >= 0 && cell.x < this.size && cell.y >= 0 && cell.y < this.size; };
太不可思议了吧?!??
我们使用到的this._positionToCoordinates()和this._coordinatesToPosition()这俩个函数有什么用呢?
回想一下我们上面讨论的,我们用到了一个一维数组来布局我们的方格。这从应用的性能和处理复杂动画来说都是一种更好的选择。我们将以接下来探讨动画。暂且看来,我们只是得益于利用了一维数组来代表多维数组的复杂性。
一维数组中的多维数组
我们如何在一个一维数组中表示一个多维数组?让我们看看没有颜色的网格表示的游戏板,和它们的格用值表示。在代码中,这个多维数组分解为数组的数组:
查看每个格的位置,当我们从单个数组角度看时,会看到一个模式出现:
我们可以看到第一个格,(0,0)映射到格的0的位置。第二个数组位置 1 指向网格的 (1,0) 位置。移动到下一行,网格的 (0,1) 位置指向一维数组的第 4 个元素,而索引为 5 的元素指向 (1.1)。
推算出位置之间的关系,我们可以看出方程中出现两个位置之间的关系。
i = x + ny
这里的 i 是格的索引,x 和 y 是在多维数组中的位置,n 是格每行/列的数量。
我们定义两个转换格位置为 x-y 坐标系或 y-x 坐标系的帮助函数。从概念上讲,很容易将格位置处理为 x-y 坐标,但是函数上我们将设置我们的一维数组中的每个拼贴。
// Helper to convert x to x,y this._positionToCoordinates = function(i) { var x = i % service.size, y = (i - x) / service.size; return { x: x, y: y }; }; // Helper to convert coordinates to position this._coordinatesToPosition = function(pos) { return (pos.y * service.size) + pos.x; };
最初的游戏者位置
现在,开始一个新的游戏,我们将想要设置一些开始的块。我们将随便的为我们的游戏者在游戏面板中选择这些开始的地方。
.service('GridService', function(TileModel) { this.startingTileNumber = 2; // ... this.buildStartingPosition = function() { for (var x = 0; x < this.startingTileNumber; x++) { this.randomlyInsertNewTile(); } }; // ...
建立一个开始位置相对简单,因为只需要调用 randomlyInsertNewTile() 函数放置拼贴的数量。randomlyInsertNewTile() 函数需要我们知道所有可以随便放置拼贴的位置。这在函数上很容易实现,因为所有我们需要做的是走过唯一数组并跟踪数组中没有放置拼贴的位置。
.service('GridService', function(TileModel) { // ... // Get all the available tiles this.availableCells = function() { var cells = [], self = this; this.forEach(function(x,y) { var foundTile = self.getCellAt({x:x, y:y}); if (!foundTile) { cells.push({x:x,y:y}); } }); return cells; }; // ...
列出了游戏板上所有可用的坐标,我们可以简单地从这个数组中选择一个随机的位置。我们的 randomAvailableCell() 函数将为我们处理这些。我们可以用几种不同的方式来实现。这里显示我们在2048中的实现。
.service('GridService', function(TileModel) { // ... this.randomAvailableCell = function() { var cells = this.availableCells(); if (cells.length > 0) { return cells[Math.floor(Math.random() * cells.length)]; } }; // ...
在这里,我们可以简单地创建一个新的TileModel实例并插入到我们的 this.tiles 数组中。
.service('GridService', function(TileModel) { // ... this.randomlyInsertNewTile = function() { var cell = this.randomAvailableCell(), tile = new TileModel(cell, 2); this.insertTile(tile); }; // Add a tile to the tiles array this.insertTile = function(tile) { var pos = this._coordinatesToPosition(tile); this.tiles[pos] = tile; }; // Remove a tile from the tiles array this.removeTile = function(pos) { var pos = this._coordinatesToPosition(tile); delete this.tiles[pos]; } // ... });
现在,由于我们使用了 Angular ,我们的方块在我们的视图中将只是魔法般的显示为游戏板上的拼贴。
”记住,下一步要做的是写测试来测试我们关于函数的假设。我们在为这个项目写测试时发现几个bug,你也会发现。
键盘互锁
好了,现在在游戏板上有了我们的拼贴块。有趣的是一个游戏你不能玩?让我们转换注意力到在游戏里添加互动。
这篇文章的目的,我们只关注游戏板交互,把触摸操作放在一边。不过,添加触摸动作应该不难,特别是我们只对滑动感兴趣,这是 ngTouch 提供的。我们不管这个先管实现。
游戏本身通过使用箭头键(或a,w,s,d键)操作。在我们的游戏中,我们想要允许用户简单的在页面上与游戏交互。与要求用户关注游戏板元素(或任何其他页面上的元素,就此而言)相反。这将允许用户只关注文档与游戏交互。
为了允许用户的这种交互类型,添加一个事件监听器到文档。在Angular中,我们将“绑定”我们的事件监听器和由Angular提供的 $document 服务。为了处理定义用户交互,我们将在一个服务中封装我们的键盘事件绑定。记住,我们在页面中只需要一个键盘处理器,所以一个服务是最好的。
另外,我们也希望在我们检测到用户键盘操作时,设置自定义动作发生。使用一个服务将允许我们自然的添加它到我们的angular对象并根据用户输入产生动作。
首先,我们创建一个新的模块(就像我们所做的基于模块的开发),在 app/scripts/keyboard/keyboard.js 文件(如果之前不存在,我们需要创建它)中叫做 Keyboard。
// app/scripts/keyboard/keyboard.js angular.module('Keyboard', []);
对于我们创建的任何新的 JavaScript,我们需要在我们的 index.heml 文件中引用。现在的 <script> 标签列表看起来像这样:
<!-- body --> <script src="scripts/app.js"></script> <script src="scripts/grid/grid.js"></script> <script src="scripts/grid/grid_directive.js"></script> <script src="scripts/grid/tile_directive.js"></script> <script src="scripts/keyboard/keyboard.js"></script> <script src="scripts/game/game.js"></script> </body> </html>
而由于我们创建了一个新的模块,我们也将需要告诉我们的Angular模块,我们想把这个新模块用作我们自己的应用程序的依赖项:
.module('twentyfourtyeightApp', ['Game', 'Grid', 'Keyboard'])
这个键盘服务背后的意思是,我们将在$document上绑定keydown事件,来捕获来自文档中的用户交互组件。在另外一端的我们的angular对象中,我们会将事件处理函数进行注册,而后它就可以在用户交互发生时被调用.
让我们开始吧.
// app/scripts/keyboard/keyboard.js angular.module('Keyboard', []) .service('KeyboardService', function($document) { // Initialize the keyboard event binding this.init = function() { }; // Bind event handlers to get called // when an event is fired this.keyEventHandlers = []; this.on = function(cb) { }; });
init() 函数会让 KeyboardService 开始侦听键盘事件. 我们将会过滤掉不感兴趣的键盘事件.
对于我们感兴趣的任何事件触发,我们将会组织默认动作的运行,并将该事件派发到我们的keyEventHandlers.
我怎么知道什么事件是我们感兴趣的呢?因为我们只对有限数量的键盘事件感兴趣,所以我们可以通过用我们感性的其中一个键盘事件来进行检查确认.
当箭头按键被按下的时候,文档对象会收到一个事件,这个事件带上了被按下的键盘按键的按键编码.
我们可以创建一个这些事件的映射表,然后检查键盘动作在这个关注映射表中的存在.
// app/scripts/keyboard/keyboard.js angular.module('Keyboard', []) .service('KeyboardService', function($document) { var UP = 'up', RIGHT = 'right', DOWN = 'down', LEFT = 'left'; var keyboardMap = { 37: LEFT, 38: UP, 39: RIGHT, 40: DOWN }; // Initialize the keyboard event binding this.init = function() { var self = this; this.keyEventHandlers = []; $document.bind('keydown', function(evt) { var key = keyboardMap[evt.which]; if (key) { // An interesting key was pressed evt.preventDefault(); self._handleKeyEvent(key, evt); } }); }; // ... });
任何时候keyboardMap中的按键触发了 keydown 事件, KeyboardService 都会运行 this._handleKeyEvent 函数.
这个函数的全部责任就是调用每个时间处理器中注册了的每一个按键处理函数. 它将会简单的遍历按键处理函数的数组,包括按键事件和原始的事件,每一个都调用一遍:
// ... this._handleKeyEvent = function(key, evt) { var callbacks = this.keyEventHandlers; if (!callbacks) { return; } evt.preventDefault(); if (callbacks) { for (var x = 0; x < callbacks.length; x++) { var cb = callbacks[x]; cb(key, evt); } } }; // ...
另外,我们只需要将我们的处理器函数放到我们的处理器列表中就可以了.
// ... this.on = function(cb) { this.keyEventHandlers.push(cb); }; // ...
使用Keyboard服务
现在我们已经有能力观察来自用户的键盘事件, 我们需要在我们的应用启动时启动它. 因为我们是把它作为服务创建的,我们可以简单的在主控制器中做这些事情.
首先,我们将需要调用init()函数启动在键盘上的监听. 然后,我们将要把我们的处理器函数注册到GameManager 对 move() 函数的调用上.
回到我们的GameController, 我们将新增 newGame() 和 startGame() 函数. newGame() 函数将简单的调用游戏服务来创建一个新的游戏,并启动键盘事件处理程序.
然我们来看看代码!我们需要为我们应用程序注入作为新的模块依赖的Keyboard模块:
angular.module('twentyfourtyeightApp', ['Game', 'Keyboard']) // ...
现在我们就可以吧 KeyboardService 注入到我们的 GameController 并在发生用户交互时启动. 首先是 newGame() 方法:
// ... (from above) .controller('GameController', function(GameManager, KeyboardService) { this.game = GameManager; // Create a new game this.newGame = function() { KeyboardService.init(); this.game.newGame(); this.startGame(); }; // ...
我们还没有在GameManager上定义newGame()方法, 很快我们就会充实它.
当我们把新游戏创建好,我们会调用 startGame(). startGame() 函数将会设置键盘服务事件处理器:
.controller('GameController', function(GameManager, KeyboardService) { // ... this.startGame = function() { var self = this; KeyboardService.on(function(key) { self.game.move(key); }); }; // Create a new game on boot this.newGame(); });
按下开始按钮
我们已经做了很多工作来让自己达到这样一个里程碑:开始游戏. 我们需要实现的最后一个方法就是GameManager里面的newGame()方法:
-
构建一个空的游戏面板d
-
设置开始位置
-
初始化游戏
我们已经在我们的GridService里面实现了这一逻辑, 因此现在只是要想办法把它给挂上去了!
在我们的 app/scripts/game/game.js 文件中,让我们来添加这个 newGame() 函数. 这个函数将会把我们的游戏统计重设到预期的开始条件:
angular.module('Game', []) .service('GameManager', function(GridService) { // Create a new game this.newGame = function() { GridService.buildEmptyGameBoard(); GridService.buildStartingPosition(); this.reinit(); }; // Reset game state this.reinit = function() { this.gameOver = false; this.win = false; this.currentScore = 0; this.highScore = 0; // we'll come back to this }; });
在我们的浏览器汇总加载好这个页面,我们将得到一个网格… 因为我们还没有定义任何移动功能,所有现在看起来还相当的令人乏味.
让你的游戏动起来 (游戏主循环)
现在让我们来深入研究一下我们游戏的实际功能是怎么实现的. 当用户按下任何方向键, 我们会调用GridService上的move()函数(我们曾在GameController里面创建了这个函数).
为了构建 move() 函数, 我们将需要定义游戏约束. 即,我们需要定义在每一个动作上我们的游戏将如何反应.
对于每一个动作,我们需要:
-
确定用户的方向键指示的向量.
-
为面板上的每一个小块找到其所有的最远可能位置。同时,拿下一个位置的方块比较看看我们是不是能够把它们合并.
-
对于每一个方块,我们将会想要确认是否有下一个带有相同值的方块存在.
-
如果该方块已经是合并后的结果了,那我们就把它认为是已经用过了的,并跳过它.
-
如果方块还没有合并过,那么我就要把它认为是可以合并的.
-
如果不存在下一个方块,那么我们就只要将方块移动到最远的位置上就行了. (这意味着是面板上的最远端).
-
如果存在下一个方块:
-
并且下一个方块的值是跟当前方块不同的值,那么我们就将方块平铺到最远的位置(下一个方块的位置是当前方块移动的边界).
-
并且下一个方块的值是跟当前方块相同的值,那么我们就找到了一个可能的合并.
-
在最终的位置添加一个以合并数为其值的新方块
-
移除旧的方块
-
更新游戏的得分
-
检查是否产生了获胜的方块值
-
想面板添加一个新的方块
-
检查我们是否需要显示游戏结束 gameOver 帧
-
当前游戏的得分
-
玩家的历史最高分
现在我们已经把功能定义好了,我们就可以制定构建move()函数的策略了.
angular.module('Game', []) .service('GameManager', function(GridService) { // ... this.move = function(key) { var self = this; // Hold a reference to the GameManager, for later // define move here if (self.win) { return false; } }; // ... });
对于移动有几个条件需要考虑:如果游戏结束了,并且我们已经以某种方式结束了游戏循环,我们将简单的返回并继续循环.
接下来我们将需要遍历整个网格,找出所有可能的位置. 由于网格有责任了解那个位置是打开的, 我们将在GridService上创建一个新的函数,以帮助我们找出所有可能的遍历位置.
为了找出方向,我们将需要挑选出用户按键所指示的向量. 例如,如果用户按下右方向键,那么将是想要往x轴增长的方向移动.
如果用户按下了上方向键,那么用户是想方块往y轴减少的方向移动. 我们可以使用一个JavaScript对象将我们的向量映射到用户所按下的键(我们可以从KeyboardService获取到), 向下面这样:
// In our `GridService` app/scripts/grid/grid.jsvar vectors = { 'left': { x: -1, y: 0 }, 'right': { x: 1, y: 0 }, 'up': { x: 0, y: -1 }, 'down': { x: 0, y: 1 }};
现在我们将简单的遍历所有可能的位置,使用向量来决定我们想要遍历潜在位置的方向:
.service('GridService', function(TileModel) { // ... this.traversalDirections = function(key) { var vector = vectors[key]; var positions = {x: [], y: []}; for (var x = 0; x < this.size; x++) { positions.x.push(x); positions.y.push(x); } // Reorder if we're going right if (vector.x > 0) { positions.x = positions.x.reverse(); } // Reorder the y positions if we're going down if (vector.y > 0) { positions.y = positions.y.reverse(); } return positions; }; // ...
现在随着我们新的 traversalDirections() 被定义,我们可以遍历move()函数中所有可能的移动了。回到我们的GameManager, 我们将使用这些潜在的为哈子让网格里面的方块跑起来.
// ... this.move = function(key) { var self = this; // define move here if (self.win) { return false; } var positions = GridService.traversalDirections(key); positions.x.forEach(function(x) { positions.y.forEach(function(y) { // For every position }); }); }; // ...
现在在我们的位置循环中,我们将遍历所有可能位置,并找出位置中现有的方块。从这里开始我们将开始像功能的第二部分进发,找出从方块出发所有更远处的位置:
// ... // For every position // save the tile's original position var originalPosition = {x:x,y:y}; var tile = GridService.getCellAt(originalPosition); if (tile) { // if we have a tile here var cell = GridService.calculateNextPosition(tile, key); // ... }
如果我们找到了一个方块,我们将开始从这个方块开始寻找最远的可能位置. 为此,我们将一步一步遍历网格的下一个位置,检查下一个格子是否在网格的边界以内,还有是否这个网格单元所在的位置是空的(也就是还没有方块).
如果这个网格单元是空的并且在网格的边界之内,那么我们将继续转移到下一个网格单元并检查同样的条件.
如果这两个条件有一个没有满足,那么我们就可能找到了网格的边界,或者是找到了下一个单元. 我们将吧下一个位置作为新的位置newPosition保存,并且获取到下一个单元(不管它是否存在).
由于这个过程设计到网格,所以我们将把这个函数放到GridService里面:
// in GridService // ... this.calculateNextPosition = function(cell, key) { var vector = vectors[key]; var previous; do { previous = cell; cell = { x: previous.x + vector.x, y: previous.y + vector.y }; } while (this.withinGrid(cell) && this.cellAvailable(cell)); return { newPosition: previous, next: this.getCellAt(cell) }; };
现在我们就可以为我们的 方块计算下一个可能的位置了,我们还可以检查潜在的合并.
合并被定义成一个方块融入另一个值与之相同的方块. 我们将检查是否下一个位置有相同值的方块,还有之前它是否已经合并过.
// ... // For every position // save the tile's original position var originalPosition = {x:x,y:y}; var tile = GridService.getCellAt(originalPosition); if (tile) { // if we have a tile here var cell = GridService.calculateNextPosition(tile, key), next = cell.next; if (next && next.value === tile.value && !next.merged) { // Handle merged } else { // Handle moving tile } // ... }
现在,如果下一个位置不满足条件,俺么我们就只要让方块从当前位置向下一个位置进行简单的移动就行了(代码中的else语句).
这是其中比较容易处理的情况,我们要做的就是将方块移动到新的位置newPosition.
// ... if (next && next.value === tile.value && !next.merged) { // Handle merged } else { GridService.moveTile(tile, cell.newPosition); }
移动方块
如果可能会猜想到的,moveTile()方法是一个最有可能被定义在GridService中的操作.
移动方面就是简单的更新方块在数组中的位置,还有就是更新TileModel.
如我们已经定义的,有两个单独的操作用于分开的两个目的. 当我们要:
移动数组中的方块
GridService数组会从后端开始映射方块的定位. 数组中方块的位置并没有被绑定到网格的位置上.
更新TileModel上的位置
我们会为前端放置方块的CSS更新坐标.
简而言之:为了保持对后端方块的跟踪,我们将需要更新GridService中的 this.tilesarray 并更新方块对象的位置.
而moveTile() 就编程了简单的两步操作 :
// GridService // ... this.moveTile = function(tile, newPosition) { var oldPos = { x: tile.x, y: tile.y }; // Update array location this.setCellAt(oldPos, null); this.setCellAt(newPosition, tile); // Update tile model tile.updatePosition(newPosition); };
现在我们将需要定义我们的 tile.updatePosition() 方法. 方法并不像它听起来的那样,它只是简单的更新了模型自身的x和y坐标:
.factory('TileModel', function() { // ... Tile.prototype.updatePosition = function(newPos) { this.x = newPos.x; this.y = newPos.y; }; // ... });
回到我们的GridService, 我们可以简单的调用 .moveTile() 来同时更新GridService.tiles 数组和方块自身上面的位置.
合并方块
现在我们已经处理的较简单的情况,而合并方块也就成了接下来我们需要处理的问题。合并是这样被定义的:
合并发生在某个方块在下一个位置遇到值与之相同的另一个方块的时候.
当一个方块被合并,它就移动的面板并更新当前游戏的得分以及(在必要的时候)最高得分.
合并需要下面这几步:
分解下来,合并操作就成了一些需要处理的简单操作.
// ... var hasWon = false; // ... if (next && next.value === tile.value && !next.merged) { // Handle merged var newValue = tile.value * 2; // Create a new tile var mergedTile = GridService.newTile(tile, newValue); mergedTile.merged = [tile, cell.next]; // Insert the new tile GridService.insertTile(mergedTile); // Remove the old tile GridService.removeTile(tile); // Move the location of the mergedTile into the next position GridService.moveTile(merged, next); // Update the score of the game self.updateScore(self.currentScore + newValue); // Check for the winning value if (merged.value >= self.winningValue) { hasWon = true; } } else { // ...
因为我们只想支持每行一个单独的方块移动(那就是如果我们有两个可能的合并,那么每行只会有一个合并会发生), 我们还需要保持对已经合并的方块的跟踪. 我们为此定义了.merged标识.
在我们放下对这个函数的关注之前,我们使用了两个还有没有定义好的函数.
GridService.newTile() 方法创建了一个新的TileModel对象。我们在GridService中的这个操作只是简单的包含我们所创建的新方块的位置:
// GridService this.newTile = function(pos, value) { return new TileModel(pos, value); }; // ...
我们将回到 self.updateScore() 方法一小会儿. 现在,我们有足够多的信息知道它更新了游戏的分值(如方法名称所示).
方块移动之后
我们只想尽在做出一次有效的移动之后才添加新的方块,因此我们将需要去检查看看是否实际真的发生了任何从一个方块到另一个方块的移动.
var hasMoved = false; // ... hasMoved = true; // we moved with a merge } else { GridService.moveTile(tile, cell.newPosition); } if (!GridService.samePositions(originalPos, cell.newPosition)) { hasMoved = true; } // ...
在所有的方块都已经移动(或者尝试着要被移动)之后,我们将检查游戏是否已经被完成。如果游戏实际上已经结束了,我们就将设置游戏上的self.win.
我们会在当我们有一个方块碰撞的时候移动,因此在合并的条件下,我们将简单的把 hasMovedvariable 设置成 true.
最后,我们将会检查面板上是否有任何的移动发生. 如果有,我们将:
if (!GridService.samePositions(originalPos, cell.newPosition)) { hasMoved = true; } if (hasMoved) { GridService.randomlyInsertNewTile(); if (self.win || !self.movesAvailable()) { self.gameOver = true; } } // ...
重设方块
在我们运行任意一次主游戏循环之前,我们将需要重设每一个方块,比如我们不在需要跟踪他们的合并状态. 即,每次我们要做出单个的移动时,都要将之前的状态清除,让每一个方块都能再次移动. 为此,在移动的循环开始处,我们将会调用:
GridService.prepareTiles();
GridService中的prepareTiles()方法简单的遍历了所有的方块并重设了它们的状态:
this.prepareTiles = function() { this.forEach(function(x,y,tile) { if (tile) { tile.reset(); } }); };
保留分值
回到 updateScore() 方法 ; 游戏本身需要跟踪两个分值:
当前得分 currentScore 只是一个简单的变量,我们将在每一次游戏的内存中对它进行跟踪. 也就是说我们不需要任何特殊的方式来处理它.
历史最高分 highScore, 是一个我们会持久化的变量. 我们有几种方法来处理这个问题,使用本地存储 localstorage, cookies, 或者是两者的结合.
因为cookie是两种方式中最简单,也是在跨浏览器时最安全的一种方法, 因此我们也就采用把我们的最高分 highScore 设置到一个cookie中.
在Angular中访问cookie的最简单方式是使用 angular-cookies 模块.
为了使用这个模块,我们将需要从 angularjs.org 下载它,或者使用包管理器,比如bower,来安装它.
$ bower install --save angular-cookies
像往常一样,我们需要在index.html中引用脚本,并对应用上的设置模块级依赖 ofngCookies .
我们将向下面这样更新我们的 app/index.html :
<script src="bower_components/angular-cookies/angular-cookies.js"></script>
现在就是要把 ngCookies 模块作为模块级依赖添加进去 (在我们将要引用cookie的 Game 模块上):
angular.module('Game', ['Grid', 'ngCookies'])
设置好对ngCookies的依赖,我们就可以将 $cookieStore 服务注入到我们的 GameManager服务中去了. 而我们现在就可以在我们用户的浏览器上获取和设置cookie了.
例如,为了获取用户最近的最高得分,我们将编写一个函数来为我们从用户的cookie中获取它:
this.getHighScore = function() { return parseInt($cookieStore.get('highScore')) || 0; }
回到GameManager类上的updateScore()方法, 我们将更新本地的当前得分. 如果当前得分比我们之前的最高得分还要高,那我们就将更新最高得分的cookie.
this.updateScore = function(newScore) { this.currentScore = newScore; if (this.currentScore > this.getHighScore()) { this.highScore = newScore; // Set on the cookie $cookieStore.put('highScore', newScopre); } };
解决对方块的跟踪问题
现在我们可以让方块显示在屏幕上了,但是屏幕上会出现一个问题,那就是一些奇怪的行为会让我们得到重复的方块. 此外,我们的方块也会出现在不可预期的位置.
这个问题的原因是Angular只知道方块是被赋予了一个唯一的标识,然后被放在方块数组中的. 我们在视图中设置了这个唯一的标识符,作为数组中方块的 $index(也就是它在数组中的索引,或者说位置). 因为我们会在数组中到处移动方块,所以$index不再能够对具有唯一标识的方块进行跟踪. 我们需要一个不同的跟踪方案.
<div id="game"> <!-- grid-container --> <div class="tile-container"> <div tile ng-model='tile' ng-repeat='tile in ngModel.tiles track by $index'></div> </div> </div>
我们将会通过方块自身唯一的uuid来对其进行跟踪,而不是依赖于数组来识别方块的位置. 创建我们自己的唯一标识将确保angular可以讲方块数组中的方块作为它们自己的唯一对象进行对待. Angular 将会把识别唯一的表示,并把方块看做是它自身的对象, 只要保证方块唯一的uuid没有发生变化就行.
当我们创建一个新的实体是,我们就能使用TileModel很容易的实现一个唯一的标识方案. 我们也可以想出我们自己的创意来创建唯一的标识.
只要我们创建的每一个 TileModel 实体都是唯一的,我们如何生成唯一性id都无所谓.
为了创建一个唯一的id,我们跳转到 StackOverflow, 找到 rfc4122-compliant,一个全局的唯一标识生成器,并用一个单独的方法next()将这个算法封装成一个工厂:
.factory('GenerateUniqueId', function() { var generateUid = function() { // http://www.ietf.org/rfc/rfc4122.txt var d = new Date().getTime(); var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = (d + Math.random()*16)%16 | 0; d = Math.floor(d/16); return (c === 'x' ? r : (r&0x7|0x8)).toString(16); }); return uuid; }; return { next: function() { return generateUid(); } }; })
为了使用这个 GenerateUniqueId 工厂, 我们可以将它注入,并调用 GenerateUniqueId.next() 来创建新的uuid. 回到我们的 TileModel, 我们可以为(构造器中的)实体创建一个唯一的id了:
// In app/scripts/grid/grid.js // ... .factory('TileModel', function(GenerateUniqueId) { var Tile = function(pos, val) { this.x = pos.x; this.y = pos.y; this.value = val || 2; // Generate a unique id for this tile this.id = GenerateUniqueId.next(); this.merged = null; }; // ... });
现在我们的每一个方块都有了一个唯一的标识符, 我们可以告诉Angular通过这个id而不是 $index进行跟踪.
<!-- ... --> <div tile ng-model='tile' ng-repeat='tile in ngModel.tiles track by $id(tile.id)'></div> <!-- ... -->
这一方案只有一个问题。因为我们是(明确的)使用null初始化我们的数组的,并且我们会用null来重设数组(而不是 sort 或者 resize 这个数组), Angular就会不管不顾的将null作为对象来进行跟踪. 因为null值并没有唯一的id,因此这就将会造成我们的浏览器抛出一个错误,而且浏览器它也没有办法处理重复的对象.
因此,我们可以使用一个内置的angular工具来跟踪唯一id,还有对象的$index位置(null 值对象可以用它们在数组中的位置进行跟踪,因为每一个位置只会有一个). 我们可以像下面这样通过修改网格指令的视图来计算出null对象:
<!-- ... --> <div tile ng-model='tile' ng-repeat='tile in ngModel.tiles track by $id(tile.id || $index)'></div> <!-- ... -->
这个问题可以通过依赖数据架构的一种不同的实现来解决, 比如在一个迭代器中查找每一个TileModel的位置,而不是依赖于方块数据的索引,或者是在每次发生变化(或者执行了一次$digest())时都对数组重新组合一次. 为了简单明了起见,我们已经用数组对其进行了实现,而这是唯一一个我们需要针对这个实现进行处理的副作用.
我们赢了吗?!??游戏结束了
当我们输掉2048原作游戏时,一个游戏结束的提示框滑入屏幕,它允许我们重新开始游戏,并且在推ter上关注游戏的创建者。这不光是一个呈现给玩家的炫酷效果,它还介绍了一种中断游戏运行的好方法。
我们可以用一些基本地angular技术轻易的创建这种效果。我们已经在GameManager中用gamOver变量来记录游戏是否结束。我们可以创建一个<div>标签来包含游戏结束提示框,并且在游戏方格中以绝对坐标给它定位。这种技术(和Angular)的神奇的地方就在于简单地就可以实现如此功能,并且还没有任何的花招:
我们可以简单地创建一个<div>元素来包含游戏结束或胜利时的消息,并且根据游戏的状态呈现出来。举个例子,游戏结束提示框像这样:
<!-- ... --> <div id="game-container"> <div grid ng-model='ctrl.game' class="row"></div> <div id="game-over" ng-if="ctrl.game.gameOver" class="row game-overlay"> Game over <div class="lower"> <a class="retry-button" ng-click='ctrl.newGame()'>Try again</a> </div> </div> <!-- ... -->
比较难的一部分是处理样式。比较高效的做法是,我们只是将元素放置到游戏方格中的一个绝对位置上,然后由浏览器去完成布局的工作。这是与样式(注意,完事的CSS样式可以到下面的github链接中找到)相关的一部分:
.game-overlay { width: $width; height: $width; background-color: rgba(255, 255, 255, 0.47); position: absolute; top: 0; left: 0; z-index: 10; text-align: center; padding-top: 35%; overflow: hidden; box-sizing: border-box; .lower { display: block; margin-top: 29px; font-size: 16px; } }
我们能够以相同的方法来实现胜利时的提示框,只需要创建一个表示胜利的.game-overlay元素即可。
动画
2048游戏原作中一个令人印象深刻的一点是方块看上去神奇的从一个位置滑到下一个位置,并且游戏结束或者胜利时的提示框很自然的出现在了屏幕上。当我们使用Angular时,我们可以免费实现几乎一模一样的效果(感谢CSS)
实际上,我们已经建立起了游戏,这样我们创建滑动、显现、展现等动画效果就很容易实现。我们(几乎)没有用JavaScript来实现它们。
对 CSS 定位进行动画处理(即添加方块滑动)
当我们使用position-[x]-[y]类,通过CSS定位方格时,一旦在方格上设置了一个新位置,DOM元素将会添加一个新类position-[newX]-[newY],同时移除旧类position-[oldX]-[oldY]。在这种情况下,我们可以通过在.tile类上定义一个CSS过渡,简单地定义默认的滑动动作发生在CSS类本身。
相关的SCSS如下:
.tile { @include border-radius($tile-radius); @include transition($transition-time ease-in-out); -webkit-transition-property: -webkit-transform; -moz-transition-property: -moz-transform; transition-property: transform; z-index: 2; }
定义好了CSS过渡,滑块现在可以轻松地在一个位置和新位置之间滑动了。(是的,真的就是如此简单。)
让结束画面动起来
现在,让我们在动画上找些 乐子,试试 ng-Animate 模块。这是 angular 框架一个开箱即用的模块。
在写代码前,需要首先安装ng-Animate。有两个方法,一是直接从 angularjs.org 下载,一是用包管理器(例如 bower)安装。
$ bower install --save angular-animate
照例,要在我们的 HTML 文件中引用这个脚本,这样浏览器才能载入模块。修改 index.html 文件载入 angular-animate.js:
<script src="bower_components/angular-animate/angular-animate.js"></script>
像任何其他 angular 模块一样,我们需要告诉 angular 框架我们的模块需要依赖 angular-animate。 只需修改 app/app.js 文件的依赖数组即可:
angular .module('twentyfourtyeightApp', ['Game', 'Grid', 'Keyboard', 'ngAnimate', 'ngCookies']) // ...
ngAnimate模块
尽管深入地讨论ngAnimate超出了本文的范围(查看ng-book来深入了解它如何工作),我们仅仅简单地了解它如何工作,以便可以为我们的游戏实现动画效果。
ngAnimate作为一个独立的模块,angular任何时候在一个相关的指令中添加一个新的对象(到我们的游戏中),都将给它附值一个CSS类(免费)。我们可以使用这些类来为我们游戏中的不同组件添上动画效果:
命令 | 进入类 | 离开类 |
---|---|---|
ng-repeat | ng-enter | ng-leave |
ng-if | ng-enter | ng-leave |
ng-class | [className]-add | [className]-remove |
当一个元素被添加进入ng-repeat命令的范围,新的DOM元素将会自动地被附上ng-enter这个CSS类。然后,当它真正被添加到视图上去后,将添加上ng-enter-active这个CSS类。这是很重要的,因为它将允许我们在ng-enter类里构建我们希望的动画效果,并且在ng-enter-active类里设置动画的样式。这个功能和ng-leave在元素从ng-repeat迭代指令中移除时起到的效果一样。
当一个新的CSS类从一个DOM元素上被添加(或被移除)时,相应的CSS类[classname]-add和[classname]-add-active将被添加到这个DOM元素上。这里我们再一次在相应的类里设置我们的CSS动画。
让游戏结束的提示画面动起来
我们可以使用ng-enter类让游戏结束或者游戏胜利时的提示画面以动画效果呈现出来。记住,.game-overlay这个类被隐藏起来了,需要用ng-if指令来显示它。当ng-if条件改变时,ngAnimate将会在表达式值为真时添加.ng-enter和.ng-enter-active类(或者angular移除这个元素时添加.ng-leave和.ng-leave-active)。
我们将在.ng-enter类中构建动画,然后在.ng-enter-active类里面启动它。相关的SCSS如下:
.game-overlay { // ... &.ng-enter { @include transition(all 1000ms ease-in); @include transform(translate(0, 100%)); opacity: 0; } &.ng-enter-active { @include transform(translate(0, 0)); opacity: 1; } // ... }
所有的SCSS都可以在文章最后的github链接中找到。
自定义场景
假如我们想要创建一个不同大小的游戏板。比如说,2048游戏原作是一个4x4的格子,那如果我们想要创建一个3x3或者6x6的游戏板呢?我们可以轻易地做到而不需要改动很多代码。
游戏板本身被SCSS创建和放置,并且格子在.GridService中被管理。那样,我们需要对这两个地方做出修改来让我们可以创建自定义的游戏板。
动态 CSS
那好,我们不是真正需要用到动态CSS,而是创建一个我们真正需要的CSS类。我们能够动态的创建DOM元素标记,它允许动态地设置格子,而不是创建一个单独的#game标记。换句话说,我们创建一个3x3的游戏板,将它嵌套在一个ID为#game-3和ID为#game-6的DOM元素中。
我们能够在已经存在的动态SCSS外部创建一个混合类。通过简单地找到#game这个样式ID,然后将它封装到mixin里面。例如:
@mixin game-board($tile-count: 4) { $tile-size: ($width - $tile-padding * ($tile-count + 1)) / $tile-count; #game-#{$tile-count} { position: relative; padding: $tile-padding; cursor: default; background: #bbaaa0; // ... }
现在我们可以包含这个game-board混合类来动态创建一个样式表,它包含了多种版本的游戏板,每一种由它们相应的#game-[n]标记来区别。
为了构建多版本的游戏板,我们可以简单地迭代所有我们希望创建的游戏板,然后调用这个混合类。
$min-tile-count: 3; // lowest tile count $max-tile-count: 6; // highest tile count @for $i from $min-tile-count through $max-tile-count { @include game-board($i); }
动态GridService
现在我们有自己的CSS封装类来创建多种大小的游戏板,我们需要修改我们的GridService,这样我们能够在程序启动时设置方格的大小。
Angular 让这个过程相当容易。首先,我们需要让我们的GridService成为一个provider,而不是一个直接的service。如果你不了解service和provider之间的差别,查看mg-book作深入的研究。简单来说,一个provider允许我们在启动前配置它。
另外,我们需要修改构造函数,在provider上设置为$get方法:
.provider('GridService', function() { this.size = 4; // Default size this.setSize = function(sz) { this.size = sz ? sz : 0; }; var service = this; this.$get = function(TileModel) { // ...
我们模块上任何不在$get中方法在.config()函数中都可用。$get函数中的任何东西对于运行中的程度来说都是可用的,但在.config()里的就不可用。
这就是所有我们需要做的事来让游戏板的大小成为动态的。现在,让我们创建一个6x6的游戏板,而不是默认的4x4。在我们程式里的.config()函数中,我们能够调用GridServiceProvider来设置大小:
angular .module('twentyfourtyeightApp', ['Game', 'Grid', 'Keyboard', 'ngAnimate', 'ngCookies']) .config(function(GridServiceProvider) { GridServiceProvider.setSize(4); })
当创建一个provider时,Angular动态地创建一个config-time模块,它可以让我们可以用:[serviceName]Provider为名字注入进去。
演示地址
完整的例子地址如下: http://ng2048.github.io/.
结论
啧啧! 我们希望你享受整个使用Angular来创建2048游戏的过程. 在这个话题上有很多评论. 如果你也喜欢,请在下方留下评论. 如果你对Angular感兴趣了, 看看我们的书Complete Book on AngularJS. 这本书涵盖了所有你需要知道关于AngularJS的知识并且坚持不断更新。
感谢
非常感谢 Gabriele Cirulli 发明了这了不起 (并且会上瘾)的2048游戏以及 给这篇文章带来的灵感。在这篇文章里面很多想法都是为围绕着游戏本身以及如何构建它来描述的。
完整的源码
游戏完整的源码可以从该地址获取 http://d.pr/pNtX. 在本地构建,只需要clone源码并且运行
$ npm install $ bower install $ grunt serve
故障排除
如果你在构建 npm install时候遇到麻烦, 先保证你有最新版本的node.js以及npm.
本文仓库源码测试运行在 nodev0.10.26 以及npm1.4.3.
这里有个好的方法去获取一个最新的node版本是通过 n 节点版本管理:
$ sudo npm cache clean -f $ sudo npm install -g n $ sudo n stable