Game Engine Zero Vol 1 · Three Arcade Games
ch 04 / 24
Chapter 04

Sprites and Animation

Loading a sprite sheet

This chapter loads one PNG that holds the game's small pictures. The program cuts it into paddles, ball frames and digits, then animates the ball with a counter in Update.

The sheet is 64 by 32 pixels. It contains two paddles, four ball frames and the digits 0 to 9. Ebitengine draws the whole sheet through DrawImage, or it draws a sub-image that views one rectangle of the same sheet.

Showing the sheet

Download the sheet into the module, at assets/pong-sheet.png; the programs open it by that path, from the module's root, where every go run in this book is typed. The second file is the same sheet at four times its size on the court colour, for looking at; nothing loads it.

▣ Build · stage 1 — the sheet on the screen
mkdir -p assets
// cmd/sprite/main.go — create
package main

import (
	"image/color"
	"image/png"
	"log"
	"os"

	"github.com/hajimehoshi/ebiten/v2"
)

const (
	screenW = 320
	screenH = 180
)

var courtColor = color.RGBA{R: 16, G: 20, B: 28, A: 255}

// loadImage reads a PNG file and hands its pixels to Ebitengine.
func loadImage(path string) (*ebiten.Image, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	img, err := png.Decode(f)
	if err != nil {
		return nil, err
	}
	return ebiten.NewImageFromImage(img), nil
}

// drawAt draws img scaled by s with its top-left corner at (x, y).
func drawAt(screen, img *ebiten.Image, x, y, s float64) {
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Scale(s, s)
	op.GeoM.Translate(x, y)
	screen.DrawImage(img, op)
}

type Game struct {
	sheet *ebiten.Image
}

func (g *Game) Update() error {
	return nil
}

// drawSheet draws the whole sheet at three times its size.
func (g *Game) drawSheet(screen *ebiten.Image) {
	screen.Fill(courtColor)
	drawAt(screen, g.sheet, 8, 8, 3)
}

func (g *Game) Draw(screen *ebiten.Image) {
	g.drawSheet(screen)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return screenW, screenH
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Sprites")
	ebiten.SetTPS(60)
	sheet, err := loadImage("assets/pong-sheet.png")
	if err != nil {
		log.Fatal(err)
	}
	if err := ebiten.RunGame(&Game{sheet: sheet}); err != nil {
		log.Fatal(err)
	}
}
go vet ./...
go run ./cmd/sprite
A dark window with, in its upper left, the sheet drawn three times its size: two tall light bars side by side, four small squares beside them, and a row of tiny light digits below, with the court colour showing through everywhere the sheet is transparent.
The window after stage 1: the sheet at three times its size, the court colour showing through its transparent pixels.

loadImage opens the file, decodes the PNG with image/png and hands the decoded image to ebiten.NewImageFromImage. Ebitengine then has an image it can draw.

The file is decoded once in main, before the window opens. Reading it in Draw would read the same file every time the screen was painted. The paddles sit at the sheet's top-left corner, the ball frames sit beside them, and the digits sit along the bottom.

The sprite sheet at four times its size on a dark court colour: two tall light bars at the top left, each with a brighter column near one edge; four small light squares beside them, the second, third and fourth each carrying one grey pixel at the top, the right and the bottom; a row of ten tiny light digits, 0 to 9, along the lower part; the rest dark.
pong-sheet-4x.png: the sheet at four times. Two paddles, each lit one column in from the side that will face the net; the ball's four frames, with a grey seam at the top, the right and the bottom of the last three; the ten digits along row 24.
⚙ Tool — a pixel editor, for a sheet of your own

The sheet was made pixel by pixel, 362 painted pixels in all. Piskel (piskelapp.com) runs in a browser and exports a PNG. LibreSprite is a free installed editor.

Draw on a transparent background. Keep each piece at whole-pixel positions you write down. Export at one times, not scaled, because the game does the scaling.

Cutting sub-images

A piece of the sheet is a rectangle: its left column, top row, width and height, in the sheet's own pixels. The paddles are at (0, 0) and (4, 0), four wide and twenty-four tall; the ball's frames start at column 8 and follow every four columns, four by four; the digits start at (0, 24) and follow every three columns, three wide and five tall. SubImage takes such a rectangle and returns an image that is the sheet seen through it: drawing the sub-image draws those pixels of the sheet and no others, and no pixel is copied to make it.

▣ Build · stage 2 — the pieces, cut out and laid out, extend cmd/sprite/main.go
// cmd/sprite/main.go — extend
import (
	"image"
	"image/color"
	"image/png"
	"log"
	"os"

	"github.com/hajimehoshi/ebiten/v2"
)

type Game struct {
	sp *sprites
}

// drawSheet draws the whole sheet at three times its size.
func (g *Game) drawSheet(screen *ebiten.Image) {
	screen.Fill(courtColor)
	drawAt(screen, g.sp.sheet, 8, 8, 3)
}

