botscripten
Version:
Craft rich bot conversations using the Twine/Twee format
349 lines (299 loc) • 9.15 kB
JavaScript
import Passage from "./Passage";
import escape from "lodash.escape";
import unescape from "lodash.unescape";
const selectPassages = "tw-passagedata";
const selectCss = '*[type="text/twine-css"]';
const selectJs = '*[type="text/twine-javascript"]';
const selectActiveLink = "#user-response-panel a[data-passage]";
const selectActiveButton = "#user-response-panel button[data-passage]";
const selectActiveInput = "#user-response-panel input";
const selectActive = "#active-passage";
const selectHistory = "#history";
const selectResponses = "#user-response-panel";
const typingIndicator = "#animation-container";
const IS_NUMERIC = /^[\d]+$/;
/**
* Determine if a provided string contains only numbers
* In the case of `pid` values for passages, this is true
*/
const isNumeric = d => IS_NUMERIC.test(d);
/**
* Format a user passage (such as a response)
*/
const USER_PASSAGE_TMPL = ({ id, text }) => `
<div class="chat-passage-reset">
<div class="chat-passage-wrapper" data-speaker="you">
<div class="chat-passage" data-speaker="you" data-upassage="${id}">
${text}
</div>
</div>
</div>
`;
/**
* Format a message from a non-user
*/
const OTHER_PASSAGE_TMPL = ({ speaker, tags, text }) => `
<div class="chat-passage-reset">
<div data-speaker="${speaker}" class="chat-passage-wrapper ${tags.join(
" "
)}">
<div data-speaker="${speaker}" class="chat-passage">
${text}
</div>
</div>
</div>
`;
const DIRECTIVES_TMPL = directives => `
<div class="directives">
${directives
.map(
({ name, content }) =>
`<div class="directive" name="${name}">${content.trim()}</div>`
)
.join("")}
</div>
`;
/**
* Forces a delay via promises in order to spread out messages
*/
const delay = async (t = 0) => new Promise(resolve => setTimeout(resolve, t));
// Find one/many nodes within a context. We [...findAll] to ensure we're cast as an array
// not as a node list
const find = (ctx, s) => ctx.querySelector(s);
const findAll = (ctx, s) => [...ctx.querySelectorAll(s)] || [];
/**
* Standard Twine Format Story Object
*/
class Story {
version = 2; // Twine v2
document = null;
story = null;
name = "";
startsAt = 0;
current = 0;
history = [];
passages = {};
showPrompt = false;
errorMessage = "\u26a0 %s";
directives = {};
elements = {};
userScripts = [];
userStyles = [];
constructor(win, src) {
this.window = win;
if (src) {
this.document = document.implementation.createHTMLDocument(
"Botscripten Injected Content"
);
} else {
this.document = document;
}
this.story = find(this.document, "tw-storydata");
// elements
this.elements = {
active: find(this.document, selectActive),
history: find(this.document, selectHistory),
};
// properties of story node
this.name = this.story.getAttribute("name") || "";
this.startsAt = this.story.getAttribute("startnode") || 0;
findAll(this.story, selectPassages).forEach(p => {
const id = parseInt(p.getAttribute("pid"));
const name = p.getAttribute("name");
const tags = (p.getAttribute("tags") || "").split(/\s+/g);
const passage = p.innerHTML || "";
this.passages[id] = new Passage(id, name, tags, passage, this);
});
find(this.document, "title").innerHTML = this.name;
this.userScripts = (findAll(this.document, selectJs) || []).map(
el => el.innerHTML
);
this.userStyles = (findAll(this.document, selectCss) || []).map(
el => el.innerHTML
);
}
/**
* Starts the story by setting up listeners and then advancing
* to the first item in the stack
*/
start = () => {
// activate userscripts and styles
this.userStyles.forEach(s => {
const t = this.document.createElement("style");
t.innerHTML = s;
this.document.body.appendChild(t);
});
this.userScripts.forEach(s => {
// eval is evil, but this is simply how Twine works
// eslint-disable-line
globalEval(s);
});
// when you click on a[data-passage] (response link)...
this.document.body.addEventListener("click", e => {
if (!e.target.matches(selectActiveLink)) {
return;
}
this.advance(
this.findPassage(e.target.getAttribute("data-passage")),
e.target.innerHTML
);
});
// when you click on button[data-passage] (response input)...
this.document.body.addEventListener("click", e => {
if (!e.target.matches(selectActiveButton)) {
return;
}
// capture and disable showPrompt feature
const value = find(this.document, selectActiveInput).value;
this.showPrompt = false;
this.advance(
this.findPassage(e.target.getAttribute("data-passage")),
value
);
});
this.advance(this.findPassage(this.startsAt));
};
/**
* Find a passage based on its id or name
*/
findPassage = idOrName => {
idOrName = `${idOrName}`.trim();
if (isNumeric(idOrName)) {
return this.passages[idOrName];
} else {
// handle passages with ' and " (can't use a css selector consistently)
const p = findAll(this.story, "tw-passagedata").filter(
p => unescape(p.getAttribute("name")).trim() === idOrName
)[0];
if (!p) return null;
return this.passages[p.getAttribute("pid")];
}
};
/**
* Advance the story to the passage specified, optionally adding userText
*/
advance = async (passage, userText = null) => {
this.history.push(passage.id);
const last = this.current;
// .active is captured & cleared
const existing = this.elements.active.innerHTML;
this.elements.active.innerHTML = "";
// whatever was in active is moved up into history
this.elements.history.innerHTML += existing;
// if there is userText, it is added to .history
if (userText) {
this.renderUserMessage(
last,
userText,
s => (this.elements.history.innerHTML += s)
);
}
// The new passage is rendered and placed in .active
// after all renders, user options are displayed
await this.renderPassage(
passage,
s => (this.elements.active.innerHTML += s)
);
if (!passage.hasTag("wait") && passage.links.length === 1) {
// auto advance if the wait tag is not set and there is exactly
// 1 link found in our pssage.
this.advance(this.findPassage(passage.links[0].target));
return;
}
this.renderChoices(passage);
};
/**
* Render text as if it came from the user
*/
renderUserMessage = async (pid, text, renderer) => {
await renderer(
USER_PASSAGE_TMPL({
id: pid,
text,
})
);
this.scrollToBottom();
return Promise.resolve();
};
/**
* Render a Twine passage object
*/
renderPassage = async (passage, renderer) => {
const speaker = passage.getSpeaker();
let statements = passage.render();
console.log(statements.directives);
await renderer(DIRECTIVES_TMPL(statements.directives));
let next = statements.text.shift();
this.showTyping();
while (next) {
const content = OTHER_PASSAGE_TMPL({
speaker,
tags: passage.tags,
text: next,
});
await delay(this.calculateDelay(next)); // todo
await renderer(content);
next = statements.text.shift();
}
this.hideTyping();
this.scrollToBottom();
return Promise.resolve();
};
/**
* A rough function for determining a waiting period based on string length
*/
calculateDelay = txt => {
const typingDelayRatio = 0.3;
const rate = 20; // ms
return txt.length * rate * typingDelayRatio;
};
/**
* Shows the typing indicator
*/
showTyping = () => {
find(this.document, typingIndicator).style.visibility = "visible";
};
/**
* Hides the typing indicator
*/
hideTyping = () => {
find(this.document, typingIndicator).style.visibility = "hidden";
};
/**
* Scrolls the document as far as possible (based on history container's height)
*/
scrollToBottom = () => {
const hist = find(this.document, selectHistory);
document.scrollingElement.scrollTop = hist.offsetHeight;
};
/**
* Clears the choices panel
*/
removeChoices = () => {
const panel = find(this.document, selectResponses);
panel.innerHTML = "";
};
/**
* Renders the choices panel with a set of options based on passage links
*/
renderChoices = passage => {
this.removeChoices();
const panel = find(this.document, selectResponses);
passage.links.forEach(l => {
panel.innerHTML += `<a href="javascript:void(0)" class="user-response" data-passage="${escape(
l.target
)}">${l.display}</a>`;
});
};
/**
* Registers a custom directive for this story
* Signature of (directiveContent, outputText, story, passage, next)
*/
directive = (id, cb) => {
if (!this.directives[id]) {
this.directives[id] = [];
}
this.directives[id].push(cb);
};
}
export default Story;