Przejdź do treści
School IT / Przestrzeń do nauki

Warsztat: mini-gra „Łap owoce”

Masz już wszystkie klocki potrzebne do zbudowania gry: płótno i kształty do rysowania, klawiaturę do sterowania, zegar do ruchu, losowanie do zaskakiwania gracza i klasy do opisania obiektów. W tym warsztacie złożysz je w całość według planu, którego używa każda gra — od prostych gier zręcznościowych po duże produkcje: pętla gry, stan gry i ekrany. Potem sześć pomysłów na własne gry.

C# XAML pętla gry Canvas DispatcherTimer kolizje 2 × 90 min
CEL LEKCJI

Czego się dziś nauczysz

  • Zaplanujesz grę: obiekty, zasady, stany i sterowanie — zanim napiszesz kod.
  • Zbudujesz pętlę gry z zegarem, która w każdym tyknięciu porusza, sprawdza kolizje i aktualizuje wynik.
  • Obsłużysz stany gry: start, rozgrywka, pauza, koniec, i nakładkę z komunikatem.
  • Zrobisz płynne sterowanie klawiaturą i bezpiecznie usuniesz obiekty z listy w trakcie pętli.
  • Sprawisz, że gra stopniowo robi się trudniejsza.
ZADANIE

Projekt gry

Gra „Łap owoce” — założenia

  1. Plansza 480 × 520 z niebieskim niebem przechodzącym w zieleń. Na dole koszyk sterowany strzałkami ← →, ruszający się płynnie, dopóki strzałka jest wciśnięta.
  2. Z góry w losowych miejscach spadają obiekty: pomarańczowe owoce (70%), złote gwiazdki (12%) i bomby (18%). Każdy spada z trochę inną prędkością.
  3. Złapany owoc: +1 punkt, złota gwiazdka: +5 punktów, bomba: −1 życie. Przepuszczony owoc: −1 życie (gwiazdki i bomby można przepuścić).
  4. Gracz ma 3 życia pokazywane serduszkami. Im więcej punktów, tym częściej i szybciej spadają obiekty.
  5. Spacja zaczyna grę, wstrzymuje ją i wznawia oraz zaczyna nową grę po przegranej. Na początku, podczas pauzy i po przegranej plansza jest przyciemniona i widać na niej komunikat. Program pamięta rekord sesji.

Zanim zaczniesz pisać, wypisz obiekty gry i stan, który trzeba pamiętać:

ElementJak zrobiony
koszykRectangle w XAML, pozycja na płótnie
spadające obiektyklasa Spadajacy (kształt, rodzaj, prędkość), lista List<Spadajacy>
nakładka z komunikatemBorder na całe płótno z wysokim ZIndex
stan grypola: punkty, życia, rekord, czy gra trwa, które strzałki są wciśnięte
czasjeden zegar co 20 ms — pętla gry
TEORIA

Pętla gry

Każda gra czasu rzeczywistego ma w środku pętlę, która kilkadziesiąt razy na sekundę wykonuje te same kroki. U nas tę rolę pełni Tick zegara:

MainWindow.xaml.cs — fragment
private void Petla_Tick(object? sender, EventArgs e)
{
    tykniec++;
    RuszKoszyk();          // 1. wejście gracza → ruch
    DodawajObiekty();      // 2. nowe obiekty w grze
    RuszObiekty();         // 3. ruch, kolizje, punkty i życia
    PokazWynik();          // 4. aktualizacja napisów
}

Każdy krok to osobna, krótka metoda. Dzięki temu łatwo znaleźć błąd („obiekty nie znikają” — szukasz w RuszObiekty) i łatwo rozbudować grę o nowy krok.

TEORIA

Stan gry i ekrany

START
ekran powitalny
GRA
zegar chodzi
PAUZA
zegar stoi
KONIEC
wynik, rekord

Spacja: START → GRA ⇄ PAUZA; brak żyć: GRA → KONIEC; Spacja: KONIEC → GRA

