【51CTO.com快译】
简介
Horizon是一个著名的跨平台可扩展的后端框架,适用于构建跨平台基于JavaScript的移动应用程序,尤其是那些需要实时功能的应用。这个框架是由来自RethinkDB产品的程序员开发的,因此使用RethinkDB作为默认数据库。如果你还不熟悉RethinkDB,那么你只需知识它是一个开放源码的支持实时功能的数据库(https://www.rethinkdb.com)。
Horizon框架公开一组客户端API来允许你与底层数据库进行交互。这意味着,你不必编写任何后端代码。你要做的就是,搭建一个新的服务器,运行它,Horizon将会自动管理其他内容。借助于Horizon,你可以轻松地实现实时连接的客户端和服务器之间的数据同步。
如果你想要了解更多关于Horizon的消息,请查阅其 faq页面(http://horizon.io/faq/)。
在本教程中,你要使用Icon和Horizon来协同开发一个Tic-Tac-Toe井字游戏。因此,阅读本文的前提是假定你已经了解Icon和Horizon,所以我不打算解释程序中Icon相关的特定代码。当然,如果你想要一点有关Icon的背景知识的话,我建议你去查阅这个网址http://ionicframework.com/getting-started/。如果你想继续阅读本文内容,那么请你先下载文章的示例工程源码(https://github.com/anchetaWern/ionic-horizon-tictactoe)。
下图给出的是本文示例应用程序最终的结果快照。
安装Horizon
RethinkDB用作Horizon的数据库。因此,在安装Horizon之前你需要先安装RethinkDB。有关安装RethinkDB的具体信息,你可以从网址https://www.rethinkdb.com/docs/install/处找到答案。
一旦安装了RethinkDB,你就可以在终端程序中执行以下命令通过npm工具来安装Horizon:
npm install -g horizon
Horizon服务器开发
Horizon服务器用作应用程序的后端。每当应用程序执行代码时,它要与数据库进行通信。
您可以通过在您的终端执行以下命令来创建一个新的Horizon服务器:
hz init tictactoe-server
这个命令将创建RethinkDB数据库并提供Horizon所使用的文件。
一旦创建了服务器,您可以通过执行以下命令运行它:
hz serve --dev
在上面的命令中,指定-dev作为一个选项。这意味着,你想要运行一个开发服务器。在开发服务器中会设置以下选项:
--secure no:这意味着websocket和文件不会通过加密连接提供服务。
--permissions no:禁用权限约束。这意味着,任何客户端都可以在数据库中执行任何他们想执行的操作。Horizon的权限系统基于白名单。这意味着,默认情况下,所有用户都没有权限来做任何事情。你必须显式地指定允许哪些操作。
--auto-create-collection yes:在首次使用时自动创建一个集合。在Horizon中,集合相当于关系数据库中的表。此选项设置为true意味着,每次客户端使用一个新的集合,它都会被自动创建。
--auto-create-index yes:在首次使用中自动创建索引。
--start-rethinkdb yes:在当前目录中自动启动RethinkDB的一个新实例。
--allow-unauthenticated yes:允许未经身份验证的用户来执行数据库操作。
--allow-anonymous yes:允许匿名用户执行数据库操作。
--serve-static ./dist:启用静态文件服务。如果你想要在浏览器中测试与Horizon API的交互时,这是很有用的。Horizon服务器默认运行在端口8181,所以你可以通过访问地址http://localhost:8181来访问服务器。
【注意】--dev选项永远不要用于生产环境下,因为它会打开大量的易于被攻击者能够利用的漏洞。
构建Ionic应用程序
现在,我们已经作好了充分准备。接下来,我们着手创建一个Ionic程序框架,命令如下:
ionic start tictactoe blank
安装Chance.js
接下来,您需要安装chance.js,这是一个JavaScript实用程序库,用于生成随机数据。在本应用程序中,我们使用它来为玩家生成一个唯一的ID。你可以通过bower工具并使用下面的命令来安装chance.js:
bower install chance
创建index.html
现在,打开文件www/index.html,并把其内容修改为如下:
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
- <title></title>
- <link href="lib/ionic/css/ionic.css" rel="stylesheet">
- <link href="css/style.css" rel="stylesheet">
- <!-- IF using Sass (run gulp sass first), then uncomment below and remove the CSS includes above
- <link href="css/ionic.app.css" rel="stylesheet">
- -->
- <!-- chance.js -->
- <script src="lib/chance/dist/chance.min.js"></script>
- <!-- ionic/angularjs js -->
- <script src="lib/ionic/js/ionic.bundle.js"></script>
- <!-- cordova script (this will be a 404 during development) -->
- <script src="cordova.js"></script>
- <!-- horizon script -->
- <script src="http://127.0.0.1:8181/horizon/horizon.js"></script>
- <!-- your app's js -->
- <script src="js/app.js"></script>
- <!--main app logic -->
- <script src="js/controllers/HomeController.js"></script>
- </head>
- <body ng-app="starter">
- <ion-nav-view></ion-nav-view>
- </body>
- </html>
上面的代码大部分来自于Icon空白向导模板生成的样板代码。现在,我们来添加对chance.js脚本的引用:
- <script src="lib/chance/dist/chance.min.js"></script>
Horizon服务器将自动提供Horizon脚本服务,代码如下:
- <script src="http://127.0.0.1:8181/horizon/horizon.js"></script>
【注意】如果你以后想部署这些内容的话,你必须修改URL。
接下来,主应用程序逻辑位于下面这个脚本文件中:
- <script src="js/controllers/HomeController.js"></script>
编写主程序app.js
文件app.js是运行初始化应用程序代码的地方。下面,需要打开文件www/js/app.js并把如下内容添加到run函数的下面:
- .config(function($stateProvider, $urlRouterProvider) {
- $stateProvider
- .state('home', {
- cache: false,
- url: '/home',
- templateUrl: 'templates/home.html'
- });
- // if none of the above states are matched, use this as the fallback
- $urlRouterProvider.otherwise('/home');
- });
这将为默认的应用程序页设置一个路由。此路由将指定页面所使用的模板和可以访问它的URL。
开发控制器程序HomeController.Js
现在,我们在路径www/js/controllers下创建一个控制器文件HomeController.js,并修改其代码如下:
- (function(){
- angular.module('starter')
- .controller('HomeController', ['$scope', HomeController]);
- function HomeController($scope){
- var me = this;
- $scope.has_joined = false;
- $scope.ready = false;
- const horizon = Horizon({host: 'localhost:8181'});
- horizon.onReady(function(){
- $scope.$apply(function(){
- $scope.ready = true;
- });
- });
- horizon.connect();
- $scope.join = function(username, room){
- me.room = horizon('tictactoe');
- var id = chance.integer({min: 10000, max: 999999});
- me.id = id;
- $scope.player = username;
- $scope.player_score = 0;
- me.room.findAll({room: room, type: 'user'}).fetch().subscribe(function(row){
- var user_count = row.length;
- if(user_count == 2){
- alert('Sorry, room is already full.');
- }else{
- me.piece = 'X';
- if(user_count == 1){
- me.piece = 'O';
- }
- me.room.store({
- id: id,
- room: room,
- type: 'user',
- name: username,
- piece: me.piece
- });
- $scope.has_joined = true;
- me.room.findAll({room: room, type: 'user'}).watch().subscribe(
- function(users){
- users.forEach(function(user){
- if(user.id != me.id){
- $scope.$apply(function(){
- $scope.opponent = user.name;
- $scope.opponent_piece = user.piece;
- $scope.opponent_score = 0;
- });
- }
- });
- },
- function(err){
- console.log(err);
- }
- );
- me.room.findAll({room: room, type: 'move'}).watch().subscribe(
- function(moves){
- moves.forEach(function(item){
- var block = document.getElementById(item.block);
- block.innerHTML = item.piece;
- block.className = "col done";
- });
- me.updateScores();
- },
- function(err){
- console.log(err);
- }
- );
- }
- });
- }
- $scope.placePiece = function(id){
- var block = document.getElementById(id);
- if(!angular.element(block).hasClass('done')){
- me.room.store({
- type: 'move',
- room: me.room_name,
- block: id,
- piece: me.piece
- });
- }
- };
- me.updateScores = function(){
- const possible_combinations = [
- [1, 4, 7],
- [2, 5, 8],
- [3, 2, 1],
- [4, 5, 6],
- [3, 6, 9],
- [7, 8, 9],
- [1, 5, 9],
- [3, 5, 7]
- ];
- var scores = {'X': 0, 'O': 0};
- possible_combinations.forEach(function(row, row_index){
- var pieces = {'X' : 0, 'O': 0};
- row.forEach(function(id, item_index){
- var block = document.getElementById(id);
- if(angular.element(block).hasClass('done')){
- var piece = block.innerHTML;
- pieces[piece] += 1;
- }
- });
- if(pieces['X'] == 3){
- scores['X'] += 1;
- }else if(pieces['O'] == 3){
- scores['O'] += 1;
- }
- });
- $scope.$apply(function(){
- $scope.player_score = scores[me.piece];
- $scope.opponent_score = scores[$scope.opponent_piece];
- });
- }
- }
- })();
现在,分析一下上面代码。首先,设置默认状态。其中,has_joined变量用于是否玩家已经进入某个房间。其次,ready变量用于确定是否用户已经连接到Horizon服务器。当这个变量值为false时,还不能向用户显示应用程序的界面。
- $scope.has_joined = false;
- $scope.ready = false;
连接到服务器的代码如下:
- const horizon = Horizon({host: 'localhost:8181'});
- horizon.onReady(function(){
- $scope.$apply(function(){
- $scope.ready = true;
- });
- });
- horizon.connect(); //connect to the server
如我前面所提到的,Horizon服务器默认使用的是8181端口。这正是我们为什么使用local:8181作为端口的原因。如果你连接到一个远程服务器,这应该对应于分配给服务器的IP地址或者域名。当用户连接到服务器时,onReady事件将会触发。正是在此时,我们把ready设置为true,这样就可以向用户显示程序的UI部分了。
- horizon.onReady(function(){
- $scope.$apply(function(){
- $scope.ready = true;
- });
- });
进入房间
每当用户点击Join按钮时,将执行join函数:
- $scope.join = function(username, room){
- ...
- };
在此函数内部,连接到一个称为tictactoe的集合。
【注意】因为我们处于开发模式下;所以,如果集合不存在的话,系统将自动为你创建。
- me.room = horizon('tictactoe');
接下来,生成一个ID,并把它设置为当前用户的ID:
- var id = chance.integer({min: 10000, max: 999999});
- me.id = id;
接下来,设置玩家用户名和默认的玩家得分。
- $scope.player = username;
- $scope.player_score = 0;
【注意】这两个变量都被绑定到模板中;所以,你可以随时显示与更新它们。
接下来,进行文档查询,查询条件是:room属性为当前房间且type属性为user。千万不要把这种查询与subscribe函数弄混,在这里我们并不监听数据变化的。而且,这里还要使用fetch函数;这意味着,只有在用户进入一个房间时才执行该操作。相关代码如下:
- me.room.findAll({room: room, type: 'user'}).fetch().subscribe(function(row){
- ...
- });
一旦结果返回,即检查用户个数。当然,井字游戏只能由两个玩家玩,所以,如果用户想加入一个已经有两名玩家的房间的话,系统会向他们发出警报。
- var user_count = row.length;
- if(user_count == 2){
- alert('Sorry, room is already full.');
- }else{
- ...
- }
上面代码中的else语句将继续处理接受用户的逻辑,即根据当前用户数确定将被分配给用户的卡片。第一个加入该房间的人得到"X"卡片,而第二个人得到"O"卡片。
- me.piece = 'X';
- if(user_count == 1){
- me.piece = 'O';
- }
一旦你选定了卡片,就把新用户存储到集合中,并把has_joined开关值取反,从而显示井字棋盘。
- me.room.store({
- id: id,
- room: room,
- type: 'user',
- name: username,
- piece: me.piece
- });
- $scope.has_joined = true;
接下来,侦听集合中的变化。这次,不是通过fetch方式,而是使用watch方式。具体地说,每当添加新文档或更新(或删除)匹配查询的现有文档时,都要执行回调函数。回调函数执行时,循环遍历所有的结果并设置对手的详细信息——如果该文档的用户ID与当前用户的ID不匹配的话。本程序中正是通过这种方式来向当前用户显示他们的对手是谁。
- me.room.findAll({room: room, type: 'user'}).watch().subscribe(
- function(users){
- users.forEach(function(user){
- if(user.id != me.id){
- $scope.$apply(function(){
- $scope.opponent = user.name;
- $scope.opponent_piece = user.piece;
- $scope.opponent_score = 0;
- });
- }
- });
- },
- function(err){
- console.log(err);
- }
- );
接下来要订阅move事件,该事件每当玩家把他们的卡片放到棋盘上从而这导致文档变化时就触发一次。如果发生这种情况,则遍历所有的移动动作并将文本添加到相应的格子。从现在开始,代码中将使用“block”一词来表示棋盘上的每一个格子。
添加的文本对应于每个用户所使用的卡片;此外,代码中还将类名替换为“col done”。其中,col相应于Ionic编程中网格实现类,而done是用于表示一个特定块上已经已经有一个卡片的类。如果用户还能将卡片放在格子上,我们就使用这种办法来检查。在更新棋盘用户界面后,通过调用updateScores函数(将在以后添加这个函数)来更新玩家的成绩。
- me.room.findAll({room: room, type: 'move'}).watch().subscribe(
- function(moves){
- moves.forEach(function(item){
- var block = document.getElementById(item.block);
- block.innerHTML = item.piece;
- block.className = "col done";
- });
- me.updateScores();
- },
- function(err){
- console.log(err);
- }
- );
放置卡片
每当用户点击棋盘上的任何一格时都要调用placePiece函数,同时要提供对应格子的ID值作为此函数的参数。这允许我们随心所欲地操纵游戏格子。在本程序中,使用此函数来检查某个游戏格子是否属于done类型。如果没有done标志,则创建一个新的移动,并显示当前房间、格子ID值及对应的卡片。
- $scope.placePiece = function(id){
- var block = document.getElementById(id);
- if(!angular.element(block).hasClass('done')){
- me.room.store({
- type: 'move',
- room: me.room_name,
- block: id,
- piece: me.piece
- });
- }
- };
更新玩家得分
为了更新玩家得分,需要构建一个包含可能的获胜组合的数组,如下所示:
- const possible_combinations = [
- [1, 4, 7],
- [2, 5, 8],
- [3, 2, 1],
- [4, 5, 6],
- [3, 6, 9],
- [7, 8, 9],
- [1, 5, 9],
- [3, 5, 7]
- ];
在这段代码中,[1, 4, 7]对应于第一行,[1, 2, 3]对应于第一列。只要相应的数字存在,顺序是无关紧要的。下面的图形有助于你更直观地了解这一点。
接下来,需要初始化每个单独卡片的得分并循环遍历每个可能的组合。对于每一次循环遍历,初始化已经放到棋盘上的卡片总数。然后针对每一种可能的组合进行循环遍历。使用id来检查是否相应的格子上已经放了卡片。如果已经有了卡片,则取得实际卡片并把卡片总数加1。在循环结束后,检查是否卡片总数等于3。如果卡片总数等于3,则增加该卡片得分数,直到遍历完所有可能的组合。一旦完成,更新当前玩家和对手的得分值。
- var scores = {'X': 0, 'O': 0};
- possible_combinations.forEach(function(row, row_index){
- var pieces = {'X' : 0, 'O': 0};
- row.forEach(function(id, item_index){
- var block = document.getElementById(id);
- if(angular.element(block).hasClass('done')){ //check if there's already a piece
- var piece = block.innerHTML;
- pieces[piece] += 1;
- }
- });
- if(pieces['X'] == 3){
- scores['X'] += 1;
- }else if(pieces['O'] == 3){
- scores['O'] += 1;
- }
- });
- //update current player and opponent score
- $scope.$apply(function(){
- $scope.player_score = scores[me.piece];
- $scope.opponent_score = scores[$scope.opponent_piece];
- });
创建主模板文件
现在,在目录www/templates下创建一个模板文件home.html,并添加如下代码:
- <ion-view title="Home" ng-controller="HomeController as home_ctrl" ng-init="connect()">
- <header class="bar bar-header bar-stable">
- <h1 class="title">Ionic Horizon Tic Tac Toe</h1>
- </header>
- <ion-content class="has-header" ng-show="home_ctrl.ready">
- <div id="join" class="padding" ng-hide="home_ctrl.has_joined">
- <div class="list">
- <label class="item item-input">
- <input type="text" ng-model="home_ctrl.room" placeholder="Room Name">
- </label>
- <label class="item item-input">
- <input type="text" ng-model="home_ctrl.username" placeholder="User Name">
- </label>
- </div>
- <button class="button button-positive button-block" ng-click="join(home_ctrl.username, home_ctrl.room)">
- join
- </button>
- </div>
- <div id="game" ng-show="home_ctrl.has_joined">
- <div id="board">
- <div class="row">
- <div class="col" ng-click="placePiece(1)" id="1"></div>
- <div class="col" ng-click="placePiece(2)" id="2"></div>
- <div class="col" ng-click="placePiece(3)" id="3"></div>
- </div>
- <div class="row">
- <div class="col" ng-click="placePiece(4)" id="4"></div>
- <div class="col" ng-click="placePiece(5)" id="5"></div>
- <div class="col" ng-click="placePiece(6)" id="6"></div>
- </div>
- <div class="row">
- <div class="col" ng-click="placePiece(7)" id="7"></div>
- <div class="col" ng-click="placePiece(8)" id="8"></div>
- <div class="col" ng-click="placePiece(9)" id="9"></div>
- </div>
- </div>
- <div id="scores">
- <div class="row">
- <div class="col col-50 player">
- <div class="player-name" ng-bind="player"></div>
- <div class="player-score" ng-bind="player_score"></div>
- </div>
- <div class="col col-50 player">
- <div class="player-name" ng-bind="opponent"></div>
- <div class="player-score" ng-bind="opponent_score"></div>
- </div>
- </div>
- </div>
- </div>
- </ion-content>
- </ion-view>
现在,我们来分析一下上面的代码。首先,创建了一个总的包装器,在用户连接到Horizon服务器前这部分内容是不显示的:
- <ion-content class="has-header" ng-show="home_ctrl.ready">
- ...
- </ion-content>
加入游戏房间的表单代码如下:
- <div id="join" class="padding" ng-hide="home_ctrl.has_joined">
- <div class="list">
- <label class="item item-input">
- <input type="text" ng-model="home_ctrl.room" placeholder="Room Name">
- </label>
- <label class="item item-input">
- <input type="text" ng-model="home_ctrl.username" placeholder="User Name">
- </label>
- </div>
- <button class="button button-positive button-block" ng-click="join(home_ctrl.username, home_ctrl.room)">
- join
- </button>
- </div>
井字棋棋盘的设计相关代码如下:
- <div id="board">
- <div class="row">
- <div class="col" ng-click="placePiece(1)" id="1"></div>
- <div class="col" ng-click="placePiece(2)" id="2"></div>
- <div class="col" ng-click="placePiece(3)" id="3"></div>
- </div>
- <div class="row">
- <div class="col" ng-click="placePiece(4)" id="4"></div>
- <div class="col" ng-click="placePiece(5)" id="5"></div>
- <div class="col" ng-click="placePiece(6)" id="6"></div>
- </div>
- <div class="row">
- <div class="col" ng-click="placePiece(7)" id="7"></div>
- <div class="col" ng-click="placePiece(8)" id="8"></div>
- <div class="col" ng-click="placePiece(9)" id="9"></div>
- </div>
- </div>
玩家得分部分对应的代码如下:
- <div id="scores">
- <div class="row">
- <div class="col col-50 player">
- <div class="player-name" ng-bind="player"></div>
- <div class="player-score" ng-bind="player_score"></div>
- </div>
- <div class="col col-50 player">
- <div class="player-name" ng-bind="opponent"></div>
- <div class="player-score" ng-bind="opponent_score"></div>
- </div>
- </div>
- </div>
编写样式文件
下面给出客户端应用程序的样式定义:
- #board .col {
- text-align: center;
- height: 100px;
- line-height: 100px;
- font-size: 30px;
- padding: 0;
- }
- #board .col:nth-child(2) {
- border-right: 1px solid;
- border-left: 1px solid;
- }
- #board .row:nth-child(2) .col {
- border-top: 1px solid;
- border-bottom: 1px solid;
- }
- .player {
- font-weight: bold;
- text-align: center;
- }
- .player-name {
- font-size: 18px;
- }
- .player-score {
- margin-top: 15px;
- font-size: 30px;
- }
- #scores {
- margin-top: 30px;
- }
运行应用程序
现在,你可以通过执行应用程序根目录下的如下命令在你的浏览器中测试应用程序:
- ionic serve
这样启动的服务器端将服务于本地项目并在你的默认浏览器中打开一个新的选项卡。
如果你想要和朋友一起测试的话,你可以使用Ngrok把Horizon服务器发布到互联网上,命令如下:
- ngrok http 8181
这个命令将生成一个URL,当你连接到Horizon服务器时可以用作主机地址:
- const horizon = Horizon({host: 'xxxx.ngrok.io'});
除此之外,还要在index.html文件中改变到horizon.js文件的引用:
- <script src="http://xxxx.ngrok.io/horizon/horizon.js"></script>
若要创建程序的移动版本,需要在你的项目中添加一个平台(例如,安卓系统)。这假定你已经在自己的计算机上安装了Android SDK。
- ionic platform add android
接下来,我们生成.apk文件,命令如下:
- ionic build android
到此,你可以把这个.apk文件发送给你的朋友来一起把玩这个游戏。当然,你也可以自己玩这个游戏,这全是你自己的事了。
小结
在本教程中,你开发的仅仅是一个再简单不过的应用程序;因此,还有很多方面加以适当改进的话,效果会更好。下面列举的是供你参考改进的几个内容,把这些当成你的技能作业好了。
再开发一个4×4或5×5的版本:目前你开发出的3×3版本几乎总是会导致僵局,尤其是如果两名玩井字游戏的玩家都是专家级的话。
得分逻辑:你不得不通过大量的循环来取得玩家得分。也许你可以想出更好的方案来实现。
美化游戏风格:当前游戏的风格十分平实,其实它模拟的是适用于在纸上玩的井字游戏。
添加动画:当用户加入一个房间时,你可以尝试为棋盘添加“滑落”动画效果;或者当玩家把卡片放到棋盘上时实现“弹出”动画效果。您可以使用animate.css文件来实现这类动画。
添加SNS登录支持:在这么简单的一个应用中添加SNS功能恐怕要求有点过了,但如果你想要了解如何在Horizon框架中实现身份验证工作原理,这倒是一个相当不错的锻炼机会。使用Horizon认证,你可以让用户登录其Facebook、Twitter或Github帐户。
添加再玩一次功能:游戏完毕后你可以尝试添加一个“Play Again”按钮。按下这个按钮时,系统将清除排行榜和得分,以便玩家可以再玩一次。
添加实时排行榜功能:添加比赛排行榜来显示谁赢了比赛。
【51CTO译稿,合作站点转载请注明原文译者和出处为51CTO.com】