RPG Player Types.

The book "How to Make an RPG" shows you step-by-step how to make three fully featured RPGs. In this article we'll add a little polish the dialog code that's used in the cutscenes for the RPGs. All changes are made using the codebase for the final RPG: Cave Quest.

This the first in what will hopefully be a series of articles that add the kind of polish that would impractical to cover in the book.

Cave Quest contains numerous cutscenes where characters talk to each other using dialog boxes. The player can dismiss the dialog boxes while the cutscene plays. This is fine. The player should be able to skip through dialog but there's a problem; skipping dialog doesn't work very well.

If you dismiss a dialog box you still have to wait the same amount of time as if you hadn't dismissed it all!

You can see the issue here:

The dialog issue.

The player dismisses the dialogs by pressing Space but it doesn't matter; both cutscenes end at the same time. This is frustrating and potentially confusing for the player. Therefore it needs to be polished away!

It should work like this:

The dialog issue.

Here the player dismisses the dialogs and proceeds through the cutscene more quickly.

That's the entire fix.

Want to know the gritty details of how this fix went in? Then read on.

State of Play

First we need to find some code exhibiting this behavior; luckily that's not hard to do! The opening cutscene for the Cave Quest RPG starts with the major half-way through an explanation about what the player should do. The major says a number of lines and skipping them can result in dead-space in the cutscene where nothing is happening.

Here's a snippet of the cutscene definition:

local intro =
{
    -- first part of the cutscene omitted
    -- we only care about the talking!
    SOP.Say("handin", "major", "So, in conclusion...", 2.3, sayDef),

Cutscenes are handled with a data structure called a Storyboard. A Storyboard is a list of events that run one after another until there are no more events to play and the cutscene ends. An operation called Say is used to make NPCs speak.

The Say operation in this snippet has four parameters:

  • mapId - The map where this cutscene is taking place (handin refers to the current map).
  • npcId - The NPC saying the lines.
  • text - The lines of text to say.
  • time - The time to display the dialog box.

In the code snippet the Say operation is set to display the dialog box for 2.3 seconds.

If the player cancels the dialog box in the first 2.3 seconds, it will disappear but it doesn't really matter as we won't advance to the next operation until the full 2.3 seconds have passed!

It appears we need to edit the Say operation to cancel the timer after the dialog box is dismissed.

The Say Operation

All cutscene operations and events live in a file called StoryboardEvents.lua. Here's the source code for the Say operation:

-- In StoryboardEvents.lua
function SOP.Say(mapId, npcId, text, time, params)

    time = time or 1
    params = params or {textScale = 0.8}

    return function(storyboard)
        local map = GetMapRef(storyboard, mapId)
        local npc = map.mNPCbyId[npcId]
        if npcId == "hero" then
           npc = storyboard.mStates[mapId].mHero
        end
        local pos = npc.mEntity.mSprite:GetPosition()
        storyboard.mStack:PushFit(
            gRenderer,
            -map.mCamX + pos:X(), -map.mCamY + pos:Y() + 32,
            text, -1, params)
        local box = storyboard.mStack:Top()
        return TimedTextboxEvent:Create(box, time)
    end
end

The only line of this code that matters is the final line where the TimedTextboxEvent object is created and returned. This event has the time-to-display information passed through to it; so that's where we we need to make code changes.

The TimedTextboxEvent

Here's the source for the TimedTextboxEvent.

-- In StoryboardEvents.lua
TimedTextboxEvent = {}
TimedTextboxEvent.__index = TimedTextboxEvent
function TimedTextboxEvent:Create(box, time)
    local this =
    {
        mTextbox = box,
        mCountDown = time
    }
    setmetatable(this, self)
    return this
end

function TimedTextboxEvent:Update(dt, storyboard)
    self.mCountDown = self.mCountDown - dt
    if self.mCountDown <= 0 then
        self.mTextbox:OnClick()
    end
end
function TimedTextboxEvent:Render() end

function TimedTextboxEvent:IsBlocking()
    return self.mCountDown > 0
end

function TimedTextboxEvent:IsFinished()
    return not self:IsBlocking()
end

The Storyboard skips over events if their IsFinished function returns true or if their IsBlocking function returns false. Textboxes should generally stop a storyboard advancing so we'll leave IsBlocking as is for now.

Let's modify the IsFinished function to only return true if the box has been fully dismissed. The textbox object has a function IsDead that returns true when it's been fully dismissed.

Here's the modified IsFinished function.

function TimedTextboxEvent:IsFinished()
    return self.mTextbox:IsDead()
end

And that's almost it. If you dismiss a dialog, the cutscene will skip to the next event. But there's one minor problem remaining. The TimedTextboxEvent will return false for IsBlocking as soon as the the textbox begins to be dismissed. Instead it should wait until the textbox has finished being dismissed.

We'll fix this by modifying the IsBlocking call.

function TimedTextboxEvent:IsBlocking()
    return not self.mTextbox:IsDead()
end

This means the TimedTextboxEvent will block the cutscene until the dialogbox totally finishes it's dismiss transition.

Now cutscene dialog flows a lot better!

Tweaking Storyboards

In the book my cutscenes aren't as neat as they could be. The storyboard table for the opening Cave Quest dialog looks like this:

local intro =
{
    -- code omitted

    SOP.Say("handin", "major", "So, in conclusion...", 2.3,sayDef),
    SOP.Wait(0.75),
    SOP.Say("handin", "major", "Head north to the mine.", 2.3,sayDef),
    SOP.Wait(2),
    SOP.Say("handin", "major", "Find the skull ruby.", 2.3,sayDef),
    SOP.Wait(2),
    SOP.Say("handin", "major", "Bring it back here to me.", 2.5, sayDef),
    SOP.Wait(1.75),

Look at all those Waits between the Say operations! Even if the player dismisses the textbox the next instruction is a Wait anyway! They player isn't going to be able to skip through the conversation as fast they'd like.

This is pretty easy to fix. Instead of having the Wait operations we just add the wait time to the previous Say operation's time. Here's the modified code demonstrating the fix:

local intro =
{
    -- code omitted
    SOP.Say("handin", "major", "So, in conclusion...", 3.05, sayDef),
    SOP.Say("handin", "major", "Head north to the mine.", 4.3,sayDef),
    SOP.Say("handin", "major", "Find the skull ruby.", 4.3,sayDef),
    SOP.Say("handin", "major", "Bring it back here to me.", 4.25, sayDef),
    SOP.Say("handin", "major", "Then I'll give you the second half of your fee.", 5.25, sayDef),
    SOP.Say("handin", "major", "Do we have an agreement?", 4.0, sayDef),
}

With the Wait operations removed we can skip through this dialog as fast as we like. Far more user friendly!

The fixed cutscene.

Further Work

Ideally dialog box display-time should be calculated by the computer. A rough approximation would be word_count * some_fraction_of_a_second. Then as the developer we wouldn't have to manually fill in all these times.

But I am not going to add code to do this. My aim for the polish is to make the game nice for the end user, not the game creator. I may come back to automatic-wait-times in a future article!

Source

There's a github with an updated project here.