Cómo crear un juego Java con JavaFX y la biblioteca FXGL. PARTE 1

Te sorprenderá lo poco que se necesita código para crear un juego de arcade en 2D

FXGL, el marco de desarrollo de juegos JavaFX , es exactamente lo que necesita para ampliar sus habilidades de Java y convertirse en un desarrollador de juegos. FXGL es una dependencia que agrega a su proyecto Java y JavaFX ; no necesita ninguna instalación o configuración adicional. Funciona de inmediato en todas las plataformas.

Gracias a la API FXGL simple y limpia, puede crear juegos 2D con un código mínimo y entregarlos como un solo 

.jar
archivo ejecutable o imagen nativa. Almas Baimagambetov , profesor titular de desarrollo de juegos en la Universidad de Brighton, es el creador de FXGL, y el proyecto es completamente de código abierto y tiene una descripción clara sobre cómo puedes contribuir a él.

Puede ver ejemplos básicos del uso de la biblioteca FXGL en el proyecto principal de GitHub . Los juegos más complejos se proporcionan en un proyecto separado, FXGLGames . También puede utilizar FXGL para crear aplicaciones comerciales con controles de interfaz de usuario complejos; también hay controles de interfaz 3D en la biblioteca, pero aún son experimentales.

En este artículo, verá todo el código necesario para crear un juego divertido en el que Duke dispara en círculo mientras se mueve, evitando los servidores en la nube flotantes. Antes de continuar, es posible que desee ver un video de un minuto que muestra cómo se juega el juego.https://player.vimeo.com/video/556576921

En un artículo de seguimiento, verá cómo controlar el juego con un joystick en un dispositivo Raspberry Pi . Por ahora, sin embargo, el objetivo es crear el juego en sí.

Activos de imagen y código fuente

El proyecto terminado, llamado JavaMagazineFXGL, está disponible en GitHub . Este es un proyecto de Maven con el que puedes construir 

mvn package
. En el 
pom.xml
archivo solo hay una dependencia, porque el propio FXGL depende de JavaFX.


<dependencies>
        <dependency>
            <groupId>com.github.almasb</groupId>
            <artifactId>fxgl</artifactId>
            <version>${fxgl.version}</version>
        </dependency>
    </dependencies>

El código completo para el juego consta de sólo unas pocas clases, que incluyen las 

EntityFactory
CloudComponent
PlayerComponent
, así como las 
Main
modificaciones de la clase y de la aplicación FXGL.

De forma predeterminada, FXGL carga imágenes del 

src > main > resources > assets > textures
directorio, que tiene las pocas imágenes, como Duke, que se utilizan en el juego. (Consulte la Figura 1 , la Figura 2 y la Figura 3 ).

El personaje de Duke: duke.png

Figura 1.  El personaje de Duke: duke.png

Los obstáculos de la nube: cloud-network.png

Figura 2.  Los obstáculos de la nube: cloud-network.png

La bala de Duke: sprite_bullet.png

Figura 3.  Bala de Duke: sprite_bullet.png

La EntityFactory

Todos los objetos del juego en una aplicación FXGL son de tipo 

Entity
y deben definirse en un 
EntityFactory
. En este juego de muestra, están en una clase llamada 
GameFactory.java
.


public enum EntityType {
    BACKGROUND, CENTER, DUKE, CLOUD, BULLET
}

Al definir una enumeración con los tipos, es más fácil hacer referencia a ellos más adelante, como en la detección de colisiones. Para cada tipo de entidad, un 

@Spawns
método anotado define el diseño y el comportamiento del objeto. Tanto el fondo como el círculo centrado tienen un tamaño fijo, que se define en 
SpawnData
. FXGL ofrece muchos componentes para controlar las entidades y, en este caso, la aplicación utiliza el 
IrremovableComponent
porque estas entidades nunca deben eliminarse.


@Spawns("background")
public Entity spawnBackground(SpawnData data) {
    return entityBuilder(data)
            .type(EntityType.BACKGROUND)
            .view(new Rectangle(data.<Integer>get("width"),
                data.<Integer>get("height"), Color.YELLOWGREEN))
            .with(new IrremovableComponent())
            .zIndex(-100)
            .build();
}

@Spawns("center")
public Entity spawnCenter(SpawnData data) {
    return entityBuilder(data)
            .type(EntityType.CENTER)
            .collidable()
            .viewWithBBox(new Circle(data.<Integer>get("x"),
                 data.<Integer>get("y"), data.<Integer>get("radius"), Color.DARKRED))
            .with(new IrremovableComponent())
            .zIndex(-99)
            .build();
}

Para el personaje de Duke y las nubes, le di a las imágenes una 

viewWithBBox
para la detección de colisiones. Usé FXGL 
AutoRotationComponent
y personalizado 
PlayerComponent
CloudComponent
porque el juego necesita agregar controles específicos a estas entidades.


@Spawns("duke")
public Entity newDuke(SpawnData data) {
    return entityBuilder(data)
            .type(EntityType.DUKE)
            .viewWithBBox(texture("duke.png", 50, 50))
            .collidable()
            .with((new AutoRotationComponent()).withSmoothing())
            .with(new PlayerComponent())
            .build();
}

@Spawns("cloud")
public Entity newCloud(SpawnData data) {
    return entityBuilder(data)
            .type(EntityType.CLOUD)
            .viewWithBBox(texture("cloud-network.png", 50, 50))
            .with((new AutoRotationComponent()).withSmoothing())
            .with(new CloudComponent())
            .collidable()
            .build();
}

La viñeta no necesita un componente personalizado, porque los FXGL 

ProjectileComponent
OffscreenCleanComponent
tienen toda la funcionalidad necesaria.


@Spawns("bullet")
public Entity newBullet(SpawnData data) {
    return entityBuilder(data)
            .type(EntityType.BULLET)
            .viewWithBBox(texture("sprite_bullet.png", 22, 11))
            .collidable()
            .with(new ProjectileComponent(data.get("direction"), 350), new OffscreenCleanComponent())
            .build();
}

El CloudComponent

La 

CloudComponent
clase ilustra la flexibilidad que proporciona un componente FXGL. 
OnUpdate
se llama en cada tic del motor, lo que permite que el juego controle completamente el comportamiento de la entidad a la que está adjunto el componente.

En este caso, el juego mueve la nube en la dirección que se calculó aleatoriamente al inicializar el componente. El juego comprueba si la nube llega al borde del juego y la elimina del mundo del juego si lo hace.


public class CloudComponent extends Component {
    private final Point2D direction = new Point2D(
       FXGLMath.random(-1D, 1D),
       FXGLMath.random(-1D, 1D)
    );

    @Override
    public void onUpdate(double tpf) {
        entity.translate(direction.multiply(3));
        checkForBounds();
    }

    private void checkForBounds() {
        if (entity.getX() < 0) {
            remove();
        }
        if (entity.getX() >= getAppWidth()) {
            remove();
        }
        if (entity.getY() < 0) {
            remove();
        }
        if (entity.getY() >= getAppHeight()) {
            remove();
        }
    }

    public void remove() {
        entity.removeFromWorld();
    }
}

El PlayerComponent

El 

PlayerComponent
usa un 
Point2D
objeto para definir la dirección de Duke. Inicialmente, Duke se mueve hacia la parte inferior derecha. El 
up
down
left
, y 
right
métodos de cambio de la dirección definida gradualmente por el 
ROTATION_CHANGE
valor. Al igual que con el 
CloudComponent
, hay una verificación de los bordes de la pantalla, pero en este caso, el código llama al 
die
método cuando Duke llega al borde.

El 

die
método disminuye el valor de “número de vidas” y restablece la dirección y la posición para volver a la posición inicial. Cuando al jugador no le quedan vidas, el juego muestra un cuadro de mensaje de “Fin del juego”.

El 

shoot
método genera una nueva bala en la posición actual de Duke, lo que le da a la bala la misma dirección en la que viaja Duke.


public class PlayerComponent extends Component {
    private static final double ROTATION_CHANGE = 0.01;
    private Point2D direction = new Point2D(1, 1);

    @Override
    public void onUpdate(double tpf) {
        entity.translate(direction.multiply(1));
        checkForBounds();
    }

    private void checkForBounds() {
        if (entity.getX() < 0) {
            die();
        }
        if (entity.getX() >= getAppWidth()) {
            die();
        }
        if (entity.getY() < 0) {
            die();
        }
        if (entity.getY() >= getAppHeight()) {
            die();
        }
    }

