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

@@ -7,7 +7,13 @@ enum State {
DECK_BUILDER,
GAME_SETUP,
PLAYING,
PAUSED
PAUSED,
LOGIN,
REGISTER,
ONLINE_LOBBY,
ONLINE_GAME,
PROFILE,
LEADERBOARD
}
var current_state: State = State.MENU
@@ -20,6 +26,16 @@ const DECK_BUILDER_SIZE := Vector2i(1600, 900)
const GAME_SETUP_SIZE := Vector2i(800, 600)
# Game window size
const GAME_SIZE := Vector2i(2160, 980)
# Login screen size
const LOGIN_SIZE := Vector2i(400, 500)
# Register screen size
const REGISTER_SIZE := Vector2i(400, 600)
# Online lobby size
const ONLINE_LOBBY_SIZE := Vector2i(600, 700)
# Profile screen size
const PROFILE_SIZE := Vector2i(600, 700)
# Leaderboard screen size
const LEADERBOARD_SIZE := Vector2i(600, 700)
# Scene references
var main_menu: MainMenu = null
@@ -27,12 +43,26 @@ var deck_builder: DeckBuilder = null
var game_setup_menu: GameSetupMenu = null
var game_scene: Node3D = null
var pause_menu: PauseMenu = null
var login_screen: LoginScreen = null
var register_screen: RegisterScreen = null
var online_lobby: OnlineLobby = null
var profile_screen: ProfileScreen = null
var leaderboard_screen: LeaderboardScreen = null
# Selected decks for gameplay
var selected_deck: Deck = null
var player1_deck: Array = [] # Card IDs for player 1
var player2_deck: Array = [] # Card IDs for player 2
# AI settings
var is_vs_ai: bool = false
var ai_difficulty: int = AIStrategy.Difficulty.NORMAL
# Online game settings
var is_online_game: bool = false
var online_game_data: Dictionary = {}
var online_pause_menu: Control = null
# Preload the main game scene script
const MainScript = preload("res://scripts/Main.gd")
@@ -52,6 +82,18 @@ func _input(event: InputEvent) -> void:
_show_pause_menu()
State.PAUSED:
_hide_pause_menu()
State.LOGIN:
_on_login_back()
State.REGISTER:
_on_register_back()
State.ONLINE_LOBBY:
_on_online_lobby_back()
State.ONLINE_GAME:
_show_online_pause_menu()
State.PROFILE:
_on_profile_back()
State.LEADERBOARD:
_on_leaderboard_back()
func _show_main_menu() -> void:
# Clean up any existing game
@@ -86,12 +128,37 @@ func _show_main_menu() -> void:
game_setup_menu.queue_free()
game_setup_menu = null
# Create main menu
# Clean up login screen if exists
if login_screen:
login_screen.queue_free()
login_screen = null
# Clean up register screen if exists
if register_screen:
register_screen.queue_free()
register_screen = null
# Clean up online lobby if exists
if online_lobby:
online_lobby.queue_free()
online_lobby = null
# Clean up profile screen if exists
if profile_screen:
profile_screen.queue_free()
profile_screen = null
# Clean up leaderboard screen if exists
if leaderboard_screen:
leaderboard_screen.queue_free()
leaderboard_screen = null
if not main_menu:
main_menu = MainMenu.new()
add_child(main_menu)
main_menu.play_game.connect(_on_start_game)
main_menu.deck_builder.connect(_on_deck_builder)
main_menu.online_game.connect(_on_online_game)
main_menu.visible = true
current_state = State.MENU
@@ -169,9 +236,11 @@ func _on_game_setup_back() -> void:
_show_main_menu()
func _on_game_setup_start(p1_deck: Array, p2_deck: Array) -> void:
func _on_game_setup_start(p1_deck: Array, p2_deck: Array, p_is_vs_ai: bool = false, p_ai_difficulty: int = AIStrategy.Difficulty.NORMAL) -> void:
player1_deck = p1_deck
player2_deck = p2_deck
is_vs_ai = p_is_vs_ai
ai_difficulty = p_ai_difficulty
if game_setup_menu:
game_setup_menu.visible = false
@@ -234,6 +303,10 @@ func _start_new_game() -> void:
if player2_deck.size() > 0:
game_scene.player2_deck = player2_deck
# Pass AI configuration
game_scene.is_vs_ai = is_vs_ai
game_scene.ai_difficulty = ai_difficulty
add_child(game_scene)
# Create pause menu
@@ -264,3 +337,405 @@ func _on_restart_game() -> void:
func _on_return_to_menu() -> void:
_show_main_menu()
# ======= ONLINE PLAY =======
func _on_online_game() -> void:
# Hide menu
if main_menu:
main_menu.visible = false
# Check if already authenticated
if NetworkManager and NetworkManager.is_authenticated:
_show_online_lobby()
else:
_show_login_screen()
func _show_login_screen() -> void:
# Switch to login screen window size
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
DisplayServer.window_set_size(LOGIN_SIZE)
var screen := DisplayServer.screen_get_size()
DisplayServer.window_set_position(Vector2i(
(screen.x - LOGIN_SIZE.x) / 2,
(screen.y - LOGIN_SIZE.y) / 2
))
# Set viewport to login size
get_tree().root.content_scale_size = LOGIN_SIZE
# Create login screen
if not login_screen:
login_screen = LoginScreen.new()
add_child(login_screen)
login_screen.login_successful.connect(_on_login_successful)
login_screen.register_requested.connect(_on_register_requested)
login_screen.back_pressed.connect(_on_login_back)
login_screen.visible = true
login_screen.focus_email()
current_state = State.LOGIN
func _on_login_back() -> void:
if login_screen:
login_screen.visible = false
login_screen.clear_form()
_show_main_menu()
func _on_login_successful(_user_data: Dictionary) -> void:
if login_screen:
login_screen.visible = false
login_screen.clear_form()
_show_online_lobby()
func _on_register_requested() -> void:
if login_screen:
login_screen.visible = false
login_screen.clear_form()
_show_register_screen()
func _show_register_screen() -> void:
# Switch to register screen window size
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
DisplayServer.window_set_size(REGISTER_SIZE)
var screen := DisplayServer.screen_get_size()
DisplayServer.window_set_position(Vector2i(
(screen.x - REGISTER_SIZE.x) / 2,
(screen.y - REGISTER_SIZE.y) / 2
))
# Set viewport to register size
get_tree().root.content_scale_size = REGISTER_SIZE
# Create register screen
if not register_screen:
register_screen = RegisterScreen.new()
add_child(register_screen)
register_screen.registration_successful.connect(_on_registration_successful)
register_screen.login_requested.connect(_on_login_from_register)
register_screen.back_pressed.connect(_on_register_back)
register_screen.visible = true
register_screen.focus_email()
current_state = State.REGISTER
func _on_register_back() -> void:
if register_screen:
register_screen.visible = false
register_screen.clear_form()
_show_main_menu()
func _on_registration_successful(_message: String) -> void:
# After successful registration, show login screen
if register_screen:
register_screen.visible = false
register_screen.clear_form()
_show_login_screen()
func _on_login_from_register() -> void:
if register_screen:
register_screen.visible = false
register_screen.clear_form()
_show_login_screen()
func _show_online_lobby() -> void:
# Switch to online lobby window size
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
DisplayServer.window_set_size(ONLINE_LOBBY_SIZE)
var screen := DisplayServer.screen_get_size()
DisplayServer.window_set_position(Vector2i(
(screen.x - ONLINE_LOBBY_SIZE.x) / 2,
(screen.y - ONLINE_LOBBY_SIZE.y) / 2
))
# Set viewport to online lobby size
get_tree().root.content_scale_size = ONLINE_LOBBY_SIZE
# Create online lobby
if not online_lobby:
online_lobby = OnlineLobby.new()
add_child(online_lobby)
online_lobby.back_pressed.connect(_on_online_lobby_back)
online_lobby.game_starting.connect(_on_online_game_starting)
online_lobby.profile_requested.connect(_show_profile_screen)
online_lobby.leaderboard_requested.connect(_show_leaderboard_screen)
# Connect to game_ended signal for handling online game completion
if NetworkManager and not NetworkManager.game_ended.is_connected(_on_online_game_ended):
NetworkManager.game_ended.connect(_on_online_game_ended)
online_lobby.visible = true
current_state = State.ONLINE_LOBBY
func _on_online_lobby_back() -> void:
if online_lobby:
online_lobby.visible = false
_show_main_menu()
func _on_online_game_starting(game_data: Dictionary) -> void:
if online_lobby:
online_lobby.visible = false
# Store game data
online_game_data = game_data
is_online_game = true
var opponent = game_data.get("opponent", {})
print("Starting online game against: ", opponent.get("username", "Unknown"))
print("Game ID: ", game_data.get("game_id", ""))
print("Local player index: ", game_data.get("your_player_index", 0))
print("First player: ", game_data.get("first_player", 0))
# Switch to game window size
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, false)
DisplayServer.window_set_size(GAME_SIZE)
var screen := DisplayServer.screen_get_size()
DisplayServer.window_set_position(Vector2i(
(screen.x - GAME_SIZE.x) / 2,
(screen.y - GAME_SIZE.y) / 2
))
# Set viewport to game size
get_tree().root.content_scale_size = GAME_SIZE
_start_online_game(game_data)
func _start_online_game(game_data: Dictionary) -> void:
# Make sure game isn't paused
get_tree().paused = false
# Clean up existing game
if game_scene:
game_scene.queue_free()
game_scene = null
# Reset GameManager state
if GameManager:
GameManager.is_game_active = false
GameManager.game_state = null
# Create new game scene
game_scene = Node3D.new()
game_scene.set_script(MainScript)
# Mark as online game
game_scene.is_online_game = true
# Get the deck ID from NetworkManager (set when joining queue/room)
var deck_id = game_data.get("deck_id", "")
var local_player_index = game_data.get("your_player_index", 0)
# Load the selected deck for the local player
var local_deck = _load_deck_by_id(deck_id)
# For online games, player positions are swapped based on index
# The local player is always displayed on the bottom (player 1 position visually)
# But the game logic uses the server-assigned indices
if local_player_index == 0:
game_scene.player1_deck = local_deck
game_scene.player2_deck = [] # Opponent's deck is hidden
else:
game_scene.player1_deck = [] # Opponent's deck is hidden
game_scene.player2_deck = local_deck
# Pass game configuration
game_scene.is_vs_ai = false
game_scene.online_game_data = game_data
add_child(game_scene)
# Setup online game specifics after scene is added
game_scene.setup_online_game(game_data)
# Create online pause menu (no restart option, has forfeit)
_create_online_pause_menu()
current_state = State.ONLINE_GAME
func _load_deck_by_id(deck_id: String) -> Array:
# Load deck from CardDatabase saved decks or starter decks
if deck_id.is_empty():
# Use default starter deck
return CardDatabase.get_starter_deck_ids("Fire Starter")
# Check saved decks
var saved_decks = CardDatabase.get_saved_decks()
for deck in saved_decks:
if deck.get("id", "") == deck_id:
return deck.get("card_ids", [])
# Check starter decks
var starter_decks = CardDatabase.get_starter_decks()
for deck in starter_decks:
if deck.get("id", "") == deck_id:
return deck.get("card_ids", [])
# Fallback to first starter deck
return CardDatabase.get_starter_deck_ids("Fire Starter")
func _create_online_pause_menu() -> void:
if online_pause_menu:
online_pause_menu.queue_free()
online_pause_menu = Control.new()
online_pause_menu.set_anchors_preset(Control.PRESET_FULL_RECT)
online_pause_menu.visible = false
# Semi-transparent background
var bg = ColorRect.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0, 0, 0, 0.7)
online_pause_menu.add_child(bg)
# Menu container
var container = VBoxContainer.new()
container.set_anchors_preset(Control.PRESET_CENTER)
container.custom_minimum_size = Vector2(300, 200)
container.add_theme_constant_override("separation", 20)
online_pause_menu.add_child(container)
# Title
var title = Label.new()
title.text = "PAUSED"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 32)
container.add_child(title)
# Resume button
var resume_btn = Button.new()
resume_btn.text = "Resume"
resume_btn.custom_minimum_size = Vector2(200, 50)
resume_btn.pressed.connect(_hide_online_pause_menu)
container.add_child(resume_btn)
# Forfeit button
var forfeit_btn = Button.new()
forfeit_btn.text = "Forfeit Game"
forfeit_btn.custom_minimum_size = Vector2(200, 50)
forfeit_btn.pressed.connect(_on_forfeit_game)
container.add_child(forfeit_btn)
add_child(online_pause_menu)
func _show_online_pause_menu() -> void:
if online_pause_menu:
online_pause_menu.visible = true
get_tree().paused = true
func _hide_online_pause_menu() -> void:
if online_pause_menu:
online_pause_menu.visible = false
get_tree().paused = false
func _on_forfeit_game() -> void:
# Send concede to server
if NetworkManager:
NetworkManager.send_concede()
_hide_online_pause_menu()
# Game end will be handled by the game_ended signal
func _on_online_game_ended(_result: Dictionary) -> void:
is_online_game = false
online_game_data = {}
if online_pause_menu:
online_pause_menu.queue_free()
online_pause_menu = null
# Return to online lobby after a delay
await get_tree().create_timer(3.0).timeout
_show_online_lobby()
# ======= PROFILE SCREEN =======
func _show_profile_screen() -> void:
# Hide online lobby
if online_lobby:
online_lobby.visible = false
# Switch to profile screen window size
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
DisplayServer.window_set_size(PROFILE_SIZE)
var screen := DisplayServer.screen_get_size()
DisplayServer.window_set_position(Vector2i(
(screen.x - PROFILE_SIZE.x) / 2,
(screen.y - PROFILE_SIZE.y) / 2
))
# Set viewport to profile size
get_tree().root.content_scale_size = PROFILE_SIZE
# Create profile screen
if not profile_screen:
profile_screen = ProfileScreen.new()
add_child(profile_screen)
profile_screen.back_pressed.connect(_on_profile_back)
else:
profile_screen.refresh()
profile_screen.visible = true
current_state = State.PROFILE
func _on_profile_back() -> void:
if profile_screen:
profile_screen.visible = false
_show_online_lobby()
# ======= LEADERBOARD SCREEN =======
func _show_leaderboard_screen() -> void:
# Hide online lobby
if online_lobby:
online_lobby.visible = false
# Switch to leaderboard screen window size
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
DisplayServer.window_set_size(LEADERBOARD_SIZE)
var screen := DisplayServer.screen_get_size()
DisplayServer.window_set_position(Vector2i(
(screen.x - LEADERBOARD_SIZE.x) / 2,
(screen.y - LEADERBOARD_SIZE.y) / 2
))
# Set viewport to leaderboard size
get_tree().root.content_scale_size = LEADERBOARD_SIZE
# Create leaderboard screen
if not leaderboard_screen:
leaderboard_screen = LeaderboardScreen.new()
add_child(leaderboard_screen)
leaderboard_screen.back_pressed.connect(_on_leaderboard_back)
else:
leaderboard_screen.refresh()
leaderboard_screen.visible = true
current_state = State.LEADERBOARD
func _on_leaderboard_back() -> void:
if leaderboard_screen:
leaderboard_screen.visible = false
_show_online_lobby()

View File

@@ -8,6 +8,7 @@ var game_ui: GameUI
var hand_display: HandDisplay
var hand_layer: CanvasLayer
var action_log: ActionLog
var choice_modal: ChoiceModal
# Player damage displays
var damage_displays: Array[DamageDisplay] = []
@@ -16,6 +17,20 @@ var damage_displays: Array[DamageDisplay] = []
var player1_deck: Array = []
var player2_deck: Array = []
# AI settings (set by GameController before game starts)
var is_vs_ai: bool = false
var ai_difficulty: int = AIStrategy.Difficulty.NORMAL
var ai_controller: AIController = null
var ai_player_index: int = 1 # AI is always Player 2
# Online game settings (set by GameController before game starts)
var is_online_game: bool = false
var online_game_data: Dictionary = {}
var local_player_index: int = 0
var online_game_id: String = ""
var opponent_info: Dictionary = {}
var turn_timer_label: Label = null
func _ready() -> void:
_setup_table()
_setup_ui()
@@ -93,6 +108,14 @@ func _setup_ui() -> void:
var damage_display = DamageDisplay.new()
damage_displays.append(damage_display)
# Choice modal for multi-modal ability choices (layer 200 - highest priority)
choice_modal = ChoiceModal.new()
add_child(choice_modal)
# Connect choice modal to AbilitySystem autoload
if AbilitySystem:
AbilitySystem.choice_modal = choice_modal
func _position_hand_display() -> void:
var viewport = get_viewport()
if viewport:
@@ -136,11 +159,383 @@ func _connect_signals() -> void:
# Field card action signal (deferred to ensure game_ui is ready)
call_deferred("_connect_field_card_signals")
# Connect attack signal for AI blocking (deferred to ensure game_state exists)
call_deferred("_connect_attack_signal")
# ======= ONLINE GAME SETUP =======
func setup_online_game(game_data: Dictionary) -> void:
is_online_game = true
online_game_data = game_data
online_game_id = game_data.get("game_id", "")
local_player_index = game_data.get("your_player_index", 0)
opponent_info = game_data.get("opponent", {})
print("Setting up online game: ", online_game_id)
print("Local player index: ", local_player_index)
print("Opponent: ", opponent_info.get("username", "Unknown"))
# Connect network signals
_connect_network_signals()
# Create turn timer UI
_create_turn_timer_ui()
# Create opponent info UI
_create_opponent_info_ui()
func _connect_network_signals() -> void:
if not NetworkManager:
return
# Game events
if not NetworkManager.opponent_action_received.is_connected(_on_opponent_action):
NetworkManager.opponent_action_received.connect(_on_opponent_action)
if not NetworkManager.turn_timer_update.is_connected(_on_turn_timer_update):
NetworkManager.turn_timer_update.connect(_on_turn_timer_update)
if not NetworkManager.phase_changed.is_connected(_on_network_phase_changed):
NetworkManager.phase_changed.connect(_on_network_phase_changed)
if not NetworkManager.action_confirmed.is_connected(_on_action_confirmed):
NetworkManager.action_confirmed.connect(_on_action_confirmed)
if not NetworkManager.action_failed.is_connected(_on_action_failed):
NetworkManager.action_failed.connect(_on_action_failed)
if not NetworkManager.opponent_disconnected.is_connected(_on_opponent_disconnected):
NetworkManager.opponent_disconnected.connect(_on_opponent_disconnected)
if not NetworkManager.opponent_reconnected.is_connected(_on_opponent_reconnected):
NetworkManager.opponent_reconnected.connect(_on_opponent_reconnected)
if not NetworkManager.game_state_sync.is_connected(_on_game_state_sync):
NetworkManager.game_state_sync.connect(_on_game_state_sync)
func _disconnect_network_signals() -> void:
if not NetworkManager:
return
if NetworkManager.opponent_action_received.is_connected(_on_opponent_action):
NetworkManager.opponent_action_received.disconnect(_on_opponent_action)
if NetworkManager.turn_timer_update.is_connected(_on_turn_timer_update):
NetworkManager.turn_timer_update.disconnect(_on_turn_timer_update)
if NetworkManager.phase_changed.is_connected(_on_network_phase_changed):
NetworkManager.phase_changed.disconnect(_on_network_phase_changed)
if NetworkManager.action_confirmed.is_connected(_on_action_confirmed):
NetworkManager.action_confirmed.disconnect(_on_action_confirmed)
if NetworkManager.action_failed.is_connected(_on_action_failed):
NetworkManager.action_failed.disconnect(_on_action_failed)
if NetworkManager.opponent_disconnected.is_connected(_on_opponent_disconnected):
NetworkManager.opponent_disconnected.disconnect(_on_opponent_disconnected)
if NetworkManager.opponent_reconnected.is_connected(_on_opponent_reconnected):
NetworkManager.opponent_reconnected.disconnect(_on_opponent_reconnected)
if NetworkManager.game_state_sync.is_connected(_on_game_state_sync):
NetworkManager.game_state_sync.disconnect(_on_game_state_sync)
func _create_turn_timer_ui() -> void:
# Create turn timer label
turn_timer_label = Label.new()
turn_timer_label.text = "2:00"
turn_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
turn_timer_label.add_theme_font_size_override("font_size", 28)
turn_timer_label.add_theme_color_override("font_color", Color.WHITE)
# Position at top center
turn_timer_label.set_anchors_preset(Control.PRESET_CENTER_TOP)
turn_timer_label.offset_top = 10
turn_timer_label.offset_bottom = 50
turn_timer_label.offset_left = -50
turn_timer_label.offset_right = 50
if game_ui:
game_ui.add_child(turn_timer_label)
func _create_opponent_info_ui() -> void:
# Create opponent info label (next to timer)
var opponent_label = Label.new()
opponent_label.name = "OpponentInfo"
opponent_label.text = "vs %s (ELO: %d)" % [
opponent_info.get("username", "Unknown"),
opponent_info.get("elo", 1000)
]
opponent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
opponent_label.add_theme_font_size_override("font_size", 18)
opponent_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
# Position below timer
opponent_label.set_anchors_preset(Control.PRESET_CENTER_TOP)
opponent_label.offset_top = 45
opponent_label.offset_bottom = 70
opponent_label.offset_left = -150
opponent_label.offset_right = 150
if game_ui:
game_ui.add_child(opponent_label)
# ======= ONLINE GAME HELPERS =======
func _is_local_player_turn() -> bool:
if not is_online_game:
return true
if not GameManager.game_state:
return true
return GameManager.game_state.turn_manager.current_player_index == local_player_index
func _can_perform_local_action() -> bool:
if not is_online_game:
return true
return _is_local_player_turn()
# ======= NETWORK EVENT HANDLERS =======
func _on_opponent_action(action_data: Dictionary) -> void:
var action_type = action_data.get("action_type", "")
var payload = action_data.get("payload", {})
print("Received opponent action: ", action_type)
match action_type:
"play_card":
_apply_opponent_play_card(payload)
"attack":
_apply_opponent_attack(payload)
"block":
_apply_opponent_block(payload)
"pass":
_apply_opponent_pass()
"discard_cp":
_apply_opponent_discard_cp(payload)
"dull_backup_cp":
_apply_opponent_dull_backup_cp(payload)
"attack_resolved":
_apply_opponent_attack_resolved()
_:
print("Unknown opponent action: ", action_type)
# Update visuals after opponent action
_sync_visuals()
_update_hand_display()
_update_cp_display()
func _apply_opponent_play_card(payload: Dictionary) -> void:
var card_instance_id = payload.get("card_instance_id", 0)
# Find the card in opponent's hand and play it
# For online, we trust the server - the opponent's hand is hidden anyway
# Just sync visuals when server confirms
print("Opponent played card: ", card_instance_id)
func _apply_opponent_attack(payload: Dictionary) -> void:
var attacker_instance_id = payload.get("attacker_instance_id", 0)
print("Opponent declared attack with card: ", attacker_instance_id)
# Find attacker and apply attack declaration
# The server will handle phase changes
func _apply_opponent_block(payload: Dictionary) -> void:
var blocker_instance_id = payload.get("blocker_instance_id", null)
if blocker_instance_id == null:
print("Opponent chose not to block")
else:
print("Opponent declared block with card: ", blocker_instance_id)
func _apply_opponent_pass() -> void:
print("Opponent passed priority")
# Server handles phase advancement
func _apply_opponent_discard_cp(payload: Dictionary) -> void:
var card_instance_id = payload.get("card_instance_id", 0)
print("Opponent discarded card for CP: ", card_instance_id)
func _apply_opponent_dull_backup_cp(payload: Dictionary) -> void:
var card_instance_id = payload.get("card_instance_id", 0)
print("Opponent dulled backup for CP: ", card_instance_id)
func _apply_opponent_attack_resolved() -> void:
print("Attack resolved")
func _on_turn_timer_update(seconds_remaining: int) -> void:
if turn_timer_label:
var minutes = seconds_remaining / 60
var secs = seconds_remaining % 60
turn_timer_label.text = "%d:%02d" % [minutes, secs]
# Color based on time remaining
if seconds_remaining <= 10:
turn_timer_label.add_theme_color_override("font_color", Color.RED)
elif seconds_remaining <= 30:
turn_timer_label.add_theme_color_override("font_color", Color.YELLOW)
else:
turn_timer_label.add_theme_color_override("font_color", Color.WHITE)
func _on_network_phase_changed(phase_data: Dictionary) -> void:
var phase = phase_data.get("phase", 0)
var current_player_index = phase_data.get("current_player_index", 0)
var turn_number = phase_data.get("turn_number", 1)
# Validate player index bounds
if current_player_index < 0 or current_player_index > 1:
push_error("Invalid player index from server: %d" % current_player_index)
return
print("Network phase changed: phase=", phase, " player=", current_player_index, " turn=", turn_number)
# Update local game state to match server
if GameManager.game_state:
GameManager.game_state.turn_manager.current_player_index = current_player_index
GameManager.game_state.turn_manager.turn_number = turn_number
# Phase enum values should match between server and client
GameManager.game_state.turn_manager.current_phase = phase
_sync_visuals()
_update_hand_display()
_update_cp_display()
# Switch camera to current player
if table_setup:
table_setup.switch_camera_to_player(current_player_index)
func _on_action_confirmed(action_type: String) -> void:
print("Action confirmed by server: ", action_type)
func _on_action_failed(action_type: String, error: String) -> void:
print("Action failed: ", action_type, " - ", error)
game_ui.show_message("Action failed: " + error)
func _on_opponent_disconnected(reconnect_timeout: int) -> void:
game_ui.show_message("Opponent disconnected. Waiting %d seconds for reconnect..." % reconnect_timeout)
func _on_opponent_reconnected() -> void:
game_ui.show_message("Opponent reconnected!")
# Hide the message after a moment
await get_tree().create_timer(2.0).timeout
game_ui.hide_message()
func _on_game_state_sync(state: Dictionary) -> void:
print("Received game state sync: ", state)
# This is for reconnection - sync local state with server
var current_player_index = state.get("current_player_index", 0)
var current_phase = state.get("current_phase", 0)
var turn_number = state.get("turn_number", 1)
# Validate player index bounds
if current_player_index < 0 or current_player_index > 1:
push_error("Invalid player index in game state sync: %d" % current_player_index)
return
if GameManager.game_state:
GameManager.game_state.turn_manager.current_player_index = current_player_index
GameManager.game_state.turn_manager.current_phase = current_phase
GameManager.game_state.turn_manager.turn_number = turn_number
# Sync timer display from server state
var timer_seconds = state.get("turn_timer_seconds", 120)
if turn_timer_label:
var minutes = timer_seconds / 60
var secs = timer_seconds % 60
turn_timer_label.text = "%d:%02d" % [minutes, secs]
_sync_visuals()
_update_hand_display()
_update_cp_display()
func _connect_attack_signal() -> void:
# Wait for game_state to be available
if GameManager.game_state:
GameManager.game_state.attack_declared.connect(_on_attack_declared)
func _on_attack_declared(attacker: CardInstance) -> void:
# If human attacks and AI is the defender, AI needs to decide on blocking
if is_vs_ai and ai_controller:
var current_player_index = GameManager.game_state.turn_manager.current_player_index
# AI is always player index 1, so if current player is 0 (human), AI needs to block
if current_player_index != ai_player_index:
# AI needs to make a block decision
call_deferred("_process_ai_block", attacker)
func _process_ai_block(attacker: CardInstance) -> void:
if ai_controller and not ai_controller.is_processing:
ai_controller.process_block_decision(attacker)
func _start_game() -> void:
GameManager.start_new_game(player1_deck, player2_deck)
# Setup AI controller if playing vs AI
if is_vs_ai:
_setup_ai_controller()
# Force an update of visuals after a frame to ensure everything is ready
call_deferred("_force_initial_update")
func _setup_ai_controller() -> void:
ai_controller = AIController.new()
add_child(ai_controller)
ai_controller.setup(ai_player_index, ai_difficulty, GameManager)
ai_controller.set_game_state(GameManager.game_state)
# Connect AI signals
ai_controller.ai_thinking.connect(_on_ai_thinking)
ai_controller.ai_action_completed.connect(_on_ai_action_completed)
func _on_ai_thinking(_player_index: int) -> void:
game_ui.show_message("AI is thinking...")
func _on_ai_action_completed() -> void:
game_ui.hide_message()
_sync_visuals()
_update_hand_display()
_update_cp_display()
# Check if we need to continue AI processing
if _is_ai_turn():
call_deferred("_process_ai_turn")
func _is_ai_turn() -> bool:
if not is_vs_ai or not GameManager.game_state:
return false
return GameManager.game_state.turn_manager.current_player_index == ai_player_index
func _process_ai_turn() -> void:
if _is_ai_turn() and ai_controller and not ai_controller.is_processing:
ai_controller.process_turn()
func _force_initial_update() -> void:
_sync_visuals()
_update_hand_display()
@@ -158,6 +553,34 @@ func _on_game_started() -> void:
func _on_game_ended(winner_name: String) -> void:
game_ui.show_message(winner_name + " wins the game!")
# For online games, report game end to server
if is_online_game and GameManager.game_state:
var winner_index = -1
for i in range(2):
var player = GameManager.game_state.get_player(i)
if player and player.player_name == winner_name:
winner_index = i
break
if winner_index != -1:
# Determine winner user ID based on index
# Local player is either index 0 or 1, with opponent being the other
var winner_user_id = ""
if winner_index == local_player_index:
winner_user_id = NetworkManager.current_user.get("id", "")
# Server will determine actual winner from both clients reporting
var reason = "damage" # Could be "deck_out" if deck is empty
var losing_player = GameManager.game_state.get_player(1 - winner_index)
if losing_player and losing_player.deck.is_empty():
reason = "deck_out"
NetworkManager.send_report_game_end(winner_user_id, reason)
# Disconnect network signals
if is_online_game:
_disconnect_network_signals()
func _on_turn_changed(_player_name: String, _turn_number: int) -> void:
_sync_visuals()
_update_hand_display()
@@ -168,12 +591,21 @@ func _on_turn_changed(_player_name: String, _turn_number: int) -> void:
var player_index = GameManager.game_state.turn_manager.current_player_index
table_setup.switch_camera_to_player(player_index)
# If AI's turn, start AI processing after a brief delay
if _is_ai_turn():
# Defer to allow visuals to update first
call_deferred("_process_ai_turn")
func _on_phase_changed(_phase_name: String) -> void:
_update_playable_highlights()
_update_cp_display()
# Refresh hand after draw phase completes (hand updates on entering main phase)
_update_hand_display()
# If AI's turn and we're in a decision phase, continue AI processing
if _is_ai_turn():
call_deferred("_process_ai_turn")
func _on_damage_dealt(player_name: String, _amount: int) -> void:
# Find player index
if GameManager.game_state:
@@ -226,9 +658,18 @@ func _update_playable_highlights() -> void:
hand_display.clear_highlights()
func _on_hand_card_action(card: CardInstance, action: String) -> void:
# Block input if not local player's turn in online game
if is_online_game and not _is_local_player_turn():
game_ui.show_message("Not your turn!")
await get_tree().create_timer(1.0).timeout
game_ui.hide_message()
return
match action:
"play":
# Try to play the card
if is_online_game:
NetworkManager.send_play_card(card.instance_id)
GameManager.try_play_card(card)
_sync_visuals()
_update_hand_display()
@@ -236,6 +677,8 @@ func _on_hand_card_action(card: CardInstance, action: String) -> void:
"discard_cp":
# Discard for CP
if is_online_game:
NetworkManager.send_discard_for_cp(card.instance_id)
GameManager.discard_card_for_cp(card)
_sync_visuals()
_update_hand_display()
@@ -280,6 +723,11 @@ func _on_table_card_clicked(card: CardInstance, zone_type: Enums.ZoneType, playe
GameManager.InputMode.SELECT_CP_SOURCE:
if zone_type == Enums.ZoneType.FIELD_BACKUPS:
if player_index == GameManager.game_state.turn_manager.current_player_index:
# Block if not local player's turn in online game
if is_online_game and not _is_local_player_turn():
return
if is_online_game:
NetworkManager.send_dull_backup_for_cp(card.instance_id)
GameManager.dull_backup_for_cp(card)
_sync_visuals()
_update_cp_display()
@@ -288,6 +736,11 @@ func _on_table_card_clicked(card: CardInstance, zone_type: Enums.ZoneType, playe
GameManager.InputMode.SELECT_ATTACKER:
if zone_type == Enums.ZoneType.FIELD_FORWARDS:
if player_index == GameManager.game_state.turn_manager.current_player_index:
# Block if not local player's turn in online game
if is_online_game and not _is_local_player_turn():
return
if is_online_game:
NetworkManager.send_attack(card.instance_id)
GameManager.declare_attack(card)
_sync_visuals()
return
@@ -296,6 +749,13 @@ func _on_table_card_clicked(card: CardInstance, zone_type: Enums.ZoneType, playe
if zone_type == Enums.ZoneType.FIELD_FORWARDS:
var opponent_index = 1 - GameManager.game_state.turn_manager.current_player_index
if player_index == opponent_index:
# In online games, only the defending player (non-active) can block
# Check if we are the defender
if is_online_game and local_player_index == GameManager.game_state.turn_manager.current_player_index:
# We are the attacker, can't block
return
if is_online_game:
NetworkManager.send_block(card.instance_id)
GameManager.declare_block(card)
_sync_visuals()
return
@@ -310,21 +770,31 @@ func _connect_field_card_signals() -> void:
game_ui.field_card_action_requested.connect(_on_field_card_action)
func _on_field_card_action(card: CardInstance, zone_type: Enums.ZoneType, player_index: int, action: String) -> void:
# Block input if not local player's turn in online game
if is_online_game and not _is_local_player_turn():
game_ui.show_message("Not your turn!")
return
match action:
"dull_cp":
if is_online_game:
NetworkManager.send_dull_backup_for_cp(card.instance_id)
GameManager.dull_backup_for_cp(card)
_sync_visuals()
_update_cp_display()
"attack":
if is_online_game:
NetworkManager.send_attack(card.instance_id)
GameManager.declare_attack(card)
_sync_visuals()
func _input(event: InputEvent) -> void:
# Keyboard shortcuts
if event is InputEventKey and event.pressed:
# Ctrl+Z for undo
# Ctrl+Z for undo (disabled in online games)
if event.keycode == KEY_Z and event.ctrl_pressed:
_on_undo_requested()
if not is_online_game:
_on_undo_requested()
return
# L key to toggle action log
@@ -336,6 +806,10 @@ func _input(event: InputEvent) -> void:
match event.keycode:
KEY_SPACE:
# Pass priority / end phase
if is_online_game:
if not _is_local_player_turn():
return
NetworkManager.send_pass()
GameManager.pass_priority()
_sync_visuals()
_update_hand_display()

View File

@@ -92,6 +92,7 @@ func _parse_card(data: Dictionary) -> CardData:
card.category = data.get("category", "") if data.get("category") != null else ""
card.is_generic = data.get("is_generic", false) if data.get("is_generic") != null else false
card.has_ex_burst = data.get("has_ex_burst", false) if data.get("has_ex_burst") != null else false
card.has_haste = data.get("has_haste", false) if data.get("has_haste") != null else false
card.image_path = data.get("image", "") if data.get("image") != null else ""
# Parse abilities
@@ -455,6 +456,7 @@ class CardData:
var category: String = ""
var is_generic: bool = false
var has_ex_burst: bool = false
var has_haste: bool = false
var image_path: String = ""
var abilities: Array[AbilityData] = []
@@ -466,6 +468,14 @@ class CardData:
func is_multi_element() -> bool:
return elements.size() > 1
## Check if card has a specific ability by name (e.g., "Brave", "Haste", "First Strike")
func has_ability(ability_name: String) -> bool:
for ability in abilities:
if ability.name.to_lower() == ability_name.to_lower():
return true
return false
class AbilityData:
var type: Enums.AbilityType = Enums.AbilityType.FIELD
var name: String = ""

View File

@@ -25,6 +25,17 @@ var zone_type: Enums.ZoneType = Enums.ZoneType.DECK
# Temporary effects (cleared at end of turn)
var power_modifiers: Array[int] = []
var temporary_abilities: Array = []
var temporary_keywords: Dictionary = {} # keyword -> duration
var restrictions: Dictionary = {} # restriction_type -> duration
var requirements: Dictionary = {} # requirement_type -> duration
var protections: Dictionary = {} # protection_type -> duration
# Counters
var counters: Dictionary = {} # counter_type -> count
# Special states
var is_frozen: bool = false
var base_power_override: int = -1 # -1 means use card_data.power
# Turn tracking
var turns_on_field: int = 0
@@ -43,11 +54,18 @@ func _init(data: CardDatabase.CardData = null, owner: int = 0) -> void:
if data:
current_power = data.power
## Get the card's current power (base + modifiers)
## Get the card's current power (base + modifiers + field effects)
func get_power() -> int:
var total = current_power
for mod in power_modifiers:
total += mod
# Add field effect modifiers from AbilitySystem
var tree = Engine.get_main_loop()
if tree and tree.root and tree.root.has_node("AbilitySystem"):
var ability_system = tree.root.get_node("AbilitySystem")
total += ability_system.get_field_power_modifier(self)
return max(0, total)
## Check if this is a Forward
@@ -76,8 +94,18 @@ func dull() -> void:
## Activate this card
func activate() -> void:
# Frozen cards can't activate during Active Phase (but can be activated by effects)
state = Enums.CardState.ACTIVE
## Attempt to activate during Active Phase (respects frozen)
func activate_during_active_phase() -> bool:
if is_frozen:
is_frozen = false # Frozen wears off but card stays dull
return false
state = Enums.CardState.ACTIVE
return true
## Check if this card can attack
func can_attack() -> bool:
if not is_forward():
@@ -86,19 +114,34 @@ func can_attack() -> bool:
return false
if attacked_this_turn:
return false
if has_restriction("CANT_ATTACK"):
return false
# Must have been on field since start of turn (or have Haste)
if turns_on_field < 1 and not has_haste():
return false
return true
## Check if this card must attack (if able)
func must_attack() -> bool:
return has_requirement("MUST_ATTACK")
## Check if this card can block
func can_block() -> bool:
if not is_forward():
return false
if is_dull():
return false
if has_restriction("CANT_BLOCK"):
return false
return true
## Check if this card must block (if able)
func must_block() -> bool:
return has_requirement("MUST_BLOCK")
## Check if this card can use dull abilities
func can_use_dull_ability() -> bool:
# Must have been on field since start of turn (or have Haste)
@@ -109,14 +152,21 @@ func can_use_dull_ability() -> bool:
return false
return true
## Check if card has Haste (from abilities)
## Check if card has Haste
func has_haste() -> bool:
if not card_data:
return false
# Check explicit has_haste field first
if card_data.has_haste:
return true
# Fallback: search ability text for backwards compatibility
for ability in card_data.abilities:
if ability.type == Enums.AbilityType.FIELD:
if "haste" in ability.effect.to_lower():
return true
# Check field-granted keywords
if _has_field_keyword("HASTE"):
return true
return false
## Check if card has Brave (from abilities)
@@ -127,6 +177,9 @@ func has_brave() -> bool:
if ability.type == Enums.AbilityType.FIELD:
if "brave" in ability.effect.to_lower():
return true
# Check field-granted keywords
if _has_field_keyword("BRAVE"):
return true
return false
## Check if card has First Strike
@@ -137,6 +190,17 @@ func has_first_strike() -> bool:
if ability.type == Enums.AbilityType.FIELD:
if "first strike" in ability.effect.to_lower():
return true
# Check field-granted keywords
if _has_field_keyword("FIRST_STRIKE"):
return true
return false
## Check for field-granted keyword from AbilitySystem
func _has_field_keyword(keyword: String) -> bool:
var tree = Engine.get_main_loop()
if tree and tree.root and tree.root.has_node("AbilitySystem"):
var ability_system = tree.root.get_node("AbilitySystem")
return ability_system.has_field_keyword(self, keyword)
return false
## Get primary element
@@ -191,3 +255,288 @@ func get_display_name() -> String:
func _to_string() -> String:
return "[CardInstance: %s (%s)]" % [get_display_name(), instance_id]
# =============================================================================
# KEYWORD MANAGEMENT
# =============================================================================
## Add a temporary keyword
func add_temporary_keyword(keyword: String, duration: String = "END_OF_TURN") -> void:
temporary_keywords[keyword.to_upper()] = duration
## Check if card has a keyword (from card data, temp, or field effects)
func has_keyword(keyword: String) -> bool:
var kw_upper = keyword.to_upper()
# Check temporary keywords
if temporary_keywords.has(kw_upper):
return true
# Check field-granted keywords
if _has_field_keyword(kw_upper):
return true
# Check card's base keywords
if card_data:
match kw_upper:
"HASTE":
return card_data.has_haste
"BRAVE":
for ability in card_data.abilities:
if ability.type == Enums.AbilityType.FIELD and "brave" in ability.effect.to_lower():
return true
"FIRST_STRIKE":
for ability in card_data.abilities:
if ability.type == Enums.AbilityType.FIELD and "first strike" in ability.effect.to_lower():
return true
return false
## Remove all abilities
func remove_all_abilities() -> void:
temporary_abilities.clear()
temporary_keywords.clear()
## Remove a specific ability
func remove_ability(ability_name: String) -> void:
var name_upper = ability_name.to_upper()
temporary_abilities.erase(name_upper)
temporary_keywords.erase(name_upper)
# =============================================================================
# RESTRICTIONS & REQUIREMENTS
# =============================================================================
## Add a restriction (can't attack, can't block, etc.)
func add_restriction(restriction_type: String, duration: String = "END_OF_TURN") -> void:
restrictions[restriction_type.to_upper()] = duration
## Check if card has a restriction
func has_restriction(restriction_type: String) -> bool:
return restrictions.has(restriction_type.to_upper())
## Add a requirement (must attack, must block, etc.)
func add_requirement(requirement_type: String, duration: String = "END_OF_TURN") -> void:
requirements[requirement_type.to_upper()] = duration
## Check if card has a requirement
func has_requirement(requirement_type: String) -> bool:
return requirements.has(requirement_type.to_upper())
# =============================================================================
# PROTECTION
# =============================================================================
## Add protection from damage/effects
func add_protection(protection_type: String, duration: String = "END_OF_TURN") -> void:
protections[protection_type.to_upper()] = duration
## Check if card has protection from something
func has_protection(protection_type: String) -> bool:
var pt_upper = protection_type.to_upper()
# Check local protections
if protections.has(pt_upper):
return true
# Check for ALL protection
if protections.has("ALL"):
return true
# Check field-granted protection
var tree = Engine.get_main_loop()
if tree and tree.root and tree.root.has_node("AbilitySystem"):
var ability_system = tree.root.get_node("AbilitySystem")
return ability_system.has_field_protection(self, protection_type)
return false
# =============================================================================
# FROZEN STATE
# =============================================================================
## Set frozen state
func set_frozen(frozen: bool) -> void:
is_frozen = frozen
## Check if frozen (doesn't activate during Active Phase)
func is_card_frozen() -> bool:
return is_frozen
# =============================================================================
# COUNTERS
# =============================================================================
## Add counters
func add_counters(counter_type: String, amount: int = 1) -> void:
var ct = counter_type.to_upper()
counters[ct] = counters.get(ct, 0) + amount
## Remove counters
func remove_counters(counter_type: String, amount: int = 1) -> void:
var ct = counter_type.to_upper()
counters[ct] = max(0, counters.get(ct, 0) - amount)
if counters[ct] == 0:
counters.erase(ct)
## Get counter count
func get_counter_count(counter_type: String) -> int:
return counters.get(counter_type.to_upper(), 0)
# =============================================================================
# POWER MANIPULATION
# =============================================================================
## Set base power (for swap/transform effects)
func set_base_power(new_power: int) -> void:
base_power_override = new_power
## Get base power (respecting override)
func get_base_power() -> int:
if base_power_override >= 0:
return base_power_override
return card_data.power if card_data else 0
# =============================================================================
# DAMAGE MANIPULATION
# =============================================================================
## Heal damage
func heal_damage(amount: int) -> void:
damage_received = max(0, damage_received - amount)
## Remove all damage
func remove_all_damage() -> void:
damage_received = 0
# =============================================================================
# COPY & TRANSFORM
# =============================================================================
## Copy abilities from another card
func copy_abilities_from(other: CardInstance) -> void:
if other and other.card_data:
for ability in other.card_data.abilities:
temporary_abilities.append(ability)
## Copy stats from another card
func copy_stats_from(other: CardInstance) -> void:
if other:
base_power_override = other.get_base_power()
## Become a copy of another card
func become_copy_of(other: CardInstance) -> void:
if other:
copy_stats_from(other)
copy_abilities_from(other)
## Transform into something else
func transform(into: Dictionary) -> void:
if into.has("power"):
base_power_override = int(into.power)
if into.has("name"):
# Transform name handling would require additional infrastructure
pass
# =============================================================================
# PROPERTY MODIFICATION
# =============================================================================
# Temporary element/job storage
var _temp_element: String = ""
var _temp_element_duration: String = ""
var _temp_job: String = ""
var _temp_job_duration: String = ""
## Set temporary element
func set_temporary_element(element: String, duration: String = "END_OF_TURN") -> void:
_temp_element = element.to_upper()
_temp_element_duration = duration
## Set temporary job
func set_temporary_job(job: String, duration: String = "END_OF_TURN") -> void:
_temp_job = job
_temp_job_duration = duration
## Get current elements (including temporary)
func get_current_elements() -> Array:
if _temp_element != "":
var element = Enums.element_from_string(_temp_element)
return [element]
return get_elements()
## Get current job (including temporary)
func get_current_job() -> String:
if _temp_job != "":
return _temp_job
return card_data.job if card_data else ""
# =============================================================================
# CLEANUP
# =============================================================================
## Reset temporary effects at end of turn (extended)
func end_turn_cleanup() -> void:
power_modifiers.clear()
temporary_abilities.clear()
damage_received = 0
attacked_this_turn = false
# Clear END_OF_TURN duration effects
_clear_duration_effects("END_OF_TURN")
## Clear effects with specific duration
func _clear_duration_effects(duration: String) -> void:
for key in temporary_keywords.keys():
if temporary_keywords[key] == duration:
temporary_keywords.erase(key)
for key in restrictions.keys():
if restrictions[key] == duration:
restrictions.erase(key)
for key in requirements.keys():
if requirements[key] == duration:
requirements.erase(key)
for key in protections.keys():
if protections[key] == duration:
protections.erase(key)
# Clear temporary element/job
if _temp_element_duration == duration:
_temp_element = ""
_temp_element_duration = ""
if _temp_job_duration == duration:
_temp_job = ""
_temp_job_duration = ""

View File

@@ -41,6 +41,7 @@ enum TurnPhase {
## Attack Phase Steps
enum AttackStep {
NONE, # Not in attack phase or between attacks
PREPARATION,
DECLARATION,
BLOCK_DECLARATION,

View File

@@ -63,6 +63,17 @@ func start_game(first_player: int = -1) -> void:
players[1].draw_cards(5)
game_active = true
# Connect ability system if available
var ability_system = Engine.get_singleton("AbilitySystem")
if ability_system == null:
# Try getting from scene tree (autoload)
var tree = Engine.get_main_loop()
if tree and tree.root.has_node("AbilitySystem"):
ability_system = tree.root.get_node("AbilitySystem")
if ability_system:
ability_system.connect_to_game(self)
turn_manager.start_game(first_player)
game_started.emit()

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

View File

@@ -0,0 +1,219 @@
class_name CardFilter
extends RefCounted
## CardFilter - Shared card filtering utility used by EffectResolver, FieldEffectManager, and TargetSelector
##
## This utility provides a unified way to filter cards based on various criteria
## including element, job, category, cost, power, card type, and state.
## Check if a card matches a filter dictionary
static func matches_filter(card: CardInstance, filter: Dictionary, source: CardInstance = null) -> bool:
if not card or not card.card_data:
return false
if filter.is_empty():
return true
# Element filter
if filter.has("element"):
var element_str = str(filter.element).to_upper()
var element = Enums.element_from_string(element_str)
if element not in card.get_elements():
return false
# Job filter
if filter.has("job"):
var job_filter = str(filter.job).to_lower()
var card_job = str(card.card_data.job).to_lower() if card.card_data.job else ""
if job_filter != card_job:
return false
# Category filter
if filter.has("category"):
if not _has_category(card, str(filter.category).to_upper()):
return false
# Cost filters
if not _matches_cost_filter(card, filter):
return false
# Power filters
if not _matches_power_filter(card, filter):
return false
# Card name filter
if filter.has("card_name"):
var name_filter = str(filter.card_name).to_lower()
var card_name = str(card.card_data.name).to_lower() if card.card_data.name else ""
if name_filter != card_name:
return false
if filter.has("name"):
var name_filter = str(filter.name).to_lower()
var card_name = str(card.card_data.name).to_lower() if card.card_data.name else ""
if name_filter != card_name:
return false
# Card type filter
if filter.has("card_type"):
if not _matches_type(card, str(filter.card_type)):
return false
# State filters
if filter.has("is_dull"):
var card_dull = card.is_dull() if card.has_method("is_dull") else card.is_dull
if card_dull != filter.is_dull:
return false
if filter.has("is_active"):
var card_active = card.is_active() if card.has_method("is_active") else not card.is_dull
if card_active != filter.is_active:
return false
# Exclude self
if filter.get("exclude_self", false) and source != null and card == source:
return false
return true
## Count cards that match a filter
static func count_matching(cards: Array, filter: Dictionary, source: CardInstance = null) -> int:
var count = 0
for card in cards:
if matches_filter(card, filter, source):
count += 1
return count
## Get all cards that match a filter
static func get_matching(cards: Array, filter: Dictionary, source: CardInstance = null) -> Array:
var matching: Array = []
for card in cards:
if matches_filter(card, filter, source):
matching.append(card)
return matching
## Get the highest power among cards (optionally filtered)
static func get_highest_power(cards: Array, filter: Dictionary = {}, source: CardInstance = null) -> int:
var highest = 0
for card in cards:
if filter.is_empty() or matches_filter(card, filter, source):
var power = card.get_power() if card.has_method("get_power") else 0
if power > highest:
highest = power
return highest
## Get the lowest power among cards (optionally filtered)
static func get_lowest_power(cards: Array, filter: Dictionary = {}, source: CardInstance = null) -> int:
var lowest = -1
for card in cards:
if filter.is_empty() or matches_filter(card, filter, source):
var power = card.get_power() if card.has_method("get_power") else 0
if lowest == -1 or power < lowest:
lowest = power
return lowest if lowest != -1 else 0
# =============================================================================
# PRIVATE HELPER METHODS
# =============================================================================
## Check if a card has a specific category
static func _has_category(card: CardInstance, category_filter: String) -> bool:
# Check card's category field
if card.card_data.has("category") and card.card_data.category:
if category_filter in str(card.card_data.category).to_upper():
return true
# Check categories array if present
if card.card_data.has("categories"):
for cat in card.card_data.categories:
if category_filter in str(cat).to_upper():
return true
return false
## Check if a card matches cost filter criteria
static func _matches_cost_filter(card: CardInstance, filter: Dictionary) -> bool:
var card_cost = card.card_data.cost
# Exact cost filter
if filter.has("cost") and not filter.has("cost_comparison"):
if card_cost != int(filter.cost):
return false
# Min/max style cost filters (from TargetSelector)
if filter.has("cost_min") and card_cost < int(filter.cost_min):
return false
if filter.has("cost_max") and card_cost > int(filter.cost_max):
return false
# Cost comparison filter
if filter.has("cost_comparison") and filter.has("cost_value"):
var target_cost = int(filter.cost_value)
match str(filter.cost_comparison).to_upper():
"LTE":
if card_cost > target_cost:
return false
"GTE":
if card_cost < target_cost:
return false
"EQ":
if card_cost != target_cost:
return false
"LT":
if card_cost >= target_cost:
return false
"GT":
if card_cost <= target_cost:
return false
return true
## Check if a card matches type filter
static func _matches_type(card: CardInstance, type_filter: String) -> bool:
match type_filter.to_upper():
"FORWARD":
return card.is_forward()
"BACKUP":
return card.is_backup()
"SUMMON":
return card.is_summon()
"MONSTER":
return card.is_monster()
"CHARACTER":
return card.is_forward() or card.is_backup()
return true
## Check if a card matches power filter criteria
static func _matches_power_filter(card: CardInstance, filter: Dictionary) -> bool:
var power = card.get_power() if card.has_method("get_power") else card.card_data.power
# Min/max style power filters
if filter.has("power_min") and power < int(filter.power_min):
return false
if filter.has("power_max") and power > int(filter.power_max):
return false
# Comparison style power filter
if filter.has("power_comparison") and filter.has("power_value"):
var target = int(filter.power_value)
match str(filter.power_comparison).to_upper():
"LTE":
if power > target:
return false
"GTE":
if power < target:
return false
"LT":
if power >= target:
return false
"GT":
if power <= target:
return false
return true

View File

@@ -0,0 +1,510 @@
class_name ConditionChecker
extends RefCounted
## Centralized condition evaluation for all ability types
## Handles conditions like "If you control X", "If you have received Y damage", etc.
## Main evaluation entry point
## Returns true if condition is met, false otherwise
func evaluate(condition: Dictionary, context: Dictionary) -> bool:
if condition.is_empty():
return true # Empty condition = unconditional
var condition_type = condition.get("type", "")
match condition_type:
"CONTROL_CARD":
return _check_control_card(condition, context)
"CONTROL_COUNT":
return _check_control_count(condition, context)
"DAMAGE_RECEIVED":
return _check_damage_received(condition, context)
"BREAK_ZONE_COUNT":
return _check_break_zone_count(condition, context)
"CARD_IN_ZONE":
return _check_card_in_zone(condition, context)
"FORWARD_STATE":
return _check_forward_state(condition, context)
"COST_COMPARISON":
return _check_cost_comparison(condition, context)
"POWER_COMPARISON":
return _check_power_comparison(condition, context)
"ELEMENT_MATCH":
return _check_element_match(condition, context)
"CARD_TYPE_MATCH":
return _check_card_type_match(condition, context)
"JOB_MATCH":
return _check_job_match(condition, context)
"CATEGORY_MATCH":
return _check_category_match(condition, context)
"AND":
return _check_and(condition, context)
"OR":
return _check_or(condition, context)
"NOT":
return _check_not(condition, context)
_:
push_warning("ConditionChecker: Unknown condition type '%s'" % condition_type)
return false
# =============================================================================
# CONTROL CONDITIONS
# =============================================================================
func _check_control_card(condition: Dictionary, context: Dictionary) -> bool:
var card_name = condition.get("card_name", "")
var player = context.get("player_id", 0)
var game_state = context.get("game_state")
if not game_state:
return false
# Check all field cards for the player
var field_cards = _get_field_cards(game_state, player)
for card in field_cards:
if card and card.card_data and card.card_data.name == card_name:
return true
return false
func _check_control_count(condition: Dictionary, context: Dictionary) -> bool:
var card_type = condition.get("card_type", "")
var element = condition.get("element", "")
var job = condition.get("job", "")
var category = condition.get("category", "")
var comparison = condition.get("comparison", "GTE")
var value = condition.get("value", 1)
var player = context.get("player_id", 0)
var game_state = context.get("game_state")
if not game_state:
return false
var count = 0
var field_cards = _get_field_cards(game_state, player)
for card in field_cards:
if not card or not card.card_data:
continue
var matches = true
# Check card type filter
if card_type != "" and not _matches_card_type(card, card_type):
matches = false
# Check element filter
if element != "" and not _matches_element(card, element):
matches = false
# Check job filter
if job != "" and not _matches_job(card, job):
matches = false
# Check category filter
if category != "" and not _matches_category(card, category):
matches = false
if matches:
count += 1
return _compare(count, comparison, value)
# =============================================================================
# DAMAGE CONDITIONS
# =============================================================================
func _check_damage_received(condition: Dictionary, context: Dictionary) -> bool:
var comparison = condition.get("comparison", "GTE")
var value = condition.get("value", 1)
var player = context.get("player_id", 0)
var game_state = context.get("game_state")
if not game_state:
return false
var damage = _get_player_damage(game_state, player)
return _compare(damage, comparison, value)
# =============================================================================
# ZONE CONDITIONS
# =============================================================================
func _check_break_zone_count(condition: Dictionary, context: Dictionary) -> bool:
var card_name = condition.get("card_name", "")
var card_names: Array = condition.get("card_names", [])
if card_name != "" and card_name not in card_names:
card_names.append(card_name)
var comparison = condition.get("comparison", "GTE")
var value = condition.get("value", 1)
var player = context.get("player_id", 0)
var game_state = context.get("game_state")
if not game_state:
return false
var count = 0
var break_zone = _get_break_zone(game_state, player)
for card in break_zone:
if not card or not card.card_data:
continue
# If no specific names, count all
if card_names.is_empty():
count += 1
elif card.card_data.name in card_names:
count += 1
return _compare(count, comparison, value)
func _check_card_in_zone(condition: Dictionary, context: Dictionary) -> bool:
var zone = condition.get("zone", "") # "HAND", "DECK", "BREAK_ZONE", "REMOVED"
var card_name = condition.get("card_name", "")
var card_type = condition.get("card_type", "")
var player = context.get("player_id", 0)
var game_state = context.get("game_state")
if not game_state:
return false
var zone_cards: Array = []
match zone:
"HAND":
zone_cards = _get_hand(game_state, player)
"DECK":
zone_cards = _get_deck(game_state, player)
"BREAK_ZONE":
zone_cards = _get_break_zone(game_state, player)
"REMOVED":
zone_cards = _get_removed_zone(game_state, player)
"FIELD":
zone_cards = _get_field_cards(game_state, player)
for card in zone_cards:
if not card or not card.card_data:
continue
var matches = true
if card_name != "" and card.card_data.name != card_name:
matches = false
if card_type != "" and not _matches_card_type(card, card_type):
matches = false
if matches:
return true
return false
# =============================================================================
# CARD STATE CONDITIONS
# =============================================================================
func _check_forward_state(condition: Dictionary, context: Dictionary) -> bool:
var state = condition.get("state", "") # "DULL", "ACTIVE", "DAMAGED"
var check_self = condition.get("check_self", false)
var target = context.get("target_card") if not check_self else context.get("source_card")
if not target:
return false
match state:
"DULL":
return target.is_dull if target.has_method("get") or "is_dull" in target else false
"ACTIVE":
return not target.is_dull if "is_dull" in target else false
"DAMAGED":
if "current_power" in target and target.card_data:
return target.current_power < target.card_data.power
"FROZEN":
return target.is_frozen if "is_frozen" in target else false
return false
func _check_cost_comparison(condition: Dictionary, context: Dictionary) -> bool:
var comparison = condition.get("comparison", "LTE")
var value = condition.get("value", 0)
var compare_to = condition.get("compare_to", "") # "SELF_COST", "VALUE", or empty for value
var target = context.get("target_card")
var source = context.get("source_card")
if not target or not target.card_data:
return false
var target_cost = target.card_data.cost
var compare_value = value
if compare_to == "SELF_COST" and source and source.card_data:
compare_value = source.card_data.cost
return _compare(target_cost, comparison, compare_value)
func _check_power_comparison(condition: Dictionary, context: Dictionary) -> bool:
var comparison = condition.get("comparison", "LTE")
var value = condition.get("value", 0)
var compare_to = condition.get("compare_to", "") # "SELF_POWER", "VALUE"
var target = context.get("target_card")
var source = context.get("source_card")
if not target:
return false
var target_power = target.current_power if "current_power" in target else 0
var compare_value = value
if compare_to == "SELF_POWER" and source:
compare_value = source.current_power if "current_power" in source else 0
return _compare(target_power, comparison, compare_value)
# =============================================================================
# CARD ATTRIBUTE CONDITIONS
# =============================================================================
func _check_element_match(condition: Dictionary, context: Dictionary) -> bool:
var element = condition.get("element", "")
var check_self = condition.get("check_self", false)
var target = context.get("target_card") if not check_self else context.get("source_card")
if not target or not target.card_data:
return false
return _matches_element(target, element)
func _check_card_type_match(condition: Dictionary, context: Dictionary) -> bool:
var card_type = condition.get("card_type", "")
var check_self = condition.get("check_self", false)
var target = context.get("target_card") if not check_self else context.get("source_card")
if not target:
return false
return _matches_card_type(target, card_type)
func _check_job_match(condition: Dictionary, context: Dictionary) -> bool:
var job = condition.get("job", "")
var check_self = condition.get("check_self", false)
var target = context.get("target_card") if not check_self else context.get("source_card")
if not target or not target.card_data:
return false
return _matches_job(target, job)
func _check_category_match(condition: Dictionary, context: Dictionary) -> bool:
var category = condition.get("category", "")
var check_self = condition.get("check_self", false)
var target = context.get("target_card") if not check_self else context.get("source_card")
if not target or not target.card_data:
return false
return _matches_category(target, category)
# =============================================================================
# LOGICAL OPERATORS
# =============================================================================
func _check_and(condition: Dictionary, context: Dictionary) -> bool:
var conditions: Array = condition.get("conditions", [])
for sub_condition in conditions:
if not evaluate(sub_condition, context):
return false
return true
func _check_or(condition: Dictionary, context: Dictionary) -> bool:
var conditions: Array = condition.get("conditions", [])
for sub_condition in conditions:
if evaluate(sub_condition, context):
return true
return false
func _check_not(condition: Dictionary, context: Dictionary) -> bool:
var inner: Dictionary = condition.get("condition", {})
return not evaluate(inner, context)
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
func _compare(actual: int, comparison: String, expected: int) -> bool:
match comparison:
"EQ":
return actual == expected
"NEQ":
return actual != expected
"GT":
return actual > expected
"GTE":
return actual >= expected
"LT":
return actual < expected
"LTE":
return actual <= expected
return false
func _matches_card_type(card, card_type: String) -> bool:
if not card or not card.card_data:
return false
var type_upper = card_type.to_upper()
var card_type_value = card.card_data.type
# Handle string or enum type
if card_type_value is String:
return card_type_value.to_upper() == type_upper
# Handle Enums.CardType enum
match type_upper:
"FORWARD":
return card_type_value == Enums.CardType.FORWARD
"BACKUP":
return card_type_value == Enums.CardType.BACKUP
"SUMMON":
return card_type_value == Enums.CardType.SUMMON
"MONSTER":
return card_type_value == Enums.CardType.MONSTER
return false
func _matches_element(card, element: String) -> bool:
if not card or not card.card_data:
return false
var element_upper = element.to_upper()
var card_element = card.card_data.element
if card_element is String:
return card_element.to_upper() == element_upper
# Handle Enums.Element enum
match element_upper:
"FIRE":
return card_element == Enums.Element.FIRE
"ICE":
return card_element == Enums.Element.ICE
"WIND":
return card_element == Enums.Element.WIND
"EARTH":
return card_element == Enums.Element.EARTH
"LIGHTNING":
return card_element == Enums.Element.LIGHTNING
"WATER":
return card_element == Enums.Element.WATER
"LIGHT":
return card_element == Enums.Element.LIGHT
"DARK":
return card_element == Enums.Element.DARK
return false
func _matches_job(card, job: String) -> bool:
if not card or not card.card_data:
return false
var card_job = card.card_data.get("job", "")
if card_job is String:
return card_job.to_lower() == job.to_lower()
return false
func _matches_category(card, category: String) -> bool:
if not card or not card.card_data:
return false
var card_categories = card.card_data.get("categories", [])
if card_categories is Array:
for cat in card_categories:
if cat is String and cat.to_lower() == category.to_lower():
return true
return false
# =============================================================================
# GAME STATE ACCESSORS
# These abstract away the game state interface for flexibility
# =============================================================================
func _get_field_cards(game_state, player: int) -> Array:
if game_state.has_method("get_field_cards"):
return game_state.get_field_cards(player)
elif game_state.has_method("get_player_field"):
return game_state.get_player_field(player)
elif "players" in game_state and player < game_state.players.size():
var p = game_state.players[player]
if "field" in p:
return p.field
return []
func _get_player_damage(game_state, player: int) -> int:
if game_state.has_method("get_player_damage"):
return game_state.get_player_damage(player)
elif "players" in game_state and player < game_state.players.size():
var p = game_state.players[player]
if "damage" in p:
return p.damage
return 0
func _get_break_zone(game_state, player: int) -> Array:
if game_state.has_method("get_break_zone"):
return game_state.get_break_zone(player)
elif "players" in game_state and player < game_state.players.size():
var p = game_state.players[player]
if "break_zone" in p:
return p.break_zone
return []
func _get_hand(game_state, player: int) -> Array:
if game_state.has_method("get_hand"):
return game_state.get_hand(player)
elif "players" in game_state and player < game_state.players.size():
var p = game_state.players[player]
if "hand" in p:
return p.hand
return []
func _get_deck(game_state, player: int) -> Array:
if game_state.has_method("get_deck"):
return game_state.get_deck(player)
elif "players" in game_state and player < game_state.players.size():
var p = game_state.players[player]
if "deck" in p:
return p.deck
return []
func _get_removed_zone(game_state, player: int) -> Array:
if game_state.has_method("get_removed_zone"):
return game_state.get_removed_zone(player)
elif "players" in game_state and player < game_state.players.size():
var p = game_state.players[player]
if "removed_zone" in p:
return p.removed_zone
return []

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,681 @@
class_name FieldEffectManager
extends RefCounted
## FieldEffectManager - Manages continuous FIELD abilities
## Tracks active field effects and calculates their impact on the game state
# Active field abilities by source card instance_id
var _active_abilities: Dictionary = {} # instance_id -> Array of abilities
## Register field abilities when a card enters the field
func register_field_abilities(card: CardInstance, abilities: Array) -> void:
var field_abilities: Array = []
for ability in abilities:
var parsed = ability.get("parsed", {})
if parsed.get("type") == "FIELD":
field_abilities.append({
"ability": ability,
"source": card
})
if not field_abilities.is_empty():
_active_abilities[card.instance_id] = field_abilities
## Unregister field abilities when a card leaves the field
func unregister_field_abilities(card: CardInstance) -> void:
_active_abilities.erase(card.instance_id)
## Get total power modifier for a card from all active field effects
func get_power_modifiers(card: CardInstance, game_state) -> int:
var total_modifier: int = 0
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "POWER_MOD":
if _card_matches_effect_target(card, effect, source, game_state):
total_modifier += effect.get("amount", 0)
return total_modifier
## Check if a card has a keyword granted by field effects
func has_keyword(card: CardInstance, keyword: String, game_state) -> bool:
var keyword_upper = keyword.to_upper()
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "KEYWORD":
var granted_keyword = str(effect.get("keyword", "")).to_upper()
if granted_keyword == keyword_upper:
if _card_matches_effect_target(card, effect, source, game_state):
return true
return false
## Get all keywords granted to a card by field effects
func get_granted_keywords(card: CardInstance, game_state) -> Array:
var keywords: Array = []
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "KEYWORD":
if _card_matches_effect_target(card, effect, source, game_state):
var keyword = effect.get("keyword", "")
if keyword and keyword not in keywords:
keywords.append(keyword)
return keywords
## Check if a card has protection from something via field effects
func has_protection(card: CardInstance, protection_type: String, game_state) -> bool:
var protection_upper = protection_type.to_upper()
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "PROTECTION":
var from = str(effect.get("from", "")).to_upper()
if from == protection_upper or from == "ALL":
if _card_matches_effect_target(card, effect, source, game_state):
return true
return false
## Check if a card is affected by a damage modifier
func get_damage_modifier(card: CardInstance, game_state) -> int:
var total_modifier: int = 0
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "DAMAGE_MODIFIER":
if _card_matches_effect_target(card, effect, source, game_state):
total_modifier += effect.get("amount", 0)
return total_modifier
## Check if a card matches an effect's target specification
func _card_matches_effect_target(
card: CardInstance,
effect: Dictionary,
source: CardInstance,
game_state
) -> bool:
var target = effect.get("target", {})
if target.is_empty():
# No target specified, assume applies to source only
return card == source
var target_type = str(target.get("type", "")).to_upper()
# Check owner
var owner = str(target.get("owner", "ANY")).to_upper()
match owner:
"CONTROLLER":
if card.controller_index != source.controller_index:
return false
"OPPONENT":
if card.controller_index == source.controller_index:
return false
# "ANY" matches all
# Check if applies to self
if target_type == "SELF":
return card == source
# Check if applies to all matching
if target_type == "ALL":
return _matches_filter(card, target.get("filter", {}), source)
# Default check filter
return _matches_filter(card, target.get("filter", {}), source)
## Check if a card matches a filter (duplicated from TargetSelector for independence)
func _matches_filter(
card: CardInstance,
filter: Dictionary,
source: CardInstance
) -> bool:
if filter.is_empty():
return true
# Card type filter
if filter.has("card_type"):
var type_str = str(filter.card_type).to_upper()
match type_str:
"FORWARD":
if not card.is_forward():
return false
"BACKUP":
if not card.is_backup():
return false
"SUMMON":
if not card.is_summon():
return false
"CHARACTER":
if not (card.is_forward() or card.is_backup()):
return false
# Element filter
if filter.has("element"):
var element_str = str(filter.element).to_upper()
var element = Enums.element_from_string(element_str)
if element not in card.get_elements():
return false
# Cost filters
if filter.has("cost_min") and card.card_data.cost < int(filter.cost_min):
return false
if filter.has("cost_max") and card.card_data.cost > int(filter.cost_max):
return false
if filter.has("cost") and card.card_data.cost != int(filter.cost):
return false
# Power filters
if filter.has("power_min") and card.get_power() < int(filter.power_min):
return false
if filter.has("power_max") and card.get_power() > int(filter.power_max):
return false
# Name filter
if filter.has("name") and card.card_data.name != filter.name:
return false
# Category filter
if filter.has("category") and card.card_data.category != filter.category:
return false
# Job filter
if filter.has("job") and card.card_data.job != filter.job:
return false
# Exclude self
if filter.get("exclude_self", false) and card == source:
return false
return true
## Get count of active field abilities
func get_active_ability_count() -> int:
var count = 0
for instance_id in _active_abilities:
count += _active_abilities[instance_id].size()
return count
## Clear all active abilities (for game reset)
func clear_all() -> void:
_active_abilities.clear()
# =============================================================================
# BLOCK IMMUNITY CHECKS
# =============================================================================
## Check if a card has block immunity (can't be blocked by certain cards)
func has_block_immunity(card: CardInstance, potential_blocker: CardInstance, game_state) -> bool:
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var source = ability_data.source
if source != card:
continue
var ability = ability_data.ability
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "BLOCK_IMMUNITY":
var condition = effect.get("condition", {})
if _blocker_matches_immunity_condition(potential_blocker, condition, card):
return true
return false
## Check if blocker matches the immunity condition
func _blocker_matches_immunity_condition(
blocker: CardInstance,
condition: Dictionary,
attacker: CardInstance
) -> bool:
if condition.is_empty():
return true # Unconditional block immunity
var comparison = condition.get("comparison", "")
var attribute = condition.get("attribute", "")
var value = condition.get("value", 0)
var compare_to = condition.get("compare_to", "")
var blocker_value = 0
match attribute:
"cost":
blocker_value = blocker.card_data.cost if blocker.card_data else 0
"power":
blocker_value = blocker.get_power()
var compare_value = value
if compare_to == "SELF_POWER":
compare_value = attacker.get_power()
match comparison:
"GTE":
return blocker_value >= compare_value
"GT":
return blocker_value > compare_value
"LTE":
return blocker_value <= compare_value
"LT":
return blocker_value < compare_value
"EQ":
return blocker_value == compare_value
return false
# =============================================================================
# ATTACK RESTRICTION CHECKS
# =============================================================================
## Check if a card has attack restrictions
func has_attack_restriction(card: CardInstance, game_state) -> bool:
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "RESTRICTION":
var restriction = effect.get("restriction", "")
if restriction in ["CANNOT_ATTACK", "CANNOT_ATTACK_OR_BLOCK"]:
if _card_matches_effect_target(card, effect, source, game_state):
return true
return false
## Check if a card has block restrictions
func has_block_restriction(card: CardInstance, game_state) -> bool:
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "RESTRICTION":
var restriction = effect.get("restriction", "")
if restriction in ["CANNOT_BLOCK", "CANNOT_ATTACK_OR_BLOCK"]:
if _card_matches_effect_target(card, effect, source, game_state):
return true
return false
# =============================================================================
# TAUNT CHECKS (Must be targeted if possible)
# =============================================================================
## Get cards that must be targeted by opponent's abilities if possible
func get_taunt_targets(player_index: int, game_state) -> Array:
var taunt_cards: Array = []
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "TAUNT":
var target = effect.get("target", {})
if target.get("type") == "SELF":
if source.controller_index == player_index:
taunt_cards.append(source)
return taunt_cards
# =============================================================================
# COST MODIFICATION
# =============================================================================
## Get cost modifier for playing a card
func get_cost_modifier(
card_to_play: CardInstance,
playing_player: int,
game_state
) -> int:
var total_modifier = 0
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
var effect_type = effect.get("type", "")
if effect_type == "COST_REDUCTION":
if _cost_effect_applies(effect, card_to_play, playing_player, source, game_state):
total_modifier -= effect.get("amount", 0)
elif effect_type == "COST_REDUCTION_SCALING":
if _cost_effect_applies(effect, card_to_play, playing_player, source, game_state):
var reduction = _calculate_scaling_cost_reduction(effect, source, game_state)
total_modifier -= reduction
elif effect_type == "COST_INCREASE":
if _cost_effect_applies(effect, card_to_play, playing_player, source, game_state):
total_modifier += effect.get("amount", 0)
return total_modifier
## Check if a cost modification effect applies to a card being played
func _cost_effect_applies(
effect: Dictionary,
card: CardInstance,
player: int,
source: CardInstance,
game_state
) -> bool:
var for_player = effect.get("for_player", "CONTROLLER")
# Check if effect applies to this player
match for_player:
"CONTROLLER":
if player != source.controller_index:
return false
"OPPONENT":
if player == source.controller_index:
return false
# Check card filter
var card_filter = effect.get("card_filter", "")
if card_filter and not _card_matches_name_filter(card, card_filter):
return false
# Check condition
var condition = effect.get("condition", {})
if not condition.is_empty():
if not _cost_condition_met(condition, source, game_state):
return false
return true
## Check if a card name matches a filter
func _card_matches_name_filter(card: CardInstance, filter_text: String) -> bool:
if not card or not card.card_data:
return false
var filter_lower = filter_text.to_lower()
var card_name = card.card_data.name.to_lower()
# Direct name match
if card_name in filter_lower or filter_lower in card_name:
return true
return false
## Check if a cost condition is met
func _cost_condition_met(condition: Dictionary, source: CardInstance, game_state) -> bool:
if condition.has("control_card_name"):
var name_to_find = condition.control_card_name.to_lower()
var player = game_state.get_player(source.controller_index)
if player:
for card in player.field_forwards.get_cards():
if name_to_find in card.card_data.name.to_lower():
return true
for card in player.field_backups.get_cards():
if name_to_find in card.card_data.name.to_lower():
return true
return false
if condition.has("control_category"):
var category = condition.control_category.to_lower()
var player = game_state.get_player(source.controller_index)
if player:
for card in player.field_forwards.get_cards():
if category in card.card_data.category.to_lower():
return true
for card in player.field_backups.get_cards():
if category in card.card_data.category.to_lower():
return true
return false
return true
# =============================================================================
# SCALING COST REDUCTION
# =============================================================================
## Calculate cost reduction for a COST_REDUCTION_SCALING effect
func _calculate_scaling_cost_reduction(
effect: Dictionary,
source: CardInstance,
game_state
) -> int:
var reduction_per = effect.get("reduction_per", 1)
var scale_by = str(effect.get("scale_by", "")).to_upper()
var scale_filter = effect.get("scale_filter", {})
# Get scale value using similar logic to EffectResolver
var scale_value = _get_scale_value(scale_by, source, game_state, scale_filter)
return scale_value * reduction_per
## Get scale value based on scale_by type (with optional filter)
## Mirrors the logic in EffectResolver for consistency
func _get_scale_value(
scale_by: String,
source: CardInstance,
game_state,
scale_filter: Dictionary = {}
) -> int:
if not source or not game_state:
return 0
var player_index = source.controller_index
var player = game_state.get_player(player_index)
if not player:
return 0
# Determine owner from filter (default to CONTROLLER)
var owner = scale_filter.get("owner", "CONTROLLER").to_upper() if scale_filter else "CONTROLLER"
# Get cards based on scale_by and owner
var cards_to_count: Array = []
match scale_by:
"DAMAGE_RECEIVED":
# Special case - not card-based
return _get_damage_for_owner(owner, player_index, game_state)
"FORWARDS_CONTROLLED", "FORWARDS":
cards_to_count = _get_forwards_for_owner(owner, player_index, game_state)
"BACKUPS_CONTROLLED", "BACKUPS":
cards_to_count = _get_backups_for_owner(owner, player_index, game_state)
"FIELD_CARDS_CONTROLLED", "FIELD_CARDS":
cards_to_count = _get_field_cards_for_owner(owner, player_index, game_state)
"CARDS_IN_HAND":
cards_to_count = _get_hand_for_owner(owner, player_index, game_state)
"CARDS_IN_BREAK_ZONE":
cards_to_count = _get_break_zone_for_owner(owner, player_index, game_state)
"OPPONENT_FORWARDS":
cards_to_count = _get_forwards_for_owner("OPPONENT", player_index, game_state)
"OPPONENT_BACKUPS":
cards_to_count = _get_backups_for_owner("OPPONENT", player_index, game_state)
_:
push_warning("FieldEffectManager: Unknown scale_by type: " + scale_by)
return 0
# If no filter, just return count
if not scale_filter or scale_filter.is_empty() or (scale_filter.size() == 1 and scale_filter.has("owner")):
return cards_to_count.size()
# Apply filter and count matching cards using CardFilter utility
return CardFilter.count_matching(cards_to_count, scale_filter)
# =============================================================================
# OWNER-BASED ACCESS HELPERS FOR SCALING
# =============================================================================
func _get_forwards_for_owner(owner: String, player_index: int, game_state) -> Array:
match owner.to_upper():
"CONTROLLER":
var player = game_state.get_player(player_index)
return player.field_forwards.get_cards() if player and player.field_forwards else []
"OPPONENT":
var opponent = game_state.get_player(1 - player_index)
return opponent.field_forwards.get_cards() if opponent and opponent.field_forwards else []
_:
var all_cards = []
for p in game_state.players:
if p and p.field_forwards:
all_cards.append_array(p.field_forwards.get_cards())
return all_cards
func _get_backups_for_owner(owner: String, player_index: int, game_state) -> Array:
match owner.to_upper():
"CONTROLLER":
var player = game_state.get_player(player_index)
return player.field_backups.get_cards() if player and player.field_backups else []
"OPPONENT":
var opponent = game_state.get_player(1 - player_index)
return opponent.field_backups.get_cards() if opponent and opponent.field_backups else []
_:
var all_cards = []
for p in game_state.players:
if p and p.field_backups:
all_cards.append_array(p.field_backups.get_cards())
return all_cards
func _get_field_cards_for_owner(owner: String, player_index: int, game_state) -> Array:
var cards = []
cards.append_array(_get_forwards_for_owner(owner, player_index, game_state))
cards.append_array(_get_backups_for_owner(owner, player_index, game_state))
return cards
func _get_hand_for_owner(owner: String, player_index: int, game_state) -> Array:
match owner.to_upper():
"CONTROLLER":
var player = game_state.get_player(player_index)
return player.hand.get_cards() if player and player.hand else []
"OPPONENT":
var opponent = game_state.get_player(1 - player_index)
return opponent.hand.get_cards() if opponent and opponent.hand else []
_:
var all_cards = []
for p in game_state.players:
if p and p.hand:
all_cards.append_array(p.hand.get_cards())
return all_cards
func _get_break_zone_for_owner(owner: String, player_index: int, game_state) -> Array:
match owner.to_upper():
"CONTROLLER":
var player = game_state.get_player(player_index)
return player.break_zone.get_cards() if player and player.break_zone else []
"OPPONENT":
var opponent = game_state.get_player(1 - player_index)
return opponent.break_zone.get_cards() if opponent and opponent.break_zone else []
_:
var all_cards = []
for p in game_state.players:
if p and p.break_zone:
all_cards.append_array(p.break_zone.get_cards())
return all_cards
func _get_damage_for_owner(owner: String, player_index: int, game_state) -> int:
match owner.to_upper():
"CONTROLLER":
var player = game_state.get_player(player_index)
return player.damage if player and "damage" in player else 0
"OPPONENT":
var opponent = game_state.get_player(1 - player_index)
return opponent.damage if opponent and "damage" in opponent else 0
_:
var total = 0
for p in game_state.players:
if p and "damage" in p:
total += p.damage
return total
# =============================================================================
# MULTI-ATTACK CHECKS
# =============================================================================
## Get maximum attacks allowed for a card this turn
func get_max_attacks(card: CardInstance, game_state) -> int:
var max_attacks = 1 # Default is 1 attack per turn
for instance_id in _active_abilities:
var abilities = _active_abilities[instance_id]
for ability_data in abilities:
var ability = ability_data.ability
var source = ability_data.source
var parsed = ability.get("parsed", {})
for effect in parsed.get("effects", []):
if effect.get("type") == "MULTI_ATTACK":
if _card_matches_effect_target(card, effect, source, game_state):
var attack_count = effect.get("attack_count", 1)
if attack_count > max_attacks:
max_attacks = attack_count
return max_attacks

View File

@@ -0,0 +1,174 @@
class_name TargetSelector
extends RefCounted
## TargetSelector - Validates and provides target options for effects
## Get all valid targets for an effect's target specification
func get_valid_targets(
target_spec: Dictionary,
source: CardInstance,
game_state
) -> Array:
if target_spec.is_empty():
return []
var candidates: Array = []
var zone = str(target_spec.get("zone", "FIELD")).to_upper()
var owner = str(target_spec.get("owner", "ANY")).to_upper()
var filter = target_spec.get("filter", {})
var target_type = str(target_spec.get("type", "CHOOSE")).to_upper()
# Handle SELF and ALL targets specially
if target_type == "SELF":
return [source]
elif target_type == "ALL":
return _get_all_matching(owner, zone, filter, source, game_state)
# Collect candidates from appropriate zones
match zone:
"FIELD":
candidates = _get_field_cards(owner, source, game_state)
"HAND":
candidates = _get_hand_cards(owner, source, game_state)
"BREAK_ZONE", "BREAK":
candidates = _get_break_zone_cards(owner, source, game_state)
"DECK":
candidates = _get_deck_cards(owner, source, game_state)
_:
# Default to field
candidates = _get_field_cards(owner, source, game_state)
# Apply filters using CardFilter utility
return CardFilter.get_matching(candidates, filter, source)
## Get all cards matching filter (for "ALL" target type)
func _get_all_matching(
owner: String,
zone: String,
filter: Dictionary,
source: CardInstance,
game_state
) -> Array:
var candidates = _get_field_cards(owner, source, game_state)
return CardFilter.get_matching(candidates, filter, source)
## Get cards from field
func _get_field_cards(
owner: String,
source: CardInstance,
game_state
) -> Array:
var cards: Array = []
match owner:
"CONTROLLER":
var player = game_state.get_player(source.controller_index)
if player:
cards.append_array(_get_player_field_cards(player))
"OPPONENT":
var opponent = game_state.get_player(1 - source.controller_index)
if opponent:
cards.append_array(_get_player_field_cards(opponent))
"ANY", _:
for player in game_state.players:
cards.append_array(_get_player_field_cards(player))
return cards
## Get all field cards for a player
func _get_player_field_cards(player) -> Array:
var cards: Array = []
cards.append_array(player.field_forwards.get_cards())
cards.append_array(player.field_backups.get_cards())
return cards
## Get cards from hand
func _get_hand_cards(
owner: String,
source: CardInstance,
game_state
) -> Array:
var cards: Array = []
var player_index = source.controller_index
if owner == "OPPONENT":
player_index = 1 - player_index
var player = game_state.get_player(player_index)
if player:
cards.append_array(player.hand.get_cards())
return cards
## Get cards from break zone
func _get_break_zone_cards(
owner: String,
source: CardInstance,
game_state
) -> Array:
var cards: Array = []
var player_index = source.controller_index
if owner == "OPPONENT":
player_index = 1 - player_index
var player = game_state.get_player(player_index)
if player:
cards.append_array(player.break_zone.get_cards())
return cards
## Get cards from deck
func _get_deck_cards(
owner: String,
source: CardInstance,
game_state
) -> Array:
# Usually not directly targetable, used for search effects
var cards: Array = []
var player_index = source.controller_index
if owner == "OPPONENT":
player_index = 1 - player_index
var player = game_state.get_player(player_index)
if player:
cards.append_array(player.deck.get_cards())
return cards
## Validate that a set of targets meets the target specification requirements
func validate_targets(
targets: Array,
target_spec: Dictionary,
source: CardInstance,
game_state
) -> bool:
var target_type = str(target_spec.get("type", "CHOOSE")).to_upper()
# Check count requirements
if target_spec.has("count"):
var required = int(target_spec.count)
if targets.size() != required:
return false
elif target_spec.has("count_up_to"):
var max_count = int(target_spec.count_up_to)
if targets.size() > max_count:
return false
# Validate each target is valid
var valid_targets = get_valid_targets(target_spec, source, game_state)
for target in targets:
if target not in valid_targets:
return false
return true

View File

@@ -0,0 +1,233 @@
class_name TriggerMatcher
extends RefCounted
## TriggerMatcher - Matches game events to ability triggers
## Scans all cards on field for abilities that trigger from the given event
## Reference to ConditionChecker for evaluating trigger conditions
var condition_checker: ConditionChecker = null
## Find all abilities that should trigger for a given event
func find_triggered_abilities(
event_type: String,
event_data: Dictionary,
game_state,
all_abilities: Dictionary
) -> Array:
var triggered = []
# Check abilities on all cards in play
for player in game_state.players:
# Check forwards
for card in player.field_forwards.get_cards():
var card_abilities = all_abilities.get(card.card_data.id, [])
triggered.append_array(_check_card_abilities(card, card_abilities, event_type, event_data, game_state))
# Check backups
for card in player.field_backups.get_cards():
var card_abilities = all_abilities.get(card.card_data.id, [])
triggered.append_array(_check_card_abilities(card, card_abilities, event_type, event_data, game_state))
return triggered
## Check all abilities on a card for triggers
func _check_card_abilities(
card: CardInstance,
abilities: Array,
event_type: String,
event_data: Dictionary,
game_state
) -> Array:
var triggered = []
for ability in abilities:
if _matches_trigger(ability, event_type, event_data, card, game_state):
triggered.append({
"source": card,
"ability": ability,
"event_data": event_data
})
return triggered
## Check if an ability's trigger matches the event
func _matches_trigger(
ability: Dictionary,
event_type: String,
event_data: Dictionary,
source_card: CardInstance,
game_state
) -> bool:
var parsed = ability.get("parsed", {})
if parsed.is_empty():
return false
# Only AUTO abilities have triggers
if parsed.get("type") != "AUTO":
return false
var trigger = parsed.get("trigger", {})
if trigger.is_empty():
return false
# Check event type matches
var trigger_event = trigger.get("event", "")
if not _event_matches(trigger_event, event_type):
return false
# Check source filter
var trigger_source = trigger.get("source", "ANY")
if not _source_matches(trigger_source, event_data, source_card, game_state):
return false
# Check additional trigger filters
if trigger.has("source_filter"):
var filter = trigger.source_filter
var event_card = event_data.get("card")
if event_card and not _matches_card_filter(event_card, filter):
return false
# Check trigger condition (if present)
var trigger_condition = trigger.get("condition", {})
if not trigger_condition.is_empty() and condition_checker:
var context = {
"source_card": source_card,
"target_card": event_data.get("card"),
"game_state": game_state,
"player_id": source_card.controller_index if source_card else 0,
"event_data": event_data
}
if not condition_checker.evaluate(trigger_condition, context):
return false
return true
## Check if event type matches trigger event
func _event_matches(trigger_event: String, actual_event: String) -> bool:
# Direct match
if trigger_event == actual_event:
return true
# Handle variations
match trigger_event:
"ENTERS_FIELD":
return actual_event in ["ENTERS_FIELD", "CARD_PLAYED"]
"LEAVES_FIELD":
return actual_event in ["LEAVES_FIELD", "FORWARD_BROKEN", "CARD_BROKEN"]
"DEALS_DAMAGE":
return actual_event in ["DEALS_DAMAGE", "DEALS_DAMAGE_TO_OPPONENT", "DEALS_DAMAGE_TO_FORWARD"]
"DEALS_DAMAGE_TO_OPPONENT":
return actual_event == "DEALS_DAMAGE_TO_OPPONENT"
"DEALS_DAMAGE_TO_FORWARD":
return actual_event == "DEALS_DAMAGE_TO_FORWARD"
"BLOCKS_OR_IS_BLOCKED":
return actual_event in ["BLOCKS", "IS_BLOCKED"]
return false
## Check if source matches trigger requirements
func _source_matches(
trigger_source: String,
event_data: Dictionary,
source_card: CardInstance,
game_state
) -> bool:
var event_card = event_data.get("card")
match trigger_source:
"SELF":
# Trigger source must be this card
return event_card == source_card
"CONTROLLER":
# Trigger source must be controlled by same player
if event_card:
return event_card.controller_index == source_card.controller_index
var event_player = event_data.get("player", -1)
return event_player == source_card.controller_index
"OPPONENT":
# Trigger source must be controlled by opponent
if event_card:
return event_card.controller_index != source_card.controller_index
var event_player = event_data.get("player", -1)
return event_player != source_card.controller_index and event_player >= 0
"ANY", _:
# Any source triggers
return true
return true
## Check if a card matches a filter
func _matches_card_filter(card: CardInstance, filter: Dictionary) -> bool:
if filter.is_empty():
return true
# Card type filter
if filter.has("card_type"):
var type_str = str(filter.card_type).to_upper()
match type_str:
"FORWARD":
if not card.is_forward():
return false
"BACKUP":
if not card.is_backup():
return false
"SUMMON":
if not card.is_summon():
return false
# Element filter
if filter.has("element"):
var element_str = str(filter.element).to_upper()
var element = Enums.element_from_string(element_str)
if element not in card.get_elements():
return false
# Cost filters
if filter.has("cost_min"):
if card.card_data.cost < filter.cost_min:
return false
if filter.has("cost_max"):
if card.card_data.cost > filter.cost_max:
return false
if filter.has("cost"):
if card.card_data.cost != filter.cost:
return false
# Power filters
if filter.has("power_min"):
if card.get_power() < filter.power_min:
return false
if filter.has("power_max"):
if card.get_power() > filter.power_max:
return false
# State filters
if filter.has("is_dull"):
if card.is_dull() != filter.is_dull:
return false
if filter.has("is_active"):
if card.is_active() != filter.is_active:
return false
# Name filter
if filter.has("name"):
if card.card_data.name != filter.name:
return false
# Category filter
if filter.has("category"):
if card.card_data.category != filter.category:
return false
# Job filter
if filter.has("job"):
if card.card_data.job != filter.job:
return false
return true

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)

View File

@@ -0,0 +1,617 @@
class_name NetworkManager
extends Node
## NetworkManager - Singleton for handling network communication
## Manages authentication, WebSocket connection, and game messaging
# ======= SIGNALS =======
# Connection and auth
signal connection_state_changed(state: ConnectionState)
signal authenticated(user_data: Dictionary)
signal authentication_failed(error: String)
signal logged_out
# Matchmaking - maps from server messages: queue_joined, queue_left, match_found, room_*
signal matchmaking_update(data: Dictionary)
signal queue_joined
signal queue_left
signal match_found(game_data: Dictionary)
signal room_created(room_data: Dictionary)
signal room_joined(room_data: Dictionary)
signal room_updated(room_data: Dictionary)
# Game messages - maps from server 'opponent_action' message
signal opponent_action_received(action: Dictionary)
# Maps from server 'turn_timer' message
signal turn_timer_update(seconds_remaining: int)
signal game_started(game_data: Dictionary)
signal game_ended(result: Dictionary)
signal phase_changed(phase_data: Dictionary)
signal action_confirmed(action_type: String)
signal action_failed(action_type: String, error: String)
signal opponent_disconnected(reconnect_timeout: int)
signal opponent_reconnected
signal game_state_sync(state: Dictionary)
# Error handling
signal network_error(error: String)
# ======= ENUMS =======
enum ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
AUTHENTICATING,
AUTHENTICATED,
IN_QUEUE,
IN_ROOM,
IN_GAME
}
# ======= CONSTANTS =======
const DEFAULT_HTTP_URL = "http://localhost:3000"
const DEFAULT_WS_URL = "ws://localhost:3001"
const HEARTBEAT_INTERVAL = 10.0 # seconds
const RECONNECT_DELAY = 5.0
const MAX_RECONNECT_ATTEMPTS = 3
const TOKEN_FILE = "user://auth_token.dat"
# ======= STATE =======
var connection_state: ConnectionState = ConnectionState.DISCONNECTED
var http_base_url: String = DEFAULT_HTTP_URL
var ws_url: String = DEFAULT_WS_URL
# Auth state
var auth_token: String = ""
var current_user: Dictionary = {}
var is_authenticated: bool = false
# WebSocket
var _websocket: WebSocketPeer = null
var _heartbeat_timer: Timer = null
var _reconnect_attempts: int = 0
# HTTP request pool
var _http_requests: Array[HTTPRequest] = []
# Game session
var current_game_id: String = ""
var current_room_code: String = ""
var opponent_info: Dictionary = {}
var local_player_index: int = 0
func _ready() -> void:
# Try to load saved token on startup
_load_token()
# Setup heartbeat timer
_heartbeat_timer = Timer.new()
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL
_heartbeat_timer.timeout.connect(_on_heartbeat_timeout)
add_child(_heartbeat_timer)
func _process(_delta: float) -> void:
# Poll WebSocket if connected
if _websocket:
_websocket.poll()
var state = _websocket.get_ready_state()
match state:
WebSocketPeer.STATE_OPEN:
while _websocket.get_available_packet_count() > 0:
var packet = _websocket.get_packet()
_on_websocket_message(packet)
WebSocketPeer.STATE_CLOSING:
pass # Wait for close
WebSocketPeer.STATE_CLOSED:
var code = _websocket.get_close_code()
var reason = _websocket.get_close_reason()
print("WebSocket closed: ", code, " - ", reason)
_on_websocket_closed()
# ======= CONFIGURATION =======
func configure(http_url: String, ws_url_param: String) -> void:
http_base_url = http_url
ws_url = ws_url_param
# ======= HTTP AUTH API =======
func register(email: String, password: String, username: String) -> Dictionary:
var result = await _http_post("/api/auth/register", {
"email": email,
"password": password,
"username": username
})
return result
func login(email: String, password: String) -> Dictionary:
var result = await _http_post("/api/auth/login", {
"email": email,
"password": password
})
if result.success:
auth_token = result.token
current_user = result.user
is_authenticated = true
_save_token()
authenticated.emit(current_user)
else:
authentication_failed.emit(result.message)
return result
func logout() -> void:
auth_token = ""
current_user = {}
is_authenticated = false
_clear_token()
disconnect_websocket()
logged_out.emit()
func verify_email(token: String) -> Dictionary:
return await _http_post("/api/auth/verify-email", { "token": token })
func forgot_password(email: String) -> Dictionary:
return await _http_post("/api/auth/forgot-password", { "email": email })
func reset_password(token: String, new_password: String) -> Dictionary:
return await _http_post("/api/auth/reset-password", {
"token": token,
"newPassword": new_password
})
func resend_verification(email: String) -> Dictionary:
return await _http_post("/api/auth/resend-verification", { "email": email })
func get_profile() -> Dictionary:
return await _http_get("/api/user/profile", true)
func get_match_history(limit: int = 20, offset: int = 0) -> Dictionary:
return await _http_get("/api/user/match-history?limit=%d&offset=%d" % [limit, offset], true)
func get_leaderboard(limit: int = 50, offset: int = 0) -> Dictionary:
return await _http_get("/api/leaderboard?limit=%d&offset=%d" % [limit, offset], false)
func save_deck(name: String, card_ids: Array) -> Dictionary:
return await _http_post("/api/user/decks", {
"name": name,
"cardIds": card_ids
}, true)
func delete_deck(deck_id: String) -> Dictionary:
return await _http_delete("/api/user/decks/" + deck_id)
# ======= HTTP HELPERS =======
func _get_http_request() -> HTTPRequest:
# Reuse or create HTTP request node
for req in _http_requests:
if not req.is_inside_tree():
add_child(req)
# Check if request is not busy (simplified check)
return req
var new_req = HTTPRequest.new()
_http_requests.append(new_req)
add_child(new_req)
return new_req
func _http_post(endpoint: String, body: Dictionary, auth_required: bool = false) -> Dictionary:
var http = _get_http_request()
var url = http_base_url + endpoint
var headers = PackedStringArray(["Content-Type: application/json"])
if auth_required and auth_token != "":
headers.append("Authorization: Bearer " + auth_token)
var json_body = JSON.stringify(body)
var error = http.request(url, headers, HTTPClient.METHOD_POST, json_body)
if error != OK:
return { "success": false, "message": "HTTP request failed" }
var result = await http.request_completed
return _parse_http_response(result)
func _http_get(endpoint: String, auth_required: bool = false) -> Dictionary:
var http = _get_http_request()
var url = http_base_url + endpoint
var headers = PackedStringArray()
if auth_required and auth_token != "":
headers.append("Authorization: Bearer " + auth_token)
var error = http.request(url, headers, HTTPClient.METHOD_GET)
if error != OK:
return { "success": false, "message": "HTTP request failed" }
var result = await http.request_completed
return _parse_http_response(result)
func _http_delete(endpoint: String) -> Dictionary:
var http = _get_http_request()
var url = http_base_url + endpoint
var headers = PackedStringArray()
if auth_token != "":
headers.append("Authorization: Bearer " + auth_token)
var error = http.request(url, headers, HTTPClient.METHOD_DELETE)
if error != OK:
return { "success": false, "message": "HTTP request failed" }
var result = await http.request_completed
return _parse_http_response(result)
func _parse_http_response(result: Array) -> Dictionary:
var response_code = result[1]
var body = result[3]
if response_code == 0:
return { "success": false, "message": "Connection failed" }
var json = JSON.new()
var parse_result = json.parse(body.get_string_from_utf8())
if parse_result != OK:
return { "success": false, "message": "Invalid response" }
var data = json.data
if data is Dictionary:
return data
return { "success": false, "message": "Unexpected response format" }
# ======= WEBSOCKET =======
func connect_websocket() -> void:
if _websocket != null:
disconnect_websocket()
_set_connection_state(ConnectionState.CONNECTING)
_websocket = WebSocketPeer.new()
var error = _websocket.connect_to_url(ws_url)
if error != OK:
print("WebSocket connection error: ", error)
_set_connection_state(ConnectionState.DISCONNECTED)
network_error.emit("Failed to connect to server")
return
# Wait for connection
await get_tree().create_timer(0.5).timeout
if _websocket.get_ready_state() == WebSocketPeer.STATE_OPEN:
_on_websocket_connected()
func disconnect_websocket() -> void:
if _websocket:
_websocket.close()
_websocket = null
_heartbeat_timer.stop()
_set_connection_state(ConnectionState.DISCONNECTED)
current_game_id = ""
current_room_code = ""
func _on_websocket_connected() -> void:
print("WebSocket connected")
_set_connection_state(ConnectionState.CONNECTED)
_reconnect_attempts = 0
# Authenticate with JWT
if auth_token != "":
_set_connection_state(ConnectionState.AUTHENTICATING)
_send_ws_message("auth", { "token": auth_token })
_heartbeat_timer.start()
func _on_websocket_message(data: PackedByteArray) -> void:
var json = JSON.new()
var parse_result = json.parse(data.get_string_from_utf8())
if parse_result != OK:
push_error("Failed to parse WebSocket message: " + str(parse_result))
network_error.emit("Invalid message from server")
return
var message = json.data
if not message is Dictionary or not message.has("type"):
push_error("Invalid message format: missing 'type' field")
network_error.emit("Invalid message format from server")
return
_handle_ws_message(message)
func _on_websocket_closed() -> void:
_websocket = null
_heartbeat_timer.stop()
var was_authenticated = connection_state == ConnectionState.AUTHENTICATED or connection_state == ConnectionState.IN_GAME
_set_connection_state(ConnectionState.DISCONNECTED)
# Try to reconnect if we were authenticated
if was_authenticated and _reconnect_attempts < MAX_RECONNECT_ATTEMPTS:
_reconnect_attempts += 1
print("Attempting reconnect... (attempt ", _reconnect_attempts, ")")
await get_tree().create_timer(RECONNECT_DELAY).timeout
connect_websocket()
func _handle_ws_message(message: Dictionary) -> void:
var msg_type = message.get("type", "")
var payload = message.get("payload", {})
match msg_type:
"auth_success":
print("WebSocket authenticated as: ", payload.get("username", ""))
_set_connection_state(ConnectionState.AUTHENTICATED)
"auth_error":
print("WebSocket auth error: ", payload.get("message", ""))
_set_connection_state(ConnectionState.CONNECTED)
authentication_failed.emit(payload.get("message", "Authentication failed"))
"pong":
pass # Heartbeat response
"error":
print("Server error: ", payload.get("message", ""))
network_error.emit(payload.get("message", "Unknown error"))
"disconnected":
print("Disconnected: ", payload.get("message", ""))
disconnect_websocket()
# Matchmaking messages
"queue_joined":
_set_connection_state(ConnectionState.IN_QUEUE)
queue_joined.emit()
matchmaking_update.emit({ "type": "queue_joined", "position": payload.get("position", 0) })
"queue_left":
_set_connection_state(ConnectionState.AUTHENTICATED)
queue_left.emit()
matchmaking_update.emit({ "type": "queue_left" })
"match_found":
_set_connection_state(ConnectionState.IN_GAME)
current_game_id = payload.get("game_id", "")
opponent_info = payload.get("opponent", {})
local_player_index = payload.get("your_player_index", 0)
match_found.emit(payload)
"room_created":
_set_connection_state(ConnectionState.IN_ROOM)
current_room_code = payload.get("code", "")
room_created.emit(payload)
"room_joined":
_set_connection_state(ConnectionState.IN_ROOM)
current_room_code = payload.get("code", "")
room_joined.emit(payload)
"room_updated":
room_updated.emit(payload)
"room_left":
_set_connection_state(ConnectionState.AUTHENTICATED)
current_room_code = ""
matchmaking_update.emit({ "type": "room_left" })
# Game messages
"game_start":
_set_connection_state(ConnectionState.IN_GAME)
current_game_id = payload.get("game_id", "")
game_started.emit(payload)
"opponent_action":
opponent_action_received.emit(payload)
"turn_timer":
turn_timer_update.emit(payload.get("seconds_remaining", 0))
"phase_changed":
phase_changed.emit(payload)
"action_confirmed":
action_confirmed.emit(payload.get("action_type", ""))
"action_failed":
action_failed.emit(payload.get("action_type", ""), payload.get("error", "Unknown error"))
network_error.emit("Action failed: " + payload.get("error", "Unknown error"))
"opponent_disconnected":
opponent_disconnected.emit(payload.get("reconnect_timeout_seconds", 60))
"opponent_reconnected":
opponent_reconnected.emit()
"game_state_sync":
game_state_sync.emit(payload)
"game_ended":
game_ended.emit(payload)
_set_connection_state(ConnectionState.AUTHENTICATED)
current_game_id = ""
_:
print("Unknown message type: ", msg_type)
func _send_ws_message(type: String, payload: Dictionary) -> void:
if _websocket == null or _websocket.get_ready_state() != WebSocketPeer.STATE_OPEN:
print("Cannot send message - WebSocket not connected")
return
var message = {
"type": type,
"payload": payload
}
var json = JSON.stringify(message)
_websocket.send_text(json)
func _on_heartbeat_timeout() -> void:
if _websocket and _websocket.get_ready_state() == WebSocketPeer.STATE_OPEN:
_send_ws_message("ping", { "client_time": Time.get_unix_time_from_system() })
func _set_connection_state(new_state: ConnectionState) -> void:
if connection_state != new_state:
connection_state = new_state
connection_state_changed.emit(new_state)
# ======= MATCHMAKING =======
func join_queue(deck_id: String) -> void:
_send_ws_message("queue_join", { "deck_id": deck_id })
func leave_queue() -> void:
_send_ws_message("queue_leave", {})
func create_room(deck_id: String) -> void:
_send_ws_message("room_create", { "deck_id": deck_id })
func join_room(room_code: String, deck_id: String) -> void:
_send_ws_message("room_join", { "room_code": room_code.to_upper(), "deck_id": deck_id })
func leave_room() -> void:
_send_ws_message("room_leave", {})
func set_room_ready(ready: bool) -> void:
_send_ws_message("room_ready", { "ready": ready })
# ======= GAME ACTIONS =======
func send_game_action(action_type: String, payload: Dictionary) -> void:
payload["game_id"] = current_game_id
_send_ws_message("action_" + action_type, payload)
func send_play_card(card_instance_id: int) -> void:
send_game_action("play_card", { "card_instance_id": card_instance_id })
func send_attack(attacker_instance_id: int) -> void:
send_game_action("attack", { "attacker_instance_id": attacker_instance_id })
func send_block(blocker_instance_id) -> void: # Can be int or null
send_game_action("block", { "blocker_instance_id": blocker_instance_id })
func send_pass() -> void:
send_game_action("pass", {})
func send_concede() -> void:
send_game_action("concede", {})
func send_discard_for_cp(card_instance_id: int) -> void:
send_game_action("discard_cp", { "card_instance_id": card_instance_id })
func send_dull_backup_for_cp(card_instance_id: int) -> void:
send_game_action("dull_backup_cp", { "card_instance_id": card_instance_id })
func send_attack_resolved() -> void:
send_game_action("attack_resolved", {})
func send_report_game_end(winner_id: String, reason: String) -> void:
send_game_action("report_game_end", { "winner_id": winner_id, "reason": reason })
# ======= TOKEN PERSISTENCE =======
func _save_token() -> void:
if auth_token == "":
return
var file = FileAccess.open(TOKEN_FILE, FileAccess.WRITE)
if file:
file.store_string(auth_token)
file.close()
func _load_token() -> void:
if not FileAccess.file_exists(TOKEN_FILE):
return
var file = FileAccess.open(TOKEN_FILE, FileAccess.READ)
if file:
auth_token = file.get_as_text().strip_edges()
file.close()
if auth_token != "":
# Validate token by fetching profile
var profile = await get_profile()
if profile.success:
current_user = profile.user
is_authenticated = true
authenticated.emit(current_user)
else:
# Token invalid, clear it
_clear_token()
func _clear_token() -> void:
auth_token = ""
if FileAccess.file_exists(TOKEN_FILE):
DirAccess.remove_absolute(TOKEN_FILE)
# ======= UTILITY =======
func get_connection_state_name() -> String:
match connection_state:
ConnectionState.DISCONNECTED: return "Disconnected"
ConnectionState.CONNECTING: return "Connecting"
ConnectionState.CONNECTED: return "Connected"
ConnectionState.AUTHENTICATING: return "Authenticating"
ConnectionState.AUTHENTICATED: return "Online"
ConnectionState.IN_QUEUE: return "In Queue"
ConnectionState.IN_ROOM: return "In Room"
ConnectionState.IN_GAME: return "In Game"
return "Unknown"

347
scripts/ui/ChoiceModal.gd Normal file
View File

@@ -0,0 +1,347 @@
class_name ChoiceModal
extends CanvasLayer
## ChoiceModal - UI component for multi-modal ability choices
## Displays options like "Select 1 of 3 following actions" and returns selection
signal choice_made(selected_indices: Array)
signal choice_cancelled
# UI Elements
var backdrop: ColorRect
var modal_panel: Panel
var title_label: Label
var options_container: VBoxContainer
var confirm_button: Button
var cancel_button: Button
# State
var _modes: Array = []
var _select_count: int = 1
var _select_up_to: bool = false
var _selected_indices: Array = []
var _cancellable: bool = false
var _option_buttons: Array = []
# Cached styles for option buttons (created once, reused)
var _option_normal_style: StyleBoxFlat
var _option_selected_style: StyleBoxFlat
func _ready() -> void:
layer = 200 # High z-index for modal overlay
_create_cached_styles()
_create_ui()
visible = false
func _create_cached_styles() -> void:
# Normal button style
_option_normal_style = StyleBoxFlat.new()
_option_normal_style.bg_color = Color(0.15, 0.15, 0.2, 0.9)
_option_normal_style.set_border_width_all(1)
_option_normal_style.border_color = Color(0.3, 0.3, 0.4)
_option_normal_style.set_corner_radius_all(4)
_option_normal_style.set_content_margin_all(10)
# Selected button style (gold highlight)
_option_selected_style = StyleBoxFlat.new()
_option_selected_style.bg_color = Color(0.25, 0.22, 0.15, 0.95)
_option_selected_style.border_color = Color(0.7, 0.55, 0.2)
_option_selected_style.set_border_width_all(2)
_option_selected_style.set_corner_radius_all(4)
_option_selected_style.set_content_margin_all(10)
func _create_ui() -> void:
# Backdrop - semi-transparent dark overlay
backdrop = ColorRect.new()
add_child(backdrop)
backdrop.color = Color(0, 0, 0, 0.7)
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
backdrop.gui_input.connect(_on_backdrop_input)
# Center container for modal
var center = CenterContainer.new()
add_child(center)
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Modal panel
modal_panel = Panel.new()
center.add_child(modal_panel)
modal_panel.custom_minimum_size = Vector2(500, 200)
var style = StyleBoxFlat.new()
style.bg_color = Color(0.08, 0.08, 0.12, 0.98)
style.border_color = Color(0.5, 0.4, 0.2) # Gold border
style.set_border_width_all(2)
style.set_corner_radius_all(8)
style.set_content_margin_all(20)
modal_panel.add_theme_stylebox_override("panel", style)
# Main vertical layout
var vbox = VBoxContainer.new()
modal_panel.add_child(vbox)
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
vbox.offset_left = 20
vbox.offset_right = -20
vbox.offset_top = 20
vbox.offset_bottom = -20
vbox.add_theme_constant_override("separation", 15)
# Title
title_label = Label.new()
vbox.add_child(title_label)
title_label.text = "Select an action:"
title_label.add_theme_font_size_override("font_size", 20)
title_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# Options container
options_container = VBoxContainer.new()
vbox.add_child(options_container)
options_container.add_theme_constant_override("separation", 8)
options_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
# Button row
var button_row = HBoxContainer.new()
vbox.add_child(button_row)
button_row.add_theme_constant_override("separation", 15)
button_row.alignment = BoxContainer.ALIGNMENT_CENTER
# Confirm button (for multi-select)
confirm_button = _create_button("Confirm", Color(0.2, 0.5, 0.3))
button_row.add_child(confirm_button)
confirm_button.pressed.connect(_on_confirm_pressed)
confirm_button.visible = false # Only shown for multi-select
# Cancel button
cancel_button = _create_button("Cancel", Color(0.5, 0.3, 0.3))
button_row.add_child(cancel_button)
cancel_button.pressed.connect(_on_cancel_pressed)
cancel_button.visible = false # Only shown if cancellable
func _create_button(text: String, base_color: Color) -> Button:
var button = Button.new()
button.text = text
button.custom_minimum_size = Vector2(100, 40)
var normal_style = StyleBoxFlat.new()
normal_style.bg_color = base_color
normal_style.set_border_width_all(1)
normal_style.border_color = base_color.lightened(0.3)
normal_style.set_corner_radius_all(4)
normal_style.set_content_margin_all(8)
button.add_theme_stylebox_override("normal", normal_style)
var hover_style = normal_style.duplicate()
hover_style.bg_color = base_color.lightened(0.15)
button.add_theme_stylebox_override("hover", hover_style)
var pressed_style = normal_style.duplicate()
pressed_style.bg_color = base_color.darkened(0.1)
button.add_theme_stylebox_override("pressed", pressed_style)
return button
func _create_option_button(index: int, description: String) -> Button:
var button = Button.new()
button.text = str(index + 1) + ". " + description
button.custom_minimum_size = Vector2(460, 50)
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
button.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
# Normal state
var normal_style = StyleBoxFlat.new()
normal_style.bg_color = Color(0.15, 0.15, 0.2, 0.9)
normal_style.set_border_width_all(1)
normal_style.border_color = Color(0.3, 0.3, 0.4)
normal_style.set_corner_radius_all(4)
normal_style.set_content_margin_all(10)
button.add_theme_stylebox_override("normal", normal_style)
# Hover state
var hover_style = normal_style.duplicate()
hover_style.bg_color = Color(0.2, 0.2, 0.3, 0.95)
hover_style.border_color = Color(0.5, 0.4, 0.2)
button.add_theme_stylebox_override("hover", hover_style)
# Pressed/selected state (gold highlight)
var pressed_style = normal_style.duplicate()
pressed_style.bg_color = Color(0.25, 0.22, 0.15, 0.95)
pressed_style.border_color = Color(0.7, 0.55, 0.2)
pressed_style.set_border_width_all(2)
button.add_theme_stylebox_override("pressed", pressed_style)
# Font settings
button.add_theme_font_size_override("font_size", 14)
button.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85))
button.add_theme_color_override("font_hover_color", Color(1, 0.95, 0.8))
# Connect press signal
button.pressed.connect(_on_option_pressed.bind(index))
return button
## Show modal and await selection
## Returns array of selected mode indices
func show_choices(
title: String,
modes: Array,
select_count: int = 1,
select_up_to: bool = false,
cancellable: bool = false
) -> Array:
_modes = modes
_select_count = select_count
_select_up_to = select_up_to
_cancellable = cancellable
_selected_indices = []
_option_buttons = []
# Update title
if select_up_to:
title_label.text = "Select up to %d action%s:" % [select_count, "s" if select_count > 1 else ""]
else:
title_label.text = "Select %d action%s:" % [select_count, "s" if select_count > 1 else ""]
# Clear and populate options
for child in options_container.get_children():
child.queue_free()
await get_tree().process_frame # Wait for queue_free
for i in range(modes.size()):
var mode = modes[i]
var description = mode.get("description", "Option " + str(i + 1))
var button = _create_option_button(i, description)
options_container.add_child(button)
_option_buttons.append(button)
# Show confirm button only for multi-select
confirm_button.visible = (select_count > 1 or select_up_to)
_update_confirm_button()
# Show cancel if cancellable
cancel_button.visible = cancellable
# Resize panel to fit content
await get_tree().process_frame
var content_height = 20 + 30 + 15 + (modes.size() * 58) + 15 + 50 + 20
modal_panel.custom_minimum_size = Vector2(500, min(content_height, 600))
visible = true
# Wait for selection
var result = await _wait_for_selection()
visible = false
return result
## Internal: Wait for user selection using a callback pattern
func _wait_for_selection() -> Array:
var result: Array = []
# Create a one-shot signal connection
var completed = false
var on_choice = func(indices: Array):
result = indices
completed = true
var on_cancel = func():
result = []
completed = true
choice_made.connect(on_choice, CONNECT_ONE_SHOT)
choice_cancelled.connect(on_cancel, CONNECT_ONE_SHOT)
# Wait until completed
while not completed:
await get_tree().process_frame
return result
func _on_option_pressed(index: int) -> void:
if _select_count == 1 and not _select_up_to:
# Single select - immediately return
choice_made.emit([index])
return
# Multi-select - toggle selection
if index in _selected_indices:
_selected_indices.erase(index)
else:
if _selected_indices.size() < _select_count:
_selected_indices.append(index)
_update_option_visuals()
_update_confirm_button()
func _update_option_visuals() -> void:
for i in range(_option_buttons.size()):
var button = _option_buttons[i] as Button
var is_selected = i in _selected_indices
# Use cached styles instead of creating new ones each time
if is_selected:
button.add_theme_stylebox_override("normal", _option_selected_style)
else:
button.add_theme_stylebox_override("normal", _option_normal_style)
func _update_confirm_button() -> void:
if _select_up_to:
confirm_button.disabled = false
confirm_button.text = "Confirm (%d)" % _selected_indices.size()
else:
confirm_button.disabled = _selected_indices.size() != _select_count
confirm_button.text = "Confirm (%d/%d)" % [_selected_indices.size(), _select_count]
func _on_confirm_pressed() -> void:
if _select_up_to or _selected_indices.size() == _select_count:
choice_made.emit(_selected_indices.duplicate())
func _on_cancel_pressed() -> void:
choice_cancelled.emit()
func _on_backdrop_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
if _cancellable:
choice_cancelled.emit()
func _input(event: InputEvent) -> void:
if not visible:
return
# Keyboard shortcuts
if event is InputEventKey and event.pressed:
# Number keys 1-9 for quick selection
if event.keycode >= KEY_1 and event.keycode <= KEY_9:
var index = event.keycode - KEY_1
if index < _modes.size():
_on_option_pressed(index)
get_viewport().set_input_as_handled()
# Enter to confirm (multi-select only)
elif event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER:
if confirm_button.visible and not confirm_button.disabled:
_on_confirm_pressed()
get_viewport().set_input_as_handled()
# Escape to cancel
elif event.keycode == KEY_ESCAPE:
if _cancellable:
choice_cancelled.emit()
get_viewport().set_input_as_handled()

View File

@@ -5,7 +5,7 @@ extends CanvasLayer
## Allows selection of game type and decks for each player
signal back_pressed
signal start_game_requested(p1_deck: Array, p2_deck: Array)
signal start_game_requested(p1_deck: Array, p2_deck: Array, is_vs_ai: bool, ai_difficulty: int)
const WINDOW_SIZE := Vector2(800, 600)
@@ -15,6 +15,8 @@ var main_vbox: VBoxContainer
var title_label: Label
var game_type_container: HBoxContainer
var game_type_dropdown: OptionButton
var ai_difficulty_container: HBoxContainer
var ai_difficulty_dropdown: OptionButton
var players_container: HBoxContainer
var player1_panel: Control
var player2_panel: Control
@@ -25,6 +27,7 @@ var p2_preview: Control
var buttons_container: HBoxContainer
var start_button: Button
var back_button: Button
var p2_title_label: Label # Reference to update "PLAYER 2" / "AI OPPONENT"
# Deck data
var saved_decks: Array[String] = []
@@ -32,6 +35,10 @@ var starter_decks: Array = [] # Array of StarterDeckData
var p1_selected_deck: Array = [] # Card IDs
var p2_selected_deck: Array = [] # Card IDs
# AI settings
var is_vs_ai: bool = false
var ai_difficulty: int = AIStrategy.Difficulty.NORMAL # Default to Normal
func _ready() -> void:
# Set high layer to be on top of everything
@@ -114,14 +121,37 @@ func _create_game_type_selector() -> void:
game_type_container.add_child(label)
game_type_dropdown = OptionButton.new()
game_type_dropdown.custom_minimum_size = Vector2(250, 36)
game_type_dropdown.add_item("2-Player Local (Share Screen)")
game_type_dropdown.add_item("vs AI (Coming Soon)")
game_type_dropdown.set_item_disabled(1, true)
game_type_dropdown.custom_minimum_size = Vector2(200, 36)
game_type_dropdown.add_item("2-Player Local")
game_type_dropdown.add_item("vs AI")
game_type_dropdown.add_theme_font_size_override("font_size", 14)
game_type_dropdown.item_selected.connect(_on_game_type_changed)
_style_dropdown(game_type_dropdown)
game_type_container.add_child(game_type_dropdown)
# AI Difficulty dropdown (initially hidden)
ai_difficulty_container = HBoxContainer.new()
ai_difficulty_container.add_theme_constant_override("separation", 10)
ai_difficulty_container.visible = false
game_type_container.add_child(ai_difficulty_container)
var diff_label = Label.new()
diff_label.text = "Difficulty:"
diff_label.add_theme_font_size_override("font_size", 18)
diff_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
ai_difficulty_container.add_child(diff_label)
ai_difficulty_dropdown = OptionButton.new()
ai_difficulty_dropdown.custom_minimum_size = Vector2(120, 36)
ai_difficulty_dropdown.add_item("Easy")
ai_difficulty_dropdown.add_item("Normal")
ai_difficulty_dropdown.add_item("Hard")
ai_difficulty_dropdown.select(1) # Default to Normal
ai_difficulty_dropdown.add_theme_font_size_override("font_size", 14)
ai_difficulty_dropdown.item_selected.connect(_on_ai_difficulty_changed)
_style_dropdown(ai_difficulty_dropdown)
ai_difficulty_container.add_child(ai_difficulty_dropdown)
func _create_player_panels() -> void:
players_container = HBoxContainer.new()
@@ -157,12 +187,17 @@ func _create_player_panel(title: String, player_num: int) -> Control:
margin.add_child(inner_vbox)
# Player title
var title_label = Label.new()
title_label.text = title
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title_label.add_theme_font_size_override("font_size", 18)
title_label.add_theme_color_override("font_color", Color(1.0, 0.95, 0.8))
inner_vbox.add_child(title_label)
var player_title = Label.new()
player_title.name = "TitleLabel"
player_title.text = title
player_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
player_title.add_theme_font_size_override("font_size", 18)
player_title.add_theme_color_override("font_color", Color(1.0, 0.95, 0.8))
inner_vbox.add_child(player_title)
# Store reference for Player 2 to update when AI mode changes
if player_num == 2:
p2_title_label = player_title
# Deck dropdown
var dropdown = OptionButton.new()
@@ -579,10 +614,26 @@ func _create_separator_style() -> StyleBoxFlat:
return style
func _on_game_type_changed(index: int) -> void:
is_vs_ai = (index == 1)
ai_difficulty_container.visible = is_vs_ai
# Update Player 2 panel title
if p2_title_label:
if is_vs_ai:
p2_title_label.text = "AI OPPONENT"
else:
p2_title_label.text = "PLAYER 2"
func _on_ai_difficulty_changed(index: int) -> void:
ai_difficulty = index # 0=Easy, 1=Normal, 2=Hard
func _on_back_pressed() -> void:
back_pressed.emit()
func _on_start_pressed() -> void:
if p1_selected_deck.size() >= 1 and p2_selected_deck.size() >= 1:
start_game_requested.emit(p1_selected_deck, p2_selected_deck)
start_game_requested.emit(p1_selected_deck, p2_selected_deck, is_vs_ai, ai_difficulty)

View File

@@ -309,6 +309,14 @@ func _show_next_message() -> void:
func _on_message_timer_timeout() -> void:
_show_next_message()
## Hide message immediately (e.g., when AI finishes thinking)
func hide_message() -> void:
message_queue.clear()
message_panel.visible = false
message_timer.stop()
## Show card detail panel
func show_card_detail(card: CardInstance) -> void:
if not card or not card.card_data:

View File

@@ -0,0 +1,442 @@
class_name LeaderboardScreen
extends CanvasLayer
## LeaderboardScreen - Displays top players ranked by ELO
signal back_pressed
# Window dimensions
const WINDOW_SIZE := Vector2i(600, 700)
# Pagination
const PLAYERS_PER_PAGE = 20
# UI Components
var main_container: VBoxContainer
var back_button: Button
var title_label: Label
var info_label: Label
var leaderboard_section: PanelContainer
var leaderboard_list: VBoxContainer
var header_row: HBoxContainer
var pagination_container: HBoxContainer
var prev_button: Button
var page_label: Label
var next_button: Button
var loading_label: Label
var error_label: Label
# State
var current_page: int = 0
var is_loading: bool = false
var players_cache: Array = []
# Styling
var custom_font: Font = preload("res://JimNightshade-Regular.ttf")
const BG_COLOR := Color(0.12, 0.11, 0.15, 1.0)
const PANEL_COLOR := Color(0.18, 0.16, 0.22, 1.0)
const ACCENT_COLOR := Color(0.4, 0.35, 0.55, 1.0)
const GOLD_COLOR := Color(1.0, 0.84, 0.0, 1.0)
const SILVER_COLOR := Color(0.75, 0.75, 0.75, 1.0)
const BRONZE_COLOR := Color(0.8, 0.5, 0.2, 1.0)
const TEXT_COLOR := Color(0.9, 0.88, 0.82, 1.0)
const MUTED_COLOR := Color(0.6, 0.58, 0.52, 1.0)
const HIGHLIGHT_COLOR := Color(0.3, 0.35, 0.5, 1.0)
func _ready() -> void:
_create_ui()
_load_leaderboard()
func _create_ui() -> void:
# Background
var bg = ColorRect.new()
bg.color = BG_COLOR
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(bg)
# Main container
main_container = VBoxContainer.new()
main_container.set_anchors_preset(Control.PRESET_FULL_RECT)
main_container.add_theme_constant_override("separation", 16)
add_child(main_container)
var margin = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 24)
margin.add_theme_constant_override("margin_right", 24)
margin.add_theme_constant_override("margin_top", 16)
margin.add_theme_constant_override("margin_bottom", 16)
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
main_container.add_child(margin)
var content = VBoxContainer.new()
content.add_theme_constant_override("separation", 16)
margin.add_child(content)
# Back button
back_button = _create_button("< Back", false)
back_button.custom_minimum_size = Vector2(80, 32)
back_button.pressed.connect(_on_back_pressed)
content.add_child(back_button)
# Title
title_label = Label.new()
title_label.add_theme_font_override("font", custom_font)
title_label.add_theme_font_size_override("font_size", 28)
title_label.add_theme_color_override("font_color", TEXT_COLOR)
title_label.text = "LEADERBOARD"
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
content.add_child(title_label)
# Info label
info_label = Label.new()
info_label.add_theme_font_override("font", custom_font)
info_label.add_theme_font_size_override("font_size", 12)
info_label.add_theme_color_override("font_color", MUTED_COLOR)
info_label.text = "Minimum 10 games required to appear on leaderboard"
info_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
content.add_child(info_label)
# Leaderboard section
_create_leaderboard_section(content)
# Error label
error_label = Label.new()
error_label.add_theme_font_override("font", custom_font)
error_label.add_theme_font_size_override("font_size", 14)
error_label.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
error_label.visible = false
content.add_child(error_label)
func _create_leaderboard_section(parent: VBoxContainer) -> void:
leaderboard_section = _create_panel_section("TOP PLAYERS")
leaderboard_section.size_flags_vertical = Control.SIZE_EXPAND_FILL
parent.add_child(leaderboard_section)
var content = leaderboard_section.get_child(0) as VBoxContainer
# Header row
header_row = _create_header_row()
content.add_child(header_row)
# Separator
var separator = HSeparator.new()
var sep_style = StyleBoxFlat.new()
sep_style.bg_color = MUTED_COLOR
sep_style.content_margin_top = 1
separator.add_theme_stylebox_override("separator", sep_style)
content.add_child(separator)
# Loading indicator
loading_label = Label.new()
loading_label.add_theme_font_override("font", custom_font)
loading_label.add_theme_font_size_override("font_size", 14)
loading_label.add_theme_color_override("font_color", MUTED_COLOR)
loading_label.text = "Loading..."
loading_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
content.add_child(loading_label)
# Player list
leaderboard_list = VBoxContainer.new()
leaderboard_list.add_theme_constant_override("separation", 4)
leaderboard_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
content.add_child(leaderboard_list)
# Pagination
pagination_container = HBoxContainer.new()
pagination_container.add_theme_constant_override("separation", 16)
pagination_container.alignment = BoxContainer.ALIGNMENT_CENTER
content.add_child(pagination_container)
prev_button = _create_button("< Prev", false)
prev_button.custom_minimum_size = Vector2(80, 32)
prev_button.pressed.connect(_on_prev_page)
pagination_container.add_child(prev_button)
page_label = Label.new()
page_label.add_theme_font_override("font", custom_font)
page_label.add_theme_font_size_override("font_size", 14)
page_label.add_theme_color_override("font_color", TEXT_COLOR)
page_label.text = "Page 1"
pagination_container.add_child(page_label)
next_button = _create_button("Next >", false)
next_button.custom_minimum_size = Vector2(80, 32)
next_button.pressed.connect(_on_next_page)
pagination_container.add_child(next_button)
func _create_header_row() -> HBoxContainer:
var row = HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
# Rank
var rank_header = _create_header_label("RANK", 50)
row.add_child(rank_header)
# Player
var player_header = _create_header_label("PLAYER", 0)
player_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(player_header)
# ELO
var elo_header = _create_header_label("ELO", 60)
elo_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
row.add_child(elo_header)
# Win Rate
var wr_header = _create_header_label("WIN%", 60)
wr_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
row.add_child(wr_header)
# Games
var games_header = _create_header_label("GAMES", 60)
games_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
row.add_child(games_header)
return row
func _create_header_label(text: String, min_width: int) -> Label:
var label = Label.new()
label.add_theme_font_override("font", custom_font)
label.add_theme_font_size_override("font_size", 12)
label.add_theme_color_override("font_color", MUTED_COLOR)
label.text = text
if min_width > 0:
label.custom_minimum_size.x = min_width
return label
func _create_panel_section(title: String) -> PanelContainer:
var panel = PanelContainer.new()
var style = StyleBoxFlat.new()
style.bg_color = PANEL_COLOR
style.set_corner_radius_all(8)
style.set_content_margin_all(16)
panel.add_theme_stylebox_override("panel", style)
var vbox = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 12)
panel.add_child(vbox)
var title_label = Label.new()
title_label.add_theme_font_override("font", custom_font)
title_label.add_theme_font_size_override("font_size", 18)
title_label.add_theme_color_override("font_color", TEXT_COLOR)
title_label.text = title
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(title_label)
return panel
func _create_button(text: String, primary: bool) -> Button:
var button = Button.new()
button.text = text
button.add_theme_font_override("font", custom_font)
button.add_theme_font_size_override("font_size", 16)
button.custom_minimum_size = Vector2(0, 40)
var normal = StyleBoxFlat.new()
var hover = StyleBoxFlat.new()
var pressed = StyleBoxFlat.new()
var disabled = StyleBoxFlat.new()
if primary:
normal.bg_color = ACCENT_COLOR
hover.bg_color = ACCENT_COLOR.lightened(0.15)
pressed.bg_color = ACCENT_COLOR.darkened(0.15)
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
else:
normal.bg_color = Color(0.25, 0.23, 0.3)
hover.bg_color = Color(0.3, 0.28, 0.35)
pressed.bg_color = Color(0.2, 0.18, 0.25)
button.add_theme_color_override("font_color", TEXT_COLOR)
button.add_theme_color_override("font_hover_color", TEXT_COLOR)
disabled.bg_color = Color(0.2, 0.18, 0.22)
button.add_theme_color_override("font_disabled_color", MUTED_COLOR)
for style in [normal, hover, pressed, disabled]:
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", pressed)
button.add_theme_stylebox_override("disabled", disabled)
return button
func _load_leaderboard() -> void:
is_loading = true
loading_label.visible = true
_clear_leaderboard_list()
var offset = current_page * PLAYERS_PER_PAGE
var result = await NetworkManager.get_leaderboard(PLAYERS_PER_PAGE, offset)
loading_label.visible = false
is_loading = false
if result.success:
var players = result.get("players", [])
players_cache = players
_display_players(players)
_update_pagination()
else:
_show_error(result.get("message", "Failed to load leaderboard"))
func _display_players(players: Array) -> void:
_clear_leaderboard_list()
if players.is_empty():
var empty_label = Label.new()
empty_label.add_theme_font_override("font", custom_font)
empty_label.add_theme_font_size_override("font_size", 14)
empty_label.add_theme_color_override("font_color", MUTED_COLOR)
empty_label.text = "No players found"
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
leaderboard_list.add_child(empty_label)
return
for player_data in players:
var player_row = _create_player_row(player_data)
leaderboard_list.add_child(player_row)
func _create_player_row(player_data: Dictionary) -> PanelContainer:
var panel = PanelContainer.new()
var style = StyleBoxFlat.new()
style.set_corner_radius_all(4)
style.set_content_margin_all(8)
# Check if this is the current user
var current_username = ""
if NetworkManager and NetworkManager.is_authenticated:
current_username = NetworkManager.current_user.get("username", "")
var is_current_user = player_data.get("username", "") == current_username
if is_current_user:
style.bg_color = HIGHLIGHT_COLOR
else:
style.bg_color = Color(0.15, 0.14, 0.18, 0.5)
panel.add_theme_stylebox_override("panel", style)
var row = HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
panel.add_child(row)
var rank = player_data.get("rank", 0)
# Rank with medal colors for top 3
var rank_label = Label.new()
rank_label.add_theme_font_override("font", custom_font)
rank_label.add_theme_font_size_override("font_size", 16)
rank_label.custom_minimum_size.x = 50
rank_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
match rank:
1:
rank_label.text = "#1"
rank_label.add_theme_color_override("font_color", GOLD_COLOR)
2:
rank_label.text = "#2"
rank_label.add_theme_color_override("font_color", SILVER_COLOR)
3:
rank_label.text = "#3"
rank_label.add_theme_color_override("font_color", BRONZE_COLOR)
_:
rank_label.text = "#%d" % rank
rank_label.add_theme_color_override("font_color", TEXT_COLOR)
row.add_child(rank_label)
# Player name
var name_label = Label.new()
name_label.add_theme_font_override("font", custom_font)
name_label.add_theme_font_size_override("font_size", 16)
name_label.add_theme_color_override("font_color", ACCENT_COLOR if is_current_user else TEXT_COLOR)
name_label.text = player_data.get("username", "Unknown")
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(name_label)
# ELO
var elo_label = Label.new()
elo_label.add_theme_font_override("font", custom_font)
elo_label.add_theme_font_size_override("font_size", 16)
elo_label.add_theme_color_override("font_color", TEXT_COLOR)
elo_label.text = str(player_data.get("eloRating", 1000))
elo_label.custom_minimum_size.x = 60
elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
row.add_child(elo_label)
# Win rate
var wr_label = Label.new()
wr_label.add_theme_font_override("font", custom_font)
wr_label.add_theme_font_size_override("font_size", 14)
wr_label.add_theme_color_override("font_color", MUTED_COLOR)
wr_label.text = "%d%%" % player_data.get("winRate", 0)
wr_label.custom_minimum_size.x = 60
wr_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
row.add_child(wr_label)
# Games played
var games_label = Label.new()
games_label.add_theme_font_override("font", custom_font)
games_label.add_theme_font_size_override("font_size", 14)
games_label.add_theme_color_override("font_color", MUTED_COLOR)
games_label.text = str(player_data.get("gamesPlayed", 0))
games_label.custom_minimum_size.x = 60
games_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
row.add_child(games_label)
return panel
func _clear_leaderboard_list() -> void:
for child in leaderboard_list.get_children():
child.queue_free()
func _update_pagination() -> void:
page_label.text = "Page %d" % (current_page + 1)
prev_button.disabled = current_page == 0
# Disable next if we got fewer results than requested
next_button.disabled = players_cache.size() < PLAYERS_PER_PAGE
func _show_error(message: String) -> void:
error_label.text = message
error_label.visible = true
await get_tree().create_timer(5.0).timeout
if is_instance_valid(error_label):
error_label.visible = false
func _on_back_pressed() -> void:
back_pressed.emit()
func _on_prev_page() -> void:
if current_page > 0:
current_page -= 1
_load_leaderboard()
func _on_next_page() -> void:
current_page += 1
_load_leaderboard()
func refresh() -> void:
current_page = 0
_load_leaderboard()

