Größere Anwendung: Asteroids
Asteroids wurde 1979 von Atari entwickelt und selbst veröffentlicht. Es ist ein Videospiel-Klassiker. Das Spiel besteht darin, dass die Spielenden ein dreieckiges Raumschiff steuern, mit dem Ziel, Asteroiden zu zerstören, indem sie diese abschießen.
Im Folgenden erstellen wir ein größeres Beispiel, in dem wir einen Teil des Asteroids-Spiels programmieren. Dieses Spiel ist ebenfalls eine Übung im Kurs – schreiben Sie das Spiel in die bereitgestellte Vorlage (am Ende des Beispiels), indem Sie das Beispiel schrittweise befolgen.
Das Spiel wird in mehrere Teile aufgeteilt:
- Erstellen des Spielfensters
- Erstellen des Raumschiffs
- Drehen des Raumschiffs
- Bewegen des Raumschiffs
- Erstellen eines Asteroiden
- Die Kollision zwischen dem Raumschiff und einem Asteroiden
- Mehrere Asteroiden
- Im Fenster bleiben
- Projektile
- Hinzufügen von Punkten
- Kontinuierliches Hinzufügen von Asteroiden
Beginnen wir mit der Erstellung des Spielfensters.
Erstellen des Spielfensters
Wir bauen die Anwendung so auf, dass das Spielfenster eine beliebige Anzahl von Elementen enthalten kann, deren Positionen vom verwendeten Layout ignoriert werden. Diese Aufgabe passt gut zur Pane-Klasse. Die Pane-Klasse enthält eine Liste vom Typ ObservableList, die Kind-Elemente enthält. Auf die Liste kann über die Methode getChildren
der Pane-Klasse zugegriffen werden.
Das folgende Programm erstellt ein Fenster, das 300 Pixel breit und 200 Pixel hoch ist. An der Position 30, 50 im Fenster befindet sich ein Kreis mit einem Radius von 10 Pixeln. In Computerprogrammen befindet sich der Ursprung des Koordinatensystems typischerweise in der oberen linken Ecke des Fensters. Der y-Wert erhöht sich, wenn Sie nach unten gehen.
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Circle;
import javafx.stage.Stage;
public class PaneExample extends Application {
@Override
public void start(Stage stage) throws Exception {
Pane pane = new Pane();
pane.setPrefSize(300, 200);
pane.getChildren().add(new Circle(30, 50, 10));
Scene scene = new Scene(pane);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch(args);
}
}

