TheGame #6: Kolonizujemy planetę z komponentem Galaxy

gildia-developerow.pl 4 miesięcy temu

Grudzień zawitał a my mamy kolejną iterację projektu! Dziś, tradycyjnie, otwieramy nowy komponent – Galaxy. Choć startujemy tylko z kolonizacją planet, to wygląda na to, iż ten komponent będzie solidną podstawą czegoś większego. O tym i o kilku innych rzeczach przeczytacie w dzisiejszym wpisie.

O projekcie, Pull Request i takie tam

Jeżeli wpadłeś i nie wiesz, o co chodzi, to… robię grę =) Jest to kosmiczna strategia MMO w stylu OGame, XWars czy SpacePioneers – mamy planety, statki i takie tam. Ten wpis jest częścią serii wpisów o projekcie TheGame, zachęcam Cię do zapoznania się z innymi częściami serii.

Z technicznego punktu widzenia tworzę Modularny Monolit, którego źródła znajdziesz na GitHubie pod linkiem https://github.com/senghe/TheGame. Serdecznie zachęcam do gwiazdkowania projektu. A o ile wkurzają Cię maile o pękniętych buildach, to zostaw gwiazdkę, ale od-obserwuj sobie repozytorium

Link do Pull Requestu, o którym dziś będę rozprawiał: https://github.com/senghe/TheGame/pull/5. Aha, ten PR nie pozostało skończony – brakuje kilku drobnych elementów. Ale to w sumie dobrze, bo masz na spokojnie czas, aby zapoznać się z kodzikiem, a choćby – coś tam skomentować.

No dobra, wstęp mamy, więc… lecimy!

Galaktyczne MVP

Tak jak wspomniałem – jest się z czego cieszyć, bo mamy nowy komponent Projekt prężnie rozrasta się, bo mamy już… 6 komponentów pokrytych logiką. Do grona komponentów dołącza dziś Galaxy, który pełni rolę MVP (ang. Minimal Valuable Product). W grze, którą tworzę, komponent galaktyki będzie odpowiedzialny za następujące procesy:

  • Kolonizacja planet,
  • Zbieranie pól zniszczeń,
  • Tworzenie, niszczenie oraz lokalizacja księżyców,
  • Wyprawy ekspedycyjne.

W chwili obecnej zaimplementowana zostaje jedynie funkcjonalność kolonizacji planety. Na ten moment nie ma zbyt wielu warunków brzegowych związanych z kolonizacją. Jedynymi warunkami są:

  • Flota posiada przynajmniej jeden statek kolonizacyjny,
  • Spełnione są podstawowe warunki podróżowania, takie jak spełnienie kosztu paliwa czy wielkość ładunku,
  • Miejsce docelowe w galaktyce, w chwili startu floty oraz w momencie dotarcia floty do celu podróży, pozostaje nieskolonizowane.

Mamy tutaj jeden przypadek do rozpatrzenia – zawrócenie floty w momencie, kiedy do procesu kolonizacji nie może dojść. Wymagana tutaj jest komunikacja między komponentami FleetJourney oraz Galaxy.

Proces kolonizacji polega na utworzeniu nowej planety, która zostaje przypisana do gracza. W momencie kolonizacji wylosowany zostaje zestaw cech charakterystycznych dla tej planety. Docelowo każda planeta będzie miała swoje adekwatności, takie jak:

  • zwiększony / zmniejszony limit pól pod budowę budynków,
  • zwiększona / zmniejszona wydajność konkretnych kopalni,
  • zwiększona / zmniejszona wydajność energetyczna,
  • zwiększona / zmniejszona wydajność satelit słonecznych.

Zakres losowanych wartości zależny będzie szczególnie od położenia planety w układzie słonecznym. Te cieplejsze będą znajdowały się bliżej początku układu (zakładamy, iż najbliżej gwiazdy), a te chłodniejsze będą oddalone znacznie od początku układu. O ile te pierwsze będą mogły wygenerować więcej energii elektrycznej, o tyle te drugie będą bardziej obfite w surowce służące jako paliwo. Im bliżej środka galaktyki będziemy, tym większa szansa będzie na wylosowanie dużej planety.

Update techniczny, czyli to, co lubimy najbardziej

No dobra, umówmy się: nie po gadki o galaktyce tu jesteśmy O ile prace nad kolonizacją nie były jakieś rozległe, to pojawiło się w tzw. międzyczasie kilka tematów, które chciałem poruszyć

Proces kolonizacji planety a odpowiedzialności komponentów

Na pewno zauważyliście, iż w środku mamy dwa MissionEligibilityCheckery. I nie, nie jest to pomyłka. Jest to odseparowanie odpowiedzialności między komponentami tak, aby ich domeny nie wyciekały. I tak EligibilityChecker z komponentu FleetJourney weryfikuje, czy możemy przeprowadzić kolonizację (w domyśle: czy jest z nami kolonizator?), a EligibilityChecker z komponentu Galaxy sprawdza, czy nie chcemy przypadkiem zaatakować swojej planety lub wysłać flotę na misję stacjonowania na planetę innego gracza.

