From e4bea7c0828ad205362b07af54d195578430a505 Mon Sep 17 00:00:00 2001 From: Signal Date: Sat, 28 Jun 2025 15:16:16 +0000 Subject: [PATCH] Update mainmenu/init.lua --- mainmenu/init.lua | 284 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 196 insertions(+), 88 deletions(-) diff --git a/mainmenu/init.lua b/mainmenu/init.lua index 43bcf6d..c1d8bf1 100644 --- a/mainmenu/init.lua +++ b/mainmenu/init.lua @@ -12,18 +12,17 @@ size = window.max_formspec_size default_textures = minetest.get_texturepath_share().."/base/pack/" --FIXME: Assets -local assets = "/Users/iboettcher/eclipse-workspace/mods/mtmenu/" +local assets = os.getenv("HOME").."/eclipse-workspace/mods/mtmenu/" local version = minetest.get_version() --- Holds all the interface parts +-- This is where all view-specific state information goes. state = {} local fe = minetest.formspec_escape local hte = minetest.hypertext_escape ---FIXME: Replace with /content because pause menu -dofile(minetest.get_builtin_path().."mainmenu/settings/settingtypes.lua") +dofile(minetest.get_builtin_path().."common/settings/settingtypes.lua") ---[[ @@ -37,7 +36,6 @@ minetest.set_formspec_prepend("\ style[nobg,nobg:hovered,nobg:focused,nobg:hovered+focused;border=false;bgimg="..default_textures.."blank.png;bgimg_middle=0]\ ") --- Prepended to the meta menu's formspec. local meta_header = "formspec_version[8]\ size["..size.x..","..size.y.."]\ padding[0,0]\ @@ -47,7 +45,6 @@ local meta_header = "formspec_version[8]\ " -- box[0,0;"..size.x..","..size.y..";]\ --- Prepended to game menus' formspecs. local game_header = "formspec_version[8]\ size["..size.x..","..size.y.."]\ padding[0,0]\ @@ -65,16 +62,20 @@ local content_header = "formspec_version[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:@selected_world:fi2 + @set:list_width:@WIDTH * 0.3 + @else:fi2 + @set:list_width:@WIDTH * 0.8 + @endif:fi2 + + image[${@WIDTH * 0.1 +-0.1},${@HEIGHT * 0.1 +-0.1};${@WIDTH * 0.8 + 0.2},${@HEIGHT * 0.8 + 0.2};$DEFAULT_ASSET_PATH/bg_translucent.png;8,8] + scroll_container[${@WIDTH * 0.1},${@HEIGHT * 0.1};${@list_width},${@HEIGHT * 0.8};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] @@ -84,9 +85,21 @@ local default_game_menu = [[ 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}] + button[0,${(@i +-1) * 0.5};${@list_width},0.5;.select_world_${@name};${@name}] @endforeach:worlds scroll_container_end[] + + @if:@selected_world:fi + box[${@WIDTH * 0.4 +-0.05},${@HEIGHT * 0.1};0.1,${@HEIGHT * 0.8};#292d2fff] + scroll_container[${@WIDTH * 0.4},${@HEIGHT * 0.1};${@WIDTH * 0.8},${@HEIGHT * 0.8};worldmodsscroll;vertical;;0,0] + @foreach:@WORLDMODS:wm + label[0,${@i};${@name}] + @endforeach:wm + scroll_container_end[] + scrollbar[-800,6;0,2;vertical;worldmodsscroll;] + @else:fi + + @endif:fi scrollbaroptions[arrows=hide] scrollbar[-800,6;0,2;vertical;worldscroll;]
@@ -99,7 +112,7 @@ local default_game_menu = [[ @endforeach:myloop ]] - + function core.on_before_close() --minetest.settings:write() end @@ -117,11 +130,6 @@ 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) @@ -129,7 +137,7 @@ function minetest.handle_async(func, parameter, callback) return false end - local jobid = minetest.do_async_callback(serialized_func, serialized_param) + local jobid = minetest.do_async_callback(func, serialized_param) minetest.async_jobs[jobid] = callback @@ -173,13 +181,44 @@ 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 +function get_mods_for_game(id) + local out = {} + for _, x in ipairs(get_all_mods()) do + local conf = Settings(x.path.."mod.conf") + local unsupported_games = conf:get("unsupported_games") + if unsupported_games then + if table.indexof(unsupported_games:trim():split(","), id) == -1 then + out[#out +1] = x + end + else + local supported_games = conf:get("supported_games") + if supported_games then + if table.indexof(supported_games:trim():split(","), id) ~= -1 then + out[#out +1] = x + end + else + out[#out +1] = x + end + end + end + return out +end + +function get_mods_for_world(world) + local config = minetest.check_mod_configuration(world) + for _, x in ipairs(config.unsatisfied_mods) do + x.unsatisfied = true + config.satisfied_mods[#config.satisfied_mods +1] = x + end + return config.satisfied_mods +end + -- Returns a list of content available for download. function get_available_content() local version = minetest.get_version() @@ -357,6 +396,10 @@ 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 @@ -393,9 +436,13 @@ file's top-level object will correspond to a variable. (Note that variable names 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), + - @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 @@ -436,6 +483,12 @@ Example: @endif: ``` +Notes: + - The only uniqueness requirements for the name of a block is that it not be + the name of a statment of the same type contained in the body of that block. + This is so that the parser knows which `end` belongs to which block without + having to manage state. + Note: Because of the way the main menu works, image paths must be specified in full. To make this non-painful, when referencing images use '$ASSET_PATH/' instead of just the image name. $ASSET_PATH will be replaced with the actual path @@ -444,7 +497,7 @@ there. You can use $ASSET_PATH in any context. Additionally, $DEFAULT_ASSET_PATH 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) +local function build_template_dialog(fs, depth) if fs:trim() == "" then return end local dialog = {} local i = 0 @@ -452,43 +505,44 @@ local function build_template_dialog(fs) -- Extract foreach loops local prev = 1 while i < 1000 do + local unfound = 0 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) +-- print(string.rep("-", 20)..(depth or 0)) + if a then +-- print("for each "..pattern.." ("..name..")\n"..content.."\nend for each ("..name..")") + dialog[#dialog +1] = build_template_dialog(fs:sub(prev, a -1), (depth or 0) +1) dialog[#dialog +1] = { - condition = expr:trim(), + foreach = pattern:trim(), name = name, - content = build_template_dialog(content:trim()), - else_content = build_template_dialog(else_content:trim()) + content = build_template_dialog(content:trim(), (depth or 0) +1) } prev = b i = i +1 + else + unfound = unfound +1 end - if i > 0 then - dialog[#dialog +1] = fs:sub(prev +1) + local a, b, expr, name, content, else_content = fs:find("@if:([^:]+):(%w*)\n(.-)\n%s-@else:%2\n(.-\n?)@endif:%2", prev) + if a then +-- print("if "..expr.." ("..name..")\n"..content.."\nend if ("..name..")") + dialog[#dialog +1] = build_template_dialog(fs:sub(prev +1, a -1), (depth or 0) +1) + dialog[#dialog +1] = { + condition = expr:trim(), + name = name, + content = build_template_dialog(content:trim(), (depth or 0) +1), + else_content = build_template_dialog(else_content:trim(), (depth or 0) +1) + } + prev = b + i = i +1 + else + unfound = unfound +1 end + if unfound > 1 then break 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, " ")) @@ -497,19 +551,20 @@ end local function build_game_menu(input) -- MARK: Environment variables - input = input + 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("<(%l+)>(.-)") do + for name, fs in input:gmatch("%f[^\n]<(%l+)>(.-)%f[^\n]") do if name == "meta" then menu[name] = parse_conf_text(fs) else menu[name] = build_template_dialog(fs) end end +-- print(dump(menu, " ")) return menu end @@ -629,13 +684,25 @@ local function evaluate_template_expression(expr, vars, depth) -- 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) + 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 + + -- 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 + -- If there are no operators, this is a constant expression and we can just return. if not expr:find("%p") then return expr end @@ -649,7 +716,7 @@ 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) + local a, b, name, expr = fs:find("@set:([%a_]+):([^\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) @@ -673,8 +740,17 @@ local function evaluate_template_foreach(loop, vars) 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) + elseif loop.foreach:sub(1, 1) == "@" then + local var = loop.foreach:sub(2) + 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.current_world and get_mods_for_world(state.current_world) or {} + else + list = vars[var] + end end for i, x in ipairs(list) do local vars2 = {} @@ -704,21 +780,19 @@ local function evaluate_template_conditional(cond, vars) return out end --- Game dialogs might contain e.g. foreach loops, so build those if needed. +-- Process the syntax tree for a game dialog and output the resulting string. 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 + 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 @@ -763,12 +837,9 @@ function show_meta_menu(v) 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) + --This looks neat, 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 + if x.menuicon_path == "" then x.menuicon_path = assets.."games.png" end --TODO: Do these need some kind of background? @@ -852,7 +923,7 @@ function show_game_menu(args) end end end - + if #backgrounds > 0 then local path = game.path.."/menu/"..backgrounds[math.random(#backgrounds)] if minetest.set_background("background", path) then @@ -898,6 +969,7 @@ function show_game_menu(args) 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 +-- print(fs) minetest.update_formspec(game_header..fs.."\ button[0,"..(size.y -1)..";1,1;to_meta_menu;<]\ @@ -912,6 +984,23 @@ function add_favorite_server(address, port) f:close() end +function remove_favorite_server(address, port) + local idx + for i, x in pairs(state.favorite_servers) do + if x.address == address and x.port == port then + idx = i + break + end + end + if idx then + table.remove(state.favorite_servers, idx) + local f = io.open(minetest.get_user_path().."/client/serverlist/favoriteservers.json", "w") + f:write(minetest.write_json(state.favorite_servers)) + f:flush() + f:close() + end +end + function refresh_server_list() minetest.handle_async( function(param) @@ -934,6 +1023,9 @@ function refresh_server_list() local list = table.copy(state.favorite_servers) table.insert_all(list, result) state.serverlist = list + if state.serverlist_filtered then + state.serverlist_filtered = search_server_list(state.servers_filter) + end if state.loc == "servers" then show_servers_menu() end @@ -1363,15 +1455,18 @@ function show_content_menu() fs = fs.."\ box[0,0;"..size.x..",1;#403e39ff]\ box[0,1;"..size.x..",0.1;#292d2fff]\ - style[content_search,content_cancel;bgimg="..assets.."btn_bg_2.png;bgimg_middle=8,8]\ + style[content_search,content_cancel;bgimg="..assets.."btn_bg_2_dark.png;bgimg_middle=8,8]\ image_button[0.125,0.125;0.75,0.75;"..assets.."search.png;content_search;]\ + tooltip[content_search;Search;#444;#aaa]\ image_button[1,0.125;0.75,0.75;"..assets.."cancel.png;content_cancel;]\ + tooltip[content_cancel;Cancel;#444;#aaa]\ style[test;bgimg="..assets.."white.png;bgimg_middle=0;bgcolor=#403e39ff]\ style[test:hovered;bgimg="..assets.."white.png;bgimg_middle=0]\ image[2,0.125;"..(size.x -9)..",0.75;"..assets.."btn_bg_2_light.png;8,8]\ field[2.1,0.125;"..(size.x -9.2)..",0.75;content_filter;;]\ - button["..(w -7)..",0;4,1;test;Browse Online Content...]\ - button["..(w -3)..",0;3,1;test;Add Content...]\ + button["..(w -6)..",0;4,1;test;Browse Online Content...]\ + image_button["..(w -1.5)..",0.125;0.75,0.75;"..assets.."new_package.png;new_package;]\ + tooltip[new_package;New Package...;#444;#aaa]\ " local start = w /2 -(pages *0.125) @@ -1392,7 +1487,8 @@ function show_content_menu() end function minetest.button_handler(data) - state.menu_vars = data + if not state.menu_vars then state.menu_vars = {} end + setmetatable(state.menu_vars, {__index = data}) if state.loc == "games" then if data.content or data.ht == "action:content" then if state.loc ~= "content" then show_content() end @@ -1422,11 +1518,16 @@ function minetest.button_handler(data) 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 state.serverlist_filtered = search_server_list(data.servers_filter) show_servers_menu() elseif data.servers_cancel then state.serverlist_filtered = nil show_servers_menu() + elseif data.unfavorite_server then + state.current_server.favorite = nil + remove_favorite_server(state.current_server.address, state.current_server.port) + show_servers_menu() elseif data.show_server_mods then state.showing_server_mods = true show_servers_menu() @@ -1449,23 +1550,23 @@ function minetest.button_handler(data) show_servers_menu() elseif data.confirm_join_server or data.key_enter_field == "server_username" or data.key_enter_field == "server_password" or data.key_enter_field == "server_address" then minetest.settings:set("name", data.server_username) - if not tonumber(data.server_port) then - state.server_connection_error = { - msg = "Invalid port.", - element = "server_port" - } - show_servers_menu() - state.server_connection_error = nil - return - end local address local port if state.connecting_to_server then + if not tonumber(data.server_port) then + state.server_connection_error = { + msg = "Invalid port.", + element = "server_port" + } + show_servers_menu() + state.server_connection_error = nil + return + end address = data.server_address:lower() port = data.server_port:lower() else address = state.current_server.address:lower() - port = state.current_server.port:lower() + port = state.current_server.port end local is_favorite = false for _, x in ipairs(state.favorite_servers) do @@ -1523,10 +1624,17 @@ function minetest.button_handler(data) show_game_menu { overlay_dialog = k:sub(string.len(".overlay_dialog_>")) } + elseif k:sub(1, string.len(".select_world_")) == ".select_world_" then + state.menu_vars.selected_world = k:sub(string.len(".select_world_>")) + show_game_menu() elseif k == ".unoverlay_dialog" then show_game_menu { unoverlay_dialog = true } + elseif k:sub(1, string.len(".set_")) == ".set_" then + local name, value = k:match "%.set_(.-)_to_(.*)" + state.menu_vars[name] = value == "" and "0" or value + show_game_menu() end end end