Unsere Anwendung heißt AsteroidsApplication
. Die AsteroidsApplication
verwendet das obige Beispiel. Die Anwendung fügt jedoch keinen Kreis zum Fenster hinzu, sondern wir haben einen Titel für die Anwendung hinzugefügt. Die Breite des Fensters beträgt 600 Pixel und die Höhe 400 Pixel.
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.stage.Stage;
public class AsteroidsApplication extends Application {
@Override
public void start(Stage stage) throws Exception {
Pane pane = new Pane();
pane.setPrefSize(600, 400);
Scene scene = new Scene(pane);
stage.setTitle("Asteroids!");
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Erstellen des Raumschiffs
Als Nächstes erstellen wir das Raumschiff. Im Asteroids-Spiel ist das Raumschiff ein Dreieck. Die Darstellung des Dreiecks erfolgt mithilfe der Polygon-Klasse, die zur Darstellung von Polygonen verwendet wird. Die Ecken des Polygons werden entweder als Parameter des Konstruktors oder in die Liste, die in der Polygon-Klasse enthalten ist, gesetzt.
Im folgenden Beispiel haben wir ein Parallelogramm hinzugefügt, das 100 Pixel breit und 50 Pixel hoch ist, indem wir die Polygon-Klasse verwenden.
@Override
public void start(Stage stage) throws Exception {
Pane pane = new Pane();
pane.setPrefSize(300, 200);
Polygon parallelogram = new Polygon(0, 0, 100, 0, 100, 50, 0, 50);
pane.getChildren().add(parallelogram);
Scene scene = new Scene(pane);
stage.setScene(scene);
stage.show();
}

Das Polygon kann mit den Methoden setTranslateX
und setTranslateY
der Polygon-Klasse in eine passendere Position verschoben werden. Im folgenden Beispiel wird ein Parallelogramm wie zuvor erstellt, aber nun wurde es um 100 Pixel nach rechts und um 20 Pixel nach unten verschoben.
@Override
public void start(Stage stage) throws Exception {
Pane pane = new Pane();
pane.setPrefSize(300, 200);
Polygon parallelogram = new Polygon(0, 0, 100, 0, 100, 50, 0, 50);
parallelogram.setTranslateX(100);
parallelogram.setTranslateY(20);
pane.getChildren().add(parallelogram);
Scene scene = new Scene(pane);
stage.setScene(scene);
stage.show();
}

Lassen Sie uns ein Dreieck erstellen, das das Raumschiff darstellt, und es zu unserer AsteroidsApplication
hinzufügen. Wir setzen das Dreieck in die Mitte des Bildschirms – da die Breite des Bildschirms 600 Pixel und die Höhe 400 Pixel beträgt, verschieben wir das Dreieck um 300 Pixel nach rechts und 200 Pixel nach unten.
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Polygon;
import javafx.stage.Stage;
public class AsteroidsApplication extends Application {
@Override
public void start(Stage stage) throws Exception {
Pane pane = new Pane();
pane.setPrefSize(600, 400);
Polygon ship = new Polygon(-5, -5, 10, 0, -5, 5);
ship.setTranslateX(300);
ship.setTranslateY(200);
pane.getChildren().add(ship);
Scene scene = new Scene(pane);
stage.setTitle("Asteroids!");
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Drehen des Raumschiffs: Tastatur-Listener, Teil 1
Klassen wie Polygon und Circle erben die Node-Klasse von JavaFX. Die Node-Klasse verfügt über eine Variable rotate
, die die Rotation des Nodes in Grad beschreibt. Das Drehen eines Objekts, das von der Node-Klasse erbt, ist daher recht einfach – Sie verwenden einfach die vorhandene Methode setRotate
. Die Methode erhält den Grad der Rotation als Parameter.
Im folgenden Beispiel haben wir ein vorheriges Beispiel so geändert, dass das Parallelogramm um 30 Grad gedreht wird.
@Override
public void start(Stage stage) throws Exception {
Pane pane = new Pane();
pane.setPrefSize(600, 400);
Polygon ship = new Polygon(-5, -5, 10, 0, -5, 5);
ship.setTranslateX(300);
ship.setTranslateY(200);
ship.setRotate(30);
pane.getChildren().add(ship);
Scene scene = new Scene(pane);
stage.setScene(scene);
stage.show();
}
In Wirklichkeit möchten wir jedoch nicht, dass sich das Raumschiff nur einmal dreht, sondern dass man das Raumschiff während des Spiels steuern kann.
Das Scene
-Objekt, das den Inhalt des Fensters beschreibt, bietet die Methode setOnKeyPressed
, der ein Objekt zur Ereignisbehandlung als Parameter übergeben werden kann. Lassen Sie uns einen Ereignis-Handler erstellen, der auf Tastaturereignisse reagiert. Tastaturereignisse haben eine aufgezählte Variable KeyCode
, die uns mitteilt, welche Taste gedrückt wurde. Wir interessieren uns für die Tasten Links (LEFT) und Rechts (RIGHT).
Zunächst erstellen wir eine Testversion, in der das Drehen des Raumschiffs einfach ist. Wenn die Spiel enden die linke Pfeiltaste drücken, wird der Winkel auf -30 gesetzt. Wenn sie die rechte Taste drücken, wird der Winkel auf 30 gesetzt.
scene.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.LEFT) {
ship.setRotate(-30);
}
if (event.getCode() == KeyCode.RIGHT) {
ship.setRotate(30);
}
});
Wenn das Raumschiff ein Parallelogramm wäre, würde die Funktionalität folgendermaßen aussehen:

Das Drehen kann gleichmäßiger gestaltet werden, indem vorhandene Informationen über die Drehung verwendet werden. Im nächsten Beispiel dreht sich das Raumschiff jeweils um fünf Grad.
scene.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.LEFT) {
ship.setRotate(ship.getRotate() - 5);
}
if (event.getCode() == KeyCode.RIGHT) {
ship.setRotate(ship.getRotate() + 5);
}
});
Das Parallelogramm in der folgenden Animation kann links oder rechts gedreht werden.

Drehen des Raumschiffs: Tastatur-Listener, Teil 2
Der vorherige Ansatz ermöglicht ein "ganz okay"-Drehen eines Nodes. Es gibt jedoch ein Problem – die Bewegung ist nicht flüssig. Wenn die Taste gedrückt wird, dreht sich das Raumschiff, hält eine kurze Pause ein und dreht sich dann weiter.
Das liegt daran, wie Programme standardmäßig Tastaturereignisse verarbeiten. Wenn das Programm die Tastatureingabe sofort bei Tastendruck mehrfach verarbeiten würde, wäre beispielsweise das Schreiben von Text recht schwierig, da selbst kurze Tastendrücke sofort mehrere Zeichen erzeugen würden.
Ändern wir die Verarbeitung der Tastaturereignisse so, dass wir eine Aufzeichnung der gedrückten Tasten führen. Dies kann zum Beispiel mithilfe einer Hashtabelle geschehen. Die Hashtabelle enthält das KeyCode
-Objekt, das die Taste darstellt, als Schlüssel und eine Boolean-Variable als Wert. Wenn der Wert der Boolean-Variable für eine bestimmte Taste true
ist, wird die Taste gedrückt, andernfalls nicht.
Nun berücksichtigen wir auch das Loslassen der Taste, d. h. das onKeyReleased
-Ereignis.
Map<KeyCode, Boolean> pressedKeys = new HashMap<>();
scene.setOnKeyPressed(event -> {
pressedKeys.put(event.getCode(), Boolean.TRUE);
});
scene.setOnKeyReleased(event -> {
pressedKeys.put(event.getCode(), Boolean.FALSE);
});
Aber! Derzeit dreht nichts das Raumschiff.
Genau. Wir müssen noch eine Funktion zum Drehen hinzufügen. Wir werden die Klasse AnimationTimer
verwenden, die zum Erstellen von Animationen gedacht ist, und ihr die Verantwortung übertragen, das Raumschiff zu drehen, falls die linke oder rechte Taste gedrückt wird.
Map<KeyCode, Boolean> pressedKeys = new HashMap<>();
scene.setOnKeyPressed(event -> {
pressedKeys.put(event.getCode(), Boolean.TRUE);
});
scene.setOnKeyReleased(event -> {
pressedKeys.put(event.getCode(), Boolean.FALSE);
});
new AnimationTimer() {
@Override
public void handle(long now) {
if (pressedKeys.getOrDefault(KeyCode.LEFT, false)) {
ship.setRotate(ship.getRotate() - 5);
}
if (pressedKeys.getOrDefault(KeyCode.RIGHT, false)) {
ship.setRotate(ship.getRotate() + 5);
}
}
}.start();
Die Methode handle
der Klasse AnimationTimer
wird etwa 60 Mal pro Sekunde aufgerufen. Jetzt ist die Rotation viel flüssiger (obwohl das in der Animation als GIF unten nicht sehr deutlich wird ...).

