271 lines
14 KiB
Lua
Executable File
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})
|