Hey everyone. Today I'm going to walk you through the data structure for a simple cinematic system I built for my game this last week. Just a little disclaimer, this will be covering the data portion of the system, I won't be showing you how to execute it since that would be pretty specific to your game. That being said, if I get enough views I'll do a second part where I explain my execution implementation.
General Design and Flow
In my game there can be cinematics before the player goes on a mission and after they complete it. This is probably simpler than most role-playing games but it is enough to convey a plot which is all I need it to do. It would also be extremely easy to start this type of cinematic at any point in a game like in a traditional JRPG. The cinematics are linear (with ability check branches that lead back to the main track) conversations between actors, set on backdrops. Actors take turns to say their lines. This goes on until the cinematic is complete and the player is taken to a mission or back to the map.
Data Structure
I use several Scriptable Object types to hold all needed data for a cinematic. The ones I'll cover are: "CinematicConfig", "CinematicSceneConfig", "CinematicActionConfig", "CinematicActorConfig" and "DialogueCinematicActionConfig" as an example action. Here they are as code:
public class CinematicConfig : ScriptableObject
{
[SerializeField]
private List<CinematicSceneConfig> cinematicScenesBefore;
[SerializeField]
private List<CinematicSceneConfig> cinematicScenesAfter;
}
"CinematicConfig" is our entry point, this is the config you add as reference in your mission as it contains references to all other Cinematic data objects. As I mentioned, in my game there can only be before and after mission cinematics. Replacing the two lists with just one would allow you to use this at any point in your game, granted you have the right execution setup.
public class CinematicSceneConfig : ScriptableObject
{
[SerializeField]
private string musicFileName;
[SerializeField]
private string dwellingName;
[SerializeField]
private AreaType areaType;
[SerializeField]
private DwellingType dwellingType;
[SerializeField]
private List<CinematicActionConfig> cinematicActions;
}
The cinematic scene is the middle manager of this system, it has information about the backdrop, the sound track and a list of actions. To generate a backdrop location I set an area (tile biome) and dwelling (mission location). The backdrop is another system I reuse in different parts of the game which is made up of layered 2D images corresponding to areas and dwellings (the sets) that create a coherent final image. I set location values manually because I want to allow cinematics in areas/dwellings different from the mission area/dwelling. The name is mostly there for visual purposes. If I wanted to set the cinematic in the location of the mission I would copy the location settings of the mission.
public class CinematicActionConfig : ScriptableObject
{
[SerializeField]
protected float delay;
[SerializeField]
protected bool canContinueBeforeDelay;
public virtual CinematicAction GetAction() => null;
}
public abstract class CinematicAction : IExecutable
{
public float Delay { get; protected set; }
public bool CanContinueBeforeDelay { get; protected set; }
public event Action OnComplete;
protected CinematicChatPrefab cinematicChatPrefab;
public void Execute(object args = null)
{
cinematicChatPrefab = (CinematicChatPrefab)args;
ExecuteAction(InvokeOnComplete);
}
protected abstract void ExecuteAction(Action onComplete);
protected void InvokeOnComplete() => OnComplete?.Invoke();
}
And this is the workhorse of the whole system - the fully customisable actions. The classes above are meant to be derived from to create all the specific types of action you want to be executed during the cinematic. For example these can be: "DialogueActionConfig", "BranchingDialogueActionConfig", "ScreenShakeActionConfig", "PlaySoundActionConfig", "GiveGoldActionConfig" and anything you can think of really. As you can see I use an abstract class for the actual executable code. This keeps everything nice and separate and it means that all "CinematicAction" classes are responsible for their own logic.
public class CinematicActorConfig : ScriptableObject
{
[SerializeField]
private string actorName;
[SerializeField]
private string imageFileName;
}
This one is significantly less interesting than the action config but it is important for the next config I have to show. This can be expanded, or if you have your own generic NPC configs you can use those in its place.
Example Action
public class DialogueCinematicActionConfig : CinematicActionConfig
{
[SerializeField]
private bool willWaitForPlayerInput = true;
[SerializeField]
private CinematicActorConfig cinematicActor;
[SerializeField]
private string dialogue;
[SerializeField, Range(0.01f, 0.3f)]
private float dialogueCharacterDuration = 0.05f;
public override CinematicAction GetAction() => new DialogueCinematicAction(delay, canContinueBeforeDelay, willWaitForPlayerInput, cinematicActor, dialogue, dialogueCharacterDuration);
}
public class DialogueCinematicAction : CinematicAction
{
public float Duration => Dialogue.Length * DialogueCharacterDuration;
public bool WillWaitForPlayerInput { get; protected set; }
public CinematicActorConfig CinematicActor { get; protected set; }
public string Dialogue { get; protected set; }
public float DialogueCharacterDuration { get; protected set; }
public DialogueCinematicAction(
float delay,
bool canContinueBeforeDelay,
bool willWaitForPlayerInput,
CinematicActor cinematicActor,
string dialogue,
float dialogueCharacterDuration) : base(delay, canContinueBeforeDelay)
{
WillWaitForPlayerInput = willWaitForPlayerInput;
CinematicActor = cinematicActor;
Dialogue = dialogue;
DialogueCharacterDuration = dialogueCharacterDuration;
}
protected override void ExecuteAction(Action onComplete){}
}
As you can see these are derived from the action and action config classes. The above is very close to what my dialogue action looks like. As you can see I'm using the actor for their name and the character icon (loaded at runtime), a dialogue string which is the text the character says as well as a duration for each character (as in letter or number etc.) in the dialogue. The character duration is there to get the text to appear one character at a time. There is a flag to allow the player to skip the text animation.
This is just one paragraph of text in a cinematic, if you want the same character to say things twice in a row you would have two "DialogueCinematicActionConfig" in the "CinematicSceneConfig"'s actions list. If there is no actor referenced the game loads the player name/icon.
Closing Words
As I said this is a very simple data structure for a cinematic system that you can write in a couple hours. It can be very easily expanded so feel free to try it out.
Just to give you an example. I have a generic trigger event system that can do a bunch of stuff like give gold, experience, items, generate new missions, remove missions and a bunch more that is driven by configs. I exposed the event resolution code and created a cinematic action config to use it. Now I can do everything the event system can during a dialogue and it is completely codeless!
Needless to say, I'm very happy with the result and hope you find the above useful too! See you next week for more ramblings!
Comments