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 CrossHairListener
crossHair.addCrossHairListener(this);
- po utworzeniu obiektu CrossHair
void 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() !!! } });