Files
last-hearth/mods/hearth/encounters.lua
T
2026-11-03 12:41:09 -05:00

271 lines
14 KiB
Lua
Executable File

-- Opt-in, non-lethal training only. No wildlife, drops, node edits or PvP.
local C, M = core, assert(hearth.combat, "Install hearth.combat before encounters")
local moving = {} -- One ephemeral opponent per consenting player; never serialized.
local function now() return C.get_us_time() / 1000000 end
local function offset(p, y) return {x = p.x, y = p.y + y, z = p.z} end
local function copy(p) return {x = p.x, y = p.y, z = p.z} end
local function feedback(player, message, kind)
if hearth.feedback then hearth.feedback(player, message, kind) end
end
local function sound(name, player)
if hearth.feedback_sound then hearth.feedback_sound(name, player, 0.25)
else C.sound_play(name, {pos = player:get_pos(), gain = 0.25, max_hear_distance = 12}, true) end
end
local function live(player)
if not player or not player:is_player() then return false end
local name = player:get_player_name()
return C.get_player_by_name(name) == player and hearth.players[name] ~= nil
and hearth.sessions[name] ~= nil and not hearth.sessions[name].waiting and player:get_hp() > 0
end
local function allowed(pos, name)
return pos ~= nil and hearth.claim_allowed(pos, name, "combat") == true and not C.is_protected(pos, name)
end
local function eye(player)
return offset(player:get_pos(), player:get_properties().eye_height or 1.55)
end
local function stop(self)
local v = self.object:get_velocity()
self.object:set_velocity({x = 0, y = v.y, z = 0})
end
local animations = {idle = {0, 19, 12}, approach = {20, 39, 24}, windup = {70, 79, 12},
strike = {60, 69, 28}, recover = {0, 19, 12}, cooldown = {0, 19, 12}}
local function phase(self, value, t, duration)
self.phase, self.deadline = value, t + (duration or 0)
if value == "approach" then self.approach_until = t + 8 end
if self.mobile then
local f = animations[value]
self.object:set_animation({x = f[1], y = f[2]}, f[3], 0.12, value ~= "strike")
end
local label = value == "windup" and "COUNTER INCOMING / step aside or face + brace"
or (value == "cooldown" and "ROUND COMPLETE / rest 3 seconds" or "PRACTICE / " .. self.health .. "/30")
self.object:set_properties({nametag = label})
end
local function finish(self, t, player, detached)
if self.phase == "cooldown" then return end
local s = self.target and hearth.sessions[self.target]
if s then
s.spar_ready_at = math.max(s.spar_ready_at or 0, t + 3)
if s.spar_entity == self then s.spar_entity = nil end
end
self.target, self.round_complete, self.direction = nil, false, nil
if not detached then stop(self); phase(self, "cooldown", t, 3) end
if player then feedback(player, "Round complete / rest, then strike to opt in again", "success") end
end
local function begin(self, player, t)
if not live(player) then return false end
local name, s = player:get_player_name(), hearth.sessions[player:get_player_name()]
if t < (s.spar_ready_at or 0) or (s.spar_entity and s.spar_entity ~= self) then return false end
self.target, s.spar_entity = name, self
self.stuck, self.perception, self.last_ai_pos, self.direction = 0, 0, nil, nil
if self.mobile then phase(self, "approach", t) else phase(self, "windup", t, 0.75) end
return true
end
local function punch(self, player)
-- Never trust client damage, elapsed punch time, direction or tool capabilities.
if not live(player) or self.phase == "cooldown" then return true end
local name, t = player:get_player_name(), now()
if (self.mobile and self.owner ~= name) or (self.target and self.target ~= name) then return true end
local state, s, pos = hearth.players[name], hearth.sessions[name], self.object:get_pos()
if not pos or (s.spar_entity and s.spar_entity ~= self) then return true end
local head, center, stack = eye(player), offset(pos, 0.9), player:get_wielded_item()
local attack = M.valid_attack({live = true, hp = player:get_hp(), state = state, session = s,
now = t, weapon = stack:get_name(), origin = player:get_pos(), eye = head, target = center,
look = player:get_look_dir(), allowed = allowed(pos, name) and allowed(player:get_pos(), name),
clear = M.distance(head, center) <= 3.4 and C.line_of_sight(head, center, 0.1) == true})
if not attack or self.round_complete then return true end
if not self.target and not begin(self, player, t) then return true end
state.stamina = state.stamina - attack.stamina
s.combat_now, s.attack_ready_at, s.strike_until = t, t + attack.cadence, t + 10 / 28
stack:add_wear(attack.wear); player:set_wielded_item(stack)
self.health = math.max(0, self.health - attack.damage)
sound(attack.sound, player); sound("hearth_impact", player)
feedback(player, "Hit confirmed / target " .. self.health .. "/30", "success")
-- Damage NEVER cancels or restarts a committed windup, even the final hit.
if self.health == 0 then self.round_complete = true end
return true
end
local function strike(self, player)
if not live(player) then return end
local pos, p, name = self.object:get_pos(), player:get_pos(), player:get_player_name()
if not pos or not allowed(pos, name) or not allowed(p, name) then return end
local center, head = offset(pos, 0.9), eye(player)
if M.distance(center, head) > 2.2 or not C.line_of_sight(center, head, 0.1) then
feedback(player, "Counter evaded", "info"); return
end
local state, s = hearth.players[name], hearth.sessions[name]
local guard = M.valid_guard({live = true, hp = player:get_hp(), state = state, session = s,
weapon = player:get_wielded_item():get_name(), controls = player:get_player_control(),
eye = head, target = center, look = player:get_look_dir()})
if guard then state.stamina = state.stamina - 8 end
if player:get_hp() > 2 then player:set_hp(math.max(2, player:get_hp() - (guard and 1 or 3)), {type = "set_hp", from = "mod"}) end
sound(guard and "hearth_block" or "hearth_impact", player)
feedback(player, guard and "Braced / reduced impact" or "Counter landed / step back or aside", guard and "success" or "warning")
end
local function node_ok(pos, ground)
local node = C.get_node_or_nil(pos)
local def = node and C.registered_nodes[node.name]
if not def or node.name == "ignore" or (def.liquidtype and def.liquidtype ~= "none")
or ((def.groups or {}).water or 0) > 0 or ((def.groups or {}).liquid or 0) > 0 then return false end
return ground and def.walkable == true or (not ground and def.walkable == false)
end
local function safe_step(self, pos, dx, dz)
local nextpos = {x = pos.x + dx * 0.7, y = pos.y, z = pos.z + dz * 0.7}
return M.distance(nextpos, self.home) <= 12 and allowed(nextpos, self.target)
and node_ok(offset(nextpos, -0.2), true) and node_ok(offset(nextpos, 0.1), false)
and node_ok(offset(nextpos, 1.3), false)
and C.line_of_sight(offset(pos, 0.9), offset(nextpos, 0.9), 0.1) == true
end
local function perceive(self, pos, destination, dt)
local dx, dz = destination.x - pos.x, destination.z - pos.z
local length = math.sqrt(dx * dx + dz * dz)
self.direction = nil
if length < 0.1 then return end
dx, dz = dx / length, dz / length
if self.last_ai_pos and M.distance(self.last_ai_pos, pos) < 0.08 then self.stuck = self.stuck + dt
else self.stuck = 0 end
self.last_ai_pos = copy(pos)
if self.stuck > 3 then return end
if self.stuck > 1.5 then
dx, dz = self.home.x - pos.x, self.home.z - pos.z
length = math.sqrt(dx * dx + dz * dz)
if length < 0.1 then return end
dx, dz = dx / length, dz / length
end
for _, d in ipairs({{dx, dz}, {-dz, dx}, {dz, -dx}}) do
if safe_step(self, pos, d[1], d[2]) then self.direction = d; return end
end
end
local function step(self, dt)
dt = math.max(0, math.min(tonumber(dt) or 0, 0.1))
local t, pos = now(), self.object:get_pos()
if not pos then return end
if self.phase == "cooldown" then
stop(self)
if t >= self.deadline then self.health = 30; phase(self, "idle", t) end
return
end
if not self.target then stop(self); return end
local player = C.get_player_by_name(self.target)
if not live(player) or not allowed(pos, self.target) or not allowed(player:get_pos(), self.target)
or M.distance(player:get_pos(), self.home) > 12 or M.distance(pos, self.home) > 12 then
finish(self, t); return
end
if self.phase == "windup" then
stop(self)
if t >= self.deadline then phase(self, "strike", t, 0.12); strike(self, player) end
elseif self.phase == "strike" then
stop(self); if t >= self.deadline then phase(self, "recover", t, 0.9) end
elseif self.phase == "recover" then
stop(self)
if t >= self.deadline then
if self.round_complete then finish(self, t, player)
elseif self.mobile then phase(self, "approach", t)
else
local s = hearth.sessions[self.target]; if s and s.spar_entity == self then s.spar_entity = nil end
self.target = nil; phase(self, "idle", t)
end
end
elseif self.phase == "approach" then
-- A moving-but-unproductive avoidance loop also has a hard budget.
if self.round_complete or t >= self.approach_until then finish(self, t, player); return end
self.perception = self.perception + dt
if self.perception >= 0.2 then
self.perception = 0
local p = player:get_pos()
self.object:set_yaw(C.dir_to_yaw({x = p.x - pos.x, y = 0, z = p.z - pos.z}))
if M.distance(offset(pos, 0.9), eye(player)) <= 2.0
and C.line_of_sight(offset(pos, 0.9), eye(player), 0.1) then
stop(self); phase(self, "windup", t, 0.75)
feedback(player, "Counter incoming / step aside or face + brace", "warning"); return
end
perceive(self, pos, player:get_pos(), 0.2)
if self.stuck > 3 then finish(self, t, player); return end
end
local d, v = self.direction, self.object:get_velocity()
self.object:set_velocity({x = d and d[1] * 2.2 or 0, y = v.y, z = d and d[2] * 2.2 or 0})
end
end
local function definition(mobile)
return {
initial_properties = {physical = true, collide_with_objects = false,
collisionbox = {-0.3, 0, -0.3, 0.3, 1.6, 0.3}, visual = mobile and "mesh" or "cube",
mesh = "hearth_person.b3d", textures = mobile and {"hearth_person.png"}
or {hearth.texture("#c7aa83"), hearth.texture("#947853"), hearth.texture("#947853"),
hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853")},
visual_size = mobile and {x = 1, y = 1} or {x = 0.6, y = 1.6, z = 0.6},
pointable = true, hp_max = 100, static_save = not mobile,
nametag = "PRACTICE / strike to opt in", nametag_color = "#e5c890"},
on_activate = function(self)
self.mobile, self.health, self.perception, self.stuck = mobile, 30, 0, 0
self.target, self.owner, self.round_complete, self.direction = nil, nil, false, nil
self.last_ai_pos = nil
self.home = copy(self.object:get_pos())
self.object:set_armor_groups({immortal = 1})
self.object:set_acceleration({x = 0, y = -9.81, z = 0})
stop(self); phase(self, "idle", now()) -- Ignore old staticdata; keep identity, reset health.
end,
on_punch = punch, on_step = step,
on_deactivate = function(self)
if self.owner and moving[self.owner] == self then moving[self.owner] = nil end
if self.target then finish(self, now(), nil, true) end
end,
}
end
C.register_entity("hearth:practice", definition(false))
C.register_entity("hearth:sparring", definition(true))
-- Independent movement observations, including before the first hit.
local sample_elapsed = 0
C.register_globalstep(function(dt)
sample_elapsed = sample_elapsed + math.max(0, math.min(dt, 0.1))
if sample_elapsed < 0.1 then return end
sample_elapsed = 0
local t = now()
for _, player in ipairs(C.get_connected_players()) do
if live(player) then
local s = hearth.sessions[player:get_player_name()]
s.combat_sample = M.sample(s.combat_sample, player:get_pos(), t)
end
end
end)
local function deployment_allowed(name, practice)
local single = C.is_singleplayer()
local deployment = C.settings:get("hearth_environment") or (single and "local" or "production")
if deployment == "production" then return false end -- No developer spawning, even for admins.
if practice then return single and deployment == "local" end
return (single and deployment == "local")
or (deployment == "staging" and C.check_player_privs(name, {server = true}) == true)
end
local function spawn(name, mobile)
if not deployment_allowed(name, not mobile) then return false, "Training placement requires local play or privileged staging /spar." end
local player, t = C.get_player_by_name(name), now()
if not live(player) then return false, "Join and finish arrival first." end
local s = hearth.sessions[name]
if t < (s.spar_ready_at or 0) or s.spar_entity then return false, "Finish your round and rest first." end
if mobile and moving[name] and moving[name].object:get_pos() then return false, "Your sparring opponent is already placed; strike it to opt in again." end
local p, look = player:get_pos(), player:get_look_dir()
local length = math.sqrt(look.x^2 + look.z^2)
if length < 0.01 then return false, "Look towards open ground." end
local pos = {x = p.x + look.x / length * 3, y = p.y, z = p.z + look.z / length * 3}
if not allowed(p, name) or not allowed(pos, name) or not node_ok(offset(pos, -0.2), true)
or not node_ok(offset(pos, 0.1), false) or not node_ok(offset(pos, 1.3), false)
or not C.line_of_sight(eye(player), offset(pos, 0.9), 0.1) then return false, "Look at clear, loaded, dry ground outside other claims." end
if not mobile then
for _, obj in ipairs(C.get_objects_inside_radius(pos, 30)) do
local e = obj:get_luaentity()
if e and e.name == "hearth:practice" then return false, "A practice target is already nearby." end
end
end
local object = C.add_entity(pos, mobile and "hearth:sparring" or "hearth:practice")
if not object then return false, "Could not place training target." end
if mobile then
local e = object:get_luaentity(); e.owner, moving[name] = name, e
begin(e, player, t)
end
return true, mobile and "Sparring started / step back or aside; face with spear + RMB to brace." or "Practice target placed. Strike it to start."
end
C.register_chatcommand("practice", {description = "Place a stationary practice target (local play only)",
func = function(name) return spawn(name, false) end})
C.register_chatcommand("spar", {description = "Opt in to one moving sparring opponent (local or server-privileged staging only)",
func = function(name) return spawn(name, true) end})