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
- 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 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 blokudependencies
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ť.
Ú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
.
Ú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.
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í.
Ú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 smeredirection
.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()
asetActor()
slúžia ako getter a setter pre aktéra, ktorý vykonáva danú akciu. Scéna zavolá metódusetActor()
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ódaexecute()
bola aspoň raz zavolaná. Keďže akciaMove
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ť hodnotutrue
. Nezabudnite zavolať metódustoppedMoving()
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.
Ú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
- Who is Ellen Ripley
- Callback: odovzdávanie vykonateľného kódu ako argument volania funkcie, wikipedia.org
- Java Tutorial: Enumeračný typ
- Návrhový vzor Observer
- Návrhový vzor Null Object
- Návrhový vzor Factory Method