Need opinions on my first attempt at a Dialogue system [Documentation]

Hello DevForum,

I’m happy to share a few of the first documentations about a dialogue system that I will use in one of my projects. It is my first attempt at making something related to dialogues, so feedback is much appreciated at this time around. If it’s anything about timing, sounds or visuals, please suggest ideas or offer constructive criticism. The reason I’m making this post is for the reason that I want to know what to improve and if I am heading towards the right direction with my ideas.

!!! Videos of the dialogue system in action are at the end of the post. !!!

So – keeping that in mind, let’s start.


How did I create the dialogue system?

I went into this idea with a few thoughts in my head. I wanted it to include:

  1. Typewriting styled appearance
  2. Adaptive sounds
  3. Character and personality dependent on who is talking
  4. Click-To-Skip feature
  5. Dialogue bubble with smooth animations and colors

Yes, it is quite a lot to pick out for being my first attempt at making dialogue in a game, but what’s the fun without a little bit of a challenge.
Knowing that I was going to use the actual system in my project, I wanted it to be easily accessible and modified for adding more future dialogue. Hence I started everything with a LocalScript and a ModuleScript. In the LocalScript I will handle the actual message receiving and displaying, and the module will handle everything related to the coding of the message itself, such as: to filter, format, and get the type-writer effect. Yes, this is capable to perform in the LocalScript, but this saves space and offers a better visibility of my scripts.


Point 1. Typewriting styled appearance

I dove in head-first to do research about this, since I have not done in-depth analysis of how to get this text effect. Using the Engine API for UI Animations and an official DevForum post about the Typewriter Effect I was able to get a fair amount of information, enough for me to get myself to start creating one for myself. Using the provided information that was given, soon enough I had a functional effect for my text.
With this step done, I was able to pass a function into the module with the arguments whoIsTalking (the player or name of any NPC, useful for step 3.) and any message in a string.

function SpeechModule:SpeechBubble(whoIsTalking, text)
	
	local function removeTags(str)
		str = str:gsub("<br%s*/>", "\n")
		return (str:gsub("<[^<>]->", ""))
	end
	
	local displayText = removeTags(text)
	local index = 0
	
	--//print(whoIsTalking .. ": " .. displayText)
	
	for first, last in utf8.graphemes(displayText) do 
		
		local grapheme = displayText:sub(first, last) 
		index += 1
		
		textBubble.MaxVisibleGraphemes = index
			
		task.wait(0.05)
				
	end
	
end

Point 2. Adaptive sounds

I did not want my text to show up silent, so I immediately aimed for something like Wii-games (Go Vacation) and Animal Crossing, who play a sound when graphemes appear one at a time.
Since this has to be handled with every letter, this was something I had to adapt into the ModuleScript.

I went to pick a few sounds from the Toolbox and mixed and matched with them, speeding them up or shortening them to see which ones had a balance between short and noticeable but not too much to the point it would get annoying and/or hard to listen to. Eventually I was able to pick a few and saved them in the folders.

Using the sound I picked for the player, I was able to play that sound along with a randomizing volume and playback speed to variate it enough to not get repetitive, I added the following code to the module:

if grapheme ~= " " then
				
	local sound = speechBubble.BubbleSounds.PlayerSound
	local presetVolume = sound.Volume
				
	sound.PlaybackSpeed = math.random(9, 11)/10
	sound.Volume = presetVolume + (math.random(-1, 1)/10)
				
	sound:Play()
	sound.Volume = presetVolume

end

To make sure spaces did not produce any sound (because it is not a character that makes sound when talking,) I made sure to filter it out of the if statement. That completes adding sounds for each letter being displayed.


Point 3. Character and personality dependent on who is talking

Since like in Animal Crossing, dependent on who you talk to, the NPC will have different sounds, making you able to know who you were talking to even if your eyes are closed. It adds more depth to the game, so I thought it was a must. I really wanted to get this accurate and even the smaller details to be thought of. I knew that in order to make a character or personality, every bit of text has to be different.

  • Visually – It had to have it’s own colour.
  • Sound – It had to make a distinct sound to have a different personality.
  • Speed – Even the subtle change of the speed in which letters appear can make a difference.

