So erstellen Sie ein Karten-Matching-Spiel mit Angular und RxJS

Veröffentlicht: 2022-03-10
Kurze Zusammenfassung ↬ Dieser Artikel ist Angular-Entwicklern gewidmet, die das Konzept der reaktiven Programmierung nutzen möchten. Dabei handelt es sich um einen Programmierstil, der sich – vereinfacht gesagt – mit der Verarbeitung von asynchronen Datenströmen befasst.

Heute möchte ich mich auf Datenströme konzentrieren, die aus Klickereignissen auf der Benutzeroberfläche resultieren. Die Verarbeitung solcher Clickstreams ist besonders sinnvoll für Anwendungen mit intensiver Benutzerinteraktion, bei denen viele Ereignisse verarbeitet werden müssen. Ich möchte Ihnen auch RxJS etwas näher vorstellen; Es ist eine JavaScript-Bibliothek, die verwendet werden kann, um Ereignisbehandlungsroutinen kompakt und prägnant in einem reaktiven Stil auszudrücken.

Was bauen wir?

Lernspiele und Wissensquizze sind sowohl bei jüngeren als auch bei älteren Nutzern beliebt. Ein Beispiel ist das Spiel „Pair Matching“, bei dem der Nutzer verwandte Paare in einer Mischung aus Bildern und/oder Textschnipseln finden muss.

Die folgende Animation zeigt eine einfache Variante des Spiels: Der Nutzer wählt nacheinander zwei Elemente auf der linken und rechten Seite des Spielfelds in beliebiger Reihenfolge aus. Korrekt zusammengehörige Paare werden in einen separaten Bereich des Spielfelds verschoben, während falsche Zuordnungen sofort aufgelöst werden, so dass der Benutzer eine neue Auswahl treffen muss.

Screenshot des Lernspiels „Passende Paare“
Ein kleiner Vorgeschmack auf das Spiel, das wir heute erstellen werden

In diesem Tutorial bauen wir Schritt für Schritt ein solches Lernspiel auf. Im ersten Teil werden wir eine Angular-Komponente bauen, die nur das Spielfeld des Spiels zeigt. Unser Ziel ist es, dass die Komponente für unterschiedliche Anwendungsfälle und Zielgruppen konfiguriert werden kann – vom Tierquiz bis zum Vokabeltrainer in einer Sprachlern-App. Dafür bietet Angular das Konzept der Inhaltsprojektion mit anpassbaren Templates an, die wir nutzen werden. Um das Prinzip zu veranschaulichen, werde ich zwei Versionen des Spiels („Spiel1“ und „Spiel2“) mit unterschiedlichen Layouts bauen.

Im zweiten Teil des Tutorials konzentrieren wir uns auf die reaktive Programmierung. Immer wenn ein Paar zusammenpasst, muss der Benutzer eine Art Feedback von der App erhalten; diese Ereignisbehandlung wird mit Hilfe der Bibliothek RxJS realisiert.

  • Anforderungen
    Um diesem Tutorial folgen zu können, muss die Angular-CLI installiert sein.
  • Quellcode
    Den Quellcode dieses Tutorials finden Sie hier (14KB).
Mehr nach dem Sprung! Lesen Sie unten weiter ↓

1. Erstellen einer eckigen Komponente für das Lernspiel

So erstellen Sie das Grundgerüst

Lassen Sie uns zunächst ein neues Projekt namens „learning-app“ erstellen. Bei der Angular CLI geht das mit dem Befehl ng new learning-app . In der Datei app.component.html ersetze ich den vorgenerierten Quellcode wie folgt:

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

Im nächsten Schritt wird die Komponente für das Lernspiel erstellt. Ich habe es „matching-game“ genannt und den Befehl ng generate component matching-game verwendet. Dadurch wird ein separater Unterordner für die Spielkomponente mit den erforderlichen HTML-, CSS- und Typescript-Dateien erstellt.

Wie bereits erwähnt, muss das Lernspiel für unterschiedliche Zwecke konfigurierbar sein. Um dies zu demonstrieren, erstelle ich zwei zusätzliche Komponenten ( game1 und game2 ), indem ich denselben Befehl verwende. Ich füge die Spielkomponente als untergeordnete Komponente hinzu, indem ich den vorgenerierten Code in der Datei game1.component.html oder game2.component.html durch das folgende Tag ersetze:

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

