Lua scripting

Cinematics

Camera shots for a story scene, written as data in local frames (the room, a person, the space between two people), played while the script waits.

A cutscene is a list of shots. Each shot puts the camera somewhere, points it at something, and holds or moves for a few seconds. Dialogue keeps running through the textbox over it, so a scene can be shot and written at the same time.

There is no level to author against: every room is generated when the player walks in, and stands wherever the city put the building. So a shot is never a world position. It is a position in a frame the shot names:

frame origin axes
room the floor centre of the room the player is in X and Y are a fraction of the room's half-size (-1 and 1 are the walls, 0 the middle); Z is metres above the floor
person:<role> that person's feet X forward, Y right, Z up, in metres
between:<a>,<b> the floor point between two people X runs from a towards b, in metres
piece:<match> the floor centre of a piece of furniture in the room whose kind, lead type or id contains <match> (piece:TV, piece:Sofa) X points out of the piece's front, Z up, in metres. Game:Piece("<match>") gives the same piece in the stage frame. No such piece: the shot plays framed on the room instead and the log says so
piece:window the shell's window nearest the player (windows are not furniture): its centre on the floor, a wall tile wide X points into the room from the glass, Z up, in metres

The same shot frames a studio flat and a warehouse; a close-up follows whoever plays the role.

A cutscene file

Cutscenes/<id>.ulcutscene in your mod:

json{
  "letterbox": true,
  "fade_in": 0.6,
  "fade_out": 0.8,
  "shots": [
    { "frame": "room", "from": [0.8, -0.8, 1.7], "to": [0.4, -0.8, 1.6], "look_at": [0, 0, 1.1], "fov": 38, "duration": 4 },
    { "frame": "person:friend", "from": [1.4, 0.5, 1.6], "look_at": "face", "fov": 32, "duration": 3, "blend": 0.8 },
    { "frame": "between:player,friend", "from": [0, 2.2, 1.5], "look_at": [0, 0, 1.3], "duration": 3 }
  ]
}

One shot:

