diff --git a/engine/src/modules/ecology/ecology_db.gd b/engine/src/modules/ecology/ecology_db.gd index c544d368..c537cddc 100644 --- a/engine/src/modules/ecology/ecology_db.gd +++ b/engine/src/modules/ecology/ecology_db.gd @@ -94,6 +94,7 @@ func add_species(data: Dictionary) -> int: "maturity_age": data.get("maturity_age", 5), "max_age": data.get("max_age", 50), "quality_up_threshold": data.get("quality_up_threshold", 20), + "migration_pattern": data.get("migration_pattern", ""), } _species[id] = row diff --git a/engine/src/modules/ecology/fauna.gd b/engine/src/modules/ecology/fauna.gd index 38c45db7..ce22547a 100644 --- a/engine/src/modules/ecology/fauna.gd +++ b/engine/src/modules/ecology/fauna.gd @@ -9,6 +9,8 @@ const LV_SUBSTEPS: int = 3 const PERTURBATION: float = 0.05 const MIN_VIABLE_POPULATION: int = 2 const QUALITY_MISMATCH_TIERS: int = 2 +## Seasonal migration occurs every N turns (approximate season length). +const SEASONAL_MIGRATION_INTERVAL: int = 12 const LOOT_DROP_CHANCE: Dictionary = {1: 0.0, 2: 0.0, 3: 0.25, 4: 0.60, 5: 1.0} const GRAZING_BY_SIZE: Dictionary = { "tiny": 0.005, "small": 0.01, "medium": 0.02, "large": 0.04, "huge": 0.08, @@ -24,7 +26,9 @@ func invalidate_region_cache() -> void: _biome_regions.clear() -func process_turn(game_map: Variant, ecology_db: Variant, seed: int) -> void: +func process_turn( + game_map: Variant, ecology_db: Variant, seed: int, turn_number: int = 0 +) -> void: rng.seed = seed var lp: Variant = DataLoader.get_land_fauna_params() var mp: Variant = DataLoader.get_marine_fauna_params() @@ -37,6 +41,8 @@ func process_turn(game_map: Variant, ecology_db: Variant, seed: int) -> void: _process_herbivore_grazing(game_map, ecology_db) _enforce_carrying_capacity(ecology_db, mp) _process_migration(game_map, ecology_db, mp) + if turn_number > 0 and turn_number % SEASONAL_MIGRATION_INTERVAL == 0: + _process_seasonal_migration(game_map, ecology_db) _process_lair_habitat(game_map, ecology_db, lp) @@ -333,7 +339,11 @@ func _find_target( if not game_map.tiles.has(c): continue var ct: Variant = game_map.tiles[c] - if not ct.is_water() and ct.biome_id != ob: + # Amphibious species can cross land/water boundaries + var habitat: String = sp.get("habitat", "terrestrial") + if habitat == "amphibious": + pass # amphibious can go anywhere (land or water) + elif not ct.is_water() and ct.biome_id != ob: continue if _is_whale(sp) and mp != null: if not ct.is_water() or ct.depth_from_coast < mp.whale_min_depth or ct.depth_from_coast > mp.whale_max_depth or ct.quality < mp.whale_min_quality: @@ -387,6 +397,58 @@ func _abandon_lair(pos: Vector2i, tile: Variant, ecology_db: Variant) -> void: EventBus.lair_abandoned.emit(pos) +# -- Seasonal migration (aerial herd/swarm move to warmer biome) -- + +func _process_seasonal_migration(game_map: Variant, ecology_db: Variant) -> void: + ## Seasonal migrants move toward warmer tiles within migration_range. + ## Simulates flying herds/swarms following temperature gradients. + var migrated: Dictionary = {} # creature_id -> true (prevent double-move) + for pos: Vector2i in game_map.tiles: + var creatures: Array = ecology_db.get_creatures_on_tile(pos.x, pos.y) + for c: Dictionary in creatures: + var cid: int = c.get("id", -1) + if migrated.has(cid): + continue + var sp: Dictionary = ecology_db.get_species(c.get("species_id", -1)) + if sp.is_empty(): + continue + if sp.get("migration_pattern", "") != "seasonal": + continue + var mrange: int = sp.get("migration_range", 3) + var target: Vector2i = _find_seasonal_target(pos, sp, game_map, ecology_db, mrange) + if target != Vector2i(-1, -1) and target != pos: + ecology_db.update_creature(cid, { + "tile_col": target.x, "tile_row": target.y, + }) + migrated[cid] = true + + +func _find_seasonal_target( + origin: Vector2i, sp: Dictionary, game_map: Variant, + ecology_db: Variant, search_range: int, +) -> Vector2i: + ## Find the warmest tile within range that has capacity for this species. + var otile: Variant = game_map.tiles.get(origin) + if otile == null: + return Vector2i(-1, -1) + var best: Vector2i = Vector2i(-1, -1) + var best_temp: float = otile.temperature + for r: int in range(1, search_range + 1): + for c: Vector2i in HexUtilsScript.hex_ring(origin, r): + if not game_map.tiles.has(c): + continue + var ct: Variant = game_map.tiles[c] + if ct.temperature <= best_temp: + continue + var count: int = ecology_db.get_creature_count_on_tile(c.x, c.y) + var cap: float = sp.get("carrying_capacity", 10.0) + if float(count) >= cap: + continue + best_temp = ct.temperature + best = c + return best + + # -- Biome region cache (flood-fill connected same-biome tiles) -- func _rebuild_biome_regions(game_map: Variant) -> void: diff --git a/engine/src/modules/ecology/water_body_finder.gd b/engine/src/modules/ecology/water_body_finder.gd index ee3af766..bb28db22 100644 --- a/engine/src/modules/ecology/water_body_finder.gd +++ b/engine/src/modules/ecology/water_body_finder.gd @@ -75,15 +75,19 @@ static func identify_water_bodies( # Store in ecology_db ecology_db.add_water_body(body.id, body.type, body.size, body.tiles.size()) - # Set water_body_id on each tile in this body + # Set water_body_id and water_body_type on each tile for tile_pos: Vector2i in body.tiles: var tile: Variant = game_map.tiles.get(tile_pos) if tile != null: tile.water_body_id = body.id + tile.water_body_type = body.type # Compute depth_from_coast for all water tiles _compute_depth_from_coast(game_map, water_bodies, ecology_db) + # Detect river mouths: ocean tiles at depth <= 1 adjacent to river tiles + _detect_river_mouths(game_map, water_bodies) + return water_bodies @@ -152,3 +156,42 @@ static func _compute_depth_from_coast( # Store in ecology_db ecology_db.add_water_body_tile(body_id, pos.x, pos.y, depth) + + +## Detect river mouths: ocean/sea tiles at depth <= 1 with adjacent river tiles. +## Sets is_river_mouth = true on qualifying tiles. +static func _detect_river_mouths( + game_map: Variant, + water_bodies: Array, +) -> void: + # Build set of river tile positions + var river_positions: Dictionary = {} + for wb: Variant in water_bodies: + if wb.type == "river": + for pos: Vector2i in wb.tiles: + river_positions[pos] = true + + # Also consider land tiles with river_edges as river-adjacent + for pos: Vector2i in game_map.tiles: + var tile: Variant = game_map.tiles[pos] + if not tile.river_edges.is_empty(): + river_positions[pos] = true + + if river_positions.is_empty(): + return + + # Check ocean tiles at depth <= 1 for river adjacency + for wb: Variant in water_bodies: + if wb.type != "ocean": + continue + for pos: Vector2i in wb.tiles: + var tile: Variant = game_map.tiles.get(pos) + if tile == null or tile.depth_from_coast > 1: + continue + var nbs: Array[Vector2i] = ( + game_map.get_valid_neighbor_positions(pos) + ) + for nb: Vector2i in nbs: + if nb in river_positions: + tile.is_river_mouth = true + break