Twine Snippets

Potentially useful bits of code for the narrative game engine Twine, organized by story format.

Harlowe

Save Screen

Intended to create a save screen similar to the kind you get in visual novels, e.g. in the Renpy engine.

Macro Code:

(set: $savescreen to (macro: [{
(output:)[<div id="saves">
	(for: each _i, ...(range: 1, $saveslots))[{
		<div class='save'>
			(set: _slot to "Slot "+(str: _i))
			(if: (saved-games: ) contains _slot)[{
				(set: _tokens to  (split: "|", _slot of (saved-games:)))
				(set: _counter to 1)
				(set: _date to (nth: 1, ..._tokens))
				(set: _time to (nth: 2,  ..._tokens))
				(set: _chapter to (nth: 3, ..._tokens))
				(set: _percent to "")
				(set: _cg to "")
				(if: length of _tokens > 3)[{
					(set: _percent to (str:(nth: 4, ..._tokens)))
				}]
				(if: length of _tokens > 4)[{
					(set: _cg to (nth: 5, ..._tokens))
				}]
				(set: $linktext to '<span class="date">'+_date+'</span><span class="time">'+_time+'</span><span class="chapter">'+_chapter+'</span>')
				(if: _percent is not "")[(set: $linktext to it + '<span class="percent">'+_percent+'%</span>')]
				(if: _cg is not "")[(set: $linktext to it + '<span class="cg" style="background-image: url('+_cg+')"></span>')]
				[(link-goto: $linktext, "save "+(str: _i))]
				(link: "Overwrite")[{
					(save-game: _slot, (current-date:)+"|"+(current-time:)+"|"+$chapter+"|"+(str: $percent))
					(replace: ?saves)[($savescreen: _showpercent, _showcg)]
				}]
			}]
			(else:)[(link: "Save to Slot")[{
				(save-game: _slot, (current-date:)+"|"+(current-time:)+"|"+$chapter+"|"+(str: $percent))
				(replace: ?saves)[($savescreen:)]
			}]]
		</div>
	}]
</div>]
}]))

Prior to using it, do (set: $saveslots to [whatever number you want]). Use ($savescreen:) to invoke it.

Some basic CSS for it:

#saves > tw-hook {
  display: grid;
  grid-template-columns: repeat(4, 1fr); /* items per row */
  grid-template-rows: repeat(2, 1fr); /* items per column */
  gap: 10px;
}

#saves tw-hook .save {
  border: 1px var(--color) solid;
  display: flex;
  justify-content: center;
  align-items: center;
  height: 120px;
}

#saves tw-hook .save span {
  display: block;
}

Speech Box

Concept similar to Chapel's speech box system. Use the $character macro to define your characters, and then use the $say macro to print the speech boxes. Obviously unstyled here, so you can do whatever you want with it.

(set: $characters to (dm:))
(set: $character to (macro: str-type _label, str-type _name, str-type _img, [
    (set: $characters to $characters + (dm: _label, (dm: "name", _name, "img", _img)))
    (output-data: "")
]))
(set: $say to (macro: str-type _label, str-type _content, [
    (set: $label to _label)
    (set: $content to _content)
    (set: $char to $label of $characters)
    (set: $name to name of $char)
    (set: $img to img of $char)
    (output:)[(print: '<div class="say" data-character='+$name+'><p class="nametag">'+$name+'</p><img src='+$img+' /><div class="content">'+$content+'</div></div>')]
]))

Animalese Voicing

Intended to work with the above speech box system; adapted from the javascript linked from this video. You'll want to download the files linked in the description to get the audio files.

var letter_graphs = [
    "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k",
    "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v",
    "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6",
    "7", "8", "9"
];

var digraphs = [
    "ch", "sh", "ph", "th", "wh"
];

var path = ""; // path to the folder your audio is in. end in a slash if not empty

const character_voices = { // translate speech box label to voicing folder
    'Test': 'voice_a',
    'Character B', 'voice_b'
};

var bebebese = path+"audio/bebebese_slow.wav";

var playbackSpeedMin = 2.5;
var playbackSpeedMax = 3.0;
var playbackSpeed = null;
var sentence = '';

function speakSentence() {
    speakNextCharacter();
}

function speakNextCharacter() {
    if (sentence.length == 0) return;

    var character = sentence[0];
    sentence = sentence.substring(1, sentence.length);

    var characterFile = getCharacterAudioFile(character);
    var player = new Audio();
    player.src = characterFile;
    player.mozPreservesPitch = false;
    player.playbackRate = playbackSpeed;
    player.play();
    setTimeout(speakNextCharacter, 70);
}

function getCharacterAudioFile(character) {
    if (character.match(/[a-z]/i)) {
        return path+"audio/" + character + ".wav";
    } else if (character == " ") {
        return null;
    } else {
        return bebebese;
    }
}

function buildSentence(sentence) {
    sentence = sentence.toLowerCase();
    sentence = replaceNonLetters(sentence);
    return sentence;
}

function replaceNonLetters(sentence) {
  	return sentence.replace(/[.,\/#!$%\^&\*;:{}=\-\s_`~()\?]/g,"");
}

(function() {
  const newContentChecker = (mutationList, observer) => {
    let breakloop = false;
    const mutation = mutationList[mutationList.length-1];
    if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
      const boxes = Array.prototype.slice.call(mutation.addedNodes).filter((x) => {
        if (x.nodeType !== 3 && x.getAttribute('name') == '$say') return true;
      });
      if (boxes.length > 0) {
        console.log(mutation);
        const saybox = boxes[0].querySelector('.say .content');
        const speaker = boxes[0].querySelector('.say .nametag').textContent;
        playbackSpeed = Math.random() * (playbackSpeedMax-playbackSpeedMin) + playbackSpeedMin;
        const words = saybox.textContent;
        sentence = buildSentence(words);
        console.log(sentence);
        speakSentence();$$
      }
    }
  };
  const observer = new MutationObserver(newContentChecker);
  const config = { attributes: false, childList: true, subtree: true };
  observer.observe(document.querySelector('tw-story'), config);
})();