feat(world): Introduce procedural biome generation, species traits, and dynamic narrative flavor text for world simulation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 00:29:34 -07:00
parent 2e1e38a133
commit da7a90f646
3 changed files with 117 additions and 18 deletions

View file

@ -1,9 +1,8 @@
class_name BiomeClassifier
extends RefCounted
## Classifies tiles into biome_id from substrate + climate + flora state.
## Implements ~5 proof biomes for M2a, remainder return "unclassified".
const WaterBodyScript = preload("res://engine/src/models/world/water_body.gd")
## Covers all 26 biomes: 8 aquatic, 4 tropical, 5 temperate, 3 cold,
## 5 elevation, 1 subterranean. No fallback/unclassified returns.
## Main classification entry point. tile is Variant (Tile RefCounted).
@ -12,10 +11,13 @@ static func classify(tile: Variant) -> String:
return _classify_aquatic(tile)
if _get_substrate(tile) == "volcanic":
return "volcanic"
# Subterranean check before land — cave tiles get cave biome regardless of surface climate
if _has_cave(tile):
return "subterranean"
return _classify_land(tile)
## Aquatic classification: water body type + temperature + depth.
## Aquatic classification: water body type + temperature + depth + adjacency flags.
static func _classify_aquatic(tile: Variant) -> String:
var wb_type: String = _get_water_body_type(tile)
@ -30,18 +32,27 @@ static func _classify_aquatic(tile: Variant) -> String:
var depth: int = _get_depth(tile)
var temp: float = _get_temperature(tile)
# Estuary: river mouth meeting ocean — pre-computed flag on tile
if _is_river_mouth(tile) and depth <= 1:
return "estuary"
# Mangrove: tropical + coastal wetland substrate — check substrate override
# Mangrove tiles have wetland substrate adjacent to ocean with warm temp
if _get_substrate(tile) == "wetland" and temp > 0.55:
return "mangrove"
# Coral reef: tropical shallow ocean
if temp > 0.55 and depth <= 2:
return "coral_reef"
# Shallow vs deep ocean by depth from coast
if depth <= 3:
return "shallow_ocean"
if depth > 3:
return "deep_ocean"
return "shallow_ocean"
return "deep_ocean"
## Land classification: elevation x temperature x moisture x canopy.
## M2a proof biomes: temperate_forest, temperate_grassland + basic elevation/tropical.
## Evaluated in priority order: wetland override → elevation → tropical → temperate → cold.
static func _classify_land(tile: Variant) -> String:
var temp: float = _get_temperature(tile)
var moisture: float = _get_moisture(tile)
@ -82,7 +93,7 @@ static func _classify_land(tile: Variant) -> String:
return "savanna"
return "desert"
# Temperate proof biomes
# Temperate
if temp > 0.25:
if canopy > 0.5:
return "temperate_forest"
@ -90,7 +101,7 @@ static func _classify_land(tile: Variant) -> String:
return "temperate_grassland"
return "chaparral"
# Cold biomes
# Cold
if temp > 0.1:
if canopy > 0.3:
return "boreal_forest"
@ -152,3 +163,15 @@ static func _get_canopy(tile: Variant) -> float:
if "canopy_cover" in tile:
return tile.canopy_cover
return 0.0
static func _is_river_mouth(tile: Variant) -> bool:
if "is_river_mouth" in tile:
return tile.is_river_mouth
return false
static func _has_cave(tile: Variant) -> bool:
if "has_cave" in tile:
return tile.has_cave
return false

View file

