diff --git a/engine/src/modules/ai/wild_creature_ai.gd b/engine/src/modules/ai/wild_creature_ai.gd index 8ce7027a..501e5697 100644 --- a/engine/src/modules/ai/wild_creature_ai.gd +++ b/engine/src/modules/ai/wild_creature_ai.gd @@ -1,2 +1,238 @@ class_name WildCreatureAI extends RefCounted +## Governs wild creature behavior each turn. +## Wild creatures (owner == -1) guard their home NPC building (lair), attack player +## units within detection radius, and roam within a leash radius. +## Called by TurnManager during the wild creatures phase. + +const UnitScript = preload("res://engine/src/entities/unit.gd") +const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd") +const PathfinderScript = preload("res://engine/src/map/pathfinder.gd") +const UnitManagerScript = preload("res://engine/src/modules/management/unit_manager.gd") + +const DEFAULT_DETECTION_RADIUS: int = 4 +const DEFAULT_LEASH_RADIUS: int = 5 +const ROAM_CHANCE: int = 40 + +var _rng: RandomNumberGenerator = RandomNumberGenerator.new() +var _unit_manager: RefCounted + + +func _init(unit_manager: RefCounted) -> void: + _unit_manager = unit_manager + _rng.randomize() + + +func process_wild_turn(game_map: RefCounted) -> void: + var primary_layer: Dictionary = GameState.get_primary_layer() + if primary_layer.is_empty(): + return + + var wild_units: Array = [] + for unit_ref: Variant in primary_layer.get("units", []): + if unit_ref is UnitScript and unit_ref.owner == -1 and unit_ref.is_alive(): + wild_units.append(unit_ref) + + var wilds_cfg: Dictionary = _get_wilds_config() + var detection_radius: int = wilds_cfg.get("detection_radius", DEFAULT_DETECTION_RADIUS) + var leash_radius: int = wilds_cfg.get("roaming_leash_radius", DEFAULT_LEASH_RADIUS) + + for unit: RefCounted in wild_units: + unit.refresh_turn() + _act(unit, game_map, detection_radius, leash_radius) + + +func spawn_initial_creatures(game_map: RefCounted) -> void: + ## Spawn one tier-1 creature at every lair NPC building. + var wilds_cfg: Dictionary = _get_wilds_config() + var lair_types: Array = wilds_cfg.get("lair_types", []) + var primary_layer: Dictionary = GameState.get_primary_layer() + if primary_layer.is_empty(): + return + + for b: Variant in GameState.npc_buildings: + var type_id: String = b.type_id + # Skip non-lair buildings (villages, ruins) + if type_id == "village" or type_id == "ruin": + continue + + var tier1_pool: Array = _get_tier_pool(type_id, "tier_1", lair_types) + if tier1_pool.is_empty(): + continue + + var unit_type_id: String = tier1_pool[_rng.randi() % tier1_pool.size()] + var unit_data: Dictionary = DataLoader.get_unit(unit_type_id) + if unit_data.is_empty(): + continue + + var unit: RefCounted = UnitScript.new() + unit.id = "wild_%d" % _rng.randi() + unit.type_id = unit_type_id + unit.owner = -1 + unit.position = b.position + unit.max_hp = unit_data.get("hp", 8) + unit.hp = unit.max_hp + unit.movement_remaining = unit_data.get("movement", 2) + unit.has_attacked = false + + primary_layer.get("units", []).append(unit) + EventBus.wild_creature_spawned.emit(unit, b.position) + + +func _act( + unit: RefCounted, + game_map: RefCounted, + detection_radius: int, + leash_radius: int, +) -> void: + if unit.movement_remaining <= 0: + return + + var home_pos: Vector2i = _find_nearest_lair(unit.position, leash_radius + 2) + var target_pos: Variant = _find_attack_target(unit, detection_radius) + + if target_pos != null: + _move_toward(unit, target_pos, game_map) + elif _is_outside_leash(unit.position, home_pos, leash_radius): + _move_toward(unit, home_pos, game_map) + elif _rng.randi_range(1, 100) <= ROAM_CHANCE: + _roam(unit, home_pos, game_map, leash_radius) + + +func _find_attack_target( + unit: RefCounted, + detection_radius: int, +) -> Variant: + var primary_layer: Dictionary = GameState.get_primary_layer() + var candidates: Array[Dictionary] = [] + + for other: Variant in primary_layer.get("units", []): + if not other is UnitScript: + continue + if other.owner < 0: + continue + var dist: int = HexUtilsScript.hex_distance(unit.position, other.position) + if dist <= detection_radius: + candidates.append({"pos": other.position, "dist": dist}) + + if candidates.is_empty(): + return null + + candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a["dist"] < b["dist"] + ) + return candidates[0]["pos"] + + +func _find_nearest_lair(from_pos: Vector2i, search_radius: int) -> Vector2i: + ## Find nearest lair NPC building within search_radius via GameState. + var best_pos: Vector2i = from_pos + var best_dist: int = 999999 + + for b: Variant in GameState.npc_buildings: + if b.type_id == "village" or b.type_id == "ruin": + continue + var dist: int = HexUtilsScript.hex_distance(from_pos, b.position) + if dist <= search_radius and dist < best_dist: + best_dist = dist + best_pos = b.position + + return best_pos + + +func _is_outside_leash( + unit_pos: Vector2i, home_pos: Vector2i, leash_radius: int +) -> bool: + return HexUtilsScript.hex_distance(unit_pos, home_pos) > leash_radius + + +func _move_toward( + unit: RefCounted, + target: Vector2i, + game_map: RefCounted, +) -> void: + var pathfinder: RefCounted = PathfinderScript.new(game_map) + var path: Array[Vector2i] = pathfinder.find_path( + unit.position, target, unit.movement_remaining, unit.is_flying() + ) + + if path.size() < 2: + return + + var next_pos: Vector2i = path[1] + + var units_there: Array = _unit_manager.get_units_at(next_pos) + for other: Variant in units_there: + if other is UnitScript and other.owner >= 0: + return + + var from_pos: Vector2i = unit.position + var tile: Variant = game_map.get_tile(next_pos) + if tile == null: + return + + var cost: int = tile.get_movement_cost() + if unit.is_flying(): + cost = 1 + + if unit.movement_remaining < cost: + return + + unit.movement_remaining -= cost + unit.position = next_pos + EventBus.unit_moved.emit(unit, from_pos, next_pos) + + +func _roam( + unit: RefCounted, + home_pos: Vector2i, + game_map: RefCounted, + leash_radius: int, +) -> void: + var neighbors: Array[Vector2i] = HexUtilsScript.get_neighbors(unit.position) + var valid: Array[Vector2i] = [] + + for neighbor: Vector2i in neighbors: + if not game_map.is_valid_position(neighbor): + continue + var tile: Variant = game_map.get_tile(neighbor) + if tile == null or tile.is_water(): + continue + if HexUtilsScript.hex_distance(neighbor, home_pos) > leash_radius: + continue + var units_there: Array = _unit_manager.get_units_at(neighbor) + var has_player_unit: bool = false + for u: Variant in units_there: + if u is UnitScript and u.owner >= 0: + has_player_unit = true + break + if not has_player_unit: + valid.append(neighbor) + + if valid.is_empty(): + return + + var dest: Vector2i = valid[_rng.randi() % valid.size()] + var from_pos: Vector2i = unit.position + unit.movement_remaining -= 1 + unit.position = dest + EventBus.unit_moved.emit(unit, from_pos, dest) + + +func _get_tier_pool(lair_type_id: String, tier: String, lair_types: Array) -> Array: + for lt: Dictionary in lair_types: + if lt.get("id", "") == lair_type_id: + var spawn_pool: Dictionary = lt.get("spawn_pool", {}) + return spawn_pool.get(tier, []) + return [] + + +func _get_wilds_config() -> Dictionary: + var data: Dictionary = DataLoader.get_wilds_config() + if not data.is_empty(): + return data + return { + "detection_radius": DEFAULT_DETECTION_RADIUS, + "roaming_leash_radius": DEFAULT_LEASH_RADIUS, + "lair_types": [], + }