Jak stworzyć grę w dopasowywanie kart za pomocą Angular i RxJS?

Opublikowany: 2022-03-10
Krótkie podsumowanie ↬ Ten artykuł jest przeznaczony dla programistów Angulara, którzy chcą wykorzystać koncepcję programowania reaktywnego. Jest to styl programowania, który – w uproszczeniu – zajmuje się przetwarzaniem asynchronicznych strumieni danych.

Dzisiaj chciałbym skupić się na strumieniach danych wynikających ze zdarzeń kliknięć w interfejsie użytkownika. Przetwarzanie takich strumieni kliknięć jest szczególnie przydatne w aplikacjach o intensywnej interakcji użytkownika, w których trzeba przetworzyć wiele zdarzeń. Chciałbym również nieco więcej przedstawić Wam RxJS; jest to biblioteka JavaScript, której można użyć do wyrażenia procedur obsługi zdarzeń w sposób zwięzły i zwięzły w stylu reaktywnym.

Co budujemy?

Gry edukacyjne i quizy wiedzy są popularne zarówno wśród młodszych, jak i starszych użytkowników. Przykładem jest gra „dopasowywanie par”, w której użytkownik musi znaleźć powiązane pary w mieszaninie obrazów i/lub fragmentów tekstu.

Poniższa animacja przedstawia prostą wersję gry: Użytkownik wybiera jeden po drugim dwa elementy po lewej i prawej stronie pola gry, w dowolnej kolejności. Prawidłowo dopasowane pary są przenoszone do oddzielnego obszaru pola gry, a wszelkie błędne przypisania są natychmiast rozwiązywane, aby użytkownik musiał dokonać nowego wyboru.

Zrzut ekranu z gry edukacyjnej „dopasowywanie par”
Zapowiedź gry, którą dziś stworzymy

W tym samouczku krok po kroku zbudujemy taką grę edukacyjną. W pierwszej części zbudujemy komponent Angular, który po prostu pokazuje pole gry. Naszym celem jest, aby komponent można było skonfigurować dla różnych przypadków użycia i grup docelowych — od quizu na zwierzętach po trenera słownictwa w aplikacji do nauki języków. W tym celu Angular oferuje koncepcję projekcji treści z konfigurowalnymi szablonami, z których będziemy korzystać. Aby zilustrować zasadę, zbuduję dwie wersje gry („game1” i „game2”) o różnych układach.

W drugiej części samouczka skupimy się na programowaniu reaktywnym. Za każdym razem, gdy para jest dopasowana, użytkownik musi uzyskać jakąś informację zwrotną z aplikacji; to właśnie ta obsługa zdarzeń realizowana jest za pomocą biblioteki RxJS.

  • Wymagania
    Aby wykonać ten samouczek, należy zainstalować Angular CLI.
  • Kod źródłowy
    Kod źródłowy tego samouczka można znaleźć tutaj (14 KB).
Więcej po skoku! Kontynuuj czytanie poniżej ↓

1. Budowanie komponentu kątowego do gry edukacyjnej

Jak stworzyć podstawowy framework

Najpierw utwórzmy nowy projekt o nazwie „aplikacja do nauki”. Dzięki Angular CLI możesz to zrobić za pomocą polecenia ng new learning-app . W pliku app.component.html podmieniam wstępnie wygenerowany kod źródłowy w następujący sposób:

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

W kolejnym kroku tworzony jest komponent do gry edukacyjnej. Nazwałem go „gra w dopasowywanie” i użyłem polecenia ng generate component matching-game . Spowoduje to utworzenie osobnego podfolderu dla komponentu gry z wymaganymi plikami HTML, CSS i Typescript.

Jak już wspomniano, gra edukacyjna musi być konfigurowalna do różnych celów. Aby to zademonstrować, tworzę dwa dodatkowe komponenty ( game1 i game2 ) za pomocą tego samego polecenia. Dodaję składnik gry jako składnik potomny, zastępując wstępnie wygenerowany kod w pliku game1.component.html lub game2.component.html następującym tagiem:

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

