Look out, Duke! Don’t run into a cloud!
FXGL, the JavaFX Game Development Framework, is exactly what you need to extend your Java skills to become a game developer. FXGL is a dependency you add to your Java and JavaFX project; it doesn’t need any additional installation or setup. It works out of the box on all platforms.
Thanks to the simple and clean FXGL API, you can build 2D games with minimal code and deliver them as a single executable .jar
file or native image. Almas Baimagambetov, senior lecturer in game development at the University of Brighton, is the creator of FXGL, and the project is fully open source and has a clear description about how you can contribute to it.
You can see basic examples of using the FXGL library in the main GitHub project. More-complex games are provided in a separate project, FXGLGames. You can also use FXGL to build business applications with complex UI controls; there are also 3D interface controls in the library, but those are still experimental.
In this article, you will see all the code needed to build a fun game where Duke shoots at a circle while moving around—while avoiding floating cloud servers. Before proceeding, you might want to watch a one-minute video that shows how the game is played.
Oracle Java Magazine—FXGL from Frank Delporte on Vimeo
In a follow-up article, you’ll see how to control the game with a joystick on a Raspberry Pi device. For now, though, the goal is to create the game itself.
The finished project, called JavaMagazineFXGL, is available on GitHub. This is a Maven project you can build with mvn package
. In the pom.xml
file there is only one dependency, because FXGL itself depends on JavaFX.
<dependencies>
<dependency>
<groupId>com.github.almasb</groupId>
<artifactId>fxgl</artifactId>
<version>${fxgl.version}</version>
</dependency>
</dependencies>
The full code for the game consists of only a few classes, which include the EntityFactory
, CloudComponent
, and PlayerComponent
, as well as the Main
class and FXGL application overrides.
By default, FXGL loads images from the src > main > resources > assets > textures
directory, which has the few images, such as Duke, that are used in the game. (See Figure 1, Figure 2, and Figure 3.)
Figure 1. The Duke character: duke.png
Figure 2. The cloud obstacles: cloud-network.png
Figure 3. Duke’s bullet: sprite_bullet.png
All the game objects in an FXGL application are of type Entity
and need to be defined in an EntityFactory
. In this sample game, they are in a class called GameFactory.java
.
public enum EntityType {
BACKGROUND, CENTER, DUKE, CLOUD, BULLET
}
By defining an enum with the types, it becomes easier to reference them later, such as in collision detection. For each entity type, a @Spawns
annotated method defines the layout and behavior of the object. Both the background and centered circle have a fixed size, which is defined in SpawnData
. FXGL offers many components to control the entities, and in this case, the application uses the IrremovableComponent
because these entities should never be removed.
@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();
}
For the Duke character and clouds, I gave the images a viewWithBBox
for collision detection. I used the FXGL AutoRotationComponent
and custom PlayerComponent
and CloudComponent
because the game needs to add specific controls to these entities.
@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();
}
The bullet doesn’t need a custom component, because FXGL’s ProjectileComponent
and OffscreenCleanComponent
have all the needed functionality.
@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();
}
The CloudComponent
class illustrates the flexibility that’s provided by an FXGL component. OnUpdate
is called at each engine tick, allowing the game to fully control the behavior of the entity to which the component is attached.
In this case, the game moves the cloud in the direction that was randomly calculated upon initialization of the component. The game checks whether the cloud hits the border of the game and removes it from the game world if it does.
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();
}
}
The PlayerComponent
uses a Point2D
object to define Duke’s direction. Initially, Duke moves to the bottom right. The up
, down
, left
, and right
methods change the direction gradually defined by the ROTATION_CHANGE
value. As with the CloudComponent
, there is a check for the borders of the screen, but in this case, the code calls the die
method when Duke hits the border.
The die
method decreases the “number of lives” value and resets the direction and position to get back to the starting position. When the player doesn’t have any lives left, the game shows a “Game Over” message box.
The shoot
method spawns a new bullet at Duke’s current position, giving the bullet the same direction that Duke is traveling in.
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());
}
}
}
With the three previous classes, everything is prepared to create the game. All that’s left is the main
class. The main
class combines everything and extends from FXGL GameApplication
, which provides multiple override methods to configure your game. These methods are called during initialization in the following order:
GameApplication
InitSettings()
, which initialize the app settingsFXGL.*
methodsinitInput()
, which initializes inputs, such as key presses and mouse buttonsonPreInit()
, which is called once per application lifetime, before initGame()
initGameVars()
, which can be overridden to provide global variablesinitGame()
, which initializes each game’s objectsinitPhysics()
, which initializes collision handlers and physics propertiesinitUI()
, which starts the main game loop execution on the JavaFX UI threadBecause the game should run full-screen with the right dimensions, the application reads these values in the main
method. The game also uses the GameFactory
, created earlier, and requires an Entity
for the player to contain the Duke entity.
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);
}
FXGL overrides. GameSettings
contains a long list of methods to configure your game. This example uses only those necessary to make the game full-screen and give it a title.
@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
configures the input events that control the game. Because Duke should rotate while the player presses one of the arrow keys, the onKey
method is used. Firing a bullet should happen only once each time the space bar is pressed, so this uses the onKeyDown
method. onPreInit
is not used in this example application.
@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());
}
The game needs a value for the number of lives that are left and a value for the score. Both are defined in initGameVars
and used in initUI
.
@Override
protected void initGameVars(Map<String, Object> vars) {
vars.put("score", 0);
vars.put("lives", 5);
}
Let’s add some stuff to the game world! First, add the entity factory that generates the game entities. Then, it’s time to add the spawns: first a full-screen background, then a circle in the middle of the screen and, finally, Duke as the player entity.
@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);
}
Next, define the collision handlers as follows:
Use lambdas to define the type of entities that need to be handled and the actions to be taken.
@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();
});
}
The player needs to see the score and lives-remaining variables defined in initGameVars
, and this is done with initUI
. By binding the textProperty
to these values, the onscreen data will always be up to date.
@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);
}
Those were all the overrides called during the initialization needed for the game. There is one final initialization, but this is called every frame when the game is in play state: The game shall always have 10 clouds on the screen. By adding one cloud per frame, if needed, the clouds will appear one by one at the start of the game.
@Override
protected void onUpdate(double tpf) {
if (getGameWorld().getEntitiesByType(EntityType.CLOUD).size() < 10) {
spawn("cloud", getAppWidth() / 2, getAppHeight() / 2);
}
}
That’s it! That’s all the code you need to have a fully functional game in JavaFX. Thanks to the clever helper methods provided by FXGL, such as FXGLMath.random
, you don’t need to write a lot of code or include extra dependencies to achieve very nice results. This example is a nice starting point if you are new to game development. Have fun with the source code, use it as an inspiration, and please share what you created.
A follow-up article will take this game to the Raspberry Pi single-board computer and show how to add physical buttons and a joystick to create a true arcade-like experience.
Frank Delporte is the author of Getting Started with Java on Raspberry Pi and works at Azul Systems. He is also lead coach of CoderDojo Belgium in Ieper.
Previous Post