371
scripts/ui/LoginScreen.gd Normal file
View File

@@ -0,0 +1,371 @@
class_name LoginScreen
extends CanvasLayer
## LoginScreen - Email/password login form for online play
signal login_successful(user_data: Dictionary)
signal register_requested
signal forgot_password_requested
signal back_pressed
const WINDOW_SIZE := Vector2(400, 500)
# UI Components
var background: PanelContainer
var main_vbox: VBoxContainer
var title_label: Label
var email_input: LineEdit
var password_input: LineEdit
var login_button: Button
var register_link: Button
var forgot_password_link: Button
var back_button: Button
var error_label: Label
var loading_spinner: Control
var status_label: Label
# State
var _is_loading: bool = false
func _ready() -> void:
layer = 100
_create_ui()
# Connect to NetworkManager signals
if NetworkManager:
NetworkManager.authenticated.connect(_on_authenticated)
NetworkManager.authentication_failed.connect(_on_auth_failed)
func _create_ui() -> void:
# Background panel
background = PanelContainer.new()
add_child(background)
background.position = Vector2.ZERO
background.size = WINDOW_SIZE
background.add_theme_stylebox_override("panel", _create_panel_style())
# Main layout with margin
var margin = MarginContainer.new()
background.add_child(margin)
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 40)
margin.add_theme_constant_override("margin_right", 40)
margin.add_theme_constant_override("margin_top", 30)
margin.add_theme_constant_override("margin_bottom", 30)
main_vbox = VBoxContainer.new()
margin.add_child(main_vbox)
main_vbox.add_theme_constant_override("separation", 15)
# Title
_create_title()
# Login form
_create_form()
# Error label
_create_error_label()
# Spacer
var spacer = Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
main_vbox.add_child(spacer)
# Links
_create_links()
# Back button
_create_back_button()
func _create_title() -> void:
title_label = Label.new()
title_label.text = "LOGIN"
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title_label.add_theme_font_size_override("font_size", 32)
title_label.add_theme_color_override("font_color", Color(1.0, 0.95, 0.8))
main_vbox.add_child(title_label)
# Separator
var separator = HSeparator.new()
separator.add_theme_stylebox_override("separator", _create_separator_style())
main_vbox.add_child(separator)
# Spacer
var spacer = Control.new()
spacer.custom_minimum_size.y = 20
main_vbox.add_child(spacer)
func _create_form() -> void:
# Email field
var email_label = Label.new()
email_label.text = "Email"
email_label.add_theme_font_size_override("font_size", 16)
email_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
main_vbox.add_child(email_label)
email_input = LineEdit.new()
email_input.placeholder_text = "Enter your email"
email_input.custom_minimum_size = Vector2(0, 40)
_style_input(email_input)
email_input.text_submitted.connect(_on_input_submitted)
main_vbox.add_child(email_input)
# Password field
var password_label = Label.new()
password_label.text = "Password"
password_label.add_theme_font_size_override("font_size", 16)
password_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
main_vbox.add_child(password_label)
password_input = LineEdit.new()
password_input.placeholder_text = "Enter your password"
password_input.secret = true
password_input.custom_minimum_size = Vector2(0, 40)
_style_input(password_input)
password_input.text_submitted.connect(_on_input_submitted)
main_vbox.add_child(password_input)
# Login button
var button_spacer = Control.new()
button_spacer.custom_minimum_size.y = 10
main_vbox.add_child(button_spacer)
login_button = Button.new()
login_button.text = "Login"
login_button.custom_minimum_size = Vector2(0, 45)
_style_button(login_button, true)
login_button.pressed.connect(_on_login_pressed)
main_vbox.add_child(login_button)
func _create_error_label() -> void:
error_label = Label.new()
error_label.text = ""
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
error_label.add_theme_font_size_override("font_size", 14)
error_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
error_label.visible = false
main_vbox.add_child(error_label)
func _create_links() -> void:
var links_container = VBoxContainer.new()
links_container.add_theme_constant_override("separation", 8)
main_vbox.add_child(links_container)
# Register link
register_link = Button.new()
register_link.text = "Don't have an account? Register"
register_link.flat = true
register_link.add_theme_font_size_override("font_size", 14)
register_link.add_theme_color_override("font_color", Color(0.6, 0.7, 1.0))
register_link.add_theme_color_override("font_hover_color", Color(0.8, 0.85, 1.0))
register_link.pressed.connect(_on_register_pressed)
links_container.add_child(register_link)
# Forgot password link
forgot_password_link = Button.new()
forgot_password_link.text = "Forgot Password?"
forgot_password_link.flat = true
forgot_password_link.add_theme_font_size_override("font_size", 14)
forgot_password_link.add_theme_color_override("font_color", Color(0.7, 0.7, 0.8))
forgot_password_link.add_theme_color_override("font_hover_color", Color(0.9, 0.9, 1.0))
forgot_password_link.pressed.connect(_on_forgot_password_pressed)
links_container.add_child(forgot_password_link)
func _create_back_button() -> void:
var button_container = HBoxContainer.new()
button_container.alignment = BoxContainer.ALIGNMENT_CENTER
main_vbox.add_child(button_container)
back_button = Button.new()
back_button.text = "Back"
back_button.custom_minimum_size = Vector2(100, 40)
_style_button(back_button, false)
back_button.pressed.connect(_on_back_pressed)
button_container.add_child(back_button)
# ======= STYLING =======
func _create_panel_style() -> StyleBoxFlat:
var style = StyleBoxFlat.new()
style.bg_color = Color(0.08, 0.08, 0.12, 1.0)
style.set_border_width_all(0)
style.set_corner_radius_all(0)
return style
func _create_separator_style() -> StyleBoxFlat:
var style = StyleBoxFlat.new()
style.bg_color = Color(0.5, 0.4, 0.2, 0.5)
style.content_margin_top = 1
return style
func _style_input(input: LineEdit) -> void:
var style = StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.12, 0.16)
style.border_color = Color(0.4, 0.35, 0.25)
style.set_border_width_all(1)
style.set_corner_radius_all(4)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 8
style.content_margin_bottom = 8
input.add_theme_stylebox_override("normal", style)
var focus_style = style.duplicate()
focus_style.border_color = Color(0.7, 0.6, 0.3)
input.add_theme_stylebox_override("focus", focus_style)
input.add_theme_color_override("font_color", Color(0.95, 0.9, 0.8))
input.add_theme_color_override("font_placeholder_color", Color(0.5, 0.5, 0.55))
input.add_theme_font_size_override("font_size", 16)
func _style_button(button: Button, is_primary: bool) -> void:
var style = StyleBoxFlat.new()
if is_primary:
style.bg_color = Color(0.3, 0.25, 0.15)
style.border_color = Color(0.6, 0.5, 0.3)
else:
style.bg_color = Color(0.15, 0.15, 0.2)
style.border_color = Color(0.4, 0.35, 0.25)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.content_margin_left = 20
style.content_margin_right = 20
style.content_margin_top = 10
style.content_margin_bottom = 10
button.add_theme_stylebox_override("normal", style)
var hover_style = style.duplicate()
if is_primary:
hover_style.bg_color = Color(0.4, 0.35, 0.2)
hover_style.border_color = Color(0.8, 0.7, 0.4)
else:
hover_style.bg_color = Color(0.2, 0.2, 0.25)
hover_style.border_color = Color(0.5, 0.45, 0.35)
button.add_theme_stylebox_override("hover", hover_style)
var pressed_style = style.duplicate()
pressed_style.bg_color = Color(0.1, 0.1, 0.12)
button.add_theme_stylebox_override("pressed", pressed_style)
var disabled_style = style.duplicate()
disabled_style.bg_color = Color(0.1, 0.1, 0.12)
disabled_style.border_color = Color(0.25, 0.25, 0.3)
button.add_theme_stylebox_override("disabled", disabled_style)
button.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
button.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.8))
button.add_theme_color_override("font_pressed_color", Color(0.7, 0.65, 0.55))
button.add_theme_color_override("font_disabled_color", Color(0.45, 0.42, 0.38))
button.add_theme_font_size_override("font_size", 18)
# ======= EVENT HANDLERS =======
func _on_input_submitted(_text: String) -> void:
_on_login_pressed()
func _on_login_pressed() -> void:
if _is_loading:
return
var email = email_input.text.strip_edges()
var password = password_input.text
# Validate inputs
if email.is_empty():
_show_error("Please enter your email")
return
if password.is_empty():
_show_error("Please enter your password")
return
if not _is_valid_email(email):
_show_error("Please enter a valid email address")
return
# Start login
_set_loading(true)
_hide_error()
var result = await NetworkManager.login(email, password)
_set_loading(false)
if result.success:
login_successful.emit(result.user)
else:
_show_error(result.message)
func _on_register_pressed() -> void:
register_requested.emit()
func _on_forgot_password_pressed() -> void:
forgot_password_requested.emit()
func _on_back_pressed() -> void:
back_pressed.emit()
func _on_authenticated(user_data: Dictionary) -> void:
login_successful.emit(user_data)
func _on_auth_failed(error: String) -> void:
_set_loading(false)
_show_error(error)
# ======= HELPERS =======
func _is_valid_email(email: String) -> bool:
var regex = RegEx.new()
regex.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")
return regex.search(email) != null
func _show_error(message: String) -> void:
error_label.text = message
error_label.visible = true
func _hide_error() -> void:
error_label.text = ""
error_label.visible = false
func _set_loading(loading: bool) -> void:
_is_loading = loading
login_button.disabled = loading
login_button.text = "Logging in..." if loading else "Login"
email_input.editable = not loading
password_input.editable = not loading
func clear_form() -> void:
email_input.text = ""
password_input.text = ""
_hide_error()
_set_loading(false)
func focus_email() -> void:
email_input.grab_focus()

