3. Footrzasta — porządki

By 21 February 2016 Footrzasta

Do tej pory rozwi­jal­iśmy naszą grę pod kątem poje­dynczych funkcji. Pomi­mo tego, że dopiero zaczęliśmy — już zdążyliśmy nagro­madz­ić niewiel­ki dług tech­niczny. W tej częś­ci pozbędziemy się go oraz zapro­jek­tu­je­my menu aplikacji, z czego wyniknie mały refak­tor­ing kodu.

Choć ‘dług tech­niczny’ brz­mi bard­zo negaty­wnie, jest to zjawisko nat­u­ralne — więcej o tym jak pow­sta­je i jak sobie z nim radz­ić zna­jdziesz w osob­nym wpisie, póki co zajmi­jmy się jdego iden­ty­fikacją w naszym projekcie.

Identyfikujemy potencjalne problemy

W naszym przy­pad­ku może­my wskazać kil­ka prob­lemów związanych z długiem technicznym:

  1. Wczy­tu­jąc czcion­ki, robimy to w ser­wisie TextSer­vice, choć wydzielil­iśmy osob­ny ser­wis do wczy­ty­wa­nia zasobów gry
  2. Nie może­my zmienić wielkoś­ci czcion­ki, jest predefiniowana
  3. W kilku miejs­cach przewi­ja­ją nam się ‘mag­iczne wartoś­ci’, które w przyszłoś­ci mogą powodować dup­likac­je w kodzie — przykła­dem jest sze­rokość i wysokość ekranu w jednostkach
  4. Zależnoś­ci w kilku mod­ułach pow­tarza­ją się, za każdym razem wpisu­je­my numer wer­sji, co może prowadz­ić do pomyłek w przyszłości

Zajmi­jmy się każdym z tych prob­lemów po kolei.

Zarządzanie zasobami — wczytywanie czcionek

W obec­nym kodzie, pomi­mo wydzie­le­nia ser­wisu do zarządza­nia zasoba­mi, nadal wczy­tu­je­my czcion­ki w TextSer­vice. Zmien­imy to w taki sposób, aby wszys­tkie oper­ac­je związane z wczy­ty­waniem plików i zasobów odby­wały się poprzez nasz mod­uł zarządza­nia zasoba­mi, a TextSer­vice 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 kopi­u­je­my obec­nie uży­wany 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 krok­iem jest mały refac­tor­ing naszego kodu — podob­nie jak w przy­pad­ku obrazów chce­my mieć dwie metody — wczy­ty­wa­nia fontu z pliku, jeśli nie była wczy­tana, oraz zwraca­nia go z pamię­ci podręcznej.

Dzie­limy więc metodę get­Font 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);
}

Doda­je­my 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łu­gi czcionek z TextSer­vice i zastąpić go wywołaniem Resource­M­an­ag­er. Do tego musimy pobrać obiekt ResourcesManager:

@Autowired
private ResourcesManager resourcesManager;

oraz pod­mienić wywołanie get­Font() na odpowied­nie wywołanie metody obiek­tu resourcesManager:

TrueTypeFont font = resourcesManager.getFontResource(fontName);

Umożliwienie zmiany rozmiaru czcionki

W naszej aplikacji na tą chwilę założyliśmy stałą wielkość czcion­ki — 24. Nie jest to jed­nak opty­malne, w różnych miejs­cach naszej gry w przyszłoś­ci może­my chcieć sko­rzys­tać z różnych rozmiarów.

