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

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.