Na początku używam tylko komponentu game1 . Aby mieć pewność, że gra 1 wyświetli się natychmiast po uruchomieniu aplikacji, dodaję ten tag do pliku app.component.html :

 <app-game1></app-game1>

Podczas uruchamiania aplikacji za pomocą ng serve --open przeglądarka wyświetli komunikat „matching-game works”. (Jest to obecnie jedyna zawartość match-game.component.html .)

Teraz musimy przetestować dane. W folderze /app tworzę plik o nazwie pair.ts , w którym definiuję klasę Pair :

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

Obiekt pair składa się z dwóch powiązanych ze sobą tekstów ( leftpart i rightpart ) oraz identyfikatora.

Pierwsza gra ma być quizem gatunkowym, w którym gatunek (np. dog ) należy przypisać do odpowiedniej klasy zwierząt (np. mammal ).

W pliku animals.ts definiuję tablicę z danymi testowymi:

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

Komponent game1 potrzebuje dostępu do naszych danych testowych. Przechowywane są na terenie posesji animals . Plik game1.component.ts ma teraz następującą zawartość:

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

Pierwsza wersja komponentu gry

Nasz następny cel: Gra matching-game musi zaakceptować dane gry z komponentu nadrzędnego (np. game1 ) jako dane wejściowe. Dane wejściowe to tablica obiektów „pary”. Interfejs użytkownika gry powinien zostać zainicjowany przekazanymi obiektami podczas uruchamiania aplikacji.

Zrzut ekranu z gry edukacyjnej „dopasowywanie par”

W tym celu musimy postępować w następujący sposób:

  1. Dodaj pairs właściwości do komponentu gry za pomocą dekoratora @Input .
  2. Dodaj tablice solvedPairs i unsolvedPairs jako dodatkowe prywatne właściwości komponentu. (Konieczne jest rozróżnienie między parami już „rozwiązanymi” i „jeszcze nie rozwiązanymi”.)
  3. Po uruchomieniu aplikacji (patrz funkcja ngOnInit ) wszystkie pary są nadal „nierozwiązane” i dlatego są przenoszone do tablicy 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]); } } }

Ponadto definiuję szablon HTML komponentu matching-game . Istnieją pojemniki na nierozwiązane i rozwiązane pary. Dyrektywa ngIf zapewnia, że ​​odpowiedni kontener jest wyświetlany tylko wtedy, gdy istnieje co najmniej jedna nierozwiązana lub rozwiązana para.

W kontenerze dla nierozwiązanych par (class container unsolved ), najpierw wymienione są wszystkie left (patrz lewa ramka w GIF-ie powyżej), a następnie wszystkie right (patrz prawa ramka w GIF-ie) składników par. (Do wylistowania par używam dyrektywy ngFor .) W tej chwili wystarczy prosty przycisk jako szablon.

Za pomocą wyrażenia szablonu {{{pair.leftpart}} i { {{pair.rightpart}}} wartości właściwości leftpart i rightpart poszczególnych obiektów pary są odpytywane podczas iteracji tablicy pair . Służą jako etykiety dla generowanych przycisków.

Przypisane pary są wymienione w drugim kontenerze ( container solved ). Zielony pasek ( connector klasy ) wskazuje, że należą do siebie.

Odpowiedni kod CSS pliku matching-game.component.css można znaleźć w kodzie źródłowym na początku artykułu.

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

W komponencie game1 tablica animals jest teraz powiązana z właściwością pairs komponentu matching-game (jednokierunkowe wiązanie danych).

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

Wynik pokazano na poniższym obrazku.

Aktualny stan interfejsu użytkownika
Aktualny stan interfejsu użytkownika

Oczywiście nasza gra w dopasowywanie nie jest jeszcze zbyt trudna, ponieważ lewa i prawa część pary znajdują się naprzeciwko siebie. Aby parowanie nie było zbyt trywialne, należy pomieszać odpowiednie części. Rozwiązuję problem za pomocą samodzielnie zdefiniowanego shuffle rur, które stosuję do tablicy unsolvedPairs po prawej stronie ( test parametrów jest potrzebny później, aby wymusić aktualizację rury):

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

Kod źródłowy potoku jest przechowywany w pliku shuffle.pipe.ts w folderze aplikacji (zobacz kod źródłowy na początku artykułu). Zwróć także uwagę na plik app.module.ts , w którym potok musi zostać zaimportowany i wymieniony w deklaracjach modułu. Teraz żądany widok pojawi się w przeglądarce.

Wersja rozszerzona: korzystanie z konfigurowalnych szablonów w celu umożliwienia indywidualnego projektowania gry

Zamiast przycisku powinno być możliwe określenie dowolnych fragmentów szablonu, aby dostosować grę. W pliku matching-game.component.html zastępuję szablon przycisku dla lewej i prawej strony gry tagiem ng-template . Następnie przypisuję nazwę odwołania do szablonu do właściwości ngTemplateOutlet . Daje mi to dwa symbole zastępcze, które podczas renderowania widoku są zastępowane zawartością odpowiedniego odwołania do szablonu.

Mamy tu do czynienia z koncepcją projekcji treści : pewne części szablonu komponentu są podawane z zewnątrz i „rzutowane” do szablonu w zaznaczonych miejscach.

Podczas generowania widoku Angular musi wstawić dane gry do szablonu. Za pomocą parametru ngTemplateOutletContext mówię Angularowi, że w szablonie jest używana zmienna contextPair , której należy przypisać bieżącą wartość zmiennej pair z dyrektywy ngFor .

Poniższa lista przedstawia zamiennik dla kontenera unsolved . W solved kontenerze przyciski należy również zastąpić tagami 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> ...

W pliku matching-game.component.ts należy zadeklarować zmienne obu odniesień do szablonu ( leftpart_temp i rightpart_temp ). Dekorator @ContentChild wskazuje, że jest to projekcja treści, tj. Angular oczekuje teraz, że dwa fragmenty szablonu z odpowiednim selektorem ( leftpart lub rightpart ) są dostarczane w komponencie nadrzędnym między tagami <app-matching-game></app-matching-game> match <app-matching-game></app-matching-game> elementu hosta (patrz @ViewChild ).

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

Nie zapomnij: typy ContentChild i TemplateRef muszą być zaimportowane z pakietu podstawowego.

W komponencie nadrzędnym game1 dwa wymagane fragmenty szablonu z selektorami leftpart i rightpart są teraz wstawiane.

Dla uproszczenia ponownie użyję tutaj przycisków:

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

Atrybut let-animalPair="contextPair" służy do określenia, że ​​zmienna kontekstowa contextPair jest używana we fragmencie szablonu o nazwie animalPair .

Fragmenty szablonu można teraz zmieniać według własnego gustu. Aby to zademonstrować, używam komponentu game2 . Plik game2.component.ts otrzymuje taką samą zawartość jak game1.component.ts . W game2.component.html zamiast przycisku używam indywidualnie zaprojektowanego elementu div . Klasy CSS są przechowywane w pliku 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>

Po dodaniu tagów <app-game2></app-game2> na stronie głównej app.component.html , po uruchomieniu aplikacji pojawia się druga wersja gry:

Alternatywny widok gry w grze składowej2
Alternatywny widok gry w grze game2

Możliwości projektowe są teraz prawie nieograniczone. Można by na przykład zdefiniować podklasę Pair , która zawiera dodatkowe właściwości. Na przykład adresy obrazów mogą być przechowywane dla lewej i/lub prawej części. Obrazy mogą być wyświetlane w szablonie wraz z tekstem lub zamiast tekstu.

2. Kontrola interakcji użytkownika z RxJS

Zalety programowania reaktywnego za pomocą RxJS

Aby zmienić aplikację w interaktywną grę, zdarzenia (np. zdarzenia kliknięcia myszą), które są wyzwalane w interfejsie użytkownika, muszą zostać przetworzone. W programowaniu reaktywnym brane są pod uwagę ciągłe sekwencje zdarzeń, tzw. „strumienie”. Strumień może być obserwowany (jest to „obserwowalny”), tzn. może być jeden lub więcej „obserwatorów” lub „abonentów” subskrybujących strumień. Są powiadamiani (zwykle asynchronicznie) o każdej nowej wartości w strumieniu i mogą na nią reagować w określony sposób.

Dzięki takiemu podejściu można osiągnąć niski poziom sprzężenia między częściami aplikacji. Istniejące obserwatory i obserwowalne są od siebie niezależne, a ich sprzężenie można zmieniać w czasie wykonywania.

Biblioteka JavaScript RxJS zapewnia dojrzałą implementację wzorca projektowego Observer. Ponadto RxJS zawiera liczne operatory do konwersji strumieni (np. filtr, mapa) lub łączenia ich w nowe strumienie (np. merge, concat). Operatory są „czystymi funkcjami” w sensie programowania funkcjonalnego: nie wywołują skutków ubocznych i są niezależne od stanu poza funkcją. Logika programu składająca się tylko z wywołań czystych funkcji nie potrzebuje globalnych ani lokalnych zmiennych pomocniczych do przechowywania stanów pośrednich. To z kolei sprzyja tworzeniu bezstanowych i luźno powiązanych bloków kodu. Dlatego pożądane jest zrealizowanie dużej części obsługi zdarzeń przez sprytną kombinację operatorów strumieni. Przykłady tego są podane w następnej sekcji, w oparciu o naszą grę w dopasowywanie.

Integracja RxJS z obsługą zdarzeń komponentu kątowego

Framework Angular współpracuje z klasami biblioteki RxJS. Dlatego RxJS jest automatycznie instalowany podczas instalacji Angulara.

Poniższy obrazek przedstawia główne klasy i funkcje, które odgrywają rolę w naszych rozważaniach:

Model podstawowych klas do obsługi zdarzeń w Angular/RxJS
Model podstawowych klas do obsługi zdarzeń w Angular/RxJS
Nazwa klasy Funkcjonować
Obserwowalny (RxJS) Klasa bazowa reprezentująca strumień; innymi słowy, ciągła sekwencja danych. Można zasubskrybować obserwowalny. Funkcja pipe służy do zastosowania jednej lub większej liczby funkcji operatora do obserwowalnego wystąpienia.
Temat (RxJS) Podklasa obserwowalnych zapewnia następną funkcję do publikowania nowych danych w strumieniu.
Emiter zdarzeń (kątowy) Jest to podklasa specyficzna dla kąta, która jest zwykle używana tylko w połączeniu z dekoratorem @Output w celu zdefiniowania wyjścia komponentowego. Podobnie jak kolejna funkcja, funkcja emit służy do wysyłania danych do abonentów.
Subskrypcja (RxJS) Funkcja subscribe obiektu obserwowalnego zwraca instancję subskrypcji. Po użyciu składnika wymagane jest anulowanie subskrypcji.

Za pomocą tych klas chcemy zaimplementować interakcję użytkownika w naszej grze. Pierwszym krokiem jest upewnienie się, że element wybrany przez użytkownika z lewej lub prawej strony jest wizualnie podświetlony.

Wizualna reprezentacja elementów jest kontrolowana przez dwa fragmenty kodu szablonu w komponencie nadrzędnym. Decyzję o sposobie ich wyświetlania w wybranym stanie należy zatem również pozostawić komponentowi nadrzędnemu. Powinien otrzymywać odpowiednie sygnały, gdy tylko dokonany zostanie wybór z lewej lub prawej strony lub gdy tylko wybór ma zostać cofnięty.

W tym celu w pliku matching-game.component.ts definiuję cztery wartości wyjściowe typu EventEmitter . Typy Output i EventEmitter muszą zostać zaimportowane z pakietu podstawowego.

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

W szablonie matching-game.component.html reaguję na zdarzenie mousedown po lewej i prawej stronie, a następnie wysyłam ID wybranego elementu do wszystkich odbiorców.

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

W naszym przypadku odbiornikami są komponenty game1 i game2 . Tam można teraz zdefiniować obsługę zdarzeń leftpartSelected , rightpartSelected , leftpartUnselected i rightpartUnselected . Zmienna $event reprezentuje emitowaną wartość wyjściową, w naszym przypadku ID. Poniżej możesz zobaczyć listę dla game1.component.html , dla game2.component.html obowiązują te same zmiany.

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

W game1.component.ts (i podobnie w game2.component.ts ) zaimplementowano teraz funkcje obsługi event . Przechowuję identyfikatory wybranych elementów. W szablonie HTML (patrz wyżej) elementom tym przypisywana jest selected klasa . Plik CSS game1.component.css określa, jakie zmiany wizualne spowoduje ta klasa (np. zmiany koloru lub czcionki). Resetowanie zaznaczenia (odznaczenie) opiera się na założeniu, że pary obiektów zawsze mają dodatnie identyfikatory.

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

W następnym kroku wymagana jest obsługa zdarzeń w pasującym komponencie gry. Należy określić, czy przypisanie jest poprawne, to znaczy czy lewy zaznaczony element pasuje do prawego zaznaczonego elementu. W takim przypadku przypisaną parę można przenieść do kontenera dla rozwiązanych par.

Chciałbym sformułować logikę oceny za pomocą operatorów RxJS (patrz następny rozdział). Aby się przygotować, tworzę temat assignmentStream w matching-game.component.ts . Powinien emitować wybrane przez użytkownika elementy po lewej lub prawej stronie. Celem jest użycie operatorów RxJS do modyfikacji i podziału strumienia w taki sposób, aby uzyskać dwa nowe strumienie: jeden strumień solvedStream , który zapewnia poprawnie przypisane pary, oraz drugi strumień failedStream , który zapewnia nieprawidłowe przypisania. Chciałbym zasubskrybować te dwa strumienie z subscribe , aby móc w każdym przypadku przeprowadzić odpowiednią obsługę zdarzeń.

Potrzebuję również odniesienia do utworzonych obiektów subskrypcji, abym mógł anulować subskrypcje za pomocą „unsubscribe” przy opuszczaniu gry (patrz ngOnDestroy ). Klasy Subject i Subscription muszą być zaimportowane z pakietu „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(); }

