Skip to content

Lua Cutscenes Recipe Book

microlith57 edited this page Oct 22, 2024 · 26 revisions

This is a recipe-book intended to provide an overview of everything you might want to do with Lua Cutscenes, in approximate order of complexity. All code snippets here are free to use in your cutscenes and are released into the public domain.

This page is currently a work-in-progress!

Jump to reusable helpers

Warm-up: Cutscene structure

A cutscene is a .lua file placed within your packaged mod; its path should usually be Assets/yournickname/yourmodname/cutscenename.lua, for which you would enter Assets/yournickname/yourmodname/cutscenename into an entity's parameters. This path should use / characters rather than \, even on windows, due to the way Everest reads modded assets.

When something happens (like the player talking to a Lua Talker or entering a Lua Cutscene Trigger), one of the functions in that lua file will be run (provided it exists), and can take arguments (though it doesn't have to). All cutscenes can define the following functions:

function onBegin(room)
  -- cutscene code goes here, and will be run when the cutscene begins
  -- this one gets to do things that take time (more on that later!)
end

function onEnd(room, wasSkipped)
  -- cutscene code goes here, and will be run when the cutscene ends
  -- `room` is the current room, and `wasSkipped` is `true` if the cutscene was skipped
  -- this one can't do things that take time
end

Additionally, triggers can define these (none of them can do things that take time):

function onEnter(player)
  -- code here is run when the player enters the trigger
end

function onLeave(player)
  -- code here is run when the player leaves the trigger
end

function onStay(player)
  -- code here is run every frame while the player is touching the trigger
end

You can define functions other than these, and call them whenever you want, in order to decrease repetition in your code.

Cutscene Arguments

Helper functions

Lua Cutscenes provides many helper functions that let you do things in your cutscenes; a full list is available here 🔗, and the lua code those functions are written in is here 🔗.

To call a function, for example setCameraOffset 🔗, you write the name of the function (you don't need to put helpers), followed by the list of arguments you want to give it, like this:

function onBegin()
  -- set the camera offset to 0.5 in the x direction
  setCameraOffset(0.5, 0.0)

  -- wait for 1 second
  wait(1)

  -- set the camera offset to 0.2 in the x direction, and -0.3 in the y direction
  setCameraOffset(0.2, -0.3)

  -- wait again, for 1.5 seconds this time
  wait(1.5)

  -- make some variables to use (generally, pick an alphanumeric name without spaces, and starting with a letter):
  local offset_x = -1
  local offset_y = 0.1
  -- then use them:
  setCameraOffset(offset_x, offset_y)
end

Note that in the documentation this function is written as helpers.setCameraOffset(x[, y]); the square brackets mean that that argument is optional.

Persistent data: Remembering things through room transitions

Flags

You can get and set flags to store data, and this is typically used to change a cutscene's behaviour depending on choices the player has made. For example, here is the code for a talker that has one dialog path when first talked to, and another if it's been talked to before:

function onTalk()
  -- find out if the cutscene has run before
  local ran_before = getFlag("example_cutscene_ran")

  -- if this is the first time:
  if not ran_before then
    -- set this for the next time round
    setFlag("example_cutscene_ran", true)

    -- display the first-time dialog
    say("EXAMPLE_CUTSCENE_DIALOG_1")
  else
    -- display the talked-to dialog
    say("EXAMPLE_CUTSCENE_DIALOG_2")
  end
end

By putting the if statement the other way around, you can make a cutscene that runs only once:

function onBegin()
  if getFlag("example_cutscene_ran") then
    -- the cutscene has already run, so don't run it again
    return
  end
  setFlag("example_cutscene_ran", true)

  -- handle the cutscene proper
  say("EXAMPLE_CUTSCENE_DIALOG_1")
end

Note that this technique doesn't prevent lua talkers from displaying their interact prompt, it just makes interacting with them do nothing.

Counters

Accessing and using C# objects

The Lua Cutscenes helper functions are useful, but can't do everything. Fortunately, it is possible to interact with C# objects as well, which allows you to do almost anything — you can read and modify almost any data from anywhere in the game code, and call any C# method you like.

require

You can get a C# class like this:

local calc = require("#monocle.calc")

It's good practice to have all your require statements at the top of your cutscene file, outside of any functions. Some of these classes are already imported by the helper functions (see here 🔗)

Once you've done that, you can then call methods on the class:

local calc = require("#monocle.calc")

function onBegin()
  -- output into the log the size of this angle, in radians:
  --            . (1, 1)
  --           /
  --          /
  --         /)     <-- here
  -- (0, 0) .----
  log(calc.Angle(vector2(1, 1)))
end

You can also get static fields on classes:

local calc = require("#monocle.calc")

function onBegin()
  -- how many radians are in a circle?
  log(calc.Circle)
end

You can find a list of all the methods and fields of a class by decompiling it 🔗 or looking at its metadata in an IDE. Note that whether a method is marked as public or private is not relevant when using lua — you can call private methods and get private fields the same way as public ones.

Static vs. Instance methods / fields

When getting values or calling methods on C# objects, it's important to know about the differences between static and instance methods. A static method or field exists on the class, like calc.Circle or csharpVector2.Lerp(start_pos, end_pos, amount), while an instance method or field exists on some instance of that class, like player.Position or level:GetSpawnPoint(pos). In Lua, an instance method is called with instance:methodname() rather than instance.methodname().

Enums

Enum members 🔗, for technical reasons, cannot be retrieved like normal static fields. Instead, you can use the getEnum(enum, value) helper function, called with the full name of the enum type and the name of the member you want. For example, to get MoveBlock.Directions.Right, you can use getEnum("Celeste.MoveBlock.Directions", "Right").

Example: Talking to Badeline

The following functions (adapted from the vanilla cutscene in the last checkpoint of Farewell) allow you to create and remove a Badeline entity that doesn't do anything by itself, which is good for dialog:

-- store the badeline entity outside the functions, so it can be accessed whenever necessary
local badeline = nil

local function badeline_appears(left_side)
  -- determine the position and flipping properties
  local pos_x, scale
  if left_side then
    pos_x = player.Position.X - 18
    scale = 1
  else
    pos_x = player.Position.X + 18
    scale = -1
  end
  local pos_y = player.Position.Y - 8

  -- create and add a new badeline dummy entity
  badeline = celeste.BadelineDummy(vector2(pos_x, pos_y))
  badeline.Sprite.Scale = vector2(scale, 1.0)
  getLevel():Add(badeline)

  -- play sound + effect
  getLevel().Displacement:AddBurst(badeline.Center, 0.5, 8, 32, 0.5)
  playSound("event:/char/badeline/maddy_split", badeline.Position)

  -- wait until the next frame so all that can take effect properly
  wait()
end

local function badeline_vanishes()
  -- tell the badeline entity to disappear
  badeline:Vanish()
  Input.Rumble(getEnum("Celeste.RumbleStrength", "Medium"), getEnum("Celeste.RumbleLength", "Medium"))
  -- clear the stored variable so the memory can be used for something else
  badeline = nil

  -- wait until the next frame
  wait()
end

function onBegin(room)
  disableMovement()
  badeline_appears()
  say("EXAMPLE_CUTSCENE_DIALOG_1")
  badeline_vanishes()
end

function onEnd(room, wasSkipped)
  if badeline then
    badeline:RemoveSelf()
    badeline = nil
  end
  enableMovement()
end

Generic Methods

Example: Getting Entities

Coroutines: What does it mean to take time to do something?

In Monocle (the engine Celeste is built with), there's a concept of coroutines, which are a way to have a function pause and then resume later. You can tell that a C# method can be made into a coroutine because it will return an IEnumerator.

Additionally, the onBegin and onTalk functions are set up as coroutines, which means that they can be paused and resumed later. This is why only they can call functions like wait 🔗 and say 🔗 ­— these functions need some way to pause the function and then resume it after the waiting or dialog is finished. In addition to calling those helper functions, you can do things like calling other coroutines with coroutine.yield.

yield

The coroutine.yield function allows you to call C# coroutines. For example:

-- minimal version of CS02_Mirror
function onBegin()
  -- get a DreamBlock from the room
  local dream_block = getEntity("DreamBlock")
  -- get a DreamMirror from the room
  local mirror = getEntity("DreamMirror")
  local break_direction = 0

  -- break the mirror by running one of the mirror's coroutines
  coroutine.yield(mirror:BreakRoutine(break_direction))
  -- this code only runs once it's finished breaking:
  coroutine.yield(dream_block:Activate())
  -- the cutscene only finishes once the dream block has activated
end

Example: Controlling the camera

Zooming the camera is done like this:

-- zoom to a point
-- * position is a vector2 for where to zoom to
-- * zoom_amount is a number (how much to zoom)
-- * duration is a number (how many seconds the zoom should take)
-- because this uses coroutine.yield, your cutscene will only resume after the zoom is finished
coroutine.yield(engine.Scene:ZoomTo(position, zoom_amount, duration))

-- zoom out again
-- again, your cutscene will only resume after the zoom is finished
coroutine.yield(engine.Scene:ZoomBack(duration))

-- zoom instantly
engine.Scene:ZoomSnap(vector2(x_position, y_position), zoom_amount)
-- or
engine.Scene:ResetZoom()

-- zoom to a point, but from an already-zoomed-in state
-- works like ZoomTo, but cleanly transitions
coroutine.yield(engine.Scene:ZoomAcross(new_position, new_zoom_amount, duration))

Note that the positions here are screen-space positions.

Delegates

Making your own Coroutines

Sometimes it is necessary to have two functions running in paralell, which coroutine.yield can't do. To do this, you can create a Monocle Coroutine, which is a sort of "glue" that attaches a compatible function to an entity, so that it acts like a cutscene for the lifetime of that entity. Choosing an appropriate entity to attach them to is important; usually the cutscene entity is the best choice.

From a C# Method

Any C# method that returns an IEnumerator can be made into a Coroutine. Here is an example that displays some dialog while zooming in at the same time:

local monocle = require("#monocle")

-- store the coroutine out here so we can cancel it
local coroutine = nil

function onBegin()
  -- create a Coroutine to zoom the camera
  coroutine = monocle.Coroutine(engine.Scene:ZoomTo(position, zoom_amount, duration))
  -- attach the Coroutine to the cutscene entity (either trigger or talker)
  cutsceneEntity:Add(coroutine)
  -- the camera zoom is now taking effect

  -- this will pause the cutscene code until it's finished, but the camera zoom will continue in the background
  say("EXAMPLE_CUTSCENE_DIALOG_1")

  -- cancel the coroutine and detach it from the cutscene entity
  coroutine:Cancel()
  cutsceneEntity:Remove(coroutine)

  -- zoom back out
  coroutine.yield(engine.Scene:ZoomBack(duration))
end

function onEnd(room, wasSkipped)
  -- now, what if the player skipped the cutscene? let's make sure the zoom is cancelled
  -- the if statement is to make sure we don't try to cancel a nil value
  if coroutine then
    coroutine:Cancel()
    cutsceneEntity:Remove(coroutine)
  end

  -- make sure no zoom effects leak past the end of the cutscene
  engine.Scene:ResetZoom()
end

From a Lua Function

It is also possible to convert Lua functions into Coroutine instances, which effectively allows you to run several cutscenes at once. This is more fragile, especially if those functions outlive your main cutscene function.

Here is a helper function to convert a Lua function into a Monocle Coroutine:

local lua_helper = celesteMod.LuaCutscenes.LuaHelper
local monocle = require("#monocle")

local function makeCoroutine(func)
  return monocle.Coroutine(lua_helper.LuaCoroutineToIEnumerator(coroutine.create(func)))
end

This can be used like this:

local lua_helper = celesteMod.LuaCutscenes.LuaHelper
local monocle = require("#monocle")

local function makeCoroutine(func)
  return monocle.Coroutine(lua_helper.LuaCoroutineToIEnumerator(coroutine.create(func)))
end

---

local function walkBackAndForth()
  -- this 'mini cutscene' runs forever, and causes the player to walk back and forth
  -- it will be cancelled by calling Cancel on its Coroutine instance; it's OK that the function never actually ends
  while true do
    walk(16)
    wait(0.5)
    walk(-16)
    wait(0.5)
  end
end

-- variable to store the coroutine instance between onBegin and onEnd
local walkBackAndForthCoroutine

function onBegin()
  -- begin walking back and forth
  -- note that the function is passed to makeCoroutine without calling it, unlike how C# methods are done.
  walkBackAndForthCoroutine = makeCoroutine(walkBackAndForth)
  cutsceneEntity:Add(walkBackAndForthCoroutine)

  -- at the same time, start the dialog
  say("EXAMPLE_CUTSCENE_DIALOG_1")
end

function onEnd(room, wasSkipped)
  -- either the dialog finished or the cutscene was skipped, so stop walking
  if walkBackAndForthCoroutine then
    walkBackAndForthCoroutine:Cancel()
    cutsceneEntity:Remove(walkBackAndForthCoroutine)
  end
end

Adding entities to the level

Helpers

-- Play a player animation with optional duration
-- Player animations are defined in `Celeste/Content/Graphics/Sprites.xml`
function playSprite(sprite, duration)
    player.DummyAutoAnimate = false
    player.Sprite:Play(sprite, false, false)
    if (duration) then
        wait(duration)
        player.DummyAutoAnimate = true
    end
end
-- summon a Theo NPC at a position (e.g. `player.Position - vector2(16, 0)`
function summonTheo(position)
    theo = NPC(position)

    local sprite = SpriteBank:Create("theo")
    theo:Add(sprite)
    
    theo.IdleAnim = "idle"
    theo.MoveAnim = "walk"
    theo.Maxspeed = 48
    theo.MoveY = false

    sprite:Play("wakeup", false, false)

    getRoom():Add(theo)
    return theo
end
Clone this wiki locally