如何使用 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 應用程序