== 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.