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);
})();