31. Sands¶
Towers (chapter 30) gave the player a mouse and a map larger than the screen, but nothing to command: the enemies walked on their own, and the towers stood still. Sands hands over an army. Two houses share a desert planet, each with a construction yard on a plateau of rock. Harvesters gather the crystal that lies in the sand, a refinery turns it into credits, and the credits pay for wind turbines, a factory, turrets and vehicles: fast trikes, tanks with turning turrets, launchers whose rockets burst and hurt everything around them. The computer plays the other house by the same rules: it builds its base, harvests, defends and attacks in growing waves. Whoever loses the last building has lost. On the open sand of the later missions, a burrower hunts whatever drives there.

The early 1990s set the rules that real-time strategy has followed since: harvest, build, produce, command groups with the mouse, explore a map hidden in the dark. Each of these is a small piece of engine, and Sands is the game where they all come together. It is the largest game of this handbook, and the first in which the computer does not only move enemies but plays: it decides what to build, where, and whom to attack.
What you will learn
- Choosing units: a click, a box, Shift to add, Ctrl and a number for a group that is kept
- Group moves: a slot for each unit, the slots shared out without crossing, one flow field per order and a cache for them
- Fog of war: three states per tile, circles of sight, soft edges drawn from 81 pictures, enemy buildings remembered as ghosts
- The minimap: one pixel per tile, painted only where it changed
- An economy: credits with a store, production paid while it runs, power that slows everything down
- A computer opponent: a building plan, a place for each building, and an influence map that finds the weakest spot
- Saving a whole game: hundreds of things in a save file, numbers exact to the bit, and a loaded battle that goes on exactly as it would have
- A ground drawn on other threads: dunes, rock and crystal, worked out pixel by pixel, in squares, while the game runs
The complete game is in amade-sands-game.
The rules¶
- A map is 64 × 64 tiles of 16 units: sand, dunes (slower to drive on), rock, cliffs (closed to everything) and crystal, a little or a lot. The view shows 32 × 25 tiles; the panel on the right takes 128 of the 640 units.
- Each house starts with a construction yard, a little money and a few vehicles. The yard builds one building at a time; when it is ready, the player puts it on rock, near a building of their own, where nothing stands. A building costs credits, and they are paid while it is built: without money, the work waits.
- Wind turbines make power; every other building uses it. With too little power, the yard and the factory work at half speed, the turrets reload slowly, and the outpost's minimap goes dark.
- A refinery comes with a harvester, brought by an airlifter. A harvester drives to the crystal, fills its hopper, drives home and unloads; the crystal becomes credits. Refineries and silos store credits; what they cannot hold is lost.
- The factory makes trikes, tanks and launchers, five in a row at most. Turrets guard the base. A building can be sold for half its price.
- A side sees what its vehicles and buildings see. What it saw once stays on the map, dimmed, with the enemy's buildings as they were when it last looked.
- The burrower appears after a while on the missions that have one. It hunts vehicles on the sand, harvesters first, and swallows them whole. It cannot reach rock.
- A side without buildings has lost. The fastest win of each mission is kept, and winning opens the next.
Step 1: Choosing and commanding¶
Every game so far knew at each moment which thing the player steers. Here the player says it, with the mouse, and the convention for it has not changed since the nineties: a click picks one vehicle, a drag opens a box and picks all in it, Shift adds to the choice, Ctrl and a number keep it as a group that the number alone calls back. The engine's Selection keeps what is picked and the nine groups:
public final class Selection<T> {
/** Groups 1 to 9, as on the keys; index 0 is unused. */
public static final int GROUPS = 10;
private final Set<T> picked = new LinkedHashSet<>();
private final List<Set<T>> groups = new ArrayList<>();
private int version;
public Selection() {
for (int i = 0; i < GROUPS; i++) groups.add(new LinkedHashSet<>());
}
/** Picks {@code things}; with {@code adding}, as with Shift, keeps what was picked before. */
public void pick(Collection<? extends T> things, boolean adding) {
if (!adding) picked.clear();
picked.addAll(things);
version++;
}
/** A click on one thing; with {@code adding} it toggles: a thing picked already is taken out. */
public void click(T thing, boolean adding) {
if (adding && picked.contains(thing)) picked.remove(thing);
else pick(List.of(thing), adding);
version++;
}
public void clear() {
picked.clear();
version++;
}
/** Keeps the selection as group {@code number}, 1 to 9: Ctrl and the number. */
public void store(int number) {
Set<T> group = groups.get(check(number));
group.clear();
group.addAll(picked);
}
/** Picks group {@code number} again; true if it has anything in it. */
public boolean recall(int number) {
Set<T> group = groups.get(check(number));
if (group.isEmpty()) return false;
picked.clear();
picked.addAll(group);
version++;
return true;
}
/** Takes everything out of the selection and the groups that {@code alive} says is gone. */
public void keepOnly(Predicate<? super T> alive) {
if (picked.removeIf(alive.negate())) version++;
for (Set<T> group : groups) group.removeIf(alive.negate());
}
The picked things are kept in a LinkedHashSet: no thing twice, and the order of picking, so that the panel shows the first picked first. keepOnly is called after every step with "is it still alive?". A tank that blows up leaves the choice and every group at once, and nobody has to remember to take it out.
@Test
void shiftAddsAndTogglesGroupsAreKeptAndTheDeadLeave() {
Selection<String> selection = new Selection<>();
selection.pick(List.of("tank 1", "tank 2"), false);
selection.click("trike", true);
assertEquals(List.of("tank 1", "tank 2", "trike"), selection.all(), "in the order of picking");
selection.click("tank 2", true);
assertEquals(List.of("tank 1", "trike"), selection.all(), "Shift and a click on a picked one takes it out");
selection.store(1); // Ctrl+1
selection.click("harvester", false);
assertEquals(List.of("harvester"), selection.all(), "a click without Shift replaces");
assertTrue(selection.recall(1)); // 1
assertEquals(List.of("tank 1", "trike"), selection.all());
assertFalse(selection.recall(2), "an empty group picks nothing");
selection.keepOnly(thing -> !thing.equals("trike")); // the trike was destroyed
assertEquals(List.of("tank 1"), selection.all());
assertEquals(List.of("tank 1"), selection.group(1), "and it left the group as well");
}
The difference between a click and a box is a few units of mouse movement. A hand that clicks moves the mouse a little, and that must stay a click and not become a box of two pixels that picks nothing. BoxSelect is that arithmetic:
public final class BoxSelect {
/** How far the mouse may move between press and release and still be a click, in logical units. */
public static final double SLOP = 4;
/** What a press and a release came to. */
public sealed interface Result {
}
/** The button went down and up on nearly the same spot. */
public record Click(double x, double y) implements Result {
}
/** A box dragged open, its edges sorted: left is less than right, top less than bottom. */
public record Box(double left, double top, double right, double bottom) implements Result {
public boolean contains(double x, double y) {
return x >= left && x <= right && y >= top && y <= bottom;
}
}
private boolean pressed;
private boolean dragging;
private double startX;
private double startY;
private double x;
private double y;
/** The left button went down here. */
public void press(double px, double py) {
pressed = true;
dragging = false;
startX = x = px;
startY = y = py;
}
/** The mouse is here now; once it has gone further than {@link #SLOP} from the press, it drags a box. */
public void move(double px, double py) {
if (!pressed || Double.isNaN(px)) return;
x = px;
y = py;
if (Math.abs(x - startX) > SLOP || Math.abs(y - startY) > SLOP) dragging = true;
}
/** The button came up here: a click or a box; {@code null} if it was never pressed. */
public Result release(double px, double py) {
if (!pressed) return null;
move(px, py);
pressed = false;
if (!dragging) return new Click(startX, startY);
dragging = false;
return box();
}
release returns a sealed Result, so the game's switch over it must name both cases, and Java checks that it does. The game asks the world what lies under a click or inside a box, and only the player's own vehicles are picked. A click on an enemy vehicle in sight, or on a building, shows it in the panel with its health instead; an enemy is looked at, never chosen, so no order can reach it:
/** The left button came up: a click chooses what is under it, a box all of the player's vehicles in it. */
private void release(double wx, double wy, boolean adding) {
BoxSelect.Result result = box.release(wx, wy);
switch (result) {
case null -> {
}
case BoxSelect.Click(double x, double y) -> {
Unit unit = game.unitAt(x, y);
Building there = game.building((int) Math.floor(x / Field.TILE), (int) Math.floor(y / Field.TILE));
if (unit != null && unit.side() == PLAYER) {
selection.click(unit, adding);
building = null;
watched = null;
group = 0;
effects.yes();
} else if (unit != null && game.visible(PLAYER, unit)) {
selection.clear();
building = null;
watched = unit;
effects.tick();
} else if (there != null && (there.side() == PLAYER || game.visible(PLAYER, there))) {
selection.clear();
building = there;
watched = null;
effects.tick();
} else if (!adding) {
selection.clear();
building = null;
watched = null;
}
}
case BoxSelect.Box picked -> {
List<Unit> inside = new ArrayList<>();
for (Unit unit : game.units()) {
if (unit.side() == PLAYER && picked.contains(unit.x(), unit.y())) inside.add(unit);
}
if (!inside.isEmpty() || !adding) selection.pick(inside, adding);
if (!inside.isEmpty()) {
building = null;
watched = null;
group = 0;
effects.yes();
}
}
}
}
The right button is the order. What it means depends on what is under the mouse and on who is chosen, and one button doing the right thing is much of what makes such a game pleasant: an enemy is attacked, a harvester sent to the crystal harvests, a harvester sent to a refinery goes home to unload, everything else drives there. Ctrl turns a move into a hunt, in which the vehicles fight whatever they meet on the way.
/**
* The right button on the map: the chosen attack an enemy under it, harvesters go to crystal or home
* to a refinery, the rest drive there; with Ctrl they fight their way there. Without anything chosen,
* or while a building waits for its place, it takes back the choice.
*/
private void command(double wx, double wy, boolean hunt) {
if (placing != null) {
placing = null;
return;
}
List<Unit> chosen = selection.all();
if (chosen.isEmpty()) {
building = null;
watched = null;
return;
}
int col = (int) Math.floor(wx / Field.TILE);
int row = (int) Math.floor(wy / Field.TILE);
if (!game.contains(col, row)) return;
Cell cell = new Cell(col, row);
Thing enemy = enemyAt(wx, wy, col, row);
boolean harvesters = chosen.stream().anyMatch(unit -> unit.type().harvests());
Building there = game.building(col, row);
if (enemy != null) {
game.attack(chosen, enemy);
} else if (harvesters && there != null && there.side() == PLAYER && there.type().dock() >= 0) {
game.bringHome(chosen);
} else if (harvesters && game.crystal(col, row) > 0) {
game.harvest(chosen, cell);
} else if (hunt) {
game.hunt(chosen, cell);
} else {
game.move(chosen, cell);
}
order = cell;
orderAge = 0;
effects.yes();
}
Step 2: A group on the move¶
Towers had one goal for every enemy, the castle, and one flow field for it. Here every order has a goal of its own, and a group of five tanks sent to one tile raises two questions that one walking orc never did: where does each tank stop, since five cannot stand on one tile? And how do they get there without driving into each other?
A slot for each¶
The answer to the first is a slot for each unit: the open tiles nearest the target, found by a breadth-first search from it, so that a slot is never behind a wall from the target. A click on a rock still sends the group next to it:
/**
* Up to {@code count} open tiles nearest to {@code target}, the target first if it is open, then
* outwards by steps in eight directions. Fewer if not so many are reachable. A closed target is
* replaced by the nearest open tile, so a click on a rock still sends the group next to it.
*/
public static List<Cell> slots(Cell target, int count, int width, int height, Passable open) {
List<Cell> slots = new ArrayList<>();
if (count <= 0 || !inside(target.col(), target.row(), width, height)) return slots;
Cell start = open.test(target.col(), target.row()) ? target : nearestOpen(target, width, height, open);
if (start == null) return slots;
boolean[] seen = new boolean[width * height];
ArrayDeque<Cell> queue = new ArrayDeque<>();
queue.add(start);
seen[start.row() * width + start.col()] = true;
while (!queue.isEmpty() && slots.size() < count) {
Cell cell = queue.poll();
slots.add(cell);
for (int d = 0; d < 8; d++) {
int c = cell.col() + DX[d];
int r = cell.row() + DY[d];
if (!inside(c, r, width, height) || seen[r * width + c] || !open.test(c, r)) continue;
// No corner cut: a slot must be walkable from the one before.
if (d >= 4 && (!open.test(c, cell.row()) || !open.test(cell.col(), r))) continue;
seen[r * width + c] = true;
queue.add(new Cell(c, r));
}
}
return slots;
}
Which unit takes which slot matters more than it seems. Give the slot at the front to the tank at the back, and the two cross each other's way just before they arrive, and jostle. The best assignment, the one with the least driving in total, is a known problem with a known answer (the Hungarian method), but it is a page of tricky code. The greedy assignment is five lines and nearly as good for a dozen units: take the closest pair of a unit and a slot, then the closest of what is left, and so on.
/**
* Which slot each unit takes: {@code result[unit]} is an index into {@code slots}, or -1 for a unit
* left without one when there are fewer slots than units. Positions are in tiles, 2.5 being the middle
* of column 2. The closest pair of unit and slot is taken first, then the closest of the rest.
*/
public static int[] assign(double[] xs, double[] ys, List<Cell> slots) {
int units = xs.length;
int[] slotOf = new int[units];
Arrays.fill(slotOf, -1);
int pairs = units * slots.size();
double[] distance = new double[pairs];
for (int u = 0; u < units; u++) {
for (int s = 0; s < slots.size(); s++) {
int pair = u * slots.size() + s;
distance[pair] = Math.hypot(slots.get(s).col() + 0.5 - xs[u], slots.get(s).row() + 0.5 - ys[u]);
}
}
// Sort the pairs by distance; ties keep the order of the units, then of the slots.
Integer[] sorted = new Integer[pairs];
for (int i = 0; i < pairs; i++) sorted[i] = i;
Arrays.sort(sorted, (a, b) -> {
int nearer = Double.compare(distance[a], distance[b]);
return nearer != 0 ? nearer : Integer.compare(a, b);
});
boolean[] taken = new boolean[slots.size()];
int left = Math.min(units, slots.size());
for (int i = 0; i < pairs && left > 0; i++) {
int u = sorted[i] / slots.size();
int s = sorted[i] % slots.size();
if (slotOf[u] >= 0 || taken[s]) continue;
slotOf[u] = s;
taken[s] = true;
left--;
}
return slotOf;
}
@Test
void eachUnitTakesTheSlotThatSuitsIt() {
// Three units coming from the left, in a column; three slots in a column on the right.
double[] xs = {1.5, 1.5, 1.5};
double[] ys = {1.5, 3.5, 5.5};
List<Cell> slots = List.of(new Cell(9, 5), new Cell(9, 1), new Cell(9, 3));
int[] slotOf = Formation.assign(xs, ys, slots);
assertArrayEquals(new int[]{1, 2, 0}, slotOf, "the top unit to the top slot: no crossing");
int[] tooFew = Formation.assign(xs, ys, List.of(new Cell(9, 3)));
assertArrayEquals(new int[]{-1, 0, -1}, tooFew, "the nearest one gets the only slot");
}
One field for the group¶
The way there is a flow field again, but now the field depends on the order. Searching a new field for each unit would give every unit the same field five times; searching one field per group is the idea. The game asks a FieldCache, which searches each goal once and keeps the field while the map stays as it is. Two groups sent to the same place share it, and the harvesters, who all drive home to the same refinery, share theirs for the whole game. A building that goes up or comes down changes the map: then invalidate forgets every field, and each is searched again when it is next needed.
public final class FieldCache {
public static final int DEFAULT_SIZE = 32;
private final int width;
private final int height;
private final Passable open;
private final Map<Cell, FlowField> fields;
private int searches;
public FieldCache(int width, int height, Passable open) {
this(width, height, open, DEFAULT_SIZE);
}
public FieldCache(int width, int height, Passable open, int size) {
this.width = width;
this.height = height;
this.open = open;
// An access-ordered map that drops its eldest entry: the least recently used field.
this.fields = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Cell, FlowField> eldest) {
return size() > size;
}
};
}
/** The field towards {@code goal}: from the cache, or searched now. */
public FlowField toward(Cell goal) {
FlowField field = fields.get(goal);
if (field == null) {
field = FlowField.toward(width, height, open, goal);
fields.put(goal, field);
searches++;
}
return field;
}
/** The map changed: every field is searched again when it is next needed. */
public void invalidate() {
fields.clear();
}
The cache keeps only the 32 fields used last: an access-ordered LinkedHashMap that drops its eldest entry does that in one method. A player who clicks all over the map would otherwise fill the memory with fields nobody follows any more.
The game's move puts it together: slots, the assignment, and one tile for the whole group's field, way:
/**
* Sends vehicles to a tile. Each gets a slot of its own near it, the one that suits it best, and they
* all follow one flow field towards the tile.
*/
public void move(Collection<Unit> group, Cell target) {
send(group, target, Task.MOVE);
}
private void send(Collection<Unit> group, Cell target, Task task) {
List<Unit> vehicles = group.stream().filter(Unit::alive).toList();
if (vehicles.isEmpty() || !contains(target.col(), target.row())) return;
List<Cell> slots = Formation.slots(target, vehicles.size(), width, height, this::open);
double[] xs = new double[vehicles.size()];
double[] ys = new double[vehicles.size()];
for (int i = 0; i < vehicles.size(); i++) {
xs[i] = vehicles.get(i).x / Field.TILE;
ys[i] = vehicles.get(i).y / Field.TILE;
}
int[] slotOf = Formation.assign(xs, ys, slots);
// The field leads to the tile clicked; a building's or a cliff's is closed, so then to the slot nearest to it.
Cell way = open(target.col(), target.row()) || slots.isEmpty() ? target : slots.getFirst();
for (int i = 0; i < vehicles.size(); i++) {
Unit unit = vehicles.get(i);
Cell slot = slotOf[i] >= 0 ? slots.get(slotOf[i]) : target;
release(unit);
unit.task = task;
unit.goal = slot;
unit.way = way;
unit.guard = slot;
unit.target = 0;
unit.stuck = 0;
}
}
Driving¶
Each vehicle then follows the group's field until it is near the goal, and drives straight to its own slot for the last tiles. "Straight" needs a test: may it drive in a line from here to there, or is a cliff in between? clearLine walks the line through the grid tile by tile, as a ray is traced through a grid in a 3D renderer, and refuses to cut a corner:
/**
* Whether a unit can walk straight from one point to another, positions in tiles: every tile the line
* passes through is open, and where it passes a corner, both tiles beside the corner are. Walked
* through tile by tile, as a ray is traced through a grid (Amanatides and Woo, 1987).
*/
public static boolean clearLine(double x0, double y0, double x1, double y1, int width, int height, Passable open) {
int col = (int) Math.floor(x0);
int row = (int) Math.floor(y0);
int endCol = (int) Math.floor(x1);
int endRow = (int) Math.floor(y1);
if (!inside(col, row, width, height) || !open.test(col, row)) return false;
double dx = x1 - x0;
double dy = y1 - y0;
int stepCol = dx > 0 ? 1 : -1;
int stepRow = dy > 0 ? 1 : -1;
// How far along the line, 0 to 1, the next vertical and the next horizontal tile edge lie.
double tCol = dx == 0 ? Double.POSITIVE_INFINITY : ((dx > 0 ? col + 1 : col) - x0) / dx;
double tRow = dy == 0 ? Double.POSITIVE_INFINITY : ((dy > 0 ? row + 1 : row) - y0) / dy;
double deltaCol = dx == 0 ? Double.POSITIVE_INFINITY : Math.abs(1 / dx);
double deltaRow = dy == 0 ? Double.POSITIVE_INFINITY : Math.abs(1 / dy);
int guard = Math.abs(endCol - col) + Math.abs(endRow - row) + 2;
while ((col != endCol || row != endRow) && guard-- > 0) {
if (Math.abs(tCol - tRow) < 1e-12) {
// Through a corner: the two tiles beside it must be open as well.
if (!passable(col + stepCol, row, width, height, open) || !passable(col, row + stepRow, width, height, open)) {
return false;
}
col += stepCol;
row += stepRow;
tCol += deltaCol;
tRow += deltaRow;
} else if (tCol < tRow) {
col += stepCol;
tCol += deltaCol;
} else {
row += stepRow;
tRow += deltaRow;
}
if (!passable(col, row, width, height, open)) return false;
}
return true;
}
A vehicle turns before it drives, as a tank does, and drives slower the further it points off its way. A vehicle that means to move but hardly does is stuck, usually behind others who already stand in the slots near it, and gives up after a while near its goal:
private boolean drive(Unit unit, Cell goal, double seconds) {
return drive(unit, goal, null, seconds);
}
/**
* Drives a vehicle a step towards a tile, its slot; true when it has arrived. Near the slot, with
* nothing in the way, it drives straight there. Else it follows the flow field towards {@code way},
* the tile its group was sent to, which all of the group share and the cache keeps; once there, or
* without a group, the field towards the slot itself. It turns first and drives when it points the
* right way.
*/
private boolean drive(Unit unit, Cell goal, Cell way, double seconds) {
if (goal == null) return true;
double gx = (goal.col() + 0.5) * Field.TILE;
double gy = (goal.row() + 0.5) * Field.TILE;
double distance = Math.hypot(gx - unit.x, gy - unit.y);
if (distance < Field.ARRIVED) return true;
Vector heading;
double tx = unit.x / Field.TILE;
double ty = unit.y / Field.TILE;
if (distance < Field.DIRECT * Field.TILE
&& Formation.clearLine(tx, ty, gx / Field.TILE, gy / Field.TILE, width, height, this::open)) {
heading = new Vector(gx - unit.x, gy - unit.y).normalized();
} else {
Cell via = way;
if (via == null || Math.hypot((via.col() + 0.5) * Field.TILE - unit.x,
(via.row() + 0.5) * Field.TILE - unit.y) < 1.5 * Field.TILE) {
via = goal;
}
FlowField field = fields.toward(via);
heading = field.heading(tx, ty);
if (heading.lengthSquared() == 0) {
// On the field's goal but not yet at the slot; or the goal cannot be reached at all.
if (!field.reaches((int) Math.floor(tx), (int) Math.floor(ty))) return true;
heading = new Vector(gx - unit.x, gy - unit.y).normalized();
}
}
double want = heading.angle();
unit.angle = Angles.turnTowards(unit.angle, want, Field.TURN * seconds);
double off = Math.abs(Angles.difference(unit.angle, want));
if (off > 1.2) return false;
Ground ground = level.ground((int) Math.floor(tx), (int) Math.floor(ty));
double speed = unit.type().speed() * Field.TILE * (ground == Ground.DUNE ? Field.DUNE_SPEED : 1);
double step = Math.min(distance, speed * seconds * Math.cos(off));
double beforeX = unit.x;
double beforeY = unit.y;
nudge(unit, Math.cos(unit.angle) * step, Math.sin(unit.angle) * step);
double moved = Math.hypot(unit.x - beforeX, unit.y - beforeY);
unit.moving = moved > 0;
// A vehicle that hardly moves, though it means to, gives up near the goal, and in the end anywhere.
unit.stuck = moved < step * 0.25 ? unit.stuck + seconds : Math.max(0, unit.stuck - seconds);
return unit.stuck > 1.5 && distance < 2.5 * Field.TILE || unit.stuck > 5;
}
The slots keep the group apart at the end. On the way, nothing does yet: five tanks following one field converge on the same tiles. So after all vehicles have moved, those that overlap push each other apart, through the spatial grid of chapter 24. Moving ones yield more than those that stand, and a harvester at work hardly at all, so a column that passes it does not shove it off its crystal:
/**
* Vehicles that overlap push each other apart, the moving ones more than those that stand: a group
* spreads out instead of driving into one point, and a harvester at work is not shoved off its crystal.
*/
private void separate() {
crowd.clear();
for (Unit unit : units) crowd.add(unit, unit.x, unit.y, unit.radius());
for (Unit unit : units) {
crowd.forEachNear(unit.x, unit.y, unit.radius(), other -> {
if (other.id() <= unit.id()) return;
double dx = other.x - unit.x;
double dy = other.y - unit.y;
double d = Math.hypot(dx, dy);
double least = (unit.radius() + other.radius()) * 0.8;
if (d >= least) return;
if (d < 1e-6) {
dx = 1;
dy = 0;
d = 1;
}
double a = weight(unit);
double b = weight(other);
if (a + b == 0) return;
double push = Math.min(least - d, 2) * 0.5;
nudge(unit, -dx / d * push * a / (a + b), -dy / d * push * a / (a + b));
nudge(other, dx / d * push * b / (a + b), dy / d * push * b / (a + b));
});
}
}
@Test
void aGroupSentToOneTileSpreadsOverSlotsOfItsOwn() {
Game game = mission(1);
for (Unit unit : game.units()) unit.health = 0;
run(game, STEP);
int searches = game.searches();
List<Unit> group = new ArrayList<>();
for (int i = 0; i < 6; i++) group.add(vehicle(game, "tank", Side.BLUE, 14 + i % 3, 15 + i / 3));
Cell target = sand(game, 30, 26);
game.move(group, target);
Set<Cell> slots = new HashSet<>();
for (Unit tank : group) slots.add(tank.goal());
assertEquals(6, slots.size(), "a slot each");
run(game, 40);
Set<Cell> arrived = new HashSet<>();
for (Unit tank : group) {
assertEquals(Task.IDLE, tank.task(), tank + " has arrived");
assertTrue(Math.hypot(tank.x() - (tank.guard.col() + 0.5) * Field.TILE,
tank.y() - (tank.guard.row() + 0.5) * Field.TILE) < Field.TILE, tank + " stands at its slot");
arrived.add(tank.cell());
assertTrue(Math.abs(tank.cell().col() - target.col()) <= 3 && Math.abs(tank.cell().row() - target.row()) <= 3);
}
assertTrue(arrived.size() >= 5, "they stand apart: " + arrived);
assertEquals(1, game.searches() - searches, "the group shares one flow field");
}
The test counts the searches, too: six tanks, one order, one field.
Step 3: Fog of war¶
A strategy game is a game of what you do not know. Each side has a FogOfWar of its own, and every tile is in one of three states: unseen, black, nobody of this side has ever been there; seen, someone looked once but nobody looks now, the ground is known but not what moves on it; visible, someone sees it now.
public final class FogOfWar {
public enum State { UNSEEN, SEEN, VISIBLE }
private final int width;
private final int height;
private final boolean[] seen;
/** The step in which a tile was last looked at; it is visible if that is the current step. */
private final int[] lookedAt;
/** What {@link #lookedAt} said at the end of the step before, to find the tiles that changed. */
private final boolean[] wasVisible;
private final Map<Integer, int[]> circles = new HashMap<>();
private int step = 1;
private int version;
private boolean everything;
public FogOfWar(int width, int height) {
if (width <= 0 || height <= 0) throw new IllegalArgumentException("empty map");
this.width = width;
this.height = height;
this.seen = new boolean[width * height];
this.lookedAt = new int[width * height];
this.wasVisible = new boolean[width * height];
}
/** A new step: what was visible is visible only if someone looks again. */
public void begin() {
step++;
}
/** Everything within {@code radius} tiles of the middle of {@code col}, {@code row} is visible now. */
public void look(int col, int row, int radius) {
for (int offset : circle(radius)) {
// An offset packs the column and the row into one int: 16 bits each, with a sign.
int c = col + (short) (offset >> 16);
int r = row + (short) offset;
if (c < 0 || r < 0 || c >= width || r >= height) continue;
int i = r * width + c;
lookedAt[i] = step;
if (!seen[i]) {
seen[i] = true;
version++;
}
}
}
/** The end of a step: tiles that came into sight or went out of it change the version. */
public void end() {
for (int i = 0; i < lookedAt.length; i++) {
boolean now = lookedAt[i] == step;
if (now != wasVisible[i]) {
wasVisible[i] = now;
version++;
}
}
}
/** The tiles of a circle, as offsets from its middle tile: those whose middles lie within the radius. */
private int[] circle(int radius) {
return circles.computeIfAbsent(radius, r -> {
int[] offsets = new int[(2 * r + 1) * (2 * r + 1)];
int n = 0;
for (int dr = -r; dr <= r; dr++) {
for (int dc = -r; dc <= r; dc++) {
// A little more than r, so the circle of 1 is the tile and its four sides and corners.
if (dc * dc + dr * dr <= r * r + r) offsets[n++] = (dc << 16) | (dr & 0xFFFF);
}
}
return Arrays.copyOf(offsets, n);
});
}
Three small ideas make it cheap enough for a hundred vehicles in every step:
- Visible is a step number. Instead of clearing a whole array of "visible" flags at the start of every step, the fog counts its steps and writes the current one into each tile that someone looks at. A tile is visible if its number is the current step.
beginis one addition. - Circles are kept. The tiles within a radius are worked out once per radius, as offsets from the middle, and every look of that radius walks the same list. No square roots in the step.
- A version. The fog counts every change of a tile's state. The minimap and the fog's picture are worked out again only when the version has changed.
What a side has seen goes into the save file as one bit per tile; what it sees now needs no saving, because the next step looks again:
/**
* The tiles seen so far, for a save file: one bit per tile, as Base64 text, 700 characters for a map
* of 64 × 64. What is visible needs no saving: the next step looks again.
*/
public String seenAsText() {
byte[] bits = new byte[(seen.length + 7) / 8];
for (int i = 0; i < seen.length; i++) if (seen[i]) bits[i / 8] |= (byte) (1 << (i % 8));
return Base64.getEncoder().encodeToString(bits);
}
/** Takes back what {@link #seenAsText()} wrote; an {@link IllegalArgumentException} if it does not fit. */
public void restoreSeen(String text) {
byte[] bits = Base64.getDecoder().decode(text);
if (bits.length != (seen.length + 7) / 8) {
throw new IllegalArgumentException("seen tiles of another map size: " + bits.length + " bytes");
}
for (int i = 0; i < seen.length; i++) seen[i] = (bits[i / 8] & (1 << (i % 8))) != 0;
version++;
}
The game looks in every step, for both sides, with every vehicle and every building. A building looks from its middle, a little further than its sight, so that a large one sees as far from its edges as a small one. And it remembers what it saw: an enemy building that comes into sight becomes a ghost, its kind and place, which stays on the map after the units have driven on. Only when the side sees the place again and the building is gone does the ghost go. That is how a player finds the enemy base once and can send an army there later.
/** Each side's fog: what its vehicles and buildings see now, and the enemy buildings it has found or lost. */
void look() {
for (House house : houses.values()) {
FogOfWar fog = house.fog;
fog.begin();
for (Unit unit : units) {
if (unit.side() != house.side()) continue;
fog.look((int) Math.floor(unit.x / Field.TILE), (int) Math.floor(unit.y / Field.TILE),
(int) Math.ceil(unit.type().sight()));
}
for (Building building : buildings) {
if (building.side() != house.side()) continue;
BuildingType type = building.type();
fog.look(building.col() + type.width() / 2, building.row() + type.height() / 2,
(int) Math.ceil(type.sight() + Math.max(type.width(), type.height()) / 2.0));
}
fog.end();
for (Building building : buildings) {
if (building.side() != house.side() && visible(house.side(), building)) {
house.known.put(building.id(), new Ghost(building.id(), building.type(), building.side(),
building.col(), building.row()));
}
}
house.known.values().removeIf(ghost -> !things.containsKey(ghost.id()) && seesAny(fog, ghost));
}
}
@Test
void anEnemyBuildingOnceSeenStaysOnTheMapUntilItIsSeenGone() {
Game game = mission(1);
Building turbine = first(game, Side.RED, "turbine");
assertFalse(game.house(Side.BLUE).known().stream().anyMatch(g -> g.id() == turbine.id()), "far off, unseen");
Unit scout = vehicle(game, "trike", Side.BLUE, turbine.col() - 4, turbine.row() + 1);
run(game, 0.1);
assertTrue(game.house(Side.BLUE).known().stream().anyMatch(g -> g.id() == turbine.id()), "found");
scout.health = 0;
run(game, 0.1);
turbine.health = 0; // destroyed while nobody of blue looks
run(game, 0.1);
assertTrue(game.house(Side.BLUE).known().stream().anyMatch(g -> g.id() == turbine.id()), "still a ghost");
vehicle(game, "trike", Side.BLUE, turbine.col() - 4, turbine.row() + 1);
run(game, 0.1);
assertFalse(game.house(Side.BLUE).known().stream().anyMatch(g -> g.id() == turbine.id()), "seen gone");
}
Soft edges¶
Drawn tile by tile, the fog would be a staircase of black squares at the edge of everything a unit sees. The FogPainter gives each corner of a tile a state instead, the darkest of the four tiles that meet there, and shades the tile smoothly from corner to corner. Three states at four corners make 3⁴ = 81 kinds of tile, and that is all there will ever be. Each is drawn once as a sprite, at the size of the screen, and only copied after that: the trick of the turned sprites of chapter 30, used for darkness.
public final class FogPainter {
/** How dark a seen tile is drawn that nobody sees now, 0 clear to 1 black. */
public static final double DIM = 0.48;
private final double tile;
private final Color shroud;
private final Sprite[] tiles = new Sprite[81];
private final Rectangle2D.Double rectangle = new Rectangle2D.Double();
/** A painter for tiles of {@code tile} logical units, in black. */
public FogPainter(double tile) {
this(tile, new Color(0x050302));
}
public FogPainter(double tile, Color shroud) {
this.tile = tile;
this.shroud = shroud;
for (int kind = 0; kind < tiles.length; kind++) {
int k = kind;
tiles[kind] = Sprite.drawn(tile, tile, (g, w, h) -> draw(g, w, h, k));
}
}
/** The darkness of a state: 0 visible, {@link #DIM} seen, 1 unseen. */
static double darkness(int level) {
return level == 0 ? 0 : level == 1 ? DIM : 1;
}
/**
* The fog over the tiles from {@code fromCol}, {@code fromRow} to {@code toCol}, {@code toRow}, in the
* world's coordinates: draw it after the world and before the marks that must stay readable.
*/
public void render(Graphics2D g, FogOfWar fog, int fromCol, int fromRow, int toCol, int toRow) {
g.setColor(shroud);
for (int row = Math.max(0, fromRow); row <= Math.min(fog.height() - 1, toRow); row++) {
int black = -1; // where a run of black tiles began
int last = Math.min(fog.width() - 1, toCol);
for (int col = Math.max(0, fromCol); col <= last + 1; col++) {
int kind = col > last ? -1 : kind(corner(fog, col, row), corner(fog, col + 1, row),
corner(fog, col, row + 1), corner(fog, col + 1, row + 1));
if (kind == 80) { // all black: no picture needed
if (black < 0) black = col;
continue;
}
if (black >= 0) {
fill(g, black * tile, row * tile, (col - black) * tile, tile);
black = -1;
}
if (kind > 0) tiles[kind].drawSeamless(g, col * tile, row * tile);
}
}
}
/**
* Black from {@code x}, {@code y}, {@code w} × {@code h}, its edges rounded to the screen's pixels just as
* {@link Sprite#drawSeamless} rounds them: a shape filled in the world's coordinates may round the other
* way, and leave a line of pixels uncovered between the black and a soft tile.
*/
private void fill(Graphics2D g, double x, double y, double w, double h) {
AffineTransform t = g.getTransform();
if ((t.getType() & (AffineTransform.TYPE_MASK_ROTATION | AffineTransform.TYPE_GENERAL_TRANSFORM)) != 0) {
rectangle.setRect(x, y, w, h);
g.fill(rectangle);
return;
}
long left = Math.round(t.getTranslateX() + x * t.getScaleX());
long right = Math.round(t.getTranslateX() + (x + w) * t.getScaleX());
long top = Math.round(t.getTranslateY() + y * t.getScaleY());
long bottom = Math.round(t.getTranslateY() + (y + h) * t.getScaleY());
g.setTransform(new AffineTransform());
g.fillRect((int) left, (int) top, (int) (right - left), (int) (bottom - top));
g.setTransform(t);
}
/** The kind of tile with these corner states, top left, top right, bottom left, bottom right. */
static int kind(int topLeft, int topRight, int bottomLeft, int bottomRight) {
return topLeft + 3 * topRight + 9 * bottomLeft + 27 * bottomRight;
}
/**
* The state of the corner at the top left of tile {@code col}, {@code row}: the darkest of the four
* tiles around it. Beyond the edge of the map the tiles inside count, so the edge gets no dark rim.
*/
static int corner(FogOfWar fog, int col, int row) {
int darkest = 0;
for (int dr = -1; dr <= 0; dr++) {
for (int dc = -1; dc <= 0; dc++) {
int c = Math.clamp(col + dc, 0, fog.width() - 1);
int r = Math.clamp(row + dr, 0, fog.height() - 1);
darkest = Math.max(darkest, level(fog.state(c, r)));
}
}
return darkest;
}
private static int level(FogOfWar.State state) {
return switch (state) {
case VISIBLE -> 0;
case SEEN -> 1;
case UNSEEN -> 2;
};
}
/**
* One kind of tile, pixel by pixel at its size on the screen: the darkness of the four corners mixed by
* how near each pixel is to each, along a smooth curve, plus a little noise near the edge of the fog.
*/
private void draw(Graphics2D g, double w, double h, int kind) {
AffineTransform at = g.getTransform();
int pw = Math.max(1, (int) Math.ceil(w * Math.abs(at.getScaleX())));
int ph = Math.max(1, (int) Math.ceil(h * Math.abs(at.getScaleY())));
double[] corner = {darkness(kind % 3), darkness(kind / 3 % 3), darkness(kind / 9 % 3), darkness(kind / 27)};
BufferedImage image = new BufferedImage(pw, ph, BufferedImage.TYPE_INT_ARGB);
int rgb = shroud.getRGB() & 0xFFFFFF;
for (int py = 0; py < ph; py++) {
double v = smooth((py + 0.5) / ph);
for (int px = 0; px < pw; px++) {
double u = smooth((px + 0.5) / pw);
double dark = (corner[0] * (1 - u) + corner[1] * u) * (1 - v) + (corner[2] * (1 - u) + corner[3] * u) * v;
// Fray the edge: noise only where the shade is between clear and black.
double edge = dark * (1 - dark) * 4;
dark += (noise((px + 0.5) / pw, (py + 0.5) / ph) - 0.5) * 0.5 * edge;
int alpha = (int) Math.round(255 * Math.clamp(dark, 0, 1));
image.setRGB(px, py, alpha << 24 | rgb);
}
}
g.drawImage(image, 0, 0, (int) Math.ceil(w), (int) Math.ceil(h), 0, 0, pw, ph, null);
}
private static double smooth(double t) {
return t * t * (3 - 2 * t);
}
/**
* Value noise with a period of one tile in both directions, so that the noise at the right edge of one
* tile is the noise at the left edge of the next: a lattice of 4 × 4 values per tile, wrapped.
*/
static double noise(double u, double v) {
double x = u * 4;
double y = v * 4;
int xi = (int) Math.floor(x);
int yi = (int) Math.floor(y);
double fx = smooth(x - xi);
double fy = smooth(y - yi);
double a = lattice(xi, yi);
double b = lattice(xi + 1, yi);
double c = lattice(xi, yi + 1);
double d = lattice(xi + 1, yi + 1);
return (a + (b - a) * fx) * (1 - fy) + (c + (d - c) * fx) * fy;
}
private static double lattice(int x, int y) {
long h = Math.floorMod(x, 4) * 374761393L + Math.floorMod(y, 4) * 668265263L;
h = (h ^ (h >>> 13)) * 1274126177L;
h ^= h >>> 16;
return (h & 0xFFFF) / 65535.0;
}
Two details took some looking. Taking the darkest tile at each corner means that the fog reaches half a tile into what is visible, and never the other way: nothing that stands on a hidden tile shows through a soft edge. And the black tiles in the middle of the shroud are not drawn as 81 pictures but as long rectangles, one per run of tiles in a row, which is much faster. The first version filled them in the world's coordinates, and at most sizes of the window a line of pixels showed between the black and the soft tiles next to it: the rectangle and the sprite rounded their edges to the screen's pixels in different ways. fill now rounds the rectangle exactly as Sprite.drawSeamless rounds a sprite, and the lines are gone. The appendix collects this rule with the others.
Step 4: The minimap¶
The panel shows the whole map, small: 64 × 64 tiles in a box of 90 × 90 units. Drawing 4,096 little rectangles in every frame would cost more than the battlefield. The Minimap keeps an image of exactly one pixel per tile, and the game paints a pixel only when its tile changes, which is seldom: the fog lifts, crystal is harvested. In every frame the image is copied, scaled with hard edges, and the things that move are dotted over it.
public final class Minimap {
private final double x;
private final double y;
private final double width;
private final double height;
private final int columns;
private final int rows;
private final double tile;
private final BufferedImage image;
private final int[] pixels;
private final Rectangle2D.Double rectangle = new Rectangle2D.Double();
private final BasicStroke thin = new BasicStroke(0.8f);
private boolean dirty = true;
/**
* A minimap that shows a map of {@code columns} × {@code rows} tiles of {@code tile} units inside the
* box {@code x}, {@code y}, {@code width} × {@code height} of the screen. A map that is not square keeps
* its proportions and is centred in the box.
*/
public Minimap(double x, double y, double width, double height, int columns, int rows, double tile) {
if (columns <= 0 || rows <= 0) throw new IllegalArgumentException("empty map");
double scale = Math.min(width / columns, height / rows);
this.width = columns * scale;
this.height = rows * scale;
this.x = x + (width - this.width) / 2;
this.y = y + (height - this.height) / 2;
this.columns = columns;
this.rows = rows;
this.tile = tile;
this.image = new BufferedImage(columns, rows, BufferedImage.TYPE_INT_RGB);
this.pixels = new int[columns * rows];
}
/** The colour of one tile, {@code 0xRRGGBB}; drawn with the next {@link #render}. */
public void paint(int col, int row, int rgb) {
int i = row * columns + col;
if (pixels[i] == rgb) return;
pixels[i] = rgb;
dirty = true;
}
/** The map's picture, scaled into the box with hard edges: every tile a little square. */
public void render(Graphics2D g) {
if (dirty) {
image.setRGB(0, 0, columns, rows, pixels, 0, columns);
dirty = false;
}
Object before = g.getRenderingHint(RenderingHints.KEY_INTERPOLATION);
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
g.drawImage(image, (int) Math.round(x), (int) Math.round(y), (int) Math.round(x + width) - (int) Math.round(x),
(int) Math.round(y + height) - (int) Math.round(y), null);
if (before != null) g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, before);
}
/** A thing at {@code worldX}, {@code worldY}, as a square of {@code size} units at least a tile large. */
public void dot(Graphics2D g, double worldX, double worldY, double size, Color colour) {
double s = Math.max(size, width / columns);
g.setColor(colour);
rectangle.setRect(screenX(worldX) - s / 2, screenY(worldY) - s / 2, s, s);
g.fill(rectangle);
}
/** A box of the world, such as a building, in its true size. */
public void box(Graphics2D g, double worldX, double worldY, double worldWidth, double worldHeight, Color colour) {
g.setColor(colour);
rectangle.setRect(screenX(worldX), screenY(worldY), Math.max(1, worldWidth / (columns * tile) * width),
Math.max(1, worldHeight / (rows * tile) * height));
g.fill(rectangle);
}
/** The box of what the camera sees. */
public void view(Graphics2D g, Camera camera, double alpha, Color colour) {
double left = screenX(camera.x(alpha));
double top = screenY(camera.y(alpha));
double right = screenX(camera.x(alpha) + camera.viewWidth());
double bottom = screenY(camera.y(alpha) + camera.viewHeight());
g.setStroke(thin);
g.setColor(colour);
rectangle.setRect(left, top, right - left, bottom - top);
g.draw(rectangle);
}
A click on it moves the camera there, a drag scrolls, and the right button sends the chosen vehicles: the minimap converts between its box and the world, as the camera converts between the screen and the world.
/** True if a point of the screen lies on the minimap. */
public boolean contains(double screenX, double screenY) {
return screenX >= x && screenX < x + width && screenY >= y && screenY < y + height;
}
/** Where in the world a point of the minimap lies, in the world's units. */
public double worldX(double screenX) {
return (screenX - x) / width * columns * tile;
}
public double worldY(double screenY) {
return (screenY - y) / height * rows * tile;
}
/** Where on the screen a point of the world is shown. */
public double screenX(double worldX) {
return x + worldX / (columns * tile) * width;
}
public double screenY(double worldY) {
return y + worldY / (rows * tile) * height;
}
The game paints the minimap after every step in which the fog's or the crystal's version has changed, and draws the buildings, ghosts and vehicles over it. It shows nothing at all without an outpost with power: in the classic strategy games the radar is a building you have to earn.
/**
* The map, small: what the side has seen, its buildings and vehicles and the enemy's it sees, and the
* view. Only with an outpost that has power; without, a word on what is missing.
*/
private void minimap(Graphics2D g, Game game, Side side, Minimap minimap, Camera camera, double alpha) {
g.setColor(new Color(0x050302));
g.fill(MINIMAP);
House house = game.house(side);
if (!game.radar(side)) {
g.setFont(font(9, true));
g.setColor(GREY);
centred(g, msg.get("panel.radar"), MINIMAP.getCenterX(), MINIMAP.getCenterY() - 3);
g.setFont(font(7, false));
centred(g, msg.get(house.lowPower() ? "panel.radar.power" : "panel.radar.none"), MINIMAP.getCenterX(),
MINIMAP.getCenterY() + 9);
bevel(g, MINIMAP.x - 1, MINIMAP.y - 1, MINIMAP.width + 2, MINIMAP.height + 2, true);
return;
}
minimap.render(g);
for (Ghost ghost : house.known()) {
BuildingType type = ghost.type();
minimap.box(g, ghost.col() * Field.TILE, ghost.row() * Field.TILE, type.width() * Field.TILE,
type.height() * Field.TILE, minimapColour(ghost.side(), side));
}
for (Building building : game.buildings()) {
if (building.side() != side && !game.visible(side, building)) continue;
minimap.box(g, building.col() * Field.TILE, building.row() * Field.TILE,
building.type().width() * Field.TILE, building.type().height() * Field.TILE,
minimapColour(building.side(), side));
}
for (Unit unit : game.units()) {
if (unit.side() != side && !game.visible(side, unit)) continue;
minimap.dot(g, unit.x(alpha), unit.y(alpha), 1.6, unit.side() == side ? Color.WHITE : new Color(0xF04A3A));
}
minimap.view(g, camera, alpha, VIEW);
bevel(g, MINIMAP.x - 1, MINIMAP.y - 1, MINIMAP.width + 2, MINIMAP.height + 2, true);
}
Step 5: Crystals, credits and power¶
Harvesting¶
A harvester is a vehicle with a task of its own. On crystal it takes in a little in every step, and the tile loses what it takes; full, it drives home; on the pad of the refinery it unloads. Without crystal under it, it looks for the nearest field from where it last harvested, so it goes back to the same field until it is empty:
/** A harvester takes in crystal where it stands, or looks for the nearest; full, it drives home. */
private void harvest(Unit unit, double seconds) {
unit.harvesting = false;
if (unit.load >= unit.type().capacity()) {
unit.task = Task.RETURN;
unit.goal = null;
return;
}
Cell here = unit.cell();
int i = here.row() * width + here.col();
boolean there = unit.goal == null || Math.abs(unit.goal.col() - here.col()) <= 1
&& Math.abs(unit.goal.row() - here.row()) <= 1;
if (crystal[i] > 0 && there) {
unit.harvesting = true;
unit.field = here;
unit.work += Field.HARVEST_RATE * seconds;
int take = Math.min(Math.min((int) unit.work, crystal[i]), unit.type().capacity() - unit.load);
if (take > 0) {
unit.work -= take;
crystal[i] -= take;
unit.load += take;
crystalVersion++;
}
if (crystal[i] == 0) unit.goal = null;
return;
}
if (unit.goal == null || crystal(unit.goal.col(), unit.goal.row()) == 0) {
unit.goal = findCrystal(unit.field != null ? unit.field : here, unit);
if (unit.goal == null) {
if (unit.load > 0) unit.task = Task.RETURN;
else settle(unit);
return;
}
}
if (drive(unit, unit.goal, seconds)) {
if (crystal(unit.goal.col(), unit.goal.row()) == 0 || unit.stuck > 1.5) unit.goal = null;
unit.stuck = 0;
}
}
/** On the pad, the crystal goes into the refinery; what the storage cannot hold is lost. */
private void unload(Unit unit, double seconds) {
Building refinery = null;
for (Building building : buildings) if (building.docked == unit.id()) refinery = building;
if (refinery == null) {
unit.task = Task.RETURN;
return;
}
double dx = refinery.dockX() - unit.x;
double dy = refinery.dockY() - unit.y;
double d = Math.hypot(dx, dy);
if (d > 0.5) {
double step = Math.min(d, unit.type().speed() * Field.TILE * seconds);
unit.x += dx / d * step;
unit.y += dy / d * step;
unit.angle = Angles.turnTowards(unit.angle, Math.PI / 2, Field.TURN * seconds);
return;
}
unit.work += Field.UNLOAD_RATE * seconds;
int give = Math.min((int) unit.work, unit.load);
if (give > 0) {
unit.work -= give;
unit.load -= give;
House house = houses.get(unit.side());
int lost = house.credits.earn(give);
house.harvested += give - lost;
if (lost > 0 && time - house.full > Field.ALARM_SECONDS) {
house.full = time;
events.add(new Event.StorageFull(unit.side()));
}
if (unit.load == 0) events.add(new Event.Unloaded(unit, unit.type().capacity()));
}
if (unit.load == 0) {
refinery.docked = 0;
unit.work = 0;
unit.task = Task.HARVEST;
unit.goal = null;
}
}
@Test
void aHarvesterFillsUpOnTheCrystalAndBringsItHome() {
Game game = mission(1);
Unit harvester = of(game, Side.BLUE, "harvester").getFirst();
House house = game.house(Side.BLUE);
house.credits.pay(1000);
int credits = house.credits();
int crystal = 0;
for (int row = 0; row < 64; row++) for (int col = 0; col < 64; col++) crystal += game.crystal(col, row);
List<Event> events = run(game, 90);
Event.Unloaded unloaded = (Event.Unloaded) events.stream().filter(e -> e instanceof Event.Unloaded)
.findFirst().orElseThrow();
assertEquals(harvester, unloaded.harvester());
assertEquals(credits + 700, house.credits(), "a full load, 700 credits");
int left = 0;
for (int row = 0; row < 64; row++) for (int col = 0; col < 64; col++) left += game.crystal(col, row);
int taken = 0;
for (Side side : Side.values()) taken += game.house(side).harvested();
for (Unit unit : game.units()) taken += unit.load();
assertEquals(crystal - taken, left, "taken from the ground, none made up");
}
The money goes into Credits, which know how much they can hold. A limit to the store is a rule with a purpose: it makes the player spend instead of saving up for ever, and build silos when the harvesters bring more than the refineries hold.
public final class Credits {
private int amount;
private int capacity;
/** {@code amount} to start with; {@code capacity} what can be kept, {@link Integer#MAX_VALUE} for no limit. */
public Credits(int amount, int capacity) {
if (amount < 0 || capacity < 0) throw new IllegalArgumentException("negative credits");
this.capacity = capacity;
this.amount = amount;
}
public int amount() {
return amount;
}
public int capacity() {
return capacity;
}
/**
* A new limit, when a silo is built or destroyed. Credits above it stay: a silo that burns does not
* burn the money already spent from it. They only cannot grow until they are below it again.
*/
public void setCapacity(int capacity) {
if (capacity < 0) throw new IllegalArgumentException("negative capacity");
this.capacity = capacity;
}
/** Takes {@code price} if there is that much; true if it was paid. */
public boolean pay(int price) {
if (price < 0) throw new IllegalArgumentException("negative price " + price);
if (price > amount) return false;
amount -= price;
return true;
}
/**
* Brings in up to {@code income}, as far as the store holds it, and returns what did not fit: the
* harvester keeps it in its hold, or it is lost.
*/
public int earn(int income) {
if (income < 0) throw new IllegalArgumentException("negative income " + income);
int room = Math.max(0, capacity - amount);
int taken = Math.min(room, income);
amount += taken;
return income - taken;
}
/** Gives back what was paid, as for a cancelled order: past the limit, like a refund always is. */
public void refund(int credits) {
if (credits < 0) throw new IllegalArgumentException("negative refund " + credits);
amount += credits;
}
/** True if the store is full. */
public boolean full() {
return amount >= capacity;
}
}
Paying while it is made¶
In the classic strategy games, a tank is not paid when it is ordered but while the factory works on it: 300 credits over 14 seconds is a little more than 21 credits a second. Without money, the work stands still and waits; with half the power, it goes at half the speed. That keeps the game moving, because the player can order something with nearly no money and watch it grow as the harvesters come in. Production is that queue:
public final class Production<T> {
/** One order: what, its price and how long it takes at full speed. */
public record Order<T>(T item, int cost, double seconds) {
public Order {
if (cost < 0 || seconds <= 0) throw new IllegalArgumentException("cost " + cost + ", seconds " + seconds);
}
}
private final List<Order<T>> queue = new ArrayList<>();
/** Credits paid for the first order so far, and how far it is, 0 to 1. */
private int paid;
private double done;
private boolean held;
private T ready;
/** Adds an order at the end of the queue. */
public void order(T item, int cost, double seconds) {
queue.add(new Order<>(item, cost, seconds));
}
/**
* One step: the first order is worked on for {@code seconds} at {@code speed} (1 full, 0.5 half), as
* far as the credits pay for it. Returns the item finished in this step, or {@code null}.
*/
public T update(double seconds, Credits credits, double speed) {
if (queue.isEmpty() || held || ready != null || speed <= 0) return null;
Order<T> first = queue.getFirst();
double wanted = done + seconds * speed / first.seconds();
if (wanted > 1 - 1e-9) wanted = 1; // what the sum of many small steps misses
// The credits this much work costs, rounded up: a credit is paid before its work is done.
int due = (int) Math.ceil(wanted * first.cost() - 1e-9) - paid;
if (due > 0) {
int paying = Math.min(due, credits.amount());
credits.pay(paying);
paid += paying;
}
// The work goes only as far as it is paid for: without money it stands still.
done = first.cost() == 0 ? wanted : Math.min(wanted, (double) paid / first.cost());
if (done < 1 || paid < first.cost()) return null;
queue.removeFirst();
paid = 0;
done = 0;
return first.item();
}
The difficulty is that credits are whole and the payment per step is not: 21 credits a second at 120 steps a second is less than a fifth of a credit per step. So the queue pays a whole credit as soon as the work reaches it, and lets the work grow only as far as it has been paid. No credit, no progress, and never a fraction of a credit shown in the panel.
@Test
void theWorkIsPaidWhileItIsDoneAndStopsWithoutMoney() {
Credits credits = new Credits(1000, 2000);
Production<String> factory = new Production<>();
factory.order("tank", 300, 20);
assertNull(run(factory, credits, 10, 1));
assertEquals(0.5, factory.progress(), 1e-6, "half the time");
assertEquals(150, factory.paid(), "half the price, no more");
assertEquals(850, credits.amount());
assertEquals("tank", run(factory, credits, 10, 1));
assertEquals(700, credits.amount(), "the whole price at the end, not a credit more");
factory.order("tank", 300, 20);
Credits poor = new Credits(60, 2000);
assertNull(run(factory, poor, 20, 1));
assertEquals(0.2, factory.progress(), 1e-6, "stopped where the money ended");
assertEquals(0, poor.amount());
poor.earn(240); // a harvester came home
assertEquals("tank", run(factory, poor, 16, 1), "and went on from there");
}
An order can be taken back, and what was paid for it comes back with it. A building that is ready is kept in the queue until it is placed, and while it waits, the yard builds nothing else:
/** Stops or goes on with the work, keeping what was paid. */
public void hold(boolean on) {
held = on;
}
public boolean held() {
return held;
}
/**
* Removes the last order of {@code item}; if it is the one in the works, what was paid for it so far
* comes back. True if there was one.
*/
public boolean cancel(T item, Credits credits) {
for (int i = queue.size() - 1; i >= 0; i--) {
if (!queue.get(i).item().equals(item)) continue;
if (i == 0) {
credits.refund(paid);
paid = 0;
done = 0;
held = false;
}
queue.remove(i);
return true;
}
return false;
}
@Test
void theFactoryMakesWhatItIsPaidForAndLowPowerHalvesItsSpeed() {
Game game = mission(1);
assertFalse(game.produce(Side.BLUE, RULES.unit("tank")), "no factory yet");
game.add(RULES.building("factory"), Side.BLUE, 10, 9);
game.account();
assertTrue(game.produce(Side.BLUE, RULES.unit("tank")));
assertFalse(game.produce(Side.BLUE, RULES.unit("launcher")), "a launcher needs an outpost");
int credits = game.house(Side.BLUE).credits();
List<Event> events = run(game, 13.9);
assertTrue(events.stream().noneMatch(e -> e instanceof Event.Deployed));
events = run(game, 0.2);
Event.Deployed deployed = (Event.Deployed) events.stream().filter(e -> e instanceof Event.Deployed)
.findFirst().orElseThrow();
assertEquals("tank", deployed.unit().type().id());
assertEquals(12, deployed.unit().cell().row(), "below the factory's door");
assertTrue(game.house(Side.BLUE).credits() <= credits - 300 + 700, "paid, whatever the harvester brought");
// Four more factories use more power than the turbine makes: half speed.
for (int i = 0; i < 4; i++) game.add(RULES.building("factory"), Side.BLUE, 20 + 5 * i, 40);
game.account();
assertTrue(game.house(Side.BLUE).lowPower());
game.produce(Side.BLUE, RULES.unit("trike"));
run(game, 8.1);
assertEquals(0.5, game.house(Side.BLUE).factory().progress(), 0.02, "half of the eight seconds' work");
}
Where a building may stand¶
On rock, on no other building, under no vehicle, and near a building of the same side: that last rule makes a base grow from its yard instead of appearing anywhere on the map, and it is what makes the outpost on the far side of the rock a decision.
/**
* Whether the building the yard has ready may stand with its top left corner on a tile: on rock, on no
* other building and under no vehicle, and near a building of its side.
*/
public Placement placement(Side side, BuildingType type, int col, int row) {
for (int dy = 0; dy < type.height(); dy++) {
for (int dx = 0; dx < type.width(); dx++) {
int c = col + dx;
int r = row + dy;
if (!contains(c, r)) return Placement.OUTSIDE;
if (!level.ground(c, r).buildable()) return Placement.NOT_ROCK;
if (occupant[r * width + c] != 0) return Placement.OCCUPIED;
}
}
for (Unit unit : units) {
double left = col * Field.TILE - unit.radius() + 1;
double top = row * Field.TILE - unit.radius() + 1;
if (unit.x > left && unit.y > top && unit.x < left + type.width() * Field.TILE + 2 * unit.radius() - 2
&& unit.y < top + type.height() * Field.TILE + 2 * unit.radius() - 2) {
return Placement.OCCUPIED;
}
}
for (Building building : buildings) {
if (building.side() != side) continue;
int gapX = Math.max(building.col() - (col + type.width()), col - (building.col() + building.type().width()));
int gapY = Math.max(building.row() - (row + type.height()), row - (building.row() + building.type().height()));
if (Math.max(gapX, gapY) < Field.REACH) return Placement.OK;
}
return Placement.TOO_FAR;
}
@Test
void buildingsStandOnRockNearTheBaseAndNowhereElse() {
Game game = mission(1);
BuildingType silo = RULES.building("silo");
House house = game.house(Side.BLUE);
assertEquals(Game.Placement.NOT_READY, game.place(Side.BLUE, 10, 2));
house.yard.keep(silo);
assertEquals(Game.Placement.OK, game.placement(Side.BLUE, silo, 10, 2), "on rock beside the turbine");
assertEquals(Game.Placement.TOO_FAR, game.placement(Side.BLUE, silo, 14, 2), "rock, but too far away");
assertEquals(Game.Placement.OCCUPIED, game.placement(Side.BLUE, silo, 4, 4), "on the yard");
Cell sand = sand(game, 20, 6);
assertEquals(Game.Placement.NOT_ROCK, game.placement(Side.BLUE, silo, sand.col(), sand.row()));
assertEquals(Game.Placement.OUTSIDE, game.placement(Side.BLUE, silo, -1, 5));
Unit tank = of(game, Side.BLUE, "tank").getFirst();
Cell under = tank.cell();
assertEquals(Game.Placement.OCCUPIED, game.placement(Side.BLUE, silo, under.col(), under.row()), "a tank is there");
assertEquals(Game.Placement.OK, game.place(Side.BLUE, 10, 2));
assertEquals(2000 + 1500, house.storage(), "the yard and the refinery store 2,000, the silo 1,500 more");
assertNull(house.yard().ready());
}
While the player looks for the place, the building's footprint follows the mouse, green or red, tile by tile:

/** The building the yard has ready, where the mouse would put it: each tile green where it may stand, else red. */
private void placing(Graphics2D g, BuildingType type, Cell at, Game.Placement result, Side side) {
Composite before = g.getComposite();
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.6f));
Sprite picture = pictures.building(type, side == null ? Side.BLUE : side);
picture.draw(g, at.col() * Field.TILE, at.row() * Field.TILE);
g.setComposite(before);
g.setColor(result == Game.Placement.OK ? OK : BAD);
for (int dy = 0; dy < type.height(); dy++) {
for (int dx = 0; dx < type.width(); dx++) {
rectangle.setRect((at.col() + dx) * Field.TILE + 0.5, (at.row() + dy) * Field.TILE + 0.5,
Field.TILE - 1, Field.TILE - 1);
g.fill(rectangle);
}
}
}
Step 6: The things as data¶
Vehicles, buildings, missions and the computer's plan are four text files in the format of chapter 24, read by the Catalog of chapter 30. A tank:
unit tank
health 320
armour 0.35
speed 1.8
sight 4
size 0.36
cost 300
seconds 14
needs factory
weapon gun
range 4
damage 34
reload 1.6
shot 16
turret yes
A weapon is a few lines inside the vehicle's or the building's block, so a turret and a tank read their guns the same way:
/** The weapon of a unit or a turret: {@code weapon gun} or {@code weapon rocket} and its numbers. */
private static Weapon weapon(Catalog.Entry entry) {
if (!entry.has("weapon")) return null;
String word = entry.text("weapon");
Weapon.Kind kind;
try {
kind = Weapon.Kind.valueOf(word.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw entry.line("weapon").error('"' + word + "\" is not a weapon; use gun or rocket");
}
double splash = kind == Weapon.Kind.ROCKET ? positive(entry, "splash") : 0;
return new Weapon(kind, positive(entry, "range"), positive(entry, "damage"), positive(entry, "reload"),
positive(entry, "shot"), splash);
}
needs names buildings, and the rules check that they exist; what a side may build follows from what it has:
/** Whether a side has the buildings a kind needs, and a yard to build it with. */
public boolean available(Side side, BuildingType type) {
return type.buildable() && has(side, BuildingType::yard) && type.needs().stream().allMatch(id -> has(side, id));
}
/** Whether a side has a factory, and the buildings a vehicle needs. */
public boolean available(Side side, UnitType type) {
return has(side, BuildingType::factory) && type.needs().stream().allMatch(id -> has(side, id));
}
Step 7: Fighting¶
A vehicle's task decides what it does in a step. The interesting one is the one with nothing to do: an idle armed vehicle stands guard. It chases an enemy that comes into sight, but no further than a leash from where it stands, and then drives back. Without the leash, a tank guarding a refinery follows a trike across the map and leaves the refinery to the next attack.
/** What a vehicle does in a step, by its task. */
private void behave(Unit unit, double seconds) {
unit.reload -= seconds;
unit.sinceShot += seconds;
unit.moving = false;
if (unit.guard == null) unit.guard = unit.cell();
switch (unit.task) {
case IDLE -> guard(unit, seconds);
case MOVE -> {
if (drive(unit, unit.goal, unit.way, seconds)) settle(unit);
if (unit.type().turret()) fireOnTheWay(unit, seconds);
}
case ATTACK -> {
Thing enemy = things.get(unit.target);
if (enemy == null || !enemy.alive() || !visible(unit.side(), enemy)) settle(unit);
else engage(unit, enemy, seconds);
}
case HUNT -> {
Thing enemy = nearestEnemy(unit, unit.type().sight() * Field.TILE, true);
if (enemy != null) engage(unit, enemy, seconds);
else if (drive(unit, unit.goal, unit.way, seconds)) settle(unit);
}
case HARVEST -> harvest(unit, seconds);
case RETURN -> bringHome(unit, seconds);
case UNLOAD -> unload(unit, seconds);
}
if (unit.type().turret() && unit.sinceShot > 2) {
unit.turret = Angles.turnTowards(unit.turret, unit.angle, Field.TURRET_TURN * seconds * 0.5);
} else if (!unit.type().turret()) {
unit.turret = unit.angle;
}
}
/** Standing guard: an enemy in sight is chased, but no further than the leash; then back to the spot. */
private void guard(Unit unit, double seconds) {
if (unit.type().armed()) {
Thing enemy = nearestEnemy(unit, unit.type().sight() * Field.TILE, true);
double leash = (Field.LEASH + unit.type().weapon().range()) * Field.TILE;
double gx = (unit.guard.col() + 0.5) * Field.TILE;
double gy = (unit.guard.row() + 0.5) * Field.TILE;
if (enemy != null && gap(gx, gy, enemy) <= leash) {
engage(unit, enemy, seconds);
return;
}
}
double gx = (unit.guard.col() + 0.5) * Field.TILE;
double gy = (unit.guard.row() + 0.5) * Field.TILE;
if (Math.hypot(unit.x - gx, unit.y - gy) > Field.TILE) drive(unit, unit.guard, seconds);
}
A shell follows its target and always hits; a rocket flies to a point near its target, chosen when it is fired, and bursts there. A rocket can miss a trike that drives on, and hits everything near where it bursts, so launchers are strong against groups and weak against fast single vehicles:
private void fire(Weapon weapon, Side side, int shooter, double x, double y, Thing enemy) {
double toX = enemy.x();
double toY = enemy.y();
int target = enemy.id();
if (weapon.kind() == Weapon.Kind.ROCKET) {
// A rocket flies to a point near its target, chosen now: it may miss a vehicle that drives on.
double a = random.nextDouble() * 2 * Math.PI;
double d = random.nextDouble() * Field.SCATTER * Field.TILE;
toX += Math.cos(a) * d;
toY += Math.sin(a) * d;
target = 0;
}
shots.add(new Shot(weapon, side, shooter, target, x, y, toX, toY));
events.add(new Event.Fired(x, y, Math.atan2(toY - y, toX - x), weapon.kind(), side));
}
/** Shells follow their target and hit it; rockets fly to their point and burst there. */
private void fly(double seconds) {
for (Iterator<Shot> it = shots.iterator(); it.hasNext(); ) {
Shot shot = it.next();
Thing target = shot.target != 0 ? things.get(shot.target) : null;
if (target != null && target.alive()) {
shot.toX = target.x();
shot.toY = target.y();
}
shot.left -= seconds;
double share = shot.left <= 0 ? 1 : seconds / (shot.left + seconds);
shot.x += (shot.toX - shot.x) * share;
shot.y += (shot.toY - shot.y) * share;
shot.angle = Math.atan2(shot.toY - shot.lastY, shot.toX - shot.lastX);
if (shot.left > 0) continue;
it.remove();
if (shot.weapon.kind() == Weapon.Kind.GUN) {
if (target != null && target.alive()) hurt(target, shot.weapon.damage(), shot.side, shot.shooter);
events.add(new Event.Hit(shot.x, shot.y));
} else {
burst(shot);
}
}
}
/** A rocket's burst hurts every enemy it reaches, those at its edge half as much. */
private void burst(Shot shot) {
double reach = shot.weapon.splash() * Field.TILE;
List<Thing> hit = new ArrayList<>();
for (Unit unit : units) {
if (unit.side() != shot.side && unit.alive() && gap(shot.x, shot.y, unit) <= reach) hit.add(unit);
}
for (Building building : buildings) {
if (building.side() != shot.side && gap(shot.x, shot.y, building) <= reach) hit.add(building);
}
for (Thing thing : hit) {
double share = 1 - 0.5 * gap(shot.x, shot.y, thing) / reach;
hurt(thing, shot.weapon.damage() * share, shot.side, shot.shooter);
}
events.add(new Event.Burst(shot.x, shot.y, reach));
}
Being hit tells the side, now and then, where it is attacked, which the panel says and the computer acts on; and an idle vehicle that is shot at fires back:
/** Damage, less the armour. The side hit is told, now and then, and an idle vehicle fires back. */
private void hurt(Thing thing, double damage, Side by, int shooter) {
switch (thing) {
case Unit unit -> {
unit.health -= damage * (1 - unit.type().armour());
Thing attacker = things.get(shooter);
if (unit.task == Task.IDLE && unit.type().armed() && attacker instanceof Unit) {
unit.task = Task.ATTACK;
unit.target = shooter;
}
}
case Building building -> building.health -= damage * (1 - building.type().armour());
}
House house = houses.get(thing.side());
house.attacker = shooter;
house.attackedX = thing.x();
house.attackedY = thing.y();
if (time - house.alarm > Field.ALARM_SECONDS) {
house.alarm = time;
events.add(new Event.UnderAttack(thing.side(), thing.x(), thing.y()));
}
}
@Test
void aTurretDestroysATrikeThatComesTooClose() {
Game game = mission(1);
game.add(RULES.building("turret"), Side.BLUE, 17, 10);
game.account();
Unit trike = vehicle(game, "trike", Side.RED, 24, 11);
game.move(List.of(trike), new Cell(19, 11));
List<Event> events = run(game, 15);
assertFalse(trike.alive());
assertTrue(events.stream().anyMatch(e -> e instanceof Event.Destroyed d && d.unit() == trike));
assertTrue(events.stream().anyMatch(e -> e instanceof Event.UnderAttack a && a.side() == Side.RED));
assertEquals(1, game.house(Side.BLUE).destroyed());
}
The burrower¶
The burrower is the one thing on the map that belongs to nobody. It smells vehicles on the sand, harvesters at twice the distance, for they shake the ground; it turns towards its prey at a limited rate, so a fast trike can outrun it on a curve; it turns away from rock. A vehicle in its mouth is gone. Then it dives, and comes up again far from everyone after a while.
/**
* The burrower hunts the nearest vehicle on the sand, harvesters above all, for they shake the ground; with
* none in reach it wanders. It turns away from rock. A vehicle in its mouth is gone; then it dives, and
* comes up again far away.
*/
private void burrower(double seconds) {
if (burrower == null) {
if (time < Field.BURROWER_FIRST) return;
Cell cell = sandFarFromAll();
if (cell == null) return;
burrower = new Burrower((cell.col() + 0.5) * Field.TILE, (cell.row() + 0.5) * Field.TILE);
wander();
events.add(new Event.BurrowerRose(burrower.x, burrower.y));
return;
}
if (!burrower.up) {
burrower.timer -= seconds;
if (burrower.timer > 0) return;
Cell cell = sandFarFromAll();
if (cell == null) {
burrower.timer = 5;
return;
}
burrower.x = burrower.lastX = (cell.col() + 0.5) * Field.TILE;
burrower.y = burrower.lastY = (cell.row() + 0.5) * Field.TILE;
burrower.up = true;
wander();
events.add(new Event.BurrowerRose(burrower.x, burrower.y));
return;
}
burrower.sniff -= seconds;
if (burrower.sniff <= 0) {
burrower.sniff = 0.5;
burrower.prey = 0;
double best = Field.BURROWER_SMELL * Field.TILE;
for (Unit unit : units) {
if (!onSand(unit.x, unit.y)) continue;
double d = Math.hypot(unit.x - burrower.x, unit.y - burrower.y) / (unit.type().harvests() ? 2 : 1);
if (d < best) {
best = d;
burrower.prey = unit.id();
}
}
}
Thing prey = things.get(burrower.prey);
if (prey instanceof Unit unit && unit.alive() && onSand(unit.x, unit.y)) {
burrower.toX = unit.x;
burrower.toY = unit.y;
if (Math.hypot(unit.x - burrower.x, unit.y - burrower.y) < Field.BURROWER_BITE) {
unit.health = 0;
events.add(new Event.BurrowerAte(unit));
burrower.meals++;
burrower.up = false;
burrower.timer = Field.BURROWER_DIVE;
burrower.prey = 0;
return;
}
} else if (Math.hypot(burrower.toX - burrower.x, burrower.toY - burrower.y) < Field.TILE) {
wander();
}
double want = Math.atan2(burrower.toY - burrower.y, burrower.toX - burrower.x);
burrower.angle = Angles.turnTowards(burrower.angle, want, 2.5 * seconds);
double nx = burrower.x + Math.cos(burrower.angle) * Field.BURROWER_SPEED * seconds;
double ny = burrower.y + Math.sin(burrower.angle) * Field.BURROWER_SPEED * seconds;
if (onSand(nx, ny)) {
burrower.x = nx;
burrower.y = ny;
} else {
burrower.prey = 0;
burrower.sniff = 2;
wander();
}
}
@Test
void theBurrowerSwallowsVehiclesOnTheSandButNotOnRock() {
Game game = mission(2);
game.time = Field.BURROWER_FIRST - 1;
Cell open = sand(game, 24, 30);
Unit onSand = vehicle(game, "trike", Side.BLUE, open.col(), open.row());
game.stop(List.of(onSand));
run(game, 1.1);
assertNotNull(game.burrower());
game.burrower.x = onSand.x() + 40;
game.burrower.y = onSand.y();
Unit onRock = vehicle(game, "trike", Side.BLUE, 12, 34);
List<Event> events = run(game, 10);
assertTrue(events.stream().anyMatch(e -> e instanceof Event.BurrowerAte ate && ate.unit() == onSand));
assertFalse(onSand.alive());
assertTrue(onRock.alive());
assertFalse(game.burrower().up(), "it dives after a meal");
}
One step¶
All of it in order: production, the computer's thoughts once a second, the vehicles, pushing apart, the turrets, the shots, the airlifters, the burrower, the dead, the fog, and whether someone has won:
public void update(double seconds) {
events.clear();
time += seconds;
for (Unit unit : units) unit.remember();
for (Building building : buildings) building.lastTurret = building.turret;
for (Shot shot : shots) shot.remember();
for (Airlifter airlifter : airlifters) airlifter.remember();
if (burrower != null) burrower.remember();
produce(seconds);
thought += seconds;
if (thought >= Field.THINK_SECONDS) {
thought -= Field.THINK_SECONDS;
for (Commander commander : commanders.values()) commander.think(this);
}
for (int i = 0; i < units.size(); i++) behave(units.get(i), seconds);
separate();
for (Building building : buildings) defend(building, seconds);
fly(seconds);
carry(seconds);
if (level.mission().burrower()) burrower(seconds);
bury();
look();
judge();
}
Step 8: The computer opponent¶
The computer plays by exactly the rules the player plays by. It has no money the player could not have, no units, no faster factory; it calls build, place, produce, move and hunt as the panel and the mouse do. A Commander thinks once a second, not in every step: a decision a second is quick enough for a strategy game, and it costs nothing.
void think(Game game) {
House house = game.house(side);
placeReady(game, house);
if (house.yard().isEmpty() && house.yard().ready() == null) {
BuildingType next = nextBuilding(game, house);
if (next != null) game.build(side, next);
}
if (house.factory().isEmpty()) {
UnitType next = nextVehicle(game);
if (next != null) game.produce(side, next);
}
List<Unit> idleHarvesters = new ArrayList<>();
for (Unit unit : game.units()) {
if (unit.side() == side && unit.type().harvests() && unit.task() == Task.IDLE) idleHarvesters.add(unit);
}
if (!idleHarvesters.isEmpty()) game.harvest(idleHarvesters, null);
defend(game, house);
attack(game);
}
What to build¶
The order of its buildings is a list in commander.txt: turbine, refinery, outpost, factory, turbine, refinery, silo, turbine, factory. It builds the first one of which it has fewer than the list names up to there. So a building it loses is simply the first missing one again, and the plan heals itself; and before a building that would use more power than it has, it builds a turbine.
/**
* The first building of the plan of which it has fewer than the plan names up to there; first a turbine
* if the power would not last. After the plan, turrets.
*/
BuildingType nextBuilding(Game game, House house) {
BuildingType turbine = null;
for (BuildingType type : plan.build()) if (type.power() > 0) turbine = type;
int spare = house.powerMade() - house.powerUsed();
List<BuildingType> wanted = new ArrayList<>(plan.build());
BuildingType turret = turret(game);
if (turret != null) for (int i = 0; i < mission.turrets(); i++) wanted.add(turret);
for (int i = 0; i < wanted.size(); i++) {
BuildingType type = wanted.get(i);
int planned = 0;
for (int j = 0; j <= i; j++) if (wanted.get(j) == type) planned++;
if (game.count(side, type) >= planned || !game.available(side, type)) continue;
if (type.power() < 0 && spare + type.power() < 0 && turbine != null && game.available(side, turbine)) {
return turbine;
}
return type;
}
return spare < 0 && turbine != null && game.available(side, turbine) ? turbine : null;
}
Where to put it¶
Of all the tiles where the building may stand, it takes the one nearest the yard, with a free tile all round so that the vehicles can still pass between the buildings; a turret instead stands at the side of the base that faces the enemy. Trying every tile of the map is a few thousand calls of placement, once for each building: cheap.
/**
* Where a building fits: of all the tiles it may stand on, with a tile free all round it so that the
* vehicles can pass, a turret the one nearest to the enemy's base, any other the one nearest to the yard.
*/
Cell spot(Game game, BuildingType type) {
Building yard = null;
double enemyX = 0;
double enemyY = 0;
int enemies = 0;
for (Building building : game.buildings()) {
if (building.side() == side && building.type().yard() && yard == null) yard = building;
if (building.side() != side) {
enemyX += building.x();
enemyY += building.y();
enemies++;
}
}
double homeX = yard != null ? yard.x() : enemyX / Math.max(1, enemies);
double homeY = yard != null ? yard.y() : enemyY / Math.max(1, enemies);
boolean turret = type.weapon() != null;
double towardX = turret && enemies > 0 ? enemyX / enemies : homeX;
double towardY = turret && enemies > 0 ? enemyY / enemies : homeY;
Cell best = null;
double bestScore = Double.MAX_VALUE;
for (int row = 0; row < game.level().height(); row++) {
for (int col = 0; col < game.level().width(); col++) {
if (game.placement(side, type, col, row) != Game.Placement.OK) continue;
if (!turret && !roomAround(game, type, col, row)) continue;
double x = (col + type.width() / 2.0) * Field.TILE;
double y = (row + type.height() / 2.0) * Field.TILE;
double score = Math.hypot(x - towardX, y - towardY);
if (turret) score += 0.5 * Math.hypot(x - homeX, y - homeY);
if (score < bestScore) {
best = new Cell(col, row);
bestScore = score;
}
}
}
return best;
}
Whom to attack: the influence map¶
When the time has come and its army is large enough, the computer attacks, each time with more vehicles. The question is where. An influence map answers it without looking at single units: every armed vehicle and turret of the enemy spreads its strength around it, full on its own tile and less further away, and added up the map shows where the enemy is strong. The enemy building with the least strength on it is the one defended worst.
public final class InfluenceMap {
private final int width;
private final int height;
private final double[] values;
public InfluenceMap(int width, int height) {
if (width <= 0 || height <= 0) throw new IllegalArgumentException("empty map");
this.width = width;
this.height = height;
this.values = new double[width * height];
}
public void clear() {
Arrays.fill(values, 0);
}
/**
* Spreads {@code strength} around a tile: all of it there, falling in a straight line to nothing at
* {@code reach} tiles, as the crow flies.
*/
public void add(int col, int row, double strength, int reach) {
for (int r = Math.max(0, row - reach); r <= Math.min(height - 1, row + reach); r++) {
for (int c = Math.max(0, col - reach); c <= Math.min(width - 1, col + reach); c++) {
double distance = Math.hypot(c - col, r - row);
if (distance >= reach + 1) continue;
values[r * width + c] += strength * (1 - distance / (reach + 1));
}
}
}
/** The strength at a tile; 0 outside the map. */
public double at(int col, int row) {
return col >= 0 && row >= 0 && col < width && row < height ? values[row * width + col] : 0;
}
/** The tile of the greatest strength, the first of them from the top left; {@code null} if all are 0. */
public Cell strongest() {
int best = -1;
for (int i = 0; i < values.length; i++) if (values[i] > 0 && (best < 0 || values[i] > values[best])) best = i;
return best < 0 ? null : new Cell(best % width, best / width);
}
/** Of {@code candidates}, the one with the least strength on it; the first of them on a tie. */
public Cell weakest(Iterable<Cell> candidates) {
Cell best = null;
double least = Double.MAX_VALUE;
for (Cell cell : candidates) {
double value = at(cell.col(), cell.row());
if (value < least) {
least = value;
best = cell;
}
}
return best;
}
/**
* When the time has come and the army is large enough, as many idle armed vehicles as this attack
* needs go hunting towards the enemy building that is defended worst.
*/
private void attack(Game game) {
if (game.time() < nextAttack) return;
List<Unit> army = new ArrayList<>();
for (Unit unit : game.units()) {
if (unit.side() == side && unit.type().armed() && unit.task() == Task.IDLE) army.add(unit);
}
int needed = Math.min(mission.army() + wave * mission.grows(), ARMY);
if (army.size() < needed) return;
Cell target = weakest(game);
if (target == null) return;
// The vehicles made first go; the rest stay at home.
game.hunt(army.subList(0, needed), target);
wave++;
nextAttack = game.time() + mission.every();
}
/** The enemy building with the least of the enemy's strength around it. */
Cell weakest(Game game) {
InfluenceMap map = new InfluenceMap(game.level().width(), game.level().height());
for (Unit unit : game.units()) {
Weapon weapon = unit.type().weapon();
if (unit.side() == side || weapon == null) continue;
Cell cell = unit.cell();
map.add(cell.col(), cell.row(), weapon.damage() / weapon.reload(), (int) Math.ceil(weapon.range()) + 2);
}
List<Cell> candidates = new ArrayList<>();
for (Building building : game.buildings()) {
if (building.side() == side) continue;
Weapon weapon = building.type().weapon();
if (weapon != null) {
map.add(building.centre().col(), building.centre().row(), weapon.damage() / weapon.reload(),
(int) Math.ceil(weapon.range()) + 2);
}
candidates.add(building.centre());
}
influence = map;
return map.weakest(candidates);
}
F3 shows the map as the computer last saw it, over the battlefield:

The strength of a weapon is its damage per second, and its reach a little more than its range. That is a guess, and it is meant to be one: an opponent that knows exactly where to strike is no fun, and one that attacks the strongest point is no opponent. Influence maps are as old as strategy games and still in use, because what they decide can be seen, as above, which no other kind of game AI offers so simply.
The test lets the computer play mission 1 alone for eight minutes and checks that it has built a base, harvested and attacked:
@Test
void theComputerBuildsItsBaseAndAttacks() {
Game game = new Game(Level.load(1, RULES), RULES, 3, Set.of(Side.RED));
List<Event> events = run(game, 480);
long placed = events.stream().filter(e -> e instanceof Event.Placed p && p.building().side() == Side.RED).count();
assertTrue(placed >= 5, "it built " + placed);
assertTrue(game.commander(Side.RED).wave() >= 1, "and attacked");
assertTrue(game.house(Side.RED).harvested() > 2000, "with crystal it harvested");
assertTrue(game.units().stream().anyMatch(u -> u.side() == Side.RED && u.task() == Task.HUNT)
|| game.buildings().stream().anyMatch(b -> b.side() == Side.BLUE && b.health() < b.maxHealth()),
"its army is out hunting, or has found the base");
}
On the title screen it plays against itself, on both sides, at double speed.
Step 9: Saving the whole game¶
Jump (chapter 27) saved progress: a handful of numbers. A running battle is a few hundred things, each with a dozen values, many of them in lists: vehicles, buildings, shots in the air, airlifters, both production queues of both sides, the fog, the ghosts, the crystal on every tile, the computer's plans, and the random numbers. The engine's SaveFile holds keys and values; a Snapshot gives the lists names it can hold and a person can read:
public final class Snapshot {
private Snapshot() {
}
public static Writer writer() {
return new Writer(new TreeMap<>(), "");
}
public static Reader reader(Map<String, String> values) {
return new Reader(values, "");
}
/** A key that is missing or does not hold what was asked for; a game throws it, too, for a value it cannot use. */
public static final class SnapshotException extends IllegalArgumentException {
public SnapshotException(String message) {
super(message);
}
}
/** Writes values under a prefix; {@link #add} opens the next element of a list. */
public static final class Writer {
private final Map<String, String> values;
private final String prefix;
private Writer(Map<String, String> values, String prefix) {
this.values = values;
this.prefix = prefix;
}
public Writer put(String key, String value) {
values.put(prefix + key, value);
return this;
}
public Writer put(String key, int value) {
return put(key, Integer.toString(value));
}
public Writer put(String key, long value) {
return put(key, Long.toString(value));
}
/** {@link Double#toString} gives the shortest text that reads back as exactly the same double. */
public Writer put(String key, double value) {
return put(key, Double.toString(value));
}
public Writer put(String key, boolean value) {
return put(key, Boolean.toString(value));
}
public Writer put(String key, Enum<?> value) {
return put(key, value.name());
}
/** The values of one thing under {@code key}, such as {@code base.x}, without a list around it. */
public Writer child(String key) {
return new Writer(values, prefix + key + ".");
}
/** The next element of the list {@code key}: {@code key.0.}, {@code key.1.} and so on, and a count. */
public Writer add(String key) {
String count = prefix + key + ".count";
int index = Integer.parseInt(values.getOrDefault(count, "0"));
values.put(count, Integer.toString(index + 1));
return new Writer(values, prefix + key + "." + index + ".");
}
/** An empty list, so that reading it gives no elements rather than a missing key. */
public Writer list(String key) {
values.putIfAbsent(prefix + key + ".count", "0");
return this;
}
/** Everything written, sorted by key. */
public Map<String, String> values() {
return Collections.unmodifiableMap(values);
}
}
/** Reads what a {@link Writer} wrote, under a prefix. */
public static final class Reader {
private final Map<String, String> values;
private final String prefix;
private Reader(Map<String, String> values, String prefix) {
this.values = values;
this.prefix = prefix;
}
public boolean has(String key) {
return values.containsKey(prefix + key);
}
public String text(String key) {
String value = values.get(prefix + key);
if (value == null) throw new SnapshotException("missing " + prefix + key);
return value;
}
public String text(String key, String fallback) {
return values.getOrDefault(prefix + key, fallback);
}
public int integer(String key) {
try {
return Integer.parseInt(text(key));
} catch (NumberFormatException e) {
throw new SnapshotException(prefix + key + " is not a whole number: " + values.get(prefix + key));
}
}
public long whole(String key) {
try {
return Long.parseLong(text(key));
} catch (NumberFormatException e) {
throw new SnapshotException(prefix + key + " is not a whole number: " + values.get(prefix + key));
}
}
public double number(String key) {
try {
return Double.parseDouble(text(key));
} catch (NumberFormatException e) {
throw new SnapshotException(prefix + key + " is not a number: " + values.get(prefix + key));
}
}
public boolean bool(String key) {
String value = text(key);
if (!value.equals("true") && !value.equals("false")) {
throw new SnapshotException(prefix + key + " is neither true nor false: " + value);
}
return value.equals("true");
}
public <E extends Enum<E>> E choice(String key, Class<E> type) {
String value = text(key);
try {
return Enum.valueOf(type, value);
} catch (IllegalArgumentException e) {
throw new SnapshotException(prefix + key + " is no " + type.getSimpleName() + ": " + value);
}
}
public Reader child(String key) {
return new Reader(values, prefix + key + ".");
}
/** The elements of the list {@code key}, in the order they were added. */
public List<Reader> list(String key) {
int count = integer(key + ".count");
List<Reader> list = new ArrayList<>(count);
for (int i = 0; i < count; i++) list.add(new Reader(values, prefix + key + "." + i + "."));
return list;
}
}
}
The goal is not "close enough". A loaded battle must go on exactly as the saved one would have, step for step, or the test below cannot be written, and any bug in saving hides behind "the battle went a little differently". Three things make that possible:
- Numbers to the bit.
Double.toStringwrites the shortest text that reads back as exactly the same double. Not%.2f, not%.6f. - The random numbers. The rockets scatter by random numbers. The generator of chapter 22 is SplitMix64, whose whole state is one
long; saved with the game, it lets the loaded game draw the very numbers the saved one would have drawn next. - Nothing derived. What follows from other values (the walls of buildings on the map, the power made and used, the storage, the flow fields) is not saved but worked out again after loading. What is not saved cannot be saved wrong.
/**
* Where the generator stands: the one number that, saved with a game, lets {@link #resume} draw exactly
* the numbers this one would have drawn next. SplitMix64 has no other state.
*/
public long state() {
return state;
}
/** A generator that started with {@code seed} and stands at {@code state}, as {@link #state()} said. */
public static SeededRandom resume(long seed, long state) {
SeededRandom random = new SeededRandom(seed);
random.state = state;
return random;
}
The game's Archive writes and reads it all:
public static Map<String, String> write(Game game) {
Snapshot.Writer w = Snapshot.writer();
w.put("mission", game.level().number());
w.put("time", game.time).put("thought", game.thought).put("next", game.nextId);
w.put("phase", game.phase).put("winner", game.winner == null ? "none" : game.winner.id());
w.put("seed", game.random.seed()).put("random", game.random.state());
w.put("crystal", crystal(game.crystal));
for (House house : game.houses.values()) house(w.child("house." + house.side().id()), house);
for (Commander commander : game.commanders.values()) {
w.add("commander").put("side", commander.side().id()).put("attack", commander.nextAttack)
.put("wave", commander.wave).put("mixed", commander.mixed).put("failures", commander.failures);
}
w.list("building");
for (Building b : game.buildings) {
w.add("building").put("id", b.id()).put("kind", b.type().id()).put("side", b.side().id())
.put("col", b.col()).put("row", b.row()).put("health", b.health).put("turret", b.turret)
.put("reload", b.reload).put("since", b.sinceShot).put("docked", b.docked).put("built", b.built);
}
w.list("unit");
for (Unit u : game.units) {
Snapshot.Writer unit = w.add("unit").put("id", u.id()).put("kind", u.type().id())
.put("side", u.side().id()).put("x", u.x).put("y", u.y).put("angle", u.angle)
.put("turret", u.turret).put("health", u.health).put("task", u.task).put("target", u.target)
.put("reload", u.reload).put("since", u.sinceShot).put("load", u.load).put("work", u.work)
.put("stuck", u.stuck).put("moving", u.moving).put("harvesting", u.harvesting);
cell(unit, "goal", u.goal);
cell(unit, "way", u.way);
cell(unit, "guard", u.guard);
cell(unit, "field", u.field);
}
w.list("shot");
for (Shot s : game.shots) {
w.add("shot").put("weapon", owner(game.rules(), s.weapon)).put("side", s.side.id())
.put("shooter", s.shooter).put("target", s.target).put("x", s.x).put("y", s.y)
.put("toX", s.toX).put("toY", s.toY).put("angle", s.angle).put("left", s.left)
.put("flight", s.flight);
}
w.list("airlifter");
for (Airlifter c : game.airlifters) {
w.add("airlifter").put("side", c.side.id()).put("x", c.x).put("y", c.y).put("angle", c.angle)
.put("toX", c.toX).put("toY", c.toY).put("homeX", c.homeX).put("homeY", c.homeY)
.put("cargo", c.cargo == null ? "none" : c.cargo.id());
}
if (game.burrower != null) {
Burrower m = game.burrower;
w.child("burrower").put("x", m.x).put("y", m.y).put("angle", m.angle).put("up", m.up).put("timer", m.timer)
.put("prey", m.prey).put("toX", m.toX).put("toY", m.toY).put("sniff", m.sniff)
.put("meals", m.meals);
}
return w.values();
}
/** The battle a save file holds, on the level of its mission. */
public static Game read(Map<String, String> values, Rules rules, java.util.function.IntFunction<Level> levels) {
Snapshot.Reader r = Snapshot.reader(values);
Level level = levels.apply(r.integer("mission"));
Set<Side> computer = EnumSet.noneOf(Side.class);
List<Snapshot.Reader> commanders = r.list("commander");
for (Snapshot.Reader c : commanders) computer.add(side(c, "side"));
Game game = new Game(level, rules, r.whole("seed"), computer, false);
game.random = SeededRandom.resume(r.whole("seed"), r.whole("random"));
game.time = r.number("time");
game.thought = r.number("thought");
game.nextId = r.integer("next");
game.phase = r.choice("phase", Game.Phase.class);
String winner = r.text("winner");
game.winner = winner.equals("none") ? null : Side.ofId(winner);
int[] crystal = new int[game.crystal.length];
byte[] bytes = Base64.getDecoder().decode(r.text("crystal"));
if (bytes.length != crystal.length) throw new Snapshot.SnapshotException("the crystal is not of this map");
for (int i = 0; i < crystal.length; i++) crystal[i] = bytes[i] & 0xFF;
game.restoreCrystal(crystal);
for (Snapshot.Reader c : commanders) {
Commander commander = game.commanders.get(side(c, "side"));
commander.nextAttack = c.number("attack");
commander.wave = c.integer("wave");
commander.mixed = c.integer("mixed");
commander.failures = c.integer("failures");
}
for (Snapshot.Reader b : r.list("building")) {
Building building = new Building(b.integer("id"), rules.building(b.text("kind")), side(b, "side"),
b.integer("col"), b.integer("row"), b.number("built"));
building.health = b.number("health");
building.turret = building.lastTurret = b.number("turret");
building.reload = b.number("reload");
building.sinceShot = b.number("since");
building.docked = b.integer("docked");
game.add(building);
}
for (Snapshot.Reader u : r.list("unit")) {
Unit unit = new Unit(u.integer("id"), rules.unit(u.text("kind")), side(u, "side"), u.number("x"),
u.number("y"), u.number("angle"));
unit.turret = unit.lastTurret = u.number("turret");
unit.health = u.number("health");
unit.task = u.choice("task", Task.class);
unit.target = u.integer("target");
unit.reload = u.number("reload");
unit.sinceShot = u.number("since");
unit.load = u.integer("load");
unit.work = u.number("work");
unit.stuck = u.number("stuck");
unit.moving = u.bool("moving");
unit.harvesting = u.bool("harvesting");
unit.goal = cell(u, "goal");
unit.way = cell(u, "way");
unit.guard = cell(u, "guard");
unit.field = cell(u, "field");
game.add(unit);
}
for (Snapshot.Reader s : r.list("shot")) {
Shot shot = new Shot(weapon(rules, s.text("weapon")), side(s, "side"), s.integer("shooter"),
s.integer("target"), s.number("x"), s.number("y"), s.number("toX"), s.number("toY"));
shot.angle = s.number("angle");
shot.left = s.number("left");
shot.flight = s.number("flight");
game.shots.add(shot);
}
for (Snapshot.Reader c : r.list("airlifter")) {
String cargo = c.text("cargo");
Airlifter airlifter = new Airlifter(side(c, "side"), c.number("homeX"), c.number("homeY"), c.number("toX"),
c.number("toY"), cargo.equals("none") ? null : rules.unit(cargo));
airlifter.x = airlifter.lastX = c.number("x");
airlifter.y = airlifter.lastY = c.number("y");
airlifter.angle = c.number("angle");
game.airlifters.add(airlifter);
}
if (r.has("burrower.x")) {
Snapshot.Reader m = r.child("burrower");
Burrower burrower = new Burrower(m.number("x"), m.number("y"));
burrower.angle = burrower.lastAngle = m.number("angle");
burrower.up = m.bool("up");
burrower.timer = m.number("timer");
burrower.prey = m.integer("prey");
burrower.toX = m.number("toX");
burrower.toY = m.number("toY");
burrower.sniff = m.number("sniff");
burrower.meals = m.integer("meals");
game.burrower = burrower;
}
game.account();
for (House house : game.houses.values()) house(r.child("house." + house.side().id()), house, rules);
game.look();
return game;
}
And the test that the design aims at: a battle of five minutes with both sides played by the computer, saved, loaded, and both run on for two more minutes. Their save files must be the same, value for value:
@Test
void aBattleSavedAndLoadedGoesOnExactlyAsItWouldHave() {
Game game = new Game(Level.load(2, RULES), RULES, 11, EnumSet.allOf(Side.class));
run(game, 300);
Map<String, String> saved = Archive.write(game);
Game loaded = Archive.read(saved, RULES, number -> Level.load(number, RULES));
assertEquals(saved, Archive.write(loaded), "nothing lost on the way");
run(game, 120);
run(loaded, 120);
assertEquals(Archive.write(game), Archive.write(loaded), "and both went on alike");
assertTrue(game.units().size() > 10, "with a battle going on: " + game.units().size());
}
When this test fails, it names the first key that differs, and that is nearly always the field someone forgot to save.
Step 10: Drawing the desert¶
The ground¶
The ground of Bricks or Towers was tiles with a little noise. A desert needs more: dunes with ripples that catch the light, rock with cracks and the shadows of cliffs, scree at the foot of the rock, and crystals that glint teal in the hollows. Terrain works out every pixel on its own, from noise and the tiles of the map, with light from the top left. That takes a second or two for a whole map, too long for the start of a mission and much too long for a frame.
So it is done on other threads, in squares of 8 × 8 tiles. A square shows the plain colours of its tiles until its picture is ready, and the player, who looks at the base first, never sees that. Each square keeps two pictures: its ground without crystal, and the colour the crystal would have on each pixel, both lit already. When harvesting changes the crystal under a square by a step, the two are mixed again, which is quick, and also done on another thread.
/**
* After every step: a square whose crystal has changed by a step is mixed again. The same terrain serves
* every game on its map, the title's and the player's, so a new game is a change, too.
*/
void update(Game game) {
boolean changed = game != this.game || game.crystalVersion() != version;
this.game = game;
version = game.crystalVersion();
for (Chunk chunk : chunks) {
if (changed) chunk.wanted = crystal(game, chunk);
boolean idle = chunk.work == null || chunk.work.isDone();
if (idle && !Arrays.equals(chunk.mixed, chunk.wanted)) schedule(chunk);
}
}
/** The square's picture with the crystal in {@code steps}: the ground and the crystal's colour mixed, pixel by pixel. */
private BufferedImage mix(Chunk chunk, byte[] steps) {
int[] ground = chunk.ground;
int[] crystal = chunk.crystal;
float[] weight = chunk.weight;
float[] amount = new float[(CHUNK + 2) * (CHUNK + 2)];
for (int i = 0; i < amount.length; i++) amount[i] = steps[i] / (float) STEPS;
int width = Math.min(SIDE, (level.width() - chunk.col) * Field.TILE * PIXELS);
int height = Math.min(SIDE, (level.height() - chunk.row) * Field.TILE * PIXELS);
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
int[] out = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
double x0 = chunk.col * Field.TILE;
double y0 = chunk.row * Field.TILE;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int i = y * SIDE + x;
int col = ground[i];
if (weight[i] > 0) {
double wx = x0 + x / (double) PIXELS;
double wy = y0 + y / (double) PIXELS;
double s = local(amount, warpX(wx, wy) - x0, warpY(wx, wy) - y0);
double cover = Art.smooth(0.06, 0.2, s) * (0.75 + 0.25 * Art.smooth(0.2, 0.9, s));
if (cover > 0) col = Art.mix(col, crystal[i], cover * weight[i]);
}
out[y * width + x] = col;
}
}
pebbles(image, chunk, x0, y0);
return image;
}
A picture made on another thread is handed over through an AtomicReference, and the game's thread takes it at the start of a frame: a worker never draws on a picture the game is showing, and no lock is needed. The first version had a grid of faint squares in the dunes. The height of the ground stepped at every tile's middle, and the light showed the step; the heights are now eased between the middles, with a smooth step, and the grid is gone.
The things¶
The buildings, vehicles in 32 directions, the harvester with five fillings of its hopper, the burrower, explosions in eight pictures at three sizes: all are drawn once from vector shapes when the window opens, at the size of the screen, and copied after that. Pictures holds them:
final class Pictures {
static final int TURNS = 32;
/** A harvester's hopper is drawn empty, full and at three steps between. */
static final int LOADS = 5;
/** An explosion grows and fades in this many pictures, at each of {@link #BLASTS} sizes. */
static final int FRAMES = 8;
static final double[] BLASTS = {3.5, 7, 13};
static final double[] PUFFS = {2.5, 4, 5.5, 7, 8.5};
private final Map<String, Sprite> buildings = new HashMap<>();
private final Map<String, Sprite> rubble = new HashMap<>();
private final Map<String, Sprite> icons = new HashMap<>();
private final Map<String, Rotations> vehicles = new HashMap<>();
private final Map<String, Rotations> shadows = new HashMap<>();
private final Map<String, Sprite> wrecks = new HashMap<>();
private final Map<Side, Rotations> turrets = new EnumMap<>(Side.class);
private final Map<Side, Rotations> heads = new EnumMap<>(Side.class);
private final Map<Side, Rotations> airlifters = new EnumMap<>(Side.class);
final Rotations airlifterShadow = Rotations.of(TURNS, 26, (g, w, h) -> {
g.translate(w / 2, h / 2);
Art.airlifterShadow(g);
});
final Rotations shell = Rotations.of(TURNS, 16, (g, w, h) -> {
g.translate(w / 2, h / 2);
Art.shell(g);
});
final Rotations rocket = Rotations.of(TURNS, 18, (g, w, h) -> {
g.translate(w / 2, h / 2);
Art.rocket(g);
});
final Rotations flash = Rotations.of(TURNS, 20, (g, w, h) -> {
g.translate(w / 2, h / 2);
Art.flash(g);
});
final Sprite burrower = Sprite.drawn(44, 44, (g, w, h) -> {
g.translate(w / 2, h / 2);
Art.burrower(g, 22);
});
final Sprite burrowerSign = Sprite.drawn(32, 28, (g, w, h) -> {
g.translate(w / 2, h / 2);
Art.burrowerSign(g);
});
final Sprite grain = Sprite.drawn(10, 10, (g, w, h) -> Art.crystalGrain(g, 5, 5, 5));
/** By size, then frame. */
final Sprite[][] blasts = new Sprite[BLASTS.length][FRAMES];
final Sprite[] puffs = new Sprite[PUFFS.length];
A building is made of blocks with a roof, walls in the light and in the shadow, and a shadow on the ground, all from one method:
/** A box seen from above and a little from the south: roof, front wall, and its shadow to the bottom right. */
static void block(Graphics2D g, double x, double y, double w, double h, double height, int roof, int face) {
Path2D shadow = new Path2D.Double();
shadow.moveTo(x + w, y + 2);
shadow.lineTo(x + w + height * 0.8, y + 2 + height * 0.9);
shadow.lineTo(x + w + height * 0.8, y + h + height * 0.5);
shadow.lineTo(x + height * 0.4, y + h + height * 0.5);
shadow.lineTo(x, y + h);
shadow.lineTo(x + w, y + h);
shadow.closePath();
// The ground darkens right at the walls.
for (int i = 3; i >= 1; i--) {
g.setColor(new Color(20, 12, 6, 22));
g.fill(new RoundRectangle2D.Double(x - i * 0.8, y + h - height * 0.3 - i * 0.6, w + i * 1.6,
height * 0.3 + i * 1.6, 3, 3));
}
g.setColor(new Color(20, 12, 6, 95));
g.fill(shadow);
The battlefield is drawn in layers, ground first, the fog over everything that moves, the marks of the player's choice over the fog:
/** The battle as {@code viewer} sees it, or all of it for {@code null}, as on the title screen. */
void paint(Graphics2D g, Game game, Side viewer, 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));
int fromCol = (int) Math.floor(camera.x(alpha) / Field.TILE) - 1;
int fromRow = (int) Math.floor(camera.y(alpha) / Field.TILE) - 1;
int toCol = (int) ((camera.x(alpha) + camera.viewWidth()) / Field.TILE) + 1;
int toRow = (int) ((camera.y(alpha) + camera.viewHeight()) / Field.TILE) + 1;
terrain.render(g, camera, alpha);
effects.renderGround(g, camera, alpha);
Burrower burrower = game.burrower();
if (burrower != null && !burrower.up() && sees(game, viewer, burrower.x(alpha), burrower.y(alpha))) {
effects.renderBurrower(g, burrower, alpha);
}
if (viewer != null) ghosts(g, game, viewer, camera, alpha);
for (Building building : game.buildings()) {
if (shows(game, viewer, building)) building(g, building, camera, alpha);
}
for (Unit unit : game.units()) {
if (!shows(game, viewer, unit)) continue;
Rotations shadow = pictures.shadow(unit.type());
shadow.draw(g, unit.x(alpha) + Art.LX, unit.y(alpha) + Art.LY, unit.angle(alpha));
}
for (Unit unit : game.units()) if (shows(game, viewer, unit)) unit(g, unit, alpha);
if (burrower != null && burrower.up() && sees(game, viewer, burrower.x(alpha), burrower.y(alpha))) {
effects.renderBurrower(g, burrower, alpha);
}
for (Shot shot : game.shots()) {
if (sees(game, viewer, shot.x(alpha), shot.y(alpha))) shot(g, shot, alpha);
}
effects.render(g, alpha);
for (Airlifter airlifter : game.airlifters()) airlifter(g, airlifter, alpha);
if (viewer != null) fogPainter.render(g, game.house(viewer).fog(), fromCol, fromRow, toCol, toRow);
if (marks.influence() != null) influence(g, marks.influence(), fromCol, fromRow, toCol, toRow);
marks(g, marks, viewer, alpha);
g.setTransform(screen);
g.setClip(clip);
}
The panel, and a frame time measured¶
The first benchmark of the finished game said 17 to 22 milliseconds a frame at 1920 × 1200, without a graphics card: too slow. The profiler (Java Flight Recorder, -XX:StartFlightRecording) said where: 70 % of each frame went into the panel, not the battlefield. Its background, its tabs, buttons and build cells were filled with gradients in every frame, and a gradient is drawn pixel by pixel in software. None of it changes from one frame to the next. Drawn once into sprites, faces, and copied after that, the frame took 11 to 14 milliseconds. The lesson of chapter 21 again, and the reason the appendix starts with it: measure first, and then look at what you would not have suspected.
On a wide screen the canvas widens, as in Towers, up to 1024 units, and the battlefield gets all of the extra width. The panel is laid out for 640 and stays as it is: it is drawn moved to the right edge (g.translate(width - 640, 0)), and the mouse is moved back by as much before the panel's buttons, cells and minimap are asked whether they were clicked. Not one of its rectangles needed to change:
Test: the panel at the right edge
@Test
void aWideScreenShowsMoreMapAndMovesThePanelToTheRightEdge() {
SandsGame.PlayScene play = mission();
game.resize(711); // 16:9: 711 × 400
steps(2);
assertEquals(Field.VIEW_WIDTH + 71, play.camera().viewWidth(), 1e-9, "the map gets the extra width");
// The minimap is 71 units further right; once there is radar, a click on it moves the view.
Cheat.build(play.game(), Side.BLUE, "outpost");
if (play.game().house(Side.BLUE).lowPower()) Cheat.build(play.game(), Side.BLUE, "turbine");
steps(2);
double before = play.camera().x();
moveTo(play.minimap().x() + 71 + play.minimap().width() - 2, play.minimap().y() + play.minimap().height() - 2);
click(MouseEvent.BUTTON1);
assertNotEquals(before, play.camera().x());
// The mouse at the real right edge of the screen moves the map to the right.
play.camera().jumpTo(0, 0);
moveTo(710, 200);
double x = play.camera().x();
steps(10);
assertTrue(play.camera().x() > x);
}
Step 11: Everything else¶
- Events. The rules say what happened in a step as a list of records, as since chapter 26: fired, hit, burst, destroyed, collapsed, ready, placed, deployed, under attack, the burrower rose, the burrower ate.
Effectsturns them into explosions, smoke, wrecks and sounds, but only where the player sees them, or for the player's own side: the player must not hear a battle in the fog. - Messages. The same events become the line at the bottom of the map: Construction complete, Our base is under attack, Harvester swallowed by the burrower.
- Music. A slow desert tune in MML (chapter 24), and short ones for winning and losing.
- Scenes. Title (the computer against itself), the choice of a mission, the battle, the pause (continue, save, load, leave), the result with the numbers of both sides.

/** Shows what happened in the last step, and plays it if {@code audible}, as far as {@code viewer} can see it. */
void react(Game game, Side viewer, boolean audible) {
for (Event event : game.events()) {
switch (event) {
case Event.Fired(double x, double y, double angle, Weapon.Kind kind, Side side) -> {
if (kind == Weapon.Kind.ROCKET) {
puff(x - Math.cos(angle) * 4, y - Math.sin(angle) * 4, 1);
if (heard(game, viewer, audible, x, y, side)) play(sounds.rocket);
} else if (heard(game, viewer, audible, x, y, side)) {
Unit shooter = game.unitAt(x, y);
play(shooter != null && shooter.type().turret() || shooter == null ? sounds.cannon : sounds.gun);
}
}
case Event.Hit(double x, double y) -> {
blast(x, y, 0);
sparkle(x, y, 4, SPARK, 40);
if (heard(game, viewer, audible, x, y, null)) play(sounds.hit);
}
case Event.Burst(double x, double y, double radius) -> {
blast(x, y, Pictures.blast(radius));
sparkle(x, y, 10, FIRE, 50);
dust(x, y, 8, DUST);
puff(x, y, 2);
if (heard(game, viewer, audible, x, y, null)) play(sounds.burst);
}
case Event.Destroyed(Unit unit) -> {
blast(unit.x(), unit.y(), 2);
sparkle(unit.x(), unit.y(), 16, FIRE, 60);
sparkle(unit.x(), unit.y(), 8, METAL, 40);
for (int i = 0; i < 3; i++) puff(unit.x(), unit.y(), 3);
wrecks.add(new Wreck(unit.x(), unit.y(), unit.type(), time));
if (heard(game, viewer, audible, unit.x(), unit.y(), unit.side())) play(sounds.boom);
}
case Event.Collapsed(Building building) -> {
BuildingType type = building.type();
for (int i = 0; i < type.width() * type.height(); i++) {
double x = (building.col() + random.nextDouble() * type.width()) * Field.TILE;
double y = (building.row() + random.nextDouble() * type.height()) * Field.TILE;
blast(x, y, 2);
puff(x, y, 4);
sparkle(x, y, 6, FIRE, 60);
dust(x, y, 6, METAL);
}
rubble.add(new Rubble(building.col() * Field.TILE, building.row() * Field.TILE, type, time));
if (heard(game, viewer, audible, building.x(), building.y(), building.side())) play(sounds.collapse);
}
case Event.Placed(Building building) -> {
BuildingType type = building.type();
for (int i = 0; i < 2 * (type.width() + type.height()); i++) {
double x = (building.col() + random.nextDouble() * type.width()) * Field.TILE;
double y = (building.row() + type.height()) * Field.TILE;
dust(x, y, 1, DUST);
}
if (building.side() == viewer && audible) play(sounds.placed);
}
case Event.Sold(Building building, int refund) -> {
dust(building.x(), building.y(), 20, DUST);
say(building.x(), building.y(), "+" + refund, Hud.AMBER);
if (building.side() == viewer && audible) play(sounds.credits);
}
case Event.Unloaded(Unit harvester, int credits) -> {
if (harvester.side() == viewer) {
say(harvester.x(), harvester.y() - 10, "+" + credits, Hud.AMBER);
if (audible) play(sounds.credits);
}
}
case Event.Ready(Side side, BuildingType type) -> {
if (side == viewer && audible) play(sounds.ready);
}
case Event.Deployed(Unit unit) -> {
if (unit.side() == viewer && audible) play(sounds.ready);
}
case Event.UnderAttack(Side side, double x, double y) -> {
if (side == viewer && audible) play(sounds.alarm);
}
case Event.BurrowerRose(double x, double y) -> {
dust(x, y, 24, DUST);
if (heard(game, viewer, audible, x, y, null)) play(sounds.burrower);
}
case Event.BurrowerAte(Unit unit) -> {
dust(unit.x(), unit.y(), 30, DUST);
sparkle(unit.x(), unit.y(), 8, METAL, 30);
if (audible && (unit.side() == viewer || visible(game, viewer, unit.x(), unit.y()))) play(sounds.boom);
}
case Event.Over(Side winner) -> {
if (audible && viewer != null) mixer.playMusic(winner == viewer ? music.won : music.lost, false);
}
case Event.StorageFull storageFull -> {
}
}
}
}
What moved into the engine¶
| Package | What | For |
|---|---|---|
select |
Selection, BoxSelect |
choosing units by click and box, groups on the number keys |
path |
Formation (slots, their assignment, a clear line), FieldCache |
moving groups, a flow field per order |
fog |
FogOfWar, FogPainter |
what each side sees, with soft edges |
minimap |
Minimap |
the map in a corner, painted per tile |
economy |
Credits, Production |
money with a store, queues paid while they run |
ai |
InfluenceMap |
where a side is strong, for the computer's decisions |
save |
Snapshot |
a whole game as keys and values, exact |
random |
SeededRandom.state and resume |
random numbers that go on after loading |
Each of them is a page or two, each has its tests, and none of them knows of crystal or tanks: the economy game of chapter 32 uses the minimap as it is, next to goods of its own.
Exercises¶
- Repair. Damaged buildings are repaired for credits while the player holds a button. What does that mean for
Production, which knows only orders with a fixed price? Is repair an order? - Formation shapes. The slots are a blob around the target. Give the group a line facing the way it drives, the launchers behind the tanks. Where in
Formationdoes that go, and what doesassignneed to know? - A smarter harvest. The computer's harvesters drive to the nearest crystal even where the player's tanks wait. Use the influence map to send them to the safest field instead.
- Fog for the computer. The computer's attack sees the player's buildings through the fog (
weakestlooks at all of them). Give it only what its own fog has seen, its ghosts, and watch it scout. - Replays. The battle is exact from a save; the inputs are few (orders, builds). Record the orders with their step numbers, as Snake recorded keys (chapter 22), and play a whole battle back from the start.
- A third house.
Sidehas two values. What would three houses need: in the fog, in the influence map, injudge?