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,798 @@
class_name AbilitySystem
extends Node
## AbilitySystem - Central coordinator for ability processing
## Loads processed abilities and handles trigger matching and effect resolution
signal ability_triggered(source: CardInstance, ability: Dictionary)
signal effect_resolved(effect: Dictionary, targets: Array)
signal targeting_required(effect: Dictionary, valid_targets: Array, callback: Callable)
signal targeting_completed(effect: Dictionary, selected_targets: Array)
signal choice_modal_required(effect: Dictionary, modes: Array, callback: Callable)
signal optional_effect_prompt(player_index: int, effect: Dictionary, description: String, callback: Callable)
const ABILITIES_PATH = "res://data/abilities_processed.json"
# Loaded ability data
var _abilities: Dictionary = {} # card_id -> Array of parsed abilities
var _version: String = ""
var _stats: Dictionary = {}
# Sub-systems
var trigger_matcher: TriggerMatcher
var effect_resolver: EffectResolver
var target_selector: TargetSelector
var field_effect_manager: FieldEffectManager
var condition_checker: ConditionChecker
# UI Reference
var choice_modal: ChoiceModal = null
# Effect resolution stack
var _pending_effects: Array = []
var _is_resolving: bool = false
var _waiting_for_choice: bool = false
var _waiting_for_optional: bool = false
# Connected game state
var _game_state = null # GameState reference
func _ready() -> void:
_load_abilities()
_init_subsystems()
func _init_subsystems() -> void:
trigger_matcher = TriggerMatcher.new()
effect_resolver = EffectResolver.new()
target_selector = TargetSelector.new()
field_effect_manager = FieldEffectManager.new()
condition_checker = ConditionChecker.new()
# Wire ConditionChecker to subsystems that need it
effect_resolver.condition_checker = condition_checker
trigger_matcher.condition_checker = condition_checker
# Connect effect resolver signals
effect_resolver.effect_completed.connect(_on_effect_completed)
effect_resolver.choice_required.connect(_on_choice_required)
func _load_abilities() -> void:
if not FileAccess.file_exists(ABILITIES_PATH):
push_warning("AbilitySystem: No processed abilities found at " + ABILITIES_PATH)
push_warning("Run: python tools/ability_processor.py")
return
var file = FileAccess.open(ABILITIES_PATH, FileAccess.READ)
if not file:
push_error("AbilitySystem: Failed to open " + ABILITIES_PATH)
return
var json = JSON.new()
var error = json.parse(file.get_as_text())
file.close()
if error != OK:
push_error("AbilitySystem: Failed to parse abilities JSON: " + json.get_error_message())
return
var data = json.get_data()
_version = data.get("version", "unknown")
_stats = data.get("statistics", {})
_abilities = data.get("abilities", {})
print("AbilitySystem: Loaded v%s - %d cards, %d abilities (%d high confidence)" % [
_version,
_stats.get("total_cards", 0),
_stats.get("total_abilities", 0),
_stats.get("parsed_high", 0)
])
## Connect to a game state to listen for events
func connect_to_game(game_state) -> void:
if _game_state:
_disconnect_from_game()
_game_state = game_state
# Connect to game events that can trigger abilities
game_state.card_played.connect(_on_card_played)
game_state.summon_cast.connect(_on_summon_cast)
game_state.attack_declared.connect(_on_attack_declared)
game_state.block_declared.connect(_on_block_declared)
game_state.forward_broken.connect(_on_forward_broken)
game_state.damage_dealt.connect(_on_damage_dealt)
game_state.card_moved.connect(_on_card_moved)
game_state.combat_resolved.connect(_on_combat_resolved)
# Turn manager signals
if game_state.turn_manager:
game_state.turn_manager.phase_changed.connect(_on_phase_changed)
game_state.turn_manager.turn_started.connect(_on_turn_started)
game_state.turn_manager.turn_ended.connect(_on_turn_ended)
print("AbilitySystem: Connected to GameState")
func _disconnect_from_game() -> void:
if not _game_state:
return
# Disconnect all signals
if _game_state.card_played.is_connected(_on_card_played):
_game_state.card_played.disconnect(_on_card_played)
if _game_state.summon_cast.is_connected(_on_summon_cast):
_game_state.summon_cast.disconnect(_on_summon_cast)
if _game_state.attack_declared.is_connected(_on_attack_declared):
_game_state.attack_declared.disconnect(_on_attack_declared)
if _game_state.block_declared.is_connected(_on_block_declared):
_game_state.block_declared.disconnect(_on_block_declared)
if _game_state.forward_broken.is_connected(_on_forward_broken):
_game_state.forward_broken.disconnect(_on_forward_broken)
if _game_state.damage_dealt.is_connected(_on_damage_dealt):
_game_state.damage_dealt.disconnect(_on_damage_dealt)
if _game_state.card_moved.is_connected(_on_card_moved):
_game_state.card_moved.disconnect(_on_card_moved)
_game_state = null
## Get parsed abilities for a card
func get_abilities(card_id: String) -> Array:
return _abilities.get(card_id, [])
## Check if a card has parsed abilities
func has_abilities(card_id: String) -> bool:
return _abilities.has(card_id) and _abilities[card_id].size() > 0
## Get a specific parsed ability
func get_ability(card_id: String, ability_index: int) -> Dictionary:
var abilities = get_abilities(card_id)
if ability_index >= 0 and ability_index < abilities.size():
return abilities[ability_index]
return {}
## Process a game event and trigger matching abilities
func process_event(event_type: String, event_data: Dictionary) -> void:
if not _game_state:
return
var triggered = trigger_matcher.find_triggered_abilities(
event_type, event_data, _game_state, _abilities
)
for trigger_info in triggered:
_queue_ability(trigger_info)
## Queue an ability for resolution
func _queue_ability(trigger_info: Dictionary) -> void:
var source = trigger_info.source as CardInstance
var ability = trigger_info.ability as Dictionary
var parsed = ability.get("parsed", {})
if not parsed or not parsed.has("effects"):
return
# Check if ability has a cost that needs to be paid
var cost = parsed.get("cost", {})
if not cost.is_empty() and source and _game_state:
var player = _game_state.get_player(source.controller_index)
if player:
# Validate cost
var validation = _validate_ability_cost(cost, source, player)
if not validation.valid:
# Cannot pay cost - emit signal and skip ability
ability_cost_failed.emit(source, ability, validation.reason)
push_warning("AbilitySystem: Cannot pay cost for ability - %s" % validation.reason)
return
# Pay the cost
_pay_ability_cost(cost, source, player)
ability_triggered.emit(source, ability)
# Add effects to pending stack (LIFO for proper resolution order)
var effects = parsed.get("effects", [])
for i in range(effects.size() - 1, -1, -1):
_pending_effects.push_front({
"effect": effects[i],
"source": source,
"controller": source.controller_index,
"ability": ability,
"event_data": trigger_info.get("event_data", {})
})
# Start resolving if not already
if not _is_resolving:
_resolve_next_effect()
## Resolve the next pending effect
func _resolve_next_effect() -> void:
if _pending_effects.is_empty():
_is_resolving = false
return
_is_resolving = true
var pending = _pending_effects[0]
var effect = pending.effect
var source = pending.source
# Check if effect is optional and we haven't prompted yet
if effect.get("optional", false) and not pending.get("optional_prompted", false):
_waiting_for_optional = true
pending["optional_prompted"] = true # Mark as prompted to avoid re-prompting
# Determine which player should decide
var player_index = source.controller_index if source else 0
# Build description from effect
var description = _build_effect_description(effect)
# Emit signal for UI to handle
optional_effect_prompt.emit(player_index, effect, description, _on_optional_effect_choice)
return # Wait for callback
# Check if effect needs targeting
if _effect_needs_targeting(effect):
var valid_targets = target_selector.get_valid_targets(
effect.get("target", {}), source, _game_state
)
if valid_targets.is_empty():
# No valid targets, skip effect
_pending_effects.pop_front()
_resolve_next_effect()
return
# Request target selection from player
targeting_required.emit(effect, valid_targets, _on_targets_selected)
# Wait for targeting_completed signal
else:
# Resolve immediately
_execute_effect(pending)
## Execute an effect with its targets
func _execute_effect(pending: Dictionary) -> void:
var effect = pending.effect
var source = pending.source
var targets = pending.get("targets", [])
effect_resolver.resolve(effect, source, targets, _game_state)
## Called when effect resolution completes
func _on_effect_completed(effect: Dictionary, targets: Array) -> void:
effect_resolved.emit(effect, targets)
if not _pending_effects.is_empty():
_pending_effects.pop_front()
_resolve_next_effect()
## Called when player selects targets
func _on_targets_selected(targets: Array) -> void:
if _pending_effects.is_empty():
return
_pending_effects[0]["targets"] = targets
targeting_completed.emit(_pending_effects[0].effect, targets)
_execute_effect(_pending_effects[0])
## Check if effect requires player targeting
func _effect_needs_targeting(effect: Dictionary) -> bool:
if not effect.has("target"):
return false
var target = effect.target
return target.get("type") == "CHOOSE"
## Called when player responds to optional effect prompt
func _on_optional_effect_choice(accepted: bool) -> void:
_waiting_for_optional = false
if _pending_effects.is_empty():
return
if accepted:
# Player chose to execute the optional effect
# Continue with normal resolution (targeting or execution)
var pending = _pending_effects[0]
var effect = pending.effect
if _effect_needs_targeting(effect):
var source = pending.source
var valid_targets = target_selector.get_valid_targets(
effect.get("target", {}), source, _game_state
)
if valid_targets.is_empty():
_pending_effects.pop_front()
_resolve_next_effect()
return
targeting_required.emit(effect, valid_targets, _on_targets_selected)
else:
_execute_effect(pending)
else:
# Player declined the optional effect - skip it
_pending_effects.pop_front()
_resolve_next_effect()
## Build a human-readable description of an effect for prompts
func _build_effect_description(effect: Dictionary) -> String:
var effect_type = str(effect.get("type", "")).to_upper()
var amount = effect.get("amount", 0)
match effect_type:
"DRAW":
var count = effect.get("amount", 1)
return "Draw %d card%s" % [count, "s" if count > 1 else ""]
"DAMAGE":
return "Deal %d damage" % amount
"POWER_MOD":
var sign = "+" if amount >= 0 else ""
return "Give %s%d power" % [sign, amount]
"DULL":
return "Dull a Forward"
"ACTIVATE":
return "Activate a card"
"BREAK":
return "Break a card"
"RETURN":
return "Return a card to hand"
"SEARCH":
return "Search your deck"
"DISCARD":
var count = effect.get("amount", 1)
return "Discard %d card%s" % [count, "s" if count > 1 else ""]
_:
# Use the original_text if available
if effect.has("original_text"):
return effect.original_text
return "Use this effect"
## Called when EffectResolver encounters a CHOOSE_MODE effect
func _on_choice_required(effect: Dictionary, modes: Array) -> void:
if _pending_effects.is_empty():
return
var pending = _pending_effects[0]
var source = pending.get("source") as CardInstance
# Check for enhanced condition (e.g., "If you have 5+ Ifrit, select 3 instead")
var select_count = effect.get("select_count", 1)
var select_up_to = effect.get("select_up_to", false)
var enhanced = effect.get("enhanced_condition", {})
if not enhanced.is_empty() and _check_enhanced_condition(enhanced, source):
select_count = enhanced.get("select_count", select_count)
select_up_to = enhanced.get("select_up_to", select_up_to)
# If we have a ChoiceModal, use it
if choice_modal:
_waiting_for_choice = true
_handle_modal_choice_async(effect, modes, select_count, select_up_to, source)
else:
# No UI available - auto-select first N modes
push_warning("AbilitySystem: No ChoiceModal available, auto-selecting first mode(s)")
var auto_selected: Array = []
for i in range(min(select_count, modes.size())):
auto_selected.append(i)
_on_modes_selected(effect, modes, auto_selected, source)
## Handle modal choice asynchronously
func _handle_modal_choice_async(
effect: Dictionary,
modes: Array,
select_count: int,
select_up_to: bool,
source: CardInstance
) -> void:
var selected = await choice_modal.show_choices(
"", # Title is generated by ChoiceModal
modes,
select_count,
select_up_to,
false # Not cancellable for mandatory abilities
)
_waiting_for_choice = false
_on_modes_selected(effect, modes, selected, source)
## Cached regex for enhanced condition parsing
var _enhanced_count_regex: RegEx = null
## Check if enhanced condition is met
func _check_enhanced_condition(condition: Dictionary, source: CardInstance) -> bool:
var description = condition.get("description", "").to_lower()
# Parse "if you have X or more [Card Name] in your Break Zone"
if "break zone" in description:
# Initialize regex once (lazy)
if _enhanced_count_regex == null:
_enhanced_count_regex = RegEx.new()
_enhanced_count_regex.compile("(\\d+) or more")
var match_result = _enhanced_count_regex.search(description)
if match_result:
var required_count = int(match_result.get_string(1))
# Check break zone for matching cards
if _game_state and source:
var player = _game_state.get_player(source.controller_index)
if player:
# Count matching cards in break zone
var break_zone_count = 0
for card in player.break_zone.get_cards():
# Simple name matching (description contains card name pattern)
if card.card_data and card.card_data.name.to_lower() in description:
break_zone_count += 1
return break_zone_count >= required_count
return false
return false
# =============================================================================
# COST VALIDATION AND PAYMENT
# =============================================================================
## Signal emitted when cost cannot be paid
signal ability_cost_failed(source: CardInstance, ability: Dictionary, reason: String)
## Validate that a player can pay the cost for an ability
## Returns true if cost can be paid, false otherwise
func _validate_ability_cost(
cost: Dictionary,
source: CardInstance,
player
) -> Dictionary:
var result = {"valid": true, "reason": ""}
if cost.is_empty():
return result
# Check CP cost
var cp_cost = cost.get("cp", 0)
var element = cost.get("element", "")
if cp_cost > 0:
if element and element != "" and element.to_upper() != "ANY":
# Specific element required
var element_enum = Enums.element_from_string(element)
if player.cp_pool.get_cp(element_enum) < cp_cost:
result.valid = false
result.reason = "Not enough %s CP (need %d, have %d)" % [
element, cp_cost, player.cp_pool.get_cp(element_enum)
]
return result
else:
# Any element
if player.cp_pool.get_total_cp() < cp_cost:
result.valid = false
result.reason = "Not enough CP (need %d, have %d)" % [
cp_cost, player.cp_pool.get_total_cp()
]
return result
# Check discard cost
var discard = cost.get("discard", 0)
if discard > 0:
if player.hand.get_count() < discard:
result.valid = false
result.reason = "Not enough cards in hand to discard (need %d, have %d)" % [
discard, player.hand.get_count()
]
return result
# Check dull self cost
var dull_self = cost.get("dull_self", false)
if dull_self and source:
if source.is_dull():
result.valid = false
result.reason = "Card is already dulled"
return result
# Check specific card discard
var specific_discard = cost.get("specific_discard", "")
if specific_discard != "":
# Player must have a card with this name in hand
var has_card = false
for card in player.hand.get_cards():
if card.card_data and card.card_data.name.to_lower() == specific_discard.to_lower():
has_card = true
break
if not has_card:
result.valid = false
result.reason = "Must discard a card named '%s'" % specific_discard
return result
return result
## Pay the cost for an ability
## Returns true if cost was paid successfully
func _pay_ability_cost(
cost: Dictionary,
source: CardInstance,
player
) -> bool:
if cost.is_empty():
return true
# Pay CP cost
var cp_cost = cost.get("cp", 0)
var element = cost.get("element", "")
if cp_cost > 0:
if element and element != "" and element.to_upper() != "ANY":
# Spend specific element CP
var element_enum = Enums.element_from_string(element)
player.cp_pool.add_cp(element_enum, -cp_cost)
else:
# Spend from any element (generic)
var remaining = cp_cost
for elem in Enums.Element.values():
var available = player.cp_pool.get_cp(elem)
if available > 0:
var to_spend = mini(available, remaining)
player.cp_pool.add_cp(elem, -to_spend)
remaining -= to_spend
if remaining <= 0:
break
# Pay dull self cost
var dull_self = cost.get("dull_self", false)
if dull_self and source:
source.dull()
# Note: Discard costs are handled through separate UI interaction
# The discard selection would be queued as a separate effect
return true
## Called when player selects mode(s)
func _on_modes_selected(
effect: Dictionary,
modes: Array,
selected_indices: Array,
source: CardInstance
) -> void:
# Queue the effects from selected modes
for index in selected_indices:
if index >= 0 and index < modes.size():
var mode = modes[index]
var mode_effects = mode.get("effects", [])
for mode_effect in mode_effects:
_pending_effects.push_back({
"effect": mode_effect,
"source": source,
"controller": source.controller_index if source else 0,
"ability": effect,
"event_data": {}
})
# Remove the CHOOSE_MODE effect from pending and continue
if not _pending_effects.is_empty():
_pending_effects.pop_front()
_resolve_next_effect()
# =============================================================================
# Event Handlers
# =============================================================================
func _on_card_played(card: CardInstance, player_index: int) -> void:
# Register field abilities
if card.is_forward() or card.is_backup():
var card_abilities = get_abilities(card.card_data.id)
field_effect_manager.register_field_abilities(card, card_abilities)
# Trigger enters field events
process_event("ENTERS_FIELD", {
"card": card,
"player": player_index,
"zone_from": Enums.ZoneType.HAND,
"zone_to": Enums.ZoneType.FIELD_FORWARDS if card.is_forward() else Enums.ZoneType.FIELD_BACKUPS
})
func _on_summon_cast(card: CardInstance, player_index: int) -> void:
process_event("SUMMON_CAST", {
"card": card,
"player": player_index
})
func _on_attack_declared(attacker: CardInstance) -> void:
process_event("ATTACKS", {
"card": attacker,
"player": attacker.controller_index
})
func _on_block_declared(blocker: CardInstance) -> void:
if not _game_state or not _game_state.turn_manager:
return
var attacker = _game_state.turn_manager.current_attacker
process_event("BLOCKS", {
"card": blocker,
"attacker": attacker,
"player": blocker.controller_index
})
if attacker:
process_event("IS_BLOCKED", {
"card": attacker,
"blocker": blocker,
"player": attacker.controller_index
})
func _on_forward_broken(card: CardInstance) -> void:
# Unregister field abilities
field_effect_manager.unregister_field_abilities(card)
process_event("LEAVES_FIELD", {
"card": card,
"zone_from": Enums.ZoneType.FIELD_FORWARDS,
"zone_to": Enums.ZoneType.BREAK
})
func _on_damage_dealt(player_index: int, amount: int, cards: Array) -> void:
process_event("DAMAGE_DEALT_TO_PLAYER", {
"player": player_index,
"amount": amount,
"cards": cards
})
# Check for EX BURST triggers on damage cards
for card in cards:
if card.card_data and card.card_data.has_ex_burst:
_trigger_ex_burst(card, player_index)
func _on_card_moved(card: CardInstance, from_zone: Enums.ZoneType, to_zone: Enums.ZoneType) -> void:
# Handle zone changes
if to_zone == Enums.ZoneType.BREAK:
field_effect_manager.unregister_field_abilities(card)
func _on_combat_resolved(attacker: CardInstance, blocker: CardInstance) -> void:
if not blocker:
# Unblocked attack
process_event("DEALS_DAMAGE_TO_OPPONENT", {
"card": attacker,
"player": attacker.controller_index
})
else:
# Blocked combat
process_event("DEALS_DAMAGE", {
"card": attacker,
"target": blocker,
"player": attacker.controller_index
})
process_event("DEALS_DAMAGE", {
"card": blocker,
"target": attacker,
"player": blocker.controller_index
})
func _on_phase_changed(phase: Enums.TurnPhase) -> void:
var event_type = ""
match phase:
Enums.TurnPhase.ACTIVE:
event_type = "START_OF_ACTIVE_PHASE"
Enums.TurnPhase.DRAW:
event_type = "START_OF_DRAW_PHASE"
Enums.TurnPhase.MAIN_1:
event_type = "START_OF_MAIN_PHASE"
Enums.TurnPhase.ATTACK:
event_type = "START_OF_ATTACK_PHASE"
Enums.TurnPhase.MAIN_2:
event_type = "START_OF_MAIN_PHASE_2"
Enums.TurnPhase.END:
event_type = "START_OF_END_PHASE"
if event_type:
process_event(event_type, {
"player": _game_state.turn_manager.current_player_index if _game_state else 0
})
func _on_turn_started(player_index: int, turn_number: int) -> void:
process_event("START_OF_TURN", {
"player": player_index,
"turn_number": turn_number
})
func _on_turn_ended(player_index: int) -> void:
process_event("END_OF_TURN", {
"player": player_index
})
## Trigger EX BURST for a damage card
func _trigger_ex_burst(card: CardInstance, damaged_player: int) -> void:
var card_abilities = get_abilities(card.card_data.id)
for ability in card_abilities:
var parsed = ability.get("parsed", {})
if parsed.get("is_ex_burst", false):
# Queue the EX BURST ability
_queue_ability({
"source": card,
"ability": ability,
"event_data": {
"player": damaged_player,
"trigger_type": "EX_BURST"
}
})
break
## Get power modifier from field effects for a card
func get_field_power_modifier(card: CardInstance) -> int:
return field_effect_manager.get_power_modifiers(card, _game_state)
## Check if a card has a field-granted keyword
func has_field_keyword(card: CardInstance, keyword: String) -> bool:
return field_effect_manager.has_keyword(card, keyword, _game_state)
## Check if a card has field-granted protection
func has_field_protection(card: CardInstance, protection_type: String) -> bool:
return field_effect_manager.has_protection(card, protection_type, _game_state)
## Get all granted keywords for a card from field effects
func get_field_keywords(card: CardInstance) -> Array:
return field_effect_manager.get_granted_keywords(card, _game_state)
## Trigger EX BURST on a specific card (called by EffectResolver)
func trigger_ex_burst_on_card(card: CardInstance) -> void:
if not card or not card.card_data:
return
var card_abilities = get_abilities(card.card_data.id)
for ability in card_abilities:
var parsed = ability.get("parsed", {})
if parsed.get("is_ex_burst", false):
_queue_ability({
"source": card,
"ability": ability,
"event_data": {
"trigger_type": "EX_BURST_TRIGGERED"
}
})
break