Skip to content

30. Towers

In every game so far the player steered one thing: a paddle, a ship, a snake, a jumping figure. In Towers they steer nothing. Orcs, dire wolves and trolls come out of their lairs and march on the castle, and the player builds towers that shoot at them: archers, cannons and frost towers. By default the enemies keep to a road and the towers stand beside it. The game has a second way to play, though, and that is the one this chapter is built around: open ground. There the road is grass like any other, the enemies walk the shortest way to the castle, and every tower the player builds stands in that way and makes it longer. The towers are the maze. The one rule that holds it all together is that a tower may never close the last way.

The meadow in the seventh wave, a maze of towers The same with the flow field shown

The meadow in the seventh wave (left): five walls of towers with gaps at alternate ends, and nothing on the grass to show the way: only the enemies walking it. The same with F3 (right): every tile points the way to the castle, white near it, orange far away.

Tower defence grew out of custom maps for strategy games around 2002, and its two schools have been arguing since: games with a fixed road, where towers stand beside it, and games with open ground, where the player builds the maze. The first is the more common; Towers has both, one switch apart. The second is the harder to make, because the computer must find the way for every enemy again whenever a tower is built, and must say no before a tower closes it. This chapter makes that cheap with one idea, the flow field, and moves the game from the keyboard to the mouse.

What you will learn

  • Flow fields: one search from the goal gives every tile its way, for any number of units
  • Placing without blocking: a trial search before every tower, and why it is fast enough
  • The mouse in a world larger than the screen: screen and world coordinates through the camera, dragging, the edges of the screen
  • Menus over the game: a build menu beside the tile, by mouse and by keys
  • Units and towers as data files: blocks of properties, stages that inherit from each other, typos found
  • Aiming: turning turrets, leading the target, a burst that hits everything near it
  • The ground: drawn once, in squares, each when the camera first sees it
  • Turned sprites: 32 directions cached once

The complete game is in amade-towers-game.

The rules

  1. A battlefield is a grid of tiles of 20 units: grass, a road, trees, rocks and water. It is larger than the screen of 640 × 400; the bar at the top takes 22 of those, the view shows 32 × 19 tiles.
  2. Enemies come out of one or more lairs in waves and walk to the castle. Each one that gets there costs lives: an orc or a wolf one, a troll three. When no lives are left, the castle falls.
  3. Towers stand on grass, one per tile. An archer shoots arrows, fast and far. A cannon throws a ball that bursts where its target will be and hurts everything around it. A frost tower sends out a wave of cold that slows every enemy near it. Each tower can be upgraded twice and sold for 70 % of what it cost.
  4. On the road, the default, enemies keep to the road, and towers stand on the grass beside it. On open ground, enemies walk the shortest way over grass and road alike, and trees, rocks, water and towers are in their way. A tower that would leave a lair, or an enemy already on its way, without any way to the castle is refused. The choice of a battlefield switches between the two.
  5. Beaten enemies bring gold. Between two waves there is a pause; calling the next wave early brings a gold piece for each second of it left.
  6. When the last wave is beaten, the battle is won: three stars for keeping 90 % of the lives, two for half, one otherwise. The stars are saved, and the next battlefield opens.

Step 1: The flow field

In Maze (chapter 25) four ghosts each searched for their way with A, many times a second. Towers has two hundred enemies on the map at the end of a level. Two hundred A searches every time a tower is built would still be possible; two hundred every step would not. And they are all the same search: every enemy goes to the same castle.

So turn it round. Search once, from the castle outwards, and write into every tile how far it is from the castle and which neighbour is one step nearer. That is a flow field: a field of arrows, like the one on the right above. An enemy anywhere on the map looks at the tile it stands on and walks towards the neighbour the arrow points at. It needs no search of its own, and the field is the same for one enemy and for a thousand.

amade-game-engine · path/FlowField.java
public final class FlowField {
    /** The distance of a tile no way leads from. */
    public static final int UNREACHABLE = Integer.MAX_VALUE;
    /** What a step to a side costs, and a diagonal one. */
    public static final int STRAIGHT = 10;
    public static final int DIAGONAL = 14;
    /** The eight neighbours: first the four sides (up, left, down, right), then the corners. */
    private static final int[] DX = {0, -1, 0, 1, -1, -1, 1, 1};
    private static final int[] DY = {-1, 0, 1, 0, -1, 1, 1, -1};
    private static final byte NONE = -1;

    private final int width;
    private final int height;
    private final int[] distance;
    /** Per tile, the neighbour to go to (an index into DX and DY), or NONE at a goal and where no way leads. */
    private final byte[] next;
    private int visited;

    private FlowField(int width, int height) {
        if (width <= 0 || height <= 0) throw new IllegalArgumentException("empty field");
        this.width = width;
        this.height = height;
        this.distance = new int[width * height];
        this.next = new byte[width * height];
        Arrays.fill(distance, UNREACHABLE);
        Arrays.fill(next, NONE);
    }

    /** The way to the nearest of {@code goals} from every tile that {@code open} lets one enter. */
    public static FlowField toward(int width, int height, Passable open, Cell... goals) {
        FlowField field = new FlowField(width, height);
        field.search(open, goals);
        return field;
    }

Units walk in eight directions, so the search has two step lengths: 10 to a side, 14 diagonally (10 × √2, rounded). With steps of different lengths, breadth-first search is not enough: the queue must give out the tile nearest to the castle first. That is Dijkstra's algorithm, which is breadth-first search with a priority queue, or A* without a target to aim at:

amade-game-engine · path/FlowField.java
    /**
     * Dijkstra's search: the tile nearest to a goal that has not been settled yet is settled next, and its
     * neighbours learn a shorter distance through it. Breadth-first search (chapter 25) is the same with
     * equal steps; here diagonal steps cost more, so the queue must be sorted by distance.
     */
    private void search(Passable open, Cell... goals) {
        Heap queue = new Heap(width * height);
        for (Cell goal : goals) {
            if (!contains(goal.col(), goal.row())) throw new IllegalArgumentException(goal + " is outside the field");
            int index = goal.row() * width + goal.col();
            distance[index] = 0;
            queue.push(0, index);
        }
        while (!queue.isEmpty()) {
            long entry = queue.pop();
            int index = (int) entry;
            int here = (int) (entry >>> 32);
            if (here > distance[index]) continue;     // settled already by a shorter way
            visited++;
            int col = index % width;
            int row = index / width;
            for (int d = 0; d < 8; d++) {
                int c = col + DX[d];
                int r = row + DY[d];
                if (!step(open, col, row, d)) continue;
                int n = r * width + c;
                int through = here + (d < 4 ? STRAIGHT : DIAGONAL);
                if (through < distance[n]) {
                    distance[n] = through;
                    // The way from n leads back the way the search came: to the opposite neighbour.
                    next[n] = (byte) opposite(d);
                    queue.push(through, n);
                }
            }
        }
    }

    /** Whether a unit may step from a tile to its neighbour {@code d}: open, and no corner cut. */
    private boolean step(Passable open, int col, int row, int d) {
        int c = col + DX[d];
        int r = row + DY[d];
        if (!contains(c, r) || !open.test(c, r)) return false;
        if (d < 4) return true;
        return open.test(c, row) && open.test(col, r);
    }

Two details:

  • No cut corners. A diagonal step is allowed only when both tiles beside it are open too. Otherwise an orc would squeeze between two towers that touch at their corners.
  • The arrow points back. The search reaches a tile from its neighbour nearer the castle. The way from that tile is to that neighbour: the opposite direction.
amade-game-engine · test/path/FlowFieldTest.java
    @Test
    void diagonalStepsCostMoreAndCornersAreNotCut() {
        TileMap<Character> map = map("""
                row ......
                row ..X...
                row ...X..
                row ......
                """);
        FlowField field = field(map, new Cell(5, 3));
        assertEquals(0, field.distance(5, 3));
        assertEquals(10, field.distance(4, 3), "one step to the side");
        assertEquals(14, field.distance(4, 2), "one diagonal step");
        assertEquals(28 + 30, field.distance(0, 1), "two diagonal steps and three straight ones");
        // From (2, 2) the diagonal to (3, 3) would cut the corner of the wall at (3, 2): down first.
        assertEquals(new Cell(2, 3), field.next(2, 2));
        assertNull(field.next(5, 3), "at the goal");
    }

A unit is not on a tile's middle; it is somewhere in its tile. heading gives the direction from where it is to the middle of the next tile. The comment explains why that never leads through a closed tile, and why a unit may aim a little to the side of the middle, so that a crowd walks beside each other instead of in a single file. The test lets fifty units walk a winding field with random offsets and checks every step:

amade-game-engine · path/FlowField.java
    /**
     * Where a unit at {@code x}, {@code y} (in tiles: 2.5 is the middle of column 2) should walk: towards
     * the middle of the next tile, length 1. On a goal it walks to the goal's middle, and is there when the
     * heading becomes the zero vector; where no way leads, it stays.
     *
     * <p>A unit that walks this way never touches a closed tile. It is somewhere in its tile and walks
     * straight to the middle of a neighbour; the two tiles together are a rectangle, or with a diagonal step
     * and both tiles beside it open, a square of four, and a straight line between two points of a
     * rectangle stays inside it. That holds for any point of the next tile, so a unit may aim a little to
     * the side of the middle, to walk beside the others instead of behind them.
     */
    public Vector heading(double x, double y) {
        int col = (int) Math.floor(x);
        int row = (int) Math.floor(y);
        if (!reaches(col, row)) return Vector.ZERO;
        Cell to = next(col, row);
        if (to == null) to = new Cell(col, row);
        Vector way = new Vector(to.col() + 0.5 - x, to.row() + 0.5 - y);
        return way.lengthSquared() < 1e-12 ? Vector.ZERO : way.normalized();
    }
amade-game-engine · test/path/FlowFieldTest.java
    @Test
    void unitsFollowTheArrowsAndNeverEnterAClosedTile() {
        TileMap<Character> map = map("""
                row ..........
                row .XXXXXXXX.
                row .X......X.
                row .X.XX.XXX.
                row ...X..X...
                row XXXX..XXXX
                """);
        FlowField field = field(map, new Cell(4, 4));
        SeededRandom random = new SeededRandom(7);
        for (int unit = 0; unit < 50; unit++) {
            // Anywhere in the open tile (0, 0), aiming up to 0.3 tiles beside the middle of each next tile.
            double x = random.nextDouble();
            double y = random.nextDouble();
            double aside = (random.nextDouble() - 0.5) * 0.6;
            for (int step = 0; step < 4000; step++) {
                int col = (int) Math.floor(x);
                int row = (int) Math.floor(y);
                assertTrue(map.get(col, row) != 'X', "unit " + unit + " in a wall at " + col + ", " + row);
                if (field.isGoal(col, row)) break;
                Cell next = field.next(col, row);
                Vector way = new Vector(next.col() + 0.5 + aside - x, next.row() + 0.5 + aside - y);
                Vector move = way.normalized().times(Math.min(0.02, way.length()));
                x += move.x();
                y += move.y();
            }
            assertTrue(field.isGoal((int) Math.floor(x), (int) Math.floor(y)), "unit " + unit + " arrived");
        }
        assertEquals(new Vector(0, -1), field.heading(4.5, 4.9), "on the goal: to its middle");
        assertEquals(Vector.ZERO, field.heading(4.5, 4.5));
    }

A tile no way leads from keeps the distance UNREACHABLE. That is how the game will know that a tower closes the way:

amade-game-engine · test/path/FlowFieldTest.java
    @Test
    void aNewWallCanCutTheWayAndTheFieldSaysSo() {
        TileMap<Character> map = map("""
                row ......
                row XX.XXX
                row ......
                """);
        Cell goal = new Cell(5, 2);
        assertTrue(field(map, goal).reaches(0, 0));
        map.set(2, 1, 'X');      // a tower in the only gap
        FlowField cut = field(map, goal);
        assertFalse(cut.reaches(0, 0));
        assertEquals(FlowField.UNREACHABLE, cut.distance(0, 0));
        assertEquals(Vector.ZERO, cut.heading(0.5, 0.5), "nowhere to go");
        assertTrue(cut.reaches(0, 2));
    }

How much cheaper is one field than a search per unit? The test counts the tiles each one looks at:

amade-game-engine · test/path/FlowFieldTest.java
    @Test
    void oneFieldServesAllUnitsWhereAStarSearchesForEach() {
        TileMap<Character> map = map("""
                row ................................
                row ..XXXXXX........XXXXXXXX........
                row ........X..............X........
                row ..XXXX..X...XXXXXXXX...X..XXXX..
                row .....X..X..........X...X........
                row .....X..XXXXXXXX...X...XXXXXX...
                row .....X.............X............
                row ................................
                """);
        Cell castle = new Cell(16, 6);
        FlowField field = field(map, castle);
        int settled = field.visited();
        int searched = 0;
        int units = 0;
        for (int row = 0; row < map.height(); row++) {
            if (map.get(0, row) == 'X') continue;
            for (int i = 0; i < 25; i++) {
                AStar.Path path = AStar.find(map, new Cell(0, row), castle,
                        (c, r) -> map.get(c, r) == 'X' ? Double.POSITIVE_INFINITY : 1);
                searched += path.visited();
                units++;
            }
        }
        assertEquals(200, units);
        // 200 searches of about 37 tiles each, against one search of all 200 open tiles.
        assertTrue(searched > 30 * settled, searched + " tiles for A*, " + settled + " for the field");
    }

The field looks at every open tile once, 200 here. A* looks at fewer per search, because it aims at the castle, but it searches for each unit. For 200 units, the field wins by more than thirty times.

Step 2: Towers make the maze

The game keeps one flow field, from all the castle's tiles at once, and searches it again after every change of the map: a tower built, a tower sold. Units walk where the ground lets them and no tower stands:

amade-towers-game · domain/Ground.java
public enum Ground {
    GRASS, ROAD, TREE, PINE, ROCK, WATER, LAIR, CASTLE;

    /** Towers stand on grass; on open ground the road is grass too. */
    public boolean buildable(Mode mode) {
        return this == GRASS || this == ROAD && mode == Mode.OPEN;
    }

    /**
     * Units walk out of their lair and into the castle, on the road, and on open ground over grass as well;
     * trees, rocks and water are always in the way.
     */
    public boolean walkable(Mode mode) {
        return this == ROAD || this == LAIR || this == CASTLE || this == GRASS && mode == Mode.OPEN;
    }

    /** The ground a gid of the map stands for; an unknown class is an error naming the map. */
    static Ground of(TiledMap map, int gid) {
        return switch (map.type(gid)) {
            case "" -> GRASS;
            case "road" -> ROAD;
            case "tree" -> TREE;
            case "pine" -> PINE;
            case "rock" -> ROCK;
            case "water" -> WATER;
            default -> throw new DataException(map.source() + ": the tile class \"" + map.type(gid)
                    + "\" means nothing to the game; use road, tree, pine, rock or water");
        };
    }
}
amade-towers-game · domain/Game.java
    /** Units walk where the ground lets them, on the road or over open ground, and no tower stands. */
    public boolean open(int col, int row) {
        return level.ground(col, row).walkable(mode) && towerAt[row * level.width() + col] == null;
    }

