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

488 lines
16 KiB
Text

== Lesson 11: Hitting Things
image::learn/11-hitting-things.png[The finished lesson, 480]
Your ship slides along the bottom of the screen and the rocks fall past it.
Nothing has ever touched anything. In this lesson the ship fires, the rocks
break, and the game finally pushes back.
Two things in a game touch when the numbers say they do. Nobody looks at the
pictures. Working out what the numbers have to say is the whole of collision,
and you can do it yourself in a dozen lines before the engine does it for you
in one.
=== Where You Are
The script from lesson ten loads its artwork from the `art` folder beside it,
draws the ship near the bottom, and drops rocks down the screen. It looks like
this, with the details of the rocks left out:
[source,lua]
----
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shootSound = soundLoad(DIR .. "art/shoot.wav")
local boomSound = soundLoad(DIR .. "art/boom.wav")
----
Everything in this lesson is added to that script. The whole finished thing is
in `learn/11-hitting-things.singe` if you lose your way.
=== Work It Out Yourself First
The ship is a picture 32 wide and 24 tall, drawn with its top left corner at
`shipX`, `shipY`. A rock is 24 by 24 at `rock.x`, `rock.y`. Are they touching?
That question is hard to answer directly and easy to answer backwards. Two
rectangles are *not* touching when one of them is entirely to the left of the
other, or entirely to the right, or entirely above, or entirely below. There is
no fifth way to miss. If none of those four is true, they are touching.
Type this in, above your other functions, and read it rather than trusting it:
[source,lua]
----
local function overlap(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
----
Each `if` is one of the four ways to miss. `ax + aw` is the right hand edge of
the first rectangle, so `ax + aw < bx` reads "the first one ends before the
second one starts", which is what "entirely to the left" means in numbers. The
same shape three times more -- for right, above, and below, with `y` and the
heights in place of `x` and the widths -- and then the only thing left is
`true`.
Those are the eight numbers any collision test needs: a corner and a size, for
each of two things. Keep the shape in your head, because you have now written
it once and will never write it again.
=== The Engine Already Has It
Delete `overlap`. Singe ships the same test, and it is called `collideRects`:
[source,lua]
----
if collideRects(shipX, shipY, 32, 24, rock.x, rock.y, 24, 24) then
soundPlay(boomSound)
end
----
Eight numbers in, `true` or `false` out, in the same order you just wrote them:
the first rectangle's corner and size, then the second one's. It is one of a
small family of collision functions, all of them plain questions with no
handles to keep and nothing to set up, and the manual's Collide section lists
every one. Two edges that touch exactly count as touching, and a rectangle with
no width or height touches nothing at all.
None of them move anything. `collideRects` tells you that the ship and the rock
are in the same place; what to do about it is yours to decide, and everything
interesting in a game happens in that decision.
=== The Picture Is Not the Hit Box
Draw the ship's 32 by 24 rectangle on paper and shade in the ship. There is
sky in the corners. A rock that clips the top right corner of that rectangle
has not hit the ship, it has flown past the wing, and a player who loses a life
for it will say the game cheated -- and be right.
So the box you test is not the picture you draw. It is smaller, and it sits
inside the picture:
[source,lua]
----
local SHIP_WIDTH = 32
local SHIP_HEIGHT = 24
local SHIP_PAD_X = 6
local SHIP_PAD_Y = 5
local ROCK_WIDTH = 24
local ROCK_HEIGHT = 24
local ROCK_PAD = 4
----
The pad is how far in from each edge the real thing starts. Six pixels off each
side of the ship and five off the top and bottom leaves a box 20 by 14 in the
middle of a 32 by 24 picture, which is about where the hull is.
Rather than write that arithmetic out at every test, write it once per kind of
thing:
[source,lua]
----
local function rockBox(rock)
return rock.x + ROCK_PAD, rock.y + ROCK_PAD, ROCK_WIDTH - ROCK_PAD * 2, ROCK_HEIGHT - ROCK_PAD * 2
end
local function shipBox()
return shipX + SHIP_PAD_X, shipY + SHIP_PAD_Y, SHIP_WIDTH - SHIP_PAD_X * 2, SHIP_HEIGHT - SHIP_PAD_Y * 2
end
----
Each of those hands back four numbers at once, separated by commas. A function
may return as many values as it likes, and the way to catch them is a list of
names on the left of the `=`:
[source,lua]
----
local function shipHitsRock(rock)
local sx, sy, sw, sh = shipBox()
local rx, ry, rw, rh = rockBox(rock)
return collideRects(sx, sy, sw, sh, rx, ry, rw, rh)
end
----
The padding arithmetic is now in exactly two places, and the numbers it uses
are at the top of the file, so you can tune the game's fairness by changing one
number rather than by hunting through your tests.
=== Firing Back
A shot is a small record in a list, exactly like a rock. `shot.png` is 4 wide
and 10 tall, and shots travel up the screen instead of down:
[source,lua]
----
local SHOT_WIDTH = 4
local SHOT_HEIGHT = 10
local SHOT_SPEED = 6
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local shots = {}
local function fireShot()
local shot = {}
shot.x = shipX + SHIP_WIDTH / 2 - SHOT_WIDTH / 2
shot.y = shipY - SHOT_HEIGHT
shots[#shots + 1] = shot
soundPlay(shootSound)
end
local function moveShots()
for i = #shots, 1, -1 do
local shot = shots[i]
shot.y = shot.y - SHOT_SPEED
if shot.y + SHOT_HEIGHT < 0 then
table.remove(shots, i)
end
end
end
----
The fire button calls it, alongside the movement you already have:
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_LEFT then
movingLeft = true
elseif what == SWITCH_RIGHT then
movingRight = true
elseif what == SWITCH_BUTTON1 then
fireShot()
end
end
----
And the shots are drawn in `onOverlayUpdate` the way the rocks are:
[source,lua]
----
for _, shot in ipairs(shots) do
spriteDraw(shotSprite, shot.x, shot.y)
end
----
=== A Shot Is Almost a Point
A shot is four pixels wide. Shrinking that into a hit box would leave nearly
nothing, and testing a whole rectangle against a rock to find out whether a
sliver of light touched it is more machinery than the question deserves.
Test the tip instead. `collidePointRect` asks whether one point falls inside
one rectangle -- two numbers for the point, then the rectangle's corner and
size:
[source,lua]
----
local function shotHitsRock(shot, rock)
local rx, ry, rw, rh = rockBox(rock)
return collidePointRect(shot.x + SHOT_WIDTH / 2, shot.y, rx, ry, rw, rh)
end
----
`shot.y` is the top of the shot and `shot.x + SHOT_WIDTH / 2` is halfway across
it, so the point being tested is the middle of the shot's nose. That is where a
player thinks a shot is.
=== Deciding What Happened
One function, called once a frame, asks every question and acts on the answers:
[source,lua]
----
local function checkHits()
for s = #shots, 1, -1 do
for r = #rocks, 1, -1 do
if shotHitsRock(shots[s], rocks[r]) then
table.remove(rocks, r)
table.remove(shots, s)
soundPlay(boomSound)
break
end
end
end
for r = #rocks, 1, -1 do
if shipHitsRock(rocks[r]) then
table.remove(rocks, r)
soundPlay(boomSound)
end
end
end
----
Call it from `onOverlayUpdate`, after everything has moved and before anything
is drawn:
[source,lua]
----
moveShip()
moveRocks()
moveShots()
checkHits()
----
Run it. Rocks fall, shots rise, and where they meet there is a bang and a gap.
The rock that reaches the ship makes the same noise and disappears, which is
not yet a punishment; lesson twelve turns it into one.
=== Seeing the Boxes
The hit boxes are invisible, which makes a mistake in them invisible too. Draw
them while you are working:
[source,lua]
----
local SHOW_BOXES = false
local function drawBox(x, y, width, height)
overlayBox(x, y, x + width - 1, y + height - 1)
end
local function drawBoxes()
colorForeground(0, 255, 0, 255)
drawBox(shipBox())
for _, rock in ipairs(rocks) do
drawBox(rockBox(rock))
end
end
----
And at the end of the drawing in `onOverlayUpdate`:
[source,lua]
----
if SHOW_BOXES then
drawBoxes()
end
----
Change `SHOW_BOXES` to `true` and the green outlines appear over the artwork.
Change it back when you are done. A switch like this, one word at the top of a
file that turns a picture of what the program believes on and off, is worth
more than any amount of staring.
=== What Just Happened
[source,lua]
----
local function rockBox(rock)
return rock.x + ROCK_PAD, rock.y + ROCK_PAD, ROCK_WIDTH - ROCK_PAD * 2, ROCK_HEIGHT - ROCK_PAD * 2
end
----
Four values from one `return`, separated by commas. Lua is happy to hand back
as many as you want, and the caller decides how many to keep. When a call like
this is the *last* thing inside another call's parentheses, all four are passed
along, which is why `drawBox(rockBox(rock))` works and gives `drawBox` its four
arguments. Anywhere else in an argument list, only the first value survives.
That rule bites exactly once, and it bites at the end of this lesson.
[source,lua]
----
return collideRects(sx, sy, sw, sh, rx, ry, rw, rh)
end
----
`collideRects` wants eight numbers: corner and size, corner and size. It gives
back `true` or `false` and changes nothing. Because the answer comes straight
back out of the function with `return`, `shipHitsRock(rock)` reads like a
question wherever it is used.
[source,lua]
----
return collidePointRect(shot.x + SHOT_WIDTH / 2, shot.y, rx, ry, rw, rh)
----
`collidePointRect` wants six: a point, then a rectangle. The edge counts as
inside, so a shot exactly on the boundary hits. Use it whenever one side of the
question is small enough that its size does not matter.
[source,lua]
----
for s = #shots, 1, -1 do
----
The loop runs backwards, from the last shot down to the first, because it
removes things as it goes. `table.remove(shots, 3)` shuffles every later shot
down one place, so a forward loop would step straight over the shot that moved
into the gap. Counting down, everything you have not looked at yet is below
you, and removing something above you cannot disturb it. You met this in lesson
six; this is where it earns its keep.
[source,lua]
----
break
end
end
end
----
`break` leaves the loop it is inside immediately. One shot destroys one rock,
so once a rock is gone there is no point comparing the same shot against the
rest of them -- and the shot itself has been removed, so carrying on would
compare a shot that no longer exists. `break` stops the inner loop over rocks;
the outer loop over shots carries on with the next shot.
[source,lua]
----
colorForeground(0, 255, 0, 255)
overlayBox(x, y, x + width - 1, y + height - 1)
----
`overlayBox` draws the outline of a rectangle, and it takes two *corners*, not
a corner and a size -- the one place in this lesson where the numbers change
shape. The `- 1` is because both corners are included: a box 20 wide that
starts at 100 ends at 119. The colour is not an argument. `colorForeground`
sets it, and every shape drawn afterwards uses it until something changes it
again.
=== The Rest of the Family
`collideRects` and `collidePointRect` will carry most 2D games. The others are
there for the shapes rectangles describe badly, and each is the same kind of
plain question:
* `collideCircles` and `collidePointCircle`, for anything round. A circle is a
centre and a radius.
* `collideRectCircle`, for a round thing against a square one.
* `collidePointPolygon`, for a shape with corners of its own -- a dragon, a
continent, an odd-shaped button. You give it a flat list of `x`, `y`, `x`,
`y` numbers.
* `collideSegments`, for two line segments crossing.
That last one matters more than it sounds. Your shot moves six pixels a frame
and the rocks are sixteen pixels of hit box, so a shot cannot get past one
without being tested inside it. Make the shot fast enough -- forty pixels a
frame -- and it teleports from above a rock to below it, never once being in
the same place as the rock, and it never hits anything. Drawing the line from
where the shot was to where it is now, and asking `collideSegments` whether
that line crosses an edge, is the cure. Slow things do not need it.
There is one collision callback in Singe, `onCollision`, and it is not for
this. It belongs to the 3D physics engine: bodies with mass, falling and
bouncing off one another in three dimensions, telling you where they touched
and how hard. You will meet it in lesson twenty-four. Nothing on the overlay
reports itself, and nothing needs to. Your rocks are in a list, you know how
many there are, and asking is cheap.
And when you want to know not just *whether* two things met but where the
moving one should end up -- sliding along a wall instead of stopping dead
inside it -- the collide calls deliberately do not answer that. A library
called `bump` is bundled with Singe for exactly that job, and the manual's
Included Libraries section says how to reach it.
=== Try It
. *Take the padding out.* Set `ROCK_PAD` to `0` and play for a minute. Then set
it to `8` and play again. One of them feels like the game is lying to you.
. *Look at what you are testing.* Set `SHOW_BOXES` to `true` and watch the
green outlines while a rock passes the ship. Now set `SHIP_PAD_X` to `20` and
look again.
. *Make the shot too fast.* Set `SHOT_SPEED` to `40`. Count how many rocks you
hit out of ten. Work out from the numbers why that happens before you change
it back.
. *Use the whole shot.* Rewrite `shotHitsRock` to use `collideRects` with the
shot's full 4 by 10 rectangle instead of `collidePointRect` with its nose.
Play both and decide which one you prefer; there is no right answer, which is
worth knowing.
. *Rocks against rocks.* Write a loop that compares every rock with every other
rock and prints a line with `debugPrint` when two of them overlap. Watch how
many comparisons that is, and why nobody does it for a thousand rocks.
=== Break It on Purpose
The two boxes are already four values each, so it is tempting to feed them
straight in and skip the eight local names:
[source,lua]
----
local function shipHitsRock(rock)
return collideRects(shipBox(), rockBox(rock))
end
----
That looks right and it is wrong. Run it and the game stops with:
----
62:collideRects: Expected 8 argument(s), got 5.
----
Five. `rockBox(rock)` is the last thing in the parentheses, so all four of its
values went in. `shipBox()` is not, so it was cut down to one. One plus four is
five, and `collideRects` will not guess at the other three.
This error comes from the engine rather than from Lua, so it is shaped a little
differently from the ones in lesson eight: the number at the front is the line,
then the name of the function that refused, then the complaint. Put the eight
local names back.
=== What You Learned
* Collision is arithmetic on rectangles, and you can write it yourself.
* Two rectangles miss in exactly four ways; if none of them is true, they hit.
* `collideRects` takes a corner and a size for each of two rectangles and
answers `true` or `false`.
* `collidePointRect` asks whether a point is inside a rectangle, which is the
right question for anything small.
* The box you test is smaller than the picture you draw, or the game feels
unfair.
* A function can return several values, and only the last call in an argument
list passes all of them on.
* Loop backwards through a list you are removing things from, and `break` out
of a loop that has nothing left to find.
* Drawing your hit boxes on screen turns an invisible bug into a visible one.
* `onCollision` is for 3D physics bodies, not for sprites on the overlay.
=== Next Time
Rocks break and the ship gets hit, and neither costs anything. Lesson twelve
adds a score that goes up, lives that run out, a title screen to come back to,
and a high score that is still there tomorrow.