Jeśli przypisanie jest poprawne, wykonywane są następujące kroki:

  • Przypisana para jest przenoszona do kontenera na rozwiązane pary.
  • Zdarzenia leftpartUnselected i rightpartUnselected są wysyłane do komponentu nadrzędnego.

Żadna para nie jest przenoszona, jeśli przypisanie jest nieprawidłowe. Jeśli błędne przypisanie zostało wykonane od lewej do prawej ( side1 ma wartość left ), zaznaczenie powinno zostać cofnięte dla elementu po lewej stronie (zobacz GIF na początku artykułu). Jeśli przypisanie jest wykonywane od prawej do lewej, wybór jest cofany dla elementu po prawej stronie. Oznacza to, że ostatni kliknięty element pozostaje w wybranym stanie.

W obu przypadkach przygotowuję odpowiednie funkcje obsługi handleSolvedAssignment i handleFailedAssignment (funkcja usuwania: patrz kod źródłowy na końcu tego artykułu):

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

Teraz musimy zmienić punkt widzenia z konsumenta, który subskrybuje dane, na producenta, który je generuje. W pliku matching-game.component.html upewniam się, że po kliknięciu elementu powiązany obiekt pair jest wpychany do strumienia assignmentStream . Sensowne jest użycie wspólnego strumienia dla lewej i prawej strony, ponieważ kolejność przypisania nie jest dla nas ważna.

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

