41. Loop, Time and Drawing¶
A real-time game is a loop: read the input, advance the rules, draw a frame, again and again. This chapter covers the parts of the engine that keep that loop going and bring its picture to the screen: the loop and its fixed steps, the clocks for rules that need a pace or a countdown of their own, the canvas, full screen and the measuring of frames. At the end comes the other way to animate, for games that wait for the player.
The game loop: GameLoop and FixedStep¶
Package loop · introduced in 20. Paddle · source: GameLoop, FixedStep
What it is for. Every game from Paddle to Sands runs on a GameLoop: a thread of its own that runs commands posted from Swing, advances the rules in fixed steps, usually 120 a second, and draws a frame, at most 60 a second. You give it two functions: update(seconds), which advances the rules by exactly one step, and render(alpha), which draws.
When to choose it. For any game in which something moves while the player does nothing. A game that only reacts to clicks (Mines, Solitaire) needs no loop: Model-View-Presenter and Swing's own painting fit it better, with an Animator for the few moments something moves.
How it works. Real time comes in uneven pieces: one frame took 15 ms, the next 18. The rules want even steps, because a ball that moves 18/15 as far in one step as in another bounces differently, and because tests must be able to repeat a game step for step. FixedStep is the arithmetic between the two: it collects the elapsed time, says how many whole steps are due, and keeps the rest for the next frame.
public final class FixedStep {
private final long step;
private final int maxSteps;
private long accumulated;
/**
* @param stepNanos length of one step in nanoseconds
* @param maxSteps most steps per frame; after a long pause (a debugger, a suspended laptop) the game
* does not try to catch up for minutes but simply continues
*/
public FixedStep(long stepNanos, int maxSteps) {
if (stepNanos <= 0 || maxSteps < 1) throw new IllegalArgumentException();
this.step = stepNanos;
this.maxSteps = maxSteps;
}
/** Adds the time since the last frame and returns how many steps to run now. */
public int advance(long elapsedNanos) {
accumulated += Math.max(0, elapsedNanos);
long steps = accumulated / step;
if (steps > maxSteps) {
accumulated = 0;
return maxSteps;
}
accumulated -= steps * step;
return (int) steps;
}
/** How far the time is between the last step and the next, 0 to 1, for drawing in between. */
public double alpha() {
return (double) accumulated / step;
}
/** One step in seconds, the {@code dt} of the rules. */
public double seconds() {
return step / 1e9;
}
}
The rest of a step is alpha(), 0 to 1: how far the time is between the last step and the next. render(alpha) gets it, so the drawing can place a moving thing between its last two positions and the movement looks smooth at any frame rate (drawing between steps). After a long pause (a debugger, a suspended laptop) the loop runs at most 8 steps and forgets the rest, instead of racing through minutes of game.
The loop itself is short. Commands come first, so a menu's New Game takes effect before the next step; an exception in a frame is logged and the loop goes on; the rest of the frame is slept away.
private void run() {
long last = System.nanoTime();
while (running) {
long frameStart = System.nanoTime();
int steps = step.advance(frameStart - last);
last = frameStart;
try {
for (Runnable command; (command = commands.poll()) != null; ) command.run();
for (int i = 0; i < steps; i++) update.accept(step.seconds());
render.accept(step.alpha());
} catch (RuntimeException e) {
// One broken frame must not end the game; the log tells what happened.
LOG.error("Error in the game loop", e);
}
// Sleep away the rest of the frame instead of burning a CPU core.
long rest = frameNanos - (System.nanoTime() - frameStart);
if (rest > 0) LockSupport.parkNanos(rest);
}
}
| Method | What it does |
|---|---|
new GameLoop(updatesPerSecond, framesPerSecond, update, render) |
The loop; 120 and 60 in every game of this book |
start(), stop(), running() |
Starts the thread, stops it and waits for it to end |
post(command) |
Runs command on the loop thread before the next step; from any thread |
FixedStep.advance(elapsedNanos), alpha(), seconds() |
The arithmetic, usable without the loop in tests |
Pitfall: touching the game from Swing
Everything the rules own belongs to the loop thread. A menu action that changes the game directly races with the step that is running. Wrap it in loop.post(...). The input classes need no posting: they are made to be written by Swing and read by the loop (chapter 20).
Test: a fixed step in uneven frames
@Test
void unevenFramesBecomeEvenSteps() {
FixedStep step = new FixedStep(10 * MS, 8); // steps of 10 ms
assertEquals(1, step.advance(15 * MS)); // 15 ms: one step, 5 ms left
assertEquals(0.5, step.alpha(), 1e-9);
assertEquals(2, step.advance(18 * MS)); // 5 + 18 = 23 ms: two steps, 3 ms left
assertEquals(0.3, step.alpha(), 1e-9);
assertEquals(0, step.advance(4 * MS)); // 7 ms: not yet a step
assertEquals(0.01, step.seconds(), 1e-12);
}
A pace of its own: Ticker¶
Package loop · introduced in 22. Snake · source: Ticker
What it is for. Rules that move in ticks slower than the update step: the snake that moves one cell every 150 ms, the walls that fall one by one at the end of a round of Blast, the heartbeat of Rocks that beats faster the longer a wave lasts. The loop still steps 120 times a second, for input and effects; the ticker counts the steps' time and says when a tick is due.
When to choose it. For something that happens regularly, at a pace that may change. For something that happens once, after a while, use Timers; for things that move smoothly, the update step itself.
How it works. advance(seconds) adds the time and returns how many ticks are due, usually 0 or 1, and keeps the remainder. A nanosecond of tolerance makes 120 steps of 1/120 s count as the full second that floating point misses by 1e-15. setInterval changes the pace from the next tick on, keeping the time since the last one, so a snake that speeds up does not jump. progress(alpha * seconds) says how far the time is between two ticks, for drawing a snake that glides from cell to cell (chapter 22).
public final class Ticker {
private static final double EPSILON = 1e-9;
private double interval;
private double elapsed;
private long count;
/** @param interval seconds between two ticks */
public Ticker(double interval) {
setInterval(interval);
}
/** Adds {@code seconds} of game time and returns how many ticks are due now, usually 0 or 1. */
public int advance(double seconds) {
elapsed += Math.max(0, seconds);
int due = 0;
// A nanosecond of tolerance: 120 steps of 1/120 s add up to 0.99999999 s in floating point, and a
// tick due at exactly one second must not wait for the next step.
while (elapsed + EPSILON >= interval) {
elapsed = Math.max(0, elapsed - interval);
due++;
}
count += due;
return due;
}
/**
* How far the time is from the last tick to the next, 0 to 1, for drawing in between.
*
* @param ahead time that has passed since the last update step, usually {@code alpha} times the step
*/
public double progress(double ahead) {
return Math.clamp((elapsed + Math.max(0, ahead)) / interval, 0, 1);
}
/** Changes the pace from the next tick on; the time since the last tick is kept. */
public void setInterval(double interval) {
if (!(interval > 0)) throw new IllegalArgumentException("interval must be positive: " + interval);
this.interval = interval;
}
public double interval() {
return interval;
}
/** The ticks so far. */
public long count() {
return count;
}
/** Back to the start: no ticks, no time. */
public void reset() {
elapsed = 0;
count = 0;
}
}
Many countdowns: Timers¶
Package time · introduced in 26. Blast · source: Timers
What it is for. Many countdowns at once, each belonging to a key: the fuse of every bomb, the flames on every tile, the time a power-up blinks. start(key, seconds) starts or restarts one, update(seconds, expired) lets time pass and hands over the keys whose time ran out.
When to choose it. When things end after a while and the order of their ending matters. Two bombs whose fuses end within the same step must explode in the order they would in real time, or a chain reaction comes out differently. A field double fuse in each bomb, counted down in a loop over the bombs, hands them over in the order of the loop instead.
How it works. All timers lose the step's seconds; those at or below zero are sorted by how far below zero they went, which is how early in the step they ran out, and on a tie by the order they were started. Only then are they removed and handed over, so a handler that starts a new timer starts it for the next step.
public final class Timers<K> {
/** A nanosecond of tolerance, as in the Ticker: 300 steps of 1/120 s leave 2.5 s at 1e-15, not at 0. */
private static final double EPSILON = 1e-9;
private static final class Timer {
double left;
long order;
}
private final Map<K, Timer> timers = new LinkedHashMap<>();
private final List<Map.Entry<K, Timer>> due = new ArrayList<>();
private long started;
/** Starts the timer of {@code key} with {@code seconds}; a running one starts again. */
public void start(K key, double seconds) {
Timer timer = timers.computeIfAbsent(key, k -> new Timer());
timer.left = seconds;
timer.order = started++;
}
/**
* Lets {@code seconds} pass. The timers that ran out are removed and handed to {@code expired}, the one
* that ran out first first. Timers the handler starts count from the next step on.
*/
public void update(double seconds, Consumer<? super K> expired) {
due.clear();
for (Map.Entry<K, Timer> entry : timers.entrySet()) {
Timer timer = entry.getValue();
timer.left -= seconds;
if (timer.left <= EPSILON) due.add(Map.entry(entry.getKey(), timer));
}
if (due.isEmpty()) return;
// The most negative ran out earliest within the step; equal times by the order they were started.
due.sort((a, b) -> {
int byTime = Double.compare(a.getValue().left, b.getValue().left);
return byTime != 0 ? byTime : Long.compare(a.getValue().order, b.getValue().order);
});
for (Map.Entry<K, Timer> entry : due) timers.remove(entry.getKey(), entry.getValue());
for (Map.Entry<K, Timer> entry : due) expired.accept(entry.getKey());
}
Besides start and update there are running(key), left(key), cancel(key), keys() (in the order they were first started), size() and clear().
Test: timers in the order they run out
@Test
void timersRunOutInTheOrderOfTheirTimeEvenWithinOneStep() {
Timers<String> fuses = new Timers<>();
fuses.start("late", 0.5);
fuses.start("early", 0.3);
fuses.start("same as early, started later", 0.3);
List<String> expired = new ArrayList<>();
fuses.update(0.2, expired::add);
assertEquals(List.of(), expired);
assertEquals(0.1, fuses.left("early"), 1e-9);
fuses.update(0.4, expired::add); // a long step: all three run out in it
assertEquals(List.of("early", "same as early, started later", "late"), expired);
assertTrue(fuses.isEmpty());
}
@Test
void aHandlerMayStartTimersThatCountFromTheNextStep() {
Timers<String> timers = new Timers<>();
timers.start("fuse", STEP);
List<String> expired = new ArrayList<>();
timers.update(STEP, key -> {
expired.add(key);
if (key.equals("fuse")) timers.start("flames", 0.5);
});
assertEquals(List.of("fuse"), expired);
assertEquals(0.5, timers.left("flames"), 1e-9, "no time taken from the new timer in this step");
}
The time of a game: GameClock¶
Package time · introduced in 10. Mines, moved into the engine with 11. Solitaire · source: GameClock
What it is for. The time one game took, as a high score records it: it starts with the first move (start() only counts the first call), stops when the game ends, and shows whole seconds the way a player expects, a started second counting as one.
When to choose it. For games that wait for the player, in which real time passes between the moves. A real-time game measures its time in steps instead: the step count times the step length is exact and repeats in a replay, the wall clock does not.
How it works. The clock reads the time from a LongSupplier of nanoseconds: System::nanoTime in the game, a number the test moves forward in tests, so a test can win a game of Mines in 4.2 seconds without waiting (chapter 10).
The seasons of a year: Seasons¶
Package time · introduced in 32. Colony · source: Seasons
What it is for. A year of seasons, each as long as the game wants: Colony's mission 3 has a long winter of 170 seconds and a short spring. It answers which season it is, which year, how far the season is, and, after each step, how many seasons began in it, so that "Winter" is said exactly once.
When to choose it. For any game time that goes round: seasons, day and night, the shifts of a factory, the tides. It only counts; what a season means (no growing, snow, more fires) is the game's.
How it works. The only state is the time since the start, so a save file keeps one number and restore sets it. Season and year are worked out from it every time they are asked: whole years first, then the seasons of the year begun. update compares the number of seasons begun before and after the step; a step longer than a season, as when a test runs fast, reports all of them.
public final class Seasons {
private final double[] lengths;
private final double yearLength;
private double time;
public Seasons(double... lengths) {
if (lengths.length == 0) throw new IllegalArgumentException("a year needs one season at least");
double sum = 0;
for (double length : lengths) {
if (length <= 0) throw new IllegalArgumentException("season of " + length + " s");
sum += length;
}
this.lengths = lengths.clone();
this.yearLength = sum;
}
/** Lets {@code seconds} pass; the number of seasons that began meanwhile. */
public int update(double seconds) {
if (seconds < 0) throw new IllegalArgumentException("negative time " + seconds);
long before = count(time);
time += seconds;
return (int) (count(time) - before);
}
/** The seasons begun since the start, the first not counted. */
private long count(double at) {
long years = (long) Math.floor(at / yearLength);
double rest = at - years * yearLength;
int season = 0;
while (season < lengths.length - 1 && rest >= lengths[season]) {
rest -= lengths[season];
season++;
}
return years * lengths.length + season;
}
/** The current season, 0 to the number of seasons less one. */
public int season() {
return (int) (count(time) % lengths.length);
}
/** The current year, from 0. */
public int year() {
return (int) (count(time) / lengths.length);
}
| Method | What it does |
|---|---|
new Seasons(150, 150, 150, 90) |
Four seasons of these lengths, in seconds |
update(seconds) |
Lets time pass; how many seasons began |
season(), year() |
The current season from 0, the current year from 0 |
progress(), left() |
How far the season is, 0 to 1; the seconds left in it |
count(), length(season) |
The number of seasons, the length of one |
time(), restore(time) |
The whole state, for a save file |
Which clock?¶
| The question | Use |
|---|---|
| How far did the ball move in this step? | The seconds of update, from GameLoop |
| Is it time for the snake's next cell? | Ticker |
| Which bombs explode now, and in which order? | Timers |
| How long has this ghost been frightened? | StateMachine.seconds() (chapter 46) |
| How long did the player need for this game of Mines? | GameClock |
| Is it winter yet? | Seasons |
| How far along is this card's flight? | Tween (chapter 42) |
The canvas: GameCanvas and RenderMode¶
Package swing · introduced in 20. Paddle, render modes and the overlay in 21. Bricks · source: GameCanvas, RenderMode
What it is for. The drawing surface of every real-time game. The game draws in fixed logical coordinates, 640 × 400 in most games, as if the screen always had that size; the canvas scales the picture to its real size, keeps the proportions and fills the rest with black bars. Drawing is active: the game loop calls render(alpha) whenever it wants a frame, and the canvas draws into a hidden buffer of a BufferStrategy and shows it at once by copying it onto the screen, never by flipping, which can leave the screen black in full screen on X11 (appendix). Swing's repaint() is not involved, so nothing waits for the event thread.
When to choose it. For a game with a GameLoop. A game that waits for the player draws in a plain JComponent with paintComponent, as Mines and Solitaire do.
How it works. A frame has two layers, drawn by two painters you give the canvas: the world (field, sprites, particles) and the overlay (score, texts, menus), both in the same logical coordinates. How the world reaches the screen is the RenderMode:
public enum RenderMode {
/**
* Everything is drawn at the full size of the screen, through one scaling transform. Sharp, but every
* shape and image costs as many pixels as it covers on the screen: on a 4K screen about 30 times as
* many as at 640 × 400.
*/
DIRECT,
/**
* The world is drawn at the logical size itself, then enlarged by a whole factor with hard pixel edges:
* the look of old games. Drawing costs 640 × 400 pixels on any screen; black bars fill what is left.
*/
PIXEL;
In DIRECT mode, world and overlay are drawn through one scaling transform, with antialiasing on. In PIXEL mode, the world is drawn into a VolatileImage of the logical size, with antialiasing off and hard interpolation, and that image is enlarged by the largest whole factor that fits (wholeScale), so every logical pixel becomes a square block. The overlay is then drawn straight onto the screen at the same place and scale, so the score stays sharp over a world of big pixels (chapter 21).
Both the screen's buffers and the pixel-mode buffer live in video memory where there is one and can be lost; the canvas draws again until they are not, and calls Toolkit.sync() after every frame so X11 does not deliver frames in bursts (appendix).
/** Draws one frame; called by the game loop on its thread. Does nothing while the canvas is not shown. */
public void render(double alpha) {
synchronized (frameLock) {
if (!isDisplayable() || getWidth() <= 0 || getHeight() <= 0) return;
BufferStrategy buffers = getBufferStrategy();
if (buffers == null) {
createBuffers();
return;
}
long start = System.nanoTime();
logicalWidth = currentWidth();
// The buffers live in video memory and can be lost (another program, a mode switch): draw again.
do {
do {
Graphics2D g = (Graphics2D) buffers.getDrawGraphics();
try {
paintFrame(g, alpha);
} finally {
g.dispose();
}
} while (buffers.contentsRestored());
buffers.show();
} while (buffers.contentsLost());
// Without this, X11 may collect frames and show them in bursts.
Toolkit.getDefaultToolkit().sync();
frameTimes.record(start, System.nanoTime());
}
}
| Method | What it does |
|---|---|
new GameCanvas(width, height, painter) |
A canvas whose painter draws everything |
new GameCanvas(width, height, painter, overlay) |
World and overlay painted separately |
render(alpha) |
Draws one frame; the loop's render function |
setRenderMode(mode), renderMode() |
DIRECT or PIXEL, from the next frame on; from any thread |
toLogical(x, y) |
A point of the component, such as a mouse event's, in logical coordinates, placed as the current mode places the picture |
setMaxLogicalWidth(max) |
Lets the picture widen up to max units to fill a wide screen; from the next frame on |
logicalWidth(), minLogicalWidth() |
The width of the current frame, and the width the canvas was made with |
widthFor(width, height, min, max, logicalHeight, mode) |
The logical width for a screen of that many pixels |
setShowFrameTimes(show), frameTimes() |
The frame-time display, and its numbers |
withoutFrames(change) |
Runs a change that takes the canvas off the screen for a moment, with no frame drawn meanwhile |
Pitfall: a heavyweight in a Swing window
The canvas is an AWT Canvas, a native window inside the Swing window. Lightweight popup menus open behind it unless JPopupMenu.setDefaultLightWeightPopupEnabled(false), and Swing's menu shortcuts never see keys the canvas receives. The canvas therefore hands every key the game does not consume on to Swing's key bindings (chapter 20).
Wide screens. The canvas keeps the proportions of its logical size, so a 640 × 400 picture has black bars on a 16:9 screen. A game whose world is larger than the screen calls setMaxLogicalWidth(max): the height stays, and the logical width grows with the screen, up to max, until the picture fills it. The game asks logicalWidth() in each update step and lays out its view and panels from it; the width is set at the start of each frame on the loop thread, so the step and the frame after it agree, and toLogical uses the same width for the mouse (chapter 30). Games on a fixed field (Paddle to Jump) keep their bars.
/**
* The logical width for a screen of {@code width} × {@code height} pixels: the height decides the scale
* ({@link #wholeScale whole} in pixel mode), and the width is as much as that scale fits on the screen,
* between {@code minWidth} and {@code maxWidth}.
*/
public static int widthFor(int width, int height, int minWidth, int maxWidth, int logicalHeight,
RenderMode mode) {
if (maxWidth <= minWidth || width <= 0 || height <= 0) return minWidth;
double scale = mode == RenderMode.PIXEL
? wholeScale(width, height, minWidth, logicalHeight)
: scale(width, height, minWidth, logicalHeight);
// Whole logical units, rounded down: the picture is at most a scale's width narrower than the screen.
return Math.clamp((int) Math.floor(width / scale + 1e-9), minWidth, maxWidth);
}
Which mode? DIRECT is the default and sharp at every size. PIXEL costs 640 × 400 pixels on any screen, which makes it the fast choice for very large screens and slow drivers, and gives the look of old games. Every game offers both in its Graphics menu (chapter 48) and keeps the choice in its settings.
Test: the mouse where pixel mode puts the picture
@Test
void mousePositionsAreMappedLikeThePicture() {
// 1920 × 1080 drawn directly: scaled 2.7 times to 1728 × 1080, with black bars of 96 pixels left and right.
Point2D.Double p = GameCanvas.toLogical(96, 0, 1920, 1080, 640, 400, RenderMode.DIRECT);
assertEquals(0, p.x, 1e-9);
assertEquals(0, p.y, 1e-9);
p = GameCanvas.toLogical(960, 540, 1920, 1080, 640, 400, RenderMode.DIRECT);
assertEquals(320, p.x, 1e-9);
assertEquals(200, p.y, 1e-9);
// In pixel mode the same screen shows 2 × 640 × 400 = 1280 × 800, starting at 320, 140.
p = GameCanvas.toLogical(320, 140, 1920, 1080, 640, 400, RenderMode.PIXEL);
assertEquals(0, p.x, 1e-9);
assertEquals(0, p.y, 1e-9);
p = GameCanvas.toLogical(1599, 939, 1920, 1080, 640, 400, RenderMode.PIXEL);
assertEquals(639.5, p.x, 1e-9);
assertEquals(399.5, p.y, 1e-9);
// On a black bar: outside the field.
assertTrue(GameCanvas.toLogical(10, 540, 1920, 1080, 640, 400, RenderMode.PIXEL).x < 0);
}
Test: the width on wide screens
@Test
void aWideningCanvasFillsWideScreens() {
// 16:9 at 1920 × 1080: 400 units high means 2.7 pixels a unit, and 1920 / 2.7 = 711 units wide.
assertEquals(711, GameCanvas.widthFor(1920, 1080, 640, 960, 400, RenderMode.DIRECT));
// 16:10 needs nothing more; a 4:3 screen keeps the narrowest width and gets bars above and below.
assertEquals(640, GameCanvas.widthFor(1920, 1200, 640, 960, 400, RenderMode.DIRECT));
assertEquals(640, GameCanvas.widthFor(1024, 768, 640, 960, 400, RenderMode.DIRECT));
// 21:9 at 3440 × 1440 would be 955 units; a maximum of 900 leaves narrow bars.
assertEquals(900, GameCanvas.widthFor(3440, 1440, 640, 900, 400, RenderMode.DIRECT));
// Pixel mode: 1080 / 400 gives a whole 2, so 1920 / 2 = 960 units, and only bars above and below.
assertEquals(960, GameCanvas.widthFor(1920, 1080, 640, 960, 400, RenderMode.PIXEL));
// A canvas that may not widen stays as it is.
assertEquals(640, GameCanvas.widthFor(1920, 1080, 640, 640, 400, RenderMode.DIRECT));
// With the widened width the screen's left edge is logical 0 and its right edge the full width.
Point2D.Double left = GameCanvas.toLogical(0, 540, 1920, 1080, 711, 400, RenderMode.DIRECT);
Point2D.Double right = GameCanvas.toLogical(1919, 540, 1920, 1080, 711, 400, RenderMode.DIRECT);
assertEquals(0, left.x, 0.5);
assertEquals(711, right.x, 0.5);
}
Full screen: FullScreen¶
Package swing · introduced in 20. Paddle · source: FullScreen
What it is for. Switching the game window between a normal window and full screen, with F11 from GameMenus.fullScreen. In full screen the window has no frame and no menu bar, and the menu shortcuts keep working.
How it works. enter() uses GraphicsDevice.setFullScreenWindow on the monitor the window is on, but never changes the screen's resolution: the canvas scales the picture itself. Where the platform has no full-screen mode, the window simply covers the monitor. A frame can only lose its frame while it is not displayable, so the switch disposes the window and shows it again, inside canvas.withoutFrames so the loop draws nothing into a window that is gone. A hidden menu bar ignores its shortcuts; while it is hidden, FullScreen copies each item's accelerator to the root pane, which clicks the item.
public void enter() {
if (active) return;
GraphicsDevice device = frame.getGraphicsConfiguration().getDevice();
windowBounds = frame.getBounds();
windowState = frame.getExtendedState();
switched = System.nanoTime();
canvas.withoutFrames(() -> {
reopen(true);
if (device.isFullScreenSupported()) {
device.setFullScreenWindow(frame);
} else {
frame.setBounds(device.getDefaultConfiguration().getBounds());
frame.setVisible(true);
}
});
active = true;
mirrorShortcuts();
canvas.requestFocusInWindow();
}
Pitfall: the game ends on F11
Disposing the window sends windowClosed. A game that stops its loop in a window listener of its own ends when the player presses F11. End the game with fullScreen.whenClosed(action), which runs only when the window is really closed (chapter 20).
Pitfall: the game pauses on F11
Disposing the window also takes the focus away for a moment. A game that pauses in a windowDeactivated listener of its own pauses whenever the player switches. Pause with fullScreen.whenDeactivated(action): it runs at once when the focus goes to another program, and after a switch only if the window has not got the focus back within a second (chapter 20).
/**
* Runs the action when the window loses the focus to another window or program, e.g. to pause the
* game, but not when it only loses it for a moment while switching to or from full screen. A window
* that is still without the focus a second after a switch counts as left then.
*/
public void whenDeactivated(Runnable action) {
deactivateActions.add(action);
}
private void deactivated() {
long since = (System.nanoTime() - switched) / 1_000_000;
if (since >= SETTLE_MILLIS) {
deactivateActions.forEach(Runnable::run);
return;
}
Timer later = new Timer((int) (SETTLE_MILLIS - since), e -> {
if (!frame.isActive() && frame.isDisplayable()) deactivateActions.forEach(Runnable::run);
});
later.setRepeats(false);
later.start();
}
Frame times: FrameTimes¶
Package loop · introduced in 21. Bricks · source: FrameTimes
What it is for. The numbers behind the frame-time display that every game shows with F12: how long the frames of the last second took to draw, on average and at worst, and how many there were. At 60 frames a second a frame has 16.7 ms; the display turns red when the worst frame took longer.
When to use it. Before making a game faster. Every performance chapter of this book started with these numbers and kept a change only when they improved: the sprite cache in Bricks, the antialiasing in Swarm, the opaque caches in Jump, the panel in Sands. The rules for drawing fast are collected in the appendix.
How it works. The canvas records the start and end of every frame in a ring of 1,000 entries and drops those older than a second. The methods are synchronized, because the loop records and the display may read from elsewhere.
Animation on the event thread: Animator¶
Package swing · introduced in 11. Solitaire · source: Animator
What it is for. Movement in a game that has no game loop: a card that flies to its pile, the cards that bounce after a won game of Solitaire. The animator calls a frame function about 60 times a second on the event thread, with the current System.nanoTime(), for as long as the function says there is more to show.
When to choose it. In a Swing component of a game that waits for the player, where something moves for a moment and then everything rests again. It costs nothing while nothing moves. A game in which something always moves wants a GameLoop.
How it works. A Swing Timer of 16 ms, coalescing, with Toolkit.sync() after each frame. start() while it runs does nothing, so every change can simply call it. The positions themselves come from Tweens: the frame function asks each tween how far it is at now, repaints, and returns whether any is still running (chapter 11).