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 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.

Klatki - spacerujący zombie

Idea animacji jest następująca:

  1. wyświetlamy i-tą klatkę w miejscu o współrzędnych (x,y)
  2. usypiamy wątek (na przykład na 1000/30 ms)
  3. zwiększamy i o 1, tzn. i=(i+1)%10
  4. 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):

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<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ć 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

[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

  1. Napisz klasę ZombieFactory implementującą interfejs SpriteFactory.
  2. 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
  3. Zadbaj, aby ZombieFactory była singletonem (może istnieć tylko jeden obiekt tej klasy)
  4. 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.

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<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 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...
        }
    });
pz1/java-zombie.txt · Last modified: 2023/01/16 14:25 by pszwed
CC Attribution-Share Alike 4.0 International
Driven by DokuWiki Recent changes RSS feed Valid CSS Valid XHTML 1.0