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

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.