    public void shoot() {
        spawn("bullet", new SpawnData(
                getEntity().getPosition().getX() + 20,
                getEntity().getPosition().getY() - 5)
                .put("direction", direction));
    }

    public void die() {
        inc("lives", -1);

        if (geti("lives") <= 0) {
            getDialogService().showMessageBox("Game Over",
                    () -> getGameController().startNewGame());
            return;
        }

        entity.setPosition(0, 0);
        direction = new Point2D(1, 1);
        right();
    }

    public void up() {
        if (direction.getY() > -1) {
            direction = new Point2D(direction.getX(), direction.getY() - ROTATION_CHANGE);
        }
    }

    public void down() {
        if (direction.getY() < 1) {
            direction = new Point2D(direction.getX(), direction.getY() + ROTATION_CHANGE);
        }
    }

    public void left() {
        if (direction.getX() > -1) {
            direction = new Point2D(direction.getX() - ROTATION_CHANGE, direction.getY());
        }
    }

    public void right() {
        if (direction.getX() < 1) {
            direction = new Point2D(direction.getX() + ROTATION_CHANGE, direction.getY());
        }
    }
}

Anulaciones de la aplicación de clase principal y FXGL

Con las tres clases anteriores, todo está preparado para crear el juego. Todo lo que queda es la 

main
clase. La 
main
clase combina todo y se extiende desde FXGL 
GameApplication
, que proporciona múltiples métodos de anulación para configurar tu juego. Estos métodos se llaman durante la inicialización en el siguiente orden:

  1. Campos de instancia de su subclase de 
    GameApplication
  2. InitSettings()
    , que inicializa la configuración de la aplicación
  3. Configuración de servicios, después de lo cual puede llamar de forma segura a cualquier 
    FXGL.*
    método
  4. Lo siguiente, que se ejecuta en un hilo de interfaz de usuario de JavaFX
    • initInput()
      , que inicializa entradas, como pulsaciones de teclas y botones del mouse
    • onPreInit()
      , que se llama una vez por vida de la aplicación, antes 
      initGame()
  5. Lo siguiente, que no se ejecuta en el hilo de la interfaz de usuario de JavaFX
    • initGameVars()
      , que se puede anular para proporcionar variables globales
    • initGame()
      , que inicializa los objetos de cada juego
    • initPhysics()
      , que inicializa los controladores de colisiones y las propiedades físicas
    • initUI()
      , que inicia la ejecución del bucle principal del juego en el hilo de la interfaz de usuario de JavaFX

Debido a que el juego debe ejecutarse en pantalla completa con las dimensiones correctas, la aplicación lee estos valores en el 

main
método. El juego también usa el 
GameFactory
, creado anteriormente, y requiere 
Entity
que el jugador contenga la entidad Duke.


private final GameFactory gameFactory = new GameFactory();
private Entity player;

private static int screenWidth;
private static int screenHeight;

public static void main(String[] args) {
    Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
    screenWidth = (int) screenSize.getWidth();
    screenHeight = (int) screenSize.getHeight();
    launch(args);
}

Anulaciones FXGL. 

GameSettings
contiene una larga lista de métodos para configurar tu juego. Este ejemplo usa solo los necesarios para hacer que el juego sea de pantalla completa y darle un título.


@Override
    protected void initSettings(GameSettings settings) {
        settings.setHeight(screenHeight);
        settings.setWidth(screenWidth);
        settings.setFullScreenAllowed(true);
        settings.setFullScreenFromStart(true);
        settings.setTitle("Oracle Java Magazine - FXGL");
    }

Override 

initInput
configura los eventos de entrada que controlan el juego. Debido a que Duke debe rotar mientras el jugador presiona una de las teclas de flecha, 
onKey
se usa el método. Disparar una bala debe suceder solo una vez cada vez que se presiona la barra espaciadora, por lo que esto usa el 
onKeyDown
método. 
onPreInit
no se utiliza en esta aplicación de ejemplo.


@Override
    protected void initInput() {
        onKey(KeyCode.LEFT, "left", () -> this.player.getComponent(PlayerComponent.class).left());
        onKey(KeyCode.RIGHT, "right", () -> this.player.getComponent(PlayerComponent.class).right());
        onKey(KeyCode.UP, "up", () -> this.player.getComponent(PlayerComponent.class).up());
        onKey(KeyCode.DOWN, "down", () -> this.player.getComponent(PlayerComponent.class).down());
        onKeyDown(KeyCode.SPACE, "Bullet", () -> this.player.getComponent(PlayerComponent.class).shoot());
    }

