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:
parent
a98b23cfe8
commit
a289fd2e65
1 changed files with 236 additions and 0 deletions
|
|
@ -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": [],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue