Nauka programowania gier komputerowych w Javie. Autor: Piotr Modzelewski E-mail: [email protected] Wstęp Celem tego referatu jest napisanie prostej gry w Javie, jednakże nie trywialnej. W dziele tworzenia używać będziemy dogodności programowania obiektowego. Tworzenie gry nie będzie się odbywać od razu. Zaczniemy od małego projektu, który będzie się rozrastać. Kod będziemy poprawiać, ulepszać a nawet usuwać, aby zobaczyć jak przy różnych jego wersjach działać będzie nasza aplikacja. Celem tego kursu jest oprócz stworzenia gry: Ulepszanie nauki Javy Zdobycie podstaw na temat Swingu i AWT Zrozumienie obsługi zdarzeń oraz komponentów Nauka Javy2D Nauka HashMapy i ArrayList Sztuczek programistycznych Pierwsze okno Pierwszym krokiem tworzenia naszej aplikacji jest stworzenia okna. Wykorzystamy Bibliotekę Swing (dokładnie JFrame). Zakładamy, że nasz projekt nazywa się WojnaSwiatow import javax.swing.JFrame; public class WojnaSwiatow{ public static final int SZEROKOSC = 800; public static final int WYSOKOSC = 600; public WojnaSwiatow() { JFrame okno = new JFrame(".: Wojna Swiatow :."); okno.setBounds(0,0,SZEROKOSC,WYSOKOSC); okno.setVisible(true); } public static void main(String[] args) { WojnaSwiatow inv = new WojnaSwiatow(); } } Widzimy, że okno to nie będzie nawet kończyć programu w momencie jego zamknięcia. Tak być nie może. Oczywiście naprawienie tego nie będzie kłopotem. Wystarczy konstruktor WojnaSwiatow( ) zmodyfikować dodając na jego końcu: okno.addWindowListener(new WindowAdapter() { public void windowClosing(WindowEvent e){ System.exit(0); } }); Oraz dodatkowo zaimportować obsługę zdarzeń: import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; Zabawa w rysowanie Podstawą w grze jest oczywiście grafika, a co za tym idzie, umiejętność rysowania. Każdy kto miał doczynienia z okienkami wie, że rysowanie w oknie to nadpisanie metody paint( ) . Nasza klasa, WojnaSwiatow , nie jest jednak de facto oknem. Aby jednak je w nią zamienić wystarczy dziedziczyć z Canvas : import javax.swing.JFrame; … public class WojnaSwiatow extends Canvas{ Wspaniale. Teraz spróbujmy sprawdzić czy to wystarczy. Najprościej będzie, jeżeli coś narysujem. Bez zbędnego gadania, zróbmy to. Na początku zaimportujmy: import javax.swing.JPanel; import java.awt.Color; import java.awt.Dimension; import java.awt.Graphics; Teraz, aby utworzyć panel w oknie, modyfikujemy konstruktor: public WojnaSwiatow() { JFrame okno = new JFrame(".:Wojna Swiatow:."); JPanel panel = (JPanel)okno.getContentPane(); setBounds(0,0,SZEROKOSC,WYSOKOSC); panel.setPreferredSize(new Dimension(SZEROKOSC,WYSOKOSC)); panel.setLayout(null); panel.add(this); okno.setBounds(0,0,SZEROKOSC,WYSOKOSC); okno.setVisible(true); okno.addWindowListener( new WindowAdapter() { public void windowClosing(WindowEvent e) { System.exit(0); } }); } No to jak mamy panel, na którym będziemy rysować, pora to zrobić, na razie dla testów będzie to banalna figura geometryczna. Zgadnijcie jaka. Oczywiście jak napisałem wyżej, polega to na nadpisaniu metody paint( ), którą umieścimy zaraz pod konstruktorem WojnaSwiatow( ): public void paint(Graphics g){ g.setColor (Color.red); g.fillOval( SZEROKOSC/2-10, WYSOKOSC/2-10,20,20); } Uruchamiamy i jeśli wszystko dobrze zrobiliśmy, widzimy jak się spodziewaliśmy czerwone kółeczko. Wstawiamy rysunki No można bawić się w rysowanie, ale rysowanie złożonych grafik w Javie jest nie tylko czasochłonne, ale i nieefektywne. Dlatego też wczytywać będziemy gotowe obrazki, stworzone choćby w Gimpie. Obrazek musi znajdować się w pewnym miejscu na dysku, do którego ma dostęp program. Miejscem wyjścia, gdy pracujemy w NetBeans, jest folderg src. Gdy korzystasz z innego środowiska musisz sam sprawdzić gdzie umieszczasz rysunki i jak do nich dotrzeć. Zakładamy, że w folderze src mamy folder img z naszymi obrazkami. Wstawmy najpierw jednego stworka: Rys 1: Oto nasz straszny stworek. Zaczynam od zaimportowania nowych bibliotek: import java.awt.image.BufferedImage; import java.net.URL; import javax.imageio.ImageIO; Teraz napiszemy metodę do wczytywania obrazków: public BufferedImage loadImage(String sciezka) { URL url=null; try { url = getClass().getClassLoader().getResource(sciezka); return ImageIO.read(url); } catch (Exception e) { System.out.println("Przy otwieraniu " + sciezka +" jako " + url); System.out.println("Wystapil blad : "+e.getClass().getName()+" "+e.getMessage()); System.exit(0); return null; } } Jak widać w razie błędu przerwanie programu. To ważne, bo będziemy wiedzieć jaki plik źle zlokalizowaliśmy. Co ważniejsze jak widzimy, ścieżka jest ścieżką względna, co umożliwia nam pobranie obrazka skądkolwiek, niezależnie gdzie leży obecnie nasza gra (a może w przyszłości aplet). Pozostaje zmienienie metody paint( ): public void paint(Graphics g){ BufferedImage potworek = loadImage("img/potworek.gif"); g.drawImage(potworek, 40, 40,this); } Jako rezultat widzimy: Rys 2. Straszny potwor zamknięty w naszym okienku. Jeśli jednak dokładnie przyjrzymy się kodowi, widzimy że ponieważ metoda paint( ) jest wywoływana przy każdym przerysowania okna, za każdym razem będzie on ładowany od początku. Niezbyt efektywne. Można pomyśleć żeby ładować to od razu w konstruktorze raz a dobrze. No ale pomyślmy, że tworzymy większy projekt, powiedzmy aplet z setkami stworków, tekstur i innych obrazków. Za każdym razem, ktoś kto ściąga nasz aplet musi czekać Az to wszystko się załaduje, a możliwe ze nawet niektórych z nich nigdy nie obejrzy, bo występuje powiedzmy w 50 level’u, gdy on skończy grę przy 10… Zastosujmy sztuczkę nazywaną deferred loading. Dodajemy do atrybutów WojnaSwiatow naszego potworka. public class WojnaSwiatow extends Canvas{ public static final int SZEROKOSC = 800; public static final int WYSOKOSC = 600; public BufferedImage potworek = null; … Teraz zmieniamy metodę rysowania: public void paint(Graphics g){ if (potworek==null) potworek = loadImage("img/potworek.gif"); g.drawImage(potworek, 40, 40,this); } Skuteczne aczkolwiek nieeleganckie i kłopotliwe gdy będą setki grafik. Java oferuje jednak metode nazywana getSprite( ). Sprite (z ang., dosłownie duszek) to dwuwymiarowy obrazek używany w systemach grafiki dwuwymiarowej i 2.5wymiarowej, który po przesunięciu i ewentualnie przeskalowaniu jest przenoszony na ekran. Sprite'y pozwalają na bardzo łatwe uzyskiwanie na ekranie niezbyt wyszukanych obiektów animowanych. Wiele układów graficznych 2D jest wyposażonych w zdolność do automatycznego generowania i animacji sprite'ów. Namiastkę trzeciego wymiaru można uzyskać przez skalowanie sprite'ów oraz ich wyświetlanie w kolejności od dalszych do bliższych (w ten sposób bliższe częściowo zakrywają dalsze). W systemach grafiki 3D zamiast sprite'ów używa się raczej modeli opartych na wielokątach. Więc obrazki będziemy ładować jako duszki. Ponieważ może ich być setki dobrze będzie trzymać je w Hashmapie jako pary (nazwa-sprite’a,zaladowany-plik). Na początku importujemy. import java.util.HashMap; Zmieniamy więc też atrybuty bo nasz stary potworek jest już nam niepotrzebny, natomiast warto zadeklarować HashMap’e. public static final int SZEROKOSC = 800; public static final int WYSOKOSC = 600; public HashMap sprites; Na wstępie konstruktora dopisujemy: public WojnaSwiatow() { sprites = new HashMap(); ... Tworzymy nową metodę pod metodą loadImage( ): public BufferedImage getSprite(String sciezka) { BufferedImage img = (BufferedImage)sprites.get(sciezka); if (img == null) { img = loadImage("img/"+sciezka); sprites.put(sciezka,img); } return img; } Ostatnim akordem zoptymalizowanego wczytywania jest zmiana metody print( ): public void paint(Graphics g){ g.drawImage(getSprite("potworek.gif"), 40, 40,this); } Animacja Należy uświadomić sobie, że każda gra dzieje się wg następującego scenariusza: 1. Odświeżenie stanu świata – tutaj odbywają się ruchy potworów, akcje gracza, 2. Odświeżenie ekranu – tutaj odbywa się przeniesienie pierwszego na ekran 3. GOTO 1. Nic ciężkiego, więc zaimplementujmy to. Główna pętla będzie odbywać się w metodzie gra( ). Odświeżanie swiata natomiast, to metoda OdswiezSwiat( ). Oczywiście na tym etapie, odświeżanie to nie jest skomplikowane. Będzie to losowe umieszczanie potworka na planszy. Potrzeba nam do tego dwóch nowych atrybutow, pozX i pozY. public class public public public public WojnaSwiatow extends Canvas{ static final int SZEROKOSC = 800; static final int WYSOKOSC = 600; HashMap sprites; int pozX,pozY; public WojnaSwiatow() { pozX=SZEROKOSC/2; pozY=WYSOKOSC/2; no i zaraz nad main( ) dodajemy: public void paint(Graphics g){ g.drawImage(getSprite("potworek.gif"), pozX, pozY,this); } public void updateWorld() { pozX = (int)(Math.random()*SZEROKOSC); pozY = (int)(Math.random()*WYSOKOSC); } public void game() { while (isVisible()) { updateWorld(); paint(getGraphics()); } } public static void main(String[] args) { WojnaSwiatow inv = new WojnaSwiatow(); inv.game(); } } Po zobaczeniu rezultatu: Rys 3. Atak!? Widać, że nie o to nam chodziło. Odpowiedź na pytanie dlaczego tak się dzieje jest dość oczywiste. Wywołujemy ręcznie metodę paint ( ). Nie przerysowuje ona okna, tylko nanosi przecież na już przerysowane. Skoro tak, to po prostu nanosi to co ma być zawarte prócz tła, a skoro te nie było przerysowane, to po prostu nanosi na to co było, musimy więc ręcznie czyścić okno. Na razie wystarczy, czyścić okno przed narysowaniem potwora. Wystarczy podmienić metodę paint( ): public void paint(Graphics g){ g.setColor(getBackground()); g.fillRect(0,0,getWidth(),getHeight()); g.drawImage(getSprite("potworek.gif"), pozX, pozY,this); } dodatkowo pozbawimy użytkownika zmniejszania wielkości okna dopisując na końcu konstruktora: okno.setResizable(false); Gdy uruchomimy projekt okaże się, że znów jest nie tak. Potworek rusza się tak szybko, że trudno go zauważyć. Nieuniknione jest to, aby zapewnić mu pewne opóźnienie. Zmienia się więc troche nasz wcześniejszy scenariusz: 1. Odświeżenie stanu świata – tutaj odbywają się ruchy potworów, akcje gracza, 2. Odświeżenie ekranu – tutaj odbywa się przeniesienie pierwszego na ekran 3. Czekaj trochę. 4. GOTO 1. Opóźnienie będzie kolejnym atrybutem: public class WojnaSwiatow public static final int public static final int public static final int public HashMap sprites; public int pozX,pozY; extends Canvas{ SZEROKOSC = 800; WYSOKOSC = 600; SZYBKOSC = 60; ... Użyjemy go w głównej pętli gry: public void game() { while (isVisible()) { updateWorld(); paint(getGraphics()); try { Thread.sleep(SZYBKOSC); } catch (InterruptedException e) {} } } No to coś już widać. OK. Pora na to by nasz potworek ruszał się w bardziej rozsądny sposób – dokładniej, horyzontalnie odbijając się od brzegów ekranu. W tym celu wprowadzimy szybkość do atrybutów. public int pozX,pozY,vX; Nadać mu odpowiednią wartość w konstruktorze: public WojnaSwiatow() { pozX=SZEROKOSC/2; pozY=WYSOKOSC/2; vX=2; ... No i wpiszemy odpowiedni ruch: public void updateWorld() { pozX += vX; if (pozX < 0 || pozX > SZEROKOSC) vX = -vX; } Po uruchomieniu, przyjemność animacji zakłóci nam jeden aspekt. Paskudne migotanie. Dlaczego tak się dzieje? Dlatego że nasze oko widzi ekran na chwilę przed narysowaniem aktora, dlatego animacja nie jest spójna. Rozwiązaniem tego problemu jest tzw. podwójne buforowanie. Cała sztuczka, to przechowywanie w pamięci obrazu o tych samych rozmiarach co okno, nanoszenie tam zmian i dopiero potem rysowanie całości na ekran. Brzmi trochę strasznie, ale jest na tyle popularne, że JDK od wersji 1.4 ma wbudowane instrumenty do realizacji tego pomysłu. Klasa odpowiadająca za to wszystko to BufferStrategy. Urzywanie jej jest bardzo proste: 1. Na początku musimy zdecydować, które okno lub komponent będziemy buforować. Tutaj trzeba wybrać oczywiście ten, na którym będziemy rysować, w tym przypadku oczywiście klasę WojnaSwiatow 2. wywołujemy metodę createBufferStrategy(n), gdzie przekazujemy ilość buforów mających być utworzonych. Nie wszystkie jednak systemy pozwalają na stworzenie więcej niż 2 buforów 3. Używamy metody getBufferStrategy(), aby uzyskać instancję BudderStrategy 4. Aby malować na obrazku poza ekranem, używamy metody getDrawGraphics() 5. Aby odslonić ukryty bufor używamy metody show(); Na wstepie zaimportować musimy: import java.awt.image.BufferStrategy; Dodajemy atrybut klasy: public BufferStrategy strategia; Na końcu kostruktora dopisujemy: createBufferStrategy(2); strategia = getBufferStrategy(); requestFocus(); podmieniamy metodę paint() na: public void paintWorld() { Graphics g = strategia.getDrawGraphics(); g.setColor(Color.black); g.fillRect(0,0,getWidth(),getHeight()); g.drawImage(getSprite("potworek.gif"), pozX, pozY,this); strategia.show(); } No i oczywiście zmieniamy paint na paintWorld() w game(). FPS Tworząc szatę graficzną gry powinno się jak najszybciej stworzyć licznik FPS (ang. Frame per second – klatki na sekundę). Dlaczego jest to takie ważne? Często tworząc gry chce się dokładać coraz nowe i nowe rzeczy. Jednakże wszystko, czy to rysowanie, czy to liczenie wg skomplikowanego algorytmu ruchów przeciwników, potrzebuje czasu. Natomiast animacja, aby zachowała płynność musi mieć odpowiednią ilość klatek na sekundę. Oczywiście nie ma różnicy między 1700 FPS a 120 FPS. Dlaczego? Dlatego, że monitor tak często ich nie odświeży. Obecnie, monitory pracują z częstotliwościa 100 Hz, co oznacza, że większa liczba klatek będzie niezauważalna. Licznik FPS jest ważnym doradcą. Widząc przy konturowym nakreśleniu stworów, ze spada do 70 klatek, wiemy, że nałożenie tekstur spowoduje spowolnienie poniżej płynności. Obliczenia wymagać będą nowego atrybutu: public class WojnaSwiatow extends Canvas{ public static final int SZEROKOSC = 800; public static final int WYSOKOSC = 600; public static final int SZYBKOSC = 10; public long usedTime; public HashMap sprites; public int pozX,pozY,vX; public BufferStrategy strategia; ... Licznik trzeba również wyświetlić na ekranie: public void paintWorld() { Graphics g = strategia.getDrawGraphics(); g.setColor(Color.black); g.fillRect(0,0,getWidth(),getHeight()); g.drawImage(getSprite("potworek.gif"), pozX, pozY,this); g.setColor(Color.white); if (usedTime > 0) g.drawString(String.valueOf(1000/usedTime)+" fps",5,WYSOKOSC-50); else g.drawString("--- fps",5,WYSOKOSC-50); strategia.show(); } Wreszcie pora obliczyć ten czekany FPS: public void game() { usedTime=1000; while (isVisible()) { long startTime = System.currentTimeMillis(); updateWorld(); paintWorld(); usedTime = System.currentTimeMillis()-startTime; try { Thread.sleep(SZYBKOSC); } catch (InterruptedException e) {} } } Refactoring kodu W tym momencie program niebezpiecznie zaczyna przybierać na wadze, i dodatkowo, staje się nieczytelny. Trzeba coś z tym zrobić. Łatwo domyśleć się jak to zrobimy. Wyodrębnimy samodzielne obiekty, które składają się na nasz kod i podzielimy je na samodzielne pliki. Rzeczą, która najbardziej rzuca się w oczy są Sprite’y. Stworzmy więc dla nich handlera, zauważając, że jedyna metoda, która jest potrzebna publicznie, to getSprite( ). SpriteCache.java import import import import java.awt.image.BufferedImage; java.net.URL; java.util.HashMap; javax.imageio.ImageIO; public class SpriteCache { public HashMap sprites; public SpriteCache() { sprites = new HashMap(); } private BufferedImage loadImage(String sciezka) { URL url=null; try { url = getClass().getClassLoader().getResource(sciezka); return ImageIO.read(url); } catch (Exception e) { System.out.println("Przy otwieraniu " + sciezka +" jako " + url); System.out.println("Wystapil blad : "+e.getClass().getName()+" "+e.getMessage()); System.exit(0); return null; } } public BufferedImage getSprite(String sciezka) { BufferedImage img = (BufferedImage)sprites.get(sciezka); if (img == null) { img = loadImage("img/"+sciezka); sprites.put(sciezka,img); } return img; } } Następnie od razu nasuwa mi się pojęcie sceny. Scena koordynuje wszystkim co dzieje się w grze – ile jest potworów, ile strzałów, który jest obecny level itd. Ważną jej cechą jest wyłączność na kontakt z SpriteCache. Stage.java import java.awt.image.ImageObserver; public interface Stage extends ImageObserver { public static final int SZEROKOSC = 800; public static final int WYSOKOSC = 600; public static final int SZYBKOSC = 10; public SpriteCache getSpriteCache(); } Zajmijmy się stworzeniami. Oczywistym jest, że w zamierzamy mieć więcej niż jednego stwora. Wszystkie będą podobne, różnić ich będzie jedynie pozycja i szybkość. Spróbujmy pomyśleć co wspólnego maja potwory, w celu zaprojektowania ich klasy. Oczywiście stworki mają pozycję jak powiedzieliśmy Mają grafikę, która jest pokazywana na ekranie Mają rozmiar, który może być specyficzny dla pojedynczego Muszą też cos czynić czy to walczyć czy też poruszać się po prostu. Klasa musi mieć więc metodę, która będzie im to umożliwiać Ale jeśli pomyślimy trochę bardziej, łatwo dojdziemy do wniosku, że te rzeczy są charakterystyczne nie tylko dla potworków, ale dla wszystkich poruszających się „rzeczy” na ekranie (kule, gracz, jakieś spadające bonusy). Więc można stworzyć jedną klasę, z której inne będą dziedziczyć. Zwykło się ją nazywać aktorem. Actor.java import java.awt.Graphics2D; import java.awt.image.BufferedImage; public class Actor { protected int x,y; protected int width, height; protected String spriteName; protected Stage stage; protected SpriteCache spriteCache; public Actor(Stage stage) { this.stage = stage; spriteCache = stage.getSpriteCache(); } public void paint(Graphics2D g){ g.drawImage( spriteCache.getSprite(spriteName), x,y, stage ); } public int getX() { return x; } public void setX(int i) { x = i; } public int getY() { return y; } public void setY(int i) { y = i; } public String getSpriteName() { return spriteName; } public void setSpriteName(String string) { spriteName = string; BufferedImage image = spriteCache.getSprite(spriteName); height = image.getHeight(); width = image.getWidth(); } public public public public int getHeight() { return height; } int getWidth() { return width; } void setHeight(int i) {height = i; } void setWidth(int i) { width = i; } public void act() { } } Teraz nie pozostaje nam napisać nic innego jak właściwego potwora. Monster.java public class Monster extends Actor { protected int vx; public Monster(Stage stage) { super(stage); setSpriteName("potworek.gif"); } public void act() { x+=vx; if (x < 0 || x > Stage.SZEROKOSC) vx = -vx; } public int getVx() { return vx; } public void setVx(int i) {vx = i; } } No to pozostaje nam je rozmnożyć w programie głównym. WojnaSwiatow.java import import import import import import import import import import import java.awt.Canvas; javax.swing.JFrame; javax.swing.JPanel; java.awt.Color; java.awt.Dimension; java.awt.Graphics; java.awt.event.WindowAdapter; java.awt.event.WindowEvent; java.awt.Graphics2D; java.awt.image.BufferStrategy; java.util.ArrayList; public class WojnaSwiatow extends Canvas implements Stage{ public long usedTime; public BufferStrategy strategia; private SpriteCache spriteCache; private ArrayList actors; public WojnaSwiatow() { spriteCache = new SpriteCache(); JFrame okno = new JFrame(".: Wojna Swiatow :."); JPanel panel = (JPanel)okno.getContentPane(); setBounds(0,0,Stage.SZEROKOSC,Stage.WYSOKOSC); panel.setPreferredSize(new Dimension(Stage.SZEROKOSC,Stage.WYSOKOSC)); panel.setLayout(null); panel.add(this); okno.setBounds(0,0,Stage.SZEROKOSC,Stage.WYSOKOSC); okno.setVisible(true); okno.addWindowListener( new WindowAdapter() { public void windowClosing(WindowEvent e) { System.exit(0); } }); okno.setResizable(false); createBufferStrategy(2); strategia = getBufferStrategy(); requestFocus(); } public void initWorld() { actors = new ArrayList(); for (int i = 0; i < 10; i++){ Monster m = new Monster(this); m.setX( (int)(Math.random()*Stage.SZEROKOSC) ); m.setY( i*20 ); m.setVx( (int)(Math.random()*3)+1 ); actors.add(m); } } public void paintWorld() { Graphics2D g = (Graphics2D)strategia.getDrawGraphics(); g.setColor(Color.black); g.fillRect(0,0,getWidth(),getHeight()); for (int i = 0; i < actors.size(); i++) { Actor m = (Actor)actors.get(i); m.paint(g); } g.setColor(Color.white); if (usedTime > 0) g.drawString(String.valueOf(1000/usedTime)+" fps",0,Stage.WYSOKOSC-50); else g.drawString("--- fps",0,Stage.WYSOKOSC-50); strategia.show(); } public void updateWorld() { for (int i = 0; i < actors.size(); i++) { Actor m = (Actor)actors.get(i); m.act(); } } public SpriteCache getSpriteCache() { return spriteCache; } public void game() { usedTime=1000; initWorld(); while (isVisible()) { long startTime = System.currentTimeMillis(); updateWorld(); paintWorld(); usedTime = System.currentTimeMillis()-startTime; try { Thread.sleep(Stage.SZYBKOSC); } catch (InterruptedException e) {} } } public static void main(String[] args) { WojnaSwiatow inv = new WojnaSwiatow(); inv.game(); } } Widać, że grasuje nam grupka potworków: Rys 4. Rodzinka. Animacja Niezależnie od gatunku gry obiekty 2D podczas poruszania powinny podlegać jakiejś animacji, która niemalże zawsze zależy od klatek. W celu wywołania iluzji ruchu aktor cykluje pomiędzy sekwencją obrazków. Dla uproszczenia w tym przypadku nasz potworek będzie miał tylko 2 klatki animacji. Rys 5. Dwie twarze tego samego zła… Nazwijmy je potworek0.gif i potworek1.gif. Zmieńmy więc odpowiednio klasy: W Actor.java dodajemy atrybuty: protected int currentFrame; protected String[] spriteNames; zmieniamy też konstruktor: public Actor(Stage stage) { this.stage = stage; spriteCache = stage.getSpriteCache(); currentFrame = 0; } setSpriteName() zastępujemy: public void setSpriteNames(String[] names) { spriteNames = names; height = 0; width = 0; for (int i = 0; i < names.length; i++ ) { BufferedImage image = spriteCache.getSprite(spriteNames[i]); height = Math.max(height,image.getHeight()); width = Math.max(width,image.getWidth()); } } Poprawiamy też metodę act () . public void act() { currentFrame = (currentFrame + 1) % spriteNames.length; } Pozostaje już tylko nanieść drobne zmiany w: Monster.java public class Monster extends Actor { protected int vx; public Monster(Stage stage) { super(stage); setSpriteNames( new String[] {"potworek0.gif","potworek1.gif"}); } public void act() { super.act(); x+=vx; if (x < 0 || x > Stage.SZEROKOSC) vx = -vx; } public int getVx() { return vx; } public void setVx(int i) {vx = i; } } No coś tam miga. Ale za szybko, żeby zauważyć, teoretycznie można byłoby w stringu mieć 12x potworek0.gif i 12x potworek1.gif tworząc tak jakby 24 klatki. Jednakże jest to brzydkie, niepotrzebnie marnuje pamięć itd... itd… Nie lepiej po prostu nie zmieniać obrazka zawsze tylko po kilku obejściach? Ustalmy więc dwa nowe atrybuty w Actor.java: protected int frameSpeed; protected int t; frameSpeed to po prostu szybkość zmieniania się klatek. Natomiast t to zmienna pomocnicza. Zainicjujmy je w konstruktorze: public Actor(Stage stage) { this.stage = stage; spriteCache = stage.getSpriteCache(); currentFrame = 0; frameSpeed = 1; t=0; } Dodajmy metody do kontroli frameSpeed: public int getFrameSpeed() {return frameSpeed; } public void setFrameSpeed(int i) {frameSpeed = i; } No i wreszcie zmieńmy metodę act() na trochę inteligentniejszą. public void act() { t++; if (t % frameSpeed == 0){ t=0; currentFrame = (currentFrame + 1) % spriteNames.length; } } W Monster.java na koniec konstruktora dajemy setFrameSpeed(25); i uruchomiamy. W zależności od woli dostosowujemy szybkość przerzucania klatek. Gracz No czas zamienić ten projekt w coś co nie jest czystą animacją i swawolą potworów. Pora wprowadzić kogoś kto by trochę te potwory pogonił. Rolą tą ma pełnić gracz. Musimy mu zapewnić: Ruszanie się we wszystkich 8 kierunkach Gracz porusza się tylko gdy klawisz jest wciśnięty Widzimy, że gracz jest podobny do potwora, z wyjątkiem tego, że kontroluje go gracz i porusza się we wszystkich kierunkach. Rys 6. Statek naszego gracza. Tworzymy więc plik dla gracza: Player.java public class Player extends Actor { protected int vx; protected int vy; public Player(Stage stage) { super(stage); setSpriteNames( new String[] {"nave.gif"}); } public void act() { super.act(); x+=vx; y+=vy; if (x < 0 || x > Stage.SZEROKOSC) vx = -vx; if (y < 0 || y > Stage.WYSOKOSC) vy = -vy; } public public public public int getVx() { return vx; } void setVx(int i) {vx = i; } int getVy() { return vy; } void setVy(int i) {vy = i; } } Należy jeszcze zmienić glówną klasę WojnaSwiatow.java. Co prawda gracz jest aktorem jak każdy inny, z oczywistych względów będziemy go traktować troszeczkę bardziej indywidualnie. WojnaSwiatow.java import import import import import import import import import import import java.awt.Canvas; javax.swing.JFrame; javax.swing.JPanel; java.awt.Color; java.awt.Dimension; java.awt.Graphics; java.awt.event.WindowAdapter; java.awt.event.WindowEvent; java.awt.Graphics2D; java.awt.image.BufferStrategy; java.util.ArrayList; public class WojnaSwiatow extends Canvas implements Stage{ public long usedTime; public BufferStrategy strategia; private SpriteCache spriteCache; private ArrayList actors; private Player player; public WojnaSwiatow() { spriteCache = new SpriteCache(); JFrame okno = new JFrame(".: Wojna Swiatow :."); JPanel panel = (JPanel)okno.getContentPane(); setBounds(0,0,Stage.SZEROKOSC,Stage.WYSOKOSC); panel.setPreferredSize(new Dimension(Stage.SZEROKOSC,Stage.WYSOKOSC)); panel.setLayout(null); panel.add(this); okno.setBounds(0,0,Stage.SZEROKOSC,Stage.WYSOKOSC); okno.setVisible(true); okno.addWindowListener( new WindowAdapter() { public void windowClosing(WindowEvent e) { System.exit(0); } }); okno.setResizable(false); createBufferStrategy(2); strategia = getBufferStrategy(); requestFocus(); } public void initWorld() { actors = new ArrayList(); for (int i = 0; i < 10; i++){ Monster m = new Monster(this); m.setX( (int)(Math.random()*Stage.SZEROKOSC) ); m.setY( i*20 ); m.setVx( (int)(Math.random()*3)+1 ); actors.add(m); } player = new Player(this); player.setX(Stage.SZEROKOSC/2); player.setY(Stage.WYSOKOSC - 2*player.getHeight()); player.setVx(5); } public void paintWorld() { Graphics2D g = (Graphics2D)strategia.getDrawGraphics(); g.setColor(Color.black); g.fillRect(0,0,getWidth(),getHeight()); for (int i = 0; i < actors.size(); i++) { Actor m = (Actor)actors.get(i); m.paint(g); } player.paint(g); g.setColor(Color.white); if (usedTime > 0) g.drawString(String.valueOf(1000/usedTime)+" fps",0,Stage.WYSOKOSC-50); else g.drawString("--- fps",0,Stage.WYSOKOSC-50); strategia.show(); } public void updateWorld() { for (int i = 0; i < actors.size(); i++) { Actor m = (Actor)actors.get(i); m.act(); } player.act(); } public SpriteCache getSpriteCache() { return spriteCache; } public void game() { usedTime=1000; initWorld(); while (isVisible()) { long startTime = System.currentTimeMillis(); updateWorld(); paintWorld(); usedTime = System.currentTimeMillis()-startTime; try { Thread.sleep(20); } catch (InterruptedException e) {} } } public static void main(String[] args) { WojnaSwiatow inv = new WojnaSwiatow(); inv.game(); } } Rys 7. Do gry wchodzi gracz No wspaniale, przydałoby nadać graczowi w końcu kontrolę nad statkiem. Używać będziemy do tego klawiszy kursorów, w sposób trywialny, naciśnięcie klawisza „lewo” spowoduje poruszanie się w lewo. Jeśli naciśniemy klawisze „lewo” i „góra” to będzie się poruszać w obu tych kierunkach. Co prawda można by załatwić interpretację naciskania klawiszy w głównym pliku jest to dość nieeleganckie i nawet kłopotliwe przy rozbudowie projektu. Scenariusz obsługi zdarzenia naciśnięcia klawisza będzie następujący: 1. WojnaSwiatow otrzymuje zdarzenia klawiatury 2. Sprawdza czy to nie klawisze specjalne dopowiadające np. za Pauze, Restart, Wyjscie, coprawda nie mamy ich zaimplementowanych, ale może kiedyś…. Dopiero po tym oddaje to klasie gracza 3. Klasa gracza radzi sobie z interpretacja Nic więc prostszego, jak to zrobić po prostu. W pliku WojnaSwiatow.java klasę utworzymy „słuchaczem przycisków”: import java.awt.event.KeyListener; import java.awt.event.KeyEvent; public class WojnaSwiatow extends Canvas implements Stage, KeyListener{ na końcu kontruktora, musimy go zainicjować: public WojnaSwiatow() { spriteCache = new SpriteCache(); JFrame okno = new JFrame(".: Wojna Swiatow :."); JPanel panel = (JPanel)okno.getContentPane(); setBounds(0,0,Stage.SZEROKOSC,Stage.WYSOKOSC); panel.setPreferredSize(new Dimension(Stage.SZEROKOSC,Stage.WYSOKOSC)); panel.setLayout(null); panel.add(this); okno.setBounds(0,0,Stage.SZEROKOSC,Stage.WYSOKOSC); okno.setVisible(true); okno.addWindowListener( new WindowAdapter() { public void windowClosing(WindowEvent e) { System.exit(0); } }); okno.setResizable(false); createBufferStrategy(2); strategia = getBufferStrategy(); requestFocus(); addKeyListener(this); } W końcu dodajemy metody: public void keyPressed(KeyEvent e) { player.keyPressed(e); } public void keyReleased(KeyEvent e) { player.keyReleased(e); } public void keyTyped(KeyEvent e) {} I usuwamy linijkę z initWorld() player.setVx(5); bo nie chcemy już bazowej prędkości. Najwięcej zmian nastąpi w Player.java oczywiście: Player.java import java.awt.event.KeyEvent; public class Player extends Actor { protected static final int PLAYER_SPEED = 4; protected int vx; protected int vy; private boolean up,down,left,right; public Player(Stage stage) { super(stage); setSpriteNames( new String[] {"nave.gif"}); } public void act() { super.act(); x+=vx; y+=vy; if (x < 0 || x > Stage.SZEROKOSC) vx = -vx; if (y < 0 || y > Stage.WYSOKOSC) vy = -vy; } public public public public int getVx() { return vx; } void setVx(int i) {vx = i; } int getVy() { return vy; } void setVy(int i) {vy = i; } protected void updateSpeed() { vx=0;vy=0; if (down) vy = PLAYER_SPEED; if (up) vy = -PLAYER_SPEED; if (left) vx = -PLAYER_SPEED; if (right) vx = PLAYER_SPEED; } public void keyReleased(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_DOWN : down = false; break; case KeyEvent.VK_UP : up = false; break; case KeyEvent.VK_LEFT : left = false; break; case KeyEvent.VK_RIGHT : right = false; break; } updateSpeed(); } public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_UP : up = true; break; case KeyEvent.VK_LEFT : left = true; break; case KeyEvent.VK_RIGHT : right = true; break; case KeyEvent.VK_DOWN : down = true;break; } updateSpeed(); } } Zauważmy, że stała PLAYER_SPEED mówi jasno, że kursory nie zmieniają prędkości, a jedynie kierunek statku. Strzelanie Pora wprowadzić do gry trochę przemocy. Statek naszego gracza będzie w stanie strzelać promieniami lasera. Na razie zajmiemy się jedynie ich lotem, tworzeniem i usuwaniem. Niszczeniem potworów zajmiemy się potem. Jasno więc możemy stwierdzić co na razie musi robić nasza „kula”: 1. Wystrzelone pojawiają się tuż nad statkiem 2. Poruszają się ciągle naprzód ze stała prędkością 3. Jest limit kul na ekranie Jak widać, z punktu widzenia gry kule to po prostu aktorzy. Nową rzeczą jest to, że pojawiają się i znikają. Dodatkowo nie zależą od głównej pętli, gdyż nie ma ona wpływu na to, kiedy gracz wystrzeli. Jakby tego było mało to klasa Player a nie WojnySwiatow interpretuje naciśniecie, a to przecież ona trzyma listę aktorów… Jak temu zaradzić? Z pomocą przychodzi Stage: Stage.java import java.awt.image.ImageObserver; public interface Stage extends ImageObserver { public static final int SZEROKOSC = 800; public static final int WYSOKOSC = 600; public static final int SZYBKOSC = 20; public SpriteCache getSpriteCache(); public void addActor(Actor a); } Zyskała ona umiejętność tworzenia aktora. Usuwanie jednak nie jest już tak łatwe. Gdyby Stage mogła kasować wystąpił by problem adekwatny do Race Condition. Wyobraźmy sobie, że Stage chce usunąć obiekt a w tym samym momencie główna pętla chce na nim pracować – katastrofa. Można co prawda zakładać jakiś semafor czy inne zabezpieczenia na listę, ale to dodatkowy ciężar, a my dążymy do jak największej efektywności gry. Lepszym rozwiązaniem jest dodatnie dodatkowego pola dla aktora, który jest zgłoszeniem aktora do usunięcia z listy, i normalna pętla gry jak takowy napotka, to wtedy usunie. Zmodyfikujmy więc Actor.java dodając dodatkowe pole oraz metody: protected boolean markedForRemoval; public void remove() { markedForRemoval = true; } public boolean isMarkedForRemoval() { return markedForRemoval; } Teraz wystarczy dodać dodatkową metodę i zmodyfikować pętlę w WojnaSwiatow.java: public void addActor(Actor a) { actors.add(a); } public void updateWorld() { int i = 0; while (i < actors.size()) { Actor m = (Actor)actors.get(i); if (m.isMarkedForRemoval()) { actors.remove(i); } else { m.act(); i++; } } player.act(); } Rys 8. Nasz straszny laser Spokojnie możemy stworzyć klasę kuli: Bullet.java public class Bullet extends Actor { protected static final int BULLET_SPEED=10; public Bullet(Stage stage) { super(stage); setSpriteNames( new String[] {"bullet.gif"}); } public void act() { super.act(); y-=BULLET_SPEED; if (y < 0) remove(); } } Pozostaje nam tylko związać z klawiszem spacji wystrzał kuli. Modyfikujemy więc Player.java public void fire() { Bullet b = new Bullet(stage); b.setX(x); b.setY(y - b.getHeight()); stage.addActor(b); } public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_UP : up = true; break; case KeyEvent.VK_LEFT : left = true; break; case KeyEvent.VK_RIGHT : right = true; break; case KeyEvent.VK_DOWN : down = true;break; case KeyEvent.VK_SPACE : fire(); break; } updateSpeed(); } Efekt jest ciekawy: Rys 9. Jakieś one kuloodporne… Bomby W celu zróżnicowania uzbrojenia, dajmy graczowi dodatkowy zasób. Bomby będą jakby falą uderzeniową złożoną z 8 kul ognia poruszających się we wszystkich kierunkach, i powiedzmy, że ma ich ograniczoną ilość – powiedzmy 5. Zasada oczywiście jest podobna, i dzięki programowaniu obiektowemu jest to niemal trywialne Rys 10. Tak docelowo ma wygalać efekt. Bomb.java public class public public public public public public public public Bomb extends static final static final static final static final static final static final static final static final Actor { int UP_LEFT = 0; int UP = 1; int UP_RIGHT = 2; int LEFT = 3; int RIGHT = 4; int DOWN_LEFT = 5; int DOWN = 6; int DOWN_RIGHT = 7; protected static final int BOMB_SPEED = 5; protected int vx; protected int vy; public Bomb(Stage stage, int heading, int x, int y) { super(stage); this.x = x; this.y = y; String sprite =""; switch (heading) { case UP_LEFT : vx = -BOMB_SPEED; vy = -BOMB_SPEED; sprite="bombUL.gif";break; case UP : vx = 0; vy = -BOMB_SPEED; sprite="bombU.gif";break; case UP_RIGHT: vx = BOMB_SPEED; vy = -BOMB_SPEED; sprite="bombUR.gif";break; case LEFT : vx = -BOMB_SPEED; vy = 0; sprite = "bombL.gif";break; case RIGHT : vx = BOMB_SPEED; vy = 0; sprite = "bombR.gif";break; case DOWN_LEFT : vx = -BOMB_SPEED; vy = BOMB_SPEED; sprite="bombDL.gif";break; case DOWN : vx = 0; vy = BOMB_SPEED; sprite = "bombD.gif";break; case DOWN_RIGHT : vx = BOMB_SPEED; vy = BOMB_SPEED; sprite = "bombDR.gif";break; } setSpriteNames( new String[] {sprite}); } public void act() { super.act(); y+=vy; x+=vx; if (y < 0 || y > Stage.WYSOKOSC || x < 0 || x > Stage.SZEROKOSC) remove(); } } Teraz tylko wystarczy związać ładunek z klawiszem B dodając w Player.java: public void fireCluster() { if (clusterBombs == 0) return; clusterBombs--; stage.addActor( stage.addActor( stage.addActor( stage.addActor( stage.addActor( stage.addActor( stage.addActor( stage.addActor( new new new new new new new new Bomb(stage, Bomb(stage, Bomb(stage, Bomb(stage, Bomb(stage, Bomb(stage, Bomb(stage, Bomb(stage, Bomb.UP_LEFT, x,y)); Bomb.UP,x,y)); Bomb.UP_RIGHT,x,y)); Bomb.LEFT,x,y)); Bomb.RIGHT,x,y)); Bomb.DOWN_LEFT,x,y)); Bomb.DOWN,x,y)); Bomb.DOWN_RIGHT,x,y)); } public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_UP : up = true; break; case KeyEvent.VK_LEFT : left = true; break; case KeyEvent.VK_RIGHT : right = true; break; case KeyEvent.VK_DOWN : down = true;break; case KeyEvent.VK_SPACE : fire(); break; case KeyEvent.VK_B : fireCluster(); break; } updateSpeed(); } Efekt zaczyna być imponujący: Rys 11. Ka-Bum! Wykrycie kolizji Koniec z tym, że strzelimy do przezroczystych potworów. Czas je wytępić. Rys 12. Standardowe porównianie w grafice 2D przez nachodzenie prostokątów. Całe szczęście wszystkie klasy shape, w tym rectangle, posiadają metodę intersects ( ) , która sprawdza przecinanie. Wystarczy więc aby aktorzy zwracali swoje brzegi. Dodatkowo, aktor powinien mieć jakaś metodę do reagowania na kolizję. Dodajemy więc do Actor.java public Rectangle getBounds() { return new Rectangle(x,y,width,height); } public void collision(Actor a){} załączmy jeszcze Actor.java, oraz do WojnaSwiatow.java import java.awt.Rectangle; Nadpisujemy metodę w Monster.java public void collision(Actor a) { if (a instanceof Bullet || a instanceof Bomb) remove(); } Pozostaje teraz stwierdzić samą kolizję. Oczywiście robimy to w klasie, która trzyma całą listę czyli WojnaSwiatow.java Dodajmy więc odpowiednia metodę oraz zmieniamy pętlę główną: public void checkCollisions() { Rectangle playerBounds = player.getBounds(); for (int i = 0; i < actors.size(); i++) { Actor a1 = (Actor)actors.get(i); Rectangle r1 = a1.getBounds(); if (r1.intersects(playerBounds)) { player.collision(a1); a1.collision(player); } for (int j = i+1; j < actors.size(); j++) { Actor a2 = (Actor)actors.get(j); Rectangle r2 = a2.getBounds(); if (r1.intersects(r2)) { a1.collision(a2); a2.collision(a1); } } } } public void game() { usedTime=1000; initWorld(); while (isVisible()) { long startTime = System.currentTimeMillis(); updateWorld(); checkCollisions(); paintWorld(); usedTime = System.currentTimeMillis()-startTime; try { Thread.sleep(20); } catch (InterruptedException e) {} } } Statystyki Warto byłoby mieć w końcu statystyki, ilość ubitych potworów, ilość bomb, ilość tarczy. No i żeby było to wyświetlane na ekranie. Chcemy wyswietlac: Punkty jakie uzyskał gracz Życie gracza Ilość Bomb, która pozostała W tym celu dokonamy paru zmian. Dodatkowo podzielimy okno gry na pole gry, i na pole statystyk.. Powiedzmy, że 500 pikseli będzie zajmowała gra, a pod nią, 100 statystyki. Twoim zadaniem jest potem dobrać te liczby trochę lepiej, tak, aby bardziej estetycznie to wyglądało. Tymczasem dodajmy nową stałą do naszego interfacu Stage: public static final int WYSOKOSC_GRY = 500; Następnie dodajmy nowe atrybuty w klasie Player: public static final int MAX_SHIELDS = 200; public static final int MAX_BOMBS = 5; private int score; private int shields; na koniec konstruktora tej klasy dodajemy: clusterBombs=MAX_BOMBS; shields = MAX_SHIELDS; zmienić trzeba będzie również tej klasie metodę klas ( potworom nie potrzeba bo poruszają się jedynie w poziomie, kule jedynie do przodu, można zmienić poruszanie się bomb, to pozostawiam również tobie w ramach ćwiczeń) public void act() { super.act(); x+=vx; y+=vy; if (x < 0 ) x = 0; if (x > Stage.SZEROKOSC - getWidth()) x = Stage.SZEROKOSC - getWidth(); if (y < 0 ) y = 0; if ( y > Stage.WYSOKOSC_GRY-getHeight()) y = Stage.WYSOKOSC_GRY - getHeight(); } Dodajmy jeszcze typowe metody do zmiany i odczytu danych z klasy: public public public public public public int getScore() { return score; } void setScore(int i) { score = i; } int getShields() { return shields; } void setShields(int i) { shields = i; } int getClusterBombs() { return clusterBombs; } void setClusterBombs(int i) { clusterBombs = i; } Teraz zajmijmy się rysowaniem tego wszystkiego na ekranie. Na wstępie w pliku WojnaSwiatow.java zaimportujmy odpowiednie klasy: import java.awt.Font; import java.awt.image.BufferedImage; Dodajmy nowe metody graficzne: public void paintShields(Graphics2D g) { g.setPaint(Color.red); g.fillRect(280,Stage.WYSOKOSC_GRY,Player.MAX_SHIELDS,30); g.setPaint(Color.blue); g.fillRect(280+Player.MAX_SHIELDSplayer.getShields(),Stage.WYSOKOSC_GRY,player.getShields(),30); g.setFont(new Font("Arial",Font.BOLD,20)); g.setPaint(Color.green); g.drawString("Shields",170,Stage.WYSOKOSC_GRY+20); } public void paintScore(Graphics2D g) { g.setFont(new Font("Arial",Font.BOLD,20)); g.setPaint(Color.green); g.drawString("Score:",20,Stage.WYSOKOSC_GRY + 20); g.setPaint(Color.red); g.drawString(player.getScore()+"",100,Stage.WYSOKOSC_GRY + 20); } public void paintAmmo(Graphics2D g) { int xBase = 280+Player.MAX_SHIELDS+10; for (int i = 0; i < player.getClusterBombs();i++) { BufferedImage bomb = spriteCache.getSprite("bombUL.gif"); g.drawImage( bomb ,xBase+i*bomb.getWidth(),Stage.WYSOKOSC_GRY,this); } } public void paintfps(Graphics2D g) { g.setFont( new Font("Arial",Font.BOLD,12)); g.setColor(Color.white); if (usedTime > 0) g.drawString(String.valueOf(1000/usedTime)+" fps",Stage.SZEROKOSC50,Stage.WYSOKOSC_GRY); else g.drawString("--- fps",Stage.WIDTH-50,Stage.WYSOKOSC_GRY); } public void paintStatus(Graphics2D g) { paintScore(g); paintShields(g); paintAmmo(g); paintfps(g); } No i zmieńmy rysowanie świata: public void paintWorld() { Graphics2D g = (Graphics2D)strategia.getDrawGraphics(); g.setColor(Color.black); g.fillRect(0,0,getWidth(),getHeight()); for (int i = 0; i < actors.size(); i++) { Actor m = (Actor)actors.get(i); m.paint(g); } player.paint(g); paintStatus(g); strategia.show(); } Wynik będzie zbliżony do: Rys 13. To powoli zaczyna przypominać grę No dobrze, ale przydałoby się zliczać te punkty skoro już je wypisujemy. Chcemy aby powiedzmy, przy zabiciu potwora, dodatkowo dodawane było 20 punktów. Jednakże o zabiciu potwora wie klasa Monster, która nie ma dostępu do atrybutów gracza. Może również zaistnieć wiele sytuacji, w których trzeba będzie mieć dostęp do atrybutów gracza: zmiana osłony, dodanie bomb, dodanie bonusów. Potrzebujemy więc możliwości używania metod gracza, a do tego, potrzebujemy mieć referencję na samego gracza. W tym celu dodamy jako metodę w Stage.java : public Player getPlayer(); Zimplementujmy ją w WojnaSwiatow.java public Player getPlayer() { return player;} Dodajmy graczowi możliwość zwiększania wyniku w player.java: public void addScore(int i) { score += i; } No i wreszcie zmodyfikujmy kolizję w potworze: public void collision(Actor a) { if (a instanceof Bullet || a instanceof Bomb){ remove(); stage.getPlayer().addScore(20); } } Śmierć No na razie nasz gracz jest nieśmiertelnym wojownikiem zabijającym 10 bezbronnych potworów. Taka gra nie przyciągnie tłumów. Trzeba dodać ryzyko śmierci gracza. Na razie zróbmy to w momencie kolizji z potworem. Oczywiście śmierć gracza oznaczać będzie koniec gry (lub stracenia życia, jeśli dasz graczowi więcej niż jedno. Wtedy grę kończyć będzie stracenie ostatniego z żyć, my na razie zostańmy przy jednym życiu) . Co chcemy więc uzyskać Przy zderzeniu z potworem gracz ma tracić życie, powiedzmy że zabija tym potwora i zyskuje więcej punktów. Gdy życie gracza się kończy gra zostaje przerwana i zostanie wyświetlony napis ‘Game Over’ Na wstępie dodajmy możliwość kończenia gry do Stage.java public void gameOver(); zaimplementujmy ją w WojnaSwiatow.java public void gameOver() { gameEnded = true;} i dodajmy do WojnaSwiatow.java odpowiedni atrybut: private boolean gameEnded=false; Musimy oczywiście zmienić warunek pętli głównej programu.: public void game() { usedTime=1000; initWorld(); while (isVisible() && !gameEnded) { long startTime = System.currentTimeMillis(); updateWorld(); checkCollisions(); paintWorld(); usedTime = System.currentTimeMillis()-startTime; try { Thread.sleep(20); } catch (InterruptedException e) {} } paintGameOver(); } no i stworzyć metodę wyświetlającą napis: public void paintGameOver() { Graphics2D g = (Graphics2D)strategia.getDrawGraphics(); g.setColor(Color.white); g.setFont(new Font("Arial",Font.BOLD,20)); g.drawString("GAME OVER",Stage.SZEROKOSC/2-50,Stage.WYSOKOSC/2); strategia.show(); } Dodajmy teraz wykrycie kolizji w player.java public void collision(Actor a) { if (a instanceof Monster ) { a.remove(); addScore(40); addShields(-40); if (getShields() < 0) stage.gameOver(); } } Na razie śmierć można uzyskać jedynie szarżując na potory: Rys 14. Aaaaa !!! Gra jest jednak ciągle zbyt łatwa, a zginąć w ten sposób może jedynie bardzo niedoświadczony (lub ograniczony) gracz. Dodajmy więc potworom trochę agresji. Niechaj strzelają jakimś magicznym laserem. Dodatkowo, skorzystamy znów z animacji, żeby uzyskać ciekawy efekt owego wystrzału. Przystąpmy więc do stworzenia nowej klasy, z owym laserem właśnie. Rys 15. Kolejne gify: disparo0.gif, disparo1.gif oraz disparo2.gif stworzą animację laseru Laser.java public class Laser extends Actor { protected static final int BULLET_SPEED=3; public Laser(Stage stage) { super(stage); setSpriteNames( new String[] {"disparo0.gif","disparo1.gif","disparo2.gif"}); setFrameSpeed(10); } public void act() { super.act(); y+=BULLET_SPEED; if (y > Stage.WYSOKOSC_GRY) remove(); } } widać, że jest ona podobna do klasy bullet, różni się jednak szybkością I kierunkiem poruszania się oraz animacją. Nauczymy więc teraz potwory strzelać. Najpierw dodajmy stałą częstotliwości strzelania: protected static final double FIRING_FREQUENCY = 0.01; następnie zmieńmy metodę act(): public void fire() { Laser m = new Laser(stage); m.setX(x+getWidth()/2); m.setY(y + getHeight()); stage.addActor(m); } public void act() { super.act(); x+=vx; if (x < 0 || x > Stage.SZEROKOSC) vx = -vx; if (Math.random()<FIRING_FREQUENCY) fire(); } No i zmodyfikujmy kolizję gracza: public void collision(Actor a) { if (a instanceof Monster ) { a.remove(); addScore(40); addShields(-40); } if (a instanceof Laser ) { a.remove(); addShields(-10); } if (getShields() < 0) stage.gameOver(); } Efekt jest imponujący, nareszcie coś się dzieje: Rys 16. Unikamy i strzelamy, taka gra. Przewijające się tło Wiele gier w tym stylu ma poruszające się tło. Jest to efektywny, aczkolwiek bardzo łatwy do uzyskania w javie efekt. Wystarczy zamiast jednym kolorem, wypełniać bitmapą. Zmieniając po prostu jej bazowe koordynatach. Dla zrozumienia tego zagadnienia spojrzmy na rysunki: Rys. 17 załóżmy że uzywalibyśmy takiego tła. Rys 18. Tak jeśli wypełnimy teksturą o wspołżędnych (0,0,256,256) Rys 19 Tak natomiast gdy podamy współrzędne tekstury (0,20,256,256) Efekt to przesunięcie teksturowania o 20 pikseli. W naszej grze będziemy chcieli uzyskać łagodniejszy efekt przejścia więc będziemy używać przesunięcia o 1 piksel. Użyjemy oczywiście ciekawszego i bardziej adekwatnego tła: oceano.gif Rys 20. Piękna powierzchnia oceanu to dobre pole bitwy Jedyny plik jaki modyfikujemy to plik główny programu WojnaSwiatow.java import import import import import import import import import import import import import import import java.awt.Canvas; java.awt.image.BufferedImage; java.awt.Font; java.awt.TexturePaint; java.awt.Rectangle; javax.swing.JFrame; javax.swing.JPanel; java.awt.Color; java.awt.Dimension; java.awt.Graphics; java.awt.event.WindowAdapter; java.awt.event.WindowEvent; java.awt.Graphics2D; java.awt.image.BufferStrategy; java.util.ArrayList; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; public class WojnaSwiatow extends Canvas implements Stage, KeyListener{ public long usedTime; public BufferStrategy strategia; private SpriteCache spriteCache; private ArrayList actors; private Player player;s private boolean gameEnded=false; private BufferedImage ocean; private int t; ... public void paintWorld() { Graphics2D g = (Graphics2D)strategia.getDrawGraphics(); ocean = spriteCache.getSprite("oceano.gif"); g.setPaint(new TexturePaint(ocean, new Rectangle(0,t,ocean.getWidth(),ocean.getHeight()))); g.fillRect(0,0,getWidth(),getHeight()); for (int i = 0; i < actors.size(); i++) { Actor m = (Actor)actors.get(i); m.paint(g); } player.paint(g); paintStatus(g); strategia.show(); } ... public void game() { usedTime=1000; t = 0; initWorld(); while (isVisible() && !gameEnded) { t++; long startTime = System.currentTimeMillis(); updateWorld(); checkCollisions(); paintWorld(); usedTime = System.currentTimeMillis()-startTime; try { Thread.sleep(20); } catch (InterruptedException e) {} } paintGameOver(); } Kilka linijek kodu zapewniło nam co najmniej ciekawy efekt: Rys 21. Gra zaczyna wyglądać profesjonalnie Odradzanie się potworów Po zabiciu naszych potworów gra przestaje posiadac jakikolwiek sens. Możemy to rozwiązać przez: Tworzenie nowych potworów gdy jakiś ginie Zmienić poziom na kolejny z nowymi potworkami i tłem Nowy potworek pojawiałby się co jakiś czas, niezależnie od ilości zabitych potorów Spróbujmy uczynić pierwszy wariant. Wystarczy zmodyfikować plik Monster.java: public void spawn() { Monster m = new Monster(stage); m.setX( (int)(Math.random()*Stage.SZEROKOSC) ); m.setY( (int)(Math.random()*Stage.WYSOKOSC_GRY/2) ); m.setVx( (int)(Math.random()*20-10)+1); stage.addActor(m); } public void collision(Actor a) { if (a instanceof Bullet || a instanceof Bomb){ remove(); spawn(); stage.getPlayer().addScore(20); } } Proste dźwięki Dodanie dźwięku i muzyki do naszej gry jest bardzo proste, tworzy jednak znakomity efekt. Większość producentów gier przykłada więcej starań do tworzenia grafiki i muzyki, niż do samego programowania, co owocuje potem wspaniałymi efektami, ale fatalną grywalnością... Użyjmy prostych plików .wav : musica.wav do muzyki powtarzającej się w tle photon.wav jako dźwięku wystrzeliwania laseru przez potwory explosion.wav jako dźwięku odgrywanego przy śmierci potwora missile.wav jako dźwięku wystrzelenia rakiety przez gracza Dźwięki to nic innego jak zasoby, i tak samo jak obrazki potrzebują swój cache. Widzimy więc, że przyda się ogólna klasa ResourceCache: ResourceCache.java import java.net.URL; import java.util.HashMap; public abstract class ResourceCache { protected HashMap resources; public ResourceCache() { resources = new HashMap(); } protected Object loadResource(String name) { URL url=null; url = getClass().getClassLoader().getResource(name); return loadResource(url); } protected Object getResource(String name) { Object res = resources.get(name); if (res == null) { res = loadResource(name); resources.put(name,res); } return res; } protected abstract Object loadResource(URL url); } Musimy więc trochę zmienić SpriteCache: SpriteCache.java import java.awt.image.BufferedImage; import java.net.URL; import javax.imageio.ImageIO; public class SpriteCache extends ResourceCache{ protected Object loadResource(URL url) { try { return ImageIO.read(url); } catch (Exception e) { System.out.println("Przy otwieraniu " + url); System.out.println("Wystapil blad : "+e.getClass().getName()+" "+e.getMessage()); System.exit(0); return null; } } public BufferedImage getSprite(String name) { return (BufferedImage)getResource("img/"+name); } } no i stworzyć wkońcu: SoundCache.java import java.applet.Applet; import java.applet.AudioClip; import java.net.URL; public class SoundCache extends ResourceCache{ protected Object loadResource(URL url) { return Applet.newAudioClip(url); } public AudioClip getAudioClip(String name) { return (AudioClip)getResource("sound/"+name); } public void playSound(final String name) { getAudioClip(name).play(); } public void loopSound(final String name) { getAudioClip(name).loop(); } } Oczywiście zakładam, że pliki z muzyką będziemy trzymać w katalogu /sound. Ponieważ każda klasa powinna mieć dostęp do cache’u muzyki, trzeba zmodyfikowac stage.java: import java.awt.image.ImageObserver; public interface Stage extends ImageObserver { public static final int SZEROKOSC = 800; public static final int WYSOKOSC = 600; public static final int SZYBKOSC = 20; public static final int WYSOKOSC_GRY = 500; public SpriteCache getSpriteCache(); public void addActor(Actor a); public Player getPlayer(); public SoundCache getSoundCache(); public void gameOver(); } Teraz pozostaje nam już tylko dodać dzwięki w odpowiednich miejscach: WojnaSwiatow.java import import import import import import import import import import import import import import import import import java.awt.Canvas; java.awt.image.BufferedImage; java.awt.Font; java.awt.TexturePaint; java.awt.Rectangle; javax.swing.JFrame; javax.swing.JPanel; java.awt.Color; java.awt.Dimension; java.awt.Graphics; java.awt.event.WindowAdapter; java.awt.event.WindowEvent; java.awt.Graphics2D; java.awt.image.BufferStrategy; java.util.ArrayList; java.awt.event.KeyEvent; java.awt.event.KeyListener; public class WojnaSwiatow extends Canvas implements Stage, KeyListener{ public long usedTime; public BufferStrategy strategia; private SpriteCache spriteCache; private ArrayList actors; private Player player; private boolean gameEnded=false; private SoundCache soundCache; private BufferedImage ocean; private int t; public WojnaSwiatow() { spriteCache = new SpriteCache(); soundCache = new SoundCache(); JFrame okno = new JFrame(".: Wojna Swiatow :."); JPanel panel = (JPanel)okno.getContentPane(); setBounds(0,0,Stage.SZEROKOSC,Stage.WYSOKOSC); panel.setPreferredSize(new Dimension(Stage.SZEROKOSC,Stage.WYSOKOSC)); panel.setLayout(null); panel.add(this); okno.setBounds(0,0,Stage.SZEROKOSC,Stage.WYSOKOSC); okno.setVisible(true); okno.addWindowListener( new WindowAdapter() { public void windowClosing(WindowEvent e) { System.exit(0); } }); okno.setResizable(false); createBufferStrategy(2); strategia = getBufferStrategy(); requestFocus(); addKeyListener(this); } public SoundCache getSoundCache() { return soundCache; } ... W pliku Player.java usupełniamy metodę fire () public void fire() { Bullet b = new Bullet(stage); b.setX(x); b.setY(y - b.getHeight()); stage.addActor(b); stage.getSoundCache().playSound("missile.wav"); } a w Monster.java collision() oraz fire() public void fire() { Laser m = new Laser(stage); m.setX(x+getWidth()/2); m.setY(y + getHeight()); stage.addActor(m); stage.getSoundCache().playSound("photon.wav"); } public void collision(Actor a) { if (a instanceof Bullet || a instanceof Bomb){ remove(); stage.getSoundCache().playSound("explosion.wav"); spawn(); stage.getPlayer().addScore(20); } } Pojawia się jednak problem. Gra może zacząc spowalniać. Dlaczego? Dlatego, że wszystko wykonujemy w jednej pętli, która zawsze była spowalniana poprzez wgrywanie dźwięku. Wygodniej będzie tworzyć wątek, który ma to zrobić. Tak unikniemy spowolnienia. Zmodyfikujmy wiec SoundCache.java import java.applet.Applet; import java.applet.AudioClip; import java.net.URL; public class SoundCache extends ResourceCache{ protected Object loadResource(URL url) { return Applet.newAudioClip(url); } public AudioClip getAudioClip(String name) { return (AudioClip)getResource("sound/"+name); } public void playSound(final String name) { new Thread( new Runnable() { public void run() { getAudioClip(name).play(); } } ).start(); } public void loopSound(final String name) { new Thread( new Runnable() { public void run() { getAudioClip(name).loop(); } } ).start(); } } Optymalizacje kodu Gra praktycznie jest gotowa. Pozostaje nam jeszcze tylko ulepszyć ją, optymalizując kod. Pierwszą optymalizacją będzie trzymanie obrazków w kompatybilnym formacie. Co to znaczy? Kompatybilny obrazek trzymany w pamięci taki, że jego cechy charakterystyczne, są bardzo zbliżone do trybu wideo, który właśnie używamy. Takie obrazki są o wiele szybsze do narysowania, niż te których teraz używamy (czyli ImageIO). Scenariusz optymalizacji to: 1. Przeczytać obrazek z dysku za pomocą ImageIO 2. Stworzenie kompatybilnego rysunku o rozmiarach wczytanego 3. Przerysowanie wczytanego rysunku do nowo utworzonego Zmodyfikujmy więc SpriteCache.java: import import import import java.awt.Graphics; java.awt.GraphicsConfiguration; java.awt.GraphicsEnvironment; java.awt.Image; import import import import import java.awt.Transparency; java.awt.image.BufferedImage; java.awt.image.ImageObserver; java.net.URL; javax.imageio.ImageIO; public class SpriteCache extends ResourceCache implements ImageObserver{ protected Object loadResource(URL url) { try { return ImageIO.read(url); } catch (Exception e) { System.out.println("Przy otwieraniu " + url); System.out.println("Wystapil blad : "+e.getClass().getName()+" "+e.getMessage()); System.exit(0); return null; } } public BufferedImage createCompatible(int width, int height, int transparency) { GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefa ultConfiguration(); BufferedImage compatible = gc.createCompatibleImage(width,height,transparency); return compatible; } public BufferedImage getSprite(String name) { BufferedImage loaded = (BufferedImage)getResource("img/"+name); BufferedImage compatible = createCompatible(loaded.getWidth(),loaded.getHeight(),Transparency.BITMASK); Graphics g = compatible.getGraphics(); g.drawImage(loaded,0,0,this); return compatible; } public boolean imageUpdate(Image img, int infoflags,int x, int y, int w, int h) { return (infoflags & (ALLBITS|ABORT)) == 0; } } Następną optymalizacją będzie ulepszenie efektu, który okropnie zwolnił pracę gry. Mowa o przewijanym tle jak się słusznie domyślacie. Pozbędziemy się ciągłego teksturowania. Jak? Stworzymy obrazek w pamięci, o szerokości okna, ale wysokości większej, aby uchwycić całą pętle i będziemy odpowiednio rysować ten w ten sposób utworzony prostokąt. Zmieniamy WojnaSwiatow.java: import import import import import import import import import java.awt.Canvas; java.awt.Transparency; java.awt.image.BufferedImage; java.awt.Font; java.awt.TexturePaint; java.awt.Rectangle; javax.swing.JFrame; javax.swing.JPanel; java.awt.Color; import import import import import import import import import java.awt.Dimension; java.awt.Graphics; java.awt.event.WindowAdapter; java.awt.event.WindowEvent; java.awt.Graphics2D; java.awt.image.BufferStrategy; java.util.ArrayList; java.awt.event.KeyEvent; java.awt.event.KeyListener; public class WojnaSwiatow extends Canvas implements Stage, KeyListener{ public long usedTime; public BufferStrategy strategia; private SpriteCache spriteCache; private ArrayList actors; private Player player; private boolean gameEnded=false; private SoundCache soundCache; private BufferedImage background, backgroundTile; private int backgroundY; ... public void initWorld() { actors = new ArrayList(); for (int i = 0; i < 10; i++){ Monster m = new Monster(this); m.setX( (int)(Math.random()*Stage.SZEROKOSC) ); m.setY( i*20 ); m.setVx( (int)(Math.random()*3)+1 ); actors.add(m); } player = new Player(this); player.setX(Stage.SZEROKOSC/2); player.setY(Stage.WYSOKOSC - 2*player.getHeight()); soundCache.loopSound("musica.wav"); backgroundTile = spriteCache.getSprite("oceano.gif"); background = spriteCache.createCompatible( Stage.SZEROKOSC, Stage.WYSOKOSC+backgroundTile.getHeight(), Transparency.OPAQUE); Graphics2D g = (Graphics2D)background.getGraphics(); g.setPaint( new TexturePaint( backgroundTile, new Rectangle(0,0,backgroundTile.getWidth(),backgroundTile.getHeight()))); g.fillRect(0,0,background.getWidth(),background.getHeight()); backgroundY = backgroundTile.getHeight(); } ... public void paintWorld() { Graphics2D g = (Graphics2D)strategia.getDrawGraphics(); g.drawImage( background, 0,0,Stage.SZEROKOSC,Stage.WYSOKOSC, 0,backgroundY,Stage.SZEROKOSC,backgroundY+Stage.WYSOKOSC,this); for (int i = 0; i < actors.size(); i++) { Actor m = (Actor)actors.get(i); m.paint(g); } player.paint(g); paintStatus(g); strategia.show(); } ... public void game() { usedTime=1000; initWorld(); while (isVisible() && !gameEnded) { long startTime = System.currentTimeMillis(); backgroundY--; if (backgroundY < 0) backgroundY = backgroundTile.getHeight(); updateWorld(); checkCollisions(); paintWorld(); usedTime = System.currentTimeMillis()-startTime; try { Thread.sleep(10); } catch (InterruptedException e) {} } paintGameOver(); } ... Kolejną optymalizacją będzie pozbycie się kursora myszy.Wystarczy zmienić konstruktor dopisując na jego końcu: BufferedImage cursor = spriteCache.createCompatible(10,10,Transparency.BITMASK); Toolkit t = Toolkit.getDefaultToolkit(); Cursor c = t.createCustomCursor(cursor,new Point(5,5),"null"); setCursor(c); Istenieje jeszcze pewien niezauważalny prawie problem, jednakże świadczący o nieprofesjonaliźmie. Gdy odeślemy grę w tło i następnie spowrotem jna pierwszy plan, zobaczymy szare mignięcie okna. Dlaczego? Dlatego, że AWT automatycznie w tej sytuacji przemalowuje okna, oczywiście domyślnym szarym, a nie naszą strategią. Można to bardzo łatwo wyłączyć, dodając na koniec kontruktora: setIgnoreRepaint(true); Mówimy tym AWT, że nie ma się kłopotać, gdyż sami umiemy przemalowywać okno. Ostatnia optymalizacja, jest raczej kluczowa. Nasza gra W dużym stopniu zależy od szybkości komputera. Poprzez stałą ilość odespanego czasu czasem gra będzie działać bardzo szybko, czasem natomiast bardzo wolno. Dobrze byłoby, gdyby gra dostosowywała się do tego. Szybkość łatwo dostosować obserwując FPS-y. Znów przyda nam się wątek. Zmieńmy metodę game() : public void game() { usedTime=1000; initWorld(); while (isVisible() && !gameEnded) { long startTime = System.currentTimeMillis(); backgroundY--; if (backgroundY < 0) backgroundY = backgroundTile.getHeight(); updateWorld(); checkCollisions(); paintWorld(); usedTime = System.currentTimeMillis()-startTime; do { Thread.yield(); } while (System.currentTimeMillis()-startTime< 17); } paintGameOver(); } Słowa końcowe Mam nadzieję, że powyższy kurs wniósł wiele w naukę javy i rozwinął wasze skrzydła. Możliwości na rozwinięcia tej gry są niezliczone. Na podstawie poznanych tu sztuczek i technik zapewne będziecie w stanie stworzyć wiele zupełnie innych gier. Wierzę również, że doceniliście programowanie obiektowe. Wszelkie uwagi, proszę kierować na email. Życzę powodzenia w dalszych zabawach i testach! Dziekuje za lekturę.