Nauka programowania gier komputerowych w Javie.

advertisement
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ę.
Download
Random flashcards
Motywacja w zzl

3 Cards ypy

słowka

2 Cards kksenia.kot1997

2+2=?

2 Cards jogaf85537

Create flashcards