7. týždeň

I knew you'd come (Ripley)

Motivácia

Nová kolónia na planéte Acheron (LV-426) sa už niekoľko dní nehlási aj napriek tomu, že overenie spojenia pomocou satelitov bolo úspešné. Obávame sa najhoršieho, pretože satelitné snímky nedetegujú v kolónii žiadne známky života.

Operačné stredisko rozhodlo, že s tvojimi schopnosťami budeme schopní zostrojiť na diaľku ovládaného androida, ktorý by situáciu mohol zvládnuť. Náš taktický a strategický tím ťa povedie vo vývoji. Misia dostala názov: Ellen.

Aby si si urobil predstavu o vážnosti celej situácie, odporúčame ti zhliadnuť dokumentárne filmy Prometheus, Alien a Aliens.

Z operačného strediska zdraví Manager.

Ciele

  1. Použiť statickú metódu main() na spustenie aplikácie.
  2. Naučiť sa vytvárať a používať vlastný enumeračný typ.
  3. Oboznámiť sa s návrhovým vzorom Observer (listener, callback).

Postup

Krok 1: Let's start from the beginning

Zatiaľ ste vo svojom projekte pridávali aktérov do scény buď pomocou Inšpektora, respektíve ste písali jednoduché scenáre. Odkiaľ sa však vzala samotná scéna alebo Inšpektor? Prečo má okno spustenej aplikácie práve dané rozmery? V tomto kroku si ukážeme, ako vyskladať celú aplikáciu pekne od začiatku... od funkcie main, ktorá predstavuje vstupný bod Java aplikácií.

Úloha 1.1

V balíku sk.tuke.kpi.oop.game vytvorte triedu Main.

Úloha 1.2

V triede Main vytvorte statickú metódu main() so signatúrou, ktorá v Jave reprezentuje vstupný bod aplikácie.

Metóda, ktorá môže byť nastavená ako tá, ktorá sa zavolá prvá po spustení aplikácie, musí mať v jazyku Java nasledovnú signatúru:

public static void main(String[] args) {

}

Úloha 1.3

Upravte Gradle konfiguráciu pre spúšťanie novej main() metódy.

V súbore build.gradle.kts upravte hodnotu premennej mainClassName v bloku application na plné meno práve vytvorenej triedy Main, obsahujúcej statickú metódu main():

application {
    mainClassName = "sk.tuke.kpi.oop.game.Main"
}

Úloha 1.4

V metóde main() vytvorte hernú aplikáciu, pridajte do nej scénu a hru spustite.

K vytvoreniu aplikácie potrebujeme pripraviť minimálne 3 objekty - konfiguráciu okna, samotnú hru, a scénu, ktorú bude hra zobrazovať. Nasledujúci kód poskytuje ukážku implementácie so sprievodnými komentármi.

public static void main(String[] args) {
    // nastavenie okna hry: nazov okna a jeho rozmery
    WindowSetup windowSetup = new WindowSetup("Project Ellen", 800, 600);

    // vytvorenie instancie hernej aplikacie
    // pouzijeme implementaciu rozhrania `Game` triedou `GameApplication`
    Game game = new GameApplication(windowSetup, new LwjglBackend());  // v pripade Mac OS bude druhy parameter "new Lwjgl2Backend()"

    // vytvorenie sceny pre hru
    // pouzijeme implementaciu rozhrania `Scene` triedou `World`
    Scene scene = new World("world");

    // pridanie sceny do hry
    game.addScene(scene);

    // spustenie hry
    game.start();
}

Upozornenie

Ak používate operačný systém Mac OS, druhým parametrom pre konštruktor GameApplication je objekt triedy Lwjgl2Backend.

Úloha 1.5

Zabezpečte, aby sa po stlačení klávesy **ESCAPE** aplikácia hry ukončila.

V prvom rade, hru vieme korektne z kódu ukončiť volaním metódy stop() nad objektom typu Game, ku ktorému sa dostaneme zo scény pomocou metódy getGame().

Gamelib

