Do tej pory rozwijaliśmy naszą grę pod kątem pojedynczych funkcji. Pomimo tego, że dopiero zaczęliśmy — już zdążyliśmy nagromadzić niewielki dług techniczny. W tej części pozbędziemy się go oraz zaprojektujemy menu aplikacji, z czego wyniknie mały refaktoring kodu.
Choć ‘dług techniczny’ brzmi bardzo negatywnie, jest to zjawisko naturalne — więcej o tym jak powstaje i jak sobie z nim radzić znajdziesz w osobnym wpisie, póki co zajmijmy się jdego identyfikacją w naszym projekcie.
Identyfikujemy potencjalne problemy
W naszym przypadku możemy wskazać kilka problemów związanych z długiem technicznym:
- Wczytując czcionki, robimy to w serwisie TextService, choć wydzieliliśmy osobny serwis do wczytywania zasobów gry
- Nie możemy zmienić wielkości czcionki, jest predefiniowana
- W kilku miejscach przewijają nam się ‘magiczne wartości’, które w przyszłości mogą powodować duplikacje w kodzie — przykładem jest szerokość i wysokość ekranu w jednostkach
- Zależności w kilku modułach powtarzają się, za każdym razem wpisujemy numer wersji, co może prowadzić do pomyłek w przyszłości
Zajmijmy się każdym z tych problemów po kolei.
Zarządzanie zasobami — wczytywanie czcionek
W obecnym kodzie, pomimo wydzielenia serwisu do zarządzania zasobami, nadal wczytujemy czcionki w TextService. Zmienimy to w taki sposób, aby wszystkie operacje związane z wczytywaniem plików i zasobów odbywały się poprzez nasz moduł zarządzania zasobami, a TextService wyłącznie z niego korzystał.
Krok 1 — przenosimy kod związany z pobieraniem czcionek do implementacji interfejsu ResourcesManager
W tym celu tworzymy nowy plik, do którego kopiujemy obecnie używany kod:
public class CachingResourcesManager implements ResourcesManager {
private Logger log = LoggerFactory.getLogger(getClass());
private Map<String, TrueTypeFont> fontsCache = new HashMap<>();
private TrueTypeFont getFont(String fontName) {
if (!fontsCache.containsKey(fontName)) {
try {
InputStream inputStream = getClass().getClassLoader().getResourceAsStream("fonts/" + fontName + ".ttf");
Font awtFont = Font.createFont(Font.TRUETYPE_FONT, inputStream);
awtFont = awtFont.deriveFont(24f); // domyślny, stały rozmiar czcionki
fontsCache.put(fontName, new TrueTypeFont(awtFont, true));
} catch (Exception e) {
log.error("Nie można wczytać fontu", e);
}
}
return fontsCache.get(fontName);
}
//...
}
Krok 2 — refaktorujemy kod, wyciągamy sygnatury metod do interfejsu
Drugim krokiem jest mały refactoring naszego kodu — podobnie jak w przypadku obrazów chcemy mieć dwie metody — wczytywania fontu z pliku, jeśli nie była wczytana, oraz zwracania go z pamięci podręcznej.
Dzielimy więc metodę getFont na dwie:
public void preloadFont(String fontName) {
if (!fontsCache.containsKey(fontName)) {
try {
InputStream inputStream = getClass().getClassLoader().getResourceAsStream("fonts/" + fontName + ".ttf");
Font awtFont = Font.createFont(Font.TRUETYPE_FONT, inputStream);
awtFont = awtFont.deriveFont(24f); // domyślny, stały rozmiar czcionki
fontsCache.put(fontName, new TrueTypeFont(awtFont, true));
} catch (Exception e) {
log.error("Nie można wczytać fontu", e);
}
}
}
public TrueTypeFont getFontResource(String fontName) {
if (!fontsCache.containsKey(fontName)) {
preloadFont(fontName);
}
return fontsCache.get(fontName);
}
Dodajemy także obie metody do interfejsu:
public interface ResourcesManager {
public void preloadImage(String imageName);
public Object getImageResource(String imageName);
public TrueTypeFont getFontResource(String fontName);
public void preloadFont(String fontName);
}
Krok 3 — korzystamy z implementacji ResourceManager w TextService
Nie pozostało nam nic innego jak usunąć kod do obsługi czcionek z TextService i zastąpić go wywołaniem ResourceManager. Do tego musimy pobrać obiekt ResourcesManager:
@Autowired
private ResourcesManager resourcesManager;
oraz podmienić wywołanie getFont() na odpowiednie wywołanie metody obiektu resourcesManager:
TrueTypeFont font = resourcesManager.getFontResource(fontName);
Umożliwienie zmiany rozmiaru czcionki
W naszej aplikacji na tą chwilę założyliśmy stałą wielkość czcionki — 24. Nie jest to jednak optymalne, w różnych miejscach naszej gry w przyszłości możemy chcieć skorzystać z różnych rozmiarów.
Przede wszystkim musimy zatem zmienić sposób, w jaki wczytujemy i przechowujemy czcionki. W tym wypadku kluczem naszej mapy, na której przechowujemy czcionki, będą dwa elementy — nazwa czcionki oraz jej rozmiar. Możemy to rozwiązać na trzy sposoby:
- pozostawić strukturę bez zmian, rozmiar czcionki włączyć do ‘klucza’ mapy (obecnie String) — to rozwiązanie najprostsze, ale niezbyt elastyczne — zamiana informacji na klucz do mapy wymaga znajomości algorytmu; w drugą stronę — odczytanie informacji o nazwie czcionki i jej rozmiarze — powodowałoby konieczność stosowania wyrażeń regularnych lub innych technik przetwarzania ciągów znaków
- zamienić klucz mapy na obiekt, który będzie prawidłowo przechowywał obie wartości — ta opcja wymaga minimalnego przepisania kodu, ale jest bardzo elastyczna na przyszłość — możemy dodawać np. nowe pola
- zamienić mapowanie na hierarchię map — główna mapa mapowałaby nazwę czcionki na kolejną mapę — tym razem rozmiarów; takie podejście skomplikowało by bardziej cały kod, a dodanie kolejnych parametrów wymagałoby dodawania kolejnych ‘poziomów’.
Druga opcja wydaje się najsensowniejsza — nie będzie wymagała dużej ilości zmian, i pozwoli na jej rozszerzanie w przyszłości. Zaimplementujmy więc klasę, która pozwoli nam przechowywać wspomniane informacje i będzie kluczem mapy. Ponieważ będzie ona używana tylko wewnątrz serwisu, może być ona klasą wewnętrzną (czyli widoczną tylko w ramach jednej klasy):
public class CachingResourcesManager implements ResourcesManager {
//...
private static class FontKey {
String fontName;
Float fontSize;
public String getFontName() {
return fontName;
}
public Float getFontSize() {
return fontSize;
}
public FontKey(String fontName, Float fontSize) {
super();
this.fontName = fontName;
this.fontSize = fontSize;
}
//hashCode, equals
}
}
A następnie modyfikujemy kod metody, zmieniając od razu jej sygnaturę (pamiętajmy też o zmianie sygnatury w interfejsie):
private Map<FontKey, TrueTypeFont> fontsCache = new HashMap<>();
@Override
public void preloadFont(String fontName, Float fontSize) {
FontKey key = new FontKey(fontName, fontSize);
if (!fontsCache.containsKey(fontName)) {
try {
InputStream inputStream = getClass().getClassLoader().getResourceAsStream("fonts/" + fontName + ".ttf");
Font awtFont = Font.createFont(Font.TRUETYPE_FONT, inputStream);
awtFont = awtFont.deriveFont(fontSize);
fontsCache.put(key, new TrueTypeFont(awtFont, true));
} catch (Exception e) {
log.error("Nie można wczytać fontu", e);
}
}
}
@Override
public TrueTypeFont getFontResource(String fontName, Float fontSize) {
FontKey key = new FontKey(fontName, fontSize);
if (!fontsCache.containsKey(key)) {
preloadFont(fontName, fontSize);
}
return fontsCache.get(key);
}
Oczywiście musimy zmodyfikować także miejsca, w których wyświetlamy tekst, aby przekazywać informacje o rozmiarze.
Pozbywamy się magicznych wartości
‘Magiczne wartości’ to potoczne określenie na wszelkie wartości stałe w kodzie (np. liczby, zakresy, ciągi znaków), które nie są poprawnie wyciągnięte do ogólnodostępnej stałej. Nie jest to problem sam w sobie, a bardziej ryzyko na przyszłość — po pierwsze, takie wartości nie są opisane, a więc ich wartość może być trudna do zrozumienia dla innych. Po drugie, często taka wartość powtarza się kilkukrotnie w kodzie — zmiana jej, wymaga zmiany we wszystkich miejscach, co może prowadzić do dziwnych błędów, jeśli o jakimś zapomnimy.
W naszej grze owe magiczne wartości pojawiają się głównie w klasie Game. Przenieśmy je po prostu do publicznych stałych:
public class Game implements GameManager {
private static final int WINDOW_HEIGHT = 600;
private static final int WINDOW_WIDTH = 800;
private static final int PROJECTION_HEIGHT = 480;
private static final int PROJECTION_WIDTH = 640;
//... pozostałe metody, z wartościami podmienionymi na powyższe stałe
}
Uwaga: nie znaczy to, że każdy String, który deklarujesz w kodzie musi być stałą! Są elementy, które jesteśmy pewni, że nie powinny być używane w innych miejscach (np. nazwa katalogu z plikami czionek) — nie ma potrzeby ich ‘wyciągania’, ponieważ żaden inny komponent nie powinien z nich korzystać. W naszym przypadku wymiary ekranu i projekcji mogą być używane do wyświetlania grafik w określony sposób, dlatego warto je wyciągnąć.
Zarządzanie zależnościami i ich wersjami
Ostatni problem, który poprawimy, to wersje bibliotek — np. Slick2D. Obecnie numery wersji zaszyte są w kilku różnych POMach — wyciągniemy je do głównego, dzięki czemu w przyszłości aktualizując wersje, wystarczy je zmienić w jednym miejscu.
W pliku pom.xml głównego modułu (footrzasta), do sekcji <dependencyManagement> dodajemy poniższe elementy:
<dependency>
<groupId>org.slick2d</groupId>
<artifactId>slick2d-core</artifactId>
<version>1.0.1</version>
</dependency>
<dependency>
<groupId>org.lwjgl.lwjgl</groupId>
<artifactId>lwjgl</artifactId>
<version>2.9.3</version>
</dependency>
A następnie usuwamy numery wersji z plików pom.xml modułów (game-model oraz game-views w naszym przypadku).
Struktura menu
W naszej grze potrzebne nam będą 2 całkowicie oddzielne menu:
- Menu gry — zwane też menu głównym, gdzie będą dostępne opcje (jak np. ustawienia audio), zapisane stany gry oraz możliwość tworzenia nowej gry, kończenia pracy aplikacji i podglądu okna ‘o autorach’
- Menu pauzy — menu wyświetlane kiedy ‘wstrzymamy’ grę, a w którym powinniśmy mieć dostępne opcje takie jak powrót do menu głównego czy opcje gry
Przejścia powinny być możliwe do nawigacji — tzn. przechodząc z głównego menu do submenu i do jego submenu, powinniśmy być w stanie ‘cofnąć się’ w strukturze. W obecnym kodzie możemy to obsługiwać w metodzie closeMenu(), na sztywno okreslając widok, który chcemy wyświetlić. W tej części zbudujemy nieco bardziej uniwersalny mechanizm, który pozwoli nam wykorzystywać pewne sekcje menu ponownie.
Drzewo menu
Aby mieć konkretny plan implementacji, zaprojektujmy układ każdego z menu. Każdy ‘poziom’ będzie reprezentowany przez osobny obiekt w naszej aplikacji.
Menu główne:
- Uruchom gre
- Nowa gra
- Zapisana gra 1
- Zapisana gra 2
- …
- Praktyka
- Poziom pierwszy
- Poziom drugi
- .…
- Ustawienia
- Poziom trudności: łatwy/trudny
- Dżwięk: włącz/wyłacz
- Wyjście
- Tak
- Nie
Menu ‘pauzy’:
- Powrót do gry
- Menu główne
- …
- Dźwięk: włącz/wyłącz
- Wyjście
- Tak
- Nie
Nawigacja pomiędzy menu (widokami)
W miejsce dotychczasowej nawigacji (gdzie musieliśmy sztywno określić, jaki widok ma zostać wyświetlony) wprowadzimy coś na kształt historii — będziemy pamiętać w jakiej kolejności poszczególne widoki były wyświetlane, aby móc ‘cofać’ się pomiędzy nimi w bardziej inteligentny sposób.
Zasada działania będzie banalnie prosta — wykorzystamy do tego stos jako strukturę i udostępnimy metody do zarządzania nim. Przede wszystkim modyfikujemy metodę displayView, aby odkładała na stosie bieżący widok:
private Deque<View> history = new ArrayDeque<>();
public void displayView(String viewName) {
if (views.containsKey(viewName)) {
history.push(currentView);
currentView = views.get(viewName);
} else {
log.error("Cannot find view {}, ignoring", viewName);
}
}
A następnie dodajemy metody pozwalające ‘cofać się’ w historii oraz czyścić historię.
public void displayPrevious() {
if (!history.isEmpty()) {
currentView = history.pop();
} else {
log.debug("Views history stack is empty, no action");
}
}
public void resetHistory() {
history.clear();
}
Oczywiście sygnatury powyższych metod dodajemy także do interfejsu ViewManager.
Ostatnim krokiem jest modyfikacja istniejących widoków — zamiast wyświetlać określony widok po naciśnięciu klawisza Esc, będziemy się ‘cofać’. Nie będzie nam już potrzebna metoda closeMenu, usuwamy ją zatem z AbstractMenu oraz wyrzucamy jej implementacje. W metodzie obsługi klawiatury zamieniamy jej wywołanie na powrót do porzedniego widoku:
@Autowired
protected ViewManager viewManager;
public void handleKey(KeyboardKey key) {
switch (key){
case KEY_ENTER:
getElements().get(currentElementIndex).getAction().doAction();
break;
case KEY_ESCAPE:
viewManager.displayPrevious();
break;
case KEY_UP:
currentElementIndex = Math.floorMod(currentElementIndex-1, getElements().size());
break;
case KEY_DOWN:
currentElementIndex = Math.floorMod(currentElementIndex+1, getElements().size());
break;
}
}
Podsumowanie
W tej części uporządkowaliśmy nieco kod i rozbudowaliśmy możliwości naszej gry — możemy już wyświetlać napisy w dowolnym rozmiarze, a nawigacja jest bardziej elastyczna. Mamy też gotową strukturę menu, dzięki której będziemy mogli zacząć implementować właściwe poziomy.
Pomimo, że to dopiero trzecia część cyklu, jak sama widzisz już uzbierało nam się trochę długu technicznego. To nic nadzwyczajnego — często w trakcie implementacji odkrywamy, że coś można zrobić inaczej niż planowaliśmy, o czym innym zapominamy, a jeszcze coś okazuje się niemożliwe/zbyt pracochłonne. Ważne jest jednak, aby co jakiś czas robić przegląd całego kodu i usuwać znalezione problemy — im dłużej będziesz zwlekać, tym bardziej problematyczne się one staną.