478 lines
18 KiB
Text
478 lines
18 KiB
Text
== Lesson 21: Subtitles
|
|
|
|
image::learn/21-subtitles.png[The finished lesson, 480]
|
|
|
|
Somebody who cannot hear is going to play your game.
|
|
|
|
If your game has speech in it and no subtitles, they cannot. Not "it is harder
|
|
for them" -- they will reach the first scene where a character says the thing
|
|
that tells the player what to do, and they will stop. That is the whole of it,
|
|
and it is the reason this lesson exists rather than being a paragraph in lesson
|
|
nineteen.
|
|
|
|
The other reason is practical. Retrofitting subtitles into a finished game is
|
|
miserable. You have to find every line of speech, work out which frame it is
|
|
on, and discover that half your timings are wrong because your video was
|
|
re-encoded in the middle of the project. Done while you are writing the game,
|
|
it is an afternoon. Singe gives you a system that does the hard part, and this
|
|
lesson is how to use it.
|
|
|
|
=== It Is Not Ordinary Text Drawing
|
|
|
|
You could do subtitles yourself. Keep a list of lines with start and end times,
|
|
check the clock in `onOverlayUpdate`, and draw the current one with
|
|
`fontToSprite`. People have. Here is why you should not.
|
|
|
|
Subtitles are timed against *the disc*, not against a clock. When `srtLoad`
|
|
reads your file it converts every timestamp into a disc frame number, there and
|
|
then, using the frame rate of the disc that is loaded. From that moment the
|
|
engine is not watching a clock at all; every drawn frame it asks which cue
|
|
covers the frame the disc is on.
|
|
|
|
That one decision buys you the thing a hand rolled system never gets right:
|
|
*a subtitle survives a seek*. Send the disc anywhere -- `discSearch`,
|
|
`discSkipForward`, a step, a branch into another segment -- and the right line
|
|
is up on the next drawn frame, forwards or backwards, with nothing reloaded and
|
|
no clock reset. In a branching game from lesson sixteen, where the player's
|
|
choices jump the disc around constantly, that is the difference between working
|
|
and almost working.
|
|
|
|
It also draws in the right place. The subtitle document sits over the picture,
|
|
above your overlay, below any screen space GUI, and it follows the picture
|
|
wherever the player's settings put it -- a shrunken picture, a shifted one, a
|
|
rotated cabinet, a hole in a bezel. It takes no mouse input, so a light gun
|
|
shot passes straight through it. Getting all of that right yourself is a week
|
|
you did not plan for.
|
|
|
|
=== Write the File
|
|
|
|
Subtitles go in a SubRip file, which ends in `.srt` and is plain text you can
|
|
type in any editor. It is one of the simplest formats there is. Make a file
|
|
called `subtitles.srt` beside your script:
|
|
|
|
----
|
|
1
|
|
00:00:01,000 --> 00:00:04,000
|
|
They told us the cave was empty.
|
|
|
|
2
|
|
00:00:04,200 --> 00:00:08,000
|
|
They were wrong about
|
|
a great many things.
|
|
|
|
3
|
|
00:00:08,500 --> 00:00:13,000
|
|
Keep your light on the floor.
|
|
----
|
|
|
|
A cue is four things. A number on a line of its own. A line with two
|
|
timestamps and an arrow made of two hyphens and a greater-than sign between
|
|
them. One or more lines of text. A blank line to finish.
|
|
|
|
A timestamp is `HH:MM:SS,mmm` -- hours, minutes, and seconds separated by
|
|
colons, then a comma, then milliseconds. The comma is not a typo and a full
|
|
stop will not do. `00:00:04,200` is four and a fifth seconds in.
|
|
|
|
The cue numbers are read and thrown away, so they do not have to be in order or
|
|
even correct. Number them anyway; you will be looking for cue forty-one at some
|
|
point.
|
|
|
|
Cue two shows what a two line cue looks like. The lines are shown as you wrote
|
|
them, so *you* decide where a long line breaks, which matters more than you
|
|
would think and is covered further down.
|
|
|
|
The parser is deliberately forgiving about the things that go wrong in a hand
|
|
edited file. Windows line endings load unchanged, and leading and trailing
|
|
spaces are stripped from every line. A cue whose timestamps do not parse is
|
|
skipped and the rest of the file goes on loading, which is friendly right up
|
|
until you wonder where line twelve went. A few things are not supported: the
|
|
`<i>` and `<b>` tags some tools write are shown as text rather than obeyed, and
|
|
the positioning coordinates some tools append to the time line are ignored.
|
|
The manual's Subtitles section is the full list.
|
|
|
|
=== Load It and Switch It On
|
|
|
|
Three calls, and the order matters.
|
|
|
|
[source,lua]
|
|
----
|
|
if srtLoad(DIR .. "subtitles.srt") then
|
|
srtPosition(80)
|
|
srtEnable(true)
|
|
end
|
|
----
|
|
|
|
`srtLoad` reads the file and answers `true` or `false`. It answers `false`,
|
|
having loaded nothing, in three cases: the file cannot be read, nothing in it
|
|
parses, or *there is no disc*. In all three it prints a warning naming the file
|
|
to the console. That is a good design and you should use it: a missing
|
|
subtitle file is not worth killing a game over, but silently shipping a game
|
|
with no subtitles is worth noticing.
|
|
|
|
The "no disc" case is the one that will catch you. The timestamps have to
|
|
become frame numbers at the disc's frame rate, and until a disc is open there
|
|
is no frame rate to use. So load subtitles *after* the disc, which in an
|
|
ordinary script means from the body of the script, where everything else you
|
|
have written at the top level runs. It follows that a `.srt` cut for a video at
|
|
one frame rate lands in the wrong place on a video at another. Time your file
|
|
against the video you are actually going to ship.
|
|
|
|
`srtEnable(true)` switches the loaded cues on. A script starts with subtitles
|
|
off, always, so a game that loads them must switch them on. Switching them off
|
|
again takes down whatever is showing, and switching them back on puts up
|
|
whatever covers the frame the disc is on right now -- no hunting, no state to
|
|
keep.
|
|
|
|
Keeping subtitles on is a setting, not a decision you make for the player. Put
|
|
it in your options page from lesson twenty, save it with `saveSet` from lesson
|
|
twelve, and hand `srtEnable` whatever the player last chose.
|
|
|
|
=== Where They Sit
|
|
|
|
[source,lua]
|
|
----
|
|
srtPosition(70)
|
|
----
|
|
|
|
`srtPosition` is a percentage down the picture, from `1` to `95`, and the
|
|
default is `80`. It takes effect on the cue that is up right now and on every
|
|
cue after it. A number outside the range is ignored rather than clamped, which
|
|
means a typo leaves the position exactly where it was and says nothing, so
|
|
check yours.
|
|
|
|
Eighty per cent is low, and it is low on purpose: the bottom of the picture is
|
|
usually the least interesting part of the shot. Move it up when the bottom is
|
|
*not* boring -- when your game draws a scoreboard along the bottom edge, or
|
|
when the player's light gun crosshair lives down there.
|
|
|
|
=== Taking One Down
|
|
|
|
[source,lua]
|
|
----
|
|
srtClear()
|
|
----
|
|
|
|
`srtClear` takes whatever is on screen off at once, and leaves the loaded cues
|
|
and the enabled state alone. The line that was showing stays off until the disc
|
|
reaches the next cue. Use it when a scene ends early and you do not want the
|
|
last line of dialogue hanging over the next shot.
|
|
|
|
There is one more call that shares the subtitle machinery, and you may have
|
|
used it already:
|
|
|
|
[source,lua]
|
|
----
|
|
overlayBanner("Extra life!", 20)
|
|
----
|
|
|
|
`overlayBanner` shows a short message over the picture for about thirty drawn
|
|
frames and then removes it. It is drawn by the same document as the subtitles,
|
|
so a banner looks like a subtitle, and while one is up it takes the place of
|
|
any subtitle, which comes back when the banner times out. Its own height
|
|
argument is separate from `srtPosition` and defaults to `47`, the middle of the
|
|
picture. Messages longer than sixty characters are ignored entirely, so a long
|
|
one appears not to work at all.
|
|
|
|
=== Subtitles Already Inside the Video
|
|
|
|
Some video files carry their own subtitle tracks. If yours does, you do not
|
|
need a `.srt` at all:
|
|
|
|
[source,lua]
|
|
----
|
|
if discGetSubtitleTracks() > 0 then
|
|
srtLoadTrack(0)
|
|
else
|
|
srtLoad(DIR .. "subtitles.srt")
|
|
end
|
|
srtEnable(true)
|
|
----
|
|
|
|
`srtLoadTrack` takes a track number from `0` to `discGetSubtitleTracks() - 1`
|
|
and everything after loading is identical. `discGetSubtitleLanguage(track)`
|
|
tells you what language a track is labelled with, as a three letter code like
|
|
`eng` or `fra`, so a game can offer the player whatever its video happens to
|
|
carry.
|
|
|
|
Two warnings. Reading a track walks the whole video file once, decoding no
|
|
pictures, so a feature length disc takes a moment: do it while something else
|
|
is on screen, not between two frames of gameplay. And subtitle tracks that hold
|
|
*pictures* of the words rather than the words -- the kinds DVDs and Blu-rays
|
|
use -- cannot be read. They are counted, so the numbering matches what other
|
|
players show, but `srtLoadTrack` answers `false` for them.
|
|
|
|
=== The Whole Script
|
|
|
|
This one needs a video. You have one: `Singe/menuBackground.mkv`, which the
|
|
engine unpacked into your work folder the first time you ran anything. Tell Singe
|
|
to use it as the disc with `--framefile`, which also turns the disc on:
|
|
|
|
----
|
|
Singe --framefile=Singe/menuBackground.mkv subtitles
|
|
----
|
|
|
|
[source,lua]
|
|
----
|
|
dofile("Singe/Framework.singe")
|
|
|
|
overlaySetResolution(discGetWidth(), discGetHeight())
|
|
|
|
local HUD_X = 16
|
|
local STEP = 5
|
|
local TOP_LIMIT = 10
|
|
local LOW_LIMIT = 95
|
|
|
|
local hudFont = fontLoad("Singe/FreeSansBold.ttf", 18)
|
|
local loaded = false
|
|
local showing = false
|
|
local height = 80
|
|
|
|
|
|
if discGetSubtitleTracks() > 0 then
|
|
loaded = srtLoadTrack(0)
|
|
end
|
|
if not loaded then
|
|
loaded = srtLoad(DIR .. "subtitles.srt")
|
|
end
|
|
|
|
if loaded then
|
|
srtPosition(height)
|
|
showing = true
|
|
srtEnable(true)
|
|
end
|
|
|
|
discPlay()
|
|
|
|
|
|
function onInputPressed(what)
|
|
if not loaded then
|
|
return
|
|
end
|
|
if what == SWITCH_BUTTON1 then
|
|
showing = not showing
|
|
srtEnable(showing)
|
|
if showing then
|
|
overlayBanner("Subtitles on", 20)
|
|
else
|
|
overlayBanner("Subtitles off", 20)
|
|
end
|
|
elseif what == SWITCH_UP then
|
|
height = math.max(TOP_LIMIT, height - STEP)
|
|
srtPosition(height)
|
|
elseif what == SWITCH_DOWN then
|
|
height = math.min(LOW_LIMIT, height + STEP)
|
|
srtPosition(height)
|
|
elseif what == SWITCH_LEFT then
|
|
srtClear()
|
|
discSearch(0)
|
|
discPlay()
|
|
end
|
|
end
|
|
|
|
|
|
function onOverlayUpdate()
|
|
overlayClear()
|
|
fontSelect(hudFont)
|
|
colorForeground(255, 255, 255)
|
|
if loaded then
|
|
if showing then
|
|
fontPrint(HUD_X, 16, "Frame " .. discGetFrame() .. " subtitles ON at " .. height .. "%")
|
|
else
|
|
fontPrint(HUD_X, 16, "Frame " .. discGetFrame() .. " subtitles OFF")
|
|
end
|
|
fontPrint(HUD_X, 40, "Fire toggles them, up and down move them, left rewinds")
|
|
else
|
|
fontPrint(HUD_X, 16, "No subtitles loaded.")
|
|
fontPrint(HUD_X, 40, "Run with --framefile and check subtitles.srt is beside the script.")
|
|
end
|
|
return OVERLAY_UPDATED
|
|
end
|
|
|
|
|
|
function onShutdown()
|
|
fontUnload(hudFont)
|
|
end
|
|
----
|
|
|
|
Press fire to toggle, up and down to move the lines, and left to rewind to the
|
|
start. The rewind is the one to watch: the disc jumps back to frame zero and
|
|
the correct cue is up on the very next frame, without you doing anything about
|
|
it.
|
|
|
|
=== What Just Happened
|
|
|
|
[source,lua]
|
|
----
|
|
if discGetSubtitleTracks() > 0 then
|
|
loaded = srtLoadTrack(0)
|
|
end
|
|
if not loaded then
|
|
loaded = srtLoad(DIR .. "subtitles.srt")
|
|
end
|
|
----
|
|
|
|
Take the video's own subtitles when it has them, fall back to the file beside
|
|
the script when it does not, and end up knowing which -- if either -- worked.
|
|
Both calls answer a boolean, so the fallback is two `if` statements and no
|
|
special cases. `menuBackground.mkv` carries no subtitle track, so what you will
|
|
actually get is the file you typed.
|
|
|
|
[source,lua]
|
|
----
|
|
function onInputPressed(what)
|
|
if not loaded then
|
|
return
|
|
end
|
|
----
|
|
|
|
`return` with nothing after it leaves the function immediately. With no
|
|
subtitles loaded there is nothing for any of these keys to do, and leaving
|
|
early is clearer than wrapping the whole body in an `if`. Calling `srtEnable`
|
|
with nothing loaded is harmless, but writing code that only makes sense when it
|
|
does nothing is a habit worth not forming.
|
|
|
|
[source,lua]
|
|
----
|
|
elseif what == SWITCH_LEFT then
|
|
srtClear()
|
|
discSearch(0)
|
|
discPlay()
|
|
----
|
|
|
|
The `srtClear` is not necessary. The engine will put up the right cue for frame
|
|
zero on the next drawn frame regardless. It is there so that you can delete it
|
|
and watch what happens: the old line stays up for a fraction of a second while
|
|
the disc gets where it is going. On a slow seek that flash of the wrong line is
|
|
visible, and clearing first is how you avoid it.
|
|
|
|
=== Making Text Readable Over a Moving Picture
|
|
|
|
The system puts the words on screen. Whether anybody can read them is your
|
|
problem, and it applies to every line of text you draw over video -- a prompt
|
|
from lesson eighteen, a score, a branch choice -- not only to subtitles.
|
|
|
|
*Stay out of the edges.* Television sets have been cutting the edges off
|
|
pictures for seventy years, and arcade monitors are worse, not better. Work on
|
|
the assumption that the outer five per cent of the picture on each side may
|
|
not be there at all on somebody's screen. Keep anything a player must read
|
|
inside that. This is why `srtPosition` stops at `95` and not `100`.
|
|
|
|
*Contrast is not a colour choice, it is a guarantee.* White text over a picture
|
|
is white text over whatever the picture happens to be doing, and in two seconds
|
|
that may be a snowfield. The shipped subtitle style solves it the way film
|
|
subtitles do: a dark panel that hugs the text, plus a one pixel shadow. If you
|
|
draw your own text over video, do one or the other -- an `overlayBox` behind it
|
|
or a dark copy of the text offset by a pixel. Do not rely on the picture.
|
|
|
|
*Break your own lines.* Two short lines read faster than one long one, and a
|
|
line that wraps where you did not choose usually breaks in the wrong place. Aim
|
|
for about forty characters a line and no more than two lines at a time. Break
|
|
at a natural pause: `They were wrong about / a great many things` reads; `They
|
|
were wrong / about a great many things` stumbles.
|
|
|
|
*Leave them up long enough.* A comfortable reading speed is somewhere around
|
|
fifteen characters a second, so a forty character line wants roughly three
|
|
seconds. Look at cue one in the file above: thirty-two characters, three
|
|
seconds. If a line of dialogue is short and quick, keeping the cue up a beat
|
|
past the end of the speech is kinder than snapping it away.
|
|
|
|
*Put them where the action is not.* If the important thing in the shot is at
|
|
the bottom of the frame, move the subtitles up for that scene with
|
|
`srtPosition`. You can call it as often as you like, and the player will never
|
|
notice that you did.
|
|
|
|
=== Styling Them
|
|
|
|
The lines are drawn by a document, the same kind you met in lesson twenty, and
|
|
two files in your `Singe` folder decide what they look like:
|
|
|
|
* `Singe/subtitle.rml` -- the document. It holds one element, `#slot`, which
|
|
the engine writes the current cue or banner into.
|
|
* `Singe/subtitle.rcss` -- the style. `.cue` is the block that carries the
|
|
height `srtPosition` asked for, and `.cue .text` is the bar of text inside
|
|
it: white, bold, on a dark rounded panel that hugs the text, with a shadow.
|
|
|
|
Open `subtitle.rcss` and read it -- it is about forty lines. Change the
|
|
`font-size`, the `color`, the `background-color`, the `border-radius`, or the
|
|
`font-effect`, save, and run again. The engine only ever sets `#slot`'s
|
|
contents and the `.cue`'s height, so everything else in that file is yours and
|
|
will survive an upgrade of the engine.
|
|
|
|
Because `overlayBanner` uses the same document, restyling subtitles restyles
|
|
your banners too, which is usually what you want and is worth knowing before
|
|
you wonder why.
|
|
|
|
One limit: the subtitle document is a document, and documents need a GPU. On a
|
|
machine without one, `srtLoad` still reads your file and still answers
|
|
truthfully, and nothing is drawn. A warning says so once.
|
|
|
|
=== Try It
|
|
|
|
. *Add a fourth cue* to `subtitles.srt`, between eleven and thirteen seconds.
|
|
Save and run. Then put it out of order, as cue `99`, first in the file, and
|
|
confirm it still works.
|
|
. *Give a cue a bad timestamp* -- write `00:00:05.500` with a full stop instead
|
|
of a comma -- and run. Watch which line disappears, and notice that nothing
|
|
at all is said about it. A file that loads is not a file that is right.
|
|
. *Restyle them.* In `Singe/subtitle.rcss`, change `.cue .text` to yellow text
|
|
on a fully transparent background, then add a heavier shadow. Then play it
|
|
over the video and decide honestly whether it is still readable.
|
|
. *Break the timing on purpose.* Change every timestamp in the file so it is
|
|
two seconds late, and watch a correctly working system deliver useless
|
|
subtitles. This is what a re-encoded video does to you.
|
|
. *Put the toggle in a menu.* Take the pause page from lesson twenty, add a
|
|
checkbox, and wire it to `srtEnable`. Then save the choice with `saveSet` so
|
|
it is still on next time. This is the version your players actually want.
|
|
|
|
=== Break It on Purpose
|
|
|
|
Run the script without the `--framefile` option:
|
|
|
|
----
|
|
Singe subtitles
|
|
----
|
|
|
|
The window opens, black, with your fallback message on it, and the console
|
|
says:
|
|
|
|
----
|
|
Warning: No subtitles were loaded from subtitles.srt.
|
|
----
|
|
|
|
No crash and no error dialog, because `srtLoad` answered `false` and your
|
|
script handled it.
|
|
|
|
That is the most valuable failure in this lesson, because it is the one that
|
|
happens without anybody noticing. There is no disc, so there is no frame rate,
|
|
so there is nothing to turn the timestamps into, so nothing loads. If you had
|
|
written `srtLoad(...)` on its own and thrown the answer away, your game would
|
|
have run perfectly and shown no subtitles, and you would have found out when
|
|
somebody told you.
|
|
|
|
Use the answer. Every time.
|
|
|
|
=== What You Learned
|
|
|
|
* A game with speech and no subtitles is a game some people cannot play at all.
|
|
* Building subtitles in as you go is an afternoon; retrofitting them is not.
|
|
* Subtitles are a `.srt` file: a number, two timestamps with `-->` between
|
|
them, the text, and a blank line.
|
|
* `srtLoad` turns the timestamps into disc frame numbers at load time, which is
|
|
why a cue survives any seek.
|
|
* It must be loaded after the disc, and it answers `false` when it cannot load,
|
|
including when there is no disc. Check the answer.
|
|
* `srtEnable` switches them on, and a script always starts with them off.
|
|
* `srtPosition` places them as a percentage down the picture, `1` to `95`.
|
|
* `srtClear` takes down whatever is showing, and `overlayBanner` shares the
|
|
same document.
|
|
* `srtLoadTrack` reads subtitles out of the video file itself when it has them.
|
|
* The look lives in `Singe/subtitle.rcss` and is yours to replace.
|
|
* Text over a moving picture needs a safe margin, its own contrast, lines you
|
|
broke yourself, and time to be read.
|
|
|
|
=== Next Time
|
|
|
|
That is the last of the text. Part four goes on without you having to read it
|
|
in order, and the next lesson opens a door that has been shut for twenty
|
|
lessons: `overlayGetWidth` and `overlayGetHeight` are two numbers, and a scene
|
|
has three. Lesson twenty-two is the third one.
|