Amator robi grę w 258 liniach kodu.

https://xpil.eu/k8s

Normalni ludzie w czasie urlopu świątecznego jadą na urlop. Albo, nie wiem, grają w planszówki, robią sobie maraton filmowy, pierogi czy co tam jeszcze. A ja, durny, zainteresowałem się zagadnieniem wykrywania kolizji w symulacjach komputerowych i w efekcie, trochę niechcący, wyszła mi z tego gra.

Co prawda na samym końcu zabrakło mi rozpędu i grę pozostawiłem nieco rozgrzebaną, ale... da się w nią zagrać. Moje dzieciaki łoiły w nią dobre pół godziny zanim im się znudziło.

Opowiem dziś krok po kroku jak do tego doszło.

Przed ostatnich kilka tygodni wrzuciłem sobie na tapet - całkiem bez powodu - zagadnienie symulowania systemów złożonych z wielu cząstek. Obejrzałem trochę filmików na Tyrurce, dowiedziałem się o istnieniu zwierzęcia zwanego telesacją teselacją, próbowałem też ogarnąć kwestie sprężystości, współczynników tarcia i im podobnych, ale tu akurat poległem srodze na różniczkach. Niby człowiek przerabiał te zagadnienia na studiach, ale panie, kiedy to było...

A potem stwierdziłem, że skoro nie dam rady napisać symulacji szarpanej zimową bryzą flagi San Escobar, to spróbuję chociaż zrobić zderzenie dwóch idealnie sprężystych kulek.

Najsampierw trzeba było zdecydować się na bibliotekę wyświetlającą cosie na ekranie. Opisywana niedawno TaiChi brzmi atrackcyjnie, ale wystraszyłem się, że znów nie wykombinuję jak to draństwo przeskalować (domyślnie w TaiChi poruszamy się w obszarze o wymiarach 1 x 1 i trzeba wszystko przeliczać na całkiem małe ułamki) i zamiast tego połasiłem się na PyGame. Nigdy wcześniej tej biblioteki nie używałem, ale ponieważ jest popularna i ma dobrą dokumentację...

Zacząłem od utworzenia obszaru roboczego:

import pygame

pygame.init()
window_size = (500, 500)
screen = pygame.display.set_mode(window_size)

running = True
while running:
    pygame.display.flip()
    pygame.time.delay(100)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

Tutaj wydarza się całkiem sporo różnych różności. Najpierw inicjalizujemy bibliotekę pygame, potem ustalamy sobie rozmiar okna na 500x500 pikseli, wreszcie w nieskończonej pętli wyświetlamy zawartość okna, czekamy 100 milisekund i na koniec sprawdzamy, czy użytkownik nie zamknął okienka iksem. Efekt jest o, taki:

Szału nie ma, ale od czegoś trzeba zacząć, nieprawdaż.

Na uwagę zasługuje linia numer 9, czyli pygame.display.flip() - otóż pod maską pygame trzyma dwa "ekrany" i przełącza się niędzy nimi metodą flip(). Ogólnie logika jest taka, że jeżeli chcemy dokonać zmiany na ekranie, to po prostu używamy komend rysujących różne dynksy i wichajstry, a na koniec wołamy flip() i następuje podmianka jednego ekranu na drugi. Coś jakby mieć tablicę ścieralną na obrotowej ośce i w czasie kiedy widownia podziwia jedną stronę, my smarujemy coś na drugiej. Różnica jest taka, że obrócenie tablicy o 180 stopni odbywa się w pygame niezauważalnie szybko.

Ponadto warto zauważyć pętlę zaczynającą się od: for event in pygame.event.get() - tutaj obsługujemy różne zdarzenia, takie jak naciśnięcia klawiszy, kliknięcia przycisków myszy, ruch kursora i tak dalej.

Kolejnym krokiem było wypełnienie tego czarnego kwadratu białym kolorem, bo teraz Święta, zima, śnieg, wiadomo. Co prawda śniegu u nas w tym roku było jak na lekarstwo i już się dawno roztopił, no ale.

import pygame

pygame.init()
window_size = (500, 500)
screen = pygame.display.set_mode(window_size)
screen.fill((255, 255, 255))  # white

running = True
while running:
    pygame.display.flip()
    pygame.time.delay(100)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

Jedyna różnica między tym skryptem a poprzednim to szósta linijka: screen.fill((255, 255, 255)) czyli właśnie wypełnienie całego obszaru roboczego zadanym kolorem. Kolor podajemy w postaci trzech składowych RGB a więc (0, 0, 0) to całkiem czarny, (255, 255, 255) - biały, a na ten przykład (0, 255, 0) to zielony.

Efekt:

Wow. Duży biały kwadrat.

Warto by na nim coś umieścić.

import pygame

pygame.init()
window_size = (500, 500)
screen = pygame.display.set_mode(window_size)
screen.fill((255, 255, 255))  # white

running = True
while running:
    pygame.display.flip()
    pygame.time.delay(100)
    pygame.draw.circle(screen, (255, 0, 0), (50, 50), 30)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

W linijce numer 12 rysujemy czerwone kółko, o środku w punkcie (50, 50) oraz o promieniu 30.

Zaiste. Kółko.

Przy okazji zauważamy, że układ współrzędnych w PyGame ma oś y skierowaną w dół, a nie w górę. Zwiększanie współrzędnej y będzie przesuwać obiekt pionowo w dół:

import pygame

pygame.init()
window_size = (500, 500)
screen = pygame.display.set_mode(window_size)

running = True
x, y = 50, 50
while running:
    screen.fill((255, 255, 255))  # white
    pygame.draw.circle(screen, (255, 0, 0), (x, y), 30)
    pygame.display.flip()
    pygame.time.delay(100)
    y += 1
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

Efekt:

Hura. Mamy pierwszy ruchomy obrazek 🙂

Teraz zrobimy sobie chwilę przerwy od rysowania czerwonych kółek na białym tle i zajmiemy się... wektorami. Czemu tak? Otóż okazuje się, że będziemy potrzebować całkiem pokaźnej porcji matematyki do późniejszego wyliczania trajektorii obiektów w naszej grze. Jest to matematyka wektorowa, dlatego dodamy sobie teraz do naszego skryptu całkiem prosty kawałek kodu umożliwiający operacje na wektorach. Utworzymy sobie w tym celu klasę Vector. Najpierw szkielet:

class Vector:
    def __init__(self, x: float, y: float):
        pass

    def __add__(self, vector: 'Vector'):
        pass

    def __sub__(self, vector: 'Vector'):
        pass

    def __mul__(self, scalar):
        pass

    def __truediv__(self, scalar):
        pass

    def dotProduct(self, vector: 'Vector'):
        pass

    @property
    def magnitude(self):
        pass

    @property
    def direction(self):
        pass

Pisząc klasę w Pythonie (a tak naprawdę w dowolnym języku umożliwiającym OOP) warto zacząć od "szkieletu", czyli ustalenia nazwy samej klasy (tutaj: Vector) oraz nazw jej składowych (metody, pola itd), ale bez implementacji szczegółów. Dzięki temu możemy na dzień dobry "ogarnąć" całość klasy "z lotu ptaka" bez wchodzenia w detale. Dopiero w drugim kroku zaczynamy implementować poszczególne składowe, czyli:

