So erstellen Sie ein Karten-Matching-Spiel mit Angular und RxJS
Veröffentlicht: 2022-03-10Heute 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.
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).
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.
Dazu müssen wir wie folgt vorgehen:
- Fügen Sie die Eigenschaftspaare mithilfe des
@Input
pairs
zur Spielkomponente hinzu. - Fügen Sie die Arrays
solvedPairs
undunsolvedPairs
als zusätzliche private Eigenschaften der Komponente hinzu. (Es muss zwischen bereits „gelösten“ und „noch nicht gelösten“ Paaren unterschieden werden.) - Beim Start der Anwendung (siehe Funktion
ngOnInit
) sind alle Paare noch „unsolved“ und werden daher in das ArrayunsolvedPairs
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.
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:
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:
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
undrightpartUnselected
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!
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