====== Java Zombie ====== Napiszemy grę, w której grupa animowanych postaci (zombie) porusza się od prawego do lewego krańca ekranu, a zadaniem użytkownika będzie ich eliminacja za pomocą strzałów oddawanych za pomocą myszy. Grę pokazuje [[https://www.youtube.com/watch?v=IVH6kaJh1lc|krótki filmik na YouTube]]. Gra w zasadzie jest kopią zadania przygotowane przez dra Grzegorza Rogusa na laboratorium z przedmiotu //Wstęp do aplikacji internetowych//. Różnicą jest język i platforma implementacji: Java i Swing zamiast JavaScript i HTML. ====== Część 1. ====== ===== Animacja postaci ===== W animacji wykorzystamy obrazek zawierający 10 klatek zombie w ruchu. Tło obrazu jest przezroczyste, stąd można wyświetlać postać na dowolnym tle. {{ :pz1:walkingdead.png?direct&400 |Klatki - spacerujący zombie}} Idea animacji jest następująca: - wyświetlamy i-tą klatkę w miejscu o współrzędnych (x,y) - usypiamy wątek (na przykład na 1000/30 ms) - zwiększamy i o 1, tzn. ''i=(i+1)%10'' - zmniejszamy współrzędną x postaci (jeżeli idziemy w lewo) o jakąś wartość Czyli animowana postać (''Zombie'') powinna przechowywać: * obraz z klatkami zombie (taśmę do animacji) * numer aktualnej klatki * położenie x i y * oraz prawdopodobnie //skalę// pozwalającą na sterowanie wielkością postaci, a tym samym szybkością poruszania (małe postaci są bardziej odległe i poruszają się wolniej) oraz wielkością zajmowanego obszaru (bounding box), który powinien być sprawdzany podczas strzałów. ===== Tło ===== Tłem powinna być bitmapa dająca wrażenie horyzontu (wyraźna przestrzeń do linii horyzontu). Na przykład taka, jak na poniższym rysunku (w zadaniu dra Grzegorza Rogusa jest tło typu Haloween/Monster House): {{ :pz1:6473205443_7df3397e72_b.jpg?direct&400 |}} Najlepiej tworzyć postaci nadając im współrzędne y na linii horyzontu, natomiast rysować je tak, aby linia horyzontu znajdowała się pośrodku postaci. Da to złudzenie perspektywy. Zacznijmy od wyświetlenia tła w klasie panelu. public class DrawPanel extends JPanel { BufferedImage background; DrawPanel(URL backgroundImagageURL){ try { background = ImageIO.read(backgroundImagageURL); } catch (IOException e) { e.printStackTrace(); return; } } public void paintComponent(Graphics g){ Graphics2D g2d= (Graphics2D)g; g.drawImage(background,0,0,getWidth(),getHeight(),this); } W klasie Main utworzymy DrawPanel i uruchomimy aplikację. public class Main { public static void main(String[] args) { // write your code here JFrame frame = new JFrame("Zombie"); DrawPanel panel = new DrawPanel(Main.class.getResource("/resources/6473205443_7df3397e72_b.jpg")); frame.setContentPane(panel); frame.setSize(1000, 700); frame.setLocationRelativeTo(null); frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); frame.setResizable(true); frame.setVisible(true); } Jako bitmapę można podać dowolny adres sieciowy URL. W tym przypadku plik będzie ładowany z katalogu zasobów ''src/resources''. Katalog zasobów wraz z zawartością zostanie skopiowany do wyjściowego archiwum JAR. Zasoby są więc częścią dystrybuowanego oprogramowania. ===== Klasa Zombie ===== Implementujemy klasę Zombie public class Zombie { BufferedImage tape; int x=500; int y=500; double scale=1; int index=0; // numer wyświetlanego obrazka int HEIGHT = // z rysunku; int WIDTH = // z rysunku; Zombie(int x,int y, double scale){ this.x = x; this.y = y; this.scale = scale; this.tape = ImageIO.read(getClass().getResource("/resources/walkingdead.png")); } /** * Pobierz odpowiedni podobraz klatki (odpowiadającej wartości zmiennej index) * i wyświetl go w miejscu o współrzędnych (x,y) * @param g * @param parent * */ public void draw(Graphics g, JPanel parent){ Image img = tape.getSubimage(...); // pobierz klatkę g.drawImage(img,x,y-(int)(HEIGHT*scale)/2,(int)(WIDTH*scale),(int)(HEIGHT*scale),parent); } /** * Zmień stan - przejdź do kolejnej klatki */ public void next(){ x-=20*scale; index = (index+1)%10; } ===== Wyświetlanie (pojedynczego) zombie ===== * Dodaj (jako atrybut) obiekt klasy Zombie w ''DrawPanel'' * Dodaj kod wyświetlania zombie w ''DrawPanel.paintComponent()'' ===== Animacja (pojedynczego) zombie ===== Utwórz i uruchom wewnętrzny wątek w klasie ''DrawPanel''. Powinien on: * powodować przemieszczenie animowanych obiektów (Zombie.next() * wywołać przerysowanie okna ''repaint()'' * uśpić się na zadany czas odpowiadający parametrowi FPS class DrawPanel { ... class AnimationThread extends Thread{ public void run(){ for(int i=0;;i++) { zombie.next(); repaint(); try { sleep(1000 / 30); // 30 klatek na sekundę } catch (InterruptedException e) { e.printStackTrace(); } } } ====== Część 2. ====== ===== Wiele zombie ===== Rozwinięcie programu tak, aby obsłużył wiele zombie jest raczej proste: * DrawPanel będzie zawierał listę obiektów Zombie * AnimationThread bedzie przesuwał wszystkie obiekty na liście - wołając ''next()'' * Okresowo, np. co 1 sekundę (tu 30 klatek) będzie generowany nowy obiekt Zombie z losowo wybraną skalą ==== Uogólnienie: Sprite ==== Można zadać pytanie, czy nasz kod ma obsługiwać wyłącznie zombie? Może znajdziemy bitmapę do animacji innych postaci (trolli, wilkołaków, wilków, panter, itd.). DrawPanel powinien być więc napisany w sposób bardziej elastyczny. Należy go rozdzielić (ang. //decouple//) od klasy Zombie. Utworzymy więc interfejs ''Sprite'' (duszek): public interface Sprite { /** * Rysuje postać * @param g * @param parent */ void draw(Graphics g, JPanel parent); /** * Przechodzi do następnej klatki */ void next(); /** * Czy już zniknął z ekranu * @return */ default boolean isVisble(){return true;} /** * Czy punkt o współrzędnych _x, _y leży w obszarze postaci - * czyli czy trafiliśmy ją strzelając w punkcie o tych współrzednych * @param _x * @param _y * @return */ default boolean isHit(int _x,int _y){return false;} /** Czy jest bliżej widza niż other, czyli w naszym przypadku czy jest większy, * czyli ma wiekszą skalę... * * @param other * @return */ default boolean isCloser(Sprite other){return false;} } oraz **[1]** zaznaczymy, że klasa Zombie implementuje ten interfejs: public class Zombie implements Sprite { ... **[2]** Następnie w klasie ''DrawPanel'' * Dodamy listę duszków (a nie Zombie) List sprites = new ArrayList<>(); * oraz wywołamy wyświetlanie wszystkich duszków w ''paintComponent()'' **[3]** Dalej w klasie ''DrawPanel.AnimationThread'' zadbamy o to, aby wołać next() dla wszystkich duszków ==== Jak zlikwidować zależność pomiędzy DrawPanel i Zombie ?==== W DrawPanel muszą powstawać nowe zombie (załozyliśmy, że ''AnimationThread'' będzie tworzył nowe, co 30 klatek). * Potencjalnie oznacza to //zależność// (ang. //dependency//)- ''DrawPanel'' musi umieć utworzyć obiekt ''Zombie'', wywołać konstruktor, itd. * Z drugiej strony chcielibyśmy uniknąć tej zależności - ''DrawPanel'' powinnien widzieć wyłącznie ''Sprite'' Rozwiązanie typowe dla języków obiektowych jest zasugerowane na poniższym rysunku {{ :pz1:main-qimg-4a47b585f76d7be80c002b51d62c858e-pjlq.png?direct&400 |}} **[1]** Zdefiniujmy więc interfejs ''SpriteFactory'': public interface SpriteFactory { Sprite newSprite(int x,int y); } Funkcja newSprite(int x,int y) będzie generowała nową postać o przekazanych jako parametry współrzędnych. **[2]** Dodajmy SpriteFactory jako atrybut oraz parametr konstruktora ''DrawPanel'' DrawPanel(URL backgroundImagageURL, SpriteFactory factory){ try { background = ImageIO.read(backgroundImagageURL); } catch (IOException e) { e.printStackTrace(); return; } this.factory=factory; **[3]** Duszki ''Sprite'' będą tworzone w wątku ''DrawPanel.AnimationThread'' public void run(){ for(int i=0;;i++) { //.. if(i%30==0) { sprites.add(factory.newSprite(getWidth(),(int)(???*getHeight()))); } } } W ten sposób kolejna potencjalna zależność od fabryki Zombie jest //wstrzykiwana// do klasy ''DrawPanel'' w momencie konstrukcji (ang. //dependency injection//). Możemy elastycznie uruchamiać panele, powiązane dynamicznie z różnymi fabrykami (koni, trolli, panter, aniołków... ). ===== Zombie Factory ===== - Napisz klasę ZombieFactory implementującą interfejs SpriteFactory. - Dodaj konstruktor ''Zombie(int x,int y, double scale, BufferedImage tape)'' - dzięki temu taśma z klatkami będzie załadowana **tylko raz** w ZombieFactory, a następnie referencja do bitmapy będzie przekazana do każdej postaci - Zadbaj, aby ZombieFactory była singletonem (może istnieć tylko jeden obiekt tej klasy) - Przekaż referncję ZombieFactory jako parametr konstruktora DrawPanel // fragment kodu zombie factory public Sprite newSprite(int x,int y){ double scale = // wylosuj liczbę z zakresu 0.2 do 2.0 Zombie z = new Zombie(x,y,scale,tape); return z; } Do realizacji wzorca **singleton** możesz użyć deklaracji ''enum'', patrz [[https://home.agh.edu.pl/~pszwed/wiki/lib/exe/fetch.php?media=java:w6-java-interfejsy-klasy-wewnetrzne.pdf|wykład 6 - na końcu]]. Oczywiście nalezy zadeklarować jeden obiekt. Zwykle nazywa się ''INSTANCE''. Inna możliwa implementacja (starszy wzorzec): public class AnotherSingletonFactory implements SpriteFactory { private AnotherSingletonFactory(){} public static AnotherSingletonFactory get() { return instance; } private static AnotherSingletonFactory instance = new AnotherSingletonFactory(); @Override public Sprite newSprite(int x, int y) { return null; } } ===== Wyjątki ===== Prawdopodobnie w tym momencie uda się uruchomić animację. Prawdopodobnie też pojawią się wyjątki... Ich przyczyną będzie próba równoczesnego dostępu do listy ''sprtites'' w klasie DrawPanel przez dwa wątki: * ''AnimationThread'' - modyfikuje listę dodając nowe obiekty * Wewnętrzny wątek GUI, który wykonmuje metodę ''repaint()'' **Usuń wyjątki zapewniając wzajemne wykluczanie pomiędzy wątkami**. Jak to zrobić: * można zamienić listę duszków na osobną klasę z synchronicznymi metodami - skorzystamy wtedy z monitora klasy * można umieścić dostęp do sprites wewwnątrz bloków synchronicznych ''synchronized(sprites) { /* tu dostęp */ }'' - skorzystamy wtedy z monitora obiektu sprites * można użyć semafora typu mutex ''Semaphore mutex = new Semaphore(1);'' ===== Celownik ===== Celownik będzie przesuwał się wraz z kursorem oraz reagował na naciśnięcie przycisku myszy (oddanie strzału). Będzie więc odbiorcą zdarzeń generowanych przez mysz ''MouseEvent''. Za narysowanie celownika odpowiadał będzie ''DrawPanel''. Jeżeli będzie konieczne uaktualnienie stanu - będzie wołał metodę DrawPanel.repaint() public class CrossHair implements MouseMotionListener, MouseListener { DrawPanel parent; CrossHair(DrawPanel parent){ this.parent = parent; } /* x, y to współrzedne celownika activated - flaga jest ustawiana po oddaniu strzału (naciśnięciu przyciku myszy) */ int x; int y; boolean activated = false; **[1]** Napisz metodę ''void draw(Graphics g)'', która narysuje celownik. Pokaż stan celownika na przykład zmieniając kolor if(activated)g.setColor(Color.RED); else g.setColor(Color.WHITE); **[2]** Dodaj celownik jako atrybut ''DrawPanel'' i nie zapomnij wywołać jego metodę ''draw()'' Po utworzeniu obiektu w ''DrawPanel'' zarejestruj go, jako odbiorcę zdarzeń myszy. Użyj metod: * addMouseMotionListener(); * addMouseListener(); **[3]** Dodaj w ''CrossHair'' reakcję na ''void mouseMoved(MouseEvent e)'' - pobierz współrzędne x, y i wywołaj przerysowanie okna **[3a]** Zwrócono uwagę na nieoczekiwane zachowanie podczas obsługi touch padu. W tym przypadku mysz przechodzi w tryb dragged - analogiczny kod można umieścić w mousseDragged(). **[4]** Podobnie, zaimplementuj ''void mousePressed(MouseEvent e)'' * Pobierz współrzędne * Ustaw ''activated=true'' i przerysuj okno * Zleć zmianę stanu ''activated'' za 300 ms // jako atrybut w klasie Timer timer = new Timer("Timer"); // reakcja na mousePressed timer.schedule(new TimerTask() { @Override public void run() { activated=false; parent.repaint(); timer.cancel(); // TODO } },300); ==== Jak DrawPanel otrzyma informacje o oddanych strzałach? ==== Zastosujemy wzorzec //Observer// - czyli w terminologii Java //Listener//. ''DrawPanel'' będzie zostanie zarejestrowany jako obserwator nasłuchujący na zdarzenia generowane przez celownik. **[1]** Nie zdefiniujemy specjalnej klasy zdarzeń, ale zdefiniujemy interfejs: public interface CrossHairListener { void onShotsFired(int x,int y); } **[2]** W klasie ''CrossHair'' zadeklaruj * listę obserwatorów ("listenerów") * funkcję dodającą obserwatorów do listy * funkcję powiadamiającą wszystkich obserwatorów o zdarzeniu. Funkcja do generacji powiadomień zwykle nazywa się ''fireSomethingHappened'' ale tu // fire shots fired// byłoby trochę dziwne i niezrozumiałe... List listeners = new ArrayList(); void addCrossHairListener(CrossHairListener e){ listeners.add(e); } void notifyListeners(){ for(var e:listeners) e.onShotsFired(x,y); } **[3]** w klasie ''DrawPanel'' *Zadeklaruj ''class DrawPanel extends JPanel implements CrossHairListener'' *Wywołaj ''crossHair.addCrossHairListener(this);'' - po utworzeniu obiektu ''CrossHair'' *Zadeklaruj szkielet metody public ''void onShotsFired(int x, int y) '' ==== Ostatnie zmiany w klasie Zombie ==== Zaimplemntuj: * Metodę ''isVisible'' - czy jest widoczny? Na podstawie współrzędnej x, szerokości bitmapy i skali * ''boolean isHit(int _x,int _y)'' - sprawdź, czy punkt _x, _y lezy wewnątrz prostokąta uzytego do rysowania * ''boolean isCloser(Sprite other)'' - porównaj współczynniki skali ''if(other instanceof Zombie z){'' ==== Ostatnie zmiany w klasie DrawPanel i AnimationThread ==== **[1]** Po dodaniu Zombie - posortuj listę tak, aby te o większej skali były rysowane później. Wykorzystaj metodę ''Zombie.isCloser()'' **[2]** Usuń obiekty zombie, które nie są widoczne. Możesz w tym momencie uaktualnić statystykę naliczyć punkty **[3]** Zaimplementuje metodę ''void onShotsFired(int x, int y)'', usuń pierwszego zombie, dla którego funkcja ''isHit(x,y)'' zwraca ''true''. Prawdopodobnie musisz iterować wstecz. Możesz też uaktualnić statystyki gry. ==== Ostatnie zmiany w klasie ''Main'' ==== Zatrzymaj wątek w momencie zamknięcia okna. Zazwyczaj korzystając z kodu: frame.addWindowListener(new java.awt.event.WindowAdapter() { @Override public void windowClosing(java.awt.event.WindowEvent windowEvent) { // tu zatrzymaj watek // ale nie za pomocą metody stop() !!! //zatrzymaj też timer celownika za pomocą cancel(). Timer to także wątek... } });