Pre získanie objektu triedy Input, pomocou ktorého vieme pracovať so vstupnými zariadeniami (napr. klávesnicou), máme na objekte hry a takisto na objekte scény metódu getInput(). V oboch prípadoch získame rovnaký typ objektu (Input), rozdiel je v tom, aké udalosti vstupných zariadení budú spracovávať:

  • Objekt typu Input získaný z objektu hry (game.getInput()) spracováva udalosti globálne bez ohľadu na aktívnu scénu.
  • Objekt typu Input získaný z objektu scény (scene.getInput()) spracováva udalosti pre konkrétnu scénu (scene). V prípade, ak by sme v hre mali viac scén, vieme takto spracovávať vstup rôznym spôsobom na rôznych scénach.

Možností, ako reagovať na stlačenie klávesy, máme niekoľko:

  • budeme neustále (napr. v rámci nejakej opakovanej akcie) kontrolovať, či bola stlačená požadovaná klávesa, pomocou metódy isKeyPressed(), alebo
  • nastavíme tzv. callback v podobe lambdy odovzdanej metóde onKeyPressed(), ktorú zavolá knižnica vždy vtedy, keď dôjde k stlačeniu určitej klávesy.

Využijeme druhú možnosť pomocou callback-u. Keďže chceme ukončovať hru klávesou ESCAPE bez ohľadu na aktívnu scénu, použijeme Input objekt hry. Riešenie môže v metóde main() vyzerať nasledovne:

game.getInput().onKeyPressed(Input.Key.ESCAPE, () -> game.stop());

Alebo ekvivalentný zápis cez referenciu na bezparametrickú metódu:

game.getInput().onKeyPressed(Input.Key.ESCAPE, game::stop);

Úloha 1.6

V novom balíku sk.tuke.kpi.oop.game.scenarios si pripravte novú triedu pre písanie scenára.

Triedu nazvite napríklad FirstSteps.

Tentoraz nebude trieda scenára dediť od triedy Scenario, ale bude priamo implementovať rozhranie SceneListener z knižnice. V triede prekryte metódu sceneInitialized() z rozhrania SceneListener - do tejto metódy budete čoskoro písať scenár. Časti scenára môžete tak ako predtým organizovať do samostatných metód.

Poznámka

Triedu Gameplay, kam ste písali scenár v predošlých cvičeniach, presuňte do balíka scenarios a premenujte na TrainingGameplay.

Gamelib

Abstraktná trieda Scenario, od ktorej dedila trieda Gameplay, tiež implementovala rozhranie SceneListener.

Úloha 1.7

Pridajte triedu pre scenár ako listener na scénu vytvorenú v metóde main().

V metóde main() triedy Main vytvorte inštanciu triedy scenára a pridajte ju pomocou metódy addListener() k vytvorenej scéne.

Poznámka

Všetky úpravy týkajúce sa scény musíte vykonať pred zavolaním metódy game.start()! Kód, ktorý zapíšete za volanie tejto metódy, bude vykonaný až po tom, čo sa v rámci hry zavolá metóda game.stop().

Úloha 1.8

Overte správnosť implementácie.

Ak ste postupovali správne, po spustení aplikácie prostredníctvom Gradle úlohy run (ten istý spôsob spustenia, aký ste používali doteraz) sa zobrazí prázdne okno s názvom, ktorý ste zadali v konštruktore WindowSetup.

Všimnite si, že okno Inšpektora sa už nezobrazí.

Poznámka

Nástroj Inšpektor by ste už nemali potrebovať, keďže ho vieme viac ako dostatočne nahradiť scenárom a debuggerom vo vývojovom prostredí. Ak by ste však predsa z nejakého dôvodu chceli mať Inšpektora ešte k dispozícii, je potrebné nasledovné:

  • v súbore build.gradle.kts pridať do bloku dependencies nasledovný riadok:
implementation("sk.tuke.kpi.gamelib:gamelib-inspector:$gamelibVersion")
  • načítať zmenenú Gradle konfiguráciu (ikona Load Gradle Changes, ktorá sa zjaví v pravom hornom rohu editora v prostredí IntelliJ IDEA)
  • zaobaliť bežnú scénu do InspectableScene, ktorá slúži ako jej dekorátor - rozšíri ju o funkcionalitu Inšpektora. Túto novú scénu je potom potrebné použiť v hre:
Scene scene = new InspectableScene(new World("world"), List.of("sk.tuke.kpi"));
game.addScene(scene);

