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