Files
modolith/mods/eye_spy/render.lua
T

865 lines
31 KiB
Lua

local S = eye_spy.S
eye_spy.render = {}
-- Bump this string whenever the HUD layout math, element set, or render key
-- format changes in a way that would make old cached render keys produce a
-- wrong or stale layout for existing players.
local RENDER_LAYOUT_REV = "advanced-layout-controls-v2-padding-fix"
local ICON_TEXTURE_SIZE = 32
local ICON_DISPLAY_SIZE = 56
local ENTITY_ICON_TEXTURE = "heart.png"
local BASE_LAYOUT_LINE_ROWS = 6
local MAX_LAYOUT_SCALE_FACTOR = 3
local FIXED_LINE_ROW_BY_ID = {
dig_time = 1,
light = 2,
soil = 3,
growth = 4,
spawn = 5,
liquid = 6,
}
local FIXED_LINE_ORDER = {
"dig_time",
"light",
"soil",
"growth",
"spawn",
"liquid",
}
local layout_key_names = {
"es_global_offset_x",
"es_global_offset_y",
"es_bg_offset_x",
"es_bg_offset_y",
"es_icon_offset_x",
"es_icon_offset_y",
"es_title_offset_x",
"es_title_offset_y",
"es_subtitle_offset_x",
"es_subtitle_offset_y",
"es_lines_offset_x",
"es_lines_offset_y",
"es_footer_offset_x",
"es_footer_offset_y",
"es_bg_pad_left",
"es_bg_pad_right",
"es_bg_pad_top",
"es_bg_pad_bottom",
"es_bg_extra_w",
"es_bg_extra_h",
"es_bg_scale_x_pct",
"es_bg_scale_y_pct",
"es_icon_size",
"es_title_scale_pct",
"es_subtitle_scale_pct",
"es_line_scale_pct",
"es_footer_scale_pct",
"es_first_line_y_adj",
"es_line_step_adj",
"es_footer_nudge_adj",
"es_text_base_x_adj",
"es_icon_base_x_adj",
"es_top_margin_adj",
"es_bottom_margin_adj",
}
local layouts = {
["Top-Middle"] = { pos = { x = 0.5, y = 0 }, x_mode = "center", y_mode = "top" },
["Top-Right"] = { pos = { x = 1, y = 0 }, x_mode = "right", y_mode = "top" },
["Top-Left"] = { pos = { x = 0, y = 0 }, x_mode = "left", y_mode = "top" },
["Middle-Right"] = { pos = { x = 1, y = 0.5 }, x_mode = "right", y_mode = "middle" },
["Middle-Left"] = { pos = { x = 0, y = 0.5 }, x_mode = "left", y_mode = "middle" },
["Bottom-Right"] = { pos = { x = 1, y = 1 }, x_mode = "right", y_mode = "bottom" },
["Bottom-Left"] = { pos = { x = 0, y = 1 }, x_mode = "left", y_mode = "bottom" },
}
-- ---------------------------------------------------------------------------
-- Color math — aliased from colors.lua for local-variable call performance.
-- ---------------------------------------------------------------------------
local clamp = eye_spy.colors.clamp
local split_rgb = eye_spy.colors.split_rgb
local join_rgb = eye_spy.colors.join_rgb
local channel_luminance = eye_spy.colors.channel_luminance
local color_luminance = eye_spy.colors.color_luminance
local contrast_ratio = eye_spy.colors.contrast_ratio
local scale_color = eye_spy.colors.scale_color
local blend_color = eye_spy.colors.blend_color
local rgb_to_hsl = eye_spy.colors.rgb_to_hsl
local hsl_to_rgb = eye_spy.colors.hsl_to_rgb
local get_bg_luminance_rgb = eye_spy.colors.get_bg_luminance_rgb
local get_bg_luminance = eye_spy.colors.get_bg_luminance
local best_monochrome_for_bg = eye_spy.colors.best_monochrome_for_bg
local spectrum_color = eye_spy.colors.spectrum_color
local adapt_for_bg = eye_spy.colors.adapt_for_bg
local nudge_contrast = eye_spy.colors.nudge_contrast
local tune_contrast_preserve_hue = eye_spy.colors.tune_contrast_preserve_hue
local function build_layout_lines(view_model)
local fixed_slots = {}
local append_lines = {}
for _, line in ipairs(view_model.lines or {}) do
local fixed_row = line.id and FIXED_LINE_ROW_BY_ID[line.id] or nil
if fixed_row and fixed_slots[line.id] == nil then
fixed_slots[line.id] = line
else
append_lines[#append_lines + 1] = line
end
end
local ordered_lines = {}
for _, line_id in ipairs(FIXED_LINE_ORDER) do
if fixed_slots[line_id] then
ordered_lines[#ordered_lines + 1] = fixed_slots[line_id]
end
end
for _, line in ipairs(append_lines) do
ordered_lines[#ordered_lines + 1] = line
end
local total_rows = #ordered_lines
local required_rows = math.max(1, total_rows)
local max_rows = BASE_LAYOUT_LINE_ROWS * MAX_LAYOUT_SCALE_FACTOR
local visible_rows = math.min(required_rows, max_rows)
local layout_lines = {}
for row = 1, visible_rows do
layout_lines[row] = ordered_lines[row]
end
local overflow_count = math.max(0, total_rows - visible_rows)
if overflow_count > 0 and visible_rows > 0 then
layout_lines[visible_rows] = {
id = "overflow",
text = string.format(S("+%d more"), overflow_count),
color = 0xAAAAAA,
}
end
return layout_lines, visible_rows
end
local function estimate_panel_width(view_model, layout_lines)
local max_len = math.max(#(view_model.title or ""), #(view_model.subtitle or ""), #(view_model.footer or ""))
for _, line in ipairs(layout_lines or view_model.lines or {}) do
if line then
max_len = math.max(max_len, #(line.text or ""))
end
end
local baseline_width = view_model.icon and 468 or 420
local baseline_char_budget = view_model.icon and 176 or 208
local grow_chars = math.max(0, max_len - baseline_char_budget)
local required_width = baseline_width + math.floor(grow_chars * 6)
return clamp(required_width, baseline_width, baseline_width * MAX_LAYOUT_SCALE_FACTOR)
end
local function has_line(view_model, line_id)
for _, line in ipairs(view_model.lines) do
if line.id == line_id then
return true
end
end
return false
end
-- Hue offsets (degrees) spread each line type around the complementary of
-- the background hue so they remain visually distinct from each other.
local LINE_HUE_OFFSETS = {
growth = 0,
soil = 30,
dig_time = 55,
light = 85,
spawn = 45,
liquid = -50,
}
local auto_line_color_cache = {}
local auto_line_color_cache_size = 0
local function get_auto_line_accent(line_id, bg_hex, bg_color, base_text, bg_luminance)
local cache_key = table.concat({ line_id or "", bg_hex or "", tostring(base_text) }, "|")
local cached = auto_line_color_cache[cache_key]
if cached then
return cached
end
local bg_r, bg_g, bg_b = split_rgb(bg_color)
local bg_h, bg_s, bg_l = rgb_to_hsl(bg_r, bg_g, bg_b)
-- Dynamic accent: complementary hue + per-line offset, lightness inversely
-- proportional to bg lightness so it remains readable on any saturated bg
-- without collapsing to a monochrome colour.
local hue_off = LINE_HUE_OFFSETS[line_id] or 0
local accent_h = (bg_h + 180 + hue_off) % 360
local accent_s = clamp(0.60 + bg_s * 0.22, 0.48, 0.88)
local accent_l = clamp(0.76 - bg_l * 0.52, 0.20, 0.82)
local dr, dg, db = hsl_to_rgb(accent_h, accent_s, accent_l)
local dynamic = join_rgb(dr, dg, db)
-- Fixed palettes are still used as the starting point for near-neutral
-- backgrounds where the bg hue is undefined or very weak.
local dark_palette = {
growth = 0x8BE68B,
soil = 0xE7C882,
dig_time = 0xAFC9FF,
light = 0xF2DE98,
spawn = 0xF2B189,
liquid = 0x8CC8FF,
}
local light_palette = {
growth = 0x2D7A35,
soil = 0x7E5A1F,
dig_time = 0x345C98,
light = 0x725C1D,
spawn = 0x94442A,
liquid = 0x1E5E9A,
}
local lightness_t = clamp((bg_l - 0.35) / 0.30, 0, 1)
local dark_fixed = dark_palette[line_id] or dynamic
local light_fixed = light_palette[line_id] or dynamic
local fixed = blend_color(dark_fixed, light_fixed, lightness_t)
-- Blend: saturated bg → trust dynamic complementary; neutral bg → fixed.
local accent = blend_color(fixed, dynamic, clamp(bg_s * 1.3, 0, 1))
-- Contrast pass without hard-snapping to monochrome fallback.
local result = tune_contrast_preserve_hue(accent, bg_color, 3.6)
result = nudge_contrast(result, bg_color, 3.8)
if auto_line_color_cache_size > 512 then
auto_line_color_cache = {}
auto_line_color_cache_size = 0
end
if not auto_line_color_cache[cache_key] then
auto_line_color_cache_size = auto_line_color_cache_size + 1
end
auto_line_color_cache[cache_key] = result
return result
end
local function adjust_semantic_line_color(color, bg_color)
return nudge_contrast(
tune_contrast_preserve_hue(color, bg_color, 3.6),
bg_color,
3.8
)
end
local function get_line_color_for_manual(meta, line_id)
if line_id == "growth" then
return spectrum_color(meta:get_int("es_growth_color_val"))
end
if line_id == "soil" then
return spectrum_color(meta:get_int("es_soil_color_val"))
end
return spectrum_color(meta:get_int("es_line_color_val"))
end
local function position_text(pos)
if not pos then
return "0, 0, 0"
end
return string.format("%s, %s, %s", eye_spy.round1(pos.x) or 0, eye_spy.round1(pos.y) or 0, eye_spy.round1(pos.z) or 0)
end
local function texture_text(target)
if target and target.kind == "entity" then
return ENTITY_ICON_TEXTURE .. "^[resize:" .. ICON_TEXTURE_SIZE .. "x" .. ICON_TEXTURE_SIZE
end
local texture = target.texture or "eye_spy_default_texture.png"
if type(texture) == "table" then
texture = texture.name or texture.image or "eye_spy_default_texture.png"
end
if type(texture) ~= "string" or texture == "" then
texture = "eye_spy_default_texture.png"
end
texture = eye_spy.strip_texture_modifiers(texture)
if target.color then
texture = texture .. "^[multiply:" .. target.color
elseif target.palette then
texture = texture .. "^[multiply:#7CBD6B"
end
-- Normalize every icon texture to the same source size so HUD icon size is consistent.
texture = texture .. "^[resize:" .. ICON_TEXTURE_SIZE .. "x" .. ICON_TEXTURE_SIZE
return texture
end
-- Per-player layout signature cache. The 35-key meta scan only needs to run
-- when the player is actively changing layout values in the editor. All other
-- updates (normal play, colour slider dragging) reuse the cached string.
local player_layout_sigs = {}
local function get_player_draft(player)
if not player or not eye_spy.ui_drafts then
return nil
end
return eye_spy.ui_drafts[player:get_player_name()]
end
local function get_effective_string(meta, draft, key)
if draft and draft[key] ~= nil then
return tostring(draft[key])
end
return meta:get_string(key)
end
local function get_effective_int(meta, draft, key)
if draft and draft[key] ~= nil then
return tonumber(draft[key]) or 0
end
return meta:get_int(key)
end
function eye_spy.render.invalidate_layout_sig(player_name)
player_layout_sigs[player_name] = nil
end
local function get_layout_signature(meta, player_name)
if player_name and player_layout_sigs[player_name] then
return player_layout_sigs[player_name]
end
local parts = {}
for _, key in ipairs(layout_key_names) do
parts[#parts + 1] = tostring(meta:get_int(key))
end
local sig = table.concat(parts, ",")
if player_name then
player_layout_sigs[player_name] = sig
end
return sig
end
local function get_layout_signature_from_values(meta, draft)
local parts = {}
for _, key in ipairs(layout_key_names) do
parts[#parts + 1] = tostring(get_effective_int(meta, draft, key))
end
return table.concat(parts, ",")
end
local function build_key(view_model)
local parts = {
RENDER_LAYOUT_REV,
view_model.layout_signature or "",
view_model.layout,
view_model.bg_color,
view_model.title,
tostring(view_model.title_color),
view_model.subtitle,
tostring(view_model.subtitle_color),
view_model.footer,
tostring(view_model.footer_color),
view_model.icon and view_model.icon.text or "",
tostring(#view_model.lines),
}
for _, line in ipairs(view_model.lines) do
parts[#parts + 1] = line.id
parts[#parts + 1] = line.text
parts[#parts + 1] = tostring(line.color)
end
return table.concat(parts, "|")
end
local function hud_elem_type(def)
return def and (def.type or def.hud_elem_type)
end
local function ensure_hud_slot(player, hud_ids, key, expected_type, spec)
local id = hud_ids[key]
if id then
local def = player:hud_get(id)
if not def or hud_elem_type(def) ~= expected_type then
if def then
player:hud_remove(id)
end
hud_ids[key] = nil
id = nil
end
end
if not id then
hud_ids[key] = player:hud_add(spec)
end
end
function eye_spy.render.ensure_hud(state, player)
local hud_ids = state.hud_ids
ensure_hud_slot(player, hud_ids, "bg", "image", {
type = "image",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
scale = { x = 400, y = 82 },
alignment = { x = 0, y = 0 },
z_index = 1,
})
ensure_hud_slot(player, hud_ids, "icon", "image", {
type = "image",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
scale = { x = 3.5, y = 3.5 },
alignment = { x = 0, y = 0 },
z_index = 2,
})
ensure_hud_slot(player, hud_ids, "title", "text", {
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0xFFFFFF,
alignment = { x = 1, y = 0 },
z_index = 2,
})
ensure_hud_slot(player, hud_ids, "subtitle", "text", {
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0xEBB344,
alignment = { x = 1, y = 0 },
z_index = 2,
})
ensure_hud_slot(player, hud_ids, "footer", "text", {
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0x4343F0,
alignment = { x = 1, y = 0 },
z_index = 2,
})
end
function eye_spy.render.ensure_line_slots(state, player, count)
local line_ids = state.hud_ids.lines
for i = 1, #line_ids do
local id = line_ids[i]
local def = id and player:hud_get(id)
if not def or hud_elem_type(def) ~= "text" then
if def then
player:hud_remove(id)
end
line_ids[i] = player:hud_add({
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0xFFFFFF,
alignment = { x = 1, y = 0 },
z_index = 2,
})
end
end
while #line_ids < count do
line_ids[#line_ids + 1] = player:hud_add({
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0xFFFFFF,
alignment = { x = 1, y = 0 },
z_index = 2,
})
end
end
function eye_spy.render.hide(state, player)
if not state or not state.hud_ids.bg then
return
end
local hud_ids = state.hud_ids
player:hud_change(hud_ids.bg, "text", "")
player:hud_change(hud_ids.icon, "text", "")
player:hud_change(hud_ids.title, "text", "")
player:hud_change(hud_ids.subtitle, "text", "")
player:hud_change(hud_ids.footer, "text", "")
for _, line_id in ipairs(hud_ids.lines) do
player:hud_change(line_id, "text", "")
end
end
function eye_spy.render.remove(player)
local state = eye_spy.get_player_state(player)
local hud_ids = state.hud_ids
for key, value in pairs(hud_ids) do
if key == "lines" then
for _, line_id in ipairs(value) do
if line_id and player:hud_get(line_id) then
player:hud_remove(line_id)
end
end
elseif value and player:hud_get(value) then
player:hud_remove(value)
end
end
state.hud_ids = { lines = {} }
state.last_render_key = nil
state.last_geom_key = nil
end
function eye_spy.render.build_view_model(player, target, opts)
local perf_on = eye_spy.perf and eye_spy.perf.enabled and minetest.get_us_time
local phase_start_us = perf_on and minetest.get_us_time() or 0
local meta = player:get_meta()
local options = opts or {}
local draft = get_player_draft(player)
local effective_meta = meta
if draft then
effective_meta = {
get_string = function(_, key)
return get_effective_string(meta, draft, key)
end,
get_int = function(_, key)
return get_effective_int(meta, draft, key)
end,
}
end
local r, g, b = eye_spy.get_rgb(get_effective_string(meta, draft, "es_hud_color"))
local bg_luminance = get_bg_luminance_rgb(r or 26, g or 26, b or 27)
local bg_color_int = join_rgb(r or 26, g or 26, b or 27)
local auto_text = get_effective_string(meta, draft, "es_auto_text_color") ~= "false"
local footer_mode = get_effective_string(meta, draft, "es_footer_mode")
local show_coords = get_effective_string(meta, draft, "es_show_coords") ~= "false"
local footer_text
local coords_text = show_coords and (" " .. position_text(target.pos)) or ""
if footer_mode == "advanced" then
local full_name = target.full_name or target.name or S("Unknown")
footer_text = "[" .. full_name .. "] [" .. (target.modname or S("Unknown")) .. "]" .. coords_text
else
footer_text = "[" .. (target.modname or S("Unknown")) .. "]" .. coords_text
end
local view_model = {
visible = target.kind ~= "air",
layout = get_effective_string(meta, draft, "es_hud_alignment") ~= "" and
get_effective_string(meta, draft, "es_hud_alignment") or "Top-Middle",
bg_color = eye_spy.rgb_to_hex(r or 26, g or 26, b or 27),
title = target.description or target.name or S("Unknown"),
title_color = 0xFFFFFF,
subtitle = target.kind == "entity" and S("Entity") or (target.kind == "item" and S("Item") or S("Node")),
subtitle_color = 0xF2D23C,
footer = footer_text,
footer_color = 0x4C7EE8,
lines = {},
layout_signature = draft and get_layout_signature_from_values(meta, draft) or
get_layout_signature(meta, player:get_player_name()),
}
if get_effective_string(meta, draft, "es_show_icons") ~= "false" then
local icon_size = clamp(get_effective_int(meta, draft, "es_icon_size"), 16, 128)
if icon_size <= 0 then
icon_size = ICON_DISPLAY_SIZE
end
view_model.icon = {
text = texture_text(target),
scale = { x = icon_size / ICON_TEXTURE_SIZE, y = icon_size / ICON_TEXTURE_SIZE },
}
end
eye_spy.enrichers.run({ player = player, meta = effective_meta, preview = options.preview }, target, view_model)
if perf_on then
eye_spy.perf_record("build_vm_enrichers", minetest.get_us_time() - phase_start_us)
phase_start_us = minetest.get_us_time()
end
local show_growth = get_effective_string(meta, draft, "es_show_growth") ~= "false"
if options.preview and target.kind == "node" then
if show_growth and not has_line(view_model, "growth") then
view_model.lines[#view_model.lines + 1] = {
id = "growth",
text = S("Growth") .. ": " .. S("Preview Sample"),
color = 0x55FF55,
}
end
if show_growth and not has_line(view_model, "soil") then
view_model.lines[#view_model.lines + 1] = {
id = "soil",
text = S("Soil") .. ": " .. S("Preview Sample"),
color = 0xD8B46A,
}
end
end
if auto_text then
local base_text = best_monochrome_for_bg(bg_color_int)
view_model.title_color = base_text
-- Subtitle/footer: preserve semantic colour (amber for tool, red for health,
-- blue for footer) and nudge toward readable contrast without snapping to
-- a monochrome fallback.
view_model.subtitle_color = nudge_contrast(
tune_contrast_preserve_hue(view_model.subtitle_color, bg_color_int, 3.6),
bg_color_int, 3.8
)
view_model.footer_color = nudge_contrast(
tune_contrast_preserve_hue(view_model.footer_color, bg_color_int, 3.6),
bg_color_int, 3.8
)
for _, line in ipairs(view_model.lines) do
if line.id == "growth" or line.id == "soil" then
line.color = adjust_semantic_line_color(line.color, bg_color_int)
else
line.color = get_auto_line_accent(line.id, view_model.bg_color, bg_color_int, base_text, bg_luminance)
end
end
else
view_model.title_color = spectrum_color(get_effective_int(meta, draft, "es_title_color_val"))
view_model.subtitle_color = spectrum_color(get_effective_int(meta, draft, "es_subtitle_color_val"))
view_model.footer_color = spectrum_color(get_effective_int(meta, draft, "es_footer_color_val"))
for _, line in ipairs(view_model.lines) do
if line.id == "growth" or line.id == "soil" then
line.color = adjust_semantic_line_color(line.color, bg_color_int)
else
line.color = spectrum_color(get_effective_int(meta, draft, "es_line_color_val"))
end
end
end
if perf_on then
eye_spy.perf_record("build_vm_color_pass", minetest.get_us_time() - phase_start_us)
phase_start_us = minetest.get_us_time()
end
view_model.key = build_key(view_model)
if perf_on then
eye_spy.perf_record("build_vm_key_build", minetest.get_us_time() - phase_start_us)
end
return view_model
end
function eye_spy.render.apply(state, player, view_model)
local layout_lines, visible_line_rows = build_layout_lines(view_model)
eye_spy.render.ensure_hud(state, player)
eye_spy.render.ensure_line_slots(state, player, visible_line_rows)
local meta = player:get_meta()
local draft = get_player_draft(player)
local layout = layouts[view_model.layout] or layouts["Top-Middle"]
local line_count = visible_line_rows
local global_offset_x = get_effective_int(meta, draft, "es_global_offset_x")
local global_offset_y = get_effective_int(meta, draft, "es_global_offset_y")
local bg_offset_x = get_effective_int(meta, draft, "es_bg_offset_x")
local bg_offset_y = get_effective_int(meta, draft, "es_bg_offset_y")
local icon_offset_x = get_effective_int(meta, draft, "es_icon_offset_x")
local icon_offset_y = get_effective_int(meta, draft, "es_icon_offset_y")
local title_offset_x = get_effective_int(meta, draft, "es_title_offset_x")
local title_offset_y = get_effective_int(meta, draft, "es_title_offset_y")
local subtitle_offset_x = get_effective_int(meta, draft, "es_subtitle_offset_x")
local subtitle_offset_y = get_effective_int(meta, draft, "es_subtitle_offset_y")
local lines_offset_x = get_effective_int(meta, draft, "es_lines_offset_x")
local lines_offset_y = get_effective_int(meta, draft, "es_lines_offset_y")
local footer_offset_x = get_effective_int(meta, draft, "es_footer_offset_x")
local footer_offset_y = get_effective_int(meta, draft, "es_footer_offset_y")
local bg_pad_left = get_effective_int(meta, draft, "es_bg_pad_left")
local bg_pad_right = get_effective_int(meta, draft, "es_bg_pad_right")
local bg_pad_top = get_effective_int(meta, draft, "es_bg_pad_top")
local bg_pad_bottom = get_effective_int(meta, draft, "es_bg_pad_bottom")
local bg_extra_w = get_effective_int(meta, draft, "es_bg_extra_w")
local bg_extra_h = get_effective_int(meta, draft, "es_bg_extra_h")
local text_base_x_adj = get_effective_int(meta, draft, "es_text_base_x_adj")
local icon_base_x_adj = get_effective_int(meta, draft, "es_icon_base_x_adj")
local top_margin_adj = get_effective_int(meta, draft, "es_top_margin_adj")
local bottom_margin_adj = get_effective_int(meta, draft, "es_bottom_margin_adj")
local title_y = 16 + title_offset_y
local subtitle_y = 34 + subtitle_offset_y
local first_line_y = 52 + get_effective_int(meta, draft, "es_first_line_y_adj")
local line_step = math.max(10, 18 + get_effective_int(meta, draft, "es_line_step_adj"))
local footer_nudge = -10 + get_effective_int(meta, draft, "es_footer_nudge_adj")
local footer_y = first_line_y + (line_count * line_step) + 10 + footer_nudge
local content_top = title_y - 8 - bg_pad_top
local content_bottom = footer_y + 22 + bg_pad_bottom
local base_footer_y = first_line_y + (BASE_LAYOUT_LINE_ROWS * line_step) + 10 + footer_nudge
local base_content_bottom = base_footer_y + 22 + bg_pad_bottom
local base_bg_height = (base_content_bottom - content_top) + bg_extra_h
local max_bg_height = math.max(52, math.floor(base_bg_height * MAX_LAYOUT_SCALE_FACTOR))
local bg_height = (content_bottom - content_top) + bg_extra_h
local bg_width = estimate_panel_width(view_model, layout_lines) + bg_pad_left + bg_pad_right + bg_extra_w
local top_screen_margin = 10 + top_margin_adj
local bottom_screen_margin = 22 + bottom_margin_adj
bg_height = clamp(bg_height, 52, max_bg_height)
bg_width = clamp(bg_width, 240, 1260)
local origin_y
if layout.y_mode == "top" then
origin_y = top_screen_margin
elseif layout.y_mode == "middle" then
origin_y = -math.floor(bg_height / 2)
else
origin_y = -(bg_height + bottom_screen_margin)
end
origin_y = origin_y + global_offset_y
local center_x = global_offset_x
if layout.x_mode == "right" then
center_x = -math.floor(bg_width / 2) - 16
elseif layout.x_mode == "left" then
center_x = math.floor(bg_width / 2) + 16
end
local left_edge_x = center_x - math.floor(bg_width / 2)
local icon_x = left_edge_x + 28 + bg_pad_left + icon_base_x_adj + icon_offset_x
local text_x = left_edge_x + (view_model.icon and 68 or 16) + bg_pad_left + text_base_x_adj
local bg_scale_x_pct = clamp(get_effective_int(meta, draft, "es_bg_scale_x_pct"), 25, 300)
local bg_scale_y_pct = clamp(get_effective_int(meta, draft, "es_bg_scale_y_pct"), 25, 300)
local bg_draw_w = math.max(1, math.floor(bg_width * (bg_scale_x_pct / 100)))
local bg_draw_h = math.max(1, math.floor(bg_height * (bg_scale_y_pct / 100)))
local title_scale = clamp(get_effective_int(meta, draft, "es_title_scale_pct"), 40, 300) / 100
local subtitle_scale = clamp(get_effective_int(meta, draft, "es_subtitle_scale_pct"), 40, 300) / 100
local line_scale = clamp(get_effective_int(meta, draft, "es_line_scale_pct"), 40, 300) / 100
local footer_scale = clamp(get_effective_int(meta, draft, "es_footer_scale_pct"), 40, 300) / 100
local bg_center_y = origin_y + math.floor(bg_height / 2) - bg_pad_top
-- Geometry channel: position, offset, scale.
-- Only sent when layout or panel dimensions actually changed.
local geom_key = (view_model.layout or "")
.. "|" .. (view_model.layout_signature or "")
.. "|" .. tostring(bg_draw_w)
.. "|" .. tostring(bg_draw_h)
.. "|" .. tostring(line_count)
.. "|" .. (view_model.icon and tostring(math.floor(view_model.icon.scale.x * 64)) or "0")
if geom_key ~= (state.last_geom_key or "") then
state.last_geom_key = geom_key
player:hud_change(state.hud_ids.bg, "position", layout.pos)
player:hud_change(state.hud_ids.bg, "offset", { x = center_x + bg_offset_x, y = bg_center_y + bg_offset_y })
player:hud_change(state.hud_ids.bg, "scale", { x = bg_draw_w, y = bg_draw_h })
player:hud_change(state.hud_ids.title, "position", layout.pos)
player:hud_change(state.hud_ids.title, "offset", { x = text_x + title_offset_x, y = origin_y + title_y })
player:hud_change(state.hud_ids.title, "scale", { x = title_scale, y = title_scale })
player:hud_change(state.hud_ids.subtitle, "position", layout.pos)
player:hud_change(state.hud_ids.subtitle, "offset", { x = text_x + subtitle_offset_x, y = origin_y + subtitle_y })
player:hud_change(state.hud_ids.subtitle, "scale", { x = subtitle_scale, y = subtitle_scale })
player:hud_change(state.hud_ids.footer, "position", layout.pos)
player:hud_change(state.hud_ids.footer, "offset",
{ x = text_x + footer_offset_x, y = origin_y + footer_y + footer_offset_y })
player:hud_change(state.hud_ids.footer, "scale", { x = footer_scale, y = footer_scale })
if view_model.icon then
player:hud_change(state.hud_ids.icon, "position", layout.pos)
player:hud_change(state.hud_ids.icon, "offset", { x = icon_x, y = origin_y + 34 + icon_offset_y })
player:hud_change(state.hud_ids.icon, "scale", view_model.icon.scale)
end
-- Geometry for all allocated line slots so new/reactivated slots are positioned correctly.
for index, line_id in ipairs(state.hud_ids.lines) do
player:hud_change(line_id, "position", layout.pos)
player:hud_change(line_id, "offset", {
x = text_x + lines_offset_x,
y = origin_y + first_line_y + ((index - 1) * line_step) + lines_offset_y,
})
player:hud_change(line_id, "scale", { x = line_scale, y = line_scale })
end
end
-- Content channel: text, number/color -- always applied when view_model changed.
player:hud_change(state.hud_ids.bg, "text", "eye_spy_hud_bg.png^[multiply:#" .. view_model.bg_color)
player:hud_change(state.hud_ids.title, "text", view_model.title)
player:hud_change(state.hud_ids.title, "number", view_model.title_color)
player:hud_change(state.hud_ids.subtitle, "text", view_model.subtitle)
player:hud_change(state.hud_ids.subtitle, "number", view_model.subtitle_color)
player:hud_change(state.hud_ids.footer, "text", view_model.footer)
player:hud_change(state.hud_ids.footer, "number", view_model.footer_color)
if view_model.icon then
player:hud_change(state.hud_ids.icon, "text", view_model.icon.text)
else
player:hud_change(state.hud_ids.icon, "text", "")
end
for index, line_id in ipairs(state.hud_ids.lines) do
local line = layout_lines[index]
if line then
player:hud_change(line_id, "text", line.text)
player:hud_change(line_id, "number", line.color)
else
player:hud_change(line_id, "text", "")
end
end
end