Druhým argumentom konštruktora InspectableScene je zoznam názvov balíkov, v ktorých má Inšpektor hľadať triedy, ktoré bude zobrazovať (musia byť anotované anotáciou @Inspectable). Použitý zápis využíva statickú metódu of(), ktorá je tzv. factory metódou na rozhraní kolekcie List (zoznamu), ktorá skonštruuje nemenný zoznam, obsahujúci uvedené elementy (v tomto prípade jeden reťazec "sk.tuke.kpi").

Krok 2: Ripley is back!

Postava základného hráča, ktorú ste doteraz mali k dispozícii, sa už v novej scéne nezobrazuje. Namiesto neho si však vytvoríte vlastnú hlavnú postavu hry: Ripleyovú. A postupne ju budete učiť.

Trieda Ripley implementujúca rozhranie Actor.
Obr. 1: Trieda Ripley implementujúca rozhranie Actor.

Úloha 2.1

V novom balíku sk.tuke.kpi.oop.game.characters vytvorte triedu Ripley, ktorá bude dediť od triedy AbstractActor.

V triede vytvorte bezparametrický konštruktor, v ktorom:

  • zavolajte konštruktor rodiča a odovzdajte mu meno aktéra ako argument: Ellen.
  • nastavte animáciu s názvom player a režimom prehrávania LOOP_PINGPONG.

Animácia player.png (rozmery sprite-u: 32x32, trvanie snímku: 0.1). Na prvý pohľad by sa mohlo zdať, že sa jedná o muža, ale nedajte sa zmiasť - je to naozaj Ellen Ripley.
Obr. 2: Animácia player.png (rozmery sprite-u: 32x32, trvanie snímku: 0.1). Na prvý pohľad by sa mohlo zdať, že sa jedná o muža, ale nedajte sa zmiasť - je to naozaj Ellen Ripley.

Úloha 2.2

V pripravenom scenári vytvorte inštanciu Ripleyovej a umiestnite ju do scény.

Vloženie do scény realizujte pomocou metódy addActor(Actor actor, int x, int y), ktorou môžete priamo definovať aj pozíciu, kde bude Ripleyová umiestnená. Pohľad na zobrazenú scénu bude vycentrovaný na pozíciu [0, 0] (kým to nenastavíme inak), takže toto miesto sa bude nachádzať v strede okna.

Úloha 2.3

Overte správnosť svojej implementácie.

V prípade správnej implementácie sa vám po spustení hry zobrazí prázdny svet, v ktorom sa objaví Ripleyová na definovanej pozícii.

Ripleyová, nervózne prešľapujúca na mieste.
Obr. 3: Ripleyová, nervózne prešľapujúca na mieste.

Krok 3: First Steps with Ellen Ripley

Aktuálne vie Ripleyová iba nervózne prešľapovať na mieste. To sa však veľmi skoro zmení.

Implementácia pohybu pre aktérov.
Obr. 4: Implementácia pohybu pre aktérov.

Úloha 3.1

Vytvorte enumeračný typ Direction, v ktorom definujte 4 možné smery pohybu aktéra.

Definujte 4 smery: NORTH, EAST, SOUTH a WEST.

V enumeračnom type Direction pridajte privátne a finálne celočíselné členské premenné dx a dy, ktoré budú reprezentovať zmeny polohy v osiach x a y, potrebné pre pohyb v danom smere. Premenné inicializujte v konštruktore Direction(int dx, int dy) a pridajte im get metódy.

Poznámka

Definícia smeru NORTH bude zapísaná ako NORTH(0, 1). Ostatné smery zapíšte analogicky.

Úloha 3.2

Do enumerácie Direction pridajte metódu float getAngle(), ktorá vráti uhol (v stupňoch) zodpovedajúci danému smeru.

Úloha 3.3

Vytvorte rozhranie Movable, ktoré bude reprezentovať aktérov, ktorí sa môžu pohybovať.

Movable bude rozširovať rozhranie Actor a bude obsahovať tieto metódy:

  • int getSpeed() pre získanie rýchlosti pohybu aktéra.
  • void startedMoving(Direction direction) ako listener pre udalosť začatia pohybu v smere direction.
  • void stoppedMoving() ako listener pre udalosť zastavenia pohybu.

