Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit61a33237e0

README.md

cutscene

Author a cutscene as data, then play it. The global cutscene is injected by the prelude, so no require is needed.

A cutscene takes the camera along a shot path, letterboxes and fades the image at its boundaries, shows subtitle lines, fires cues at the moments you name, holds the player still while it runs — and gives every bit of that back when it ends, however it ends.

cutscene.play({
    duration = 6,
    letterbox = 0.12,
    shots = {
        { dur = 6, pos = { { -8, 3, 12 }, { 4, 2, 5 } }, look = "Hero", fov = { 55, 38 } },
    },
    subtitles = {
        { t = 0.5, dur = 4, text = "They came at first light.", speaker = "Mira" },
    },
    cues = {
        { t = 3, run = function() entity.find("gate"):despawn() end },
    },
})

Playing

  • cutscene.play(source, opts?) -> Playback — source is a definition table or the path of a module returning one. opts overrides definition keys for this play. The cutscene advances on its own; nothing has to be ticked. Starting one while another plays stops that one first.
  • cutscene.stop(reason?) -> boolean, cutscene.skip() -> boolean
  • cutscene.current() -> Playback?, cutscene.isPlaying() -> boolean
  • cutscene.status() — { name, time, duration, progress, paused, rate, playing, shot }
  • cutscene.sweep() — remove a camera / subtitle / overlay left behind by a playback that never got to end.

Authoring aids that read a shot path without playing it: cutscene.evaluate(shots, t), cutscene.shotDuration(shots), cutscene.easings().

The playback

play returns the handle driving the cutscene:

skip() · stop(reason?) · seek(t) · pause() · resume() · setRate(r) · onFinished(fn) · time() · getDuration() · progress() · isPlaying() · status()

onFinished(fn) is called with (playback, reason) where reason is "completed", "skipped" or "stopped" — the place to hand control back to the game.

The definition

KeyDefaultWhat it does
durationthe shots' totalHow long the cutscene runs, in seconds.
shotsnoneThe camera path. See below.
subtitlesnone{ t, dur?, text, speaker? } per line. dur defaults to 3.
cuesnone{ t, run, name? } — run(playback) fires once as the clock crosses t.

A skippable cutscene puts a Skip control on the player's surface for as long as it is on screen (the keys in skipKeys, the pad buttons in skipButtons, and an on-screen button), and takes it back off when the cutscene ends. It names at least one of the three: emptying both lists with skipTouch = false is refused where the playback is built, and skippable = false is how a cutscene plays out in full.

A skip fires every cue between the skip point and the end, so a cutscene that opens a door on its way out opens it whether or not the player watched. A seek does not: it re-derives the frame that time would have produced rather than running the clock through to it, so cues behind the new time count as already fired. Play through or skip to watch one happen; a backwards seek re-arms every cue ahead of it.

The letterbox and fade are a post pass drawn last, so they cover the UI as well as the scene — keep a HUD a cutscene shows clear of the bands. | letterbox | 0 | Band height per side, as a fraction of the screen height. | | fadeIn / fadeOut | 0.6 | Seconds of fade at each boundary. | | fadeColor / barColor | black | { r, g, b } in 0..1. | | skippable | true | Whether the player can end it early. | | skipKeys | Escape / Space / Enter | Keyboard codes that skip it. Empty declines the keyboard. | | skipButtons | start / south / east | Gamepad buttons that skip it. Empty declines the pad. | | skipTouch | true | Whether the on-screen skip button is drawn. | | skipLabel | Skip | What the on-screen button and a rebind screen call it. | | takeCamera | true when it has shots | False plays it over the viewer's own camera. | | freezePlayer | true | Hold the player still for the duration. | | hidePlayer | false | Hide the player's body. | | renderLayers | scene default | Render layers the cutscene camera draws. | | onStart / onFinish | none | onStart(pb), onFinish(pb, reason). |

Shots

A shot moves the camera between anchors over dur seconds.

{ dur = 4, pos = { { 0, 3, 10 }, { 0, 2, 4 } }, look = "Hero", fov = { 60, 40 }, ease = "inOutQuad" }

An anchor — pos or look — is a world position, the entity to read one from (by name, id or proxy), or a list of either that the shot travels along as a polyline. Reading the anchor from an entity is what lets a shot frame something that moves: it is resolved on the frame it is drawn, so a shot aimed at a walking character tracks the walk.

posOffset / lookOffset shift a resolved anchor — look = "Hero", lookOffset = { 0, 1.6, 0 } frames the character's head rather than the ground under their feet.

ease takes any name cutscene.easings() lists, or a function.

Playing from a scene

A cutscene definition living in its own module is content: it can be played by name, hot-reloads while you watch it, and the cutscene toolbox can drive it.

-- mygame/scenes/intro.module/init.luau
return { duration = 8, shots = { … }, subtitles = { … } }
-- the scene's entrypoint — the game opens on the cutscene
function onLoad()
    cutscene.play("mygame.scenes.intro")
end

For a cutscene that fires when the player reaches somewhere, put a CutsceneTrigger component on the entity instead of writing the wiring.

While authoring

The cutscene toolbox drives whatever is playing without waiting for it: zero cutscene status, zero cutscene seek 6.5, zero cutscene pause, zero cutscene rate 0.25, zero cutscene stop.

Submodules

path.module evaluates the shot list, stage.module owns what a cutscene takes over and gives back, and runtime.module is one playback's clock. Each is independently requirable; cutscene composes them.