Let's Have an Agreement

(A.k.a. Let's rewrite all the things!)

Motivácia

Kadet! Od získania hodnosti Trainee ťa delí už len posledná tréningová misia. Jej zvládnutie je pre tvoje prežitie vo svete OOP veľmi dôležité! Najskôr ti ukážeme, ako sa pripraviť na prácu bez inšpektora. A - čo je mimoriadne dôležité - dostaneme sa k ďalšej stránke polymorfizmu, ktorou je spôsob, ako sa dá s určitou skupinou typov objektov dohodnúť, ak ich naučíš rozumieť určitému rozhraniu.

V dnešnej misii pre teba náš taktický a strategický tím pripravil dva ciele zamerané na princípy OOP a jeden praktický cieľ, ktorým je refaktorizácia. Všetci dúfame, že v nej preukážeš svoje schopnosti, ktoré si získal počas tréningu a budeš tak pripravený na operácie v teréne.

Veľa šťastia pri zdolávaní cieľov! Z veliteľského mostíka zdraví Manager.

Upozornenie

Skôr než začnete prácu na tomto cvičení, aktualizujte si hernú knižnicu, ktorú v projekte používate, na verziu 2.2.0 podľa tohto postupu.

Poznámka

Aktualizácie cvičenia
  • 30.10.2018
    • Rozšírený popis lambda výrazov a príklad použitia akcie When v úlohe 1.5.
  • 23.10.2018
    • Štvrtý krok a prvá doplňujúca úloha boli spojené, keďže spolu úzko súvisia.
    • Metóda repair v rozhraní Repairable (súčasť 4. kroku) má teraz typ návratovej hodnoty boolean.

Ciele

  • Rozhrania
  • Polymorfizmus
  • Refaktorizácia

Postup

Krok 1: Lather, Rinse, Repeat

Určite ste už zaregistrovali, že na overenie správnosti postupu pri riešení misií potrebujete opakovať čím ďalej tým viac operácií po spustení aplikácie: povytvárať potrebné objekty a prepojenia medzi nimi, kontrolovať správanie po volaní metód. Okrem zdĺhavosti celého postupu hrozí aj to, že v reálnom svete bez inšpektora sa budete orientovať s problémami. S cieľom vyhnúť sa tejto situácií teraz predstavíme spôsob, ako scenáre pre testovanie misie vytvárať priamo v kóde.

Úloha 1.1

V balíku sk.tuke.kpi.oop.game vytvorte triedu Gameplay, ktorá bude dediť od triedy Scenario dostupnej v hernej knižnici a prekryte jej abstraktnú metódu setupPlay(Scene).

Rámec postavený nad knižnicou GameLib, ktorú v projekte používame, vyhľadá pri spustení aplikácie implementáciu triedy Scenario a zaregistruje ju ako tzv. listener (resp. event handler) na scéne. Pripravená metóda setupPlay() je zavolaná hneď po tom, ako je v scéne inicializovaná mapa a actori v nej definovaní - zatiaľ je to len postava hráča (Player). V tejto metóde máme ale možnosť vkladať do scény ďalších actorov a plánovať akcie, ktoré budú vykonávať. To znamená, že môžeme písať scenár.

Úloha 1.2

V scenári pridajte do scény reaktor a zapnite ho.

Pre pridanie nového actora do scény potrebujete vytvoriť (alebo už mať k dispozícii) inštanciu daného actora a pridať ho do scény na nejakú pozíciu (t.j. definovať umiestnenie ľavého dolného rohu animácie actora voči ľavému dolnému rohu mapy scény).

Pridanie a zapnutie reaktora teda môže byť zapísané takto:

Reactor reactor = new Reactor();
scene.addActor(reactor, 64, 64);
reactor.turnOn();

Implementáciu si overte spustením aplikácie. Reaktor by mal byť hneď po štarte umiestnený na danej pozícií a mal by byť zapnutý.

Úloha 1.3

Využite značky (angl. markers) v mape na umiestňovanie actorov na preddefinované pozície.

Mapa scény, ktorú používame, obsahuje niekoľko značiek, ktoré môžu uľahčiť umiestňovanie actorov. Tieto značky sú typu MapMarker a je ich možné získať z mapy scény metódou getMarkers():

Map<String, MapMarker> markers = scene.getMap().getMarkers();

Poznámka

Typ Map (java.util.Map<K, V>) reprezentuje údajovú štruktúru zobrazenie obsahujúcu záznamy pozostávajúce z kľúča (key) typu K a hodnoty (value) typu V. V uvedenom prípade toto zobrazenie obsahuje kľúče definované reťazcami (String) s názvami jednotlivých značiek, ku ktorým sú priradené dané značky (MapMarker).

Mapa scény obsahuje niekoľko takýchto značiek, ktorých mená a umiestnenie sú zobrazené na nasledovnom obrázku.

MapMarker objekty v mape scény.
Obr. 1: MapMarker objekty v mape scény.

Umiestnenie reaktora do scény teda môžeme pomocou značiek prepísať takto:

// ziskanie referencie na marker nazvany "reactor-area-1"
MapMarker reactorArea1 = markers.get("reactor-area-1");

//umiestnenie reaktora na poziciu markera
scene.addActor(reactor, reactorArea1.getPosX(), reactorArea1.getPosY());

Úloha 1.4

Pridajte do scény chladič a zapnite ho 5 sekúnd po spustení scény.

Akciu, ktorou je možné zapnúť chladič, už poznáte z minulého cvičenia. V knižnici je dostupná aj akcia Wait, ktorá v konštruktore akceptuje dĺžku trvania čakania (v sekundách). Avšak, pre riešenie úlohy potrebujeme pridať akciu zapnutia chladiča za akciu čakania. Za týmto účelom vieme použiť ďalšiu akciu z knižnice - ActionSequence, ktorá v konštruktore akceptuje akcie, ktoré bude vykonávať sekvenčne, jednu po druhej.

new ActionSequence<>(
    new Wait(5),
    // akcia pre zapnutie chladica
);

Úloha 1.5

Pomocou existujúcich akcií zapíšte scenár pre opravu prehriateho reaktora kladivom v momente, keď teplota reaktora dosiahne hodnotu 3000 stupňov.

Pri riešení tejto úlohy je potrebné akciou zavolať metódu repairWith() na reaktore. Táto metóda však očakáva jeden argument - kladivo - a teda ju nie je možné odovzdať vo forme referencie na metódu do akcie Invoke (nebude sedieť typovo).

Akcia Invoke však rovnako ako referenciu na bezparametrickú void metódu akceptuje aj jej ekvivalent: metódu zapísanú na mieste vo forme lambda výrazu.

new Invoke(() -> {
    reactor.repairWith(hammer);
});

Poznámka

Lambda výrazy je možné použiť všade tam, kde sa očakáva objekt typu rozhrania s práve jednou abstraktnou metódou - tzv. funkcionálne rozhranie. Namiesto toho, aby sme museli implementovať celú triedu s jednou metódou z toho rozhrania, vieme využiť lambda výraz. Lambda výraz teda predstavuje implementáciu tej jednej abstraktnej metódy a signatúra lambda výrazu (typy parametrov a návratovej hodnoty) musí súhlasiť so signatúrou metódy v rozhraní.
Akcia Invoke očakáva ako argument konštruktora objekt typu Runnable, čo je práve rozhranie s jednou metódou void run().

Pre riešenie druhej časti úlohy (detekcia momentu, kedy teplota reaktora dosiahne danú teplotu) môžeme zas využiť existujúcu akciu When. Prvým argumentom konštruktora akcie When je tzv. predikát - funkcia (lambda výraz) so signatúrou boolean test(Action action), ktorá v parametri dostane testovanú akciu a má vrátiť boolovskú hodnotu. Predikát sa testuje neustále od momentu naplánovania akcie When. V momente, keď prvýkrát vráti true, akcia definovaná v druhom argumente jeho konštruktora bude vykonaná.

Zápis akcie When v kontexte, kde máme k dispozícií objekty reaktora a kladiva, teda môže vyzerať nasledovne:

new When<>(
    (action) -> {
        return reactor.getTemperature() >= 3000;
    },
    new Invoke(() -> {
        reactor.repairWith(hammer)
    })
).scheduleOn(reactor);

A keďže telá oboch metód zapísaných v lambdách pozostávajú len z jedného výrazu, môžeme zápis skrátiť do nasledovnej ekvivalentnej podoby:

new When<>(
    action -> reactor.getTemperature() >= 3000,
    new Invoke(() -> reactor.repairWith(hammer))
).scheduleOn(reactor);

Úloha 1.6

Navrhnite vlastný scenár, ktorým namodelujete správanie niekoľkých navzájom prepojených actorov.

Zápisom rôznych prípadov použitia existujúcich actorov sa snažte minimalizovať použitie inšpektora.

Poznámka

Aj pri nasledujúcich úlohách využívajte možnosti zapísať použitie actorov do scenára. Scenár čleňte na metódy modelujúce jednotlivé prípady použitia. V metóde setupPlay() potom len uveďte, ktorú metódu chcete použiť.

Krok 2: Switchable

Doteraz sme vedeli ovládať vypínačom len jeden typ zariadení (objektov). Tentokrát sa však pokúsite "prehovoriť" a "dohodnúť" aj s ďalšími objektami, aby sa dali ovládať. Všetko je len otázkou, akú dohodu vo forme rozhrania im ponúknete.

Vzťah tried k rozhraniu Switchable.
Obr. 2: Vzťah tried k rozhraniu Switchable.

Úloha 2.1

V balíčku sk.tuke.kpi.oop.game vytvorte rozhranie Switchable.

Signatúry metód v tomto rozhraní a ich význam je nasledovný:

  • void turnOn() - metóda zapne ovládané zariadenie
  • void turnOff() - metóda vypne ovládané zariadenie
  • boolean isOn() - metóda vráti hodnotu, ktorá reprezentuje stav zariadenia (true - zariadenie je zapnuté, false - zariadenie je vypnuté)

Úloha 2.2

Upravte triedu Reactor tak, aby implementovala rozhranie Switchable.

Metódy, ktoré majú byť súčasťou rozhrania, už v triede máte. Správanie metódy isOn() máte ale implementované metódou isRunning(). Použite refaktorizáciu na premenovanie tejto metódy.

Úloha 2.3

Premenujte triedu Controller na PowerSwitch. Nech reprezentuje vypínač, ktorý vie zapínať a vypínať akékoľvek zariadenia implementujúce rozhranie Switchable.

Pri premenovaní triedy využite možnosti refaktorizácie vo vývojovom prostredí.

Upravte implementáciu triedy PowerSwitch tak, aby mala nasledovné verejné metódy:

  • getDevice() - poskytuje referenciu na pripojené zariadenie
  • switchOn() - zapína pripojené zariadenie
  • switchOff() - vypína pripojené zariadenie

Poznámka

Pre grafické odlíšenie vypínača vo vypnutej polohe (keď je pripojené zariadenie vypnuté) môžete využiť metódu setTint() na jeho animácii, ktorá jej pridá zafarbenie podľa definovanej farby. Napríklad použitím sivej farby sa utlmí farebnosť animácie:
getAnimation().setTint(Color.GRAY);
Zafarbenie zrušíte aplikovaním bielej farby.

Úloha 2.4

Overte správnosť svojej implementácie vytvorením inštancie reaktora a inštancie triedy PowerSwitch, pomocou ktorej budete vedieť reaktor zapínať a vypínať.
Reaktor zapnutý pomocou PowerSwitch-a.
Obr. 3: Reaktor zapnutý pomocou PowerSwitch-a.

Úloha 2.5

Upravte triedy Cooler a Light tak, aby implementovali rozhranie Switchable.

Pokiaľ tieto triedy už majú implementované požadované metódy, pridajte im anotáciu @Override. Ak majú podobné metódy (funkcionalitou), len ich premenujte. Ak však tieto metódy vôbec neexistujú, vytvorte ich.

Úloha 2.6

Overte správnosť svojej implementácie.

Správnosť overíte tak, že vytvoríte inštancie uvedených tried a pre každú inštanciu vytvoríte aj príslušný vypínač, pomocou ktorého ho budete vedieť ovládať.

Reaktor, svetlo a inteligentné chladiče ovládané pomocou PowerSwitch-ov.
Obr. 4: Reaktor, svetlo a inteligentné chladiče ovládané pomocou PowerSwitch-ov.

Krok 3: Producer/consumer

Reaktor dokáže v súčasnosti napájať len inštancie triedy Light. Vašou úlohou je pripraviť vhodný "štandard" pre napájanie ľubovoľných zariadení (navrhnúť vzájomne kompatibilné "zásuvky" a "zástrčky").

Vzťah tried k rozhraniu EnergyConsumer.
Obr. 5: Vzťah tried k rozhraniu EnergyConsumer.

Úloha 3.1

V balíčku sk.tuke.kpi.oop.game vytvorte rozhranie EnergyConsumer.

Rozhranie EnergyConsumer bude mať len jednu metódu. Jej signatúra a význam je nasledovný:

  • void setPowered(boolean) - pomocou tejto metódy bude výrobca energie oznamovať zariadeniam, že energia je, resp. nie je dodávaná.

Úloha 3.2

Upravte triedu Light tak, aby implementovala rozhranie EnergyConsumer.

Úloha 3.3

V triede Reactor vytvorte z metód addLight() a removeLight() metódy addDevice() a removeDevice(), pomocou ktorých bude možné k reaktoru pripojiť zariadenia implementujúce rozhranie EnergyConsumer.

Úloha 3.4

Overte správnosť svojej implementácie.

Pokiaľ ste postupovali správne, výsledná funkcionalita sa výrazne nezmení - stále bude svetlo jediným spotrebičom, ktorý je možné pripojiť. Tentokrát ho však reaktor nebude vidieť ako objekt typu Light, ale ako objekt typu EnergyConsumer.

Úloha 3.5

Upravte triedu Computer tak aby implementovala rozhranie EnergyConsumer.

Počítač bude fungovať len v prípade, že je napájaný elektrinou. V opačnom prípade pracovať nebude (pozastaví prehrávanie animácie a výsledky všetkých operácií budú mať hodnotu 0).

Úloha 3.6

Upravte reaktor tak, aby mohol napájať viacero zariadení.

Referencie na objekty týchto zariadení nech sa ukladajú do množiny. Využite pritom nasledovný kód na jej vytvorenie:

// deklaracia clenskej premennej pre mnozinu pripojenych zariadeni
private Set<EnergyConsumer> devices;

// vytvorenie instancie mnoziny v konstruktore
// (typovy parameter pre HashSet je odvodeny na zaklade typu premennej devices)
devices = new HashSet<>();

Poznámka

Použitie množiny oproti štandardnému zoznamu (typ List) zabezpečí, že jedno konkrétne zariadenie nebude k reaktoru pripojené viackrát.

Patrične upravte metódy addDevice() a removeDevice() v triede Reactor. Pre pridanie zariadenia do množiny použite volanie metódy add() na objekte množiny. Metódu removeDevice() upravte na parametrickú, pričom parametrom nech je objekt typu EnergyConsumer. Pre odobratie zariadenia z množiny použite volanie metódy remove(), ktorej v parametri predáte konkrétny objekt, ktorý sa má z množiny odobrať.

Úloha 3.7

Overte správnosť svojej implementácie.

Ak ste postupovali správne, budete vedieť k reaktoru tentokrát pripojiť nie len inštancie triedy Light, ale aj inštancie triedy Computer. Tu sa prejavuje polymorfizmus využitím rozhraní.

Reaktorom napájaný počítač.
Obr. 6: Reaktorom napájaný počítač.

Krok 4: Useful refactoring

V rámci prípravy na reálny svet je určite potrebné zamerať sa aj na precvičenie tzv. refaktorizácie, čo je vlastne zmena štruktúry už implemetovaného kódu. K takým zmenám dochádza často buď po zmene požiadaviek, alebo preto, že objavíme v implementácii nejaké nedostatky. V tomto prípade pôjde o zovšeobecnenie nástrojov na použiteľných actorov a o otočenie vzťahu medzi iniciátorom použitia a používaným objektom. Rozhraním rozlíšime aj skupinu actorov, ktoré je možné opraviť.

Úloha 4.1

Do balíka sk.tuke.kpi.oop.game.tools pridajte rozhranie Usable reprezentujúce použiteľných actorov - nástroje.

V rozhraní definujte jednu metódu so signatúrou void useWith(T actor), kde T reprezentuje typový parameter viazaný na podtypy Actor-a. Actor dodaný v parametri metódy useWith() bude slúžiť na dodefinovanie kontextu použitia Usable actora.

Úloha 4.2

Upravte abstraktnú triedu BreakableTool tak, aby implementovala rozhranie Usable a aby umožnila dodefinovať typový parameter vo svojich konkrétnych implementáciách (podtriedach).

Metódu use() z pôvodnej implementácie BreakableTool upravte tak, aby prekrývala metódu useWith() z rozhrania Usable. Nezabudnite na anotáciu @Override.

Úloha 4.3

Upravte konkrétne implementácie triedy BreakableTool tak, aby boli kompatibilné s úpravami z predošlej úlohy.

Typovým parametrom pre BreakableTool špecifikujte typ actora, s ktorým môže daný nástroj pracovať (ktorý vie opraviť; napr. kladivo vie opraviť reaktor).

V jednotlivých nástrojoch prekryte implementáciu metódy useWith() a vykonajte príslušnú opravu.

Nezabudnite upraviť metódy opravy reaktora repairWith() a extinguishWith(), ktoré teraz premenujte na repair() a extinguish(), keďže opravu vyvolajú samotné nástroje kladivo, resp. hasiaci prístroj. Pri týchto upravených metódach využite návratovú hodnotu boolean na signalizáciu úspešnosti, resp. neúspešnosti použitia nástroja.

Úloha 4.4

Pridajte rozhranie Repairable reprezentujúce opraviteľných actorov.

V rozhraní definujte metódu so signatúrou boolean repair(). Návratová hodnota bude vyjadrovať úspešnosť, resp. neúspešnosť opravy.

Úloha 4.5

Upravte triedu Reactor nech implementuje rozhranie Repairable.

Úloha 4.6

Upravte triedu DefectiveLight tak, aby tiež implementovala rozhranie Repairable.

Oprava svetla (svetlo prestane blikať) však vydrží len 10 sekúnd a potom sa svetlo opäť pokazí.

Poznámka

Metódy scheduleAction() na scéne a scheduleOn() na akcii vracajú objekt typu Disposable. Zavolanie metódy dispose() na takomto objekte zruší naplánovanie, resp. preruší vykonávanie akcie, ktorá bola volaním danej schedule* metódy naplánovaná. Možnosť zrušiť skôr naplánované akcie sa vám zíde pri riešení tejto úlohy.

Úloha 4.7

Vytvorte triedu Wrench pre francúzsky kľúč, ktorý bude podobne, ako kladivo, použiteľný na opravu pokazených zariadení - konkrétne zariadenia DefectiveLight.

Nech Wrench rozširuje triedu BreakableTool a má 2 použitia. Pre jeho grafickú reprezentáciu použite obrázok wrench.png.

Doplňujúce úlohy

Úloha A.1

Vytvorte triedu TimeBomb, ktorá bude predstavovať časovanú bombu.

Trieda by mala obsahovať:

  • parametrický konštruktor, pomocou ktorého nastavíte čas (v sekundách, typ float), ktorý má uplynúť od aktivácie bomby po jej detonáciu,
  • verejnú metódu activate(), ktorá bude slúžiť na aktiváciu bomby, pri ktorej sa spustí odpočítavanie do výbuchu. Pre animáciu bomby pred aktiváciou použite obrázok bomb.png. Pri aktivácii nech bomba začne iskriť (použite obrázok bomb_activated.png).
  • verejnú metódu boolean isActivated(), ktorá vráti, či je bomba aktuálne aktivovaná.

Pri detonácii bomby použite animáciu small_explosion.png (stiahnite si obrázok k ostatným animáciám v projekte). Zabezpečte, aby sa animácia prehrala jedenkrát (použite vhodný PlayMode animácie) a objekt potom zmizol z herného sveta (bomba sa rozmetala do vzduchu).

Poznámka

Pri implementácii odstránenia actora bomby zo scény odporúčame využiť akciu When.

Úloha A.2

Vytvorte triedu ChainBomb, ktorá bude potomkom triedy TimeBomb. Zabezpečte, aby sa pri výbuchu aktivovali všetky bomby typu ChainBomb, ktoré sú vzdialené 50 (a menej) jednotiek od stredu aktuálnej aktivovanej bomby.

Konštruktor triedy ChainBomb bude tiež akceptovať v parametri čas od aktivácie do detonácie.

Uvedomte si, že každý následný výbuch môže spôsobiť ďalšie výbuchy pre dominový efekt: v momente detonácie jednej bomby sa aktivujú všetky ešte neaktivované bomby v dosahu.

Pre rádius výbuchu môžete využiť triedu Ellipse2D.Float z balíka java.awt.geom a pre dočasnú reprezentáciu animácie okolitých actorov triedu Rectangle2D.Float (z toho istého balíka). Následne je možné použiť metódu intersects nad útvarom elipsy pre zistenie vzájomného prekrytia.

Pri implementácii dbajte na to, aby ste neopakovali už implementovanú funkcionalitu predka. V prípade potreby ho refaktorujte.

Úloha A.3

Vytvorte triedu Teleport, ktorou budete modelovať teleportovanie hráča medzi dvoma miestami vo svete.

Funkcia teleportu má byť nasledovná:

  • Ak hráč vojde na teleport A, bude okamžite presunutý na cieľový teleport B. Hráč vojde na teleport až vtedy, keď sa bod v strede animácie hráča dostane do oblasti definovanej animáciou teleportu.
  • Ak bol hráč práve presunutý na teleport A z iného teleportu, nebude presunutý na cieľový teleport teleportu A pokiaľ z celého priestoru teleportu A najskôr nevyjde a nevráti sa späť.
  • V prípade, že teleport A nemá pripojený cieľový teleport, hráč nebude nikam teleportovaný.
  • Teleport A nemôže mať ako cieľ nastavený sám seba (teleport A).

Trieda Teleport by mala obsahovať:

  • Konštruktor, ktorý umožní prostredníctvom parametra nastaviť cieľový teleport, a ktorý použije obrázok lift.png pre animáciu teleportu.
  • Metódy getDestination() a setDestination(Teleport destinationTeleport), ktorým je možné získať referenciu na cieľový teleport, resp. zmeniť cieľový teleport.
  • Metódu teleportPlayer(Player player), ktorou cieľový teleport nastaví novú pozíciu hráča pri teleportovaní. Hráč má byť premiestnený tak, aby súradnice stredu cieľového teleportu a súradnice stredu hráča boli totožné.

Doplňujúce zdroje