diff --git a/mainmenu/content.png b/mainmenu/content.png
new file mode 100644
index 0000000..b94ddbd
Binary files /dev/null and b/mainmenu/content.png differ
diff --git a/mainmenu/darken64.png b/mainmenu/darken64.png
new file mode 100644
index 0000000..02d64ec
Binary files /dev/null and b/mainmenu/darken64.png differ
diff --git a/mainmenu/games.png b/mainmenu/games.png
new file mode 100644
index 0000000..a68fb1f
Binary files /dev/null and b/mainmenu/games.png differ
diff --git a/mainmenu/glow.png b/mainmenu/glow.png
new file mode 100644
index 0000000..acf2d86
Binary files /dev/null and b/mainmenu/glow.png differ
diff --git a/mainmenu/init.lua b/mainmenu/init.lua
new file mode 100644
index 0000000..7dbff38
--- /dev/null
+++ b/mainmenu/init.lua
@@ -0,0 +1,1176 @@
+gamedata = {
+
+}
+
+local http = minetest.get_http_api()
+
+-- Used for brevity when making fullscreen things
+window = minetest.get_window_info()
+size = window.max_formspec_size
+
+-- Default textures
+default_textures = minetest.get_texturepath_share().."/base/pack/"
+
+--FIXME: Assets
+local assets = "/Users/iboettcher/eclipse-workspace/mods/mtmenu/"
+
+local version = minetest.get_version()
+
+-- Holds all the interface parts
+state = {}
+
+local fe = minetest.formspec_escape
+local hte = minetest.hypertext_escape
+
+---[[
+
+-- Prepended to the meta menu's formspec.
+local meta_header = "formspec_version[8]\
+ size["..size.x..","..size.y.."]\
+ padding[0,0]\
+ style[*;textcolor=#aaa]\
+ style_type[image_button;border=false]\
+ style_type[button;border=false;bgimg="..assets.."btn_bg.png;bgimg_middle=8,8]\
+ bgcolor[#000;true;#151618]\
+ style_type[box;bordercolors=#124722;borderwidths=-5;colors=#151618]\
+ box[0,0;"..size.x..","..size.y..";]\
+ image[0,"..(size.y *0.08)..");"..size.x..","..(size.x *(72/672))..";"..default_textures.."menu_header.png]\
+ "
+local meta_footer = "\
+ style[nobg;border=false;bgimg="..default_textures.."blank.png;bgimg_middle=0]\
+ style[show_servers_view;border=false;bgimg="..fe(assets).."menu_servers.png;bgimg_middle=0]\
+ style[show_servers_view:hovered;border=false;bgimg="..fe(assets).."menu_servers_hovered.png;bgimg_middle=0]\
+ button["..(size.x *0.05)..","..(size.y *0.3)..";4,4;show_servers_view;]\
+ button["..(size.x *0.05)..","..(size.y *0.3 +4)..";4,0.5;nobg;Servers]\
+ style[show_content_view;border=false;bgimg="..fe(assets).."menu_content.png;bgimg_middle=0]\
+ style[show_content_view:hovered;border=false;bgimg="..fe(assets).."menu_content_hovered.png;bgimg_middle=0]\
+ button["..(size.x -(size.x *0.05) -4)..","..(size.y *0.3)..";4,4;show_content_view;]\
+ button["..(size.x -(size.x *0.05) -4)..","..(size.y *0.3 +4)..";4,0.5;nobg;Content]\
+ "
+
+-- Prepended to game menus' formspecs.
+local game_header = "formspec_version[8]\
+ size["..size.x..","..size.y.."]\
+ padding[0,0]\
+ style[*;textcolor=#aaa]\
+ style_type[image_button;border=false]\
+ style_type[button;border=false;bgimg="..assets.."btn_bg.png;bgimg_middle=8,8]\
+ bgcolor[#0000;true;#0000]\
+ "
+local servers_header = "formspec_version[8]\
+ size["..size.x..","..size.y.."]\
+ padding[0,0]\
+ style[*;textcolor=#aaa]\
+ style_type[image_button;border=false]\
+ style_type[field;border=false]\
+ style_type[pwdfield;border=false]\
+ style_type[button;border=false;bgimg="..assets.."btn_bg_2.png;bgimg_middle=8,8]\
+ style_type[button:hovered;border=false;bgimg="..assets.."btn_bg_2_hover.png;bgimg_middle=8,8]\
+ bgcolor[#0000;true;#151618]\
+ "
+
+local content_header = "formspec_version[8]\
+ size["..size.x..","..size.y.."]\
+ padding[0,0]\
+ style[*;textcolor=#aaa]\
+ style_type[image_button;border=false]\
+ style_type[button;border=false;bgimg="..assets.."btn_bg.png;bgimg_middle=8,8]\
+ bgcolor[#0000;true;#151618]\
+ "
+
+-- The default main menu for games.
+local default_game_menu = [[
+
+ enable_clouds = true
+
+
+@set:test:Hello
+ label[2,2;Test]
+ scroll_container[1,6;4,2;worldscroll;vertical;;0,0]
+ @foreach:$WORLDS:worlds
+ @if:@i % 2:sel
+ style[.select_world_${@name};bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
+ style[.select_world_${@name}:hovered;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
+ style[.select_world_${@name}:hovered+focused;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
+ @else:sel
+ style[.select_world_${@name};bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
+ style[.select_world_${@name}:hovered;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
+ style[.select_world_${@name}:hovered+focused;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
+ @endif:sel
+ button[0,${@i * 0.5};4,0.5;.select_world_${@name};${@test}]
+ @endforeach:worlds
+ scroll_container_end[]
+ scrollbaroptions[arrows=hide]
+ scrollbar[-800,6;0,2;vertical;worldscroll;]
+
+
+ label[2,2;Add World, asset path is $ASSET_PATH]
+ button[2,3;3,1;.show_dialog_main;back]
+
+ @foreach:[hello,there,bob]:myloop
+ label[6,${@i + 3};${@item}]
+ @endforeach:myloop
+
+]]
+
+function core.on_before_close()
+ --minetest.settings:write()
+end
+
+
+minetest.async_jobs = {}
+
+local function handle_job(jobid, serialized_retval)
+ local retval = minetest.deserialize(serialized_retval)
+ assert(type(minetest.async_jobs[jobid]) == "function")
+ minetest.async_jobs[jobid](retval)
+ minetest.async_jobs[jobid] = nil
+end
+
+minetest.async_event_handler = handle_job
+
+function minetest.handle_async(func, parameter, callback)
+ -- Serialize function
+ local serialized_func = string.dump(func)
+
+ assert(serialized_func ~= nil)
+
+ -- Serialize parameters
+ local serialized_param = minetest.serialize(parameter)
+
+ if serialized_param == nil then
+ return false
+ end
+
+ local jobid = minetest.do_async_callback(serialized_func, serialized_param)
+
+ minetest.async_jobs[jobid] = callback
+
+ return true
+end
+
+
+-- MARK: - Helpers
+
+-- Returns the contents of the file, or nil in case of failure.
+local function read_file(path)
+ local f = io.open(path)
+ if f then
+ local data = f:read("a")
+ f:close()
+ if data == "" then return end
+ return data
+ end
+end
+
+-- Check if a file exists.
+local function file_exists(path)
+ local f = io.open(path)
+ if f then
+ f:close()
+ return true
+ end
+ return false
+end
+
+-- Returns the get_games() entry corresponding to a given game ID.
+local function get_game_info(game)
+ for _, x in ipairs(minetest.get_games()) do
+ if x.id == game then
+ return x
+ end
+ end
+end
+
+local function get_worlds_for_game(id)
+ local out = {}
+ for _, x in ipairs(minetest.get_worlds()) do
+ if x.gameid == id then
+ if math.random() > 0.5 then x.selected = true end
+ out[#out +1] = x
+ end
+ end
+ return out
+end
+
+-- Returns a list of content available for download.
+function get_all_content()
+ local version = minetest.get_version()
+ local cdb = minetest.settings:get("contentdb_url")
+ local url = cdb.."/api/packages/?type=mod&type=game&type=txp&protocol_version="..minetest.get_max_supp_proto().."&engine_version="..minetest.urlencode(version.string)
+
+ for _, x in pairs(minetest.settings:get("contentdb_flag_blacklist"):split(",")) do
+ x = x:trim()
+ if x ~= "" then
+ url = url.."&hide="..minetest.urlencode(x)
+ end
+ end
+
+ local languages
+ local lang = minetest.get_language()
+ if lang ~= "" then
+ languages = { lang, "en;q=0.8" }
+ else
+ languages = { "en" }
+ end
+
+ local http = minetest.get_http_api()
+ local response = http.fetch_sync({
+ url = url,
+ extra_headers = {
+ "Accept-Language: "..table.concat(languages, ", ")
+ },
+ })
+ if not response.succeeded then
+ return
+ end
+ local items = minetest.parse_json(response.data)
+ local out = {}
+ for _, x in pairs(items) do
+ out[#out +1] = x
+ end
+ return out
+end
+
+-- Parses text as .conf format.
+function parse_conf_text(txt)
+ local out = {}
+ local multiline = false
+ local key = ""
+ local value = ""
+ for line in (txt.."\n"):gmatch("(.*)\n") do
+ if multiline then
+ if line:find("\"\"\"") then
+ line = line:gsub("\"\"\"", ""):trim()
+ multiline = false
+ out[key] = value.."\n"..line
+ key = ""
+ value = ""
+ else
+ value = value.."\n"..line:trim()
+ end
+ else
+ local k, v = line:match("(.-)=(.*)")
+ if not k then break end
+ if v:find("\"\"\"") then
+ key = k:trim()
+ value = v:gsub("\"\"\"", ""):trim()
+ multiline = true
+ else
+ out[k:trim()] = v:trim()
+ end
+ end
+ end
+ return out
+end
+
+--[[ 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.
+
+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).
+
+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.
+Grouping is not supported (yet).
+
+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.
+
+Example:
+```
+@if: @name = test :
+
+@else:
+
+@endif:
+```
+
+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 function build_template_dialog(fs)
+ 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 = 1
+ while i < 1000 do
+ local a, b, pattern, name, content = fs:find("@foreach:([^:]+):(%w*)\n(.-)\n%s-@endforeach:%2", prev)
+ if not a then break end
+-- print("for each "..pattern.." ("..name..")\n"..content.."\nend for each ("..name..")")
+ dialog[#dialog +1] = build_template_dialog(fs:sub(prev +1, a -1))
+ dialog[#dialog +1] = {
+ foreach = pattern:trim(),
+ name = name,
+ content = build_template_dialog(content:trim())
+ }
+ prev = b
+ i = i +1
+ end
+
+ if i > 0 then
+ dialog[#dialog +1] = build_template_dialog(fs:sub(prev +1))
+ else
+ -- Extract conditionals
+ prev = 0
+ while i < 1000 do
+ local a, b, expr, name, content, else_content = fs:find("@if:([^:]+):(%w*)\n(.-)\n%s-@else:%2\n(.-\n?)@endif:%2", prev)
+ if not a then break end
+ -- print("for each "..pattern.." ("..name..")\n"..content.."\nend for each ("..name..")")
+ dialog[#dialog +1] = fs:sub(prev +1, a -1)
+ dialog[#dialog +1] = {
+ condition = expr:trim(),
+ name = name,
+ content = build_template_dialog(content:trim()),
+ else_content = build_template_dialog(else_content:trim())
+ }
+ prev = b
+ i = i +1
+ end
+ if i > 0 then
+ dialog[#dialog +1] = fs:sub(prev +1)
+ end
+ end
+
+ if #dialog == 1 then dialog = dialog[1] end
+ if i == 0 then dialog = fs end
+-- minetest.log(dump(dialog, " "))
+ return dialog
+end
+
+local function build_game_menu(input)
+ -- MARK: Environment variables
+ input = 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("<(%l+)>(.-)%1>") do
+ if name == "meta" then
+ menu[name] = parse_conf_text(fs)
+ else
+ menu[name] = build_template_dialog(fs)
+ end
+ end
+ 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
+
+ -- 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 expr:find("%p") then return expr 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)
+ -- Assignment statements
+ local offset = 1
+ while offset < #fs do
+ local a, b, name, expr = fs:find("@set:(%w+):(.-)\n", offset)
+ if not a then break end
+ vars[name] = evaluate_template_expression(expr, vars)
+ fs = fs:sub(1, a -1)..fs:sub(b +1)
+ offset = a
+ end
+ -- Interpolations
+ offset = 1
+ while offset < #fs do
+ local a, b, expr = fs:find("%${([^}]-)}", offset)
+ if not a then break end
+ local result = evaluate_template_expression(expr, vars)
+ fs = fs:sub(1, a -1)..result..fs:sub(b +1)
+ offset = a +#result
+ 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 == "$WORLDS" then
+ list = get_worlds_for_game(state.current_game.id)
+ 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
+ return out
+end
+
+-- Game dialogs might contain e.g. foreach loops, so build those if needed.
+function evaluate_game_dialog(dialog, vars)
+ local out = ""
+ if type(dialog) == "string" then return evaluate_template_block(dialog, vars) end
+ for _, x in ipairs(dialog) do
+ if type(x) == "string" then
+ out = out..evaluate_template_block(x, vars)
+ elseif x.condition then
+ out = out..evaluate_template_conditional(x, vars)
+ elseif x.foreach then
+ out = out..evaluate_template_foreach(x, vars)
+ else
+ for _, c in ipairs(x) do
+ out = out..evaluate_game_dialog(c, vars)
+ end
+ end
+ end
+ return out
+end
+
+-- MARK: - Views
+
+-- The meta menu, for choosing which game menu to enter or entering the servers/content views.
+local last_game = minetest.settings:get("menu_last_game")
+function show_meta_menu(v)
+ state.loc = "games"
+ local games = minetest.get_games()
+ if #games < 1 then
+ minetest.update_formspec(meta_header.."\
+ hypertext[0,0;"..size.x..","..size.y..";ht;\
+ \
+ ]\
+ ")
+ return
+ end
+ local idx = 1
+ for i, x in ipairs(games) do
+ if x.id == last_game then idx = i end
+ end
+ if v then idx = v else v = idx end
+ local fs = ""
+
+
+ fs = fs.."\
+ scroll_container[0,0;"..size.x..","..size.y..";carousel;horizontal;;]\
+ box[0,0;"..(#games *5)..",1;#0000]\
+ "
+
+
+ local center = (size.x /2) +(v /10)
+
+ for i, x in ipairs(games) do
+ fs = fs.."\
+ image_button["..(center +(i -(#games /2)) *0.25)..","..(size.y -0.5)..";0.25,0.25;"..assets..(i == idx and "circle_light" or "circle")..".png;jump"..i..";]\
+ "
+
+ local dist = i -idx
+ local scale = 4 /(math.abs(dist) +1)
+ if scale > 0.1 then
+ --This looks cool, but is useless for UI purposes: center +10^(1 /math.abs(dist)) *math.sign(dist)
+ local lc = center +math.abs(dist *10)^0.55 *math.sign(dist)
+ local test = io.open(x.menuicon_path)
+ if test then
+ test:close()
+ else
+ x.menuicon_path = assets.."games.png"
+ end
+ --TODO: Do these need some kind of background?
+ fs = fs.."\
+ image_button["..(lc -(scale /2))..","..(size.y /2 -(scale /2) +(4 -scale) /2 +2)..";"..scale..","..scale..";"..fe(x.menuicon_path)..";game"..fe(x.id)..";]\
+ tooltip[game"..fe(x.id)..";"..fe(x.title)..";#444;#aaa]\
+ "
+ if dist == 0 then
+ fs = fs.."\
+ style[_game_label;bgimg="..default_textures.."blank.png]\
+ button["..(lc -(scale /2))..","..(size.y /2 +4)..";4,0.5;_game_label;"..fe(x.title).."]\
+ "
+ end
+ end
+ end
+
+
+ fs = fs.."\
+ scroll_container_end[]\
+ scrollbaroptions[min=1;max="..#games..";smallstep=1]\
+ scrollbar[0,-800;"..size.x..",0;horizontal;carousel;"..(v or "").."]\
+ "
+
+ minetest.update_formspec(meta_header..fs..meta_footer)
+end
+
+-- Shows games' custom menus.
+function show_game_menu(args)
+ if not state.current_game then
+ minetest.log("warning", "Main menu attempted to show a menu for a nonexistent game; aborting.")
+ return
+ end
+ if not args then args = {} end
+ local game = state.current_game
+ if not game.menu then
+ local file = read_file(game.path.."/menu/mainmenu.txt") or default_game_menu
+
+ local overlays = {}
+ local backgrounds = {}
+ local headers = {}
+ local footers = {}
+ local music = {}
+
+ for _, x in ipairs(minetest.get_dir_list(game.path.."/menu", false)) do
+ if x:sub(1, string.len("background")) == "background" then
+ local a, b, id = x:find("background.(%d+).png")
+ if a or x == "background.png" then
+ backgrounds[(tonumber(id) or 0) +1] = x
+ end
+ elseif x:sub(1, string.len("overlay")) == "overlay" then
+ local a, b, id = x:find("overlay.(%d+).png")
+ if a or x == "overlay.png" then
+ overlays[(tonumber(id) or 0) +1] = x
+ end
+ elseif x:sub(1, string.len("header")) == "header" then
+ local a, b, id = x:find("header.(%d+).png")
+ if a or x == "header.png" then
+ headers[(tonumber(id) or 0) +1] = x
+ end
+ elseif x:sub(1, string.len("footer")) == "footer" then
+ local a, b, id = x:find("footer.(%d+).png")
+ if a or x == "footer.png" then
+ footers[(tonumber(id) or 0) +1] = x
+ end
+ elseif x:sub(1, string.len("theme")) == "theme" then
+ local a, b, id = x:find("theme.(%d+).ogg")
+ if a or x == "theme.ogg" then
+ music[(tonumber(id) or 0) +1] = x
+ end
+ end
+ end
+
+ if #backgrounds > 0 then
+ local path = game.path.."/menu/"..backgrounds[math.random(#backgrounds)]
+ if minetest.set_background("background", path) then
+ game.background = path
+ end
+ elseif #overlays > 0 then
+ local path = game.path.."/menu/"..overlays[math.random(#overlays)]
+ -- I don't know why, but using "overlay" here simply does nothing. Whatever
+ -- the difference is, it isn't visual, so this effectively 'fixes' it.
+ if minetest.set_background("background", path) then
+ game.overlay = path
+ end
+ else
+ minetest.set_background("background", "")
+ end
+
+ if #headers > 0 then
+ local path = game.path.."/menu/"..headers[math.random(#headers)]
+ if minetest.set_background("header", path) then
+ game.header = path
+ end
+ end
+
+ game.menu = build_game_menu(file)
+ end
+
+ if args.show_dialog then
+ state.menu_current = {args.show_dialog}
+ elseif args.overlay_dialog then
+ state.menu_current[#state.menu_current +1] = args.overlay_dialog
+ elseif args.unoverlay_dialog and #state.menu_current > 1 then
+ state.menu_current[#state.menu_current] = nil
+ end
+
+ local fs = ""
+
+ if not game.background and not game.overlay then
+ fs = "\
+ bgcolor[#000;true;#151618]\
+ "
+ end
+
+ for i, x in ipairs(state.menu_current) do
+ fs = fs..evaluate_game_dialog(game.menu[x], setmetatable({WIDTH = size.x, HEIGHT = size.y}, {__index = state.menu_vars}))
+ end
+
+ minetest.update_formspec(game_header..fs.."\
+ button[0,"..(size.y -1)..";1,1;to_meta_menu;<]\
+ ")
+end
+
+
+function refresh_server_list()
+ minetest.handle_async(
+ function(param)
+ local http = minetest.get_http_api()
+ local url = ("%s/list?proto_version_min=%d&proto_version_max=%d"):format(
+ "https://servers.luanti.org" or minetest.settings:get("serverlist_url"),
+ minetest.get_min_supp_proto(),
+ minetest.get_max_supp_proto())
+
+ local response = http.fetch_sync({ url = url })
+ if not response.succeeded then
+ return {}
+ end
+
+ local retval = minetest.parse_json(response.data)
+ return retval and retval.list or {}
+ end,
+ nil,
+ function(result)
+ state.serverlist = result
+ -- If we need to load the server list, we need to read the list of favorite servers as well.
+ local favorite_servers = minetest.parse_json(read_file(minetest.get_user_path().."/client/serverlist/favoriteservers.json") or "{}")
+ if #favorite_servers > 0 then
+ for _, x in ipairs(favorite_servers) do
+ x.favorite = true
+ end
+ table.insert_all(favorite_servers, state.serverlist)
+ state.serverlist = favorite_servers
+ end
+ state.favorite_servers = favorite_servers
+ if state.loc == "servers" then
+ show_servers_menu()
+ end
+ end
+ )
+end
+
+-- Shows the server list.
+function show_servers_menu()
+ local fs = ""
+ local loading
+ if not state.serverlist then
+ loading = true
+ refresh_server_list()
+ local favorite_servers = minetest.parse_json(read_file(minetest.get_user_path().."/client/serverlist/favoriteservers.json") or "{}")
+ for _, x in ipairs(favorite_servers) do
+ x.favorite = true
+ end
+ state.serverlist = favorite_servers
+-- fs = "\
+-- hypertext[0,0;"..size.x..","..size.y..";ht;\
+-- ]\
+-- "
+ end
+ local current_server
+ local joining_server
+
+ fs = "\
+ style[servers_filter;border=false;textcolor=#aaa]\
+ image["..(size.x *0.095)..","..(size.y *0.1 -0.85)..";"..(size.x *0.81 -2.55)..",0.75;"..assets.."btn_bg_2_light.png;8,8]\
+ field["..(size.x *0.095 +0.1)..","..(size.y *0.1 -0.85)..";"..(size.x *0.81 -2.2)..",0.75;servers_filter;;"..(state.menu_vars.servers_filter or "").."]\
+ style[servers_search,servers_refresh,servers_cancel;bgimg="..assets.."btn_bg_2.png;bgimg_middle=8,8]\
+ image_button["..(size.x *0.91 -0.85)..","..(size.y *0.1 -0.85)..";0.75,0.75;"..assets.."refresh.png;servers_refresh;]\
+ image_button["..(size.x *0.91 -1.7)..","..(size.y *0.1 -0.85)..";0.75,0.75;"..assets.."cancel.png;servers_cancel;]\
+ image_button["..(size.x *0.91 -2.55)..","..(size.y *0.1 -0.85)..";0.75,0.75;"..assets.."search.png;servers_search;]\
+ image["..(size.x *0.095)..","..(size.y *0.1)..";"..(size.x *0.81)..","..(size.y *0.8)..";"..assets.."btn_bg.png;8,8]\
+ scroll_container[0,"..(size.y *0.1 +0.1)..";"..size.x..","..(size.y *0.8 -0.2)..";serverscroll;vertical;;0,0]\
+ "
+ local listx = size.x *0.1
+ local infox = state.current_server and size.x *0.6 or size.x *0.9
+ local max = size.x *0.9
+ local i = 0
+-- if #state.favorite_servers > 0 then
+-- fs = fs.."\
+-- box["..listx..","..(i *0.5)..";"..(infox -listx)..",0.5;#"..(i %2 == 1 and "373530" or "403e39").."ff]\
+-- box["..(listx -0.05)..","..(i *0.5 +0.2)..";"..(infox -listx +0.1)..",0.1;#292d2fff]\
+-- box["..((infox -listx) /2 -1.5)..","..(i *0.5)..";3,0.5;#"..(i %2 == 1 and "373530" or "403e39").."ff]\
+-- box["..((infox -listx) /2 -1.55)..","..(i *0.5 +0.125)..";0.1,0.25;#292d2fff]\
+-- box["..((infox -listx) /2 +1.45)..","..(i *0.5 +0.125)..";0.1,0.25;#292d2fff]\
+-- hypertext["..((infox -listx) /2 -1.5)..","..(i *0.5)..";3,0.5;;Favorite Servers]\
+-- "
+-- i = i +1
+-- end
+-- for idx, x in ipairs(state.favorite_servers) do
+-- if x.address and x.port then
+-- local address = minetest.encode_base64(x.address)
+-- if address == state.current_server then
+-- current_server = x
+-- end
+-- if address == state.joining_server then
+-- joining_server = x
+-- end
+-- fs = fs.."\
+-- style[serverinfof"..idx..";border=false;bgimg="..assets.."white.png;bgimg_middle=0;bgcolor=#"..(i %2 == 1 and "373530" or "403e39").."ff]\
+-- style[serverinfof"..idx..":hovered;border=false;bgimg="..assets.."white.png;bgimg_middle=0]\
+-- button["..listx..","..(i *0.5)..";"..(infox -listx)..",0.5;serverinfof"..idx..";]\
+-- label["..(listx +0.1)..","..(i *0.5 +0.25)..";"..fe(x.address..":"..x.port).."]\
+-- "
+-- i = i +1
+-- end
+-- end
+-- if i > 0 then
+-- fs = fs.."\
+-- box["..listx..","..(i *0.5)..";"..(infox -listx)..",0.5;#"..(i %2 == 1 and "373530" or "403e39").."ff]\
+-- box["..(listx -0.05)..","..(i *0.5 +0.2)..";"..(infox -listx +0.1)..",0.1;#292d2fff]\
+-- box["..((infox -listx) /2 -1.5)..","..(i *0.5)..";3,0.5;#"..(i %2 == 1 and "373530" or "403e39").."ff]\
+-- box["..((infox -listx) /2 -1.55)..","..(i *0.5 +0.125)..";0.1,0.25;#292d2fff]\
+-- box["..((infox -listx) /2 +1.45)..","..(i *0.5 +0.125)..";0.1,0.25;#292d2fff]\
+-- hypertext["..((infox -listx) /2 -1.5)..","..(i *0.5)..";3,0.5;;Public Servers]\
+-- "
+-- i = i +1
+-- end
+ for idx, x in ipairs(state.serverlist) do
+ -- Skip incompatible servers. You can't join them, so showing them is rather pointless.
+ if (x.proto_max or version.proto_min) >= version.proto_min then
+ local ping_lvl = 0
+ local lag = (x.lag or 0) * 1000 + (x.ping or 0) * 250
+ if lag <= 125 then
+ ping_lvl = 4
+ elseif lag <= 175 then
+ ping_lvl = 3
+ elseif lag <= 250 then
+ ping_lvl = 2
+ elseif lag <= 400 then
+ ping_lvl = 1
+ end
+
+ local name = x.name and x.name:trim() or ""
+ if name == "" then
+ name = minetest.colorize("#888", x.address..":"..x.port)
+ end
+ -- Otherwise, the colons in an IPv6 address would make the style element blow up.
+ --local address = minetest.encode_base64(x.address)
+ local label_offset = 0.1
+ fs = fs.."\
+ style[serverinfo"..idx..";border=false;bgimg="..assets.."white.png;bgimg_middle=0;bgcolor=#"..(i %2 == 1 and "373530" or "403e39").."ff]\
+ style[serverinfo"..idx..":hovered;border=false;bgimg="..assets.."white.png;bgimg_middle=0]\
+ button["..listx..","..(i *0.5)..";"..(infox -listx)..",0.5;serverinfo"..idx..";]\
+ "
+ if x.favorite then
+ -- The overlay dialog doesn't occlude area tooltips, so they are disabled in that case.
+ fs = fs.."\
+ image["..(listx)..","..(i *0.5)..";0.5,0.5;"..assets.."menu_servers_favorite.png]\
+ "..(state.joining_server and "" or "tooltip["..(listx)..","..(i *0.5)..";0.5,0.5;Favorite server;#444;#aaa]\
+ ")
+ label_offset = 0.6
+ end
+ fs = fs.."\
+ label["..(listx +label_offset)..","..(i *0.5 +0.25)..";"..fe(name).."]\
+ "..((x.ping or x.lag) and "image["..(infox -0.6)..","..(i *0.5)..";0.5,0.5;"..assets.."menu_servers_icon_ping_"..ping_lvl..".png]\
+ "..(state.joining_server and "" or "tooltip["..(infox -0.6)..","..(i *0.5)..";0.5,0.5;Ping: "..math.floor(lag)..";#444;#aaa]\
+ ") or "")
+ i = i +1
+ end
+ end
+ if loading then
+ fs = fs.."\
+ hypertext["..listx..","..(i *0.5)..";"..(infox -listx)..",0.5;;Loading...]\
+ "
+ end
+ fs = fs.."\
+ scroll_container_end[]\
+ scrollbar[-800,0;1,1;vertical;serverscroll;"..(state.menu_vars.serverscroll and tonumber(state.menu_vars.serverscroll:sub(5)) or 0).."]\
+ "
+ if state.current_server then
+ fs = fs.."box["..(infox -0.05)..","..(size.y *0.1 +0.05)..";0.1,"..(size.y *0.8 -0.1)..";#292d2fff]\
+ container["..infox..","..(size.y *0.1).."]\
+ hypertext[0,0;"..(max -infox)..",0.75;;"..fe(hte(state.current_server.name or state.current_server.address..":"..state.current_server.port)).."]\
+ image[0.1,0.75;"..(max -infox -0.2)..",0.5;"..assets.."btn_bg_2_dark.png;8,8]\
+ box["..((max -infox) *0.7 -0.05)..",0.75;0.1,0.5;#292d2fff]\
+ hypertext[0.2,0.8;"..((max -infox) *0.7 -0.2)..",0.5;server_address;"..fe(hte(state.current_server.address)).."]\
+ hypertext["..((max -infox) *0.7 +0.1)..",0.8;"..((max -infox) *0.3 -0.2)..",0.5;server_port;"..fe(hte(tostring(state.current_server.port))).."]\
+ hypertext[0.1,1.5;"..(max -infox -0.2)..","..((size.y *0.8 -1.5 -1.05))..";server_desc;"..fe(hte(state.current_server.description or "")).."]\
+ button[0.1,"..(size.y *0.8 -1)..";"..(max -infox -0.2)..",0.75;join_server;Join Server]\
+ container_end[]\
+ "
+ end
+
+ fs = fs.."\
+ button[0,"..(size.y -1)..";1,1;to_meta_menu;<]\
+ "
+
+ if state.joining_server or state.connecting_to_server then
+ local offset = 0
+ if state.server_connection_error then
+ offset = offset +1
+ fs= fs.."\
+ hypertext[0,0;"..(size.x *0.5)..",1;;"..fe(hte(state.server_connection_error)).."]\
+ "
+ end
+ fs = fs.."\
+ box[0,0;"..size.x..","..size.y..";#0008]\
+ image["..(size.x *0.25 -0.2)..","..(size.y *0.25 -0.1)..";"..(size.x *0.5 +0.4)..","..(size.y *0.5 +0.2)..";"..assets.."btn_bg.png;8,8]\
+ scroll_container["..(size.x *0.25)..","..(size.y *0.25)..";"..(size.x *0.5)..","..(size.y *0.5 +0.2)..";serverconfscroll;vertical;;0,0]\
+ hypertext[0,"..offset..";"..(size.x *0.5)..",0.75;;Connecting to "..fe(hte(state.current_server.name or state.current_server.address..":"..state.current_server.port)).."...]\
+ image[0,"..(1.25 +offset)..";"..(size.x *0.5)..",0.75;"..assets.."btn_bg_2_light.png;8,8]\
+ label[0.2,"..(1.1 +offset)..";Username]\
+ field[0.2,"..(1.25 +offset)..";"..(size.x *0.5 -0.4)..",0.75;server_username;;"..minetest.settings:get("name").."]\
+ image[0,"..(2.25 +offset)..";"..(size.x *0.5)..",0.75;"..assets.."btn_bg_2_light.png;8,8]\
+ style[server_password;border=false;textcolor=#aaa;bgimg="..assets.."btn_bg.png]\
+ label[0.2,"..(2.1 +offset)..";Password]\
+ pwdfield[0.2,"..(2.25 +offset)..";"..(size.x *0.5 -0.4)..",0.75;server_password;]\
+ button[0,"..(3.25 +offset)..";"..(size.x *0.5)..",0.75;cancel_join_server;Cancel]\
+ button[0,"..(4.25 +offset)..";"..(size.x *0.5)..",0.75;confirm_join_server;Join]\
+ scroll_container_end[]\
+ scrollbar[-800,0;1,1;vertical;serverconfscroll;"..(state.menu_vars.serverconfscroll and tonumber(state.menu_vars.serverconfscroll:sub(5)) or 0).."]\
+ "
+ end
+ minetest.update_formspec(servers_header..fs)
+end
+
+-- Shows the content manager.
+function show_content_menu()
+ local fs = ""
+ minetest.update_formspec(content_header..fs.."\
+ button[0,"..(size.y -1)..";1,1;to_meta_menu;<]\
+ ")
+end
+
+function minetest.button_handler(data)
+ state.menu_vars = data
+ if state.loc == "games" then
+ if data.content or data.ht == "action:content" then
+ if state.loc ~= "content" then show_content() end
+ elseif data.games then
+ if state.loc ~= "games" then show_meta_menu() end
+ elseif data.carousel and data.carousel:sub(1, 4) == "CHG:" then
+ local v = tonumber(data.carousel:sub(5))
+ show_meta_menu(v)
+ else
+ for k, v in pairs(data) do
+ if k:sub(1, 4) == "game" then
+ local id = k:sub(5)
+ state.current_game = get_game_info(id)
+ state.menu_current = {"main"}
+ state.loc = "game"
+ state.menu_vars = {}
+ show_game_menu()
+ minetest.settings:set("menu_last_game", id)
+ last_game = id
+ elseif k:sub(1, 4) == "jump" then
+ local v = tonumber(k:sub(5))
+ show_meta_menu(v)
+ end
+ end
+ end
+ elseif state.loc == "servers" then
+ if data.servers_refresh then
+ refresh_server_list()
+ elseif data.servers_search or data.key_enter_field == "servers_filter" then
+ state.servers_filter = data.servers_filter
+ show_servers_menu()
+ elseif data.servers_cancel then
+ state.servers_filter = nil
+ show_servers_menu()
+ elseif data.join_server then
+ state.joining_server = true
+ show_servers_menu()
+ elseif data.cancel_join_server then
+ state.joining_server = nil
+ show_servers_menu()
+ elseif data.confirm_join_server or data.key_enter_field == "server_username" or data.key_enter_field == "server_password" then
+ minetest.settings:set("name", data.server_username)
+ minetest.settings:set("address", state.current_server.address)
+ minetest.settings:set("remote_port", state.current_server.port)
+ gamedata = {
+ playername = data.server_username,
+ password = data.server_password,
+ address = state.current_server.address,
+ port = state.current_server.port,
+ selected_world = 0,
+ singleplayer = false
+ }
+
+ minetest.start()
+ else
+ for k, v in pairs(data) do
+ if k:sub(1, string.len("serverinfo")) == "serverinfo" then
+ local idx = k:sub(string.len("serverinfo>"))
+ if idx:sub(1, 1) == "f" then
+ state.current_server = state.favorite_servers[tonumber(idx:sub(2))]
+ else
+ state.current_server = state.serverlist[tonumber(idx)]
+ end
+ show_servers_menu()
+ end
+ end
+ end
+ elseif state.loc == "content" then
+ for k, v in pairs(data) do
+
+ end
+ elseif state.loc == "game" then
+ for k, v in pairs(data) do
+ if k:sub(1, string.len(".show_dialog_")) == ".show_dialog_" then
+ show_game_menu {
+ show_dialog = k:sub(string.len(".show_dialog_>"))
+ }
+ elseif k:sub(1, string.len(".overlay_dialog_")) == ".overlay_dialog_" then
+ show_game_menu {
+ overlay_dialog = k:sub(string.len(".overlay_dialog_>"))
+ }
+ elseif k == ".unoverlay_dialog" then
+ show_game_menu {
+ unoverlay_dialog = true
+ }
+ end
+ end
+ end
+ if data.to_meta_menu then
+ state.current_game = nil
+ state.loc = "games"
+ show_meta_menu()
+ elseif data.show_servers_view then
+ state.current_game = nil
+ state.loc = "servers"
+ show_servers_menu()
+ elseif data.show_content_view then
+ state.current_game = nil
+ state.loc = "content"
+ show_content_menu()
+ end
+end
+
+function minetest.event_handler(ev)
+ -- Quit on quit
+ if ev == "MenuQuit" then
+ if state.joining_server then
+ state.joining_server = nil
+ show_servers_menu()
+ elseif state.loc ~= "games" then
+ show_meta_menu()
+ else
+ minetest.close()
+ end
+ end
+end
+
+minetest.set_clouds(false)
+
+if last_game then
+ state.current_game = get_game_info(last_game)
+ state.menu_current = {"main"}
+ state.loc = "game"
+ show_game_menu()
+else
+ show_meta_menu()
+end
+
+--]]