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

Canvas — płótno ze współrzędnymi

Wszystkie dotychczasowe panele same decydowały, gdzie postawić kontrolkę: jedna pod drugą, w komórce siatki, przy krawędzi. W grach i rysunkach chcesz powiedzieć wprost: „postaw piłkę w punkcie (120, 80)”. Do tego służy Canvas — płótno, na którym każda kontrolka ma swoje współrzędne. Połączysz je z myszą, klawiaturą, zegarem i losowaniem z poprzednich lekcji.

XAML C# Canvas Canvas.Left / Top ZIndex kolizje 90 min
CEL LEKCJI

Czego się dziś nauczysz

  • Umieścisz kontrolkę na płótnie w podanym punkcie przez Canvas.Left i Canvas.Top.
  • Odczytasz i zmienisz pozycję z kodu metodami Canvas.GetLeft/SetLeft.
  • Ustalisz, która kontrolka jest na wierzchu, przez Panel.ZIndex.
  • Zatrzymasz obiekt na krawędziach płótna i odbijesz go od ścian.
  • Sprawdzisz, czy dwa prostokąty na płótnie na siebie nachodzą.
TEORIA

Współrzędne na płótnie

MainWindow.xaml
<Canvas Width="400" Height="250" Background="AliceBlue">
    <Border Canvas.Left="40" Canvas.Top="30" Width="80" Height="50" Background="Tomato"/>
    <Border Canvas.Left="200" Canvas.Top="120" Width="60" Height="60"
            Background="SteelBlue" CornerRadius="30"/>
    <TextBlock Canvas.Right="10" Canvas.Bottom="10" Text="prawy dolny róg"/>
</Canvas>
Canvas–□✕
(40, 30)
(200, 120) prawy dolny róg (0, 0)

każda kontrolka ma swój punkt — lewy górny róg kontrolki w podanych współrzędnych

Canvas.Left to odległość lewej krawędzi kontrolki od lewej krawędzi płótna, Canvas.Top — górnej od górnej. Oś Y rośnie w dół, jak w lekcji 25. Można też liczyć od prawej i dolnej krawędzi (Canvas.Right, Canvas.Bottom), ale nie mieszaj obu sposobów dla jednej kontrolki. Kontrolki na płótnie się nie rozciągają — podaj im Width i Height.

TEORIA

Pozycja z kodu

Canvas.Left to właściwość dołączona — jak Grid.Row. W kodzie czytasz i zmieniasz ją metodami klasy Canvas:

MainWindow.xaml.cs
double x = Canvas.GetLeft(Gracz);       // odczyt
double y = Canvas.GetTop(Gracz);

Canvas.SetLeft(Gracz, x + 10);          // przesunięcie o 10 w prawo
Canvas.SetTop(Gracz, y - 5);            // i o 5 w górę

// nowa kontrolka w punkcie (100, 50)
Border kafel = new Border { Width = 40, Height = 40, Background = Brushes.Gold };
Canvas.SetLeft(kafel, 100);
Canvas.SetTop(kafel, 50);
Plotno.Children.Add(kafel);

GetLeft bez ustawionej pozycji zwraca NaN

Jeżeli kontrolka nie ma ustawionego Canvas.Left, płótno rysuje ją przy lewej krawędzi, ale Canvas.GetLeft zwraca double.NaN — „nie liczba”. NaN + 10 to dalej NaN i kontrolka „nie chce się ruszyć”. Ustaw pozycję w XAML albo w kodzie, zanim zaczniesz ją zmieniać.

TEORIA

Kolejność rysowania

Kontrolki na płótnie mogą na siebie nachodzić. Domyślnie późniejsza w Children jest na wierzchu. Kolejność zmienisz bez przestawiania kontrolek właściwością Panel.ZIndex — większa liczba rysuje się wyżej:

XAML i C#
<TextBlock Text="PAUZA" Panel.ZIndex="100" .../>
Panel.SetZIndex(Gracz, 10);             // gracz ponad tłem i przeszkodami

ZIndex działa w każdym panelu (także w komórce Grid), ale najczęściej używa się go na płótnie.

TEORIA

Rozmiar płótna i krawędzie

Płótno nie ogranicza kontrolek — kontrolka z Canvas.Left="900" narysuje się poza płótnem, a nawet na innych częściach okna. ClipToBounds="True" obcina wszystko, co wychodzi poza płótno. Pilnowanie krawędzi należy do Ciebie:

MainWindow.xaml.cs
double maxX = Plotno.ActualWidth - Gracz.Width;     // najdalej w prawo
double maxY = Plotno.ActualHeight - Gracz.Height;   // najniżej

double x = Canvas.GetLeft(Gracz) + dx;
x = Math.Max(0, Math.Min(x, maxX));                 // „przytnij” do przedziału 0…maxX
Canvas.SetLeft(Gracz, x);

Gdy płótno ma stały Width/Height, możesz używać tych liczb. Gdy rośnie razem z oknem (stoi w wierszu * albo jako ostatnie dziecko DockPanel), korzystaj z ActualWidth/ActualHeight — rozmiaru po ułożeniu.

TEORIA

Kolizje

W grach co chwilę pytasz: „czy gracz dotknął monety?”. Najprostszy test traktuje obie kontrolki jak prostokąty. Dwa prostokąty nie nachodzą na siebie, jeśli jeden leży w całości na lewo, na prawo, nad albo pod drugim. W każdym innym przypadku — nachodzą:

MainWindow.xaml.cs
private bool Koliduja(FrameworkElement a, FrameworkElement b)
{
    double ax = Canvas.GetLeft(a), ay = Canvas.GetTop(a);
    double bx = Canvas.GetLeft(b), by = Canvas.GetTop(b);

    return ax < bx + b.Width && ax + a.Width > bx      // nachodzą w poziomie
        && ay < by + b.Height && ay + a.Height > by;   // i w pionie
}

FrameworkElement to wspólna klasa bazowa ramek, napisów, przycisków i obrazów — dzięki temu ta sama metoda działa dla każdej pary kontrolek.

PRZYKŁAD

Odbijające się piłki

Kliknięcie w płótno dodaje w tym miejscu piłkę w losowym kolorze, lecącą w losowym kierunku. Zegar 50 razy na sekundę przesuwa wszystkie piłki i odbija je od ścian. Prawy przycisk na piłce ją usuwa, Spacja wstrzymuje ruch (napis „PAUZA” jest zawsze na wierzchu dzięki ZIndex), a C czyści płótno. Płótno rośnie razem z oknem — piłki odbijają się od jego aktualnych krawędzi.

Odbijające się piłki–□✕
Klik — nowa piłka • prawy klik na piłce — usuń • Spacja — pauza • C — wyczyśćPiłek: 5
↗↘↖

pięć piłek w ruchu — strzałki pokazują kierunki

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

namespace Lekcja29;

// Jedna piłka: kontrolka na płótnie + prędkość
public class Pilka
{
    public Border Widok { get; }
    public double Vx { get; set; }      // przesunięcie w poziomie na jedno tyknięcie
    public double Vy { get; set; }      // przesunięcie w pionie

    public Pilka(Border widok, double vx, double vy)
    {
        Widok = widok;
        Vx = vx;
        Vy = vy;
    }

    // pozycja czytana i zapisywana prosto na płótnie
    public double X
    {
        get => Canvas.GetLeft(Widok);
        set => Canvas.SetLeft(Widok, value);
    }

    public double Y
    {
        get => Canvas.GetTop(Widok);
        set => Canvas.SetTop(Widok, value);
    }
}
MainWindow.xaml — pełny plik
<Window x:Class="Lekcja29.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Odbijające się piłki" Height="480" Width="680"
        WindowStartupLocation="CenterScreen" KeyDown="Window_KeyDown">

    <DockPanel>
        <Border DockPanel.Dock="Top" Background="#FF1F2937" Padding="10,6">
            <DockPanel>
                <TextBlock x:Name="Licznik" DockPanel.Dock="Right" Foreground="White"
                           VerticalAlignment="Center"/>
                <TextBlock Foreground="White" VerticalAlignment="Center"
                           Text="Klik — nowa piłka • prawy klik na piłce — usuń • Spacja — pauza • C — wyczyść"/>
            </DockPanel>
        </Border>

        <!-- płótno: współrzędne liczone od lewego górnego rogu -->
        <Canvas x:Name="Plotno" Background="#FFF8FAFC" ClipToBounds="True"
                MouseLeftButtonDown="Plotno_MouseLeftButtonDown">
            <TextBlock x:Name="Pauza" Text="PAUZA" FontSize="48" FontWeight="Bold"
                       Foreground="#40000000" Canvas.Left="20" Canvas.Top="10"
                       Panel.ZIndex="100" Visibility="Collapsed"/>
        </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.Threading;

namespace Lekcja29;

