Module:Dialogue
Jump to navigation
Jump to search
This module implements {{Dialogue}}.
-- Function to process Forename and Surname placeholders
local function processNames(text)
-- Define name types and their patterns
local nameTypes = {"Forename", "Surname"}
local patterns = {
{"'''(%s)'''", "<u>%1</u>"},
{"''(%s)''", "<u>%1</u>"},
{"%[(%s)%]", "<u>%1</u>"},
{'"(%s)"', "<u>%1</u>"},
{"(%W)(%s)(%W)", "%1<u>%2</u>%3"},
{"^(%s)(%W)", "<u>%1</u>%2"},
{"(%W)(%s)$", "%1<u>%2</u>"}
}
-- Process each name type with all patterns
for _, name in ipairs(nameTypes) do
for _, pattern in ipairs(patterns) do
local search = pattern[1]:gsub("%%s", name)
local replace = pattern[2]
text = text:gsub(search, replace)
end
-- Handle name as entire line
if text == name then
text = "<u>" .. name .. "</u>"
end
end
return text
end
-- #region Parsing - Converting the input line format into data
--- Parses dialogue speaker strings into speaker name and link
local function parseSpeaker(str)
-- Remove surrounding bold markup ('''), if any
str = mw.text.trim(str:gsub("'''", ""))
-- Determine speaker name and link
local speakerName, speakerLink
if str:match("@") then
-- Speaker name and link are different (format: "Link@Name" or "x@Name" for no link)
speakerLink, speakerName = str:match("^(.-)@(.+)$")
speakerLink = mw.text.trim(speakerLink)
speakerName = mw.text.trim(speakerName)
else
-- Speaker name and link are the same
-- TODO: if we ever get access to semantic scribunto, maybe do a
-- "Has canonical name" query for the speaker here?
speakerName = str
speakerLink = str
end
if speakerName == "System" then
-- If speaker is "System", treat this as speakerless dialogue
speakerName = nil
speakerLink = nil
elseif speakerLink == "x" then
-- Link override of "x" disables linking
speakerLink = nil
end
return speakerName, speakerLink
end
--- Parses a single line of input and returns all its information
local function parseLine(line)
line = mw.text.trim(line)
-- End-of-option delimiters
if line == "---" or line == "–––" or line == "—" then
return {type = "section-end"}
end
-- Choices
local choiceText = line:match("^>%s*(.+)$")
if choiceText then
return {type = "choice", text = processNames(choiceText)}
end
-- Other special directives
if line:sub(1, 1) == "-" then
-- Line is a special directive
return {type = "directive", value = line:sub(2)}
end
-- Mediawiki headings
local headingMarker, headingTitle = line:match("^(=+)(.+)%1$")
if headingMarker and headingTitle then
return {type = "heading", level = #headingMarker, title = headingTitle}
end
-- Anything else is an actual dialogue line
-- Determine who's speaking
local speakerName, speakerLink
local speakerMatch, text = line:match("^(.-):%s*(.+)$")
if speakerMatch and text then
speakerName, speakerLink = parseSpeaker(speakerMatch)
else
-- No speaker specified, so use the entire line as the message text
text = line
end
-- Process forename/surname/etc placeholders in dialogue text
text = processNames(text)
if speakerName then
return {type = "dialogue", name = speakerName, link = speakerLink, text = text}
else
return {type = "system", text = text}
end
end
--- Sequentially parses each line of input text and returns an iterator for the parsed data.
local function parsedLinesIter(text)
local lines = mw.text.split(text, "\n")
local i = 0
return function()
local line
repeat
-- advance the iterator one line
i = i + 1
line = lines[i]
-- if there is no next line, we've reached the end - return nothing
if not line then
return nil
end
-- trim leading/trailing whitespace
line = mw.text.trim(line)
until line ~= "" -- continue advancing past any empty line
-- parse and return the line
return parseLine(line)
end
end
-- #endregion
-- #region Rendering - Converting parsed data into markup/wikitext
local userContentFrame = mw.getCurrentFrame():newChild({})
--- Expands templates/magic words/etc in user input.
local function preprocess(text)
return userContentFrame:preprocess(text)
end
-- declare the renderLine function early since we need to recurse back to it
-- from other functions - lua doesn't have function declaration hoisting, we
-- have to do it manually
local renderLine
--- Renders an error message.
local function renderError(text)
return mw.html.create("p")
:node(mw.html.create("strong"):addClass("error")
:wikitext("Error: " .. text)
)
end
--- Renders a line of dialogue from the system rather than a speaker.
local function renderSystemMessage(line)
return mw.html.create("div"):addClass("dialogue-line dialogue-line--system")
:wikitext(preprocess(line.text))
end
--- Renders multiple consecutive lines of dialogue from the same speaker.
--- Consumes lines from the input until it finds a different speaker or a
--- non-dialogue line.
local function renderSpeakerDialogueGroup(line, next)
-- Keep track of the speaker information we're starting with
local speakerName = line.name
local speakerLink = line.link
-- Assemble the container for this speaker's dialogue
local childContainer = mw.html.create("div"):addClass("dialogue-container")
local wrapper = mw.html.create("div"):addClass("dialogue-box dialogue-box--speaker")
:node(mw.html.create("div"):addClass("dialogue-box-header")
-- Render the speaker's name and link
:wikitext(
speakerLink
and "'''[[" .. speakerLink .. "|" .. speakerName .. "]]'''"
or "'''" .. speakerName .. "'''"
)
)
:node(childContainer)
while true do
-- Add the current line of dialogue to the container
childContainer:node(mw.html.create("div"):addClass("dialogue-line")
:wikitext(preprocess(line.text))
)
line = next()
if
not line -- if there's no more lines left,
or line.type ~= "dialogue" -- or the next line isn't dialogue,
or line.name ~= speakerName -- or the speaker's name is different,
or line.link ~= speakerLink -- or the speaker's link is different,
then -- then we're done with this container
return wrapper, line
end
-- loop and continue processing lines from the same speaker
end
end
--- Renders a group of optional dialogue. Consumes lines from the input until
--- it finds the "optional end" directive or end of input.
local function renderOptionalDialogue(line, next)
local childContainer = mw.html.create("div"):addClass("dialogue-container mw-collapsible-content")
local wrapper = mw.html.create("div"):addClass("dialogue-box dialogue-box--optional mw-collapsible mw-collapsed")
:node(mw.html.create("div"):addClass("dialogue-box-header mw-collapsible-toggle")
:wikitext("''Optional dialogue''")
)
:node(childContainer)
line = next() -- move past the "optional start" marker
while true do
if not line then
-- no more lines, return what we have
return wrapper, line
elseif line.type == "directive" and line.value == "optional end" then
-- discard end directive, return what we have + resume parent container from the line after the end directive
return wrapper, next()
end
-- otherwise, render the line into the child container and move to the next
local rendered
rendered, line = renderLine(line, next)
childContainer:node(rendered)
end
end
--- Renders a group of choices the player can make and the dialogue that follows
--- each choice. Consumes lines from the input until it sees the "section end"
--- marker or end of input.
local function renderChoices(line, next)
-- TODO: rename all these classes from "option" to "choice" and make them make sense
local childContainer = mw.html.create("div"):addClass("dialogue-container")
local dialogueChoiceLine = mw.html.create("div"):addClass("dialogue-line dialogue-line--choice")
:wikitext(preprocess(line.text))
local wrapper = mw.html.create("div"):addClass("dialogue-container")
:node(dialogueChoiceLine)
:node(childContainer)
line = next()
local hasResponse = false
while true do
if not line then
-- no more lines, return what we have
return wrapper, line
elseif line.type == "choice" then
-- end of this choice and beginning of another - return what we have
-- and have the parent context resume, starting with the next choice
-- TODO: this is hacky
return wrapper, line
elseif line.type == "section-end" then
-- reached end of choice, return what we have and resume parent from
-- line after section end
return wrapper, next()
end
if not hasResponse then
hasResponse = true
wrapper:addClass("mw-collapsible mw-collapsed dialogue-choice-has-response")
childContainer:addClass("mw-collapsible-content")
dialogueChoiceLine:addClass("mw-collapsible-toggle")
end
-- otherwise, render the line into the choice container and advance
local renderedLine
renderedLine, line = renderLine(line, next)
childContainer:node(renderedLine)
end
end
--- Renders dialogue that occurs in a cutscene. Consumes lines from the input
-- until it sees the "cutscene end" marker or end of input.
local function renderCutscene(line, next)
local childContainer = mw.html.create("div"):addClass("dialogue-container")
-- Check whether this cutscene is voiced or not and show the right message
local cutsceneStartWikitext
if line.value == "voiced cutscene start" then
cutsceneStartWikitext = "[[File:Voiced cutscene icon.png|24px|link=]] ''Start of voiced cutscene.''"
else
cutsceneStartWikitext = "[[File:Cutscene icon.png|24px|link=]] ''Start of cutscene.''"
end
local wrapper = mw.html.create("div"):addClass("dialogue-cutscene-wrapper")
:node(mw.html.create("div"):addClass("dialogue-line dialogue-line--cutscene")
:wikitext(cutsceneStartWikitext)
)
:node(childContainer)
:node(mw.html.create("div"):addClass("dialogue-line dialogue-line--cutscene")
:wikitext("''End of cutscene.''")
)
line = next()
while true do
if not line then
return wrapper, line
elseif line.type == "directive" and line.value == "cutscene end" then
return wrapper, next()
end
local renderedLine
renderedLine, line = renderLine(line, next)
childContainer:node(renderedLine)
end
end
--- Renders special directives, such as cutscene start/end and the end of a quest.
local function renderDirective(line, next)
if line.value == "cutscene start" or line.value == "voiced cutscene start" then
return renderCutscene(line, next)
elseif line.value == "cutscene end" then
-- this is handled by renderCutscene; if we encounter this
-- directive here then it's unmatched
return renderError("Extraneous <code>-cutscene end</code>"), next()
elseif line.value == "optional start" then
return renderOptionalDialogue(line, next)
elseif line.value == "optional end" then
-- this is handled by renderOptionalDialogue; if we encounter this
-- directive here then it's unmatched
return renderError("Extraneous <code>-optional end</code>"), next()
elseif line.value == "quest complete" then
return
mw.html.create("div"):addClass("dialogue-quest-complete")
:wikitext("[[File:Quest Complete.png|500px|link=]]"),
next()
end
-- Unknown directive
return renderError("Unknown directive: " .. line.value)
end
--- Renders a line, branching out into containers as necessary.
-- NOTE: this function is already declared as local at the top of this section
-- because we need to recurse into it from earlier functions
function renderLine(line, next)
if line.type == "dialogue" then
-- The renderer for spoken dialogue will advance through multiple
-- lines and group dialogue lines from the same speaker together,
-- returning both the rendered result *and* the line that comes next
return renderSpeakerDialogueGroup(line, next)
elseif line.type == "system" then
return renderSystemMessage(line), next()
elseif line.type == "directive" then
return renderDirective(line, next)
elseif line.type == "choice" then
return renderChoices(line, next)
elseif line.type == "section-end" then
-- Section ends are consumed by the relevant container rendering
-- functions when needed, so any that get picked up here don't
-- actually close anything
return renderError("Extraneous <code>---</code> section ending"), next()
elseif line.type == "heading" then
return mw.html.create("h" .. line.level):wikitext(preprocess(line.title)), next()
end
return renderError("Unknown line type ".. line.type), next()
end
-- #endregion
-- Exports
local p = {}
--- Main parse/render loop (exported for ease of debugging)
function p.render(text)
local next = parsedLinesIter(text)
local line = next() -- start with first line
local outputContainer = mw.html.create("div"):addClass("dialogue-container")
while line do
local renderedLine
renderedLine, line = renderLine(line, next)
outputContainer:node(renderedLine)
end
return outputContainer
end
-- Template/{{#invoke}} entrypoint
function p.main(frame)
userContentFrame = frame:newChild({})
local text = require("Module:Arguments").getArgs(frame)[1]
-- replace <nowiki> strip markers with their contents
text = mw.text.unstripNoWiki(text or "")
-- decode the HTML entities nowiki leaves behind
text = mw.text.decode(text)
return p.render(text)
end
return p