Drugą ciekawostką o kolonizacji jest to, iż podczas procesu zasiedlania planety surowce powinny zostać wyładowane na planecie. Chociaż mamy w tej chwili przygotowany listener, który mógłby załatwić sprawę, to… my nie mamy w tej chwili pewności, iż event listener wyładowujący surowce wykona się po tym, który kolonizuje planetę. Na chwilę, w której piszę ten wpis, jest to jeszcze kwestia nierozwiązana. Chociaż kto wie, może skorzystam z opcji priorytetyzowania message handlerów, bo i tak pod spodem będę miał Messengera.

A skoro o wyładowywaniu surowców mowa, to o ile uporam się z kolejnością wykonywania zdarzeń, to reszta będzie działać. Bo już w tej chwili w komponencie ResourceStorage mamy logikę, która podczas dispatchowania surowców tworzy odpowiedni magazyn, o ile ten jeszcze nie istnieje.

Wybór odpowiedniego agregatu to dopiero sztuka…

Jakiś czas temu napisałem wpis o tym, czym są i jakie warunki muszą spełniać agregaty w Domain Driven Design: W poszukiwaniu agregatów w Domain Driven Design. Jednym z warunków kluczowych jest to, iż w momencie pracy na agregacie musi on zostać w pełni załadowany do pamięci.

Powyższe oznacza, iż agregatem komponentu Galaxy nie może zostać galaktyka, ponieważ ta może mieć bardzo dużą ilość układów słonecznych (liczoną choćby w setkach). Wczytanie takiej liczby relacji byłoby niedobre dla performance ze względu na dużą ilość danych, które musiałaby nam przekazać baza danych. Ale nie tylko. Bo pamiętajmy, iż te dane dalej wpadną w proces hydracji, a następnie będą musiały być przechowywane w pamięci. W ogóle to niedobre jest.

Pojedyncza planeta również nie może być agregatem, ze względu na przyszłe plany związane chociażby ekspedycją, czyli misjami bonusowymi w obrębie układu słonecznego. Będziemy mieli w przyszłości możliwość tworzenia specjalnych wydarzeń galaktycznych, w których będą mogły brać udział floty graczy.

Skoro galaktyka to za dużo, a planeta to za mało, to może układ słoneczny? Ten wygląda na optymalny rozmiar dla agregatu oraz może zawierać dodatkowe niezmienniki takie jak kolonizacja planety czy realizacja wyżej wspomnianych wypraw ekspedycyjnych. No i tak – właśnie tak wybrałem. Rdzeniem agregatu jest układ słoneczny.

Dwa komponenty, czy jeden z dwoma agregatami?

We wpisie #5 – Podróżujemy statkami z komponentem podjąłem rozterkę o wyciekaniu domen. Bo do SharedKernelu trafiły ValueObjecty domenowe, takie jak: GalaxyPoint, BuildingType, czy… w tej chwili dodany FleetMissionType Niektóre komponenty zaczynają mieć ze sobą odrobinę więcej wspólnego. Niby moglibyśmy pozamykać te komponenty między sobą i skopiować do każdego z nich wewnętrzny egzemplarz każdego z wymienionych ValueObjectów, no ale… nie jestem do tego przekonany.

A może by tak podejść do tego inaczej? Może warto by było zmergować ze sobą jakoś te komponenty, które wzajemnie się uzupełniają? Skoro FleetJourney oraz Galaxy korzystają z koordynatów czy informacji o planetach… może by tak połączyć je w jeden, który posiada dwa agregaty?

A może jeszcze inaczej, i wydzielić Bounded Contexty? Bo w zasadzie to mamy już dwa konteksty – jeden związany z tym, co znajduje się na planecie (budynki, stocznia, magazyny) oraz drugi, który wiąże ze sobą komponenty związane z galaktyką (galaktyka sama w sobie, flota no i w przyszłości… komponent walki).

Rozwiązania na te rozterki w tej chwili nie mam, a jedyne co mi na tą chwilę przychodzi do głowy, to słynne powiedzenie: „Jeździć, obserwować”.

Zmiany w komponencie FleetJourney

Teraz wspomnę krótko o refactoringu, którym rozpocząłem PRkę. Bo zadałem sobie pytanie: czym jest statek kolonizacyjny? Czym będzie różnił się od innego statku, na przykład transportera? I czym do… diaska jest transporter? Zdałem sobie sprawę, iż pole poniższe nie będzie dobre:

<?php namespace TheGame\Application\Component\FleetJourney\Domain; // ... final class ShipsGroup implements ShipsGroupInterface { public function __construct( private readonly string $type, private int $quantity, private readonly int $speed, private readonly int $unitLoadCapacity, ) { } public function getType(): string { return $this->type; } // ... }

Na podstawie pola typu string nie jestem w stanie tego stwierdzić. Inaczej musiałbym zrobić hardcode, iż typ "colonization-ship" to statek kolonizacyjny. A jak sami sobie zdajecie z tego sprawę – nie jest to dobre. Musiałem dodać „drugi typ”. I zaczęły się schody, bo trzeba było go jakoś nazwać. I tak powstał pomysł, iż w zasadzie obecne pole type to nic innego, jak nazwa statku. Czyli robimy refactoring na shipName. I choćby fajniej to wygląda. Obok nowo powstałego shipName dołożyłem shipClass.

