Theater Mode: Animation Viewer
Project Overview
Brief: This mod adds a new in-game menu that lets the player replay cutscenes, view character animations, change the time of day or weather conditions, and more. This was done through creative use of the game's Lua functions as well as minor code injection to allow tracking time of day and spawning the menu with a button combination.
Despite being a very story driven game, Sonic Frontiers has no way to replay the game's numerous cutscenes. After seeing a request for this feature on Twitter, I did some digging and found the cutscenes were simply called by passing a string to a Lua function, so I set to work on making a system that would let the player view any cutscene at will. During development, this was extended to allow several other niceties missing from the original game, like a time of day or weather toggle, as well as an animation viewer.
NOTE: This page focuses on the design and implementation of the cutscene and animation viewers. Weather toggling and time of day selection were handled by updating variables then calling game functions with the methods outlined below.
While technically this project could've been brute forced by making a massive table for every animation/cutscene string in the game, I chose to design it in a more modular fashion. My goal was to structure the data how one might when designing an in-engine editor that stitches together code based on input to fields.
This made for a far more interesting challenge, and the end result was a rather flexible system which I was able to easily extend whenever I wanted to include a new feature.
Still, the developers clearly didn't intend for something like this to be written in Lua, so I had to get a tad creative. Broadly, these were my objectives.
- Page system: Frontiers has a Lua function which creates an interactable box (SelectBox) on screen with up to 5 options displayed. Another function returns an integer corresponding to the option selected.
- Minimize navigation: A staple UI rule further compounded by each SelectBox forcing a short animation to play before accepting user input. Information needed to be displayed concisely with a way to store related cutscenes or animations within sub-pages so players could more efficiently navigate the menu.
- Minimize code: Animations are often divided into multiple parts (start/loop/end), and those parts can even have directional variants. These are typically distinguished by suffixes, so rather than writing the full animation name into a table, entries should be provided with a root and suffixes then assembled at runtime.
While the specifics vary slightly between options, this is the general structure I used for pages.
ExampleTable = {
[1] = { title = "sbox_title_1", event = "chosen_string_1" },
[2] = { title = "sbox_title_2", event = "chosen_string_2" },
[3] = { title = "sbox_title_3", event = "chosen_string_3" },
[4] = { title = "sbox_title_4", event = "chosen_string_4" },
Page = { pageCount = 2, pageRemaining = 1 }
}
A page will typically display three options from this table at a time (option 4 is used to access the next page, and option 5 closes the menu), so pageCount tracks how many pages the menu can go through before looping, and pageRemaining is the number of options to display on the last page.
Page navigation is handled with a simple recursive function, and two upvalues (cutsceneIndex, currentPage
) to track your index in the table and current page respectively, with the latter just indicating when to reset back to page 1. This could have been accomplished with a single variable, but splitting it into two made things more intuitive.
When the function is called, it grabs up to 3 entries from the table then extracts their titles to form the SelectBox. Once the player presses a button, the event string is extracted from the corresponding entry then used as either further input or a function argument, depending on the type of menu.
-- Pseudocode
function CutsceneSelect()
local entry_1 = ExampleTable[cutsceneIndex]
local entry_2 = ExampleTable[cutsceneIndex+1] or {}
local entry_3 = ExampleTable[cutsceneIndex+2] or {}
local select_box_titles = {
entry_1.title,
entry_2.title,
entry_3.title,
"page_next",
"menu_exit"
}
-- Easy access to chosen entry
local entry_reference = {}
-- Summon the box and store the chosen button
ShowSelectBox(table.unpack(select_box_titles))
local choice = GetSelectResult()
-- Adjust choice if entries 2 or 3 were blank
-- ...
-- Update entry_reference
if choice == 0 then
entry_reference = entry_1
elseif choice == 2 then
-- And so on
-- ...
elseif choice == 3 then
-- "Next Page" option
currentPage++
-- Length of the table, or the next "page"
cutsceneIndex = math.min(#ExampleTable, cutsceneIndex + 3)
if currentPage > ExampleTable.Page.pageCount then
ResetParams() -- reset currentPage and cutsceneIndex to 1
end
-- Tail call
return CutsceneSelect()
elseif choice == 4 then
-- "Exit menu" option
ResetParams()
end
end
Cutscenes were by far the easiest data to manage. Frontiers plays these by running a Lua function called PlayDiEvent
which accepts a string argument corresponding to a cutscene file name.
This meant my cutscene tables simply needed to have fields for the SelectBox title and the filename itself (like the example table in the previous section), along with an optional field for hiding actors in the game world (most cutscenes are played in real time, so the game occasionally does this with another Lua function).
The real trouble came from organizing and documenting every cutscene in the game. Across the five (six, counting the DLC) islands in Frontiers, there are roughly 340 cutscenes (including unused ones), and none of them are named.
After watching every cutscene and coming up with suitably descriptive monikers, I divided them into the following categories:
- Main Story.
- Side Story (optional character interactions, typically 10 per island).
- Boss Events (intro/victory scenes, quick time events, etc.)
- Miscellaneous (real time variants of pre-rendered cutscenes or anything else that didn't fit in another category).
To manage this additional layer without tweaking my page structure, I added an upvalue called ReferenceTable
. Each of the options above is stored in their own table, so when the player selects the Cutscene page and chooses their category, ReferenceTable becomes a reference to that category, and the page navigation code then operates ReferenceTable.
Note: Since most cutscenes are play in real time, I automatically select the correct table for the player's current island. The full 340 cutscenes will never be available in one instance.
It took about 4 days to fully design and implement the cutscene viewer (along with time of day and weather options). It took an additional 8 to add the animation viewer.
Once again, Frontiers left in a Lua function for changing the player's animation, ChangePlayerAnimInHold
. This accepts a string argument for the animation, as well as a float for how long the animation should play. While this meant I could re-use the page structure from the Cutscene Suite (with animations being categorized by combat, traversal, stage gimmick, etc.), the code needed to be heavily adapted to keep with the project goals.
Take Sonic's Sky Diving animation for example. There are 9 animations for this state - DIVE_START, DIVE, DIVE_FAST, DIVE_FAST_LOOP, DIVE_FAST_END, DIVE_IDLE_START, DIVE_IDLE, DIVE_DAMAGE, DIVE_PIPE
.
- Organizationally, this is a problem as the animations would take up three entire pages. If the player isn't interested in a Dive animation, it's not easy to skip them. Instead, "Diving" should be presented as one option that opens a new menu containing these variants. This new menu would need to support pages as well due to the number of variants.
- Some of these variants have sub parts, like
DIVE_FAST
having_LOOP
and_END
segments. Realistically, the player will want to view the entire sequence, so these would need to be played in order without extra user input. - While not an issue with this animation, some entries can have directional variants that also have sub parts attached. These would need to once again be handled by opening a new menu.
- Typically, sub parts and direction suffixes append in that order, but it's not consistent (e.g.,
SLIDEDOWN_START_R
vs.BUMP_JUMP_R_LOOP
), so the order would need to be malleable.
In short, this meant I would need a lot of string manipulation if I wanted to avoid hardcoding every entry, so I tweaked my table structure a bit.
- Each entry in the animation table still supports Title and Event. Event is now the root animation name (e.g.,
DIVE
). - Entries now support fields for
direction, subcategories, appends, duration
. These can either be an array of values or directly assigned. - Strings inside
appends
are concatenated with the root name to create new strings then stored inside an array. This step normally happens after directions are considered, but a flag can be included to make it happen first. Direction
andsubcategories
hold prefixes/suffixes that will modify the root name. Their presence signals that a new menu should be constructed by concatenating them with the root word to create new strings.- Directions do not support pages and thus can contain 5 options (neutral plus four directional variants). Subcategories can only display 4 options at a time since they support pages.
- Duration informs how long each part of the final sequence should play for, in seconds.
I chose this system since it was the easiest to imagine in an editor. If this were to be used in, say, Unity's inspector, it would be logical to have a field for the animation name with checkboxes as children for sub parts or directions.
With all that in mind, the internal structure for Dive would look like this:
TraversalTable = {
[1] = {
title = "button_name_dive",
event = "DIVE",
-- Applies to each entry since a table isn't used.
duration = 5,
subcategories = {
"", "_DAMAGE", "_FAST",
"IDLE_", "PIPE",
pageCount = 2
},
appends = {
-- Constructs "DIVE_START"
[1] = {"_START"},
-- Constructs "DIVE_FAST",
-- "DIVE_FAST_LOOP",
-- "DIVE_FAST_END"
[3] = {"", "_LOOP", "_END"},
-- "DIVE_FAST_START", "DIVE_FAST"
[4] = {"_START", ""}
}
}
}
When one of the dive animations is finally selected, an array gets constructed using these values then a function is called to iterate over it, playing each animation in the array.
My insistance on minimizing hardcoding created a real issue with the animation subcategories described in the previous section. Up until this point, every page had a clearly defined structure defined ahead of time in Lua and tracked using upvalues. This meant recursion was the simplest choice for navigating the table since a function simply had to modify the upvalues then call itself to display the updated page.
When the player first selects an animation, I retrieve the entry from the main Animation Table the same way I handle cutscenes, then I check its properties.
- If there's a Direction table, create a new menu using the
AnimDirSelect
function. - If there's a subcategory, create a new menu using the
AnimSubCatSelect
function. - If the animation has multiple parts (loop, end, etc.), run the
AnimStitch
function to generate an array of strings then play them sequentially. - Else, play the animation outright.
Directional variants were simple enough to handle as they were capped at 5 directions, but subcategories could require pages. The issue is, subcategories don't have a formal, structured table attached like regular pages. They're assembled on the fly whenever AnimSubCatSelect
is called which meant using recursion for page navigation would completely wipe the table, and all associated data, each time.
Ultimately, I found an iterative approach to be the best solution here.
The function takes a reference to the specific animation entry as a parameter. When first called, it constructs tables for all possible SelectBox names and animation sequences by iterating over the reference. From there, an infinite loop begins, and local variables declared outside of the loop track which entries get displayed as well as what to retrieve when the player finally makes a choice.
In a few rare instances, an option in a subcategory needs to open yet another menu to display its variations. I was unable to cleanly solve that within the loop, so for those cases I write flags in the main table which direct to a separate sub table with the original animation structure. If the loop encounters these flags, it will perform a recursive call on AnimSubCatSelect
, effectively allowing subcategories to go infinitely deep.