    /** One search from the castle for all units, after every change of the map. */
    private void rebuildWay() {
        flow = FlowField.toward(level.width(), level.height(), this::open, level.castle().toArray(Cell[]::new));
        trail = trail(flow);
        version++;
    }

The trail is the way from each lair to the castle, following the arrows. The rules do not need it, and nothing draws it (Step 7 says why): the simple builder of the tests and of the title screen puts its towers beside it.

May a tower stand here?

Before a tower is built, the game asks whether the way stays open. The question is answered by the same search, once more, with the tile closed as if the tower stood there. Every lair must still reach the castle, and so must every enemy already on the map: an orc that a tower walls into a corner would stand there for ever.

amade-towers-game · domain/Game.java
    /**
     * Whether a tower may stand on a tile, gold aside. A tower must never close the way: every lair, and
     * every unit already on the map, must still reach the castle. The flow field answers that with one
     * search, with the tile closed as if the tower stood there.
     */
    public Placement placement(int col, int row) {
        if (!level.contains(col, row) || !level.ground(col, row).buildable(mode) || tower(col, row) != null) {
            return Placement.NOT_HERE;
        }
        Cell cell = new Cell(col, row);
        if (askedVersion == version && askedTime == time && cell.equals(askedCell)) return askedPlacement;
        Placement answer = Placement.OK;
        for (Creep creep : creeps) {
            if (creep.col() == col && creep.row() == row) answer = Placement.OCCUPIED;
        }
        if (answer == Placement.OK) {
            FlowField trial = FlowField.toward(level.width(), level.height(),
                    (c, r) -> (c != col || r != row) && open(c, r), level.castle().toArray(Cell[]::new));
            for (Cell lair : level.lairs()) if (!trial.reaches(lair)) answer = Placement.BLOCKS;
            for (Creep creep : creeps) if (!trial.reaches(creep.col(), creep.row())) answer = Placement.BLOCKS;
        }
        askedVersion = version;
        askedTime = time;
        askedCell = cell;
        askedPlacement = answer;
        return answer;
    }

One search of a battlefield of 40 × 24 tiles is quick, so asking on every click is no problem. The mouse asks more often than that: the tile under it is marked red where no tower may stand, in every frame. So the answer is kept, together with the map's version and the game's time, and given again until one of them changes.

amade-towers-game · test/domain/GameTest.java
    @Test
    void towersMakeTheMazeButNeverCloseTheWay() {
        Game game = game(ONE_ORC,
                ".....T....",
                "L.......CC",
                "..........");
        TowerType archer = game.rules().tower("archer");
        assertEquals(80, game.flow().distance(0, 1), "eight tiles straight to the castle");
        assertEquals(Game.Placement.OK, game.build(3, 1, archer), "in the way, but there is a way round");
        assertTrue(game.flow().distance(0, 1) > 80, "round it: " + game.flow().distance(0, 1));
        assertEquals(Game.Placement.OK, game.build(3, 0, archer));
        assertEquals(Game.Placement.BLOCKS, game.build(3, 2, archer), "the last gap");
        assertEquals(Game.Placement.NOT_HERE, game.build(5, 0, archer), "a tree stands there");
        assertEquals(Game.Placement.NOT_HERE, game.build(3, 1, archer), "a tower stands there");
        assertEquals(Game.Placement.NOT_HERE, game.build(8, 1, archer), "the castle");
        assertTrue(game.flow().reaches(0, 1));
    }

    @Test
    void noTowerIsBuiltOnAUnitOrWhereItWouldWallOneIn() {
        Game game = game(ONE_ORC,
                "L.........CC",
                "TTTT.TTTTTTT");
        game.callWave();
        run(game, 2.0);         // the orc is on (2, 0)
        Creep orc = game.creeps().getFirst();
        assertEquals(2, orc.col());
        TowerType archer = game.rules().tower("archer");
        assertEquals(Game.Placement.OCCUPIED, game.build(2, 0, archer));
        assertEquals(Game.Placement.BLOCKS, game.build(4, 0, archer), "the orc could not go on");
        assertEquals(Game.Placement.BLOCKS, game.build(1, 0, archer), "the lair would be walled in");
    }

On the road

The road is one more class of tile in the Tiled map, and one more question to Ground: may a unit walk here, may a tower stand here, and the answer now depends on how the battle is fought. On the road, units walk on road tiles only and towers stand on grass only; on open ground, the road is grass:

amade-towers-game · domain/Ground.java
public enum Ground {
    GRASS, ROAD, TREE, PINE, ROCK, WATER, LAIR, CASTLE;

    /** Towers stand on grass; on open ground the road is grass too. */
    public boolean buildable(Mode mode) {
        return this == GRASS || this == ROAD && mode == Mode.OPEN;
    }

    /**
     * Units walk out of their lair and into the castle, on the road, and on open ground over grass as well;
     * trees, rocks and water are always in the way.
     */
    public boolean walkable(Mode mode) {
        return this == ROAD || this == LAIR || this == CASTLE || this == GRASS && mode == Mode.OPEN;
    }

    /** The ground a gid of the map stands for; an unknown class is an error naming the map. */
    static Ground of(TiledMap map, int gid) {
        return switch (map.type(gid)) {
            case "" -> GRASS;
            case "road" -> ROAD;
            case "tree" -> TREE;
            case "pine" -> PINE;
            case "rock" -> ROCK;
            case "water" -> WATER;
            default -> throw new DataException(map.source() + ": the tile class \"" + map.type(gid)
                    + "\" means nothing to the game; use road, tree, pine, rock or water");
        };
    }
}

Nothing else changes. The flow field is searched over the tiles a unit may enter, so on the road it simply finds the way along the road, round its corners, from every lair. A tower can never stand on a road tile, so it can never close the way, and the trial search of placement always says yes. The same rules, the same field, the same walking, two different games:

amade-towers-game · test/domain/GameTest.java
    @Test
    void onTheRoadUnitsKeepToItAndTowersStandBesideIt() {
        String[] rows = {
                "..........",
                "L###....CC",
                "...######.",
        };
        Rules rules = Maps.rules(ONE_ORC);
        Game road = new Game(Maps.of(rules, rows), rules, Mode.ROAD);
        TowerType archer = road.rules().tower("archer");
        assertEquals(Game.Placement.NOT_HERE, road.build(2, 1, archer), "not on the road");
        assertFalse(road.flow().reaches(5, 1), "the grass is not walked on");
        assertEquals(30 + 10 + 50 + 10, road.flow().distance(0, 1), "along the road, round its corners");
        assertEquals(Game.Placement.OK, road.build(5, 1, archer), "beside it");
        assertEquals(Game.Placement.OK, road.build(5, 0, archer), "a tower beside the road never blocks it");

        Game open = new Game(Maps.of(rules, rows), rules, Mode.OPEN);
        assertEquals(80, open.flow().distance(0, 1), "on open ground the road is grass: straight on");
        assertEquals(Game.Placement.OK, open.build(2, 1, open.rules().tower("archer")));
    }

    @Test
    void onTheRoadTheUnitsAreTougher() {
        Rules rules = Maps.rules("""
                level 1
                  gold 0
                  lives 5
                  road 2
                  wave orc 1 1
                """);
        String[] rows = {"L#######CC"};
        for (Mode mode : Mode.values()) {
            Game game = new Game(Maps.of(rules, rows), rules, mode);
            game.callWave();
            game.update(STEP);
            assertEquals(mode == Mode.ROAD ? 76 : 38, game.creeps().getFirst().maxHealth(), 1e-9, mode.toString());
        }
    }

    @Test
    void aMapWithoutARoadIsPlayedOnOpenGround() {
        Rules rules = Maps.rules(ONE_ORC);
        Game game = new Game(Maps.of(rules, "L.......CC"), rules, Mode.ROAD);
        assertEquals(Mode.OPEN, game.mode());
    }

    @Test
    void aRoadThatDoesNotReachTheCastleIsAnError() {
        TextData.DataException error = assertThrows(TextData.DataException.class,
                () -> Maps.of(Maps.rules(ONE_ORC), "L###......CC"));
        assertEquals("test.tmx: the road does not lead from the lair at (0, 0) to the castle", error.getMessage());
    }

A map's road must lead from every lair to the castle, with its tiles side by side: a unit does not cut corners, so two road tiles that touch only at a corner do not join. Level.read checks it with one more flow field and rejects the map otherwise, as it rejects a castle that covers no tile. The roads of the three battlefields are two tiles wide, so that a crowd can walk beside each other.

The meadow on the road

Walking

Each step, a unit looks at the arrow of its tile and walks towards the next tile's middle, a little to one side: each unit has its own offset, drawn once when it leaves the lair. It turns towards where it walks at a limited speed, so that the picture does not jump between the eight directions:

amade-towers-game · domain/Game.java
    /** A unit walks towards the next tile of the flow field, beside its middle; in the castle it is gone. */
    private void walk(Creep creep, double seconds) {
        creep.previousX = creep.x;
        creep.previousY = creep.y;
        creep.previousAngle = creep.angle;
        if (creep.cold > 0) {
            creep.cold -= seconds;
            if (creep.cold <= 0) creep.slow = 1;
        }
        int col = creep.col();
        int row = creep.row();
        if (flow.isGoal(col, row)) {
            leak(creep);
            return;
        }
        Cell next = flow.next(col, row);
        if (next == null) return;       // walled in: cannot happen, placement() sees to that
        double toX = (next.col() + 0.5 + creep.asideX()) * Field.TILE;
        double toY = (next.row() + 0.5 + creep.asideY()) * Field.TILE;
        double dx = toX - creep.x;
        double dy = toY - creep.y;
        double way = Math.hypot(dx, dy);
        double step = Math.min(way, creep.type().speed() * Field.TILE * creep.slow * seconds);
        if (way > 1e-9) {
            creep.x += dx / way * step;
            creep.y += dy / way * step;
            creep.angle = turn(creep.angle, Math.atan2(dy, dx), Field.UNIT_TURN * seconds);
        }
        creep.vx = (creep.x - creep.previousX) / seconds;
        creep.vy = (creep.y - creep.previousY) / seconds;
        creep.walked += step;
    }

When a tower is built in front of a walking orc, the orc does nothing special. The field has changed, and in the next step the arrow of its tile points somewhere else. Every unit on the map takes the new way at once, which is what makes building in front of a running wave worth trying.

amade-towers-game · test/domain/GameTest.java
    @Test
    void unitsWalkTheShortestWayToTheCastleAndCostLivesThere() {
        Game game = game(ONE_ORC,
                "..........",
                "L.......CC",
                "..........");
        game.callWave();
        game.update(STEP);
        assertEquals(1, game.creeps().size());
        Creep orc = game.creeps().getFirst();
        assertEquals(0, orc.col(), "out of the lair, its first step taken");
        // From the middle of the lair to the castle's first tile: 7.5 tiles at one tile a second.
        run(game, 7.3);
        assertEquals(1, game.creeps().size(), "still on its way");
        run(game, 0.4);
        assertTrue(game.creeps().isEmpty(), "in the castle");
        assertEquals(4, game.lives(), "an orc costs a life");
    }

A step of the game, in its order:

amade-towers-game · domain/Game.java
    public void update(double seconds) {
        events.clear();
        events.addAll(commanded);
        commanded.clear();
        if (phase != Phase.PLAYING) return;
        time += seconds;
        if (countingDown) {
            countdown -= seconds;
            if (countdown <= 0) startWave(0, events);
        }
        spawn(seconds);
        for (Creep creep : creeps) walk(creep, seconds);
        for (Tower tower : towers) act(tower, seconds);
        for (Shot shot : shots) fly(shot, seconds);
        creeps.removeIf(Creep::gone);
        shots.removeIf(shot -> shot.done);
        end();
    }

Through each other

Watch a wave for a while and a wolf catches up with a troll and walks right through it. Nothing in walk knows about other units: each one follows the field on its own, as if the others were not there. That is a choice, and tower defence games make it in three ways:

  • Overlap. Units are ghosts to each other. A fast one passes a slow one, and a crowd can stand on one spot. Most tower defence games do this, Bloons TD and Kingdom Rush among them. It costs nothing, the way of each unit is exactly the way of the field, and crowds bunch up, which rewards bursts and frost.
  • Volume. Each unit has a body, and bodies push each other apart. Strategy games and the tower defence maps made in them work this way, because their units collide anyway. A troll in a narrow gap now holds up the wolves behind it: traffic jams become part of the game, and the game needs rules so that a jam never freezes for good.
  • One per cell. On a grid, a unit may only step into a free cell. It waits or steps aside when the next one is taken: a board game more than a crowd.

Towers overlaps, for four reasons. The flow field stays the whole truth about where a unit walks, so every test of walking and building stays exact. A cannon burst or a frost ring in a bunch of enemies is the reward the player builds for, and pushing would spread the bunch. The waves were balanced with units that never wait for each other, and jams behind a troll would change every level's difficulty. And the eye forgives it: each unit walks a little to one side (the offset above), so most of the time a crowd looks like a crowd and not like one sprite. Exercise 7 tries the other way.

Step 3: Units, towers and waves as data

Swarm (chapter 24) read its flight paths from text files with TextData: lines of words, comments after #, errors with the file and the line. Towers has three kinds of things with many numbers each, and those numbers are what a tower defence game is made of: an archer that costs 40 or 45 changes the whole game. They belong in files where they can be changed without compiling, and where the game designer can read them:

amade-towers-game · data/towers.txt (shortened)
tower archer
  shot arrow
  cost 40
  range 3.0
  reload 0.75
  damage 9
  speed 14
  turn 10
  upgrade archer-2

tower archer-2 like archer
  cost 45
  range 3.3
  reload 0.6
  damage 14
  upgrade archer-3

The engine's Catalog reads such files: blocks that begin with a kind and a name (tower archer), and properties below them. A block can be like another one before it and takes over all its properties; its own lines replace them. The second stage of the archer says only what changes.

amade-game-engine · data/Catalog.java
    /**
     * Reads the blocks of the {@code kinds} given, in the order of the file. Names are unique within a
     * kind; a line before the first block is an error.
     */
    public static List<Entry> read(List<TextData.Line> lines, String... kinds) {
        Set<String> known = Set.of(kinds);
        List<Entry> entries = new ArrayList<>();
        Map<String, Entry> byName = new LinkedHashMap<>();
        Entry current = null;
        for (TextData.Line line : lines) {
            String keyword = line.keyword();
            if (known.contains(keyword)) {
                current = header(line, byName);
                entries.add(current);
                continue;
            }
            if (current == null) {
                throw line.error("expected " + String.join(" or ", kinds) + " before \"" + keyword + '"');
            }
            if (current.properties.containsKey(keyword) && current.inherited.contains(keyword)) {
                // The block's own line replaces what it took over with like.
                current.properties.remove(keyword);
                current.inherited.remove(keyword);
            }
            current.properties.computeIfAbsent(keyword, key -> new ArrayList<>()).add(line);
        }
        return Collections.unmodifiableList(entries);
    }

    private static Entry header(TextData.Line line, Map<String, Entry> byName) {
        String kind = line.keyword();
        String name = line.word(1);
        if (line.size() != 2 && !(line.size() == 4 && line.word(2).equals("like"))) {
            throw line.error("expected \"" + kind + " <name>\" or \"" + kind + " <name> like <other>\"");
        }
        if (byName.containsKey(kind + " " + name)) throw line.error("there is already a " + kind + " " + name);
        Entry entry = new Entry(kind, name, line);
        if (line.size() == 4) {
            Entry model = byName.get(kind + " " + line.word(3));
            if (model == null) throw line.error("no " + kind + " " + line.word(3) + " before this line");
            model.properties.forEach((key, given) -> {
                entry.properties.put(key, new ArrayList<>(given));
                entry.inherited.add(key);
            });
        }
        byName.put(kind + " " + name, entry);
        return entry;
    }

The game asks a block for its properties by name: a number, a whole number, a word, each with or without a value to fall back on. Every property it asks for is noted. At the end, finish looks for a property the game never asked for. That is either a typo (rnage 3) or a property that has gone out of use, and both should not pass in silence. A typo in a data file that is ignored is the worst kind of bug: the game runs, the tower has its default range, and nobody knows why.

amade-game-engine · data/Catalog.java
public final class Catalog {
    /** One thing: its kind, its name, and its properties by name, each with the lines that gave it. */
    public static final class Entry {
        private final String kind;
        private final String name;
        private final TextData.Line header;
        private final Map<String, List<TextData.Line>> properties = new LinkedHashMap<>();
        private final Set<String> asked = new HashSet<>();
        /** Properties taken over with like and not yet given by the block itself. */
        private final Set<String> inherited = new HashSet<>();

        private Entry(String kind, String name, TextData.Line header) {
            this.kind = kind;
            this.name = name;
            this.header = header;
        }

        public String kind() {
            return kind;
        }

        public String name() {
            return name;
        }

        public boolean has(String key) {
            asked.add(key);
            return properties.containsKey(key);
        }

        /** The line of a property given once, such as {@code range 2.5}; an error if it is missing or given twice. */
        public TextData.Line line(String key) {
            List<TextData.Line> lines = lines(key);
            if (lines.isEmpty()) throw error(key + " is missing");
            if (lines.size() > 1) throw lines.get(1).error(key + " is given twice");
            return lines.getFirst();
        }

        /** All lines of a property that may be given several times, such as the levels of a tower. */
        public List<TextData.Line> lines(String key) {
            asked.add(key);
            return properties.getOrDefault(key, List.of());
        }

        /** A property of one number: {@code health 60}. */
        public double number(String key) {
            TextData.Line line = line(key);
            if (line.size() != 2) throw line.error(key + " takes one number");
            return line.number(1);
        }

        public double number(String key, double fallback) {
            return has(key) ? number(key) : fallback;
        }

        public int integer(String key) {
            TextData.Line line = line(key);
            if (line.size() != 2) throw line.error(key + " takes one whole number");
            return line.integer(1);
        }

        public int integer(String key, int fallback) {
            return has(key) ? integer(key) : fallback;
        }

        /** A property of one word: {@code shot arrow}. */
        public String text(String key) {
            TextData.Line line = line(key);
            if (line.size() != 2) throw line.error(key + " takes one word");
            return line.word(1);
        }

        public String text(String key, String fallback) {
            return has(key) ? text(key) : fallback;
        }

        /** An error at the line that names this thing. */
        public TextData.DataException error(String message) {
            return header.error(kind + " " + name + ": " + message);
        }

        /** Throws for the first property the game did not ask for: a typo, or something no longer used. */
        public void finish() {
            for (Map.Entry<String, List<TextData.Line>> property : properties.entrySet()) {
                if (!asked.contains(property.getKey())) {
                    throw property.getValue().getFirst().error(
                            '"' + property.getKey() + "\" is not a property of a " + kind);
                }
            }
        }
    }
amade-game-engine · test/data/CatalogTest.java
    @Test
    void mistakesNameTheFileAndTheLine() {
        assertEquals("units.txt:1: expected unit or tower before \"health\"",
                assertThrows(TextData.DataException.class, () -> read("health 60\nunit orc\n")).getMessage());
        assertEquals("units.txt:3: there is already a unit orc",
                assertThrows(TextData.DataException.class, () -> read("unit orc\nhealth 1\nunit orc\n")).getMessage());
        assertEquals("units.txt:2: no unit troll before this line",
                assertThrows(TextData.DataException.class, () -> read("unit orc\nunit wolf like troll\n")).getMessage());

        Catalog.Entry orc = read("""
                unit orc
                  health 60
                  sped 1.1
                  speed 1.0 1.2
                """).getFirst();
        assertEquals(60, orc.number("health"));
        assertEquals("units.txt:4: speed takes one number",
                assertThrows(TextData.DataException.class, () -> orc.number("speed")).getMessage());
        assertEquals("units.txt:1: unit orc: bounty is missing",
                assertThrows(TextData.DataException.class, () -> orc.integer("bounty")).getMessage());
        // The game asked for health, speed and bounty; "sped" is a typo nobody asked for.
        assertEquals("units.txt:3: \"sped\" is not a property of a unit",
                assertThrows(TextData.DataException.class, orc::finish).getMessage());
    }

Reading the units is a few lines. The towers need a little more: the stages are found from the upgrade lines, so archer-3 knows it is the third stage of the archer, and a circle of upgrades or an upgrade to a tower that does not exist is an error at its line:

amade-towers-game · domain/Rules.java
    private static Map<String, UnitType> units(List<TextData.Line> lines) {
        Map<String, UnitType> units = new LinkedHashMap<>();
        for (Catalog.Entry unit : Catalog.read(lines, "unit")) {
            UnitType type = new UnitType(unit.name(), positive(unit, "health"), positive(unit, "speed"),
                    unit.number("armour", 0), unit.integer("bounty"), unit.integer("harm", 1),
                    unit.number("size", 0.3));
            unit.finish();
            units.put(type.id(), type);
        }
        return units;
    }
amade-towers-game · domain/Rules.java
    private static Map<String, TowerType> towers(List<TextData.Line> lines) {
        List<Catalog.Entry> entries = Catalog.read(lines, "tower");
        Map<String, Catalog.Entry> byName = new LinkedHashMap<>();
        Map<String, String> before = new LinkedHashMap<>();
        for (Catalog.Entry tower : entries) {
            byName.put(tower.name(), tower);
            String next = upgrade(tower);
            if (next != null && before.put(next, tower.name()) != null) {
                throw tower.error("a second tower upgrades to " + next);
            }
        }
        Map<String, TowerType> towers = new LinkedHashMap<>();
        for (Catalog.Entry tower : entries) {
            // The first stage of the kind: follow the upgrades backwards.
            String kind = tower.name();
            int stage = 1;
            while (before.containsKey(kind)) {
                kind = before.get(kind);
                stage++;
                if (stage > entries.size()) throw tower.error("the upgrades go round in a circle");
            }
            String upgrade = upgrade(tower);
            if (upgrade != null && !byName.containsKey(upgrade)) throw tower.error("there is no tower " + upgrade);
            TowerType.Shot shot = shot(tower);
            TowerType type = new TowerType(tower.name(), kind, stage, shot, tower.integer("cost"),
                    positive(tower, "range"), positive(tower, "reload"), tower.number("damage", 0),
                    tower.number("splash", 0), tower.number("slow", 1), tower.number("cold", 0),
                    shot == TowerType.Shot.FROST ? 0 : positive(tower, "speed"), tower.number("turn", 6), upgrade);
            tower.finish();
            towers.put(type.id(), type);
        }
        return towers;
    }

A wave is one line: groups of a unit, how many, and the seconds between two of them. Each level makes its waves a little tougher than the one before, by a factor of the health, and on the road tougher still by a factor of its own, road: there no maze makes the enemies' way longer, so the same towers have less time to shoot:

amade-towers-game · domain/Rules.java
    private static Map<Integer, Plan> plans(List<TextData.Line> lines, Map<String, UnitType> units) {
        Map<Integer, Plan> plans = new LinkedHashMap<>();
        for (Catalog.Entry level : Catalog.read(lines, "level")) {
            int number;
            try {
                number = Integer.parseInt(level.name());
            } catch (NumberFormatException e) {
                throw level.error("a level is a number");
            }
            List<Wave> waves = new ArrayList<>();
            for (TextData.Line line : level.lines("wave")) {
                // wave orc 8 1.2 wolf 4 0.6: groups of a unit, how many, and the seconds between them
                if (line.size() < 4 || (line.size() - 1) % 3 != 0) {
                    throw line.error("a wave is groups of three words: a unit, how many, the seconds between");
                }
                List<Wave.Group> groups = new ArrayList<>();
                for (int i = 1; i < line.size(); i += 3) {
                    UnitType unit = units.get(line.word(i));
                    if (unit == null) throw line.error('"' + line.word(i) + "\" is not a unit");
                    groups.add(new Wave.Group(unit, line.integer(i + 1), line.number(i + 2)));
                }
                waves.add(new Wave(groups));
            }
            if (waves.isEmpty()) throw level.error("no waves");
            plans.put(number, new Plan(number, level.integer("gold"), level.integer("lives"),
                    level.number("tougher", 0), level.number("road", 1), waves));
            level.finish();
        }
        return plans;
    }
amade-towers-game · test/domain/RulesTest.java
    @Test
    void aMistakeInADataFileNamesItsLine() {
        List<TextData.Line> units = TextData.load(Rules.class, Rules.FOLDER + "units.txt");
        List<TextData.Line> waves = TextData.load(Rules.class, Rules.FOLDER + "waves.txt");
        List<TextData.Line> towers = TextData.parse("towers.txt", new StringReader("""
                tower archer
                  shot arrow
                  cost 40
                  rnage 3
                  range 3
                  reload 0.75
                  speed 14
                """));
        assertEquals("towers.txt:4: \"rnage\" is not a property of a tower",
                assertThrows(TextData.DataException.class, () -> Rules.read(units, towers, waves)).getMessage());
        List<TextData.Line> badWave = TextData.parse("waves.txt", new StringReader("""
                level 1
                  gold 100
                  lives 20
                  wave orc 8 1.2 goblin 4 0.5
                """));
        assertEquals("waves.txt:4: \"goblin\" is not a unit",
                assertThrows(TextData.DataException.class, () -> Rules.read(units,
                        TextData.load(Rules.class, Rules.FOLDER + "towers.txt"), badWave)).getMessage());
    }

The battlefields come from Tiled, as in Jump: the tile layer ground with the classes tree, pine, rock and water, and the object layer things with one castle and one or more lairs.

amade-towers-game · domain/Level.java
    /** The rules' view of a map: its ground by the tiles' classes, the lairs and the castle by their objects. */
    public static Level read(int number, TiledMap map, Rules.Plan plan) {
        if (map.tileWidth() != Field.TILE || map.tileHeight() != Field.TILE) {
            throw new DataException(map.source() + ": the tiles must be " + Field.TILE + " × " + Field.TILE);
        }
        TileMap<Ground> ground = map.tiles("ground", gid -> Ground.of(map, gid));
        List<Cell> lairs = new ArrayList<>();
        List<Cell> castle = new ArrayList<>();
        Box box = null;
        for (TiledObject thing : map.objects("things")) {
            switch (thing.type()) {
                case "lair" -> {
                    Cell cell = cell(map, thing, thing.centerX(), thing.centerY());
                    ground.set(cell.col(), cell.row(), Ground.LAIR);
                    lairs.add(cell);
                }
                case "castle" -> {
                    if (box != null) throw error(map, thing, "a second castle; a map has one");
                    box = new Box(thing.x(), thing.y(), thing.width(), thing.height());
                    // Every tile the castle covers by more than half is the castle's.
                    for (int row = 0; row < ground.height(); row++) {
                        for (int col = 0; col < ground.width(); col++) {
                            double x = (col + 0.5) * Field.TILE;
                            double y = (row + 0.5) * Field.TILE;
                            if (x > thing.x() && x < thing.right() && y > thing.y() && y < thing.bottom()) {
                                ground.set(col, row, Ground.CASTLE);
                                castle.add(new Cell(col, row));
                            }
                        }
                    }
                    if (castle.isEmpty()) throw error(map, thing, "the castle covers no tile");
                }
                default -> throw error(map, thing, "the class \"" + thing.type()
                        + "\" means nothing to the game; use lair or castle");
            }
        }
        if (lairs.isEmpty()) throw new DataException(map.source() + ": no object of the class lair");
        if (box == null) throw new DataException(map.source() + ": no object of the class castle");
        Level level = new Level(number, map, ground, lairs, castle, box, plan);
        if (level.hasRoad()) {
            FlowField way = FlowField.toward(level.width(), level.height(),
                    (c, r) -> level.ground(c, r).walkable(Mode.ROAD), castle.toArray(Cell[]::new));
            for (Cell lair : lairs) {
                if (!way.reaches(lair)) {
                    throw new DataException(map.source() + ": the road does not lead from the lair at ("
                            + lair.col() + ", " + lair.row() + ") to the castle");
                }
            }
        }
        return level;
    }

Step 4: Aiming and shooting

A tower has a turret that turns. In each step it looks for its target: of all enemies in range, the one with the shortest way left to the castle. That is not the nearest to the tower, but the most dangerous one, and the flow field already knows each enemy's distance. The turret turns towards it at its own speed (a cannon turns slowly, an archer fast) and fires only when it points at it, within 0.2 radians, and has reloaded:

amade-towers-game · domain/Game.java
    /** A tower turns to the unit nearest to the castle within its range and fires when it points at it. */
    private void act(Tower tower, double seconds) {
        TowerType type = tower.type;
        tower.cooldown -= seconds;
        tower.sinceShot += seconds;
        tower.previousAngle = tower.angle;
        double range = type.range() * Field.TILE;
        if (type.shot() == TowerType.Shot.FROST) {
            if (tower.cooldown > 0 || first(tower, range) == null) return;
            for (Creep creep : creeps) {
                if (!creep.gone && distance(tower, creep) <= range) {
                    creep.slow = Math.min(creep.slow, type.slow());
                    creep.cold = Math.max(creep.cold, type.slowSeconds());
                    hurt(creep, type.damage());
                }
            }
            tower.cooldown = type.reload();
            tower.sinceShot = 0;
            events.add(new Event.Frosted(tower));
            return;
        }
        tower.target = first(tower, range);
        if (tower.target == null) return;
        double aim = Math.atan2(tower.target.y - tower.y(), tower.target.x - tower.x());
        tower.angle = turn(tower.angle, aim, type.turn() * seconds);
        if (tower.cooldown <= 0 && Math.abs(Math.IEEEremainder(aim - tower.angle, 2 * Math.PI)) < Field.AIMED) {
            fire(tower, tower.target);
            tower.cooldown = type.reload();
            tower.sinceShot = 0;
        }
    }

    /** The unit in range with the shortest way left to the castle: the one to stop first. */
    private Creep first(Tower tower, double range) {
        Creep best = null;
        int bestWay = Integer.MAX_VALUE;
        for (Creep creep : creeps) {
            if (creep.gone || distance(tower, creep) > range) continue;
            int way = flow.distance(creep.col(), creep.row());
            if (way < bestWay) {
                best = creep;
                bestWay = way;
            }
        }
        return best;
    }

An arrow flies after its target and hits it; it is fast enough that nobody sees it bend. If the target falls first, the arrow lands where it was. A cannon ball is slow and does not steer. It is aimed at where the target will be when the ball lands. The target walks on at its speed while the ball flies; how far, depends on how long the ball flies, which depends on how far it goes. Two guesses are enough: the first takes the flight time to where the target is now, the second the flight time to the first guess.

amade-towers-game · domain/Game.java
    private void fire(Tower tower, Creep target) {
        TowerType type = tower.type;
        // From the muzzle, a little in front of the middle of the turret.
        double x = tower.x() + Math.cos(tower.angle) * 8;
        double y = tower.y() + Math.sin(tower.angle) * 8;
        double toX = target.x;
        double toY = target.y;
        if (type.shot() == TowerType.Shot.BALL) {
            // Where the target will be when the ball lands: it walks on while the ball flies. Two guesses
            // are enough; the second already uses the flight time to the first.
            for (int i = 0; i < 2; i++) {
                double flight = Math.hypot(toX - x, toY - y) / (type.shotSpeed() * Field.TILE);
                toX = target.x + target.vx * flight;
                toY = target.y + target.vy * flight;
            }
        }
        shots.add(new Shot(type, x, y, toX, toY, target));
        events.add(new Event.Fired(tower));
    }

Where the ball lands, it bursts, and everything the burst reaches is hit, the target or not. "Reaches" counts the enemy's size too: a troll is hit by a burst whose edge touches its shoulder.

amade-towers-game · domain/Game.java
        // A ball bursts: everything whose body the burst reaches is hit, the target or not.
        double radius = shot.splash() * Field.TILE;
        for (Creep creep : creeps) {
            if (!creep.gone && Math.hypot(creep.x - shot.x, creep.y - shot.y) <= radius + creep.type().size() * Field.TILE) {
                hurt(creep, shot.damage());
            }
        }
        events.add(new Event.Burst(shot.x, shot.y, radius));
amade-towers-game · test/domain/GameTest.java
    @Test
    void anArcherTurnsShootsAndWinsTheBounty() {
        Game game = game(ONE_ORC,
                "..........",
                "L.......CC",
                "..........");
        TowerType archer = game.rules().tower("archer");
        game.build(4, 0, archer);
        game.callWave();
        game.update(STEP);
        int gold = game.gold();
        int fired = 0;
        for (int i = 0; i < 8 * 120 && !game.creeps().isEmpty(); i++) {
            game.update(STEP);
            fired += (int) game.events().stream().filter(e -> e instanceof Event.Fired).count();
        }
        assertTrue(game.creeps().isEmpty());
        assertEquals(5, game.lives(), "the orc never reached the castle");
        // 38 hit points, 9 per arrow: five arrows.
        assertEquals(5, fired);
        assertEquals(gold + 5, game.gold(), "the bounty of an orc");
    }
amade-towers-game · test/domain/GameTest.java
    @Test
    void aCannonBallHitsEverythingWhereItBursts() {
        Game game = game("""
                level 1
                  gold 200
                  lives 20
                  wave orc 3 0.05
                """,
                "..........",
                "L.......CC",
                "..........");
        game.build(5, 0, game.rules().tower("cannon"));
        game.callWave();
        Event.Burst burst = null;
        for (int i = 0; i < 6 * 120 && burst == null; i++) {
            game.update(STEP);
            for (Event e : game.events()) if (e instanceof Event.Burst b) burst = b;
        }
        assertTrue(burst != null);
        long hurt = game.creeps().stream().filter(c -> c.health() < c.maxHealth()).count();
        assertEquals(3, hurt, "three orcs close together, one ball");
    }
amade-towers-game · test/domain/GameTest.java
    @Test
    void frostSlowsWhatIsInRange() {
        Game game = game(ONE_ORC,
                "..........",
                "L.......CC",
                "..........");
        game.build(3, 0, game.rules().tower("frost"));
        game.callWave();
        run(game, 2.0);
        Creep orc = game.creeps().getFirst();
        assertTrue(orc.cold());
        assertEquals(0.5, orc.slow(), "it keeps half its speed");
        double x = orc.x();
        double y = orc.y();
        game.update(STEP);
        assertEquals(0.5 * 20 / 120, Math.hypot(orc.x() - x, orc.y() - y), 1e-9, "half a tile a second");
    }

Gold, waves and the end

The first wave comes after 30 seconds, every later one 15 seconds after the last enemy of the one before has left its lair. The player may call a wave early, and is paid for it:

amade-towers-game · domain/Game.java
    /**
     * The next wave now. Called while the pause before it runs, it brings a gold piece for each second
     * left; called while the wave before is still coming, nothing but more enemies at once.
     */
    public void callWave() {
        if (phase != Phase.PLAYING || wavesStarted >= plan.waves().size()) return;
        int bonus = countingDown ? (int) Math.ceil(countdown) : 0;
        gold += bonus;
        startWave(bonus, commanded);
    }
amade-towers-game · test/domain/GameTest.java
    @Test
    void upgradesCostAndSellingBringsBackPartOfAllThatWasPaid() {
        Game game = game(ONE_ORC,
                "..........",
                "L.......CC",
                "..........");
        game.build(4, 0, game.rules().tower("cannon"));
        Tower cannon = game.tower(4, 0);
        assertEquals(140, game.gold());
        assertTrue(game.upgrade(cannon));
        assertEquals("cannon-2", cannon.type().id());
        assertEquals(2, cannon.type().stage());
        assertEquals(85, game.gold(), "55 for the second stage");
        assertFalse(game.upgrade(cannon), "the third costs 95");
        game.sell(cannon);
        assertEquals(85 + 80, game.gold(), "70 % of 60 + 55");
        assertEquals(null, game.tower(4, 0));
    }

    @Test
    void callingTheNextWaveEarlyBringsGold() {
        Game game = game("""
                level 1
                  gold 0
                  lives 20
                  wave orc 1 1
                  wave orc 1 1
                """,
                "L.......CC");
        run(game, 10.5);
        game.callWave();
        assertEquals(20, game.gold(), "19.5 of the first 30 seconds were left: 20 gold");
        run(game, 1);
        assertEquals(Field.BREAK_SECONDS - 1, game.countdown().orElseThrow(), 0.02, "the pause before the second");
    }
amade-towers-game · test/domain/GameTest.java
    @Test
    void theLevelIsWonWhenAllWavesAreBeatenAndLostWithTheLastLife() {
        Game won = game(ONE_ORC,
                "L.......CC",
                "..........");
        won.build(3, 1, won.rules().tower("archer"));
        won.build(5, 1, won.rules().tower("archer"));
        won.callWave();
        run(won, 10);
        assertEquals(Game.Phase.WON, won.phase());
        assertEquals(3, won.stars(), "no life lost");

        Game lost = game("""
                level 1
                  gold 0
                  lives 2
                  wave troll 1 1
                """, "L...CC");
        lost.callWave();
        run(lost, 10);
        assertEquals(Game.Phase.LOST, lost.phase(), "a troll costs three lives");
        assertEquals(0, lost.lives());
    }

Everything that happens reaches the user interface as events, as in Blast and Jump:

amade-towers-game · domain/Event.java
public sealed interface Event {
    record Built(Tower tower) implements Event {
    }

    record Upgraded(Tower tower) implements Event {
    }

    record Sold(Tower tower, int refund) implements Event {
    }

    /** An arrow or a ball leaves a turret. */
    record Fired(Tower tower) implements Event {
    }

    /** A wave of cold around a frost tower. */
    record Frosted(Tower tower) implements Event {
    }

    record ArrowHit(double x, double y) implements Event {
    }

    /** A cannon ball bursts; {@code radius} in units. */
    record Burst(double x, double y, double radius) implements Event {
    }

    record Killed(Creep creep, int bounty) implements Event {
    }

    /** A unit reached the castle. */
    record Leaked(Creep creep, int harm) implements Event {
    }

    /** {@code number} counts from 1; {@code bonus} is the gold for calling it before its time. */
    record WaveStarted(int number, int bonus) implements Event {
    }

    record Won(int stars) implements Event {
    }

    record Lost() implements Event {
    }
}

Can every level be won?

Jump proved with a search that each level can be finished. A tower defence level has too many ways to play for a search, but a test can play it: a simple player that spends its gold once a second, mostly on archers, sometimes a cannon or a frost tower, each on the free tile near the trail that covers the most of the way or makes it longest, and upgrades when it has ten towers.

amade-towers-game · test/domain/Defender.java
public final class Defender {
    static final double STEP = 1.0 / 120;

    private final Game game;

    public Defender(Game game) {
        this.game = game;
    }

    /** Plays the level to its end; at most an hour of game time. */
    Game play() {
        for (int step = 0; step < 3600 * 120 && game.phase() == Game.Phase.PLAYING; step++) {
            if (step % 120 == 0) spend();
            game.update(STEP);
        }
        return game;
    }

    /** Spends the gold, as the test player does once a second: for screenshots of a battle. */
    public void spend() {
        while (true) {
            TowerType kind = nextKind();
            if (game.gold() >= kind.cost() && buildBest(kind)) continue;
            if (game.towers().size() >= 10 && upgradeCheapest()) continue;
            return;
        }
    }

    /** Mostly archers, a cannon in every third tower from the third wave on, a frost tower in every seventh. */
    private TowerType nextKind() {
        int n = game.towers().size();
        if (n % 7 == 6) return game.rules().tower("frost");
        if (game.wavesStarted() >= 3 && n % 3 == 2) return game.rules().tower("cannon");
        return game.rules().tower("archer");
    }
amade-towers-game · test/domain/LevelTest.java
    @Test
    void aSimplePlayerWinsEveryLevelOnTheRoadAndOnOpenGround() {
        for (Mode mode : Mode.values()) {
            for (int number = 1; number <= Level.FILES.size(); number++) {
                Game game = new Defender(new Game(Level.load(number, rules), rules, mode)).play();
                System.out.printf("%s level %d: %s with %d of %d lives, %d towers, %.0f s%n", mode, number,
                        game.phase(), game.lives(), game.level().plan().lives(), game.towers().size(), game.time());
                assertEquals(Game.Phase.WON, game.phase(), mode + " level " + number + ": " + game.lives() + " lives");
            }
        }
    }

The simple player builds no maze; it only puts towers beside the way. If it wins, a player who thinks wins too. When a number in units.txt makes the trolls too strong, this test fails before anyone has played. A simpler cousin of it, Autobuild, builds the towers of the battle behind the title screen.

Step 5: The mouse in the world

Until now the mouse was a pointer on a screen of fixed size: the paddle of Bricks, the aim in Rocks. In Towers it points into a world larger than the screen, below a bar, through a camera that moves.

The engine's Mouse has done the first conversion since chapter 21: from the window's pixels to the canvas's logical units, whatever the window's size, the black bars and the render mode. It collects what the Swing thread sees and hands it to the game loop once per step, like the keyboard: moved, pressed, released, held, inside the canvas or not.

amade-game-engine · input/Mouse.java
public final class Mouse {
    private double x = Double.NaN;
    private double y = Double.NaN;
    private boolean movedSincePoll;
    private boolean moved;
    private int pressedSincePoll;   // one bit per button
    private int pressed;
    private int downNow;     // one bit per button, as the Swing thread last saw it
    private int down;
    private int releasedSincePoll;
    private int released;
    private boolean insideNow;
    private boolean inside;
    private int wheelSincePoll;
    private int wheel;

    /** Listens to the canvas; positions are converted the way the canvas currently draws. */
    public void attach(GameCanvas canvas) {
        MouseAdapter listener = new MouseAdapter() {
            @Override
            public void mouseMoved(MouseEvent e) {
                Point2D.Double p = canvas.toLogical(e.getX(), e.getY());
                moved(p.x, p.y);
            }

            @Override
            public void mouseDragged(MouseEvent e) {
                mouseMoved(e);
            }

            @Override
            public void mousePressed(MouseEvent e) {
                canvas.requestFocusInWindow();   // a click into the game gives the keys to the game, too
                pressed(e.getButton());
            }

            @Override
            public void mouseReleased(MouseEvent e) {
                released(e.getButton());
            }

            @Override
            public void mouseEntered(MouseEvent e) {
                mouseMoved(e);
            }

            @Override
            public void mouseExited(MouseEvent e) {
                exited();
            }

            @Override
            public void mouseWheelMoved(MouseWheelEvent e) {
                wheeled(e.getWheelRotation());
            }
        };
        canvas.addMouseListener(listener);
        canvas.addMouseMotionListener(listener);
        canvas.addMouseWheelListener(listener);
    }

    public synchronized void moved(double x, double y) {
        this.x = x;
        this.y = y;
        movedSincePoll = true;
        insideNow = true;
    }

    /** The mouse left the canvas: for a game that scrolls while the mouse is at the edge, a reason to stop. */
    public synchronized void exited() {
        insideNow = false;
    }

    public synchronized void pressed(int button) {
        if (button > 0 && button < 32) {
            pressedSincePoll |= 1 << button;
            downNow |= 1 << button;
        }
    }

    public synchronized void released(int button) {
        if (button > 0 && button < 32) {
            downNow &= ~(1 << button);
            releasedSincePoll |= 1 << button;
        }
    }

    /** The wheel turned by {@code notches}: positive towards the player, negative away. */
    public synchronized void wheeled(int notches) {
        wheelSincePoll += notches;
    }

    /** Call once per update step on the game loop thread, before asking. */
    public synchronized void poll() {
        moved = movedSincePoll;
        pressed = pressedSincePoll;
        down = downNow;
        released = releasedSincePoll;
        inside = insideNow;
        wheel = wheelSincePoll;
        wheelSincePoll = 0;
        movedSincePoll = false;
        pressedSincePoll = 0;
        releasedSincePoll = 0;
    }

    /** The last position in logical coordinates; {@code NaN} until the mouse has moved over the canvas. */
    public synchronized double x() {
        return x;
    }

    public synchronized double y() {
        return y;
    }

    /** True if the mouse moved between the last two polls. */
    public synchronized boolean wasMoved() {
        return moved;
    }

    /** True if the button ({@link MouseEvent#BUTTON1} …) went down between the last two polls. */
    public synchronized boolean wasPressed(int button) {
        return button > 0 && button < 32 && (pressed & 1 << button) != 0;
    }

    /** True if the button came up between the last two polls: the end of a drag. */
    public synchronized boolean wasReleased(int button) {
        return button > 0 && button < 32 && (released & 1 << button) != 0;
    }

    /** How many notches the wheel turned between the last two polls: positive towards the player, negative away, 0 if not at all. */
    public synchronized int wheel() {
        return wheel;
    }

    /** True if the mouse was over the canvas at the last poll. */
    public synchronized boolean isInside() {
        return inside;
    }

    /** True if the button was held down at the last poll: for actions that last as long as it is held. */
    public synchronized boolean isDown(int button) {
        return button > 0 && button < 32 && (down & 1 << button) != 0;
    }
}

The second conversion is the camera's: from the screen into the world. The camera of chapter 27 knew where it stood in the world; now it also knows where its view stands on the screen, below the bar (placedAt(0, 22)). A point of the screen is in the world at the camera's position plus its place in the view:

amade-game-engine · camera/Camera.java
    /**
     * Where a point of the screen, such as the mouse, lies in the world: the camera's position plus the
     * point's place in the view. Screen coordinates are those of the canvas, logical, so the window's size,
     * the black bars and the render mode are already taken care of ({@code Mouse}). The answer is for the
     * camera after the last step, where the rules live; the picture may lag a fraction of a step behind.
     */
    public double worldX(double screenX) {
        return x + screenX - screenLeft;
    }

    public double worldY(double screenY) {
        return y + screenY - screenTop;
    }

    /** Where a point of the world is drawn on the screen, between the last two steps. */
    public double screenX(double worldX, double alpha) {
        return worldX - x(alpha) + screenLeft;
    }

    public double screenY(double worldY, double alpha) {
        return worldY - y(alpha) + screenTop;
    }

    /** True if a point of the screen lies in the view, not on a bar beside it. */
    public boolean shows(double screenX, double screenY) {
        return screenX >= screenLeft && screenX < screenLeft + viewWidth
                && screenY >= screenTop && screenY < screenTop + viewHeight;
    }
amade-game-engine · test/camera/CameraTest.java
    @Test
    void theMousePointsIntoTheWorldThroughTheCamera() {
        // A map of 800 × 480 below a bar of 22: the view is 640 × 378, from y = 22 on the screen.
        Camera camera = new Camera(640, 378, 800, 480).placedAt(0, 22);
        camera.moveBy(100, 50);
        assertEquals(100, camera.x(), 1e-9);
        assertEquals(415, camera.worldX(315), 1e-9, "the mouse at 315 on the screen, 100 into the world");
        assertEquals(50 + 200 - 22, camera.worldY(200), 1e-9, "the bar is not part of the view");
        assertEquals(315, camera.screenX(415, 1), 1e-9, "and back");
        assertTrue(camera.shows(315, 200));
        assertFalse(camera.shows(315, 10), "on the bar");
    }

    @Test
    void theViewIsMovedByThePlayerAsFarAsTheEdges() {
        Camera camera = new Camera(640, 378, 800, 480);
        camera.moveBy(-30, 0);
        assertEquals(0, camera.x(), 1e-9, "not left of the world");
        camera.moveBy(500, 500);
        assertEquals(160, camera.x(), 1e-9, "800 - 640");
        assertEquals(102, camera.y(), 1e-9, "480 - 378");
        camera.moveBy(0, 0);
        assertEquals(160, camera.x(0), 1e-9, "a step without moving: no drift between the steps");
    }

worldX uses the camera after the last step, where the rules are, not the picture drawn between two steps. The difference is a fraction of a step, less than a unit; using the picture's position would let a click land on another tile than the one the rules think of.

In Jump the camera followed the player. In Towers there is nobody to follow: the player moves the camera. moveBy does it as far as the edges allow, and must be called in every step, also with 0, 0, so that the position before the step is kept for drawing in between:

amade-game-engine · camera/Camera.java
    /**
     * One update step of a camera that the player moves, by keys, the mouse at the edge of the screen or
     * dragging the map: by {@code dx}, {@code dy} in the world, as far as the edges allow. Call it in every
     * step, with 0, 0 when nothing moves, so that drawing between two steps does not drift.
     */
    public void moveBy(double dx, double dy) {
        previousX = x;
        previousY = y;
        x = clampX(x + dx);
        y = clampY(y + dy);
    }

The play scene moves it three ways: by the arrow keys, by the mouse at the edge of the screen, and by dragging the map with the right or middle button held, the way strategy games do. A drag moves the map by exactly as far as the mouse moved, so the point under the mouse stays under it. A click on the bar is the bar's; a click on the map is on a tile:

amade-towers-game · ui/game/TowersGame.java
        private void input(double seconds) {
            double mx = mouse.x();
            double my = mouse.y();
            boolean onMap = !Double.isNaN(mx) && camera.shows(mx, my) && !menu.contains(mx, my);
            hover = onMap ? cellAt(mx, my) : null;
            if (menu.isOpen() && mouse.wasMoved()) menu.hover(mx, my);

            if (controls.wasPressed(Action.BACK)) closeMenu();
            if (controls.wasPressed(Action.NEXT)) game.callWave();
            if (controls.wasPressed(Action.FAST)) fast = !fast;
            if (controls.wasPressed(Action.FLOW)) flow = !flow;
            if (menu.isOpen()) {
                if (controls.wasPressed(Action.UP)) menu.move(-1);
                if (controls.wasPressed(Action.DOWN)) menu.move(1);
                if (controls.wasPressed(Action.OK)) carryOut(menu.chooseHighlighted());
                if (controls.wasPressed(Action.ONE)) carryOut(menu.key(1));
                if (controls.wasPressed(Action.TWO)) carryOut(menu.key(2));
                if (controls.wasPressed(Action.THREE)) carryOut(menu.key(3));
            }

            if (mouse.wasPressed(MouseEvent.BUTTON1)) click(mx, my);
            boolean dragging = mouse.isDown(MouseEvent.BUTTON3) || mouse.isDown(MouseEvent.BUTTON2);
            if (mouse.wasPressed(MouseEvent.BUTTON3) || mouse.wasPressed(MouseEvent.BUTTON2)) closeMenu();

            // The map moves by the keys, by the edge of the screen, and by dragging it along.
            double dx = 0;
            double dy = 0;
            if (!menu.isOpen()) {
                dx += controls.axis(Action.LEFT, Action.RIGHT) * PAN_SPEED * seconds;
                dy += controls.axis(Action.UP, Action.DOWN) * PAN_SPEED * seconds;
            }
            if (mouse.isInside() && !dragging && !Double.isNaN(mx)) {
                if (mx < EDGE) dx -= PAN_SPEED * seconds;
                if (mx > width - EDGE) dx += PAN_SPEED * seconds;
                if (my > Field.HEIGHT - EDGE) dy += PAN_SPEED * seconds;
            }
            if (dragging && !Double.isNaN(dragX)) {
                dx += dragX - mx;
                dy += dragY - my;
            }
            dragX = dragging ? mx : Double.NaN;
            dragY = dragging ? my : Double.NaN;
            double x = camera.x();
            double y = camera.y();
            camera.moveBy(dx, dy);
            if (camera.x() != x || camera.y() != y) closeMenu();
        }

        /** The tile under a point of the screen. */
        private Cell cellAt(double screenX, double screenY) {
            int col = (int) Math.floor(camera.worldX(screenX) / Field.TILE);
            int row = (int) Math.floor(camera.worldY(screenY) / Field.TILE);
            return game.level().contains(col, row) ? new Cell(col, row) : null;
        }

        /** A left click: on the bar's buttons, on the open menu, on a tower, or on the grass. */
        private void click(double mx, double my) {
            if (my < Field.BAR) {
                if (hud.nextButton.contains(mx, my)) game.callWave();
                if (hud.fastButton.contains(mx, my)) fast = !fast;
                return;
            }
            if (menu.isOpen()) {
                boolean inside = menu.contains(mx, my);
                carryOut(menu.click(mx, my));
                if (inside) return;
                closeMenu();
            }
            if (hover == null) return;
            Tower tower = game.tower(hover.col(), hover.row());
            if (tower != null) openTowerMenu(tower);
            else if (game.placement(hover.col(), hover.row()) == Game.Placement.NOT_HERE) effects.no();
            else openBuildMenu(hover);
        }

The top edge does not scroll: the bar is there, and a mouse on its buttons should not move the map. A camera that moves closes an open menu, which would otherwise point at a tile that has moved away.

A wider screen, a wider view

The canvas of chapter 20 keeps the logical 640 × 400 and fills the rest of the screen with black bars. On a 16:9 monitor in full screen that leaves 96 pixels black on each side, and the mouse that should scroll the map at the edge of the screen finds the edge of the picture somewhere in between. Paddle must keep its court; a battlefield need not: it is larger than the screen anyway, and a wider screen can simply show more of it.

So the canvas may widen. The game tells it how far, and from then on the height stays at 400 while the width grows until the picture fills the screen:

amade-game-engine · swing/GameCanvas.java
    /**
     * The logical width of the current frame: the width the canvas was made with, or more on a wide screen
     * if the canvas may {@linkplain #setMaxLogicalWidth widen}. The game loop thread sees the same width in
     * the update steps as in the frame drawn after them.
     */
    public int logicalWidth() {
        return logicalWidth;
    }

    /** The width the canvas was made with: the narrowest the picture gets. */
    public int minLogicalWidth() {
        return minLogicalWidth;
    }

    public int maxLogicalWidth() {
        return maxLogicalWidth;
    }

    /**
     * Lets the logical picture grow wider than the width the canvas was made with, up to {@code maxWidth},
     * to fill a wide screen instead of leaving black bars beside it. Only for a game that can show more
     * of its world, such as a scrolling map; a fixed playing field keeps its bars. Takes effect with the
     * next frame.
     */
    public void setMaxLogicalWidth(int maxWidth) {
        maxLogicalWidth = Math.max(minLogicalWidth, maxWidth);
    }
amade-game-engine · swing/GameCanvas.java
    /**
     * The logical width for a screen of {@code width} × {@code height} pixels: the height decides the scale
     * ({@link #wholeScale whole} in pixel mode), and the width is as much as that scale fits on the screen,
     * between {@code minWidth} and {@code maxWidth}.
     */
    public static int widthFor(int width, int height, int minWidth, int maxWidth, int logicalHeight,
                               RenderMode mode) {
        if (maxWidth <= minWidth || width <= 0 || height <= 0) return minWidth;
        double scale = mode == RenderMode.PIXEL
                ? wholeScale(width, height, minWidth, logicalHeight)
                : scale(width, height, minWidth, logicalHeight);
        // Whole logical units, rounded down: the picture is at most a scale's width narrower than the screen.
        return Math.clamp((int) Math.floor(width / scale + 1e-9), minWidth, maxWidth);
    }

At 1920 × 1080 the scale is 1080 / 400 = 2.7 and the width 1920 / 2.7 = 711 units; in pixel mode the scale is a whole 2 and the width 960. The width is set once per frame, on the loop thread, so the update step and the frame after it see the same one. The mouse is converted with the same width, so its x runs from 0 at the left edge of the screen to 711 at the right.

The game reads the width in every update step and hands it on. Towers caps it at 720, the width of its smallest battlefield, so that no screen shows more than a whole battlefield and black beside it:

amade-towers-game · domain/Field.java
    /**
     * How wide the screen may grow on a wide monitor: the map gets the extra width, up to the width of the
     * smallest battlefield, so that no screen shows more than a whole battlefield.
     */
    public static final int MAX_WIDTH = 720;

Three things follow the width. The camera gets a wider view, with its middle where it was (resize); the bar puts its buttons at the right edge instead of at 640; and the build menu may open as far right as the screen goes (OverlayMenu.setBounds). Everything else, the tiles, the towers, the mouse on the map, already works in world coordinates through the camera.

amade-game-engine · camera/Camera.java
    /**
     * Gives the view a new size, e.g. when the canvas {@linkplain eu.amadeos.gameengine.swing.GameCanvas#logicalWidth()
     * widens} on a wide screen. The middle of the view stays where it was, as far as the edges of the world
     * allow; a size that has not changed changes nothing. Call it before {@link #moveBy} or {@link #follow}
     * in an update step.
     */
    public void resize(double width, double height) {
        if (width <= 0 || height <= 0) throw new IllegalArgumentException("empty view");
        if (width == viewWidth && height == viewHeight) return;
        double middleX = x + viewWidth / 2;
        double middleY = y + viewHeight / 2;
        double previousMiddleX = previousX + viewWidth / 2;
        double previousMiddleY = previousY + viewHeight / 2;
        viewWidth = width;
        viewHeight = height;
        deadWidth = Math.min(deadWidth, width);
        deadHeight = Math.min(deadHeight, height);
        x = clampX(middleX - width / 2);
        y = clampY(middleY - height / 2);
        previousX = clampX(previousMiddleX - width / 2);
        previousY = clampY(previousMiddleY - height / 2);
    }
Test: a 16:9 screen
amade-towers-game · test/ui/game/TowersGameTest.java
    @Test
    void aWideScreenShowsMoreMapAndKeepsTheButtonsAtTheRightEdge() {
        game.resize(711);                       // 16:9: 711 × 400
        TowersGame.PlayScene play = game.play(1);
        steps(2);
        assertEquals(711, play.camera().viewWidth(), 1e-9, "the map gets the extra width");
        mouse.moved(711 - 42 - 184 + 60, 10);   // on "next wave", 71 units right of where 640 has it
        steps(1);
        click();
        assertEquals(1, play.game().wavesStarted());
        play.camera().jumpTo(0, 0);
        mouse.moved(710, 200);                  // the real right edge of the screen moves the map
        double x = play.camera().x();
        steps(10);
        assertTrue(play.camera().x() > x);
    }

Step 6: Menus over the game

A click on grass opens the build menu beside the tile: a row per tower, with its picture, name and price. A click on a tower opens its menu: upgrade, with the price of the next stage, or sell. These menus are not Swing menus. They are drawn by the game, over the game, in the game's style, and they work in full screen.

The menu of a cannon

The engine's OverlayMenu is such a menu. It is given the part of the screen it may cover, and opens beside the box of the thing it is about: to its right, or to its left when there is no room, and moved up or down to stay inside.

amade-game-engine · menu/OverlayMenu.java
    /**
     * Opens the menu beside the box {@code anchorX}, {@code anchorY}, {@code anchorWidth} ×
     * {@code anchorHeight}, the thing it is about: to its right, vertically centred on it; to its left when
     * there is no room on the right; and moved up or down as far as needed to stay inside the bounds. The
     * first row that can be chosen is highlighted, so Enter chooses it.
     */
    public void open(String title, List<Item<T>> items, double anchorX, double anchorY,
                     double anchorWidth, double anchorHeight) {
        if (items.isEmpty()) throw new IllegalArgumentException("a menu needs something to choose");
        this.title = title;
        this.items = List.copyOf(items);
        double height = height();
        x = anchorX + anchorWidth + GAP;
        if (x + width > boundsRight) x = anchorX - GAP - width;
        x = Math.clamp(x, boundsLeft, Math.max(boundsLeft, boundsRight - width));
        y = anchorY + anchorHeight / 2 - height / 2;
        y = Math.clamp(y, boundsTop, Math.max(boundsTop, boundsBottom - height));
        highlighted = -1;
        move(1);
        openness = 0;
        open = true;
    }
amade-game-engine · test/menu/OverlayMenuTest.java
    @Test
    void theMenuOpensBesideItsTileAndStaysOnTheScreen() {
        menu.open("Build", towers, 420, 222, 20, 20);
        assertEquals(420 + 20 + 8, menu.x(), "to the right of the tile");
        assertEquals(232 - 94 / 2.0, menu.y(), "centred on it: 94 high");
        menu.open("Build", towers, 600, 222, 20, 20);
        assertEquals(600 - 8 - 132, menu.x(), "no room on the right: to the left");
        menu.open("Build", towers, 300, 30, 20, 20);
        assertEquals(22, menu.y(), "not over the bar at the top");
        menu.open("Build", towers, 300, 380, 20, 20);
        assertEquals(400 - 94, menu.y(), "not below the screen");
    }

A row can be shown and not chosen: a tower the player cannot pay for is grey, its price red. The mouse highlights the row under it; a click chooses it; a click outside closes the menu, as every menu does. The keys work as well: up and down move the highlight past the rows that cannot be chosen, Enter chooses, and 1, 2, 3 choose a row directly, so the player who knows the menu never needs to aim:

amade-game-engine · menu/OverlayMenu.java
    /** The row at a point, or -1. */
    public int rowAt(double px, double py) {
        if (!contains(px, py) || py < y + titleHeight) return -1;
        return (int) ((py - y - titleHeight) / rowHeight);
    }

