Skip to content

Instantly share code, notes, and snippets.

@codebreadGist
Last active March 2, 2022 23:45
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save codebreadGist/8f12f90664f8a66db37b6f0f3f690f1e to your computer and use it in GitHub Desktop.
Save codebreadGist/8f12f90664f8a66db37b6f0f3f690f1e to your computer and use it in GitHub Desktop.
Scrolling, word wrapping text with various colors and font styles (Monogame)
Monogame/XNA doesn't allow you to alter the style of a string in the middle of drawing it, so instead you have to draw each
individual piece of a string if you want to bold or italicize a word. This involves knowing how long each bit of string is
and calling DrawString() for each one, all while making sure they line up correctly. Add in the ability to word wrap and
scroll the dialogue one letter at a time and it becomes a pretty painful problem to solve.
To start, I wrote a function that can take a dialogue box width and a string with special formatting strings embedded in it.
The string used for the text above is this:
"I can't {red}shake {blue}the {green}feeling {normal}that {italics}something {normal}belongs here...
Something extraordinarily {bold}ordinary."
No line breaks or anything like that, just the font styles wrapped in curly braces.
Step by step, the function works as follows:
1. Split the string into subsections on '{' to get the subsections
string[] sections = text.Split('{');
2. For each section, split on '}' to get the subsection and font style as a 2 element array. Add these to a List<string[]>
subsections
for (int i = 0; i < sections.Length; i++)
{
subsections.Add(sections[i].Split('}'));
if (subsections[i].Length == 2 && subsections[i][1] == "") // Remove any element that only has a style and no text
subsections.RemoveAt(i);
else if (subsections[i].Length > 1) //I want the text to be the first element, and the style second
Array.Reverse(subsections[i]);
}
Now I have a List that looks like this:
{
["I can't"]
["shake", "red"]
["the", "green"]
...
["something", "italics"]
...
}
3. Loop through each subsection and change your font/color based on the second element in each array. I used a switch
statement here, catching the "italics", "bold", and "normal" specifically, and then casting anything else to a Color.
Of course, you only want to do this if the array has a second element (you'll notice above my first element doesn't because
I didn't give it a formatting string)
for (int i = 0; i < subsections.Count; i++)
{
//Set the next font
if (subsections[i].Length > 1)
{
switch (subsections[i][1])
{
case "bold":
currentColor = Color.Black;
currentFont = bold != null ? bold : dialogueFontBold;
break;
case "italics":
currentFont = italics != null ? italics : dialogueFontItalic;
currentColor = Color.Black;
break;
case "normal":
currentFont = dialogueFont;
currentColor = Color.Black;
break;
default:
currentFont = dialogueFont;
System.Drawing.Color tempColor = System.Drawing.Color.FromName(subsections[i][1]);
currentColor = new Color(tempColor.R, tempColor.G, tempColor.B);
break;
}
}
...
...
}
4. In the same loop, I split the first element (the dialogue) on every space, creating an array of words. I then measure
the length of a single space character using the currentFont that I set above and loop through the array of words that I
just created. For each word I measure it and add it to a StringBuilder. As I go I keep track of the total "lineWidth" and
do some different things based on how big that number gets. I also keep track of the total length of the entire dialogue
string, "stringNum", so I can use it for scrolling dialogue later.
for (int i = 0; i < subsections.Count; i++)
{
//Set the next font
...
...
StringBuilder sb = new StringBuilder();
List<string> words = subsections[i][0].Split(' ').ToList();
float spaceWidth = currentFont.MeasureString(" ").X;
lineWidth = 0;
for (int j = 0; j < words.Count; j++)
{
if (words[j] != "")
{
Vector2 size = currentFont.MeasureString(words[j]);
if (words[j].Contains("\n"))
{
lineWidth = 0;
}
if (lineWidth + size.X < maxLineWidth)
{
sb.Append(words[j] + " ");
lineWidth += size.X + spaceWidth;
}
else
{
words[j] = "\n" + words[j];
subsections.Insert(i + 1, new string[] {
SplitList(words, j), subsections[i].Length > 1 ? subsections[i][1] : "normal" });
subsections[i][0] = String.Join(" ", words);
break;
}
}
}
stringNum += sb.Length;
}
That last part in the "else" statement is probably confusing, because it was confusing to me when I wrote it. I'll break
it down for you. If the current subsection we're looping through looks like this:
["how are you doing", "bold"] , then the word array looks like this:
["how", "are", "you", "doing"]
If the word "you" ends up going beyond the bounds of the dialogue box, it will end up in that else block with a line
break added.
["how", "are", "\nyou", "doing"]
Then I call a function that I wrote called SplitString, which takes in a List<string> and the index to split it on. It
will remove all of the elements at the index and after the index passed in, and return the removed elements as a new
concatenated String. I then insert that new string into the "subsections" List at the next index, and I use the current
index's font style for it. In other words we go from this:
["hello", "normal"]
["how are you doing", "bold"]
["today?", "normal"]
to:
["hello", "normal"]
["how are", "bold"]
["\nyou doing", "bold"]
["today?", "normal"]
and then the "words" loop breaks and keeps going, with that new addition next in line. It catches on the first "if"
statement and resets the lineWidth, and all is good in the world.
5. Now, once ALL of that is done I end up with the "subsections" List broken up based on style and also new lines so
word wrap will work. At this point I save the "subsections" List and move onto the Draw Loop.
scrollTextSections = subsections; //scrollTextSections is a List<string[]>defined at class level.
At this point I should mention that you could easily draw the dialogue now if you didn't want scrolling text. At the end of
each iteration of the "subsection" loop, after the "words" loop, you could call DrawString() and use the default
position + linewidth, currentFont, and currentColor to get the desired, non-scrolling effect. In order to get the vertical
positioning correct you would have to keep track of the amount of times you had a new line break and multiply that by the
height of a newline character for the currentFont you're using. For a more in-depth look at that, keep reading.
6.The scrolling is the most difficult part, as I have to keep track not only of where in the dialogue the "scroll index" is,
but I have to know where it is relative to each subsection as well.
DISCLAIMER: This code sucks a lot, and a lot of it is just redoing what I did above a second time in order to get the
correct font, color, linewidth and new lines added. I can't do it before getting my scrollTextSections list though, because
I can't be making edits to the "subsections" list as I scroll through the dialogue.
6.1. To start, I set some base stuff:
SpriteFont currentFont = dialogueFont;
Color currentColor = Color.Black;
float lineHeight = currentFont.MeasureString("\n").Y;
int numberOfNewLines = 0;
Vector2 posOffset = new Vector2(0, 0);
float lineWidth = 0f;
currentFullStringIndex = 0; //int defined at class level
scrollDialogue = ""; //String defined at class level
6.2. Loop through scrollTextSections and get the currentFont and currentColor, same as Step 3 above.
6.3. In the same loop, set some local variables and split the current iteration's string into a "words" array. Then loop
through the words and set the line width as you go, while appending to a StringBuilder. This is similar to Step 4 above,
but reduced a bit:
StringBuilder sb = new StringBuilder();
List<string> words = scrollTextSections[i][0].Split(' ').ToList();
float spaceWidth = currentFont.MeasureString(" ").X;
for (int j = 0; j < words.Count; j++)
{
if (words[j] != "")
{
Vector2 size = currentFont.MeasureString(words[j]);
if (words[j].Contains("\n"))
{
lineWidth = 0;
numberOfNewLines++;
posOffset.X = 0;
}
if (lineWidth + size.X < maxLineWidth || size.X > maxLineWidth)
{
sb.Append(words[j] + " ");
lineWidth += size.X + spaceWidth;
}
}
}
It's a lot cleaner now because of the "\n" new line breaks I added in Step 4 above. This means that lineWidth should NEVER
go beyond maxLineWidth. You'll notice that I'm also keeping track of the "numberOfNewLines" added, as well as resetting my
"posOffset.X" back to 0 when this happens. Both of these are used to draw the subsections in their correct spot on the
screen, relative to the other subsections.
6.4. Still in the "scrollTextSections" loop, I create a String that holds the current subsections dialogue, just for
readability:
String dialogue = scrollTextSections[i][0];
6. 5. Scroll the dialogue!
if (scrollDialogueNum < stringNum && scrollDialogueNum >= currentFullStringIndex &&
scrollDialogueNum - currentFullStringIndex < dialogue.Length)
{
scrollDialogue = dialogue.Substring(0, scrollDialogueNum - currentFullStringIndex);
scrollDialogueNum++;
s.DrawString(currentFont, scrollDialogue, pos + posOffset, currentColor, 0, Vector2.Zero, 1f, SpriteEffects.None, 0);
posOffset.X = lineWidth;
posOffset.Y = lineHeight / 2 * numberOfNewLines;
}
else if (scrollDialogueNum >= currentFullStringIndex + dialogue.Length)
{
s.DrawString(currentFont, dialogue, pos + posOffset, currentColor, 0, Vector2.Zero, 1f, SpriteEffects.None, 0);
posOffset.X = lineWidth;
posOffset.Y = lineHeight / 2 * numberOfNewLines;
}
currentFullStringIndex += sb.Length;
Yowza. So, to break this down...
scrollDialogueNum: The current character index we're on, relative to the FULL dialogue string
stringNum: The full dialogue string's length, in characters
currentFullStringIndex: This keeps track of where we are in our Draw Loop. If we have 6 elements in "scrollTextSections",
that means we're calling DrawString 6 times, one for each element. At the end of each of those iterations I add the
length of the current element's WORDS to "currentFullStringIndex". Notice I say words instead of string. I don't want to
add the length of any "\n", which is why I use the StringBuilder's length. (StringBuilder's ignore linebreaks for their
length variable)
scrollDialogue: The string we're drawing based on how far the text as scrolled so far
Alright, so the first "if" statement is making a few checks. The first is to make sure we haven't reached the end of the
scrolling yet. The second,
scrollDialogueNum >= currentFullStringIndex
is to make sure that I only enter the "if" block when we're supposed to be drawing the next letter. Remember, this will
get hit 6 times per Draw Loop if I'm drawing 6 subsections, so I only want to increment scrollDialogueNum if we're drawing
the word that the text is currently scrolling through. The last check,
scrollDialogueNum - currentFullStringIndex < dialogue.Length
is making sure I stay within the bounds of each subsection when I draw. If scrollDialogueNum is at 30, but I'm drawing
the first subsection (meaning currentFullStringIndex is 0), that means I'm going to try drawing dialogue[30]. If the first
subsection's dialogue is, "I can't", as it is in the gif above, this would throw an exception.
If all of these things pass, we move inside:
scrollDialogue = dialogue.Substring(0, scrollDialogueNum - currentFullStringIndex);
scrollDialogueNum++;
s.DrawString(currentFont, scrollDialogue, pos + posOffset, currentColor, 0, Vector2.Zero, 1f, SpriteEffects.None, 0);
posOffset.X = lineWidth;
posOffset.Y = lineHeight / 2 * numberOfNewLines;
I set scrollDialogue to be equal to the current subsection's dialogue, but only up until the current scrolling letter.
To better understand this, imagine we have this string and each word is a subsection: "Super Daryl Deluxe" If
"scrollDialogue = 15" and we're currently drawing the third subsection (so currentFullStringIndex = 12, the length
of the first and second subsections combined), that means I'm on the letter 'U' in "Deluxe", because I want to draw
the 15th character. But since this subsection is only "Deluxe", 15 is out of bounds. Subtract the length of the previous
two subsections (scrollDialogueNum - currentFullStringIndex) and you get 3, which is the index of 'U' in "Deluxe".
So now we have the correct substring of the word we're trying to draw. I draw it using "pos", which is the position i pass
into the function, and then I add the posOffset to it. Afterward I set the "posOffset" variable to be the current lineWidth
(size of the current subsection) for X and the amount of new lines for Y. I cut the lineHeight in half because if you
measure just a "\n", as I did, it gives you the height of two lines.
Lastly, to break down the second part of 6.5 above:
else if (scrollDialogueNum >= currentFullStringIndex + dialogue.Length)
{
s.DrawString(currentFont, dialogue, pos + posOffset, currentColor, 0, Vector2.Zero, 1f, SpriteEffects.None, 0);
posOffset.X = lineWidth;
posOffset.Y = lineHeight / 2 * numberOfNewLines;
}
currentFullStringIndex += sb.Length;
The check is saying: okay you failed the "if" statement above, which means maybe you're done scrolling, or maybe you're
trying to draw a word you've already scrolled through. If either of those is the case, draw the entire word. Finally,
increment the "currentFullStringIndex " by the length of the current StringBuilder.
-----------------------------
Alright, that's everything. I'll admit this code is pretty messy and there's probably a better way to get the same results,
but I have seen a lot of people asking about word wrap, or scrolling dialogue, or changing font styles mid-string with very
few solutions posted, and I've never seen them combined as one. Hopefully this helps someone out.
Credits:
- For the word wrapping part I had found a post a year or so ago that helped me: http://stackoverflow.com/a/15987581
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment