Java Arena
Step 7 of 8

Firing bubbles

The submarine fights back: Space fires a bubble from its nose, and a bubble that hits a jellyfish pops it. Both disappear from the sea. This step needs things to be removed from the game, which takes a little care.

The class Bubble

A bubble is one more Mover: a small circle that starts where the submarine is, heads where its nose points, and goes fast, 2.5 pixels a frame:

public class Bubble extends Mover {

    public Bubble(double x, double y, double heading) {
        super(new Circle(3, Color.LIGHTCYAN), x, y);
        turn(heading);
        accelerate(2.5);
    }
}

A circle looks the same however it's turned, but its rotate still says where accelerate pushes it. The submarine's own speed isn't added: a bubble always leaves at 2.5 pixels a frame.

Firing on a key press

Turning and speeding up use the held keys in the game loop. Firing is different: one press, one bubble (on your own computer a held Space counts as many presses, as step 8 says). So the bubble is made in the pressed handler, and only for Space. The event line key SPACE presses and releases Space with no frame in between, so the game loop would never even see Space held; the pressed handler sees every press. To keep the sea from filling up, the submarine fires only while fewer than 4 bubbles are out:

scene.setOnKeyPressed(event -> {
    held.put(event.getCode(), true);
    if (event.getCode() == KeyCode.SPACE && bubbles.size() < 4) {
        Bubble bubble = new Bubble(sub.getShape().getTranslateX(), sub.getShape().getTranslateY(), sub.getShape().getRotate());
        bubbles.add(bubble);
        pane.getChildren().add(bubble.getShape());
    }
});

Alive or not

When a bubble hits a jellyfish, both should go. Removing them from their lists while a for-each loop goes through those lists throws a ConcurrentModificationException (module 14 showed it); in the game loop, JavaFX prints it and goes on with the next frame, and the rest of that frame's loop never runs. So the game does it in two passes: first it only marks them, with a flag that every Mover now has, and after the loops it removes everything that's marked. Mover gets a field alive, true at the start, with isAlive() and setAlive(boolean).

Removing has two parts. The shape must leave the pane's children, or it stays on the screen; and the Mover must leave its list, or it keeps moving and colliding unseen. Module 37's streams do both:

bubbles.stream()
        .filter(b -> !b.isAlive())
        .forEach(b -> pane.getChildren().remove(b.getShape()));
bubbles = bubbles.stream()
        .filter(b -> b.isAlive())
        .collect(Collectors.toList());

The first statement removes the shape of every bubble that isn't alive from the pane. The second keeps the bubbles that are alive in a new list and puts it in the field bubbles. That's why bubbles and jellyfish are now fields of Main and not local variables of start: the code of the timer can assign a field, but not a local variable.

Main.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.stream.Collectors;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;

public class Main extends Application {
    public static final int WIDTH = 360;
    public static final int HEIGHT = 240;

    private Map<KeyCode, Boolean> held = new HashMap<>();
    private Random random = new Random(16);
    private List<Jellyfish> jellyfish = new ArrayList<>();
    private List<Bubble> bubbles = new ArrayList<>();

