== Lesson 18: Quick-Time Events image::learn/18-qte.png[The finished lesson, 480] A prompt appears. You have half a second. Press the right thing and the story goes on; press the wrong thing, or nothing at all, and it goes somewhere else. That is the whole idea, and a surprising number of video games are made of almost nothing else. You already have every piece: lesson fourteen gave you frames, lesson six gave you a list of records, lesson eleven gave you input, and lesson sixteen gave you branching. This lesson puts them together and adds one thing that is genuinely new -- a window of time, and what happens when it closes. === The Clock Is the Film Everything here is measured in *frames*, not in seconds, and that is worth stopping on because it is the single decision that makes this work. Lesson fourteen said a frame is the address of a moment in the film. The prompt has to appear when the actor starts to swing, and the window has to close when the fist arrives. Those are not times, they are places in the footage, and you found them by stepping through it. If you write the window in seconds you have to start a stopwatch when the prompt appears, and then the two clocks -- yours and the film's -- can drift apart. If the video stutters on a slow machine, your stopwatch keeps running and the player is punished for their hardware. Frames cannot drift, because there is only one clock. `discGetFrame()` is the time, always. A window is two frame numbers, and "how long is left" is subtraction. Run with the same video as before: ---- Singe -R -v Singe/menuBackground.mkv moves ---- Still the purple grid and the sunset. Picture a fight. === A Move Is a Record Here is the table the whole lesson hangs on: [source,lua] ---- local moves = { { first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" }, { first = 140, last = 185, switch = SWITCH_UP, name = "UP" }, { first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" }, { first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" }, } ---- Four moves. Each one says when the window opens, when it closes, which switch answers it, and what to put on screen. Forty five frames is about a second and a half at this video's rate, which is generous; real games often give you less than half that. Everything that makes a move a move is in that table. There is no code anywhere below that knows there are four moves, or that the second one is up, or that the first window opens at frame sixty. Adding a fifth move is adding a line. Retiming every move is editing numbers. Nobody has to read a function to change the fight. That is not a trick for this lesson. It is the difference between a game you can tune and a game you cannot, and it costs nothing to do from the start. When you find yourself about to write `if frame == 60 then` a fourth time, stop and make a table instead. === What Happens When Three things can happen to a move, and they are three different outcomes, not two: * The player presses the right switch inside the window. Score it and play on. * The player presses a *wrong* switch inside the window. They tried and failed. * The window closes with no answer at all. They froze. Lazy code treats the last two the same. Games almost never do, because they feel completely different to play: guessing wrong is a mistake, and freezing is a different mistake, and the film usually goes somewhere different for each. Here the wrong answer sends the disc to frame `362` and no answer sends it to frame `388`, which on this footage means two slightly different amounts of sunset. On real footage they would be two different shots of you losing. So each record grows a fourth field while the run is going -- `answer` -- which is `nil` until something happens to the move, and then one of three strings: [source,lua] ---- move.answer = "right" move.answer = "wrong" move.answer = "late" ---- `nil` is the value Lua gives you for a field nobody has set. You met it in lesson six. Here it earns its keep: "no answer yet" is genuinely a different state from any of the three answers, and `nil` says so without your inventing a word for it. === Finding the Move That Is Being Asked Everything else needs to know one thing: which move, if any, is open right now? [source,lua] ---- local function moveAt(frame) for _, move in ipairs(moves) do if move.answer == nil and frame >= move.first and frame <= move.last then return move end end return nil end ---- Walk the list, and answer with the first move whose window covers this frame and which has not been answered. If nothing matches, answer `nil`. Returning `nil` on purpose is worth noticing. A function that sometimes has no answer should say so, and "no move is open" is a perfectly good answer. The caller then writes `if move ~= nil then`, and the code that draws the prompt never runs when there is nothing to prompt for. The `move.answer == nil` test is what stops a move being asked twice. The moment the player gets it right, the prompt vanishes even though the window is still open -- which is exactly what it should do, because they have answered. === Reading the Answer [source,lua] ---- function onInputPressed(what) if not isDirection(what) then return end local move = moveAt(discGetFrame()) if move == nil then return end if what == move.switch then move.answer = "right" score = score + RIGHT_SCORE else move.answer = "wrong" endRun("WRONG WAY", WRONG_FRAME) end end ---- Read it top to bottom. If the switch is not one of the four directions, it is none of our business -- leave. If no move is open, a direction press means nothing -- leave. Otherwise there is exactly one move to compare against, and one comparison to make. Both of those early `return` lines are guards: they throw out the cases you do not care about so that the code underneath only ever runs in the one situation it was written for. Writing it the other way round, with the real work nested three levels deep inside three `if` blocks, says the same thing and is much harder to read. `isDirection` is a tiny function that does nothing but keep that first line short: [source,lua] ---- local function isDirection(what) return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT end ---- Notice that it returns the comparison itself rather than saying `if ... then return true else return false end`. A comparison is already `true` or `false`. Testing whether something is true so that you can say it is true is a habit worth losing early. === The Window Closing Nobody presses anything, and the frames keep going by. Something has to notice. That something is `onOverlayUpdate`, because it runs every frame whether or not the player does anything: [source,lua] ---- if not runOver then for _, move in ipairs(moves) do if move.answer == nil and frame > move.last then move.answer = "late" endRun("TOO SLOW", LATE_FRAME) break end end end ---- A move that is unanswered and whose last frame has gone past was missed. `break` leaves the loop immediately, because once the run is over there is no point looking at the rest. `endRun` is the branch, and it is the same branch you wrote in lesson sixteen: [source,lua] ---- local function endRun(reason, frame) verdict = reason runOver = true discSkipToFrame(frame) end ---- Three lines. Remember what to say, remember that the run is finished, and send the film somewhere else. A missed quick-time event is not a special mechanism. It is a choice the player made by not making one, and the film branches on it exactly as it branches on a door they picked. === A Gauge That Drains The player cannot see frame numbers. They need to see time running out, and they need to see it without looking away from the action, which means a shape that changes rather than a number that counts. Two numbers give you everything: [source,lua] ---- local left = move.last - frame local span = move.last - move.first ---- `span` is how long the window is. `left` is how much of it remains. The bar is `left` out of `span` of the full width: [source,lua] ---- fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H) ---- `//` is division that throws away the fraction, which you want here because a bar three hundred and seventeen and a half pixels wide is not a thing. Multiply before you divide -- `GAUGE_W * left // span` and not `GAUGE_W * (left // span)` -- or the fraction is thrown away while it is still the only information you have, and the bar jumps between full and empty with nothing in between. There is no filled rectangle in the overlay. `overlayBox` draws an outline, which is what you want for the frame around the gauge, and the fill is your own: [source,lua] ---- local function fillBar(x, y, width, height) if width < 1 then return end for row = 0, height - 1 do overlayLine(x, y + row, x + width - 1, y + row) end end ---- Eighteen horizontal lines stacked on top of each other. The guard at the top matters: at the very last frame of the window `left` is zero, the width is zero, and without it `x + width - 1` is one pixel to the *left* of `x` and you draw a short backwards line instead of nothing. The last touch is a colour change when it gets desperate: [source,lua] ---- if left * 3 < span then colorForeground(255, 60, 60, 255) else colorForeground(255, 200, 0, 255) end ---- The bar turns red in its last third. Written that way -- multiplying the time left rather than dividing the span -- it stays whole numbers and works for a window of any length. === The Whole Thing [source,lua] ---- -- Learn to Program with Singe -- Lesson 18: Quick-Time Events dofile("Singe/Framework.singe") overlaySetResolution(discGetWidth(), discGetHeight()) local FIRST_FRAME = 20 local WRONG_FRAME = 362 local LATE_FRAME = 388 local LAST_FRAME = 415 local PROMPT_COL = 54 local PROMPT_ROW = 22 local GAUGE_X = 210 local GAUGE_Y = 330 local GAUGE_W = 300 local GAUGE_H = 18 local RIGHT_SCORE = 100 local moves = { { first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" }, { first = 140, last = 185, switch = SWITCH_UP, name = "UP" }, { first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" }, { first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" }, } local score = 0 local runOver = false local verdict = "" local function fillBar(x, y, width, height) if width < 1 then return end for row = 0, height - 1 do overlayLine(x, y + row, x + width - 1, y + row) end end local function isDirection(what) return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT end local function moveAt(frame) for _, move in ipairs(moves) do if move.answer == nil and frame >= move.first and frame <= move.last then return move end end return nil end local function startRun() score = 0 runOver = false verdict = "" for _, move in ipairs(moves) do move.answer = nil end discSkipToFrame(FIRST_FRAME) end local function endRun(reason, frame) verdict = reason runOver = true discSkipToFrame(frame) end function onInputPressed(what) if runOver then if what == SWITCH_START1 then startRun() end return end if not isDirection(what) then return end local move = moveAt(discGetFrame()) if move == nil then return end if what == move.switch then move.answer = "right" score = score + RIGHT_SCORE else move.answer = "wrong" endRun("WRONG WAY", WRONG_FRAME) end end function onOverlayUpdate() local frame = discGetFrame() if not runOver then for _, move in ipairs(moves) do if move.answer == nil and frame > move.last then move.answer = "late" endRun("TOO SLOW", LATE_FRAME) break end end end if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then if not runOver then verdict = "CLEAR" runOver = true end discSearch(LAST_FRAME) end overlayClear() local move = nil if not runOver then move = moveAt(frame) end if move ~= nil then local left = move.last - frame local span = move.last - move.first overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name) colorForeground(255, 255, 255, 255) overlayBox(GAUGE_X - 2, GAUGE_Y - 2, GAUGE_X + GAUGE_W + 1, GAUGE_Y + GAUGE_H + 1) if left * 3 < span then colorForeground(255, 60, 60, 255) else colorForeground(255, 200, 0, 255) end fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H) end overlayPrint(2, 1, "SCORE " .. score .. " FRAME " .. frame) if runOver then overlayPrint(2, 3, verdict) overlayPrint(2, 4, "PRESS 1 TO RUN IT AGAIN") end return OVERLAY_UPDATED end startRun() ---- Arrow keys answer the prompts. Get all four and the film runs to the end and holds there on `CLEAR`. Get one wrong, or sit on your hands, and it cuts to a different frame with a different word on screen. `1` runs it again. === What Just Happened Most of it you have already read. Four pieces are worth a second look. [source,lua] ---- if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then ---- The end of the film has to be caught, or it runs off the end and there is nothing to see. `discSearch` goes to a frame and *pauses* there, which is exactly what a final shot wants. The `discGetState()` half stops that happening again on every one of the following frames: once the disc is paused it is no longer `DISC_PLAYING`, so the condition is false and the seek is not repeated sixty times a second. `discGetState` and the `DISC_*` constants are in the manual under `discGetState`. [source,lua] ---- local move = nil if not runOver then move = moveAt(frame) end ---- `onOverlayUpdate` asks for the open move again, rather than being told by `onInputPressed`. That is deliberate. There is exactly one function that knows which move is open, and everybody asks it. If the prompt came from a variable that the input code kept up to date, then two pieces of code would be remembering the same fact, and sooner or later one of them would be wrong. When you are choosing between asking and remembering, ask. [source,lua] ---- overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name) ---- `overlayPrint` counts in character cells, as it has since lesson one, so the prompt is positioned by eye: column fifty four is near enough the middle for words of this length. It is near enough, not right, and "PRESS RIGHT" sits a character further left than "PRESS UP". Lesson nineteen loads a real font, and with it the ability to measure a string in pixels and centre it properly. [source,lua] ---- function onInputPressed(what) if runOver then if what == SWITCH_START1 then startRun() end return end ---- One callback, two completely different jobs, chosen by `runOver`. While the run is going the directions mean moves; once it is over the only key that means anything is start. A variable that decides which rules apply is a *state*, which lesson twelve called by that name, and a game is mostly a small number of them. === Try It . *Make it harder.* Change the first move's `last` from `105` to `75`. Fifteen frames is half a second. Play it and find out whether you can. . *Add a move.* Put a fifth line in the table with a window between frames `370` and `400`, and push `LAST_FRAME` up to `418`. You should not have to touch any other line. If you do, something in the code knows too much about the table. . *Score the speed.* Award more points for answering early. In `onInputPressed`, where the right answer is scored, work out `left` and `span` from `move` and `discGetFrame()` the same way the gauge does, and add `RIGHT_SCORE * left // span` instead of `RIGHT_SCORE`. Decide whether that makes the game better or only busier. . *Give a wrong answer its own consequence.* Instead of ending the run, let a wrong press cost fifty points and leave the window open to try again. You will need to stop setting `move.answer`, and you will meet the reason it was set in the first place. . *Two prompts at once.* Overlap two moves' windows and see what happens. `moveAt` returns the first match, so only one is ever asked. Decide what you would want, and what it would take. === Break It on Purpose Delete the `break` from the loop that finds missed moves, and miss the first one on purpose. The run ends, the disc jumps, and everything looks right. Now put `debugPrint(move.name .. " missed")` next to `move.answer = "late"` and do it again. The console shows one line, as you would expect. Then take out the `if not runOver then` around the loop as well, and miss the first move again: ---- LEFT missed UP missed RIGHT missed DOWN missed ---- The first line arrives when the window closes, as before. The other three arrive together on the very next frame, the instant the disc lands at 388: every remaining window is now in the past, so every unanswered move is missed at once, and each one calls `endRun` and seeks all over again. The game still more or less works, which is what makes it dangerous. Three extra seeks in a single frame, three overwrites of the verdict, and nothing on screen says so. There is no error message here either. The lesson is that `break` and that guard are not tidiness. They are the two lines that say "this has already been decided", and code that keeps deciding something it has already decided is one of the most reliable sources of bugs you will ever write. === What You Learned * A quick-time event is a prompt, a window, and a consequence. * Measure the window in frames, because the film is the clock and there must only ever be one clock. * Keep the moves in a table of records, so that adding a move is adding a line. * Right, wrong, and no answer are three outcomes, and they usually lead three different places. * `nil` in a record field is a real state: nobody has answered yet. * One function answers "which move is open", and everybody asks it rather than keeping a copy. * Guard clauses -- early `return` for the cases you do not care about -- keep the real work unnested. * `//` divides and throws away the fraction; multiply before you divide. * `overlayBox` draws an outline, so a filled bar is a stack of `overlayLine` calls. * A missed move is a branch, and `discSkipToFrame` takes it, exactly as in lesson sixteen. === Next Time Part three is done. You can play film, wait on it, branch on it, aim at it, and time the player against it. What you have been writing on top of it all is the engine's console font, which is six pixels wide and meant for diagnostics. Lesson nineteen loads a real typeface, measures it, and makes the words on screen look like they belong to the game.