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ć.
- zwalniamy kulkę odrobinę (parametr
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:
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).
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:
- 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)
- Zmienić kod losujący początkowe położenie kulek tak, żeby wszystkie kulki były na dzień dobry nieruchome.
- Łatwe
- 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).
- 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.
- 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 zmiennejparticles
, którą definiuję pod koniec, bo potrzebuję najpierw definicji klasyParticle
) - 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
naFalse
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
klasyParticle
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 listyparticles
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!
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.