minetest-hud_fs/init.lua

611 lines
20 KiB
Lua

--
-- hud_fs: Render formspecs into HUDs
--
-- Copyright © 2021 by luk3yx
--
local hud_fs = {}
local modname = minetest.get_current_modname()
_G[modname] = hud_fs
local DEBUG = false
local DEFAULT_SCALE = 64
local DEFAULT_Z_INDEX = 0
local floor, type, pairs, max = math.floor, type, pairs, math.max
-- Attempt to use modlib's parser
local colorstring_to_number
if minetest.global_exists("modlib") and (modlib.version or 0) >= 54 then
local pcall, from_string = pcall, modlib.minetest.colorspec.from_string
function colorstring_to_number(col)
local ok, spec = pcall(from_string, col)
if not ok or not spec then return end
return spec:to_number_rgb()
end
else
colorstring_to_number = dofile(minetest.get_modpath(modname) ..
"/colorstring_to_number.lua")
end
hud_fs.colorstring_to_number = colorstring_to_number
-- Hacks to allow colorize() to work to some extent on labels
local function get_label_number(label)
local number, text = label:match("^\027%(c@([^%)]+)%)(.*)$")
-- Remove trailing escape sequence added by minetest.colorize().
if text then
text = text:gsub("\027%(c@[^%)]+%)$", "")
end
return text or label, number and colorstring_to_number(number) or 0xFFFFFF
end
-- Disable the race condition workaround in 5.4.1 singleplayer
-- The bug was fixed in 5.4.1 but there's no (easy) way to detect 5.4.1 clients
-- The workaround is disabled for 5.5.0+ clients (if the server is 5.5.0+)
-- regardless of this setting.
local enable_race_condition_workaround = true
if minetest.is_singleplayer() then
local version = minetest.get_version()
if version.project == "Minetest" and version.string == "5.4.1" then
enable_race_condition_workaround = false
end
end
local nodes = {}
function nodes.label(node, scale)
local text, number = get_label_number(node.label)
local elem = {
hud_elem_type = "text",
text = text,
alignment = {x = 1, y = 0},
number = number
}
-- Hack for newlines. This will unfortunately break if the font size is
-- changed from the default.
if elem.text:find('[\r\n]') then
elem.alignment.y = 1
node.y = node.y - 10 / scale
end
return elem
end
function nodes.image(node, scale, _, possibly_using_gles)
local w = floor(node.w * scale)
local h = floor(node.h * scale)
local texture = node.texture_name
if w > 0 and h > 0 and texture ~= "" then
texture = "(" .. texture .. ")^[resize:" .. w .. "x" .. h
else
-- Minetest throws an error with zero-width HUD images, use blank.png
-- to keep the image around (for future HUD update calls) while
-- silencing the error.
texture = "blank.png"
end
-- Hacks to work around textures being aligned to a power of 2 on some
-- video drivers
if possibly_using_gles then
local true_w = 2 ^ math.ceil(math.log(w, 2))
local true_h = 2 ^ math.ceil(math.log(h, 2))
if true_w ~= w or true_h ~= h then
texture = ("[combine:%sx%s:0,0=%s"):format(
true_w, true_h,
texture:gsub("\\", "\\\\"):gsub("%^", "\\^"):gsub(":", "\\:")
)
end
end
return {
hud_elem_type = "image",
text = texture,
alignment = {x = 1, y = 1},
scale = {x = 1, y = 1},
}
end
-- Hack box[] into image[]
function nodes.box(node, scale, add_node, possibly_using_gles)
local col = node.color
-- Add default transparency
if col:byte(1) == 35 then
if #col == 4 then
col = col:gsub("[^#]", "%1%1") .. "8C"
elseif #col == 7 then
col = col .. "8C"
end
end
node.texture_name = 'hud_fs_box.png^[colorize:' .. col
return nodes.image(node, scale, add_node, possibly_using_gles)
end
function nodes.textarea(node, scale, add_node)
-- Add in separate nodes for the label and background
if node.label and node.label ~= "" then
add_node("label", {
x = node.x,
y = node.y - 10 / scale,
label = node.label
})
end
if node.name and node.name ~= "" then
add_node("box", {
x = node.x,
y = node.y,
w = node.w,
h = node.h,
color = "#757575FF"
})
end
node.x = node.x + 5 / scale
node.y = node.y + 3 / scale
local lines = (node.default or ""):split("\n", true)
local max_line_length = node.w * scale / 8
for i, line in ipairs(lines) do
lines[i] = minetest.wrap_text(line, max_line_length)
end
return {
hud_elem_type = "text",
text = table.concat(lines, "\n"),
alignment = {x = 1, y = 1},
number = 0xFFFFFF,
scale = {x = node.w * scale, y = node.h * scale}
}
end
local function get_tile_image(tiles, preferred_texture)
local tile
for i = preferred_texture, 1, -1 do
tile = tiles[i]
if tile then break end
end
if type(tile) == "table" then tile = tile.name end
if type(tile) ~= "string" then tile = "unknown_item.png" end
return tile
end
function nodes.item_image(node, scale, add_node, possibly_using_gles)
local def = minetest.registered_items[node.item_name]
if not def then
node.texture_name = "unknown_item.png"
elseif def.inventory_image and def.inventory_image ~= "" then
node.texture_name = def.inventory_image
elseif def.wield_image and def.wield_image ~= "" then
node.texture_name = def.wield_image
elseif def.tiles then
node.texture_name = minetest.inventorycube(
get_tile_image(def.tiles, 1),
get_tile_image(def.tiles, 6),
get_tile_image(def.tiles, 3)
)
else
node.texture_name = "unknown_node.png"
end
return nodes.image(node, scale, add_node, possibly_using_gles)
end
function nodes.button(node, _, add_node)
-- This function is used by image_button and item_image_button as well
if node.drawborder == nil or node.drawborder then
add_node("box", {
x = node.x,
y = node.y,
w = node.w,
h = node.h,
color = "#515151FF"
})
end
if node.texture_name and node.texture_name ~= "" then
add_node("image", node)
elseif node.item_name and node.item_name ~= "" then
add_node("item_image", node)
end
node.x = node.x + node.w / 2
node.y = node.y + node.h / 2
local text, number = get_label_number(node.label)
return {
hud_elem_type = "text",
text = text,
alignment = {x = 0, y = 0},
number = number
}
end
nodes.button_exit = nodes.button
nodes.image_button = nodes.button
nodes.image_button_exit = nodes.button
nodes.item_image_button = nodes.button
local function render_error(err)
minetest.log("error", "[hud_fs] Error rendering HUD: " .. tostring(err))
return {}
end
local function render(tree, possibly_using_gles, scale, z_index)
if type(tree) == "string" then
local err
tree, err = formspec_ast.parse(tree)
if not tree then
return render_error(err)
end
end
tree = formspec_ast.flatten(tree)
-- if (tree.formspec_version or 1) < 2 then
-- return render_error("Formspec version 1 is not supported!")
-- end
local hud_elems = {}
local size_w, size_h = 0, 0
local pos = {x = 0.5, y = 0.5}
local offset_x, offset_y = 0, 0
scale = scale or DEFAULT_SCALE
z_index = z_index or DEFAULT_Z_INDEX
local function add_node(node_type, node)
local elem = nodes[node_type](node, scale, add_node,
possibly_using_gles)
elem.position = pos
elem.name = node_type
elem.z_index = z_index
elem.offset = {
x = (node.x + offset_x) * scale,
y = (node.y + offset_y) * scale
}
hud_elems[#hud_elems + 1] = elem
z_index = z_index + 1
end
for _, node in ipairs(tree) do
local node_type = node.type
if node_type == "size" then
size_w, size_h = node.w, node.h
offset_x, offset_y = -size_w / 2, -size_h / 2
elseif node_type == "position" then
pos = {x = node.x, y = node.y}
elseif node_type == "anchor" then
if size_w == 0 or size_h == 0 then
return render_error("anchor[] without size[]")
end
offset_x = -node.x * size_w
offset_y = -node.y * size_h
elseif nodes[node_type] then
add_node(node_type, node)
elseif node_type == nil and node.hud_elem_type then
-- Pass through plain HUD elements
hud_elems[#hud_elems + 1] = node
end
end
return hud_elems
end
local hud_elems = {}
--[[
hud_elems[player_name][formname] = {
{List of HUD IDs},
{List of HUD definitions}
}
]]
-- Returns needs_replacing, differences
local function compare_elems(old_elem, new_elem)
local differences = {}
for k, v in pairs(old_elem) do
local v2 = new_elem[k]
if type(v) == "table" and type(v2) == "table" then
-- Tables are guaranteed to be vectors at the moment, don't bother
-- checking anything else to improve performance.
if v.x ~= v2.x or v.y ~= v2.y or v.z ~= v2.z then
differences[#differences + 1] = k
end
elseif v ~= v2 then
-- Sometimes the HUD element will need to be deleted/re-added.
if k == "hud_elem_type" or v2 == nil then return true, nil end
differences[#differences + 1] = k
end
end
-- Check for missing keys in old_elem
for k in pairs(new_elem) do
if old_elem[k] == nil then
differences[#differences + 1] = k
end
end
return false, differences
end
local function reshow_hud(name, formname, data)
if not hud_elems[name] or hud_elems[name][formname] ~= data then
return
end
data[3] = nil
local fs = data[4]
if fs then
data[4] = nil
hud_fs.show_hud(name, formname, fs)
end
end
local scales = {}
local z_indexes = {}
function hud_fs.show_hud(player, formname, formspec)
if type(player) == "string" then
player = minetest.get_player_by_name(player)
if not player then return end
end
local name = player:get_player_name()
if not hud_elems[name] then
hud_elems[name] = {}
end
-- Work around Minetest bug (should be fixed in 5.4)
local info = minetest.get_player_information(name)
if not info then return end
local data = hud_elems[name][formname]
if not data then
data = {{}, {}}
hud_elems[name][formname] = data
end
local proto_ver = info.protocol_version
-- Work around client-side race conditions in MT <= 5.4.0
if proto_ver < 40 and data[3] then
data[4] = formspec
return
end
-- MultiCraft-specific detection of clients which may be using OpenGL ES 1
local possibly_using_gles = (info.platform == "Android" or
info.platform == "iOS")
local ids, elems = data[1], data[2]
local new_elems = render(formspec, possibly_using_gles, scales[formname],
z_indexes[formname])
-- Z-index was added to MT 5.2.0 (protocol version 39) and is ignored by
-- older clients. Because of the way HUDs work, sometimes it's safest to
-- just delete and re-add every single element for these older clients.
if proto_ver < 39 then
-- Maybe we can get away with just doing hud_change() and not resending
local diff_cache = {}
local can_update = true
local update_packets = max(#elems - #new_elems, 0)
for i, new_elem in ipairs(new_elems) do
local old_elem = elems[i]
if old_elem then
local needs_replacing, diff = compare_elems(old_elem, new_elem)
if needs_replacing then
can_update = false
break
end
if #diff > 0 then
diff_cache[i] = diff
update_packets = update_packets + 1
end
else
-- If elements are added the whole HUD needs to be resent.
can_update = false
break
end
end
local resend_packets = #elems + #new_elems
if can_update and resend_packets >= update_packets then
-- Send lots of hud_change packets
for i, new_elem in ipairs(new_elems) do
local diff = diff_cache[i]
if diff then
for _, stat in ipairs(diff) do
player:hud_change(ids[i], stat, new_elem[stat])
end
elems[i] = new_elem
end
end
-- Remove any extra elements
for i = #new_elems + 1, #ids do
player:hud_remove(ids[i])
ids[i] = nil
elems[i] = nil
end
if DEBUG then
minetest.chat_send_player(name, "[DEBUG] Sent " ..
update_packets .. " packet(s) using hud_change() and " ..
"hud_remove() to update HUD")
end
else
-- Or resend every single HUD element
for i = 1, #ids do
player:hud_remove(ids[i])
ids[i] = nil
elems[i] = nil
end
for i, elem in ipairs(new_elems) do
ids[i] = player:hud_add(elem)
elems[i] = elem
end
-- Block future HUD modifications if the new HUD isn't empty
if new_elems[1] and enable_race_condition_workaround then
data[3] = true
minetest.after(0.05, reshow_hud, name, formname, data)
end
if DEBUG then
minetest.chat_send_player(name, "[DEBUG] Sent " ..
resend_packets .. " packet(s) resending entire HUD")
end
end
return
end
-- As MT 5.2.0+ clients support z_index, the HUD IDs don't need to be
-- sequential and the update packets can therefore be more efficient.
local replaced, modified, modify_packets, added = 0, 0, 0, 0
for i, new_elem in ipairs(new_elems) do
local old_elem = elems[i]
if old_elem then
local needs_replacing, diff = compare_elems(old_elem, new_elem)
if needs_replacing or #diff > 2 then
-- Resend the entire element if there are more than two
-- differences as this only sends two packets to the client.
player:hud_remove(ids[i])
ids[i] = player:hud_add(new_elem)
replaced = replaced + 1
else
-- Otherwise it's more efficient to use multiple hud_change()
-- calls (this is a no-op if #diff == 0).
for _, stat in ipairs(diff) do
player:hud_change(ids[i], stat, new_elem[stat])
modify_packets = modify_packets + 1
end
if #diff > 0 then modified = modified + 1 end
end
else
ids[i] = player:hud_add(new_elem)
added = added + 1
end
elems[i] = new_elem
end
-- Remove any extra elements
local removed = 0
for i = #new_elems + 1, #ids do
player:hud_remove(ids[i])
ids[i] = nil
elems[i] = nil
removed = removed + 1
end
-- Only block future HUD modifications if any elements have been added
if proto_ver < 40 and added > 0 and enable_race_condition_workaround then
data[3] = true
minetest.after(0.05, reshow_hud, name, formname, data)
end
if DEBUG then
local packets = modify_packets + replaced * 2 + added + removed
minetest.chat_send_player(name, "[DEBUG] Sent " .. packets ..
" network packet(s): Modified " .. modified ..
" elements in-place (for " .. modify_packets ..
" packet(s)), replaced " .. replaced .. ", added " .. added ..
" and removed " .. removed .. " element(s).")
end
end
function hud_fs.close_hud(player, formname)
hud_fs.show_hud(player, formname, {})
end
minetest.register_on_leaveplayer(function(player)
hud_elems[player:get_player_name()] = nil
end)
-- Sets the base z-index for formname. Should not be done when the formspec is
-- open. Note that this is not used for all elements, if the formspec contains
-- 10 HUD elements it will use a z-index ranging from z_index to z_index + 9.
-- This has no effect for clients older than 5.2.0.
function hud_fs.set_z_index(formname, z_index)
if z_index == DEFAULT_Z_INDEX then z_index = nil end
z_indexes[formname] = z_index
end
-- Sets the scale for formname. This can be done when the HUD is open, however
-- the scale won't be changed for existing formspecs.
function hud_fs.set_scale(formname, scale)
if scale == DEFAULT_SCALE then scale = nil end
scales[formname] = scale
end
-- Testing
--[=[
if not DEBUG then return end
local using_fs = {}
local function poll()
for _, player in ipairs(minetest.get_connected_players()) do
local name = player:get_player_name()
local fs = "formspec_version[3]" ..
"size[4,4.5] position[1,0] anchor[1,0] no_prepend[]" ..
"bgcolor[#00000022;true]" ..
"box[0,0;5,2;#380C2AFF]" ..
"label[0.25,0.5;This is a test HUD]" ..
"label[0.25,1;" .. minetest.colorize("#00ffff", "Server uptime: " ..
floor(minetest.get_server_uptime()) .. " seconds") ..
"]" ..
"label[0.25,1.5;Run /hud to interact!]"
if math.random(0, 1) == 0 then
fs = fs .. "image[3,1.5;1,1;default_dirt.png]"
end
-- if math.random(1, 5) > 1 then
-- fs = fs .. "label[0,1.5;Second label (test)\nNewline test\rabc]"
-- end
fs = fs ..
"box[0,2;5,2;#380C2ABF]" ..
"container[0,2.5]" ..
"item_image[0,0;1,1;carts:rail]" ..
"item_image[1,0;1,1;default:dirt_with_grass]" ..
"container[2,-0.5]" ..
"item_image[0,0.5;1,1;default:cactus]" ..
"item_image[1,0.5;1,1;default:tree]" ..
"container_end[]" ..
"container_end[]" ..
"button[0,4;2,0.5;mrkr;Waypoints]" ..
"button[2,4;2,0.5;close;Close]"
if using_fs[name] then
minetest.show_formspec(name, "hud_fs:uptime", fs)
hud_fs.close_hud(name, "hud_fs:uptime")
else
hud_fs.show_hud(name, "hud_fs:uptime", fs)
end
end
end
local function poll_outer()
poll()
minetest.after(1, poll_outer)
end
minetest.register_on_mods_loaded(poll_outer)
minetest.register_chatcommand("hud", {
func = function(name, _)
using_fs[name] = true
poll()
end,
})
minetest.register_chatcommand("hud2", {
func = function(name, _)
minetest.show_formspec(name, "hud_fs:uptime", "formspec_version[3]" ..
"size[4,4.5] position[1,0] anchor[1,0] no_prepend[]" ..
"bgcolor[#ff000000;neither;#00ff0000]" ..
"button[0,4;2,0.5;mrkr;Waypoints]" ..
"button[2,4;2,0.5;close;Close]")
end,
})
minetest.register_on_player_receive_fields(function(player, formname, fields)
if formname ~= "hud_fs:uptime" then return end
local name = player:get_player_name()
if fields.mrkr then
minetest.registered_chatcommands["mrkr"].func(name, "")
elseif fields.close then
minetest.close_formspec(name, "hud_fs:uptime")
elseif not fields.quit then
return
end
using_fs[name] = nil
poll()
end)
]=]