extends Node3D ## Main - Root scene that coordinates the game # Components var table_setup: TableSetup 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] = [] # Deck configurations (set by GameController before game starts) 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() _connect_signals() # Start game when ready if GameManager.is_initialized: _start_game() else: GameManager.game_ready.connect(_start_game) func _setup_table() -> void: table_setup = TableSetup.new() add_child(table_setup) # Connect table signals table_setup.card_clicked.connect(_on_table_card_clicked) func _setup_ui() -> void: # Main game UI overlay (has its own CanvasLayer) game_ui = GameUI.new() game_ui.layer = 10 # Base UI layer add_child(game_ui) # Action log (collapsible panel on right side) var log_layer = CanvasLayer.new() log_layer.layer = 12 # Above other UI add_child(log_layer) action_log = ActionLog.new() log_layer.add_child(action_log) # Position on right side of screen action_log.set_anchors_preset(Control.PRESET_CENTER_RIGHT) action_log.offset_left = -290 action_log.offset_right = -10 action_log.offset_top = -200 action_log.offset_bottom = 200 # Connect undo signal action_log.undo_requested.connect(_on_undo_requested) # Connect to GameManager undo signals GameManager.undo_available_changed.connect(_on_undo_available_changed) # Ensure ActionLog connects to GameManager after it's in the tree action_log.call_deferred("_connect_game_manager_signals") # Hand display needs its own CanvasLayer to render on top of 3D hand_layer = CanvasLayer.new() hand_layer.layer = 11 # Above the game UI add_child(hand_layer) # Container for hand at bottom of screen - must fill the viewport var hand_container = Control.new() hand_layer.add_child(hand_container) hand_container.set_anchors_preset(Control.PRESET_FULL_RECT) hand_container.mouse_filter = Control.MOUSE_FILTER_IGNORE # Hand display positioned at bottom - use explicit positioning hand_display = HandDisplay.new() hand_container.add_child(hand_display) # Position at bottom of screen with explicit coordinates # We'll update position in _process or use a deferred call after container is sized call_deferred("_position_hand_display") hand_display.card_action_requested.connect(_on_hand_card_action) hand_display.card_hovered.connect(_on_hand_card_hovered) hand_display.card_unhovered.connect(_on_hand_card_unhovered) hand_display.card_selected.connect(_on_hand_card_selected) hand_display.hand_minimized_changed.connect(_on_hand_minimized_changed) # Create damage displays (positioned by 3D camera overlay later) for i in range(2): 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: # get_visible_rect() gives the coordinate space for 2D content # With canvas_items + expand, this is the expanded design space var vp_size = viewport.get_visible_rect().size var is_min = hand_display and hand_display.is_minimized var card_height = 273.0 if is_min: # Minimized: push cards down so only the top portion peeks up var peek_height = 65.0 # How much of the card top is visible hand_display.position = Vector2(10, vp_size.y - peek_height) hand_display.size = Vector2(vp_size.x - 20, card_height + 25.0) else: # Full hand: cards at the bottom of the screen var hand_height = card_height + 25.0 # Extra space for hover lift var bottom_offset = 10.0 hand_display.position = Vector2(10, vp_size.y - bottom_offset - card_height) hand_display.size = Vector2(vp_size.x - 20, hand_height) if not viewport.size_changed.is_connected(_on_viewport_resized): viewport.size_changed.connect(_on_viewport_resized) func _on_viewport_resized() -> void: var viewport = get_viewport() if viewport and hand_display: _position_hand_display() func _on_hand_minimized_changed(_minimized: bool) -> void: _position_hand_display() func _connect_signals() -> void: # GameManager signals GameManager.game_started.connect(_on_game_started) GameManager.game_ended.connect(_on_game_ended) GameManager.turn_changed.connect(_on_turn_changed) GameManager.phase_changed.connect(_on_phase_changed) GameManager.damage_dealt.connect(_on_damage_dealt) # 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() _update_cp_display() func _on_game_started() -> void: _sync_visuals() _update_hand_display() # Set initial camera to first player's perspective if GameManager.game_state and table_setup: var player_index = GameManager.game_state.turn_manager.current_player_index table_setup.switch_camera_to_player(player_index) 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() _update_cp_display() # Rotate camera to face the current player's mat if GameManager.game_state and table_setup: 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: for i in range(2): var player = GameManager.game_state.get_player(i) if player and player.player_name == player_name: if i < damage_displays.size(): damage_displays[i].set_damage(player.damage_zone.get_count()) func _sync_visuals() -> void: if GameManager.game_state and table_setup: table_setup.sync_with_game_state(GameManager.game_state) func _update_hand_display() -> void: if not GameManager.game_state: return # Show current player's hand var current_player = GameManager.get_current_player() if current_player: var cards = current_player.hand.get_cards() hand_display.update_hand(cards) func _update_cp_display() -> void: if not GameManager.game_state: return var current_player = GameManager.get_current_player() if current_player: game_ui.update_cp_display(current_player.cp_pool) func _update_playable_highlights() -> void: if not GameManager.game_state: return var phase = GameManager.get_current_phase() var player = GameManager.get_current_player() if not player: hand_display.clear_highlights() return match phase: Enums.TurnPhase.MAIN_1, Enums.TurnPhase.MAIN_2: # Highlight cards that can be played hand_display.highlight_playable(func(card: CardInstance) -> bool: return player.cp_pool.can_afford_card(card.card_data) ) _: 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() _update_cp_display() "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() _update_cp_display() "view": # Show detailed card view game_ui.show_card_detail(card) func _on_hand_card_hovered(_card: CardInstance) -> void: # Hand cards use the selection panel for detail view, not the GameUI hover preview # So we intentionally do nothing here - no hover preview for hand cards pass func _on_hand_card_unhovered() -> void: game_ui.hide_card_detail() func _on_hand_card_selected(_card: CardInstance) -> void: # Close field card panel if open game_ui.hide_field_card_selection() # Ensure any stale hover preview is hidden game_ui.hide_card_detail() func _on_undo_requested() -> void: if GameManager.undo_last_action(): _sync_visuals() _update_hand_display() _update_cp_display() _update_playable_highlights() # Restore input mode based on current phase GameManager.restore_input_mode_for_phase() func _on_undo_available_changed(available: bool) -> void: if action_log: action_log.set_undo_available(available) func _on_table_card_clicked(card: CardInstance, zone_type: Enums.ZoneType, player_index: int) -> void: var input_mode = GameManager.input_mode # Handle special input modes first (these take priority) match input_mode: 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() return 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 GameManager.InputMode.SELECT_BLOCKER: 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 # Close hand selection panel if open hand_display.deselect() # Show field card selection panel with card image and actions game_ui.show_field_card_selection(card, zone_type, player_index) func _connect_field_card_signals() -> void: if game_ui: 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 (disabled in online games) if event.keycode == KEY_Z and event.ctrl_pressed: if not is_online_game: _on_undo_requested() return # L key to toggle action log if event.keycode == KEY_L and not event.ctrl_pressed: if action_log: action_log.toggle_panel() return 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() _update_cp_display() KEY_ESCAPE: # Cancel current selection GameManager.clear_selection() table_setup.clear_all_highlights() hand_display.clear_highlights() game_ui.hide_field_card_selection()