Bewegen des Raumschiffs: Erster Versuch
Jetzt können wir das Raumschiff drehen. Als Nächstes fügen wir die Möglichkeit hinzu, sich zu bewegen. Das Raumschiff sollte sich in jede Himmelsrichtung bewegen können, was bedeutet, dass wir Werte für sowohl die x- als auch die y-Koordinaten benötigen, um die Bewegung darzustellen. Die konkrete Implementierung der Bewegung besteht darin, die Position des Polygons, das das Raumschiff darstellt, während des Programmlaufs zu ändern.
Lassen Sie uns die vorhandene Java-Klasse Point2D verwenden, um die Bewegung darzustellen – die Klasse hat sowohl x- als auch y-Koordinaten.
Die erste Testversion besteht darin, eine Bewegungsvariable zu erstellen und sie in die Methode handle
der Klasse AnimationTimer
einzufügen.
Point2D movement = new Point2D(1, 0);
new AnimationTimer() {
@Override
public void handle(long now) {
if (pressedKeys.getOrDefault(KeyCode.LEFT, false)) {
ship.setRotate(ship.getRotate() - 5);
}
if (pressedKeys.getOrDefault(KeyCode.RIGHT, false)) {
ship.setRotate(ship.getRotate() + 5);
}
ship.setTranslateX(ship.getTranslateX() + movement.getX());
}
}.start();
Hurra! Das Raumschiff bewegt sich (und kann gedreht werden). Allerdings verschwindet es recht schnell ...

Die von uns gewählte Klasse Point2D
ist in mancher Hinsicht wie die Klasse String
– nämlich, sie ist unveränderlich, sodass sie nicht geändert werden kann. Wir können die Werte eines vorhandenen Punkts nicht ändern, und das Aufrufen der Methoden eines Punkts gibt immer einen neuen Punktwert zurück. Dies stellt ein Problem dar, da wir die Werte der Objekte in Methoden nicht ändern können. Die folgende Lösung ist daher ausgeschlossen.
new AnimationTimer() {
@Override
public void handle(long now) {
// .. funktioniert nicht ..
if (pressedKeys.getOrDefault(KeyCode.UP, false)) {
movement = movement.add(new Point2D(1, 1));
}
// ..
}
}.start();
Methodenaufrufe sind jedoch erlaubt. Es ist Zeit für Refactoring und Aufräumen der Programmstruktur ...
Bewegen des Raumschiffs: Refactoring
Erstellen wir eine Klasse namens Ship
, die ein Polygon-Objekt und ein Point2D-Objekt enthält. Das Polygon-Objekt stellt das Raumschiff dar, und das Point2D-Objekt stellt die Bewegung des Raumschiffs dar. Das Raumschiff erhält die x- und y-Koordinaten des Schiffs als Konstruktorparameter. Das Raumschiff kann nach links oder rechts gedreht werden.
import javafx.geometry.Point2D;
import javafx.scene.shape.Polygon;
public class Ship {
private Polygon character;
private Point2D movement;
public Ship(int x, int y) {
this.character = new Polygon(-5, -5, 10, 0, -5, 5);
this.character.setTranslateX(x);
this.character.setTranslateY(y);
this.movement = new Point2D(0, 0);
}
public Polygon getCharacter() {
return character;
}
public void turnLeft() {
this.character.setRotate(this.character.getRotate() - 5);
}
public void turnRight() {
this.character.setRotate(this.character.getRotate() + 5);
}
public void move() {
this.character.setTranslateX(this.character.getTranslateX() + this.movement.getX());
this.character.setTranslateY(this.character.getTranslateY() + this.movement.getY());
}
}
Dieses Refactoring führt zu Änderungen an mehreren Stellen im Programm. Anstelle eines Punktes zur Darstellung der Bewegung und eines Polygons zur Darstellung des Raumschiffs erstellen wir ein Ship
-Objekt. Außerdem erhält das Pane
-Objekt das Polygon
-Objekt des Raumschiffs, jedoch nicht das Ship
-Objekt selbst.
Ship ship = new Ship(150, 100);
pane.getChildren().add(ship.getCharacter());
Auch die Methode im AnimationTimer
-Objekt sollte aktualisiert werden, um die Methoden des Raumschiffs zu verwenden.
new AnimationTimer() {
@Override
public void handle(long now) {
if (pressedKeys.getOrDefault(KeyCode.LEFT, false)) {
ship.turnLeft();
}
if (pressedKeys.getOrDefault(KeyCode.RIGHT, false)) {
ship.turnRight();
}
ship.move();
}
}.start();
Bewegen des Raumschiffs: Zweiter Versuch
Das Raumschiff bewegt sich, aber es ist noch nicht möglich, die Bewegung zu beeinflussen. Lassen Sie uns eine Beschleunigungsfunktion hinzufügen. Das Raumschiff sollte beschleunigen, sodass sich die Geschwindigkeit in die Richtung erhöht, in die das Raumschiff
zeigt. Wir können die Beschleunigungsinformation aus dem Rotationsgrad des Schiffs entnehmen, den wir mit der Methode getRotate()
ermitteln können. Wir haben es bereits beim Drehen des Raumschiffs kennengelernt.
Die Richtung der Beschleunigung kann mithilfe der Sinus- und Kosinusfunktionen ermittelt werden. Die vorhandene Java-Klasse Math enthält die relevanten Methoden. Die Methoden gehen davon aus, dass ihre Parameter in Radiant vorliegen, sodass wir auch die Methode der Math-Klasse benötigen, die Grad in Radiant umwandelt.
double changeX = Math.cos(Math.toRadians(*angle in degrees*));
double changeY = Math.sin(Math.toRadians(*angle in degrees*));
Die erste Version der Methode accelerate
der Klasse Ship
sieht folgendermaßen aus.
public void accelerate() {
double changeX = Math.cos(Math.toRadians(this.character.getRotate()));
double changeY = Math.sin(Math.toRadians(this.character.getRotate()));
this.movement = this.movement.add(changeX, changeY);
}
Lassen Sie uns die Möglichkeit zur Beschleunigung in die Anwendung einfügen. Die Methode accelerate
wird aufgerufen, wenn die Spielenden die Pfeiltaste nach oben drücken.
new AnimationTimer() {
@Override
public void handle(long now) {
if (pressedKeys.getOrDefault(KeyCode.LEFT, false)) {
ship.turnLeft();
}
if (pressedKeys.getOrDefault(KeyCode.RIGHT, false)) {
ship.turnRight();
}
if (pressedKeys.getOrDefault(KeyCode.UP, false)) {
ship.accelerate();
}
ship.move();
}
}.start();

Wie Sie sehen, beschleunigt das Raumschiff. Die Beschleunigung ist jedoch recht stark, sodass sie etwas angepasst werden sollte. Ändern wir die Methode accelerate
des Raumschiffs so, dass die Änderung nur 5 % des vorherigen Werts beträgt.
public void accelerate() {
double changeX = Math.cos(Math.toRadians(this.character.getRotate()));
double changeY = Math.sin(Math.toRadians(this.character.getRotate()));
changeX *= 0.05;
changeY *= 0.05;
this.movement = this.movement.add(changeX, changeY);
}
Jetzt ist es mehr oder weniger möglich, das Raumschiff zu steuern.

Erstellen eines Asteroiden
Als Nächstes erstellen wir einen Asteroiden. Ein Asteroid hat eine Form, eine Position und eine Bewegung.
Hm...
Wenn wir darüber nachdenken, sind dies fast genau dieselben Dinge, die auch ein Raumschiff benötigt – der einzige Unterschied liegt in der Form. Dies ist ein guter Zeitpunkt, um zu verallgemeinern. Wir erstellen eine abstrakte Klasse namens Character
, die ein Polygon und eine Position als Konstruktorparameter erhält. Beachten Sie, dass die Funktionalität fast vollständig aus der Klasse Ship
kopiert ist.
import javafx.geometry.Point2D;
import javafx.scene.shape.Polygon;
public abstract class Character {
private Polygon character;
private Point2D movement;
public Character(Polygon polygon, int x, int y) {
this.character = polygon;
this.character.setTranslateX(x);
this.character.setTranslateY(y);
this.movement = new Point2D(0, 0);
}
public Polygon getCharacter() {
return character;
}
public void turnLeft() {
this.character.setRotate(this.character.getRotate() - 5);
}
public void turnRight() {
this.character.setRotate(this.character.getRotate() + 5);
}
public void move() {
this.character.setTranslateX(this.character.getTranslateX() + this.movement.getX());
this.character.setTranslateY(this.character.getTranslateY() + this.movement.getY());
}
public void accelerate() {
double changeX = Math.cos(Math.toRadians(this.character.getRotate()));
double changeY = Math.sin(Math.toRadians(this.character.getRotate()));
changeX *= 0.05;
changeY *= 0.05;
this.movement = this.movement.add(changeX, changeY);
}
}
Ändern wir die Klasse Ship
so, dass sie von der Klasse Character
erbt.
import javafx.scene.shape.Polygon;
public class Ship extends Character {
public Ship(int x, int y) {
super(new Polygon(-5, -5, 10, 0, -5, 5), x, y);
}
}
Ganz einfach.
Dann erstellen wir die Klasse Asteroid
. Der erste Entwurf wird ein Rechteck sein – um die Form des Asteroiden kümmern wir uns später.
import javafx.scene.shape.Polygon;
public class Asteroid extends Character {
public Asteroid(int x, int y) {
super(new Polygon(20, -20, 20, 20, -20, 20, -20, -20), x, y);
}
}
Testen wir, ob wir auch einen Asteroiden zur Anwendung hinzufügen können.
Pane pane = new Pane();
// Größe einstellen ...
Ship ship = new Ship(150, 100);
Asteroid asteroid = new Asteroid(50, 50);
pane.getChildren().add(ship.getCharacter());
pane.getChildren().add(asteroid.getCharacter());
asteroid.turnRight();
asteroid.turnRight();
asteroid.accelerate();
asteroid.accelerate();
Damit ein Asteroid sich bewegt, muss die zugehörige move
-Methode in der Animation aufgerufen werden.
new AnimationTimer() {
@Override
public void handle(long now) {
if (pressedKeys.getOrDefault(KeyCode.LEFT, false)) {
ship.turnLeft();
}
if (pressedKeys.getOrDefault(KeyCode.RIGHT, false)) {
ship.turnRight();
}
if (pressedKeys.getOrDefault(KeyCode.UP, false)) {
ship.accelerate();
}
ship.move();
asteroid.move();
}
}.start();
Jetzt enthält die Anwendung sowohl ein Raumschiff als auch einen Asteroiden.

Die Kollision zwischen dem Raumschiff und einem Asteroiden
Als Nächstes implementieren wir die Kollision zwischen einem Raumschiff und einem Asteroiden. Wenn das Raumschiff mit einem Asteroiden kollidiert, wird die Methode stop
eines AnimationTimer
-Objekts aufgerufen, und die Animation stoppt.
Sowohl das Raumschiff als auch der Asteroid sind Charaktere (Character
s). Fügen Sie der Klasse Character
eine Methode hinzu, um zu prüfen, ob zwei Charaktere kollidieren. Zurzeit kollidieren zwei Charaktere nie.
public boolean collide(Character other) {
return false;
}
Die Shape-Klasse, von der die Polygon-Klasse erbt, verfügt über eine praktische Methode zur Kollisionserkennung. Die Methode public static Shape intersect(Shape shape1, Shape shape2) gibt den Schnittpunkt zweier Shape
-Objekte zurück.
Ändern wir die Methode collide
, sodass sie die Methode intersect
verwendet.
public boolean collide(Character other) {
Shape collisionArea = Shape.intersect(this.character, other.getCharacter());
return collisionArea.getBoundsInLocal().getWidth() != -1;
}
Fügen wir noch die Funktionalität hinzu, die die Anwendung stoppt, wenn eine Kollision auftritt.
new AnimationTimer() {
@Override
public void handle(long now) {
if (pressedKeys.getOrDefault(KeyCode.LEFT, false)) {
ship.turnLeft();
}
if (pressedKeys.getOrDefault(KeyCode.RIGHT, false)) {
ship.turnRight();
}
if (pressedKeys.getOrDefault(KeyCode.UP, false)) {
ship.accelerate();
}
ship.move();
asteroid.move();
if (ship.collide(asteroid)) {
stop();
}
}
}.start();
Jetzt stoppt die Anwendung, wenn das Raumschiff und der Asteroid kollidieren.

Mehrere Asteroiden
Nun fügen wir mehr Asteroiden hinzu. Wir können die Asteroiden als Liste darstellen. Im folgenden Beispiel erstellen wir zuerst ein Raumschiff und fügen dann fünf Asteroiden hinzu.
Ship ship = new Ship(150, 100);
List<Asteroid> asteroids = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Random rnd = new Random();
Asteroid asteroid = new Asteroid(rnd.nextInt(100), rnd.nextInt(100));
asteroids.add(asteroid);
}
pane.getChildren().add(ship.getCharacter());
asteroids.forEach(asteroid -> pane.getChildren().add(asteroid.getCharacter()));
Lassen Sie uns das Zeichnen der Asteroiden und die Kollisionsprüfung so ändern, dass sie mit mehreren Asteroiden funktionieren.
new AnimationTimer() {
@Override
public void handle(long now) {
if (pressedKeys.getOrDefault(KeyCode.LEFT, false)) {
ship.turnLeft();
}
if (pressedKeys.getOrDefault(KeyCode.RIGHT, false)) {
ship.turnRight();
}
if (pressedKeys.getOrDefault(KeyCode.UP, false)) {
ship.accelerate();
}
ship.move();
asteroids.forEach(asteroid -> asteroid.move());
asteroids.forEach(asteroid -> {
if (ship.collide(asteroid)) {
stop();
}
});
}
}.start();
Jetzt sehen wir beim Starten der Anwendung mehrere Asteroiden.

Derzeit sehen alle Asteroiden gleich aus und bewegen sich gleich. Es wäre schön, wenn es etwas Variation zwischen den Asteroiden gäbe. Ändern wir die Klasse Asteroid
so, dass sie eine Methode zur zufälligen Zuweisung von Attributen zu den Asteroiden hat. Wir können entscheiden, dass Asteroiden immer fünf Ecken haben und ihre Grundform immer ein Fünfeck ist. Wir können die Form der Asteroiden variieren, indem wir die Positionen der Ecken etwas verschieben.
Die Formel zur Berechnung der Winkel der Ecken eines Fünfecks finden Sie unter http://mathworld.wolfram.com/Pentagon.html. Im folgenden Beispiel haben wir die Formel verwendet und der Größe der Asteroiden sowie den Positionen der Ecken etwas Zufälligkeit hinzugefügt.
import java.util.Random;
import javafx.scene.shape.Polygon;
public class PolygonFactory {
public Polygon createPolygon() {
Random rnd = new Random();
double size = 10 + rnd.nextInt(10);
Polygon polygon = new Polygon();
double c1 = Math.cos(Math.PI * 2 / 5);
double c2 = Math.cos(Math.PI / 5);
double s1 = Math.sin(Math.PI * 2 / 5);
double s2 = Math.sin(Math.PI * 4 / 5);
polygon.getPoints().addAll(
size, 0.0,
size * c1, -1 * size * s1,
-1 * size * c2, -1 * size * s2,
-1 * size * c2, size * s2,
size * c1, size * s1);
for (int i = 0; i < polygon.getPoints().size(); i++) {
int change = rnd.nextInt(5) - 2;
polygon.getPoints().set(i, polygon.getPoints().get(i) + change);
}
return polygon;
}
}
Ändern wir die Klasse Asteroid
so, dass sie die PolygonFactory
-Klasse zur Erstellung von Polygonen verwendet.
public class Asteroid extends Character {
public Asteroid(int x, int y) {
super(new PolygonFactory().createPolygon(), x, y);
}
}
Jetzt sehen die Asteroiden etwas abwechslungsreicher aus.