View File

@@ -76,7 +76,7 @@ func _create_menu() -> void:
deck_builder_button.pressed.connect(_on_deck_builder_pressed)
online_button = _create_overlay_button("Online", 2)
online_button.disabled = true
online_button.pressed.connect(_on_online_pressed)
settings_button = _create_overlay_button("Settings", 3)
settings_button.disabled = true
@@ -168,6 +168,10 @@ func _on_deck_builder_pressed() -> void:
deck_builder.emit()
func _on_online_pressed() -> void:
online_game.emit()
func _on_quit_pressed() -> void:
quit_game.emit()
get_tree().quit()

661
scripts/ui/OnlineLobby.gd Normal file
View File

@@ -0,0 +1,661 @@
class_name OnlineLobby
extends CanvasLayer
## OnlineLobby - Matchmaking UI for ranked queue and private rooms
signal game_starting(game_data: Dictionary)
signal back_pressed
signal profile_requested
signal leaderboard_requested
# Window dimensions
const WINDOW_SIZE := Vector2i(600, 700)
# UI Components
var main_container: VBoxContainer
var back_button: Button
var header_container: HBoxContainer
var username_label: Label
var elo_label: Label
var deck_section: VBoxContainer
var deck_dropdown: OptionButton
var ranked_section: PanelContainer
var ranked_content: VBoxContainer
var find_match_button: Button
var cancel_search_button: Button
var queue_status_label: Label
var private_section: PanelContainer
var private_content: VBoxContainer
var create_room_button: Button
var join_container: HBoxContainer
var room_code_input: LineEdit
var join_room_button: Button
var room_section: PanelContainer
var room_content: VBoxContainer
var room_code_label: Label
var copy_code_button: Button
var host_label: Label
var guest_label: Label
var ready_button: Button
var leave_room_button: Button
var error_label: Label
var nav_buttons_container: HBoxContainer
var profile_button: Button
var leaderboard_button: Button
# State
var is_in_queue: bool = false
var is_in_room: bool = false
var is_ready: bool = false
var queue_start_time: float = 0.0
var current_room_code: String = ""
var selected_deck_id: String = ""
# Styling
var custom_font: Font = preload("res://JimNightshade-Regular.ttf")
const BG_COLOR := Color(0.12, 0.11, 0.15, 1.0)
const PANEL_COLOR := Color(0.18, 0.16, 0.22, 1.0)
const ACCENT_COLOR := Color(0.4, 0.35, 0.55, 1.0)
const TEXT_COLOR := Color(0.9, 0.88, 0.82, 1.0)
const MUTED_COLOR := Color(0.6, 0.58, 0.52, 1.0)
func _ready() -> void:
_create_ui()
_connect_network_signals()
_update_user_info()
_fetch_decks()
func _process(_delta: float) -> void:
# Update queue timer if searching
if is_in_queue:
var elapsed = Time.get_ticks_msec() / 1000.0 - queue_start_time
var minutes = int(elapsed) / 60
var seconds = int(elapsed) % 60
queue_status_label.text = "Searching... %d:%02d" % [minutes, seconds]
func _create_ui() -> void:
# Background
var bg = ColorRect.new()
bg.color = BG_COLOR
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(bg)
# Main container
main_container = VBoxContainer.new()
main_container.set_anchors_preset(Control.PRESET_FULL_RECT)
main_container.add_theme_constant_override("separation", 16)
add_child(main_container)
var margin = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 24)
margin.add_theme_constant_override("margin_right", 24)
margin.add_theme_constant_override("margin_top", 16)
margin.add_theme_constant_override("margin_bottom", 16)
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
main_container.add_child(margin)
var content = VBoxContainer.new()
content.add_theme_constant_override("separation", 16)
margin.add_child(content)
# Back button
back_button = _create_button("< Back", false)
back_button.custom_minimum_size = Vector2(80, 32)
back_button.pressed.connect(_on_back_pressed)
content.add_child(back_button)
# Header with username and ELO
header_container = HBoxContainer.new()
header_container.add_theme_constant_override("separation", 16)
content.add_child(header_container)
username_label = Label.new()
username_label.add_theme_font_override("font", custom_font)
username_label.add_theme_font_size_override("font_size", 24)
username_label.add_theme_color_override("font_color", TEXT_COLOR)
username_label.text = "Welcome!"
username_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header_container.add_child(username_label)
elo_label = Label.new()
elo_label.add_theme_font_override("font", custom_font)
elo_label.add_theme_font_size_override("font_size", 20)
elo_label.add_theme_color_override("font_color", ACCENT_COLOR)
elo_label.text = "ELO: 1000"
header_container.add_child(elo_label)
# Deck selection section
deck_section = VBoxContainer.new()
deck_section.add_theme_constant_override("separation", 8)
content.add_child(deck_section)
var deck_label = Label.new()
deck_label.add_theme_font_override("font", custom_font)
deck_label.add_theme_font_size_override("font_size", 16)
deck_label.add_theme_color_override("font_color", MUTED_COLOR)
deck_label.text = "SELECT DECK"
deck_section.add_child(deck_label)
deck_dropdown = OptionButton.new()
deck_dropdown.add_theme_font_override("font", custom_font)
deck_dropdown.add_theme_font_size_override("font_size", 16)
deck_dropdown.custom_minimum_size = Vector2(0, 40)
deck_dropdown.item_selected.connect(_on_deck_selected)
deck_section.add_child(deck_dropdown)
# Ranked match section
ranked_section = _create_panel_section("RANKED MATCH")
content.add_child(ranked_section)
ranked_content = ranked_section.get_child(0) as VBoxContainer
var ranked_desc = Label.new()
ranked_desc.add_theme_font_override("font", custom_font)
ranked_desc.add_theme_font_size_override("font_size", 14)
ranked_desc.add_theme_color_override("font_color", MUTED_COLOR)
ranked_desc.text = "Find opponents near your skill level"
ranked_content.add_child(ranked_desc)
find_match_button = _create_button("Find Match", true)
find_match_button.pressed.connect(_on_find_match_pressed)
ranked_content.add_child(find_match_button)
cancel_search_button = _create_button("Cancel Search", false)
cancel_search_button.pressed.connect(_on_cancel_search_pressed)
cancel_search_button.visible = false
ranked_content.add_child(cancel_search_button)
queue_status_label = Label.new()
queue_status_label.add_theme_font_override("font", custom_font)
queue_status_label.add_theme_font_size_override("font_size", 14)
queue_status_label.add_theme_color_override("font_color", ACCENT_COLOR)
queue_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
queue_status_label.visible = false
ranked_content.add_child(queue_status_label)
# Private match section
private_section = _create_panel_section("PRIVATE MATCH")
content.add_child(private_section)
private_content = private_section.get_child(0) as VBoxContainer
create_room_button = _create_button("Create Room", true)
create_room_button.pressed.connect(_on_create_room_pressed)
private_content.add_child(create_room_button)
var separator_label = Label.new()
separator_label.add_theme_font_override("font", custom_font)
separator_label.add_theme_font_size_override("font_size", 12)
separator_label.add_theme_color_override("font_color", MUTED_COLOR)
separator_label.text = "─────────── OR ───────────"
separator_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
private_content.add_child(separator_label)
join_container = HBoxContainer.new()
join_container.add_theme_constant_override("separation", 8)
private_content.add_child(join_container)
var code_label = Label.new()
code_label.add_theme_font_override("font", custom_font)
code_label.add_theme_font_size_override("font_size", 14)
code_label.add_theme_color_override("font_color", TEXT_COLOR)
code_label.text = "Code:"
join_container.add_child(code_label)
room_code_input = LineEdit.new()
room_code_input.add_theme_font_override("font", custom_font)
room_code_input.add_theme_font_size_override("font_size", 16)
room_code_input.placeholder_text = "ABC123"
room_code_input.max_length = 6
room_code_input.custom_minimum_size = Vector2(100, 36)
room_code_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL
room_code_input.text_changed.connect(_on_room_code_changed)
join_container.add_child(room_code_input)
join_room_button = _create_button("Join", false)
join_room_button.custom_minimum_size = Vector2(80, 36)
join_room_button.pressed.connect(_on_join_room_pressed)
join_container.add_child(join_room_button)
# Room section (shown when in a room)
room_section = _create_panel_section("ROOM")
room_section.visible = false
content.add_child(room_section)
room_content = room_section.get_child(0) as VBoxContainer
var room_header = HBoxContainer.new()
room_header.add_theme_constant_override("separation", 8)
room_content.add_child(room_header)
room_code_label = Label.new()
room_code_label.add_theme_font_override("font", custom_font)
room_code_label.add_theme_font_size_override("font_size", 20)
room_code_label.add_theme_color_override("font_color", ACCENT_COLOR)
room_code_label.text = "Room: ------"
room_code_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
room_header.add_child(room_code_label)
copy_code_button = _create_button("Copy", false)
copy_code_button.custom_minimum_size = Vector2(60, 28)
copy_code_button.pressed.connect(_on_copy_code_pressed)
room_header.add_child(copy_code_button)
var players_container = VBoxContainer.new()
players_container.add_theme_constant_override("separation", 4)
room_content.add_child(players_container)
host_label = Label.new()
host_label.add_theme_font_override("font", custom_font)
host_label.add_theme_font_size_override("font_size", 16)
host_label.add_theme_color_override("font_color", TEXT_COLOR)
host_label.text = "Host: ---"
players_container.add_child(host_label)
guest_label = Label.new()
guest_label.add_theme_font_override("font", custom_font)
guest_label.add_theme_font_size_override("font_size", 16)
guest_label.add_theme_color_override("font_color", TEXT_COLOR)
guest_label.text = "Guest: Waiting..."
players_container.add_child(guest_label)
var room_buttons = HBoxContainer.new()
room_buttons.add_theme_constant_override("separation", 8)
room_content.add_child(room_buttons)
ready_button = _create_button("Ready", true)
ready_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
ready_button.pressed.connect(_on_ready_pressed)
room_buttons.add_child(ready_button)
leave_room_button = _create_button("Leave Room", false)
leave_room_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
leave_room_button.pressed.connect(_on_leave_room_pressed)
room_buttons.add_child(leave_room_button)
# Error label
error_label = Label.new()
error_label.add_theme_font_override("font", custom_font)
error_label.add_theme_font_size_override("font_size", 14)
error_label.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
error_label.visible = false
content.add_child(error_label)
# Navigation buttons (Profile and Leaderboard)
nav_buttons_container = HBoxContainer.new()
nav_buttons_container.add_theme_constant_override("separation", 16)
nav_buttons_container.alignment = BoxContainer.ALIGNMENT_CENTER
content.add_child(nav_buttons_container)
profile_button = _create_button("Profile", false)
profile_button.custom_minimum_size = Vector2(120, 36)
profile_button.pressed.connect(_on_profile_pressed)
nav_buttons_container.add_child(profile_button)
leaderboard_button = _create_button("Leaderboard", false)
leaderboard_button.custom_minimum_size = Vector2(120, 36)
leaderboard_button.pressed.connect(_on_leaderboard_pressed)
nav_buttons_container.add_child(leaderboard_button)
func _create_panel_section(title: String) -> PanelContainer:
var panel = PanelContainer.new()
var style = StyleBoxFlat.new()
style.bg_color = PANEL_COLOR
style.set_corner_radius_all(8)
style.set_content_margin_all(16)
panel.add_theme_stylebox_override("panel", style)
var vbox = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 12)
panel.add_child(vbox)
var title_label = Label.new()
title_label.add_theme_font_override("font", custom_font)
title_label.add_theme_font_size_override("font_size", 18)
title_label.add_theme_color_override("font_color", TEXT_COLOR)
title_label.text = title
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(title_label)
return panel
func _create_button(text: String, primary: bool) -> Button:
var button = Button.new()
button.text = text
button.add_theme_font_override("font", custom_font)
button.add_theme_font_size_override("font_size", 16)
button.custom_minimum_size = Vector2(0, 40)
var normal = StyleBoxFlat.new()
var hover = StyleBoxFlat.new()
var pressed = StyleBoxFlat.new()
var disabled = StyleBoxFlat.new()
if primary:
normal.bg_color = ACCENT_COLOR
hover.bg_color = ACCENT_COLOR.lightened(0.15)
pressed.bg_color = ACCENT_COLOR.darkened(0.15)
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
else:
normal.bg_color = Color(0.25, 0.23, 0.3)
hover.bg_color = Color(0.3, 0.28, 0.35)
pressed.bg_color = Color(0.2, 0.18, 0.25)
button.add_theme_color_override("font_color", TEXT_COLOR)
button.add_theme_color_override("font_hover_color", TEXT_COLOR)
disabled.bg_color = Color(0.2, 0.18, 0.22)
button.add_theme_color_override("font_disabled_color", MUTED_COLOR)
for style in [normal, hover, pressed, disabled]:
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", pressed)
button.add_theme_stylebox_override("disabled", disabled)
return button
func _connect_network_signals() -> void:
NetworkManager.queue_joined.connect(_on_queue_joined)
NetworkManager.queue_left.connect(_on_queue_left)
NetworkManager.room_created.connect(_on_room_created)
NetworkManager.room_joined.connect(_on_room_joined)
NetworkManager.room_updated.connect(_on_room_updated)
NetworkManager.matchmaking_update.connect(_on_matchmaking_update)
NetworkManager.match_found.connect(_on_match_found)
NetworkManager.network_error.connect(_on_network_error)
func _disconnect_network_signals() -> void:
if NetworkManager.queue_joined.is_connected(_on_queue_joined):
NetworkManager.queue_joined.disconnect(_on_queue_joined)
if NetworkManager.queue_left.is_connected(_on_queue_left):
NetworkManager.queue_left.disconnect(_on_queue_left)
if NetworkManager.room_created.is_connected(_on_room_created):
NetworkManager.room_created.disconnect(_on_room_created)
if NetworkManager.room_joined.is_connected(_on_room_joined):
NetworkManager.room_joined.disconnect(_on_room_joined)
if NetworkManager.room_updated.is_connected(_on_room_updated):
NetworkManager.room_updated.disconnect(_on_room_updated)
if NetworkManager.matchmaking_update.is_connected(_on_matchmaking_update):
NetworkManager.matchmaking_update.disconnect(_on_matchmaking_update)
if NetworkManager.match_found.is_connected(_on_match_found):
NetworkManager.match_found.disconnect(_on_match_found)
if NetworkManager.network_error.is_connected(_on_network_error):
NetworkManager.network_error.disconnect(_on_network_error)
func _update_user_info() -> void:
var user = NetworkManager.current_user
if user.has("username"):
username_label.text = "Welcome, %s!" % user.username
if user.has("stats") and user.stats.has("elo_rating"):
elo_label.text = "ELO: %d" % user.stats.elo_rating
else:
elo_label.text = "ELO: 1000"
func _fetch_decks() -> void:
deck_dropdown.clear()
deck_dropdown.add_item("-- Select a Deck --", 0)
# Add decks from user profile
var user = NetworkManager.current_user
if user.has("decks") and user.decks is Array:
for i in range(user.decks.size()):
var deck = user.decks[i]
deck_dropdown.add_item(deck.name, i + 1)
deck_dropdown.set_item_metadata(i + 1, deck.id)
# Also add local starter decks as fallback
if deck_dropdown.item_count <= 1:
var starter_decks = _get_local_decks()
for i in range(starter_decks.size()):
deck_dropdown.add_item(starter_decks[i].name, i + 1)
deck_dropdown.set_item_metadata(i + 1, "local_%d" % i)
func _get_local_decks() -> Array:
# Load starter decks from local file
var decks = []
var file_path = "res://data/starter_decks.json"
if FileAccess.file_exists(file_path):
var file = FileAccess.open(file_path, FileAccess.READ)
if file:
var json = JSON.new()
var result = json.parse(file.get_as_text())
if result == OK and json.data is Dictionary:
if json.data.has("decks"):
decks = json.data.decks
file.close()
return decks
func _show_error(message: String) -> void:
error_label.text = message
error_label.visible = true
# Auto-hide after 5 seconds
await get_tree().create_timer(5.0).timeout
if is_instance_valid(error_label):
error_label.visible = false
func _update_ui_state() -> void:
# Update button states based on current state
var has_deck = selected_deck_id != ""
# Queue UI
find_match_button.visible = not is_in_queue and not is_in_room
find_match_button.disabled = not has_deck
cancel_search_button.visible = is_in_queue
queue_status_label.visible = is_in_queue
# Private match UI
private_section.visible = not is_in_queue and not is_in_room
create_room_button.disabled = not has_deck
join_room_button.disabled = not has_deck or room_code_input.text.length() != 6
# Room UI
room_section.visible = is_in_room
# Disable ranked section when in room
ranked_section.visible = not is_in_room
# ========== BUTTON HANDLERS ==========
func _on_profile_pressed() -> void:
profile_requested.emit()
func _on_leaderboard_pressed() -> void:
leaderboard_requested.emit()
func _on_back_pressed() -> void:
# Leave queue or room before going back
if is_in_queue:
NetworkManager.leave_queue()
if is_in_room:
NetworkManager.leave_room()
_disconnect_network_signals()
back_pressed.emit()
func _on_deck_selected(index: int) -> void:
if index == 0:
selected_deck_id = ""
else:
selected_deck_id = str(deck_dropdown.get_item_metadata(index))
_update_ui_state()
func _on_room_code_changed(_new_text: String) -> void:
room_code_input.text = room_code_input.text.to_upper()
_update_ui_state()
func _on_find_match_pressed() -> void:
if selected_deck_id == "":
_show_error("Please select a deck first")
return
# Connect to WebSocket if not connected
if NetworkManager.connection_state < NetworkManager.ConnectionState.CONNECTED:
NetworkManager.connect_websocket()
await NetworkManager.connection_state_changed
# Wait a bit for auth
await get_tree().create_timer(0.5).timeout
NetworkManager.join_queue(selected_deck_id)
func _on_cancel_search_pressed() -> void:
NetworkManager.leave_queue()
func _on_create_room_pressed() -> void:
if selected_deck_id == "":
_show_error("Please select a deck first")
return
# Connect to WebSocket if not connected
if NetworkManager.connection_state < NetworkManager.ConnectionState.CONNECTED:
NetworkManager.connect_websocket()
await NetworkManager.connection_state_changed
await get_tree().create_timer(0.5).timeout
NetworkManager.create_room(selected_deck_id)
func _on_join_room_pressed() -> void:
var code = room_code_input.text.strip_edges().to_upper()
if code.length() != 6:
_show_error("Room code must be 6 characters")
return
if selected_deck_id == "":
_show_error("Please select a deck first")
return
# Connect to WebSocket if not connected
if NetworkManager.connection_state < NetworkManager.ConnectionState.CONNECTED:
NetworkManager.connect_websocket()
await NetworkManager.connection_state_changed
await get_tree().create_timer(0.5).timeout
NetworkManager.join_room(code, selected_deck_id)
func _on_copy_code_pressed() -> void:
DisplayServer.clipboard_set(current_room_code)
copy_code_button.text = "Copied!"
await get_tree().create_timer(1.5).timeout
if is_instance_valid(copy_code_button):
copy_code_button.text = "Copy"
func _on_ready_pressed() -> void:
is_ready = not is_ready
ready_button.text = "Not Ready" if is_ready else "Ready"
NetworkManager.set_room_ready(is_ready)
func _on_leave_room_pressed() -> void:
NetworkManager.leave_room()
# ========== NETWORK SIGNAL HANDLERS ==========
func _on_queue_joined() -> void:
is_in_queue = true
queue_start_time = Time.get_ticks_msec() / 1000.0
queue_status_label.text = "Searching... 0:00"
_update_ui_state()
func _on_queue_left() -> void:
is_in_queue = false
_update_ui_state()
func _on_room_created(room_data: Dictionary) -> void:
is_in_room = true
current_room_code = room_data.get("code", "")
_update_room_display(room_data)
_update_ui_state()
func _on_room_joined(room_data: Dictionary) -> void:
is_in_room = true
current_room_code = room_data.get("code", "")
_update_room_display(room_data)
_update_ui_state()
func _on_room_updated(room_data: Dictionary) -> void:
_update_room_display(room_data)
func _update_room_display(room_data: Dictionary) -> void:
room_code_label.text = "Room: %s" % room_data.get("code", "------")
var host = room_data.get("host", {})
var host_ready = "" if host.get("ready", false) else ""
host_label.text = "Host: %s%s" % [host.get("username", "---"), host_ready]
var guest = room_data.get("guest", null)
if guest:
var guest_ready = "" if guest.get("ready", false) else ""
guest_label.text = "Guest: %s%s" % [guest.get("username", "---"), guest_ready]
ready_button.disabled = false
else:
guest_label.text = "Guest: Waiting for opponent..."
ready_button.disabled = true
func _on_matchmaking_update(data: Dictionary) -> void:
var update_type = data.get("type", "")
match update_type:
"queue_left":
is_in_queue = false
_update_ui_state()
"room_left":
is_in_room = false
is_ready = false
ready_button.text = "Ready"
current_room_code = ""
room_code_input.text = ""
_update_ui_state()
var reason = data.get("reason", "")
if reason != "":
_show_error(reason)
func _on_match_found(game_data: Dictionary) -> void:
print("Match found! Game ID: ", game_data.get("game_id", ""))
is_in_queue = false
is_in_room = false
_disconnect_network_signals()
game_starting.emit(game_data)
func _on_network_error(error: String) -> void:
_show_error(error)
# ========== CLEANUP ==========
func _exit_tree() -> void:
_disconnect_network_signals()