    /** The mouse moved: the row under it is highlighted. Off the menu, the highlight stays where it was. */
    public void hover(double px, double py) {
        int row = rowAt(px, py);
        if (row >= 0) highlighted = row;
    }

    /**
     * A click: on a row that can be chosen, its value, and the menu closes. Elsewhere on the menu nothing
     * happens. Outside the menu it closes without a choice, as a menu does.
     */
    public T click(double px, double py) {
        if (!open) return null;
        if (!contains(px, py)) {
            close();
            return null;
        }
        int row = rowAt(px, py);
        return row >= 0 ? choose(row) : null;
    }

    /** A shortcut, 1 for the first row: the keys 1, 2, 3 of a build menu. */
    public T key(int number) {
        return open && number >= 1 && number <= items.size() ? choose(number - 1) : null;
    }

    /** The highlight to the next row that can be chosen, {@code +1} down, {@code -1} up; it wraps round. */
    public void move(int direction) {
        int n = items.size();
        int from = highlighted >= 0 ? highlighted : direction > 0 ? -1 : n;
        for (int i = 1; i <= n; i++) {
            int row = Math.floorMod(from + direction * i, n);
            if (items.get(row).enabled()) {
                highlighted = row;
                return;
            }
        }
    }

    /** Enter: the highlighted row. */
    public T chooseHighlighted() {
        return open && highlighted >= 0 ? choose(highlighted) : null;
    }

    private T choose(int row) {
        Item<T> item = items.get(row);
        if (!item.enabled()) return null;
        close();
        return item.value();
    }
amade-game-engine · test/menu/OverlayMenuTest.java
    @Test
    void theMouseAndTheKeysChooseWhatCanBeChosen() {
        menu.open("Build", towers, 100, 200, 20, 20);
        double left = menu.x() + 20;
        double row = menu.y() + 16 + 13;            // the middle of the first row
        menu.hover(left, row + 26);
        assertEquals(1, menu.highlighted(), "the archer under the mouse");
        assertNull(menu.click(left, row + 52), "the frost tower is too dear");
        assertTrue(menu.isOpen());
        assertNull(menu.click(left, menu.y() + 5), "on the title: nothing");
        assertEquals("archer", menu.click(left, row + 26));
        assertFalse(menu.isOpen(), "a choice closes the menu");

        menu.open("Build", towers, 100, 200, 20, 20);
        assertNull(menu.key(3), "3 is the frost tower");
        assertEquals("cannon", menu.key(1));

        menu.open("Build", towers, 100, 200, 20, 20);
        assertEquals(0, menu.highlighted(), "the first that can be chosen");
        menu.move(-1);
        assertEquals(1, menu.highlighted(), "up from the first wraps round, past the frost tower");
        assertEquals("archer", menu.chooseHighlighted());

        menu.open("Build", towers, 100, 200, 20, 20);
        assertNull(menu.click(10, 390), "outside");
        assertFalse(menu.isOpen(), "a click outside closes it");
    }

The menu holds values of any type T. Towers gives it orders: build this tower, upgrade, sell. A sealed interface of records, and a switch carries them out:

amade-towers-game · ui/game/TowersGame.java
        private void openBuildMenu(Cell cell) {
            List<OverlayMenu.Item<Order>> items = new ArrayList<>();
            for (TowerType type : rules.buildable()) {
                items.add(new OverlayMenu.Item<>(new Build(type), painter.pictures().icon(type.kind(), 1),
                        msg.get("tower." + type.kind()), Integer.toString(type.cost()), game.gold() >= type.cost()));
            }
            building = cell;
            selected = null;
            open(msg.get("menu.build"), items, cell);
        }

        private void openTowerMenu(Tower tower) {
            TowerType type = tower.type();
            TowerType next = rules.upgrade(type);
            List<OverlayMenu.Item<Order>> items = new ArrayList<>();
            if (next != null) {
                items.add(new OverlayMenu.Item<>(new Upgrade(), painter.pictures().icon(type.kind(), next.stage()),
                        msg.get("menu.upgrade"), Integer.toString(next.cost()), game.gold() >= next.cost()));
            } else {
                items.add(new OverlayMenu.Item<>(new Upgrade(), painter.pictures().icon(type.kind(), type.stage()),
                        msg.get("menu.top"), "", false));
            }
            items.add(new OverlayMenu.Item<>(new Sell(), null, msg.get("menu.sell"), "+" + tower.refund(), true));
            selected = tower;
            building = null;
            open(msg.format("menu.tower", msg.get("tower." + type.kind()), stage(type.stage())), items, tower.cell());
        }

        private void open(String title, List<OverlayMenu.Item<Order>> items, Cell cell) {
            double x = camera.screenX(cell.col() * Field.TILE, 1);
            double y = camera.screenY(cell.row() * Field.TILE, 1);
            menu.open(title, items, x, y, Field.TILE, Field.TILE);
            effects.tick();
        }

        /** What a menu row does; nothing for {@code null}, a click beside the rows. */
        private void carryOut(Order order) {
            switch (order) {
                case null -> {
                    return;
                }
                case Build(TowerType type) -> {
                    Game.Placement result = game.build(building.col(), building.row(), type);
                    if (result != Game.Placement.OK) refuse(result);
                }
                case Upgrade upgrade -> {
                    if (!game.upgrade(selected)) refuse(Game.Placement.TOO_DEAR);
                }
                case Sell sell -> game.sell(selected);
            }
            closeMenu();
        }

When the rules refuse a tower, the player is told why, in a short line over the map that fades: not enough gold, an enemy stands there, or the tower would close the last way.

amade-towers-game · test/ui/game/TowersGameTest.java
    @Test
    void aClickOnTheGrassOpensTheBuildMenuAndItsFirstRowBuildsAnArcher() {
        TowersGame.PlayScene play = game.play(1);
        Cell cell = free(play);
        clickOn(play, cell);
        assertTrue(play.menu().isOpen());
        assertEquals(3, play.menu().items().size(), "archer, cannon, frost");
        int gold = play.game().gold();
        tap(KeyEvent.VK_1);
        Tower tower = play.game().tower(cell.col(), cell.row());
        assertNotNull(tower);
        assertEquals("archer", tower.type().id());
        assertEquals(gold - 40, play.game().gold());
        assertFalse(play.menu().isOpen());
    }

    @Test
    void aClickOnATowerOpensItsMenuToUpgradeOrSellIt() {
        TowersGame.PlayScene play = game.play(1);
        Cell cell = free(play);
        play.game().build(cell.col(), cell.row(), game.rules().tower("archer"));
        clickOn(play, cell);
        assertTrue(play.menu().isOpen());
        assertInstanceOf(TowersGame.Upgrade.class, play.menu().items().get(0).value());
        tap(KeyEvent.VK_ENTER);
        assertEquals("archer-2", play.game().tower(cell.col(), cell.row()).type().id());
        clickOn(play, cell);
        tap(KeyEvent.VK_2);
        assertNull(play.game().tower(cell.col(), cell.row()), "sold");
    }
amade-towers-game · test/ui/game/TowersGameTest.java
    @Test
    void aTowerThatWouldCloseTheLastWayIsRefusedWithAReason() {
        Game battle = battle(5000, 20, "orc", 1);
        TowersGame.PlayScene play = game.play(battle);
        // A wall across the meadow; the tile the rules refuse is the last gap.
        Cell gap = null;
        for (int row = 0; row < battle.level().height(); row++) {
            if (battle.build(12, row, game.rules().tower("archer")) == Game.Placement.BLOCKS) gap = new Cell(12, row);
        }
        assertNotNull(gap);
        play.camera().jumpTo(gap.col() * Field.TILE, gap.row() * Field.TILE);
        steps(1);
        clickOn(play, gap);
        tap(KeyEvent.VK_1);
        assertNull(battle.tower(gap.col(), gap.row()));
        assertEquals("That would close the last way to the castle", play.toast());
    }

How the menu looks is a Style: colours, fonts, and a picture before each detail, here a coin before the price. The default is dark wood and brass, readable over any map.

amade-game-engine · menu/OverlayMenu.java
    /** How the menu looks: the panel, the texts, and an optional picture before each detail, e.g. a coin. */
    public record Style(Color top, Color bottom, Color border, Color title, Color text, Color detail,
                        Color disabled, Color disabledDetail, Color highlight, Color shortcut,
                        Font titleFont, Font labelFont, Font detailFont, Font shortcutFont, Sprite detailIcon) {
        /** Dark wood and brass, readable over any map. */
        public static final Style DEFAULT = new Style(new Color(0x2A2420), new Color(0x1A1612), new Color(0x8A6A3A),
                new Color(0xC8A870), new Color(0xF2E8D4), new Color(0xFFE08A), new Color(0x8A7E6C),
                new Color(0xE36B5B), new Color(0x4A3C28), new Color(0x8A7A60),
                new Font(Font.SANS_SERIF, Font.BOLD, 8).deriveFont(8.5f), new Font(Font.SANS_SERIF, Font.BOLD, 10),
                new Font(Font.SANS_SERIF, Font.BOLD, 10), new Font(Font.SANS_SERIF, Font.PLAIN, 7).deriveFont(7.5f),
                null);

        public Style withDetailIcon(Sprite icon) {
            return new Style(top, bottom, border, title, text, detail, disabled, disabledDetail, highlight, shortcut,
                    titleFont, labelFont, detailFont, shortcutFont, icon);
        }
    }

Step 7: Drawing the battlefield

Towers is not pixel art. Its pictures were drawn as vector shapes with Java2D, from a mockup made before any rules existed: grass of soft noise, trees as clusters of shaded circles, towers seen from above with brass bands for their stages, orcs with shoulders and a weapon. Art holds all of these drawings, and Pictures turns them into sprites, drawn once at the size of the screen and copied after that (chapter 21).

amade-towers-game · ui/game/Pictures.java
final class Pictures {
    static final int TURNS = 32;
    static final List<String> KINDS = List.of("archer", "cannon", "frost");
    static final List<String> UNITS = List.of("orc", "wolf", "troll");
    static final int STAGES = 3;
    static final int TREES = 12;
    static final int PINES = 8;
    /** The colours of the flow field's arrows, from near the castle to far away. */
    static final int SHADES = 8;

    private final Map<String, Sprite> bases = new HashMap<>();
    private final Map<String, Sprite> icons = new HashMap<>();
    private final Map<String, Rotations> turrets = new HashMap<>();
    private final Map<String, Rotations> creeps = new HashMap<>();
    private final Map<String, Sprite> creepShadows = new HashMap<>();
    private final Map<String, Sprite> rimes = new HashMap<>();
    final Sprite towerShadow = Sprite.drawn(30, 30, (g, w, h) -> Art.towerShadow(g, 10, 10));
    final Rotations flash = Rotations.of(TURNS, 40, (g, w, h) -> Art.flash(g, w / 2 + 9.6, h / 2));
    final Rotations arrow = Rotations.of(TURNS, 14, (g, w, h) -> Art.arrow(g, w / 2, h / 2));
    final Rotations arrowShadow = Rotations.of(TURNS, 14, (g, w, h) -> Art.arrowShadow(g, w / 2, h / 2));
    final Sprite ball = Sprite.drawn(4, 4, (g, w, h) -> Art.ball(g, 2, 2, 3.4));
    final Sprite ballShadow = Sprite.drawn(6, 6, (g, w, h) -> Art.spot(g, 3, 3, 5, new Color(0, 0, 0, 90)));
    final Sprite coin = Sprite.drawn(7, 7, (g, w, h) -> Art.coin(g, 0, 0, 7));
    final Sprite[] trees = new Sprite[TREES];
    final Sprite[] pines = new Sprite[PINES];
    /** By direction, in the order of {@link Trail}, and by shade. */
    final Sprite[][] flow = new Sprite[8][SHADES];

Turned sprites

A turret turns smoothly, an orc turns where it walks. Turning a picture while drawing it (g.rotate before drawImage) works, but it is slow, and the turned copy is blurred a little. So Rotations draws the picture 32 times, each turned by 11.25° more, and draws the nearest one. At the size of a turret no eye sees the steps.

amade-game-engine · sprite/Rotations.java
public final class Rotations {
    private final Sprite[] sprites;
    private final double size;

    private Rotations(Sprite[] sprites, double size) {
        this.sprites = sprites;
        this.size = size;
    }

    /**
     * {@code steps} sprites of {@code size} × {@code size}, each {@code drawing} turned around the middle by
     * one more step. The drawing points to the right, angle 0, as {@code Vector.ofAngle} does.
     */
    public static Rotations of(int steps, double size, Sprite.Drawing drawing) {
        if (steps < 1) throw new IllegalArgumentException("at least one direction");
        Sprite[] sprites = new Sprite[steps];
        for (int i = 0; i < steps; i++) {
            double angle = 2 * Math.PI * i / steps;
            sprites[i] = Sprite.drawn(size, size, (g, w, h) -> {
                g.rotate(angle, w / 2, h / 2);
                drawing.draw(g, w, h);
            });
        }
        return new Rotations(sprites, size);
    }

    public int steps() {
        return sprites.length;
    }

    /** The direction nearest to {@code angle}, any angle in radians, also negative or above 2π. */
    public int index(double angle) {
        double turns = angle / (2 * Math.PI) * sprites.length;
        return Math.floorMod(Math.round(turns), sprites.length);
    }

    public Sprite at(double angle) {
        return sprites[index(angle)];
    }

