feature updates

This commit is contained in:
2026-02-02 16:28:53 -05:00
parent bf9aa3fa23
commit 44c06530ac
83 changed files with 282641 additions and 11251 deletions

View 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()

View 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
View 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
View 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
View 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)