Table of Contents
Laboratorium 14a
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.
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.
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 prawo) 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):
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.
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(); } } }
Wiele zombie
Rozwinięcie programu tak, aby obsłużył wiele zombie jest 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;} 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
- Dodamy listę duszków (a nie Zombie)
List<Sprite> 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ć obiektZombie
, wywołać konstruktor, itd. - Z drugiej strony chcielibyśmy uniknąć tej zależności -
DrawPanel
powinnien widzieć wyłącznieSprite
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 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ładowany tylko raz w ZombieFactory, a następnie referencja do bitmapy będzie przekzana 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 wykład 6 - na końcu. Oczywiście nalezy zadeklarować jeden obiekt. Zwykle nazywa się INSTANCE
.
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
[4] Podobnie, zaimplementuj void mousePressed(MouseEvent e)
- Pobierz współrzędne
- Ustaw
activated=true
i przerysuj okno - Zleć zmianę stanu
activated
za 300 ms
Timer timer = new Timer("Timer"); timer.schedule(new TimerTask() { @Override public void run() { activated=false; parent.repaint(); } },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óe
- 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…
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
- Zadeklaruj
class DrawPanel extends JPanel implements CrossHairListener
- Wywołaj
crossHair.addCrossHairListener(this);
- po utworzeniu obiektuCrossHair
- 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 rysowaniaboolean isCloser(Sprite other)
- porównaj współczynniki skaliif(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() !!! } });