--[[ MARK: - Template Engine Builds a formspec from the contents of a game-provided main menu file. ================================================================================= Menu File Documentation ================================================================================= Menu files are essentially just formspecs, but with special semantics and extended syntax. First, menu files are made up of a number of dialog definitions. A dialog definition is basically an HTML tag, where the tag name is the name of the dialog. For example this: ```
label[2,2;A label]
button[1,1;4,1;button;This is a button] ``` will define a dialog named 'main', with a label in it, and a dialog named 'other', with a button in it. Every menu file must have a dialog named 'main', which serves as the entry point of the game's menu. Note that to prevent dialog definitions from conflicting with the contents of hypertext[] elements, they must not have leading whitespace before the opening delimiter. Being able to define these other dialogs would be pretty pointless if they couldn't be used for anything. Accordingly, you can use standard formspec actions, e.g. buttons, to segue to a different dialog. To do this, set the action name to '.show_dialog_', where is the name of the dialog you want to open. You can also use '.overlay_dialog_' to draw the target dialog on top of the current dialog, and '.unoverlay_dialog' to hide it again. There are several other action patterns that invoke special behavior: - - '.play': Start the game. - '.play_with_world_named_': Start the game on the specified world. If the world in question does not exist, it will be created. It is also possible to define metadata for the main menu. To do this, create a dialog section named 'meta'. The contents of this section will then be parsed in the same way as minetest.conf instead of being treated as a dialog. Available metadata options are: - 'enable_clouds': true/false (defaults to false) Now, all this is good when we know exactly what content we want on the main menu. But what if we want to render e.g. a list of worlds, or even a list of game modes? This can be done using the @foreach construct. @foreach takes in a list of values, the name of a JSON file, or a reference to a menu-exposed list. The major advantage of a foreach loop, however, is interpolation. Inside a foreach loop, the pattern '${}' will be replaces with the result of . Inside an expression, you can reference variables as '@'. Which variables are available depends on the iterated list. In the case of a list literal, the current item is exposed as a variable named @item, while for a JSON file, each key in the file's top-level object will correspond to a variable. (Note that variable names may only contain letters or numbers, even if mapped from a JSON file.) The menu-provided lists are: - @WORLDS: The list of worlds for this game. Exposes @name (the world name), @path (the world's full absolute path), @gameid (the ID of the current game), and @selected (whether the world is currently selected). - @MODS: The list of installed mods that are not incompatible with this game. Exposes @name, @title, @description, @author, @path, @depends, and @optional_depends. - @WORLDMODS: The list of mods installed on the current world. No-op if no world is selected. Expressions also support rudimentary mathematical operations, namely addition (+), 'subtraction' (+-), multiplication (*), division (/), and exponentiation (^). Trying to perform math on a non-tonumber()-able variable will treat the variable as 0. As an example, this: ``` label[1,1;Worlds:] @foreach:$WORLDS:name label[1,${@i + 1};World named ${@name}] @endforeach:name @foreach:[one,two,three]:name2 label[4,${@i + 1};Item ${@i} is named: ${@item}] @endforeach:name2 ``` will: - Create a "Worlds" label at 1,1; - Create a label for every world with that world's name and a Y coordinate that corresponds to the world's position in the list, plus 1 so these labels start below the existing label. - Create labels for "one", "two", and "three" that display the item's value and its position in the list, with similarly increasing Y-coordinates. Besides foreach loops, menu files also support conditionals. Conditionals are written as '@if:: ... @else: ... @endif:', where is any expression. The conditional will be replaced with the contents of its first block if the condition evaluates to non-zero, and the contents of its second block otherwise. Note that the second block may not be omitted due to how the parser works. Example: ``` @if: @name = test : @else: @endif: ``` Notes: - The only uniqueness requirements for the name of a block is that it not be the name of a statment of the same type contained in the body of that block. This is so that the parser knows which `end` belongs to which block without having to manage state. Note: Because of the way the main menu works, image paths must be specified in full. To make this non-painful, when referencing images use '$ASSET_PATH/' instead of just the image name. $ASSET_PATH will be replaced with the actual path to the game's menu/ directory, so images used by the main menu should be stored there. You can use $ASSET_PATH in any context. Additionally, $DEFAULT_ASSET_PATH is the path to the builtin assets folder, and $NO_IMAGE is the path to blank.png, in case you need to stylistically unset a background image defined by a global style. --]] local fe = minetest.formspec_escape local hte = minetest.hypertext_escape local function build_template_dialog(fs, depth) if fs:trim() == "" then return end local dialog = {} local i = 0 -- "(.-\n?)%s-@foreach:([^:]+):(%l*)\n(.-)\n%s-@endforeach:%3(\n?.*)" -- Extract foreach loops local prev = 0 while i < 1000 do local fe_start, fe_end, fe_pattern, fe_name, fe_content = fs:find("@foreach:([^:]+):(%w-)\n(.-)\n%s-@endforeach:%2", prev) local if_start, if_end, if_expr, if_name, if_content, else_content = fs:find("@if:([^:]+):(%w-)\n(.-)\n%s-@else:%2\n(.-)\n%s-@endif:%2", prev) -- print(string.rep("-", 20)..(depth or 0)) if not fe_start and not if_start then break end if fe_start and fe_start < (if_start or math.huge) then -- print("for each "..pattern.." ("..name..")\n"..content.."\nend for each ("..name..")") dialog[#dialog +1] = build_template_dialog(fs:sub(prev +1, fe_start -1), (depth or 0) +1) dialog[#dialog +1] = { foreach = fe_pattern:trim(), name = fe_name, content = build_template_dialog(fe_content:trim(), (depth or 0) +1) } prev = fe_end i = i +1 end if if_start and if_start < (fe_start or math.huge) then -- print("if "..expr.." ("..name..")\n"..content.."\nend if ("..name..")") dialog[#dialog +1] = build_template_dialog(fs:sub(prev +1, if_start -1), (depth or 0) +1) dialog[#dialog +1] = { condition = if_expr:trim(), name = if_name, content = build_template_dialog(if_content:trim(), (depth or 0) +1), else_content = build_template_dialog(else_content:trim(), (depth or 0) +1) } prev = if_end i = i +1 end end if prev > 1 then dialog[#dialog +1] = build_template_dialog(fs:sub(prev +1)) end if #dialog == 1 then dialog = dialog[1] end if i == 0 then dialog = fs end -- minetest.log(dump(dialog, " ")) return dialog end function build_game_menu(input) -- MARK: Environment variables input = "\n"..input :gsub("%$ASSET_PATH", fe(state.current_game.path.."/menu")) :gsub("%$DEFAULT_ASSET_PATH", fe(assets:sub(1, #assets -1))) :gsub("%$NO_IMAGE", fe(default_textures.."blank.png")) :gsub("/%*.-%*/", "") local menu = {} for name, fs in input:gmatch("%f[^\n]<(%l+)>(.-)%f[^\n]") do if name == "meta" then menu[name] = parse_conf_text(fs) else menu[name] = build_template_dialog(fs) end end -- print(dump(menu, " ")) return menu end -- Split a template expression into a binary-tree node. local function split_template_expression(expr) -- These are basically the operator definitions, listed from lowest -- (evaluated last) to highest (evaluated first) precedence. local a, b, op -- Or if not a then a, b, op = expr:find("(|)") end -- And if not a then a, b, op = expr:find("(&)") end -- Greater than if not a then a, b, op = expr:find("(>)") end -- Greater than or equal to if not a then a, b, op = expr:find("(>=)") end -- Less than if not a then a, b, op = expr:find("(<)") end -- Less than or equal to if not a then a, b, op = expr:find("(<=)") end -- Equal to if not a then a, b, op = expr:find("(==)") end if not a then a, b, op = expr:find("(=)") end -- Addition if not a then a, b, op = expr:find("(+)") end -- Multiplication if not a then a, b, op = expr:find("(*)") end -- Division if not a then a, b, op = expr:find("(/)") end -- Modulo if not a then a, b, op = expr:find("(%%)") end -- Exponent if not a then a, b, op = expr:find("(%^)") end if not a then return {value = expr} end return { op = op, lhs = split_template_expression(expr:sub(1, a -1)), rhs = split_template_expression(expr:sub(b +1)) } end -- Reduce a template expression from a binary-tree node into a single value. local function reduce_template_expression(tree) if tree.op == "|" then return (reduce_template_expression(tree.lhs) ~= "0" or reduce_template_expression(tree.rhs) ~= "0") and "1" or "0" elseif tree.op == "&" then return (reduce_template_expression(tree.lhs) ~= "0" and reduce_template_expression(tree.rhs) ~= "0") and "1" or "0" elseif tree.op == ">" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) > (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0" elseif tree.op == ">=" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) >= (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0" elseif tree.op == "<" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) < (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0" elseif tree.op == "<=" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) <= (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0" elseif tree.op == "=" or tree.op == "==" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) == (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0" elseif tree.op == "+" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) + (tonumber(reduce_template_expression(tree.rhs)) or 0) elseif tree.op == "*" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) * (tonumber(reduce_template_expression(tree.rhs)) or 0) elseif tree.op == "/" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) / (tonumber(reduce_template_expression(tree.rhs)) or 0) elseif tree.op == "%" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) % (tonumber(reduce_template_expression(tree.rhs)) or 0) elseif tree.op == "^" then return (tonumber(reduce_template_expression(tree.lhs)) or 0) ^ (tonumber(reduce_template_expression(tree.rhs)) or 0) else return tree.value end end -- Evaluate an interpolation expression, with an optional table of variables. local function evaluate_template_expression(expr, vars, depth) if expr == "" then return "0" end if not depth then depth = 0 end -- This handles the case where vars is omitted, because then it ends up -- just setting vars to an empty table. if type(vars) ~= "table" then vars = {item = vars} end -- Check for operators early, because variables may contain punctuation when expanded. -- A class is used instead of %p because %p matches @, which is used for variables. local has_operators = expr:find("[|&><=+*/%%^]") -- Expand all variables so we can deal with a constexpr. local offset = 1 while offset < 100000 do local a, b, name = expr:find("@([%a_]+)", offset) if not a then break end -- If referencing an undefined variable, default to 0 because it's safest that way. local result = minetest.formspec_escape(tostring(vars[name] or "0")) expr = expr:sub(1, a -1)..result..expr:sub(b +1) offset = a +#result end -- If there are no operators, this is a constant expression and we can just return. if not has_operators then return expr end -- Condense sub-expressions. local offset = 1 while offset < 100000 do local a, b, se = expr:find("(%b())", offset) if not a then break end se = se:gsub("^%((.*)%)$", "%1") local result = evaluate_template_expression(se, vars, depth +1) expr = expr:sub(1, a -1)..result..expr:sub(b +1) offset = a +#result end -- Expression parsing local tree = split_template_expression(expr) return tostring(reduce_template_expression(tree)) end -- Do variable interpolation for the given formspec using the provided variable table. local function evaluate_template_block(fs, vars) local offset = 0 while offset < 100000 do local s_start, s_end, s_name, s_expr = fs:find("@set:@?([%w_]+):([^\n]+)", offset) local i_start, i_end, i_expr = fs:find("%${([^}]-)}", offset) if not s_start and not i_start then break end if s_start and s_start < (i_start or math.huge) then -- Assignment statements vars[s_name] = evaluate_template_expression(s_expr, vars) fs = fs:sub(1, s_start -1)..fs:sub(s_end +1) offset = s_start elseif i_start then -- Interpolations local result = evaluate_template_expression(i_expr, vars) fs = fs:sub(1, i_start -1)..result..fs:sub(i_end +1) offset = i_start +#result end end return fs end -- Interpret the list expression of a foreach loop, then iterate. local function evaluate_template_foreach(loop, vars) local out = "" local list = {} if loop.foreach:sub(1,1) == "[" then list = loop.foreach:gsub("^%[(.*)%]$", "%1"):split(",") elseif loop.foreach:sub(1, 1) == "@" then local var = loop.foreach:sub(2) -- Builtin variables take precedence. if var == "WORLDS" then list = get_worlds_for_game(state.current_game.id) elseif var == "MODS" then list = get_mods_for_game(state.current_game.id) elseif var == "WORLDMODS" then list = state.menu_vars.selected_world and get_mods_for_world(state.menu_vars.selected_world) or {} else list = vars[var] end end for i, x in ipairs(list) do local vars2 = {} if type(x) ~= "table" then vars2.item = x else for k, v in pairs(x) do vars2[k] = v end end vars.i = i out = out..evaluate_game_dialog(loop.content, setmetatable(vars2, {__index = vars, __newindex = vars})).."\n" end return out end -- Determine which branch of an if statement should be evaluated. local function evaluate_template_conditional(cond, vars) local out = "" local list = {} local condition = evaluate_template_expression(cond.condition, vars) if condition ~= "0" then out = out..evaluate_game_dialog(cond.content, vars).."\n" else out = out..evaluate_game_dialog(cond.else_content, vars).."\n" end -- print("Evaluated condition `"..cond.condition.."` as "..out) return out end -- Process the syntax tree for a game dialog and output the resulting string. function evaluate_game_dialog(dialog, vars) local out = "" if not dialog then return out end if type(dialog) == "string" then return evaluate_template_block(dialog, vars) elseif dialog.condition then out = out..evaluate_template_conditional(dialog, vars) elseif dialog.foreach then out = out..evaluate_template_foreach(dialog, vars) else for _, c in ipairs(dialog) do out = out..evaluate_game_dialog(c, vars) end end return out end template = {} function template.build_expr(expr) return {type = "expr", value = expr} end function template.build(menu) -- First pass: Collect all tokens into a flat list local block = {} local item local offset = 1 local pos = 1 while true do local matched = false local stmt_start, stmt_end, stmt = menu:find("{%%%s*(.-)%s*%%}", pos) local expr_start, expr_end, expr = menu:find("{{(.-)}}", pos) local comment_start, comment_end, comment = menu:find("{#(.-)#}", pos) -- Statements if stmt_start and stmt_start < (expr_start or math.huge) and stmt_start < (comment_start or math.huge) then block[#block +1] = menu:sub(offset, stmt_start -1) local verb = stmt:match "^(%a+)" if verb == "if" then item = { type = "if", parent = block, conditions = {{condition = template.build_expr(stmt:match "if%s*(.*)"), body = {}}} } block[#block +1] = item block = item.conditions[1].body elseif verb == "elseif" then local x = { condition = template.build_expr(stmt:match "if%s*(.*)"), body = {} } item.conditions[#item.conditions +1] = x block = x.body elseif verb == "else" then local x = { condition = template.build_expr("true"), body = {} } item.conditions[#item.conditions +1] = x block = x.body elseif verb == "endif" then block = item.parent elseif verb == "for" then elseif verb == "endfor" then elseif verb == "macro" then elseif verb == "endmacro" then elseif verb == "set" then elseif verb == "endset" then elseif verb == "include" then end print(verb) offset = stmt_end +1 pos = stmt_end matched = true end -- Interpolations if expr_start and expr_start < (stmt_start or math.huge) and expr_start < (comment_start or math.huge) then block[#block +1] = menu:sub(offset, expr_start -1) block[#block +1] = template.build_expr(expr) offset = expr_end +1 pos = expr_end matched = true end -- Comments if comment_start and comment_start < (stmt_start or math.huge) and comment_start < (expr_start or math.huge) then offset = expr_end pos = expr_end matched = true end if not matched then break end end return block end function template.evaluate_expr(expr) if expr.type == "" then else return false end end function template.evaluate(dialog, vars) local out = "" for _, item in ipairs(dialog) do if type(item) == "string" then out = out..item else if item.type == "if" then for _, cond in ipairs(item.conditions) do if template.evaluate_expr(cond.condition) then out = out..template.evaluate(cond.body) break end end elseif item.type == "for" then end end end return out end print(template.evaluate(template.build [[ This is a {{blah}}. {% if foo %} Foo {% elseif bar %} Bar {% else %} Baz {% endif %} {% for x in [a,b,c] %} The item is {{ x }}! Uppercase: {{ x:upper() }} {% endfor %} ]], { foo = false }))