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 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.
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.
Idea animacji jest następująca:
i=(i+1)%10
Czyli animowana postać (Zombie) powinna przechowywać:
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):
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 implements CrossHairListener{ 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.
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; }
DrawPanel DrawPanel.paintComponent()
Utwórz i uruchom wewnętrzny wątek w klasie DrawPanel.
Powinien on:
repaint()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(); } } }
Rozwinięcie programu tak, aby obsłużył wiele zombie jest proste:
next()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;} default void addSpriteListener(SpriteListener s){} }
oraz [1] zaznaczymy, że klasa Zombie implementuje ten interfejs:
public class Zombie implements Sprite { ...
[2] Następnie w klasie DrawPanel
List<Sprite> sprites = new ArrayList<>();
paintComponent()
[3] Dalej w klasie DrawPanel.AnimationThread zadbamy o to, aby wołać next() dla wszystkich duszków
W DrawPanel muszą powstawać nowe zombie (załozyliśmy, że AnimationThread będzie tworzył nowe, co 30 klatek).
DrawPanel musi umieć utworzyć obiekt Zombie, wywołać konstruktor, itd. DrawPanel powinnien widzieć wyłącznie Sprite Rozwiązanie typowe dla języków obiektowych jest zasugerowane na poniższym rysunku
[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 do klasy DrawPanel kolejna potencjalna zależność od fabryki Zombie jest wstrzykiwana w momencie konstrukcji (ang. dependency injection). Możemy uruchamiać różne panele, powiązane dynamicznie z różnymi fabrykami, itd.
Zombie(int x,int y, double scale, BufferedImage tape) - dzięki temu taśma z klatkami będzie załadowany tylko raz w ZombieFactory, a następnie referencja do bitmapy będzie przekzana do każdej postaci // 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 wykład 6 - na końcu. Oczywiście nalezy zadeklarować jeden obiekt. Zwykle nazywa się INSTANCE.
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 repaint()Usuń wyjątki zapewniając wzajemne wykluczanie pomiędzy wątkami. Jak to zrobić:
synchronized(sprites) { /* tu dostęp */ } - skorzystamy wtedy z monitora obiektu spritesSemaphore mutex = new Semaphore(1);
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:
[3] Dodaj w CrossHair reakcję na void mouseMoved(MouseEvent e) - pobierz współrzędne x, y i wywołaj przerysowanie okna
[4] Podobnie, zaimplementuj void mousePressed(MouseEvent e)
activated=true i przerysuj oknoactivated za 300 msTimer timer = new Timer("Timer"); timer.schedule(new TimerTask() { @Override public void run() { activated=false; parent.repaint(); } },300);
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
fireSomethingHappened ale tu fire shots fired byłoby trochę dziwne…List<CrossHairListener> listeners = new ArrayList<CrossHairListener>(); void addCrossHairListener(CrossHairListener e){ listeners.add(e); } void notifyListeners(){ for(var e:listeners) e.onShotsFired(x,y); }
[3] w klasie DrawPanel
class DrawPanel extends JPanel implements CrossHairListenercrossHair.addCrossHairListener(this); - po utworzeniu obiektu CrossHairvoid onShotsFired(int x, int y) Zaimplemntuj:
isVisible - czy jest widoczny? Na podstawie współrzędnej x, szerokości bitmapy i skaliboolean isHit(int _x,int _y) - sprawdź, czy punkt _x, _y lezy wewnątrz prostokąta uzytego do rysowaniaboolean isCloser(Sprite other) - porównaj współczynniki skali if(other instanceof Zombie z){
[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.
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() !!! } });