Zunächst verwende ich nur die Komponente game1 . Um sicherzustellen, dass Spiel 1 direkt nach dem Start der Anwendung angezeigt wird, füge ich dieses Tag in die Datei app.component.html ein :

 <app-game1></app-game1>

Beim Starten der Anwendung mit ng serve --open zeigt der Browser die Meldung „Matching-Game Works“ an. (Dies ist derzeit der einzige Inhalt von matching-game.component.html .)

Jetzt müssen wir die Daten testen. Im Ordner /app erstelle ich eine Datei namens pair.ts , in der ich die Klasse Pair definiere:

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

Ein Paarobjekt besteht aus zwei zusammengehörigen Texten ( leftpart und rightpart ) und einer ID.

Das erste Spiel soll ein Artenquiz sein, bei dem Arten (z. B. dog ) der entsprechenden Tierklasse (z. B. mammal ) zugeordnet werden müssen.

In der Datei animals.ts definiere ich ein Array mit Testdaten:

 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'}, ];

Die Komponente game1 benötigt Zugriff auf unsere Testdaten. Sie werden in den Eigentumstieren animals . Die Datei game1.component.ts hat nun folgenden Inhalt:

 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() { } }

Die erste Version der Spielkomponente

Unser nächstes Ziel: Die Spielkomponente matching-game muss die Spieldaten der übergeordneten Komponente (zB game1 ) als Eingabe akzeptieren. Die Eingabe ist ein Array von „Paar“-Objekten. Die Benutzeroberfläche des Spiels sollte beim Start der Anwendung mit den übergebenen Objekten initialisiert werden.

Screenshot des Lernspiels „Passende Paare“

Dazu müssen wir wie folgt vorgehen:

  1. Fügen Sie die Eigenschaftspaare mithilfe des @Input pairs zur Spielkomponente hinzu.
  2. Fügen Sie die Arrays solvedPairs und unsolvedPairs als zusätzliche private Eigenschaften der Komponente hinzu. (Es muss zwischen bereits „gelösten“ und „noch nicht gelösten“ Paaren unterschieden werden.)
  3. Beim Start der Anwendung (siehe Funktion ngOnInit ) sind alle Paare noch „unsolved“ und werden daher in das Array unsolvedPairs verschoben.
 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]); } } }

Außerdem definiere ich das HTML-Template der matching-game Komponente. Es gibt Container für die ungelösten und gelösten Paare. Die ngIf Direktive sorgt dafür, dass der jeweilige Container nur angezeigt wird, wenn mindestens ein ungelöstes oder gelöstes Paar existiert.

Im Container für die ungelösten Paare (Klasse container unsolved ) werden zunächst alle left (siehe linker Frame im GIF oben) und dann alle right (siehe rechter Frame im GIF) Komponenten der Paare aufgelistet. (Ich verwende die ngFor Direktive, um die Paare aufzulisten.) Im Moment reicht ein einfacher Button als Vorlage.