    /** Draws the picture nearest to {@code angle} with its middle at {@code x}, {@code y}. */
    public void draw(Graphics2D g, double x, double y, double angle) {
        at(angle).draw(g, x - size / 2, y - size / 2);
    }
}
amade-game-engine · test/sprite/RotationsTest.java
    @Test
    void theNearestDirectionIsShown() {
        // A bar from the middle to the right edge: pointing at angle 0.
        Rotations pointer = Rotations.of(32, 20, (g, w, h) -> {
            g.setColor(Color.WHITE);
            g.fill(new Rectangle2D.Double(w / 2, h / 2 - 2, w / 2, 4));
        });
        assertEquals(0, pointer.index(0.04));
        assertEquals(1, pointer.index(Math.PI / 16 - 0.04), "11.25° per step");
        assertEquals(8, pointer.index(Math.PI / 2));
        assertEquals(24, pointer.index(-Math.PI / 2), "up");
        assertEquals(0, pointer.index(4 * Math.PI), "two whole turns");

        BufferedImage image = new BufferedImage(40, 40, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = image.createGraphics();
        g.scale(2, 2);
        pointer.draw(g, 10, 10, Math.PI / 2 + 0.05);     // pointing down
        g.dispose();
        assertEquals(255, image.getRGB(20, 34) >>> 24, "below the middle");
        assertEquals(0, image.getRGB(34, 20) >>> 24, "not to the right");
    }

The painter draws, from the ground up: the ground, scorch marks of bursts, the flow field if it is shown, the shadows of towers and enemies, the enemies, the towers, then the trees over all of them, so that an orc walking past a tree disappears under its crown. Then health bars, shots, effects and the marks of the mouse.

amade-towers-game · ui/game/TowersPainter.java
    void paint(Graphics2D g, Game game, Terrain terrain, Camera camera, Effects effects, Marks marks, double alpha) {
        Shape clip = g.getClip();
        AffineTransform screen = g.getTransform();
        g.clip(new Rectangle2D.Double(camera.screenLeft(), camera.screenTop(), camera.viewWidth(), camera.viewHeight()));
        g.translate(camera.screenLeft() - camera.x(alpha), camera.screenTop() - camera.y(alpha));
        Level level = game.level();
        // The tiles in view, and one more all round for what reaches over the edge of its tile.
        int fromCol = Math.max(0, (int) Math.floor(camera.x(alpha) / Field.TILE) - 1);
        int fromRow = Math.max(0, (int) Math.floor(camera.y(alpha) / Field.TILE) - 1);
        int toCol = Math.min(level.width() - 1, (int) ((camera.x(alpha) + camera.viewWidth()) / Field.TILE) + 1);
        int toRow = Math.min(level.height() - 1, (int) ((camera.y(alpha) + camera.viewHeight()) / Field.TILE) + 1);

        terrain.render(g, camera, alpha);
        effects.renderGround(g);
        if (marks.flow()) flow(g, game.flow(), fromCol, fromRow, toCol, toRow);
        for (Tower tower : game.towers()) {
            pictures.towerShadow.draw(g, tower.cell().col() * Field.TILE, tower.cell().row() * Field.TILE);
        }
        for (Creep creep : game.creeps()) {
            Sprite shadow = pictures.creepShadow(creep.type().id());
            shadow.draw(g, creep.x(alpha) - shadow.width() / 2, creep.y(alpha) - shadow.height() / 2);
        }
        for (Creep creep : game.creeps()) creep(g, creep, alpha);
        for (Tower tower : game.towers()) tower(g, tower, effects.time(), alpha);
        trees(g, level, fromCol, fromRow, toCol, toRow);
        for (Creep creep : game.creeps()) healthBar(g, creep, alpha);
        for (Shot shot : game.shots()) shot(g, shot, alpha);
        effects.render(g, alpha);
        marks(g, game, marks);
        g.setTransform(screen);
        g.setClip(clip);
    }
amade-towers-game · ui/game/TowersPainter.java
    /** A tower: its base, and the turret turned towards its target, kicked back for a moment after a shot. */
    private void tower(Graphics2D g, Tower tower, double time, double alpha) {
        TowerType type = tower.type();
        double x = tower.x();
        double y = tower.y();
        pictures.base(type.kind(), type.stage()).draw(g, x - 10, y - 10);
        double angle = type.shot() == TowerType.Shot.FROST ? time * CRYSTAL_TURN : tower.angle(alpha);
        double since = tower.sinceShot();
        if (type.shot() == TowerType.Shot.BALL && since < RECOIL_SECONDS) {
            double back = RECOIL * (1 - since / RECOIL_SECONDS);
            x -= Math.cos(angle) * back;
            y -= Math.sin(angle) * back;
        }
        pictures.turret(type.kind()).draw(g, x, y, angle);
        if (type.shot() == TowerType.Shot.BALL && since < FLASH_SECONDS) pictures.flash.draw(g, x, y, angle);
    }

A cannon ball flies in an arc, but the rules move it in a straight line over the ground. The arc is only drawn: the ball higher by a parabola of how far it has flown, its shadow on the ground below.

amade-towers-game · ui/game/TowersPainter.java
    /** An arrow flies low and straight; a cannon ball rises and falls, its shadow on the ground below it. */
    private void shot(Graphics2D g, Shot shot, double alpha) {
        double x = shot.x(alpha);
        double y = shot.y(alpha);
        if (shot.kind() == TowerType.Shot.ARROW) {
            double angle = shot.angle();
            pictures.arrowShadow.draw(g, x + 2, y + 3, angle);
            pictures.arrow.draw(g, x, y, angle);
            return;
        }
        double along = shot.along(alpha);
        double height = ARC * 4 * along * (1 - along);
        pictures.ballShadow.draw(g, x - 3 + height * 0.3, y - 3 + height * 0.35);
        pictures.ball.draw(g, x - 2, y - 2 - height);
    }

The flow field is drawn with F3 as one small arrow sprite per tile, in eight directions and eight shades:

amade-towers-game · ui/game/TowersPainter.java
    /** F3: an arrow on every tile that leads to the castle, white near it and orange far away. */
    private void flow(Graphics2D g, FlowField field, int fromCol, int fromRow, int toCol, int toRow) {
        int far = 1;
        for (int row = 0; row < field.height(); row++) {
            for (int col = 0; col < field.width(); col++) {
                if (field.reaches(col, row)) far = Math.max(far, field.distance(col, row));
            }
        }
        for (int row = fromRow; row <= toRow; row++) {
            for (int col = fromCol; col <= toCol; col++) {
                Cell next = field.next(col, row);
                if (next == null) continue;
                int d = Integer.numberOfTrailingZeros(Trail.bit(next.col() - col, next.row() - row));
                int shade = (int) Math.round((double) field.distance(col, row) / far * (Pictures.SHADES - 1));
                pictures.flow[d][shade].draw(g, col * Field.TILE, row * Field.TILE);
            }
        }
    }

The ground

The ground of a battlefield is the most expensive picture: noise for the grass, ponds with their banks, rocks, the shadows of trees, the lairs and the castle, and the road. The road is the union of its tiles' squares, which has sharp corners, rounded by a trick from image processing: grown by a few units and shrunk again, which rounds the inner corners (a closing), then shrunk and grown again, which rounds the outer ones (an opening). Growing a shape by r is adding the outline drawn with a pen 2r wide; shrinking it is taking that outline away:

amade-towers-game · ui/game/Art.java
    /**
     * The road over {@code tiles}, the union of its tiles' squares: with its corners rounded, a band of trodden
     * grass along its edges, and the bare earth inside.
     */
    static void road(Graphics2D g, Area tiles) {
        if (tiles.isEmpty()) return;
        Area road = opened(closed(tiles, ROAD_ROUNDING), ROAD_ROUNDING);
        g.setColor(DIRT_RIM);
        g.fill(road);
        g.setColor(DIRT);
        g.fill(eroded(road, 2.5));
        g.setColor(DIRT_EDGE);
        g.setStroke(new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        g.draw(road);
    }

    /** Rounds the inner corners of a shape: grown by {@code r} and shrunk again. */
    private static Area closed(Area area, double r) {
        return eroded(grown(area, r), r);
    }

    /** Rounds the outer corners: shrunk by {@code r} and grown again. */
    private static Area opened(Area area, double r) {
        return grown(eroded(area, r), r);
    }

    private static Area grown(Area area, double r) {
        Area grown = new Area(area);
        grown.add(new Area(new BasicStroke((float) (2 * r), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)
                .createStrokedShape(area)));
        return grown;
    }

    private static Area eroded(Area area, double r) {
        Area eroded = new Area(area);
        eroded.subtract(new Area(new BasicStroke((float) (2 * r), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)
                .createStrokedShape(area)));
        return eroded;
    }

On open ground there is no road, and no path either: the grass is plain, and the way shows only in where the enemies walk, and in the arrows of F3. An earlier version of this game drew a trodden trail along the current way, which moved with every tower. It looked like a road, though, and a road says "the enemies keep to me", which on open ground is not true. Maze games mostly leave the ground plain, as Desktop Tower Defense and Fieldrunners do, and let the player read the way from the crowd; others draw the way as a line or arrows. Towers keeps the arrows for F3.

So the ground never changes, and it is drawn once and then only copied. For the meadow, 36 × 22 tiles, one picture of the whole map would do. Terrain cuts the ground into squares of 8 × 8 tiles anyway, each a sprite of its own, because a map may be much larger than the screen: a sprite drawn in code is drawn when it is first shown, so a square the camera does not see is neither drawn nor copied:

amade-towers-game · ui/game/Terrain.java
final class Terrain {
    static final int CHUNK = 8;
    private static final int SIZE = CHUNK * Field.TILE;

    private final Level level;
    private final Mode mode;
    private final BufferedImage grass;
    private final int across;
    private final Sprite[] squares;

    Terrain(Level level, Mode mode) {
        this.level = level;
        this.mode = mode;
        this.grass = Art.grass(level.width() * Field.TILE, level.height() * Field.TILE, level.number());
        this.across = (level.width() + CHUNK - 1) / CHUNK;
        int down = (level.height() + CHUNK - 1) / CHUNK;
        squares = new Sprite[across * down];
        for (int i = 0; i < squares.length; i++) squares[i] = picture(i % across * CHUNK, i / across * CHUNK);
    }

Drawing a square is the same for any square: the part of the grass, the ponds, the road, the rocks and shadows, all of them clipped to the square by the sprite. The tiles one beyond its edge are drawn as well, so that a pond or a road that crosses the edge continues into the next square without a seam:

amade-towers-game · ui/game/Terrain.java
    private void draw(Graphics2D g, int col0, int row0, double x0, double y0, double w, double h) {
        int p = Art.GRASS_PIXELS;
        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        g.drawImage(grass, (int) x0, (int) y0, (int) (x0 + w), (int) (y0 + h),
                (int) x0 * p, (int) y0 * p, (int) (x0 + w) * p, (int) (y0 + h) * p, null);
        int fromCol = col0 - 2;
        int toCol = col0 + CHUNK + 1;
        int fromRow = row0 - 2;
        int toRow = row0 + CHUNK + 1;
        Area pond = new Area();
        List<Double> ripples = new ArrayList<>();
        for (int row = fromRow; row <= toRow; row++) {
            for (int col = fromCol; col <= toCol; col++) {
                if (level.contains(col, row) && level.ground(col, row) == Ground.WATER) {
                    pond.add(new Area(new RoundRectangle2D.Double(col * Field.TILE - 1, row * Field.TILE - 1,
                            Field.TILE + 2, Field.TILE + 2, 12, 12)));
                    ripples.add((double) col * Field.TILE);
                    ripples.add((double) row * Field.TILE);
                }
            }
        }
        if (!pond.isEmpty()) Art.water(g, pond, ripples.stream().mapToDouble(Double::doubleValue).toArray());
        if (mode == Mode.ROAD) Art.road(g, road(fromCol, fromRow, toCol, toRow));
        for (int row = fromRow; row <= toRow; row++) {
            for (int col = fromCol; col <= toCol; col++) {
                if (!level.contains(col, row)) continue;
                double x = col * Field.TILE;
                double y = row * Field.TILE;
                Random random = new Random(col * 7919L + row * 104729L + level.number());
                switch (level.ground(col, row)) {
                    case ROCK -> Art.rock(g, x, y, random);
                    case TREE -> Art.treeShadow(g, x, y, false);
                    case PINE -> Art.treeShadow(g, x, y, true);
                    case ROAD -> {
                        if (mode == Mode.ROAD) Art.pebbles(g, x + 10, y + 10, random);
                    }
                    default -> {
                    }
                }
            }
        }
        for (Cell lair : level.lairs()) Art.lair(g, lair.col() * Field.TILE, (lair.row() + 0.5) * Field.TILE);
        Level.Box castle = level.castleBox();
        Art.castle(g, castle.x() + 3, castle.y() + 3, castle.width() - 6, castle.height() - 6);
    }

In each frame, render copies the squares the camera sees:

amade-towers-game · ui/game/Terrain.java
    /**
     * The squares the camera sees. A square is a unit larger than its tiles, to the right and below, so
     * that rounding to whole pixels never leaves a gap between two; the next square covers the overlap.
     */
    void render(Graphics2D g, Camera camera, double alpha) {
        for (int i = 0; i < squares.length; i++) {
            double x = i % across * CHUNK * Field.TILE;
            double y = i / across * CHUNK * Field.TILE;
            if (camera.sees(x, y, SIZE + 1, SIZE + 1, alpha)) squares[i].draw(g, x, y);
        }
    }

Every pixel of a square is covered: the grass is everywhere. Chapter 27 taught Sprite to look at a picture's pixels and cache it opaque if none is transparent. A picture drawn in code is drawn into a transparent cache, though, and the sprite cannot know in advance that nothing will stay transparent. opaque() is the promise, and with it a square is copied as a plain copy, without blending:

amade-game-engine · sprite/Sprite.java
    /**
     * The same drawing, promised to cover every pixel of its box, such as a whole map's ground: its cache
     * is then opaque and copied without blending, which a picture drawn in code otherwise never is. What
     * the drawing leaves out shows black.
     */
    public Sprite opaque() {
        if (drawing == null) throw new IllegalStateException("an image knows its own transparency");
        return new Sprite(width, height, null, drawing, hard, true);
    }

./gradlew benchmark measures it, as in Jump: on the computer this chapter was written on, which draws in software, a frame takes between 1.7 and 3.6 ms, depending on the battlefield, at 1280 × 800 and at 1920 × 1200.

The ford Two caves

The ford (left), where the road crosses the river. Two caves (right): two lairs, and two roads that meet before the castle.

Step 8: Everything else

The battle runs at double speed with F, by running the step of the rules twice:

amade-towers-game · ui/game/TowersGame.java
    /**
     * A battle. The mouse builds: a click on grass opens the build menu, a click on a tower its menu. The
     * map moves with the keys, the right mouse button held down, or the mouse at the edge of the screen.
     */
    final class PlayScene implements Scene {
        private final Game game;
        private final Camera camera;
        private final Terrain terrain;
        private final OverlayMenu<Order> menu = new OverlayMenu<>(MENU_WIDTH, MENU_ROW, MENU_TITLE,
                4, Field.BAR + 4, Field.WIDTH - 4, Field.HEIGHT - 4);
        /** The tile the build menu is open for, or the tower whose menu is open. */
        private Cell building;
        private Tower selected;
        private Cell hover;
        private boolean fast;
        private boolean flow;
        private double dragX = Double.NaN;
        private double dragY = Double.NaN;
        private String banner;
        private double bannerShown = Double.MAX_VALUE;
        private String toast;
        private double toastShown = Double.MAX_VALUE;
        private double ended;
        private Progress.Record record;

        PlayScene(Level level) {
            this(new Game(level, rules, mode));
        }

        PlayScene(Game battle) {
            game = battle;
            Level level = battle.level();
            camera = TowersGame.this.camera(level);
            terrain = TowersGame.this.terrain(battle);
            Level.Box castle = level.castleBox();
            camera.jumpTo(camera.worldWidth() / 2, castle.y() + castle.height() / 2);
            effects.clear();
            effects.music();
            banner(msg.format("play.level", level.number(), hud.name(level.number())));
        }

        @Override
        public void update(double seconds) {
            if (game.phase() != Game.Phase.PLAYING) {
                ended += seconds;
                if (ended >= END_SECONDS) {
                    scenes.push(new ResultScene(this));
                    return;
                }
            }
            if (controls.wasPressed(Action.PAUSE) || controls.wasPressed(Action.BACK) && !menu.isOpen()) {
                scenes.push(new PauseScene());
                return;
            }
            camera.resize(Field.VIEW_WIDTH + width - Field.WIDTH, Field.VIEW_HEIGHT);
            menu.setBounds(4, Field.BAR + 4, width - 4, Field.HEIGHT - 4);
            input(seconds);
            int times = fast ? 2 : 1;
            for (int i = 0; i < times; i++) step(seconds);
            menu.update(seconds);
            bannerShown += seconds;
            toastShown += seconds;
        }

        /** One step of the rules, what they did, and the ground under it. */
        void step(double seconds) {
            game.update(seconds);
            effects.react(game.events(), true);
            effects.update(seconds);
            for (Event event : game.events()) {
                switch (event) {
                    case Event.WaveStarted started -> banner(started.bonus() > 0
                            ? msg.format("play.wave.early", started.number(), started.bonus())
                            : msg.format("play.wave", started.number()));
                    case Event.Won won -> {
                        record = progress.won(game.level().number(), won.stars(), levels.size());
                        chosen = Math.min(levels.size(), game.level().number() + 1);
                    }
                    default -> {
                    }
                }
            }
            if (selected != null && !game.towers().contains(selected)) selected = null;
        }

The bar shows gold, lives and the wave, and the two buttons the mouse can press:

amade-towers-game · ui/game/Hud.java
    /** The bar at the top: gold, lives, the wave, and the buttons for the next wave and for double speed. */
    void bar(Graphics2D g, Game game, boolean fast, double mouseX, double mouseY) {
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        g.setPaint(new GradientPaint(0, 0, WOOD_TOP, 0, (float) Field.BAR, WOOD_BOTTOM));
        rectangle.setRect(0, 0, width, Field.BAR);
        g.fill(rectangle);
        g.setColor(BRASS);
        rectangle.setRect(0, Field.BAR - 1, width, 1);
        g.fill(rectangle);
        g.setFont(font(12, true));
        Art.coin(g, 10, 5, 12);
        g.setColor(CHALK);
        g.drawString(Integer.toString(game.gold()), 28, 15);
        Art.heart(g, 80, 6, 12);
        g.setColor(game.lives() <= game.level().plan().lives() / 4 ? RED : CHALK);
        g.drawString(Integer.toString(game.lives()), 98, 15);
        g.setColor(CHALK);
        centred(g, msg.format("hud.wave", game.wavesStarted(), game.waves()), width / 2, 15);

        boolean more = game.wavesStarted() < game.waves() && game.phase() == Game.Phase.PLAYING;
        OptionalDouble countdown = game.countdown();
        String next = countdown.isPresent()
                ? msg.format("hud.next.in", (int) Math.ceil(countdown.getAsDouble()))
                : msg.get("hud.next");
        button(g, nextButton, next, more ? CHALK : GREY, more && nextButton.contains(mouseX, mouseY));
        button(g, fastButton, "x2", fast ? GREEN : GREY, fastButton.contains(mouseX, mouseY));
    }

The events become dust where a tower is built, smoke at a cannon's muzzle, bursts with scorch marks that fade over twenty seconds, rings of frost, a floating "+5" over a beaten enemy, sounds, and a march in MML. Sounds are throttled: twenty archers shooting in the same step play one bow, not twenty.

amade-towers-game · ui/game/Effects.java
    /** Shows and, if {@code audible}, plays what happened in the last step. */
    void react(List<Event> events, boolean audible) {
        for (Event event : events) {
            switch (event) {
                case Event.Built built -> {
                    dust(built.tower().x(), built.tower().y(), 14, STONE);
                    if (audible) play(sounds.build);
                }
                case Event.Upgraded upgraded -> {
                    sparkle(upgraded.tower().x(), upgraded.tower().y(), 14, GOLD);
                    if (audible) play(sounds.upgrade);
                }
                case Event.Sold sold -> {
                    dust(sold.tower().x(), sold.tower().y(), 18, STONE);
                    say(sold.tower().x(), sold.tower().y() - 6, "+" + sold.refund(), Hud.GOLD);
                    if (audible) play(sounds.coins);
                }
                case Event.Fired fired -> {
                    Tower tower = fired.tower();
                    if (tower.type().shot() == TowerType.Shot.BALL) {
                        smoke(tower.x() + Math.cos(tower.angle()) * 11, tower.y() + Math.sin(tower.angle()) * 11,
                                tower.angle());
                        if (audible) play(sounds.cannon);
                    } else if (audible) {
                        play(sounds.bow);
                    }
                }
                case Event.Frosted frosted -> {
                    Tower tower = frosted.tower();
                    rings.add(new Ring(tower.x(), tower.y(), tower.type().range() * Field.TILE, time));
                    if (audible) play(sounds.frost);
                }
                case Event.ArrowHit(double x, double y) -> {
                    sparkle(x, y, 2, DIRT);
                    if (audible) play(sounds.hit);
                }
                case Event.Burst(double x, double y, double radius) -> {
                    bursts.add(new Burst(x, y, radius, time));
                    scorches.add(new Scorch(x, y, radius * 0.9, time));
                    if (scorches.size() > MOST_SCORCHES) scorches.removeFirst();
                    sparkle(x, y, 10, DIRT);
                    sparkle(x, y, 6, FIRE);
                    dust(x, y, 8, DARK_SMOKE);
                    if (audible) play(sounds.burst);
                }
                case Event.Killed killed -> {
                    Creep creep = killed.creep();
                    sparkle(creep.x(), creep.y(), 8, BLOOD);
                    say(creep.x(), creep.y() - 8, "+" + killed.bounty(), Hud.GOLD);
                    if (audible) play(sounds.fall);
                }
                case Event.Leaked leaked -> {
                    sparkle(leaked.creep().x(), leaked.creep().y(), 12, BLOOD);
                    say(leaked.creep().x(), leaked.creep().y() - 8, "-" + leaked.harm(), Hud.RED);
                    if (audible) play(sounds.leak);
                }
                case Event.WaveStarted started -> {
                    if (audible) play(sounds.horn);
                }
                case Event.Won won -> {
                    if (audible) mixer.playMusic(music.won, false);
                }
                case Event.Lost lost -> {
                    if (audible) mixer.playMusic(music.lost, false);
                }
            }
        }
    }

The progress is saved with SaveFile (chapter 27): the battlefields open and the most stars won on each. The choice of a battlefield shows them, and can be played with the mouse or with the keys:

The title, with a battle playing behind it The choice of a battlefield

The title (left), with `Autobuild` defending the meadow behind it. The choice (right).
amade-towers-game · ui/game/TowersGame.java
    /** Three cards, one for each battlefield; one opens when the one before it is won. */
    final class ChoiceScene implements Scene {
        private final Map<String, Game> fields = new HashMap<>();
        private final Map<Integer, Camera> cameras = new HashMap<>();
        private double time;

        ChoiceScene() {
            effects.clear();
            chosen = Math.min(chosen, progress.open());
        }

        @Override
        public void update(double seconds) {
            time += seconds;
            if (controls.wasPressed(Action.BACK)) {
                scenes.replace(new TitleScene());
                return;
            }
            int before = chosen;
            int open = Math.min(progress.open(), levels.size());
            if (controls.wasPressed(Action.LEFT)) chosen = Math.max(1, chosen - 1);
            if (controls.wasPressed(Action.RIGHT)) chosen = Math.min(open, chosen + 1);
            int card = cardAt(mouse.x(), mouse.y());
            if (mouse.wasMoved() && card > 0 && card <= open) chosen = card;
            if (chosen != before) effects.tick();
            boolean clicked = mouse.wasPressed(MouseEvent.BUTTON1) && card > 0 && card <= open;
            if (controls.wasPressed(Action.MODE) || mouse.wasPressed(MouseEvent.BUTTON1) && onMode(mouse.y())) {
                mode = mode == Mode.ROAD ? Mode.OPEN : Mode.ROAD;
                settings.setMode(mode);
                effects.tick();
            }
            if (controls.wasPressed(Action.OK) || clicked) scenes.replace(new PlayScene(levels.get(chosen - 1)));
        }

        /** Whether a point's height is that of the line that says how battles are fought. */
        boolean onMode(double y) {
            return Math.abs(y - (MODE_LINE - 4)) <= 10;
        }
amade-towers-game · test/ui/game/TowersGameTest.java
    @Test
    void aWonBattleIsSavedAndOpensTheNextBattlefield() {
        Game battle = battle(1000, 20, "orc", 2);
        TowersGame.PlayScene play = game.play(battle);
        for (int row = 8; row <= 12; row++) battle.build(3, row, game.rules().tower("archer"));
        tap(KeyEvent.VK_N);
        seconds(30);
        assertEquals(Game.Phase.WON, battle.phase());
        assertInstanceOf(TowersGame.ResultScene.class, game.top());
        assertEquals(3, progress.stars(1));
        assertEquals(2, progress.open());
        seconds(1);
        click();
        TowersGame.PlayScene next = assertInstanceOf(TowersGame.PlayScene.class, game.top());
        assertEquals(2, next.game().level().number());
    }

The keys are bound as in the earlier games and can be changed in the settings file:

amade-towers-game · ui/game/TowersGame.java
        controls = new Controls<>(Action.class, keyboard)
                .bind(Action.LEFT, settings.keys("left", KeyEvent.VK_LEFT, KeyEvent.VK_A))
                .bind(Action.RIGHT, settings.keys("right", KeyEvent.VK_RIGHT, KeyEvent.VK_D))
                .bind(Action.UP, settings.keys("up", KeyEvent.VK_UP, KeyEvent.VK_W))
                .bind(Action.DOWN, settings.keys("down", KeyEvent.VK_DOWN, KeyEvent.VK_S))
                .bind(Action.NEXT, settings.keys("next", KeyEvent.VK_N))
                .bind(Action.FAST, settings.keys("fast", KeyEvent.VK_F))
                .bind(Action.FLOW, settings.keys("flow", KeyEvent.VK_F3))
                .bind(Action.MODE, settings.keys("mode", KeyEvent.VK_M))
                .bind(Action.PAUSE, settings.keys("pause", KeyEvent.VK_P))
                .bind(Action.BACK, KeyEvent.VK_ESCAPE)
                .bind(Action.OK, KeyEvent.VK_ENTER, KeyEvent.VK_SPACE)
                .bind(Action.ONE, KeyEvent.VK_1, KeyEvent.VK_NUMPAD1)
                .bind(Action.TWO, KeyEvent.VK_2, KeyEvent.VK_NUMPAD2)
                .bind(Action.THREE, KeyEvent.VK_3, KeyEvent.VK_NUMPAD3);

./gradlew art draws the tileset picture for Tiled and the program's icon from the same vector drawings as the game, so the editor shows trees where the game has trees. Full screen (F11), the render modes, the frame times (F12), sound and the five languages work as in the earlier games.

What moved into the engine

From Towers Engine package Why
One search from the goal for every unit, in eight directions without cutting corners eu.amadeos.gameengine.path.FlowField Many units with one goal: Sands' harvesters and armies
Blocks of properties in text files, like, typos found eu.amadeos.gameengine.data.Catalog Every game with units, buildings or items
A menu drawn over the game, beside the thing it is about, by mouse and keys eu.amadeos.gameengine.menu.OverlayMenu Every command given on the map
Mouse released, mouse inside the canvas eu.amadeos.gameengine.input.Mouse Dragging, scrolling at the edge
Screen to world and back, a view below a bar, a camera the player moves, a view that grows with the screen eu.amadeos.gameengine.camera.Camera Every map the mouse points into
A picture that widens to fill a wide screen instead of black bars eu.amadeos.gameengine.swing.GameCanvas Every game with a world larger than the screen
32 turned copies of a picture eu.amadeos.gameengine.sprite.Rotations Everything seen from above that turns
Drawn sprites cached opaque eu.amadeos.gameengine.sprite.Sprite Every ground drawn in code

What stays in the game: the ground and its classes, the road and open ground, the rules of building, walking, aiming and gold, the units, towers and waves, the battlefields, the art, the terrain in squares, the painter, the sounds and tunes, the progress and the scenes.

Exercises

  1. Short cuts. On the road, let wolves (and only wolves) leave it and cross open grass. Each kind of unit then needs a flow field of its own: how many fields does the game keep, and when is each searched again?
  2. A tower of your own. Add a fourth kind to towers.txt: a poison tower whose shot hurts a little every second for a few seconds. Which properties does it need, which lines of Game, and what does finish say before you have written them?
  3. Flying units. Bats fly straight to the castle over towers, trees and water. They need no flow field, or a second one: which is simpler, and which lets a frost tower still matter?
  4. The longest maze. Write a test that builds, with a fixed amount of gold, the maze that makes the way from the lair to the castle the longest. Is a greedy builder (always the tile that lengthens the way most) good enough?
  5. Sell back the maze. Selling a tower opens the way again, and every enemy takes the shorter way at once. Is that a trick players will use, and should the rules allow it?
  6. A minimap. Draw the whole battlefield small in a corner, the towers as dots, the view as a box. A click on it moves the camera there. You will need it in the next chapter.
  7. Group flow. Two hundred orcs on one way walk through each other. Give each a little push away from its neighbours (the spatial grid of chapter 24 finds them) and see how a crowd forms in a gap, and how a troll in it now holds up the wolves behind it. Does LevelTest still win every level?

Towers is the first chapter of Part III. Its enemies all want the same thing, and the player commands no one. In Sands, a real-time strategy game, the player selects their own units with the mouse and sends each group somewhere else, the map is dark where nobody has looked, and a computer opponent builds its own base.