El juego necesita un valor para el número de vidas que quedan y un valor para la puntuación. Ambos se definen 

initGameVars
y se utilizan en 
initUI
.


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

¡Agreguemos algunas cosas al mundo del juego! Primero, agregue la fábrica de entidades que genera las entidades del juego. Luego, es hora de agregar los engendros: primero un fondo de pantalla completa, luego un círculo en el medio de la pantalla y, finalmente, Duke como la entidad del jugador.


@Override
    protected void initGame() {
        getGameWorld().addEntityFactory(this.gameFactory);

        // Background color
        spawn("background", new SpawnData(0, 0).put("width", getAppWidth())
                .put("height", getAppHeight()));

        // Circle in the middle of the screen
        int circleRadius = 80;
        spawn("center", new SpawnData(
           (getAppWidth() / 2) - (circleRadius / 2),
           (getAppHeight() / 2) - (circleRadius / 2))
                .put("x", (circleRadius / 2))
                .put("y", (circleRadius / 2))
                .put("radius", circleRadius));

        // Add the player
        this.player = spawn("duke", 0, 0);
    }

A continuación, defina los controladores de colisiones de la siguiente manera:

  • Siempre que Duke golpea una nube o el círculo central, el jugador pierde una vida.
  • Siempre que una bala golpea una nube, la puntuación del jugador aumenta; ambas entidades deben eliminarse del juego.

Use lambdas para definir el tipo de entidades que deben manejarse y las acciones que deben tomarse.


@Override
    protected void initPhysics() {
        onCollisionBegin(EntityType.DUKE, EntityType.CENTER, (duke, center) ->
            this.player.getComponent(PlayerComponent.class).die());

        onCollisionBegin(EntityType.DUKE, EntityType.CLOUD, (enemy, cloud) ->
            this.player.getComponent(PlayerComponent.class).die());

        onCollisionBegin(EntityType.BULLET, EntityType.CLOUD, (bullet, cloud) -> {
            inc("score", 1);
            bullet.removeFromWorld();
            cloud.removeFromWorld();
        });
    }

El jugador necesita ver la puntuación y las variables de vidas restantes definidas en 

initGameVars
, y ya está 
initUI
. Al vincular el 
textProperty
a estos valores, los datos en pantalla siempre estarán actualizados.


@Override
    protected void initUI() {
        Text scoreLabel = getUIFactoryService().newText("Score", Color.BLACK, 22);
        Text scoreValue = getUIFactoryService().newText("", Color.BLACK, 22);
        Text livesLabel = getUIFactoryService().newText("Lives", Color.BLACK, 22);
        Text livesValue = getUIFactoryService().newText("", Color.BLACK, 22);

        scoreLabel.setTranslateX(20);
        scoreLabel.setTranslateY(20);

        scoreValue.setTranslateX(90);
        scoreValue.setTranslateY(20);

        livesLabel.setTranslateX(getAppWidth() - 100);
        livesLabel.setTranslateY(20);

        livesValue.setTranslateX(getAppWidth() - 30);
        livesValue.setTranslateY(20);

        scoreValue.textProperty().bind(getWorldProperties().intProperty("score").asString());
        livesValue.textProperty().bind(getWorldProperties().intProperty("lives").asString());

        getGameScene().addUINodes(scoreLabel, scoreValue, livesLabel, livesValue);
    }

Esas fueron todas las anulaciones llamadas durante la inicialización necesarias para el juego. Hay una inicialización final, pero se llama cada fotograma cuando el juego está en estado de juego: el juego siempre tendrá 10 nubes en la pantalla. Al agregar una nube por cuadro, si es necesario, las nubes aparecerán una por una al comienzo del juego.


@Override
    protected void onUpdate(double tpf) {
        if (getGameWorld().getEntitiesByType(EntityType.CLOUD).size() < 10) {
            spawn("cloud", getAppWidth() / 2, getAppHeight() / 2);
        }
    }

Fuente https://blogs.oracle.com/javamagazine/java-javafx-fxgl-game-development