-- 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, })