Files

854 lines
25 KiB
Lua

local S = eye_spy.S
eye_spy.enrichers = {
registry = {},
}
local node_tool_state_by_player = {}
-- Called by init.lua's on_leaveplayer handler to free per-player cached state.
function eye_spy.enrichers.on_player_leave(player_name)
node_tool_state_by_player[player_name] = nil
end
local function add_line(view_model, id, text, color)
view_model.lines[#view_model.lines + 1] = {
id = id,
text = text,
color = color,
}
end
local function meta_bool(meta, key, default_value)
local raw = meta:get_string(key)
if raw == "" then
return default_value == true
end
return raw ~= "false"
end
local function growth_bar(percent)
local slots = 10
local p = math.max(0, math.min(100, percent or 0))
local filled = math.floor((p / 100) * slots + 0.5)
return "[" .. string.rep("|", filled) .. string.rep(" ", slots - filled) .. "] " .. p .. "%"
end
local function liquid_name_from_node(name)
if not name or name == "" then
return S("Unknown")
end
if name:find("water", 1, true) then
return S("Water")
end
if name:find("lava", 1, true) then
return S("Lava")
end
return eye_spy.readable_name_from_id(name) or S("Unknown")
end
local ETHEREAL_SAPLING_SOILS = {
["ethereal:basandra_bush_sapling"] = "ethereal:fiery_dirt",
["ethereal:yellow_tree_sapling"] = "group:soil",
["ethereal:big_tree_sapling"] = "default:dirt_with_grass",
["ethereal:banana_tree_sapling"] = "ethereal:grove_dirt",
["ethereal:frost_tree_sapling"] = "ethereal:crystal_dirt",
["ethereal:mushroom_sapling"] = "ethereal:mushroom_dirt",
["ethereal:mushroom_brown_sapling"] = "ethereal:mushroom_dirt",
["ethereal:palm_sapling"] = "default:sand",
["ethereal:willow_sapling"] = "ethereal:gray_dirt",
["ethereal:redwood_sapling"] = "default:dirt_with_dry_grass",
["ethereal:giant_redwood_sapling"] = "default:dirt_with_dry_grass",
["ethereal:orange_tree_sapling"] = "ethereal:prairie_dirt",
["ethereal:birch_sapling"] = "default:dirt_with_grass",
["ethereal:sakura_sapling"] = "ethereal:bamboo_dirt",
["ethereal:olive_tree_sapling"] = "ethereal:grove_dirt",
["ethereal:lemon_tree_sapling"] = "ethereal:grove_dirt",
}
local function get_current_dig_result(player, nodename)
if not player or not nodename or nodename == "" then
return false, nil
end
local wielded = player:get_wielded_item()
local can_dig, dig_time = eye_spy.tool_can_dig_node(wielded, nodename)
if can_dig then
return true, dig_time
end
local hand_can_dig, hand_dig_time = eye_spy.tool_can_dig_node(ItemStack(""), nodename)
if hand_can_dig then
return true, hand_dig_time
end
return false, nil
end
local function get_below_node(pos)
if not pos then
return nil, nil
end
local below_pos = { x = pos.x, y = pos.y - 1, z = pos.z }
local below = minetest.get_node_or_nil(below_pos)
if not below then
return nil, nil
end
return below, minetest.registered_nodes[below.name]
end
local function is_water_node(node, def)
local name = node and node.name or ""
if name == "" then
return false
end
if name:find("water", 1, true) then
return true
end
local source_name = def and def.liquid_alternative_source or ""
local flowing_name = def and def.liquid_alternative_flowing or ""
return source_name:find("water", 1, true) ~= nil or flowing_name:find("water", 1, true) ~= nil
end
local function has_nearby_water(pos, radius)
if not pos then
return false
end
local scan_radius = math.max(1, tonumber(radius) or 3)
for dx = -scan_radius, scan_radius do
for dz = -scan_radius, scan_radius do
for dy = -1, 1 do
if dx ~= 0 or dy ~= 0 or dz ~= 0 then
local neighbor = minetest.get_node_or_nil({
x = pos.x + dx,
y = pos.y + dy,
z = pos.z + dz,
})
if neighbor then
local neighbor_def = minetest.registered_nodes[neighbor.name]
if is_water_node(neighbor, neighbor_def) then
return true
end
end
end
end
end
end
return false
end
local function is_soil_wet(soil_pos, soil_group)
if (tonumber(soil_group) or 0) >= 3 then
return true
end
return has_nearby_water(soil_pos, 3)
end
local function get_node_label(name)
if not name or name == "" then
return S("Unknown")
end
if name:sub(1, 6) == "group:" then
local group_name = name:sub(7)
if group_name == "soil" then
return S("Soil")
end
return group_name
end
local def = minetest.registered_nodes[name]
local desc = def and def.description
if desc and desc ~= "" then
return desc:match("^[^\n]*") or desc
end
return name
end
local function get_soil_state(pos)
local below, below_def = get_below_node(pos)
local soil_group = below and minetest.get_item_group(below.name, "soil") or 0
local below_pos = pos and { x = pos.x, y = pos.y - 1, z = pos.z } or nil
local is_wet = soil_group > 0 and is_soil_wet(below_pos, soil_group)
return {
node = below,
def = below_def,
soil_group = soil_group,
is_soil = soil_group > 0,
is_wet = is_wet,
}
end
local function get_target_soil_state(target)
local soil_group = minetest.get_item_group(target.name or "", "soil")
if soil_group <= 0 then
return nil
end
return {
node = {
name = target.name,
param1 = target.node and target.node.param1 or 0,
param2 = target.node and target.node.param2 or 0,
},
def = target.def,
soil_group = soil_group,
is_soil = true,
is_wet = is_soil_wet(target.pos, soil_group),
}
end
local function infer_max_stage(base_name, current_stage)
local max_stage = current_stage or 0
for stage = max_stage + 1, 16 do
if minetest.registered_nodes[base_name .. "_" .. stage] then
max_stage = stage
else
break
end
end
if max_stage > 0 then
return max_stage
end
end
local function get_registered_crop_info(base_name)
if farming and farming.registered_plants and farming.registered_plants[base_name] then
local plant = farming.registered_plants[base_name]
return {
system = "farming",
steps = plant.steps,
minlight = plant.minlight or (farming and farming.min_light) or 12,
maxlight = plant.maxlight or (farming and farming.max_light) or 15,
}
end
if x_farming and x_farming.registered_plants then
local short_name = base_name:match(":(.+)$")
local plant = short_name and x_farming.registered_plants[short_name]
if plant then
return {
system = "x_farming",
steps = plant.steps,
minlight = plant.minlight or 13,
maxlight = plant.maxlight or 14,
fertility = plant.fertility,
}
end
end
end
local function soil_matches_fertility(soil_state, fertility)
if not fertility or #fertility == 0 then
return true
end
local below_def = soil_state.def
local groups = below_def and below_def.groups or nil
if not groups then
return false
end
for _, group_name in ipairs(fertility) do
if groups[group_name] and groups[group_name] > 0 then
return true
end
end
return false
end
local function get_growth_light(pos)
if not pos then
return nil
end
return minetest.get_node_light(pos)
end
local function get_growth_info(target)
local def = target.def
local groups = def and def.groups or {}
if groups.sapling then
local meta = minetest.get_meta(target.pos)
return {
kind = "sapling",
age = meta and meta:get_int("age") or 0,
groups = groups,
}
end
local stage_suffix = target.name:match("_(%d+)$")
local base_name = stage_suffix and target.name:gsub("_(%d+)$", "", 1) or nil
local plant = base_name and get_registered_crop_info(base_name) or nil
if not stage_suffix or not (groups.plant or groups.crop or groups.growing or plant) then
return nil
end
local stage = tonumber(stage_suffix) or 0
plant = plant or {}
local max_stage = plant.steps or infer_max_stage(base_name, stage)
if not max_stage or max_stage <= 0 then
return nil
end
return {
kind = "crop",
system = plant.system or "generic",
stage = stage,
max_stage = max_stage,
minlight = plant.minlight or def.minlight,
maxlight = plant.maxlight or def.maxlight,
fertility = plant.fertility,
}
end
local function get_sapling_soil_requirement(target)
local requirement = ETHEREAL_SAPLING_SOILS[target.name]
if requirement then
return requirement
end
return "group:soil"
end
local function soil_matches_requirement(soil_state, requirement)
if not requirement or requirement == "" then
return true
end
if requirement:sub(1, 6) == "group:" then
return minetest.get_item_group(soil_state.node and soil_state.node.name or "", requirement:sub(7)) > 0
end
return soil_state.node and soil_state.node.name == requirement or false
end
local function get_crop_conditions(target, growth_info)
local soil_state = get_soil_state(target.pos)
local light = target.light_level
if light == nil then
light = get_growth_light(target.pos)
end
local minlight = growth_info.minlight
local maxlight = growth_info.maxlight
local fertility_ok = soil_matches_fertility(soil_state, growth_info.fertility)
local soil_ok = soil_state.is_wet and fertility_ok
local light_ok = true
if minlight and light and light < minlight then
light_ok = false
end
if maxlight and light and light > maxlight then
light_ok = false
end
if light == nil and (minlight or maxlight) then
light_ok = false
end
return {
soil = soil_state,
light = light,
soil_ok = soil_ok,
light_ok = light_ok,
fertility_ok = fertility_ok,
}
end
local function get_soil_line(target, growth_info)
local soil_state = get_soil_state(target.pos)
local target_groups = target.def and target.def.groups or {}
if growth_info and growth_info.kind == "crop" then
local suitable = soil_state.is_soil and soil_matches_fertility(soil_state, growth_info.fertility)
local moisture_label
if soil_state.is_wet then
moisture_label = S("Wet")
elseif soil_state.is_soil then
moisture_label = S("Dry")
else
moisture_label = S("No Soil")
end
local color
if suitable and soil_state.is_wet then
color = 0x6BD66B
elseif suitable then
color = 0xFFAA55
else
color = 0xE56363
end
return {
text = S("Soil") .. ": " .. moisture_label .. " | " .. (suitable and S("Suitable") or S("Unsuitable")),
color = color,
}
end
if growth_info and growth_info.kind == "sapling" then
local requirement = get_sapling_soil_requirement(target)
local suitable = soil_matches_requirement(soil_state, requirement)
return {
text = S("Soil") ..
": " .. get_node_label(requirement) .. " | " .. (suitable and S("Suitable") or S("Unsuitable")),
color = suitable and 0x6BD66B or 0xE56363,
}
end
if target_groups.flora or target_groups.attached_node then
return nil
end
local target_soil_state = get_target_soil_state(target)
if target_soil_state then
return {
text = S("Soil") .. ": " .. (target_soil_state.is_wet and S("Wet") or S("Dry")),
color = target_soil_state.is_wet and 0x5555FF or 0xFFAA55,
}
end
end
function eye_spy.enrichers.register(id, spec)
eye_spy.enrichers.registry[#eye_spy.enrichers.registry + 1] = {
id = id,
enabled = spec.enabled,
apply = spec.apply,
}
end
local function ensure_shared_context(context, target)
local shared = context.shared
if shared and shared._target_key == (target.key or target.name) then
return shared
end
shared = {
_target_key = target.key or target.name,
dig_cached = false,
growth_cached = false,
conditions_cached = false,
soil_line_cached = false,
}
function shared:get_dig_result()
if not self.dig_cached then
self.dig_cached = true
self.can_dig, self.dig_time = get_current_dig_result(context.player, target.name)
end
return self.can_dig, self.dig_time
end
function shared:get_growth_info()
if not self.growth_cached then
self.growth_cached = true
self.growth_info = get_growth_info(target)
end
return self.growth_info
end
function shared:get_crop_conditions()
if not self.conditions_cached then
self.conditions_cached = true
local growth_info = self:get_growth_info()
if growth_info and growth_info.kind == "crop" then
self.crop_conditions = get_crop_conditions(target, growth_info)
else
self.crop_conditions = nil
end
end
return self.crop_conditions
end
function shared:get_soil_line()
if not self.soil_line_cached then
self.soil_line_cached = true
self.soil_line = get_soil_line(target, self:get_growth_info())
end
return self.soil_line
end
context.shared = shared
return shared
end
local function get_cached_node_tool(context, target)
local player = context and context.player
if not player then
return nil
end
local shared = ensure_shared_context(context, target)
local player_name = player:get_player_name()
local wielded = player:get_wielded_item()
local wield_name = wielded:get_name() or ""
local target_key = target.key or target.name or ""
local cached = node_tool_state_by_player[player_name]
if cached and cached.target_key == target_key and cached.wield_name == wield_name then
return cached.tool
end
local can_dig = shared:get_dig_result()
local tool_type_hint, matches_tool_type = nil, false
if eye_spy.get_node_tool_hint_and_match then
tool_type_hint, matches_tool_type = eye_spy.get_node_tool_hint_and_match(target.name, wielded)
end
local label = tool_type_hint or S("Unknown")
local color
if can_dig then
color = 0x6BD66B
elseif matches_tool_type then
color = 0xFFAA55
else
color = 0xE56363
end
local tool = {
description = label,
color = color,
}
node_tool_state_by_player[player_name] = {
target_key = target_key,
wield_name = wield_name,
tool = tool,
}
return tool
end
function eye_spy.enrichers.run(context, target, view_model)
ensure_shared_context(context, target)
for _, enricher in ipairs(eye_spy.enrichers.registry) do
if not enricher.enabled or enricher.enabled(context, target, view_model) then
local perf_on = eye_spy.perf and eye_spy.perf.enabled and minetest.get_us_time
local start_us = perf_on and minetest.get_us_time() or 0
local ok, err = pcall(enricher.apply, context, target, view_model)
if not ok then
minetest.log("error", "[eye_spy] enricher '" .. (enricher.id or "?") ..
"' error: " .. tostring(err))
end
if perf_on then
eye_spy.perf_record("enricher_" .. (enricher.id or "unknown"), minetest.get_us_time() - start_us)
end
end
end
end
eye_spy.enrichers.register("node_tool", {
enabled = function(_, target)
return target.kind == "node"
end,
apply = function(context, target, view_model)
local tool = get_cached_node_tool(context, target) or {
description = S("Unknown"),
color = 0xEBB344,
}
view_model.subtitle = tool.description ~= "" and tool.description or S("Unknown")
view_model.subtitle_color = tool.color or 0xEBB344
end,
})
eye_spy.enrichers.register("entity_health", {
enabled = function(_, target)
return target.kind == "entity"
end,
apply = function(context, target, view_model)
if context.meta:get_string("es_hud_health_in") == "Hearts" then
view_model.subtitle = S("Health") ..
": " ..
(eye_spy.round1((target.hp or 0) / 2) or S("Unknown")) ..
"♥/" .. (eye_spy.round1((target.max_hp or 0) / 2) or S("Unknown")) .. "♥"
else
view_model.subtitle = S("Health") ..
": " .. (eye_spy.round1(target.hp) or S("Unknown")) .. "/" .. (eye_spy.round1(target.max_hp) or S("Unknown"))
end
view_model.subtitle_color = 0xFF4040
end,
})
eye_spy.enrichers.register("item_details", {
enabled = function(_, target)
return target.kind == "item"
end,
apply = function(_, target, view_model)
local count = tonumber(target.stack_count) or 1
if count > 1 then
add_line(view_model, "item_count", S("Count") .. ": " .. tostring(count), 0xE8D37A)
end
local wear = tonumber(target.stack_wear) or 0
if wear > 0 then
local remain_pct = math.floor(math.max(0, 1 - (wear / 65535)) * 100 + 0.5)
local wear_color = 0xE56363
if remain_pct >= 60 then
wear_color = 0x6BD66B
elseif remain_pct >= 30 then
wear_color = 0xF0B35E
end
add_line(view_model, "item_wear", S("Durability") .. ": " .. tostring(remain_pct) .. "%", wear_color)
end
if target.item_def_description and target.item_def_description ~= "" then
local lines = {}
for line in target.item_def_description:gmatch("[^\n]+") do
table.insert(lines, line)
end
for i = 2, #lines do
add_line(view_model, "item_def_line_" .. i, lines[i], 0xCCCCCC)
end
end
if target.stack_has_meta then
local detail = target.stack_meta_summary
if detail and detail ~= "" then
add_line(view_model, "item_meta", S("Metadata") .. ": " .. detail, 0x98C9FF)
else
add_line(view_model, "item_meta", S("Metadata"), 0x98C9FF)
end
end
end,
})
eye_spy.enrichers.register("growth", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_growth", eye_spy.config.show_growth)
end,
apply = function(context, target, view_model)
local shared = ensure_shared_context(context, target)
local growth_info = shared:get_growth_info()
if not growth_info then
return
end
if growth_info.kind == "sapling" then
local age = growth_info.age or 0
if age > 0 then
local percent = math.min(math.floor((age / 20) * 100), 100)
add_line(view_model, "growth", S("Maturity") .. ": " .. growth_bar(percent), 0xAAAAFF)
else
add_line(view_model, "growth", S("Status") .. ": " .. S("Rooting"), 0x888888)
end
return
end
if growth_info.kind == "crop" then
local percent = math.floor(math.min(growth_info.stage / math.max(growth_info.max_stage, 1), 1) * 100)
local conditions = shared:get_crop_conditions() or get_crop_conditions(target, growth_info)
local stalled = growth_info.stage < growth_info.max_stage and
(not conditions.soil_ok or not conditions.light_ok)
local status = ""
if stalled then
local reasons = {}
if not conditions.soil_ok then
if not conditions.soil.is_wet then
-- Soil is dry (and may also be wrong type)
reasons[#reasons + 1] = conditions.soil.is_soil and S("Dry Soil") or S("Bad Soil")
else
-- Soil is wet but wrong fertility type for this crop
reasons[#reasons + 1] = S("Bad Soil")
end
end
if not conditions.light_ok then
reasons[#reasons + 1] = S("Bad Light")
end
status = " (" .. S("Stalled")
if #reasons > 0 then
status = status .. ": " .. table.concat(reasons, ", ")
end
status = status .. ")"
end
add_line(
view_model,
"growth",
S("Growth") .. ": " .. growth_bar(percent) .. status,
stalled and 0xFFAA55 or 0x55FF55
)
end
end,
})
eye_spy.enrichers.register("dig_time", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_dig_time", true)
end,
apply = function(context, target, view_model)
local shared = ensure_shared_context(context, target)
local diggable, time = shared:get_dig_result()
if not diggable then
add_line(view_model, "dig_time", S("Dig Time") .. ": ∞", 0xA9C5FF)
return
end
local t = tonumber(time) or 0
add_line(view_model, "dig_time", S("Dig Time") .. ": " .. string.format("%.2fs", t), 0xA9C5FF)
end,
})
eye_spy.enrichers.register("soil", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_growth", eye_spy.config.show_growth)
end,
apply = function(context, target, view_model)
local shared = ensure_shared_context(context, target)
local soil_line = shared:get_soil_line()
if not soil_line then
return
end
add_line(view_model, "soil", soil_line.text, soil_line.color)
end,
})
eye_spy.enrichers.register("light_level", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_light_level", true)
end,
apply = function(_, target, view_model)
if target.light_level == nil then
add_line(view_model, "light", S("Light") .. ": " .. S("Unknown"), 0xAAAAAA)
return
end
add_line(view_model, "light", S("Light") .. ": " .. tostring(target.light_level), 0xF2DD88)
end,
})
eye_spy.enrichers.register("spawn_risk", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_spawn_hint", false)
end,
apply = function(context, target, view_model)
if target.light_level == nil then
add_line(view_model, "spawn", S("Spawn Risk") .. " (" .. S("Est") .. "): " .. S("Unknown"), 0xAAAAAA)
return
end
local threshold = context.meta:get_int("es_spawn_safe_light_threshold")
if threshold < 0 or threshold > 15 then
threshold = 8
end
if target.light_level <= threshold then
add_line(view_model, "spawn", S("Spawn Risk") .. " (" .. S("Est") .. "): " .. S("Dark Enough"), 0xF08A55)
else
add_line(view_model, "spawn", S("Spawn Risk") .. " (" .. S("Est") .. "): " .. S("Likely Safe"), 0x6BD66B)
end
end,
})
eye_spy.enrichers.register("liquid_info", {
enabled = function(context, target)
if not eye_spy.config.show_liquids then
return false
end
local show_liquid = meta_bool(context.meta, "es_show_liquid_info", true)
if not show_liquid then
return false
end
return target.is_liquid or target.liquid_obstacle ~= nil
end,
apply = function(_, target, view_model)
local liquid_target = target.is_liquid and target or target.liquid_obstacle
if not liquid_target then
return
end
local name = liquid_name_from_node(liquid_target.full_name or liquid_target.name)
local detail
if liquid_target.liquid_type == "source" then
detail = S("Source")
elseif liquid_target.liquid_level then
detail = S("Level") .. ": " .. tostring(liquid_target.liquid_level)
else
detail = S("Flowing")
end
add_line(view_model, "liquid", S("Liquid") .. ": " .. name .. " (" .. detail .. ")", 0x6FB8FF)
end,
})