Table of Contents

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

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

Animacja (pojedynczego) zombie

Utwórz i uruchom wewnętrzny wątek w klasie DrawPanel.

Powinien on:

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:

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

List<Sprite> sprites = new ArrayList<>();

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

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:

Usuń wyjątki zapewniając wzajemne wykluczanie pomiędzy wątkami. Jak to zrobić:

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:

[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)

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

Ostatnie zmiany w klasie Zombie

Zaimplemntuj:

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...
        }
    });