Initial commit

This commit is contained in:
2026-11-01 19:35:46 -05:00
commit 6ebe95b208
17 changed files with 2204 additions and 0 deletions
+520
View File
@@ -0,0 +1,520 @@
-- Original, asset-free content for The Last Hearth (Luanti 5.18).
-- Survival state is owned/persisted by the caller; numeric needs use 0..100.
local api = core
local MAX_REACH, MAX_FUEL = 6, 600
local FUEL = { ["hearth:sticks"] = 45, ["hearth:log"] = 180 }
local COOK_COST, BOIL_COST = 20, 10
local directions = {
{x = 1, y = 0, z = 0}, {x = -1, y = 0, z = 0},
{x = 0, y = 0, z = 1}, {x = 0, y = 0, z = -1},
}
local function texture(color)
return hearth.texture(color)
end
local function lowpart(percent, color)
-- Texture-name arguments must escape modifiers/colons, including [fill.
local overlay = texture(color):gsub("[\\^:]", function(c) return "\\" .. c end)
return "^[lowpart:" .. percent .. ":" .. overlay
end
local function near(player, pos)
if not player or not player:is_player() then return false end
local p = player:get_pos()
if not p then return false end
local dx, dy, dz = p.x - pos.x, p.y - pos.y, p.z - pos.z
return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH
end
local function allowed(pos, player)
if not near(player, pos) then return false end
local name = player:get_player_name()
if api.is_protected(pos, name) or not hearth.claim_allowed(pos, name) then
api.record_protection_violation(pos, name)
return false
end
return true
end
local function can_dig(pos, player)
return allowed(pos, player)
end
local function node_def(pos)
local node = api.get_node_or_nil(pos)
return node and api.registered_nodes[node.name], node
end
local function placement_pos(pointed)
if not pointed or pointed.type ~= "node" then return nil end
local under = node_def(pointed.under)
local above = node_def(pointed.above)
if not under or not above then return nil end
if under.buildable_to then return pointed.under end
if above.buildable_to then return pointed.above end
end
local function place(stack, player, pointed)
if not pointed or pointed.type ~= "node" then return stack end
local def = node_def(pointed.under)
-- item_place dispatches right-click callbacks before attempting placement.
if def and def.on_rightclick and player and not player:get_player_control().sneak then
if not allowed(pointed.under, player) then return stack end
return api.item_place(stack, player, pointed)
end
local pos = placement_pos(pointed)
if not pos or not allowed(pos, player) then return stack end
return api.item_place(stack, player, pointed)
end
local function is_building(name)
return name == "hearth:wall" or name == "hearth:roof"
end
local function solid(def)
return def and def.walkable ~= false and not def.buildable_to
and (not def.liquidtype or def.liquidtype == "none")
and not (def.groups and def.groups.leaves)
end
local function supported(pos)
-- Search existing construction only: at most two horizontal links and
-- sixteen blocks down. The horizontal budget never resets at a new block,
-- so sequential placement cannot extend a cantilever indefinitely.
local queue = {{pos = pos, span = 0, depth = 0}}
local seen, index = {}, 1
while index <= #queue do
local entry = queue[index]
index = index + 1
local p = entry.pos
local key = p.x .. ":" .. p.y .. ":" .. p.z .. ":" .. entry.span
if not seen[key] then
seen[key] = true
local below = {x = p.x, y = p.y - 1, z = p.z}
local def, node = node_def(below)
if solid(def) then
if not is_building(node.name) then return true end
if entry.depth < 15 then
queue[#queue + 1] = {pos = below, span = entry.span,
depth = entry.depth + 1}
end
end
if entry.span < 2 then
for _, d in ipairs(directions) do
local adjacent = {x = p.x + d.x, y = p.y, z = p.z + d.z}
local _, n = node_def(adjacent)
if n and is_building(n.name) then
queue[#queue + 1] = {pos = adjacent, span = entry.span + 1,
depth = entry.depth}
end
end
end
end
end
return false
end
local function place_building(stack, player, pointed)
if not pointed or pointed.type ~= "node" then return stack end
local under = node_def(pointed.under)
if under and under.on_rightclick and player and not player:get_player_control().sneak then
if not allowed(pointed.under, player) then return stack end
return api.item_place(stack, player, pointed)
end
local pos = placement_pos(pointed)
if not pos or not allowed(pos, player) then return stack end
if not supported(pos) then
hearth.notify(player, "Needs solid support within two horizontal blocks (maximum height 16).")
return stack
end
return api.item_place(stack, player, pointed)
end
local function owner(pos, player)
if player and player:is_player() then
api.get_meta(pos):set_string("owner", player:get_player_name())
end
end
local function register_node(name, def)
def.on_place = def.on_place or place
def.can_dig = can_dig
api.register_node("hearth:" .. name, def)
end
register_node("stone", {
description = "Weathered stone", tiles = {texture("#79766b") .. "^[contrast:12"},
groups = {cracky = 3, stone = 1}, drop = "hearth:loose_stone 3",
})
register_node("soil", {
description = "Earth", tiles = {texture("#68513c")},
groups = {crumbly = 3, soil = 1},
})
register_node("grass", {
description = "Meadow turf", tiles = {texture("#66764a"), texture("#68513c"),
texture("#68513c") .. lowpart(25, "#66764a")},
groups = {crumbly = 3, soil = 1}, drop = "hearth:soil",
})
register_node("sand", {
description = "River sand", tiles = {texture("#b7a47a")},
groups = {crumbly = 3, falling_node = 1, sand = 1},
})
for _, flowing in ipairs({false, true}) do
local name = flowing and "water_flowing" or "water_source"
register_node(name, {
description = flowing and "Flowing river water" or "River water (unsafe to drink)",
drawtype = flowing and "flowingliquid" or "liquid",
tiles = {texture("#527e82") .. "^[opacity:185"},
special_tiles = {
{name = texture("#527e82") .. "^[opacity:185", backface_culling = false},
{name = texture("#527e82") .. "^[opacity:185", backface_culling = true},
},
paramtype = "light", paramtype2 = flowing and "flowingliquid" or nil,
use_texture_alpha = "blend", walkable = false, pointable = false,
diggable = false, buildable_to = true, drowning = 1,
liquidtype = flowing and "flowing" or "source",
liquid_alternative_flowing = "hearth:water_flowing",
liquid_alternative_source = "hearth:water_source",
liquid_viscosity = 1, liquid_renewable = false, liquid_range = 4,
post_effect_color = {a = 100, r = 48, g = 83, b = 88},
groups = {water = 3, liquid = 3, not_in_creative_inventory = 1}, drop = "",
})
end
register_node("log", {
description = "Timber log (requires an axe)",
tiles = {texture("#a18a62"), texture("#a18a62"), texture("#564534")},
paramtype2 = "facedir", groups = {tree = 1, choppy = 2, flammable = 2},
})
register_node("leaves", {
description = "Canopy leaves", drawtype = "allfaces_optional",
tiles = {texture("#536744")}, paramtype = "light", sunlight_propagates = true,
groups = {snappy = 3, leaves = 1, flammable = 2}, drop = "hearth:sticks",
})
register_node("loose_stone", {
description = "Loose flint stone", drawtype = "nodebox",
tiles = {texture("#96938b")}, inventory_image = texture("#96938b"),
wield_image = texture("#96938b"), paramtype = "light", walkable = false,
node_box = {type = "fixed", fixed = {-0.25, -0.5, -0.2, 0.25, -0.32, 0.2}},
selection_box = {type = "fixed", fixed = {-0.3, -0.5, -0.3, 0.3, -0.2, 0.3}},
groups = {dig_immediate = 3, flint = 1},
})
register_node("sticks", {
description = "Dry sticks", drawtype = "nodebox", tiles = {texture("#8e7451")},
inventory_image = texture("#8e7451"), wield_image = texture("#8e7451"),
paramtype = "light", walkable = false,
node_box = {type = "fixed", fixed = {-0.4, -0.48, -0.06, 0.4, -0.4, 0.06}},
groups = {dig_immediate = 3, flammable = 2},
})
local function eat(amounts, replacement)
return function(stack, player)
if not player or not player:is_player() then return stack end
local state = hearth.players[player:get_player_name()]
if not state then return stack end
local needed = false
for key, value in pairs(amounts) do
if type(state[key]) == "number" and state[key] < 100 and value > 0 then
needed = true
end
end
if not needed then return stack end
for key, value in pairs(amounts) do
if type(state[key]) == "number" then
state[key] = math.max(0, math.min(100, state[key] + value))
end
end
if replacement then stack:replace(replacement) else stack:take_item() end
return stack
end
end
api.register_craftitem("hearth:berries", {
description = "Wild berries", inventory_image = texture("#995a65"),
on_use = eat({food = 12, water = 4}),
})
api.register_craftitem("hearth:raw_food", {
description = "Foraged food (cook at a hearth)", inventory_image = texture("#ad8263"),
})
api.register_craftitem("hearth:cooked_food", {
description = "Hearth-cooked forage", inventory_image = texture("#bf985b"),
on_use = eat({food = 35, stamina = 10}),
})
register_node("berry_bush", {
description = "Wild berry bush", drawtype = "allfaces_optional",
tiles = {texture("#63734b") .. lowpart(20, "#995a65")},
paramtype = "light", walkable = false, sunlight_propagates = true,
groups = {snappy = 3, flora = 1, flammable = 2}, drop = "hearth:berries 3",
})
register_node("reeds", {
description = "Thatching rushes", drawtype = "plantlike", tiles = {texture("#a89965")},
inventory_image = texture("#a89965"), wield_image = texture("#a89965"),
paramtype = "light", walkable = false, sunlight_propagates = true,
groups = {snappy = 3, flora = 1, flammable = 2},
})
register_node("planks", {
description = "Split timber planks", tiles = {texture("#a48a61")},
groups = {choppy = 3, wood = 1, flammable = 2},
})
register_node("wall", {
description = "Timber wall", tiles = {texture("#947853")},
groups = {choppy = 3, wood = 1, flammable = 2},
on_place = place_building, after_place_node = owner,
})
register_node("roof", {
description = "Thatch roof", tiles = {texture("#b4a16b")},
groups = {snappy = 3, flammable = 3},
on_place = place_building, after_place_node = owner,
})
-- No hand choppy/cracky capability: timber needs an axe, stone a pick.
api.register_item(":", {
type = "none", wield_image = texture("#c7aa83"), wield_scale = {x = 1, y = 1, z = 1},
tool_capabilities = {
full_punch_interval = 1, max_drop_level = 0,
groupcaps = {
crumbly = {times = {[3] = 1.4}, uses = 0, maxlevel = 1},
snappy = {times = {[3] = 0.6}, uses = 0, maxlevel = 1},
}, damage_groups = {fleshy = 1},
},
})
local function tool(name, description, color, groupcaps, damage, interval)
api.register_tool("hearth:" .. name, {
description = description, inventory_image = "hearth_icon_" .. name .. ".png",
tool_capabilities = {full_punch_interval = interval, max_drop_level = 1,
groupcaps = groupcaps, damage_groups = {fleshy = damage}},
})
end
tool("knife", "Flint knife", "#b0aaa0", {
snappy = {times = {[3] = 0.2, [2] = 0.5, [1] = 1}, uses = 45, maxlevel = 1},
}, 3, 0.7)
tool("axe", "Flint axe", "#89877e", {
choppy = {times = {[3] = 0.8, [2] = 1.8, [1] = 3}, uses = 40, maxlevel = 1},
}, 4, 1.1)
tool("pick", "Flint pick", "#8e9292", {
cracky = {times = {[3] = 2.4}, uses = 35, maxlevel = 1},
}, 3, 1.2)
tool("spear", "Flint spear", "#aa9a7a", {}, 5, 1.2)
local function fuel_left(pos, now)
local deadline = tonumber(api.get_meta(pos):get_string("fuel_until")) or 0
-- Corrupt/future metadata cannot produce an unbounded fuel reserve.
if deadline ~= deadline then return 0 end
return math.max(0, math.min(MAX_FUEL, deadline - now))
end
local function refresh_fire(pos, remaining, now)
local meta = api.get_meta(pos)
meta:set_string("fuel_until", tostring(remaining > 0 and now + remaining or 0))
meta:set_string("infotext", remaining > 0
and ("Last Hearth: " .. math.ceil(remaining) .. "s fuel; feed sticks/logs, cook forage or boil water")
or "Cold hearth: feed dry sticks or a log")
local node = api.get_node(pos)
local name = remaining > 0 and "hearth:fire_lit" or "hearth:fire"
if node.name ~= name then
api.swap_node(pos, {name = name, param2 = node.param2})
end
if remaining > 0 then
api.get_node_timer(pos):start(math.min(5, remaining))
else
api.get_node_timer(pos):stop()
end
end
local function fire_tick(pos)
local now = os.time()
refresh_fire(pos, fuel_left(pos, now), now)
return false -- refresh_fire schedules the next absolute-deadline check.
end
local function use_fire(pos, _, player, stack)
if not allowed(pos, player) or stack:is_empty() then return stack end
local now = os.time()
local remaining = fuel_left(pos, now)
local item = stack:get_name()
local burn = FUEL[item]
if burn then
if remaining + burn <= MAX_FUEL then
stack:take_item(1)
remaining = remaining + burn
else
hearth.notify(player, "The hearth has no room for that much fuel yet.")
end
else
local cost, result
if item == "hearth:raw_food" then
cost, result = COOK_COST, "hearth:cooked_food"
elseif item == "hearth:water_raw" then
cost, result = BOIL_COST, "hearth:water_clean"
end
if cost then
local inv = player:get_inventory()
-- A multi-item cooking stack needs room before either input or fuel
-- is touched. Single-item inputs are replaced directly in the hand.
if remaining < cost then
hearth.notify(player, "Add fuel before cooking or boiling water.")
elseif stack:get_count() == 1 then
stack:replace(result)
remaining = remaining - cost
elseif inv and inv:room_for_item("main", result) then
local leftover = inv:add_item("main", result)
if leftover:is_empty() then
stack:take_item(1)
remaining = remaining - cost
end
else
hearth.notify(player, "Make inventory space for the cooked food.")
end
end
end
refresh_fire(pos, remaining, now)
return stack
end
for _, lit in ipairs({false, true}) do
register_node(lit and "fire_lit" or "fire", {
description = "Stone hearth", drawtype = "nodebox", paramtype = "light",
tiles = {texture(lit and "#dd9952" or "#6e695e")},
node_box = {type = "fixed", fixed = {-0.5, -0.5, -0.5, 0.5, -0.15, 0.5}},
light_source = lit and 10 or 0, drop = "hearth:fire",
groups = {cracky = 3, dig_immediate = 2, not_in_creative_inventory = lit and 1 or nil},
on_construct = function(pos) refresh_fire(pos, 0, os.time()) end,
after_place_node = owner, on_rightclick = use_fire, on_timer = fire_tick,
-- There is deliberately no hearth inventory/formspec. Deny injected
-- inventory actions as well, including actions against legacy metadata.
allow_metadata_inventory_put = function() return 0 end,
allow_metadata_inventory_take = function() return 0 end,
allow_metadata_inventory_move = function() return 0 end,
})
end
api.register_lbm({
name = "hearth:expire_fire", label = "Reconcile real-time hearth fuel",
nodenames = {"hearth:fire", "hearth:fire_lit"}, run_at_every_load = true,
action = fire_tick,
})
api.register_craftitem("hearth:vessel", {
description = "Carved stone cup (place on water to fill)",
inventory_image = texture("#a19a88"), stack_max = 1, liquids_pointable = true,
on_place = function(stack, player, pointed)
if not pointed or pointed.type ~= "node" then return stack end
local pos = pointed.under
if not allowed(pos, player) then return stack end
local node = api.get_node(pos)
if node.name == "hearth:water_source" or node.name == "hearth:water_flowing" then
stack:replace("hearth:water_raw")
end
return stack
end,
})
api.register_craftitem("hearth:water_raw", {
description = "Stone cup of untreated water (boil at a hearth)",
inventory_image = texture("#527e82"), stack_max = 1,
-- Primary use/placement on a hearth also works without targeting its top.
on_use = function(stack, player, pointed)
if pointed and pointed.type == "node" then
local node = api.get_node(pointed.under)
if node.name == "hearth:fire" or node.name == "hearth:fire_lit" then
return use_fire(pointed.under, node, player, stack)
end
end
return stack
end,
on_place = function(stack, player, pointed)
if pointed and pointed.type == "node" then
local node = api.get_node(pointed.under)
if node.name == "hearth:fire" or node.name == "hearth:fire_lit" then
return use_fire(pointed.under, node, player, stack)
end
end
return stack
end,
})
api.register_craftitem("hearth:water_clean", {
description = "Stone cup of boiled water", inventory_image = texture("#82adb0"),
stack_max = 1, on_use = eat({water = 40}, "hearth:vessel"),
})
local recipes = {
{"hearth:knife", {{"hearth:loose_stone", "hearth:loose_stone"}, {"", "hearth:sticks"}}},
{"hearth:axe", {{"hearth:loose_stone", "hearth:loose_stone"}, {"hearth:loose_stone", "hearth:sticks"}, {"", "hearth:sticks"}}},
{"hearth:pick", {{"hearth:loose_stone", "hearth:loose_stone", "hearth:loose_stone"}, {"", "hearth:sticks", ""}, {"", "hearth:sticks", ""}}},
{"hearth:spear", {{"hearth:loose_stone"}, {"hearth:sticks"}, {"hearth:sticks"}}},
{"hearth:vessel", {{"hearth:loose_stone", "", "hearth:loose_stone"}, {"", "hearth:loose_stone", ""}}},
{"hearth:fire", {{"hearth:loose_stone", "hearth:loose_stone", "hearth:loose_stone"}, {"hearth:loose_stone", "", "hearth:loose_stone"}, {"hearth:loose_stone", "hearth:loose_stone", "hearth:loose_stone"}}},
{"hearth:wall 4", {{"hearth:planks", "hearth:planks"}, {"hearth:planks", "hearth:planks"}}},
{"hearth:roof 4", {{"hearth:reeds", "hearth:reeds", "hearth:reeds"}, {"hearth:sticks", "", "hearth:sticks"}}},
}
for _, recipe in ipairs(recipes) do
api.register_craft({output = recipe[1], recipe = recipe[2]})
end
api.register_craft({type = "shapeless", output = "hearth:planks 4", recipe = {"hearth:log"}})
api.register_craft({type = "shapeless", output = "hearth:sticks 4", recipe = {"hearth:planks"}})
api.register_craft({type = "shapeless", output = "hearth:raw_food", recipe = {"hearth:berries", "hearth:berries"}})
-- Do not clear other mods' registrations or change global protection/mapgen.
api.register_alias("mapgen_stone", "hearth:stone")
api.register_alias("mapgen_water_source", "hearth:water_source")
api.register_alias("mapgen_river_water_source", "hearth:water_source")
api.register_biome({
name = "hearth_temperate", node_top = "hearth:grass", depth_top = 1,
node_filler = "hearth:soil", depth_filler = 3, node_stone = "hearth:stone",
node_water_top = "hearth:water_source", depth_water_top = 1,
node_water = "hearth:water_source", node_river_water = "hearth:water_source",
node_riverbed = "hearth:sand", depth_riverbed = 2,
y_min = 2, y_max = 31000, heat_point = 50, humidity_point = 50,
})
api.register_biome({
name = "hearth_riverbank", node_top = "hearth:sand", depth_top = 1,
node_filler = "hearth:sand", depth_filler = 2, node_stone = "hearth:stone",
node_water = "hearth:water_source", node_river_water = "hearth:water_source",
node_riverbed = "hearth:sand", depth_riverbed = 2,
y_min = -31000, y_max = 1, heat_point = 50, humidity_point = 50,
})
local function decoration(name, surfaces, ratio, ymin, ymax)
api.register_decoration({
name = "hearth:" .. name, deco_type = "simple", place_on = surfaces,
sidelen = 16, fill_ratio = ratio, y_min = ymin, y_max = ymax,
decoration = "hearth:" .. name,
})
end
decoration("loose_stone", {"hearth:grass", "hearth:sand"}, 0.035, 1, 200)
decoration("sticks", {"hearth:grass"}, 0.03, 2, 200)
decoration("berry_bush", {"hearth:grass"}, 0.018, 2, 120)
decoration("reeds", {"hearth:sand", "hearth:grass"}, 0.025, 1, 200)
-- Small seep springs make upland starts viable without handing out supplies.
-- Replace one surface block: surrounding earth contains water where level;
-- natural slopes can create a short flowing seep instead.
api.register_decoration({
name = "hearth:seep_springs", deco_type = "simple", place_on = {"hearth:grass"},
sidelen = 16, fill_ratio = 0.0015, y_min = 3, y_max = 150,
decoration = "hearth:water_source", place_offset_y = -1, flags = "force_placement",
})
-- Table schematics are x-fastest, then y, then z; no external assets.
local tree = {size = {x = 5, y = 6, z = 5}, data = {}}
for z = 0, 4 do
for y = 0, 5 do
for x = 0, 4 do
local trunk = x == 2 and z == 2 and y < 5
local canopy = y >= 3 and math.abs(x - 2) + math.abs(z - 2) <= (y == 5 and 1 or 3)
tree.data[#tree.data + 1] = {
name = trunk and "hearth:log" or (canopy and "hearth:leaves" or "air"),
prob = (trunk or canopy) and 255 or 0,
}
end
end
end
api.register_decoration({
name = "hearth:temperate_trees", deco_type = "schematic",
place_on = {"hearth:grass"}, sidelen = 16, fill_ratio = 0.006,
y_min = 3, y_max = 150, schematic = tree, rotation = "random",
flags = "place_center_x,place_center_z",
})
+75
View File
@@ -0,0 +1,75 @@
local C = core
-- A deliberately artificial sparring target: opt-in, persistent, non-lethal.
-- No claim of production combat animation/prediction: validates the first loop.
C.register_entity("hearth:practice", {
initial_properties = {physical = true, collide_with_objects = false,
collisionbox = {-0.3, 0, -0.3, 0.3, 1.6, 0.3}, visual = "cube",
visual_size = {x = 0.6, y = 1.6, z = 0.6},
textures = {hearth.texture("#c7aa83"), hearth.texture("#947853"), hearth.texture("#947853"),
hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853")},
pointable = true, hp_max = 100, static_save = true,
nametag = "PRACTICE / strike to spar · step back to stop", nametag_color = "#e5c890"},
on_activate = function(self)
self.object:set_armor_groups({immortal = 1})
self.object:set_acceleration({x = 0, y = -9.81, z = 0})
self.health, self.cooldowns = 30, {}
end,
on_punch = function(self, puncher)
if not puncher or not puncher:is_player() then return true end
local pos, p = self.object:get_pos(), puncher:get_pos()
if not pos or not p or vector.distance(pos, p) > 3.5 then return true end
if not C.line_of_sight(vector.offset(p, 0, 1.2, 0), vector.offset(pos, 0, 0.8, 0), 0.25) then return true end
local name = puncher:get_player_name()
local state = hearth.players[name]
if not state then return true end
local tool = puncher:get_wielded_item():get_name()
local attacks = {['hearth:spear'] = {5, 1.2}, ['hearth:axe'] = {4, 1.1}, ['hearth:knife'] = {3, 0.7}}
local attack = attacks[tool] or {1, 1}
local now = C.get_us_time() / 1000000
if now - (self.cooldowns[name] or -100) < attack[2] or state.stamina < 10 then return true end
self.cooldowns[name] = now
state.stamina = state.stamina - 10
self.health = self.health - attack[1]
-- A faster weapon cannot postpone an already committed counter forever.
if not self.target then self.target, self.delay = name, 0.75 end
self.object:set_properties({nametag = "COUNTER INCOMING / brace or step away", nametag_color = "#f38ba8"})
if self.health <= 0 then
self.health, self.target = 30, nil
self.object:set_properties({nametag = "ROUND COMPLETE / ready again", nametag_color = "#a6e3a1"})
hearth.notify(puncher, "Practice complete. Good footwork saves stamina; right-click your spear to brace.")
else hearth.notify(puncher, "Hit confirmed / target " .. self.health .. "/30") end
return true
end,
on_step = function(self, dt)
if not self.target then return end
self.delay = self.delay - math.min(dt, 0.1)
if self.delay > 0 then return end
local player = C.get_player_by_name(self.target)
if player and player:get_hp() > 2 then
local p, pos = player:get_pos(), self.object:get_pos()
if vector.distance(p, pos) < 2.5 and C.line_of_sight(vector.offset(pos, 0, 1, 0), vector.offset(p, 0, 1, 0), 0.25) then
local state, session = hearth.players[self.target], hearth.sessions[self.target]
local blocking = state and session and session.blocking and state.stamina >= 8
if blocking then state.stamina = state.stamina - 8 end
player:set_hp(math.max(2, player:get_hp() - (blocking and 1 or 3)), {type = "set_hp", from = "mod"})
hearth.notify(player, blocking and "Braced / reduced impact" or "Counter landed / step back beyond reach")
end
end
self.target = nil
self.object:set_properties({nametag = "PRACTICE / " .. self.health .. "/30 · strike to spar", nametag_color = "#e5c890"})
end,
})
C.register_chatcommand("practice", {description = "Place one practice opponent near you (local play only)",
func = function(name)
if not C.is_singleplayer() then return false, "Practice placement is local-play only." end
local player = C.get_player_by_name(name)
if not player then return false end
local pos = vector.round(vector.add(player:get_pos(), vector.multiply(player:get_look_dir(), 3)))
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
if C.get_node(pos).name ~= "air" or C.is_protected(pos, name) then return false, "Look at an open space nearby." end
return C.add_entity(pos, "hearth:practice") ~= nil, "Practice target placed. Strike it to start; step away to stop."
end})
+51
View File
@@ -0,0 +1,51 @@
hearth = {players = {}, sessions = {}}
local path = core.get_modpath("hearth")
hearth.model = dofile(path .. "/model.lua")
-- Original texture generation: no downloaded assets or asset licensing ambiguity.
function hearth.texture(color)
return "hearth_" .. color:sub(2):lower() .. ".png"
end
function hearth.notify(player, message)
if player then core.chat_send_player(player:get_player_name(), message) end
end
local storage = core.get_mod_storage()
hearth.storage = storage
-- A small claim registry is persisted in mod storage; never deserialize code.
local claim_data = storage:get_string("claims")
local claims = claim_data ~= "" and core.parse_json(claim_data) or {}
function hearth.claim_allowed(pos, name)
for owner, p in pairs(claims) do
if owner ~= name and math.abs(pos.x - p.x) <= 12 and math.abs(pos.z - p.z) <= 12
and math.abs(pos.y - p.y) <= 24 then return false end
end
return true
end
local old_protected = core.is_protected
core.is_protected = function(pos, name)
return not hearth.claim_allowed(pos, name) or old_protected(pos, name)
end
core.register_chatcommand("claim", {
description = "Protect a 25 x 25 homestead here (one per player). /claim remove to release.",
func = function(name, param)
local player = core.get_player_by_name(name)
if not player then return false, "Join the world first." end
if param == "remove" then
claims[name] = nil
elseif param == "" then
local p = vector.round(player:get_pos())
if claims[name] then return false, "Release your existing claim before moving it." end
for _, other in pairs(claims) do
if math.abs(p.x - other.x) <= 25 and math.abs(p.z - other.z) <= 25 then
return false, "Too close to another homestead. Move at least 26 blocks away."
end
end
claims[name] = {x = p.x, y = p.y, z = p.z}
else return false, "Use /claim or /claim remove." end
storage:set_string("claims", core.write_json(claims))
return true, param == "remove" and "Homestead released." or "Homestead protected. Others cannot build or harvest here."
end,
})
dofile(path .. "/content.lua")
dofile(path .. "/interface.lua")
dofile(path .. "/runtime.lua")
dofile(path .. "/encounters.lua")
+122
View File
@@ -0,0 +1,122 @@
local C = core
local esc = C.formspec_escape
local bg, text, dim, gold = "#1e1e2e", "#cdd6f4", "#a6adc8", "#e5c890"
local pages = {
{"First light", "YOUR FIRST HEARTH", "Start with what the valley gives you.",
"Collect loose flint and dry sticks from the ground. Gather berries for food and leaves for extra sticks.\n\nOpen your inventory with I. Make a flint axe, then fell timber. Split logs into planks; craft walls and a thatch roof.\n\nA roof keeps the rain off. Walls stop exposure. A fueled hearth dries and warms you. Leave a small opening: enclosed fires need ventilation.\n\nThere is no quest timer. Your first success is a home that works."},
{"Craft & build", "WORK WITH YOUR HANDS", "All recipes use the 3 x 3 inventory grid.",
"AXE: two flints across the top; flint + stick below; a stick beneath the handle.\nKNIFE: two flints above one stick (right column).\nSPEAR: flint above two sticks.\nCUP: flint at top-left and top-right, flint at center.\nHEARTH: eight flints around an empty center.\n\nOne log -> four planks. One plank -> four sticks. Four planks in a square -> four walls.\nROOF: three reeds across the top, two sticks at the ends of the next row.\n\nWalls and roofs need support within two horizontal blocks. Columns may be up to 16 blocks high. Existing unsupported blocks do not collapse in this prototype."},
{"Fire & food", "A FIRE WORTH KEEPING", "Gather. Prepare. Eat. Recover.",
"Place your stone hearth. Right-click it with sticks or logs to add fuel. Each stick burns for 45 real seconds; a log for three minutes.\n\nCraft two berries into forage. Right-click a fueled hearth with forage to cook; left-click with cooked food to eat. Berries can also be eaten raw.\n\nUse the stone cup on river water to fill it. Right-click a fueled hearth with untreated water to boil it. Left-click to drink boiled water.\n\nCooking is a fuel-consuming instant prototype, not a realistic timed process. No spoilage, farming or pottery yet."},
{"Read the world", "SHELTER IS A SYSTEM", "The HUD tells you why you feel the way you do.",
"A covered roof plus nearby walls reduces exposure. Wetness rises in rain and falls under cover; a hearth helps you dry. Check the shelter indicator as you build.\n\nA very enclosed fire carries a smoke warning and causes harm: provide ventilation. This is a bounded enclosure approximation, not smoke simulation.\n\nAux1 (usually E) sprints. Right-click while holding a spear to brace against the practice opponent; bracing costs stamina and reduces walking speed.\n\nApproach the practice target near arrival. It retaliates only after you strike it. Step outside its short reach to recover. PvP is disabled."},
{"Your homestead", "MAKE A PLACE YOURS", "A community without compulsory chores.",
"Use /claim at your homestead to protect a 25 x 25 area, extending 24 blocks up/down. One claim per person. /claim remove releases it. Stand at least 26 blocks from another claim.\n\nYour inventory and needs persist between sessions. Logged-out characters do not starve or freeze. Fuel continues to expire in real time.\n\nDeath keeps your inventory and returns you to arrival, with needs partially restored. This forgiving prototype policy will be revisited after playtests.\n\nThis is not yet a hardened public server: moderation, shared claim membership and all grief scenarios need further testing."},
{"Clock & location", "ONE WORLD. ONE CLOCK.", "A real calendar, not a daily login requirement.",
"Local play asks you to choose approximate coordinates. No location service, IP lookup or external request is used. Use /location to change them. Coordinates stay in local world storage.\n\nA community server must set its shared latitude and longitude in configuration. Remote players cannot override them. The world follows the host's real UTC clock.\n\nSun height and day length follow latitude, longitude and date. The engine's fixed sky orbit is approximated; it is not an exact astronomical renderer. The HUD uses UTC and local solar time, not civil timezone labels.\n\nClimate and rain are synthetic. Calendar-season labels use meteorological months; southern latitudes reverse them."},
}
function hearth.guide(player, page)
page = math.max(1, math.min(#pages, page or 1))
hearth.sessions[player:get_player_name()].page = page
local p = pages[page]
local f = {"formspec_version[6]size[14,10]bgcolor[", bg, ";true]box[0,0;14,10;#1e1e2eff]",
"style_type[button;border=false;bgcolor=#313244;textcolor=", text, "]",
"box[0,0;3.3,10;#181825]label[0.45,0.65;", esc("eom.dev / SEASON VI"), "]",
"label[0.45,1.3;", esc("THE LAST HEARTH"), "]"}
for i, pg in ipairs(pages) do
f[#f + 1] = "button[0.35," .. (2 + i * 0.72) .. ";2.6,0.58;page_" .. i .. ";" .. esc(pg[1]) .. "]"
end
f[#f + 1] = "label[3.85,0.7;" .. esc("FIELD NOTES / " .. string.format("%02d", page)) .. "]"
f[#f + 1] = "style[title;textcolor=" .. gold .. "]label[3.85,1.35;" .. esc(p[2]) .. "]"
f[#f + 1] = "label[3.85,2.0;" .. esc(p[3]) .. "]"
f[#f + 1] = "textarea[3.8,2.7;9.65,5.6;;;" .. esc(p[4]) .. "]"
f[#f + 1] = "button_exit[10.5,8.85;2.8,0.65;close;Back to the valley]"
C.show_formspec(player:get_player_name(), "hearth:guide", table.concat(f))
end
function hearth.inventory(player)
player:set_inventory_formspec("formspec_version[6]size[12.4,11.3]bgcolor[" .. bg .. ";true]box[0,0;12.4,11.3;#1e1e2eff]"
.. "label[0.5,0.45;eom.dev / THE LAST HEARTH]label[0.5,1.05;Prepare something worth carrying.]"
.. "listcolors[#313244;#45475a;#cdd6f4;#181825;#cdd6f4]"
.. "list[current_player;craft;0.6,1.8;3,3;]list[current_player;craftpreview;4.8,2.9;1,1;]"
.. "label[4.9,2.2;RESULT]label[6.5,1.8;GATHER / BUILD / ENDURE]"
.. "textarea[6.5,2.4;5.4,2.6;;;Collect flint and sticks first.\nA roof keeps rain off.\nA hearth makes shelter a home.]"
.. "button[7.1,4.35;3.8,0.6;field_guide;Open field notes]"
.. "list[current_player;main;0.6,5.7;10,4;]listring[current_player;main]listring[current_player;craft]")
end
function hearth.location_form(player, error_text)
C.show_formspec(player:get_player_name(), "hearth:location",
"formspec_version[6]size[10.8,7.3]bgcolor[" .. bg .. ";true]box[0,0;10.8,7.3;#1e1e2eff]"
.. "label[0.65,0.6;THE LAST HEARTH / SET YOUR WORLD'S LOCATION]"
.. "textarea[0.65,1.2;9.5,1.7;;;" .. esc("Choose approximate coordinates near you. No location is collected automatically.\nThey stay in this world; no internet lookup is performed.\nExample only: 45 latitude, 0 longitude.") .. "]"
.. "field[0.65,3.2;4.4,0.8;latitude;Latitude (-90 to 90);]"
.. "field[5.6,3.2;4.4,0.8;longitude;Longitude (-180 to 180);]"
.. "label[0.65,4.5;" .. esc(error_text or "No location chosen: neutral equatorial preview until configured.") .. "]"
.. "button[0.65,5.3;4.4,0.75;save_location;Use this location]"
.. "button_exit[5.6,5.3;4.4,0.75;later;Choose later]")
end
C.register_on_player_receive_fields(function(player, form, fields)
if form == "hearth:guide" then
for i = 1, #pages do if fields["page_" .. i] then hearth.guide(player, i); return true end end
elseif form == "hearth:location" and fields.save_location then
if not C.is_singleplayer() then return true end
local lat, lon = fields.latitude or "", fields.longitude or ""
if #lat > 16 or #lon > 16 or not hearth.model.location(lat, lon) then
hearth.location_form(player, "Enter valid finite coordinates within the ranges above.")
else
hearth.set_location(tonumber(lat), tonumber(lon))
hearth.guide(player)
end
return true
elseif form == "" and fields.field_guide then hearth.guide(player); return true end
end)
C.register_chatcommand("guide", {description = "Open the Last Hearth field notes",
func = function(name) local p = C.get_player_by_name(name); if p then hearth.guide(p) end; return true end})
C.register_chatcommand("location", {description = "Choose local-world reference coordinates",
func = function(name)
if not C.is_singleplayer() then return false, "Server coordinates are selected by its administrator." end
local p = C.get_player_by_name(name); if p then hearth.location_form(p) end; return true
end})
function hearth.make_hud(player)
local s = hearth.sessions[player:get_player_name()]
s.hud = {}
s.hud.title = player:hud_add({type = "text", position = {x = 0.025, y = 0.04},
alignment = {x = 1, y = 0}, text = "THE LAST HEARTH / eom.dev", number = 0xe5c890, size = {x = 1, y = 1}})
s.hud.clock = player:hud_add({type = "text", position = {x = 0.025, y = 0.085},
alignment = {x = 1, y = 0}, text = "", number = 0xcdd6f4, size = {x = 1, y = 1}})
s.hud.needs = player:hud_add({type = "text", position = {x = 0.025, y = 0.14},
alignment = {x = 1, y = 0}, text = "", number = 0xcdd6f4, size = {x = 1, y = 1}})
s.hud.context = player:hud_add({type = "text", position = {x = 0.5, y = 0.77},
alignment = {x = 0, y = 0}, text = "", number = 0xe5c890, size = {x = 1, y = 1}})
s.hud.help = player:hud_add({type = "text", position = {x = 0.975, y = 0.04},
alignment = {x = -1, y = 0}, text = "I Craft & field notes | E Sprint | /claim Homestead",
number = 0xa6adc8, size = {x = 1, y = 1}})
player:hud_set_flags({minimap = false})
player:hud_set_hotbar_itemcount(10)
end
function hearth.update_hud(player, env)
local name = player:get_player_name()
local s, state = hearth.sessions[name], hearth.players[name]
if not s or not s.hud then return end
local clock = hearth.clock
local hour = math.floor(clock.hour)
player:hud_change(s.hud.clock, "text", clock.season .. " / " .. clock.date
.. string.format(" / Solar %02d:%02d / %d C%s", hour, math.floor(clock.hour % 1 * 60),
math.floor(env.temperature), hearth.has_location and "" or " [LOCATION NOT SET]"))
local function bar(n) return string.rep("|", math.floor(n / 10)) .. string.rep(".", 10 - math.floor(n / 10)) end
player:hud_change(s.hud.needs, "text", "WARMTH " .. bar(state.warmth)
.. "\nWATER " .. bar(state.water) .. "\nFOOD " .. bar(state.food)
.. "\nSTAMINA " .. bar(state.stamina))
local context = env.shelter >= 0.6 and "SHELTERED" or (env.roof and "ROOFED / add walls" or "EXPOSED / find cover")
context = context .. (env.rain and " | RAIN" or " | CLEAR")
if state.wet > 25 then context = context .. " | WET CLOTHING" end
if env.heat > 0 then context = context .. " | HEARTH WARMTH" end
if env.smoke then context = "SMOKE / open a wall or roof vent" end
if state.warmth < 20 then context = context .. " | COLD: shelter and fire needed" end
player:hud_change(s.hud.context, "text", context)
player:hud_change(s.hud.context, "number", (env.smoke or state.warmth < 20) and 0xf38ba8 or 0xe5c890)
end
+5
View File
@@ -0,0 +1,5 @@
name = hearth
title = The Last Hearth
description = Original survival-first eom.dev prototype. No external game dependencies.
author = eom.dev
min_minetest_version = 5.18
+72
View File
@@ -0,0 +1,72 @@
-- Pure simulation functions: no engine state, I/O, network, or mutable clock.
local M = {}
function M.clamp(n, low, high) return math.max(low, math.min(high, n)) end
function M.location(lat, lon)
lat, lon = tonumber(lat), tonumber(lon)
if not lat or not lon or lat ~= lat or lon ~= lon or math.abs(lat) > 90
or math.abs(lon) > 180 then return nil end
return {latitude = lat, longitude = lon}
end
function M.solar(timestamp, latitude, longitude)
local t = os.date("!*t", timestamp)
local leap = t.year % 4 == 0 and (t.year % 100 ~= 0 or t.year % 400 == 0)
local days = leap and 366 or 365
local utc = t.hour + t.min / 60 + t.sec / 3600
local gamma = 2 * math.pi / days * (t.yday - 1 + (utc - 12) / 24)
-- NOAA fractional-year approximation; weather is synthetic, not a forecast.
local equation = 229.18 * (0.000075 + 0.001868 * math.cos(gamma)
- 0.032077 * math.sin(gamma) - 0.014615 * math.cos(2 * gamma)
- 0.040849 * math.sin(2 * gamma))
local declination = 0.006918 - 0.399912 * math.cos(gamma)
+ 0.070257 * math.sin(gamma) - 0.006758 * math.cos(2 * gamma)
+ 0.000907 * math.sin(2 * gamma) - 0.002697 * math.cos(3 * gamma)
+ 0.00148 * math.sin(3 * gamma)
local solar_hour = (utc + longitude / 15 + equation / 60) % 24
local lat = math.rad(latitude)
local sin_elevation = math.sin(lat) * math.sin(declination)
+ math.cos(lat) * math.cos(declination) * math.cos(math.rad((solar_hour - 12) * 15))
local elevation = math.deg(math.asin(M.clamp(sin_elevation, -1, 1)))
-- Adapt the engine's fixed equinox orbit so its daylight matches this latitude.
local engine_time
if solar_hour <= 12 then engine_time = (elevation + 90) / 360
else engine_time = (270 - elevation) / 360 end
local south = latitude < 0 and 1 or 0
local index = (math.floor((t.month % 12) / 3) + south * 2) % 4 + 1
local season = ({"Winter", "Spring", "Summer", "Autumn"})[index]
local annual = math.cos(2 * math.pi * (t.yday - 205) / days) * (south == 1 and -1 or 1)
local mean = 27 - math.abs(latitude) * 0.43
local amplitude = 3 + math.abs(latitude) * 0.22
return {hour = solar_hour, timeofday = engine_time, elevation = elevation,
daylight = elevation > -0.833, season = season,
temperature = mean + annual * amplitude + math.sin(math.rad(elevation)) * 4,
date = os.date("!%Y-%m-%d %H:%M UTC", timestamp)}
end
function M.weather(timestamp, latitude, longitude)
-- UTC-indexed deterministic six-hour fronts: restart/unloading do not reroll.
local front = math.floor(timestamp / 21600)
local wave = math.sin(front * 12.9898 + latitude * 0.1 + longitude * 0.03) * 43758.5453
return wave - math.floor(wave) > 0.68 and "rain" or "clear"
end
function M.needs(state, env, dt)
dt = M.clamp(dt, 0, 2) -- stalled frames never replay lethal elapsed time
local s = {}
for k, v in pairs(state) do s[k] = v end
local shelter = M.clamp(env.shelter or 0, 0, 1)
s.wet = M.clamp(s.wet + ((env.rain and shelter < 0.6) and 1.2 or -0.35) * dt, 0, 100)
local feels = env.temperature - s.wet * 0.09 + (env.heat or 0) * 14 + shelter * 5
local target = M.clamp(60 + feels * 1.5, 0, 100)
s.warmth = M.clamp(s.warmth + M.clamp(target - s.warmth, -0.16, 0.3) * dt, 0, 100)
s.food = M.clamp(s.food - (env.sprinting and 0.035 or 0.012) * dt, 0, 100)
s.water = M.clamp(s.water - (env.sprinting and 0.06 or 0.025) * dt, 0, 100)
s.stamina = M.clamp(s.stamina + (env.sprinting and -13 or
(s.food > 0 and s.water > 0 and 9 or 2)) * dt, 0, 100)
return s, (s.warmth < 8 or s.food <= 0 or s.water <= 0) and dt or 0
end
function M.fuel_remaining(deadline, now)
return M.clamp(deadline - now, 0, 600)
end
return M
+221
View File
@@ -0,0 +1,221 @@
local C, M = core, hearth.model
local lat_setting, lon_setting = C.settings:get("hearth_latitude"), C.settings:get("hearth_longitude")
local configured = M.location(lat_setting, lon_setting)
local location_data = hearth.storage:get_string("location")
local stored = location_data ~= "" and C.parse_json(location_data) or nil
local selected = configured or (C.is_singleplayer() and stored and M.location(stored.latitude, stored.longitude))
if not C.is_singleplayer() and not configured then
error("Last Hearth server requires explicit hearth_latitude and hearth_longitude settings.")
end
hearth.has_location = selected ~= nil
local location = selected or {latitude = 0, longitude = 0}
function hearth.set_location(lat, lon)
assert(C.is_singleplayer(), "Only local worlds can select location in game")
local value = assert(M.location(lat, lon), "Invalid reference coordinates")
location, hearth.has_location = value, true
hearth.storage:set_string("location", C.write_json(value))
end
local function timestamp()
-- Date fixtures are only accepted for local play, never a public server.
if C.is_singleplayer() then
local fixture = tonumber(C.settings:get("hearth_preview_date"))
if fixture and fixture >= 946684800 and fixture <= 4102444800 then return fixture end
end
return os.time()
end
local function clock_update()
local now = timestamp()
hearth.clock = M.solar(now, location.latitude, location.longitude)
hearth.weather = M.weather(now, location.latitude, location.longitude)
if C.is_singleplayer() then
local weather = C.settings:get("hearth_preview_weather")
if weather == "rain" or weather == "clear" then hearth.weather = weather end
end
C.set_timeofday(hearth.clock.timeofday)
end
C.after(0, clock_update)
local function solid(pos)
local n = C.get_node_or_nil(pos)
if not n then return false end
local def = C.registered_nodes[n.name]
return def and def.walkable ~= false and not def.buildable_to
and C.get_item_group(n.name, "leaves") == 0
end
function hearth.environment(pos)
local head = vector.round({x = pos.x, y = pos.y + 1, z = pos.z})
local roof = false
for y = 1, 5 do if solid(vector.offset(head, 0, y, 0)) then roof = true; break end end
local walls = 0
for _, d in ipairs({{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) do
for r = 1, 3 do
if solid(vector.offset(head, d[1] * r, 0, d[2] * r)) then walls = walls + 1; break end
end
end
local shelter = roof and (0.25 + walls * 0.1875) or 0
local fire = C.find_node_near(pos, 4, {"hearth:fire_lit"})
local heat = 0
if fire then
local deadline = tonumber(C.get_meta(fire):get_string("fuel_until")) or 0
if M.fuel_remaining(deadline, os.time()) > 0 then
-- A wall between the hearth and player blocks its warmth.
local from = vector.offset(fire, 0, 0.6, 0)
if C.line_of_sight(from, head, 0.5) then heat = 1 - math.min(0.8, vector.distance(pos, fire) / 5) end
end
end
return {roof = roof, shelter = shelter, heat = heat, smoke = heat > 0 and roof and walls == 4,
rain = hearth.weather == "rain", temperature = hearth.clock.temperature - math.max(0, pos.y) * 0.0065}
end
local defaults = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100}
local function save(player)
local s = hearth.players[player:get_player_name()]
if s then player:get_meta():set_string("hearth:needs", C.write_json(s)) end
end
local spawn_data = hearth.storage:get_string("spawn")
local spawn = spawn_data ~= "" and C.parse_json(spawn_data) or nil
local spawn_pending = false
local waiting = {}
local function arrive(player)
if spawn then
player:set_pos(spawn)
player:set_velocity({x = 0, y = 0, z = 0})
end
end
local function locate_spawn(player)
if spawn then arrive(player); return end
waiting[player:get_player_name()] = true
if spawn_pending then return end
spawn_pending = true
C.emerge_area({x = -48, y = -16, z = -48}, {x = 48, y = 96, z = 48}, function(_, _, left)
if left ~= 0 then return end
local best, best_score
for x = -44, 44, 4 do for z = -44, 44, 4 do
for y = 78, 3, -1 do
local n = C.get_node({x = x, y = y, z = z})
if n.name == "hearth:grass" then
local p = {x = x, y = y + 1, z = z}
if C.get_node(p).name == "air" and C.get_node(vector.offset(p, 0, 1, 0)).name == "air" then
local score = math.abs(x) + math.abs(z) + y * 0.2
if not best_score or score < best_score then best, best_score = p, score end
end
break
end
end
end end
if not best then
C.log("error", "[hearth] No safe arrival in initial area; select a different world seed.")
for name in pairs(waiting) do C.kick_player(name, "No safe spawn found. Please choose another world seed.") end
waiting, spawn_pending = {}, false
return
end
spawn = best
hearth.storage:set_string("spawn", C.write_json(spawn))
for name in pairs(waiting) do local p = C.get_player_by_name(name); if p then arrive(p) end end
waiting = {}
end)
end
C.register_on_joinplayer(function(player)
local name, meta = player:get_player_name(), player:get_meta()
local saved_data = meta:get_string("hearth:needs")
local saved = saved_data ~= "" and C.parse_json(saved_data) or {}
local state = {}
for key, value in pairs(defaults) do state[key] = M.clamp(tonumber(saved[key]) or value, 0, 100) end
hearth.players[name], hearth.sessions[name] = state, {page = 1, damage = 0, save = 0, ready = false}
local inv = player:get_inventory()
inv:set_size("main", 40); inv:set_size("craft", 9); inv:set_width("craft", 3)
inv:set_size("craftpreview", 1); inv:set_size("craftresult", 1)
player:set_properties({visual = "mesh", mesh = "hearth_person.obj", textures = {hearth.texture("#947853")},
visual_size = {x = 1, y = 1}, collisionbox = {-0.3, 0, -0.3, 0.3, 1.75, 0.3},
stepheight = 0.6, eye_height = 1.55, hp_max = 20})
player:set_armor_groups({fleshy = 100})
hearth.inventory(player); hearth.make_hud(player)
player:set_clouds({density = 0.35, color = "#bec4c8e0", height = 140, speed = {x = 2, z = -1}})
if meta:get_int("hearth:arrived") == 0 then
meta:set_int("hearth:arrived", 1)
locate_spawn(player)
end
C.after(1, function()
local p = C.get_player_by_name(name)
if not p then return end
if not hearth.has_location and C.is_singleplayer() then hearth.location_form(p)
else hearth.guide(p) end
end)
end)
C.register_on_leaveplayer(function(player)
save(player)
local name = player:get_player_name()
hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil
end)
C.register_on_shutdown(function() for _, p in ipairs(C.get_connected_players()) do save(p) end end)
C.register_on_respawnplayer(function(player)
local s = hearth.players[player:get_player_name()]
if s then for k, v in pairs(defaults) do s[k] = v end end
locate_spawn(player)
return true
end)
-- PvP is off regardless of a deployment accidentally changing enable_pvp.
C.register_on_punchplayer(function() return true end)
local elapsed, clock_elapsed = 0, 0
C.register_globalstep(function(dt)
elapsed, clock_elapsed = elapsed + dt, clock_elapsed + dt
if clock_elapsed >= 10 then clock_update(); clock_elapsed = 0 end
-- Small bounded steps for smooth sprint state; needs/HUD at 1 Hz.
for _, player in ipairs(C.get_connected_players()) do
local name = player:get_player_name()
local s, state = hearth.sessions[name], hearth.players[name]
if s then
local controls = player:get_player_control()
s.sprinting = controls.aux1 and controls.up and not controls.sneak and state.stamina > 8
s.blocking = player:get_wielded_item():get_name() == "hearth:spear" and controls.RMB
local speed = s.blocking and 0.6 or (s.sprinting and 1.45 or 1)
if speed ~= s.speed then player:set_physics_override({speed = speed, jump = 1, gravity = 1}); s.speed = speed end
end
end
if elapsed < 1 then return end
local step = math.min(elapsed, 2); elapsed = 0
for _, player in ipairs(C.get_connected_players()) do
local name, pos = player:get_player_name(), player:get_pos()
local s, state = hearth.sessions[name], hearth.players[name]
if s and not waiting[name] and player:get_hp() > 0 then
local env = hearth.environment(pos)
env.sprinting = s.sprinting
local updated, danger = M.needs(state, env, step)
if env.heat > 0 then updated.wet = math.max(0, updated.wet - env.heat * step) end
hearth.players[name] = updated
s.damage = s.damage + danger + (env.smoke and step or 0)
if s.damage >= 4 then
player:set_hp(player:get_hp() - 1, {type = "set_hp", from = "mod"}); s.damage = 0
end
if pos.y < -100 then arrive(player) end
hearth.update_hud(player, env)
s.save = s.save + step
if s.save >= 10 then save(player); s.save = 0 end
end
end
end)
-- Tiny client-local rain effect, not thousands of entities or a global ABM.
C.register_globalstep((function()
local timer = 0
return function(dt)
timer = timer + dt; if timer < 3 then return end; timer = 0
for _, p in ipairs(C.get_connected_players()) do
local density = hearth.weather == "rain" and 0.65 or 0.25
p:set_clouds({density = density})
if hearth.weather == "rain" then
local pos = p:get_pos()
if not hearth.environment(pos).roof then
C.add_particlespawner({amount = 80, time = 3,
minpos = vector.offset(pos, -7, 7, -7), maxpos = vector.offset(pos, 7, 10, 7),
minvel = {x = -1, y = -12, z = 0}, maxvel = {x = -1, y = -10, z = 0},
minexptime = 0.5, maxexptime = 0.8, minsize = 0.4, maxsize = 0.7,
collisiondetection = true, collision_removal = true,
texture = hearth.texture("#a4c4d4"), playername = p:get_player_name()})
end
end
end
end
end)())