Metódam startedMoving() a stoppedMoving() pridajte v rámci rozhrania prázdnu default implementáciu, aby ich nebolo nutné implementovať v každom Movable aktérovi, ak to daný aktér nebude vyžadovať.

Default implementácia metódy stoppedMoving() v rozhraní bude vyzerať takto:

default void stoppedMoving() {}

Úloha 3.4

Upravte triedu Ripley tak, aby implementovala rozhranie Movable.

Nastavte Ripleyovej rýchlosť napríklad na hodnotu 2. V metóde startedMoving() otočte animáciu v smere pohybu a spustite ju. V metóde stoppedMoving animáciu pozastavte.

Úloha 3.5

V balíku sk.tuke.kpi.oop.game.actions vytvorte triedu pre akciu Move, ktorá bude reprezentovať pohyb aktéra v určitom smere za určitú dobu.

Pri implementácií použite priamo rozhranie Action z balíka knižnice sk.tuke.kpi.gamelib.actions. Akcia bude mať vlastný typový parameter viazaný na typ Movable, keďže chceme zabezpečiť, aby túto akciu bolo možné použiť len v spojitosti s aktérmi, ktorí sa môžu pohybovať (implementujú Movable).

Jej konštruktor bude mať parametre určujúce smer pohybu a trvanie pohybu v sekundách.

public Move(Direction direction, float duration) {
    // implementacia konstruktora akcie
}

Pridajte aj preťažený konštruktor len s jedným parametrom typu Direction. V tomto prípade budeme čas pohybu považovať za nulový, čo bude znamenať pohyb aktéra len o jeden krok, bez ďalšieho opakovania.

Úloha 3.6

V akcii Move implementujte metódy getActor(), setActor(), isDone() a reset() z rozhrania Action.

Význam jednotlivých metód je nasledovný:

  • getActor() a setActor() slúžia ako getter a setter pre aktéra, ktorý vykonáva danú akciu. Scéna zavolá metódu setActor() pri naplánovaní akcie a tak aktéra nastaví.
  • isDone() slúži ako getter pre overenie stavu dokončenia akcie. Po inicializovaní akcie by táto metóda mala vrátiť false, aby metóda execute() bola aspoň raz zavolaná. Keďže akcia Move bude môcť nejaký čas trvať, táto metóda by mala vrátiť true až keď uplynie určený čas. Zmenu stavu dokončenia akcie budete riešiť v ďalšej úlohe.
  • reset() metóda obnoví stav akcie (s výnimkou nastaveného aktéra) do pôvodného stavu ako po vytvorení akcie.

Úloha 3.7

V akcii Move implementujte metódu execute() z rozhrania Action.

V rámci metódy execute() implementujte nasledovné:

  • V prípade, že je metóda volaná prvýkrát, zavolajte metódu startedMoving() na aktérovi vykonávajúcom akciu.
  • Aktualizujte pozíciu aktéra vzhľadom na smer pohybu a jeho rýchlosť (získate ju metódou getSpeed()).
  • Ak celkový čas behu akcie prekročí, alebo sa rovná času definovanému pri vytváraní inštancie akcie, ukončite akciu. To znamená, že od toho momentu musí metóda isDone() vrátiť hodnotu true. Nezabudnite zavolať metódu stoppedMoving() na pohybujúcom sa aktérovi, aby ten mohol patrične zareagovať.

Poznámka

Čas, ktorý uplynul medzi dvoma volaniami metódy execute(), dostanete v parametri deltaTime metódy execute().

Poznámka

Pre vyhodnotenie zhody uplynutého času akcie voči definovanému trvaniu myslite na to, že v binárnej reprezentácii desatinných čísel dochádza k malým odchýlkam, kvôli ktorým dve čísla môžu byť takmer zhodné, no nemusia byť úplne zhodné. Vyhodnotenie rovnosti preto realizujte ako porovnanie absolútnej hodnoty rozdielu porovnávaných čísel voči číslu dostatočne blízkemu 0, napr. 1e-5.

Úloha 3.8

Overte správnosť implementácie pohybu, naplánovaním akcie Move pre Ripleyovú v pripravenom scenári.

Pri správnej implementácii by sa Ripleyová mala posunúť v definovanom smere počas definovanej doby, pričom jej animácia by sa mala prehrávať len pri pohybe.