Przede wszys­tkim musimy zatem zmienić sposób, w jaki wczy­tu­je­my i prze­chowu­je­my czcion­ki. W tym wypad­ku kluczem naszej mapy, na której prze­chowu­je­my czcion­ki, będą dwa ele­men­ty — nazwa czcion­ki oraz jej rozmi­ar. Może­my to rozwiązać na trzy sposoby:

  • pozostaw­ić struk­turę bez zmi­an, rozmi­ar czcion­ki włączyć do ‘klucza’ mapy (obec­nie String) — to rozwiązanie najprost­sze, ale niezbyt elasty­czne — zami­ana infor­ma­cji na klucz do mapy wyma­ga zna­jo­moś­ci algo­ryt­mu; w drugą stronę — odczy­tanie infor­ma­cji o nazwie czcion­ki i jej rozmi­arze — powodowało­by konieczność stosowa­nia wyrażeń reg­u­larnych lub innych tech­nik przetwarza­nia ciągów znaków
  • zamienić klucz mapy na obiekt, który będzie praw­idłowo prze­chowywał obie wartoś­ci — ta opc­ja wyma­ga min­i­mal­nego przepisa­nia kodu, ale jest bard­zo elasty­cz­na na przyszłość — może­my dodawać np. nowe pola
  • zamienić mapowanie na hier­ar­chię map — głów­na mapa mapowała­by nazwę czcion­ki na kole­jną mapę — tym razem rozmi­arów; takie pode­jś­cie skom­p­likowało by bardziej cały kod, a dodanie kole­jnych para­metrów wyma­gało­by dodawa­nia kole­jnych ‘poziomów’.

Dru­ga opc­ja wyda­je się najsen­sown­iejsza — nie będzie wyma­gała dużej iloś­ci zmi­an, i poz­woli na jej rozsz­erzanie w przyszłoś­ci. Zaim­ple­men­tu­jmy więc klasę, która poz­woli nam prze­chowywać wspom­ni­ane infor­ma­c­je i będzie kluczem mapy. Ponieważ będzie ona uży­wana tylko wewnątrz ser­wisu, może być ona klasą wewnętrzną (czyli widoczną tylko w ramach jed­nej 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ęp­nie mody­fiku­je­my kod metody, zmieni­a­jąc od razu jej syg­naturę (pamię­ta­jmy też o zmi­an­ie syg­natu­ry 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);
}

Oczy­wiś­cie musimy zmody­fikować także miejs­ca, w których wyświ­et­lamy tekst, aby przekazy­wać infor­ma­c­je o rozmiarze.

Pozbywamy się magicznych wartości

Mag­iczne wartoś­ci’ to potoczne określe­nie na wszelkie wartoś­ci stałe w kodzie (np. licz­by, zakresy, cią­gi znaków), które nie są poprawnie wyciąg­nięte do ogóln­o­dostęp­nej stałej. Nie jest to prob­lem sam w sobie, a bardziej ryzyko na przyszłość — po pier­wsze, takie wartoś­ci nie są opisane, a więc ich wartość może być trud­na do zrozu­mienia dla innych. Po drugie, częs­to taka wartość pow­tarza się kilkukrot­nie w kodzie — zmi­ana jej, wyma­ga zmi­any we wszys­t­kich miejs­cach, co może prowadz­ić do dzi­wnych błędów, jeśli o jakimś zapomnimy.

W naszej grze owe mag­iczne wartoś­ci pojaw­ia­ją się głównie w klasie Game. Prze­nieśmy je po pros­tu do pub­licznych 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
}

Uwa­ga: nie znaczy to, że każdy String, który deklaru­jesz w kodzie musi być stałą! Są ele­men­ty, które jesteśmy pewni, że nie powin­ny być uży­wane w innych miejs­cach (np. nazwa kat­a­logu z plika­mi czionek) — nie ma potrze­by ich ‘wycią­ga­nia’, ponieważ żaden inny kom­po­nent nie powinien z nich korzys­tać. W naszym przy­pad­ku wymi­ary ekranu i pro­jekcji mogą być uży­wane do wyświ­et­la­nia grafik w określony sposób, dlat­ego warto je wyciągnąć.

Zarządzanie zależnościami i ich wersjami

Ostat­ni prob­lem, który popraw­imy, to wer­sje bib­liotek — np. Slick2D. Obec­nie numery wer­sji zaszyte są w kilku różnych POMach — wyciąg­niemy je do głównego, dzię­ki czemu w przyszłoś­ci aktu­al­izu­jąc wer­sje, wystar­czy je zmienić w jed­nym miejscu.

W pliku pom.xml głównego mod­ułu (footrza­s­ta), do sekcji <depen­den­cy­Man­age­ment> doda­je­my 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ęp­nie usuwamy numery wer­sji z plików pom.xml mod­ułów (game-mod­el oraz game-views w naszym przypadku).

Struktura menu

