beats
The four moments the camera of *First Light: Lucina* takes itself off the plate, as data.
game.staging.beats
The four moments the camera of First Light: Lucina takes itself off the plate, as data.
local Beats = require("game.staging.beats")
local beat = Beats.build.opening({
seconds = function(id) return Lines.seconds(id) end,
screen = ui.screenSize(),
anchors = Beats.DEFAULT_ANCHORS,
})
Beats.duration(beat) --> how long it runs, without playing anything
Pure. Nothing here spawns, plays, resolves or ticks: a builder takes a context
and returns a beat — shots, spoken lines and named cues — which
Staging.component turns into a cutscene.play definition and answers every
side effect of. That split is what lets a beat be read, timed and argued about
without a camera, and what keeps every effect in one place.
Why a solve and not an offset
The game is published to a phone held upright, a laptop and a television at once. A 3:4 frame is barely half as wide as a 16:9 frame at the same lens, so a station that frames the lens on a monitor loses it on a phone.
Every station in here is therefore a direction from what the shot is about and a distance solved from the live screen:
Beats.fitDistance(halfW, halfH, fovDeg, aspect)
— the distance at which a subject of those half-extents fits both of the frame's half-angles, the larger requirement winning. On a tall frame the horizontal angle is the small one, so a laterally wide subject pushes the camera back; on a wide frame the vertical angle is, so a tall one does. One number answers for every shape.
The room is small, so the lens gives instead
Beats.ROOM is the box the camera may stand in — a lamp room is a few metres
across, with a floor under it and a lantern deck over it, and the solve will
happily ask for a station three metres behind the stone on a very tall screen.
Beats.frame clamps the station into that box and widens the lens by exactly
as much as the clamp took away, which is the honest trade in an interior and
the reason no beat ends up inside the table or outside the wall.
Measured at 1080×1440, at 1920×1080 and at 1440×3120, every station of every beat lands inside the room and every subject stays in frame.
Every beat begins and ends at home
Beats.HOME_EYE / HOME_AT / HOME_FOV are the station the scene parks
CameraRig on. A beat opens on it and closes on it, so the cut in and the cut
out are a move rather than a jump — and the cutscene runtime hands the viewport
back to whatever held it, so a beat skipped mid-move still leaves the game where
it found it.
The shape of a beat
{
name = "opening",
letterbox = 0.11, fadeIn = 1.1, fadeOut = 0.8, fadeColor = nil,
shots = { { dur = …, pos = …, look = …, fov = … }, … },
lines = { { id = "opening_a", t = 0.9 }, … },
cues = { { t = …, name = "lens", value = 1 },
{ t = …, name = "sfx", sfx = "relight" } },
}
lines and cues are what the component expands: a line becomes a cutscene
subtitle plus a cue that starts its recording at the same instant; a lens cue
drives the lens's ignition toward value; an sfx cue asks the sound slice for
a name off the bus. Shot durations are built from the lines' own lengths, so a
beat is exactly as long as what is said in it.
Anchors
Beats.DEFAULT_ANCHORS names the four world points a shot aims at — lens,
window, plate, tray. The component reads them off the scene entities
(lens_origin, window_origin, plate, tray_origin) and hands them over;
these values stand in when one is missing, so a beat built against a
half-dressed scene is still a shot rather than a refusal.
Scoped to this part · feeds back into the world's score.