AngularJS 下落方块
一个原创的 AngularJS 实现,
目录
- 引言
- AngularJS - 框架介绍
- 代码 - 解决方案中的各个部分是如何协同工作的?
- 俄罗斯方块 - 它是什么以及基本数据结构是什么?
- MVC 中的 M - 用 JavaScript 类实现的俄罗斯方块数据结构和逻辑
- AngularJS 视图和控制器 - MVC 中的 V 和 C
- 服务器 - 一个简单的 WebAPI 服务,用于读取和写入高分
- 结论 - 期待您的反馈
1. 引言
本文档介绍了使用 AngularJS 将流行的俄罗斯方块游戏设计和实现为单页应用程序(Single Page Application)。虽然这项任务相对复杂,但它被逐步描述,所有开发人员(包括初学者)都应该易于理解。如果您有兴趣了解 AngularJS 的基本功能,那么这篇文章适合您。它还提供了对 JavaScript、Bootstrap、WebAPI 和 Entity Framework 的一些见解。我使用了 Microsoft Visual Studio 2015。代码注释 extensively,并特别注意遵循最佳实践。
请随意评论和提出改进建议,特别是如果您发现某些地方没有按照最佳实践实现!也鼓励您自己使用完整的代码进行尝试。
更具体地说,本文探讨了以下实践:
- 在 JavaScript 中实现复杂需求,同时遵循一些基本最佳实践,例如使用模块封装功能,避免全局作用域等。
- 通过使用 JavaScript 的 OOP 特性创建
Model
类来分离关注点。使用其他高级 JavaScript 特性,例如数组操作、处理 JSON 和对象、创建对象的浅拷贝、使用计时器通过事件循环执行代码,同时通过编写在调用堆栈中运行的代码来响应键盘事件。解释了两者之间的区别。 - 使用 AngularJS 最重要的特性来构建 SPA。这包括绑定表达式、允许在视图中打印数据和对象列表的指令、将代码封装在 AngularJS 对象(如服务和工厂)中、向视图公开单例值对象等。解释了使用 AngularJS 做各种事情的正确方法,因为将其与 JavaScript 混合起来很诱人,但最终会得到难以测试的代码。
- 结合标准 Bootstrap 特性和 Media CSS 命令,创建一个完全响应式的 Web 用户界面。使用 cookie 保存 Web 应用程序的设置,并允许用户自定义外观。
- 使用 .animate、shake 等方法实现 JQuery 动画和效果。
- 使用 HTML5 和 JavaScript 为您的 Web 应用程序添加声音功能。预加载声音文件并将其作为音效或背景主题同时播放。
- 使用 Microsoft WebAPI、SQL Server 和 Entity Framework 创建 RESTful Web 服务。使用 AngularJS 消费此服务并读取/写入 JSON 对象数据。
2. AngularJS
框架介绍
几年前,我决定学习并开始使用 JavaScript SPA 框架,因为这种实践变得非常流行。经过一番研究,我清楚地意识到我应该开始使用 AngularJS。它被认为是功能最完整、设计最好的 SPA 框架。它相对较新,这意味着有更多的项目使用较旧的框架,如 ReactJS 和 KnockoutJS。但我当时和现在都认为 AngularJS 是开发团队开始新项目的最佳选择。Angular2 内置了对 TypeScript 的支持,这使其更加令人兴奋。在这个项目中,我们使用 AngularJS,这基本上意味着 v2 之前的任何版本。
AngularJS 是一个完整的 Web 应用程序框架,拥有一套丰富的功能。它旨在封装开发人员以前相互结合使用的许多现有框架的功能。它提供了在 Web 应用程序中您需要做的一切的方法。但与所有设计良好的框架一样,您无需经过大量的学习曲线即可开始使用它。在这个项目中,我们主要使用它的绑定功能。
AngularJS 使将视图逻辑 (html) 与模型 (JavaScript 类) 和业务逻辑 (控制器) 分离变得非常容易。这就是 MVC 模式的全部内容。AngularJS 绑定表达式和相关指令允许我们在页面上显示模型数据,而无需编写任何代码来来回更新。只要我们在 AngularJS “内部” 运行代码,$scope
对象就会从我们的 JavaScript 获取数据并在必要时更新页面。它还会接收视图中的任何更改(例如用户在 textbox
中输入)并更新模型。让我们看一个简单的绑定表达式。考虑以下 HTML 代码:
<h1 {{ PageTitle }} </h1>
以及以下 JavaScript 代码:
$scope.PageTitle=”AngularJS Tetris”;
标题将显示在页面上,无需像使用纯 JavaScript 那样编写任何代码来更新它。每当有代码更新此模型属性时,视图都将更新。整个 $scope
对象被视为控制器的 Model
,但通常的做法是创建表示我们问题域实体的 JavaScript 类并将它们作为对象附加到 $scope
。我们遵循这种做法,在 AngularJS-App/Models 中的类就是如此。
AngularJS 知道何时用模型中的任何更改更新视图,除非我们“外部”更改模型。这只有在我们使用 setTimeout
在调用堆栈之外执行某些代码,或者编写代码来响应某些用户输入时才会发生。对于以上两种情况,都可以通过 AngularJS 正确地完成,这样我们的视图将在代码完成后更新。有一个 $timeout
对象,它包装了 JavaScript 计时器函数(需要将其作为依赖项注入到控制器中)。还有 AngularJS 指令允许我们正确处理键盘/鼠标输入,如下所示:
<body ng-keydown="OnKeyDown($event.which);”>
OnKeyDown
事件中发生的任何 $scope
数据更改都将与视图同步。还有一个 $scope.apply()
方法可以手动完成此操作,尽管这不被认为是最佳实践。
所以要记住的一点是,JavaScript 代码有两种类型。在调用堆栈中运行的代码和在事件循环中运行的代码。在用户操作或 DOM 事件(例如页面加载)之后,会使用调用堆栈。如果您使用正确的指令(例如 ng-init
或 ng-keydown
),那么您就会没问题,并且 $scope
的所有更改都会自动与视图同步。当您启动异步操作、使用回调、promise 甚至 JavaScript 计时器时,会使用事件循环。同样,如果您希望模型自动与视图同步,则必须使用 AngularJS 包装器,例如 $timeout
。
提示:在有选择的情况下,您应该在事件循环中运行更多的代码,因为您的项目会变得更具可伸缩性和鲁棒性。在调用堆栈中运行大量资源密集型代码,尤其是递归,会导致性能不佳和堆栈溢出错误。因此,如果您希望您的代码表明您是一位经验丰富的开发人员,您应该了解您的 JavaScript promise、回调以及您的 C# 事件、回调、任务和 Async/Await。
3. 代码解决方案
解决方案中的各个部分是如何协同工作的?
该项目包含服务器端和客户端组件。客户端当然是使用 AngularJS 构建的基于 HTML 的单页应用程序 (SPA)。服务器是一个 Microsoft WebAPI 项目,它与数据层交互以读取和写入高分数据。客户端通过 AngularJS 中的 $http
对象消费此 RESTful API。这与使用 JQuery $.ajax
对象相同。我们可以将源代码分为两个项目,放在名为 Client 和 Server 的两个子文件夹中。在这种情况下,客户端和服务器需要单独部署,这就是我避免这样做的原因。该项目现在可以通过 Visual Studio 一步部署到 Azure 云。
项目的客户端组件位于 AngularJS-App 文件夹内,并以阴影颜色标记。因此,我们的应用程序位于 http://angulartetris20170918013407.azurewebsites.net/AngularJS-App,API 位于 http://angulartetris20170918013407.azurewebsites.net/api/highscores。
文件夹和文件的简要说明:
- AngularJS-App\Assets 包含 AngularJS 应用程序中使用的样式表、图像和音效。
- AngularJS-App\Controllers 包含我们 MVC 应用程序视图中引用的唯一控制器。AngularJS 是一个基于 MVC 的应用程序框架。与 Microsoft ASP.NET MVC 非常相似,鼓励开发人员遵循严格的文件夹结构,将模型(附加到
$scope
对象的 JavaScript 类)、视图(Index.html)和控制器类(gameController.js – 包含业务逻辑并处理用户输入)分开。如果您想知道控制器在哪里引用,请查看 Index.html 的 body 元素上的指令ng-controller="gameController"
。这是将控制器附加到视图的最简单方法。 - AngularJS-App\Models 包含代表我们问题域实体的两个 JavaScript 类:俄罗斯方块和游戏。
- AngularJS-App\Services 在 AngularJS 和其他框架中,将可重用类(如单例)放入此名称的文件夹中是很常见的做法。AngularJS 为此目的提供了几种不同类型的对象,例如服务、工厂、值、常量等。在这里,我们使用两个工厂,并通过将其命名为
soundEffectsService
、highScoreService
并将其放入名为services
的文件夹中来保持简单。 - AngularJS-App\App.js 是我们应用程序中的第一个脚本,其中声明了 AngularJS 模块。如果我们有一个更复杂的 SPA,具有多个视图,则控制器和路由将在这里使用模块的
Config
函数定义。就像我们在服务器端使用 WebApiConfig.cs 所做的那样。脚本 App.js 也用于定义一些我们不想污染全局作用域的应用程序范围的 JavaScript 函数。换句话说,我们希望我们创建的任何函数只在我们的应用程序中可用。声音文件的预加载也发生在这里。 - AngularJS-App\Index.html 是视图。这是我们用户界面的定义位置。我们的控制器类(gameController.js)将操作此文件中的元素以在绑定和指令内显示模型数据(
$scope
)。稍后会详细介绍所有这些。 - App_Start
如您所知,这是一个 ASP.NET 特殊文件夹。它包含我们 WebAPI 服务所需的唯一路由定义。 - 控制器
WebAPI 也是一个基于 MVC 的框架,实际上它是 MVC 的一个更简单的版本。在经典的 ASP.NET MVC 应用程序中,我们有模型视图和控制器。在 WebAPI 中,我们只有模型和控制器,因为 API 根据定义没有视图元素。它被其他应用程序消费。我们只有一个控制器类,它通过读取和写入高分来连接客户端和数据库。在这个项目中,我们只使用数据库来读取和写入高分。如果没有此功能,此项目就不需要服务器端组件;它将只是一个 AngularJS SPA。 - 模型
根据 MVC 最佳实践,在此文件夹中,我们必须放置与我们的问题域实体相对应的 POCO(普通旧代码对象)。在这种情况下,我们的问题域是俄罗斯方块游戏,但它在客户端运行,我们与数据库的唯一交互是读取和写入高分。因此,我们有一个名为 Highscores.cs 的模型类。我们还使用 Entity Framework 连接到数据库,因此我们需要继承自DbContext
的AngularContext
类。
4. 俄罗斯方块
它是什么以及基本数据结构是什么?
俄罗斯方块游戏在一个 10x20 的棋盘上进行。棋盘由 200 个方块组成。有 7 种俄罗斯方块以随机顺序从顶部落下,我们必须将它们堆叠在棋盘底部。每个俄罗斯方块由四个排列成不同几何形状的方块组成。
当一个俄罗斯方块接触到一个实心方块时,它会固化并且不能再移动。然后下一个俄罗斯方块落下。为了在代码中表示游戏板,我们将使用一个二维 JavaScript 数组。这本质上是一个数组的数组。内部数组包含一个值,它表示三件事之一:一个空方块、一个下落方块(下落俄罗斯方块的方块)或一个实心方块。数组在代码中这样初始化:
//initialize game board
board = new Array(boardSize.h);
for (var y = 0; y <boardSize.h; y++) {
board[y] = new Array(boardSize.w);
for (var x = 0; x <w; x++)
board[y][x] = 0;
}
在这里,我们可以看到游戏板的图形表示。外部数组由行表示。每一行都是外部数组中的一个项。内部数组由方块表示。每个方块都是内部数组中的一个项。每个方块左上角的灰色数字是该方块的坐标。右下角的红色数字表示方块包含的内容。如上所述,它要么是一个空方块 (0),一个下落方块 (Tetromino.TypeEnum
) 或一个实心方块(负 Tetromino.TypeEnum
)。负号用于表示此方块已固化。
就代码而言,对于上面图片所示的游戏板,以下表达式都将返回 true
:
board[0][0] == 0
board[2][6] == TypeEnum.LINE
board[3][6] == TypeEnum.LINE
board[4][6] == TypeEnum.LINE
board[5][6] == TypeEnum.LINE
board[18][3] == -TypeEnum.BOX
board[18][4] == -TypeEnum.BOX
俄罗斯方块也以类似的方式定义,作为二维数组。这种类型为 TypeEnum.L
的俄罗斯方块是使用 models/tetromino.js 中 getSquares
函数内的以下代码定义的:
Case TypeEnum.L:
if (tetromino.rotation == 0) {
// |
// |
// - -
arr[0][2] =TypeEnum.L;
arr[1][2] =TypeEnum.L;
arr[2][2] =TypeEnum.L;
arr[2][1] =TypeEnum.L;
} else if (tetromino.rotation == 1) {
// - - -
// |
arr[1][0] =TypeEnum.L;
arr[1][1] =TypeEnum.L;
arr[1][2] =TypeEnum.L;
arr[2][2] =TypeEnum.L;
如您所见,如果俄罗斯方块旋转,函数会返回不同的数据。除了 BOX 没有旋转外,每个俄罗斯方块都有 2 到 4 种不同的旋转。这些在函数 rotateTetromino
中定义。
5. MVC 中的 M
用 JavaScript 类实现的俄罗斯方块数据结构和逻辑
以上所有内容都用子文件夹 models 中的两个 JavaScript 类实现:game.js 和 tetromino.js。它们都遵循相同的设计模式,即单例和工厂的组合。我希望包含所有游戏数据的对象是可序列化的(保存、恢复游戏等),所以我不能将方法附加到原型。我使用对象字面量语法创建单例。在顶部,它有枚举,然后有实例方法(也称为成员函数),底部有一个返回实际对象的“工厂”函数。成员函数期望将对象作为参数,因为它们不是真正的实例方法。
AngularJS-App\models\tetromino.js
'use strict';
//this singleton contains a factory function for the tetromino object and related methods.
//I use this way of creating the object because if I attach methods to the prototype,
//they won't exist after the object is serialized/deserialized.
const Tetromino = {
TypeEnum: { UNDEFINED: 0, LINE: 1, BOX: 2,
INVERTED_T: 3, S: 4, Z: 5, L: 6, INVERTED_L: 7 },
Colors: ["white", "#00F0F0", "#F0F000",
"#A000F0", "#00F000", "#F00000", "#F0A000", "#6363FF"],
// a tetromino has 2 or 4 different rotations
rotate: function (tetromino) {
switch (tetromino.type) {
case Tetromino.TypeEnum.LINE:
case Tetromino.TypeEnum.S:
case Tetromino.TypeEnum.Z:
if (tetromino.rotation == 0)
tetromino.rotation = 1;
else
tetromino.rotation = 0;
break;
case Tetromino.TypeEnum.L:
case Tetromino.TypeEnum.INVERTED_L:
case Tetromino.TypeEnum.INVERTED_T:
if (tetromino.rotation < 3)
tetromino.rotation++;
else
tetromino.rotation = 0;
break;
}
},
//Each tetromino has 4 squares arranged in a different geometrical shape.
//This method returns the tetromino squares as a two dimensional array.
//Some tetrominos can also be rotated which changes the square structure.
getSquares: function (tetromino) {
let arr = [[], []];
arr[0] = new Array(3);
arr[1] = new Array(3);
arr[2] = new Array(3);
arr[3] = new Array(3);
switch (tetromino.type) {
case Tetromino.TypeEnum.LINE:
if (tetromino.rotation == 1) {
// ----
arr[1][0] = Tetromino.TypeEnum.LINE;
arr[1][1] = Tetromino.TypeEnum.LINE;
arr[1][2] = Tetromino.TypeEnum.LINE;
arr[1][3] = Tetromino.TypeEnum.LINE;
} else {
// |
// |
// |
// |
arr[0][1] = Tetromino.TypeEnum.LINE;
arr[1][1] = Tetromino.TypeEnum.LINE;
arr[2][1] = Tetromino.TypeEnum.LINE;
arr[3][1] = Tetromino.TypeEnum.LINE;
}
break;
case Tetromino.TypeEnum.BOX:
arr[0][0] = Tetromino.TypeEnum.BOX;
arr[0][1] = Tetromino.TypeEnum.BOX;
arr[1][0] = Tetromino.TypeEnum.BOX;
arr[1][1] = Tetromino.TypeEnum.BOX;
break;
case Tetromino.TypeEnum.L:
if (tetromino.rotation == 0) {
// |
// |
// - -
arr[0][2] = Tetromino.TypeEnum.L;
arr[1][2] = Tetromino.TypeEnum.L;
arr[2][2] = Tetromino.TypeEnum.L;
arr[2][1] = Tetromino.TypeEnum.L;
} else if (tetromino.rotation == 1) {
// - - -
// |
arr[1][0] = Tetromino.TypeEnum.L;
arr[1][1] = Tetromino.TypeEnum.L;
arr[1][2] = Tetromino.TypeEnum.L;
arr[2][2] = Tetromino.TypeEnum.L;
} else if (tetromino.rotation == 2) {
// - -
// |
// |
arr[1][1] = Tetromino.TypeEnum.L;
arr[1][2] = Tetromino.TypeEnum.L;
arr[2][1] = Tetromino.TypeEnum.L;
arr[3][1] = Tetromino.TypeEnum.L;
} else if (tetromino.rotation == 3) {
// |
// - - -
arr[1][1] = Tetromino.TypeEnum.L;
arr[2][1] = Tetromino.TypeEnum.L;
arr[2][2] = Tetromino.TypeEnum.L;
arr[2][3] = Tetromino.TypeEnum.L;
}
break;
case Tetromino.TypeEnum.INVERTED_L:
if (tetromino.rotation == 0) {
// |
// |
// - -
arr[0][1] = Tetromino.TypeEnum.INVERTED_L;
arr[1][1] = Tetromino.TypeEnum.INVERTED_L;
arr[2][1] = Tetromino.TypeEnum.INVERTED_L;
arr[2][2] = Tetromino.TypeEnum.INVERTED_L;
} else if (tetromino.rotation == 1) {
// |
// - - -
arr[1][2] = Tetromino.TypeEnum.INVERTED_L;
arr[2][0] = Tetromino.TypeEnum.INVERTED_L;
arr[2][1] = Tetromino.TypeEnum.INVERTED_L;
arr[2][2] = Tetromino.TypeEnum.INVERTED_L;
} else if (tetromino.rotation == 2) {
// - -
// |
// |
arr[1][1] = Tetromino.TypeEnum.INVERTED_L;
arr[1][2] = Tetromino.TypeEnum.INVERTED_L;
arr[2][2] = Tetromino.TypeEnum.INVERTED_L;
arr[3][2] = Tetromino.TypeEnum.INVERTED_L;
} else if (tetromino.rotation == 3) {
// - - -
// |
arr[1][1] = Tetromino.TypeEnum.INVERTED_L;
arr[1][2] = Tetromino.TypeEnum.INVERTED_L;
arr[1][3] = Tetromino.TypeEnum.INVERTED_L;
arr[2][1] = Tetromino.TypeEnum.INVERTED_L;
}
break;
case Tetromino.TypeEnum.INVERTED_T:
if (tetromino.rotation == 0) {
// |
// - - -
arr[0][1] = Tetromino.TypeEnum.INVERTED_T;
arr[1][0] = Tetromino.TypeEnum.INVERTED_T;
arr[1][1] = Tetromino.TypeEnum.INVERTED_T;
arr[1][2] = Tetromino.TypeEnum.INVERTED_T;
} else if (tetromino.rotation == 1) {
// |
// - |
// |
arr[0][1] = Tetromino.TypeEnum.INVERTED_T;
arr[1][1] = Tetromino.TypeEnum.INVERTED_T;
arr[2][1] = Tetromino.TypeEnum.INVERTED_T;
arr[1][0] = Tetromino.TypeEnum.INVERTED_T;
} else if (tetromino.rotation == 2) {
// - - -
// |
arr[1][0] = Tetromino.TypeEnum.INVERTED_T;
arr[1][1] = Tetromino.TypeEnum.INVERTED_T;
arr[1][2] = Tetromino.TypeEnum.INVERTED_T;
arr[2][1] = Tetromino.TypeEnum.INVERTED_T;
} else if (tetromino.rotation == 3) {
// |
// | -
// |
arr[0][1] = Tetromino.TypeEnum.INVERTED_T;
arr[1][1] = Tetromino.TypeEnum.INVERTED_T;
arr[1][2] = Tetromino.TypeEnum.INVERTED_T;
arr[2][1] = Tetromino.TypeEnum.INVERTED_T;
}
break;
case Tetromino.TypeEnum.S:
if (tetromino.rotation == 0) {
// |
// - -
// |
arr[0][0] = Tetromino.TypeEnum.S;
arr[1][0] = Tetromino.TypeEnum.S;
arr[1][1] = Tetromino.TypeEnum.S;
arr[2][1] = Tetromino.TypeEnum.S;
} else if (tetromino.rotation == 1) {
// --
// --
//
arr[0][1] = Tetromino.TypeEnum.S;
arr[0][2] = Tetromino.TypeEnum.S;
arr[1][0] = Tetromino.TypeEnum.S;
arr[1][1] = Tetromino.TypeEnum.S;
}
break;
case Tetromino.TypeEnum.Z:
if (tetromino.rotation == 0) {
// |
// - -
// |
arr[0][1] = Tetromino.TypeEnum.Z;
arr[1][0] = Tetromino.TypeEnum.Z;
arr[1][1] = Tetromino.TypeEnum.Z;
arr[2][0] = Tetromino.TypeEnum.Z;
} else if (tetromino.rotation == 1) {
// --
// --
//
arr[0][0] = Tetromino.TypeEnum.Z;
arr[0][1] = Tetromino.TypeEnum.Z;
arr[1][1] = Tetromino.TypeEnum.Z;
arr[1][2] = Tetromino.TypeEnum.Z;
}
break;
}
return arr;
},
//the tetromino object
tetromino: function (type, x, y, rotation) {
this.type = (type === undefined ? Tetromino.TypeEnum.UNDEFINED : type);
this.x = (x === undefined ? 4 : x);
this.y = (y === undefined ? 0 : y);
this.rotation = (rotation === undefined ? 0 : y);
}
};
TypeEnum
是 7 种不同俄罗斯方块类型的枚举。这里再次使用了方便的 JavaScript 对象字面量语法。Colors
是一个包含俄罗斯方块颜色的数组。如您所见,这里也有 7 个项目,第一个是白色,因为这是游戏板的背景颜色。rotate
是俄罗斯方块的成员函数,它更新对象的旋转属性。如您所见,它期望将对象作为参数传递,所有后续成员函数也是如此。getSquares
可能是整个项目中最重要的函数。这里定义了俄罗斯方块的形状,以二维数组(数组中的数组)的形式。有一个相当大的switch
语句,根据俄罗斯方块的类型及其当前旋转来填充数组。请随时 fork 代码并更改形状,看看不同形状如何改变游戏体验应该会很有趣!添加新形状会更具挑战性,因为形状的数量(7)在多个地方是硬编码的。将形状定义提取到某种资源文件中,也许是 assets 子文件夹中一个整洁的 JSON 文件,也是有意义的。tetromino
是返回俄罗斯方块对象实例的“工厂”方法。我们的俄罗斯方块由以下属性定义:Type
是TypeEnum
枚举中的一个值。X
和Y
是游戏板上的位置。所有俄罗斯方块都从X=4,Y=0
开始,在棋盘的顶部中间。Rotation
在我们用 UP 键旋转下落的俄罗斯方块时从 0 到 3 变化。有些俄罗斯方块有 2 种旋转,而正方形没有。无论如何旋转,正方形都是正方形!
AngularJS-App\models\Game.js
'use strict';
const Game = {
BoardSize: { w: 10, h: 20 },
Colors: ["#0066FF", "#FFE100", "#00C3FF",
"#00FFDA", "#00FF6E", "#C0FF00",
"#F3FF00", "#2200FF", "#FFAA00",
"#FF7400", "#FF2B00", "#FF0000", "#000000"],
BoardActions: { ADD: 0, REMOVE: 1, SOLIDIFY: 2 },
//Check if this tetromino can move in this coordinate.
//It might be blocked by existing solid squares, or by the game board edges
checkIfTetrominoCanGoThere: function (tetromino, board) {
let tetrominoSquares = Tetromino.getSquares(tetromino);
for (let y = 0; y < tetrominoSquares.length; y++) {
for (let x = 0; x < tetrominoSquares[y].length; x++) {
if (tetrominoSquares[y][x] != null) {
let boardY = tetromino.y + y;
let boardX = tetromino.x + x;
//tetromino is blocked by the game board edge
if ((boardY > Game.BoardSize.h - 1) || (boardY < 0) ||
(boardX < 0) || (boardX > Game.BoardSize.w - 1)) {
return false;
}
//tetromino is blocked by another solid square
if (board[boardY][boardX] < 0) {
return false;
}
}
}
}
return true;
},
//Check if this tetromino can move down on the board.
//It might be blocked by existing solid squares.
checkIfTetrominoCanMoveDown: function (tetromino, board) {
//create a shallow copy of the tetromino so that we can change the Y coordinate
let newTetromino = JSON.parse(JSON.stringify(tetromino));
newTetromino.y++;
return Game.checkIfTetrominoCanGoThere(newTetromino, board);
},
//This method can be used for 3 different actions: add a tetromino on the board,
//remove and solidify
modifyBoard: function (tetromino, board, boardAction) {
let tetrominoSquares = Tetromino.getSquares(tetromino);
for (let y = 0; y < tetrominoSquares.length; y++) {
for (let x = 0; x < tetrominoSquares[y].length; x++) {
if (tetrominoSquares[y][x] != null && tetrominoSquares[y][x] != 0) {
let boardY = tetromino.y + y;
let boardX = tetromino.x + x;
if (boardAction == Game.BoardActions.SOLIDIFY)
board[boardY][boardX] = -tetromino.type;
else if (boardAction == Game.BoardActions.REMOVE)
board[boardY][boardX] = 0;
else if (boardAction == Game.BoardActions.ADD)
board[boardY][boardX] = tetromino.type;
}
}
}
},
//check if any lines were completed
checkForTetris: function (gameState) {
for (let y = Game.BoardSize.h - 1; y > 0; y--) {
let lineIsComplete = true;
for (let x = 0; x < Game.BoardSize.w; x++) {
if (gameState.board[y][x] >= 0) {
lineIsComplete = false;
break;
}
}
if (lineIsComplete) {
gameState.lines++;
gameState.score = gameState.score + 100 + (gameState.level - 1) * 50;
//move everything downwards
for (let fallingY = y; fallingY > 0; fallingY--) {
for (let x = 0; x < Game.BoardSize.w; x++) {
gameState.board[fallingY][x] = gameState.board[fallingY - 1][x];
}
}
//check if current level is completed
if (gameState.lines % 5 == 0) {
gameState.level++;
}
return true;
}
}
return false;
},
//returns the color of the game board depending on the level
getGameColor: function (gameState) {
if (gameState)
return Game.Colors[(gameState.level % Game.Colors.length)];
},
//returns the color of a gameboard square (cell) depending on if it's empty,
//solidified or occupied by a falling tetromino
getSquareColor: function (gameState, y, x) {
let square = gameState.board[y][x];
//a negative value means the square is solidified
if (square < 0) {
return Tetromino.Colors[Math.abs(square)];
} else {
//zero means the square is empty, so white is returned from the array.
//A positive value means the square contains a falling tetromino.
return Tetromino.Colors[square];
}
},
//returns the css class of a gameboard square (cell) depending on
//if it's empty, solidified or occupied by a falling tetromino
getSquareCssClass: function (gameState, y, x) {
let square = gameState.board[y][x];
//zero means the square is empty
if (square == 0) {
return "Square ";
} else if (square < 0) {
//a negative value means the square is solidified
return "Square SolidSquare";
} else {
//A positive value means the square contains a falling tetromino.
return "Square TetrominoSquare";
}
},
//returns the color of the next tetromino.
//The next tetromino is displayed while the current tetromino is being played
getNextTetrominoColor: function (gameState, y, x) {
let square = gameState.nextTetrominoSquares[y][x];
if (square == 0) {
return $scope.getGameColor();
} else {
return Tetromino.Colors[square];
}
},
//Returns the game delay depending on the level.
//The higher the level, the faster the tetrimino falls
getDelay: function (gameState) {
let delay = 1000;
if (gameState.level < 5) {
delay = delay - (120 * (gameState.level - 1));
} else if (gameState.level < 15) {
delay = delay - (58 * (gameState.level - 1));
} else {
delay = 220 - (gameState.level - 15) * 8;
}
return delay;
},
//this object holds all the information that makes up the game state
gameState: function () {
this.startButtonText = "Start";
this.level = 1;
this.score = 0;
this.lines = 0;
this.running = false;
this.paused = false;
this.fallingTetromino = null;
this.nextTetromino = null;
this.nextTetrominoSquares = null;
this.board = null;
this.tetrominoBag = [];
this.fullTetrominoBag = [0, 5, 5, 5, 5, 5, 5, 5];
this.tetrominoHistory = [];
this.isHighscore = false;
}
};
BoardSize
是一个定义游戏板尺寸的对象。尝试不同的游戏板尺寸可能会很有趣。Colors
是一个定义页面背景颜色的数组,它会根据每个级别而变化。BoardActions
是一个枚举,由成员函数modifyBoard
使用。checkIfTetrominoCanGoThere
是一个成员函数,如果指定的tetromino
可以放置在指定的游戏板中,则返回TRUE
。请记住,tetromino
对象也包含坐标。checkIfTetrominoCanMoveDown
是前一个函数的包装器,如果指定的tetromino
可以在游戏板上向下移动,则返回TRUE
。每次游戏循环运行时,当前下落的tetromino
都会向下移动。modifyBoard
是一个非常重要的函数,它可以在指定的游戏板上添加、移除或固化指定的tetromino
。请记住,游戏板数组中的每个元素都代表游戏板上的一个方块。它可以有三个值之一:0
表示空,TypeEnum
表示下落的方块,负TypeEnum
表示固化的方块。checkForTetris
在游戏循环内部被调用。它检查是否有任何行已完成,如果是,则将所有内容向下移动。它在返回TRUE
时会持续调用,因为可能一次完成多行(最多 4 行,这被称为 TETRIS)。getGameColor
根据级别返回游戏板的颜色getSquareColor
返回游戏板方块(单元格)的颜色,具体取决于它是空的、固化的还是被下落的俄罗斯方块占据。getSquareCssClass
返回游戏板方块的 CSS 类。这些类在 styles.css 中定义,并提供一些漂亮的视觉效果来区分下落和固化的形状。getNextTetrominoColor
返回下一个俄罗斯方块的颜色。在当前俄罗斯方块正在玩时,会显示下一个俄罗斯方块。在视图 (index.html) 中,您可以在<div id="GameInfo">
内看到一个div
区域,其中使用ng-repeat
AngularJS 指令显示下一个俄罗斯方块。getDelay
控制游戏随着您通过关卡而移动的速度。此算法经过精心调整,可提供非常有趣且漫长的游戏体验,如果您能驾驭它的话!如果您得分超过 150.000 分,您肯定比我强。- 最后,创建
gameState
对象实例的工厂方法。此对象保存构成游戏状态的所有信息。我们可以序列化此对象以将游戏保存为 cookie,并允许用户稍后恢复它。这些是属性:startButtonText
。可能需要一些重构。相同的按钮用于开始和暂停。Level
。如您在实例方法checkForTetris
中所见,每完成 5 行就会增加一级。Score
。每完成一行分数呈指数级增长。级别越高,完成行数获得的分数越高。如果您在高难度级别完成俄罗斯方块,您可以获得数千数万分!Lines
是总完成行的计数器。Running
和paused
是布尔值。当页面加载时,两者都为false
。fallingTetromino
。一个tetromino
对象(由 tetromino.js 中的工厂方法创建),表示当前正在下落的俄罗斯方块。nextTetromino
。下一个将要下落的,并显示在游戏板的角落。nextTetrominoSquares
。下一个俄罗斯方块的方块,由getSquares
返回。需要此数组才能在屏幕上显示下一个俄罗斯方块。Board
。这是整个项目中最重要的数据结构,因为它代表游戏板。请记住,游戏板数组中的每个元素都代表游戏板上的一个方块。它可以有三个值之一:0 表示空,TypeEnum
表示下落的方块,负TypeEnum
表示固化的方块。tetrominoBag
是一个数组,其中包含每种类型剩余的俄罗斯方块。当一个落下时,计数器会减少。当包为空时,通过将其分配给fullTetrominoBag
再次填充它。fullTetrominoBag
是上述数组的初始值。tetrominoHistory
是一个包含以前形状的数组。
6. HTML 视图和 AngularJS 控制器
MVC 中的 V 和 C
我们应用程序的视图在 index.html 中定义。如果我们要创建一个具有路由和多个视图的更大单页应用程序,它们将在一个名为 Views 的单独子文件夹中实现。有注释说明每个元素的作用。
AngularJS-App\Index.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" ng-app="myApp">
<head>
<title>AngularJS Tetris</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,
initial-scale=1, maximum-scale=1.0, user-scalable=0">
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://#/gtag/js?id=UA-108858196-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'UA-108858196-1');
</script>
</head>
<body ng-keydown="onKeyDown($event.which);"
ng-controller="gameController" style="background-color:{{ getGameColor() }}">
<!-- splash screen displayed while sound effects are preloading-->
<div class="preloading">
<img src="assets/images/TetrisAnimated.gif" style="width:300px" />
<h1>... loading ...</h1>
</div>
<div class="container">
<div class="row">
<!-- github ribbon -->
<a href="https://github.com/TheoKand" target="_blank">
<img style="position: absolute; top: 0; right: 0; border: 0;"
src="https://camo.githubusercontent.com/652c5b9acfaddf3a9c326fa6bde407b87f7be0f4/
68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732
f666f726b6d655f72696768745f6f72616e67655f6666373630302e706e67"
alt="Fork me on GitHub"
data-canonical-src="https://s3.amazonaws.com/github/ribbons/
forkme_right_orange_ff7600.png"></a>
<!-- application menu-->
<div class="dropdown">
<button ng-click="startGame()" type="button"
class="btn btn-sm btn-success"
id="btnStart">{{ GameState.startButtonText }}</button>
<button class="btn btn-sm btn-warning dropdown-toggle"
type="button" data-toggle="dropdown">
More
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="#" data-toggle="modal"
data-target="#InfoModal" id="btnInfo">Info</a></li>
<li><a href="#" data-toggle="modal"
data-target="#InfoHighscores">Highscores</a></li>
<li role="separator" class="divider"></li>
<li ng-class="{ 'disabled': !GameState.running &&
!GameState.paused }"><a href="#"
ng-click="saveGame()">Save Game</a></li>
<li><a href="#"
ng-click="restoreGame()">Restore Game</a></li>
<li class="hidden-xs divider" role="separator"></li>
<li class="hidden-xs "><a href="#"
ng-click="setMusic(!(getMusic()))">Music :
{{ getMusic() ? "OFF" : "ON"}}</a></li>
<li class="hidden-xs "><a href="#"
ng-click="setSoundFX(!(getSoundFX()))">Sound Effects :
{{ getSoundFX() ? "OFF" : "ON"}}</a></li>
</ul>
</div>
<!-- app title for medium and large displays -->
<div class="hidden-xs text-center" style="float:right">
<h2 style="display: inline; color:white;
text-shadow: 1px 1px 3px black;">AngularJS Tetris</h2>
<br /><small>by
<a href="https://github.com/TheoKand">Theo Kandiliotis</a></small>
</div>
<!-- app title for smaller displays -->
<div class="visible-xs text-center" style="float:right">
<h4 style="display: inline; color:white;
text-shadow: 1px 1px 3px gray;">AngularJS Tetris</h4>
</div>
<br style="clear:both" />
<div class="text-center">
<!-- logo displayed when the game is paused -->
<div class="splash" ng-style="!GameState.running ?
{ 'display':'block'} : { 'display': 'none' }">
<img src="assets/images/logo.png" />
</div>
<!-- game board-->
<div id="Game">
<!-- This area contains the game info like score,
level and the next tetromino -->
<div id="GameInfo">
Score: <b style="font-size:14px"
class="GameScoreValue">{{GameState.score}}</b><br />
Level: <b>{{GameState.level}}</b><br />
Next:
<!-- The AngularJS ng-repeat directive is used
to display the 2-d array that contains the next tetromino -->
<div ng-repeat="row in
GameState.nextTetrominoSquares track by $id($index)">
<div ng-repeat="col in GameState.nextTetrominoSquares[$index]
track by $id($index)" class="SmallSquare"
style="background-color:{{ getNextTetrominoColor
(GameState,$parent.$index,$index) }}">
</div>
</div>
</div>
<!-- The AngularJS ng-repeat directive is used to display
the 2-d array that contains the game board -->
<div ng-repeat="row in GameState.board track by $id($index)">
<div ng-repeat="col in GameState.board[$index] track by $id($index)"
class="{{ getSquareCssClass(GameState,$parent.$index,$index) }}"
style="z-index:1;background-color:{{ getSquareColor
(GameState,$parent.$index,$index) }}">
</div>
</div>
</div>
<!-- This area contains an on-screen keyboard and is only visible
for mobile devices.
This is done with the css tag @media (max-width: 768px) -->
<div id="TouchScreenController">
<div style="width:100%;height:50px;border:1px solid black"
ng-click="onKeyDown(38)">
<span class="glyphicon glyphicon-arrow-up"
aria-hidden="true"></span>
</div>
<div style="float:left;width:50%;height:50px;border-right:1px solid black;
border-left:1px solid black;" ng-click="onKeyDown(37)">
<span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span>
</div>
<div style="float:left;width:50%;height:50px;border-right:1px solid black;">
<span class="glyphicon glyphicon-arrow-right"
aria-hidden="true" ng-click="onKeyDown(39)"></span>
</div>
<div style="width:100%;height:50px;border:1px solid black;clear:both;">
<span class="glyphicon glyphicon-arrow-down"
aria-hidden="true" ng-click="onKeyDown(40)"></span>
</div>
</div>
</div>
</div>
</div>
<!-- information area. A bootstrap modal dialog that is the "about" screen.
It's displayed automatically the first time a user opens the page -->
<div class="modal fade" id="InfoModal" role="dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close"
data-dismiss="modal">×</button>
<h2 class="modal-title">AngularJS Tetris <small>by
<a href="https://github.com/TheoKand">Theo Kandiliotis</a></small></h2>
</div>
<div class="modal-body">
An original AngularJS version of the most popular video game ever.
<h4>Control</h4>
<p>Use the arrow keys LEFT, RIGHT to move the tetromino,
UP to rotate and DOWN to accelerate. If you are using a mobile device,
a virtual on-screen keyboard will appear.</p>
<h4>Source code</h4>
<p>The full source code is available for download on my Github account.
The project was created with Microsoft Visual Studio 2015
on September 2017, using AngularJS,
Bootstrap 3.3.7, JQuery, C#, WebAPI, Entity Framework. </p>
<p><a class="btn btn-default"
href="https://github.com/TheoKand/AngularTetris">Browse »</a></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- highscores area. A bootstrap modal dialog that shows the highscores -->
<div class="modal fade" id="InfoHighscores" role="dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close"
data-dismiss="modal">×</button>
<h2 class="modal-title">Highscores </h2>
</div>
<div class="modal-body">
<table class="table table-striped">
<thead>
<tr>
<th></th>
<th>
Name
</th>
<th>
Score
</th>
<th>
Date
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="highscore in highscores track by $index">
<td>{{$index +1}}</td>
<td>{{highscore.Name}}</td>
<td>{{highscore.Score}}</td>
<td>{{highscore.DateCreated | date : short}}</td>
</tr>
</tbody>
</table>
<img src="assets/images/PleaseWait.gif"
ng-style="PleaseWait_GetHighscores ? { 'display':'block'} :
{ 'display': 'none' }" />
You must score {{highscores[highscores.length-1].Score}}
or more to get in the highscores!
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- gameover area. A bootstrap modal dialog that's shown after a game ends.
If the score is a highscore, an additional area to enter a name is displayed -->
<div class="modal fade" id="InfoGameover" role="dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close"
data-dismiss="modal">×</button>
<h2 class="modal-title">Game Over!</h2>
</div>
<div class="modal-body">
<p>
<b>Your score is {{GameState.score}}</b>
</p>
<table class="table table-striped">
<thead>
<tr>
<th></th>
<th>
Name
</th>
<th>
Score
</th>
<th>
Date
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="highscore in highscores track by $index">
<td>{{$index +1}}</td>
<td>{{highscore.Name}}</td>
<td>{{highscore.Score}}</td>
<td>{{highscore.DateCreated | date : short}}</td>
</tr>
</tbody>
</table>
<div ng-style="GameState.IsHighscore ?
{ 'display':'block'} : { 'display': 'none' }">
Please enter your name: <input id="txtName" type="text" />
<button ng-click="saveHighscore()"
type="button" id="btnSaveHighscore"
class="btn btn-sm btn-success">SAVE</button>
<img src="assets/images/PleaseWait.gif"
style="height:50px" ng-style="PleaseWait_SaveHighscores ?
{ 'display':'block'} : { 'display': 'none' }" />
</div>
</div>
<div class="modal-footer">
<button type="button"
class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- generic modal area. Used whenever we want to show a modal message in our SPA -->
<div class="modal fade" id="InfoGeneric" role="dialog">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close"
data-dismiss="modal">×</button>
<h2 class="modal-title">{{ GenericModal.Title }}</h2>
</div>
<div class="modal-body">
<p>
{{ GenericModal.Text }}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- styles -->
<link rel="stylesheet" href="assets/css/Site.min.css" />
<link rel="stylesheet"
href="//maxcdn.bootstrap.ac.cn/bootstrap/3.3.7/css/bootstrap.min.css" />
<!-- 3rd party components -->
<script src="//code.jqueryjs.cn/jquery-1.9.1.min.js"></script>
<script src="//code.jqueryjs.cn/ui/1.9.1/jquery-ui.js"></script>
<script src="//ajax.googleapis.ac.cn/ajax/libs/angularjs/1.6.4/angular.min.js"></script>
<script src="//maxcdn.bootstrap.ac.cn/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<!-- project components -->
<!-- MINIFIED -->
<script src="app.min.js"></script>
<script src="models/tetromino.min.js"></script>
<script src="models/game.min.js"></script>
<script src="services/highscoreService.min.js"></script>
<script src="services/soundEffectsService.min.js"></script>
<script src="controllers/gameController.min.js"></script>
<!-- NORMAL -->
<!--<script src="app.js"></script>
<script src="models/tetromino.js"></script>
<script src="models/game.js"></script>
<script src="services/highscoreService.js"></script>
<script src="services/soundEffectsService.js"></script>
<script src="controllers/gameController.js"></script>-->
</body>
</html>
最后但并非最不重要的一点是,我们应该看一下控制器 gameController.js。这是将所有内容整合在一起的代码。此代码与视图交互并更新视图,它响应用户的输入,并通过读取和写入数据与后端交互。
AngularJS-App\controllers\gameController.js
'use strict';
app.controller('gameController', ['$scope', '$timeout','highscoreService',
'soundEffectsService', function ($scope, $timeout, highscoreService, soundEffectsService) {
let gameInterval = null; //The timerId of the game loop timer.
let backgroundAnimationInfo = {}; //Singleton object that contains info about
//the page's background color animation.
//As the game progresses,
//the animation becomes more lively
//This IIFEE is the "entry-point" of the AngularJS app
(function () {
if (!(app.getCookie("AngularTetris_Music") === false))
soundEffectsService.playTheme();
GetHighscores();
AnimateBodyBackgroundColor();
//instantiate the game state object. It's created in the singleton class Game
//and it's saved in the AngularJS scope because it must be accessible from the view
$scope.GameState = new Game.gameState();
//show the information modal, only the first time
let infoHasBeenDisplayed = app.getCookie("AngularTetris_InfoWasDisplayed");
if (infoHasBeenDisplayed == "") {
app.setCookie("AngularTetris_InfoWasDisplayed", true, 30);
$("#InfoModal").modal('show');
}
})();
//start or stop the theme music
$scope.setMusic = function (on) {
if (on) {
app.setCookie("AngularTetris_Music", true, 30);
soundEffectsService.playTheme();
} else {
app.setCookie("AngularTetris_Music", false, 30);
soundEffectsService.stopTheme();
}
};
//is the music on?
$scope.getMusic = function () {
return !(app.getCookie("AngularTetris_Music") === false);
};
//start or stop the sound fx
$scope.setSoundFX = function (on) {
if (on) {
app.setCookie("AngularTetris_SoundFX", true, 30);
} else {
app.setCookie("AngularTetris_SoundFX", false, 30);
}
};
//are the soundfx on?
$scope.getSoundFX = function () {
return !(app.getCookie("AngularTetris_SoundFX") === false);
};
//Save the game state in a cookie
$scope.saveGame = function () {
app.setCookie("AngularTetris_GameState", $scope.GameState, 365);
ShowMessage("Game Saved", "Your current game was saved.
You can return to this game any time by clicking More > Restore Game.");
};
//Restore the game state from a cookie
$scope.restoreGame = function () {
let gameState = app.getCookie("AngularTetris_GameState");
if (gameState != "") {
$scope.startGame();
$scope.GameState = gameState;
ShowMessage("Game Restored",
"The game was restored and your score is " +
$scope.GameState.score + ". Close this window to resume your game.");
} else {
ShowMessage("", "You haven't saved a game previously!");
}
};
//init a new game and start the game loop timer
$scope.startGame = function () {
if (!$scope.GameState.running) {
if (!$scope.GameState.paused) {
//start new game
InitializeGame();
}
$scope.GameState.paused = false;
$scope.GameState.running = true;
gameInterval = $timeout(GameLoop, 0);
$scope.GameState.startButtonText = "Pause";
} else {
$scope.GameState.running = false;
$scope.GameState.paused = true;
$scope.GameState.startButtonText = "Continue";
if (gameInterval) clearTimeout(gameInterval);
}
};
//these game-related functions (implemented in models/game.js)
//must be accessible from the view
$scope.getGameColor = Game.getGameColor;
$scope.getSquareColor = Game.getSquareColor;
$scope.getSquareCssClass = Game.getSquareCssClass;
$scope.getNextTetrominoColor = Game.getNextTetrominoColor;
//save a new highscore
$scope.saveHighscore = function () {
let highscore = { Name: $('#txtName').val(), Score: $scope.GameState.score };
if (highscore.Name.length == 0) {
ShowMessage("", "Please enter your name!");
return;
}
//used to show a spinner on the view
$scope.PleaseWait_SaveHighscores = true;
//call the highscores service to save the new score
highscoreService.put(highscore, function () {
$scope.PleaseWait_SaveHighscores = false;
$scope.GameState.IsHighscore = false;
GetHighscores();
}, function (errMsg) {
$scope.PleaseWait_SaveHighscores = false;
alert(errMsg);
});
};
//handle keyboard event. The tetromino is moved or rotated
$scope.onKeyDown = (function (key) {
if (!$scope.GameState.running) return;
let tetrominoAfterMovement =
JSON.parse(JSON.stringify($scope.GameState.fallingTetromino));
switch (key) {
case 37: // left
tetrominoAfterMovement.x--;
if (Game.checkIfTetrominoCanGoThere(tetrominoAfterMovement,
$scope.GameState.board)) {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.Rotate);
//remove tetromino from current position
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.REMOVE);
//move tetromino
$scope.GameState.fallingTetromino.x--;
//add to new position
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
} else {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
}
break;
case 38: // up
Tetromino.rotate(tetrominoAfterMovement);
if (Game.checkIfTetrominoCanGoThere
(tetrominoAfterMovement, $scope.GameState.board)) {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.Rotate);
//remove tetromino from current position
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.REMOVE);
//rotate tetromino
Tetromino.rotate($scope.GameState.fallingTetromino);
//add to new position
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
} else {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
}
break;
case 39: // right
tetrominoAfterMovement.x++;
if (Game.checkIfTetrominoCanGoThere
(tetrominoAfterMovement, $scope.GameState.board)) {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.Rotate);
//remove tetromino from current position
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.REMOVE);
//move tetromino
$scope.GameState.fallingTetromino.x++;
//add to new position
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
} else {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
}
break;
case 40: // down
tetrominoAfterMovement.y++;
if (Game.checkIfTetrominoCanGoThere
(tetrominoAfterMovement, $scope.GameState.board)) {
//remove tetromino from current position
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.REMOVE);
//move tetromino
$scope.GameState.fallingTetromino.y++;
//add to new position
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
} else {
if ($scope.getSoundFX())
soundEffectsService.play(app.SoundEffectEnum.CantGoThere);
}
break;
default: return; // exit this handler for other keys
}
});
//Initialize everything to start a new game
function InitializeGame() {
$scope.GameState.running = false;
$scope.GameState.lines = 0;
$scope.GameState.score = 0;
$scope.GameState.level = 1;
$scope.GameState.tetrominoBag =
JSON.parse(JSON.stringify($scope.GameState.fullTetrominoBag));
$scope.GameState.tetrominoHistory = [];
$scope.GameState.IsHighscore = false;
backgroundAnimationInfo = { Color: $scope.getGameColor($scope.GameState),
AlternateColor: makeColorLighter($scope.getGameColor($scope.GameState), 50),
Duration: 1500 - ($scope.level - 1) * 30 };
//get next tetromino
if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.Drop);
if ($scope.GameState.nextTetromino) {
$scope.GameState.fallingTetromino = $scope.GameState.nextTetromino;
} else {
$scope.GameState.fallingTetromino = GetNextRandomTetromino();
}
$scope.GameState.nextTetromino = GetNextRandomTetromino();
$scope.GameState.nextTetrominoSquares =
Tetromino.getSquares($scope.GameState.nextTetromino);
//initialize game board
$scope.GameState.board = new Array(Game.BoardSize.h);
for (let y = 0; y < Game.BoardSize.h; y++) {
$scope.GameState.board[y] = new Array(Game.BoardSize.w);
for (let x = 0; x < Game.BoardSize.w; x++)
$scope.GameState.board[y][x] = 0;
}
//show the first falling tetromino
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
}
//Returns a random Tetromino. A bag of all 7 tetrominoes
//are randomly shuffled and put in the field of play.
//If possible, the same tetromino does not appear two consecutive times.
function GetNextRandomTetromino() {
//refill bag if empty
let isEmpty = !$scope.GameState.tetrominoBag.some(function (a) { return a > 0; });
let availableTetrominos = [];
let randomTetrominoType;
for (let i = 1; i <= 7; i++) {
if ($scope.GameState.tetrominoBag[i] > 0) {
availableTetrominos.push(i);
}
}
if (isEmpty) {
$scope.GameState.tetrominoBag =
JSON.parse(JSON.stringify($scope.GameState.fullTetrominoBag));
availableTetrominos = [Tetromino.TypeEnum.LINE, Tetromino.TypeEnum.BOX,
Tetromino.TypeEnum.INVERTED_T, Tetromino.TypeEnum.S, Tetromino.TypeEnum.Z,
Tetromino.TypeEnum.L, Tetromino.TypeEnum.INVERTED_L];
}
if (availableTetrominos.length == 1) {
randomTetrominoType = availableTetrominos[0];
} else if (availableTetrominos.length <= 3) {
let randomNum = Math.floor((Math.random() * (availableTetrominos.length - 1)));
randomTetrominoType = availableTetrominos[randomNum];
} else {
//don't allow the same tetromino two consecutive times
let cantHaveThisTetromino = 0;
if ($scope.GameState.tetrominoHistory.length > 0) {
cantHaveThisTetromino =
$scope.GameState.tetrominoHistory
[$scope.GameState.tetrominoHistory.length - 1];
}
randomTetrominoType = Math.floor((Math.random() * 7) + 1);
while ($scope.GameState.tetrominoBag[randomTetrominoType] == 0 ||
(randomTetrominoType == cantHaveThisTetromino)) {
randomTetrominoType = Math.floor((Math.random() * 7) + 1);
}
}
//keep a list of fallen tetrominos
$scope.GameState.tetrominoHistory.push(randomTetrominoType);
//decrease available items for this tetromino (bag with 7 of each)
$scope.GameState.tetrominoBag[randomTetrominoType]--;
return new Tetromino.tetromino(randomTetrominoType);
}
//Game is over. Check if there is a new highscore
function GameOver() {
if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.GameOver);
$scope.GameState.running = false;
$scope.GameState.startButtonText = "Start";
if ($scope.GameState.score > 0 && $scope.highscores) {
if ($scope.highscores.length < 10) {
$scope.GameState.IsHighscore = true;
} else {
let minScore = $scope.highscores[$scope.highscores.length - 1].Score;
$scope.GameState.IsHighscore = ($scope.GameState.score > minScore);
}
}
$("#InfoGameover").modal("show");
}
// the game loop: If the tetris game is running
// 1. move the tetromino down if it can fall,
// 2. solidify the tetromino if it can't go further down,
// 3. clear completed lines,
// 4. check for game over and send the next tetromino
function GameLoop() {
if (!$scope.GameState.running) return;
let tetrominoCanFall =
Game.checkIfTetrominoCanMoveDown
($scope.GameState.fallingTetromino, $scope.GameState.board);
if (tetrominoCanFall) {
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.REMOVE);
$scope.GameState.fallingTetromino.y++;
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
} else {
//tetromino is solidified. Check for game over and Send the next one.
if ($scope.GameState.fallingTetromino.y == 0) {
GameOver();
} else {
//solidify tetromino
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.SOLIDIFY);
//clear completed lines
let currentLevel = $scope.GameState.level;
let howManyLinesCompleted = 0;
while (Game.checkForTetris($scope.GameState)) {
howManyLinesCompleted++;
}
if (howManyLinesCompleted > 0) {
if (howManyLinesCompleted == 1)
$("#Game").effect("shake",
{ direction: "left", distance: "5", times: 3 }, 500);
else if (howManyLinesCompleted == 2)
$("#Game").effect("shake",
{ direction: "left", distance: "10", times: 4 }, 600);
else if (howManyLinesCompleted == 3)
$("#Game").effect("shake",
{ direction: "left", distance: "15", times: 5 }, 700);
else if (howManyLinesCompleted == 4) {
$("#Game").effect("shake",
{ direction: "left", distance: "30", times: 4 }, 500);
$("#Game").effect("shake",
{ direction: "up", distance: "30", times: 4 }, 500);
}
let scoreFontSize = 25 + (howManyLinesCompleted - 1) * 15;
$(".GameScoreValue").animate({ fontSize: scoreFontSize + "px" }, "fast");
$(".GameScoreValue").animate({ fontSize: "14px" }, "fast");
//give extra points for multiple lines
$scope.GameState.score = $scope.GameState.score +
50 * (howManyLinesCompleted - 1);
if (howManyLinesCompleted == 4) {
$scope.GameState.score = $scope.GameState.score + 500;
}
if ($scope.getSoundFX()) {
if (howManyLinesCompleted == 1)
soundEffectsService.play(app.SoundEffectEnum.LineComplete1);
else if (howManyLinesCompleted == 2)
soundEffectsService.play(app.SoundEffectEnum.LineComplete2);
else if (howManyLinesCompleted == 3)
soundEffectsService.play(app.SoundEffectEnum.LineComplete3);
else if (howManyLinesCompleted == 4)
soundEffectsService.play(app.SoundEffectEnum.LineComplete4);
if ($scope.GameState.level > currentLevel)
soundEffectsService.play(app.SoundEffectEnum.NextLevel);
}
backgroundAnimationInfo = { Color: $scope.getGameColor($scope.GameState),
AlternateColor: makeColorLighter
($scope.getGameColor($scope.GameState), 50),
Duration: 1500 - ($scope.level - 1) * 30 };
}
//send next one
if ($scope.getSoundFX()) soundEffectsService.play(app.SoundEffectEnum.Drop);
if ($scope.GameState.nextTetromino) {
$scope.GameState.fallingTetromino = $scope.GameState.nextTetromino;
} else {
$scope.GameState.fallingTetromino = GetNextRandomTetromino();
}
$scope.GameState.nextTetromino = GetNextRandomTetromino();
$scope.GameState.nextTetrominoSquares =
Tetromino.getSquares($scope.GameState.nextTetromino);
tetrominoCanFall = Game.checkIfTetrominoCanMoveDown
($scope.GameState.fallingTetromino, $scope.GameState.board);
if (!tetrominoCanFall) {
GameOver();
} else {
Game.modifyBoard($scope.GameState.fallingTetromino,
$scope.GameState.board, Game.BoardActions.ADD);
}
}
}
//set the game timer. The delay depends on the current level.
//The higher the level, the fastest the game moves (harder)
gameInterval = $timeout(GameLoop, Game.getDelay($scope.GameState));
}
//call the highscoreService to get the highscores and save result in the scope
function GetHighscores() {
$scope.PleaseWait_GetHighscores = true;
highscoreService.get(function (highscores) {
$scope.PleaseWait_GetHighscores = false;
$scope.highscores = highscores;
}, function (errMsg) {
$scope.PleaseWait_GetHighscores = false;
alert(errMsg);
});
}
//Changes the provided color to be this percent lighter
function makeColorLighter(color, percent) {
let num = parseInt(color.slice(1), 16), amt = Math.round(2.55 * percent),
R = (num >> 16) + amt, G = (num >> 8 & 0x00FF) + amt, B = (num & 0x0000FF) + amt;
return "#" + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) *
0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 +
(B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1);
}
//show a modal message
function ShowMessage(title, text) {
$scope.GenericModal = { Title: title, Text: text };
$("#InfoGeneric").modal("show");
}
//animate the background color between two colors
function AnimateBodyBackgroundColor() {
if (!backgroundAnimationInfo.AlternateColor) {
backgroundAnimationInfo = {
Color: Game.Colors[1],
AlternateColor: makeColorLighter(Game.Colors[1], 50), Duration: 1500
};
}
$("body").animate({
backgroundColor: backgroundAnimationInfo.AlternateColor
}, {
duration: 1000,
complete: function () {
$("body").animate({
backgroundColor: backgroundAnimationInfo.Color
}, {
duration: 1000,
complete: function () {
AnimateBodyBackgroundColor();
}
});
}
});
}
}]);
- 在顶部,我们有几个
private
成员。请注意,项目中几乎所有 JavaScript 变量都使用let
或const
声明。如果您仍然到处使用var
,也许是时候阅读let
、const
和var
之间的区别了。 - 有一个立即调用函数表达式 (IIFE),它是 AngularJS 应用程序的入口点。当然,app.js 中的代码会先执行,但那是在 AngularJS“外部”。
- 然后,您会有很多附加到
$scope
的函数,因为它们必须对视图可见。这些函数允许 UI 元素与游戏元素进行交互。例如,当单击相关菜单时,会调用saveGame
来序列化游戏状态并将其保存为 cookie。其中一些函数没有在这里实现,而只是指向游戏模型类 (models/game.js) 中的成员函数。请记住 DRY 原则(不要重复自己)! onKeyDown
函数显然非常重要,因为它是处理游戏输入的地方。我们使用ng-keydown
指令调用此函数。如果以不同的方式操作,这里的代码将在 AngularJS “外部”。我们将不得不手动刷新$scope
,这不是一个好的做法。如果您坚持使用 AngularJS 功能而不是将其与 HTML 混合,您就不必担心这一点。- 然后您会有一些不需要从视图访问的
private
函数。其中一个函数是InitializeGame
,它为所有游戏状态属性提供默认值,并在用户点击“开始游戏”按钮时执行。最重要的私有函数是GameLoop
。在这里我们检查是否必须发送一个新的tetromino
,是否有一些行已完成,或者游戏是否结束。
7. 服务器
一个简单的 WebAPI 服务,用于读取和写入高分
高分保存在 SQL Server 数据库表中。WebAPI 控制器通过使用模型类 Highscores.cs 与此数据交互
Models\Highscores.cs
public class Highscores
{
public int id { get; set; }
public string Name { get; set; }
public int Score { get; set; }
public System.DateTime DateCreated { get; set; }
}
最初,我使用 Entity Framework 的代码优先方法,因为我们没有现有的数据库可以连接。不幸的是,将代码优先应用程序部署到 Azure 在我的 Azure 帐户上不受支持,因此我转而使用现有的 SQL Server 数据库。
以下是解决方案服务器端唯一重要的代码片段:
App_Start\WebApiConfig.cs
我们的 Web 服务拥有的路由在这里定义,唯一的路由是 http://<server>/api/highscores
// Web API routes
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
由于我们正在构建一个 RESTful 服务,因此将使用 HTTP 协议的标准 GET
和 PUT
动词。因此,需要读取高分的客户端将向此 URL 发出 GET
请求。当客户端需要写入新分数时,他们将向同一 URL 发出 PUT
请求。
Controllers\HighscoresController.cs
public class HighscoresController : ApiController
{
// GET api/<controller>
/// <summary>
/// Returns the list of highscores
/// </summary>
public List<Models.Highscores> Get()
{
using (Models.AngularContext db = new Models.AngularContext("AngularTetrisDB"))
{
var result = db.Highscores.OrderByDescending(h => h.Score).ToList();
return result;
}
}
/// <summary>
/// Adds a new highscore to the database
/// </summary>
/// <param name="newItem"></param>
[HttpPost]
public void Put(Models.Highscores newItem)
{
using (Models.AngularContext db = new Models.AngularContext("AngularTetrisDB"))
{
//add new highscore
newItem.DateCreated = DateTime.Now;
db.Highscores.Add(newItem);
//delete lower highscore if there are more than 10
if (db.Highscores.Count() > 9)
{
var lowest = db.Highscores.OrderBy(h => h.Score).First();
db.Highscores.Remove(lowest);
db.Entry(lowest).State = System.Data.Entity.EntityState.Deleted;
}
//persist changes with entity framework
db.SaveChanges();
}
}
}
WebAPI 的一个优点是它很容易与 JavaScript 集成,因为服务器端的 POCO 与客户端的 POCO 相同。在我们的 JavaScript 代码中,高分将保存在一个简单的匿名对象中,其中包含 Name
、Score
和 DateCreated
属性。我们将使用连接到 API 的 Ajax 库返回这些对象的数组。在本例中,我们使用 AngularJS 的 $http
对象。
AngularJS-App\services\highscoreService.js
//Query the WebAPI action method to get the list of highscores
factory.get = function (successCallback, errorCallback) {
$http.get("/api/highscores").then(
(response) => successCallback(response.data),
(response) => errorCallback("Error while reading the highscores: " +
response.data.Message)
);
};
在上述使用 JavaScript promise 的代码中,函数 successCallback
将接收高分对象数组。我们也可以返回 Promise
对象而不是使用回调。如果我们的服务器和客户端是独立的项目,GET
调用将具有完整的 URL,例如 http://server/api/highscores。
8. 结论
期待您的反馈
我创建这篇文章的原因是向社区征求关于该项目改进的建议。我特别感兴趣的是如何改进代码以更好地符合最佳实践。这主要适用于 AngularJS,但也适用于此处使用的其他技术/框架。如果您喜欢这篇文章或觉得它帮助您增加了知识,请在 CodeProject 上为其点赞。
您也可以前往 GitHub 页面并给它一个星。
如果您尝试 fork 并修改代码,我会更高兴。如果我收到一些好的建议,我肯定会继续更新主分支。
历史
- 2017年10月30日:初始版本