589 lines
19 KiB
Text
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.
|