如何利用AngularJS开发2048游戏
这期内容当中小编将会给大家带来有关如何利用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.在这个应用中,不需要多视图,所以我们创建单一的
在我们的主文件app/index.html中,我们需要包含所有的依赖项(包括angular.js自身和JS文件,即scripts/app.js),如下:
2048
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中清楚地知道我们期待在哪里查询数据,哪个控制器负责哪个组件。
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.
在视图中,我们要显示以下一些项目:
游戏静态头
当前游戏分数和本地用户最高分
游戏板
游戏静态头可以这样来完成:
ng-2048
{{ ctrl.game.currentScore }}{{ ctrl.game.highScore }}
注意到,当在视图中引用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实例:
编写这个指令,使它能包含在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来跟踪遍历的结果。
可以看到第一个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单地迭代每一种颜色,并且动态地基于这个方块的数值来创建一个类。也就是说,当一个方块的值是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数组中的方块对象。
{{ ngModel.value }}使用这条基础指令,我们几乎就要把它显示在屏幕上了。所有以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 文件中引用。现在的