488 lines
16 KiB
Text
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.
|