    @Override
    public void start(Stage stage) {
        Pane pane = new Pane();
        pane.setPrefSize(WIDTH, HEIGHT);
        pane.getChildren().add(new Rectangle(WIDTH, HEIGHT, Color.MIDNIGHTBLUE));

        Sub sub = new Sub(WIDTH / 2, HEIGHT / 2);
        pane.getChildren().add(sub.getShape());
        for (int i = 0; i < 4; i++) {
            Jellyfish j = new Jellyfish(random.nextInt(WIDTH), random.nextInt(60), random.nextInt(360));
            jellyfish.add(j);
            pane.getChildren().add(j.getShape());
        }

        Scene scene = new Scene(pane);
        scene.setOnKeyPressed(event -> {
            held.put(event.getCode(), true);
            if (event.getCode() == KeyCode.SPACE && bubbles.size() < 4) {
                Bubble bubble = new Bubble(sub.getShape().getTranslateX(), sub.getShape().getTranslateY(), sub.getShape().getRotate());
                bubbles.add(bubble);
                pane.getChildren().add(bubble.getShape());
            }
        });
        scene.setOnKeyReleased(event -> held.put(event.getCode(), false));

        new AnimationTimer() {
            @Override
            public void handle(long now) {
                if (held.getOrDefault(KeyCode.LEFT, false)) {
                    sub.turn(-4);
                }
                if (held.getOrDefault(KeyCode.RIGHT, false)) {
                    sub.turn(4);
                }
                if (held.getOrDefault(KeyCode.UP, false)) {
                    sub.accelerate(0.125);
                }
                sub.move();
                jellyfish.forEach(j -> j.move());
                bubbles.forEach(b -> b.move());

                for (Bubble b : bubbles) {
                    for (Jellyfish j : jellyfish) {
                        if (b.collides(j)) {
                            b.setAlive(false);
                            j.setAlive(false);
                            System.out.println("Pop!");
                        }
                    }
                }
                bubbles.stream()
                        .filter(b -> !b.isAlive())
                        .forEach(b -> pane.getChildren().remove(b.getShape()));
                jellyfish.stream()
                        .filter(j -> !j.isAlive())
                        .forEach(j -> pane.getChildren().remove(j.getShape()));
                bubbles = bubbles.stream()
                        .filter(b -> b.isAlive())
                        .collect(Collectors.toList());
                jellyfish = jellyfish.stream()
                        .filter(j -> j.isAlive())
                        .collect(Collectors.toList());

                for (Jellyfish j : jellyfish) {
                    if (sub.collides(j)) {
                        sub.getShape().setFill(Color.RED);
                        System.out.println("Bump!");
                        stop();
                        break;
                    }
                }
            }
        }.start();

        stage.setScene(scene);
        stage.setTitle("Bubbles");
        stage.show();
    }

    public static double wrap(double value, double size) {
        double result = value % size;
        if (result < 0) {
            result = result + size;
        }
        return result;
    }

    public static void main(String[] args) {
        launch(Main.class);
    }
}
Mover.java
import javafx.geometry.Point2D;
import javafx.scene.shape.Shape;

public abstract class Mover {
    private Shape shape;
    private Point2D velocity;
    private boolean alive;

    public Mover(Shape shape, double x, double y) {
        this.shape = shape;
        this.shape.setTranslateX(x);
        this.shape.setTranslateY(y);
        this.velocity = new Point2D(0, 0);
        this.alive = true;
    }

    public Shape getShape() {
        return shape;
    }

    public boolean isAlive() {
        return alive;
    }

    public void setAlive(boolean alive) {
        this.alive = alive;
    }

    public void turn(double degrees) {
        shape.setRotate(shape.getRotate() + degrees);
    }

    public void accelerate(double amount) {
        double angle = Math.toRadians(shape.getRotate());
        velocity = velocity.add(Math.cos(angle) * amount, Math.sin(angle) * amount);
    }

    public void move() {
        shape.setTranslateX(Main.wrap(shape.getTranslateX() + velocity.getX(), Main.WIDTH));
        shape.setTranslateY(Main.wrap(shape.getTranslateY() + velocity.getY(), Main.HEIGHT));
    }

    public boolean collides(Mover other) {
        Shape overlap = Shape.intersect(shape, other.getShape());
        return overlap.getBoundsInLocal().getWidth() != -1;
    }
}
Sub.java
import javafx.scene.paint.Color;
import javafx.scene.shape.Polygon;

public class Sub extends Mover {

    public Sub(double x, double y) {
        super(new Polygon(-15, -6, 9, -6, 15, 0, 9, 6, -15, 6), x, y);
        getShape().setFill(Color.GOLD);
    }
}
Jellyfish.java
import javafx.scene.paint.Color;
import javafx.scene.shape.Polygon;