func (g *Game) Draw(screen *ebiten.Image) {
	g.drawSheet(screen)
	g.drawPieces(screen)
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Sprites")
	ebiten.SetTPS(60)
	sheet, err := loadImage("assets/pong-sheet.png")
	if err != nil {
		log.Fatal(err)
	}
	if err := ebiten.RunGame(&Game{sp: cutSprites(sheet)}); err != nil {
		log.Fatal(err)
	}
}

// sprites is the sheet cut into the pictures a game draws. Each piece is a
// sub-image: a view onto the one sheet, not a copy of its pixels.
type sprites struct {
	sheet       *ebiten.Image
	left, right *ebiten.Image    // the paddles, four by twenty-four
	ball        [4]*ebiten.Image // the ball's four spin frames, four by four
	digits      [10]*ebiten.Image
}

// cutSprites cuts the pieces out of the sheet, where the sheet's layout
// puts them: paddles at the top left, the ball's frames beside them, the
// digits, three by five each, along row 24.
func cutSprites(sheet *ebiten.Image) *sprites {
	cut := func(x, y, w, h int) *ebiten.Image {
		return sheet.SubImage(image.Rect(x, y, x+w, y+h)).(*ebiten.Image)
	}
	s := &sprites{sheet: sheet, left: cut(0, 0, 4, 24), right: cut(4, 0, 4, 24)}
	for i := range s.ball {
		s.ball[i] = cut(8+4*i, 0, 4, 4)
	}
	for i := range s.digits {
		s.digits[i] = cut(3*i, 24, 3, 5)
	}
	return s
}

// drawPieces draws each piece the sheet was cut into, at four times: the
// paddles to the right of the sheet, the ball's frames and the digits in
// a row under it.
func (g *Game) drawPieces(screen *ebiten.Image) {
	drawAt(screen, g.sp.left, 216, 8, 4)
	drawAt(screen, g.sp.right, 240, 8, 4)
	for i, b := range g.sp.ball {
		drawAt(screen, b, float64(8+24*i), 116, 4)
	}
	for i, d := range g.sp.digits {
		drawAt(screen, d, float64(120+16*i), 116, 4)
	}
}
go vet ./...
go run ./cmd/sprite
The sheet at three times in the upper left, the two paddles at four times standing to its right, and below, in a row, the ball's four frames at four times and the ten digits at four times, each piece drawn apart from its neighbours.
The window after stage 2: the sheet, and beside and below it each of the sixteen pieces at four times, drawn apart.

cut is declared inside cutSprites so the rectangles stay close to the sheet layout. Each call names the left column, top row, width and height of one piece.

SubImage returns Go's image.Image interface. Ebitengine documents that a sub-image of an Ebitengine image is also an Ebitengine image, so the type assertion turns it back into *ebiten.Image.

No pixels are copied when the program cuts the sheet. Drawing sixteen pieces means sixteen DrawImage calls, all reading rectangles from the same loaded image.

One sheet, sixteen rectangles cut from it, and the ball's frame chosen by the tick Left: a grid standing for the 64-by-32 sheet, with the two paddles outlined at its top left, four small squares beside them labelled ball frames 0 to 3, and a row of ten small rectangles near the bottom labelled digits 0 to 9, with their pixel coordinates. Right: a short table: ticks 0 to 5 draw frame 0, 6 to 11 frame 1, 12 to 17 frame 2, 18 to 23 frame 3, and 24 begins again; a note says the frame is the spin count divided by six, the remainder dropped, modulo four. THE SHEET, 64 × 32 (0,0) (4,0) ball frames 0 to 3 (8,0) (12,0) (16,0) (20,0), 4 × 4 digits 0 to 9 (3i, 24), 3 × 5 paddles (0,0) and (4,0), 4 × 24 WHICH FRAME, ON WHICH TICK ticks 0 to 5: frame 0 ticks 6 to 11: frame 1 ticks 12 to 17: frame 2 ticks 18 to 23: frame 3 tick 24: frame 0 again frame = (spin / 6) % 4 the division drops its remainder; % keeps the remainder after dividing by 4
Figure 4.1 — the sheet's layout, as sixteen rectangles, and the counter that picks the ball's frame: six ticks a frame, four frames a turn.

Animating with a counter

▣ Build · stage 3 — the spin, counted in Update, extend cmd/sprite/main.go
// cmd/sprite/main.go — extend
type Game struct {
	sp   *sprites
	spin int // ticks the ball has been turning
}

func (g *Game) Update() error {
	g.spin++
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	g.drawSheet(screen)
	g.drawPieces(screen)
	g.drawSpin(screen)
}

// The ball turns one frame every six ticks: a full turn in twenty-four
// ticks, two and a half turns a second.
const spinEvery = 6

