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+)>(.-)") 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 + +--]]