class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    def __add__(self, vector: 'Vector'):
        return Vector(self.x + vector.x, self.y + vector.y)

    def __sub__(self, vector: 'Vector'):
        return Vector(self.x - vector.x, self.y - vector.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __truediv__(self, scalar):
        return Vector(self.x / scalar, self.y / scalar)

    def dotProduct(self, vector: 'Vector'):
        return self.x * vector.x + self.y * vector.y

    @property
    def magnitude(self):
        return math.sqrt(self.x ** 2 + self.y ** 2)

    @property
    def direction(self):
        return math.atan2(self.x, self.y)

__init__ to konstruktor (czyli kod, który wykona się jednorazowo przy tworzeniu obiektu danej klasy), natomiast _add__, __sub__, __mul__ oraz __truediv__ to funkcje, które będą się wykonywać automatycznie kiedy użyjemy jednego z czterech podstawowych operatorów arytmetycznych czyli +, -, * oraz /. Warto jeszcze zauważyć, że w poprzedniej wersji Pythona (2.x) operator dzielenia był obsługiwany metodą __div__, ale w 3.x zmieniono to na __truediv__, aby umożliwić zaimplementowanie zarówno operatora / ("zwykłe" dzielenie) jak też // (dzielenie całkowitoliczbowe). Tak więc __truediv__ implementuje operator dzielenia, a (tutaj niepotrzebny) __floordiv__ - dzielenia całkowitoliczbowego.

Warto zauważyć jeszcze jedną ciekawostkę: definiując metodę, dajmy na to, __add__ (dodawanie) potrzebujemy dwóch wektorów. Jednym z nich jest wektor bieżący, a drugim - jakiś inny (tutaj nazywamy go po prostu vector choć niektóre szkoły sugerują nazwę other dla odróżnienia od self). Python jest językiem, który nie sprawdza typów danych na etapie kompilacji kodu, ale dobrą praktyką jest mimo wszystko dodawać do parametrów funkcji typy danych; raz, dla własnej higieny psychicznej, dwa - bo wtedy edytor kodu będzie nam później ładnie podpowiadał to i owo. Normalnie (tzn. poza klasą) parametr typu Vector oznaczylibyśmy tak: v: Vector. Ale tutaj klasa Vector jeszcze nie istnieje, bo dopiero ją tworzymy, więc próba takiego użycia parametru skończy się komunikatem błędu. Na szczęście Python dopuszcza w takiej sytuacji podanie nazwy klasy jako łańcucha tekstowego: v: 'Vector'.

Dodatkowo tworzymy sobie jeszcze metodę dotProduct (iloczyn skalarny wektorów) oraz dwa pola wyliczane (ozdobione za pomocą @property) czyli magnitude (długość wektora) oraz direction (kierunek wektora, czyli kąt - w radianach).

No ale samymi wektorami to my się tutaj nie najemy, trzeba te wektory do czegoś poprzyczepiać żeby nabrały chociaż odrobiny sensu. Na początku pokazałem jak zrobić kulkę (a tak naprawdę kółko) na ekranie - a więc logicznym kolejnym krokiem jest stworzenie klasy reprezentującej taka kulkę. Klasę nazwę Particle (dosł. "cząsteczka"):

from typing import Tuple

class Particle:
    def __init__(self, pos_x: float, pos_y: float, vel_x: float, vel_y: float, radius: float, colour: Tuple[int, int, int]):
        self.x = pos_x
        self.y = pos_y
        self.radius = radius
        self.colour = colour
        self.velocity = Vector(vel_x, vel_y)

Kulka ma swoją pozycję i prędkość, a także promień oraz kolor. Kolor jest trójką liczb całkowitych (RGB), a ponieważ nie planujemy zmiany koloru kulki, typ danych ustalamy na Tuple[int, int, int].

Co taka kulka może robić?

Może powstać (to już mamy załatwione), może się też poruszać. W tym celu tworzymy nową metodę move:

damping = 0.005

class Particle:
    def __init__(self, pos_x: float, pos_y: float, vel_x: float, vel_y: float, radius: float, colour: Tuple[int, int, int]):
        self.x = pos_x
        self.y = pos_y
        self.velocity = Vector(vel_x, vel_y)
        self.radius = radius
        self.colour = colour

    def move(self, dt: float):
        if (self.velocity.magnitude == 0):
            return

        if (self.velocity.magnitude <= 1):
            self.velocity = Vector(0, 0)
            return

        self.velocity *= (1-damping)
        moveVector = self.velocity * dt
        self.x += moveVector.x
        self.y += moveVector.y

        if (self.x + self.radius >= window_size[0] or self.x-self.radius <= 0):
            self.velocity.x *= -1
            self.x += self.velocity.x * dt

        if (self.y+self.radius >= window_size[1] or self.y-self.radius <= 0):
            self.velocity.y *= -1
            self.y += self.velocity.y * dt

Tu wydarza się całkiem sporo rzeczy:

  • Jeżeli prędkość kulki jest zero, nie robimy nic,
  • Jeżeli prędkość kulki jest nie większa od jeden, ustalamy prędkość na zero (i też nie robimy nic). Takie zachowanie pozwoli nam uniknąć sytuacji, że musimy niepotrzebnie długo czekać na zatrzymanie się kulki, która toczy się już bardzo wolno. Nie ma to wprawdzie nic wspólnego ze światem rzeczywistym, ale nie musi. Zasadniczo zamiast jedynki powinienem użyć tutaj jakiegoś bardziej dynamicznego parametru, ale mi się już nie chciało.
  • Jeżeli prędkość kulki jest większa od jeden:
    • zwalniamy kulkę odrobinę (parametr damping ustawiony chwilowo na 0.005 - można go zwiększyć dla lepszego hamowania, albo zmniejszyć dla lepszego poślizgu),
    • potem przesuwamy ją o wektor prędkości przemnożony przez kwant czasu.
    • wreszcie sprawdzamy, czy kulka nie dotarła do bandy i jeżeli tak, odbijamy ją (i przesuwamy znów o kwant czasu). Niestety oznacza to, że kulka w momencie odbicia od bandy pokona w kwancie czasu drogę, jaką "normalnie" pokonałaby w dwóch, ale już mi się nie chciało kombinować.

Póki co bawimy się tylko zmienianiem położenia kulki; dobrze byłoby ją też dodatkowo narysować, bo inaczej nic nie będzie widać...

class Particle:
# (...)

    def draw(self, screen):
        pygame.draw.circle(
            screen, (0, 0, 0), (self.x, self.y), self.radius, 3)
        pygame.draw.circle(
            screen, self.colour, (self.x, self.y), self.radius - 3)

Kulkę będziemy rysować jako czarne (czyli (0,0,0)) koło o grubości 3 pikseli i promieniu zdefiniowanym w polu radius, wypełnione w środku kolorem colour.

Sprawdźmy czy to w ogóle działa:

import pygame
import math
from typing import Tuple

class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    def __add__(self, vector: 'Vector'):
        return Vector(self.x + vector.x, self.y + vector.y)

    def __sub__(self, vector: 'Vector'):
        return Vector(self.x - vector.x, self.y - vector.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __truediv__(self, scalar):
        return Vector(self.x / scalar, self.y / scalar)

    def dotProduct(self, vector: 'Vector'):
        return self.x * vector.x + self.y * vector.y

    @property
    def magnitude(self):
        return math.sqrt(self.x ** 2 + self.y ** 2)

    @property
    def direction(self):
        return math.atan2(self.x, self.y)

damping = 0.005

class Particle:
    def __init__(self, pos_x: float, pos_y: float, vel_x: float, vel_y: float, radius: float, colour: Tuple[int, int, int]):
        self.x = pos_x
        self.y = pos_y
        self.radius = radius
        self.colour = colour
        self.velocity = Vector(vel_x, vel_y)

    def move(self, dt: float):
        if (self.velocity.magnitude == 0):
            return

        if (self.velocity.magnitude <= 1):
            self.velocity = Vector(0, 0)
            return

        self.velocity *= (1-damping)
        moveVector = self.velocity * dt
        self.x += moveVector.x
        self.y += moveVector.y

        if (self.x + self.radius >= window_size[0] or self.x-self.radius <= 0):
            self.velocity.x *= -1
            self.x += self.velocity.x * dt

        if (self.y+self.radius >= window_size[1] or self.y-self.radius <= 0):
            self.velocity.y *= -1
            self.y += self.velocity.y * dt

    def draw(self, screen):
        pygame.draw.circle(
            screen, (0, 0, 0), (self.x, self.y), self.radius, 3)
        pygame.draw.circle(
            screen, self.colour, (self.x, self.y), self.radius - 3)        

pygame.init()
window_size = (200, 86)
screen = pygame.display.set_mode(window_size)
running = True
p = Particle(50,50, 20, 20, 10, (0, 255,0))
screen.fill((255, 255, 255))  # white
p.draw(screen)
pygame.display.flip()
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

Od tej pory nie będę już wstawiał tutaj animacji, które się pojawiają - za dużo kombinowania, nie chce mi się. Można mi zaufać, albo można sobie uruchomić kod samemu...

Powyższy kawałek kodu faktycznie tworzy prościutką animację odbijającej się w niedużym prostokącie zielonej kulki.

Teraz zacznie się najsmakowitszy kawałek, czyli spróbujemy dodać drugą kulkę i spowodować, żeby obydwie odbijały się nie tylko od brzegów "planszy", ale też od siebie samych. W tym celu musimy wprowadzić trochę matematyki w celu (A) wykrycia, że kulki się zderzyły oraz (B) ustalenia w jakich kierunkach oraz z jakimi prędkościami powinny poturlać się po odbiciu.

Zanim jednak zabierzemy się za odbijanie kulek, wprowadźmy najpierw masę każdej kulki. Przyjmijmy dla hecy, że kulki są trójwymiarowe (tylko widzimy je w 2D), a masa jest proporcjonalna do objętości, czyli \( M = \frac{4}{3} \pi r^3\). A więc do klasy Particle dodajemy jeszcze:

class Particle:
# (...)
    @property
    def mass(self):
        return 4/3*math.pi*self.radius**3

Uzbrojeni w masę oraz definicję wektora wraz ze wszystkimi potrzebnymi działaniami, możemy teraz zabrać się za samo gęste czyli liczenie wektora kolizji (inaczej mówiąc: w których kierunkach i z jakimi prędkościami kulki się od siebie odbiją):

class Particle:
# (...)
    def collision_vector(self, other: 'Particle'):
        position_diff: Vector = Vector(self.x-other.x, self.y-other.y)
        velocity_diff: Vector = self.velocity - other.velocity
        return self.velocity - position_diff * velocity_diff.dotProduct(position_diff) / position_diff.magnitude ** 2 * (2 * other.mass / (self.mass + other.mass))

Jeżeli miałbym teraz odtworzyć cały proces myślowy, który doprowadził mnie do powyższego równania, pewnie bym poległ. Podpowiem tylko, że trzeba wystartować od tego artykułu na Wikipedii.

Metoda collision_vector zwraca nowy wektor prędkości po odbiciu się od innej cząstki. Zauważmy, że proces jest jednostronny tzn. wołamy metodę dla kulki bieżącej i dostajemy w wyniku informację o nowym wektorze po odbiciu dla tej jednej kulki. Nie wiemy póki co nic o tym, jak zachowa się druga kulka, a więc domyślamy się, że metodę collision_vector trzeba będzie dla każdej wykrytej kolizji zawołać dwukrotnie, po jednym razie dla każdej kulki.

No właśnie. Wykrywanie kolizji. Powyższa metoda nie sprawdza, czy te dwie kulki się właśnie zderzają czy nie. Ona mówi wyłącznie jak będzie się poruszać kulka po odbiciu od innej kulki. Żeby sprawdzić, czy dwie kulki właśnie się od siebie odbijają, potrzebujemy kolejnej metody. Nazwiemy ją resolve_collision (dosł. "rozwiąż kolizję") i zbudujemy ją w ten sposób, że będzie ona zwracać True jeżeli kolizja nastąpiła lub False jeżeli nie. W przypadku kolizji zmienimy tu również trajektorię, zarówno własną jak też drugiej kulki, zgodnie z równaniem przedstawionym w ciele metody collision_vector.

Dygresja

Zanim się zabierzemy za wykrywanie kolizji, oddalmy się na chwilę na siedem metrów od ekranu w celu chwilowego spojrzenia na problem z szerszej perspektywy. Nasz ogólny algorytm wykrywania kolizji będzie działał następująco: dla każdej pary kulek, sprawdź czy właśnie nastąpiła kolizja. Jeżeli tak, zmodyfikuj odpowiednio trasy i prędkości obydwu kulek. Jeżeli nie, nic nie rób. Jak można się łatwo domyśleć, jest to algorytm dość głupawy; dla dwóch kulek mamy tylko jedną parę do sprawdzenia. Dla dziesięciu - 45 par. Dla stu kulek - prawie pięć tysięcy par. I tak dalej. Liczba par rośnie, niestety, z kwadratem liczby kulek, a więc algorytm powyżej pewnej liczby zacznie się dławić. Są oczywiście sposoby na obejście tego limitu; najpopularniejszy to podzielenie planszy na niewielkie wirtualne kwadraty i sprawdzanie kolizji wyłącznie z kulkami znajdującymi się w naszym kwadracie lub kwadratach sąsiadujących, dzięki czemu czas wyliczenia wszystkich kolizji skraca się znacząco nawet dla bardzo dużych liczb kulek. Ale to już odrobinę za wysokie progi na nasze głupiątko, więc pozostaniemy przy algorytmie naiwnym.

Jeszcze jedna dygresja

Może się zdarzyć tak, że w tym samym kwancie czasu niektóre kulki będą wchodzić w więcej niż jedną kolizję. Nasz algorytm tego w pełni nie uwzględnia, więc przy zbyt "ciasnym" układzie kulek (za mała plansza, za duże kulki, za dużo kulek itd) może się okazać, że wystąpią błędy - na przykład całkiem znienacka jedna kulka "sklei się" z inną albo wręcz "wpadnie" do wnętrza innej kulki i robi się dziwnie. Nie będziemy się takim wariantem teraz martwić - po prostu zadbamy o to, żeby kulki miały wystarczająco dużo miejsca, a także będziemy używać bardzo małego kwantu czasu - dzięki temu te "efekty uboczne" będa pojawiać się bardzo rzadko albo i wcale.

Koniec dygresji (póki co).

Aby sprawdzić, czy dwie kulki właśnie weszły w kolizję, trzeba porównać ich wzajemną odległość z sumą ich promieni.

class Particle:
# (...)
    def resolve_collision(self, other: 'Particle'):
        distance = ((self.x - other.x)**2 +
                    (self.y - other.y)**2)**0.5
        overlap = (self.radius + other.radius) - distance
        if (overlap < 0):
            return

        overlap_proportion = overlap / distance
        self.move(-dt * overlap_proportion/2)
        other.move(-dt * overlap_proportion/2)

        cv1 = self.collision_vector(other)
        cv2 = other.collision_vector(self)
        self.velocity = cv1
        other.velocity = cv2
        self.move(dt*(1-overlap_proportion/2))
        other.move(dt*(1-overlap_proportion/2))

Tutaj znów wydarza się kilka całkiem interesujących rzeczy. Najpierw mierzymy odległość między kulkami (Pitagorasem) i zapisujemy ją w zmiennej distance. Potem sprawdzamy, czy kulki na siebie "nachodzą" (odejmując odległość od sumy promieni). Jeżeli nie, to znaczy że nie ma kolizji i nie mamy nic do roboty. Jeżeli natomiast kulki na siebie "nachodzą" (co jest w świecie fizycznym niemożliwe, przynajmniej jeżeli kulki nie są zrobione z plasteliny), to musimy wprowadzić stosowne korekty.

Najpierw wyliczamy jaki ułamek odległości między kulkami stanowi część wspólna, i zapisujemy ten ułamek w zmiennej overlap_proportion. Poniższy rysunek obrazuje o co chodzi:

Chodzi o to jaką część sumy promieni obydwu cząstek stanowi czerwony odcinek. Czym więcej, tym większej korekty potrzebujemy.

Miejmy na uwadze, że jeszcze nie narysowaliśmy kulek, a więc na ekranie widać póki co sytuację z chwili (t - Δt). Powyższy rysunek pokazuje układ kulek w chwili t, ale użytkownik widzi kulki przed zderzeniem.

Ponieważ w rzeczywistym świecie kulki nie przeniknęłyby się (zakładamy że są idealnie sztywne, czyli niesprężyste), trzeba "cofnąć zegar" o ułamek kwantu czasu, do momentu, w którym jeszcze się nie przenikały. Robimy to w liniach 11 i 12 (proszę zwrócić uwagę na minus przed dt). A więc po wykonaniu linii 11 i 12 kulki "cofnęły się w czasie" akurat o tyle, żeby się ze sobą stykać, ale na siebie nie nachodzić.

Dopiero teraz wyznaczamy wektory kolizji obydwu kulek (zmienne cv1 i cv2), modyfikujemy ich wektory prędkości i przesuwamy kulki - już w nowych kierunkach - o odległość wyliczoną na podstawie kwantu czasu *pomniejszonego* o tą samą korektę, którą zastosowaliśmy przy cofaniu czasu. Dzięki temu nie robimy żadnych "cudów", energia zostaje zachowana a kulki podążają nowymi trajektoriami.

Cały skrypt wygląda w tej chwili o, tak:

import pygame
import math
from typing import Tuple

class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    def __add__(self, vector: 'Vector'):
        return Vector(self.x + vector.x, self.y + vector.y)

    def __sub__(self, vector: 'Vector'):
        return Vector(self.x - vector.x, self.y - vector.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __truediv__(self, scalar):
        return Vector(self.x / scalar, self.y / scalar)

    def dotProduct(self, vector: 'Vector'):
        return self.x * vector.x + self.y * vector.y

    @property
    def magnitude(self):
        return math.sqrt(self.x ** 2 + self.y ** 2)

    @property
    def direction(self):
        return math.atan2(self.x, self.y)

damping = 0.0005

class Particle:
    def __init__(self, pos_x: float, pos_y: float, vel_x: float, vel_y: float, radius: float, colour: Tuple[int, int, int]):
        self.x = pos_x
        self.y = pos_y
        self.radius = radius
        self.colour = colour
        self.velocity = Vector(vel_x, vel_y)

    def move(self, dt: float):
        if (self.velocity.magnitude == 0):
            return

        if (self.velocity.magnitude <= 1):
            self.velocity = Vector(0, 0)
            return

        self.velocity *= (1-damping)
        moveVector = self.velocity * dt
        self.x += moveVector.x
        self.y += moveVector.y

        if (self.x + self.radius >= window_size[0] or self.x-self.radius <= 0):
            self.velocity.x *= -1
            self.x += self.velocity.x * dt

        if (self.y+self.radius >= window_size[1] or self.y-self.radius <= 0):
            self.velocity.y *= -1
            self.y += self.velocity.y * dt

    @property
    def mass(self):
        return 4/3*math.pi*self.radius**3

    def collision_vector(self, other: 'Particle'):
        position_diff: Vector = Vector(self.x-other.x, self.y-other.y)
        velocity_diff: Vector = self.velocity - other.velocity
        return self.velocity - position_diff * velocity_diff.dotProduct(position_diff) / position_diff.magnitude ** 2 * (2 * other.mass / (self.mass + other.mass))

    def resolve_collision(self, other: 'Particle'):
        distance = ((self.x - other.x)**2 +
                    (self.y - other.y)**2)**0.5
        overlap = (self.radius + other.radius) - distance
        if (overlap < 0):
            return

        overlap_proportion = overlap / distance
        self.move(-dt * overlap_proportion)
        other.move(-dt * overlap_proportion)

        cv1 = self.collision_vector(other)
        cv2 = other.collision_vector(self)
        self.velocity = cv1
        other.velocity = cv2
        self.move(dt*(1-overlap_proportion))
        other.move(dt*(1-overlap_proportion))

    def draw(self, screen):
        pygame.draw.circle(
            screen, (0, 0, 0), (self.x, self.y), self.radius, 3)
        pygame.draw.circle(
            screen, self.colour, (self.x, self.y), self.radius - 3)        

pygame.init()
window_size = (700, 500)
screen = pygame.display.set_mode(window_size)
running = True
p1 = Particle(150,150, 60, 20, 50, (0, 255,0))
p2 = Particle(250,250, 20, -40, 50, (255, 0, 0))
screen.fill((255, 255, 255))
p1.draw(screen)
p2.draw(screen)
pygame.display.flip()
dt = 0.05
while running:
    screen.fill((255, 255, 255))
    p1.move(dt)
    p2.move(dt)
    p1.resolve_collision(p2)
    p1.draw(screen)
    p2.draw(screen)
    pygame.time.delay(10)
    pygame.display.flip()
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

W efekcie dostajemy dwie kulki odbijające się od krawędzi okna a także od siebie nawzajem. Przy okazji zauważamy, że w linii 111 wywołujemy metodę resolve_collision kulki p1 (z parametrem p2), ale nigdy nie robimy tego samego w drugą stronę (a więc nigdzie nie ma p2.resolve_collision(p1)). Dzieje się tak, ponieważ metoda resolve_collision "załatwia sprawę" dla obydwu kulek, a nie tylko jednej. Warto o tym pamiętać później, kiedy kulek zrobi się więcej.

No to skoro chcemy więcej kulek, zróbmy więcej kulek!

import pygame
import math
import random
from typing import Tuple

class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    def __add__(self, vector: 'Vector'):
        return Vector(self.x + vector.x, self.y + vector.y)

    def __sub__(self, vector: 'Vector'):
        return Vector(self.x - vector.x, self.y - vector.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __truediv__(self, scalar):
        return Vector(self.x / scalar, self.y / scalar)

    def dotProduct(self, vector: 'Vector'):
        return self.x * vector.x + self.y * vector.y

    @property
    def magnitude(self):
        return math.sqrt(self.x ** 2 + self.y ** 2)

    @property
    def direction(self):
        return math.atan2(self.x, self.y)

damping = 0
dt = 0.03

class Particle:
    def __init__(self, pos_x: float, pos_y: float, vel_x: float, vel_y: float, radius: float, colour: Tuple[int, int, int]):
        self.x = pos_x
        self.y = pos_y
        self.radius = radius
        self.colour = colour
        self.velocity = Vector(vel_x, vel_y)

    def move(self, dt: float):
        if (self.velocity.magnitude == 0):
            return

        if (self.velocity.magnitude <= 1):
            self.velocity = Vector(0, 0)
            return

        self.velocity *= (1-damping)
        moveVector = self.velocity * dt
        self.x += moveVector.x
        self.y += moveVector.y

        if (self.x + self.radius >= window_size[0] or self.x-self.radius <= 0):
            self.velocity.x *= -1
            self.x += self.velocity.x * dt

        if (self.y+self.radius >= window_size[1] or self.y-self.radius <= 0):
            self.velocity.y *= -1
            self.y += self.velocity.y * dt

    @property
    def mass(self):
        return 4/3*math.pi*self.radius**3

    def collision_vector(self, other: 'Particle'):
        position_diff: Vector = Vector(self.x-other.x, self.y-other.y)
        velocity_diff: Vector = self.velocity - other.velocity
        return self.velocity - position_diff * velocity_diff.dotProduct(position_diff) / position_diff.magnitude ** 2 * (2 * other.mass / (self.mass + other.mass))

    def resolve_collision(self, other: 'Particle'):
        distance = ((self.x - other.x)**2 +
                    (self.y - other.y)**2)**0.5
        overlap = (self.radius + other.radius) - distance
        if (overlap < 0):
            return True


        overlap_proportion = overlap / distance
        self.move(-dt * overlap_proportion)
        other.move(-dt * overlap_proportion)

        cv1 = self.collision_vector(other)
        cv2 = other.collision_vector(self)
        self.velocity = cv1
        other.velocity = cv2
        self.move(dt*(1-overlap_proportion))
        other.move(dt*(1-overlap_proportion))
        return False

    def draw(self, screen):
        pygame.draw.circle(
            screen, (0, 0, 0), (self.x, self.y), self.radius, 3)
        pygame.draw.circle(
            screen, self.colour, (self.x, self.y), self.radius - 3)        

pygame.init()
window_size = (700, 500)
screen = pygame.display.set_mode(window_size)
running = True

colour_red = (255, 0, 0)
colour_green = (0, 255, 0)
colour_blue = (0, 0, 255)
colour_white = (255, 255, 255)
colours = (colour_red, colour_green, colour_blue)


particles: list[Particle] = []
particles_count = 10
radius = 30
for i in range(particles_count):
    particle_added = False
    while not particle_added:

        pos_x = radius+random.random() * (window_size[0] - 2 * radius)
        pos_y = radius+random.random() * (window_size[1] - 2 * radius)
        vel_x = 10
        vel_y = 10
        colour = random.sample(colours, k=1)[0]
        p = Particle(pos_x, pos_y, vel_x, vel_y, radius, colour)
        collision_counter = 0
        for j in range(i):
            if (not p.resolve_collision(particles[j])):
                collision_counter += 1
        if (collision_counter == 0):
            particles.append(p)
            particle_added = True

while running:
    screen.fill((255, 255, 255))  # white

    for p in particles:
        p.move(dt)

    for p1i in range(len(particles)):
        for p2i in range(p1i+1, len(particles)):
            particles[p1i].resolve_collision(particles[p2i])
    
    for p in particles:
        p.draw(screen)

    pygame.display.flip()
    pygame.time.delay(2)
    
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

W liniach 105 - 109 definiujemy sobie kolory (biały, czerwony, niebieski, zielony).

W liniach 115 - 131 generujemy dziesięć kulek o losowych kolorach R, G lub B plus zawsze jedna biała. Pętla while zaczynająca się linii 117 dba o to, żeby kolejno dodana kulka nie była w stanie kolizji z żadną z już dodanych kulek. W tym celu zmodyfikowaliśmy nieco metodę resolve_collision - w liniach 79 i 92 zwraca on a teraz odpowiednio True lub False w zależności od tego czy kulki są w stanie kolizji czy nie.

W linii 133 zaczyna sę główna pętla programu, czyli: pomaluj ekran na biało, przesuń kulki, obsłuż ewentualne kolizje, narysuj kulki, obróć wirtualną "tablicę" na drugą stronę (czyli na nasze: odśwież ekran), poczekaj dwie milisekundy na wszelki wypadek, wreszcie sprawdź czy użytkownik nie kliknął iksa.

Aha, w tej wersji skryptu parametr damping został ustawiony na zero - nasz wirtualny "stół" po którym "toczą" się kulki jest pozbawiony tarcia, animacja będzie trwała w nieskończoność, albo dopóki ktoś nie kliknie iksa. Docelowo damping będzie miał niewielką wartość dodatnią. Ile konkretnie? Trzeba będzie poeksperymentować.

Nie byłbym sobą gdybym nie spróbował na chwilę ustawić damping na wartość ujemną - kulki wówczas cały czas przyspieszają, wreszcie dochodzi do tego, że w jednym kwancie czasu przemieszczają odległość większą niż szerokość planszy i w efekcie "zamarzają".

Generowanie kulek jest czynnością dość przydatną i będziemy jej w docelowej grze używać w co najmniej dwóch miejscach, warto więc zrobić sobie w tym celu osobną funkcję create_particle, która na wejściu dostanie wymiary ekranu oraz aktualne położenie wszystkich kulek, a na wyjściu zwróci kulkę o losowym położeniu i prędkości, która dodatkowo nie jest w stanie kolizji z żadną poprzednią kulką.

Albo nie. Wersja dla leniwych. Ekran jest już w skrypcie (zmienna globalna screen), więc funkcja sobie te wymiary stamtąd odczyta. Kulki też już są (zmienna globalna particles), więc tak naprawdę create_particle nie potrzebuje żadnych parametrów. Niezbyt to eleganckie i w większym projekcie by nie przeszło, ale tutaj - hulaj dusza...

A nawet jeszce lepiej: zamiast zwracać nową kulkę, niech funkcja create_particle po prostu spróbuje dodać ją do zestawu (maksymalnie sto razy) i zwróci informację czy się udało:

def create_particle() -> bool:
    sx, sy = window_size[0], window_size[1]
    pxmin, pymin = radius+1, radius+1
    pxmax, pymax = sx - radius - 1, sy - radius - 1
    vmin, vmax = 0, 20
    colour = random.sample(colours, k=1)[0]
    success = False
    counter = 0
    while not success and counter <= 100:
        counter += 1
        success = True
        px = pxmin + random.random() * (pxmax - pxmin)
        py = pymin + random.random() * (pymax - pymin)
        vx = vmin + random.random() * (vmax - vmin)
        vy = vmin + random.random() * (vmax - vmin)
        p = Particle(px, py, vx, vy, radius, colour)
        for pp in particles:
            if not p.resolve_collision(pp):
                success = False
    if(success):
        particles.append(p)
        return True
    else:
        return False

Tym samym kod generujący kulki, który w poprzedniej wersji skryptu znajdował się między liniami 115 i 131, teraz został zredukowany do zaledwie trzech linii:

for i in range(particles_count):
    if (not create_particle()):
        break

Cały skrypt wygląda (póki co) tak:

import pygame
import math
import random
from typing import Tuple


class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    def __add__(self, vector: 'Vector'):
        return Vector(self.x + vector.x, self.y + vector.y)

    def __sub__(self, vector: 'Vector'):
        return Vector(self.x - vector.x, self.y - vector.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __truediv__(self, scalar):
        return Vector(self.x / scalar, self.y / scalar)

    def dotProduct(self, vector: 'Vector'):
        return self.x * vector.x + self.y * vector.y

    @property
    def magnitude(self):
        return math.sqrt(self.x ** 2 + self.y ** 2)

    @property
    def direction(self):
        return math.atan2(self.x, self.y)


damping = 0
dt = 0.03


class Particle:
    def __init__(self, pos_x: float, pos_y: float, vel_x: float, vel_y: float, radius: float, colour: Tuple[int, int, int]):
        self.x = pos_x
        self.y = pos_y
        self.radius = radius
        self.colour = colour
        self.velocity = Vector(vel_x, vel_y)

    def move(self, dt: float = dt):
        if (self.velocity.magnitude == 0):
            return

        if (self.velocity.magnitude <= 1):
            self.velocity = Vector(0, 0)
            return

        self.velocity *= (1-damping)
        moveVector = self.velocity * dt
        self.x += moveVector.x
        self.y += moveVector.y

        if (self.x + self.radius >= window_size[0] or self.x-self.radius <= 0):
            self.velocity.x *= -1
            self.x += self.velocity.x * dt

        if (self.y+self.radius >= window_size[1] or self.y-self.radius <= 0):
            self.velocity.y *= -1
            self.y += self.velocity.y * dt

    @property
    def mass(self):
        return 4/3*math.pi*self.radius**3

    def collision_vector(self, other: 'Particle'):
        position_diff: Vector = Vector(self.x-other.x, self.y-other.y)
        velocity_diff: Vector = self.velocity - other.velocity
        return self.velocity - position_diff * velocity_diff.dotProduct(position_diff) / position_diff.magnitude ** 2 * (2 * other.mass / (self.mass + other.mass))

    def resolve_collision(self, other: 'Particle'):
        distance = ((self.x - other.x)**2 +
                    (self.y - other.y)**2)**0.5
        overlap = (self.radius + other.radius) - distance
        if (overlap < 0):
            return True

        overlap_proportion = overlap / distance
        self.move(-dt * overlap_proportion)
        other.move(-dt * overlap_proportion)

        cv1 = self.collision_vector(other)
        cv2 = other.collision_vector(self)
        self.velocity = cv1
        other.velocity = cv2
        self.move(dt*(1-overlap_proportion))
        other.move(dt*(1-overlap_proportion))
        return False

    def draw(self, screen):
        pygame.draw.circle(
            screen, (0, 0, 0), (self.x, self.y), self.radius, 3)
        pygame.draw.circle(
            screen, self.colour, (self.x, self.y), self.radius - 3)


pygame.init()
window_size = (700, 500)
screen = pygame.display.set_mode(window_size)
running = True


colour_red = (255, 0, 0)
colour_green = (0, 255, 0)
colour_blue = (0, 0, 255)
colour_white = (255, 255, 255)
colours = (colour_red, colour_green, colour_blue)


particles: list[Particle] = []
particles_count = 20
radius = 30


def create_particle() -> bool:
    sx, sy = window_size[0], window_size[1]
    pxmin, pymin = radius+1, radius+1
    pxmax, pymax = sx - radius - 1, sy - radius - 1
    vmin, vmax = 0, 20
    colour = random.sample(colours, k=1)[0]
    success = False
    counter = 0
    while not success and counter <= 100:
        counter += 1
        success = True
        px = pxmin + random.random() * (pxmax - pxmin)
        py = pymin + random.random() * (pymax - pymin)
        vx = vmin + random.random() * (vmax - vmin)
        vy = vmin + random.random() * (vmax - vmin)
        p = Particle(px, py, vx, vy, radius, colour)
        for pp in particles:
            if not p.resolve_collision(pp):
                success = False
    if(success):
        particles.append(p)
        return True
    else:
        return False

for i in range(particles_count):
    if (not create_particle()):
        break

while running:
    screen.fill(colour_white)

    for p in particles:
        p.move()

    for p1i in range(len(particles)):
        for p2i in range(p1i+1, len(particles)):
            particles[p1i].resolve_collision(particles[p2i])

    for p in particles:
        p.draw(screen)

    pygame.display.flip()
    pygame.time.delay(2)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

Wspomniałem na samym początku, że udało mi się (niechcący) stworzyć grę. Póki co do gry jeszcze daleko - mamy póki co prosty system z odbijającymi się wewnątrz prostokąta kulkami trzech kolorów.

W dodatku system pełen błędów - jeżeli powyższy kod zapuścimy, pójdziemy na kawę i wrócimy po kilku minutach, zobaczymy, że kulki zamiast się ładnie odbijać, posklejały się ze sobą i stoją w miejscu. Ale przymykamy oko.

Aby z tego zrobić grę, trzeba pomysłu. Nie będę ściemniał - pomysł ukradłem z pewnej gry, w którą grałem jeszcze jako nastoletni szczeniak na moim starym Panasonicu CF-170, na czterokolorowym ekranie CGA o szalonej rozdzielczości 320x200 pikseli, pod DOS-em 3.30 (w wersji, żeby było zabawniej, niemieckojęzycznej).

Zdjęcie ukradnięte stąd: https://collection.sciencemuseumgroup.org.uk/objects/co8669708/panasonic-cf-170-notebook-computer-computer

Niestety nie udało mi się znaleźć (ani przypomnieć) nazwy tej konkretnej gry, chociaż spędziłem na Google całkiem sporo czasu próbując. Może ktoś ze starszych Czytelników pamięta? Grało się na dwuwymiarowym, prostokątnym stole, jakby bilardowym, ale bez łuz. Na początku miało się do dyspozycji kilka kulek w trzech kolorach (czerwony R, zielony G oraz niebieski B) oraz jedną białą kulkę, w którą się uderzało. Celem gry było zderzanie kulek w taki sposób, żeby wszystkie (oprócz białej) zniknęły, przy czym reguły były takie, że zderzenie dwóch kulek w tym samym kolorze skutkowało ich zniknięciem, a zderzenie dwóch kulek o różnych kolorach - utworzeniem trzeciej kulki. Grę przegrywało się, jeżeli na stole pojawiło się zbyt dużo kulek.

A może kiedy wykorzystało się limit ruchów? Czasu? No nie pamiętam.

No dobra. Czyli tak: mamy póki co kod, którzy tworzy nam dowolną liczbę kulek o zadanym rozmiarze na prostokątnej planszy a także umożliwia ich wzajemne interakcje. Żeby z tego zrobić grę opisaną powyżej, trzeba:

  1. Dodać na stół jedną białą kulkę.
    • Łatwe
    • Właśnie dlatego nasze kulki mają czarną obwódkę (inaczej biała kulka na białym tle byłaby niewidoczna)
  2. Zmienić kod losujący początkowe położenie kulek tak, żeby wszystkie kulki były na dzień dobry nieruchome.
    • Łatwe
  3. Dodać możliwość "uderzania" białej kulki w zadanym przez gracza kierunku oraz z pożądaną siłą (prędkością)
    • Średnio łatwe
    • Na przykład naciśnięcie lewego przycisku myszy rozpocznie proces celowania, a jego puszczenie - zakończy i wykona "uderzenie"
    • Możliwe wyłącznie kiedy wszystkie kulki są nieruchome
    • Trzeba wykumać jak obsłużyć zdarzenia płynące od myszy
    • Można pomyśleć o dodatkowym wsparciu do celowania, na przykład przedłużyć "kij" w każdą stronę tak, żeby gracz wiedział gdzie potoczy się biała kulka - przynajmniej do pierwszego odbicia
    • Długość "kija" w chwili puszczenia przycisku myszy steruje siłą uderzenia (czym dłuższy tym mocniej).
  4. Dodać logikę "znikającą" kulki po zderzeniu dwóch w tym samym kolorze oraz "rozmnażającą" kulki po zderzeniu dwóch różnych. W oryginalnej grze nowa kulka pojawiała się pomiędzy tymi zderzanymi, "rozpychając" je na boki. Ja z lenistwa zrobię tak, że nowa kulka pojawi się w losowym miejscu (byle tylko nie kolidowała z innymi kulkami w chwili pojawienia się).
    • Średnio łatwe
    • Usunięcie kulki z listy particles pozmienia indeksy pozostałych kulek, trzeba uważać na potencjalne błędy logiczne z tym związane.
  5. Dodać logikę wyświetlającą komunikat o wygranej / przegranej w zależności od liczby kulek.
    • Raczej łatwe
    • Gdzie wyświetlić komunikat? Może na belce tytułowej?

Zaczniemy od dodania jednej białej kulki. Dla ułatwienia przyjmiemy, że będzie to zawsze pierwsza kulka w zestawie particles (czyli particles[0]). Zmiana pojawi się w procedurze create_particle:

# (...)

    if(len(particles)==0):
        colour = colour_white
    else:
        colour = random.sample(colours, k=1)[0]

# (...)

Teraz malutka modyfikacja "unieruchamiająca" kulki na dzień dobry. Również w kodzie tej samej procedury:

Zamiast:

# (...)
        vx = vmin + random.random() * (vmax - vmin)
        vy = vmin + random.random() * (vmax - vmin)
        p = Particle(px, py, vx, vy, radius, colour)
# (...)

... robimy po prostu:

# (...)
        p = Particle(px, py, 0, 0, radius, colour)
# (...)

Kolejnym krokiem jest zaimplementowanie "kija" oraz "uderzania" w białą kulkę.

Zaczniemy od procedury, która rysuje nam "kij":

def draw_cue(screen, cue, particle: Particle):

    startx = cue[0]
    starty = cue[1]
    endx = particle.x
    endy = particle.y
    cue_vector = Vector(endx - startx, endy - starty) * 10
    pygame.draw.line(screen, (0, 0, 0), (startx, starty), (startx+cue_vector.x, starty+cue_vector.y), 2)

Procedura rysuje czarną linię zaczynającą się w miejscu wskazywanym przez mysz, biegnącą przez środek zadanej kuli (w naszym przypadku będzie to zawsze kula biała) oraz ciągnącą się dziesięć długości dalej w celu umożliwienia lepszego celowania.

Teraz spróbujemy to wszystko zebrać w jedną w miarę logiczną, za przeproszeniem, kupę:

import pygame
import math
import random
from typing import Tuple

## GLOBALS ##

damping = 0.001
dt = 0.03
velocity_combined = 0
running = True
pygame.init()
window_size = (700, 500)
screen = pygame.display.set_mode(window_size)
colour_red = (255, 0, 0)
colour_green = (0, 255, 0)
colour_blue = (0, 0, 255)
colour_white = (255, 255, 255)
colours = (colour_red, colour_green, colour_blue)
particles_count = 3
radius = 30


class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    def __add__(self, vector: 'Vector'):
        return Vector(self.x + vector.x, self.y + vector.y)

    def __sub__(self, vector: 'Vector'):
        return Vector(self.x - vector.x, self.y - vector.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __truediv__(self, scalar):
        return Vector(self.x / scalar, self.y / scalar)

    def dotProduct(self, vector: 'Vector'):
        return self.x * vector.x + self.y * vector.y

    @property
    def magnitude(self):
        return math.sqrt(self.x ** 2 + self.y ** 2)

    @property
    def direction(self):
        return math.atan2(self.x, self.y)


class Particle:
    def __init__(self, pos_x: float, pos_y: float, vel_x: float, vel_y: float, radius: float, colour: Tuple[int, int, int]):
        self.x = pos_x
        self.y = pos_y
        self.radius = radius
        self.colour = colour
        self.velocity = Vector(vel_x, vel_y)

    def move(self, dt: float = dt) -> float:
        if (self.velocity.magnitude == 0):
            return 0

        if (self.velocity.magnitude <= 1):
            self.velocity = Vector(0, 0)
            return 0

        self.velocity *= (1-damping)
        moveVector = self.velocity * dt
        self.x += moveVector.x
        self.y += moveVector.y

        if (self.x + self.radius >= window_size[0] or self.x-self.radius <= 0):
            self.velocity.x *= -1
            self.x += self.velocity.x * dt

        if (self.y+self.radius >= window_size[1] or self.y-self.radius <= 0):
            self.velocity.y *= -1
            self.y += self.velocity.y * dt

        return self.velocity.magnitude

    @property
    def mass(self):
        return 4/3*math.pi*self.radius**3

    def collision_vector(self, other: 'Particle'):
        position_diff: Vector = Vector(self.x-other.x, self.y-other.y)
        velocity_diff: Vector = self.velocity - other.velocity
        return self.velocity - position_diff * velocity_diff.dotProduct(position_diff) / position_diff.magnitude ** 2 * (2 * other.mass / (self.mass + other.mass))

    def resolve_collision(self, other: 'Particle'):
        distance = ((self.x - other.x)**2 +
                    (self.y - other.y)**2)**0.5
        overlap = (self.radius + other.radius) - distance
        if (overlap < 0):
            return True

        overlap_proportion = overlap / distance
        self.move(-dt * overlap_proportion)
        other.move(-dt * overlap_proportion)

        cv1 = self.collision_vector(other)
        cv2 = other.collision_vector(self)
        self.velocity = cv1
        other.velocity = cv2
        self.move(dt*(1-overlap_proportion))
        other.move(dt*(1-overlap_proportion))
        return False

    def draw(self, screen):
        pygame.draw.circle(
            screen, (0, 0, 0), (self.x, self.y), self.radius, 3)
        pygame.draw.circle(
            screen, self.colour, (self.x, self.y), self.radius - 3)



def create_particle() -> bool:
    sx, sy = window_size[0], window_size[1]
    pxmin, pymin = radius+1, radius+1
    pxmax, pymax = sx - radius - 1, sy - radius - 1
    vmin, vmax = 0, 20
    if (len(particles) == 0):
        colour = colour_white
    else:
        colour = random.sample(colours, k=1)[0]
    success = False
    counter = 0
    while not success and counter <= 100:
        counter += 1
        success = True
        px = pxmin + random.random() * (pxmax - pxmin)
        py = pymin + random.random() * (pymax - pymin)
        p = Particle(px, py, 0, 0, radius, colour)
        for pp in particles:
            if not p.resolve_collision(pp):
                success = False
    if (success):
        particles.append(p)
        return True
    else:
        return False


def draw_cue(screen, cue, particle: Particle):

    startx = cue[0]
    starty = cue[1]
    endx = particle.x
    endy = particle.y
    cue_vector = Vector(endx - startx, endy - starty) * 10
    pygame.draw.line(screen, (0, 0, 0), (startx, starty), (startx+cue_vector.x, starty+cue_vector.y), 2)

particles: list[Particle] = []

for i in range(particles_count):
    if (not create_particle()):
        break

def process_events():
    global running
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if (event.type == pygame.KEYDOWN and (event.key == pygame.K_ESCAPE or event.key == pygame.K_q)):
            running = False
        


# MAIN LOOP
# play until all particles disappear (except white)
# or player closes the game window
while (running and len(particles) > 1):
    # move balls until they stop
    velocity_combined = 0.1 # fake, for the loop below to kick-in
    while(running and velocity_combined > 0):
        pygame.display.set_caption(f"Wait... ({int(velocity_combined)})")
        velocity_combined = 0

        for p in particles:
            velocity_combined += p.move()

        for p1i in range(len(particles)):
            for p2i in range(p1i+1, len(particles)):
                particles[p1i].resolve_collision(particles[p2i])

        screen.fill(colour_white)
        for p in particles:
            p.draw(screen)

        pygame.display.flip()
        pygame.time.delay(2)
        process_events()

    # wait for player to press left mouse key
    pygame.display.set_caption(f"Hold left mouse button to aim.")
    while (running and not pygame.mouse.get_pressed()[0]):
        process_events()
    
    # wait for player to release left mouse key
    pygame.display.set_caption(f"Aim then release left mouse button.")
    while (running and pygame.mouse.get_pressed()[0]):
        cue = pygame.mouse.get_pos()
        screen.fill(colour_white)
        draw_cue(screen, cue, particles[0])
        for p in particles:
            p.draw(screen)
        pygame.display.flip()
        process_events()

    # shoot white ball
    particles[0].velocity = (Vector(particles[0].x, particles[0].y) - Vector(cue[0], cue[1]))
    velocity_combined = particles[0].velocity.magnitude


    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
    pygame.time.delay(2)


    # let player aim and shoot

W powyższym kodzie nastąpiło całkiem sporo zmian i przeróbek względem tego, co pokazywałem przedtem:

  • Dodałem sekcję ## GLOBALS ze zmiennymi globalnymi (za wyjątkiem zmiennej particles, którą definiuję pod koniec, bo potrzebuję najpierw definicji klasy Particle)
  • Dodałem procedurę process_events, która sprawdza czy użytkownik klinkął iksa ewentualnie nacisnął na klawiaturze Q albo Esc - jeżeli tak, ustawia zmienną running na False dzięki czemu możemy potem szybciutko wyjść z pętli głównej.
  • Dodałem komunikaty (wyświetlane w belce tytułowej okna aplikacji) informujące gracza co się aktualnie dzieje oraz co ma w danym momencie robić.
  • Metoda move klasy Particle teraz zwraca prędkość kulki, dzięki czemu łatwiej liczy się łączną prędkość wszystkich kulek (potrzebne do stwierdzenia kiedy przestać turlać kulki i zacząć wyświetlać kij).
  • ... i parę innych drobiazgów.

Na tym etapie gra jest już "grywalna" o tyle, że jest interaktywna. Można celować białą kulką, która po zwolnieniu klawisza myszy zostaje "uderzona" i wchodzi w interakcje z innymi kulkami. Nie można jeszcze wygrać ani przegrać, bo brakuje nam punktów 4 i 5.

Zaczniemy od dodania logiki "znikającej" kulki po zderzeniu dwóch w tym samym kolorze lub "rozmnażającej" po zderzeniu dwóch różnych kolorów. W tym celu musimy zbudować sobie pustą listę kulek do usunięcia i wypełniać ją numerami kulek przy każdym uruchomieniu pętli sprawdzającej kolizje, przy zderzeniu tych samych kolorów. I jeszcze drugą listę kulek do utworzenia w razie zderzenia dwóch różnych kolorów. Te dwie listy będą zrealizowane na różne sposoby:

  • Lista nowych kulek do utworzenia: tu wystarczy nam trójelementowa lista liczb całkowitych [0,0,0], w której każdy element reprezentuje liczbę kulek, które musimy utworzyć dla każdego z kolorów R,G,B. A więc jeżeli po zakończeniu pętli wykrywającej i rozwiązującej kolizje w zmiennej tej otrzymamy na przykład [1, 0, 2] to znaczy, że musimy dodać jedną czerwoną kulkę i dwie niebieskie.
  • Lista kulek do skasowania: tu wystarczy przechowywać indeksy kulek, które trzeba usunąć ze zmiennej particles po pętli "kolizyjnej" pamiętając tylko, żeby potem kasować od końca w celu uniknięcia przeindeksowywania istniejących kulek. Przykładowo jeżeli po zakończeniu sprawdzania kolizji w liście tej pojawią się liczby 7, 4 i 11, to najpierw trzeba skasować z listy particles element numer 11, potem 7 a na koniec 4. Jeżeli bowiem skasujemy najpierw element numer 4, to pozostałe elementy (od 5 w górę) przesuną się o jedną pozycję i kulka, która przedtem miała numer 7, teraz będzie miała numer 6 - kasując teraz kulkę numer 7 usuniemy nie tą kulkę co trzeba.
import pygame
import math
import random
from typing import Tuple

## GLOBALS ##

damping = 0.001
dt = 0.03
velocity_combined = 0
running = True
pygame.init()
window_size = (700, 500)
screen = pygame.display.set_mode(window_size)
colour_red = (255, 0, 0)
colour_green = (0, 255, 0)
colour_blue = (0, 0, 255)
colour_white = (255, 255, 255)
colours = (colour_red, colour_green, colour_blue)
particles_count = 3
radius = 30


class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    def __add__(self, vector: 'Vector'):
        return Vector(self.x + vector.x, self.y + vector.y)

    def __sub__(self, vector: 'Vector'):
        return Vector(self.x - vector.x, self.y - vector.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __truediv__(self, scalar):
        return Vector(self.x / scalar, self.y / scalar)

    def dotProduct(self, vector: 'Vector'):
        return self.x * vector.x + self.y * vector.y

    @property
    def magnitude(self):
        return math.sqrt(self.x ** 2 + self.y ** 2)

    @property
    def direction(self):
        return math.atan2(self.x, self.y)


class Particle:
    def __init__(self, pos_x: float, pos_y: float, vel_x: float, vel_y: float, radius: float, colour: Tuple[int, int, int]):
        self.x = pos_x
        self.y = pos_y
        self.radius = radius
        self.colour = colour
        self.velocity = Vector(vel_x, vel_y)

    def move(self, dt: float = dt) -> float:
        if (self.velocity.magnitude == 0):
            return 0

        if (self.velocity.magnitude <= 1):
            self.velocity = Vector(0, 0)
            return 0

        self.velocity *= (1-damping)
        moveVector = self.velocity * dt
        self.x += moveVector.x
        self.y += moveVector.y

        if (self.x + self.radius >= window_size[0] or self.x-self.radius <= 0):
            self.velocity.x *= -1
            self.x += self.velocity.x * dt

        if (self.y+self.radius >= window_size[1] or self.y-self.radius <= 0):
            self.velocity.y *= -1
            self.y += self.velocity.y * dt

        return self.velocity.magnitude

    @property
    def mass(self):
        return 4/3*math.pi*self.radius**3

    def collision_vector(self, other: 'Particle'):
        position_diff: Vector = Vector(self.x-other.x, self.y-other.y)
        velocity_diff: Vector = self.velocity - other.velocity
        return self.velocity - position_diff * velocity_diff.dotProduct(position_diff) / position_diff.magnitude ** 2 * (2 * other.mass / (self.mass + other.mass))

    def resolve_collision(self, other: 'Particle'):
        distance = ((self.x - other.x)**2 +
                    (self.y - other.y)**2)**0.5
        overlap = (self.radius + other.radius) - distance
        if (overlap < 0):
            return True

        overlap_proportion = overlap / distance
        self.move(-dt * overlap_proportion/2)
        other.move(-dt * overlap_proportion/2)

        cv1 = self.collision_vector(other)
        cv2 = other.collision_vector(self)
        self.velocity = cv1
        other.velocity = cv2
        self.move(dt*(1-overlap_proportion)/2)
        other.move(dt*(1-overlap_proportion)/2)
        return False

    def draw(self, screen):
        pygame.draw.circle(
            screen, (0, 0, 0), (self.x, self.y), self.radius, 3)
        pygame.draw.circle(
            screen, self.colour, (self.x, self.y), self.radius - 3)


def create_particle(c=None) -> bool:
    sx, sy = window_size[0], window_size[1]
    pxmin, pymin = radius+1, radius+1
    pxmax, pymax = sx - radius - 1, sy - radius - 1
    vmin, vmax = 0, 20
    if (len(particles) == 0):
        colour = colour_white
    else:
        if not (c):
            colour = random.sample(colours, k=1)[0]
        else:
            colour = c
    success = False
    counter = 0
    while not success and counter <= 100:
        counter += 1
        success = True
        px = pxmin + random.random() * (pxmax - pxmin)
        py = pymin + random.random() * (pymax - pymin)
        p = Particle(px, py, 0, 0, radius, colour)
        for pp in particles:
            if not p.resolve_collision(pp):
                success = False
    if (success):
        particles.append(p)
        return True
    else:
        return False


def draw_cue(screen, cue, particle: Particle):

    startx = cue[0]
    starty = cue[1]
    endx = particle.x
    endy = particle.y
    cue_vector = Vector(endx - startx, endy - starty) * 10
    pygame.draw.line(screen, (0, 0, 0), (startx, starty),
                     (startx+cue_vector.x, starty+cue_vector.y), 2)


particles: list[Particle] = []

for i in range(particles_count):
    if (not create_particle()):
        break


def process_events():
    global running
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if (event.type == pygame.KEYDOWN and (event.key == pygame.K_ESCAPE or event.key == pygame.K_q)):
            running = False


# MAIN LOOP
# play until all particles disappear (except white)
# or player closes the game window
while (running and len(particles) > 1):
    # move balls until they stop
    velocity_combined = 0.1
    while (running and velocity_combined > 0):
        pygame.display.set_caption(f"Wait... ({int(velocity_combined)})")
        velocity_combined = 0

        for p in particles:
            velocity_combined += p.move()

        particles_to_delete: list[int] = []
        particles_to_create: list[int] = [0, 0, 0]  # rgb

        for p1i in range(len(particles)):
            for p2i in range(p1i+1, len(particles)):
                if not (particles[p1i].resolve_collision(particles[p2i])):
                    if (particles[p1i].colour == particles[p2i].colour):
                        particles_to_delete.append(p1i)
                        particles_to_delete.append(p2i)
                    else:
                        if (colour_red not in [particles[p1i].colour, particles[p2i].colour] and p1i > 0 and p2i > 0):
                            particles_to_create[0] += 1
                        elif (colour_green not in [particles[p1i].colour, particles[p2i].colour] and p1i > 0 and p2i > 0):
                            particles_to_create[1] += 1
                        elif (colour_blue not in [particles[p1i].colour, particles[p2i].colour] and p1i > 0 and p2i > 0):
                            particles_to_create[2] += 1

        particles_to_delete = list(set(particles_to_delete))
        particles_to_delete.sort(reverse=True)
        for i in particles_to_delete:
            del particles[i]
        if (sum(particles_to_create) > 0):
            rgb_string = particles_to_create[0] * 'r' + \
                particles_to_create[1] * 'g' + particles_to_create[2] * 'b'

            for c in rgb_string:
                if (c == 'r'):
                    colour = colour_red
                elif (c == 'g'):
                    colour = colour_green
                elif (c == 'b'):
                    colour = colour_blue

                if not create_particle(colour):
                    pass

        screen.fill(colour_white)
        for p in particles:
            p.draw(screen)

        pygame.display.flip()
        pygame.time.delay(2)
        process_events()

    # wait for player to press left mouse key
    pygame.display.set_caption(f"Hold left mouse button to aim.")
    while (running and not pygame.mouse.get_pressed()[0]):
        process_events()

    # wait for player to release left mouse key
    pygame.display.set_caption(f"Aim then release left mouse button.")
    while (running and pygame.mouse.get_pressed()[0]):
        cue = pygame.mouse.get_pos()
        screen.fill(colour_white)
        draw_cue(screen, cue, particles[0])
        for p in particles:
            p.draw(screen)
        pygame.display.flip()
        process_events()

    # shoot white ball
    particles[0].velocity = (
        Vector(particles[0].x, particles[0].y) - Vector(cue[0], cue[1]))
    velocity_combined = particles[0].velocity.magnitude

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
    pygame.time.delay(2)

Ostatnie, co nam pozostało, to sprawdzić czy gracz wygrał lub przegrał. Sprawdzenie wygranej jest proste (pozostała tylko jedna kulka, biała). Sprawdzenie przegranej... tu już trzeba by najpierw zdecydować co rozumiemy przez przegraną. I właśnie tutaj zabrakło mi już rozpędu 🙂 Dlatego gry nie da się przegrać, da się natomiast wygrać ewentualnie powiesić, kiedy kulek zrobi się za dużo lub pojawi się błąd związany z obsługą kolizji. Na przykład dwie kulki różnych kolorów zetkną się "na stałe" i zaczną generować nieskończenie wiele kulek trzeciego koloru.

Ostateczna wersja kodu:

import pygame
import math
import random
from typing import Tuple

## GLOBALS ##

damping = 0.001
dt = 0.03
velocity_combined = 0
running = True
pygame.init()
window_size = (700, 500)
screen = pygame.display.set_mode(window_size)
colour_red = (255, 0, 0)
colour_green = (0, 255, 0)
colour_blue = (0, 0, 255)
colour_white = (255, 255, 255)
colours = (colour_red, colour_green, colour_blue)
particles_count = 5
radius = 30


class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    def __add__(self, vector: 'Vector'):
        return Vector(self.x + vector.x, self.y + vector.y)

    def __sub__(self, vector: 'Vector'):
        return Vector(self.x - vector.x, self.y - vector.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __truediv__(self, scalar):
        return Vector(self.x / scalar, self.y / scalar)

    def dotProduct(self, vector: 'Vector'):
        return self.x * vector.x + self.y * vector.y

    @property
    def magnitude(self):
        return math.sqrt(self.x ** 2 + self.y ** 2)

    @property
    def direction(self):
        return math.atan2(self.x, self.y)


class Particle:
    def __init__(self, pos_x: float, pos_y: float, vel_x: float, vel_y: float, radius: float, colour: Tuple[int, int, int]):
        self.x = pos_x
        self.y = pos_y
        self.radius = radius
        self.colour = colour
        self.velocity = Vector(vel_x, vel_y)

    def move(self, dt: float = dt) -> float:
        if (self.velocity.magnitude == 0):
            return 0

        if (self.velocity.magnitude <= 1):
            self.velocity = Vector(0, 0)
            return 0

        self.velocity *= (1-damping)
        moveVector = self.velocity * dt
        self.x += moveVector.x
        self.y += moveVector.y

        if (self.x + self.radius >= window_size[0] or self.x-self.radius <= 0):
            self.velocity.x *= -1
            self.x += self.velocity.x * dt

        if (self.y+self.radius >= window_size[1] or self.y-self.radius <= 0):
            self.velocity.y *= -1
            self.y += self.velocity.y * dt

        return self.velocity.magnitude

    @property
    def mass(self):
        return 4/3*math.pi*self.radius**3

    def collision_vector(self, other: 'Particle'):
        position_diff: Vector = Vector(self.x-other.x, self.y-other.y)
        velocity_diff: Vector = self.velocity - other.velocity
        return self.velocity - position_diff * velocity_diff.dotProduct(position_diff) / position_diff.magnitude ** 2 * (2 * other.mass / (self.mass + other.mass))

    def resolve_collision(self, other: 'Particle'):
        distance = ((self.x - other.x)**2 +
                    (self.y - other.y)**2)**0.5
        overlap = (self.radius + other.radius) - distance
        if (overlap < 0):
            return True

        overlap_proportion = overlap / distance
        self.move(-dt * overlap_proportion/2)
        other.move(-dt * overlap_proportion/2)

        cv1 = self.collision_vector(other)
        cv2 = other.collision_vector(self)
        self.velocity = cv1
        other.velocity = cv2
        self.move(dt*(1-overlap_proportion)/2)
        other.move(dt*(1-overlap_proportion)/2)
        return False

    def draw(self, screen):
        pygame.draw.circle(
            screen, (0, 0, 0), (self.x, self.y), self.radius, 3)
        pygame.draw.circle(
            screen, self.colour, (self.x, self.y), self.radius - 3)


def create_particle(c=None) -> bool:
    sx, sy = window_size[0], window_size[1]
    pxmin, pymin = radius+1, radius+1
    pxmax, pymax = sx - radius - 1, sy - radius - 1
    vmin, vmax = 0, 20
    if (len(particles) == 0):
        colour = colour_white
    else:
        if not (c):
            colour = random.sample(colours, k=1)[0]
        else:
            colour = c
    success = False
    counter = 0
    while not success and counter <= 100:
        counter += 1
        success = True
        px = pxmin + random.random() * (pxmax - pxmin)
        py = pymin + random.random() * (pymax - pymin)
        p = Particle(px, py, 0, 0, radius, colour)
        for pp in particles:
            if not p.resolve_collision(pp):
                success = False
    if (success):
        particles.append(p)
        return True
    else:
        return False


def draw_cue(screen, cue, particle: Particle):

    startx = cue[0]
    starty = cue[1]
    endx = particle.x
    endy = particle.y
    cue_vector = Vector(endx - startx, endy - starty) * 10
    pygame.draw.line(screen, (0, 0, 0), (startx, starty),
                     (startx+cue_vector.x, starty+cue_vector.y), 2)


particles: list[Particle] = []

for i in range(particles_count):
    if (not create_particle()):
        break


def process_events():
    global running
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if (event.type == pygame.KEYDOWN and (event.key == pygame.K_ESCAPE or event.key == pygame.K_q)):
            running = False


# MAIN LOOP
# play until player closes the game window
while (running):
    # move balls until they stop
    velocity_combined = 0.1
    while (running and velocity_combined > 0):
        pygame.display.set_caption(f"Wait... ({int(velocity_combined)})")
        velocity_combined = 0

        for p in particles:
            velocity_combined += p.move()

        particles_to_delete: list[int] = []
        particles_to_create: list[int] = [0, 0, 0]  # rgb

        for p1i in range(len(particles)):
            for p2i in range(p1i+1, len(particles)):
                if not (particles[p1i].resolve_collision(particles[p2i])):
                    if (particles[p1i].colour == particles[p2i].colour):
                        particles_to_delete.append(p1i)
                        particles_to_delete.append(p2i)
                    else:
                        if (colour_red not in [particles[p1i].colour, particles[p2i].colour] and p1i > 0 and p2i > 0):
                            particles_to_create[0] += 1
                        elif (colour_green not in [particles[p1i].colour, particles[p2i].colour] and p1i > 0 and p2i > 0):
                            particles_to_create[1] += 1
                        elif (colour_blue not in [particles[p1i].colour, particles[p2i].colour] and p1i > 0 and p2i > 0):
                            particles_to_create[2] += 1

        particles_to_delete = list(set(particles_to_delete))
        particles_to_delete.sort(reverse=True)
        for i in particles_to_delete:
            del particles[i]
        if (sum(particles_to_create) > 0):
            rgb_string = particles_to_create[0] * 'r' + \
                particles_to_create[1] * 'g' + particles_to_create[2] * 'b'

            for c in rgb_string:
                if (c == 'r'):
                    colour = colour_red
                elif (c == 'g'):
                    colour = colour_green
                elif (c == 'b'):
                    colour = colour_blue

                if not create_particle(colour):
                    pass

        screen.fill(colour_white)
        for p in particles:
            p.draw(screen)

        pygame.display.flip()
        pygame.time.delay(2)
        process_events()

    # wait for player to press left mouse key
    if(len(particles)==1):
        pygame.display.set_caption("YOU WON")
    else:
        pygame.display.set_caption(f"Hold left mouse button to aim.")
    while (running and not pygame.mouse.get_pressed()[0]):
        process_events()

    # wait for player to release left mouse key
    pygame.display.set_caption(f"Aim then release left mouse button.")
    while (running and pygame.mouse.get_pressed()[0]):
        cue = pygame.mouse.get_pos()
        screen.fill(colour_white)
        draw_cue(screen, cue, particles[0])
        for p in particles:
            p.draw(screen)
        pygame.display.flip()
        process_events()

    # shoot white ball
    particles[0].velocity = (
        Vector(particles[0].x, particles[0].y) - Vector(cue[0], cue[1]))
    velocity_combined = particles[0].velocity.magnitude

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
    pygame.time.delay(2)

Podsumowując...

Napisanie pierwszej wersji tej gry zajęło mi jakieś dwa dni (tak naprawdę około 4-6 godzin spędzonych przed klawiaturą), z czego ponad jedną trzecią zużyłem na wykumanie jak policzyć wektor kolizji po odbiciu kulek. Trochę wstyd, że tak długo; dobrze chociaż, że się jednak udało.

Napisanie tego tekstu zajęło mi około tygodnia. Po drodze przepisałem całość kodu kilka razy praktycznie od zera. W dalszym ciągu jest on strasznie bałaganiarski; dałoby się go lepiej podzielić na funkcje (a może nawet moduły?) ale, jak już wspomniałem dwukrotnie, zabrakło mi weny.

Smacznego!

https://xpil.eu/k8s

Leave a Comment

Komentarze mile widziane.

Jeżeli chcesz do komentarza wstawić kod, użyj składni:
[code]
tutaj wstaw swój kod
[/code]

Jeżeli zrobisz literówkę lub zmienisz zdanie, możesz edytować komentarz po jego zatwierdzeniu.