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 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:
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 Wait
s 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!
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.
Enjoyed this article? Share or link to it, to support the site and help fellow developers.