550 lines
20 KiB
Text
550 lines
20 KiB
Text
== Lesson 17: Light Guns
|
|
|
|
image::learn/17-light-guns.png[The finished lesson, 480]
|
|
|
|
You are going to make a shooting gallery. Targets appear over the video at
|
|
frames you choose, you aim at the screen and pull a trigger, and the ones you
|
|
hit score.
|
|
|
|
You do not need a light gun to write this. You need a mouse, because to Singe
|
|
a light gun *is* a mouse: it reports a position in the same coordinates,
|
|
through the same calls, and its trigger arrives as the same switch as a mouse
|
|
button. Write the game for the mouse on your desk and it works on a cabinet
|
|
with a gun bolted to it, which is the only sane way round to do it. A gun is
|
|
not a different kind of input. It is a mouse that somebody aimed.
|
|
|
|
=== What You Are Pointing At
|
|
|
|
Start the same way as lesson fourteen, with the video the engine unpacked for
|
|
you:
|
|
|
|
----
|
|
Singe -R -v Singe/menuBackground.mkv gallery
|
|
----
|
|
|
|
That footage is a purple grid and a sunset. It is not a bank robbery. Every
|
|
frame number in this lesson refers to it so that the code runs the day you
|
|
type it, and the whole clip is 720 by 480 and about 420 frames long, which at
|
|
roughly thirty frames a second is fourteen seconds. Imagine better footage.
|
|
The code does not care what is in the picture.
|
|
|
|
=== Match the Overlay to the Video
|
|
|
|
Put this at the top of `gallery.singe`, under the `dofile` line:
|
|
|
|
[source,lua]
|
|
----
|
|
overlaySetResolution(discGetWidth(), discGetHeight())
|
|
----
|
|
|
|
Without that call the overlay is *half* the video in each direction -- 360 by
|
|
240 here -- and every coordinate you draw with, and every mouse position you
|
|
are handed, is in that smaller space. Nothing is wrong with the small overlay,
|
|
but it is one more conversion to keep in your head while you are working out
|
|
where a shot landed. Matching the video means a target at `x = 80` is eighty
|
|
pixels across the picture, and so is a shot at `x = 80`. The manual's entry
|
|
for `overlaySetResolution` explains what it costs.
|
|
|
|
=== Where the Gun Is Pointing
|
|
|
|
One call answers that:
|
|
|
|
[source,lua]
|
|
----
|
|
local x, y = mouseGetPosition(0)
|
|
----
|
|
|
|
`mouseGetPosition` hands back two numbers, which is something you have not
|
|
seen a function do before and which Lua is perfectly happy about: a function
|
|
may return as many values as it likes, and you catch them by listing that many
|
|
names on the left of the `=`. The `0` is *which* pointing device you are
|
|
asking about, counting from zero. With one mouse, or one gun, the answer is
|
|
always device `0`.
|
|
|
|
The numbers are in overlay coordinates, not desktop pixels, which is why the
|
|
call above was worth making. The position is the last one the device reported,
|
|
so you can ask for it whenever you like, as often as you like. Ask for it in
|
|
`onOverlayUpdate` and draw with the answer.
|
|
|
|
There is another way to get the same numbers. `onMouseMoved` is a callback,
|
|
like `onOverlayUpdate`, and the engine calls it every time a pointer moves,
|
|
handing it the new position. It is the right choice when you want to *react*
|
|
to movement. For drawing a crosshair you do not: you want to know where the
|
|
thing is right now, and `mouseGetPosition` tells you that without your having
|
|
to keep a copy.
|
|
|
|
=== Drawing a Crosshair
|
|
|
|
The engine will not draw one for you. A crosshair is four lines and a circle:
|
|
|
|
[source,lua]
|
|
----
|
|
local function drawCrosshair(x, y)
|
|
colorForeground(255, 255, 255, 255)
|
|
overlayCircle(x, y, 10)
|
|
overlayLine(x - 18, y, x - 4, y)
|
|
overlayLine(x + 4, y, x + 18, y)
|
|
overlayLine(x, y - 18, x, y - 4)
|
|
overlayLine(x, y + 4, x, y + 18)
|
|
end
|
|
----
|
|
|
|
The gaps matter more than the lines. Leaving the four spokes short of the
|
|
circle gives you a hole in the middle to see the target through, which is the
|
|
whole point of a crosshair and the first thing beginners draw over.
|
|
|
|
One thing you should ask before drawing it:
|
|
|
|
[source,lua]
|
|
----
|
|
local drawAim = singeWantsCrosshairs()
|
|
----
|
|
|
|
That returns `false` when the player started Singe with `--nocrosshair`. Some
|
|
light guns put a spot of their own on the screen, and two crosshairs a few
|
|
pixels apart are worse than none. The answer cannot change while the game
|
|
runs, so read it once at the top and keep it in a variable.
|
|
|
|
You do not have to hide the system pointer, because Singe already has. It
|
|
grabs the mouse at startup, which confines it to the window and hides the
|
|
desktop arrow. If you ever want it back -- for a level editor, say --
|
|
`mouseSetCaptured(false)` releases it, and the key mapped to `INPUT_GRAB`,
|
|
which is `G` in the shipped controls, toggles the same thing while you play.
|
|
|
|
=== The Trigger
|
|
|
|
A trigger is a button, and buttons arrive at `onInputPressed`, which you first
|
|
met in lesson three:
|
|
|
|
[source,lua]
|
|
----
|
|
function onInputPressed(what, device)
|
|
if what ~= SWITCH_BUTTON3 then
|
|
return
|
|
end
|
|
|
|
local x, y = mouseGetPosition(device or 0)
|
|
-- ...
|
|
end
|
|
----
|
|
|
|
`SWITCH_BUTTON3` is the left mouse button, and on a gun it is the trigger. The
|
|
shipped `controls.cfg` maps the left button to `SWITCH_BUTTON3`, the right to
|
|
`SWITCH_BUTTON1`, and the middle to `SWITCH_BUTTON2`. Those numbers look
|
|
shuffled because they are: the switches are the arcade panel's buttons, and
|
|
which physical control is wired to which is a cabinet's business, not the
|
|
game's. Left Shift is mapped to `SWITCH_BUTTON3` too, so you can fire without
|
|
a mouse while you are testing.
|
|
|
|
The second argument is the new part. When the switch came from a mouse or a
|
|
light gun, `device` is that device's index -- `0` for the first, `1` for the
|
|
second, and so on. When it came from a key or a gamepad it is `nil`, because
|
|
no pointing device was involved. That is why the call above says `device or
|
|
0`: if `device` is `nil`, use `0` instead. You will use `or` like that
|
|
constantly, and it reads exactly as it sounds -- "device, or nothing, in which
|
|
case zero".
|
|
|
|
That one extra argument is what makes a two gun cabinet possible. Player one's
|
|
gun is device `0` and player two's is device `1`, and the same
|
|
`onInputPressed` tells them apart without your guessing.
|
|
|
|
=== Did It Hit Anything
|
|
|
|
You already know how to answer this. It is lesson eleven:
|
|
|
|
[source,lua]
|
|
----
|
|
if collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then
|
|
----
|
|
|
|
A shot is a point. A target is a rectangle. `collidePointRect` asks whether
|
|
the point is inside the rectangle, and the edge counts as inside. There is
|
|
nothing special about shooting. A gun game's hit test is the same hit test a
|
|
platform game uses to find out whether you landed on a block, and the manual's
|
|
`collide` family has the rest of the shapes.
|
|
|
|
=== Targets That Come and Go
|
|
|
|
The only thing video changes is *when* a target exists. In lesson eleven a rock
|
|
was there until you shot it. Here a target is on screen while the film is
|
|
showing the part of the scene it belongs to, and then it is gone whether you
|
|
shot it or not.
|
|
|
|
That is one more field on the record, and lesson six built you the tool:
|
|
|
|
[source,lua]
|
|
----
|
|
local targets = {
|
|
{ first = 40, last = 110, x = 80, y = 110 },
|
|
{ first = 120, last = 190, x = 470, y = 140 },
|
|
{ first = 200, last = 270, x = 290, y = 250 },
|
|
{ first = 280, last = 360, x = 150, y = 300 },
|
|
}
|
|
----
|
|
|
|
Four records, each with the frame the target appears on, the frame it leaves
|
|
on, and where it sits. Adding a fifth target is adding a line. That is the
|
|
shape to reach for every time you find yourself about to write the same four
|
|
lines of code with different numbers in them.
|
|
|
|
Whether a target counts is then a small function of its own:
|
|
|
|
[source,lua]
|
|
----
|
|
local function targetIsUp(target, frame)
|
|
return not target.hit and frame >= target.first and frame <= target.last
|
|
end
|
|
----
|
|
|
|
The drawing asks it, and so does the shooting, which is the point of putting
|
|
it in one place: a target you can see and a target you can hit can never
|
|
disagree.
|
|
|
|
=== The Whole Thing
|
|
|
|
[source,lua]
|
|
----
|
|
-- Learn to Program with Singe -- Lesson 17: Light Guns
|
|
|
|
dofile("Singe/Framework.singe")
|
|
|
|
overlaySetResolution(discGetWidth(), discGetHeight())
|
|
|
|
local FIRST_FRAME = 20
|
|
local LAST_FRAME = 400
|
|
local TARGET_SIZE = 96
|
|
local HIT_SCORE = 100
|
|
local FLASH_TIME = 12
|
|
|
|
local targets = {
|
|
{ first = 40, last = 110, x = 80, y = 110 },
|
|
{ first = 120, last = 190, x = 470, y = 140 },
|
|
{ first = 200, last = 270, x = 290, y = 250 },
|
|
{ first = 280, last = 360, x = 150, y = 300 },
|
|
}
|
|
|
|
local aimX = overlayGetWidth() / 2
|
|
local aimY = overlayGetHeight() / 2
|
|
local score = 0
|
|
local shots = 0
|
|
local hits = 0
|
|
local flash = 0
|
|
local flashX = 0
|
|
local flashY = 0
|
|
local drawAim = singeWantsCrosshairs()
|
|
|
|
|
|
local function drawCrosshair(x, y)
|
|
colorForeground(255, 255, 255, 255)
|
|
overlayCircle(x, y, 10)
|
|
overlayLine(x - 18, y, x - 4, y)
|
|
overlayLine(x + 4, y, x + 18, y)
|
|
overlayLine(x, y - 18, x, y - 4)
|
|
overlayLine(x, y + 4, x, y + 18)
|
|
end
|
|
|
|
|
|
local function targetIsUp(target, frame)
|
|
return not target.hit and frame >= target.first and frame <= target.last
|
|
end
|
|
|
|
|
|
local function startRound()
|
|
score = 0
|
|
shots = 0
|
|
hits = 0
|
|
flash = 0
|
|
for _, target in ipairs(targets) do
|
|
target.hit = false
|
|
end
|
|
discSkipToFrame(FIRST_FRAME)
|
|
end
|
|
|
|
|
|
function onInputPressed(what, device)
|
|
if what == SWITCH_START1 then
|
|
startRound()
|
|
return
|
|
end
|
|
|
|
if what ~= SWITCH_BUTTON3 then
|
|
return
|
|
end
|
|
|
|
local x, y = mouseGetPosition(device or 0)
|
|
local frame = discGetFrame()
|
|
|
|
shots = shots + 1
|
|
|
|
for _, target in ipairs(targets) do
|
|
if targetIsUp(target, frame) and collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then
|
|
target.hit = true
|
|
hits = hits + 1
|
|
score = score + HIT_SCORE
|
|
flash = FLASH_TIME
|
|
flashX = x
|
|
flashY = y
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
function onOverlayUpdate()
|
|
local frame = discGetFrame()
|
|
|
|
if frame >= LAST_FRAME then
|
|
startRound()
|
|
frame = FIRST_FRAME
|
|
end
|
|
|
|
aimX, aimY = mouseGetPosition(0)
|
|
|
|
overlayClear()
|
|
|
|
for _, target in ipairs(targets) do
|
|
if targetIsUp(target, frame) then
|
|
colorForeground(255, 200, 0, 255)
|
|
overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1)
|
|
overlayCircle(target.x + TARGET_SIZE / 2, target.y + TARGET_SIZE / 2, TARGET_SIZE / 3)
|
|
end
|
|
end
|
|
|
|
if flash > 0 then
|
|
colorForeground(255, 255, 255, 255)
|
|
overlayCircle(flashX, flashY, 30 - flash * 2)
|
|
flash = flash - 1
|
|
end
|
|
|
|
overlayPrint(2, 1, "SCORE " .. score .. " HITS " .. hits .. "/" .. shots .. " FRAME " .. frame)
|
|
|
|
if drawAim then
|
|
drawCrosshair(aimX, aimY)
|
|
end
|
|
|
|
return OVERLAY_UPDATED
|
|
end
|
|
|
|
|
|
startRound()
|
|
----
|
|
|
|
Aim at a yellow box and click. The counter in the corner goes up, a white ring
|
|
opens where you hit, and the box disappears. Miss and the shot count goes up
|
|
on its own, which is the cheapest scoreboard there is and tells a player more
|
|
about how they are doing than the score does. The round restarts by itself
|
|
when the film runs out, and `1` restarts it whenever you like.
|
|
|
|
=== What Just Happened
|
|
|
|
[source,lua]
|
|
----
|
|
local frame = discGetFrame()
|
|
|
|
if frame >= LAST_FRAME then
|
|
startRound()
|
|
frame = FIRST_FRAME
|
|
end
|
|
----
|
|
|
|
The video is the clock, as it was in lesson fourteen, and `discGetFrame` is
|
|
how you read it. Everything else in the frame is decided from that one number.
|
|
When the film runs off the end of the last target, the round starts over:
|
|
`startRound` clears the score, marks every target unshot, and sends the disc
|
|
back to the beginning with `discSkipToFrame`. The line under it sets the local
|
|
copy of `frame` as well, because `discSkipToFrame` asks the disc to move and
|
|
`discGetFrame` will not catch up until the next frame. Without it the drawing
|
|
below would spend one frame working from a number that is no longer true.
|
|
|
|
[source,lua]
|
|
----
|
|
shots = shots + 1
|
|
|
|
for _, target in ipairs(targets) do
|
|
if targetIsUp(target, frame) and collidePointRect(...) then
|
|
----
|
|
|
|
Count the shot before you look for a hit, so that a miss counts too. Then walk
|
|
the list. `return` inside the loop stops at the first target hit, which means
|
|
one bullet cannot take two overlapping targets -- a rule you have to decide
|
|
one way or the other, and this is the line that decides it.
|
|
|
|
[source,lua]
|
|
----
|
|
overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1)
|
|
----
|
|
|
|
`overlayBox` wants two opposite corners, not a corner and a size, so the far
|
|
corner is the near one plus the size. The `- 1` is because both corners are
|
|
included: a box from `80` to `175` is ninety six pixels wide. `collidePointRect`
|
|
wants a corner and a size instead, which is why `TARGET_SIZE` goes in twice
|
|
there and not at all here. Mixing those two up is a genuinely common bug, and
|
|
it shows as targets you can hit slightly outside where they are drawn.
|
|
|
|
[source,lua]
|
|
----
|
|
if flash > 0 then
|
|
colorForeground(255, 255, 255, 255)
|
|
overlayCircle(flashX, flashY, 30 - flash * 2)
|
|
flash = flash - 1
|
|
end
|
|
----
|
|
|
|
`flash` is a countdown in frames, set to twelve when a shot lands and reduced
|
|
by one every time the screen is drawn. Because the radius is worked out *from*
|
|
the countdown, the ring grows as the number shrinks. Twelve frames is under
|
|
half a second. Feedback that lasts longer than that stops feeling like a
|
|
consequence of the trigger and starts feeling like weather.
|
|
|
|
=== Two Guns
|
|
|
|
You are two changes away from a two player cabinet, and neither is in the game
|
|
loop.
|
|
|
|
The first is `mouseSetMode(MOUSE_MANY)`, which tells Singe to read each device
|
|
separately instead of pooling them into one cursor. `mouseHowMany()` says how
|
|
many it found at startup, so a game normally asks before it commits:
|
|
|
|
[source,lua]
|
|
----
|
|
if mouseHowMany() >= 2 then
|
|
mouseSetMode(MOUSE_MANY)
|
|
end
|
|
----
|
|
|
|
After that, `mouseGetPosition(1)` is the second gun, and the `device` argument
|
|
of `onInputPressed` says which gun fired. Keep two crosshairs, two scores, and
|
|
one list of targets, and you have a co-operative game.
|
|
|
|
The second change is not in your script at all. The shipped `controls.cfg`
|
|
binds only the *first* mouse's buttons, so the second gun's trigger reaches
|
|
nothing. A cabinet with two guns needs its own line in that file naming both:
|
|
|
|
[source,lua]
|
|
----
|
|
INPUT_ACTION_3 = { SCANCODE.LSHIFT, GAMEPAD_0.BUTTON_X, MOUSE_0.BUTTON_LEFT, MOUSE_1.BUTTON_LEFT }
|
|
----
|
|
|
|
Say so in your instructions. A player whose second gun does nothing will blame
|
|
your game, and they will be half right.
|
|
|
|
=== The Other Way to Test a Hit
|
|
|
|
There is a second way to answer "did that shot hit something", and it is worth
|
|
knowing about even though you are not going to use it today. `vldpGetPixel(x,
|
|
y)` reads the colour of one pixel of the video frame currently on screen.
|
|
Games have used it since the very first version of Singe: paint every target
|
|
in the footage a colour that appears nowhere else, and on a trigger pull ask
|
|
what colour is under the crosshair.
|
|
|
|
It buys you hit shapes that follow the action exactly, for free, with no
|
|
rectangles to author. It costs you footage you have to prepare that way, and a
|
|
hit test you cannot see or debug. The manual's entry for `vldpGetPixel`
|
|
has the details. Rectangles are the right default; keep this in your pocket.
|
|
|
|
=== What Only a Cabinet Cares About
|
|
|
|
Everything above works on your desk. Several things do not exist on your desk
|
|
at all, and this is a good moment to name them so that they are not a surprise
|
|
later.
|
|
|
|
*Calibration.* A real light gun has to be told where the screen is, because it
|
|
is a camera looking at a monitor from wherever the player is standing. Singe's
|
|
service tools have a Light Gun screen that puts targets in the middle and at
|
|
each corner and marks where the gun actually pointed, so you can see which way
|
|
it is out. Your game does not do the calibrating and should not try.
|
|
|
|
*The Sinden border.* One popular family of guns finds the screen by looking
|
|
for a bright frame drawn around the picture. `--sindengun` draws that frame,
|
|
and the engine maps positions back into the smaller picture inside it, so your
|
|
game keeps getting coordinates that mean what they used to mean.
|
|
|
|
*Screens that are not the shape of the film.* `ratioGetX()` and `ratioGetY()`
|
|
hand you the numbers a player gave on the command line for a display whose
|
|
proportions do not match the footage. Singe does not apply them; a game that
|
|
wants them does the arithmetic itself.
|
|
|
|
*Guns going away.* `SWITCH_MOUSE_DISCONNECT` arrives at `onInputPressed` when
|
|
a gun is unplugged. No button produces it; the engine raises it. A game that
|
|
watches for it can put "PLAYER 2 GUN DISCONNECTED" on screen instead of
|
|
appearing to have died.
|
|
|
|
*No mouse at all.* A cabinet with a joystick and no pointing device can drive
|
|
the cursor from the stick, which `joyMouseEnable` turns on.
|
|
|
|
None of that changes the game you wrote. All of it is lesson thirty.
|
|
|
|
=== Try It
|
|
|
|
. *Move a target.* Change the third target's `x` to `680` and run it. Most of
|
|
it now hangs off the right edge, because the overlay is 720 wide and the
|
|
target is 96. You can still shoot the sliver that is left. Decide what you
|
|
want to happen at the edges.
|
|
. *Make the targets easier.* Change `TARGET_SIZE` to `140`. Notice that the
|
|
drawing and the hit test both change, and that you only edited one number.
|
|
That is what a named constant is for.
|
|
. *Keep score for accuracy.* Add a line that prints `hits` as a percentage of
|
|
`shots`. Guard against dividing by zero before the first shot, and find out
|
|
what Lua prints if you do not.
|
|
. *Make a target punish you.* Add a `penalty = true` field to one record and
|
|
subtract points when it is hit. Notice you are adding a field to one record,
|
|
not to all four, and that the ones without it have `nil` there.
|
|
. *Take the `return` out* of the loop in `onInputPressed`, then overlap two
|
|
targets by giving them the same frames and nearly the same position. One
|
|
shot now takes both. Decide which behaviour your game wants.
|
|
|
|
=== Break It on Purpose
|
|
|
|
Change the fire test to use `SWITCH_BUTTON1` instead of `SWITCH_BUTTON3` and
|
|
run it. Nothing breaks. No error appears. The left button stops doing
|
|
anything, and the *right* button now fires.
|
|
|
|
This is the worst kind of bug and the most common one in input code: the
|
|
program is not wrong, it is just wired to the wrong thing. There is no error
|
|
message to read, because you asked a sensible question about a switch that
|
|
exists and it answered honestly.
|
|
|
|
Two things find it. The first is `debugPrint`, from lesson eight:
|
|
|
|
[source,lua]
|
|
----
|
|
function onInputPressed(what, device)
|
|
debugPrint("switch " .. what .. " from device " .. tostring(device))
|
|
----
|
|
|
|
Now every press prints a line, and you can see what the trigger actually
|
|
sends. Note `tostring(device)`: `device` is `nil` for a key press, and joining
|
|
`nil` onto a string with `..` is an error. `tostring` turns it into the word
|
|
`nil` and the line prints.
|
|
|
|
The second is the engine's own Input Test, in the service tools, which shows
|
|
every device it can see and the last switch the game would have received. When
|
|
a button does nothing, that screen tells you which half of the problem you
|
|
have: either the switch is arriving and your game is ignoring it, or it is not
|
|
arriving at all.
|
|
|
|
=== What You Learned
|
|
|
|
* A light gun reports as a mouse, so a game written for the mouse works with a
|
|
gun.
|
|
* `mouseGetPosition(device)` returns two values, the pointer's position in
|
|
overlay coordinates.
|
|
* A function in Lua can return more than one value, and you catch them by
|
|
listing that many names.
|
|
* `singeWantsCrosshairs()` says whether to draw your own reticle. Read it once.
|
|
* Singe grabs and hides the system pointer at startup; you do not have to.
|
|
* `onInputPressed` takes a second argument naming which mouse or gun fired,
|
|
and `nil` when it was a key or a pad.
|
|
* A hit test against a rectangle is `collidePointRect`, the same collision you
|
|
learned in lesson eleven.
|
|
* A target's frame window is just two more fields on its record.
|
|
* `a or b` gives you `b` when `a` is `nil`.
|
|
* Calibration, the Sinden border, and two gun wiring live outside your script.
|
|
|
|
=== Next Time
|
|
|
|
Your gallery asks for one thing: hit that, there. The other arcade convention
|
|
video games are built on asks for something harder -- press *this*, *now*, and
|
|
you have half a second. Lesson eighteen builds a quick-time event, with a
|
|
prompt, a window measured in frames, and three different endings depending on
|
|
whether you were right, wrong, or too slow.
|