1040 lines
36 KiB
Lua
1040 lines
36 KiB
Lua
-- create the table for the functions:
|
|
yl_speak_up.parse_ink = {}
|
|
-- add a local abbreviation:
|
|
local parse_ink = yl_speak_up.parse_ink
|
|
|
|
-- remove leading and tailing blanks, spaces, tabs, newlines and equal signs from names
|
|
-- of knots and stitches;
|
|
-- helper function for the parser
|
|
parse_ink.strip_name = function(s)
|
|
local CHAR_EQUAL = string.byte("=", 1)
|
|
local CHAR_BLANK = string.byte(" ", 1)
|
|
local CHAR_TAB = string.byte("\t", 1)
|
|
local CHAR_NEWLINE = string.byte('\n', 1)
|
|
local i = 1
|
|
local k = string.len(s)
|
|
local b = string.byte(s, i)
|
|
while(i < k and (b==CHAR_EQUAL or b==CHAR_BLANK or b==CHAR_TAB or b==CHAR_NEWLINE)) do
|
|
i = i + 1
|
|
b = string.byte(s, i)
|
|
end
|
|
b = string.byte(s, k)
|
|
while(k > i and (b==CHAR_EQUAL or b==CHAR_BLANK or b==CHAR_TAB or b==CHAR_NEWLINE)) do
|
|
k = k - 1
|
|
b = string.byte(s, k)
|
|
end
|
|
return string.sub(s, i, k)
|
|
end
|
|
|
|
|
|
-- the actual parser for ink (more or less)
|
|
parse_ink.parse = function(text, print_debug)
|
|
-- just an abbreviation
|
|
local strip_name = parse_ink.strip_name
|
|
-- this is what this function shall find:
|
|
local knots = {}
|
|
|
|
-- first: scan for things that have to start at the *beginning* of a line;
|
|
-- this limitation may or may not be part of ink (hard to tell), but it does
|
|
-- make parsing a lot easier while not imposing an undue limitation on writers
|
|
|
|
-- optional whitespaces/tabs are allowed;
|
|
local CHAR_NEWLINE = string.byte('\n', 1)
|
|
-- whitespace characters
|
|
local CHAR_BLANK = string.byte(" ", 1)
|
|
local CHAR_TAB = string.byte("\t", 1)
|
|
-- allow to escape things at the start of a line
|
|
local CHAR_ESCAPE = string.byte("\\", 1)
|
|
|
|
-- covers "* " choices and "*/" end of multiline comment
|
|
local CHAR_STAR = string.byte("*", 1)
|
|
local CHAR_SLASH = string.byte("/", 1)
|
|
local CHAR_PLUS = string.byte("+", 1)
|
|
local CHAR_MINUS = string.byte("-", 1)
|
|
-- for diverts
|
|
local CHAR_GREATER = string.byte(">", 1)
|
|
-- for text that is gluecd together
|
|
local CHAR_SMALLER = string.byte("<", 1)
|
|
-- == for knots; = for stitches
|
|
local CHAR_EQUAL = string.byte("=", 1)
|
|
-- for code:
|
|
local CHAR_TILDE = string.byte("~", 1)
|
|
-- for INCLUDE
|
|
local CHAR_I = string.byte("I", 1)
|
|
-- for VAR
|
|
local CHAR_V = string.byte("V", 1)
|
|
-- for CONST
|
|
local CHAR_C = string.byte("C", 1)
|
|
|
|
-- detect labels at the start of choices and gathers
|
|
local CHAR_LABEL_START = string.byte("(", 1)
|
|
local CHAR_LABEL_END = string.byte(")", 1)
|
|
|
|
-- this is used for a lot of things:
|
|
-- * printing values for variables
|
|
-- * checking conditions
|
|
-- * if/else and switch statements - which may even cover multiple lines
|
|
-- * alternatives, cycles, shuffles etc.
|
|
local CHAR_COND_START = string.byte("{", 1)
|
|
local CHAR_COND_END = string.byte("}", 1)
|
|
|
|
local i = 1
|
|
local i_max = string.len(text)
|
|
local at_line_start = false
|
|
-- TODO: turn that into constants?
|
|
local what = ""
|
|
local last_what = ""
|
|
local multiline_comment = false
|
|
local inline_comment = false
|
|
-- knots and stitches have names
|
|
local search_for_name = nil
|
|
local name_found = nil
|
|
local search_for_nesting = nil
|
|
-- choices and gathers can be nested
|
|
local nested = 0
|
|
local last_nested = 0
|
|
local search_for_label = nil
|
|
-- choices and gathers can have labels
|
|
local label_start = -1
|
|
local label_found = nil
|
|
-- choices can have conditions
|
|
local search_for_condition = nil
|
|
local cond_start = -1
|
|
local conditions = {}
|
|
-- the actual text/content of a choice or gather
|
|
local content = ""
|
|
-- we start with text (usually a divert to the main knot)
|
|
local content_start = 1
|
|
-- the main program text...not a knot at the start
|
|
local content_type = "MAIN_PROGRAM"
|
|
local last_content_type = content_type
|
|
-- we need to know if we're inside a mutiline condition or alternative
|
|
local counted_curly_brackets = 0
|
|
|
|
local started_at = 1 -- used for printing out the line for debugging
|
|
while(i < i_max) do
|
|
local b = string.byte(text, i)
|
|
if( b == CHAR_NEWLINE) then
|
|
-- this is text (from node, stitch, gather, choice) that spans more than
|
|
-- one line
|
|
if(not(multiline_comment)
|
|
and not(inline_comment)) then
|
|
content = content..string.sub(text, content_start, i)
|
|
end
|
|
if(search_for_name) then
|
|
name_found = content
|
|
content = ""
|
|
search_for_name = false
|
|
end
|
|
-- the content of this line may become relevant
|
|
content_start = i + 1
|
|
|
|
at_line_start = true
|
|
|
|
|
|
if(print_debug) then
|
|
print("|LINE| "..string.sub(text, started_at, i - 1))
|
|
end
|
|
if(what == "START_OF_MULTILINE_COMMENT") then
|
|
what = "INSIDE_MULTILINE_COMMENT"
|
|
else
|
|
what = ""
|
|
end
|
|
|
|
started_at = i + 1
|
|
-- inline comments end here
|
|
inline_comment = false
|
|
search_for_nesting = nil
|
|
search_for_label = nil
|
|
search_for_condition = nil
|
|
search_for_name = nil
|
|
elseif(multiline_comment) then
|
|
-- nothing can start inside a multilne comment apart from it ending
|
|
-- (we only allow multiline comments to start and end in their own lines,
|
|
-- not inside other texts or structures)
|
|
if(b == CHAR_STAR and string.byte(text, i + 1) == CHAR_SLASH) then
|
|
what = "END_OF_MULTILINE_COMMENT"
|
|
multiline_comment = false
|
|
-- just to be sure that the rest of the line is really skipped
|
|
inline_comment = true
|
|
at_line_start = false
|
|
end
|
|
-- TODO: handle inline comments
|
|
elseif(not(at_line_start)) then
|
|
-- choices and gathers can be nested
|
|
if(search_for_nesting) then
|
|
-- a divert -> is following a gather -
|
|
if((b == CHAR_MINUS and string.byte(text, i + 1) == CHAR_GREATER)
|
|
-- if escaped: no point searching for label or conditions
|
|
or b == CHAR_ESCAPE) then
|
|
search_for_nesting = nil
|
|
search_for_label = nil
|
|
search_for_condition = nil
|
|
elseif(b == search_for_nesting) then
|
|
nested = nested + 1
|
|
elseif(b ~= CHAR_BLANK and b ~= CHAR_TAB) then
|
|
search_for_nesting = nil
|
|
-- choices (* and +) and gathers (-) may be followed by a (label):
|
|
if(b == CHAR_LABEL_START) then
|
|
label_start = i + 1
|
|
search_for_label = CHAR_LABEL_END
|
|
-- if no label: did we find the start of a condition already?
|
|
elseif(b == CHAR_COND_START)
|
|
and (what == "NORMAL_CHOICE" or what == "STICKY_CHOICE") then
|
|
cond_start = i + 1
|
|
search_for_condition = CHAR_COND_END
|
|
else
|
|
content_start = i
|
|
content = ""
|
|
end
|
|
end
|
|
elseif(search_for_label and search_for_label == CHAR_LABEL_END) then
|
|
if(b == CHAR_LABEL_END) then
|
|
label = string.sub(text, label_start, i-1)
|
|
search_for_label = nil
|
|
if(what == "NORMAL_CHOICE" or what == "STICKY_CHOICE") then
|
|
search_for_condition = CHAR_COND_START
|
|
end
|
|
content_start = i + 1
|
|
content = ""
|
|
end
|
|
|
|
-- choices can have multiple conditions (at least they are not multiline)
|
|
elseif(search_for_condition and search_for_condition == CHAR_COND_START) then
|
|
if(b == CHAR_COND_START) then
|
|
cond_start = i + 1
|
|
search_for_condition = CHAR_COND_END
|
|
elseif(b ~= CHAR_BLANK and b ~= CHAR_TAB) then
|
|
-- no condition found
|
|
cond_start = -1
|
|
search_for_condition = nil
|
|
-- next we get the actual text of the choice or gather
|
|
content_start = i
|
|
content = ""
|
|
end
|
|
elseif(search_for_condition and search_for_condition == CHAR_COND_END) then
|
|
if(b == CHAR_COND_END) then
|
|
table.insert(conditions, string.sub(text, cond_start, i-1))
|
|
cond_start = -1
|
|
-- there may be more conditions comming
|
|
search_for_condition = CHAR_COND_START
|
|
end
|
|
end
|
|
|
|
-- not at line start: multiline alternatives or conditions
|
|
if(b == CHAR_COND_START and string.byte(text, i - 1) ~= CHAR_ESCAPE) then
|
|
-- one more open
|
|
counted_curly_brackets = counted_curly_brackets + 1
|
|
elseif(b == CHAR_COND_END and string.byte(text, i - 1) ~= CHAR_ESCAPE) then
|
|
counted_curly_brackets = counted_curly_brackets - 1
|
|
end
|
|
|
|
-- nothing to do; we read until the end of the line
|
|
-- TODO: there are some inline things we need to check for as well
|
|
-- TODO: inline commends need to be processed here
|
|
elseif(b == CHAR_BLANK or b == CHAR_TAB) then
|
|
-- nothing to do; real start of the line not yet found
|
|
at_line_start = true
|
|
-- blanks at the beginning are usually just identation for better
|
|
-- readability of weaved content - best ignore these
|
|
if(not(multiline_comment)
|
|
and not(inline_comment)
|
|
and content_start > -1
|
|
and content_start < i + 1) then
|
|
content_start = i + 1
|
|
end
|
|
|
|
elseif(at_line_start) then
|
|
last_what = what
|
|
last_nested = nested
|
|
nested = 1
|
|
|
|
-- "\" (escape the next character)
|
|
if( b == CHAR_ESCAPE) then
|
|
-- the line can no longer start with a special char; it is text
|
|
what = "TEXT"
|
|
-- at line start: multiline alternatives or conditions
|
|
elseif(b == CHAR_COND_START and string.byte(text, i - 1) ~= CHAR_ESCAPE) then
|
|
-- one more open
|
|
counted_curly_brackets = counted_curly_brackets + 1
|
|
what = "TEXT"
|
|
elseif(b == CHAR_COND_END and string.byte(text, i - 1) ~= CHAR_ESCAPE) then
|
|
counted_curly_brackets = counted_curly_brackets - 1
|
|
what = "TEXT"
|
|
-- "//" (start of inline comment) or "/* " (start of multiline comment)
|
|
elseif(b == CHAR_SLASH) then
|
|
local b2 = string.byte(text, i + 1)
|
|
if( b2 == CHAR_SLASH) then
|
|
what = "COMMENT" -- inline comment
|
|
-- remember that we are inside a comment
|
|
inline_comment = true
|
|
-- the text of the comment is not part of the content
|
|
content_start = -1
|
|
elseif(b2 == CHAR_STAR) then
|
|
what = "START_OF_MULTILINE_COMMENT"
|
|
-- remember that we are inside a multiline comment
|
|
multiline_comment = true
|
|
-- the text of the multiline comment is not part of the content
|
|
content_start = -1
|
|
else
|
|
what = "ERROR:_UNKNOWN_SYMBOL_FOLLOWING_\"/\""
|
|
end
|
|
-- "*" (choice) - no matter what follows - it's a choice
|
|
elseif(b == CHAR_STAR) then
|
|
if(counted_curly_brackets > 0) then
|
|
-- we are inside a condition or alternate
|
|
-- normally choices would be allowed here in ink - but that'd be
|
|
-- too complicated
|
|
what = "TEXT"
|
|
else
|
|
what = "NORMAL_CHOICE"
|
|
nested = 1
|
|
search_for_nesting = b
|
|
end
|
|
-- "+" (sticky choice) - no mater what follows - it's a sticky choice
|
|
elseif(b == CHAR_PLUS) then
|
|
if(counted_curly_brackets > 0) then
|
|
-- we are inside a condition or alternate
|
|
-- normally choices would be allowed here in ink - but that'd be
|
|
-- too complicated
|
|
what = "TEXT"
|
|
else
|
|
what = "STICKY_CHOICE"
|
|
nested = 1
|
|
search_for_nesting = b
|
|
end
|
|
-- "-" (gather) or "->" divert
|
|
elseif(b == CHAR_MINUS) then
|
|
local b2 = string.byte(text, i + 1)
|
|
if(b2 == CHAR_GREATER) then
|
|
what = "TEXT" --DIVERT"
|
|
elseif(counted_curly_brackets > 0) then
|
|
-- we are inside a condition or alternate
|
|
what = "TEXT"
|
|
else
|
|
-- TODO: we might be inside a multiline if/else/switch branch
|
|
what = "GATHER"
|
|
nested = 1
|
|
search_for_nesting = b
|
|
end
|
|
-- "==" (knot) or "= " (stitch)
|
|
elseif(b == CHAR_EQUAL) then
|
|
local b2 = string.byte(text, i + 1)
|
|
if( b2 == CHAR_EQUAL) then
|
|
what = "KNOT"
|
|
search_for_name = true
|
|
else
|
|
what = "STITCH"
|
|
search_for_name = true
|
|
end
|
|
-- "<>" (glue) apart from that it's a normal text line
|
|
elseif(b == CHAR_SMALLER) then
|
|
local b2 = string.byte(text, i + 1)
|
|
if( b2 == CHAR_GREATER) then
|
|
what = "TEXT_GLUE"
|
|
end
|
|
-- "~ " (code/calculation)
|
|
elseif(b == CHAR_TILDE) then
|
|
local b2 = string.byte(text, i + 1)
|
|
what = "CODE"
|
|
-- "INCLUDE " (code/calculation)
|
|
elseif(b == CHAR_I) then
|
|
local s = string.sub(text, i, i + 7)
|
|
if( s == "INCLUDE ") then
|
|
what = "INCLUDE"
|
|
else
|
|
-- else it is just text starting with a capital I
|
|
what = "TEXT"
|
|
end
|
|
-- "VAR " (variable definition)
|
|
elseif(b == CHAR_V) then
|
|
local s = string.sub(text, i, i + 3)
|
|
if( s == "VAR ") then
|
|
what = "VAR"
|
|
else
|
|
-- else it is just text starting with a capital V
|
|
what = "TEXT"
|
|
end
|
|
-- "CONST " (variable definition)
|
|
elseif(b == CHAR_C) then
|
|
local s = string.sub(text, i, i + 5)
|
|
if( s == "CONST ") then
|
|
what = "CONST"
|
|
else
|
|
-- else it is just text starting with a capital V
|
|
what = "TEXT"
|
|
end
|
|
else
|
|
what = "TEXT"
|
|
end
|
|
-- we have processed the start of the line and found a type
|
|
at_line_start = false
|
|
|
|
-- observation: choices end at the end of a line
|
|
if(what == "TEXT"
|
|
and (content_type == "NORMAL_CHOICE" or content_type == "STICKY_CHOICE")) then
|
|
what = "WEAVE"
|
|
end
|
|
-- the collected content of the last knot, stitch, gather or choice ends
|
|
-- TODO: handle the situation of the last line
|
|
if( what == "NORMAL_CHOICE"
|
|
or what == "STICKY_CHOICE"
|
|
or what == "MAIN_PROGRAM"
|
|
or what == "GATHER"
|
|
or what == "KNOT"
|
|
or what == "STITCH"
|
|
or what == "WEAVE") then
|
|
-- there may have been content in the last line
|
|
if(content_start > -1) then
|
|
content = content..string.sub(text, content_start, i - 1)
|
|
end
|
|
-- remove tailing newlines and blanks
|
|
if(content) then
|
|
content = strip_name(content)
|
|
end
|
|
local divert_to = ""
|
|
if(content_type == "NORMAL_CHOICE" or content_type == "STICKY_CHOICE") then
|
|
local start_1, start = string.find(content, "->")
|
|
if(start) then
|
|
start = start + 1
|
|
while(start < string.len(content)
|
|
and string.byte(content, start) == CHAR_BLANK) do
|
|
start = start + 1
|
|
end
|
|
local ende = start + 1
|
|
while(ende < string.len(content)
|
|
and string.byte(content, ende) ~= CHAR_BLANK
|
|
and string.byte(content, ende) ~= CHAR_NEWLINE) do
|
|
ende = ende + 1
|
|
end
|
|
divert_to = strip_name(string.sub(content, start, ende))
|
|
content = strip_name(string.sub(content, 1, start_1 - 1))
|
|
end
|
|
end
|
|
|
|
local knot = {}
|
|
knot.content_type = content_type
|
|
knot.name = strip_name(name_found or "")
|
|
knot.label = label
|
|
knot.nested = last_nested
|
|
knot.conditions = conditions
|
|
knot.text = content
|
|
knot.divert_to = divert_to
|
|
table.insert(knots, knot)
|
|
|
|
if(not(print_debug)) then
|
|
name_found = nil
|
|
else
|
|
local s = " FOUND: "..content_type
|
|
if(name_found) then
|
|
-- name of a knot or stitch
|
|
-- TODO: strip leading and tailing blanks, tabs and = from found_name
|
|
s = s.." Name: "..tostring(knot.name)
|
|
name_found = nil
|
|
end
|
|
if(last_nested and last_nested > 0) then
|
|
s = s.."\n NESTED: "..tostring(last_nested)
|
|
end
|
|
if(label) then
|
|
s = s.."\n LABEL: ["..tostring(label).."]"
|
|
end
|
|
if(conditions and #conditions > 0) then
|
|
s = s.."\n COND: ["..table.concat(conditions, "] [").."]"
|
|
end
|
|
if(divert_to and divert_to ~= "") then
|
|
s = s.."\n TARGET: ["..tostring(divert_to).."]"
|
|
end
|
|
if(content and content ~= "") then
|
|
s = s.."\n STR: ["..tostring(content).."]:STR\n"
|
|
end
|
|
print(s)
|
|
end
|
|
|
|
last_content_type = content_type
|
|
content_type = what
|
|
-- the old content ends
|
|
content_start = i
|
|
content = ""
|
|
|
|
label = nil
|
|
conditions = {}
|
|
counted_curly_brackets = 0
|
|
end
|
|
end
|
|
-- continue with the next charcter
|
|
i = i + 1
|
|
end
|
|
return knots
|
|
end
|
|
|
|
|
|
|
|
-- TODO: intended for future parsing of ink inline functionality
|
|
parse_ink.inspect_inline = function(text)
|
|
|
|
local CHAR_INLINE_START = string.byte("{", 1)
|
|
local CHAR_INLINE_END = string.byte("}", 1)
|
|
local CHAR_ESCAPE = string.byte("\\", 1)
|
|
local CHAR_SEPERATOR = string.byte("|", 1)
|
|
local CHAR_ML_SEPERATOR = string.byte("-", 1)
|
|
local CHAR_DOPPELPUNKT = string.byte(":", 1)
|
|
local CHAR_NEWLINE = string.byte('\n', 1)
|
|
local CHAR_BLANK = string.byte(" ", 1)
|
|
local CHAR_TAB = string.byte("\t", 1)
|
|
local CHAR_CYCLE = string.byte("&", 1)
|
|
local CHAR_ONCE_ONLY = string.byte("!", 1)
|
|
local CHAR_SHUFFLE = string.byte("~", 1)
|
|
|
|
local inline_starts = {}
|
|
local sep_starts = {}
|
|
local is_multiline = {}
|
|
local level = 0
|
|
local i = 1
|
|
local i_max = string.len(text)
|
|
while(i < i_max) do
|
|
local b = string.byte(text, i)
|
|
if(at_line_start and level > 0 and is_multiline[level]
|
|
and (b ~= CHAR_ML_SEPERATOR and b ~= CHAR_BLANK and b ~= CHAR_TAB)) then
|
|
at_line_start = false
|
|
end
|
|
if(string.byte(text, i-1) == CHAR_ESCAPE) then
|
|
-- do nothing; escape sign
|
|
elseif( b == CHAR_INLINE_START) then
|
|
table.insert(inline_starts, i)
|
|
table.insert(sep_starts, {i})
|
|
table.insert(is_multiline, false)
|
|
level = level + 1
|
|
elseif(level == 0) then
|
|
-- not inside an inline expression; do nothing
|
|
elseif(b == CHAR_NEWLINE) then
|
|
at_line_start = true
|
|
is_multiline[level] = true
|
|
elseif(b == CHAR_ML_SEPERATOR and is_multiline[level] and at_line_start) then
|
|
-- if(#sep_starts[level] == 1 and string.byte(text, sep_starts[level][1] == TODO)
|
|
table.insert(sep_starts[level], i)
|
|
elseif(b == CHAR_SEPERATOR and not(is_multiline[level])) then
|
|
table.insert(sep_starts[level], i)
|
|
elseif(b == CHAR_DOPPELPUNKT and #sep_starts[level] == 1) then
|
|
print("Condition: "..string.sub(text, sep_starts[level][1] + 1, i))
|
|
-- up until now we had the condition
|
|
sep_starts[level][1] = i
|
|
elseif(b == CHAR_INLINE_END) then
|
|
local prefix = ""
|
|
for j = 1, level do
|
|
prefix = " "..prefix
|
|
end
|
|
local t = prefix..string.sub(text, inline_starts[level], i)
|
|
if(is_multiline[level]) then
|
|
print("MULTIL: "..tostring(t))
|
|
else
|
|
print("INLINE: "..tostring(t))
|
|
end
|
|
|
|
table.insert(sep_starts[level], i)
|
|
local max = #sep_starts[level]
|
|
for c, pos in ipairs(sep_starts[level]) do
|
|
if(c < max) then
|
|
local t2 = prefix.." "..tostring(c)..") ".. string.sub(text,
|
|
pos + 1, sep_starts[level][c+1] - 1)
|
|
print(t2)
|
|
end
|
|
end
|
|
table.remove(sep_starts, level)
|
|
table.remove(is_multiline, level)
|
|
|
|
local b2 = string.byte(text, inline_starts[level] + 1)
|
|
-- if(b2 == CHAR_CYCLE) then
|
|
|
|
-- -- get the inline part
|
|
-- j = inline_starts[level] + 1
|
|
-- -- remove leading blanks
|
|
-- while(j < i and string.byte(text, j) == CHAR_BLANK) do
|
|
-- j = j + 1
|
|
-- end
|
|
-- -- remove tailing blanks
|
|
-- k = i
|
|
-- while(k > j and string.byte(text, j) == CHAR_BLANK) do
|
|
-- k = k - 1
|
|
-- end
|
|
--
|
|
table.remove(inline_starts, level)
|
|
level = level - 1
|
|
end
|
|
i = i + 1
|
|
end
|
|
end
|
|
|
|
|
|
-- analyze the intermediate knot data strutcture parse_ink.parse created:
|
|
|
|
-- log to a table (debug_level > 0) and additionally to stdout (debug_level > 1)
|
|
parse_ink.print_debug = function(log_level, log, text)
|
|
if(not(log_level) or log_level < 1) then
|
|
return
|
|
end
|
|
if(log_level > 1) then
|
|
print(text)
|
|
end
|
|
if(not(log) or log_level <= 1) then
|
|
return
|
|
end
|
|
table.insert(log, text)
|
|
end
|
|
|
|
|
|
-- actions and effects are knots in ink but only part of the dialog in NPC dialogs;
|
|
-- find out which of their option knots serves which purpose
|
|
parse_ink.analyze_actions_or_effects = function(knot_list, what_to_analyze, cap_name, log_level, log)
|
|
parse_ink.print_debug(log_level, log, "\nAnalyzing "..what_to_analyze.."s:")
|
|
local success_str = "["..cap_name.." was successful]"
|
|
local failure_str = "["..cap_name.." failed]"
|
|
|
|
for knot_name, knot in pairs(knot_list) do
|
|
parse_ink.print_debug(log_level, log, " Found "..tostring(what_to_analyze)..": "..tostring(knot_name))
|
|
-- examine options (we are mostly intrested in their links)
|
|
for o_name, o_knot in pairs(knot.options) do
|
|
if(o_knot.text and o_knot.text == success_str) then
|
|
knot_list[knot_name].on_success = o_knot.divert_to
|
|
elseif(o_knot.text and o_knot.text == failure_str) then
|
|
knot_list[knot_name].on_failure = o_knot.divert_to
|
|
elseif(o_knot.text and o_knot.text == "[Back]") then
|
|
knot_list[knot_name].back = o_knot.divert_to
|
|
else
|
|
-- log that error regardless of log_level
|
|
parse_ink.print_debug(2, log,
|
|
"ERROR: Unsupported text \""..tostring(o_knot.text)..
|
|
"\" for option \""..tostring(o_name)..
|
|
"\" of "..what_to_analyze.." in knot name \""..
|
|
tostring(knot_name).."\".")
|
|
end
|
|
-- print("o_knot "..tostring(o_name).." target: "..tostring(o_knot.divert_to or "- none -"))
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- this serarches for the *real* target dialog;
|
|
-- it populates option_knot[option_name].actions and option_knot.effects with the
|
|
-- actions and effects it encountered
|
|
-- (there had to be extra knots inserted for actions and effects - which are no dialogs in NPC
|
|
-- dialog sense but are required as ink knots so that divert_to can be used in a sensible way;
|
|
-- after all actions and effects can fail)
|
|
parse_ink.find_real_target_dialog = function(option_knot, target, prefix, start_dialog, dialogs, actions, effects, log_level, log)
|
|
-- print(" option_knot: "..tostring(option_knot).." Next link: "..tostring(target))
|
|
-- go back to the start
|
|
if(not(target) or target == "" or target == start_dialog) then
|
|
return start_dialog
|
|
-- we found the end of the conversation - or another dialog
|
|
elseif(target == "END" or dialogs[target]) then
|
|
return target
|
|
-- we found a special dialog
|
|
elseif(yl_speak_up.is_special_dialog(parse_ink.strip_prefix(target, prefix))) then
|
|
return parse_ink.strip_prefix(target, prefix)
|
|
-- we found an action
|
|
elseif(actions[target]) then
|
|
-- avoid loops by restricting the number of actions visited
|
|
if(#option_knot.actions > 0) then
|
|
parse_ink.print_debug(2, log,
|
|
"WARNING: Aborting finding real target dialog. "..
|
|
"Only one action allowed per option. Using start dialog instead.")
|
|
return start_dialog
|
|
end
|
|
-- remember that we visited this action
|
|
table.insert(option_knot.actions, target)
|
|
-- recursively find the option that corresponds to the success option
|
|
return parse_ink.find_real_target_dialog(option_knot, actions[target].on_success, prefix, start_dialog, dialogs, actions, effects, log_level, log)
|
|
-- we found an effect
|
|
elseif(effects[target]) then
|
|
-- avoid loops by restricting the number of effects visited
|
|
if(#option_knot.effects > 20) then
|
|
parse_ink.print_debug(2, log,
|
|
"WARNING: Aborting finding real target dialog. "..
|
|
"Too many effects chained. Using start dialog instead.")
|
|
return start_dialog
|
|
end
|
|
-- remember that we visited this efffect
|
|
table.insert(option_knot.effects, target)
|
|
-- recursively find the option that corresponds to the success option
|
|
return parse_ink.find_real_target_dialog(option_knot, effects[target].on_success, prefix, start_dialog, dialogs, actions, effects, log_level, log)
|
|
-- the name was not found
|
|
else
|
|
parse_ink.print_debug(2, log,
|
|
"WARNING: Aborting finding real target dialog. "..
|
|
"Could not find target dialog \""..tostring(target).."\". Using start dialog.")
|
|
return start_dialog
|
|
end
|
|
end
|
|
|
|
|
|
parse_ink.print_knots = function(knots)
|
|
-- debug output
|
|
for i, knot in ipairs(knots) do
|
|
-- TODO: knot.conditions
|
|
print("Knot: "..tostring(knot.content_type).." ["..tostring(knot.nested).."] "..
|
|
tostring(knot.name).." ("..tostring(knot.label)..")"..
|
|
"\n Text: "..tostring(knot.text))
|
|
end
|
|
end
|
|
|
|
|
|
-- analyzes all knots and populates the lists knot_data, dialogs, actions and effects
|
|
-- with the names of the knots
|
|
parse_ink.analyze_knots_by_type = function(knots, log_level, log)
|
|
-- start interpreting the meaning of the knots
|
|
local knot_data = {}
|
|
local dialogs = {}
|
|
local actions = {}
|
|
local effects = {}
|
|
local start_knot = nil
|
|
local last_knot_name = nil
|
|
local dialog_list = {}
|
|
parse_ink.print_debug(log_level, log, "\nIdentifying dialogs, actions, effects and options:")
|
|
for i, knot in ipairs(knots) do
|
|
if(knot.content_type == "KNOT") then
|
|
if(knot_data[knot_name]) then
|
|
parse_ink.print_debug(2, log,
|
|
"WARNING: Knot "..tostring(knot_name).." already defined.\n"..
|
|
"Using this new data and discarding old.")
|
|
end
|
|
knot_data[knot.name] = knot
|
|
knot_data[knot.name].options = {}
|
|
knot_data[knot.name].option_list = {}
|
|
last_knot_name = knot.name
|
|
-- actions and effects are just simulated as knots - they do not represent
|
|
-- any actual dialogs in the NPC sense
|
|
if(string.sub(knot.text, 1, 9) == ":action: ") then
|
|
actions[knot.name] = knot
|
|
parse_ink.print_debug(log_level, log, "ACTION: "..tostring(knot.name).."\n")
|
|
elseif(string.sub(knot.text, 1, 9) == ":effect: ") then
|
|
effects[knot.name] = knot
|
|
parse_ink.print_debug(log_level, log, "EFFECT: "..tostring(knot.name).."\n")
|
|
else
|
|
dialogs[knot.name] = knot
|
|
-- remember the sort order of the options
|
|
parse_ink.print_debug(log_level, log, "=== "..tostring(knot.name).." ===\n")
|
|
-- keep the order of appearance of the dialog names
|
|
table.insert(dialog_list, knot.name)
|
|
end
|
|
elseif(last_knot_name
|
|
and (knot.content_type == "NORMAL_CHOICE" or knot.content_type == "STICKY_CHOICE")) then
|
|
-- we need a label for the option so that it can be referenced correctly
|
|
-- between ink and NPC dialogs; if none is provided (new dialog), then we'll
|
|
-- create a temporary name (there may also be grey_out_ options);
|
|
-- [Farewell!] is also still included
|
|
if(not(knot.label) or knot.label == "") then
|
|
knot.label = "new_"..tostring(i)
|
|
end
|
|
knot_data[last_knot_name].options[knot.label] = knot
|
|
table.insert(knot_data[last_knot_name].option_list, knot.label)
|
|
parse_ink.print_debug(log_level, log, "- "..tostring(knot.label or "ERROR")..": "..knot.text)
|
|
elseif(knot.content_type == "WEAVE"
|
|
-- this is not really a WEAVE - it's the effects list and divert of an option
|
|
and i > 1
|
|
and (knots[i-1].content_type == "NORMAL_CHOICE"
|
|
or knots[i-1].content_type == "STICKY_CHOICE")
|
|
and (knots[i-1].divert_to
|
|
or knots[i-1].divert_to == "")) then
|
|
local p = string.find(knot.text or "", "-> ")
|
|
if(p) then
|
|
knots[i-1].divert_to = string.sub(knot.text or "", p + 3)
|
|
if(p > 4) then
|
|
knots[i-1].effect_list = string.sub(knot.text, 1, p - 2)
|
|
else
|
|
knots[i-1].effect_list = ""
|
|
parse_ink.print_debug(2, log,
|
|
"ERROR: WEAVE with no text apart from divert: ["..
|
|
tostring(knot.text).."]")
|
|
end
|
|
elseif(knot.text) then
|
|
knots[i-1].text2 = knot.text
|
|
end
|
|
elseif(knot.content_type == "MAIN_PROGRAM") then
|
|
start_knot = knot
|
|
else
|
|
-- TODO "GATHER"
|
|
-- TODO "STITCH"
|
|
-- TODO "WEAVE" (real ones)
|
|
parse_ink.print_debug(2, log,
|
|
"ERROR: Cannot handle knots of type \""..tostring(knot.content_type).."\" yet.")
|
|
end
|
|
end
|
|
|
|
return {knot_data = knot_data, dialogs = dialogs, actions = actions, effects = effects, start_knot = start_knot, dialog_list = dialog_list}
|
|
end
|
|
|
|
|
|
|
|
parse_ink.find_dialog_name_prefix = function(start_knot, log_level, log)
|
|
-- the ink program has to start with "-> PREFIXd_end" (with PREFIX usually beeing something
|
|
-- like n_<NPC_ID>, so i.e. "-> n_105_d_end" might be a valid first line
|
|
-- Export to ink supports prefixes.
|
|
-- Prefixes are particulary useful if you want to combine the texts of multiple NPC in one
|
|
-- ink program for testing.
|
|
local prefix = ""
|
|
if(not(start_knot) or not(start_knot.text)) then
|
|
parse_ink.print_debug(2, log,
|
|
"ERROR: MAIN_PROGRAM/divert_to start knot not found. Please start your ink file "..
|
|
"with a divert to the main dialog!")
|
|
else
|
|
prefix = start_knot.text
|
|
end
|
|
if(string.sub(prefix, string.len(prefix) - 4) ~= "d_end"
|
|
or string.sub(prefix, 1, 3) ~= "-> ") then
|
|
parse_ink.print_debug(2, log,
|
|
"ERROR: Your MAIN_PROGRAM/divert_to ought to contain a prefix (usually n_<NPC_ID>_) "..
|
|
", followed by d_end. Example: \"-> n_105_d_end\". You used: \""..
|
|
tostring(prefix).."\".")
|
|
else
|
|
prefix = string.sub(prefix, 4, string.len(prefix) - 5)
|
|
end
|
|
parse_ink.print_debug(log_level, log, "\nUsing prefix: "..tostring(prefix))
|
|
return prefix
|
|
end
|
|
|
|
|
|
parse_ink.find_start_dialog_name = function(prefix, dialogs, log_level, log)
|
|
-- find out the real start dialog
|
|
local start_dialog = prefix.."d_end"
|
|
if(dialogs[start_dialog]) then
|
|
for o_name, o_knot in pairs(dialogs[start_dialog].options) do
|
|
-- the last option that diverts to anything else than END is what we're looking for
|
|
if(o_knot.divert_to and o_knot.divert_to ~= "END") then
|
|
start_dialog = o_knot.divert_to
|
|
end
|
|
end
|
|
end
|
|
parse_ink.print_debug(log_level, log, "\nUsing start dialog: "..tostring(start_dialog))
|
|
return start_dialog
|
|
end
|
|
|
|
|
|
-- options link to *actions* and *effects* - because that is necessary to simulate
|
|
-- NPC behaviour in inc. But for importing that data, we need the real target link
|
|
-- that happens when all actions and effects were successful.
|
|
parse_ink.adjust_links = function(dialogs, actions, effects, start_dialog, prefix, log_level, log)
|
|
-- determine on_success, on_failure and back links for actions and effects
|
|
parse_ink.analyze_actions_or_effects(actions, "action", "Action", log_level, log)
|
|
parse_ink.analyze_actions_or_effects(effects, "effect", "Effect", log_level, log)
|
|
|
|
parse_ink.print_debug(log_level, log,
|
|
"\nAssigning actions and effects to their options and adjusting those options' target dialog:")
|
|
-- now check if all options are linked properly; for this we check dialogs only
|
|
-- (actions and effects are referenced through their options)
|
|
for d_name, dialog_knot in pairs(dialogs) do
|
|
-- only the options have links
|
|
for o_name, o_knot in pairs(dialog_knot.options) do
|
|
-- actions that belong to this dialog
|
|
o_knot.actions = {}
|
|
-- effects that belong to this dialog
|
|
o_knot.effects = {}
|
|
local old_target = o_knot.divert_to
|
|
-- this function also fills the .actions and .effects table with any actions
|
|
-- and effects it finds on its way while following the successful option of
|
|
-- each action and effect encountered
|
|
local target = parse_ink.find_real_target_dialog(o_knot, o_knot.divert_to, prefix, start_dialog, dialogs, actions, effects, log_level, log)
|
|
if(target ~= o_knot.divert_to) then
|
|
parse_ink.print_debug(log_level, log,
|
|
"INFO: Changing target dialog for option \""..tostring(o_name)..
|
|
"\" of dialog "..tostring(d_name)..
|
|
"\n from "..tostring(o_knot.divert_to)..
|
|
"\n to: "..tostring(target)..
|
|
"\n Actions: "..
|
|
table.concat(o_knot.actions, ", ")..
|
|
"\n Effects: "..
|
|
table.concat(o_knot.effects, ", "))
|
|
o_knot.divert_to = target
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
parse_ink.strip_prefix = function(text, prefix)
|
|
local i = string.len(prefix)
|
|
if(string.sub(text or "", 1, i) == prefix) then
|
|
return string.sub(text, i+1)
|
|
end
|
|
return text
|
|
end
|
|
|
|
|
|
-- strip "[" and "]" enclosing text
|
|
parse_ink.strip_brackets = function(text)
|
|
if(not(text)) then
|
|
return
|
|
end
|
|
local p1 = 1
|
|
if(string.sub(text, 1, 1) == "[") then
|
|
p1 = 2
|
|
end
|
|
local p2 = string.len(text)
|
|
-- remove tailing "]" (needed for ink)
|
|
if(string.sub(text, string.len(text)) == "]") then
|
|
p2 = p2 - 1
|
|
end
|
|
return string.sub(text, p1, p2)
|
|
end
|
|
|
|
|
|
|
|
-- actually doing the import
|
|
parse_ink.import_dialogs = function(dialog, dialogs, actions, effects, start_dialog, prefix, orig_dialog_list, log_level, log)
|
|
parse_ink.print_debug(log_level, log, "\n\nStarting dialog import:")
|
|
|
|
local dialog_list = {}
|
|
-- remove the extra wrapper dialog (added for ink) from the list
|
|
for i, d_name in ipairs(orig_dialog_list) do
|
|
if(d_name ~= prefix.."d_end") then
|
|
table.insert(dialog_list, d_name)
|
|
end
|
|
end
|
|
-- we need to add the dialogs as such first so that target_dialog will work
|
|
local d_sort = 1
|
|
for i, d_name in ipairs(dialog_list) do
|
|
local dialog_knot = dialogs[d_name]
|
|
local dialog_name = parse_ink.strip_prefix(d_name, prefix)
|
|
|
|
local d_id = yl_speak_up.update_dialog(log, dialog, dialog_name, dialog_knot.text)
|
|
dialog_knot.d_id = d_id
|
|
|
|
-- adjust d_sort so that it is the same order as in the import
|
|
if(d_id and dialog.n_dialogs[d_id] and d_name ~= start_dialog) then
|
|
dialog.n_dialogs[d_id].d_sort = d_sort
|
|
d_sort = d_sort + 1
|
|
end
|
|
end
|
|
|
|
-- now we can add the options
|
|
for i, d_name in ipairs(dialog_list) do
|
|
local dialog_knot = dialogs[d_name]
|
|
local dialog_name = parse_ink.strip_prefix(d_name, prefix)
|
|
local d_id = dialog_knot.d_id
|
|
|
|
-- identify and remove options that are grey_out_ texts
|
|
-- and store them in table greyed_out
|
|
local greyed_out = {}
|
|
local tmp_option_list = {}
|
|
for i, o_name in ipairs(dialog_knot.option_list) do
|
|
if(string.sub(o_name, 1, 9) == "grey_out_") then
|
|
local o_id = string.sub(o_name, 10)
|
|
greyed_out[o_id] = parse_ink.strip_brackets(dialog_knot.options[o_name].text)
|
|
else
|
|
table.insert(tmp_option_list, o_name)
|
|
end
|
|
end
|
|
|
|
-- o_random belongs to the dialog; if one option is randomly, then the entire dialog is
|
|
local is_random = false
|
|
for i, o_name in ipairs(tmp_option_list) do
|
|
if(string.sub(o_name, 1, 9) == "randomly_") then
|
|
is_random = true
|
|
end
|
|
end
|
|
-- we now have all information to decude about the dialogs' o_random
|
|
if(is_random and not(dialog.n_dialogs[d_id].o_random)) then
|
|
parse_ink.print_debug(log_level, log, "Changed DIALOG \""..tostring(d_id).."\" to RANDOMLY SELECTED.")
|
|
dialog.n_dialogs[d_id].o_random = 1
|
|
elseif(not(is_random)
|
|
and dialog.n_dialogs[d_id]
|
|
and dialog.n_dialogs[d_id].o_random) then
|
|
parse_ink.print_debug(log_level, log, "Changed DIALOG \""..tostring(d_id).."\" back to normal (was: RANDOMLY SELECTED).")
|
|
dialog.n_dialogs[d_id].o_random = nil
|
|
end
|
|
|
|
|
|
for i, o_name in ipairs(tmp_option_list) do
|
|
local o_knot = dialog_knot.options[o_name]
|
|
local option_text = o_knot.text
|
|
local visit_only_once = (o_knot.content_type == "NORMAL_CHOICE")
|
|
local alternate_text = nil
|
|
local target_dialog = o_knot.divert_to
|
|
target_dialog = parse_ink.strip_prefix(target_dialog, prefix)
|
|
|
|
-- remove leading "[" (needed for ink but not for dialogs)
|
|
option_text = parse_ink.strip_brackets(option_text)
|
|
|
|
-- if(o_knot.effect_list and o_knot.effect_list ~= "") then
|
|
-- print("EFFECT LIST: "..tostring(d_id).." "..tostring(o_name).." "..tostring(o_knot.effect_list))
|
|
-- end
|
|
-- extract the alternate text
|
|
local p = string.find(o_knot.effect_list or "", "\n#")
|
|
if(p and p > 1 and string.sub(o_knot.effect_list, 1, 1) ~= "#") then
|
|
alternate_text = string.sub(o_knot.effect_list or "", 1, p)
|
|
else
|
|
-- TODO: the rest of this is the effect list as comment
|
|
p = 1
|
|
end
|
|
-- ignore the automaticly added Farewell!-option
|
|
if(option_text ~= "Farewell!" or target_dialog == prefix.."d_end") then
|
|
local o_id = yl_speak_up.update_dialog_option(log, dialog, d_id, o_name,
|
|
option_text, greyed_out[o_name], target_dialog,
|
|
alternate_text, visit_only_once,
|
|
-- make sure o_sort gets set approprately:
|
|
i)
|
|
end
|
|
-- TODO: deal with preconditions
|
|
-- TODO: deal with actions
|
|
-- TODO: deal with effects
|
|
-- TODO: effects are not parsed yet
|
|
end
|
|
-- the dialog may already contain further options that are no longer needed;
|
|
-- those need o_sort set and a false precondition; update_dialog did prepare the
|
|
-- options already for this, and this function here does the necessary cleanup:
|
|
yl_speak_up.update_dialog_options_completed(log, dialog, dialog_knot.d_id)
|
|
end
|
|
|
|
-- make sure the right start dialog is set
|
|
yl_speak_up.update_start_dialog(log, dialog, parse_ink.strip_prefix(start_dialog, prefix), d_sort)
|
|
|
|
return dialog
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
-- helper function - yl_speak_up includes this, but we here not
|
|
-- TODO: use the same for export
|
|
yl_speak_up.show_effect = function(r)
|
|
local text = r.r_type or "ERROR: no r.r_type! "
|
|
for k, v in pairs(r or {}) do
|
|
if(k == "r_craft_grid") then
|
|
text = text.." "..tostring(k)..": "..table.concat(v or {}, ",")
|
|
elseif(k ~= "r_id" and k ~= "r_type") then
|
|
text = text.." "..tostring(k)..": "..tostring(v)
|
|
end
|
|
end
|
|
return text
|
|
end
|
|
|
|
|
|
-- parameters:
|
|
-- dialog a yl_speak_up NPC dialog data structure containing the dialogs of the NPC
|
|
-- text the ink program that is to be parsed
|
|
-- log_level how detailled a report shall be created in the table log
|
|
-- log table - log entries will be appended to it
|
|
parse_ink.import_from_ink = function(dialog, text, log_level, log)
|
|
-- parse the ink program;
|
|
-- the "false" stands for "do not print out the parsed lines to stdout"
|
|
local knots = parse_ink.parse(text, false)
|
|
|
|
-- prepare the knots so that they can be turned into dialogs
|
|
local res = parse_ink.analyze_knots_by_type(knots, log_level, log)
|
|
-- the prefix helps to combine multiple NPC dialogs into one ink program; it is usally n_<ID>_
|
|
local prefix = parse_ink.find_dialog_name_prefix(res.start_knot, log_level, log)
|
|
-- which dialog is the start dialog?
|
|
local start_dialog = parse_ink.find_start_dialog_name(prefix, res.dialogs, log_level, log)
|
|
-- if there is an action or effect of the type "If the previous effect failed, ..", then the
|
|
-- export_to_ink will create extra knots for these actions and effects - and adjust the links;
|
|
-- this code here just turns it back to the target dialog that is shown when the action and all
|
|
-- effects were successful
|
|
parse_ink.adjust_links(res.dialogs, res.actions, res.effects, start_dialog, prefix, log_level, log)
|
|
|
|
-- now we're ready to actually import this into the dialog datastructure; uses functions_dialog.lua
|
|
parse_ink.import_dialogs(dialog, res.dialogs, res.actions, res.effects, start_dialog, prefix, res.dialog_list, log_level, log)
|
|
|
|
return dialog
|
|
end
|
|
|