minetest-menu/templates.lua
2025-10-02 17:46:57 -04:00

589 lines
21 KiB
Lua

--[[ 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:
```
<main>
label[2,2;A label]
</main>
<other>
button[1,1;4,1;button;This is a button]
</other>
```
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_<name>', where <name> is the name of the dialog you want to open.
You can also use '.overlay_dialog_<name>' 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_<name>': 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 '${<expression>}' will be replaces with the result of <expression>.
Inside an expression, you can reference variables as '@<name>'. 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:<condition>:<name> ... @else:<name> ... @endif:<name>', where
<condition> 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 :<name>
<formspec 1>
@else:<name>
<formspec 2>
@endif:<name>
```
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/<image name>'
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]</%1>") 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
}))