key default
frame room See the table above.
from [0, -0.9, 1.6] Where the camera starts, in the frame.
to Where it ends after duration; left out, the camera holds.
look_at [0, 0, 1.2] A point in the frame, or "face" (the frame person's head) or "person:<role>".
look_at_end The point it looks at by the end; left out, the look holds.
fov 50 Degrees. 30 to 40 is a portrait lens, 60 and up is wide.
duration 3 Seconds.
blend 0 Seconds the camera takes to glide from where it was. 0 is a cut.
ease smooth smooth eases the move in and out; linear does not.

While a cutscene plays, every HUD widget is hidden except the dialogue box and the choice menu; Game:HideHud(true) does the same on its own, Game:HideHud(false) brings the HUD back. The room frame is the room the player walked into; a scene launched from the map uses the room under any role, else the nearest room.

letterbox draws the black bars, fade_in and fade_out are seconds from and to black. Roles are the actors the script hands in; player is always there. A shot whose role is missing is skipped with a log line, the rest still plays.

Words on the picture

Footage is captured in the game, so the game draws the words. None of these open the dialogue box.

luaGame:Title("UNDERLIVES", "Life Has No Rules", 3)      -- a card in the middle, gone after 3 s
Game:Subtitle("Nobody told her the rent was due.", 4)  -- a line along the bottom
Game:Subtitle("")                                      -- clears it now
Game:Hold(2.5)                                         -- the script waits, picture and text stay

A duration of 0 keeps the text until the next call.

Marks and props

A beat should not depend on where a walk left everyone. A scene can place its cast and a few practicals itself, in metres in the stage frame: the origin is the player's feet, forward is the room's facing, the same frame a shot gets with frame = "stage".

luaGame:StageActor(landlord, {1.2, 0.4, 0}, 180)        -- 1.2 m ahead, a little right, facing back at the player
Game:StageProp("cylinder", { person = cop, socket = "rHand",
    position = {0, 0, 0.1}, size = {0.03, 0.03, 0.4}, color = {0.05, 0.05, 0.05} })   -- a baton in the hand
Game:StageProp("cube", { position = {0, 1.5, 0.4}, size = {1.2, 0.6, 0.8} })        -- a crate to sit on
Game:StageLight({ position = {0.6, -0.8, 2.2}, intensity = 2500, color = {1, 0.85, 0.7} })
Game:Shot({ frame = "stage", from = {2, 0.4, 1.6}, look_at = "face", fov = 40 }, false)
Game:PlayCue("door_slam")

A staged person stands still with collision and gravity off until the scene ends or Game:ClearStage() runs; props and lights are destroyed with them. StageActor and StageProp return false when the handle is not a person or no player exists yet. Shapes are cube, cylinder and sphere; sizes are scales where 1 is one metre; a rotation of {pitch, yaw, roll} in degrees is applied in the stage frame, or relative to the socket for a carried prop.

Shooting outside the game

A scene launched from the city plays in the dialogue layer with no room under it, so the camera has only the map to look at. To film a beat, run it in the cinematic harness: one venue, the player's body in it, your script, nothing else on screen.

UnrealEditor.exe <uproject> "/Game/Maps/RoomTest_Generic?game=/Script/Underlives.CinematicGameMode" -game -Cinematic=my_beat -RoomTag=residential -LookHour=19 -windowed -ResX=1920 -ResY=1080

-Cinematic names a Scenes/<name>.ulscene of your mod. -RoomTag is the venue (the same tags as rooms: residential, cafe, office, …), -RoomSeed replays one generated layout, -LookHour sets the hour, -Extras fills the venue with people. -ShotEvery=2 writes a frame every two seconds to Saved/Screenshots/Cinematic/<name>/, HUD included, to check a shot without watching it. The script spawns and dresses whoever else it needs, places them (moveToPersonStand, animations), and shoots. Game:Say has no dialogue box here: use Subtitle.

A shot is written against one room, so a beat should not roll its furniture: -RoomLayout=<preset> furnishes the venue with a layout preset instead (a Rooms/Layouts/<file>.ulroomlayout exported from the room editor, named by its name field or its file name). The venue tag, the room and the grammar come from the preset; -RoomTag is then ignored. Corners away from the door wall (y near 1) usually hold the wardrobes, so a wide shot starts near the door wall (y near -1), like the room's own viewpoint.

Framing live

The harness stays up between tries. Save your scene, a .ulcutscene or a Lua/*.lua library and the beat replays from the top: the player back on the starting mark, your files read again. Console commands drive the camera without touching a file:

Command Does
cine.reload / cine.stop replay now / drop the scene, everyone stays put
cine.cam {"frame":"person:player","from":[1.6,0.4,1.6],"look_at":"face","fov":32} hold that camera (the keys of a shot)
cine.cutscene <id> [shot] [seconds] play one cutscene file alone, from a shot (0-based)
cine.seek <shot> [seconds], cine.pause freeze on a moment of a shot
cine.shot [name] [noui] a frame to Saved/Screenshots/Cinematic/Live/
cine.hour <h>, cine.room <venue> [seed], cine.room layout=<preset> the hour, the furniture

The same lines written to Saved/Cinematic/Live/commands.txt run without typing, and Saved/Cinematic/Live/status.json answers: the shot playing, the camera in each frame (from_in_frame, ready to paste into from), the lenses the game had to move (shot_warnings) and your script's errors. On the city map, add -CineLive to get the same.

Playing it

lua-- Waits until the last shot ends.
Game:PlayCutscene("uls1_funeral_arrival", { friend = Cast.get("friend") })

-- One shot inside dialogue: the camera stays on them while they speak.
Game:Shot({ frame = "person:friend", from = {1.2, 0.3, 1.6}, look_at = "face", fov = 35, roles = { friend = friend } }, false)
Game:Say(friend, "Sad", "They did not suffer. That is what they tell you.")
Game:EndCutscene()

Game:Fade(1.0, true)    -- to black
Game:Fade(0.5, false)   -- and back
Game:Letterbox(true)

PlayCutscene and Shot return false, without pausing, when there was nothing to play: a missing file, a bad frame. The reason is in the log. A scene that ends or errors under a shot hands the camera back by itself.

The lens

A shot can carry a lens as well as a position. All keys are optional and work in Lua shots and in .ulcutscene files alike.

luaGame:Shot({ frame = "person:friend", from = {1.2, 0.3, 1.6}, look_at = "face",
    focus = "face", aperture = 1.8,   -- depth of field: what is sharp, how shallow (f-stop)
    fov = 35, fov_end = 30,           -- a slow zoom over the shot
    shake = 0.2,                      -- handheld, 0..1
    roll = 4,                         -- the horizon tilted, degrees
    speed = 0.5,                      -- the world at half speed while the shot plays (the shot clock stays real)
    orbit = 40,                       -- the lens turns round the look-at point by 40 degrees over the shot
    duration = 2.5, roles = { friend = friend } }, true)

focus is "face", "person:<role>" or a point in the shot's frame; focus_distance (metres from the lens) does instead. A focus with no aperture gets f/2.

The picture

luaGame:Grade("club_red", 0.8)            -- Grades/club_red.ulgrade from any mod, reached in 0.8 s
Game:Grade({ saturation = 0.4, vignette = 0.9, blur = 0.6, chroma = 2 }, 3)  -- composed in place
Game:Grade(nil, 1)                     -- back to the plain picture
Game:VenueLights({ scale = 0.15, color = {1, 0.4, 0.5}, flicker = 0 })  -- the room's own lights; practicals untouched
Game:Flash({1, 1, 1}, 0.3)             -- a hit of white that clears
Game:Strobe(8, 2.0)                    -- 8 Hz for two seconds
Game:Freeze(1.5)                       -- the world stands still; camera, words and music go on
Game:Fade(0.5, true, {1, 1, 1})        -- to white
Game:TitleStyle({ size = 28, color = {1, 1, 1}, y = 0.86 })
Game:Caption("Rent: $410 due Friday", { x = 0.08, y = 0.9, size = 22 }, 3)

A grade file holds the keys of the table: tint [r,g,b], saturation, contrast, gamma, vignette, grain, bloom, chroma (chromatic aberration), blur (the whole frame out of focus, 0..1), exposure (stops), temperature (Kelvin). Everything the grade, the lights, a speed or a Freeze changed is given back when the scene ends.

Sound in a scene

luaGame:Music("Music.Venue.stripclub")    -- crossfade to that cue; "" fades the music out
Game:Sfx("Trailer.Gunshot")            -- a cue, or a file: Game:Sfx("Effects/door_slam.ogg", { at = landlord, volume = 0.8 })
Game:Duck(0.35, 0.3)                   -- the music down to 35 % under a line
Game:Say(p, "Angry", "Friday.")
Game:Duck(1, 0.8)

A file is looked for as given, then under every mod's Audio/ folder.

Markers and the live director

Game:Marker("hands") names a point in a scene. It costs nothing when the scene plays; the live director uses it to jump: cine.run my_scene@hands runs the scene with no waits (holds, shots, lines; a choice takes its first available option) until that marker, then plays it. Every run also writes Saved/Cinematic/Live/timeline.json: each shot, line, voice clip, subtitle, sound, grade and marker with its time and, during cine.record, its frame. Game:Extras(6, "dance") fills the room with six people of the venue's kind doing that; Game:ClearExtras() sends them off; Game:Seed(n) fixes the random draws that follow.