如何使用 Angular 和 RxJS 创建纸牌匹配游戏

已发表: 2022-03-10
快速总结↬这篇文章是献给想要利用响应式编程概念的 Angular 开发人员。 这是一种编程风格——简单地说——处理异步数据流的处理。

今天,我想关注用户界面上的点击事件产生的数据流。 这种点击流的处理对于需要处理许多事件的密集用户交互的应用程序特别有用。 我还想向您介绍更多 RxJS; 它是一个 JavaScript 库,可用于以反应式风格简洁明了地表达事件处理例程。

我们在建造什么?

学习游戏和知识测验在年轻和年长用户中都很受欢迎。 一个例子是“配对匹配”游戏,用户必须在图像和/或文本片段的混合中找到相关配对。

下面的动画展示了一个简单的游戏版本:用户依次选择比赛场地左右两侧的两个元素,顺序不限。 正确匹配的配对被移动到比赛场地的单独区域,而任何错误的分配都会立即解除,因此用户必须做出新的选择。

学习游戏“配对”的屏幕截图
我们今天将要创建的游戏先睹为快

在本教程中,我们将逐步构建这样一个学习游戏。 在第一部分中,我们将构建一个 Angular 组件,它只是展示游戏的竞争环境。 我们的目标是该组件可以针对不同的用例和目标群体进行配置——从动物测验到语言学习应用程序中的词汇训练师。 为此,Angular 提供了带有可定制模板的内容投影概念,我们将使用它。 为了说明原理,我将构建具有不同布局的两个版本的游戏(“game1”和“game2”)。

在本教程的第二部分,我们将专注于反应式编程。 每当一对匹配时,用户需要从应用程序中获得某种反馈; 正是这种事件处理是在 RxJS 库的帮助下实现的。

  • 要求
    要学习本教程,必须安装 Angular CLI。
  • 源代码
    本教程的源代码可以在这里找到 (14KB)。
跳跃后更多! 继续往下看↓

1. 为学习游戏构建一个 Angular 组件

如何创建基本框架

首先,让我们创建一个名为“learning-app”的新项目。 使用 Angular CLI,您可以使用命令ng new learning-app执行此操作。 在文件app.component.html中,我将预先生成的源代码替换如下:

 <div> <h1>Learning is fun!</h1> </div>

在下一步中,创建学习游戏的组件。 我将其命名为“matching-game”并使用命令ng generate component matching-game 。 这将为包含所需 HTML、CSS 和 Typescript 文件的游戏组件创建一个单独的子文件夹。