Teraz klasa ShipsGroup wygląda następująco:

<?php namespace TheGame\Application\Component\FleetJourney\Domain; // ... final class ShipsGroup implements ShipsGroupInterface { public function __construct( private readonly string $shipName, private readonly ShipClass $shipClass, private int $quantity, private readonly int $speed, private readonly int $unitLoadCapacity, ) { } public function getShipName(): string { return $this->shipName; } public function getShipClass(): ShipClass { return $this->shipClass; } // ... }

A enum klasy statku ma się następująco:

<?php namespace TheGame\Application\Component\FleetJourney\Domain; enum ShipClass: string { case Colonization = 'colonization'; case Fighter = 'fighter'; case Transporter = 'transporting'; }

Z perspektywy aplikacji na chwilę obecną będziemy rozróżniać trzy klasy statków: kolonizacyjny, walczący oraz transportujący. Zarówno Lekki Myśliwiec, jak i Okręt Wojenny będą statkami klasy Fighter – różnić się będą statystykami. A mały i duży transportowiec to będą statki klasy Transporter. Dalej dojdzie pewnie Recycler, SpyProbe i inne. Na podstawie klasy statku będziemy definiowali w kodzie odpowiednie zachowania. Administratorowi aplikacji natomiast zostawimy już konfigurację tego, ile jakich statków transportujących, walczących czy kolonizujących o różnych nazwach i statystykach dostępnych będzie w grze.

Rozrastająca się Event Driven Architecture

Jak gdzieś wyżej pisałem, rozładunek surowców podczas kolonizacji mamy załatwiony przez to, iż już wcześniej nasłuchiwaliśmy odpowiednie zdarzenie dotyczące tego, iż flota dotarła na miejsce docelowe. I tak to jest z architekturą zdarzeniową – trochę układamy puzzle. Poniekąd to jest fajne, bo życie toczy się od listenera do listenera Ale z drugiej strony, to… no właśnie. Życie toczy się od listenera do listenera. A ile zdarzeń wyzwala jaką liczbę listenerów po drodze (i generuje kolejne zdarzenia po drodze), to nie tak łatwo powiedzieć.

Architektura sterowana zdarzeniami ma swoje wady i o nich trzeba mówić jasno. Bo to, iż z niej korzystam nie znaczy, iż jest tylko super. No i tak mamy architekturę, w której nie widać na pierwszy rzut oka tego, gdzie kończy się konkretny proces biznesowy i z jakich elementów się składa. To, co w tej sprawie jest do zrobienia, to na pewno ogarnięcie testów, które będą nam dokładnie pokrywały procesy biznesowe (behat) oraz testy, które będą weryfikowały to, jakie (ile oraz w jakiej kolejności) zdarzenia wpadają w konkretnym procesie biznesowym do event busa / command busa. Trzeba te dwa tematy załatwić tak, aby nie stracić kontroli nad tym, co w trawie piszczy

PHPSpec, czyli nie wszystko możemy przetestować…

Ostatni techniczny punkt dzisiejszego wpisu, czyli specki. Na pewno zauważyliście, iż kilka scenariuszy jest skipniętych, np. tutaj:

<?php namespace spec\TheGame\Application\Component\FleetJourney\Domain\Entity; // ... final class JourneySpec extends ObjectBehavior { // ... public function it_throws_exception_when_reaching_return_point_but_flying_time_didnt_pass(): void { throw new SkippingException("Cannot test the behaviour with current implementation"); } // ... }

Tak wychodzi, iż w sposób programistyczny nie zawsze jesteśmy w stanie skonfigurować odpowiedni stan agregatu (lub jego elementu) tak, aby wywołać konkretną operację. Jest tak zwłaszcza wtedy, kiedy coś ma się wydarzyć, kiedy minie odpowiednia ilość czasu (ale nie tylko). Zupełnym antywzorcem jest umieszczenie w teście wywołania funkcji sleep() – spowoduje to, iż czas wykonania testów jednostkowych będzie sztucznie puchł. Stąd stwierdziłem, iż wolę zostawić scenariusze, które są zmarkowane jako „skipped”. W przyszłości może okazać się, iż jak będę zmieniał implementację, to możliwe, iż te scenariusze – które mimo wszystko istnieją – zostaną pokryte.

A na koniec… trochę kolorków

Ponieważ głównym motywem dzisiejszego dnia jest kolonizacja planety, to pragnę przedstawić Wam nowe, tematycznie pasujące obrazki. Są to obrazki, które prawdopodobnie zamieszczę w fixturkach projektu.

Tak więc kolejno mamy: Technologia kolonizacyjna (badanie laboratoryjne) oraz trzy typy planet: znajdującą się na początku układu słonecznego (pomarańczowa), na końcu układu słonecznego (zielona) no i duża, znajdująca się w środku układu (niebieska). Częstujcie się!

Idź do oryginalnego materiału