public class Jellyfish extends Mover {

    public Jellyfish(double x, double y, double heading) {
        super(new Polygon(-10, 0, -6, -8, 0, -10, 6, -8, 10, 0, 7, 10, 3, 2, 0, 10, -3, 2, -7, 10), x, y);
        getShape().setFill(Color.ORCHID);
        turn(heading);
        accelerate(0.5);
    }

    @Override
    public void move() {
        super.move();
        turn(1);
    }
}
Bubble.java
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;

public class Bubble extends Mover {

    public Bubble(double x, double y, double heading) {
        super(new Circle(3, Color.LIGHTCYAN), x, y);
        turn(heading);
        accelerate(2.5);
    }
}
The window

After: Hold Left, run 20 frames, let go of Left, press and release Space, run 10 frames, press and release Space, run 10 frames, press and release Space, run 30 frames, hold Right, run 30 frames, let go of Right, press and release Space, then run 40 frames

This window is wider than the screen: scroll it sideways, or read it as text below.

The window as text
Window "Bubbles"
  Pane 360 x 240
    Rectangle 360 x 240 at (0, 0), fill #191970
    Polygon (-15, -6) (9, -6) (15, 0) (9, 6) (-15, 6), at (180, 120), rotate 40, fill #ffd700
    Polygon (-10, 0) (-6, -8) (0, -10) (6, -8) (10, 0) (7, 10) (3, 2) (0, 10) (-3, 2) (-7, 10), at (249.11733315717788, 119.61480784023462), rotate 215, fill #da70d6
    Polygon (-10, 0) (-6, -8) (0, -10) (6, -8) (10, 0) (7, 10) (3, 2) (0, 10) (-3, 2) (-7, 10), at (248.0048673219392, 103.62311101832802), rotate 270, fill #da70d6
    Circle radius 3 at (0, 0), translate (232.09445330007838, 64.55767409633816), rotate -80, fill #e0ffff
    Circle radius 3 at (0, 0), translate (256.6044443118983, 184.27876096865367), rotate 40, fill #e0ffff
frames run: 140
Output
Pop!
Pop!

The submarine turns up and fires three bubbles: two of them pop a jellyfish each, and the other one flies on, wrapping around the sea. Then the submarine turns to 40 degrees and fires a fourth bubble. A bubble that never hits anything flies forever here, and four of them would keep the submarine from ever firing again: the second challenge below gives bubbles a lifetime.

Your task

Challenge 1 of 3

In the starter code, bubbles and jellyfish are marked when they collide, but nothing is removed, so a popped jellyfish stays and pops again in every frame. Where the comment is, remove every bubble and every jellyfish that isn't alive: first their shapes from the pane, then the Movers from their lists, with streams, filter and collect as above. The test fires three bubbles and lets them fly.

Pop!
Pop!

Challenge 2

Bubbles that don't hit anything should pop by themselves after a while. Change the class Bubble: give it a field age, 0 at the start, and override move() so that it moves the bubble as a Mover does, then adds 1 to its age, and when the age reaches 50, marks the bubble as not alive. Main already removes every Mover that isn't alive.

The test fires a bubble and runs 50 frames: the bubble is gone, and the submarine can fire four more.

Challenge 3

Rapid fire: holding Space fires a bubble every 15 frames. Move the firing from the pressed handler into the game loop. Add a field reload, 0 at the start. At the very beginning of handle, before the keys that turn and push:

  1. if reload is more than 0, subtract 1 from it
  2. then, if Space is held, reload is 0 and fewer than 4 bubbles are out, fire a bubble as the handler did (from the submarine's place, in its heading, added to the list and the pane) and set reload to 15

The pressed handler then only puts true in the map. The test holds Space for 40 frames and runs 10 more: bubbles were fired in frames 1, 16 and 31.