So, I went ahead and made several different profiles to start with, being: Angry, Confused, Cute and Lazy. Although the following is not fool-proof for the future when different characters start having the same personality (if I even add enough for that to happen,) I will pass the emotion through the whoIsTalking argument. To get the timings right, I tested around and settled on the following:

if			whoIsTalking == "Player"	then LETTER_DELAY = 0.05
elseif		whoIsTalking == "Angry"		then LETTER_DELAY = 0.03
elseif		whoIsTalking == "Confused"	then LETTER_DELAY = 0.06
elseif		whoIsTalking == "Cute"		then LETTER_DELAY = 0.07
elseif		whoIsTalking == "Lazy"		then LETTER_DELAY = 0.08
end

Adding this to the module, it was enough to be settled. Though one thing bothered me, and that is the way punctuation is being skipped. Periods add a break to a sentence; commas add a pause. That was not yet able to be heard or seen as the way it takes the same time to go to the next letter as if the period or comma was any other letter. To solve this problem, all I had to do was I hate to say this add another if statement to override the LETTER_DELAY from the normal letters. I changed them around to what felt most natural to me.

if			grapheme == "." then task.wait(0.5)
elseif		grapheme == "," then task.wait(0.2)
elseif		grapheme == "'" then task.wait(0.1)
elseif		grapheme == "?" or grapheme == "!" then task.wait(0.3)
else		task.wait(LETTER_DELAY)--//If grapheme is a normal letter then wait the given personality's wait time
end

Point 4. Click-To-Skip feature

This point would prove to be a difficult one.
I wanted to make sure the player (because I do not know their age or reading speed) has plenty of time to read the messages as they go. If they are a fast reader, they can click to speed up the message if it’s mid-writing, but if they are a slow reader, that they are given time to read and are able to let the message sit until they are done reading.

To clear up any confusion to players who read fast but expect an auto-skip feature, I want to tween a small message into view if they are waiting too long. This alone came with a few problems.

  • The message length varies. I have to start the tween several seconds after the message ends.
  • If the player skips to the next message before the tween shows up, I have to cancel the wait time.
  • If the player spam-clicks, the tween should not load in-between messages.
  • Do not make it clash with the feature that speeds up the message. (foreshadowing)

Making the text speed up after clicking was simple enough. All I had to do was get the UserInputService of the player and wait for a mouse click. When clicked, it will tell the ModuleScript to override the local value of skipDialogue to true, meaning the LETTER_DELAY will be set to a much shorter amount, and to make it easier on the ears, make the playback speed of the sound faster as well to give the impression of hurried speech. These are added like such:

local skipDialogue = false

ReplicatedStorage.PlayerClicked.Event:Connect(function()
	skipDialogue = true
end)

...

if not skipDialogue then
				
	--//Use normal sound randomizer

else
			
	local fastSound = speechBubble.BubbleSounds[whoIsTalking]
	fastSound.PlaybackSpeed = 1.2
				
	fastSound:Play()
				
end

...

if not skipDialogue then

	--//Use normal wait time

else
			
	task.wait(0.05 / 10)--//This will be used if skipDialogue is true
			
end

Next up was the tweening of the ‘Click to Skip’ notification message. Facing the issues this brought one by one, it took some painstakingly long effort to make this function properly. Unexplainable (to me) occurrences where cancelling the tween and still having it play during the next message were blood-boiling, but I was able to fix the faulty code bit by bit. Since the module won’t return a value until the full message is displayed, I was able to use that to mark the end of the message to start the wait for the notification and the click input from the dialogue skipping. Yet in order to not confuse the mouse click for a click in the middle of the message or one for the next message, I added two local variables to state whenever the dialogue is finished or still playing. I won’t bother you with too much other details, but the most crucial part came down to this:

local clickForNextMessage = TweenService:Create(speechBubble.TextFrame.ClickForNextMessage, tweenInfoNextMessage, {TextTransparency = 0, MaxVisibleGraphemes = 29})
	
local thread = task.delay(2, function()
	--//This will show the message after two seconds of no click.
	clickForNextMessage:Play()	
end)
	
coroutine.wrap(function()
		
	repeat --//Check every 0.1 seconds after the message finished displaying if the player clicked
		task.wait(0.1)
	until playerClicked == true
		
	task.cancel(thread) --//If clicked, then cancel the :Play() thread
		
end)()
	
speechPlaying = false
speechFinished = true
	
while not playerClicked do
	task.wait(0.05)
end
	
clickForNextMessage:Cancel()

Point 5. Dialogue bubble with smooth animations and colors

The last thing I wanted to add is changes to the actual dialogue box to make it visually appealing. I already had some ideas in mind:

  • The dialogue box bouncing up from the bottom of the screen.
  • A neutral-coloured dialogue box, which will tween into a different color depending on who is talking. If the player talks, the dialogue box tweens with an animation into green (from right to left) and when the other person talks, it tweens with an animation into a different colour (from left to right).
  • The dialogue box bouncing back down and out of the screen.
  • (Optional) A viewframe of the player and NPC who they are talking to, perhaps to show more emotions and make it more visually appealing.

For the bouncing of the text box on and off the screen was all done with a tween of the position with the ‘Back’ EasingStyle. The harder part occurs for the colour changes during conversation. I had known that a ColorGradient was most suitable for this task, it was just the question of how I would tween the color. I thought I was not able to change the color in a gradient by code, so I made my system rely on separate gradient instances for every character in the game. Then with the events being fired, I add a check for who the player is talking to during that conversation. It then picks the right gradient named to the NPC and use that for the duration of the conversation. I can revert this and change it to a single gradient with a ColorSequenceKeypoint.new() function in the future.
Then with the use of tweening, I can tween the offset from the player colour to the NPC for this result:

GIF_DialogueShowcase.gif [crop output image]


Now you might think that this is great and all, but;

How does the end result look like?

Allow me to show you.
All I do is input this line of code in a script:

proxPrompt.Triggered:Connect(function(player)
	
	ReplicatedStorage.SendMessage:FireClient(player, --//Whose client to fire the dialogue to
	whoIsTalking: "Player", --//Who is going to be saying the message
	message: "I'm the player talking! I wonder how many braincells you have...", --//What they will say
	amount: 2, --//How many messages will be in the chain, used for the tweening
	talkingTo: "Cute" --//The personality of the person you will talk to
	)
	
	ReplicatedStorage.NextMessage.OnServerEvent:Wait()
--[[
When the message fires from client to server, the message will pass through a
module which will only return a value once the single message has been completed.
That value once returned will send a signal to the server to fulfill the :Wait()
function and continue with the next message. This is done for the reason that when
there are multiple messages, they don't all send at once.
]]
	
	ReplicatedStorage.SendMessage:FireClient(player,
	whoIsTalking: "Cute",
	message: "I'm an unimportant NPC with an underdeveloped brain.\n3 braincells maximum."
	)

end)

This conversation will play as following:

Note: The first conversation I let it play without clicking in the middle of the message.
The second conversation I click while the message is playing, thus speeding it up accordingly.


Here I show all the colours and sounds for the different emotions I have made:


That concludes all the documentation of the dialogue system thus far.

I of course did not include every minor detail, since this is already quite the sum of documentation, but the reason why I made the post in the way that I did is because I would like to know in-depth and constructive criticism on where improvement is possible. Whether it is about the coding, visuals, UI, sounds, anything I need to add that would make the dialogue system a better version of what it is now.
If you read everything this far, I appreciate the time and hope to answer your questions if you have any. I’ll be more than happy to go further into detail to explain how I made everything, all you’d have to do is ask.

15 Likes