Wir fügen den Asteroiden auch Bewegung und Richtung hinzu. Bewegung und Richtung wurden teilweise in der Klasse Character
definiert, aber wir möchten der Bewegung etwas Zufälligkeit hinzufügen. Wenn ein Asteroid erstellt wird, sollte seine Richtung eine zufällige Zahl zwischen [0, 360] sein. Asteroiden bewegen sich auch ein wenig – die Bewegung wird als zufällige Anzahl von accelerate
-Aufrufen beim Erstellen des Charakters definiert. Schließlich dreht sich ein Asteroid auch. Jedes Mal, wenn ein Asteroid sich bewegt, dreht er sich auch ein wenig.
import java.util.Random;
public class Asteroid extends Character {
private double rotationalMovement;
public Asteroid(int x, int y) {
super(new PolygonFactory().createPolygon(), x, y);
Random rnd = new Random();
super.getCharacter().setRotate(rnd.nextInt(360));
int accelerationAmount = 1 + rnd.nextInt(10);
for (int i = 0; i < accelerationAmount; i++) {
accelerate();
}
this.rotationalMovement = 0.5 - rnd.nextDouble();
}
@Override
public void move() {
super.move();
super.getCharacter().setRotate(super.getCharacter().getRotate() + rotationalMovement);
}
}
Im folgenden Beispiel wird in der Methode move
Vererbung verwendet. Wenn die Methode move
aufgerufen wird, ruft sie zunächst die move
-Methode der Klasse Character
auf. Dann wird der Charakter gedreht. Das Endprodukt ist ein Asteroid mit einer kleinen Rotationsbewegung.