W przykładzie stan opisują dwie rzeczy: pole trwa (czy rozgrywka jest w toku) i to, czy zegar chodzi. Wszystkie ekrany to jedna nakładka — półprzezroczysta ramka z napisem, której zmieniasz tekst i widoczność metodą PokazEkran. Kolor #B0000000 to czarny z kanałem alfa B0 (ok. 70% krycia).

TEORIA

Płynne sterowanie

Gdyby koszyk przesuwał się w KeyDown, ruszałby się „skokami”: najpierw raz, po chwili przerwy — powtórzenia klawisza (lekcja 26). Lepiej rozdzielić odpowiedzialność:

  • KeyDown i KeyUp tylko zapisują, czy strzałka jest wciśnięta (pola wLewo, wPrawo);
  • pętla gry w każdym tyknięciu przesuwa koszyk według tych pól — ruch jest równy i działa także przy dwóch klawiszach naraz.
TEORIA

Obiekty i usuwanie w pętli

W tym samym tyknięciu kilka obiektów może zostać złapanych albo spaść. W lekcji 29 po usunięciu piłki przerywaliśmy pętlę break. Tu trzeba usunąć wiele obiektów naraz, więc pętla idzie od końca listy po numerach:

MainWindow.xaml.cs — fragment
for (int i = obiekty.Count - 1; i >= 0; i--)
{
    // … ruch i kolizje obiektu obiekty[i] …
    if (zlapany || spadl)
    {
        Plansza.Children.Remove(obiekty[i].Widok);   // z ekranu
        obiekty.RemoveAt(i);                          // z listy
    }
}

Usunięcie elementu i przesuwa w dół tylko elementy o większych numerach — a te już sprawdziliśmy. Pętla od początku przeskoczyłaby element zaraz po usuniętym. Pamiętaj też o dwóch miejscach: obiekt jest i na liście, i na płótnie — usuwasz z obu.

TEORIA

Rosnąca trudność

Gra, która przez cały czas jest tak samo trudna, szybko nudzi. Dwa proste „pokrętła” zależą tu od punktów:

CoWzórEfekt
jak często nowy obiektMath.Max(15, 50 - punkty / 2) tyknięćna starcie co 1 s, od 70 punktów co 0,3 s
prędkość spadania2,5 + punkty / 25 + losowo 0–1,5na starcie 2,5–4 px na tyknięcie, przy 100 punktach — 6,5–8

Math.Max ustawia „sufit” trudności — bez niego od 100 punktów obiekty pojawiałyby się w każdym tyknięciu i gra stałaby się niemożliwa.

GOTOWA GRA

Kod gry

Łap owoce–✕
Punkty: 23 Rekord: 41♥♥♡

rozgrywka: dwa owoce, złota gwiazdka i bomba spadają do koszyka

Spadajacy.cs — pełny plik
using System.Windows.Controls;
using System.Windows.Shapes;

namespace Lekcja31;

// Jeden spadający obiekt: owoc, złota gwiazdka albo bomba
public class Spadajacy
{
    public Ellipse Widok { get; }
    public string Rodzaj { get; }       // "owoc", "zloty", "bomba"
    public double Predkosc { get; }     // pikseli na jedno tyknięcie

    public Spadajacy(Ellipse widok, string rodzaj, double predkosc)
    {
        Widok = widok;
        Rodzaj = rodzaj;
        Predkosc = predkosc;
    }

    public double X => Canvas.GetLeft(Widok);

    public double Y
    {
        get => Canvas.GetTop(Widok);
        set => Canvas.SetTop(Widok, value);
    }
}
MainWindow.xaml — pełny plik
<Window x:Class="Lekcja31.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Łap owoce" SizeToContent="WidthAndHeight" ResizeMode="CanMinimize"
        WindowStartupLocation="CenterScreen"
        KeyDown="Window_KeyDown" KeyUp="Window_KeyUp">

    <DockPanel>
        <!-- pasek wyniku -->
        <Border DockPanel.Dock="Top" Background="#FF14532D" Padding="12,6">
            <DockPanel>
                <TextBlock x:Name="Zycia" DockPanel.Dock="Right" Foreground="#FFFF6B6B" FontSize="20"/>
                <TextBlock x:Name="Wynik" Foreground="White" FontSize="18" FontWeight="Bold"/>
            </DockPanel>
        </Border>

        <!-- plansza gry 480 × 520 -->
        <Canvas x:Name="Plansza" Width="480" Height="520" ClipToBounds="True">
            <Canvas.Background>
                <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                    <GradientStop Color="#FFBFE3FF" Offset="0"/>
                    <GradientStop Color="#FFF0FFF0" Offset="1"/>
                </LinearGradientBrush>
            </Canvas.Background>

            <!-- koszyk -->
            <Rectangle x:Name="Koszyk" Width="90" Height="22" RadiusX="6" RadiusY="6"
                       Fill="SaddleBrown" Stroke="#FF5A3210" StrokeThickness="2"
                       Canvas.Left="195" Canvas.Top="485" Panel.ZIndex="5"/>

            <!-- ekran startu / końca gry -->
            <Border x:Name="Ekran" Width="480" Height="520" Background="#B0000000" Panel.ZIndex="10">
                <TextBlock x:Name="EkranTekst" Foreground="White" FontSize="22" TextAlignment="Center"
                           HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"/>
            </Border>
        </Canvas>
    </DockPanel>
</Window>
MainWindow.xaml.cs — pełny plik
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
using System.Windows.Threading;

namespace Lekcja31;

public partial class MainWindow : Window
{
    private const double PredkoscKoszyka = 7;
    private const int StartZycia = 3;

    private readonly Random los = new Random();
    private readonly DispatcherTimer petla = new DispatcherTimer();   // „serce” gry: 50 razy na sekundę
    private readonly List<Spadajacy> obiekty = new List<Spadajacy>();

    // stan gry
    private bool wLewo, wPrawo;          // które strzałki są wciśnięte
    private bool trwa = false;
    private int punkty, zycia, rekord;
    private int tykniec;                 // licznik tyknięć — do pojawiania się owoców

    public MainWindow()
    {
        InitializeComponent();

        petla.Interval = TimeSpan.FromMilliseconds(20);
        petla.Tick += Petla_Tick;

        PokazEkran("ŁAP OWOCE\n\n← → — ruch koszykiem\nZłota gwiazdka: +5, bomba: −1 życie\n\nSpacja — start");
        PokazWynik();
    }

    // ===== Sterowanie =====

    private void Window_KeyDown(object sender, KeyEventArgs e)
    {
        if (e.Key == Key.Left) wLewo = true;
        if (e.Key == Key.Right) wPrawo = true;

        if (e.Key == Key.Space && !e.IsRepeat)
        {
            if (trwa)
            {
                Pauza(!petla.IsEnabled);    // gra trwa — wstrzymaj albo wznów
            }
            else
            {
                NowaGra();                  // ekran startu albo koniec gry
            }
        }
    }

    private void Window_KeyUp(object sender, KeyEventArgs e)
    {
        if (e.Key == Key.Left) wLewo = false;
        if (e.Key == Key.Right) wPrawo = false;
    }

    // ===== Pętla gry: ruch → nowe obiekty → kolizje → wynik =====

    private void Petla_Tick(object? sender, EventArgs e)
    {
        tykniec++;
        RuszKoszyk();
        DodawajObiekty();
        RuszObiekty();
        PokazWynik();
    }

    private void RuszKoszyk()
    {
        double x = Canvas.GetLeft(Koszyk);
        if (wLewo) x -= PredkoscKoszyka;
        if (wPrawo) x += PredkoscKoszyka;
        x = Math.Max(0, Math.Min(x, Plansza.Width - Koszyk.Width));   // nie poza planszę
        Canvas.SetLeft(Koszyk, x);
    }

