Gra Tetris w WPF –rozbudowa istniejącego projektu
01 Punkt startowy – co już mamy
Zanim zaczniemy, przypomnijmy co robi każdy z istniejących plików:
| Plik | Co robi? | Co zmieniamy? |
|---|---|---|
| Klocek.cs | Tworzy Rectangle, umieszcza na Canvas przez Canvas.SetLeft/SetTop |
Mała zmiana Dopisujemy metodę PrzesuńDo() |
| FabrykaKlockow.cs | Przechowuje listę kształtów jako tablice krotek, losuje i tworzy klocki na Canvas | Mała zmiana Metoda zwraca listę stworzonych klocków |
| MainWindow.xaml.cs | Rysuje siatkę, po kliknięciu wywołuje fabrykę | Rozbudowa Dodajemy timer, kolekcje, logikę gry |
Oryginalny FabrykaKlockow – jak działają ofsety
Każdy kształt to tablica krotek (int kol, int rzad) – to są ofsety
(przesunięcia) względem punktu startowego. Kiedy klikamy w kolumnie 3, rząd 5, to kształt „I”
pojawi się na: (3+0,5+0), (3+1,5+0), (3+2,5+0), (3+3,5+0).
private static List<(int kol, int rzad)[]> _ksztalty = new() { // I – cztery klocki w rzędzie poziomym new[]{(0,0),(1,0),(2,0),(3,0)}, // O – kwadrat 2×2 new[]{(0,0),(1,0),(0,1),(1,1)} }; // klikamy w pozycji (startKol=3, startRzad=0) // kształt "I" pojawi się na: (3,0) (4,0) (5,0) (6,0)
(int kol, int rzad) oznacza parę liczb z nazwanymi polami.
Dostęp: ofset.kol, ofset.rzad.
Można też zdekonstruować: var (k, r) = ofset;
02 Co dopisujemy i dlaczego
Teraz klocki pojawiają się po kliknięciu i stoją w miejscu. Brakuje nam:
| Co brakuje | Co potrzebujemy do tego |
|---|---|
| Klocki same się poruszają | DispatcherTimer – co 500 ms przesuwa kształt o 1 rząd w dół |
| Wiedzieć które pola są zajęte | HashSet<(int,int)> – zbiór zajętych współrzędnych, szybki Contains() |
| Usuwać klocki z Canvas przy kasowaniu linii | Dictionary<(int,int), Klocek> – mapowanie: pozycja → klocek |
| Poruszać klockami metodą (nie nowym obiektem) | Metoda PrzesuńDo() w klasie Klocek |
| Sterowanie klawiaturą | Zdarzenie KeyDown na oknie głównym |
03 Klocek – dopisujemy metodę PrzesuńDo()
Teraz klasa Klocek po stworzeniu stoi w miejscu. Aby klocek mógł się
ruszyć, nie tworzymy nowego obiektu – aktualizujemy pozycję istniejącego Rectangle.
Dlatego dopisujemy metodę PrzesuńDo().
Canvas.SetLeft/SetTop.
class Klocek { public const int ROZMIAR = 30; public Rectangle Element { get; } // ↓ NOWE: zapamiętujemy aktualną pozycję w siatce public int Kolumna { get; private set; } public int Rzad { get; private set; } public Klocek(int kolumna, int rzad, Brush kolor) { Kolumna = kolumna; Rzad = rzad; Element = new Rectangle() { Width = ROZMIAR, Height = ROZMIAR, Fill = kolor, RadiusX = 6, RadiusY = 6, }; Canvas.SetLeft(Element, kolumna * ROZMIAR); Canvas.SetTop (Element, rzad * ROZMIAR); } // ↓ NOWA METODA: przesuwa Rectangle na nową pozycję siatki public void PrzesuńDo(int kolumna, int rzad) { Kolumna = kolumna; Rzad = rzad; Canvas.SetLeft(Element, kolumna * ROZMIAR); Canvas.SetTop (Element, rzad * ROZMIAR); } public void DodajDoCanvas(Canvas canvas) { canvas.Children.Add(Element); } }
Klocek.cs.
Reszta klasy pozostaje bez zmian. Celem jest jak najmniejsza ingerencja w istniejący kod.
04 HashSet – śledzenie zajętych pól
Żeby wiedzieć czy można wstawić klocek na dane pole, musimy pamiętać
które komórki siatki są zajęte. Używamy do tego HashSet<(int kol, int rzad)>.
Dlaczego HashSet, a nie List lub tablica?
| Kolekcja | Sprawdzanie czy coś jest w środku | Ocena |
|---|---|---|
| List<T> | list.Contains(x) – przegląda każdy element po kolei, O(n) |
❌ Wolno przy 200 klockach |
| bool[,] tablica 2D | Bezpośredni dostęp przez indeks: tablica[r,k], O(1) |
✅ Szybko, ale dwie tablice (bool + Klocek) |
| HashSet<T> | set.Contains(x) – tablica mieszająca, O(1) |
✅ Szybko + przechowuje krotki wprost |
HashSet to zbiór unikalnych elementów. Nie może przechowywać duplikatów.
Operacja Contains() jest bardzo szybka bo używa funkcji mieszającej (hash).
Dwa słowniki razem
Do śledzenia planszy potrzebujemy dwóch rzeczy: wiedzieć czy pole jest zajęte
i mieć dostęp do klocka na tym polu (żeby go usunąć z Canvas przy kasowaniu linii).
Możemy użyć HashSet do szybkiego sprawdzania i Dictionary
do trzymania referencji:
private const int KOLUMNY = 10; private const int RZEDY = 20; // ── plansza ── // szybkie sprawdzanie: czy pole (kol, rzad) jest zajęte? private HashSet<(int kol, int rzad)> _zajete = new(); // dostęp do klocka na danym polu (potrzebny do usuwania przy kasowaniu linii) private Dictionary<(int kol, int rzad), Klocek> _klockiNaPlanszy = new(); // ── bieżący kształt ── private List<Klocek> _aktualneKlocki = new(); private (int kol, int rzad)[] _aktualnaMapaOfsetow; private int _aktualnyStartKol; private int _aktualnyStartRzad; // ── punkty ── private int _wynik = 0;
HashSet<T> to zbiór wartości – możemy sprawdzić czy coś jest w środku.
Dictionary<TKey, TValue> to mapa klucz→wartość – na podstawie klucza
dostajemy skojarzoną wartość.
Tutaj klucz to pozycja (kol, rzad), a wartość to obiekt Klocek.
Przykład użycia
// HashSet – dodaj zajęte pole _zajete.Add((3, 5)); // HashSet – sprawdź czy pole jest zajęte bool czy = _zajete.Contains((3, 5)); // true // HashSet – usuń pole (przy kasowaniu linii) _zajete.Remove((3, 5)); // Dictionary – dodaj klocek pod danym kluczem _klockiNaPlanszy[(3, 5)] = klocek; // Dictionary – pobierz klocek z pozycji Klocek k = _klockiNaPlanszy[(3, 5)]; // Dictionary – sprawdź czy klucz istnieje if (_klockiNaPlanszy.ContainsKey((3, 5))) { ... }
05 Śledzenie bieżącego kształtu
Bieżący kształt to ten który właśnie spada. Musimy wiedzieć: gdzie jest, jakie ma ofsety,
i które klocki (Rectangle) go tworzą. Dlatego w FabrykaKlockow zmieniamy metodę
tak aby zwracała stworzone klocki zamiast tylko je tworzyć:
// PRZED: metoda nic nie zwracała // public static void UtworzLoswyKsztalt(Canvas canvas, int startKol, int startRzad) // PO: metoda zwraca listę stworzonych klocków public static List<Klocek> UtworzLoswyKsztalt(Canvas canvas, int startKol, int startRzad, out (int kol, int rzad)[] mapa) // ← out: wyprowadzamy mapę ofsetów { int index = _rand.Next(_ksztalty.Count); mapa = _ksztalty[index]; // ← przekazujemy mapę przez out var kolor = _kolory[index % _kolory.Length]; var lista = new List<Klocek>(); foreach (var (kol, rzad) in mapa) { var klocek = new Klocek(startKol + kol, startRzad + rzad, kolor); klocek.DodajDoCanvas(canvas); lista.Add(klocek); } return lista; }
out pozwala metodzie „wyprowadzić” dodatkową wartość poza wartość zwracaną.
W wywołaniu: var klocki = FabrykaKlockow.UtworzLoswyKsztalt(canvas, 4, 0, out var mapa);
Po wywołaniu mapa zawiera tablicę ofsetów użytego kształtu.
Tworzenie nowego kształtu w MainWindow
private void SpawnNowyKsztalt() { int startKol = KOLUMNY / 2 - 1; // środek górnej krawędzi int startRzad = 0; _aktualneKlocki = FabrykaKlockow.UtworzLoswyKsztalt( myCanvas, startKol, startRzad, out _aktualnaMapaOfsetow); _aktualnyStartKol = startKol; _aktualnyStartRzad = startRzad; // jeśli na starcie brak miejsca → koniec gry if (!CzyMożnaPostawić(startKol, startRzad, _aktualnaMapaOfsetow)) { _timer.Stop(); MessageBox.Show($"Koniec gry! Wynik: {_wynik}"); } }
06 DispatcherTimer – automatyczny ruch
Pętla gry to DispatcherTimer który co 500 ms wywołuje metodę MoveDown().
Uruchamiamy go w konstruktorze, zamiast obsługi kliknięcia:
private DispatcherTimer _timer = new(); public MainWindow() { InitializeComponent(); RysujSiatke(); // ← zostaje bez zmian _timer.Interval = TimeSpan.FromMilliseconds(500); _timer.Tick += (s, e) => MoveDown(); // co 500 ms → ruch w dół SpawnNowyKsztalt(); _timer.Start(); } // usuwamy myCanvas_Klik lub zostawiamy (nie przeszkadza)
DispatcherTimer uruchamia się właśnie na wątku UI, więc możemy bezpiecznie
zmieniać pozycje klocków. Zwykły System.Threading.Timer działałby na osobnym wątku
i wymagałby dodatkowego kodu (Dispatcher.Invoke).
Metoda MoveDown
private void MoveDown() { int nowyRzad = _aktualnyStartRzad + 1; if (CzyMożnaPostawić(_aktualnyStartKol, nowyRzad, _aktualnaMapaOfsetow)) { _aktualnyStartRzad = nowyRzad; OdświeżPozycjeKlocków(); // przesuń Rectangle-e na Canvas } else { // nie można zejść niżej – osadź, sprawdź linie, spawn nowego OsadźKształtNaPlanszy(); SprawdzIUsunLinie(); SpawnNowyKsztalt(); } } private void OdświeżPozycjeKlocków() { for (int i = 0; i < _aktualneKlocki.Count; i++) { var (kol, rzad) = _aktualnaMapaOfsetow[i]; _aktualneKlocki[i].PrzesuńDo( _aktualnyStartKol + kol, _aktualnyStartRzad + rzad); } }
07 Sprawdzanie kolizji z HashSet
Przed każdym ruchem sprawdzamy: czy nowa pozycja kształtu mieści się w planszy
i czy żaden z jej klocków nie trafia na zajęte pole. Do tego używamy _zajete.Contains():
private bool CzyMożnaPostawić(int startKol, int startRzad, (int kol, int rzad)[] mapa) { foreach (var (dk, dr) in mapa) { int k = startKol + dk; int r = startRzad + dr; // wychodzi poza krawędź planszy? if (k < 0 || k >= KOLUMNY) return false; if (r < 0 || r >= RZEDY) return false; // to pole jest już zajęte przez osadzony klocek? if (_zajete.Contains((k, r))) return false; } return true; // wszystkie pola wolne }
CzyMożnaPostawić zwróci false – nie ruszamy się.
Ruch w lewo i w prawo
private void MoveLeft() { if (CzyMożnaPostawić(_aktualnyStartKol - 1, _aktualnyStartRzad, _aktualnaMapaOfsetow)) { _aktualnyStartKol--; OdświeżPozycjeKlocków(); } } private void MoveRight() { if (CzyMożnaPostawić(_aktualnyStartKol + 1, _aktualnyStartRzad, _aktualnaMapaOfsetow)) { _aktualnyStartKol++; OdświeżPozycjeKlocków(); } }
08 Osadzanie kształtu na planszy
Kiedy kształt nie może zejść niżej, staje się stałą częścią planszy.
Każdy klocek wpisujemy do _zajete i _klockiNaPlanszy:
private void OsadźKształtNaPlanszy() { for (int i = 0; i < _aktualneKlocki.Count; i++) { var (dk, dr) = _aktualnaMapaOfsetow[i]; int k = _aktualnyStartKol + dk; int r = _aktualnyStartRzad + dr; _zajete.Add((k, r)); // zaznacz pole jako zajęte _klockiNaPlanszy[(k, r)] = _aktualneKlocki[i]; // zapamiętaj klocek } _aktualneKlocki.Clear(); // klocki "przeszły" do _klockiNaPlanszy } // Uwaga: Rectangle-e nadal są na Canvas – nie usuwamy ich tutaj! // Usuwamy je dopiero przy kasowaniu linii.
09 Kasowanie kompletnych linii
Po każdym osadzeniu sprawdzamy rzędy od dołu do góry.
Rząd jest kompletny gdy wszystkie KOLUMNY pozycji są w _zajete.
Algorytm krok po kroku
private void SprawdzIUsunLinie() { int usuniete = 0; for (int r = RZEDY - 1; r >= 0; r--) // skanuj od dołu { if (CzyLiniaKompletna(r)) { UsunLinię(r); ZsuńKlockiNaDół(r); r++; // sprawdź ten sam rząd jeszcze raz (pętla zrobi r--) usuniete++; } } if (usuniete > 0) DodajPunkty(usuniete); } private bool CzyLiniaKompletna(int rzad) { for (int k = 0; k < KOLUMNY; k++) if (!_zajete.Contains((k, rzad))) return false; return true; }
for po wykonaniu ciała wykona r--, więc net efekt:
r++ + r-- = zostajemy w tym samym r. Bez r++ moglibyśmy pominąć
dwie kolejne kompletne linie.
Usuwanie linii z Canvas i HashSet
private void UsunLinię(int rzad) { for (int k = 0; k < KOLUMNY; k++) { var pozycja = (k, rzad); myCanvas.Children.Remove(_klockiNaPlanszy[pozycja].Element); _klockiNaPlanszy.Remove(pozycja); // usuń z Dictionary _zajete.Remove(pozycja); // usuń z HashSet } }
Zsuwanie klocków w dół
private void ZsuńKlockiNaDół(int usunienyRzad) { // iterujemy od rzędu nad usuniętym w górę for (int r = usunienyRzad - 1; r >= 0; r--) { for (int k = 0; k < KOLUMNY; k++) { if (!_zajete.Contains((k, r))) continue; var klocek = _klockiNaPlanszy[(k, r)]; // usuń z bieżącej pozycji _zajete.Remove((k, r)); _klockiNaPlanszy.Remove((k, r)); // dodaj na nową pozycję (o jeden rząd niżej) _zajete.Add((k, r + 1)); _klockiNaPlanszy[(k, r + 1)] = klocek; // przesuń Rectangle wizualnie klocek.PrzesuńDo(k, r + 1); } } }
10 Punkty i aktualizacja interfejsu
Punkty przyznajemy za każdą usuniętą linię. Więcej linii naraz = bonus.
private void DodajPunkty(int usunieteLinii) { // 1 linia = 100 pkt, 2 = 300 pkt, 3 = 500 pkt, 4 = 800 pkt int[] premie = { 0, 100, 300, 500, 800 }; _wynik += premie[usunieteLinii]; lblWynik.Content = $"Wynik: {_wynik}"; }
XAML – dodajemy etykietę wynik
<!-- zmiana: Window z KeyDown i nowy wymiar Canvas 300×600 --> <Window ... KeyDown="Window_KeyDown" Height="680" Width="340"> <StackPanel> <Label x:Name="lblWynik" Content="Wynik: 0" Foreground="White" FontSize="20" HorizontalAlignment="Center" Margin="0,8,0,4"/> <Canvas x:Name="myCanvas" Background="#1a1a2e" Width="300" Height="600"/> </StackPanel> </Window>
11 Sterowanie klawiaturą
Zdarzenie KeyDown na oknie głównym przechwytuje naciśnięcia klawiszy
niezależnie od tego który element ma fokus.
private void Window_KeyDown(object sender, KeyEventArgs e) { switch (e.Key) { case Key.Left: MoveLeft(); break; case Key.Right: MoveRight(); break; case Key.Down: MoveDown(); break; } }
KeyDown podpinamy do Window, nie do Canvas.
Canvas nie przyjmuje fokusowania klawiatury w naturalny sposób.
Window zawsze „słucha” klawiatury gdy okno jest aktywne.
Podsumowanie – co i gdzie dopisaliśmy
| Plik | Co dopisano |
|---|---|
| Klocek.cs | Pola Kolumna, Rzad + metoda PrzesuńDo() |
| FabrykaKlockow.cs | Parametr out mapa, metoda zwraca List<Klocek> |
| MainWindow.xaml.cs | HashSet + Dictionary, timer, kolizje, osadzanie, linie, punkty, KeyDown |
| MainWindow.xaml | Atrybut KeyDown, etykieta lblWynik, Canvas 300×600 |
12 Zadania
⭐ Zadanie 1 – Nowe kształty
Uzupełnij listę _ksztalty w FabrykaKlockow o brakujące kształty Tetrisa:
- Kształt T: ofsety
(0,0)(1,0)(2,0)(1,1) - Kształt L: cztery klocki – L-kształt
- Kształt S lub Z: skos
Każdy nowy kształt dodaj też do tablicy _kolory.
⭐ Zadanie 2 – Szybkość rośnie z wynikiem
Zmień metodę DodajPunkty() tak aby co 500 punktów timer był przyspieszany o 50 ms.
Minimalny interwał to 100 ms.
💡 Podpowiedź: _timer.Interval = TimeSpan.FromMilliseconds(Math.Max(100, aktualna - 50));
⭐⭐ Zadanie 3 – Podgląd następnego kształtu
Dodaj drugie małe Canvas (np. 120×120) z prawej strony planszy gdzie wyświetlany
jest następny kształt. W FabrykaKlockow przechowaj indeks
następnego kształtu i wyświetl go na podglądzie.
⭐⭐⭐ Zadanie 4 – Obrót kształtu (↑)
Obsłuż klawisz ↑ do obrotu kształtu o 90° zgodnie z ruchem wskazówek zegara.
Wzór obrotu ofsetu (dk, dr) → (dr, -dk).
Pamiętaj: przed obrotem sprawdź CzyMożnaPostawić() z nowymi ofsetami.
Jeśli obrót jest niemożliwy (kolizja) – nie obracaj.
💡 Podpowiedź: Utwórz nową tablicę ofsetów, wylicz obrót, sprawdź kolizję,
dopiero wtedy podmień _aktualnaMapaOfsetow.