Im Fenster bleiben
Die Anwendung ist etwas langweilig, weil die Asteroiden und das Raumschiff das Bild verlassen können. Ändern wir die Anwendung so, dass die Charaktere im Bild bleiben.
Wir definieren Konstanten WIDTH
und HEIGHT
für die AsteroidsApplication
. Jede Klasse kann mit dem Schlüsselwort static
klassen-spezifische Werte haben. Im folgenden Beispiel definieren wir die Variablen WIDTH
und HEIGHT
, auf die im restlichen Programmcode zugegriffen werden kann.
public class AsteroidsApplication extends Application {
public static int WIDTH = 300;
public static int HEIGHT = 200;
@Override
public void start(Stage stage) throws Exception {
Pane pane = new Pane();
pane.setPrefSize(WIDTH, HEIGHT);
Ship ship = new Ship(WIDTH / 2, HEIGHT / 2);
List<Asteroid> asteroids = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Random rnd = new Random();
Asteroid asteroid = new Asteroid(rnd.nextInt(WIDTH / 3), rnd.nextInt(HEIGHT));
asteroids.add(asteroid);
}
pane.getChildren().add(ship.getCharacter());
asteroids.forEach(asteroid -> pane.getChildren().add(asteroid.getCharacter()));
// ...
Variablen mit dem Schlüsselwort static
sind nicht Teil von Objekten, die aus der Klasse erstellt wurden. Wenn eine static
-Variable auch public
ist – wie oben –, kann von anderen Klassen auf die Variable zugegriffen werden. Ändern wir die Methode move
der Klasse Character
so, dass sie die statischen Variablen der AsteroidsApplication
, die Klassenvariablen WIDTH
und HEIGHT
, verwendet. Die folgende Methode move
prüft, ob das Character
-Objekt im Fenster bleibt.
public void move() {
this.character.setTranslateX(this.character.getTranslateX() + this.movement.getX());
this.character.setTranslateY(this.character.getTranslateY() + this.movement.getY());
if (this.character.getTranslateX() < 0) {
this.character.setTranslateX(this.character.getTranslateX() + AsteroidsApplication.WIDTH);
}
if (this.character.getTranslateX() > AsteroidsApplication.WIDTH) {
this.character.setTranslateX(this.character.getTranslateX() % AsteroidsApplication.WIDTH);
}
if (this.character.getTranslateY() < 0) {
this.character.setTranslateY(this.character.getTranslateY() + AsteroidsApplication.HEIGHT);
}
if (this.character.getTranslateY() > AsteroidsApplication.HEIGHT) {
this.character.setTranslateY(this.character.getTranslateY() % AsteroidsApplication.HEIGHT);
}
}
Jetzt bleiben die Charaktere im Bild.

Wir sind mit dieser Version der Anwendung nicht ganz zufrieden, weil die Charaktere manchmal von einer Seite des Bildschirms zur anderen "springen". Die Größe des Charakters wird nicht berücksichtigt, sodass seine x- oder y-Koordinaten außerhalb des Bildschirms liegen können, auch wenn ein Teil des Charakters noch sichtbar ist. Wir könnten dieses Problem möglicherweise mit der Methode getBoundsInParent
der Node-Klasse lösen. Wir werden jedoch an dieser Stelle nicht weiter darauf eingehen.
Projektile
Asteroids zu spielen, ohne Projektile abzufeuern, wäre lediglich ein Spiel zum Ausweichen. Fügen wir als Nächstes Projektile hinzu. Projektile haben eine Form, eine Richtung und eine Bewegung. Wir können die Klasse Character
für die Erstellung von Projektilen verwenden. Lassen Sie uns die erste Version der Klasse Projectile
erstellen. Vorerst sind alle Projektile Quadrate.
import javafx.scene.shape.Polygon;
public class Projectile extends Character {
public Projectile(int x, int y) {
super(new Polygon(2, -2, 2, 2, -2, 2, -2, -2), x, y);
}
}
Im Gegensatz zu Raumschiffen und Asteroiden wollen wir keine Projektile auf dem Bildschirm haben, wenn die Anwendung startet. Wir deklarieren eine Liste für die Projektile, lassen diese aber zunächst leer.
List<Projectile> projectiles = new ArrayList<>();
Ein Projektil wird erstellt, wenn die Spielenden die Leertaste drücken. Wenn ein Projektil erstellt wird, ist seine Richtung dieselbe wie die des Raumschiffs. Lassen Sie uns die erste Version der Schussfunktion erstellen.
if (pressedKeys.getOrDefault(KeyCode.SPACE, false)) {
// Wir feuern
Projectile projectile = new Projectile((int) ship.getCharacter().getTranslateX(), (int) ship.getCharacter().getTranslateY());
projectile.getCharacter().setRotate(ship.getCharacter().getRotate());
projectiles.add(projectile);
pane.getChildren().add(projectile.getCharacter());
}
Jetzt feuert das Raumschiff ein Projektil ab, wenn die Spielenden die Leertaste drücken, aber die Projektile bewegen sich noch nicht. Die Projektile treffen auch noch nichts.

Wir möchten die Bewegung eines Projektils ändern können. Allerdings ist die Methode move
der Klasse Character
derzeit als private
deklariert, sodass wir nur über andere Methoden darauf zugreifen können. Fügen wir daher die Methoden getMovement
und setMovement
zur Klasse Character
hinzu.
Lassen Sie uns die Geschwindigkeit eines Projektils einstellen. Dazu beschleunigen wir das Projektil ein wenig (damit es sich immer bewegt) und normalisieren die Bewegung (wir behandeln die Geschwindigkeit als Vektor der Länge 1). Hier wird die Geschwindigkeit mit drei multipliziert.
if (pressedKeys.getOrDefault(KeyCode.SPACE, false)) {
// Wir feuern
Projectile projectile = new Projectile((int) ship.getCharacter().getTranslateX(), (int) ship.getCharacter().getTranslateY());
projectile.getCharacter().setRotate(ship.getCharacter().getRotate());
projectiles.add(projectile);
projectile.accelerate();
projectile.setMovement(projectile.getMovement().normalize().multiply(3));
pane.getChildren().add(projectile.getCharacter());
}
Zum Schluss fügen wir das Bewegen der Projektile in die allgemeine Bewegungsfunktion ein.
ship.move();
asteroids.forEach(asteroid -> asteroid.move());
projectiles.forEach(projectile -> projectile.move());
Jetzt bewegen sich die Projektile. Sie treffen jedoch noch nichts, und es gibt ziemlich viele davon... Beschränken wir die Anzahl der Projektile auf drei.
if (pressedKeys.getOrDefault(KeyCode.SPACE, false) && projectiles.size() < 3) {
// Wir feuern
Projectile projectile = new Projectile((int) ship.getCharacter().getTranslateX(), (int) ship.getCharacter().getTranslateY());
projectile.getCharacter().setRotate(ship.getCharacter().getRotate());
projectiles.add(projectile);
projectile.accelerate();
projectile.setMovement(projectile.getMovement().normalize().multiply(3));
pane.getChildren().add(projectile.getCharacter());
}
Fügen wir nun die Funktion hinzu, dass Projektile Asteroiden treffen können. Wenn ein Projektil einen Asteroiden trifft, wird dieser aus der Liste der Asteroiden entfernt und nicht mehr gezeichnet.
projectiles.forEach(projectile -> {
List<Asteroid> collisions = asteroids.stream()
.filter(asteroid -> asteroid.collide(projectile))
.collect(Collectors.toList());
collisions.stream().forEach(collided -> {
asteroids.remove(collided);
pane.getChildren().remove(collided.getCharacter());
});
});

Die Projektile verschwinden jedoch nicht, wenn sie einen Asteroiden treffen. Eine Möglichkeit, die Projektile nach einem Treffer zu entfernen, ist wie folgt beschrieben.
List<Projectile> projectilesToRemove = projectiles.stream().filter(projectile -> {
List<Asteroid> collisions = asteroids.stream()
.filter(asteroid -> asteroid.collide(projectile))
.collect(Collectors.toList());
if(collisions.isEmpty()) {
return false;
}
collisions.stream().forEach(collided -> {
asteroids.remove(collided);
pane.getChildren().remove(collided.getCharacter());
});
return true;
}).collect(Collectors.toList());
projectilesToRemove.forEach(projectile -> {
pane.getChildren().remove(projectile.getCharacter());
projectiles.remove(projectile);
});
Es funktioniert, aber wir können es noch verbessern. Im Grunde geht es darum, festzustellen, ob ein Charakter „im Spiel“ ist oder nicht. Wir könnten beispielsweise ein Attribut „alive“ hinzufügen, das das Ganze etwas übersichtlicher macht. Mit diesem Attribut verbessert sich der Code ein wenig.
projectiles.forEach(projectile -> {
asteroids.forEach(asteroid -> {
if(projectile.collide(asteroid)) {
projectile.setAlive(false);
asteroid.setAlive(false);
}
});
});
projectiles.stream()
.filter(projectile -> !projectile.isAlive())
.forEach(projectile -> pane.getChildren().remove(projectile.getCharacter()));
projectiles.removeAll(projectiles.stream()
.filter(projectile -> !projectile.isAlive())
.collect(Collectors.toList()));
asteroids.stream()
.filter(asteroid -> !asteroid.isAlive())
.forEach(asteroid -> pane.getChildren().remove(asteroid.getCharacter()));
asteroids.removeAll(asteroids.stream()
.filter(asteroid -> !asteroid.isAlive())
.collect(Collectors.toList()));
Die letzten Zeilen sind fast identisch – beide behandeln Charaktere. Vielleicht könnte man das Ganze noch refaktorisieren.

Punkte hinzufügen
Asteroids-Spiele haben fast immer ein Punktesystem. Die Punkte werden als Textobjekt angezeigt, dessen Wert sich ändert, wenn sich die Punkte ändern. Wir können entscheiden, dass die Spielenden jedes Mal 1000 Punkte erhalten, wenn sie einen Asteroiden zerstören.
Die Java-Klasse Text eignet sich hervorragend für diesen Zweck. Ein Textobjekt hat Koordinaten und Inhalt. Im folgenden Beispiel haben die Spielenden immer 0 Punkte.
@Override
public void start(Stage stage) throws Exception {
Pane pane = new Pane();
Text text = new Text(10, 20, "Points: 0");
pane.getChildren().add(text);
Scene scene = new Scene(pane);
stage.setTitle("Asteroids!");
stage.setScene(scene);
stage.show();
}

Allerdings wollen wir die Anzahl der Punkte erhöhen können. Ein nützliches Werkzeug hierfür ist die AtomicInteger-Klasse, die Ganzzahlen als gekapselte Objekte anbietet. AtomicInteger ermöglicht es uns auch, die Punkte zu erhöhen, wenn eine Methode aufgerufen wird.
@Override
public void start(Stage stage) throws Exception {
Pane pane = new Pane();
Text text = new Text(10, 20, "Points: 0");
pane.getChildren().add(text);
AtomicInteger points = new AtomicInteger();
Scene scene = new Scene(pane);
stage.setTitle("Asteroids!");
stage.setScene(scene);
stage.show();
new AnimationTimer() {
@Override
public void handle(long now) {
text.setText("Points: " + points.incrementAndGet());
}
}.start();
}

Nun können wir die Punkte anzeigen und erhöhen. Verbinden wir die Punkte mit dem Spiel, sodass die Punktezahl jedes Mal steigt, wenn ein Projektil eines Spielenden einen Asteroiden trifft.
Dies kann als Teil der Kollision zwischen einem Projektil und einem Asteroiden erfolgen.
projectiles.forEach(projectile -> {
asteroids.forEach(asteroid -> {
if(projectile.collide(asteroid)) {
projectile.setAlive(false);
asteroid.setAlive(false);
}
});
if(!projectile.isAlive()) {
text.setText("Points: " + points.addAndGet(1000));
}
});
Nun, wenn das Erhöhen der Punkte aus dem AnimationTimer entfernt wurde, erhalten die Spielenden Punkte, wenn sie einen Asteroiden treffen.

Ständiges Hinzufügen von Asteroiden
Wenn wir einen Asteroiden treffen, verschwinden sie, und bald gibt es nichts mehr zu schießen.
Das ist nicht akzeptabel!
Fügen wir eine Funktion hinzu, die das Hinzufügen von Asteroiden während des Spiels ermöglicht. Ein neuer Asteroid wird mit einer Wahrscheinlichkeit von 0,5 % jedes Mal hinzugefügt, wenn die AnimationTimer
-Methode aufgerufen wird. Ein neuer Asteroid wird nur hinzugefügt, wenn er nicht sofort mit dem Raumschiff kollidiert.
Die Methode handle
des AnimationTimer
wird etwa 60 Mal pro Sekunde aufgerufen, sodass in zehn Sekunden ein paar Asteroiden hinzugefügt werden. Wir fügen den Aufruf am Ende der Methode handle
hinzu.
if(Math.random() < 0.005) {
Asteroid asteroid = new Asteroid(WIDTH, HEIGHT);
if(!asteroid.collide(ship)) {
asteroids.add(asteroid);
pane.getChildren().add(asteroid.getCharacter());
}
}
