Gra Tetris – rozbudowa projektu WPF | school-it.pl

Gra Tetris w WPF –rozbudowa istniejącego projektu

DispatcherTimer HashSet<(int,int)> Dictionary KeyDown Rozbudowa kodu

01 Punkt startowy – co już mamy

Zanim zaczniemy, przypomnijmy co robi każdy z istniejących plików:

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

Oryginalny FabrykaKlockow.cs – kształty jako ofsetyC#
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)
Krotki w C# – (int kol, int rzad) Krotka to lekki kontener na kilka wartości bez tworzenia osobnej klasy. (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 brakujeCo 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().

Dlaczego nie new Klocek()? Tworzenie nowego obiektu przy każdym ruchu = dodawanie nowego Rectangle do Canvas i usuwanie starego. To wolne i generuje migotanie. Lepiej przesunąć istniejący Rectangle aktualizując Canvas.SetLeft/SetTop.
Klocek.cs – dopisujemy dwie rzeczyC#
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);
    }
}
Tylko dwa bloki dodatków! Zielone linie (added) to jedyne zmiany w 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?

KolekcjaSprawdzanie czy coś jest w środkuOcena
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:

MainWindow.xaml.cs – pola klasyC#
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 vs Dictionary 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ć:

FabrykaKlockow.cs – mała zmiana: metoda zwraca listę klockówC#
// 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;
}
Słowo kluczowe out 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:

MainWindow.xaml.cs – konstruktor i timerC#
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)
Dlaczego DispatcherTimer? W WPF interfejs (Canvas, Rectangle) można modyfikować tylko z wątku UI. 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():

Metoda CzyMożnaPostawićC#
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
}
Klucz do zrozumienia Metoda nie sprawdza bieżącej pozycji – sprawdza hipotetyczną pozycję podaną jako argumenty. Dzięki temu możemy testować ruch w dół, w bok lub po obrocie zanim go faktycznie wykonamy. Jeśli 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:

OsadźKształtNaPlanszyC#
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

SprawdzIUsunLinieC#
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;
}
Dlaczego r++ w środku pętli for? Po usunięciu rzędu r i zsunięciu klocków z góry, rząd r teraz zawiera to co było w r-1. Pętla 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

MainWindow.xaml – dopisujemy Label powyżej CanvasXAML
<!-- 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;
    }
}
Window vs Canvas Zdarzenie 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

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