494
scripts/ui/ProfileScreen.gd Normal file
View File

@@ -0,0 +1,494 @@
class_name ProfileScreen
extends CanvasLayer
## ProfileScreen - Displays player stats and match history
signal back_pressed
# Window dimensions
const WINDOW_SIZE := Vector2i(600, 700)
# Pagination
const MATCHES_PER_PAGE = 10
# UI Components
var main_container: VBoxContainer
var back_button: Button
var username_label: Label
var stats_container: HBoxContainer
var elo_value: Label
var wins_value: Label
var losses_value: Label
var winrate_value: Label
var games_value: Label
var history_section: PanelContainer
var history_list: VBoxContainer
var pagination_container: HBoxContainer
var prev_button: Button
var page_label: Label
var next_button: Button
var loading_label: Label
var error_label: Label
# State
var current_page: int = 0
var total_matches: int = 0
var is_loading: bool = false
var matches_cache: Array = []
# Styling
var custom_font: Font = preload("res://JimNightshade-Regular.ttf")
const BG_COLOR := Color(0.12, 0.11, 0.15, 1.0)
const PANEL_COLOR := Color(0.18, 0.16, 0.22, 1.0)
const ACCENT_COLOR := Color(0.4, 0.35, 0.55, 1.0)
const TEXT_COLOR := Color(0.9, 0.88, 0.82, 1.0)
const MUTED_COLOR := Color(0.6, 0.58, 0.52, 1.0)
const WIN_COLOR := Color(0.4, 0.8, 0.4, 1.0)
const LOSS_COLOR := Color(0.8, 0.4, 0.4, 1.0)
func _ready() -> void:
_create_ui()
_load_profile()
func _create_ui() -> void:
# Background
var bg = ColorRect.new()
bg.color = BG_COLOR
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(bg)
# Main container
main_container = VBoxContainer.new()
main_container.set_anchors_preset(Control.PRESET_FULL_RECT)
main_container.add_theme_constant_override("separation", 16)
add_child(main_container)
var margin = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 24)
margin.add_theme_constant_override("margin_right", 24)
margin.add_theme_constant_override("margin_top", 16)
margin.add_theme_constant_override("margin_bottom", 16)
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
main_container.add_child(margin)
var content = VBoxContainer.new()
content.add_theme_constant_override("separation", 16)
margin.add_child(content)
# Back button
back_button = _create_button("< Back", false)
back_button.custom_minimum_size = Vector2(80, 32)
back_button.pressed.connect(_on_back_pressed)
content.add_child(back_button)
# Title
var title = Label.new()
title.add_theme_font_override("font", custom_font)
title.add_theme_font_size_override("font_size", 28)
title.add_theme_color_override("font_color", TEXT_COLOR)
title.text = "PLAYER PROFILE"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
content.add_child(title)
# Username
username_label = Label.new()
username_label.add_theme_font_override("font", custom_font)
username_label.add_theme_font_size_override("font_size", 22)
username_label.add_theme_color_override("font_color", ACCENT_COLOR)
username_label.text = "Loading..."
username_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
content.add_child(username_label)
# Stats section
_create_stats_section(content)
# Match history section
_create_history_section(content)
# Error label
error_label = Label.new()
error_label.add_theme_font_override("font", custom_font)
error_label.add_theme_font_size_override("font_size", 14)
error_label.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
error_label.visible = false
content.add_child(error_label)
func _create_stats_section(parent: VBoxContainer) -> void:
var stats_panel = _create_panel_section("STATISTICS")
parent.add_child(stats_panel)
stats_container = HBoxContainer.new()
stats_container.add_theme_constant_override("separation", 24)
stats_panel.get_child(0).add_child(stats_container)
# Create stat boxes
var elo_box = _create_stat_box("ELO RATING")
elo_value = elo_box.get_child(1)
stats_container.add_child(elo_box)
var wins_box = _create_stat_box("WINS")
wins_value = wins_box.get_child(1)
stats_container.add_child(wins_box)
var losses_box = _create_stat_box("LOSSES")
losses_value = losses_box.get_child(1)
stats_container.add_child(losses_box)
var winrate_box = _create_stat_box("WIN RATE")
winrate_value = winrate_box.get_child(1)
stats_container.add_child(winrate_box)
var games_box = _create_stat_box("GAMES")
games_value = games_box.get_child(1)
stats_container.add_child(games_box)
func _create_stat_box(title: String) -> VBoxContainer:
var box = VBoxContainer.new()
box.add_theme_constant_override("separation", 4)
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var title_label = Label.new()
title_label.add_theme_font_override("font", custom_font)
title_label.add_theme_font_size_override("font_size", 12)
title_label.add_theme_color_override("font_color", MUTED_COLOR)
title_label.text = title
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(title_label)
var value_label = Label.new()
value_label.add_theme_font_override("font", custom_font)
value_label.add_theme_font_size_override("font_size", 24)
value_label.add_theme_color_override("font_color", TEXT_COLOR)
value_label.text = "-"
value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(value_label)
return box
func _create_history_section(parent: VBoxContainer) -> void:
history_section = _create_panel_section("MATCH HISTORY")
history_section.size_flags_vertical = Control.SIZE_EXPAND_FILL
parent.add_child(history_section)
var content = history_section.get_child(0) as VBoxContainer
# Loading indicator
loading_label = Label.new()
loading_label.add_theme_font_override("font", custom_font)
loading_label.add_theme_font_size_override("font_size", 14)
loading_label.add_theme_color_override("font_color", MUTED_COLOR)
loading_label.text = "Loading..."
loading_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
content.add_child(loading_label)
# Match list
history_list = VBoxContainer.new()
history_list.add_theme_constant_override("separation", 8)
history_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
content.add_child(history_list)
# Pagination
pagination_container = HBoxContainer.new()
pagination_container.add_theme_constant_override("separation", 16)
pagination_container.alignment = BoxContainer.ALIGNMENT_CENTER
content.add_child(pagination_container)
prev_button = _create_button("< Prev", false)
prev_button.custom_minimum_size = Vector2(80, 32)
prev_button.pressed.connect(_on_prev_page)
pagination_container.add_child(prev_button)
page_label = Label.new()
page_label.add_theme_font_override("font", custom_font)
page_label.add_theme_font_size_override("font_size", 14)
page_label.add_theme_color_override("font_color", TEXT_COLOR)
page_label.text = "Page 1"
pagination_container.add_child(page_label)
next_button = _create_button("Next >", false)
next_button.custom_minimum_size = Vector2(80, 32)
next_button.pressed.connect(_on_next_page)
pagination_container.add_child(next_button)
func _create_panel_section(title: String) -> PanelContainer:
var panel = PanelContainer.new()
var style = StyleBoxFlat.new()
style.bg_color = PANEL_COLOR
style.set_corner_radius_all(8)
style.set_content_margin_all(16)
panel.add_theme_stylebox_override("panel", style)
var vbox = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 12)
panel.add_child(vbox)
var title_label = Label.new()
title_label.add_theme_font_override("font", custom_font)
title_label.add_theme_font_size_override("font_size", 18)
title_label.add_theme_color_override("font_color", TEXT_COLOR)
title_label.text = title
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(title_label)
return panel
func _create_button(text: String, primary: bool) -> Button:
var button = Button.new()
button.text = text
button.add_theme_font_override("font", custom_font)
button.add_theme_font_size_override("font_size", 16)
button.custom_minimum_size = Vector2(0, 40)
var normal = StyleBoxFlat.new()
var hover = StyleBoxFlat.new()
var pressed = StyleBoxFlat.new()
var disabled = StyleBoxFlat.new()
if primary:
normal.bg_color = ACCENT_COLOR
hover.bg_color = ACCENT_COLOR.lightened(0.15)
pressed.bg_color = ACCENT_COLOR.darkened(0.15)
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
else:
normal.bg_color = Color(0.25, 0.23, 0.3)
hover.bg_color = Color(0.3, 0.28, 0.35)
pressed.bg_color = Color(0.2, 0.18, 0.25)
button.add_theme_color_override("font_color", TEXT_COLOR)
button.add_theme_color_override("font_hover_color", TEXT_COLOR)
disabled.bg_color = Color(0.2, 0.18, 0.22)
button.add_theme_color_override("font_disabled_color", MUTED_COLOR)
for style in [normal, hover, pressed, disabled]:
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", pressed)
button.add_theme_stylebox_override("disabled", disabled)
return button
func _load_profile() -> void:
if not NetworkManager or not NetworkManager.is_authenticated:
_show_error("Not logged in")
return
is_loading = true
loading_label.visible = true
var result = await NetworkManager.get_profile()
if result.success:
_update_profile_display(result.user)
_load_match_history()
else:
_show_error(result.message)
is_loading = false
func _update_profile_display(user: Dictionary) -> void:
username_label.text = user.get("username", "Unknown")
var stats = user.get("stats", {})
elo_value.text = str(stats.get("eloRating", 1000))
wins_value.text = str(stats.get("wins", 0))
losses_value.text = str(stats.get("losses", 0))
games_value.text = str(stats.get("gamesPlayed", 0))
# Calculate win rate
var games_played = stats.get("gamesPlayed", 0)
if games_played > 0:
var win_rate = float(stats.get("wins", 0)) / float(games_played) * 100.0
winrate_value.text = "%.1f%%" % win_rate
else:
winrate_value.text = "N/A"
func _load_match_history() -> void:
loading_label.visible = true
_clear_history_list()
var offset = current_page * MATCHES_PER_PAGE
var result = await NetworkManager.get_match_history(MATCHES_PER_PAGE, offset)
loading_label.visible = false
if result.success:
var matches = result.get("matches", [])
matches_cache = matches
_display_matches(matches)
_update_pagination()
else:
_show_error(result.message)
func _display_matches(matches: Array) -> void:
_clear_history_list()
if matches.is_empty():
var empty_label = Label.new()
empty_label.add_theme_font_override("font", custom_font)
empty_label.add_theme_font_size_override("font_size", 14)
empty_label.add_theme_color_override("font_color", MUTED_COLOR)
empty_label.text = "No matches found"
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
history_list.add_child(empty_label)
return
for match_data in matches:
var match_row = _create_match_row(match_data)
history_list.add_child(match_row)
func _create_match_row(match_data: Dictionary) -> HBoxContainer:
var row = HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
# Win/Loss indicator
var result_label = Label.new()
result_label.add_theme_font_override("font", custom_font)
result_label.add_theme_font_size_override("font_size", 14)
result_label.custom_minimum_size.x = 50
var is_win = match_data.get("isWin", false)
if is_win:
result_label.text = "WIN"
result_label.add_theme_color_override("font_color", WIN_COLOR)
else:
result_label.text = "LOSS"
result_label.add_theme_color_override("font_color", LOSS_COLOR)
row.add_child(result_label)
# Opponent
var vs_label = Label.new()
vs_label.add_theme_font_override("font", custom_font)
vs_label.add_theme_font_size_override("font_size", 14)
vs_label.add_theme_color_override("font_color", TEXT_COLOR)
vs_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var opponent = match_data.get("player1", "")
var current_username = NetworkManager.current_user.get("username", "")
if opponent == current_username:
opponent = match_data.get("player2", "Unknown")
vs_label.text = "vs %s" % opponent
row.add_child(vs_label)
# Result type
var reason_label = Label.new()
reason_label.add_theme_font_override("font", custom_font)
reason_label.add_theme_font_size_override("font_size", 12)
reason_label.add_theme_color_override("font_color", MUTED_COLOR)
reason_label.custom_minimum_size.x = 80
var result_reason = match_data.get("result", "")
match result_reason:
"damage":
reason_label.text = "Damage"
"deck_out":
reason_label.text = "Deck Out"
"concede":
reason_label.text = "Concede"
"timeout":
reason_label.text = "Timeout"
"disconnect":
reason_label.text = "Disconnect"
_:
reason_label.text = result_reason.capitalize()
row.add_child(reason_label)
# ELO change
var elo_label = Label.new()
elo_label.add_theme_font_override("font", custom_font)
elo_label.add_theme_font_size_override("font_size", 14)
elo_label.custom_minimum_size.x = 60
elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
var elo_change = match_data.get("eloChange", 0)
if is_win:
elo_label.text = "+%d" % elo_change
elo_label.add_theme_color_override("font_color", WIN_COLOR)
else:
elo_label.text = "-%d" % elo_change
elo_label.add_theme_color_override("font_color", LOSS_COLOR)
row.add_child(elo_label)
# Date
var date_label = Label.new()
date_label.add_theme_font_override("font", custom_font)
date_label.add_theme_font_size_override("font_size", 12)
date_label.add_theme_color_override("font_color", MUTED_COLOR)
date_label.custom_minimum_size.x = 80
date_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
var played_at = match_data.get("playedAt", "")
if played_at != "":
# Parse ISO date and format nicely
date_label.text = _format_date(played_at)
row.add_child(date_label)
return row
func _format_date(iso_date: String) -> String:
# Simple date formatting - extracts date portion
if iso_date.contains("T"):
var parts = iso_date.split("T")
var date_part = parts[0]
var date_components = date_part.split("-")
if date_components.size() >= 3:
return "%s/%s" % [date_components[1], date_components[2]]
return iso_date.left(10)
func _clear_history_list() -> void:
for child in history_list.get_children():
child.queue_free()
func _update_pagination() -> void:
page_label.text = "Page %d" % (current_page + 1)
prev_button.disabled = current_page == 0
# Disable next if we got fewer results than requested (end of data)
next_button.disabled = matches_cache.size() < MATCHES_PER_PAGE
func _show_error(message: String) -> void:
error_label.text = message
error_label.visible = true
await get_tree().create_timer(5.0).timeout
if is_instance_valid(error_label):
error_label.visible = false
func _on_back_pressed() -> void:
back_pressed.emit()
func _on_prev_page() -> void:
if current_page > 0:
current_page -= 1
_load_match_history()
func _on_next_page() -> void:
current_page += 1
_load_match_history()
func refresh() -> void:
current_page = 0
_load_profile()

