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.
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.
Projekt gry
Gra „Łap owoce” — założenia
- 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.
- 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ą.
- 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ć).
- Gracz ma 3 życia pokazywane serduszkami. Im więcej punktów, tym częściej i szybciej spadają obiekty.
- 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ć:
| Element | Jak zrobiony |
|---|---|
| koszyk | Rectangle w XAML, pozycja na płótnie |
| spadające obiekty | klasa Spadajacy (kształt, rodzaj, prędkość), lista List<Spadajacy> |
| nakładka z komunikatem | Border na całe płótno z wysokim ZIndex |
| stan gry | pola: punkty, życia, rekord, czy gra trwa, które strzałki są wciśnięte |
| czas | jeden zegar co 20 ms — pętla gry |
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:
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.
Stan gry i ekrany
ekran powitalny
zegar chodzi
zegar stoi
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).
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ść:
KeyDowniKeyUptylko zapisują, czy strzałka jest wciśnięta (polawLewo,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.
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:
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.
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:
| Co | Wzór | Efekt |
|---|---|---|
| jak często nowy obiekt | Math.Max(15, 50 - punkty / 2) tyknięć | na starcie co 1 s, od 70 punktów co 0,3 s |
| prędkość spadania | 2,5 + punkty / 25 + losowo 0–1,5 | na 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.
Kod gry
rozgrywka: dwa owoce, złota gwiazdka i bomba spadają do koszyka
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);
}
}
<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>
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 prostuPlansza.WidthiPlansza.Height. - Klasa
Spadajacyłączy kształt z danymi obiektu. PozycjęXtylko czyta (obiekt spada pionowo), aYczyta i zmienia. DodawajObiektykorzysta z licznika tyknięć: nowy obiekt powstaje, gdytykniecjest 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. RuszObiektysprawdza 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. KoniecGryzatrzymuje zegar, aktualizuje rekord i pokazuje nakładkę. Nowa gra najpierw usuwa z płótna pozostałe obiekty.
Sprawdź trzy rzeczy
- Zmień pętlę w
RuszObiektynafor (int i = 0; i < obiekty.Count; i++). Przy kilku obiektach znikających naraz któryś „zatnie się” na jedno tyknięcie. - Przenieś przesuwanie koszyka do
Window_KeyDown(zamiast ustawiania pól). Porównaj płynność ruchu. - Usuń
Math.Max(15, …)ze wzoru odstępu i zagraj do 100 punktów.
Zestawienie elementów
| Element | Skąd | Rola w grze |
|---|---|---|
DispatcherTimer 20 ms | lekcja 27 | pętla gry |
KeyDown/KeyUp + pola bool | lekcja 26 | płynne sterowanie |
Random | lekcja 28 | miejsce, rodzaj i prędkość obiektów |
Canvas, ZIndex, kolizje | lekcja 29 | plansza, nakładka, łapanie |
Rectangle, Ellipse | lekcja 30 | koszyk i obiekty |
klasa + List<T> | lekcja 23 | obiekty gry |
pętla for od końca + RemoveAt | C# | usuwanie wielu obiektów w jednym tyknięciu |
#B0000000 | lekcja 06 | półprzezroczysta nakładka (kanał alfa) |
Zanim utkniesz
| Sytuacja | Problem |
|---|---|
| obiekt usunięty z listy, ale nie z płótna | Na ekranie zostaje „duch”, który już nie spada. |
foreach z Remove w środku | InvalidOperationException. |
ruch w KeyDown | Skokowy 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 listy | Stare obiekty dalej spadają albo zostają na planszy. |
| trudność rośnie bez ograniczenia | Po kilku minutach gra jest niemożliwa — ustaw sufit przez Math.Max/Math.Min. |
| zegar nie zatrzymany po końcu gry | Pętla dalej działa i zmienia wynik po przegranej. |
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.
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ź.
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
Polygono punktach liczonych od (0, 0); na płótnie stawiasz go przezCanvas.Left/Topjak prostokąt.
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.
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.
Ż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.
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ą.
Co trzeba zapamiętać
- Zaplanuj grę przed kodowaniem: obiekty, stan, ekrany, sterowanie.
- Pętla gry to
Tickzegara 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ą
forod 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.