feature updates
This commit is contained in:
223
scripts/game/ai/AIController.gd
Normal file
223
scripts/game/ai/AIController.gd
Normal file
@@ -0,0 +1,223 @@
|
||||
class_name AIController
|
||||
extends Node
|
||||
|
||||
## AIController - Coordinates AI player turns
|
||||
## Handles timing, action execution, and phase transitions
|
||||
|
||||
signal ai_action_started
|
||||
signal ai_action_completed
|
||||
signal ai_thinking(player_index: int)
|
||||
|
||||
var strategy: AIStrategy
|
||||
var game_state: GameState
|
||||
var player_index: int
|
||||
var is_processing: bool = false
|
||||
|
||||
# Reference to GameManager for executing actions
|
||||
var _game_manager: Node
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func setup(p_player_index: int, difficulty: AIStrategy.Difficulty, p_game_manager: Node) -> void:
|
||||
player_index = p_player_index
|
||||
_game_manager = p_game_manager
|
||||
|
||||
# Create appropriate strategy based on difficulty
|
||||
match difficulty:
|
||||
AIStrategy.Difficulty.EASY:
|
||||
strategy = EasyAI.new(player_index)
|
||||
AIStrategy.Difficulty.NORMAL:
|
||||
strategy = NormalAI.new(player_index)
|
||||
AIStrategy.Difficulty.HARD:
|
||||
strategy = HardAI.new(player_index)
|
||||
|
||||
|
||||
func set_game_state(state: GameState) -> void:
|
||||
game_state = state
|
||||
if strategy:
|
||||
strategy.set_game_state(state)
|
||||
|
||||
|
||||
## Called when it's the AI's turn to act in the current phase
|
||||
func process_turn() -> void:
|
||||
if is_processing:
|
||||
return
|
||||
|
||||
is_processing = true
|
||||
ai_thinking.emit(player_index)
|
||||
|
||||
# Add thinking delay
|
||||
var delay := strategy.get_thinking_delay()
|
||||
await get_tree().create_timer(delay).timeout
|
||||
|
||||
var phase := game_state.turn_manager.current_phase
|
||||
|
||||
match phase:
|
||||
Enums.TurnPhase.ACTIVE:
|
||||
# Active phase is automatic - no AI decision needed
|
||||
_pass_priority()
|
||||
|
||||
Enums.TurnPhase.DRAW:
|
||||
# Draw phase is automatic
|
||||
_pass_priority()
|
||||
|
||||
Enums.TurnPhase.MAIN_1, Enums.TurnPhase.MAIN_2:
|
||||
await _process_main_phase()
|
||||
|
||||
Enums.TurnPhase.ATTACK:
|
||||
await _process_attack_phase()
|
||||
|
||||
Enums.TurnPhase.END:
|
||||
# End phase is automatic
|
||||
_pass_priority()
|
||||
|
||||
is_processing = false
|
||||
ai_action_completed.emit()
|
||||
|
||||
|
||||
## Process main phase - play cards or pass
|
||||
func _process_main_phase() -> void:
|
||||
var max_actions := 10 # Prevent infinite loops
|
||||
var actions_taken := 0
|
||||
|
||||
while actions_taken < max_actions:
|
||||
var decision := strategy.decide_main_phase_action()
|
||||
|
||||
if decision.action == "pass":
|
||||
_pass_priority()
|
||||
break
|
||||
|
||||
elif decision.action == "play":
|
||||
var card: CardInstance = decision.card
|
||||
var success := await _try_play_card(card)
|
||||
|
||||
if not success:
|
||||
# Couldn't play - pass
|
||||
_pass_priority()
|
||||
break
|
||||
|
||||
# Small delay between actions
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
|
||||
actions_taken += 1
|
||||
|
||||
|
||||
## Try to play a card, handling CP generation if needed
|
||||
func _try_play_card(card: CardInstance) -> bool:
|
||||
var player := game_state.get_player(player_index)
|
||||
var cost := card.card_data.cost
|
||||
|
||||
# Check if we have enough CP
|
||||
var current_cp := player.cp_pool.get_total_cp()
|
||||
|
||||
if current_cp < cost:
|
||||
# Need to generate CP
|
||||
var needed := cost - current_cp
|
||||
var success := await _generate_cp(needed, card.card_data.elements)
|
||||
if not success:
|
||||
return false
|
||||
|
||||
# Try to play the card
|
||||
return _game_manager.try_play_card(card)
|
||||
|
||||
|
||||
## Generate CP by dulling backups or discarding cards
|
||||
func _generate_cp(needed: int, elements: Array) -> bool:
|
||||
var generated := 0
|
||||
var max_attempts := 20
|
||||
|
||||
while generated < needed and max_attempts > 0:
|
||||
var decision := strategy.decide_cp_generation({ "needed": needed - generated, "elements": elements })
|
||||
|
||||
if decision.is_empty():
|
||||
return false
|
||||
|
||||
if decision.action == "dull_backup":
|
||||
var backup: CardInstance = decision.card
|
||||
if _game_manager.dull_backup_for_cp(backup):
|
||||
generated += 1
|
||||
await get_tree().create_timer(0.2).timeout
|
||||
|
||||
elif decision.action == "discard":
|
||||
var discard_card: CardInstance = decision.card
|
||||
if _game_manager.discard_card_for_cp(discard_card):
|
||||
generated += 2
|
||||
await get_tree().create_timer(0.2).timeout
|
||||
|
||||
max_attempts -= 1
|
||||
|
||||
return generated >= needed
|
||||
|
||||
|
||||
## Process attack phase - declare attacks
|
||||
func _process_attack_phase() -> void:
|
||||
var attack_step := game_state.turn_manager.attack_step
|
||||
|
||||
match attack_step:
|
||||
Enums.AttackStep.PREPARATION, Enums.AttackStep.DECLARATION:
|
||||
await _process_attack_declaration()
|
||||
|
||||
Enums.AttackStep.BLOCK_DECLARATION:
|
||||
# This shouldn't happen - AI blocks are handled in opponent's turn
|
||||
_pass_priority()
|
||||
|
||||
Enums.AttackStep.DAMAGE_RESOLUTION:
|
||||
# Automatic
|
||||
_pass_priority()
|
||||
|
||||
|
||||
## Declare attacks with forwards
|
||||
func _process_attack_declaration() -> void:
|
||||
var max_attacks := 5
|
||||
var attacks_made := 0
|
||||
|
||||
while attacks_made < max_attacks:
|
||||
var decision := strategy.decide_attack_action()
|
||||
|
||||
if decision.action == "end_attacks":
|
||||
# End attack phase
|
||||
_game_manager.pass_priority()
|
||||
break
|
||||
|
||||
elif decision.action == "attack":
|
||||
var attacker: CardInstance = decision.card
|
||||
var success := _game_manager.declare_attack(attacker)
|
||||
|
||||
if success:
|
||||
attacks_made += 1
|
||||
# Wait for block decision or damage resolution
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
else:
|
||||
# Couldn't attack - end attacks
|
||||
_game_manager.pass_priority()
|
||||
break
|
||||
|
||||
|
||||
## Called when AI needs to decide on blocking
|
||||
func process_block_decision(attacker: CardInstance) -> void:
|
||||
if is_processing:
|
||||
return
|
||||
|
||||
is_processing = true
|
||||
ai_thinking.emit(player_index)
|
||||
|
||||
var delay := strategy.get_thinking_delay()
|
||||
await get_tree().create_timer(delay).timeout
|
||||
|
||||
var decision := strategy.decide_block_action(attacker)
|
||||
|
||||
if decision.action == "block":
|
||||
var blocker: CardInstance = decision.card
|
||||
_game_manager.declare_block(blocker)
|
||||
else:
|
||||
_game_manager.skip_block()
|
||||
|
||||
is_processing = false
|
||||
ai_action_completed.emit()
|
||||
|
||||
|
||||
func _pass_priority() -> void:
|
||||
_game_manager.pass_priority()
|
||||
190
scripts/game/ai/AIStrategy.gd
Normal file
190
scripts/game/ai/AIStrategy.gd
Normal file
@@ -0,0 +1,190 @@
|
||||
class_name AIStrategy
|
||||
extends RefCounted
|
||||
|
||||
## Base class for AI decision-making strategies
|
||||
## Subclasses implement different difficulty levels
|
||||
|
||||
enum Difficulty { EASY, NORMAL, HARD }
|
||||
|
||||
var difficulty: Difficulty
|
||||
var player_index: int
|
||||
var game_state: GameState
|
||||
|
||||
|
||||
func _init(p_difficulty: Difficulty, p_player_index: int) -> void:
|
||||
difficulty = p_difficulty
|
||||
player_index = p_player_index
|
||||
|
||||
|
||||
func set_game_state(state: GameState) -> void:
|
||||
game_state = state
|
||||
|
||||
|
||||
## Returns the player this AI controls
|
||||
func get_player() -> Player:
|
||||
return game_state.get_player(player_index)
|
||||
|
||||
|
||||
## Returns the opponent player
|
||||
func get_opponent() -> Player:
|
||||
return game_state.get_player(1 - player_index)
|
||||
|
||||
|
||||
## Called during Main Phase - decide what card to play or pass
|
||||
## Returns: { "action": "play", "card": CardInstance } or { "action": "pass" }
|
||||
func decide_main_phase_action() -> Dictionary:
|
||||
push_error("AIStrategy.decide_main_phase_action() must be overridden")
|
||||
return { "action": "pass" }
|
||||
|
||||
|
||||
## Called when CP is needed - decide how to generate CP
|
||||
## Returns: { "action": "discard", "card": CardInstance } or { "action": "dull_backup", "card": CardInstance }
|
||||
func decide_cp_generation(needed_cp: Dictionary) -> Dictionary:
|
||||
push_error("AIStrategy.decide_cp_generation() must be overridden")
|
||||
return {}
|
||||
|
||||
|
||||
## Called during Attack Phase - decide which forward to attack with
|
||||
## Returns: { "action": "attack", "card": CardInstance } or { "action": "end_attacks" }
|
||||
func decide_attack_action() -> Dictionary:
|
||||
push_error("AIStrategy.decide_attack_action() must be overridden")
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
|
||||
## Called during Block Declaration - decide how to block
|
||||
## Returns: { "action": "block", "card": CardInstance } or { "action": "skip" }
|
||||
func decide_block_action(attacker: CardInstance) -> Dictionary:
|
||||
push_error("AIStrategy.decide_block_action() must be overridden")
|
||||
return { "action": "skip" }
|
||||
|
||||
|
||||
## Get thinking delay range in seconds based on difficulty
|
||||
func get_thinking_delay() -> float:
|
||||
match difficulty:
|
||||
Difficulty.EASY:
|
||||
return randf_range(1.5, 2.5)
|
||||
Difficulty.NORMAL:
|
||||
return randf_range(1.0, 1.5)
|
||||
Difficulty.HARD:
|
||||
return randf_range(0.5, 1.0)
|
||||
return 1.0
|
||||
|
||||
|
||||
# ============ HELPER METHODS FOR SUBCLASSES ============
|
||||
|
||||
## Get all cards in hand that can be played (have enough CP or can generate CP)
|
||||
func get_playable_cards() -> Array[CardInstance]:
|
||||
var player := get_player()
|
||||
var playable: Array[CardInstance] = []
|
||||
|
||||
for card in player.hand.get_cards():
|
||||
if _can_afford_card(card):
|
||||
playable.append(card)
|
||||
|
||||
return playable
|
||||
|
||||
|
||||
## Check if a card can be afforded (either have CP or can generate it)
|
||||
func _can_afford_card(card: CardInstance) -> bool:
|
||||
var player := get_player()
|
||||
var cost := card.card_data.cost
|
||||
var elements := card.card_data.elements
|
||||
|
||||
# Check if we already have enough CP
|
||||
var current_cp := player.cp_pool.get_total_cp()
|
||||
if current_cp >= cost:
|
||||
# Check element requirements
|
||||
for element in elements:
|
||||
if player.cp_pool.get_cp(element) > 0 or player.cp_pool.get_cp(Enums.Element.NONE) > 0:
|
||||
return true
|
||||
# If no specific element needed (Light/Dark cards), any CP works
|
||||
if elements.is_empty():
|
||||
return true
|
||||
|
||||
# Check if we can generate enough CP
|
||||
var potential_cp := _calculate_potential_cp()
|
||||
return potential_cp >= cost
|
||||
|
||||
|
||||
## Calculate total CP we could generate (hand discards + backup dulls)
|
||||
func _calculate_potential_cp() -> int:
|
||||
var player := get_player()
|
||||
var total := player.cp_pool.get_total_cp()
|
||||
|
||||
# Each card in hand can be discarded for 2 CP
|
||||
total += player.hand.get_card_count() * 2
|
||||
|
||||
# Each active backup can be dulled for 1 CP
|
||||
for backup in player.field_backups.get_cards():
|
||||
if backup.state == Enums.CardState.ACTIVE:
|
||||
total += 1
|
||||
|
||||
return total
|
||||
|
||||
|
||||
## Get forwards that can attack
|
||||
func get_attackable_forwards() -> Array[CardInstance]:
|
||||
return get_player().get_attackable_forwards()
|
||||
|
||||
|
||||
## Get forwards that can block
|
||||
func get_blockable_forwards() -> Array[CardInstance]:
|
||||
return get_player().get_blockable_forwards()
|
||||
|
||||
|
||||
## Calculate a simple card value score
|
||||
func calculate_card_value(card: CardInstance) -> float:
|
||||
var data := card.card_data
|
||||
var value := 0.0
|
||||
|
||||
match data.type:
|
||||
Enums.CardType.FORWARD:
|
||||
# Forwards valued by power/cost ratio + abilities
|
||||
value = float(data.power) / float(max(data.cost, 1))
|
||||
if data.has_ability("Brave"):
|
||||
value *= 1.3
|
||||
if data.has_ability("First Strike"):
|
||||
value *= 1.2
|
||||
if data.has_ability("Haste"):
|
||||
value *= 1.4
|
||||
Enums.CardType.BACKUP:
|
||||
# Backups valued by utility (cost efficiency)
|
||||
value = 3.0 / float(max(data.cost, 1))
|
||||
Enums.CardType.SUMMON:
|
||||
# Summons valued by effect strength (approximated by cost)
|
||||
value = float(data.cost) * 0.8
|
||||
Enums.CardType.MONSTER:
|
||||
# Monsters similar to forwards
|
||||
value = float(data.power) / float(max(data.cost, 1))
|
||||
|
||||
return value
|
||||
|
||||
|
||||
## Evaluate board advantage (positive = we're ahead)
|
||||
func evaluate_board_state() -> float:
|
||||
var player := get_player()
|
||||
var opponent := get_opponent()
|
||||
|
||||
var score := 0.0
|
||||
|
||||
# Forward power advantage
|
||||
var our_power := 0
|
||||
for forward in player.field_forwards.get_cards():
|
||||
our_power += forward.get_power()
|
||||
|
||||
var their_power := 0
|
||||
for forward in opponent.field_forwards.get_cards():
|
||||
their_power += forward.get_power()
|
||||
|
||||
score += (our_power - their_power) / 1000.0
|
||||
|
||||
# Backup count advantage
|
||||
score += (player.field_backups.get_card_count() - opponent.field_backups.get_card_count()) * 2.0
|
||||
|
||||
# Hand size advantage
|
||||
score += (player.hand.get_card_count() - opponent.hand.get_card_count()) * 0.5
|
||||
|
||||
# Damage disadvantage (more damage = worse)
|
||||
score -= (player.get_damage_count() - opponent.get_damage_count()) * 3.0
|
||||
|
||||
return score
|
||||
71
scripts/game/ai/EasyAI.gd
Normal file
71
scripts/game/ai/EasyAI.gd
Normal file
@@ -0,0 +1,71 @@
|
||||
class_name EasyAI
|
||||
extends AIStrategy
|
||||
|
||||
## Easy AI - Makes suboptimal choices, sometimes skips good plays
|
||||
## Good for beginners learning the game
|
||||
|
||||
|
||||
func _init(p_player_index: int) -> void:
|
||||
super._init(Difficulty.EASY, p_player_index)
|
||||
|
||||
|
||||
func decide_main_phase_action() -> Dictionary:
|
||||
var playable := get_playable_cards()
|
||||
|
||||
if playable.is_empty():
|
||||
return { "action": "pass" }
|
||||
|
||||
# 30% chance to just pass even if we have playable cards
|
||||
if randf() < 0.3:
|
||||
return { "action": "pass" }
|
||||
|
||||
# Pick a random playable card (not optimal)
|
||||
var card: CardInstance = playable[randi() % playable.size()]
|
||||
return { "action": "play", "card": card }
|
||||
|
||||
|
||||
func decide_cp_generation(needed_cp: Dictionary) -> Dictionary:
|
||||
var player := get_player()
|
||||
|
||||
# Prefer dulling backups first (Easy AI doesn't optimize)
|
||||
for backup in player.field_backups.get_cards():
|
||||
if backup.state == Enums.CardState.ACTIVE:
|
||||
return { "action": "dull_backup", "card": backup }
|
||||
|
||||
# Discard a random card from hand
|
||||
var hand_cards := player.hand.get_cards()
|
||||
if not hand_cards.is_empty():
|
||||
var card: CardInstance = hand_cards[randi() % hand_cards.size()]
|
||||
return { "action": "discard", "card": card }
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
func decide_attack_action() -> Dictionary:
|
||||
var attackers := get_attackable_forwards()
|
||||
|
||||
if attackers.is_empty():
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
# 40% chance to not attack even if we can
|
||||
if randf() < 0.4:
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
# Pick a random attacker
|
||||
var attacker: CardInstance = attackers[randi() % attackers.size()]
|
||||
return { "action": "attack", "card": attacker }
|
||||
|
||||
|
||||
func decide_block_action(attacker: CardInstance) -> Dictionary:
|
||||
var blockers := get_blockable_forwards()
|
||||
|
||||
if blockers.is_empty():
|
||||
return { "action": "skip" }
|
||||
|
||||
# 50% chance to skip blocking even when possible
|
||||
if randf() < 0.5:
|
||||
return { "action": "skip" }
|
||||
|
||||
# Pick a random blocker (might not be optimal)
|
||||
var blocker: CardInstance = blockers[randi() % blockers.size()]
|
||||
return { "action": "block", "card": blocker }
|
||||
271
scripts/game/ai/HardAI.gd
Normal file
271
scripts/game/ai/HardAI.gd
Normal file
@@ -0,0 +1,271 @@
|
||||
class_name HardAI
|
||||
extends AIStrategy
|
||||
|
||||
## Hard AI - Optimal rule-based decisions with full board analysis
|
||||
## Considers multiple factors and makes the best available play
|
||||
|
||||
|
||||
func _init(p_player_index: int) -> void:
|
||||
super._init(Difficulty.HARD, p_player_index)
|
||||
|
||||
|
||||
func decide_main_phase_action() -> Dictionary:
|
||||
var playable := get_playable_cards()
|
||||
|
||||
if playable.is_empty():
|
||||
return { "action": "pass" }
|
||||
|
||||
var board_eval := evaluate_board_state()
|
||||
var player := get_player()
|
||||
var opponent := get_opponent()
|
||||
|
||||
# Analyze the best play considering multiple factors
|
||||
var best_card: CardInstance = null
|
||||
var best_score := -999.0
|
||||
|
||||
for card in playable:
|
||||
var score := _evaluate_play(card, board_eval, player, opponent)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_card = card
|
||||
|
||||
# Only play if the score is positive
|
||||
if best_score > 0 and best_card:
|
||||
return { "action": "play", "card": best_card }
|
||||
|
||||
return { "action": "pass" }
|
||||
|
||||
|
||||
func decide_cp_generation(needed_cp: Dictionary) -> Dictionary:
|
||||
var player := get_player()
|
||||
|
||||
# Prioritize dulling backups (they refresh next turn, no card loss)
|
||||
var backups_to_dull: Array[CardInstance] = []
|
||||
for backup in player.field_backups.get_cards():
|
||||
if backup.state == Enums.CardState.ACTIVE:
|
||||
backups_to_dull.append(backup)
|
||||
|
||||
if not backups_to_dull.is_empty():
|
||||
# Dull backups with least useful abilities first
|
||||
backups_to_dull.sort_custom(_compare_backup_utility)
|
||||
return { "action": "dull_backup", "card": backups_to_dull[0] }
|
||||
|
||||
# Discard cards - choose most expendable
|
||||
var hand_cards := player.hand.get_cards()
|
||||
if hand_cards.is_empty():
|
||||
return {}
|
||||
|
||||
# Evaluate each card for discard value
|
||||
var best_discard: CardInstance = null
|
||||
var lowest_value := 999.0
|
||||
|
||||
for card in hand_cards:
|
||||
var value := _evaluate_discard_value(card, player)
|
||||
if value < lowest_value:
|
||||
lowest_value = value
|
||||
best_discard = card
|
||||
|
||||
if best_discard:
|
||||
return { "action": "discard", "card": best_discard }
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
func decide_attack_action() -> Dictionary:
|
||||
var attackers := get_attackable_forwards()
|
||||
var opponent := get_opponent()
|
||||
|
||||
if attackers.is_empty():
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
var opponent_blockers := opponent.get_blockable_forwards()
|
||||
|
||||
# Calculate optimal attack order
|
||||
var attack_order := _calculate_attack_order(attackers, opponent_blockers, opponent)
|
||||
|
||||
if attack_order.is_empty():
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
# Return the best attack
|
||||
return { "action": "attack", "card": attack_order[0] }
|
||||
|
||||
|
||||
func decide_block_action(attacker: CardInstance) -> Dictionary:
|
||||
var blockers := get_blockable_forwards()
|
||||
var player := get_player()
|
||||
|
||||
if blockers.is_empty():
|
||||
return { "action": "skip" }
|
||||
|
||||
var attacker_power := attacker.get_power()
|
||||
var current_damage := player.get_damage_count()
|
||||
var would_be_lethal := current_damage >= 6
|
||||
|
||||
# Evaluate all blocking options
|
||||
var best_blocker: CardInstance = null
|
||||
var best_score := 0.0 # Baseline: skip blocking (score 0)
|
||||
|
||||
for blocker in blockers:
|
||||
var score := _evaluate_block(blocker, attacker, would_be_lethal)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_blocker = blocker
|
||||
|
||||
if best_blocker:
|
||||
return { "action": "block", "card": best_blocker }
|
||||
|
||||
return { "action": "skip" }
|
||||
|
||||
|
||||
func _evaluate_play(card: CardInstance, board_eval: float, player: Player, opponent: Player) -> float:
|
||||
var data := card.card_data
|
||||
var score := calculate_card_value(card)
|
||||
|
||||
match data.type:
|
||||
Enums.CardType.FORWARD:
|
||||
# Forwards more valuable when behind on board
|
||||
if board_eval < 0:
|
||||
score *= 1.5
|
||||
|
||||
# Extra value if opponent has no blockers
|
||||
if opponent.get_blockable_forwards().is_empty():
|
||||
score *= 1.3
|
||||
|
||||
# Consider if we already have 5 forwards (max)
|
||||
if player.field_forwards.get_card_count() >= 5:
|
||||
score *= 0.3
|
||||
|
||||
Enums.CardType.BACKUP:
|
||||
# Backups valuable for long game
|
||||
var backup_count := player.field_backups.get_card_count()
|
||||
if backup_count >= 5:
|
||||
score = -10.0 # Can't play more
|
||||
elif backup_count < 3:
|
||||
score *= 1.5 # Need more backups
|
||||
elif board_eval > 5:
|
||||
score *= 1.3 # Ahead, build infrastructure
|
||||
|
||||
Enums.CardType.SUMMON:
|
||||
# Summons are situational - evaluate based on current needs
|
||||
# This is simplified; real evaluation would check summon effects
|
||||
if board_eval < -3:
|
||||
score *= 1.4 # Need removal/utility when behind
|
||||
|
||||
Enums.CardType.MONSTER:
|
||||
# Similar to forwards but usually less efficient
|
||||
score *= 0.9
|
||||
|
||||
# Penalize expensive plays when low on cards
|
||||
if player.hand.get_card_count() <= 2 and data.cost >= 4:
|
||||
score *= 0.5
|
||||
|
||||
return score
|
||||
|
||||
|
||||
func _evaluate_discard_value(card: CardInstance, player: Player) -> float:
|
||||
var value := calculate_card_value(card)
|
||||
|
||||
# Duplicates in hand are less valuable
|
||||
var same_name_count := 0
|
||||
for hand_card in player.hand.get_cards():
|
||||
if hand_card.card_data.name == card.card_data.name:
|
||||
same_name_count += 1
|
||||
if same_name_count > 1:
|
||||
value *= 0.5
|
||||
|
||||
# High cost cards we can't afford soon are less valuable
|
||||
var potential_cp := _calculate_potential_cp()
|
||||
if card.card_data.cost > potential_cp:
|
||||
value *= 0.7
|
||||
|
||||
# Cards matching elements we don't have CP for are less valuable
|
||||
var has_element_match := false
|
||||
for element in card.card_data.elements:
|
||||
if player.cp_pool.get_cp(element) > 0:
|
||||
has_element_match = true
|
||||
break
|
||||
if not has_element_match and not card.card_data.elements.is_empty():
|
||||
value *= 0.8
|
||||
|
||||
return value
|
||||
|
||||
|
||||
func _calculate_attack_order(attackers: Array[CardInstance], blockers: Array[CardInstance], opponent: Player) -> Array[CardInstance]:
|
||||
var order: Array[CardInstance] = []
|
||||
var scores: Array[Dictionary] = []
|
||||
|
||||
for attacker in attackers:
|
||||
var score := _evaluate_attack_value(attacker, blockers, opponent)
|
||||
if score > 0:
|
||||
scores.append({ "card": attacker, "score": score })
|
||||
|
||||
# Sort by score descending
|
||||
scores.sort_custom(func(a, b): return a.score > b.score)
|
||||
|
||||
for entry in scores:
|
||||
order.append(entry.card)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
func _evaluate_attack_value(attacker: CardInstance, blockers: Array[CardInstance], opponent: Player) -> float:
|
||||
var score := 0.0
|
||||
var attacker_power := attacker.get_power()
|
||||
|
||||
# Base value for dealing damage
|
||||
score += 3.0
|
||||
|
||||
# Lethal damage is extremely valuable
|
||||
if opponent.get_damage_count() >= 6:
|
||||
score += 20.0
|
||||
|
||||
# Evaluate blocking scenarios
|
||||
var profitable_blocks := 0
|
||||
for blocker in blockers:
|
||||
var blocker_power := blocker.get_power()
|
||||
if blocker_power >= attacker_power:
|
||||
profitable_blocks += 1
|
||||
|
||||
if profitable_blocks == 0:
|
||||
# No profitable blocks - guaranteed damage
|
||||
score += 5.0
|
||||
else:
|
||||
# Risk of losing our forward
|
||||
score -= calculate_card_value(attacker) * 0.5
|
||||
|
||||
# Brave forwards can attack safely (don't dull)
|
||||
if attacker.card_data.has_ability("Brave"):
|
||||
score += 2.0
|
||||
|
||||
return score
|
||||
|
||||
|
||||
func _evaluate_block(blocker: CardInstance, attacker: CardInstance, would_be_lethal: bool) -> float:
|
||||
var blocker_power := blocker.get_power()
|
||||
var attacker_power := attacker.get_power()
|
||||
var score := 0.0
|
||||
|
||||
# If lethal, blocking is almost always correct
|
||||
if would_be_lethal:
|
||||
score += 15.0
|
||||
|
||||
# Do we kill the attacker?
|
||||
if blocker_power >= attacker_power:
|
||||
score += calculate_card_value(attacker)
|
||||
|
||||
# Do we lose our blocker?
|
||||
if attacker_power >= blocker_power:
|
||||
score -= calculate_card_value(blocker)
|
||||
|
||||
# First strike changes the calculation
|
||||
if blocker.card_data.has_ability("First Strike") and blocker_power >= attacker_power:
|
||||
# We kill them before they hit us
|
||||
score += calculate_card_value(blocker) * 0.5
|
||||
|
||||
return score
|
||||
|
||||
|
||||
func _compare_backup_utility(a: CardInstance, b: CardInstance) -> bool:
|
||||
# Lower utility = dull first
|
||||
# This is simplified; could check specific backup abilities
|
||||
return calculate_card_value(a) < calculate_card_value(b)
|
||||
161
scripts/game/ai/NormalAI.gd
Normal file
161
scripts/game/ai/NormalAI.gd
Normal file
@@ -0,0 +1,161 @@
|
||||
class_name NormalAI
|
||||
extends AIStrategy
|
||||
|
||||
## Normal AI - Balanced play using cost/power heuristics
|
||||
## Makes generally good decisions but doesn't deeply analyze
|
||||
|
||||
|
||||
func _init(p_player_index: int) -> void:
|
||||
super._init(Difficulty.NORMAL, p_player_index)
|
||||
|
||||
|
||||
func decide_main_phase_action() -> Dictionary:
|
||||
var playable := get_playable_cards()
|
||||
|
||||
if playable.is_empty():
|
||||
return { "action": "pass" }
|
||||
|
||||
# Sort by value (best cards first)
|
||||
playable.sort_custom(_compare_card_value)
|
||||
|
||||
# Consider board state - prioritize forwards if we're behind
|
||||
var board_eval := evaluate_board_state()
|
||||
|
||||
for card in playable:
|
||||
var card_type := card.card_data.type
|
||||
|
||||
# If behind on board, prioritize forwards
|
||||
if board_eval < -5.0 and card_type == Enums.CardType.FORWARD:
|
||||
return { "action": "play", "card": card }
|
||||
|
||||
# If ahead, might want backups for sustainability
|
||||
if board_eval > 5.0 and card_type == Enums.CardType.BACKUP:
|
||||
if get_player().field_backups.get_card_count() < 5:
|
||||
return { "action": "play", "card": card }
|
||||
|
||||
# Default: play the highest value card we can afford
|
||||
return { "action": "play", "card": playable[0] }
|
||||
|
||||
|
||||
func decide_cp_generation(needed_cp: Dictionary) -> Dictionary:
|
||||
var player := get_player()
|
||||
|
||||
# First, dull backups (they refresh next turn)
|
||||
for backup in player.field_backups.get_cards():
|
||||
if backup.state == Enums.CardState.ACTIVE:
|
||||
return { "action": "dull_backup", "card": backup }
|
||||
|
||||
# Then, discard lowest value card from hand
|
||||
var hand_cards := player.hand.get_cards()
|
||||
if hand_cards.is_empty():
|
||||
return {}
|
||||
|
||||
# Sort by value (lowest first for discard)
|
||||
var sorted_hand := hand_cards.duplicate()
|
||||
sorted_hand.sort_custom(_compare_card_value_reverse)
|
||||
|
||||
return { "action": "discard", "card": sorted_hand[0] }
|
||||
|
||||
|
||||
func decide_attack_action() -> Dictionary:
|
||||
var attackers := get_attackable_forwards()
|
||||
var opponent := get_opponent()
|
||||
|
||||
if attackers.is_empty():
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
# Get opponent's potential blockers
|
||||
var opponent_blockers := opponent.get_blockable_forwards()
|
||||
|
||||
# Evaluate each potential attacker
|
||||
var best_attacker: CardInstance = null
|
||||
var best_score := -999.0
|
||||
|
||||
for attacker in attackers:
|
||||
var score := _evaluate_attack(attacker, opponent_blockers, opponent)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_attacker = attacker
|
||||
|
||||
# Only attack if the score is positive (favorable)
|
||||
if best_score > 0 and best_attacker:
|
||||
return { "action": "attack", "card": best_attacker }
|
||||
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
|
||||
func decide_block_action(attacker: CardInstance) -> Dictionary:
|
||||
var blockers := get_blockable_forwards()
|
||||
var player := get_player()
|
||||
|
||||
if blockers.is_empty():
|
||||
return { "action": "skip" }
|
||||
|
||||
var attacker_power := attacker.get_power()
|
||||
|
||||
# Check if this attack would be lethal
|
||||
var current_damage := player.get_damage_count()
|
||||
var would_be_lethal := current_damage >= 6 # 7th damage loses
|
||||
|
||||
# Find best blocker
|
||||
var best_blocker: CardInstance = null
|
||||
var best_score := -999.0
|
||||
|
||||
for blocker in blockers:
|
||||
var blocker_power := blocker.get_power()
|
||||
var score := 0.0
|
||||
|
||||
# Would we win the trade?
|
||||
if blocker_power >= attacker_power:
|
||||
score += 5.0 # We kill their forward
|
||||
if attacker_power >= blocker_power:
|
||||
score -= calculate_card_value(blocker) # We lose our blocker
|
||||
|
||||
# If lethal, blocking is very important
|
||||
if would_be_lethal:
|
||||
score += 10.0
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_blocker = blocker
|
||||
|
||||
# Block if favorable or if lethal
|
||||
if best_score > 0 or would_be_lethal:
|
||||
if best_blocker:
|
||||
return { "action": "block", "card": best_blocker }
|
||||
|
||||
return { "action": "skip" }
|
||||
|
||||
|
||||
func _evaluate_attack(attacker: CardInstance, opponent_blockers: Array[CardInstance], opponent: Player) -> float:
|
||||
var score := 0.0
|
||||
var attacker_power := attacker.get_power()
|
||||
|
||||
# Base value: dealing damage is good
|
||||
score += 2.0
|
||||
|
||||
# Check if opponent can block profitably
|
||||
var can_be_blocked := false
|
||||
for blocker in opponent_blockers:
|
||||
if blocker.get_power() >= attacker_power:
|
||||
can_be_blocked = true
|
||||
score -= 3.0 # Likely to lose our forward
|
||||
break
|
||||
|
||||
# If unblockable damage, more valuable
|
||||
if not can_be_blocked:
|
||||
score += 3.0
|
||||
|
||||
# If this would be lethal damage (7th), very valuable
|
||||
if opponent.get_damage_count() >= 6:
|
||||
score += 10.0
|
||||
|
||||
return score
|
||||
|
||||
|
||||
func _compare_card_value(a: CardInstance, b: CardInstance) -> bool:
|
||||
return calculate_card_value(a) > calculate_card_value(b)
|
||||
|
||||
|
||||
func _compare_card_value_reverse(a: CardInstance, b: CardInstance) -> bool:
|
||||
return calculate_card_value(a) < calculate_card_value(b)
|
||||
Reference in New Issue
Block a user