View File

@@ -0,0 +1,431 @@
class_name RegisterScreen
extends CanvasLayer
## RegisterScreen - Account creation form for online play
signal registration_successful(message: String)
signal login_requested
signal back_pressed
const WINDOW_SIZE := Vector2(400, 600)
# Validation constants
const USERNAME_MIN_LENGTH = 3
const USERNAME_MAX_LENGTH = 32
const PASSWORD_MIN_LENGTH = 8
# UI Components
var background: PanelContainer
var main_vbox: VBoxContainer
var title_label: Label
var email_input: LineEdit
var username_input: LineEdit
var password_input: LineEdit
var confirm_password_input: LineEdit
var register_button: Button
var login_link: Button
var back_button: Button
var error_label: Label
var success_label: Label
# State
var _is_loading: bool = false
func _ready() -> void:
layer = 100
_create_ui()
func _create_ui() -> void:
# Background panel
background = PanelContainer.new()
add_child(background)
background.position = Vector2.ZERO
background.size = WINDOW_SIZE
background.add_theme_stylebox_override("panel", _create_panel_style())
# Main layout with margin
var margin = MarginContainer.new()
background.add_child(margin)
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 40)
margin.add_theme_constant_override("margin_right", 40)
margin.add_theme_constant_override("margin_top", 25)
margin.add_theme_constant_override("margin_bottom", 25)
main_vbox = VBoxContainer.new()
margin.add_child(main_vbox)
main_vbox.add_theme_constant_override("separation", 10)
# Title
_create_title()
# Registration form
_create_form()
# Messages
_create_message_labels()
# Spacer
var spacer = Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
main_vbox.add_child(spacer)
# Links
_create_links()
# Back button
_create_back_button()
func _create_title() -> void:
title_label = Label.new()
title_label.text = "CREATE ACCOUNT"
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title_label.add_theme_font_size_override("font_size", 28)
title_label.add_theme_color_override("font_color", Color(1.0, 0.95, 0.8))
main_vbox.add_child(title_label)
# Separator
var separator = HSeparator.new()
separator.add_theme_stylebox_override("separator", _create_separator_style())
main_vbox.add_child(separator)
# Small spacer
var spacer = Control.new()
spacer.custom_minimum_size.y = 10
main_vbox.add_child(spacer)
func _create_form() -> void:
# Email field
var email_label = Label.new()
email_label.text = "Email"
email_label.add_theme_font_size_override("font_size", 15)
email_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
main_vbox.add_child(email_label)
email_input = LineEdit.new()
email_input.placeholder_text = "Enter your email"
email_input.custom_minimum_size = Vector2(0, 38)
_style_input(email_input)
main_vbox.add_child(email_input)
# Username field
var username_label = Label.new()
username_label.text = "Username"
username_label.add_theme_font_size_override("font_size", 15)
username_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
main_vbox.add_child(username_label)
username_input = LineEdit.new()
username_input.placeholder_text = "3-32 characters"
username_input.custom_minimum_size = Vector2(0, 38)
username_input.max_length = USERNAME_MAX_LENGTH
_style_input(username_input)
main_vbox.add_child(username_input)
# Password field
var password_label = Label.new()
password_label.text = "Password"
password_label.add_theme_font_size_override("font_size", 15)
password_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
main_vbox.add_child(password_label)
password_input = LineEdit.new()
password_input.placeholder_text = "At least 8 characters"
password_input.secret = true
password_input.custom_minimum_size = Vector2(0, 38)
_style_input(password_input)
main_vbox.add_child(password_input)
# Confirm password field
var confirm_label = Label.new()
confirm_label.text = "Confirm Password"
confirm_label.add_theme_font_size_override("font_size", 15)
confirm_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
main_vbox.add_child(confirm_label)
confirm_password_input = LineEdit.new()
confirm_password_input.placeholder_text = "Re-enter your password"
confirm_password_input.secret = true
confirm_password_input.custom_minimum_size = Vector2(0, 38)
_style_input(confirm_password_input)
confirm_password_input.text_submitted.connect(_on_input_submitted)
main_vbox.add_child(confirm_password_input)
# Register button
var button_spacer = Control.new()
button_spacer.custom_minimum_size.y = 10
main_vbox.add_child(button_spacer)
register_button = Button.new()
register_button.text = "Create Account"
register_button.custom_minimum_size = Vector2(0, 45)
_style_button(register_button, true)
register_button.pressed.connect(_on_register_pressed)
main_vbox.add_child(register_button)
func _create_message_labels() -> void:
# Error label
error_label = Label.new()
error_label.text = ""
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
error_label.add_theme_font_size_override("font_size", 13)
error_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
error_label.visible = false
main_vbox.add_child(error_label)
# Success label
success_label = Label.new()
success_label.text = ""
success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
success_label.add_theme_font_size_override("font_size", 13)
success_label.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5))
success_label.autowrap_mode = TextServer.AUTOWRAP_WORD
success_label.visible = false
main_vbox.add_child(success_label)
func _create_links() -> void:
# Login link
login_link = Button.new()
login_link.text = "Already have an account? Login"
login_link.flat = true
login_link.add_theme_font_size_override("font_size", 14)
login_link.add_theme_color_override("font_color", Color(0.6, 0.7, 1.0))
login_link.add_theme_color_override("font_hover_color", Color(0.8, 0.85, 1.0))
login_link.pressed.connect(_on_login_pressed)
main_vbox.add_child(login_link)
func _create_back_button() -> void:
var button_container = HBoxContainer.new()
button_container.alignment = BoxContainer.ALIGNMENT_CENTER
main_vbox.add_child(button_container)
back_button = Button.new()
back_button.text = "Back"
back_button.custom_minimum_size = Vector2(100, 38)
_style_button(back_button, false)
back_button.pressed.connect(_on_back_pressed)
button_container.add_child(back_button)
# ======= STYLING =======
func _create_panel_style() -> StyleBoxFlat:
var style = StyleBoxFlat.new()
style.bg_color = Color(0.08, 0.08, 0.12, 1.0)
style.set_border_width_all(0)
style.set_corner_radius_all(0)
return style
func _create_separator_style() -> StyleBoxFlat:
var style = StyleBoxFlat.new()
style.bg_color = Color(0.5, 0.4, 0.2, 0.5)
style.content_margin_top = 1
return style
func _style_input(input: LineEdit) -> void:
var style = StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.12, 0.16)
style.border_color = Color(0.4, 0.35, 0.25)
style.set_border_width_all(1)
style.set_corner_radius_all(4)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 6
style.content_margin_bottom = 6
input.add_theme_stylebox_override("normal", style)
var focus_style = style.duplicate()
focus_style.border_color = Color(0.7, 0.6, 0.3)
input.add_theme_stylebox_override("focus", focus_style)
input.add_theme_color_override("font_color", Color(0.95, 0.9, 0.8))
input.add_theme_color_override("font_placeholder_color", Color(0.5, 0.5, 0.55))
input.add_theme_font_size_override("font_size", 15)
func _style_button(button: Button, is_primary: bool) -> void:
var style = StyleBoxFlat.new()
if is_primary:
style.bg_color = Color(0.3, 0.25, 0.15)
style.border_color = Color(0.6, 0.5, 0.3)
else:
style.bg_color = Color(0.15, 0.15, 0.2)
style.border_color = Color(0.4, 0.35, 0.25)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.content_margin_left = 20
style.content_margin_right = 20
style.content_margin_top = 8
style.content_margin_bottom = 8
button.add_theme_stylebox_override("normal", style)
var hover_style = style.duplicate()
if is_primary:
hover_style.bg_color = Color(0.4, 0.35, 0.2)
hover_style.border_color = Color(0.8, 0.7, 0.4)
else:
hover_style.bg_color = Color(0.2, 0.2, 0.25)
hover_style.border_color = Color(0.5, 0.45, 0.35)
button.add_theme_stylebox_override("hover", hover_style)
var pressed_style = style.duplicate()
pressed_style.bg_color = Color(0.1, 0.1, 0.12)
button.add_theme_stylebox_override("pressed", pressed_style)
var disabled_style = style.duplicate()
disabled_style.bg_color = Color(0.1, 0.1, 0.12)
disabled_style.border_color = Color(0.25, 0.25, 0.3)
button.add_theme_stylebox_override("disabled", disabled_style)
button.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
button.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.8))
button.add_theme_color_override("font_pressed_color", Color(0.7, 0.65, 0.55))
button.add_theme_color_override("font_disabled_color", Color(0.45, 0.42, 0.38))
button.add_theme_font_size_override("font_size", 16)
# ======= EVENT HANDLERS =======
func _on_input_submitted(_text: String) -> void:
_on_register_pressed()
func _on_register_pressed() -> void:
if _is_loading:
return
var email = email_input.text.strip_edges()
var username = username_input.text.strip_edges()
var password = password_input.text
var confirm_password = confirm_password_input.text
# Validate inputs
var validation_error = _validate_inputs(email, username, password, confirm_password)
if validation_error != "":
_show_error(validation_error)
return
# Start registration
_set_loading(true)
_hide_messages()
var result = await NetworkManager.register(email, password, username)
_set_loading(false)
if result.success:
_show_success(result.message)
registration_successful.emit(result.message)
else:
_show_error(result.message)
func _on_login_pressed() -> void:
login_requested.emit()
func _on_back_pressed() -> void:
back_pressed.emit()
# ======= VALIDATION =======
func _validate_inputs(email: String, username: String, password: String, confirm_password: String) -> String:
# Email validation
if email.is_empty():
return "Please enter your email"
if not _is_valid_email(email):
return "Please enter a valid email address"
# Username validation
if username.is_empty():
return "Please enter a username"
if username.length() < USERNAME_MIN_LENGTH:
return "Username must be at least %d characters" % USERNAME_MIN_LENGTH
if username.length() > USERNAME_MAX_LENGTH:
return "Username must be at most %d characters" % USERNAME_MAX_LENGTH
if not _is_valid_username(username):
return "Username can only contain letters, numbers, underscores, and hyphens"
# Password validation
if password.is_empty():
return "Please enter a password"
if password.length() < PASSWORD_MIN_LENGTH:
return "Password must be at least %d characters" % PASSWORD_MIN_LENGTH
# Confirm password
if confirm_password != password:
return "Passwords do not match"
return ""
func _is_valid_email(email: String) -> bool:
var regex = RegEx.new()
regex.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")
return regex.search(email) != null
func _is_valid_username(username: String) -> bool:
var regex = RegEx.new()
regex.compile("^[a-zA-Z0-9_-]+$")
return regex.search(username) != null
# ======= HELPERS =======
func _show_error(message: String) -> void:
error_label.text = message
error_label.visible = true
success_label.visible = false
func _show_success(message: String) -> void:
success_label.text = message
success_label.visible = true
error_label.visible = false
func _hide_messages() -> void:
error_label.visible = false
success_label.visible = false
func _set_loading(loading: bool) -> void:
_is_loading = loading
register_button.disabled = loading
register_button.text = "Creating Account..." if loading else "Create Account"
email_input.editable = not loading
username_input.editable = not loading
password_input.editable = not loading
confirm_password_input.editable = not loading
func clear_form() -> void:
email_input.text = ""
username_input.text = ""
password_input.text = ""
confirm_password_input.text = ""
_hide_messages()
_set_loading(false)
func focus_email() -> void:
email_input.grab_focus()