singe/docs/lessons/18-qte.adoc
2026-09-22 21:57:42 -05:00

589 lines
19 KiB
Text

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