Skip to content

Instantly share code, notes, and snippets.

@Akjosch
Last active July 30, 2020 17:40
Show Gist options
  • Save Akjosch/4d06a027a9300bfee51344ffa3bf6972 to your computer and use it in GitHub Desktop.
Save Akjosch/4d06a027a9300bfee51344ffa3bf6972 to your computer and use it in GitHub Desktop.
Simple dialog system
.chat {
position: absolute;
left: 0;
right: 0;
bottom: 0;
visibility: hidden;
}
.chat.chat-open {
visibility: visible;
}
.chat-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
background-color: #000;
opacity: 0.5;
}
.chat-portrait {
position: relative;
bottom: 9.5em;
z-index: 20;
}
.chat-portrait img {
height: 128px;
}
.chat-name {
position: absolute;
bottom: 9.9em;
left: 5em;
font-weight: bold;
text-shadow: 0 0 2px black;
z-index: 30;
}
.chat-text {
border: 2px solid white;
padding: 0.5em;
border-radius: 0.5em;
position: absolute;
height: 10em;
left: 0.1em;
right: 0.1em;
bottom: 0;
background: #555;
box-sizing: border-box;
z-index: 40;
overflow: auto;
}
.chat-text-next {
color: #3a3;
animation: blink-animation 0.5s steps(5, start) infinite;
}
@keyframes blink-animation {
to {
visibility: hidden;
}
}
function parseOpts(opts) {
const optsMatch = new RegExp('(?:(?<id>\\w+(?==))|(?<val>(?<==")[^"]*(?="(?:\\s|$))|(?<==)[^\\s"]\\S*(?=(?:\\s|$))))', 'gi');
let opt = null;
let result = {};
let lastId = null;
while(opt = optsMatch.exec(opts)) {
if(typeof opt.groups.id !== 'undefined') {
lastId = opt.groups.id;
result[lastId] = '';
} else if(typeof opt.groups.val !== 'undefined' && lastId) {
result[lastId] = opt.groups.val;
lastId = null;
}
}
return result;
}
function parseDialog(passage) {
const pass = Story.get(passage);
if(!pass.tags.includes('dialog')) {
return [];
}
const lines = pass.text.split(/\n+/).filter((line) => !!line && !line.startsWith(";"));
const tokens = /^(?<command>#-|#(?=(?:[A-Za-z_]\w*|))|\*(?=[A-Za-z_]\w*)|\s*)(?<label>(?<=[#$])[A-Za-z_]\w*|)(?<text>.*)$/;
const commands = /\[(?<command>[A-Za-z_]\w*)(?:\s+(?<options>[^\]]*)|)\]/;
var blockId = 1;
var dialog = {};
var meta = {}
dialog.$meta = meta;
var currentBlock = null;
var currentLine = 0;
function initBlock(char) {
if(!currentBlock) {
currentBlock = { id: String(blockId), char: char || "", text: [] };
dialog[String(blockId)] = currentBlock;
currentLine = 0;
if(!meta.start) {
meta.start = String(blockId);
}
}
}
for(let l = 0; l < lines.length; ++ l) {
const groups = lines[l].match(tokens).groups;
switch(groups.command.trim()) {
case "#":
case "#-":
if(currentBlock) {
++ blockId;
currentBlock.next = String(blockId);
currentBlock = null;
}
initBlock(groups.command === '#' ? groups.label : '-');
break;
case "*":
// TODO
break;
case "":
let textParts = groups.text.split(commands);
for(let p = 0; p < textParts.length; p += 3) {
if(textParts[p]) {
initBlock("");
currentBlock.text[currentLine] = (currentBlock.text[currentLine] || '') + textParts[p];
}
switch(textParts[p + 1]) {
case 'r':
initBlock("");
currentBlock.text[currentLine] = (currentBlock.text[currentLine] || '') + '<br>';
break;
case 'l':
initBlock("");
++ currentLine;
break;
case 'char':
meta.chars = meta.chars || {};
let char = parseOpts(textParts[p + 2]);
if(char.id) {
meta.chars[char.id] = char;
}
break;
}
}
break;
}
}
return dialog;
}
setup.parseDialog = parseDialog;
Macro.add("dialog", {
isAsync: true,
optRe: /^(\w+)=(.*)$/,
createDialog(dialog, dialogId, state, update) {
const $portrait = jQuery('<div class="chat-portrait"></div>');
const $name = jQuery('<div class="chat-name"></div>');
const $text = jQuery('<div class="chat-text"></div>');
const $dialog = jQuery('<div class="chat"><div class="chat-overlay"></div></div>');
$dialog.attr('id', dialogId);
$dialog.append($portrait, $name, $text);
state.currentDialog = dialog.$meta.start;
state.currentLine = 0;
$text.ariaClick(() => {
var data = dialog[state.currentDialog];
state.currentLine = state.currentLine || 0;
++ state.currentLine;
if(state.currentLine >= data.text.length) {
state.currentLine = 0;
state.currentDialog = data.next;
data = dialog[state.currentDialog];
}
if(data) {
update(dialog, state, $dialog);
} else {
state.currentDialog = dialog.$meta.start;
state.currentLine = 0;
$portrait.empty();
$name.empty();
$text.empty();
$dialog.removeClass('chat-open');
}
});
return $dialog;
},
updateDialog(dialog, state, $dialog) {
var data = dialog[state.currentDialog];
var line = state.currentLine || 0;
console.debug(state);
if(data) {
var char = dialog.$meta.chars[data.char];
if(char) {
$dialog.find(".chat-portrait").empty();
if(char.img) {
$dialog.find(".chat-portrait").html("<img src='ext/chatportraits/" + char.img + "' />");
}
$dialog.find(".chat-name").empty();
if(char.name) {
$dialog.find(".chat-name").wiki(char.name);
}
} else if(data.char === '-') {
$dialog.find(".chat-portrait").empty();
$dialog.find(".chat-name").empty();
}
var $text = $dialog.find(".chat-text");
var stop = $text[0].scrollTop;
$text.empty()
.wiki(data.text.slice(0, line + 1).join(''))
.wiki("<span class='chat-text-next'>&#x25BC;</span>")
.scrollTop(stop).animate({scrollTop: $text[0].scrollHeight}, 500);
}
},
handler() {
const opts = this.args
.map((txt) => typeof txt === 'string' && txt.match(this.self.optRe))
.filter((opt) => !!opt)
.reduce((res, val) => { res[val[1]] = val[2]; return res; }, {});
const args = this.args.filter((txt) => typeof txt !== 'string' || !txt.match(this.self.optRe));
const passage = (args.length === 0 || typeof args[0] === 'undefined' ? '' : String(args[0]).trim());
if(!passage) {
return this.error("Dialog passage name missing.");
}
const dialog = parseDialog(passage);
if(!dialog.$meta) {
return this.error("Passage \"" + JSON.stringify(passage) + "\" not a proper dialog passage or empty.");
}
const dialogId = 'dialog-' + Story.get(passage).domId;
const state = { currentDialog: dialog.$meta.start, currentLine: 0 };
let $dialog = null;
if(document.getElementById(dialogId)) {
$dialog = jQuery(document.getElementById(dialogId));
} else {
$dialog = this.self.createDialog(dialog, dialogId, state, this.self.updateDialog);
$dialog.appendTo(jQuery('.passage'));
}
this.self.updateDialog(dialog, state, $dialog);
setTimeout(() => { $dialog.addClass("chat-open"); }, 40);
}
});
:: Start
<<set $pc = {
name: 'Player'
}>>
<<set $ship = {
name: "Nostromo"
}>>
<<link "Call Ground Control">><<dialog test1.dialog>><</link>>
.passage {
position: relative;
min-height: calc(100vh - 5em);
}
:: test1.dialog [dialog]
; Lines starting with a semicolon are comments
; Lines starting with # change the character doing the talking
; A "#" by itself simply clears the screen
; A "#-" resets the speaker to "nobody", with no image/portrait
; Lines starting with * denote a new label (for jumps and choices; not implemented yet)
; Lines starting with anything else, or a space, are text lines. Leading spaces are stripped.
; Empty lines are ignored
; [r] introduces a new line (you can also use <br>). [l] adds a pause waiting for user interaction.
; You can use most of HTML and SugarCube's syntax in the texts, including <<goto "Passagename">>, which ends the conversation.
; [char] registers a character with (printable) name and optional image; those can also be
; registered outside of this code, globally.
[char id=player name=$pc.name img=m_h1_e3_a1_01.png]
[char id=rgc name="Ground Control" img=f_h1_e1_a2_01.png]
#player
Regina Ground Control, this is S.S. //<<= $ship.name>>//, berth 94, with custom clearance number Alpha-Romeo-two-zero-zero-one. [l][r]
Request taxi instructions for orbital departure.
#rgc
//<<= $ship.name>>//, this Regina Ground. You are cleared to air taxi via lange Charlie to departure pod 4 North. [l][r]
Exercise caution for oversize traffic passing to your front on lane Delta. Contact Tower holding short.
#player
This is //<<= $ship.name>>//, traffic in sight, roger.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment