minetest-menu/mainmenu/init.lua
2025-07-11 17:11:52 +00:00

1888 lines
73 KiB
Lua

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 = os.getenv("HOME").."/eclipse-workspace/mods/mtmenu/"
local version = minetest.get_version()
-- This is where all view-specific state information goes.
state = {}
local fe = minetest.formspec_escape
local hte = minetest.hypertext_escape
dofile(minetest.get_builtin_path().."common/settings/settingtypes.lua")
---[[
minetest.set_formspec_prepend("\
style[*;textcolor=#aaa]\
style_type[field;border=false]\
style_type[pwdfield;border=false]\
style_type[image_button;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]\
style[nobg,nobg:hovered,nobg:focused,nobg:hovered+focused;border=false;bgimg="..default_textures.."blank.png;bgimg_middle=0]\
")
local meta_header = "formspec_version[8]\
size["..size.x..","..size.y.."]\
padding[0,0]\
bgcolor[#000;true;#151618]\
style_type[box;bordercolors=#124722;borderwidths=-5;colors=#151618]\
image[0,"..(size.y *0.08)..");"..size.x..","..(size.x *(72/672))..";"..default_textures.."menu_header.png]\
"
-- box[0,0;"..size.x..","..size.y..";]\
local game_header = "formspec_version[8]\
size["..size.x..","..size.y.."]\
padding[0,0]\
bgcolor[#0000;true;#0000]\
"
local servers_header = "formspec_version[8]\
size["..size.x..","..size.y.."]\
padding[0,0]\
bgcolor[#0000;true;#151618]\
"
local content_header = "formspec_version[8]\
size["..size.x..","..size.y.."]\
padding[0,0]\
bgcolor[#0000;true;#151618]\
"
local default_game_menu = [[
<meta>
enable_clouds = true
</meta>
<main>
@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_${@path};bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
style[.select_world_${@path}:hovered;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
style[.select_world_${@path}:hovered+focused;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
@else:sel
style[.select_world_${@path};bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
style[.select_world_${@path}:hovered;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
style[.select_world_${@path}:hovered+focused;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
@endif:sel
button[0,${(@i +-1) * 0.5};${@list_width},0.5;.select_world_${@path};${@name}]
@endforeach:worlds
scroll_container_end[]
@if:@selected_world:fi
box[${@WIDTH * 0.4 +-0.05},${@HEIGHT * 0.1 +-0.1};0.1,${@HEIGHT * 0.8 + 0.2};#292d2fff]
scroll_container[${@WIDTH * 0.4 + 0.05},${@HEIGHT * 0.1};${@WIDTH * 0.8 +-0.05},${@HEIGHT * 0.8};worlconfigscroll;vertical;;0,0]
@set:j:0
@if:@setting_damage:fii
@if:@damage_enabled:fiii
image_button[0,${@j};0.5,0.5;$DEFAULT_ASSET_PATH/checkbox_filled.png;.set_damage_enabled_to_0;]
@else:fiii
image_button[0,${@j};0.5,0.5;$DEFAULT_ASSET_PATH/checkbox_empty.png;.set_damage_enabled_to_1;]
@endif:fiii
label[0.5,${@j + 0.25};Damage]
@set:j:@j + 0.5
@else:fii
@endif:fii
@if:@setting_creative:fii
@if:@creative_enabled:fiii
image_button[0,${@j};0.5,0.5;$DEFAULT_ASSET_PATH/checkbox_filled.png;.set_creative_enabled_to_0;]
@else:fiii
image_button[0,${@j};0.5,0.5;$DEFAULT_ASSET_PATH/checkbox_empty.png;.set_creative_enabled_to_1;]
@endif:fiii
label[0.5,${@j + 0.25};Creative]
@set:j:@j + 0.5
@else:fii
@endif:fii
@set:play_str:Play
@if:@setting_server:fii
@if:@server_enabled:fiii
image_button[0,${@j};0.5,0.5;$DEFAULT_ASSET_PATH/checkbox_filled.png;.set_server_enabled_to_0;]
@set:play_str:Host server
@else:fiii
image_button[0,${@j};0.5,0.5;$DEFAULT_ASSET_PATH/checkbox_empty.png;.set_server_enabled_to_1;]
@set:play_str:Play
@endif:fiii
label[0.5,${@j + 0.25};Server]
@set:j:@j + 0.5
@else:fii
@endif:fii
@set:j:@j + 1
button[${@WIDTH * 0.05},${@j};${@WIDTH * 0.4},0.75;.overlay_dialog_modconfig;Configure mods]
button[${@WIDTH * 0.05},${@j + 1};${@WIDTH * 0.4},0.75;.play;${@play_str}]
scroll_container_end[]
scrollbar[-800,6;0,2;vertical;worldconfigscroll;]
@else:fi
@endif:fi
scrollbaroptions[arrows=hide]
scrollbar[-800,6;0,2;vertical;worldscroll;]
</main>
<modconfig>
image[${@WIDTH * 0.05 +-0.1},${@HEIGHT * 0.05 +-0.1};${@WIDTH * 0.4 + 0.2},${@HEIGHT * 0.9 +-0.75};$DEFAULT_ASSET_PATH/btn_bg.png;8,8]
hypertext[${@WIDTH * 0.05},${@HEIGHT * 0.05};${@WIDTH * 0.4},0.75;;<global valign=middle halign=center><b>Available mods</b>]
scroll_container[${@WIDTH * 0.05},${@HEIGHT * 0.05 + 0.75};${@WIDTH * 0.4},${@HEIGHT * 0.9 +-1.7};modsscroll;vertical;;0,0]
@foreach:@MODS:m
@if:@i % 2:sel
style[.select_mod_${@name};bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
style[.select_mod_${@name}:hovered;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
style[.select_mod_${@name}:hovered+focused;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
@else:sel
style[.select_mod_${@name};bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
style[.select_mod_${@name}:hovered;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
style[.select_mod_${@name}:hovered+focused;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
@endif:sel
button[0,${@i * 0.5 +-0.5};${@WIDTH * 0.4},0.5;.select_mod_${@name};${@name}]
@endforeach:m
scroll_container_end[]
scrollbar[-800,6;0,2;vertical;modsscroll;]
image[${@WIDTH * 0.55 +-0.1},${@HEIGHT * 0.05 +-0.1};${@WIDTH * 0.4 + 0.2},${@HEIGHT * 0.9 +-0.75};$DEFAULT_ASSET_PATH/btn_bg.png;8,8]
hypertext[${@WIDTH * 0.55},${@HEIGHT * 0.05};${@WIDTH * 0.4},0.75;;<global valign=middle halign=center><b>Mods for ${@selected_world_name}</b>]
scroll_container[${@WIDTH * 0.55},${@HEIGHT * 0.05 + 0.75};${@WIDTH * 0.4},${@HEIGHT * 0.9 +-1.7};worldmodsscroll;vertical;;0,0]
@foreach:@WORLDMODS:wm
@if:@i % 2:sel
style[.select_mod_${@name};bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
style[.select_mod_${@name}:hovered;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
style[.select_mod_${@name}:hovered+focused;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#373530]
@else:sel
style[.select_mod_${@name};bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
style[.select_mod_${@name}:hovered;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
style[.select_mod_${@name}:hovered+focused;bgimg=$DEFAULT_ASSET_PATH/white.png;bgimg_middle=0;bgcolor=#403e39]
@endif:sel
button[0,${@i * 0.5 +-0.5};${@WIDTH * 0.4},0.5;.select_mod_${@name};${@name}]
@endforeach:wm
scroll_container_end[]
scrollbar[-800,6;0,2;vertical;worldmodsscroll;]
image_button[${@WIDTH * 0.45},${@HEIGHT * 0.5 +-(@WIDTH * 0.1)};${@WIDTH * 0.1},${@WIDTH * 0.1};$DEFAULT_ASSET_PATH/arrow_right.png;.add_mod_to_world;]
image_button[${@WIDTH * 0.45},${@HEIGHT * 0.5};${@WIDTH * 0.1},${@WIDTH * 0.1};$DEFAULT_ASSET_PATH/arrow_left.png;.remove_mod_from_world;]
tooltip[.add_mod_to_world;Add mod to world;#444;#aaa]
tooltip[.remove_mod_from_world;Remove mod from world;#444;#aaa]
button[${@WIDTH * 0.1},${@HEIGHT * 0.95 +-0.75};${@WIDTH * 0.4},0.75;.unoverlay_dialog;Cancel]
button[${@WIDTH * 0.5},${@HEIGHT * 0.95 +-0.75};${@WIDTH * 0.4},0.75;.unoverlay_dialog;Confirm]
</modconfig>
<addworld>
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
</addworld>
]]
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 parameters
local serialized_param = minetest.serialize(parameter)
if serialized_param == nil then
return false
end
local jobid = minetest.do_async_callback(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_world_index(path)
for i, x in ipairs(minetest.get_worlds()) do
if x.path == path then
return i
end
end
return -1
end
local function get_worlds_for_game(id)
local out = {}
for _, x in ipairs(minetest.get_worlds()) do
if x.gameid == id then
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
local function get_game_mods(id)
local out = {}
local base = get_game_info(id).path.."/mods/"
for _, x in pairs(minetest.get_dir_list(base, true)) do
out[#out +1] = {
name = x,
path = base..x
}
end
return out
end
function get_mods_for_world(world)
local conf = Settings(world.."/world.mt")
local mods = {}
local game
local game_mods = {}
for k, v in pairs(conf:to_table()) do
if k == "gameid" then
game = v
elseif k:sub(1, 9) == "load_mod_" then
if v ~= "false" and v ~= "nil" and v then
mods[k:sub(10)] = minetest.get_user_path().."/"..v
end
end
end
if game then
for _, x in ipairs(get_game_mods(game)) do
game_mods[x.name] = x.path
end
end
local config = minetest.check_mod_configuration(world, mods)
-- print(dump(config))
for _, x in ipairs(config.unsatisfied_mods) do
x.unsatisfied = true
config.satisfied_mods[#config.satisfied_mods +1] = x
end
if game then
for _, x in ipairs(config.satisfied_mods) do
if game_mods[x.name] then
x.game_provided = true
end
end
end
return config.satisfied_mods
end
-- Returns a list of content available for download.
function get_available_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
-- Returns a list of all the installed mods.
function get_all_mods(dir)
local all = false
if not dir then
if state.all_mods then return state.all_mods end
dir = minetest.get_modpath()
all = true
end
local out = {}
for _, x in ipairs(minetest.get_dir_list(dir, true)) do
local info = minetest.get_content_info(dir.."/"..x)
if info.type == "mod" then
out[#out +1] = info
end
end
if all then
state.all_mods = out
end
return out
end
-- Returns a list of all the installed games.
function get_all_games(dir)
local all = false
if not dir then
if state.all_games then return state.all_games end
dir = minetest.get_gamepath()
all = true
end
local out = {}
for _, x in ipairs(minetest.get_dir_list(dir, true)) do
local info = minetest.get_content_info(dir.."/"..x)
if info.type == "game" then
out[#out +1] = info
end
end
if all then
state.all_games = out
end
return out
end
-- Returns a list of all the installed texture packs.
function get_all_texture_packs(dir)
local all = false
if not dir then
if state.all_texture_packs then return state.all_texture_packs end
dir = minetest.get_texturepath()
all = true
end
local out = {}
for _, x in ipairs(minetest.get_dir_list(dir, true)) do
local info = minetest.get_content_info(dir.."/"..x)
if info.type == "txp" then
out[#out +1] = info
end
end
if all then
state.all_texture_packs = out
end
return out
end
-- Return a list of all content, as a conglomeration of the lists of mods, games, and texture packs.
function get_all_content()
if not state.all_content then
state.all_content = {}
table.insert_all(state.all_content, get_all_mods())
table.insert_all(state.all_content, get_all_games())
table.insert_all(state.all_content, get_all_texture_packs())
table.sort(state.all_content, function(a, b)
if not a then
return b ~= nil
elseif not b then
return a == nil
end
return a.name < b.name
end)
end
return state.all_content
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:
```
<main>
label[2,2;A label]
</main>
<other>
button[1,1;4,1;button;This is a button]
</other>
```
will define a dialog named 'main', with a label in it, and a dialog named 'other',
with a button in it. Every menu file must have a dialog named 'main', which serves
as the entry point of the game's menu.
Note that to prevent dialog definitions from conflicting with the contents of
hypertext[] elements, they must not have leading whitespace before the opening
delimiter.
Being able to define these other dialogs would be pretty pointless if they couldn't
be used for anything. Accordingly, you can use standard formspec actions, e.g. buttons,
to segue to a different dialog. To do this, set the action name to
'.show_dialog_<name>', where <name> is the name of the dialog you want to open.
You can also use '.overlay_dialog_<name>' to draw the target dialog on top of the
current dialog, and '.unoverlay_dialog' to hide it again.
There are several other action patterns that invoke special behavior:
-
- '.play': Start the game.
- '.play_with_world_named_<name>': Start the game on the specified world. If the
world in question does not exist, it will be created.
It is also possible to define metadata for the main menu. To do this, create a dialog
section named 'meta'. The contents of this section will then be parsed in the same way
as minetest.conf instead of being treated as a dialog.
Available metadata options are:
- 'enable_clouds': true/false (defaults to false)
Now, all this is good when we know exactly what content we want on the main menu.
But what if we want to render e.g. a list of worlds, or even a list of game modes?
This can be done using the @foreach construct. @foreach takes in a list of values,
the name of a JSON file, or a reference to a menu-exposed list.
The major advantage of a foreach loop, however, is interpolation. Inside a foreach
loop, the pattern '${<expression>}' will be replaces with the result of <expression>.
Inside an expression, you can reference variables as '@<name>'. Which variables are
available depends on the iterated list. In the case of a list literal, the current
item is exposed as a variable named @item, while for a JSON file, each key in the
file's top-level object will correspond to a variable. (Note that variable names may
only contain letters or numbers, even if mapped from a JSON file.)
The menu-provided lists are:
- @WORLDS: The list of worlds for this game. Exposes @name (the world name),
@path (the world's full absolute path), @gameid (the ID of the current game),
and @selected (whether the world is currently selected).
- @MODS: The list of installed mods that are not incompatible with this game.
Exposes @name, @title, @description, @author, @path, @depends, and @optional_depends.
- @WORLDMODS: The list of mods installed on the current world. No-op if no world
is selected.
Expressions also support rudimentary mathematical operations, namely addition (+),
subtraction (+-), multiplication (*), division (/), and exponentiation (^). Trying
to perform math on a non-tonumber()-able variable will treat the variable as 0.
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:<condition>:<name> ... @else:<name> ... @endif:<name>', where
<condition> is any expression. The conditional will be replaced with the contents
of its first block if the condition evaluates to non-zero, and the contents of
its second block otherwise.
Example:
```
@if: @name = test :<name>
<formspec 1>
@else:<name>
<formspec 2>
@endif:<name>
```
Notes:
- The only uniqueness requirements for the name of a block is that it not be
the name of a statment of the same type contained in the body of that block.
This is so that the parser knows which `end` belongs to which block without
having to manage state.
Note: Because of the way the main menu works, image paths must be specified in full.
To make this non-painful, when referencing images use '$ASSET_PATH/<image name>'
instead of just the image name. $ASSET_PATH will be replaced with the actual path
to the game's menu/ directory, so images used by the main menu should be stored
there. You can use $ASSET_PATH in any context. Additionally, $DEFAULT_ASSET_PATH is
the path to the builtin assets folder, and $NO_IMAGE is the path to blank.png, in
case you need to stylistically unset a background image defined by a global style.
--]]
local function build_template_dialog(fs, depth)
if fs:trim() == "" then return end
local dialog = {}
local i = 0
-- "(.-\n?)%s-@foreach:([^:]+):(%l*)\n(.-)\n%s-@endforeach:%3(\n?.*)"
-- Extract foreach loops
local prev = 0
while i < 1000 do
local fe_start, fe_end, fe_pattern, fe_name, fe_content = fs:find("@foreach:([^:]+):(%w-)\n(.-)\n%s-@endforeach:%2", prev)
local if_start, if_end, if_expr, if_name, if_content, else_content = fs:find("@if:([^:]+):(%w-)\n(.-)\n%s-@else:%2\n(.-)\n%s-@endif:%2", prev)
-- print(string.rep("-", 20)..(depth or 0))
if not fe_start and not if_start then break end
if fe_start and fe_start < (if_start or math.huge) then
-- print("for each "..pattern.." ("..name..")\n"..content.."\nend for each ("..name..")")
dialog[#dialog +1] = build_template_dialog(fs:sub(prev +1, fe_start -1), (depth or 0) +1)
dialog[#dialog +1] = {
foreach = fe_pattern:trim(),
name = fe_name,
content = build_template_dialog(fe_content:trim(), (depth or 0) +1)
}
prev = fe_end
i = i +1
end
if if_start and if_start < (fe_start or math.huge) then
-- print("if "..expr.." ("..name..")\n"..content.."\nend if ("..name..")")
dialog[#dialog +1] = build_template_dialog(fs:sub(prev +1, if_start -1), (depth or 0) +1)
dialog[#dialog +1] = {
condition = if_expr:trim(),
name = if_name,
content = build_template_dialog(if_content:trim(), (depth or 0) +1),
else_content = build_template_dialog(else_content:trim(), (depth or 0) +1)
}
prev = if_end
i = i +1
end
end
if prev > 1 then
dialog[#dialog +1] = build_template_dialog(fs:sub(prev +1))
end
if #dialog == 1 then dialog = dialog[1] end
if i == 0 then dialog = fs end
-- minetest.log(dump(dialog, " "))
return dialog
end
local function build_game_menu(input)
-- MARK: Environment variables
input = "\n"..input
:gsub("%$ASSET_PATH", fe(state.current_game.path.."/menu"))
:gsub("%$DEFAULT_ASSET_PATH", fe(assets:sub(1, #assets -1)))
:gsub("%$NO_IMAGE", fe(default_textures.."blank.png"))
:gsub("/%*.-%*/", "")
local menu = {}
for name, fs in input:gmatch("%f[^\n]<(%l+)>(.-)%f[^\n]</%1>") do
if name == "meta" then
menu[name] = parse_conf_text(fs)
else
menu[name] = build_template_dialog(fs)
end
end
-- print(dump(menu, " "))
return menu
end
-- Split a template expression into a binary-tree node.
local function split_template_expression(expr)
-- These are basically the operator definitions, listed from lowest
-- (evaluated last) to highest (evaluated first) precedence.
local a, b, op
-- Or
if not a then
a, b, op = expr:find("(|)")
end
-- And
if not a then
a, b, op = expr:find("(&)")
end
-- Greater than
if not a then
a, b, op = expr:find("(>)")
end
-- Greater than or equal to
if not a then
a, b, op = expr:find("(>=)")
end
-- Less than
if not a then
a, b, op = expr:find("(<)")
end
-- Less than or equal to
if not a then
a, b, op = expr:find("(<=)")
end
-- Equal to
if not a then
a, b, op = expr:find("(==)")
end
if not a then
a, b, op = expr:find("(=)")
end
-- Addition
if not a then
a, b, op = expr:find("(+)")
end
-- Multiplication
if not a then
a, b, op = expr:find("(*)")
end
-- Division
if not a then
a, b, op = expr:find("(/)")
end
-- Modulo
if not a then
a, b, op = expr:find("(%%)")
end
-- Exponent
if not a then
a, b, op = expr:find("(%^)")
end
if not a then
return {value = expr}
end
return {
op = op,
lhs = split_template_expression(expr:sub(1, a -1)),
rhs = split_template_expression(expr:sub(b +1))
}
end
-- Reduce a template expression from a binary-tree node into a single value.
local function reduce_template_expression(tree)
if tree.op == "|" then
return (reduce_template_expression(tree.lhs) ~= "0" or reduce_template_expression(tree.rhs) ~= "0") and "1" or "0"
elseif tree.op == "&" then
return (reduce_template_expression(tree.lhs) ~= "0" and reduce_template_expression(tree.rhs) ~= "0") and "1" or "0"
elseif tree.op == ">" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) > (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0"
elseif tree.op == ">=" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) >= (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0"
elseif tree.op == "<" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) < (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0"
elseif tree.op == "<=" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) <= (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0"
elseif tree.op == "=" or tree.op == "==" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) == (tonumber(reduce_template_expression(tree.rhs)) or 0) and "1" or "0"
elseif tree.op == "+" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) + (tonumber(reduce_template_expression(tree.rhs)) or 0)
elseif tree.op == "*" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) * (tonumber(reduce_template_expression(tree.rhs)) or 0)
elseif tree.op == "/" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) / (tonumber(reduce_template_expression(tree.rhs)) or 0)
elseif tree.op == "%" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) % (tonumber(reduce_template_expression(tree.rhs)) or 0)
elseif tree.op == "^" then
return (tonumber(reduce_template_expression(tree.lhs)) or 0) ^ (tonumber(reduce_template_expression(tree.rhs)) or 0)
else
return tree.value
end
end
-- Evaluate an interpolation expression, with an optional table of variables.
local function evaluate_template_expression(expr, vars, depth)
if expr == "" then return "0" end
if not depth then depth = 0 end
-- This handles the case where vars is omitted, because then it ends up
-- just setting vars to an empty table.
if type(vars) ~= "table" then
vars = {item = vars}
end
-- Check for operators early, because variables may contain punctuation when expanded.
-- A class is used instead of %p because %p matches @, which is used for variables.
local has_operators = expr:find("[|&><=+*/%%^]")
-- Expand all variables so we can deal with a constexpr.
local offset = 1
while offset < 100000 do
local a, b, name = expr:find("@([%a_]+)", offset)
if not a then break end
-- If referencing an undefined variable, default to 0 because it's safest that way.
local result = minetest.formspec_escape(tostring(vars[name] or "0"))
expr = expr:sub(1, a -1)..result..expr:sub(b +1)
offset = a +#result
end
-- If there are no operators, this is a constant expression and we can just return.
if not has_operators then return expr end
-- Condense sub-expressions.
local offset = 1
while offset < 100000 do
local a, b, se = expr:find("(%b())", offset)
if not a then break end
se = se:gsub("^%((.*)%)$", "%1")
local result = evaluate_template_expression(se, vars, depth +1)
expr = expr:sub(1, a -1)..result..expr:sub(b +1)
offset = a +#result
end
-- Expression parsing
local tree = split_template_expression(expr)
return tostring(reduce_template_expression(tree))
end
-- Do variable interpolation for the given formspec using the provided variable table.
local function evaluate_template_block(fs, vars)
local offset = 0
while offset < 100000 do
local s_start, s_end, s_name, s_expr = fs:find("@set:@?([%w_]+):([^\n]+)", offset)
local i_start, i_end, i_expr = fs:find("%${([^}]-)}", offset)
if not s_start and not i_start then break end
if s_start and s_start < (i_start or math.huge) then
-- Assignment statements
vars[s_name] = evaluate_template_expression(s_expr, vars)
fs = fs:sub(1, s_start -1)..fs:sub(s_end +1)
offset = s_start
elseif i_start then
-- Interpolations
local result = evaluate_template_expression(i_expr, vars)
fs = fs:sub(1, i_start -1)..result..fs:sub(i_end +1)
offset = i_start +#result
end
end
return fs
end
-- Interpret the list expression of a foreach loop, then iterate.
local function evaluate_template_foreach(loop, vars)
local out = ""
local list = {}
if loop.foreach:sub(1,1) == "[" then
list = loop.foreach:gsub("^%[(.*)%]$", "%1"):split(",")
elseif loop.foreach:sub(1, 1) == "@" then
local var = loop.foreach:sub(2)
if var == "WORLDS" then
list = get_worlds_for_game(state.current_game.id)
elseif var == "MODS" then
list = get_mods_for_game(state.current_game.id)
elseif var == "WORLDMODS" then
list = state.menu_vars.selected_world and get_mods_for_world(state.menu_vars.selected_world) or {}
else
list = vars[var]
end
end
for i, x in ipairs(list) do
local vars2 = {}
if type(x) ~= "table" then
vars2.item = x
else
for k, v in pairs(x) do
vars2[k] = v
end
end
vars.i = i
out = out..evaluate_game_dialog(loop.content, setmetatable(vars2, {__index = vars, __newindex = vars})).."\n"
end
return out
end
-- Determine which branch of an if statement should be evaluated.
local function evaluate_template_conditional(cond, vars)
local out = ""
local list = {}
local condition = evaluate_template_expression(cond.condition, vars)
if condition ~= "0" then
out = out..evaluate_game_dialog(cond.content, vars).."\n"
else
out = out..evaluate_game_dialog(cond.else_content, vars).."\n"
end
-- print("Evaluated condition `"..cond.condition.."` as "..out)
return out
end
-- Process the syntax tree for a game dialog and output the resulting string.
function evaluate_game_dialog(dialog, vars)
local out = ""
if not dialog then return out end
if type(dialog) == "string" then
return evaluate_template_block(dialog, vars)
elseif dialog.condition then
out = out..evaluate_template_conditional(dialog, vars)
elseif dialog.foreach then
out = out..evaluate_template_foreach(dialog, vars)
else
for _, c in ipairs(dialog) do
out = out..evaluate_game_dialog(c, vars)
end
end
return out
end
-- 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;<global valign=middle halign=center><tag name=action color=#222 hovercolor=#444>\
<style size=72 color=#222><b>No games installed.</b></style>\
<style size=36 color=#222><action name=content>Click here to install a game.</action></style>]\
")
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 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)
if x.menuicon_path == "" then
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.."\
button["..(lc -(scale /2))..","..(size.y /2 +4)..";4,0.5;nobg;"..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.."\
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]\
style[show_settings_view,show_about_view;bgimg="..assets.."menu_tab_bg.png]\
style[show_settings_view:hovered,show_about_view:hovered;bgimg="..assets.."menu_tab_bg.png;bgcolor=#fffd]\
button[1,0;2,0.5;show_about_view;About]\
button["..(size.x -3)..",0;2,0.5;show_settings_view;Settings]\
")
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
game.disabled_settings = {}
local settings = Settings(game.path.."/game.conf")
local disabled_settings = string.split(settings:get("disabled_settings") or "", ",")
for _, x in ipairs(disabled_settings) do
x = x:trim()
if x == "enable_damage" then
game.disabled_settings.damage = false
elseif x == "!enable_damage" then
game.disabled_settings.damage = true
elseif x == "creative_mode" then
game.disabled_settings.creative = false
elseif x == "!creative_mode" then
game.disabled_settings.creative = true
elseif x == "enable_server" then
game.disabled_settings.server = false
end
end
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)
state.menu_vars = {}
state.menu_vars.setting_damage = game.disabled_settings.damage == nil
state.menu_vars.setting_creative = game.disabled_settings.creative == nil
state.menu_vars.setting_server = game.disabled_settings.server == nil
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
state.menu_vars.WIDTH = size.x
state.menu_vars.HEIGHT = size.y
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
if i > 1 then
fs = fs.."\
box[0,0;"..size.x..","..size.y..";#0009]\
"
end
fs = fs..evaluate_game_dialog(game.menu[x], state.menu_vars)
end
-- print(fs)
minetest.update_formspec(game_header..fs.."\
button[0,"..(size.y -1)..";1,1;to_meta_menu;<]\
")
end
function add_favorite_server(address, port)
state.favorite_servers[#state.favorite_servers +1] = {address = address, port = port}
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
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)
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)
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
end
)
end
function search_server_list(input)
if input:trim() == "" then
return nil
end
local search_str = ""
local words = {}
local mods = {}
local games = {}
local players = {}
input = input:lower()
for x in input:gmatch("%S+") do
if x:sub(1, 4) == "mod:" then
mods[#mods +1] = x:sub(5)
elseif x:sub(1, 7) == "player:" then
players[#players +1] = x:sub(8)
elseif x:sub(1, 5) == "game:" then
games[#games +1] = x:sub(6)
else
words[#words +1] = x
end
end
-- print(dump({words = words, mods= mods, players =players, games = games}, " "))
local out = {}
for _, x in ipairs(state.serverlist) do
local passed = true
for _, a in ipairs(words) do
if not (x.description and x.description:lower():find(a, 1, true) or x.name and x.name:lower():find(a, 1, true)) then
passed = false
end
end
-- If only `continue` existed...
if passed then
if #games > 0 then
passed = false
end
for _, a in ipairs(games) do
if a == x.gameid then
passed = true
end
end
if passed then
if x.mods and #mods > 0 then
local found = 0
for _, a in ipairs(x.mods) do
for _, b in ipairs(mods) do
if a == b then
found = found +1
end
end
end
passed = found == #mods
else
passed = not not x.mods
end
if passed then
if x.clients_list and #players > 0 then
local found = 0
for _, a in ipairs(x.clients_list) do
for _, b in ipairs(players) do
if a:lower() == b then
found = found +1
end
end
end
passed = found == #players
else
passed = not not x.clients_list
end
if passed then
out[#out +1] = x
end
end
end
end
end
return out
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.favorite_servers = favorite_servers
state.serverlist = favorite_servers
end
-- Overlay dialogs don't occlude area tooltips, so such tooltips are disabled in that case.
local showing_dialog = state.joining_server or state.connecting_to_server or state.showing_server_mods or state.showing_server_players
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 -5.65)..",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 -5.45)..",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 -5.65)..","..(size.y *0.1 -0.85)..";0.75,0.75;"..assets.."search.png;servers_search;]\
tooltip[servers_search;Search server list;#444;#aaa]\
image_button["..(size.x *0.91 -4.8)..","..(size.y *0.1 -0.85)..";0.75,0.75;"..assets.."cancel.png;servers_cancel;]\
tooltip[servers_cancel;Cancel search;#444;#aaa]\
image_button["..(size.x *0.91 -3.95)..","..(size.y *0.1 -0.85)..";0.75,0.75;"..assets.."refresh.png;servers_refresh;]\
tooltip[servers_refresh;Refresh server list;#444;#aaa]\
button["..(size.x *0.91 -3.1)..","..(size.y *0.1 -0.85)..";3,0.75;direct_connection;Direct Connection...]\
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
for idx, x in ipairs(state.serverlist_filtered or 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
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
fs = fs.."\
image["..(listx)..","..(i *0.5)..";0.5,0.5;"..assets.."menu_servers_favorite.png]\
"..(showing_dialog and "" or "tooltip["..(listx)..","..(i *0.5)..";0.5,0.5;Favorite server;#444;#aaa]\
")
label_offset = 0.6
end
local clients = ""
local color = "#aaa"
local icons_offset = (infox -0.6)
if x.clients then
icons_offset = icons_offset -1
clients = x.clients..(x.clients_max and "/"..x.clients_max or "")
if x.clients > 0 and x.clients_max then
local percent = x.clients /x.clients_max
if percent < 0.75 then
color = "#638b67"
elseif percent < 1 then
color = "#a69174"
else
color = "#9d5b5b"
end
end
fs = fs.."\
hypertext["..icons_offset..","..(i *0.5)..";1,0.5;;<global valign=middle halign=center color="..color..">"..fe(clients).."]\
"
end
if x.pvp == false then
icons_offset = icons_offset -0.5
fs = fs.."\
image["..(icons_offset)..","..(i *0.5)..";0.5,0.5;"..assets.."menu_servers_peaceful.png]\
"..(showing_dialog and "" or "tooltip["..(icons_offset)..","..(i *0.5)..";0.5,0.5;Peaceful;#444;#aaa]\
")
end
if x.creative then
icons_offset = icons_offset -0.5
fs = fs.."\
image["..(icons_offset)..","..(i *0.5)..";0.5,0.5;"..assets.."menu_servers_creative.png]\
"..(showing_dialog and "" or "tooltip["..(icons_offset)..","..(i *0.5)..";0.5,0.5;Creative;#444;#aaa]\
")
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]\
"..(showing_dialog 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;;<global valign=middle halign=center color=#777>Loading...]\
"
end
if i < 1 then
fs = fs.."\
hypertext["..listx..","..(i *0.5)..";"..(infox -listx)..",0.5;;<global valign=middle halign=center color=#777>No servers found.]\
"
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;;<global valign=middle halign=center color=#aaa><b>"..fe(hte(state.current_server.name or state.current_server.address..":"..state.current_server.port)).."</b>]\
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;<global valign=middle color=#aaa>"..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;<global valign=middle color=#aaa>"..fe(hte(tostring(state.current_server.port))).."]\
"
local buttons_offset = 0.1
if state.current_server.mods then
fs = fs.."\
image_button["..buttons_offset..",1.5;0.5,0.5;"..assets.."menu_servers_mods.png;show_server_mods;]\
tooltip[show_server_mods;Show mod list;#444;#aaa]\
"
buttons_offset = buttons_offset +0.5
end
if state.current_server.clients then
fs = fs.."\
image_button["..buttons_offset..",1.5;0.5,0.5;"..assets.."menu_servers_players.png;show_server_players;]\
tooltip[show_server_players;Show player list;#444;#aaa]\
"
buttons_offset = buttons_offset +0.5
end
if state.current_server.favorite then
fs = fs.."\
image_button["..buttons_offset..",1.5;0.5,0.5;"..assets.."menu_servers_unfavorite.png;unfavorite_server;]\
tooltip[unfavorite_server;Remove from favorites;#444;#aaa]\
"
buttons_offset = buttons_offset +0.5
end
fs = fs.."\
hypertext[0.1,2.25;"..(max -infox -0.2)..","..((size.y *0.8 -1.5 -1.05))..";server_desc;<global color=#aaa>"..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.showing_server_mods then
fs = fs.."\
box[0,0;"..size.x..","..size.y..";#0008]\
style[_even,_even:hovered;border=false;bgimg="..assets.."white.png;bgimg_middle=0;bgcolor=#403e39ff]\
style[_odd,_odd:hovered;border=false;bgimg="..assets.."white.png;bgimg_middle=0;bgcolor=#373530ff]\
image["..(size.x /4 -0.1)..","..(size.y /4 -0.1)..";"..(size.x /2 +0.2)..","..(size.y /2 +0.2)..";"..assets.."btn_bg.png;8,8]\
scroll_container["..(size.x /4)..","..(size.y /4)..";"..(size.x /2)..","..(size.y /2 -1)..";modsscroll;vertical;;0,0]\
"
for i, x in ipairs(state.current_server.mods) do
fs = fs.."\
button[0,"..(i *0.5 -0.5)..";"..(infox -listx)..",0.5;"..(i %2 == 1 and "_odd" or "_even")..";]\
label[0.1,"..(i *0.5 -0.25)..";"..fe(x).."]\
"
end
fs = fs.."\
scroll_container_end[]\
scrollbar[-800,0;1,1;vertical;modsscroll;]\
button["..(size.x /4)..","..(size.y *0.75 -0.875)..";"..(size.x /2)..",0.75;close_dialog;Back]\
"
end
if state.showing_server_players then
fs = fs.."\
box[0,0;"..size.x..","..size.y..";#0008]\
style[_even,_even:hovered;border=false;bgimg="..assets.."white.png;bgimg_middle=0;bgcolor=#403e39ff]\
style[_odd,_odd:hovered;border=false;bgimg="..assets.."white.png;bgimg_middle=0;bgcolor=#373530ff]\
image["..(size.x /4 -0.1)..","..(size.y /4 -0.1)..";"..(size.x /2 +0.2)..","..(size.y /2 +0.2)..";"..assets.."btn_bg.png;8,8]\
scroll_container["..(size.x /4)..","..(size.y /4)..";"..(size.x /2)..","..(size.y /2 -1)..";modsscroll;vertical;;0,0]\
"
for i, x in ipairs(state.current_server.clients_list) do
fs = fs.."\
button[0,"..(i *0.5 -0.5)..";"..(infox -listx)..",0.5;"..(i %2 == 1 and "_odd" or "_even")..";]\
label[0.1,"..(i *0.5 -0.25)..";"..fe(x).."]\
"
end
fs = fs.."\
scroll_container_end[]\
scrollbar[-800,0;1,1;vertical;modsscroll;]\
button["..(size.x /4)..","..(size.y *0.75 -0.875)..";"..(size.x /2)..",0.75;close_dialog;Back]\
"
end
if state.joining_server or state.connecting_to_server then
local offset = 0
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]\
"
if state.server_connection_error then
offset = offset +0.75
fs = fs.."\
hypertext[0,0;"..(size.x *0.5)..",0.75;;<global valign=middle color=#9d5b5b>"..fe(hte(state.server_connection_error.msg)).."]\
set_focus["..fe(state.server_connection_error.element)..";true]\
"
else
fs = fs.."\
set_focus[server_"..(state.joining_server and "username" or "address")..";true]\
"
end
if state.joining_server then
fs = fs.."\
hypertext[0,"..offset..";"..(size.x *0.5)..",0.75;;<global valign=middle halign=center color=#aaa>Connecting to <b>"..fe(hte(state.current_server.name or state.current_server.address..":"..state.current_server.port)).."</b>...]\
"
offset = offset +1
else
fs = fs.."\
set_focus[server_address;true]\
hypertext[0,"..offset..";"..(size.x *0.5)..",0.75;;<global valign=middle halign=center color=#aaa>Connecting to server...]\
"
offset = offset +1
fs = fs.."\
image[0,"..(0.25 +offset)..";"..(size.x *0.5)..",0.75;"..assets.."btn_bg_2_light.png;8,8]\
label[0.2,"..(0.1 +offset)..";Address]\
field[0.2,"..(0.25 +offset)..";"..(size.x *0.4 -0.4)..",0.75;server_address;;"..(state.menu_vars.server_address or "").."]\
box["..(size.x *0.4 -0.25)..","..(0.25 +offset)..";0.1,0.75;#292d2fff]\
label["..(size.x *0.4 -0.2)..","..(0.1 +offset)..";Port]\
field["..(size.x *0.4)..","..(0.25 +offset)..";"..(size.x *0.1 -0.2)..",0.75;server_port;;"..(state.menu_vars.server_port or "").."]\
"
offset = offset +1
end
fs = fs.."\
image[0,"..(0.25 +offset)..";"..(size.x *0.5)..",0.75;"..assets.."btn_bg_2_light.png;8,8]\
label[0.2,"..(0.1 +offset)..";Username]\
field[0.2,"..(0.25 +offset)..";"..(size.x *0.5 -0.4)..",0.75;server_username;;"..minetest.settings:get("name").."]\
image[0,"..(1.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,"..(1.1 +offset)..";Password]\
pwdfield[0.2,"..(1.25 +offset)..";"..(size.x *0.5 -0.4)..",0.75;server_password;]\
button[0,"..(2.25 +offset)..";"..(size.x *0.5)..",0.75;cancel_join_server;Cancel]\
button[0,"..(3.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 = ""
if not state.all_content then
get_all_content()
end
local w = size.x
local h = size.y -1.1
fs = "\
scroll_container[0,1.1;"..w..","..h..";contentscroll;horizontal;;]\
"
local cols = math.floor(w /2.5)
local spacing_x = w %2.5 /cols +0.5
local rows = math.floor(h /3)
local spacing_y = h %3 /rows +0.5
local pages = math.ceil(#state.all_content /(rows *cols))
local page = state.content_page or 1
local offset = page /10
local x = 0
local y = 0
local i = (page -1) *(rows *cols) +1
while true do
local pkg = state.all_content[i]
if not pkg then break end
local icon = pkg.type == "game" and pkg.path.."/menu/icon.png" or pkg.path.."/icon.png"
if not file_exists(icon) then
icon = assets.."menu_content.png"
end
local title = pkg.title:trim()
if title == "" then
title = pkg.name
end
fs = fs.."\
image_button["..(x *(2 +spacing_x) +(spacing_x /2) +offset)..","..(y *(2 +spacing_y) +(spacing_y /2))..";2,2;"..fe(icon)..";view_package_"..fe(pkg.path)..";]\
tooltip[view_package_"..fe(pkg.path)..";"..fe(title)..";#444;#aaa]\
hypertext["..(x *(2 +spacing_x) +(spacing_x /2) +offset)..","..(y *(2 +spacing_y) +(spacing_y /2) +2)..";2,0.5;nobg;<global color=#aaa valign=middle halign=center>"..fe(hte(title)).."]\
"
i = i +1
x = x +1
if x > cols -1 then
x = 0
y = y +1
if y > rows -1 then break end
end
end
fs = fs.."\
box[0,0;"..(pages *w)..",1;#0000]\
scroll_container_end[]\
scrollbaroptions[min=1;max="..pages..";smallstep=1]\
scrollbar[-800,0;1,1;horizontal;contentscroll;"..page.."]\
"
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_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 -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)
for i = 1, pages do
fs = fs.."\
image_button["..(start +(i *0.25))..","..(size.y -0.5)..";0.25,0.25;"..assets..(i == page and "circle_light.png" or "circle.png")..";page"..i..";]\
"
end
if state.current_package then
end
minetest.update_formspec(content_header..fs.."\
button[0,"..(size.y -1)..";1,1;to_meta_menu;<]\
")
end
function minetest.button_handler(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
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
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()
elseif data.show_server_players then
state.showing_server_players = true
show_servers_menu()
elseif data.close_dialog then
state.showing_server_players = nil
state.showing_server_mods = nil
show_servers_menu()
elseif data.direct_connection then
state.connecting_to_server = true
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
state.connecting_to_server = nil
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)
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
end
local is_favorite = false
for _, x in ipairs(state.favorite_servers) do
if x.address == address and x.port == port then
is_favorite = true
end
end
if not is_favorite then
add_favorite_server(address, port)
end
minetest.settings:set("address", address)
minetest.settings:set("remote_port", port)
gamedata = {
playername = data.server_username,
password = data.server_password,
address = address,
port = 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_filtered or state.serverlist)[tonumber(idx)]
end
show_servers_menu()
end
end
end
elseif state.loc == "content" then
if data.contentscroll and data.contentscroll:sub(1, 4) == "CHG:" then
state.content_page = tonumber(data.contentscroll:sub(5))
show_content_menu()
else
for k, v in pairs(data) do
if k:sub(1, 4) == "page" then
state.content_page = tonumber(k:sub(5))
show_content_menu()
end
end
end
elseif state.loc == "game" then
for k, v in pairs(data) do
if k == ".play" then
gamedata = {
playername = "singleplayer",
password = "",
address = nil,
port = nil,
selected_world = get_world_index(state.menu_vars.selected_world),
singleplayer = true
}
minetest.start()
elseif 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:sub(1, string.len(".select_world_")) == ".select_world_" then
state.menu_vars.selected_world = k:sub(string.len(".select_world_>"))
state.menu_vars.selected_world_name = k:match "/([^/]+)$" or "<unknown world>"
local conf = Settings(state.menu_vars.selected_world.."/world.mt")
local de = conf:get("enable_damage")
if de == "true" then
state.menu_vars.damage_enabled = true
elseif de == "false" then
state.menu_vars.damage_enabled = false
else
state.menu_vars.creative_damage = not state.current_game.disabled_settings.damage
end
local cm = conf:get("creative_mode")
if cm == "true" then
state.menu_vars.creative_enabled = true
elseif cm == "false" then
state.menu_vars.creative_enabled = false
else
state.menu_vars.creative_enabled = not not state.current_game.disabled_settings.creative
end
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
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)
-- When Esc is pressed, close the current dialog, or the game if we are on the meta menu.
if ev == "MenuQuit" then
if state.joining_server or state.connecting_to_server or state.showing_server_mods or state.showing_server_players then
state.joining_server = nil
state.connecting_to_server = nil
state.showing_server_mods = nil
state.showing_server_players = 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)
if state.current_game then
state.menu_current = {"main"}
state.loc = "game"
show_game_menu()
else
show_meta_menu()
end
else
show_meta_menu()
end
--]]