    private void DodawajObiekty()
    {
        // co ile tyknięć nowy obiekt: im więcej punktów, tym częściej (najczęściej co 15)
        int odstep = Math.Max(15, 50 - punkty / 2);
        if (tykniec % odstep != 0)
        {
            return;
        }

        int los100 = los.Next(100);
        string rodzaj = los100 < 70 ? "owoc" : los100 < 82 ? "zloty" : "bomba";

        Brush kolor = rodzaj == "owoc" ? Brushes.OrangeRed
                    : rodzaj == "zloty" ? Brushes.Gold
                    : Brushes.Black;

        Ellipse widok = new Ellipse
        {
            Width = 30,
            Height = 30,
            Fill = kolor,
            Stroke = rodzaj == "bomba" ? Brushes.Red : Brushes.DarkRed,
            StrokeThickness = 2
        };
        Canvas.SetLeft(widok, los.Next(0, (int)(Plansza.Width - widok.Width)));
        Canvas.SetTop(widok, -widok.Height);           // startuje tuż nad planszą
        Plansza.Children.Add(widok);

        double predkosc = 2.5 + punkty / 25.0 + los.NextDouble() * 1.5;   // szybciej z czasem
        obiekty.Add(new Spadajacy(widok, rodzaj, predkosc));
    }

    private void RuszObiekty()
    {
        double kx = Canvas.GetLeft(Koszyk);
        double ky = Canvas.GetTop(Koszyk);

        // pętla od końca — można bezpiecznie usuwać elementy listy
        for (int i = obiekty.Count - 1; i >= 0; i--)
        {
            Spadajacy o = obiekty[i];
            o.Y += o.Predkosc;

            bool zlapany = o.X < kx + Koszyk.Width && o.X + o.Widok.Width > kx
                        && o.Y + o.Widok.Height > ky && o.Y < ky + Koszyk.Height;
            bool spadl = o.Y > Plansza.Height;

            if (zlapany)
            {
                if (o.Rodzaj == "owoc") punkty += 1;
                else if (o.Rodzaj == "zloty") punkty += 5;
                else zycia--;                              // złapana bomba
            }
            else if (spadl && o.Rodzaj == "owoc")
            {
                zycia--;                                   // przepuszczony owoc
            }

            if (zlapany || spadl)
            {
                Plansza.Children.Remove(o.Widok);
                obiekty.RemoveAt(i);
            }
        }

        if (zycia <= 0)
        {
            KoniecGry();
        }
    }

    // ===== Stany gry =====

    private void NowaGra()
    {
        foreach (Spadajacy o in obiekty)
        {
            Plansza.Children.Remove(o.Widok);
        }
        obiekty.Clear();

        punkty = 0;
        zycia = StartZycia;
        tykniec = 0;
        trwa = true;
        Canvas.SetLeft(Koszyk, (Plansza.Width - Koszyk.Width) / 2);

        Ekran.Visibility = Visibility.Collapsed;
        petla.Start();
    }

    private void Pauza(bool wlacz)
    {
        if (wlacz)
        {
            petla.Start();
            Ekran.Visibility = Visibility.Collapsed;
        }
        else
        {
            petla.Stop();
            PokazEkran("PAUZA\n\nSpacja — dalej");
        }
    }

    private void KoniecGry()
    {
        zycia = 0;
        trwa = false;
        petla.Stop();
        rekord = Math.Max(rekord, punkty);
        PokazEkran($"KONIEC GRY\n\nPunkty: {punkty}\nRekord: {rekord}\n\nSpacja — jeszcze raz");
        PokazWynik();
    }

    private void PokazEkran(string tekst)
    {
        EkranTekst.Text = tekst;
        Ekran.Visibility = Visibility.Visible;
    }

    private void PokazWynik()
    {
        Wynik.Text = $"Punkty: {punkty}   Rekord: {rekord}";
        Zycia.Text = new string('♥', Math.Max(0, zycia)) + new string('♡', StartZycia - Math.Max(0, zycia));
    }
}