如前所述,教育游戏必须针对不同目的进行配置。 为了演示这一点,我使用相同的命令创建了两个额外的组件( game1 game2 。 通过将文件game1.component.htmlgame2.component.html中的预生成代码替换为以下标记,我将游戏组件添加为子组件:

 <app-matching-game></app-matching-game>

起初,我只使用组件game1 。 为了确保游戏 1 在启动应用程序后立即显示,我将这个标签添加到app.component.html文件中:

 <app-game1></app-game1>

使用ng serve --open启动应用程序时,浏览器将显示消息“matching-game works”。 (这是目前match-game.component.html的唯一内容。)

现在,我们需要测试数据。 在/app文件夹中,我创建了一个名为pair.ts的文件,我在其中定义了类Pair

 export class Pair { leftpart: string; rightpart: string; id: number; }

配对对象包含两个相关文本( leftpartrightpart )和一个 ID。

第一个游戏应该是一个物种测验,其中物种(例如dog )必须被分配到适当的动物类别(例如mammal )。

在文件animals.ts中,我定义了一个包含测试数据的数组:

 import { Pair } from './pair'; export const ANIMALS: Pair[] = [ { id: 1, leftpart: 'dog', rightpart: 'mammal'}, { id: 2, leftpart: 'blickbird', rightpart: 'bird'}, { id: 3, leftpart: 'spider', rightpart: 'insect'}, { id: 4, leftpart: 'turtle', rightpart: 'reptile' }, { id: 5, leftpart: 'guppy', rightpart: 'fish'}, ];

组件game1需要访问我们的测试数据。 它们存储在属性animals中。 文件game1.component.ts现在有以下内容:

 import { Component, OnInit } from '@angular/core'; import { ANIMALS } from '../animals'; @Component({ selector: 'app-game1', templateUrl: './game1.component.html', styleUrls: ['./game1.component.css'] }) export class Game1Component implements OnInit { animals = ANIMALS; constructor() { } ngOnInit() { } }

第一版游戏组件

我们的下一个目标:游戏组件matching-game必须接受来自父组件(例如game1 )的游戏数据作为输入。 输入是“对”对象的数组。 启动应用程序时,应使用传递的对象初始化游戏的用户界面。

学习游戏“配对”的屏幕截图

为此,我们需要进行如下操作:

  1. 使用@Input装饰器将属性pairs添加到游戏组件。
  2. 添加数组solvedPairsunsolvedPairs作为组件的附加私有属性。 (有必要区分已经“解决”和“尚未解决”的对。)
  3. 当应用程序启动时(参见函数ngOnInit ),所有对仍然“未解决”,因此被移动到数组unsolvedPairs中。
 import { Component, OnInit, Input } from '@angular/core'; import { Pair } from '../pair'; @Component({ selector: 'app-matching-game', templateUrl: './matching-game.component.html', styleUrls: ['./matching-game.component.css'] }) export class MatchingGameComponent implements OnInit { @Input() pairs: Pair[]; private solvedPairs: Pair[] = []; private unsolvedPairs: Pair[] = []; constructor() { } ngOnInit() { for(let i=0; i<this.pairs.length; i++){ this.unsolvedPairs.push(this.pairs[i]); } } }

此外,我定义了matching-game组件的 HTML 模板。 有未解决和已解决对的容器。 ngIf指令确保仅在至少存在一对未解决或已解决的对时才显示相应的容器。

在未解决对的容器(类container unsolved )中,首先列出了所有对的组件(参见上面 GIF 中的left框),然后列出了所有right (参见 GIF 中的右框)组件。 (我使用ngFor指令来列出这些对。)目前,一个简单的按钮作为模板就足够了。

使用模板表达式{{{pair.leftpart}}和 { {{pair.rightpart}}} ,在迭代pair数组时查询各个对对象的属性leftpartrightpart的值。 它们用作生成按钮的标签。

分配的对列在第二个容器中(已container solved )。 绿色条(类connector )表示它们属于同一类。

文件matching-game.component.css对应的CSS代码可以在文章开头的源码中找到。

 <div> <div class="container unsolved" *ngIf="unsolvedPairs.length>0"> <div class="pair_items left"> <button *ngFor="let pair of unsolvedPairs" class="item"> {{pair.leftpart}} </button> </div> <div class="pair_items right"> <button *ngFor="let pair of unsolvedPairs" class="item"> {{pair.rightpart}} </button> </div> </div> <div class="container solved" *ngIf="solvedPairs.length>0"> <div *ngFor="let pair of solvedPairs" class="pair"> <button>{{pair.leftpart}}</button> <div class="connector"></div> <button>{{pair.rightpart}}</button> </div> </div> </div>

在组件game1中,数组animals现在绑定到组件matching-gamepairs属性(单向数据绑定)。

 <app-matching-game [pairs]="animals"></app-matching-game>

结果如下图所示。

用户界面的当前状态
用户界面的当前状态

显然,我们的配对游戏还不算太难,因为配对的左右部分是直接相对的。 为了使配对不会太琐碎,应该混合正确的部分。 我用一个自定义的 pipe shuffle解决了这个问题,我将它应用于右侧的数组unsolvedPairs (稍后需要参数test来强制更新管道):

 ... <div class="pair_items right"> <button *ngFor="let pair of unsolvedPairs | shuffle:test" class="item"> {{pair.rightpart}} </button> </div> ...

pipe的源码存放在app文件夹下的shuffle.pipe.ts文件中(源码见文首)。 另请注意文件app.module.ts ,其中必须导入管道并在模块声明中列出。 现在所需的视图出现在浏览器中。

扩展版:使用可定制的模板来实现游戏的个性化设计

应该可以指定任意模板片段来自定义游戏,而不是按钮。 在文件matching-game.component.html 中,我将游戏左右两侧的按钮模板替换为ng-template标签。 然后我将模板引用的名称分配给属性ngTemplateOutlet 。 这给了我两个占位符,它们在渲染视图时被相应模板引用的内容替换。

我们在这里处理内容投影的概念:组件模板的某些部分是从外部给出的,并在标记的位置“投影”到模板中。

生成视图时,Angular 必须将游戏数据插入到模板中。 通过参数ngTemplateOutletContext我告诉 Angular 在模板中使用了一个变量contextPair ,它应该从ngFor指令中分配pair变量的当前值。

以下清单显示了容器unsolved的替换。 在已solved的容器中,按钮也必须由ng-template标签替换。

 <div class="container unsolved" *ngIf="unsolvedPairs.length>0"> <div class="pair_items left"> <div *ngFor="let pair of unsolvedPairs" class="item"> <ng-template [ngTemplateOutlet]="leftpart_temp" [ngTemplateOutletContext]="{contextPair: pair}"> </ng-template> </div> </div> <div class="pair_items right"> <div *ngFor="let pair of unsolvedPairs | shuffle:test" class="item"> <ng-template [ngTemplateOutlet]="leftpart_temp" [ngTemplateOutletContext]="{contextPair: pair}"> </ng-template> </div> </div> </div> ...

在文件matching-game.component.ts中,必须声明两个模板引用( leftpart_temprightpart_temp )的变量。 装饰器@ContentChild表示这是一个内容投影,即 Angular 现在期望在父组件中的标签<app-matching-game></app-matching-game>之间提供两个模板片段以及各自的选择器( leftpartrightpart )主机元素的<app-matching-game></app-matching-game> (请参阅@ViewChild )。

 @ContentChild('leftpart', {static: false}) leftpart_temp: TemplateRef<any>; @ContentChild('rightpart', {static: false}) rightpart_temp: TemplateRef<any>;

不要忘记: ContentChildTemplateRef类型必须从核心包中导入。

在父组件game1中,现在插入了两个必需的带有选择器leftpartrightpart的模板片段。

为简单起见,我将再次重用此处的按钮:

 <app-matching-game [pairs]="animals"> <ng-template #leftpart let-animalPair="contextPair"> <button>{{animalPair.leftpart}}</button> </ng-template> <ng-template #rightpart let-animalPair="contextPair"> <button>{{animalPair.rightpart}}</button> </ng-template> </app-matching-game>

属性let-animalPair="contextPair"用于指定上下文变量contextPair在名为animalPair的模板片段中使用。

现在可以根据自己的喜好更改模板片段。 为了证明这一点,我使用了组件game2 。 文件game2.component.ts获得与game1.component.ts相同的内容。 在game2.component.html 中,我使用单独设计的div元素而不是按钮。 CSS 类存储在文件game2.component.css中。

 <app-matching-game [pairs]="animals"> <ng-template #leftpart let-animalPair="contextPair"> <div class="myAnimal left">{{animalPair.leftpart}}</div> </ng-template> <ng-template #rightpart let-animalPair="contextPair"> <div class="myAnimal right">{{animalPair.rightpart}}</div> </ng-template> </app-matching-game>

在首页app.component.html添加标签<app-game2></app-game2>后,我启动应用时出现第二版游戏:

组件 game2 中的另一种游戏视图
组件game2中的另一种游戏视图

现在设计的可能性几乎是无限的。 例如,可以定义一个包含附加属性的Pair子类。 例如,可以为左侧和/或右侧部分存储图像地址。 图像可以与文本一起显示在模板中,也可以代替文本显示。

2. 控制用户与 RxJS 的交互

使用 RxJS 进行响应式编程的优势

要将应用程序变成交互式游戏,必须处理在用户界面触发的事件(例如鼠标点击事件)。 在反应式编程中,考虑了连续的事件序列,即所谓的“流”。 可以观察流(它是“可观察的”),即可以有一个或多个“观察者”或“订阅者”订阅该流。 流中的每个新值都会通知它们(通常是异步的),并且可以以某种方式对其做出反应。

使用这种方法,可以实现应用程序各部分之间的低级别耦合。 现有的观察者和可观察者彼此独立,它们的耦合可以在运行时改变。

JavaScript 库 RxJS 提供了观察者设计模式的成熟实现。 此外,RxJS 包含许多操作符来转换流(例如过滤器、映射)或将它们组合成新的流(例如合并、连接)。 运算符是函数式编程意义上的“纯函数”:它们不会产生副作用,并且独立于函数外部的状态。 仅由对纯函数的调用组成的程序逻辑不需要全局或局部辅助变量来存储中间状态。 这反过来又促进了无状态和松耦合代码块的创建。 因此希望通过流操作符的巧妙组合来实现大部分事件处理。 这方面的例子在下一节中给出,基于我们的配对游戏。

将 RxJS 集成到 Angular 组件的事件处理中

Angular 框架与 RxJS 库的类一起工作。 因此,RxJS 在安装 Angular 时会自动安装。

下图显示了在我们的考虑中发挥作用的主要类和函数:

Angular/RxJS 中事件处理的基本类模型
Angular/RxJS 中事件处理的基本类模型
班级名称功能
可观察的(RxJS) 表示流的基类; 换句话说,一个连续的数据序列。 可以订阅 observable。 pipe函数用于将一个或多个运算符函数应用于可观察实例。
主题(RxJS) observable 的子类提供了在流中发布新数据的下一个功能。
事件发射器(角度) 这是一个特定于角度的子类,通常仅与@Output装饰器一起使用来定义组件输出。 与下一个函数一样, emit函数用于向订阅者发送数据。
订阅 (RxJS) observable 的subscribe函数返回一个订阅实例。 使用组件后需要取消订阅。

在这些类的帮助下,我们希望在我们的游戏中实现用户交互。 第一步是确保用户在左侧或右侧选择的元素在视觉上突出显示。

元素的可视化表示由父组件中的两个模板片段控制。 因此,它们在选定状态下如何显示的决定也应该留给父组件来决定。 一旦在左侧或右侧做出选择,或者一旦要撤消选择,它就应该接收到适当的信号。

为此,我在matching-game.component.ts文件中定义了四个EventEmitter类型的输出值。 类型OutputEventEmitter必须从核心包中导入。

 @Output() leftpartSelected = new EventEmitter<number>(); @Output() rightpartSelected = new EventEmitter<number>(); @Output() leftpartUnselected = new EventEmitter(); @Output() rightpartUnselected = new EventEmitter();

在模板matching-game.component.html中,我对左右两边的mousedown事件做出反应,然后将选中项的 ID 发送给所有接收者。

 <div *ngFor="let pair of unsolvedPairs" class="item" (mousedown)="leftpartSelected.emit(pair.id)"> ... <div *ngFor="let pair of unsolvedPairs | shuffle:test" class="item" (mousedown)="rightpartSelected.emit(pair.id)">

在我们的例子中,接收者是组件game1 game2 您现在可以在那里定义事件leftpartSelectedrightpartSelectedleftpartUnselectedrightpartUnselected的事件处理。 变量$event表示发出的输出值,在我们的例子中是 ID。 在下面您可以看到game1.component.html 的清单,对于 game2.component.html适用相同的更改。

 <app-matching-game [pairs]="animals" (leftpartSelected)="onLeftpartSelected($event)" (rightpartSelected)="onRightpartSelected($event)" (leftpartUnselected)="onLeftpartUnselected()" (rightpartUnselected)="onRightpartUnselected()"> <ng-template #leftpart let-animalPair="contextPair"> <button [class.selected]="leftpartSelectedId==animalPair.id"> {{animalPair.leftpart}} </button> </ng-template> <ng-template #rightpart let-animalPair="contextPair"> <button [class.selected]="rightpartSelectedId==animalPair.id"> {{animalPair.rightpart}} </button> </ng-template> </app-matching-game>

game1.component.ts 中(和在game2.component.ts中类似),现在实现了event处理函数。 我存储所选元素的 ID。 在 HTML 模板(见上文)中,这些元素被分配了selected类。 CSS 文件game1.component.css定义了这个类将带来哪些视觉变化(例如颜色或字体变化)。 重置选择(取消选择)基于对对象始终具有正 ID 的假设。

 onLeftpartSelected(id:number):void{ this.leftpartSelectedId = id; } onRightpartSelected(id:number):void{ this.rightpartSelectedId = id; } onLeftpartUnselected():void{ this.leftpartSelectedId = -1; } onRightpartUnselected():void{ this.rightpartSelectedId = -1; }

下一步,需要在匹配游戏组件中进行事件处理。 必须确定赋值是否正确,即左选元素是否与右选元素匹配。 在这种情况下,分配的对可以移动到已解析对的容器中。

我想使用 RxJS 运算符制定评估逻辑(请参阅下一节)。 为了准备,我在matching-game.component.ts中创建了一个主题assignmentStream 。 它应该发出用户在左侧或右侧选择的元素。 目标是使用 RxJS 操作符来修改和拆分流,从而获得两个新流:一个流solvedStream提供正确分配的对,另一个流failedStream提供错误分配。 我想使用subscribe订阅这两个流,以便能够在每种情况下执行适当的事件处理。

我还需要对创建的订阅对象的引用,以便在离开游戏时可以使用“取消订阅”取消订阅(请参阅ngOnDestroy )。 SubjectSubscription类必须从包“rxjs”中导入。

 private assignmentStream = new Subject<{pair:Pair, side:string}>(); private solvedStream = new Observable<Pair>(); private failedStream = new Observable<string>(); private s_Subscription: Subscription; private f_Subscription: Subscription; ngOnInit(){ ... //TODO: apply stream-operators on //assignmentStream this.s_Subscription = this.solvedStream.subscribe(pair => handleSolvedAssignment(pair)); this.f_Subscription = this.failedStream.subscribe(() => handleFailedAssignment()); } ngOnDestroy() { this.s_Subscription.unsubscribe(); this.f_Subscription.unsubscribe(); }

如果分配正确,则执行以下步骤:

  • 分配的对将移动到已解决对的容器中。
  • 事件leftpartUnselectedrightpartUnselected被发送到父组件。

如果分配不正确,则不会移动任何对。 如果从左到右执行了错误的赋值( side1的值为left ),则应该取消对左侧元素的选择(参见文章开头的 GIF)。 如果从右到左进行分配,则取消对右侧元素的选择。 这意味着最后一个被点击的元素保持在选中状态。

对于这两种情况,我准备了对应的处理函数handleSolvedAssignmenthandleFailedAssignment (删除函数:见文末源码):

 private handleSolvedAssignment(pair: Pair):void{ this.solvedPairs.push(pair); this.remove(this.unsolvedPairs, pair); this.leftpartUnselected.emit(); this.rightpartUnselected.emit(); //workaround to force update of the shuffle pipe this.test = Math.random() * 10; } private handleFailedAssignment(side1: string):void{ if(side1=="left"){ this.leftpartUnselected.emit(); }else{ this.rightpartUnselected.emit(); } }

现在我们必须将观点从订阅数据的消费者转变为生成数据的生产者。 在文件matching-game.component.html中,我确保在单击元素时,关联的对对象被推送到流assignmentStream中。 对左侧和右侧使用公共流是有意义的,因为分配的顺序对我们来说并不重要。

 <div *ngFor="let pair of unsolvedPairs" class="item" (mousedown)="leftpartSelected.emit(pair.id)" (click)="assignmentStream.next({pair: pair, side: 'left'})"> ... <div *ngFor="let pair of unsolvedPairs | shuffle:test" class="item" (mousedown)="rightpartSelected.emit(pair.id)" (click)="assignmentStream.next({pair: pair, side: 'right'})">

与 RxJS 操作符的游戏交互设计

剩下的就是将流assignmentStream转换为流solvedStreamfailedStream 。 我按顺序应用以下运算符:

pairwise

一个作业中总是有两对。 pairwise运算符从流中成对挑选数据。 当前值和先前值组合成一对。

从以下流...

 „{pair1, left}, {pair3, right}, {pair2, left}, {pair2, right}, {pair1, left}, {pair1, right}“

…结果这个新流:

 „({pair1, left}, {pair3, right}), ({pair3, right}, {pair2, left}), ({pair2, left}, {pair2, right}), ({pair2, right}, {pair1, left}), ({pair1, left}, {pair1, right})“

例如,当用户选择左侧的dog (id=1) 和右侧的insect (id=3) 时,我们得到组合({pair1, left}, {pair3, right}) (参见数组ANIMALS文章开头)。 这些和其他组合来自上面 GIF 中显示的游戏序列。

filter

您必须从流中删除在比赛场地同一侧进行的所有组合,例如({pair1, left}, {pair1, left})({pair1, left}, {pair4, left})

因此组合comb的过滤条件是comb[0].side != comb[1].side

partition

该运算符接受一个流和一个条件,并从中创建两个流。 第一个流包含满足条件的数据,第二个流包含剩余的数据。 在我们的例子中,流应该包含正确或不正确的分配。 所以组合comb的条件是comb[0].pair===comb[1].pair

该示例产生一个“正确”的流

({pair2, left}, {pair2, right}), ({pair1, left}, {pair1, right})

和一个“错误”的流

({pair1, left}, {pair3, right}), ({pair3, right}, {pair2, left}), ({pair2, right}, {pair1, left})

map

仅需要单独的对对象来进一步处理正确的分配,例如pair2 。 map 运算符可用于表示组合comb应映射到comb[0].pair 。 如果分配不正确,则组合comb将映射到字符串comb[0].side ,因为应在 side 指定的side重置选择。

pipe函数用于连接上述运算符。 必须从包rxjs/operators导入操作符pairwisefilterpartitionmap

 ngOnInit() { ... const stream = this.assignmentStream.pipe( pairwise(), filter(comb => comb[0].side != comb[1].side) ); //pipe notation leads to an error message (Angular 8.2.2, RxJS 6.4.0) const [stream1, stream2] = partition(comb => comb[0].pair === comb[1].pair)(stream); this.solvedStream = stream1.pipe( map(comb => comb[0].pair) ); this.failedStream = stream2.pipe( map(comb => comb[0].side) ); this.s_Subscription = this.solvedStream.subscribe(pair => this.handleSolvedAssignment(pair)); this.f_Subscription = this.failedStream.subscribe(side => this.handleFailedAssignment(side)); }

现在游戏已经开始了!

学习游戏“配对”的屏幕截图
最后结果

通过使用操作符,游戏逻辑可以被声明性地描述。 我们只描述了我们的两个目标流的属性(组合成对、过滤、分区、重新映射),而不必担心这些操作的实现。 如果我们自己实现它们,我们还必须在组件中存储中间状态(例如,对左侧和右侧最后单击项目的引用)。 相反,RxJS 运算符为我们封装了实现逻辑和所需的状态,从而将编程提升到更高的抽象级别。

结论

以一个简单的学习游戏为例,我们测试了 RxJS 在 Angular 组件中的使用。 反应式方法非常适合处理用户界面上发生的事件。 使用 RxJS,事件处理所需的数据可以方便地排列为流。 许多运算符,例如filtermappartition可用于转换流。 生成的流包含以最终形式准备的数据,可以直接订阅。 为各个案例选择合适的操作员并有效地链接它们需要一点技巧和经验。 本文应该对此进行介绍。

更多资源

  • Andre Staltz 所写的“你一直错过的反应式编程简介”

SmashingMag 的相关阅读

  • 使用 Angular 管理图像断点
  • 使用 Bootstrap 设计 Angular 应用程序
  • 如何创建和部署 Angular Material 应用程序