public partial class MainWindow : Window
{
    private const double Srednica = 36;

    private readonly Random los = new Random();
    private readonly DispatcherTimer zegar = new DispatcherTimer();
    private readonly List<Pilka> pilki = new List<Pilka>();

    public MainWindow()
    {
        InitializeComponent();

        zegar.Interval = TimeSpan.FromMilliseconds(20);   // ok. 50 klatek na sekundę
        zegar.Tick += Zegar_Tick;
        zegar.Start();

        PokazLicznik();
    }

    // Klik w płótno: nowa piłka w miejscu kursora
    private void Plotno_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        Point p = e.GetPosition(Plotno);

        Border widok = new Border
        {
            Width = Srednica,
            Height = Srednica,
            CornerRadius = new CornerRadius(Srednica / 2),
            Background = new SolidColorBrush(Color.FromRgb(
                (byte)los.Next(40, 220), (byte)los.Next(40, 220), (byte)los.Next(40, 220))),
            BorderBrush = Brushes.Black,
            BorderThickness = new Thickness(1)
        };
        widok.MouseRightButtonDown += Pilka_MouseRightButtonDown;

        // prędkość: 2–6 pikseli na tyknięcie, w losowym kierunku
        double vx = los.Next(2, 7) * (los.Next(2) == 0 ? -1 : 1);
        double vy = los.Next(2, 7) * (los.Next(2) == 0 ? -1 : 1);

        Pilka pilka = new Pilka(widok, vx, vy);
        Plotno.Children.Add(widok);
        pilka.X = p.X - Srednica / 2;      // środek piłki pod kursorem
        pilka.Y = p.Y - Srednica / 2;
        pilki.Add(pilka);

        PokazLicznik();
    }

    // Prawy klik na piłce: usunięcie
    private void Pilka_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
    {
        Border widok = (Border)sender;

        foreach (Pilka p in pilki)
        {
            if (p.Widok == widok)
            {
                pilki.Remove(p);
                break;                    // po Remove nie wolno iść dalej pętlą
            }
        }

        Plotno.Children.Remove(widok);
        PokazLicznik();
    }

    private void Window_KeyDown(object sender, KeyEventArgs e)
    {
        if (e.Key == Key.Space)
        {
            zegar.IsEnabled = !zegar.IsEnabled;
            Pauza.Visibility = zegar.IsEnabled ? Visibility.Collapsed : Visibility.Visible;
        }
        else if (e.Key == Key.C)
        {
            foreach (Pilka p in pilki)
            {
                Plotno.Children.Remove(p.Widok);
            }
            pilki.Clear();
            PokazLicznik();
        }
    }

    // Każde tyknięcie: przesuń wszystkie piłki, odbij od ścian
    private void Zegar_Tick(object? sender, EventArgs e)
    {
        double szer = Plotno.ActualWidth;
        double wys = Plotno.ActualHeight;

        foreach (Pilka p in pilki)
        {
            p.X += p.Vx;
            p.Y += p.Vy;

            if (p.X < 0)                       // lewa ściana
            {
                p.X = 0;
                p.Vx = -p.Vx;
            }
            else if (p.X > szer - Srednica)    // prawa ściana
            {
                p.X = szer - Srednica;
                p.Vx = -p.Vx;
            }

            if (p.Y < 0)                       // sufit
            {
                p.Y = 0;
                p.Vy = -p.Vy;
            }
            else if (p.Y > wys - Srednica)     // podłoga
            {
                p.Y = wys - Srednica;
                p.Vy = -p.Vy;
            }
        }
    }

    private void PokazLicznik()
    {
        Licznik.Text = $"Piłek: {pilki.Count}";
    }
}

Co dzieje się po kolei

  • Każda piłka to obiekt klasy Pilka (lekcja 23): kontrolka na płótnie i prędkość w obu osiach. Wszystkie piłki są w List<Pilka>.
  • Właściwości X i Y w klasie Pilka nie przechowują liczby — ich get i set czytają i ustawiają pozycję na płótnie. Dzięki temu w kodzie okna piszesz po prostu p.X += p.Vx.
  • Płótno ma tło — inaczej kliknięcie w puste miejsce nie wywołałoby MouseLeftButtonDown (lekcja 25). ClipToBounds ukrywa fragment piłki, która na chwilę wyjdzie za krawędź po zmniejszeniu okna.
  • Nowa piłka staje środkiem pod kursorem: od pozycji kursora odejmujemy połowę średnicy. Kolor i prędkość są losowe (lekcja 28); los.Next(2) == 0 ? -1 : 1 losuje kierunek.
  • Zegar_Tick przesuwa każdą piłkę o jej prędkość. Gdy piłka wyjdzie poza krawędź, wraca na krawędź, a odpowiednia składowa prędkości zmienia znak — to odbicie.
  • Granice liczone są z ActualWidth i ActualHeight płótna, bo płótno rośnie razem z oknem.
  • Usuwając piłkę, szukamy jej obiektu po kontrolce i przerywamy pętlę break zaraz po Remove — dalsze przechodzenie po zmienionej liście skończyłoby się wyjątkiem.
  • Napis „PAUZA” ma Panel.ZIndex="100", więc jest nad wszystkimi piłkami, choć dodano go do płótna jako pierwszy.

Sprawdź trzy rzeczy

  1. Dodaj kilkadziesiąt piłek i zmień rozmiar okna — piłki dopasują się do nowych krawędzi.
  2. Usuń Panel.ZIndex="100" z napisu „PAUZA” i wstrzymaj grę nad piłką. Napis będzie pod piłkami.
  3. Usuń z Plotno_MouseLeftButtonDown dwie linie ustawiające pilka.X i pilka.Y. Piłki pojawią się w lewym górnym rogu i… nie ruszą się, bo Canvas.GetLeft zwraca NaN.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementTypCo robi
Canvas.Left, Canvas.TopdoubleWłaściwości dołączone — pozycja lewego górnego rogu kontrolki.
Canvas.Right, Canvas.BottomdoubleOdległość od prawej / dolnej krawędzi płótna.
Canvas.GetLeft(k), SetLeft(k, x), GetTop, SetTopmetodyOdczyt i zmiana pozycji z kodu; brak pozycji → NaN.
Panel.ZIndex, Panel.SetZIndex(k, n)intKolejność rysowania — większa liczba na wierzchu.
ClipToBoundsboolObcina to, co wychodzi poza panel.
FrameworkElementklasaWspólna baza kontrolek z Width, Height, Margin.
get => …; set => …;właściwośćWłaściwość, której odczyt i zapis wykonują kod.
Math.Max(a, Math.Min(x, b))doublePrzycięcie x do przedziału a…b.
CZĘSTE BŁĘDY

Zanim utkniesz

SytuacjaProblem
kontrolka „nie rusza się”Brak początkowego Canvas.Left/Top — GetLeft zwraca NaN.
Left="40" bez przedrostkaBłąd XAML — potrzebne Canvas.Left.
ramka na płótnie bez Width/HeightMa rozmiar zawartości — pusta jest niewidoczna.
płótno bez tłaKliknięcia w puste miejsce nie są zgłaszane.
krawędź liczona jako x > Plotno.ActualWidthObiekt wychodzi poza płótno o całą swoją szerokość. Odejmij szerokość obiektu.
usuwanie z listy w pętli foreach bez breakInvalidOperationException: kolekcja została zmieniona.
Plotno.Width płótna rozciąganego z oknemNaN — rozmiar nie był ustawiony. Użyj ActualWidth.
ZADANIA

Zadania

Każde zadanie wykonaj w nowym projekcie Aplikacja WPF. Obiekty na płótnie zrób z ramek, napisów i obrazów. Wskazówkę otwórz dopiero wtedy, gdy utkniesz.

ZAD 1Do mety!★☆☆

Płótno 400 × 260 z postacią (niebieskie kółko 30 × 30) w lewym górnym rogu i metą (złoty kwadrat) w prawym dolnym. Strzałki przesuwają postać o 10 pikseli, a z Shift — o 30. Postać nie wychodzi poza płótno. Pod płótnem pozycja „x = 120, y = 40” i liczba ruchów. Po dotarciu do mety: „Meta w 34 ruchach!”, meta przenosi się w losowe miejsce, a licznik się zeruje.

pokaż wskazówkę
  • Dotarcie do mety to kolizja dwóch prostokątów — skorzystaj z metody Koliduja.
ZAD 2Tablica z naklejkami★☆☆

Kliknięcie w płótno przykleja w tym miejscu naklejkę — wybrany z listy symbol (★ ♥ ☀ ☂ ✿ ♫) w wybranym kolorze, wyśrodkowany pod kursorem. Kółko myszy nad naklejką ją powiększa lub zmniejsza (rozmiar czcionki 16–96), prawy przycisk usuwa, a dwuklik przenosi na wierzch. Pod płótnem: „Naklejek: 7”.

pokaż wskazówkę
  • Rozmiar napisu znasz dopiero po ułożeniu — do środkowania użyj wymiarów przybliżonych (np. rozmiar czcionki) albo ActualWidth po dodaniu.
  • „Na wierzch”: pole z rosnącym licznikiem i Panel.SetZIndex(naklejka, ++licznik).
ZAD 3Przeciąganie kafelków★★☆

Na płótnie pięć kolorowych kafelków z nazwami mebli (biurko, krzesło, szafa, łóżko, regał) — plan pokoju. Kafelek można przeciągnąć myszą: wciśnięcie lewego przycisku chwyta go, ruch przesuwa, puszczenie odkłada. Po odłożeniu pozycja „przyskakuje” do siatki co 20 pikseli. Przeciągany kafelek jest na wierzchu i półprzezroczysty. Kafelek nie może wyjść poza płótno.

pokaż wskazówkę
  • Przy chwyceniu zapamiętaj przesunięcie kursora względem lewego górnego rogu kafelka — inaczej kafelek „skoczy” rogiem pod kursor.
  • kafel.CaptureMouse() sprawia, że kafelek dostaje ruch myszy nawet przy szybkim ruchu poza nim; w MouseLeftButtonUp wywołaj ReleaseMouseCapture().
  • Przyskok: Math.Round(x / 20) * 20.
ZAD 4Strzelnica★★☆

Gra na 30 sekund: okrągły cel pojawia się w losowym miejscu płótna i co 1,2 s przeskakuje gdzie indziej. Trafienie (kliknięcie celu) daje punkt i natychmiastowy przeskok; kliknięcie obok to pudło. Co 5 trafień cel robi się mniejszy, a przeskakuje szybciej. Nad płótnem: punkty, pudła, celność w procentach i pozostały czas. Kursor nad płótnem to celownik.

pokaż wskazówkę
  • Kliknięcie celu „przechodzi” też do płótna. W metodzie celu ustaw e.Handled = true, żeby płótno nie policzyło pudła.
  • Dwa zegary: jeden do przeskoków celu, drugi (albo różnica DateTime) do czasu gry.
ZAD 5Berek dla dwóch graczy★★☆

Dwóch graczy na jednym płótnie: czerwony sterowany W A S D, niebieski — strzałkami. Jeden goni („berek” — z napisem B na kółku). Gdy goniący dotknie uciekającego, role się zamieniają, uciekający dostaje 2 sekundy ochrony, a goniący punkt. Gracze ruszają się płynnie, dopóki klawisz jest wciśnięty — także obaj naraz. Gra trwa 60 sekund.

pokaż wskazówkę
  • Płynny ruch obu graczy: KeyDown i KeyUp ustawiają pola bool (np. wGore), a zegar co 20 ms przesuwa graczy według tych pól (lekcja 26).
ZAD 6Pong★★★

Klasyczny Pong dla dwóch graczy: lewa paletka na W/S, prawa na strzałkach, piłka odbija się od górnej i dolnej krawędzi oraz od paletek. Piłka, która minie paletkę, daje punkt przeciwnikowi i wraca na środek z losowym kierunkiem. Po każdym odbiciu od paletki piłka przyspiesza o 5%, a kąt odbicia zależy od miejsca uderzenia w paletkę. Gra do 7 punktów; Spacja — start i pauza.

pokaż wskazówkę
  • Kąt: zmierz, jak daleko od środka paletki uderzyła piłka (od −1 do 1), i ustaw Vy proporcjonalnie.
  • Po odbiciu od paletki przesuń piłkę tuż przed paletkę — inaczej w następnym tyknięciu znów wykryjesz kolizję i piłka „utknie”.
PODSUMOWANIE

Co trzeba zapamiętać

  • Canvas ustawia kontrolki według współrzędnych: Canvas.Left, Canvas.Top, od lewego górnego rogu, y w dół.
  • Z kodu: Canvas.GetLeft/SetLeft; bez ustawionej pozycji GetLeft daje NaN.
  • Panel.ZIndex — kto na wierzchu; ClipToBounds — obcinanie do płótna.
  • Krawędzie pilnujesz sam: od rozmiaru płótna (ActualWidth) odejmij rozmiar obiektu.
  • Kolizja dwóch prostokątów: nachodzą w poziomie i w pionie.

Dokumentacja: Microsoft Learn — klasa Canvas.

Postęp zapisuje się w tej przeglądarce.