Lekcja 21. Projekt 2: Kolorowy zgadywacz — gra w Angularze z zapisem wyników
ŚredniPo co się tego uczymy?
Drugi projekt jest lżejszy niż kalkulator BMI — skupia się głównie na logice front-endowej (Angular) i dynamicznym stylowaniu elementów, z prostym zapisem najlepszego wyniku na backendzie. To dobre ćwiczenie przed dużym, trzecim projektem (system zamówień).
Teoria
Zasady gry: aplikacja pokazuje losowo wybrany kolor SŁOWNIE (np. "Kliknij: NIEBIESKI"), a użytkownik musi kliknąć odpowiedni przycisk spośród kilku kolorowych kwadratów. Poprawna odpowiedź zwiększa wynik, błędna — nie. Po każdej odpowiedzi losowane jest kolejne pytanie. Po zakończeniu rundy (np. 10 pytań) wynik jest wysyłany do backendu i zapisywany jako próba do rankingu najlepszych wyników.
Kluczowe mechanizmy Angulara ćwiczone w tym projekcie:
- Dynamiczne stylowanie przez
[style.background-color]="kolor.kodHex"— wiązanie właściwości bezpośrednio do stylu CSS elementu, sterowane danymi z TypeScript (rozszerzenie[property binding]z lekcji 9); - Generowanie losowych danych —
Math.random()(wbudowana funkcja JavaScript/TypeScript) do wyboru losowego koloru i przetasowania kolejności przycisków; @forztrack(lekcja 9) do wyrenderowania dynamicznej liczby przycisków kolorów z tablicy;- Stan gry w polach klasy (aktualne pytanie, wynik, licznik pytań, flaga "gra zakończona") — ten sam wzorzec "jedno źródło prawdy w komponencie", co w Blazor czy w PageModel z Razor Pages.
Zapis wyniku na backendzie. Po zakończeniu rundy Angular wysyła POST z liczbą punktów do prostego kontrolera WynikiController, a osobna strona/zakładka "Ranking" (GET /api/wyniki?limit=10) pokazuje 10 najlepszych wyników posortowanych malejąco — dokładnie ten sam wzorzec CRUD/API, którego uczyłeś się od lekcji 11.
Schemat
Start rundy: wynik = 0, pytanie = 1, losujNastepnePytanie()
losujNastepnePytanie():
szukanyKolor = kolory[Math.floor(Math.random() * kolory.length)]
przetasowaneKolory = [...kolory].sort(() => Math.random() - 0.5)
Widok:
<p>Kliknij: {{ szukanyKolor.nazwa }}</p>
@for (k of przetasowaneKolory; track k.nazwa) {
<button [style.background-color]="k.kodHex"
(click)="sprawdzOdpowiedz(k)"></button>
}
sprawdzOdpowiedz(wybrany):
jeśli wybrany === szukanyKolor → wynik++
pytanie++
jeśli pytanie > 10 → zakonczRunde()
w przeciwnym razie → losujNastepnePytanie()
zakonczRunde():
this.http.post("/api/wyniki", { punkty: wynik }).subscribe(...)
pokazEkranKoncowy = true
Przykład z życia
Ten typ prostej gry quizowej z natychmiastową informacją zwrotną i rankingiem wyników to wzorzec spotykany w niezliczonych aplikacjach edukacyjnych i rozrywkowych — łącznie z systemem testów na samej stronie bitedu.pl, gdzie też losuje się pytania i zlicza poprawne odpowiedzi.
zgadywacz.component.html
<!-- zgadywacz.component.html -->
<div class="container text-center mt-5">
@if (!pokazEkranKoncowy) {
<h2>Kliknij kolor: <strong>{{ szukanyKolor.nazwa }}</strong></h2>
<p>Pytanie {{ numerPytania }} / 10 · Wynik: {{ wynik }}</p>
<div class="d-flex justify-content-center gap-3 my-4">
@for (kolor of przetasowaneKolory; track kolor.nazwa) {
<button class="rounded"
style="width:70px; height:70px; border:2px solid #333;"
[style.background-color]="kolor.kodHex"
(click)="sprawdzOdpowiedz(kolor)">
</button>
}
</div>
} @else {
<h2>Koniec gry!</h2>
<p>Twój wynik: {{ wynik }} / 10</p>
<button class="btn btn-primary" (click)="zagrajZnowu()">Zagraj ponownie</button>
}
</div>
zgadywacz.component.ts
// zgadywacz.component.ts
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface Kolor {
nazwa: string;
kodHex: string;
}
@Component({
selector: 'app-zgadywacz',
standalone: true,
templateUrl: './zgadywacz.component.html'
})
export class ZgadywaczComponent implements OnInit {
kolory: Kolor[] = [
{ nazwa: 'CZERWONY', kodHex: '#e74c3c' },
{ nazwa: 'NIEBIESKI', kodHex: '#3498db' },
{ nazwa: 'ZIELONY', kodHex: '#2ecc71' },
{ nazwa: 'ŻÓŁTY', kodHex: '#f1c40f' }
];
szukanyKolor!: Kolor;
przetasowaneKolory: Kolor[] = [];
wynik = 0;
numerPytania = 1;
pokazEkranKoncowy = false;
constructor(private http: HttpClient) {}
ngOnInit(): void {
this.losujNastepnePytanie();
}
losujNastepnePytanie(): void {
this.szukanyKolor = this.kolory[Math.floor(Math.random() * this.kolory.length)];
this.przetasowaneKolory = [...this.kolory].sort(() => Math.random() - 0.5);
}
sprawdzOdpowiedz(wybrany: Kolor): void {
if (wybrany.nazwa === this.szukanyKolor.nazwa) {
this.wynik++;
}
if (this.numerPytania >= 10) {
this.zakonczRunde();
return;
}
this.numerPytania++;
this.losujNastepnePytanie();
}
zakonczRunde(): void {
this.pokazEkranKoncowy = true;
this.http.post('https://localhost:7042/api/wyniki', { punkty: this.wynik })
.subscribe({ error: (e) => console.error('Nie udało się zapisać wyniku', e) });
}
zagrajZnowu(): void {
this.wynik = 0;
this.numerPytania = 1;
this.pokazEkranKoncowy = false;
this.losujNastepnePytanie();
}
}
Komentarz i wyjaśnienie kodu
[...this.kolory].sort(() => Math.random() - 0.5) to popularny (choć nie idealnie matematycznie równomierny) sposób "przetasowania" tablicy w JavaScript/TypeScript — trzy kropki (..., operator rozwinięcia/spread) tworzą KOPIĘ tablicy kolory, żeby przetasowanie NIE zmieniało oryginalnej listy (bo za każdym razem chcemy tasować od nowa, z tego samego zestawu kolorów).
Math.floor(Math.random() * this.kolory.length) to standardowy wzorzec losowania INDEKSU tablicy: Math.random() zwraca liczbę z przedziału [0, 1), mnożenie przez długość tablicy i zaokrąglenie w dół (Math.floor) daje poprawny, losowy indeks od 0 do (długość-1).
W zakonczRunde(), subskrypcja .subscribe({ error: ... }) obsługuje TYLKO błąd, bez next — to celowe: nie potrzebujemy żadnej reakcji na UDANY zapis (ekran końcowy już się pokazał), ale WARTO wiedzieć, gdyby zapis się nie powiódł (np. backend niedostępny).
Ćwiczenie samodzielne
Zbuduj komponent gry jak w przykładzie (na razie bez zapisu do backendu — sam frontend). Zagraj kilka rund, sprawdzając poprawność liczenia wyniku i losowania kolejnych pytań.
Zadania do pracy własnej
Dodaj krótką animację/efekt wizualny (np. zmianę koloru tła CAŁEJ strony na sekundę) po udzieleniu POPRAWNEJ odpowiedzi.
Dodaj kontroler
WynikiController(backend) z akcją[HttpPost]zapisującą wynik przez EF Core, i akcją[HttpGet]zwracającą 10 najlepszych wyników posortowanych malejąco — zbuduj osobny komponent "Ranking" wyświetlający tę listę.Rozbuduj grę o rosnący poziom trudności: co 3 poprawne odpowiedzi z rzędu dodawaj JEDEN nowy kolor do puli (start: 4 kolory, potem 5, 6...) — zaimplementuj też limit czasu na odpowiedź (np. 3 sekundy na pytanie, z widocznym paskiem odliczającym), po przekroczeniu którego pytanie liczy się jako błędne.
Typowe błędy
Tasowanie/modyfikowanie ORYGINALNEJ tablicy kolory zamiast jej kopii — bez operatora rozwinięcia (...) każde kolejne losowanie zniekształcałoby oryginalny zestaw danych.
Zapominanie o wywołaniu losujNastepnePytanie() w ngOnInit() — bez tego gra wystartuje z niezdefiniowanym pytaniem (błąd w konsoli przy próbie wyświetlenia szukanyKolor.nazwa, bo szukanyKolor nie miałoby jeszcze przypisanej wartości).
Porównywanie obiektów koloru przez referencję (=== na całych obiektach) zamiast po nazwie — po przetasowaniu tablicy klikany obiekt to inna instancja niż szukanyKolor, nawet jeśli reprezentuje "ten sam" kolor; porównanie musi odbywać się po WŁAŚCIWOŚCI (np. nazwa), nie po tożsamości obiektu.
Nawiązanie do egzaminu zawodowego
To drugi z ośmiu projektów realizujących INF.04.7 — skupiony głównie na logice i interaktywności front-endowej Angulara (dynamiczne stylowanie, generowanie losowych danych, zarządzanie stanem gry), z lekkim, ale kompletnym zapisem wyniku na backendzie. Kolejny, trzeci projekt (lekcja 22) to sklep internetowy — pierwszy z projektów wprost odwzorowujący nazwaną w podstawie programowej funkcjonalność "e-sklepu".