====== 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 [[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.
===== 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 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):
{{ :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 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 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 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 [[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''.
===== 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 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() !!!
}
});