Mit dem Template-Ausdruck {{{pair.leftpart}} und { {{pair.rightpart}}} werden beim Iterieren des pair -Arrays die Werte der Eigenschaften leftpart und rightpart der einzelnen Pair-Objekte abgefragt. Sie werden als Beschriftungen für die generierten Schaltflächen verwendet.

Im zweiten Container (Klassencontainer container solved ) werden die zugeordneten Paare aufgelistet. Ein grüner Balken ( connector ) zeigt an, dass sie zusammengehören.

Den entsprechenden CSS-Code der Datei matching-game.component.css finden Sie im Quellcode am Anfang des Artikels.

 <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>

In der Komponente game1 wird das Array animals nun an die pairs -Eigenschaft der Komponente matching-game gebunden (One-Way Data Binding).

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

Das Ergebnis ist im Bild unten dargestellt.

Aktueller Zustand der Benutzeroberfläche
Aktueller Zustand der Benutzeroberfläche

Offensichtlich ist unser Zuordnungsspiel noch nicht zu schwierig, da sich die linken und rechten Teile der Paare direkt gegenüberliegen. Damit das Pairing nicht zu trivial wird, sollten die richtigen Teile gemixt werden. Ich löse das Problem mit einer selbstdefinierten Pipe shuffle , die ich auf das Array unsolvedPairs auf der rechten Seite anwende (der Parameter test wird später benötigt, um eine Aktualisierung der Pipe zu erzwingen):

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

Der Quellcode der Pipe ist in der Datei shuffle.pipe.ts im App-Ordner gespeichert (siehe Quellcode am Anfang des Artikels). Beachten Sie auch die Datei app.module.ts , wo die Pipe importiert und in den Moduldeklarationen aufgeführt werden muss. Nun erscheint die gewünschte Ansicht im Browser.

Erweiterte Version: Verwendung von anpassbaren Vorlagen, um eine individuelle Gestaltung des Spiels zu ermöglichen

Statt eines Buttons soll es möglich sein, beliebige Template-Snippets anzugeben, um das Spiel anzupassen. In der Datei matching-game.component.html ersetze ich das Button-Template für die linke und rechte Seite des Spiels durch ein ng-template Tag. Der Eigenschaft ngTemplateOutlet ich dann den Namen einer Template-Referenz zu. Dadurch erhalte ich zwei Platzhalter, die beim Rendern der Ansicht durch den Inhalt der jeweiligen Template-Referenz ersetzt werden.

Wir haben es hier mit dem Konzept der Inhaltsprojektion zu tun: Bestimmte Teile des Komponenten-Templates werden von außen vorgegeben und an den markierten Stellen in das Template „projiziert“.

Beim Generieren der Ansicht muss Angular die Spieldaten in das Template einfügen. Mit dem Parameter ngTemplateOutletContext sage ich Angular, dass innerhalb des Templates eine Variable contextPair verwendet wird, der der aktuelle Wert der pair Variablen aus der ngFor Direktive zugewiesen werden soll.

Die folgende Auflistung zeigt den Ersatz für den Container unsolved . Im Container solved müssen die Schaltflächen ebenfalls durch die ng-template Tags ersetzt werden.

 <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> ...

In der Datei matching-game.component.ts müssen die Variablen beider Template-Referenzen ( leftpart_temp und rightpart_temp ) deklariert werden. Der Decorator @ContentChild zeigt an, dass es sich um eine Inhaltsprojektion handelt, dh Angular erwartet nun, dass die beiden Template-Snippets mit dem jeweiligen Selektor ( leftpart oder rightpart ) in der Elternkomponente zwischen den Tags <app-matching-game></app-matching-game> des Host-Elements (siehe @ViewChild ).

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

Nicht vergessen: Die Typen ContentChild und TemplateRef müssen aus dem Kernpaket importiert werden.

In der übergeordneten Komponente game1 werden nun die beiden benötigten Template-Snippets mit den Selektoren leftpart und rightpart eingefügt.

Der Einfachheit halber werde ich die Schaltflächen hier noch einmal verwenden:

 <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>

Das Attribut let-animalPair="contextPair" wird verwendet, um anzugeben, dass die Kontextvariable contextPair im Template-Snippet mit dem Namen animalPair .

Die Template-Snippets können nun nach eigenem Geschmack verändert werden. Um dies zu demonstrieren verwende ich die Komponente game2 . Die Datei game2.component.ts erhält den gleichen Inhalt wie game1.component.ts . In game2.component.html verwende ich statt eines Buttons ein individuell gestaltetes div -Element. Die CSS-Klassen werden in der Datei game2.component.css gespeichert.

 <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>

Nach dem Hinzufügen der Tags <app-game2></app-game2> auf der Startseite app.component.html erscheint die zweite Version des Spiels, wenn ich die Anwendung starte:

Eine alternative Ansicht des Spiels in der Komponente game2
Eine alternative Ansicht des Spiels in der Komponente game2

Die Gestaltungsmöglichkeiten sind nun nahezu unbegrenzt. Es wäre beispielsweise möglich, eine Unterklasse von Pair zu definieren, die zusätzliche Eigenschaften enthält. Beispielsweise könnten Bildadressen für den linken und/oder rechten Teil gespeichert werden. Die Bilder könnten in der Vorlage zusammen mit dem Text oder anstelle des Textes angezeigt werden.

2. Kontrolle der Benutzerinteraktion mit RxJS

Vorteile der reaktiven Programmierung mit RxJS

Um die Anwendung in ein interaktives Spiel zu verwandeln, müssen die Ereignisse (z. B. Mausklick-Ereignisse), die auf der Benutzeroberfläche ausgelöst werden, verarbeitet werden. Bei der reaktiven Programmierung werden kontinuierliche Abfolgen von Ereignissen, sogenannte „Streams“, betrachtet. Ein Stream kann beobachtet werden (es ist ein „observable“), dh es kann einen oder mehrere „Beobachter“ oder „Abonnenten“ geben, die den Stream abonnieren. Sie werden (meist asynchron) über jeden neuen Wert im Stream benachrichtigt und können darauf in bestimmter Weise reagieren.

Mit diesem Ansatz kann eine geringe Kopplung zwischen den Teilen einer Anwendung erreicht werden. Die vorhandenen Observer und Observables sind voneinander unabhängig und ihre Kopplung kann zur Laufzeit variiert werden.

Die JavaScript-Bibliothek RxJS bietet eine ausgereifte Implementierung des Observer-Entwurfsmusters. Darüber hinaus enthält RxJS zahlreiche Operatoren, um Streams umzuwandeln (zB Filter, Map) oder zu neuen Streams zusammenzufügen (zB Merge, Concat). Die Operatoren sind „reine Funktionen“ im Sinne der funktionalen Programmierung: Sie erzeugen keine Seiteneffekte und sind unabhängig vom Zustand außerhalb der Funktion. Eine Programmlogik, die nur aus Aufrufen reiner Funktionen besteht, benötigt keine globalen oder lokalen Hilfsvariablen, um Zwischenzustände zu speichern. Dies wiederum fördert die Erstellung von zustandslosen und lose gekoppelten Codeblöcken. Es ist daher wünschenswert, einen großen Teil des Event-Handlings durch eine geschickte Kombination von Stream-Operatoren zu realisieren. Beispiele hierfür finden Sie im übernächsten Abschnitt anhand unseres Zuordnungsspiels.

Integration von RxJS in die Ereignisbehandlung einer Angular-Komponente

Das Angular-Framework arbeitet mit den Klassen der RxJS-Bibliothek. RxJS wird daher automatisch installiert, wenn Angular installiert wird.

Das folgende Bild zeigt die wichtigsten Klassen und Funktionen, die bei unseren Überlegungen eine Rolle spielen:

Ein Modell der wesentlichen Klassen für die Ereignisbehandlung in Angular/RxJS
Ein Modell der wesentlichen Klassen für die Ereignisbehandlung in Angular/RxJS
Klassenname Funktion
Beobachtbar (RxJS) Basisklasse, die einen Stream darstellt; mit anderen Worten, eine kontinuierliche Folge von Daten. Ein Observable kann abonniert werden. Die pipe Funktion wird verwendet, um eine oder mehrere Operatorfunktionen auf die beobachtbare Instanz anzuwenden.
Betreff (RxJS) Die Unterklasse von Observable stellt die nächste Funktion bereit, um neue Daten im Stream zu veröffentlichen.
EventEmitter (Winkel) Dies ist eine @Output Unterklasse, die normalerweise nur in Verbindung mit dem @Output-Dekorator verwendet wird, um eine Komponentenausgabe zu definieren. Wie die nächste Funktion wird die emit -Funktion verwendet, um Daten an die Abonnenten zu senden.
Abonnement (RxJS) Die subscribe eines Observable gibt eine Abonnementinstanz zurück. Es ist erforderlich, das Abonnement nach der Verwendung der Komponente zu kündigen.

Mit Hilfe dieser Klassen wollen wir die Benutzerinteraktion in unserem Spiel umsetzen. Der erste Schritt besteht darin, sicherzustellen, dass ein Element, das vom Benutzer auf der linken oder rechten Seite ausgewählt wird, optisch hervorgehoben wird.

Die visuelle Darstellung der Elemente wird durch die beiden Vorlagenausschnitte in der übergeordneten Komponente gesteuert. Die Entscheidung, wie sie im ausgewählten Zustand angezeigt werden, sollte daher ebenfalls der übergeordneten Komponente überlassen werden. Er soll entsprechende Signale erhalten, sobald auf der linken oder rechten Seite eine Auswahl getroffen wird oder sobald eine Auswahl rückgängig gemacht werden soll.

Dazu definiere ich in der Datei matching-game.component.ts vier Ausgabewerte vom Typ EventEmitter . Die Typen Output und EventEmitter müssen aus dem Kernpaket importiert werden.

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

Im Template matching-game.component.html reagiere ich auf das mousedown -Event auf der linken und rechten Seite und sende dann die ID des ausgewählten Items an alle Empfänger.

 <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)">

In unserem Fall sind die Empfänger die Komponenten game1 und game2 . Dort können Sie nun die Ereignisbehandlung für die Ereignisse leftpartSelected , rightpartSelected , leftpartUnselected selected und rightpartUnselected . Die Variable $event repräsentiert den ausgegebenen Ausgabewert, in unserem Fall die ID. Im Folgenden sehen Sie die Auflistung für game1.component.html , für game2.component.html gelten die gleichen Änderungen.

 <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>

In game1.component.ts (und ähnlich in game2.component.ts ) sind nun die event -Handler-Funktionen implementiert. Ich speichere die IDs der ausgewählten Elemente. Im HTML-Template (siehe oben) wird diesen Elementen die Klasse selected zugeordnet. Die CSS-Datei game1.component.css definiert, welche visuellen Änderungen diese Klasse bewirkt (zB Farb- oder Schriftänderungen). Das Zurücksetzen der Auswahl (unselect) basiert auf der Annahme, dass die Paarobjekte immer positive IDs haben.

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

Im nächsten Schritt ist eine Ereignisbehandlung in der passenden Spielkomponente erforderlich. Es muss festgestellt werden, ob eine Zuordnung korrekt ist, dh ob das links ausgewählte Element mit dem rechts ausgewählten Element übereinstimmt. In diesem Fall kann das zugewiesene Paar in den Container für die aufgelösten Paare verschoben werden.

