Teil 5: Klonen eines Klassikers

FXGL Tutorial: Ein simpler Pong-Klon mit JavaFX

Almas Baimagambetov

© Shutterstock / onot

Willkommen zum fünften und letzten Teil unseres FXGL-11-Tutorials! In diesem Abschnitt werden wir gemeinsam einen Klon des Spieleklassikers Pong erstellen. Wie immer gilt, dass dieses Tutorial auf FXGL 11.0+ und Java 11+ ausgelegt ist.

Dieser Teil des Tutorials ist als eigenständige Einheit gedacht, daher sollten die vorigen Einheiten bereits abgeschlossen sein (siehe Kasten). Zudem sollte sich FXGL bereits auf dem System befinden, das entweder per Maven, Gradle oder als Uber-JAR geladen werden kann.

FXGL Tutorial: JavaFX-Spiele selbst gestalten

Eine Anmerkung: Der Einfachheit halber ist der hier verwendete Code ganz bewusst monolithisch und repetitiv gehalten. Wir werden auf viele der fortschrittlichen FXGL-Features verzichten, da diese Kenntnisse der komplexeren Konzepte voraussetzen, die an anderer Stelle im Wiki erläutert werden. Am Ende gibt es wie immer den kompletten Quellcode.

Das Spiel sollte zum Schluss so aussehen:

Quelle: Github

Importe

Wir erstellen die Datei PongApp.java, in die wir all die folgenden Objekte importieren und für den Rest des Tutorials ignorieren können. Eine Anmerkung: Mit Hilfe des letzten (statischen) Imports können wir getInput() anstatt FXGL.getInput() schreiben, was den Code kürzer und prägnanter macht.

import com.almasb.fxgl.app.GameApplication;
import com.almasb.fxgl.app.GameSettings;
import com.almasb.fxgl.entity.Entity;
import com.almasb.fxgl.input.UserAction;
import javafx.geometry.Point2D;
import javafx.scene.input.KeyCode;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import java.util.Map;

import static com.almasb.fxgl.dsl.FXGL.*;

Code

In dieser Sektion gehen wir alle Methoden durch und erläutern die wichtigsten Bestandteile des Codes. Die Größe des Spielfensters wird von FXGL standardmäßig auf 800×600 festgelegt, was sich für unser Spiel gut eignet. Diese und weitere Einstellungen können wir mit settings.setXXX() modifizieren. Zunächst setzen wir so aber nur den Titel des Spiels und fügen den Einstiegspunkt main() hinzu:

public class PongApp extends GameApplication {

