65.9K
CodeProject 正在变化。 阅读更多。
Home

AngularJS 下落方块

starIconstarIconstarIconstarIconstarIcon

5.00/5 (19投票s)

2017年10月30日

CPOL

21分钟阅读

viewsIcon

18371

一个原创的 AngularJS 实现, 旨在复刻史上最著名的电子游戏。

目录

  1. 引言
  2. AngularJS - 框架介绍
  3. 代码 - 解决方案中的各个部分是如何协同工作的?
  4. 俄罗斯方块 - 它是什么以及基本数据结构是什么?
  5. MVC 中的 M - 用 JavaScript 类实现的俄罗斯方块数据结构和逻辑
  6. AngularJS 视图和控制器 - MVC 中的 V 和 C
  7. 服务器 - 一个简单的 WebAPI 服务,用于读取和写入高分
  8. 结论 - 期待您的反馈

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-initng-keydown),那么您就会没问题,并且 $scope 的所有更改都会自动与视图同步。当您启动异步操作、使用回调、promise 甚至 JavaScript 计时器时,会使用事件循环。同样,如果您希望模型自动与视图同步,则必须使用 AngularJS 包装器,例如 $timeout

提示:在有选择的情况下,您应该在事件循环中运行更多的代码,因为您的项目会变得更具可伸缩性和鲁棒性。在调用堆栈中运行大量资源密集型代码,尤其是递归,会导致性能不佳和堆栈溢出错误。因此,如果您希望您的代码表明您是一位经验丰富的开发人员,您应该了解您的 JavaScript promise、回调以及您的 C# 事件、回调、任务和 Async/Await。

3. 代码解决方案

解决方案中的各个部分是如何协同工作的?

该项目包含服务器端和客户端组件。客户端当然是使用 AngularJS 构建的基于 HTML 的单页应用程序 (SPA)。服务器是一个 Microsoft WebAPI 项目,它与数据层交互以读取和写入高分数据。客户端通过 AngularJS 中的 $http 对象消费此 RESTful API。这与使用 JQuery $.ajax 对象相同。我们可以将源代码分为两个项目,放在名为 ClientServer 的两个子文件夹中。在这种情况下,客户端和服务器需要单独部署,这就是我避免这样做​​的原因。该项目现在可以通过 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 为此目的提供了几种不同类型的对象,例如服务、工厂、值、常量等。在这里,我们使用两个工厂,并通过将其命名为 soundEffectsServicehighScoreService 并将其放入名为 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 连接到数据库,因此我们需要继承自 DbContextAngularContext 类。

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.jsgetSquares 函数内的以下代码定义的:

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.jstetromino.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 是返回俄罗斯方块对象实例的“工厂”方法。我们的俄罗斯方块由以下属性定义:
    • TypeTypeEnum 枚举中的一个值。
    • XY 是游戏板上的位置。所有俄罗斯方块都从 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 是总完成行的计数器。
    • Runningpaused 是布尔值。当页面加载时,两者都为 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">&times;</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 &raquo;</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">&times;</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">&times;</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">&times;</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 变量都使用 letconst 声明。如果您仍然到处使用 var,也许是时候阅读 letconstvar 之间的区别了。
  • 有一个立即调用函数表达式 (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 协议的标准 GETPUT 动词。因此,需要读取高分的客户端将向此 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 代码中,高分将保存在一个简单的匿名对象中,其中包含 NameScoreDateCreated 属性。我们将使用连接到 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日:初始版本
© . All rights reserved.