Krok 4: Mission Controller

Ripleyová sa už teda vie pohybovať, zatiaľ ale len podľa vopred pripraveného scenára. To však zoči-voči nepriateľom nebude postačovať - potrebujeme niečo flexibilnejšie. V tomto kroku si ukážeme, ako navigovať Ripleyovú v reálnom čase pomocou klávesnice.

Controller pre ovládanie pohybu aktérov.
Obr. 5: Controller pre ovládanie pohybu aktérov.

Úloha 4.1

V novom balíku sk.tuke.kpi.oop.game.controllers vytvorte triedu MovableController, ktorá bude reprezentovať ovládač pohybu aktéra pomocou klávesnice.

Trieda MovableController nech implementuje rozhranie KeyboardListener z hernej knižnice. Toto rozhranie obsahuje metódy, ktoré v štýle návrhového vzoru observer slúžia ako listener funkcie, volané knižnicou pri zmene stlačených kláves na klávesnici. Implementáciou týchto metód teda vieme na stláčané (a uvoľnené) klávesy reagovať.

V triede vytvorte konštruktor, ktorý vo svojom parametri akceptuje referenciu na Movable aktéra, ktorého pohyb bude ovládať.

Úloha 4.2

Vytvorte členskú premennú pre zobrazenie (Map), ktorým bude možné "prekladať" jednu zo štyroch smerových kláves (šípky hore, vľavo, dole, vpravo) na jeden zo štyroch základných smerov enumerácie Direction.

Typ členskej premennej bude Map<Input.Key, Direction> - teda zobrazenie, kde kľúčom bude klávesa a hodnotou smer pohybu. Použitím metódy get(), ktorá akceptuje kľúč (Input.Key), tak získame príslušnú hodnotu (Direction).

Poznámka

Alternatívou takéhoto postupu prekladu klávesy na smer by bolo buď vetvenie pomocou if a else, alebo switch. Použitie zobrazenia je ale viac objektový prístup, preto použijeme tento.

Zobrazenie však musí byť "naplnené" kombináciami klávesa-smer. Túto inicializáciu viete vykonať buď pomocou metódy put() nad objektom zobrazenia, alebo pomocou factory metódy Map.ofEntries(), ktorej odovzdáte 4 parametre vytvorené volaním funkcie Map.entry() s dvojicami hodnôt klávesy a smeru pohybu:

private Map<Input.Key, Direction> keyDirectionMap = Map.ofEntries(
    Map.entry(Input.Key.UP, Direction.NORTH),
    // dalsie zaznamy zobrazenia prekladu ...
);

Úloha 4.3

Pridajte členskú premennú na uchovanie referencie na akciu pohybu Move.

V tejto členskej premennej budete udržiavať referenciu na posledne vytvorenú akciu Move, aby ste ju vedeli zrušiť v prípade, že sa smer pohybu aktéra zmení.

Úloha 4.4

Upravte implementáciu Move tak, aby ste na nej mali dostupnú verejnú metódu stop(), ktorou budete vedieť kedykoľvek zastaviť prebiehajúci pohyb.

Nezabudnite, že pre ukončenie akcie je potrebné zmeniť jej stav vrátený metódou isDone() na true a keďže pracujeme s Movable aktérom, je potrebné zavolať jeho metódu stoppedMoving()!

Poznámka

Refaktorizujte existujúcu implementáciu tak, aby ste sa vyhli duplikovaniu funkcionality.

Úloha 4.5

Implementujte metódu keyPressed() z rozhrania KeyboardListener, ktorá reprezentuje listener udalosti stlačenia klávesy na klávesnici.

V metóde skontrolujte, či stlačená klávesa je jedna zo štyroch smerových kláves. Ak áno, naplánujte pohyb ovládaného aktéra v zodpovedajúcom smere na maximálny možný čas. Nezabudnite si uložiť referenciu na vytvorenú akciu.

Poznámka

Tu viete využiť metódu containsKey() nad objektom Map-y, kam ste ukladali preklad kláves na smery pre overenie stlačenej klávesy.

Poznámka

Myslite aj na to, že predchádzajúcu akciu (ak existuje), treba zastaviť volaním jej metódy stop() pred vytvorením novej akcie pohybu.

