Files
2026-11-03 12:41:09 -05:00

144 lines
7.8 KiB
Lua
Executable File

-- M1 presentation only: no metadata, persisted preferences, chat or camera bob.
local C = core
local frames = {idle = {0, 19, 12}, walk = {20, 39, 24}, run = {40, 59, 32},
strike = {60, 69, 28}, guard = {70, 79, 12}, swim = {80, 99, 20}}
local function now() return C.get_us_time() / 1000000 end
local function session(player) return hearth.sessions[player:get_player_name()] end
local function text(value)
-- Bound HUD payloads and strip control/escape sequences from item descriptions.
return tostring(value or ""):gsub("\27%([^)]*%)", ""):gsub("[%c]", " "):sub(1, 200)
end
function hearth.feedback_sound(name, player, gain)
local spec = {pos = player:get_pos(), gain = gain or 0.25, max_hear_distance = 12}
-- Optional sound adapter contract: sound(name, SoundParameters, ephemeral).
if hearth.sound then return hearth.sound(name, spec, true) end
return C.sound_play(name, spec, true)
end
local colors = {info = 0xcdd6f4, success = 0xa6e3a1, warning = 0xf9e2af, error = 0xf38ba8}
function hearth.feedback(player, message, kind)
local s = session(player); if not s then return end
s.feedback_text, s.feedback_kind, s.feedback_until = text(message), kind, now() + 3
end
local function target_text(player)
local pos, look = player:get_pos(), player:get_look_dir()
local props = player:get_properties()
local start = {x = pos.x, y = pos.y + (props.eye_height or 1.55), z = pos.z}
local finish = {x = start.x + look.x * 5, y = start.y + look.y * 5, z = start.z + look.z * 5}
-- No object hits and no liquids: water never obscures useful node targeting.
for hit in C.raycast(start, finish, false, false) do
if hit.type == "node" then
local node = C.get_node_or_nil and C.get_node_or_nil(hit.under) or nil
if not node or node.name == "ignore" then return "" end
local def = C.registered_nodes[node.name]
if not def then return "" end
if node.name == "hearth:fire" or node.name == "hearth:fire_lit" then
local status = C.get_meta(hit.under):get_string("infotext")
return text(def.description) .. (status ~= "" and (" | " .. text(status)) or "")
end
local B = hearth.botany
local known = B and B.kind and B.kind(node.name)
local inspect_hint = known and " | /inspect species" or ""
local wield = player:get_wielded_item()
local caps = wield:get_tool_capabilities()
local dig = C.get_dig_params(def.groups or {}, caps, wield:get_wear())
local wield_def = C.registered_nodes[wield:get_name()]
if wield_def and hearth.construction then
local destination = def.buildable_to and hit.under or hit.above
local ok, reason = hearth.construction.preview(destination, player, wield:get_name())
local reasons = {ok = "Ready to place", support = "Needs nearby structural support",
protected = "Protected homestead", unloaded = "Terrain not loaded", occupied = "Space occupied",
distance = "Move closer", item = "Not a building material", invalid = "Invalid position"}
return text(wield_def.description):sub(1,120) .. " | " .. text(reasons[reason] or (ok and "Ready to place" or "Cannot place here")) .. inspect_hint
end
return text(def.description):sub(1,140)
.. (dig.diggable and " | Tool suitable" or " | Different tool needed")
.. inspect_hint
end
end
return ""
end
local function surface(player, s)
if s.swimming then return "water" end
local p = player:get_pos()
local feet = C.registered_nodes[C.get_node({x = p.x, y = p.y + 0.1, z = p.z}).name] or {}
if feet.groups and (feet.groups.water or 0) > 0 then return "water" end
local def = C.registered_nodes[C.get_node({x = p.x, y = p.y - 0.1, z = p.z}).name] or {}
local g = def.groups or {}
if (g.water or 0) > 0 then return "water" end
if (g.wood or 0) > 0 or (g.tree or 0) > 0 then return "wood" end
if (g.stone or 0) > 0 or (g.cracky or 0) > 0 then return "stone" end
return "soil"
end
function hearth.feedback_step(player, dt)
if hearth.presentation_step then hearth.presentation_step(player) end
local s = session(player); if not s or s.waiting or player:get_hp() <= 0 then return end
dt = hearth.movement.delta(dt)
local t, pos, velocity = now(), player:get_pos(), player:get_velocity()
local controls = player:get_player_control()
-- Rising edge only; holding dig never replays a strike every frame. Combat
-- may set session.strike_until using the same monotonic seconds clock.
if controls.LMB and not s.feedback_lmb then
s.strike_until = t + 10 / 28
local swing = ({["hearth:knife"] = "hearth_swing_knife",
["hearth:axe"] = "hearth_swing_axe", ["hearth:spear"] = "hearth_swing_spear"})
local sound = swing[player:get_wielded_item():get_name()]
if sound then hearth.feedback_sound(sound, player, 0.18) end
end
s.feedback_lmb = controls.LMB
local animation = s.swimming and "swim" or (s.blocking and "guard"
or ((s.strike_until or 0) > t and "strike" or (s.moving and (s.sprinting and "run" or "walk") or "idle")))
if s.animation ~= animation then
local f = frames[animation]
player:set_animation({x = f[1], y = f[2]}, f[3], 0.12, animation ~= "strike")
s.animation = animation
end
-- FOV opt-in only. Default comfort_fov=nil never touches client settings.
if s.comfort_fov ~= nil and s.applied_fov ~= s.comfort_fov then
player:set_fov(s.comfort_fov, false, 0.2); s.applied_fov = s.comfort_fov
end
local previous = s.feedback_pos
s.step_time = (s.step_time or 0) + dt
if s.grounded and s.feedback_grounded == false and (s.feedback_fall or 0) < -3 then
hearth.feedback_sound("hearth_land", player, 0.35)
s.step_time, s.step_distance = 0, 0
elseif previous and s.grounded and s.feedback_grounded and s.moving then
local distance = math.sqrt((pos.x - previous.x)^2 + (pos.z - previous.z)^2)
-- Ignore teleport deltas rather than bursting queued footfalls.
if distance <= 2 then s.step_distance = (s.step_distance or 0) + distance end
if s.step_time >= (s.sprinting and 0.28 or 0.4) and (s.step_distance or 0) >= 1.4 then
hearth.feedback_sound("hearth_step_" .. surface(player, s), player)
s.step_time, s.step_distance = 0, 0
end
elseif not s.moving or not s.grounded then s.step_distance = 0 end
if s.grounded then s.feedback_fall = 0
else s.feedback_fall = math.min(s.feedback_fall or 0, velocity.y) end
s.feedback_pos = {x = pos.x, y = pos.y, z = pos.z}
s.feedback_grounded = s.grounded
-- Bounded single-entry target cache; all raycasts/HUD traffic <= 10 Hz.
if t < (s.feedback_next or 0) then return end
s.feedback_next = t + 0.1
local message = (s.feedback_until or 0) > t and s.feedback_text or target_text(player)
local color = (s.feedback_until or 0) > t and (colors[s.feedback_kind] or colors.info) or colors.info
if not s.feedback_hud then
s.feedback_hud = player:hud_add({type = "text", position = {x = 0.5, y = 0.72},
offset = {x = 0, y = 0}, alignment = {x = 0, y = 0}, number = color, text = message})
elseif message ~= s.feedback_display then player:hud_change(s.feedback_hud, "text", message) end
if s.feedback_color and color ~= s.feedback_color then player:hud_change(s.feedback_hud, "number", color) end
s.feedback_display, s.feedback_color = message, color
end
C.register_chatcommand("comfort", {
params = "[default|fov <60..100>]", description = "Session-only static FOV; no imposed camera bob",
func = function(name, param)
local player = C.get_player_by_name(name)
local s = player and session(player)
if not s then return false, "Player session unavailable." end
if param == "" then return true, "FOV unchanged by default. Use /comfort fov 75 or /comfort default." end
local value = param:match("^fov (%d+)$")
value = value and tonumber(value)
if param == "default" then value = 0
elseif not value or value < 60 or value > 100 then return false, "Use default or fov 60..100." end
s.comfort_fov = value
return true, "Session camera preference updated."
end,
})