Files
last-hearth/mods/hearth/biomes.lua
T
2026-11-05 09:57:11 -05:00

342 lines
21 KiB
Lua
Executable File

-- Original terrain content. Registration only: never rewrites generated land,
-- changes world settings, clears registries, or owns ecological growth timers.
return function(hearth, api, register_node, construction)
assert(type(hearth) == "table" and type(api) == "table")
assert(type(register_node) == "function" and type(construction) == "table")
local M = {}
local function node(name) return "hearth:" .. name end
local function tile(name) return "hearth_" .. name .. ".png" end
local function names(list)
local out = {}
for i, name in ipairs(list) do out[i] = node(name) end
return out
end
M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce", "crab_apple", "wild_cherry", "field_maple"}
M.rocks = {"granite", "basalt", "limestone", "slate", "sandstone"}
-- Moisture/fertility/drainage are normalized habitat preferences, NOT player
-- climate values. The succession owner decides how to consume these hints.
M.surfaces = {
grass = {moisture=0.45, fertility=0.7, drainage=0.7, vegetated=true},
forest_floor = {moisture=0.6, fertility=0.9, drainage=0.6, vegetated=true},
podzol = {moisture=0.4, fertility=0.35, drainage=0.8, vegetated=true},
alluvium = {moisture=0.85, fertility=1, drainage=0.3, vegetated=true},
peat = {moisture=1, fertility=0.3, drainage=0.1, vegetated=true},
alpine_turf = {moisture=0.35, fertility=0.3, drainage=0.9, vegetated=true},
soil = {moisture=0.45, fertility=0.5, drainage=0.6, vegetated=true},
sand = {moisture=0.2, fertility=0.1, drainage=1},
gravel = {moisture=0.2, fertility=0.1, drainage=1},
scree = {moisture=0.1, fertility=0.05, drainage=1},
snow = {moisture=0.1, fertility=0, drainage=0.5},
}
for _, rock in ipairs(M.rocks) do
M.surfaces[rock] = {moisture=0.1, fertility=0, drainage=1}
end
M.surfaces.stone = {moisture=0.1, fertility=0, drainage=1}
function M.surface(value)
local name = type(value) == "table" and value.name or value
if type(name) ~= "string" then return nil end
return M.surfaces[(name:gsub("^hearth:", ""))]
end
M.profiles = {
oak = {height=7, width=7, crown="broad", heat={35,80}, humidity={30,75},
y_min=3, y_max=100, soils={"grass", "forest_floor"},
biomes={"hearth_oak_woodland", "hearth_temperate"}, shade=0.8, litter=0.9},
birch = {height=8, width=5, crown="slender", heat={20,65}, humidity={35,80},
y_min=6, y_max=145, soils={"forest_floor", "grass", "podzol"},
biomes={"hearth_birch_forest"}, shade=0.45, litter=0.7},
pine = {height=9, width=7, crown="conical", heat={0,45}, humidity={15,70},
y_min=8, y_max=175, soils={"podzol", "alpine_turf"},
biomes={"hearth_pine_taiga"}, shade=0.65, litter=0.45},
willow = {height=7, width=9, crown="drooping", heat={35,85}, humidity={70,100},
y_min=2, y_max=8, soils={"alluvium", "peat"},
biomes={"hearth_floodplain"}, shade=0.85, litter=0.8, riparian=true},
alder = {height=6, width=5, crown="riparian", heat={20,65}, humidity={75,100},
y_min=2, y_max=45, soils={"alluvium", "peat", "forest_floor"},
biomes={"hearth_wet_forest", "hearth_floodplain"}, shade=0.6, litter=1,
riparian=true, nitrogen_fixer=true},
beech = {height=8, width=9, crown="domed", heat={35,70}, humidity={35,70},
y_min=4, y_max=115, soils={"forest_floor", "grass"},
biomes={"hearth_oak_woodland", "hearth_temperate"}, shade=0.95, litter=1},
aspen = {height=9, width=5, crown="columnar", heat={20,65}, humidity={35,75},
y_min=5, y_max=140, soils={"grass", "forest_floor", "alluvium"},
biomes={"hearth_birch_forest", "hearth_temperate"}, shade=0.45, litter=0.7},
rowan = {height=6, width=7, crown="open", heat={10,55}, humidity={30,80},
y_min=10, y_max=180, soils={"podzol", "alpine_turf", "forest_floor", "grass"},
biomes={"hearth_birch_forest", "hearth_pine_taiga", "hearth_alpine_meadow"}, shade=0.4, litter=0.6},
hazel = {height=5, width=7, crown="coppice", heat={35,75}, humidity={35,75},
y_min=3, y_max=95, soils={"forest_floor", "grass", "soil"},
biomes={"hearth_oak_woodland", "hearth_temperate"}, shade=0.7, litter=0.8},
spruce = {height=10, width=5, crown="spire", heat={0,40}, humidity={40,85},
y_min=8, y_max=185, soils={"podzol", "forest_floor", "alpine_turf"},
biomes={"hearth_pine_taiga", "hearth_birch_forest"}, shade=0.85, litter=0.5},
crab_apple = {height=6,width=7,crown="round",heat={35,75},humidity={30,75},
y_min=3,y_max=100,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.65,litter=0.7},
wild_cherry = {height=9,width=7,crown="horizontal",heat={35,75},humidity={30,70},
y_min=4,y_max=120,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.6,litter=0.8},
field_maple = {height=7,width=9,crown="lobed_broad",heat={35,80},humidity={25,75},
y_min=3,y_max=110,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.8,litter=0.8},
}
function M.supports_species(species, surface)
local p = M.profiles[species]
local name = type(surface) == "table" and surface.name or surface
if not p or type(name) ~= "string" then return false end
name = name:gsub("^hearth:", "")
for _, soil in ipairs(p.soils) do if soil == name then return true end end
return false
end
-- x fastest, then y, then z. Empty cells have probability zero, so these
-- bounded full-block schematics cannot carve slopes or erase other trees.
function M.tree_schematic(species, canonical, stage)
local p = assert(M.profiles[species], "unknown tree species")
assert(stage == nil or stage == "mature" or stage == "young", "unknown tree stage")
local w, h = p.width, p.height
if stage == "young" then w, h = 3, (species == "pine" or species == "spruce") and 5 or 4 end
local c, data = math.floor(w / 2), {}
for z = 0, w - 1 do for y = 0, h - 1 do for x = 0, w - 1 do
local dx, dz = math.abs(x-c), math.abs(z-c)
local r = math.max(dx, dz)
local trunk = dx == 0 and dz == 0 and y < h-1
local leaf = false
if stage == "young" then
leaf = y >= h-3 and dx+dz <= (y == h-1 and 0 or 1)
elseif species == "oak" then
leaf = y >= 3 and r <= (y == h-1 and 1 or (y == 3 and 2 or 3))
and dx+dz <= (y == h-1 and 2 or 4)
trunk = trunk or (y == 3 and dx+dz <= 2 and (dx == 0 or dz == 0))
elseif species == "birch" then
leaf = y >= 4 and dx+dz <= (y == h-1 and 1 or 2)
elseif species == "pine" then
local radius = ({[2]=3,[3]=2,[4]=2,[5]=1,[6]=1,[7]=1,[8]=0})[y]
leaf = radius ~= nil and r <= radius and dx+dz <= radius+1
elseif species == "willow" then
leaf = (y >= 4 and r <= (y == h-1 and 2 or 3) and dx+dz <= 5)
or (y >= 1 and y <= 4 and r == 3 and (dx+dz)%2 == 0)
trunk = trunk or (y == 4 and dx+dz <= 3 and (dx == 0 or dz == 0))
elseif species == "alder" then
leaf = y >= 2 and dx+dz <= (y == h-1 and 1 or 3)
elseif species == "beech" then
leaf = y >= 3 and r <= (y == h-1 and 2 or 4) and dx+dz <= (y == h-1 and 3 or 6)
trunk = trunk or (y == 3 and dx+dz <= 3 and (dx == 0 or dz == 0))
elseif species == "aspen" then
leaf = y >= 3 and r <= (y == h-1 and 1 or 2) and dx+dz <= 2
elseif species == "rowan" then
leaf = y >= 2 and dx+dz <= (y == h-1 and 1 or 3) and not (dx == dz and r == 2)
trunk = trunk or (y == 2 and dx+dz == 1)
elseif species == "hazel" then
leaf = y >= 1 and dx+dz <= (y == h-1 and 2 or 4)
trunk = trunk or (y <= 2 and dx+dz == 1)
elseif species == "spruce" then
local radius = y >= 1 and math.min(2,math.floor((h-1-y)/3)) or -1
leaf = r <= radius and dx+dz <= radius+1
elseif species == "crab_apple" then
leaf = y>=2 and dx*dx+dz*dz <= (y==h-1 and 2 or (y==2 and 5 or 10))
trunk = trunk or (y==2 and dx+dz<=2 and (dx==0 or dz==0))
elseif species == "wild_cherry" then
leaf = y>=4 and r<=(y==h-1 and 1 or 3) and dx+dz<=4 and (y%2==0 or r<=1)
trunk = trunk or ((y==4 or y==6) and dz==0 and dx<=2)
elseif species == "field_maple" then
leaf = y>=2 and r<=(y==h-1 and 2 or 4) and dx+dz<=(y==h-1 and 3 or 5)
and not (dx==dz and r>=2)
trunk = trunk or (y==3 and dx+dz<=3 and (dx==0 or dz==0))
end
local stem = canonical and "log" or species .. "_log"
local leaves = canonical and "leaves" or species .. "_leaves"
data[#data+1] = {name=trunk and node(stem) or (leaf and node(leaves) or "air"),
prob=(trunk or leaf) and 255 or 0, param2=0}
end end end
return {size={x=w, y=h, z=w}, data=data}
end
-- Heat/humidity are Voronoi centers, not hard climate bounds. Altitude bands
-- are hard bounds; all climate points converge on one permanent snow biome.
M.biome_profiles = {
{name="hearth_temperate", top="grass", filler="soil", rock="limestone", heat=52, humidity=45, min=2, max=110},
{name="hearth_oak_woodland", top="forest_floor", filler="soil", rock="limestone", heat=65, humidity=62, min=3, max=110},
{name="hearth_birch_forest", top="forest_floor", filler="soil", rock="granite", heat=36, humidity=58, min=6, max=150},
{name="hearth_pine_taiga", top="podzol", filler="soil", rock="granite", heat=18, humidity=38, min=2, max=180},
{name="hearth_wet_forest", top="peat", filler="alluvium", rock="slate", heat=42, humidity=90, min=2, max=65},
{name="hearth_floodplain", top="alluvium", filler="alluvium", rock="sandstone", heat=68, humidity=90, min=2, max=8},
{name="hearth_dry_scrub", top="grass", filler="soil", rock="sandstone", heat=83, humidity=18, min=2, max=135},
{name="hearth_alpine_meadow", top="alpine_turf", filler="scree", rock="slate", heat=30, humidity=48, min=111, max=210},
{name="hearth_rocky_peaks", top="snow", filler="scree", rock="granite", heat=25, humidity=45, min=211, max=31000},
{name="hearth_riverbank", top="sand", filler="gravel", rock="sandstone", heat=50, humidity=50, min=-31000, max=1},
}
local registered = false
function M.register()
if registered then return M end
-- Fail closed on an integration collision instead of silently overriding
-- another owner's node definitions or publishing partly compatible art.
local function add(name, def)
assert(not (api.registered_nodes or {})[node(name)], "duplicate terrain node: " .. name)
def.tiles = def.tiles or {tile(name)}
register_node(name, def)
end
for _, rock in ipairs(M.rocks) do
add(rock, {description=rock:gsub("^%l", string.upper) .. " bedrock",
groups={cracky=3, stone=1, hearth_geology=1}})
add(rock .. "_bricks", {description=rock:gsub("^%l", string.upper) .. " masonry",
-- Brick uses a derived world texture, not an inventory silhouette.
groups={cracky=3, stone=1, hearth_building=1},
on_place=construction.place_building, after_place_node=construction.owner})
api.register_craft({type="shapeless", output=node(rock .. "_bricks") .. " 4", recipe={node(rock)}})
-- A pair preserves mass without shadowing the single-rock brick recipe.
api.register_craft({type="shapeless", output=node("stone") .. " 2", recipe={node(rock), node(rock)}})
end
for _, soil in ipairs({"forest_floor", "podzol", "alluvium", "peat", "alpine_turf"}) do
add(soil, {description=soil:gsub("_", " "):gsub("^%l", string.upper),
tiles={tile(soil), tile("soil"), tile("soil")},
groups={crumbly=3, soil=1, hearth_habitat=1}, drop=node("soil")})
end
for _, loose in ipairs({"gravel", "scree", "snow"}) do
add(loose, {description=loose:gsub("^%l", string.upper),
groups={crumbly=3, falling_node=loose == "snow" and nil or 1},
drop=loose == "snow" and node("snow") or node("loose_stone")})
end
for _, species in ipairs(M.species_order) do
local title = species:gsub("^%l", string.upper)
add(species .. "_log", {description=title .. " log (requires an axe)", paramtype2="facedir",
tiles={tile(species .. "_log_top"), tile(species .. "_log_top"), tile(species .. "_log_side")},
groups={tree=1, choppy=2, flammable=2, hearth_canopy=1}})
add(species .. "_leaves", {description=title .. " leaves", drawtype="allfaces_optional",
paramtype="light", sunlight_propagates=true, use_texture_alpha="clip", waving=1,
groups={snappy=3, leaves=1, flammable=2, hearth_canopy=1},
-- Species recruitment/seed nodes belong to the ecosystem module.
drop=node("sticks")})
add(species .. "_planks", {description=title .. " planks",
groups={choppy=3, wood=1, flammable=2}})
api.register_craft({type="shapeless", output=node(species .. "_planks") .. " 4", recipe={node(species .. "_log")}})
-- Separate slots, not a counted stack: one log must still split to planks.
api.register_craft({type="shapeless", output=node("log") .. " 2", recipe={node(species .. "_log"), node(species .. "_log")}})
api.register_craft({type="shapeless", output=node("planks"), recipe={node(species .. "_planks")}})
end
for _, resource in ipairs({{"quartz", "quartz_vein", "Quartz crystal"},
{"copper_ore", "copper_ore_vein", "Copper ore"}, {"coal", "coal_ore_vein", "Bituminous coal"}}) do
assert(not (api.registered_items or {})[node(resource[1])], "duplicate resource item")
api.register_craftitem(node(resource[1]), {description=resource[3],
inventory_image="hearth_item_" .. resource[1] .. ".png"})
add(resource[2], {description=resource[3] .. " seam (requires a pick)",
groups={cracky=3, stone=1}, drop=node(resource[1]) .. " 2"})
end
-- Non-forcing source aliases only. Existing game/mapgen bindings win.
for _, alias in ipairs({{"mapgen_stone", "stone"}, {"mapgen_water_source", "water_source"},
{"mapgen_river_water_source", "water_source"}}) do
if not (api.registered_aliases or {})[alias[1]] and not (api.registered_items or {})[alias[1]] then
api.register_alias(alias[1], node(alias[2]))
end
end
for _, p in ipairs(M.biome_profiles) do
api.register_biome({name=p.name, node_top=node(p.top), depth_top=1,
node_filler=node(p.filler), depth_filler=p.name == "hearth_rocky_peaks" and 1 or 3,
node_stone=node(p.rock), node_water=node("water_source"),
node_river_water=node("water_source"), node_riverbed=node("gravel"), depth_riverbed=2,
y_min=p.min, y_max=p.max, heat_point=p.heat, humidity_point=p.humidity,
vertical_blend=0})
end
-- Ordered geological replacement precedes ores: coherent deep intrusions,
-- then nested ore clusters restricted to their actual host lithologies.
-- v7 reselects biomes at lower altitude limits. Below the riverbank band
-- the initial host is sandstone, NOT the surface woodland's node_stone.
-- Explicit beds keep all deep resources reachable in that real engine case.
for _, bed in ipairs({{"granite", -31000, -80}, {"slate", -79, -24}, {"limestone", -23, -8}}) do
api.register_ore({name="hearth:" .. bed[1] .. "_bed", ore_type="stratum", ore=node(bed[1]),
-- A zero cluster size is valid for strata and avoids Ore::placeOre
-- skipping a one-node slice where a bed meets a mapchunk boundary.
wherein=names({"sandstone", "stone"}), y_min=bed[2], y_max=bed[3], clust_size=0,
clust_scarcity=1})
end
api.register_ore({name="hearth:basalt_intrusions", ore_type="blob", ore=node("basalt"),
wherein=names({"granite", "stone", "slate", "limestone", "sandstone"}), clust_scarcity=48*48*48,
clust_size=24, y_min=-31000, y_max=-24, noise_threshold=0.15,
noise_params={offset=0, scale=1, spread={x=96,y=64,z=96}, seed=7131, octaves=3, persistence=0.6}})
local function ore(name, hosts, distance, count, size, ymin, ymax)
api.register_ore({name="hearth:" .. name .. "_" .. tostring(ymax), ore_type="scatter",
ore=node(name), wherein=names(hosts), clust_scarcity=distance^3,
clust_num_ores=count, clust_size=size, y_min=ymin, y_max=ymax})
end
ore("quartz_vein", {"granite", "slate"}, 11, 6, 3, -31000, 160)
ore("copper_ore_vein", {"basalt", "limestone", "slate"}, 13, 5, 3, -31000, 32)
ore("coal_ore_vein", {"sandstone", "slate", "limestone"}, 10, 9, 3, -31000, 70)
ore("iron_ore_vein", {"basalt", "granite", "limestone", "stone"}, 11, 6, 3, -31000, -8)
ore("iron_ore_vein", {"basalt", "granite", "limestone", "stone"}, 19, 3, 2, -7, 85)
local surfaces = {}
for name in pairs(M.surfaces) do surfaces[#surfaces+1] = node(name) end
table.sort(surfaces)
local function deco(id, plant, ground, ratio, ymin, ymax, biomes, extra)
local def = {name="hearth:" .. id, deco_type="simple", place_on=ground,
sidelen=16, fill_ratio=ratio, y_min=ymin, y_max=ymax, biomes=biomes,
decoration=node(plant)}
for k, v in pairs(extra or {}) do def[k] = v end
api.register_decoration(def)
end
local wet = {"hearth_wet_forest", "hearth_floodplain", "hearth_riverbank"}
local woods = {"hearth_oak_woodland", "hearth_birch_forest", "hearth_pine_taiga", "hearth_wet_forest"}
local meadow = {"hearth_temperate", "hearth_dry_scrub", "hearth_alpine_meadow"}
local near_water = {spawn_by=names({"water_source", "water_flowing"}), num_spawn_by=1, check_offset=-1}
-- Establish real canopies before ground flora can occupy the trunk cell.
for _, species in ipairs(M.species_order) do
local p = M.profiles[species]
local def = {name="hearth:" .. species .. "_trees", deco_type="schematic", place_on=names(p.soils),
sidelen=16, fill_ratio=species == "willow" and 0.004 or 0.012,
y_min=p.y_min, y_max=p.y_max, biomes=p.biomes, schematic=M.tree_schematic(species),
rotation="random", flags="place_center_x,place_center_z", place_offset_y=1}
if p.riparian then
def.spawn_by, def.num_spawn_by, def.check_offset = near_water.spawn_by, 1, -1
end
api.register_decoration(def)
end
-- Canonical timber remains discoverable by legacy starting-resource probes.
api.register_decoration({name="hearth:temperate_trees", deco_type="schematic",
place_on=names({"grass"}), sidelen=16, fill_ratio=0.012,
y_min=3, y_max=110, biomes={"hearth_temperate"}, schematic=M.tree_schematic("oak", true),
rotation="random", flags="place_center_x,place_center_z", place_offset_y=1})
-- Sparse guaranteed-compatible gathering substrate, not guaranteed spawns.
-- No soil type can strand progression merely because grass is absent.
deco("loose_stone", "loose_stone", surfaces, 0.035, 1, 31000)
deco("sticks", "sticks", surfaces, 0.03, 1, 210)
deco("berry_bush", "berry_bush", names({"grass", "forest_floor", "podzol", "alluvium"}), 0.018, 2, 130)
deco("grain_patch", "grain_patch", names({"grass", "alluvium"}), 0.009, 2, 100,
{"hearth_temperate", "hearth_floodplain"})
-- Springs precede water-dependent plants: decorations see earlier writes,
-- so upland rushes can establish at the newly generated seep edges.
deco("seep_springs", "water_source", names({"grass", "forest_floor", "podzol"}), 0.0015, 3, 150,
{"hearth_temperate", "hearth_oak_woodland", "hearth_birch_forest", "hearth_pine_taiga"},
{place_offset_y=-1, flags="force_placement"})
deco("reeds", "reeds", names({"sand", "gravel", "alluvium", "peat", "grass", "forest_floor", "podzol"}), 0.09, 1, 150, nil, near_water)
deco("clay_bank", "clay_bank", names({"sand", "gravel", "alluvium"}), 0.04, -2, 8, wet, near_water)
deco("meadow_grasses", "meadow_grass", names({"grass", "alpine_turf"}), 0.15, 2, 210, meadow)
deco("meadow_flowers", "wildflowers", names({"grass", "alpine_turf"}), 0.025, 2, 210, meadow)
deco("woodland_ferns", "fern", names({"forest_floor", "peat", "alluvium"}), 0.06, 2, 145, woods)
deco("woodland_litter", "litter", names({"forest_floor", "podzol", "peat"}), 0.08, 2, 180, woods)
deco("woodland_fungi", "fungi", names({"forest_floor", "peat"}), 0.012, 2, 130, woods)
-- Three ordinary log cubes on their sides; top grain faces +X (12/4=3).
-- Random schematic rotation also permits Z-aligned logs. Never force-place.
api.register_decoration({name="hearth:woodland_deadwood", deco_type="schematic",
place_on=names({"forest_floor", "podzol", "peat"}), sidelen=16, fill_ratio=0.007,
y_min=2, y_max=160, biomes=woods, rotation="random", place_offset_y=1,
flags="place_center_x,place_center_z", schematic={size={x=3,y=1,z=1},data={
{name=node("deadwood"),prob=255,param2=12}, {name=node("deadwood"),prob=255,param2=12},
{name=node("deadwood"),prob=255,param2=12}}}})
deco("spring_foraging", "spring_greens_patch", names({"grass", "alluvium"}), 0.009, 2, 100,
{"hearth_temperate", "hearth_floodplain"})
deco("autumn_foraging", "autumn_mushrooms_patch", names({"forest_floor", "podzol"}), 0.012, 2, 145, woods)
-- Retain the old landmark contract without force-writing banks/water.
local weir = {size={x=7,y=3,z=5}, data={}}
for z=0,4 do for y=0,2 do for x=0,6 do
local edge = z == 0 or z == 4 or x == 0 or x == 6
local wall = edge and (y == 0 or ((z == 0 or z == 4) and y == 1)
or ((x == 0 or x == 6) and z >= 1 and z <= 3))
weir.data[#weir.data+1] = {name=wall and node("weir_stone") or "air", prob=wall and 255 or 0}
end end end
api.register_decoration({name="hearth:old_river_weir", deco_type="schematic",
place_on=names({"sand", "gravel", "alluvium"}), sidelen=16, fill_ratio=0.001,
y_min=0, y_max=8, biomes=wet, schematic=weir, rotation="random",
flags="place_center_x,place_center_z", place_offset_y=1,
spawn_by=near_water.spawn_by, num_spawn_by=1, check_offset=-1})
registered = true
return M
end
return M
end