Projekt interakcji gry z operatorami RxJS

Pozostaje tylko przekonwertować strumień assignmentStream na strumienie solvedStream i failedStream . Stosuję kolejno następujące operatory:

pairwise

W zadaniu zawsze są dwie pary. Operator pairwise pobiera dane w parach ze strumienia. Bieżąca wartość i poprzednia wartość są łączone w parę.

Z następnego strumienia…

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

…wyniki tego nowego strumienia:

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

Na przykład otrzymamy kombinację ({pair1, left}, {pair3, right}) , gdy użytkownik wybierze dog (id=1) po lewej stronie i insect (id=3) po prawej stronie (patrz tablica ANIMALS w początek artykułu). Te i inne kombinacje wynikają z sekwencji gry pokazanej na powyższym GIF-ie.

filter

Musisz usunąć wszystkie kombinacje ze strumienia, które zostały wykonane po tej samej stronie pola gry, np. ({pair1, left}, {pair1, left}) lub ({pair1, left}, {pair4, left}) .

Warunkiem filtrowania comb kombinowanego jest zatem comb[0].side != comb[1].side .

partition

Ten operator pobiera strumień i warunek i tworzy z nich dwa strumienie. Pierwszy strumień zawiera dane spełniające warunek, a drugi strumień zawiera pozostałe dane. W naszym przypadku strumienie powinny zawierać poprawne lub niepoprawne przypisania. Tak więc warunkiem comb złożonego jest comb[0].pair===comb[1].pair .