// drawSpin draws the frame the counter picks: at eight times in the top
// right corner, and at one, rolling along the bottom a pixel a tick.
func (g *Game) drawSpin(screen *ebiten.Image) {
	frame := (g.spin / spinEvery) % 4
	drawAt(screen, g.sp.ball[frame], 272, 8, 8)
	drawAt(screen, g.sp.ball[frame], float64(g.spin%screenW), 168, 1)
}
go vet ./...
go run ./cmd/sprite
The stage 2 window with, in the top right corner, the ball at eight times its size with a grey pixel on its right side, and a tiny ball near the left end of the bottom edge.
The window fifteen ticks after stage 3 starts: the counter reads 15, fifteen divided by six is two, and frame 2 has the seam on the right. The small ball has rolled fifteen pixels along the bottom.

The big ball turns because spin chooses a different frame every six ticks. The small ball uses the same frame and moves one pixel per tick along the bottom. A full turn takes twenty-four ticks, so the small ball rolls twenty-four pixels per turn.

Update raises spin by one. Draw may read the counter and choose a frame, but it must not change the counter.

∑ Math Interlude — six ticks a frame, four frames a turn

The counter goes 0, 1, 2, … and the frame has to go 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 2, …, 3, 3, 3, 3, 3, 3, 0, 0, …: each frame held for six ticks, then round again. Dividing the counter by six and dropping the remainder gives 0 for ticks 0 to 5, 1 for 6 to 11, 2 for 12 to 17, and keeps climbing; taking that number's remainder after dividing by four folds it back onto 0 to 3. On tick 15, 15 ÷ 6 is 2 remainder 3, so 2; 2 divided by 4 is 0 remainder 2, so frame 2. On tick 45, 45 ÷ 6 is 7; 7 divided by 4 leaves 3; frame 3. Go's / on integers drops the remainder and % keeps it, so the two steps are (spin / 6) % 4.

spinthe counter: ticks the ball has been turning
a / binteger division: how many whole times b goes into a; 15 / 6 is 2
a % bthe remainder after that division; 15 % 6 is 3, and 7 % 4 is 3
framewhich of the four pictures to draw: (spin / 6) % 4, always 0 to 3
⚠ Worked failure — the counter in Draw

The counter is used by drawing, so it is easy to put the increment in Draw:

func (g *Game) Update() error {
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	g.spin++
	g.drawSheet(screen)
	g.drawPieces(screen)
	g.drawSpin(screen)
}
The same window, with the big ball's grey pixel at the bottom instead of the right, and the small ball three times further along the bottom edge.
The same program with the counter in Draw, fifteen ticks in, on a display that paints three pictures a tick: the counter reads 45, the seam is at the bottom, and the small ball has rolled 45 pixels.

Fifteen ticks after the window opens, this display has painted 45 pictures. The counter reads 45, so the big ball shows frame 3 instead of frame 2. The small ball is at column 45 instead of column 15.

A display that paints three pictures per tick makes the animation run three times as fast. A machine that skips pictures slows it down. The counter changes game state, so it belongs in Update.

Using views and counts

A sub-image is a rectangle into a sheet. Ebitengine still draws it with DrawImage; only the source rectangle changes.

Animation uses the same draw call with a different rectangle. The rectangle is chosen by an integer that Update changes. Longer animations use more frames or a different divisor, but the idea stays the same.

Checkpoint

✓ Checkpoint — what you can now do
  • Load a PNG into an Ebitengine image in three steps, once, before the window opens.
  • Cut a sheet into sub-images from a written-down layout, and say what a sub-image shares with the sheet and what it copies.
  • Draw sixteen pieces of one sheet at four times and know that it is one image on the graphics card.
  • Pick an animation frame from a counter with one division and one remainder, and work out the frame on any tick by hand.
  • Say why the counter lives in Update, with the numbers a three-pictures-a-tick display gives when it does not.
⚡ Exercises — try first, then reveal
Exercise 1 — spin the other way. Make the small ball roll left, from column 319 down, and make its seam walk the other way round to match.

Draw it at 319 - g.spin%screenW, and pick the frame as (4 - frame) % 4: frame 1 becomes 3, 3 becomes 1, and 0 and 2 stay, so the seam goes bottom, right, top and behind, which is the turn a ball rolling left makes.

Exercise 2 — a slower turn. Change spinEvery to 12 and watch the big ball. How many turns a second now, and how far does the small ball roll per turn?

One and a quarter turns a second: 48 ticks a turn at sixty ticks a second. The small ball rolls 48 pixels a turn, twice what it rolled per turn before, and looks as if it were sliding. A rolling ball wants its frames to match its distance.

Exercise 3 — a seventeenth piece. Open the sheet in a pixel editor, draw an eight-by-eight picture of your own at (32, 0), where the sheet is empty, save it over assets/pong-sheet.png, and cut it out and draw it at eight times.

One more field in sprites, cut(32, 0, 8, 8) in cutSprites, and a drawAt somewhere free, such as (272, 60) at eight times. Every other piece is untouched, because their rectangles do not overlap yours, and the volume's games still load the file. Keep the original sheet beside it, as pong-sheet-mine.png if you like, so that the pictures in the book still match what you see.