W naszej grze potrzeb­ne nam będą 2 całkowicie odd­zielne menu:

  • Menu gry — zwane też menu głównym, gdzie będą dostęp­ne opc­je (jak np. ustaw­ienia audio), zapisane stany gry oraz możli­wość tworzenia nowej gry, kończenia pra­cy aplikacji i podglą­du okna ‘o autorach’
  • Menu pauzy — menu wyświ­et­lane kiedy ‘wstrzy­mamy’ grę, a w którym powin­niśmy mieć dostęp­ne opc­je takie jak powrót do menu głównego czy opc­je gry

Prze­jś­cia powin­ny być możli­we do naw­igacji — tzn. prze­chodząc z głównego menu do sub­menu i do jego sub­menu, powin­niśmy być w stanie ‘cofnąć się’ w struk­turze. W obec­nym kodzie może­my to obsługi­wać w metodzie close­Menu(), na szty­wno okres­la­jąc widok, który chce­my wyświ­etlić. W tej częś­ci zbudu­je­my nieco bardziej uni­w­er­sal­ny mech­a­nizm, który poz­woli nam wyko­rzysty­wać pewne sekc­je menu ponownie.

Drzewo menu

Aby mieć konkret­ny plan imple­men­tacji, zapro­jek­tu­jmy układ każdego z menu. Każdy ‘poziom’ będzie reprezen­towany przez osob­ny obiekt w naszej aplikacji.

Menu główne:

  • Uru­chom gre 
    • Nowa gra
    • Zapisana gra 1
    • Zapisana gra 2
  • Prak­ty­ka
    • Poziom pier­wszy
    • Poziom dru­gi
    • .…
  • Ustaw­ienia
    • Poziom trud­noś­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 doty­chcza­sowej naw­igacji (gdzie musieliśmy szty­wno określić, jaki widok ma zostać wyświ­et­lony) wprowadz­imy coś na ksz­tałt his­torii — będziemy pamię­tać w jakiej kole­jnoś­ci poszczególne wido­ki były wyświ­et­lane, aby móc ‘cofać’ się pomiędzy nimi w bardziej inteligent­ny sposób.

Zasa­da dzi­ała­nia będzie banal­nie pros­ta — wyko­rzys­tamy do tego stos jako struk­turę i udostęp­n­imy metody do zarządza­nia nim. Przede wszys­tkim mody­fiku­je­my metodę dis­playView, 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ęp­nie doda­je­my metody pozwala­jące ‘cofać się’ w his­torii 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();
}

Oczy­wiś­cie syg­natu­ry powyższych metod doda­je­my także do inter­fe­j­su ViewManager.

Ostat­nim krok­iem jest mody­fikac­ja ist­nieją­cych widoków — zami­ast wyświ­et­lać określony widok po naciśnię­ciu klaw­isza Esc, będziemy się ‘cofać’. Nie będzie nam już potrzeb­na meto­da close­Menu, usuwamy ją zatem z Abstract­Menu oraz wyrzu­camy jej imple­men­tac­je. W metodzie obsłu­gi klaw­iatu­ry zamieni­amy jej wywołanie na powrót do porzed­niego 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ąd­kowal­iśmy nieco kod i rozbu­dowal­iśmy możli­woś­ci naszej gry — może­my już wyświ­et­lać napisy w dowol­nym rozmi­arze, a naw­igac­ja jest bardziej elasty­cz­na. Mamy też gotową struk­turę menu, dzię­ki której będziemy mogli zacząć imple­men­tować właś­ci­we poziomy.

Pomi­mo, że to dopiero trze­cia część cyk­lu, jak sama widzisz już uzbier­ało nam się trochę długu tech­nicznego. To nic nadzwycza­jnego — częs­to w trak­cie imple­men­tacji odkry­wamy, że coś moż­na zro­bić inaczej niż planowal­iśmy, o czym innym zapom­i­namy, a jeszcze coś okazu­je się niemożliwe/zbyt pra­cochłonne. Ważne jest jed­nak, aby co jak­iś czas robić przegląd całego kodu i usuwać znalezione prob­le­my — im dłużej będziesz zwlekać, tym bardziej prob­lematy­czne się one staną.