Die Auswertungslogik möchte ich mit Hilfe von RxJS-Operatoren formulieren (siehe nächster Abschnitt). Zur Vorbereitung erstelle ich in matching-game.component.ts assignmentStream ThemenzuweisungsStream. Es sollte die vom Benutzer ausgewählten Elemente auf der linken oder rechten Seite ausgeben. Das Ziel ist es, den Stream mithilfe von RxJS-Operatoren so zu modifizieren und aufzuteilen, dass ich zwei neue Streams erhalte: einen Stream solvedStream , der die korrekt zugeordneten Paare liefert, und einen zweiten Stream failedStream , der die falschen Zuordnungen liefert. Diese beiden Streams möchte ich mit subscribe , um jeweils ein entsprechendes Event-Handling durchführen zu können.

Außerdem benötige ich einen Verweis auf die erstellten Abo-Objekte, damit ich beim Verlassen des Spiels mit „unsubscribe“ die Abos kündigen kann (siehe ngOnDestroy ). Die Klassen Subject und Subscription müssen aus dem Paket „rxjs“ importiert werden.

 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(); }

Ist die Zuordnung korrekt, werden folgende Schritte durchgeführt:

  • Das zugewiesene Paar wird in den Container für die gelösten Paare verschoben.
  • Die Ereignisse leftpartUnselected und rightpartUnselected werden an die übergeordnete Komponente gesendet.

Bei falscher Zuordnung wird kein Paar verschoben. Wenn die falsche Zuweisung von links nach rechts ausgeführt wurde ( side1 hat den Wert left ), sollte die Auswahl für das Element auf der linken Seite rückgängig gemacht werden (siehe GIF am Anfang des Artikels). Erfolgt eine Zuordnung von rechts nach links, wird die Auswahl für das Element auf der rechten Seite aufgehoben. Das bedeutet, dass das zuletzt angeklickte Element in einem ausgewählten Zustand bleibt.

Für beide Fälle bereite ich die entsprechenden Handler-Funktionen handleSolvedAssignment und handleFailedAssignment (Remove-Funktion: siehe Quellcode am Ende dieses Artikels):

 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(); } }

Jetzt müssen wir die Sichtweise vom Verbraucher, der die Daten abonniert, zum Produzenten ändern, der die Daten generiert. In der Datei matching-game.component.html sorge ich dafür, dass beim Klick auf ein Element das zugehörige Pair-Objekt in den Stream assignmentStream gepusht wird. Es ist sinnvoll, einen gemeinsamen Stream für die linke und rechte Seite zu verwenden, da die Reihenfolge der Zuweisung für uns nicht wichtig ist.

 <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'})">

Design der Spielinteraktion mit RxJS-Operatoren

Es bleibt nur noch, den Stream assignmentStream in die Streams solvedStream und failedStream . Ich wende die folgenden Operatoren nacheinander an:

pairwise

Es gibt immer zwei Paare in einer Aufgabe. Der pairwise -Operator entnimmt die Daten paarweise aus dem Stream. Der aktuelle Wert und der vorherige Wert werden zu einem Paar kombiniert.

Aus dem folgenden Stream…

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

…resultiert dieser neue Stream:

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

Zum Beispiel erhalten wir die Kombination ({pair1, left}, {pair3, right}) wenn der Benutzer dog (id=1) auf der linken Seite und insect (id=3) auf der rechten Seite auswählt (siehe Array ANIMALS at Anfang des Artikels). Diese und die anderen Kombinationen ergeben sich aus dem oben im GIF gezeigten Spielablauf.

filter

Sie müssen alle Kombinationen aus dem Stream entfernen, die auf derselben Seite des Spielfelds gemacht wurden, wie ({pair1, left}, {pair1, left}) oder ({pair1, left}, {pair4, left}) .

Die Filterbedingung für einen Kombinationskamm ist daher comb comb[0].side != comb[1].side .

partition

Dieser Operator nimmt einen Stream und eine Bedingung und erstellt daraus zwei Streams. Der erste Stream enthält die Daten, die die Bedingung erfüllen, und der zweite Stream enthält die restlichen Daten. In unserem Fall sollten die Streams richtige oder falsche Zuordnungen enthalten. Die Bedingung für einen Kombinationskamm ist also comb comb[0].pair===comb[1].pair .

Das Beispiel ergibt einen „korrekten“ Stream mit

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

und einen "falschen" Stream mit

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

map

Für die weitere Verarbeitung einer korrekten Zuordnung wird nur das einzelne Paarobjekt benötigt, z. B. pair2 . Der map-Operator kann verwendet werden, um auszudrücken, dass die Kombination comb auf comb[0].pair abgebildet werden soll. Bei falscher Zuordnung wird die Kombination comb auf die Zeichenfolge comb[0].side , da die Auswahl auf die durch side angegebene side zurückgesetzt werden soll.

Die pipe Funktion wird verwendet, um die obigen Operatoren zu verketten. Die Operatoren pairwise , filter , partition , map müssen aus dem Paket rxjs/operators importiert werden.

 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)); }

Jetzt funktioniert das Spiel bereits!

Screenshot des Lernspiels „Passende Paare“
Endergebnis

Durch die Verwendung der Operatoren konnte die Spiellogik deklarativ beschrieben werden. Wir haben nur die Eigenschaften unserer beiden Zielströme beschrieben (zu Paaren zusammengefasst, gefiltert, partitioniert, neu zugeordnet) und mussten uns nicht um die Implementierung dieser Operationen kümmern. Hätten wir sie selbst implementiert, hätten wir auch Zwischenstände in der Komponente speichern müssen (z. B. Verweise auf die zuletzt angeklickten Elemente auf der linken und rechten Seite). Stattdessen kapseln die RxJS-Operatoren die Implementierungslogik und die erforderlichen Zustände für uns und heben damit die Programmierung auf eine höhere Abstraktionsebene.

Fazit

Am Beispiel eines einfachen Lernspiels haben wir den Einsatz von RxJS in einer Angular-Komponente getestet. Der reaktive Ansatz ist gut geeignet, um Ereignisse zu verarbeiten, die auf der Benutzeroberfläche auftreten. Mit RxJS können die für das Event-Handling benötigten Daten bequem als Streams angeordnet werden. Zur Transformation der Streams stehen zahlreiche Operatoren wie filter , map oder partition zur Verfügung. Die resultierenden Streams enthalten Daten, die in ihrer endgültigen Form aufbereitet sind und direkt abonniert werden können. Es erfordert ein wenig Geschick und Erfahrung, die passenden Operatoren für den jeweiligen Fall auszuwählen und effizient zu verknüpfen. Dieser Artikel soll dazu eine Einführung geben.

Weitere Ressourcen

  • „Die Einführung in die reaktive Programmierung, die Sie bisher vermisst haben“, geschrieben von Andre Staltz

Verwandte Lektüre auf SmashingMag:

  • Verwalten von Bildhaltepunkten mit Angular
  • Gestalten einer eckigen Anwendung mit Bootstrap
  • So erstellen und implementieren Sie eine Angular-Material-Anwendung