Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save cyrusfirheir/b7b75ba3ba13caecbcb0f43f3ec30c2e to your computer and use it in GitHub Desktop.
Save cyrusfirheir/b7b75ba3ba13caecbcb0f43f3ec30c2e to your computer and use it in GitHub Desktop.
Storing dialogue in plain text and converting it to html elements on the fly using JavaScript.

Storing dialogue in plain text and converting it to html elements on the fly using JavaScript. (Part 1)

Note!
This system is not a replacement for standard usage, and has been developed only to facilitate faster/easier markup of story/dialogue data. In no way is this advanced enough to handle choices and a bajillion conditionals. At least not yet.

This sort of an approach is best suited when one needs to display text sequentially (like in a Visual Novel) without focusing much on writing html or macros. I decided to do something like this because typing:

<<speech "Speaker Name">>
	Speech text
<</speech>>

Got tiring pretty fast, given that the game is heavily story driven. It felt like I was spending more time on writing those tags and even when it became easy with snippets and keyboard shortcuts, the text was a pain to read and edit. (The woes of wanting fancy things...)

On top of that, I wanted to allow mods for my game. It's something I've planned from the very start. And if I was to let others write their own story data, such a convoluted way of writing isn't exactly user friendly.

Wanting to mimic the Visual Novel style of story progression with dialogues and monologues being shown one at a time, I knew I had to have some sort of an array from which to load individual 'speech' objects.

Which also meant I needed to get my text into an array first.

So I found myself an empty corner in class and spent my fifteen minute break scribbling out how I'd like my story data to look like.


Syntax

Having used programs like GMS2 and Unity, I've faced this problem quite a few times. And every single solution has something to do with parsing human readable text into something which the engine can work with. String functions are pretty powerful in almost every programming language, and setting up simple logic isn't very difficult.

Here's the 'syntax' I came up with:

  • Lines which declare the speaker start with > (Could've been any other sigil, and I'm leaning towards ~ at the moment because the > symbol might act up with regular html or tags. It shouldn't, but you never know.)

1		> Speaker One
  • The text to be displayed as the speaker speaks, or the 'speech', should begin from the line immediately after the declaration of the speaker. (After exactly one newline \n character.)

1		> Speaker One
2		I'm speaker one and this is what I'm saying.
  • For multiple paragraphs, type each paragraph in its own line. (Separated by one newline \n character.)

  • If the speaker changes, or no speaker is assigned (usually used for player character's internal monologues) the text starts after two lines. (After at least two newline \n characters.)

1		> Speaker One
2		I'm speaker one and this is what I'm saying.
3
4		> Speaker Two
5		And I'm speaker two. I speak more than the one before me.
6		I can even speak two different paragraphs one after the other.
7		As long as they are separated by a newline.
8
9		And this is what speaker-less text looks like.
10		> Different paragraphs of this is possible too.

Loading the data

For the storyline to be 'loaded' there needs to be something to load it into. For this case, I used an array called stack and shall be referring to it as such following now...

We now have a passage (could be external files too, if loading from a server) which stores the data, formatted as explained before:

:: prologue_00

> $pc.name
Why do you summon me, and then disappear yourself? Every single time… Real mature, father, real mature.

Shaking my head, I float through the hallways, aiming to get to the greenhouse before he vanishes from there too.
I understand he's busy, that he needs to work like he does, I really do. But I really dislike playing chase with him.
Especially when his messages are cryptic and force me to rush to him.

...

And the actual magic happens with the following JavaScript. (I probably should use the setup object instead of the window, but I'm lazy like that.)

//Takes in a string (the name of the passage) to load. Can be tweaked to allow for any plaintext source too. Currently using passages so that the game can be completely offline and there's no need to set up a server.
window.loadStory = function(p) {
  let raw = Story.get(p).text.trim().split(/(?:\r?\n){2,}/);

  for (let i = 0; i < raw.length; i++) {
    let text = raw[i].split(/\r?\n/);

    let speaker = "";

    if (text[0].startsWith(">")) {
      speaker = text[0].substring(1).trim();
      text.splice(0,1);
    }

    for (let j = 0; j < text.length; j++) {
      let obj = {speaker: speaker, text: text[j]};
      State.variables.stack = [...State.variables.stack, obj];
    }
  }
  State.variables.stack = [...State.variables.stack, {speaker: "", text: ""}];
}

Breaking it down:

let raw = Story.get(p).text.trim().split(/(?:\r?\n){2,}/);

This loads the raw text of the passage, trims the ends of whitespace, and splits it based on double carriage returns, returning a string array as such:

[
	"> $pc.name↵Why do you summon me, and then disappe…ry single time… Real mature, father, real mature.",
	"Shaking my head, I float through the hallways, aim…messages are cryptic and force me to rush to him.",
	"..."
]

Then, the following for loop iterates through the raw array

for (let i = 0; i < raw.length; i++) {...}

At each iteration, text of the current entry is split again, based on a single newline this time.

let text = raw[i].split(/\r?\n/);

text now has:

[
	"> $pc.name",
	"Why do you summon me, and then disappear yourself?…ry single time… Real mature, father, real mature."
]

Next, we set the speaker to a blank string as a default if no speaker declaration is found.

let speaker = "";

Now to check if a speaker has been declared using the > sigil.

if (text[0].startsWith(">")) {
	speaker = text[0].substring(1).trim();
	text.splice(0,1);
}

What this does is, if > is found in the first entry of the array, then the speaker is set to whatever that's been declared (plus it deletes the > sigil while it's doing so.)

In the end, the last for-loop passes each paragraph of text/speech separately (with the speaker attached to it,) to the stack as an object containing the speaker, and the text:

for (let j = 0; j < text.length; j++) {
	let obj = {speaker: speaker, text: text[j]};
	State.variables.stack = [...State.variables.stack, obj];
}

Note!
The last line uses ES6 spread syntax to push the objects into the array. If you want to support Internet Explorer or Edge, you should change it to a regular Array.prototype.push()

And the stack looks like this:

[
	{speaker: "$pc.name", text: "Why do you summon me, and then disappear yourself?…ry single time… Real mature, father, real mature."},
	{speaker: "", text: "Shaking my head, I float through the hallways, aim…the greenhouse before he vanishes from there too."},
	{speaker: "", text: "I understand he's busy, that he needs to work like… do. But I really dislike playing chase with him."},
	{speaker: "", text: "Especially when his messages are cryptic and force me to rush to him."}
]

Displaying the data

This is explained in more detail in part 2, but here's an overview.

As written before, I use a function speech(speechText [, speakerName]) (used as a macro as well) to return html containing the speaker and text in a speech box styled element. Somewhat like this:

<div class="speech">
	<fieldset>
		<legend>Daren</legend>
		<span class="speech-bubble">
			Why do you summon me, and then disappear yourself? Every single time… Real mature, father, real mature.
		</span>
	</fieldset>
</div>

The passage has a speech-container div which is positioned at the bottom of the page and it's sole purpose is to hold the 'speech' div. And getting newer speech into the container is done by the following script:

//prints text onto screen from stack
window.advanceStory = function() {
  if (State.variables.stack.length === 0) { return; /*dialogue tree end events not currently implemented, but they'd probably go in here*/ }
  let load = State.variables.stack[0];

  //replaces the current bubble
  $.wiki(`<<nobr>><<replace ".speech-container">>
            <<speech "${load.speaker}">>
              ${load.text}
            <</speech>>
          <</replace>><</nobr>>`
        );
  State.variables.stack.shift(0,1);
}

This one uses the jQuery.wiki() function provided by SugarCube v2 as it's a simple way to use TwineScript variables and macros within JavaScript code without having to mess with variable context.


Storing dialogue in plain text and converting it to html elements on the fly using JavaScript. (Part 2)

The Speech function

The following JavaScript code does the work when it comes to taking in a speaker and some text to turn it into a dialogue box which can be styled as wished.

//takes in the 'speech' text, and optionally, the speaker name. If no speaker is supplied, it's displayed as a thought or internal monologue
setup.speech = function(text="", speaker="") {
	//exits the function if there is no 'speech' data
	if (text === "") {return "";}

  let legend = "";
	let thought = "thought";

	//decides whether it's a internal monologue, or external dialogue
	if (speaker) {
		legend = `<legend>${speaker}</legend>`;
		thought = "";
	}

	let output = `<div class="speech"><fieldset>${legend}`;
	output += `<span class="speech-bubble ${thought}">`;
	output += text;
	output += `</span></fieldset></div>`;

	return output;
}

Breakdown:

let legend = "";
let thought = "thought";

//decides whether it's a internal monologue, or external dialogue
if (speaker) {
  legend = `<legend>${speaker}</legend>`;
  thought = "";
}

If a speaker has been declared, this ensures that the declared name is shown on the webpage. This uses the <legend> child element of the <fieldset> element. There are other ways to do it, such as using another span/div. Either way, the positioning has to be done using CSS anyway, so it depends on personal preference.

The second variable, thought decides whether the speech bubble to be displayed is a 'speech' bubble, or a 'thought' bubble. It's default value is 'thought', as it sets the class of the bubble to 'thought'. When a speaker is declared, there is no need for this class as the default CSS for the speech bubble is a 'speech' bubble.

Whew! The different types of bubbles are purely cosmetic and you can skip the thought variable entirely.

Finally,

let output = `<div class="speech"><fieldset>${legend}`;
output += `<span class="speech-bubble ${thought}">`;
output += text;
output += `</span></fieldset></div>`;

return output;

This sets up the html to be returned for displaying on the webpage. It basically creates the following structure as mentioned in part 1:

<div class="speech">
	<fieldset>
		<legend>Daren</legend>
		<span class="speech-bubble">
			Why do you summon me, and then disappear yourself? Every single time… Real mature, father, real mature.
		</span>
	</fieldset>
</div>

Now the classes .speech and .speech-bubble can be styled as wanted, customizing the speech bubble however you wish.

Note!
The following code can be used to add this function as a macro to be used in TwineScript too!

Macro.add('speech', {
	tags     : null,
	handler  : function () {
		let text = this.payload[0].contents.trim();
		if (text === "") {return;}
		let speaker = (this.args.length > 0) ? this.args[0] : "";

		let output = setup.speech(text, speaker);

		$(this.output).wiki(output);
	}
});

The Display passage

The following passage holds the shell for our speech element to appear in after every advancement of the story. When the passage is rendered onto the screen, the advanceStory() function is made to run after an unnoticeable delay (so that the <div> with class .speech-container is loaded into the DOM) which renders the first paragraph/speech of the already loaded story data (loaded before using loadStory().)

:: VNScreen [nobr]

<div class="speech-container">
</div>

<<timed 5ms>>
  <<run advanceStory()>>
<</timed>>

Here's the advanceStory() function again:

//prints text onto screen from stack
window.advanceStory = function() {
  //checks whether the stack is filled or not
  if (State.variables.stack.length === 0) { return; /*dialogue tree end events not currently implemented, but they'd probably go in here*/ }
  let load = State.variables.stack[0];

  //replaces the current bubble
  $.wiki(`<<nobr>><<replace ".speech-container">>
            <<speech "${load.speaker}">>
              ${load.text}
            <</speech>>
          <</replace>><</nobr>>`
        );
  State.variables.stack.shift(0,1);
}

What this basically does is, loads the first entry in the stack, replaces whatever is in .speech-container with a new speech bubble, and then removes the top element from the stack, readying the stack to return the next entry next time advanceStory() is called.

This function can be attached to a 'Continue' button, to the speech div itself (so clicking on the bubble advances the story; that's what I used) or to a 'Skip' button which repeats the function for as long as the button's held down (or you could work in till when the stack is empty, and then load a new story passage and exit the 'skipping' state.)


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment