diff --git a/engine/src/models/world/biome_classifier.gd b/engine/src/models/world/biome_classifier.gd index 16f776aa..114c6c81 100644 --- a/engine/src/models/world/biome_classifier.gd +++ b/engine/src/models/world/biome_classifier.gd @@ -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 diff --git a/engine/src/models/world/flavor_generator.gd b/engine/src/models/world/flavor_generator.gd index 3e591850..dc6b8457 100644 --- a/engine/src/models/world/flavor_generator.gd +++ b/engine/src/models/world/flavor_generator.gd @@ -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"], } diff --git a/engine/src/models/world/species_generator.gd b/engine/src/models/world/species_generator.gd index 189ee1fb..fd8c1d69 100644 --- a/engine/src/models/world/species_generator.gd +++ b/engine/src/models/world/species_generator.gd @@ -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