Przykład daje „poprawny” strumień z

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

i „niewłaściwy” strumień z

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

map

Do dalszego przetwarzania prawidłowego przypisania wymagany jest tylko pojedynczy obiekt pair, taki jak pair2 . Operatora mapowania można użyć do wyrażenia, że comb kombinacji powinien być odwzorowany na comb[0].pair . Jeśli przypisanie jest nieprawidłowe, comb kombinowany jest mapowany na łańcuch comb[0].side , ponieważ zaznaczenie powinno zostać zresetowane po stronie określonej przez side .

Funkcja pipe służy do łączenia powyższych operatorów. Operatory pairwise , filter , partition , map muszą być zaimportowane z pakietu rxjs/operators .

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

Teraz gra już działa!

Zrzut ekranu z gry edukacyjnej „dopasowywanie par”
Ostateczny wynik

Za pomocą operatorów można opisać logikę gry w sposób deklaratywny. Opisaliśmy tylko właściwości naszych dwóch strumieni docelowych (połączonych w pary, przefiltrowanych, podzielonych na partycje, przemapowanych) i nie musieliśmy się martwić implementacją tych operacji. Gdybyśmy sami je zaimplementowali, musielibyśmy również przechowywać w komponencie stany pośrednie (np. odniesienia do ostatnio klikniętych elementów po lewej i prawej stronie). Zamiast tego operatory RxJS hermetyzują dla nas logikę implementacji i wymagane stany, a tym samym podnoszą programowanie na wyższy poziom abstrakcji.

Wniosek

Na przykładzie prostej gry edukacyjnej przetestowaliśmy użycie RxJS w komponencie Angular. Podejście reaktywne dobrze nadaje się do przetwarzania zdarzeń występujących w interfejsie użytkownika. Dzięki RxJS dane potrzebne do obsługi zdarzeń można wygodnie uporządkować jako strumienie. Do przekształcania strumieni dostępne są liczne operatory, takie jak filter , map lub partition . Strumienie wynikowe zawierają dane, które są przygotowane w ostatecznej formie i mogą być subskrybowane bezpośrednio. Dobór odpowiednich operatorów do danego przypadku i ich efektywne połączenie wymaga niewielkich umiejętności i doświadczenia. Ten artykuł powinien stanowić wprowadzenie do tego.

Dalsze zasoby

  • „Wprowadzenie do programowania reaktywnego, którego ci brakowało”, napisane przez Andre Staltz

Powiązane czytanie na SmashingMag:

  • Zarządzanie punktami przerwania obrazu za pomocą Angular
  • Stylizacja aplikacji kątowej za pomocą Bootstrapa
  • Jak stworzyć i wdrożyć aplikację kątową?