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.

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.3.1 podľa postupu na tejto stránke.

Ciele

  • Použiť statickú metódu main() na spustenie aplikácie
  • Naučiť sa vytvárať a používať vlastný enumeračný typ
  • 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 actorov 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 metódy, 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);

    // 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();
}

Ú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().

Pre získanie objektu triedy Input, pomocou ktorého vieme pracovať so vstupnými zariadeniami (napr. klávesnicou), máme na objekte scény metódu getInput(). 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 nejakej klávesy.

Využijeme druhú možnosť pomocou callback-u. Riešenie môže vyzerať nasledovne:

scene.getInput().onKeyPressed(key -> {
    if (key == 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 bude trieda scenára 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.

Poznámka

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()!

Ú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ávom, 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é pri vytváraní hry 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 zoznamu List, 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 actora ako argument: Ellen.
  • nastavte animáciu s názvom player.png 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, int, int), ktorou môžete priamo definovať aj pozíciu, kde bude Ripleyová umiestnená. Štandardne sa bude pozícia [0, 0] 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 actorov.
Obr. 4: Implementácia pohybu pre actorov.

Úloha 3.1

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

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 getter metódy.

Poznámka

Definícia smeru NORTH bude zapísaná ako NORTH(0, 1).

Úloha 3.2

Do enumerácie Direction pridajte smer NONE nereprezentujúci žiaden konkrétny smer pohybu.

Hodnoty dx a dy smeru NONE nastavte na 0.

Poznámka

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

Úloha 3.3

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

Úloha 3.4

Vytvorte rozhranie Movable, ktoré bude reprezentovať actorov, 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 actora.
  • 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 actorovi, ak to daný actor nebude vyžadovať.

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

default void stoppedMoving() {}

Úloha 3.5

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.6

V balíku sk.tuke.kpi.oop.game.actions vytvorte triedu pre akciu Move, ktorá bude reprezentovať pohyb actora 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.

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 actora len o jeden krok bez opakovania.

Úloha 3.7

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 actora, ktorý vykonáva danú akciu. Nastavuje ho scéna pri naplánovaní akcie.
  • isDone() reprezentuje ukončenie 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ť, true táto metóda vráti, až keď uplynie jej čas. Zmenu stavu budete riešiť v ďalšej úlohe.
  • reset() metóda obnoví stav akcie (s výnimkou nastaveného actora) do pôvodného stavu po vytvorení akcie.

Úloha 3.8

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 actorovi vykonávajúcom akciu.
  • Aktualizujte pozíciu actora 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 actorovi.

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ácií 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.9

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 actorov.
Obr. 5: Controller pre ovládanie pohybu actorov.

Úloha 4.1

Vytvorte triedu KeyboardController, ktorá bude reprezentovať ovládač pohybu actora pomocou klávesnice.

Trieda KeyboardController 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 actora, 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 actora 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 actorom, 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 actora 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 KeyboardController-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 mala dať ovládať pomocou klávesnice.

Doplňujúce úlohy

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

Ú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 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.3

Upravte implementáciu KeyboardController tak, aby bolo možné actorov 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 KeyboardController-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