    @Override
    protected void initSettings(GameSettings settings) {
        settings.setTitle("Pong");
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Jetzt werden wir ein paar Konstanten definieren, die selbsterklärend sein sollten.

private static final int PADDLE_WIDTH = 30;
private static final int PADDLE_HEIGHT = 100;
private static final int BALL_SIZE = 20;
private static final int PADDLE_SPEED = 5;
private static final int BALL_SPEED = 5;

Wir haben drei Spielobjekte: Zwei Schläger (Paddle) und einen Ball. Die Spielobjekte werden in FXGL auch als Entity (Entität) bezeichnet. Jetzt können wir unsere Entitäten definieren:

private Entity paddle1;
private Entity paddle2;
private Entity ball;

Wir schauen uns als nächstes den Input an. Im Gegensatz zu manch anderen Frameworks, muss der Status des Inputs in FXGL nicht manuell abgefragt werden. Der Input wird in FXGL durch das Definieren von Aktionen (was das Spiel machen soll) abgehandelt, die dann an Input-Trigger gebunden werden (das Betätigen einer Taste). Hier ist ein Beispiel dazu:

@Override
protected void initInput() {
    getInput().addAction(new UserAction("Up 1") {
        @Override
        protected void onAction() {
            paddle1.translateY(-PADDLE_SPEED);
        }
    }, KeyCode.W);

    // ...
}

Der oben abgebildete Code besagt, dass sich beim Betätigen der W-Taste das paddle mit der Geschwindigkeit -PADDLE_SPEED entlang der y-Achse bewegt. Oder anders ausgedrückt, der Schläger bewegt sich nach oben. Hier ist der restliche Input:

getInput().addAction(new UserAction("Down 1") {
    @Override
    protected void onAction() {
        paddle1.translateY(PADDLE_SPEED);
    }
}, KeyCode.S);

getInput().addAction(new UserAction("Up 2") {
    @Override
    protected void onAction() {
        paddle2.translateY(-PADDLE_SPEED);
    }
}, KeyCode.UP);

getInput().addAction(new UserAction("Down 2") {
    @Override
    protected void onAction() {
        paddle2.translateY(PADDLE_SPEED);
    }
}, KeyCode.DOWN);

Jetzt werden wir Spielvariablen hinzufügen, damit wir den Punktestand von Spieler 1 und 2 festhalten können. Das könnten wir einfach via int score1; abbilden, allerdings bietet FXGL ein mächtiges Property-Konzept an, das auf Properties aus JavaFX aufbaut. Um es deutlich zu machen: In FXGL wird jede Variable intern als ein JavaFX Property abgespeichert und kann daher beobachtet und gebunden werden. Die Variablen deklarieren wir folgendermaßen:

@Override
protected void initGameVars(Map<String, Object> vars) {
    vars.put("score1", 0);
    vars.put("score2", 0);
}

FXGL wird, beruhend auf dem Standardwert, den Typ jeder Variable inferieren. In diesem Fall handelt es sich bei der 0 um einen int-Typ, deswegen wird score1 der int-Typ zugewiesen. Später werden wir sehen, wie mächtig diese Variablen im Vergleich zu primitiven Java-Typen sind.

Jetzt können wir das Erstellen unserer Entitäten in Betracht ziehen, was kein Problem darstellen sollte, wenn man die vorherigen Teile des Tutorials bereits abgeschlossen hat.

@Override
protected void initGame() {
    paddle1 = spawnBat(0, getAppHeight() / 2 - PADDLE_HEIGHT / 2);
    paddle2 = spawnBat(getAppWidth() - PADDLE_WIDTH, getAppHeight() / 2 - PADDLE_HEIGHT / 2);

    ball = spawnBall(getAppWidth() / 2 - BALL_SIZE / 2, getAppHeight() / 2 - BALL_SIZE / 2);
}

private Entity spawnBat(double x, double y) {
    return entityBuilder()
            .at(x, y)
            .viewWithBBox(new Rectangle(PADDLE_WIDTH, PADDLE_HEIGHT))
            .buildAndAttach();
}

private Entity spawnBall(double x, double y) {
    return entityBuilder()
            .at(x, y)
            .viewWithBBox(new Rectangle(BALL_SIZE, BALL_SIZE))
            .with("velocity", new Point2D(BALL_SPEED, BALL_SPEED))
            .buildAndAttach();
}

Jetzt beauftragen wir den entityBuilder damit, dass er

  1. eine neue Entität bei den angegebenen x- und y-Werten erstellen soll,
  2. die Spielfigur benutzen soll, die wir ihm zur Verfügen stellen,
  3. eine Bounding Box von der Spielfigur erstellen soll,
  4. die erstellte Entität zur Spielwelt hinzufügen soll und
  5. (im Bezug auf ball) eine weitere, leere Entitäteneigenschaft vom Typ Point2D und mit dem Namen „Velocity“ hinzufügen soll.

Als nächstes gestalten wir unser UI, das aus zwei Text-Objekten besteht. Es ist wichtig, dass wir die Text-Property dieser Objekte an die zwei Variablen binden, die wir zuvor erstellt haben. Dies ist eines der mächtigen Features, die FXGL-Variablen bieten. Im Detail bedeutet das, dass wenn score1 aktualisiert wird, auch der Text des entsprechnden UI-Objekts textScore1 automatisch aktualisiert wird.

@Override
protected void initUI() {
    Text textScore1 = getUIFactory().newText("", Color.BLACK, 22);
    Text textScore2 = getUIFactory().newText("", Color.BLACK, 22);

    textScore1.setTranslateX(10);
    textScore1.setTranslateY(50);

    textScore2.setTranslateX(getAppWidth() - 30);
    textScore2.setTranslateY(50);

    textScore1.textProperty().bind(getGameState().intProperty("score1").asString());
    textScore2.textProperty().bind(getGameState().intProperty("score2").asString());

    getGameScene().addUINodes(textScore1, textScore2);
}

Der letzte Teil des Spiels ist der Update-Tick. FXGL-Spiele benutzen normalerweise Component, um Entitäten mit Funktionalität auf jedem Frame auszustatten. Deswegen könnte es sein, dass man den Code für das Updaten gar nicht benötigt. In unserem Fall, wo es sich um ein einfaches Beispiel handelt, werden wir die traditionelle Update-Methode benutzen, die hier zu sehen ist:

@Override
protected void onUpdate(double tpf) {
    Point2D velocity = ball.getObject("velocity");
    ball.translate(velocity);

    if (ball.getX() == paddle1.getRightX()
            && ball.getY() < paddle1.getBottomY()
            && ball.getBottomY() > paddle1.getY()) {
        ball.setProperty("velocity", new Point2D(-velocity.getX(), velocity.getY()));
    }

    if (ball.getRightX() == paddle2.getX()
            && ball.getY() < paddle2.getBottomY()
            && ball.getBottomY() > paddle2.getY()) {
        ball.setProperty("velocity", new Point2D(-velocity.getX(), velocity.getY()));
    }

    if (ball.getX() <= 0) {
        getGameState().increment("score2", +1);
        resetBall();
    }

    if (ball.getRightX() >= getAppWidth()) {
        getGameState().increment("score1", +1);
        resetBall();
    }

    if (ball.getY() <= 0) {
        ball.setY(0);
        ball.setProperty("velocity", new Point2D(velocity.getX(), -velocity.getY()));
    }

    if (ball.getBottomY() >= getAppHeight()) {
        ball.setY(getAppHeight() - BALL_SIZE);
        ball.setProperty("velocity", new Point2D(velocity.getX(), -velocity.getY()));
    }
}

Wir nehmen nun das „Velocity“ Property des Balls und benutzen es dazu, den Ball auf allen Frames zu übersetzen (bewegen). Danach führen wir eine Reihe von Überprüfungen durch, in denen wir die Position des Balls gegen das Spielfenster und die Schläger prüfen. Sobald der Ball oben oder unten auf den Spielrahmen trifft, kehren wir die y-Achse um. Es verhält sich ähnlich, wenn der Ball auf einen Schläger trifft, dann kehren wir die x-Achse um. Schließlich, wenn der Ball den Schläger verfehlt und auf die Seite des Bildschirms trifft, dann punktet der gegenüberliegende Schläger und der Ball wird zurückgesetzt. Die Zurücksetzungsmethode sieht folgendermaßen aus:

private void resetBall() {
    ball.setPosition(getAppWidth() / 2 - BALL_SIZE / 2, getAppHeight() / 2 - BALL_SIZE / 2);
    ball.setProperty("velocity", new Point2D(BALL_SPEED, BALL_SPEED));
}

Das war auch schon alles! Jetzt haben wir einen einfachen Pong-Klon. Der vollständige Quellcode ist, wie immer, unten aufgeführt:

import com.almasb.fxgl.app.GameApplication;
import com.almasb.fxgl.app.GameSettings;
import com.almasb.fxgl.entity.Entity;
import com.almasb.fxgl.input.UserAction;
import javafx.geometry.Point2D;
import javafx.scene.input.KeyCode;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import java.util.Map;

import static com.almasb.fxgl.dsl.FXGL.*;

public class PongApp extends GameApplication {

    private static final int PADDLE_WIDTH = 30;
    private static final int PADDLE_HEIGHT = 100;
    private static final int BALL_SIZE = 20;
    private static final int PADDLE_SPEED = 5;
    private static final int BALL_SPEED = 5;

    private Entity paddle1;
    private Entity paddle2;
    private Entity ball;

    @Override
    protected void initSettings(GameSettings settings) {
        settings.setTitle("Pong");
    }

    @Override
    protected void initInput() {
        getInput().addAction(new UserAction("Up 1") {
            @Override
            protected void onAction() {
                paddle1.translateY(-PADDLE_SPEED);
            }
        }, KeyCode.W);

        getInput().addAction(new UserAction("Down 1") {
            @Override
            protected void onAction() {
                paddle1.translateY(PADDLE_SPEED);
            }
        }, KeyCode.S);

        getInput().addAction(new UserAction("Up 2") {
            @Override
            protected void onAction() {
                paddle2.translateY(-PADDLE_SPEED);
            }
        }, KeyCode.UP);

        getInput().addAction(new UserAction("Down 2") {
            @Override
            protected void onAction() {
                paddle2.translateY(PADDLE_SPEED);
            }
        }, KeyCode.DOWN);
    }

    @Override
    protected void initGameVars(Map<String, Object> vars) {
        vars.put("score1", 0);
        vars.put("score2", 0);
    }

    @Override
    protected void initGame() {
        paddle1 = spawnBat(0, getAppHeight() / 2 - PADDLE_HEIGHT / 2);
        paddle2 = spawnBat(getAppWidth() - PADDLE_WIDTH, getAppHeight() / 2 - PADDLE_HEIGHT / 2);

        ball = spawnBall(getAppWidth() / 2 - BALL_SIZE / 2, getAppHeight() / 2 - BALL_SIZE / 2);
    }

    @Override
    protected void initUI() {
        Text textScore1 = getUIFactory().newText("", Color.BLACK, 22);
        Text textScore2 = getUIFactory().newText("", Color.BLACK, 22);

        textScore1.setTranslateX(10);
        textScore1.setTranslateY(50);

        textScore2.setTranslateX(getAppWidth() - 30);
        textScore2.setTranslateY(50);

        textScore1.textProperty().bind(getGameState().intProperty("score1").asString());
        textScore2.textProperty().bind(getGameState().intProperty("score2").asString());

        getGameScene().addUINodes(textScore1, textScore2);
    }

    @Override
    protected void onUpdate(double tpf) {
        Point2D velocity = ball.getObject("velocity");
        ball.translate(velocity);

        if (ball.getX() == paddle1.getRightX()
                && ball.getY() < paddle1.getBottomY()
                && ball.getBottomY() > paddle1.getY()) {
            ball.setProperty("velocity", new Point2D(-velocity.getX(), velocity.getY()));
        }

        if (ball.getRightX() == paddle2.getX()
                && ball.getY() < paddle2.getBottomY()
                && ball.getBottomY() > paddle2.getY()) {
            ball.setProperty("velocity", new Point2D(-velocity.getX(), velocity.getY()));
        }

        if (ball.getX() <= 0) {
            getGameState().increment("score2", +1);
            resetBall();
        }

        if (ball.getRightX() >= getAppWidth()) {
            getGameState().increment("score1", +1);
            resetBall();
        }

        if (ball.getY() <= 0) {
            ball.setY(0);
            ball.setProperty("velocity", new Point2D(velocity.getX(), -velocity.getY()));
        }

        if (ball.getBottomY() >= getAppHeight()) {
            ball.setY(getAppHeight() - BALL_SIZE);
            ball.setProperty("velocity", new Point2D(velocity.getX(), -velocity.getY()));
        }
    }

    private Entity spawnBat(double x, double y) {
        return entityBuilder()
                .at(x, y)
                .viewWithBBox(new Rectangle(PADDLE_WIDTH, PADDLE_HEIGHT))
                .buildAndAttach();
    }

    private Entity spawnBall(double x, double y) {
        return entityBuilder()
                .at(x, y)
                .viewWithBBox(new Rectangle(BALL_SIZE, BALL_SIZE))
                .with("velocity", new Point2D(BALL_SPEED, BALL_SPEED))
                .buildAndAttach();
    }

    private void resetBall() {
        ball.setPosition(getAppWidth() / 2 - BALL_SIZE / 2, getAppHeight() / 2 - BALL_SIZE / 2);
        ball.setProperty("velocity", new Point2D(BALL_SPEED, BALL_SPEED));
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Wer mehr über die Kernfunktionen wissen oder mit vorgefertigten Spielen experimentieren will, findet dazu alles auf GitHub.

Verwandte Themen:

Geschrieben von
Almas Baimagambetov
Almas Baimagambetov
Almas is engaged with research in the field of Automated Diagram Generation. He also teaches Computer Science. In his spare time, he enjoys playing video games as well as designing and developing them.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: