Files
last-hearth/mods/hearth/content.lua
T
2026-11-01 19:35:46 -05:00

521 lines
21 KiB
Lua
Executable File

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