feat(ai-specific): Implement new pathfinding and decision-making logic for wild creatures to improve environmental interactions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:53:21 -07:00
parent a98b23cfe8
commit a289fd2e65

View file

@ -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": [],
}