singe/docs/lessons/09-pictures.adoc
2026-09-22 21:57:42 -05:00

753 lines
26 KiB
Text

== Lesson 9: Pictures
image::learn/09-pictures.png[The finished lesson, 480]
Everything you have drawn so far, you drew with boxes and lines and the
engine's own plain text. That was on purpose: eight lessons with no artwork
meant eight lessons where nothing could go wrong except your program. Now the
boxes become a ship and some rocks.
A picture the engine draws onto the overlay is called a *sprite*. This lesson
loads them, draws them, measures them, puts them in the right order, and steps
through a picture that holds several frames of an animation. By the end, the
game from lesson seven looks like a game.
=== The Art Kit
You do not have to draw anything. This book ships a small folder of artwork,
and everything in this lesson and the next uses it.
Find the `art` folder that came with the book and copy the whole folder into
this lesson's folder, beside your script. You should end up with this:
----
singe/ <- your work folder
Singe/
data/
rocks/
rocks.singe
art/
ship.png
rock.png
shot.png
star.png
walk.png
shoot.wav
boom.wav
----
Your artwork goes inside your game's own folder, and the engine's `Singe`
folder stays where it is, out in the work folder. That matters in a moment,
because it decides how you name a file when you load it.
The `Singe` folder is the one the engine unpacked for you on your very first
run, back in lesson one. It is worth knowing what is in it, because those
files are yours to use too and every reader of this book has them:
* `Singe/click.wav` -- a short click, which the engine's own menu uses for its
sound test.
* `Singe/FreeSansBold.ttf` -- a TrueType font, for lesson nineteen.
* `Singe/menuIntro.flac` -- the music the engine's menu plays. You will use it
in lesson ten.
* `Singe/menuBackground.mkv` -- a video, for lesson fourteen.
* `Singe/DragonModel.glb` and `Singe/SingeText.glb` -- two 3D models, for
lesson twenty-three.
* `Singe/missing.png` -- the picture the engine draws when a game asks for one
it cannot find.
The kit's pictures are small on purpose. The ship is 32 pixels across and 24
down, a rock is 24 by 24, a shot is 4 by 10, and a star is 8 by 8. That sounds
tiny until you remember how big the screen actually is, which is the next
thing to sort out.
=== How Big the Screen Is
Without a video to play, Singe gives your game a canvas 720 pixels across and
480 down, and the surface you draw on -- the *overlay* -- is half that in each
direction: *360 by 240*. Singe stretches it to fill the window for you, so a
32 pixel ship is about a tenth of the width of the screen, which is roughly
what a ship in an arcade game looks like.
Never type 360 and 240 into your program. Ask:
[source,lua]
----
local screenWidth = overlayGetWidth()
local screenHeight = overlayGetHeight()
----
Those two report the size of the overlay, and a game that asks is a game that
still works when somebody runs it at a different size.
There is one thing that does not count in those pixels: `overlayPrint`, from
lesson one, counts in character cells. Each cell is 6 pixels across and 13
down, so a 360 by 240 overlay is 60 columns by 18 rows of text. Sprites are in
pixels, `overlayPrint` is in cells, and mixing the two up is worth one
confused afternoon if you let it be.
=== One Picture on Screen
Start a new file next to your `art` folder and type this in.
[source,lua]
----
dofile("Singe/Framework.singe")
local ship = spriteLoad(DIR .. "art/ship.png")
function onOverlayUpdate()
overlayClear()
spriteDraw(ship, 160, 100)
return OVERLAY_UPDATED
end
function onShutdown()
spriteUnload(ship)
end
----
Run it. A small blue ship sits near the middle of a black screen, pointing up.
=== What Just Happened
Four new things, one at a time.
[source,lua]
----
local ship = spriteLoad(DIR .. "art/ship.png")
----
`spriteLoad` reads an image file off the disk, decodes it, and keeps the
picture in memory. What it hands back is not the picture. It is a *handle*: a
plain number, an entry ticket, the engine's way of saying "that one". You give
that number back to the engine every time you want to do something with the
picture, and you never need to know or care what the number is.
Print it if you like -- `debugPrint(ship)` from lesson eight will show you a
small number -- but the number itself is not yours to reason about, and
nothing in your program should ever depend on what it turns out to be. The
only rule is that you keep it somewhere you can get at it again, which is
exactly what a variable is for.
`DIR` is new too, and it solves a problem the layout you have been using since
lesson one creates. Singe looks for files relative to the folder you were
standing in when you started it, *not* relative to your script. You start it
from your work folder, so a plain `art/ship.png` sends the engine looking for
`singe/art/ship.png`, out beside the `Singe` folder. Your artwork is not
there. It is one level down, in `rocks/art/ship.png`, with your script.
`DIR` closes that gap. It is a piece of text holding the folder your script is
in, with the slash already on the end, so `DIR .. "art/ship.png"` -- joining
two pieces of text with `..`, which you have been doing since part one --
names the picture correctly however the game was started, and keeps naming it
correctly when you move the whole folder somewhere else.
`DIR` is one of the names that `Singe/Framework.singe` defines, which is what
the `dofile` line at the top of your script is for. You met that line and what
is in it in lesson seven, and this is the moment it earns its place: without
it there is no `DIR`, and without `DIR` the engine cannot find your ship.
Use `DIR` on every file your game loads, from here to the end of the book.
[source,lua]
----
spriteDraw(ship, 160, 100)
----
Draw the sprite with that handle, at that position. Like every drawing call,
it only works inside `onOverlayUpdate`.
The position is the *top left corner* of the picture, not its middle. Ask for
`(160, 100)` and the topmost, leftmost pixel of the ship lands there and the
rest of it hangs down and to the right. That trips up everybody once, usually
when they try to centre something and it comes out low and to the right by
half its own size.
If you would rather give the middle, say so:
[source,lua]
----
spriteDraw(ship, 160, 100, true)
----
A fourth argument of `true` means "treat that point as the centre". Both forms
are in the manual's entry for `spriteDraw`, along with two more that stretch
the picture into a rectangle.
[source,lua]
----
function onShutdown()
spriteUnload(ship)
end
----
`onShutdown` is a callback, like `onOverlayUpdate` and the two input callbacks
from lesson three. The engine calls it once, when your game is ending. It is
where you give back what you took: `spriteUnload` frees the picture and throws
the handle away.
Strictly, you could leave it out. The engine frees everything your script
loaded when the game ends, so a missed `spriteUnload` is not a leak. Write it
anyway. It costs one line, it says what your program owns, and in a minute you
will see the case where forgetting to unload really does matter.
One warning about that handle: once you have unloaded a sprite, the number is
dead. Drawing with it does not draw nothing, it ends your game with an error.
=== Transparency, and Why It Is a PNG
The ship is a triangle with two fins. The file it lives in is a rectangle, 32
by 24, because every image file is a rectangle. So what happened to the
corners?
They are *transparent*. A PNG can say, for every single pixel, how solid it
is, and the kit's pictures say "not there at all" for every pixel outside the
shape. Singe honours that, so the black background shows through the corners
and the ship has an outline instead of a box around it.
This is the reason the kit is PNG and not JPEG. A JPEG cannot store
transparency at all -- it would give you the ship in a grey box -- and it also
smudges hard edges, which is the last thing small artwork needs. For game
artwork, PNG. For a photograph that fills the whole screen and has no
transparent parts, JPEG is fine and much smaller. The manual's entry for
`spriteLoad` lists every format Singe will read, and there are a lot of them.
Try it yourself: change the black to something else by drawing a box behind
the ship before you draw the ship. The corners will show whatever is behind
them, which is the whole point.
=== Do Not Load While You Draw
Here is the mistake this section exists to stop. It looks completely
reasonable:
[source,lua]
----
function onOverlayUpdate()
overlayClear()
local ship = spriteLoad(DIR .. "art/ship.png")
spriteDraw(ship, 160, 100)
return OVERLAY_UPDATED
end
----
Run that and it works. It draws the ship. Nothing complains. You would have no
reason to think anything was wrong.
`onOverlayUpdate` runs about sixty times a second. That is sixty fresh copies
of the ship loaded into memory every second, three thousand six hundred a
minute, and not one of them ever unloaded. The game runs beautifully for two
minutes, gets slower, and then the machine runs out of memory and it dies.
Worse, it dies during the demo and not while you were testing.
The rule: *load once, draw many times*. Loading is slow and it takes memory.
Drawing is fast and takes none. So `spriteLoad` goes at the top of your file,
outside every function, where it runs one time as the script starts.
This is not only about sprites. Every `somethingLoad` in the engine works this
way -- sounds in lesson ten, fonts in lesson nineteen, models in lesson
twenty-three. If you find yourself typing `Load` inside a function that runs
every frame, stop and move it out.
=== How Big Is It?
You know the ship is 32 by 24 because this book told you. Your program does
not, and it should not have to. Ask:
[source,lua]
----
local shipWidth = spriteGetWidth(ship)
local shipHeight = spriteGetHeight(ship)
----
Now you can keep the ship on the screen, because the rightmost position it may
sit at is `screenWidth - shipWidth`, and put it in the middle, because the
middle is `(screenWidth - shipWidth) / 2`. Neither line has a number in it
that would have to change if somebody redrew the ship a bit bigger.
Both of these report the size *as the sprite would be drawn right now*. If you
ever scale or rotate a sprite, with `spriteScale` or `spriteRotate`, the
answer changes to match. That is usually what you want, and it will surprise
you exactly once.
=== Drawing Order
Sprites do not merge. Later covers earlier, pixel for pixel, exactly like
sticking paper cutouts on a wall. So the order of your `spriteDraw` calls is
the order from back to front:
[source,lua]
----
drawStars()
drawRocks()
drawShots()
spriteDraw(shipSprite, shipX, shipY)
drawHud()
----
Stars are furthest back, so they go down first. The ship is in front of
everything in the playfield. The score sits on top of all of it. Get this
backwards and your carefully drawn ship spends the game hiding behind the star
field, and there is no error to tell you so -- only a ship you cannot see.
`overlayClear` still comes first, before all of it. Drawing never replaces, it
only covers, so the frame starts empty every time.
=== Frames
Open `art/walk.png` in any image viewer. It is not a picture of a person. It is
a picture of four people, side by side: the same figure at four points of a
walk, each one 24 pixels across in a strip 96 wide.
One picture holding several frames of an animation is a *sprite sheet*, and it
is how nearly all 2D animation is stored. One file, one load, one handle, and
the game picks which slice to draw.
Make a second file, `walk.singe`, beside the first one:
[source,lua]
----
dofile("Singe/Framework.singe")
local walk = spriteLoadFrames(4, DIR .. "art/walk.png")
local walkFrame = 1
local walkTick = 0
local walkX = 0
function onOverlayUpdate()
overlayClear()
walkTick = walkTick + 1
if walkTick >= 8 then
walkTick = 0
walkFrame = walkFrame + 1
if walkFrame > 4 then
walkFrame = 1
end
end
walkX = walkX + 1
if walkX > overlayGetWidth() then
walkX = -spriteFrameWidth(walk)
end
spriteDrawFrame(walk, walkX, 100, walkFrame)
return OVERLAY_UPDATED
end
function onShutdown()
spriteUnload(walk)
end
----
Run it with `Singe walk`. A small figure walks steadily across the
screen, disappears off the right edge, and comes back on from the left.
`spriteLoadFrames` is `spriteLoad` with one extra thing to say: how many
frames are in the strip. *The count comes first*, before the file name, which
is the opposite of what most people guess. It divides the width of the image
by the count and remembers the slices. Four frames in a 96 pixel strip means
four frames 24 pixels wide. Get the count wrong -- say 3 -- and you get three
32 pixel slices, each showing bits of two figures, which is a memorably silly
way to find out you typed the wrong number.
`spriteDrawFrame` draws one slice. It takes the handle, where to put it, and
which frame, *in that order*, and frames are numbered *from 1*: frame 1 is the
leftmost figure and frame 4 the rightmost.
Watch out for that 1. Elsewhere in the sprite family -- `spriteSetFrame` and
`spriteGetFrame` -- frames are numbered from 0. That is an accident of
history, both are in the manual, and this book uses `spriteDrawFrame` and
counts from 1.
`spriteFrameWidth` reports the width of one frame, 24 here, rather than the 96
of the whole strip. `spriteFrameHeight` is its partner. When you want to know
how wide a slice of a sheet is, those are the two to ask, and not
`spriteGetWidth`.
The frame number is a number in a variable like any other, so the walking is
arithmetic:
[source,lua]
----
walkTick = walkTick + 1
if walkTick >= 8 then
walkTick = 0
walkFrame = walkFrame + 1
if walkFrame > 4 then
walkFrame = 1
end
end
----
Count the frames going by. Every eighth one, step to the next picture, and
after the fourth picture go back to the first. Sixty frames a second divided
by eight is between seven and eight steps a second, which for a four frame
cycle is a brisk but believable walk. Change the 8 and you change the speed:
bigger is slower.
You may wonder why you are counting at all, when the engine has `spritePlay`
to run an animation for you. It does, and for an animated GIF it is the right
answer, because a GIF carries the timing for each of its frames inside the
file. A plain strip like `walk.png` carries no timing, so the engine runs it
as fast as it is allowed to -- a hundred frames a second, twenty-five complete
walk cycles every second, a blur. When the frames come from a strip, do the
counting yourself.
=== The Game Gets Its Artwork
Now put it together. The game from lesson seven steered a box along the bottom
of the screen and dropped other boxes on it. Every box becomes a picture, and
while you are in there the ship gets something to shoot with.
The changes are all of a piece, so here is the whole script. It is longer than
anything you have written, and there is nothing in it you have not met.
[source,lua]
----
dofile("Singe/Framework.singe")
local SHIP_SPEED = 3
local SHOT_SPEED = 6
local ROCK_COUNT = 6
local STAR_COUNT = 40
local START_LIVES = 3
local HIT_SCORE = 10
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local starSprite = spriteLoad(DIR .. "art/star.png")
local screenWidth = overlayGetWidth()
local screenHeight = overlayGetHeight()
local shipWidth = spriteGetWidth(shipSprite)
local shipHeight = spriteGetHeight(shipSprite)
local rockWidth = spriteGetWidth(rockSprite)
local rockHeight = spriteGetHeight(rockSprite)
local shotWidth = spriteGetWidth(shotSprite)
local shotHeight = spriteGetHeight(shotSprite)
local shipX = (screenWidth - shipWidth) / 2
local shipY = screenHeight - shipHeight - 4
local goLeft = false
local goRight = false
local rocks = {}
local shots = {}
local stars = {}
local score = 0
local lives = START_LIVES
local over = false
function drawHud()
overlayPrint(1, 1, "SCORE " .. score)
overlayPrint(50, 1, "LIVES " .. lives)
if over then
overlayPrint(25, 8, "GAME OVER")
overlayPrint(17, 10, "PRESS 1 TO PLAY AGAIN")
end
end
function drawRocks()
for _, rock in ipairs(rocks) do
spriteDraw(rockSprite, rock.x, rock.y)
end
end
function drawShots()
for _, shot in ipairs(shots) do
spriteDraw(shotSprite, shot.x, shot.y)
end
end
function drawStars()
for _, star in ipairs(stars) do
spriteDraw(starSprite, star.x, star.y)
end
end
function newRock(rock)
rock.x = math.random(0, screenWidth - rockWidth)
rock.y = -rockHeight - math.random(0, 160)
rock.speed = math.random(8, 20) / 10
end
function onInputPressed(what)
if over then
if what == SWITCH_START1 then
startGame()
end
return
end
if what == SWITCH_LEFT then
goLeft = true
elseif what == SWITCH_RIGHT then
goRight = true
elseif what == SWITCH_BUTTON1 then
shots[#shots + 1] = { x = shipX + shipWidth / 2 - shotWidth / 2, y = shipY }
end
end
function onInputReleased(what)
if what == SWITCH_LEFT then
goLeft = false
elseif what == SWITCH_RIGHT then
goRight = false
end
end
function onOverlayUpdate()
overlayClear()
updateStars()
if not over then
updateShip()
updateShots()
updateRocks()
end
drawStars()
drawRocks()
drawShots()
spriteDraw(shipSprite, shipX, shipY)
drawHud()
return OVERLAY_UPDATED
end
function onShutdown()
spriteUnload(shipSprite)
spriteUnload(rockSprite)
spriteUnload(shotSprite)
spriteUnload(starSprite)
end
function overlapping(ax, ay, aw, ah, bx, by, bw, bh)
if ax + aw <= bx then
return false
end
if bx + bw <= ax then
return false
end
if ay + ah <= by then
return false
end
if by + bh <= ay then
return false
end
return true
end
function startGame()
shipX = (screenWidth - shipWidth) / 2
score = 0
lives = START_LIVES
over = false
shots = {}
for i = 1, ROCK_COUNT do
rocks[i] = {}
newRock(rocks[i])
end
end
function updateRocks()
for _, rock in ipairs(rocks) do
rock.y = rock.y + rock.speed
if rock.y > screenHeight then
newRock(rock)
end
if overlapping(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then
newRock(rock)
lives = lives - 1
if lives <= 0 then
over = true
end
end
end
end
function updateShip()
if goLeft then
shipX = shipX - SHIP_SPEED
end
if goRight then
shipX = shipX + SHIP_SPEED
end
if shipX < 0 then
shipX = 0
end
if shipX > screenWidth - shipWidth then
shipX = screenWidth - shipWidth
end
end
function updateShots()
for i = #shots, 1, -1 do
local shot = shots[i]
local gone = false
shot.y = shot.y - SHOT_SPEED
if shot.y + shotHeight < 0 then
gone = true
end
for _, rock in ipairs(rocks) do
if not gone and overlapping(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then
newRock(rock)
score = score + HIT_SCORE
gone = true
end
end
if gone then
table.remove(shots, i)
end
end
end
function updateStars()
for _, star in ipairs(stars) do
star.y = star.y + star.speed
if star.y > screenHeight then
star.y = 0
star.x = math.random(0, screenWidth - 1)
end
end
end
for i = 1, STAR_COUNT do
stars[i] = { x = math.random(0, screenWidth - 1), y = math.random(0, screenHeight - 1), speed = math.random(1, 3) / 4 }
end
startGame()
----
Left and right arrows steer. Space fires. When the rocks have taken your last
life, `1` starts a new game.
A few things in there are worth pointing at.
The four `spriteLoad` calls and the eight measurements are at the top, outside
every function, so they happen once. Everything after them is written in terms
of `screenWidth`, `shipWidth`, and their friends, and there is not one raw
pixel count in the whole of the playing code.
The functions are in alphabetical order, which is a habit worth picking up
now. It does not matter to Lua in the slightest -- it is for you, six months
from now, looking for `updateShots` in a file with forty functions in it.
The last two things in the file are not functions at all. They are plain
instructions at the bottom of the script: build the star field, then call
`startGame` to set up the rocks and zero the score. They run once, in order,
as the script loads, and they have to come after the functions they use.
`overlapping` is the same idea you wrote in lesson seven, moved into a
function of its own and given proper arguments. Two rectangles miss each other
if either one is entirely left of, right of, above, or below the other; if
none of those four is true, they overlap. Lesson eleven replaces it with
something the engine provides, and explains why the rectangle is the wrong
shape for a ship.
One last note for when you read other people's Singe code. Every sprite call
in this book takes the handle *first*. Singe 2.10 put it last, and a game can
ask the engine to keep doing that, so code you find online may read
`spriteDraw(x, y, ship)`. Do not copy it. Handle first is the current order
and the one the manual documents.
=== Try It
. *Centre the ship on its position.* Add `true` as a fourth argument to the
ship's `spriteDraw`. Watch where the ship jumps to, and work out from the
distance it moved what the fourth argument actually did.
. *More rocks.* Change `ROCK_COUNT` to 20. Then to 200. Somewhere between
those two the game stops being playable, and somewhere well past it the game
starts to slow down. Find both.
. *Grow the ship.* Add `spriteScale(shipSprite, 2)` at the very bottom of the
file, on the line above `startGame()`. Run it and steer into the right-hand
edge: the ship is twice the size but it still stops where the small one
stopped, and it hangs off the right edge and off the bottom. Work out why,
then move that one line up above the `spriteGetWidth` calls and watch it
come right. The manual's entry for `spriteScale` explains what scaling does
to the measurements.
. *Put the walker in.* Load `walk.png` in the game, and draw the walking
figure across the top of the screen as scenery. You will need the frame
counting from the walk script, and you will have to decide where in
`onOverlayUpdate` the draw goes -- in front of the stars, behind the rocks.
. *Unload something you are still using.* Add `spriteUnload(starSprite)` at
the bottom of the file, after `startGame()`, and run it. Read what you get.
=== Break It on Purpose
Capital letters matter, and artwork is where that bites hardest. Change the
ship's load to use a capital S:
[source,lua]
----
local shipSprite = spriteLoad(DIR .. "art/Ship.png")
----
The game refuses to start:
----
10:spriteLoad: Couldn't open art/Ship.png: No such file or directory
----
Read it the same way as the error in lesson one, but note that the shape is a
little different. `10` is the line. `spriteLoad` is the engine function that
gave up, rather than a file name -- when the complaint comes from Singe rather
than from Lua, this is what you get. Then the reason, which here is your
operating system's own wording and will differ slightly from machine to
machine.
`No such file or directory` is the computer being precise rather than unkind.
There genuinely is no file called `Ship.png`; there is one called `ship.png`,
and to a Linux or macOS machine those are two different names. On Windows it
would have worked, which is worse, because it means the bug travels to
somebody else's machine and appears there for the first time.
The other half of this error you will meet is the folder. If you put the art
kit in the wrong place, or started Singe from the wrong directory, the message
is exactly the same and the fix is not. Check three things, in this order: is
the file named exactly what you typed, is it in the `art` folder, and is that
`art` folder beside your script.
=== What You Learned
* A picture drawn onto the overlay is a sprite, loaded with `spriteLoad`.
* `spriteLoad` hands back a handle: a number that means "that picture" to the
engine, which you keep in a variable.
* `DIR` is the folder your script is in, and every file your game loads should
be named with it.
* `spriteDraw(handle, x, y)` draws it, from its top left corner, and only
inside `onOverlayUpdate`. A fourth argument of `true` means the centre
instead.
* Load once, at the top of the file. Loading inside `onOverlayUpdate` eats
memory until the game dies.
* `spriteUnload` gives a picture back, usually from `onShutdown`, and the
handle is dead afterwards.
* PNG carries transparency, which is why the corners of the ship are not
black boxes.
* `spriteGetWidth` and `spriteGetHeight` measure a sprite as it would be drawn
now, so you never have to type its size into your program.
* Sprites cover each other. The order you draw in is back to front.
* `spriteLoadFrames(count, file)` slices a strip into frames, and
`spriteDrawFrame(handle, x, y, frame)` draws one of them, counting from 1.
=== Next Time
The game looks right and sounds like nothing at all. In lesson ten the shots
get a noise, the rocks get an explosion, and something plays underneath the
whole thing -- and you meet your third callback, the one the engine uses to
tell you a sound has finished.