@ -27,6 +27,13 @@ const BIOME_PREFIXES: Dictionary = {
"chaparral": ["Brush", "Dry", "Thorn", "Smoke", "Amber"],
"coral_reef": ["Reef", "Pearl", "Bright", "Shell", "Surge"],
"volcanic": ["Obsidian", "Cinder", "Ash", "Molten", "Slag"],
"estuary": ["Brackish", "Marsh", "Delta", "Silt", "Murk"],
"pond": ["Stagnant", "Puddle", "Scum", "Shallow", "Murk"],
"river": ["Current", "Rapid", "Eddy", "Stone", "Rush"],
"mangrove": ["Root", "Tangle", "Brackish", "Stilted", "Brine"],
"cloud_forest": ["Mist", "Veil", "Drip", "Shroud", "Dew"],
"permanent_ice": ["Glacier", "Null", "White", "Eternal", "Rime"],
"subterranean": ["Pale", "Crystal", "Cave", "Blind", "Hollow"],
}
# locomotion+diet+size → archetype name options
@ -79,6 +86,24 @@ const BIOME_MOTIFS: Dictionary = {
],
"tundra": ["thick white pelt", "frost crystals", "wind-scarred hide", "pale eyes"],
"swamp": ["rot-stained hide", "fungal growths", "bile-green tones", "murk camouflage"],
"bog": ["peat-dark hide", "mist-damp fur", "sour-water stains", "lichen patches"],
"tropical_rainforest": ["rot-stained markings", "vine-wrapped limbs", "iridescent plumage", "fungal patches"],
"tropical_dry_forest": ["cracked bark hide", "ochre dust coating", "thorn protrusions", "dry leaf camouflage"],
"savanna": ["dust-caked hide", "sun-bleached mane", "ember-gold eyes", "heat-cracked plates"],
"montane_forest": ["stone-grey hide", "ridge-scarred flanks", "cloud-damp fur", "iron-hard hooves"],
"chaparral": ["smoke-stained fur", "thorn scratches", "dry brush camouflage", "amber resin patches"],
"alpine_meadow": ["wind-torn mane", "rime-crusted horns", "crest markings", "cold-hardened hide"],
"alpine_tundra": ["frost-sharded scales", "gale-worn hide", "bare rock camouflage", "scree-colored plates"],
"coral_reef": ["iridescent scales", "anemone frills", "bright warning colors", "shell fragments"],
"estuary": ["mud-streaked hide", "brackish slime coat", "reed camouflage", "silted gills"],
"pond": ["algae-coated skin", "stagnant water film", "dull coloration", "silt deposits"],
"river": ["streamlined body", "smooth pebble markings", "current-adapted fins", "stone-grey tones"],
"mangrove": ["root-bark texture", "salt-stained hide", "tangled growths", "brackish sheen"],
"cloud_forest": ["mist-beaded fur", "dripping moss mantle", "veil-like frills", "dew-bright eyes"],
"permanent_ice": ["glacier-blue veins", "frost armor plates", "snow-crystal growths", "rime-locked joints"],
"polar_desert": ["ice-crusted hide", "translucent frost plates", "snow-blind pale eyes", "void-white fur"],
"subterranean": ["translucent skin", "crystalline growths", "eyeless face", "pheromone sacs"],
"volcanic": ["obsidian plates", "ember-glow cracks", "smoke wisps", "ash-coated hide"],
}
# Trait → visual motif overlays
@ -120,6 +145,17 @@ const LANDMARK_ARCHETYPES: Dictionary = {
"desert": ["Sands", "Wastes", "Dunes"],
"tundra": ["Reach", "Expanse", "Waste"],
"lake": ["Depths", "Basin", "Mirror"],
"estuary": ["Delta", "Confluence", "Mouth"],
"pond": ["Pool", "Puddle", "Basin"],
"river": ["Falls", "Bend", "Narrows"],
"mangrove": ["Tangle", "Roots", "Maze"],
"tropical_dry_forest": ["Thicket", "Grove", "Brake"],
"chaparral": ["Scrub", "Brake", "Thicket"],
"cloud_forest": ["Veil", "Mist", "Canopy"],
"permanent_ice": ["Glacier", "Sheet", "Void"],
"polar_desert": ["Waste", "Expanse", "Null"],
"volcanic": ["Caldera", "Vent", "Flow"],
"subterranean": ["Cavern", "Grotto", "Abyss"],
}

