Files

677 lines
22 KiB
Lua

--functions
eye_spy = {}
local S = minetest.get_translator(minetest.get_current_modname())
eye_spy.S = S
-- eye_spy.cg is set in init.lua after game detection
------------------------------------------------------------
-- Mod storage (called ONCE at load time)
------------------------------------------------------------
eye_spy.storage = minetest.get_mod_storage()
------------------------------------------------------------
-- Shared runtime tables
------------------------------------------------------------
eye_spy.state = { players = {} }
eye_spy.cache = { tool_info = {} }
eye_spy.edit_mode = {}
eye_spy.pending_preview_updates = {}
eye_spy.pending_layout_refreshes = {}
eye_spy.ui_drafts = {}
------------------------------------------------------------
-- Shared helper functions
------------------------------------------------------------
-- Returns a human-readable name from a node/item id string, or nil on failure.
-- Callers are responsible for providing their own fallback string.
function eye_spy.readable_name_from_id(name)
if not name or name == "" then
return nil
end
local raw = tostring(name)
local stripped = raw:match(":(.+)$") or raw
stripped = stripped
:gsub("_source$", "")
:gsub("_flowing$", "")
:gsub("[_%-%./]+", " ")
:gsub("%s+", " ")
:gsub("^%s+", "")
:gsub("%s+$", "")
if stripped == "" then
return nil
end
return (stripped:gsub("(%a)([%w']*)", function(a, b)
return string.upper(a) .. string.lower(b)
end))
end
-- Returns the mod portion of a namespaced id (e.g. "default" from "default:stone").
function eye_spy.get_modname_from_obj(obj_name)
if not obj_name then return nil end
return obj_name:match("([^:]+)")
end
-- Rounds x to one decimal place, or returns nil if x is falsy.
function eye_spy.round1(x)
if x then
return math.floor(x * 10 + 0.5) / 10
else
return nil
end
end
-- Strips everything from the first texture-modifier caret (^) onwards.
function eye_spy.strip_texture_modifiers(tex)
if type(tex) ~= "string" then
return tex
end
local pos = tex:find("%^")
if not pos then
return tex
end
return tex:sub(1, pos - 1)
end
-- Parses an "R, G, B" string and returns three numbers.
-- Falls back to 26, 26, 27 for any component that cannot be parsed.
function eye_spy.get_rgb(v)
if not v or v == "" then
return 26, 26, 27
end
v = v:gsub("%s", "")
local r, g, b = v:match("^(%d+),(%d+),(%d+)$")
r, g, b = tonumber(r), tonumber(g), tonumber(b)
return r or 26, g or 26, b or 27
end
-- Converts three 0-255 channel values to an uppercase hex string.
function eye_spy.rgb_to_hex(r, g, b)
return string.format("%02X%02X%02X", r, g, b)
end
------------------------------------------------------------
-- Interval system
------------------------------------------------------------
local function clamp_interval_ms(value)
return math.max(50, math.min(5000, tonumber(value) or 250))
end
local function default_interval_for_mode()
if minetest.is_singleplayer and minetest.is_singleplayer() then
return 100
end
return 150
end
function eye_spy.get_server_default_interval_ms()
local raw = eye_spy.storage:get_string("server_default_interval_ms")
if raw and raw ~= "" then
return clamp_interval_ms(raw)
end
return default_interval_for_mode()
end
function eye_spy.set_server_default_interval_ms(interval_ms)
eye_spy.storage:set_string(
"server_default_interval_ms",
tostring(clamp_interval_ms(interval_ms))
)
end
function eye_spy.get_player_interval_override_ms(player_name)
if not player_name or player_name == "" then
return nil
end
local raw = eye_spy.storage:get_string("player_interval_ms:" .. player_name)
if raw == "" then
return nil
end
return clamp_interval_ms(raw)
end
function eye_spy.set_player_interval_override_ms(player_name, interval_ms)
if not player_name or player_name == "" then
return
end
local key = "player_interval_ms:" .. player_name
if interval_ms == nil then
eye_spy.storage:set_string(key, "")
return
end
eye_spy.storage:set_string(key, tostring(clamp_interval_ms(interval_ms)))
end
function eye_spy.get_effective_interval_ms(player_name)
return eye_spy.get_player_interval_override_ms(player_name)
or eye_spy.get_server_default_interval_ms()
end
function eye_spy.apply_player_interval(player)
if not player then return end
local meta = player:get_meta()
local player_name = player:get_player_name()
meta:set_int("es_update_interval_ms", eye_spy.get_effective_interval_ms(player_name))
end
------------------------------------------------------------
-- Admin / player disable system
------------------------------------------------------------
-- Returns true if a server admin has suppressed this player's HUD.
function eye_spy.is_player_server_disabled(player_name)
if not player_name or player_name == "" then return false end
return eye_spy.storage:get_string("player_disabled:" .. player_name) == "true"
end
-- Sets or clears the server-level disable flag for a player.
function eye_spy.set_player_server_disabled(player_name, disabled)
if not player_name or player_name == "" then return end
eye_spy.storage:set_string(
"player_disabled:" .. player_name,
disabled and "true" or ""
)
end
-- Returns true if the player has self-disabled their HUD via meta.
function eye_spy.is_player_self_disabled(player_name)
local player = minetest.get_player_by_name(player_name)
if not player then return false end
return player:get_meta():get_string("es_hud_enabled") == "false"
end
------------------------------------------------------------
-- Player state management
------------------------------------------------------------
-- Returns the runtime state table for a player, creating it if absent.
function eye_spy.get_player_state(player)
local player_name = player:get_player_name()
local state = eye_spy.state.players[player_name]
if not state then
state = {
timer = 0,
edit_mode = false,
last_target_key = nil,
last_render_key = nil,
last_geom_key = nil,
last_probe_pos = nil,
last_probe_dir = nil,
cached_target = nil,
skip_static_acquire = false,
hud_hidden_since_us = nil,
last_recover_try_us = 0,
hud_ids = { lines = {} },
}
eye_spy.state.players[player_name] = state
end
return state
end
-- Clears all cached per-player data and the layout signature.
function eye_spy.invalidate_player(player)
local player_name = player:get_player_name()
local state = eye_spy.get_player_state(player)
state.last_target_key = nil
state.last_render_key = nil
state.last_geom_key = nil
state.cached_target = nil
state.last_probe_pos = nil
state.last_probe_dir = nil
state.skip_static_acquire = false
state.hud_hidden_since_us = nil
state.last_recover_try_us = 0
if eye_spy.render and eye_spy.render.invalidate_layout_sig then
eye_spy.render.invalidate_layout_sig(player_name)
end
end
------------------------------------------------------------
-- Edit mode
------------------------------------------------------------
function eye_spy.enter_edit_mode(player)
if not player then return end
local player_name = player:get_player_name()
local state = eye_spy.get_player_state(player)
if not state.edit_mode then
eye_spy.ensure_player_defaults(player)
state.edit_mode = true
state.timer = 0
eye_spy.edit_mode[player_name] = true
if eye_spy.ui_drafts then
eye_spy.ui_drafts[player_name] = nil
end
minetest.show_formspec(player_name, "eye_spy:ui", eye_spy.get_ui(player, { reopen = false }))
end
end
function eye_spy.exit_edit_mode(player)
if not player then return end
local player_name = player:get_player_name()
local state = eye_spy.get_player_state(player)
if state.edit_mode then
local meta = player:get_meta()
meta:set_string("es_last_obj", "")
meta:set_int("es_last_health", -1)
state.edit_mode = false
state.last_render_key = nil
state.timer = 0
eye_spy.edit_mode[player_name] = false
eye_spy.pending_preview_updates[player_name] = nil
if eye_spy.ui_drafts then
eye_spy.ui_drafts[player_name] = nil
end
minetest.close_formspec(player_name, "eye_spy:ui")
minetest.close_formspec(player_name, "eye_spy:layout_ui")
if eye_spy.render and eye_spy.render.hide then
eye_spy.render.hide(state, player)
end
if eye_spy.render and eye_spy.render.invalidate_layout_sig then
eye_spy.render.invalidate_layout_sig(player_name)
end
end
end
------------------------------------------------------------
-- Player defaults
------------------------------------------------------------
function eye_spy.ensure_player_defaults(player)
local meta = player:get_meta()
if meta:get_string("es_hud_color") == "" then
meta:set_string("es_hud_color", "26, 26, 27")
end
if meta:get_string("es_hud_alignment") == "" then
meta:set_string("es_hud_alignment", "Top-Middle")
end
if meta:get_string("es_hud_health_in") == "" then
meta:set_string("es_hud_health_in", "Points")
end
if meta:get_string("es_auto_text_color") == "" then
meta:set_string("es_auto_text_color", "true")
end
if meta:get_string("es_title_color_val") == "" then
meta:set_int("es_title_color_val", 255)
end
if meta:get_string("es_subtitle_color_val") == "" then
meta:set_int("es_subtitle_color_val", 255)
end
if meta:get_string("es_footer_color_val") == "" then
meta:set_int("es_footer_color_val", 255)
end
if meta:get_string("es_line_color_val") == "" then
meta:set_int("es_line_color_val", 255)
end
if meta:get_string("es_growth_color_val") == "" then
meta:set_int("es_growth_color_val", 110)
end
if meta:get_string("es_soil_color_val") == "" then
meta:set_int("es_soil_color_val", 165)
end
if meta:get_string("es_show_growth") == "" then
meta:set_string("es_show_growth", eye_spy.config.show_growth and "true" or "false")
end
if meta:get_string("es_show_icons") == "" then
meta:set_string("es_show_icons", eye_spy.config.show_icons and "true" or "false")
end
if meta:get_string("es_layout_target") == "" then
meta:set_string("es_layout_target", "Global")
end
if meta:get_string("es_layout_step") == "" then
meta:set_int("es_layout_step", 2)
end
if meta:get_string("es_layout_page") == "" then
meta:set_string("es_layout_page", "Position")
end
if meta:get_string("es_footer_mode") == "" then
meta:set_string("es_footer_mode", "compact")
end
if meta:get_string("es_show_coords") == "" then
meta:set_string("es_show_coords", eye_spy.config.default_show_coords and "true" or "false")
end
if meta:get_string("es_show_light_level") == "" then
meta:set_string("es_show_light_level", eye_spy.config.default_show_light_level and "true" or "false")
end
if meta:get_string("es_show_spawn_hint") == "" then
meta:set_string("es_show_spawn_hint", eye_spy.config.default_show_spawn_hint and "true" or "false")
end
if meta:get_string("es_show_liquid_info") == "" then
meta:set_string("es_show_liquid_info", eye_spy.config.default_show_liquid_info and "true" or "false")
end
if meta:get_string("es_show_dig_time") == "" then
meta:set_string("es_show_dig_time", eye_spy.config.default_show_dig_time and "true" or "false")
end
if meta:get_string("es_spawn_safe_light_threshold") == "" then
meta:set_int("es_spawn_safe_light_threshold", 8)
end
-- Initialise hud-enabled flag so the disable check has a reliable baseline.
if meta:get_string("es_hud_enabled") == "" then
meta:set_string("es_hud_enabled", "true")
end
eye_spy.apply_player_interval(player)
local int_defaults = {
es_global_offset_x = 0,
es_global_offset_y = 0,
es_bg_offset_x = 0,
es_bg_offset_y = 0,
es_icon_offset_x = 0,
es_icon_offset_y = 0,
es_title_offset_x = 0,
es_title_offset_y = 0,
es_subtitle_offset_x = 0,
es_subtitle_offset_y = 0,
es_lines_offset_x = 0,
es_lines_offset_y = 0,
es_footer_offset_x = 0,
es_footer_offset_y = 0,
es_bg_pad_left = 7,
es_bg_pad_right = 0,
es_bg_pad_top = 5,
es_bg_pad_bottom = 0,
es_bg_extra_w = 0,
es_bg_extra_h = 0,
es_bg_scale_x_pct = 100,
es_bg_scale_y_pct = 100,
es_icon_size = 56,
es_title_scale_pct = 100,
es_subtitle_scale_pct = 100,
es_line_scale_pct = 100,
es_footer_scale_pct = 100,
es_first_line_y_adj = 0,
es_line_step_adj = -2, -- matches LAYOUT_META_DEFAULTS in ui.lua
es_footer_nudge_adj = 0,
es_text_base_x_adj = 0,
es_icon_base_x_adj = 0,
es_top_margin_adj = 0,
es_bottom_margin_adj = 0,
}
for key, value in pairs(int_defaults) do
if meta:get_string(key) == "" then
meta:set_int(key, value)
end
end
end
------------------------------------------------------------
-- Core update pipeline
------------------------------------------------------------
local function probe_state_matches(state, player)
if not state.last_probe_pos or not state.last_probe_dir then
return false
end
local pos = player:get_pos()
local dir = player:get_look_dir()
local dx = math.abs((pos.x or 0) - (state.last_probe_pos.x or 0))
local dy = math.abs((pos.y or 0) - (state.last_probe_pos.y or 0))
local dz = math.abs((pos.z or 0) - (state.last_probe_pos.z or 0))
local ddx = math.abs((dir.x or 0) - (state.last_probe_dir.x or 0))
local ddy = math.abs((dir.y or 0) - (state.last_probe_dir.y or 0))
local ddz = math.abs((dir.z or 0) - (state.last_probe_dir.z or 0))
return dx < 0.05 and dy < 0.05 and dz < 0.05
and ddx < 0.01 and ddy < 0.01 and ddz < 0.01
end
local function remember_probe_state(state, player, target)
local pos = player:get_pos()
local dir = player:get_look_dir()
state.last_probe_pos = vector.new(pos)
state.last_probe_dir = vector.new(dir)
state.cached_target = target
end
function eye_spy.update_player(player, opts)
if not player or not eye_spy.target or not eye_spy.render then
return
end
local perf_on = eye_spy.perf.enabled and minetest.get_us_time
local update_start_us = perf_on and minetest.get_us_time() or 0
local options = opts or {}
local state = eye_spy.get_player_state(player)
local target
if not options.force
and not options.preview
and state.cached_target
and probe_state_matches(state, player)
and state.skip_static_acquire
then
target = state.cached_target
state.skip_static_acquire = false
else
local acquire_start_us = perf_on and minetest.get_us_time() or 0
target = eye_spy.target.acquire(player, options)
if perf_on then
eye_spy.perf_record("target_acquire", minetest.get_us_time() - acquire_start_us)
end
if not options.preview then
remember_probe_state(state, player, target)
state.skip_static_acquire = target and target.kind ~= "air"
end
end
if not target or target.kind == "air" then
local now_us = minetest.get_us_time and minetest.get_us_time() or 0
eye_spy.render.hide(state, player)
state.last_target_key = nil
state.last_render_key = nil
state.cached_target = nil
state.skip_static_acquire = false
if now_us > 0 and not state.hud_hidden_since_us then
state.hud_hidden_since_us = now_us
end
if perf_on then
eye_spy.perf_record("update_player_total", minetest.get_us_time() - update_start_us)
end
return
end
local build_start_us = perf_on and minetest.get_us_time() or 0
local view_model = eye_spy.render.build_view_model(player, target, options)
if perf_on then
eye_spy.perf_record("build_view_model", minetest.get_us_time() - build_start_us)
end
if not options.force and view_model.key == state.last_render_key then
state.last_target_key = target.key
eye_spy.perf_inc_counter("render_skip_same_key")
if perf_on then
eye_spy.perf_record("update_player_total", minetest.get_us_time() - update_start_us)
end
return
end
local render_start_us = perf_on and minetest.get_us_time() or 0
eye_spy.render.apply(state, player, view_model)
if perf_on then
eye_spy.perf_record("render_apply", minetest.get_us_time() - render_start_us)
end
state.last_target_key = target.key
state.last_render_key = view_model.key
state.hud_hidden_since_us = nil
state.last_recover_try_us = 0
if perf_on then
eye_spy.perf_record("update_player_total", minetest.get_us_time() - update_start_us)
end
end
-- Thin wrapper used by the UI and preview system.
function eye_spy.get_hud(player, data)
eye_spy.update_player(player, data or {})
end
------------------------------------------------------------
-- Main step function
------------------------------------------------------------
function eye_spy.step(dtime)
local perf_on = eye_spy.perf.enabled and minetest.get_us_time
local step_start_us = perf_on and minetest.get_us_time() or 0
local now_us = minetest.get_us_time and minetest.get_us_time() or 0
for _, player in ipairs(minetest.get_connected_players()) do
local state = eye_spy.get_player_state(player)
local player_name = player:get_player_name()
local pending_preview = eye_spy.pending_preview_updates[player_name]
-- Service pending edit-mode preview updates.
if state.edit_mode and pending_preview then
local due_at = pending_preview.due_at_us or 0
if pending_preview.force or due_at <= now_us then
eye_spy.pending_preview_updates[player_name] = nil
eye_spy.update_player(player, {
preview = true,
preview_type = pending_preview.preview_type,
force = pending_preview.force == true,
})
end
end
-- Service pending layout-UI refreshes.
local pending_layout_refresh = eye_spy.pending_layout_refreshes[player_name]
if state.edit_mode
and pending_layout_refresh
and pending_layout_refresh.due_at_us <= now_us
then
eye_spy.pending_layout_refreshes[player_name] = nil
minetest.show_formspec(player_name, "eye_spy:layout_ui", eye_spy.get_layout_ui(player))
end
if not state.edit_mode then
local meta = player:get_meta()
-- Check both server-admin and player-self disable flags.
local hud_disabled = eye_spy.is_player_server_disabled(player_name)
or (meta:get_string("es_hud_enabled") == "false")
if hud_disabled then
if state.last_render_key then
eye_spy.render.hide(state, player)
state.last_target_key = nil
state.last_render_key = nil
state.cached_target = nil
state.skip_static_acquire = false
end
state.timer = 0
goto continue_player
end
local interval_ms = meta:get_int("es_update_interval_ms")
local interval = eye_spy.get_server_default_interval_ms() / 1000
if interval_ms > 0 then
interval = interval_ms / 1000
end
interval = math.max(0.05, math.min(5, interval))
state.timer = state.timer + math.min(dtime, interval)
if state.timer >= interval then
state.timer = 0
eye_spy.update_player(player, { preview = false })
end
-- Focus-loss / self-heal: if the HUD appears stuck hidden while we still
-- have a valid cached target, force an occasional refresh attempt.
if state.hud_hidden_since_us
and state.cached_target
and state.cached_target.kind ~= "air"
then
local hidden_for_us = now_us - state.hud_hidden_since_us
local recover_cooldown_us = 900000
if hidden_for_us >= 1200000
and (state.last_recover_try_us or 0) + recover_cooldown_us <= now_us
then
state.last_recover_try_us = now_us
eye_spy.perf_inc_counter("hud_recover_forced_refresh")
eye_spy.update_player(player, { preview = false, force = true })
end
end
end
::continue_player::
end
if perf_on then
eye_spy.perf_record("step_total", minetest.get_us_time() - step_start_us)
end
end
------------------------------------------------------------
-- HUD cleanup
------------------------------------------------------------
function eye_spy.remove_huds(player)
if eye_spy.render then eye_spy.render.remove(player) end
end