Co dzieje się po kolei

  • Płótno ma stały rozmiar 480 × 520, a okno SizeToContent="WidthAndHeight" — granice planszy to po prostu Plansza.Width i Plansza.Height.
  • Klasa Spadajacy łączy kształt z danymi obiektu. Pozycję X tylko czyta (obiekt spada pionowo), a Y czyta i zmienia.
  • DodawajObiekty korzysta z licznika tyknięć: nowy obiekt powstaje, gdy tykniec jest podzielne przez odstęp. Rodzaj losuje liczba 0–99: poniżej 70 owoc, 70–81 gwiazdka, reszta bomba.
  • Obiekt startuje nad planszą (Top = −wysokość), więc „wjeżdża” w kadr zamiast pojawiać się nagle.
  • RuszObiekty sprawdza kolizję z koszykiem warunkiem z lekcji 29 i decyduje o punktach i życiach. Złapane i te, które spadły, znikają z płótna i z listy.
  • Życia jako serduszka: new string('♥', zycia) + puste serduszka do trzech.
  • KoniecGry zatrzymuje zegar, aktualizuje rekord i pokazuje nakładkę. Nowa gra najpierw usuwa z płótna pozostałe obiekty.

Sprawdź trzy rzeczy

  1. Zmień pętlę w RuszObiekty na for (int i = 0; i < obiekty.Count; i++). Przy kilku obiektach znikających naraz któryś „zatnie się” na jedno tyknięcie.
  2. Przenieś przesuwanie koszyka do Window_KeyDown (zamiast ustawiania pól). Porównaj płynność ruchu.
  3. Usuń Math.Max(15, …) ze wzoru odstępu i zagraj do 100 punktów.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementSkądRola w grze
DispatcherTimer 20 mslekcja 27pętla gry
KeyDown/KeyUp + pola boollekcja 26płynne sterowanie
Randomlekcja 28miejsce, rodzaj i prędkość obiektów
Canvas, ZIndex, kolizjelekcja 29plansza, nakładka, łapanie
Rectangle, Ellipselekcja 30koszyk i obiekty
klasa + List<T>lekcja 23obiekty gry
pętla for od końca + RemoveAtC#usuwanie wielu obiektów w jednym tyknięciu
#B0000000lekcja 06półprzezroczysta nakładka (kanał alfa)
CZĘSTE BŁĘDY

Zanim utkniesz

SytuacjaProblem
obiekt usunięty z listy, ale nie z płótnaNa ekranie zostaje „duch”, który już nie spada.
foreach z Remove w środkuInvalidOperationException.
ruch w KeyDownSkokowy ruch i opóźnienie powtarzania klawisza.
życie odejmowane w każdym tyknięciu, gdy obiekt jest pod plansząObiekt nie został usunięty — odejmuje życie wiele razy.
nowa gra bez czyszczenia listyStare obiekty dalej spadają albo zostają na planszy.
trudność rośnie bez ograniczeniaPo kilku minutach gra jest niemożliwa — ustaw sufit przez Math.Max/Math.Min.
zegar nie zatrzymany po końcu gryPętla dalej działa i zmienia wynik po przegranej.
PROJEKTY

Projekty gier

Pierwszy projekt rozbudowuje „Łap owoce”, pozostałe to nowe gry. Każdą zaplanuj jak w tej lekcji: obiekty, stan, ekrany, pętla. Wskazówkę otwórz dopiero wtedy, gdy utkniesz.

PROJ 1„Łap owoce” — rozszerzenia★☆☆

Dodaj do gry: niebieską kroplę, która na 5 sekund poszerza koszyk dwukrotnie (pasek pod wynikiem pokazuje pozostały czas efektu), sterowanie myszą (koszyk podąża za kursorem w poziomie), poziomy co 25 punktów z napisem „Poziom 3!” na środku planszy przez 1,5 s i zapamiętanie trzech najlepszych wyników sesji pokazywanych na ekranie końca gry.

pokaż wskazówkę
  • Czas efektu: zapamiętaj chwilę końca efektu (DateTime) i sprawdzaj ją w pętli gry.
  • Przy poszerzaniu pilnuj, żeby koszyk nie wyszedł za prawą krawędź.
PROJ 2Unik — deszcz meteorytów★★☆

Statek (trójkąt) na dole ekranu porusza się w czterech kierunkach. Z góry spadają meteoryty różnej wielkości, które trzeba omijać. Punkty rosną za każdą przeżytą sekundę. Statek ma tarczę na 3 trafienia, a co 20 sekund pojawia się zielony kryształ naprawiający tarczę. Kolizję statku licz jak prostokąta, ale zmniejszonego o kilka pikseli z każdej strony — wtedy gra jest uczciwsza.

pokaż wskazówkę
  • Trójkąt statku to Polygon o punktach liczonych od (0, 0); na płótnie stawiasz go przez Canvas.Left/Top jak prostokąt.
PROJ 3Skoczek przez rury★★☆

Ptaszek (koło) spada pod wpływem „grawitacji” (prędkość pionowa rośnie w każdym tyknięciu), a Spacja podbija go w górę. Z prawej strony nadjeżdżają pary rur (prostokąty) z losowo umieszczoną szczeliną. Przelot przez szczelinę daje punkt, dotknięcie rury, ziemi lub sufitu kończy grę. Rury znikają po wyjechaniu za lewą krawędź.

pokaż wskazówkę
  • Fizyka w dwóch liniach: vy += grawitacja; y += vy;. Skok: vy = -siła.
  • Para rur to jeden obiekt klasy z dwoma prostokątami i informacją, czy już dał punkt.
PROJ 4Arkanoid★★☆

Paletka na dole (strzałki albo mysz), piłka odbijająca się od ścian i paletki oraz 5 rzędów cegieł u góry w różnych kolorach. Trafiona cegła znika i daje punkty (więcej za wyższe rzędy). Górny rząd wymaga dwóch trafień — po pierwszym zmienia kolor. Piłka, która spadnie pod paletkę, zabiera życie. Zbicie wszystkich cegieł — następny poziom z szybszą piłką.

pokaż wskazówkę
  • Kierunek odbicia od cegły: jeśli piłka wpadła z boku, zmień znak Vx, jeśli z góry lub z dołu — Vy. Prosty sposób: porównaj, na ile piłka „weszła” w cegłę w poziomie i w pionie.
PROJ 5Przejście przez ulicę★★☆

Żaba (kwadrat) zaczyna na dole i skacze o jedno pole strzałkami. Między startem a metą są cztery pasy ruchu; samochody (prostokąty różnej długości) jeżdżą w przeciwnych kierunkach z różną prędkością i wracają z drugiej strony po wyjechaniu za krawędź. Potrącenie zabiera życie i wraca żabę na start, dojście do mety daje punkty i przyspiesza ruch. Licznik czasu na jedno przejście: 20 s.

pokaż wskazówkę
  • Żaba porusza się skokami w KeyDown (tu skokowy ruch jest pożądany), a samochody — w pętli gry.
PROJ 6Najeźdźcy z kosmosu★★★

Działo na dole ekranu porusza się w poziomie i strzela Spacją (najwyżej 3 pociski jednocześnie). U góry formacja 5 × 8 przeciwników przesuwa się w bok, a po dotknięciu krawędzi schodzi niżej i zmienia kierunek. Im mniej przeciwników zostało, tym szybciej się poruszają. Losowi przeciwnicy z dolnego rzędu odpowiadają ogniem. Gra kończy się, gdy formacja zejdzie do działa albo działo straci 3 życia; zestrzelenie wszystkich — nowa fala.

pokaż wskazówkę
  • Przeciwników trzymaj w liście; formację przesuwasz, zmieniając pozycję każdego o ten sam wektor. Krawędź sprawdzasz dla skrajnych przeciwników.
  • Kolizje pocisk–przeciwnik: dwie zagnieżdżone pętle od końca, po trafieniu usuń oba obiekty i przerwij pętlę wewnętrzną.
PODSUMOWANIE

Co trzeba zapamiętać

  • Zaplanuj grę przed kodowaniem: obiekty, stan, ekrany, sterowanie.
  • Pętla gry to Tick zegara podzielony na krótkie metody: ruch, nowe obiekty, kolizje, wynik.
  • Płynne sterowanie: klawisze tylko zapisują stan, a ruch wykonuje pętla.
  • Wiele obiektów usuwaj pętlą for od końca — z listy i z płótna.
  • Stany gry (start, gra, pauza, koniec) obsługuje jedna nakładka i zatrzymywanie zegara.
  • Trudność rośnie z wynikiem, ale ma swój sufit.
Postęp zapisuje się w tej przeglądarce.