42. Sprites and Effects¶
What a game shows, apart from plain shapes: pictures from files and pictures drawn in code, many frames in one image, pictures that turn, figures of lines, movements that ease in and out, and splinters by the hundred. One idea runs through all of it: work out a picture once, then copy it. Java2D copies an image of the screen's own pixel format very fast and does almost everything else slowly, so the classes of this chapter are built around caches.
Pictures: Sprite¶
Package sprite · introduced in 21. Bricks; hard edges in 24. Swarm, seamless tiles and transparency in 27. Jump, opaque drawings in 30. Towers · source: Sprite
What it is for. A picture with a size in logical coordinates, say a brick of 40 × 16 units, whatever the size of the window. Its source is an image file, usually drawn larger than the logical size so it stays sharp on large screens, or a drawing in code, a function that paints into a box of the sprite's size.
When to choose it. For everything that looks the same from one frame to the next: bricks, tiles, insects, towers, the faces of panels. Shapes that change every frame (a health bar, a selection box) are drawn directly. A drawing that is expensive (gradients, many curves, text) is the best candidate of all for a sprite: drawn once, then copied (Sands' panel).
How it works. Scaling an image while drawing it is the classic way to make a Java2D game slow. A sprite scales itself once to the size it has on the screen, into a compatible image (the pixel format of the screen, which Java2D can keep in video memory), and from then on copies that image 1:1 to whole screen pixels. When the window changes size, the next frame scales once more.
/** Draws the sprite with its top left corner at {@code x}, {@code y} in logical coordinates. */
public void draw(Graphics2D g, double x, double y) {
AffineTransform t = g.getTransform();
int rotated = AffineTransform.TYPE_MASK_ROTATION | AffineTransform.TYPE_GENERAL_TRANSFORM;
if (UNCACHED || (t.getType() & rotated) != 0) {
// Rotated: no cache can help, scale on the fly.
drawScaled(g, x, y);
return;
}
int w = Math.max(1, (int) Math.round(width * t.getScaleX()));
int h = Math.max(1, (int) Math.round(height * t.getScaleY()));
boolean smooth = !hard && g.getRenderingHint(RenderingHints.KEY_INTERPOLATION)
!= RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR;
if (cached == null || cached.getWidth() != w || cached.getHeight() != h || cachedSmooth != smooth) {
cached = render(g.getDeviceConfiguration(), w, h, smooth);
cachedSmooth = smooth;
}
// Copy the cached picture 1:1 to whole screen pixels: no scaling, no blurring by half pixels.
double screenX = Math.round(t.getTranslateX() + x * t.getScaleX());
double screenY = Math.round(t.getTranslateY() + y * t.getScaleY());
g.setTransform(AffineTransform.getTranslateInstance(screenX, screenY));
g.drawImage(cached, 0, 0, null);
g.setTransform(t);
}
The cache also gets the least transparency the picture needs, because copying costs accordingly: an OPAQUE picture is copied as it is, a BITMASK one skips its invisible pixels, only a TRANSLUCENT one is blended pixel by pixel. An image is looked at once to find out; a drawing in code has soft edges and stays translucent, unless you promise with opaque() that it covers its whole box (chapter 27).
/**
* The least transparency the cached picture needs, because copying it costs accordingly: an opaque
* picture is copied as it is, one whose pixels are either seen or not (a {@code BITMASK}) only skips
* the unseen ones, and only a translucent one is blended with what lies below, pixel by pixel. A sky
* the size of the screen costs a quarter as much when it is known to be opaque.
*
* <p>Pictures drawn in code have soft edges and stay translucent, unless they are {@link #opaque()}. An
* image is looked at once: opaque if
* every pixel is, a bitmask if every pixel is either opaque or invisible. Smooth scaling mixes the
* edges of a bitmask with the invisible pixels around them, so then it is translucent too.
*/
private int transparency(boolean smooth) {
if (source == null) return opaque ? Transparency.OPAQUE : Transparency.TRANSLUCENT;
if (sourceTransparency == 0) sourceTransparency = transparencyOf(source);
if (sourceTransparency == Transparency.BITMASK && smooth) return Transparency.TRANSLUCENT;
return sourceTransparency;
}
static int transparencyOf(BufferedImage image) {
if (!image.getColorModel().hasAlpha()) return Transparency.OPAQUE;
boolean seeThrough = false;
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
int alpha = image.getRGB(x, y) >>> 24;
if (alpha != 0 && alpha != 255) return Transparency.TRANSLUCENT;
seeThrough |= alpha == 0;
}
}
return seeThrough ? Transparency.BITMASK : Transparency.OPAQUE;
}
| Method | What it does |
|---|---|
Sprite.load(owner, "/sprites/ball.png", w, h) |
From an image among the game's resources, shown at w × h units |
Sprite.of(image, w, h) |
From an image in memory |
Sprite.drawn(w, h, (g, w, h) -> ...) |
Drawn in code, into a box of w × h units |
hardEdges() |
The same picture, always scaled in square blocks: for pixel art, also in direct mode |
opaque() |
The same drawing, promised to cover its box: cached without transparency, copied without blending |
draw(g, x, y) |
Top left corner at x, y |
drawSeamless(g, x, y) |
Covers exactly the screen pixels from x to x + width, each edge rounded: tiles that meet without gaps |
Seamless tiles. At 2.7 screen pixels per unit, a tile of 16 units is 43.2 pixels wide. Copied 43 pixels wide at rounded positions, every fifth tile leaves a gap of one pixel, a line that flickers through a scrolling ground. drawSeamless works the width out from both rounded edges, 43 or 44, and caches up to four sizes. Use it for anything drawn edge to edge: tile maps, parallax layers, the fog (chapter 27).
Pitfall: a rotated transform skips the cache
Under a rotated transform no cached picture fits, and draw scales the source in every frame. For things that turn, use Rotations or an Outline.
Tip: measure what the cache is worth
-Damade.sprites.uncached=true makes every sprite scale in every frame, as if there were no cache. Run a game's benchmark with and without it (chapter 21).
Test: one scaling per size
@Test
void theSpriteIsScaledOnceToItsSizeOnTheScreen() {
Sprite brick = Sprite.of(source(), 20, 8); // shown at half the source's pixels
assertNull(brick.cachedSize());
BufferedImage screen = new BufferedImage(200, 100, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = screen.createGraphics();
g.scale(3, 3); // three screen pixels per logical unit
brick.draw(g, 10, 5);
assertEquals(new Dimension(60, 24), brick.cachedSize());
// At 3 × (10, 5): red on the left, blue on the right.
assertEquals(Color.RED.getRGB(), screen.getRGB(31, 16));
assertEquals(Color.BLUE.getRGB(), screen.getRGB(88, 37));
assertEquals(0, screen.getRGB(29, 16));
g.dispose();
}
A ground painted once: ScrollingLayer¶
Package sprite · introduced in 33. Town · source: ScrollingLayer
What it is for. A layer of the world that rarely changes and costs much to draw: the ground of a big map with a thousand tiles in view, and the shadows on it. The layer keeps the view as one opaque picture in screen pixels and copies it, instead of drawing the tiles again in every frame.
When to choose it. When a frame measures slow and most of the time goes into something that looks the same from one frame to the next. Sprites already make each tile cheap; the layer makes the thousand copies one. A ground that changes in most frames (snow falling, water that ripples everywhere) gains nothing; neither does a small map that fits the screen. Things that move are never part of it: they are drawn over it.
How it works. The layer remembers where its picture lies in the world, in whole screen pixels. When the view stands still and the game passes the same key, draw copies the picture. When the view has moved, the picture is shifted by copyArea and only the strips that came into view are painted, by a painter the game hands in, clipped to each strip. When the key changes (the game folds into it whatever the layer shows), or the view jumped further than the picture is wide, or the window or zoom changed the scale, the whole view is painted again.
public final class ScrollingLayer {
/** Paints the world from {@code left}, {@code top}, {@code width} × {@code height} world units. */
public interface Painter {
void paint(Graphics2D g, double left, double top, double width, double height);
}
private final double viewWidth;
private final double viewHeight;
private BufferedImage image;
private double scaleX;
private double scaleY;
private long key;
private long pixelLeft; // the world position of the picture's first column, in screen pixels
private long pixelTop;
private boolean valid;
private int painted;
private RenderingHints hints;
/** A layer for a view of {@code viewWidth} × {@code viewHeight} world units. */
public ScrollingLayer(double viewWidth, double viewHeight) {
this.viewWidth = viewWidth;
this.viewHeight = viewHeight;
}
/** Forgets the picture: the next frame paints the whole view. */
public void invalidate() {
valid = false;
}
/**
* Draws the view whose top left world point is {@code left}, {@code top} at {@code screenLeft},
* {@code screenTop} of the graphics, painting first what is missing.
*/
public void draw(Graphics2D g, double screenLeft, double screenTop, double left, double top, long key,
Painter painter) {
AffineTransform t = g.getTransform();
int rotated = AffineTransform.TYPE_MASK_ROTATION | AffineTransform.TYPE_GENERAL_TRANSFORM;
if ((t.getType() & rotated) != 0) {
// Rotated: no picture of screen pixels fits, paint directly.
Graphics2D direct = (Graphics2D) g.create();
direct.translate(screenLeft - left, screenTop - top);
direct.clip(new Rectangle2D.Double(left, top, viewWidth, viewHeight));
painter.paint(direct, left, top, viewWidth, viewHeight);
direct.dispose();
return;
}
double sx = t.getScaleX();
double sy = t.getScaleY();
hints = g.getRenderingHints(); // the painter draws as the frame does: smooth or in pixel mode
int w = (int) Math.ceil(viewWidth * sx) + 1;
int h = (int) Math.ceil(viewHeight * sy) + 1;
long pl = Math.round(left * sx);
long pt = Math.round(top * sy);
if (image == null || image.getWidth() != w || image.getHeight() != h || sx != scaleX || sy != scaleY) {
image = create(g.getDeviceConfiguration(), w, h);
scaleX = sx;
scaleY = sy;
valid = false;
}
long dx = pl - pixelLeft;
long dy = pt - pixelTop;
if (!valid || key != this.key || Math.abs(dx) >= w || Math.abs(dy) >= h) {
pixelLeft = pl;
pixelTop = pt;
this.key = key;
paint(painter, 0, 0, w, h);
valid = true;
} else if (dx != 0 || dy != 0) {
Graphics2D ig = image.createGraphics();
ig.copyArea(0, 0, w, h, (int) -dx, (int) -dy);
ig.dispose();
pixelLeft = pl;
pixelTop = pt;
if (dx > 0) paint(painter, w - (int) dx, 0, (int) dx, h);
if (dx < 0) paint(painter, 0, 0, (int) -dx, h);
if (dy > 0) paint(painter, 0, h - (int) dy, w, (int) dy);
if (dy < 0) paint(painter, 0, 0, w, (int) -dy);
}
// The picture's first column is world pixel pixelLeft; where that is on the screen, rounded.
long x = Math.round(t.getTranslateX() + (screenLeft - left) * sx + pixelLeft);
long y = Math.round(t.getTranslateY() + (screenTop - top) * sy + pixelTop);
g.setTransform(AffineTransform.getTranslateInstance(x, y));
g.drawImage(image, 0, 0, null);
g.setTransform(t);
}
/** A world position rounded to the screen pixel the layer puts it on, at the given scale. */
public static double snap(double world, double scale) {
return Math.round(world * scale) / scale;
}
/** How many regions were painted so far: for tests and for measuring what the layer saves. */
public int painted() {
return painted;
}
private void paint(Painter painter, int x, int y, int w, int h) {
Graphics2D ig = image.createGraphics();
ig.setRenderingHints(hints);
ig.clipRect(x, y, w, h);
ig.translate(-pixelLeft, -pixelTop);
ig.scale(scaleX, scaleY);
painter.paint(ig, (pixelLeft + x) / scaleX, (pixelTop + y) / scaleY, w / scaleX, h / scaleY);
ig.dispose();
painted++;
}
private static BufferedImage create(GraphicsConfiguration gc, int w, int h) {
return gc != null ? gc.createCompatibleImage(w, h, Transparency.OPAQUE)
: new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
}
}
| Method | What it does |
|---|---|
new ScrollingLayer(viewWidth, viewHeight) |
A layer for a view of that many world units |
draw(g, screenLeft, screenTop, left, top, key, painter) |
Draws the view whose top left world point is left, top, painting first what is missing |
invalidate() |
The next frame paints the whole view |
ScrollingLayer.snap(world, scale) |
A position rounded to the screen pixel the layer puts it on |
painted() |
How many regions were painted so far: what the layer saved |
The painter must cover every pixel of the region it gets, so fill a background first: the picture is opaque and is copied without blending. Because the layer puts the view at whole screen pixels, draw the rest of the world at snapped camera positions too, or things that stand on the ground slide half a pixel against it while the view scrolls (chapter 33).
Test: a moved view paints only its new strips, and looks as if painted whole
@Test
void aMovedViewPaintsOnlyTheNewStripsAndLooksAsIfPaintedWhole() {
ScrollingLayer layer = new ScrollingLayer(60, 40);
frame(layer, 10, 10, 1);
BufferedImage scrolled = frame(layer, 13.5, 8, 1);
assertEquals(3, layer.painted(), "the whole view, then a strip at the right and one at the top");
assertSame(frame(new ScrollingLayer(60, 40), 13.5, 8, 1), scrolled);
}
Many pictures in one: SpriteSheet¶
Package sprite · introduced in 24. Swarm · source: SpriteSheet
What it is for. Many small pictures in one image, in a grid of equal cells: the frames of a flapping insect, the looks of a bomber, the stages of an explosion. One file instead of fifty, drawn and edited side by side, so the frames of an animation line up.
How it works. SpriteSheet.of(image, columns, rows, frameWidth, frameHeight) cuts the image into cells numbered row by row from the top left; each cell is a view into the image (getSubimage), not a copy, and becomes a Sprite with its own cache. frame(index) or frame(column, row) gives one; hardEdges() gives the same sheet for pixel art. The same image can be cut twice with cells of different sizes, as Swarm does for its large explosion.
public final class SpriteSheet {
private final Sprite[] frames;
private final int columns;
private SpriteSheet(Sprite[] frames, int columns) {
this.frames = frames;
this.columns = columns;
}
/**
* Cuts {@code image} into {@code columns} × {@code rows} cells of equal size; each is shown at
* {@code frameWidth} × {@code frameHeight} logical units.
*/
public static SpriteSheet of(BufferedImage image, int columns, int rows, double frameWidth, double frameHeight) {
if (columns < 1 || rows < 1 || image.getWidth() % columns != 0 || image.getHeight() % rows != 0) {
throw new IllegalArgumentException(image.getWidth() + " × " + image.getHeight()
+ " pixels do not make " + columns + " × " + rows + " equal cells");
}
int w = image.getWidth() / columns;
int h = image.getHeight() / rows;
Sprite[] frames = new Sprite[columns * rows];
for (int row = 0; row < rows; row++) {
for (int column = 0; column < columns; column++) {
// A view into the sheet, not a copy: the pixels stay in the one image.
BufferedImage cell = image.getSubimage(column * w, row * h, w, h);
frames[row * columns + column] = Sprite.of(cell, frameWidth, frameHeight);
}
}
return new SpriteSheet(frames, columns);
}
Which frame when: Animation¶
Package sprite · introduced in 24. Swarm · source: Animation
What it is for. Which frame of a sheet to show a given number of seconds after an animation started: a list of frame numbers, a time per frame, and what happens at the end, LOOP (wings, flames), ONCE (an explosion, a door) or PING_PONG (breathing, swaying).
How it works. An animation keeps no time and draws nothing: frame(seconds) is arithmetic, so one animation serves forty insects, each asking with its own time, and a test asks without a screen. finished(seconds) says when a ONCE animation is over, duration() how long one run takes. Tiles of a Tiled map can carry an animation of their own, which TiledMap reads as a list of frames (chapter 45).
Turning: pictures or lines?¶
Seen from above or from the side in space, things point in every direction. A sprite cannot turn cheaply, so the engine has two other answers. Choose by size and look (chapter 23):
| The thing | Use | Because |
|---|---|---|
| A figure of straight lines: a ship, a rock of the vector screens | Outline |
Turning costs a few multiplications per corner, any angle, any size |
| A picture, small or of pixel art: a turret, a tank, an insect | Rotations |
Drawn once per direction, then copied like any sprite |
| A large, smooth picture turning freely | Avoid it | 64 cached angles of a large picture cost tens of megabytes; draw it as lines, or smaller |
Turned in advance: Rotations¶
Package sprite · introduced in 30. Towers · source: Rotations
Rotations.of(steps, size, drawing) draws a picture, pointing right at angle 0, once for each of steps directions, each a square Sprite of size units with its own cache. draw(g, x, y, angle) shows the nearest one with its middle at x, y; any angle works, negative or above 2π. At 32 directions the steps are 11.25°, and a turret turns smoothly to the eye. Towers and Sands draw their turrets, tanks and creeps this way.
public final class Rotations {
private final Sprite[] sprites;
private final double size;
private Rotations(Sprite[] sprites, double size) {
this.sprites = sprites;
this.size = size;
}
/**
* {@code steps} sprites of {@code size} × {@code size}, each {@code drawing} turned around the middle by
* one more step. The drawing points to the right, angle 0, as {@code Vector.ofAngle} does.
*/
public static Rotations of(int steps, double size, Sprite.Drawing drawing) {
if (steps < 1) throw new IllegalArgumentException("at least one direction");
Sprite[] sprites = new Sprite[steps];
for (int i = 0; i < steps; i++) {
double angle = 2 * Math.PI * i / steps;
sprites[i] = Sprite.drawn(size, size, (g, w, h) -> {
g.rotate(angle, w / 2, h / 2);
drawing.draw(g, w, h);
});
}
return new Rotations(sprites, size);
}
public int steps() {
return sprites.length;
}
/** The direction nearest to {@code angle}, any angle in radians, also negative or above 2π. */
public int index(double angle) {
double turns = angle / (2 * Math.PI) * sprites.length;
return Math.floorMod(Math.round(turns), sprites.length);
}
public Sprite at(double angle) {
return sprites[index(angle)];
}
/** Draws the picture nearest to {@code angle} with its middle at {@code x}, {@code y}. */
public void draw(Graphics2D g, double x, double y, double angle) {
at(angle).draw(g, x - size / 2, y - size / 2);
}
}
Figures of lines: Outline¶
Package shape · introduced in 23. Rocks · source: Outline
A figure given by its corners around its own centre, pairs of x and y, angle 0 pointing right: Outline.closed(...) joins the last corner to the first, Outline.open(...) does not (a flame behind a ship). draw(g, x, y, angle, scale) and fill(...) turn every corner with the same formula as Vector.rotated, sine and cosine worked out once for all corners, into one Path2D that the outline keeps and reuses: drawing creates no objects. radius() is the farthest corner, a circle that holds the figure at any angle, which is exactly what Circle collision needs.
/**
* The figure turned by {@code angle}, enlarged by {@code scale} and moved to {@code x}, {@code y}. Every
* corner is turned with the same formula as {@link eu.amadeos.gameengine.math.Vector#rotated}; sine and
* cosine are worked out once for all corners. The path is reused: it is valid until the next call.
*/
public Path2D.Double path(double x, double y, double angle, double scale) {
double cos = Math.cos(angle) * scale;
double sin = Math.sin(angle) * scale;
path.reset();
for (int i = 0; i < xs.length; i++) {
double px = x + xs[i] * cos - ys[i] * sin;
double py = y + xs[i] * sin + ys[i] * cos;
if (i == 0) path.moveTo(px, py);
else path.lineTo(px, py);
}
if (closed) path.closePath();
return path;
}
Movement in time: Easing and Tween¶
Package anim · introduced in 11. Solitaire · source: Easing, Tween
What it is for. A movement from one value to another in a given time that feels natural: a card thrown onto its pile starts fast and lands gently. An Easing maps the share of the time that has passed, 0 to 1, to the share of the way: LINEAR, EASE_OUT (thrown and landing) and EASE_IN_OUT (carried). A Tween is one such movement in time: a start, a duration, an easing.
When to choose it. For movements with a fixed start and end that the rules do not care about: cards flying, a menu growing, a number counting up. Things the rules move step by step (a ball, a unit) are drawn from their positions instead.
How it works. tween.at(now) is the eased share at a System.nanoTime() value, 0 before the start and 1 from the end on; Tween.lerp(from, to, t) turns it into a value; done(now) says when it is over. The tween knows nothing of what moves, so one idea animates everything: the cards, their turning over, the bounce at the end (chapter 11). In a game without a loop, an Animator asks the tweens 60 times a second.
@FunctionalInterface
public interface Easing {
double apply(double t);
/** Constant speed. */
Easing LINEAR = t -> t;
/** Fast start, gentle stop: for things that are thrown and land, like a card on its pile. */
Easing EASE_OUT = t -> 1 - Math.pow(1 - t, 3);
/** Gentle start and stop: for things that are carried. */
Easing EASE_IN_OUT = t -> t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
Splinters and sparks: Particles¶
Package fx · introduced in 21. Bricks · source: Particles
What it is for. Many small, short-lived squares: splinters of a brick, sparks of a shot, dust behind a tank. Each flies in a straight line, falls with gravity if you give it one, and fades out during the second half of its life. Every action game from Bricks on uses them.
When to choose it. For effects that have no say in the rules. Particles are drawn, never asked: nothing collides with a splinter. Give them a random generator of their own, forked from the rules' one, so effects never change the game (chapter 22).
How it works. Hundreds of particles are not objects but rows in a few arrays, one array per property, and the system never creates an object while the game runs: the colours are made once, sixteen shades of fading for every colour of the palette. A dead particle's row is taken over by the last living one, so the living stay at the front and a loop over count rows finds them all. render(g, alpha) draws each between its last two positions.
/** Moves all particles by one step; {@code gravity} pulls down in units per second². */
public void update(double seconds, double gravity) {
int i = 0;
while (i < count) {
life[i] -= seconds;
if (life[i] <= 0) {
remove(i);
continue; // row i now holds the former last particle: look at it again
}
previousX[i] = x[i];
previousY[i] = y[i];
vy[i] += gravity * seconds;
x[i] += vx[i] * seconds;
y[i] += vy[i] * seconds;
i++;
}
}
private void remove(int i) {
int last = --count;
x[i] = x[last];
y[i] = y[last];
previousX[i] = previousX[last];
previousY[i] = previousY[last];
vx[i] = vx[last];
vy[i] = vy[last];
life[i] = life[last];
lifetime[i] = lifetime[last];
size[i] = size[last];
colour[i] = colour[last];
}
| Method | What it does |
|---|---|
new Particles(capacity, colours...) |
Room for capacity particles; colours are referred to by index |
spawn(x, y, vx, vy, seconds, size, colour) |
A new particle; false when all rows are taken |
update(seconds, gravity) |
Moves all of them by one step |
render(g, alpha) |
Draws them |
clear(), count() |
Removes all, e.g. for a new level; how many live |