View file

@ -24,15 +24,20 @@ const TARGET_SPECIES_MIN: int = 8
const TARGET_SPECIES_MAX: int = 12
const MAX_GENERATION_ATTEMPTS: int = 100
## Amphibious weight boost multiplier when tile is adjacent to water.
const AMPHIBIOUS_ADJACENCY_BOOST: float = 2.5
## Generate species appropriate for a biome and tile quality.
## biome_trait_weights: Dictionary from biome_trait_weights.json for this biome.
## adjacent_to_water: true if this tile neighbors a water tile (boosts amphibious).
## Returns array of valid, quality-filtered TraitSets.
static func generate(
_biome_id: String,
tile_quality: int,
seed: int,
biome_trait_weights: Dictionary = {},
adjacent_to_water: bool = false,
) -> Array:
var rng := RandomNumberGenerator.new()
rng.seed = seed
@ -40,6 +45,11 @@ static func generate(
var quality_clamped: int = clampi(tile_quality, 1, 5)
var quality_probs: Dictionary = SPAWN_PROBABILITY.get(quality_clamped, SPAWN_PROBABILITY[3])
# Boost amphibious weight when tile is adjacent to water
var effective_weights: Dictionary = biome_trait_weights.duplicate(true)
if adjacent_to_water:
_boost_amphibious_weights(effective_weights)
var results: Array = []
var seen_hashes: Dictionary = {}
var attempts: int = 0
@ -48,13 +58,13 @@ static func generate(
attempts += 1
var traits := TraitSetScript.new()
traits.size = _roll_trait(rng, "size", biome_trait_weights)
traits.diet = _roll_trait(rng, "diet", biome_trait_weights)
traits.habitat = _roll_trait(rng, "habitat", biome_trait_weights)
traits.locomotion = _roll_trait(rng, "locomotion", biome_trait_weights)
traits.reproduction = _roll_trait(rng, "reproduction", biome_trait_weights)
traits.thermal = _roll_trait(rng, "thermal", biome_trait_weights)
traits.social = _roll_trait(rng, "social", biome_trait_weights)
traits.size = _roll_trait(rng, "size", effective_weights)
traits.diet = _roll_trait(rng, "diet", effective_weights)
traits.habitat = _roll_trait(rng, "habitat", effective_weights)
traits.locomotion = _roll_trait(rng, "locomotion", effective_weights)
traits.reproduction = _roll_trait(rng, "reproduction", effective_weights)
traits.thermal = _roll_trait(rng, "thermal", effective_weights)
traits.social = _roll_trait(rng, "social", effective_weights)
if not traits.validate():
continue
@ -129,3 +139,33 @@ static func _default_trait(category: String) -> String:
return "solitary"
_:
return ""
## Boost amphibious habitat weight when tile borders water.
## Modifies the weights dictionary in place.
static func _boost_amphibious_weights(weights: Dictionary) -> void:
var habitat_w: Dictionary = weights.get("habitat", {})
if habitat_w.is_empty():
return
var current: float = float(habitat_w.get("amphibious", 0))
if current <= 0.0:
# If no amphibious weight, inject a baseline
habitat_w["amphibious"] = 15
else:
habitat_w["amphibious"] = int(current * AMPHIBIOUS_ADJACENCY_BOOST)
# Also boost swimming locomotion slightly for water-adjacent tiles
var loco_w: Dictionary = weights.get("locomotion", {})
if not loco_w.is_empty():
var swim: float = float(loco_w.get("swimming", 0))
if swim > 0.0:
loco_w["swimming"] = int(swim * 1.5)
## Determine migration pattern for a species based on traits.
## Returns "seasonal" for aerial herd/swarm species, null otherwise.
static func get_migration_pattern(traits: Variant) -> Variant:
if traits.habitat != "aerial" and traits.locomotion != "flying":
return null
if traits.social == "herd" or traits.social == "swarm":
return "seasonal"
return null