Initial commit
This commit is contained in:
Executable
+520
@@ -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",
|
||||
})
|
||||
Executable
+75
@@ -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})
|
||||
Executable
+51
@@ -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")
|
||||
Executable
+122
@@ -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
|
||||
Executable
+5
@@ -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
|
||||
Executable
+72
@@ -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
|
||||
Executable
+221
@@ -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)())
|
||||
Reference in New Issue
Block a user