Úloha 4.6

Implementujte metódu keyReleased() z rozhrania KeyboardListener, ktorá reprezentuje listener udalosti uvoľnenia klávesy na klávesnici.

Postup pri tejto metóde bude analogický: skontrolujte, či sa jedná o klávesu, ktorá nás zaujíma a ak áno, zastavte prebiehajúci pohyb (ak nejaký prebieha).

Poznámka

Upravte implementáciu metód keyPressed() a keyReleased() tak, aby ste sa vyhli duplikácii funkcionality.

Úloha 4.7

V rámci scenára vytvorte inštanciu vytvoreného MovableController-a, odovzdajte mu referenciu na Ripleyovú a nastavte ho ako listener pre spracovanie vstupu.

Registrácia listenera vstupu sa realizuje metódu registerListener() na objekte Input, ktorý zo scény získate metódou getInput().

Úloha 4.8

Overte správnosť implementácie: Ripleyovej pohyb by sa mal dať ovládať pomocou klávesnice.

Krok 5: Repository

Úloha 5.1

Nahrajte (cez commit a push) váš zdrojový kód do repozitára na GitLab-e. Spravte tak do nasledujúceho cvičenia. Zároveň si pripravte otázky, ktoré by ste na cvičení chceli vyriešiť.

Zdrojový kód nahrajte aj v prípade, ak ste nestihli dokončiť všetky úlohy. Rozpracované časti, ktoré by mohli spôsobiť kompilačné chyby, odporúčame zakomentovať.

Doplňujúce úlohy

V rámci nasledujúcich doplňujúcich úloh rozšírite implementáciu pohybu o diagonálne smery.

Poznámka

Doplňujúce úlohy budú v rámci testov 2. zadania testované izolovane. To znamená, že ak ich nespravíte, nestratíte body za hlavné úlohy cvičenia. Na týchto úlohách nebudú závisieť ani ďalšie úlohy v budúcich cvičeniach.

Úloha A.1

Rozšírte implementáciu Direction o diagonálne smery pohybu.

K hodnotám enumeračného typu pridajte 4 diagonálne smery pohybu.

Úloha A.2

Do enumerácie Direction pridajte smer NONE nereprezentujúci žiaden smer pohybu (státie na mieste).

Hodnoty dx a dy smeru NONE nastavte na 0.

Poznámka

Ak by sme smer NONE nemali, museli by sme pre státie na mieste používať napr. hodnotu null a museli by sme všade kontrolovať, či direction náhodou nie je null, aby sme sa vyhli NullPointerException. Ak budeme systematicky pracovať tak, že smeru nikdy nepriradíme hodnotu null, tomuto problému sa vieme vyhnúť (samozrejme, do tej miery, nakoľko budeme dôslední).

Smer NONE budeme teda využívať v zmysle návrhového vzoru null object.

Úloha A.3

Do enumeračného typu Direction pridajte metódu combine(), pomocou ktorej bude možné skombinovať ľubovoľné 2 smery pohybu do jedného.

Metóda nech má signatúru public Direction combine(Direction other). Nech metóda vráti smer NONE v prípade, že nebude možné nájsť správny skombinovaný smer (čo by sa však za normálnych okolností stať nemalo).

Poznámka

Pri riešení tejto úlohy sa vyhnite použitiu príkazu switch, alebo rozsiahlemu vetveniu pomocou if-else. Využite metódu values() (Direction.values()) dostupnú na každom enumeračnom type, ktorá vráti pole všetkých možných hodnôt enumerácie.

Úloha A.4

Upravte implementáciu MovableController tak, aby bolo možné aktérov ovládať aj v diagonálnych smeroch pohybu, kedy sú stlačené 2 smerové klávesy (prípadne aj viac...).

Pre riešenie tejto úlohy si budete v rámci MovableController-a uchovávať množinu (Set) základných smerov pohybu, ktoré zodpovedajú všetkým práve stlačeným smerovým klávesám. Pre určenie výsledného smeru pohybu využite metódu combine() na type Direction, ktorou smery postupne skombinujete do jedného.

Overte svoju implementáciu vyskúšaním ovládania Ripleyovej v rôznych smeroch.

Doplňujúce zdroje