singe/docs/lessons/11-hitting-things.adoc

497 lines
19 KiB
Text

== Lesson 11: Hitting Things
image::learn/11-hitting-things.png[The finished lesson, 480]
Your game from lesson ten works. Shots break rocks, rocks cost lives, and the
explosions land in the right places. Watch it closely, though, and it cheats,
in both directions. A rock that slides past the tip of a wing takes a life. A
shot that goes through the empty sky beside a rock breaks it anyway.
Two things in a game touch when the numbers say they do. Nobody looks at the
pictures. This lesson makes the numbers agree with what the player sees: it
swaps your hand-written test for the engine's own, shrinks the boxes being
tested so they fit the things inside them, and gives you a switch that shows
you those boxes. The picture above is the game with that switch on.
=== Where You Are
The game asks "do these two touch?" in exactly two places, and both use
`overlapping`, the function you typed in lesson nine. One is in `updateRocks`,
where a rock meets the ship:
[source,lua]
----
if overlapping(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
The other is in `updateShots`, where a shot meets a rock:
[source,lua]
----
if not gone and overlapping(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
Everything in this lesson changes those two lines and adds a few small
functions to feed them. Nothing else in the game moves. The whole finished
script is in `learn/11-hitting-things.singe` if you lose your way.
=== Read It Once More
Lesson nine already did this part: `overlapping` is rectangle collision
written out by hand. Read it once more before you throw it away, because it is
the whole of the idea:
[source,lua]
----
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
----
The question "are these two touching?" 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.
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 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 are about to
delete it.
=== The Engine Already Has It
You met `collideRects` in lesson seven, where it caught the falling blocks in
Dodge. Lesson nine wrote the same test out by hand so you could see inside it.
You have seen inside it. Delete `overlapping`, and change the two lines from
*Where You Are* to call the engine instead:
[source,lua]
----
if collideRects(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
[source,lua]
----
if not gone and collideRects(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
Same eight numbers, in the same order: the first rectangle's corner and size,
then the second one's. Run it, and the game plays exactly as it did, which is
the point. The only difference is at the very edge. Your `<=` said that two
rectangles meeting along an edge are not touching. `collideRects` says they
are, and a rectangle with no width or height touches nothing at all. Those
awkward cases are where hand-written versions go quietly wrong, and they are
one reason to use the engine's.
`collideRects` is one of a family of collision tests, and the family is one
part of what this book calls the *game kit*: a handful of plain functions for
the things nearly every game used to write for itself. Testing whether two
things touch is one. Saving a number for next time is another, and it is
lesson twelve. Waiting a while, sliding a number smoothly from one value to
another, and showing how long each frame took are the rest. The reference
gives each part a section of its own: Collide, Save, Timer, Tween, and Stats.
The collision tests are the plainest of the lot: plain questions, with no
handles to keep and nothing to set up or free. And 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
Lesson ten promised to explain why the rectangle around a round rock is the
wrong shape. The rectangle around the ship is wrong too, and for the same
reason.
Draw the ship's 32 by 24 rectangle on paper and shade in the ship. It is a
triangle with two fins, so there is empty sky in both top corners and between
the fins. The rock is round, so there is empty sky in all four corners of its
square. A rock that clips the top corner of the ship's 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. Add three numbers to the constants at the top of your
file:
[source,lua]
----
local SHIP_PAD_X = 6
local SHIP_PAD_Y = 5
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. Four off
every side of the rock leaves 16 by 16, and that is nearly all rock.
Both boxes are a little smaller than the things they stand for, and that is on
purpose. A box slightly too small gives the player near misses that feel like
skill. A box slightly too big gives them hits that feel like cheating. Players
forgive the first and never the second.
Rather than write the arithmetic out at every test, write it once for each
kind of thing:
[source,lua]
----
function rockBox(rock)
return rock.x + ROCK_PAD, rock.y + ROCK_PAD, rockWidth - ROCK_PAD * 2, rockHeight - ROCK_PAD * 2
end
function shipBox()
return shipX + SHIP_PAD_X, shipY + SHIP_PAD_Y, shipWidth - SHIP_PAD_X * 2, shipHeight - SHIP_PAD_Y * 2
end
----
The sizes come from `rockWidth` and `shipWidth` and their friends, the
measurements lesson nine took from the pictures, so there is still not one
raw pixel size in the playing code. Redraw the ship bigger and the box grows
with it.
Each of those functions 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]
----
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
----
Now the test in `updateRocks` reads like the question it is asking:
[source,lua]
----
if shipHitsRock(rock) then
----
The padding arithmetic is in exactly two places, and the numbers it uses are
at the top of the file, so you can tune how fair the game feels by changing
one number rather than by hunting through your tests.
Put the new functions in their places in the alphabet, as lesson nine asked:
`rockBox` after `onSoundCompleted`, then `shipBox` and `shipHitsRock`.
=== Firing Back
That is the ship's side done. The other collision in the game is the one you
fire back with: every shot against every rock, in `updateShots`. It has the
same problem the ship had, in a smaller size. A shot is a 4 by 10 rectangle
tested against the rock's whole 24 by 24 square, so a shot that only passes
through the empty corner beside a rock still breaks it.
You could give the shot a padded box of its own. Look at the numbers first,
though. Pad a shot four pixels wide and there is almost nothing left, and
testing a whole rectangle to find out whether a sliver of light touched
something is more machinery than the question deserves.
=== A Shot Is Almost a Point
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]
----
function shotHitsRock(shot, rock)
local rx, ry, rw, rh = rockBox(rock)
return collidePointRect(shot.x + shotWidth / 2, shot.y, rx, ry, rw, rh)
end
----
`shot.y` is the top of the shot and `shot.x + shotWidth / 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. The rock's side of the question uses the same
`rockBox` the ship does, so a rock is the same size whatever hits it.
It goes after `shipHitsRock`, and the test in `updateShots` becomes:
[source,lua]
----
if not gone and shotHitsRock(shot, rock) then
----
=== Deciding What Happened
None of the collision tests do anything. They answer, and your code decides.
Here are the two places that decide, with their new questions in them. First
`updateRocks`:
[source,lua]
----
if shipHitsRock(rock) then
local channel = soundPlay(boomClip)
newRock(rock)
lives = lives - 1
if lives <= 0 then
dying = true
deathChannel = channel
if channel < 0 then
endGame()
end
end
end
----
And the loop inside `updateShots`:
[source,lua]
----
for _, rock in ipairs(rocks) do
if not gone and shotHitsRock(shot, rock) then
newRock(rock)
score = score + HIT_SCORE
gone = true
soundPlay(boomClip, 0, 45)
end
end
----
Everything under the questions is exactly what lesson ten left there. A rock
that hits the ship bangs, goes back to the top, and costs a life. A rock that
is shot bangs, goes back to the top, and scores. The shot goes too, and the
`not gone` stops one shot from breaking two rocks. Change any of that and you
change the game, without touching a single collision test, which is the reason
the questions and the answers are kept apart.
Run it. Fly close past a rock and let it brush a fin: you keep the life. Shoot
just beside a rock and the shot sails past. Shoot into the middle of one and it
breaks, the same as ever.
=== Seeing the Boxes
The hit boxes are invisible, which makes a mistake in them invisible too. Draw
them while you are working. One more constant at the top:
[source,lua]
----
local SHOW_BOXES = false
----
Two more functions, which go at the very top of the alphabet:
[source,lua]
----
function drawBox(x, y, width, height)
overlayBox(x, y, x + width - 1, y + height - 1)
end
function drawBoxes()
colorForeground(0, 255, 0)
drawBox(shipBox())
for _, rock in ipairs(rocks) do
drawBox(rockBox(rock))
end
end
----
And in `onOverlayUpdate`, after the ship is drawn and before the score goes on
top:
[source,lua]
----
spriteDraw(shipSprite, shipX, shipY)
if SHOW_BOXES then
drawBoxes()
end
drawHud()
----
Change `SHOW_BOXES` to `true` and green outlines appear over the artwork, as
in the picture at the top of this lesson. Change it back when you are done. A
switch like this, one word at the top of a file that turns on a picture of
what the program believes, is worth more than any amount of staring.
=== What Just Happened
[source,lua]
----
function rockBox(rock)
return rock.x + ROCK_PAD, rock.y + ROCK_PAD, rockWidth - ROCK_PAD * 2, rockHeight - 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)
----
`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 + shotWidth / 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]
----
colorForeground(0, 255, 0)
overlayBox(x, y, x + width - 1, y + height - 1)
----
Both of these are from lesson seven. `overlayBox` takes two *corners*, not a
corner and a size -- the one place in this lesson where the numbers change
shape -- and the `- 1` is because both corners are included. `colorForeground`
is a switch that stays flipped, so everything `drawBoxes` draws comes out
green. Sprites and `overlayPrint` ignore it, so the rest of the game looks the
same.
=== 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
center 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.
If you are honest, your rock is round, and the circle tests fit it better than
any box. Padding is the cheap answer and it is good enough for most games,
which is why it comes first. The exact answer is one of the things to try
below.
`collideSegments` 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 jumps from below a rock to above 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 reference'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 `16`,
play, and notice that nothing can hit you any more. Work out why from the
arithmetic in `shipBox`.
. *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 rectangle instead of `collidePointRect` with its nose. Play both
and decide which one you prefer; there is no right answer, which is worth
knowing.
. *Make the rock round.* Give the rock a circle instead of a box: its center is
`rock.x + rockWidth / 2` across and `rock.y + rockHeight / 2` down, and a
radius of `10` is about right. Use `collideRectCircle` for the ship and
`collidePointCircle` for the shot, and draw the circle in `drawBoxes` with
`overlayCircle`, which takes a center and a radius.
=== 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]
----
function shipHitsRock(rock)
return collideRects(shipBox(), rockBox(rock))
end
----
That looks right and it is wrong. Run it and the game stops before it draws a
thing:
----
186:collideRects: Expected 8 argument(s), got 5.
----
The line is the one you just typed, so yours may not be 186. Five, though.
`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 like the
ones in lessons nine and ten: 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`. It counts edges that only touch as touching.
* The collision tests are part of the game kit, plain functions with nothing
to set up. Save, Timer, Tween, and Stats are the rest of it.
* `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.
* The collision tests only answer. What happens next is your code, and keeping
the two apart lets you change either one alone.
* 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
The game counts your score and takes your lives, and the moment you quit, the
best score anybody ever got is gone. It starts in the middle of the action the
instant it loads. And it keeps track of what it is doing with two true-or-false
variables, `over` and `dying`, which is exactly the trap lesson seven warned
you about. Lesson twelve gives it a title screen, one variable in place of
those two, and a high score that is still there tomorrow.