extends Control # UI References - Main Menu @onready var main_menu_panel = $MainMenuPanel @onready var main_title = %Title @onready var username_label = %Username @onready var main_subtitle = %Subtitle @onready var create_room_btn = %CreateRoomBtn @onready var browse_rooms_btn = %BrowseRoomsBtn @onready var tutorial_btn = %TutorialBtn @onready var main_menu_profile_btn = %MainProfileBtn @onready var avatar_display = %AvatarDisplay @onready var lobby_settings_btn = %SettingsBtn @onready var quit_btn = %QuitBtn # Main Menu 3D Preview @onready var character_root = %CharacterRoot @onready var anim_player = %AnimationPlayer # UI References - Currencies @onready var gold_label = %GoldLabel @onready var star_label = %StarLabel # UI References - Server Selection @onready var server_option = %ServerOption @onready var server_ip_input = %ServerIPInput # Leaderboard Reference @onready var leaderboard_btn = %LeaderboardBtn @onready var shop_btn = %CartBtn @onready var top_right_profile_btn = %ProfileBtn @onready var mailbox_btn = get_node_or_null("%MailboxBtn") @onready var mail_badge = get_node_or_null("%MailBadge") @onready var banner1_btn = get_node_or_null("%Banner1") @onready var ticket_btn = get_node_or_null("%TicketBtn") @onready var mailbox_panel = get_node_or_null("MailboxPanel") # UI References - Room List @onready var room_list_panel = %RoomListPanel @onready var room_list = get_node_or_null("%RoomList") @onready var match_id_input = get_node_or_null("%MatchIdInput") @onready var refresh_btn = get_node_or_null("%RefreshBtn") @onready var join_btn = get_node_or_null("%JoinBtn") @onready var back_btn = get_node_or_null("%RoomListCloseBtn") @onready var room_list_profile_btn = get_node_or_null("%RoomListProfileBtn") @onready var item_template = get_node_or_null("%ItemTemplate") @onready var room_player_username = get_node_or_null("%PlayerUsername") @onready var room_player_score = get_node_or_null("%PlayerScore") @onready var room_player_rank = get_node_or_null("%Rank") @onready var room_avatar = get_node_or_null("%Avatar") # Cached leaderboard rank for local player var _local_player_rank: int = 0 # UI References - Lobby Panel @onready var lobby_panel = $LobbyPanel @onready var room_name_header = $LobbyPanel/RoomNameHeader @onready var host_banner = $LobbyPanel/HostBanner @onready var host_banner_label = $LobbyPanel/HostBanner/HostBannerLabel # UI References - Top Bar @onready var profile_btn = $LobbyPanel/TopBar/ProfileSection/ProfileBtn @onready var logout_btn = $LobbyPanel/TopBar/ProfileSection/LogoutBtn @onready var match_id_display = $LobbyPanel/TopBar/MatchIdContainer/MatchIdDisplay @onready var copy_id_btn = $LobbyPanel/TopBar/MatchIdContainer/CopyIdBtn @onready var duration_option = $LobbyPanel/TopBar/SettingsSection/DurationOption @onready var duration_text_label = $LobbyPanel/TopBar/SettingsSection/DurationTextLabel @onready var lobby_top_settings_btn = $LobbyPanel/TopBar/ProfileSection/LobbySettingsBtn @onready var random_spawn_check = $LobbyPanel/TopBar/SettingsSection/RandomSpawnCheck @onready var random_spawn_label = $LobbyPanel/TopBar/SettingsSection/RandomSpawnLabel @onready var enable_timer_check = $LobbyPanel/TopBar/SettingsSection/EnableTimerCheck @onready var enable_timer_label = $LobbyPanel/TopBar/SettingsSection/EnableTimerLabel @onready var scarcity_option = $LobbyPanel/TopBar/SettingsSection/ScarcityOption @onready var scarcity_label = $LobbyPanel/TopBar/SettingsSection/ScarcityLabel @onready var scarcity_spacer = $LobbyPanel/TopBar/SettingsSection/ScarcitySpacer @onready var spawn_spacer = $LobbyPanel/TopBar/SettingsSection/SpawnSpacer @onready var timer_spacer = $LobbyPanel/TopBar/SettingsSection/TimerSpacer @onready var game_mode_option = $LobbyPanel/TopBar/SettingsSection/GameModeOption @onready var game_mode_text_label = $LobbyPanel/TopBar/SettingsSection/GameModeTextLabel # Custom Settings Containers var sng_settings_container: HBoxContainer var sng_go_option: OptionButton var sng_stop_option: OptionButton var sng_goals_option: OptionButton var doors_settings_container: HBoxContainer var doors_swap_option: OptionButton var doors_refresh_option: OptionButton var doors_goals_option: OptionButton # UI References - Player Slots @onready var players_container = $LobbyPanel/PlayersContainer @onready var players_container2 = $LobbyPanel/PlayersContainer2 @onready var player_slots: Array[Control] = [] # UI References - Area Selector @onready var area_selector = $LobbyPanel/AreaSelector @onready var area_left_btn = $LobbyPanel/AreaSelector/AreaLeftBtn @onready var area_name_label = $LobbyPanel/AreaSelector/AreaName @onready var area_right_btn = $LobbyPanel/AreaSelector/AreaRightBtn # UI References - Bottom Bar @onready var leave_btn = $LobbyPanel/BottomBar/LeaveBtn @onready var ready_btn = $LobbyPanel/BottomBar/ReadyBtn @onready var start_game_btn = $LobbyPanel/BottomBar/StartGameBtn var invite_btn: Button @onready var status_label = $LobbyPanel/StatusLabel # Social Panel instance var social_panel_instance: Control # Lobby invite popup var _invite_popup: AcceptDialog var _pending_invite_match_id: String = "" # UI References - Status @onready var connection_status = $StatusBar/ConnectionStatus # Character preview textures var character_textures: Dictionary = {} # Profile panel instance var profile_panel_instance: Control var admin_panel_instance: Control # Store current match ID for copy function var current_match_id: String = "" var leaderboard_panel_instance: Control var shop_panel_instance: Control var daily_reward_panel_instance: Control # Bot name tracking keyed by slot index to avoid re-generating on each update var _bot_names: Dictionary = {} # Room list filter ("" = all, "Freemode", "Stop n Go", etc.) var _room_mode_filter: String = "" # ============================================================================= # Chat System # ============================================================================= const GLOBAL_CHAT_ROOM := "social_global" var _chat_channel = null var _chat_messages: Array = [] @onready var chat_display: RichTextLabel = %RichTextLabel @onready var chat_input: LineEdit = %ChatInput @onready var chat_send_btn: Button = %SendBtn var _friend_suggest_panel: PanelContainer var _friend_suggest_list: ItemList # Server Selection Controls (Now in tscn) # var server_option: OptionButton # var server_ip_input: LineEdit func _ready(): # Start background music for Lobby MusicManager.start_music() # Load character textures _load_character_textures() # Server config UI is now in tscn # Get player slot references _setup_player_slots() # Setup Game Mode specific UI dynamically _create_custom_settings_ui() # Initial UI update _on_profile_updated() # Connect button signals - Main Menu create_room_btn.pressed.connect(_on_create_room_pressed) browse_rooms_btn.pressed.connect(_on_browse_rooms_pressed) if tutorial_btn: tutorial_btn.pressed.connect(_on_tutorial_pressed) if main_menu_profile_btn: main_menu_profile_btn.pressed.connect(_on_profile_btn_pressed) if top_right_profile_btn: top_right_profile_btn.pressed.connect(_on_profile_btn_pressed) if mailbox_btn: mailbox_btn.pressed.connect(_on_mailbox_pressed) if lobby_settings_btn: lobby_settings_btn.pressed.connect(_on_settings_pressed) # Shop Button if shop_btn: shop_btn.pressed.connect(_on_shop_pressed) # Banner1 → Gacha if banner1_btn: banner1_btn.disabled = false banner1_btn.text = "✨ Gacha" banner1_btn.pressed.connect(_on_banner1_pressed) if leaderboard_btn: leaderboard_btn.pressed.connect(_on_leaderboard_pressed) if ticket_btn: ticket_btn.pressed.connect(_on_ticket_pressed) if quit_btn: quit_btn.pressed.connect(_on_quit_pressed) # Social button (main menu) - connect if node exists var social_btn = get_node_or_null("%SocialBtn") if social_btn: social_btn.pressed.connect(_on_social_pressed) # Connect Social / Friend UI var global_chat_tab_btn = get_node_or_null("%GlobalChatTabBtn") if global_chat_tab_btn: global_chat_tab_btn.pressed.connect(func(): _switch_chat_tab("global")) FriendManager.dm_message_received.connect(_on_lobby_dm_received) # Connect Server Selection signals if server_option: server_option.item_selected.connect(_on_server_option_selected) # Initialize state _on_server_option_selected(server_option.selected) if server_ip_input: server_ip_input.text_submitted.connect(_on_server_ip_submitted) server_ip_input.focus_exited.connect(func(): _on_server_ip_submitted(server_ip_input.text)) # Connect button signals - Room List if refresh_btn: refresh_btn.pressed.connect(_on_refresh_pressed) if join_btn: join_btn.pressed.connect(_on_join_pressed) if back_btn: back_btn.pressed.connect(_on_back_pressed) if match_id_input: match_id_input.text_submitted.connect(func(_text): _on_join_pressed()) if room_list: room_list.item_selected.connect(_on_room_selected) room_list.item_activated.connect(_on_room_activated) if room_list_profile_btn: room_list_profile_btn.pressed.connect(_on_profile_btn_pressed) # Connect Side Tab switching var play_side_btn = get_node_or_null("%PlayTabSideBtn") var room_side_btn = get_node_or_null("%RoomTabSideBtn") var room_tabs = get_node_or_null("%RoomListTabs") if play_side_btn and room_side_btn and room_tabs: play_side_btn.pressed.connect(func(): room_tabs.current_tab = 0 play_side_btn.button_pressed = true room_side_btn.button_pressed = false ) room_side_btn.pressed.connect(func(): room_tabs.current_tab = 1 play_side_btn.button_pressed = false room_side_btn.button_pressed = true ) # Connect Play Tab mode buttons var free_mode_btn = get_node_or_null("%FreeModeBtn") var stop_n_go_btn = get_node_or_null("%StopNGoBtn") var room_free_mode_btn = get_node_or_null("%RoomFreeModeBtn") var room_stop_n_go_btn = get_node_or_null("%RoomStopNGoBtn") if free_mode_btn: free_mode_btn.pressed.connect(func(): _host_room("Freemode")) if stop_n_go_btn: stop_n_go_btn.pressed.connect(func(): _host_room("Stop n Go")) if room_free_mode_btn: room_free_mode_btn.pressed.connect(func(): _room_mode_filter = "Freemode" if _room_mode_filter != "Freemode" else "" LobbyManager.refresh_room_list() ) if room_stop_n_go_btn: room_stop_n_go_btn.pressed.connect(func(): _room_mode_filter = "Stop n Go" if _room_mode_filter != "Stop n Go" else "" LobbyManager.refresh_room_list() ) # Connect button signals - Lobby profile_btn.pressed.connect(_on_profile_btn_pressed) if logout_btn: logout_btn.pressed.connect(_on_logout_pressed) if lobby_top_settings_btn: lobby_top_settings_btn.pressed.connect(_on_settings_pressed) copy_id_btn.pressed.connect(_on_copy_id_pressed) duration_option.item_selected.connect(_on_duration_selected) random_spawn_check.toggled.connect(_on_random_spawn_toggled) enable_timer_check.toggled.connect(_on_enable_timer_toggled) area_left_btn.pressed.connect(func(): LobbyManager.cycle_area(-1)) area_right_btn.pressed.connect(func(): LobbyManager.cycle_area(1)) leave_btn.pressed.connect(_on_leave_pressed) ready_btn.toggled.connect(_on_ready_toggled) start_game_btn.pressed.connect(_on_start_game_pressed) if scarcity_option: scarcity_option.item_selected.connect(_on_scarcity_selected) if game_mode_option: game_mode_option.item_selected.connect(_on_game_mode_selected) _setup_game_modes() # Connect LobbyManager signals LobbyManager.room_list_updated.connect(_on_room_list_updated) LobbyManager.room_joined.connect(_on_room_joined) LobbyManager.room_left.connect(_on_room_left) LobbyManager.host_disconnected.connect(_on_host_disconnected) LobbyManager.player_joined.connect(_on_player_joined) LobbyManager.player_left.connect(_on_player_left) LobbyManager.ready_state_changed.connect(_on_ready_state_changed) LobbyManager.all_players_ready.connect(_on_all_players_ready) LobbyManager.game_starting.connect(_on_game_starting) LobbyManager.match_duration_changed.connect(_on_match_duration_changed) LobbyManager.randomize_spawn_changed.connect(_on_randomize_spawn_changed) LobbyManager.enable_cycle_timer_changed.connect(_on_enable_cycle_timer_changed) LobbyManager.character_changed.connect(_on_character_changed) LobbyManager.area_changed.connect(_on_area_changed) LobbyManager.scarcity_mode_changed.connect(_on_scarcity_mode_changed) LobbyManager.game_mode_changed.connect(_on_game_mode_changed) LobbyManager.player_list_changed.connect(_update_player_slots) LobbyManager.sng_go_duration_changed.connect(_on_sng_update) LobbyManager.sng_stop_duration_changed.connect(_on_sng_update) LobbyManager.sng_required_goals_changed.connect(_on_sng_update) LobbyManager.doors_swap_time_changed.connect(_on_doors_update) LobbyManager.doors_refresh_time_changed.connect(_on_doors_update) LobbyManager.doors_required_goals_changed.connect(_on_doors_update) # Connect NakamaManager signals NakamaManager.connected_to_nakama.connect(_on_connected_to_nakama) NakamaManager.connection_failed.connect(_on_connection_failed) # Connect UserProfileManager signals UserProfileManager.profile_loaded.connect(func(_p): _on_profile_updated()) UserProfileManager.profile_updated.connect(_on_profile_updated) # Connect Chat UI if chat_send_btn: chat_send_btn.pressed.connect(_on_chat_send_pressed) if chat_input: chat_input.text_submitted.connect(func(_t): _on_chat_send_pressed()) chat_input.text_changed.connect(_on_chat_input_changed) _setup_friend_suggest_ui() # Connect Mailbox UI if MailManager: MailManager.unread_count_changed.connect(_on_mail_unread_count_changed) # Connect Social / Friend UI invite_btn = get_node_or_null("LobbyPanel/BottomBar/InviteBtn") if invite_btn: invite_btn.pressed.connect(_on_invite_friends_pressed) FriendManager.lobby_invite_received.connect(_on_lobby_invite_received) # Set initial title if already loaded _on_profile_updated() # Show main menu initially _show_panel("main_menu") # Check for disconnection reason from manager if not LobbyManager.disconnect_reason.is_empty(): connection_status.text = LobbyManager.disconnect_reason LobbyManager.disconnect_reason = "" # Try to join global chat if already connected if NakamaManager.is_connected_to_nakama(): _join_global_chat() # ============================================================================= # Setup # ============================================================================= const CHAR_NODE_MAP: Dictionary = { "Copper": "Oldpop", "Dabro": "Masbro", "Gatot": "Gatot", "Pip": "Bob" } func _setup_3d_preview() -> void: """Swaps out the active character inside the 3D MainMenu SubViewport.""" if not character_root: return var target_character = UserProfileManager.profile.get("loadout_character", "Copper") var node_name = CHAR_NODE_MAP.get(target_character, "Bob") for child in character_root.get_children(): if child is Node3D: child.visible = (child.name == node_name) if anim_player: var new_root: Node3D = character_root.get_node_or_null(node_name) as Node3D if new_root: anim_player.root_node = new_root.get_path() if anim_player.has_animation("animation-pack/idle"): anim_player.play("animation-pack/idle") elif anim_player.get_animation_list().size() > 0: anim_player.play(anim_player.get_animation_list()[0]) # Apply equipped cosmetics so the lobby preview shows the current loadout SkinManager.apply_loadout(character_root, UserProfileManager.loadout) func _load_character_textures() -> void: """Load character preview textures.""" var characters = { "Copper": "res://assets/graphics/character_selection/sc_characters/sc_copper.png", "Dabro": "res://assets/graphics/character_selection/sc_characters/sc_dabro.png", "Gatot": "res://assets/graphics/character_selection/sc_characters/sc_gatot.png", "Pip": "res://assets/graphics/character_selection/sc_characters/sc_pip.png", "Random": "res://assets/graphics/character_selection/sc_characters/sc_unknown.png" } for char_name in characters: var tex_path = characters[char_name] if ResourceLoader.exists(tex_path): character_textures[char_name] = load(tex_path) else: print("[Lobby] Character texture not found: ", tex_path) func _on_server_option_selected(index: int) -> void: if main_subtitle and server_option: main_subtitle.text = server_option.get_item_text(index).to_upper() if index == 0: # Nakama Localhost if server_ip_input: server_ip_input.visible = false NakamaManager.set_server("localhost") LobbyManager.is_lan_mode = false connection_status.text = "Mode: Local Testing (Nakama)" elif index == 1: # Nakama Remote if server_ip_input: server_ip_input.visible = true server_ip_input.placeholder_text = "IP (100.x) or Tailscale Funnel URL..." if server_ip_input: NakamaManager.set_server(server_ip_input.text) LobbyManager.is_lan_mode = false connection_status.text = "Mode: Online (Nakama Remote)" elif index == 2: # LAN Direct if server_ip_input: server_ip_input.visible = false LobbyManager.is_lan_mode = true connection_status.text = "Mode: LAN Direct (No Server)" elif index == 3: # Tekton Dash EU if server_ip_input: server_ip_input.visible = false NakamaManager.set_server("tektondash.vps.webdock.cloud") LobbyManager.is_lan_mode = false connection_status.text = "Mode: Online (Tekton Dash EU)" func _on_server_ip_submitted(new_text: String) -> void: if server_option and server_option.selected == 1: var host = new_text.strip_edges() NakamaManager.set_server(host) if host.ends_with(".ts.net"): connection_status.text = "Using Tailscale Funnel: " + host else: connection_status.text = "Server IP updated: " + host func _setup_game_modes() -> void: if not game_mode_option: return game_mode_option.clear() for mode in LobbyManager.available_game_modes: game_mode_option.add_item(mode) # Select current mode for i in range(game_mode_option.item_count): if game_mode_option.get_item_text(i) == LobbyManager.game_mode: game_mode_option.selected = i break func _setup_player_slots() -> void: """Get references to all player slot nodes.""" player_slots.clear() # Slots 1-4 in Container 1 for i in range(1, 5): var slot = players_container.get_node_or_null("PlayerSlot%d" % i) if slot: player_slots.append(slot) _connect_slot_signals(slot, i) # Slots 5-8 in Container 2 if players_container2: for i in range(5, 9): var slot = players_container2.get_node_or_null("PlayerSlot%d" % i) if slot: player_slots.append(slot) _connect_slot_signals(slot, i) func _connect_slot_signals(slot: Control, i: int): # Connect character navigation buttons for all slots var left_btn = slot.get_node_or_null("CharacterNav%d/CharLeftBtn%d" % [i, i]) var right_btn = slot.get_node_or_null("CharacterNav%d/CharRightBtn%d" % [i, i]) if left_btn: left_btn.pressed.connect(func(): LobbyManager.cycle_character(-1)) if right_btn: right_btn.pressed.connect(func(): LobbyManager.cycle_character(1)) # ============================================================================= # Panel Management # ============================================================================= func _show_panel(panel_name: String) -> void: main_menu_panel.visible = panel_name == "main_menu" room_list_panel.visible = panel_name == "room_list" lobby_panel.visible = panel_name == "lobby" # ============================================================================= # Main Menu Button Handlers # ============================================================================= func _on_tutorial_pressed() -> void: if AuthManager.is_guest: if LobbyManager.local_player_name.is_empty() or LobbyManager.local_player_name == "Player": LobbyManager.local_player_name = NameGenerator.generate_guest_name() else: LobbyManager.local_player_name = UserProfileManager.get_display_name() connection_status.text = "Starting Tutorial Scenario..." _apply_loadout_character() LobbyManager.start_tutorial("Freemode") # Give the engine two frames for room_joined signal to settle, # then immediately force-start the game — bypassing the lobby wait screen. await get_tree().process_frame await get_tree().process_frame LobbyManager.start_game(true) func _on_create_room_pressed() -> void: _show_panel("room_list") var tabs = get_node_or_null("%RoomListTabs") if tabs: tabs.current_tab = 0 if get_node_or_null("%PlayTabSideBtn"): get_node("%PlayTabSideBtn").button_pressed = true if get_node_or_null("%RoomTabSideBtn"): get_node("%RoomTabSideBtn").button_pressed = false _sync_room_profile_card() # --- PRESERVED HOSTING LOGIC --- # Moved to _host_room() — called by FreeModeBtn / StopNGoBtn # ------------------------------- func _host_room(game_mode: String) -> void: """Restore preserved hosting logic. Called by mode buttons. game_mode: set LobbyManager.game_mode before hosting (e.g. 'Stop n Go'), or 'Freemode' to keep default.""" # Set player name if AuthManager.is_guest: if LobbyManager.local_player_name.is_empty() or LobbyManager.local_player_name == "Player": LobbyManager.local_player_name = NameGenerator.generate_guest_name() else: LobbyManager.local_player_name = UserProfileManager.get_display_name() # Set game mode BEFORE is_host flag (set_game_mode guards on is_host) if not game_mode.is_empty(): LobbyManager.game_mode = game_mode LobbyManager._update_available_areas(game_mode) _apply_loadout_character() var mode_prefix := "[%s] " % game_mode if not game_mode.is_empty() else "" if LobbyManager.is_lan_mode: connection_status.text = "Starting LAN room..." var room_label := "%sLAN Room %d" % [mode_prefix, randi_range(100, 999)] var ok = await LobbyManager.create_room_lan(room_label) if not ok: connection_status.text = "Failed to start LAN room. Check port 7777." else: connection_status.text = "Creating Nakama room..." var room_label := "%sRoom %d" % [mode_prefix, randi_range(1000, 9999)] LobbyManager.create_room(room_label) func _on_browse_rooms_pressed() -> void: _show_panel("room_list") var tabs = get_node_or_null("%RoomListTabs") if tabs: tabs.current_tab = 1 if get_node_or_null("%PlayTabSideBtn"): get_node("%PlayTabSideBtn").button_pressed = false if get_node_or_null("%RoomTabSideBtn"): get_node("%RoomTabSideBtn").button_pressed = true _sync_room_profile_card() var match_id_label = get_node_or_null("%RoomListPanel/RoomListTabs/RoomTab/HBoxContainer/RightCol/MatchIdLabel") if LobbyManager.is_lan_mode: connection_status.text = "LAN Mode - Enter Host IP to join" match_id_input.placeholder_text = "Enter Host IP (e.g. 192.168.1.10)..." if match_id_label: match_id_label.text = "DIRECT CONNECT (HOST IP)" _on_refresh_pressed() # Try to discover rooms if implemented else: connection_status.text = "Loading Nakama rooms..." match_id_input.placeholder_text = "Paste match ID here..." if match_id_label: match_id_label.text = "DIRECT CONNECT (MATCH ID)" LobbyManager.refresh_room_list() # ============================================================================= # Room List Button Handlers # ============================================================================= func _on_refresh_pressed() -> void: connection_status.text = "Refreshing..." room_list.clear() LobbyManager.refresh_room_list() func _on_room_selected(index: int) -> void: """Automatically fill the match_id input when a room is clicked so it's not lost on focus change.""" if index < LobbyManager.available_rooms.size(): var room = LobbyManager.available_rooms[index] if LobbyManager.is_lan_mode: var ip = room.get("ip", "") if not ip.is_empty(): match_id_input.text = ip else: var mid = room.get("match_id", "") if not mid.is_empty(): match_id_input.text = mid func _on_room_activated(index: int) -> void: """Handle double-clicking a room to join immediately.""" _on_room_selected(index) _on_join_pressed() func _on_join_pressed() -> void: var match_id = match_id_input.text.strip_edges() if match_id.is_empty() and not LobbyManager.is_lan_mode: var selected_items = room_list.get_selected_items() if selected_items.size() == 0: connection_status.text = "Please select a room or enter Match ID" return var selected_idx = selected_items[0] if selected_idx < LobbyManager.available_rooms.size(): match_id = LobbyManager.available_rooms[selected_idx].get("match_id", "") # Determine player name if AuthManager.is_guest: if LobbyManager.local_player_name.is_empty(): LobbyManager.local_player_name = NameGenerator.generate_guest_name() else: LobbyManager.local_player_name = UserProfileManager.get_display_name() if LobbyManager.is_lan_mode: if match_id.is_empty(): # If nothing entered but something selected in list (discovered), use it var selected_items = room_list.get_selected_items() if selected_items.size() > 0: var idx = selected_items[0] if idx < LobbyManager.available_rooms.size(): match_id = LobbyManager.available_rooms[idx].get("ip", "") if match_id.is_empty(): connection_status.text = "Enter Host IP to join" return connection_status.text = "Connecting to %s..." % match_id # Apply loadout character before joining _apply_loadout_character() var ok = LobbyManager.join_room_lan(match_id) if not ok: connection_status.text = "Failed to connect to %s" % match_id else: if match_id.is_empty(): connection_status.text = "No room selected" return connection_status.text = "Joining Nakama room..." # Apply loadout character before joining _apply_loadout_character() LobbyManager.join_room(match_id) func _on_back_pressed() -> void: _show_panel("main_menu") connection_status.text = "" func _update_settings_visibility() -> void: var is_host = LobbyManager.is_host var is_freemode = LobbyManager.game_mode == "Freemode" # Duration var show_duration = is_freemode duration_option.visible = is_host and show_duration duration_text_label.visible = not is_host and show_duration $LobbyPanel/TopBar/SettingsSection/DurationLabel.visible = show_duration # Random Spawn var show_spawn = is_freemode random_spawn_check.visible = is_host and show_spawn random_spawn_label.visible = not is_host and show_spawn if spawn_spacer: spawn_spacer.visible = show_spawn # Timer var show_timer = is_freemode enable_timer_check.visible = is_host and show_timer enable_timer_label.visible = not is_host and show_timer if timer_spacer: timer_spacer.visible = show_timer # Scarcity var show_scarcity = is_freemode if scarcity_option: scarcity_option.visible = is_host and show_scarcity if scarcity_label: scarcity_label.visible = not is_host and show_scarcity if scarcity_spacer: scarcity_spacer.visible = show_scarcity # Custom mode sets var is_sng = LobbyManager.game_mode == "Stop n Go" if sng_settings_container: sng_settings_container.visible = is_sng sng_go_option.disabled = not is_host sng_stop_option.disabled = not is_host sng_goals_option.disabled = not is_host sng_goals_option.disabled = not is_host func _create_custom_settings_ui() -> void: var settings_section = $LobbyPanel/TopBar/SettingsSection if not settings_section: return # Stop n Go sng_settings_container = HBoxContainer.new() sng_settings_container.visible = false settings_section.add_child(sng_settings_container) _add_label(sng_settings_container, "Go Time:") sng_go_option = OptionButton.new() sng_go_option.add_item("10s"); sng_go_option.add_item("15s"); sng_go_option.add_item("25s") sng_go_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_sng_go_duration([10, 15, 25][idx])) sng_settings_container.add_child(sng_go_option) _add_label(sng_settings_container, "Stop Time:") sng_stop_option = OptionButton.new() sng_stop_option.add_item("3s"); sng_stop_option.add_item("4s"); sng_stop_option.add_item("5s") sng_stop_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_sng_stop_duration([3, 4, 5][idx])) sng_settings_container.add_child(sng_stop_option) _add_label(sng_settings_container, "Req Goals:") sng_goals_option = OptionButton.new() sng_goals_option.add_item("5"); sng_goals_option.add_item("8"); sng_goals_option.add_item("12") sng_goals_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_sng_required_goals([5, 8, 12][idx])) sng_settings_container.add_child(sng_goals_option) # Move Game Mode selector to the far right var gm_spacer = settings_section.get_node_or_null("GameModeSpacer") var gm_option = settings_section.get_node_or_null("GameModeOption") var gm_label = settings_section.get_node_or_null("GameModeTextLabel") if gm_spacer: settings_section.move_child(gm_spacer, -1) if gm_option: settings_section.move_child(gm_option, -1) if gm_label: settings_section.move_child(gm_label, -1) func _add_label(parent: Control, text: String): var spacer = Control.new() spacer.custom_minimum_size = Vector2(10, 0) parent.add_child(spacer) var lbl = Label.new() lbl.text = text parent.add_child(lbl) # ============================================================================= # Lobby Button Handlers # ============================================================================= func _on_ready_toggled(is_ready: bool) -> void: LobbyManager.set_ready(is_ready) ready_btn.text = "READY ✓" if is_ready else "READY" func _on_start_game_pressed() -> void: LobbyManager.start_game() func _on_leave_pressed() -> void: LobbyManager.leave_room() _show_panel("main_menu") ready_btn.button_pressed = false ready_btn.text = "READY" # Release bot names so they can be reused in the next session for key in _bot_names.keys(): NameGenerator.release_bot_name(_bot_names[key]) _bot_names.clear() func _on_copy_id_pressed() -> void: DisplayServer.clipboard_set(current_match_id) status_label.text = "Match ID copied!" func _on_duration_selected(index: int) -> void: if not LobbyManager.is_host: return var durations = [60, 120, 180, 300, 600] if index >= 0 and index < durations.size(): LobbyManager.set_match_duration(durations[index]) func _on_random_spawn_toggled(toggled_on): LobbyManager.set_randomize_spawn(toggled_on) func _on_enable_timer_toggled(toggled_on): LobbyManager.set_enable_cycle_timer(toggled_on) func _on_scarcity_selected(index: int) -> void: if not LobbyManager.is_host: return var mode = scarcity_option.get_item_text(index) LobbyManager.set_scarcity_mode(mode) func _on_scarcity_mode_changed(mode: String) -> void: if scarcity_option: for i in range(scarcity_option.item_count): if scarcity_option.get_item_text(i) == mode: scarcity_option.selected = i break if scarcity_label: scarcity_label.text = mode func _on_game_mode_selected(index: int) -> void: if not LobbyManager.is_host: return var mode = game_mode_option.get_item_text(index) LobbyManager.set_game_mode(mode) func _on_game_mode_changed(mode: String) -> void: if game_mode_option: for i in range(game_mode_option.item_count): if game_mode_option.get_item_text(i) == mode: game_mode_option.selected = i break if game_mode_text_label: game_mode_text_label.text = mode _update_settings_visibility() func _on_sng_update(_val: int = 0) -> void: if not sng_go_option: return var go_idx = [10, 15, 25].find(LobbyManager.sng_go_duration) if go_idx != -1: sng_go_option.selected = go_idx var stop_idx = [3, 4, 5].find(LobbyManager.sng_stop_duration) if stop_idx != -1: sng_stop_option.selected = stop_idx var goals_idx = [5, 8, 12].find(LobbyManager.sng_required_goals) if goals_idx != -1: sng_goals_option.selected = goals_idx func _on_doors_update(_val: int = 0) -> void: if not doors_swap_option: return var swap_idx = [10, 15, 30].find(LobbyManager.doors_swap_time) if swap_idx != -1: doors_swap_option.selected = swap_idx var refresh_idx = [15, 25, 40].find(LobbyManager.doors_refresh_time) if refresh_idx != -1: doors_refresh_option.selected = refresh_idx var goals_idx = [5, 8, 12].find(LobbyManager.doors_required_goals) if goals_idx != -1: doors_goals_option.selected = goals_idx func _update_random_spawn_label(enabled: bool) -> void: if random_spawn_label: random_spawn_label.text = "Random ✓" if enabled else "Random ✗" func _on_profile_btn_pressed() -> void: if not profile_panel_instance: var profile_panel_scene := load("res://scenes/ui/profile_panel.tscn") profile_panel_instance = profile_panel_scene.instantiate() profile_panel_instance.closed.connect(func(): profile_panel_instance.hide() if main_menu_panel: main_menu_panel.show() ) # Full-screen overlay — fill the entire lobby viewport profile_panel_instance.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(profile_panel_instance) if profile_panel_instance: if main_menu_panel: main_menu_panel.hide() profile_panel_instance.show_panel() var _mailbox_panel_instance: Control func _on_mailbox_pressed() -> void: if not _mailbox_panel_instance: var scene = load("res://scenes/ui/mailbox_panel.tscn") if scene: _mailbox_panel_instance = scene.instantiate() _mailbox_panel_instance.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) var cl := CanvasLayer.new() cl.layer = 100 cl.name = "MailboxCanvasLayer" add_child(cl) cl.add_child(_mailbox_panel_instance) if _mailbox_panel_instance.has_signal("closed"): _mailbox_panel_instance.closed.connect(func(): _mailbox_panel_instance.get_parent().queue_free() _mailbox_panel_instance = null ) if _mailbox_panel_instance: _mailbox_panel_instance.show_panel() func _on_mail_unread_count_changed(count: int) -> void: if mail_badge: if count > 0: mail_badge.text = str(count) if count < 100 else "99+" mail_badge.visible = true else: mail_badge.visible = false func _on_logout_pressed() -> void: AuthManager.logout() _go_to_login() func _on_quit_pressed() -> void: print("[Lobby] Quitting game...") get_tree().quit() func _on_settings_pressed(): var settings_menu = get_node_or_null("SettingsMenu") if not settings_menu: var scene = load("res://scenes/ui/settings_menu.tscn") if scene: settings_menu = scene.instantiate() settings_menu.name = "SettingsMenu" add_child(settings_menu) # Connect close button — restore whichever panel was visible before var close_btn = settings_menu.get_node_or_null("PanelContainer/VBoxContainer/Header/CloseButton") if close_btn: close_btn.pressed.connect(func(): if lobby_panel and lobby_panel.visible: pass # lobby_panel restored below via _restore_panel _restore_after_settings() ) if settings_menu: # Remember what is currently active settings_menu.set_meta("from_lobby", lobby_panel.visible if lobby_panel else false) main_menu_panel.hide() if lobby_panel: lobby_panel.hide() settings_menu.open() func _restore_after_settings() -> void: var settings_menu = get_node_or_null("SettingsMenu") var from_lobby: bool = settings_menu.get_meta("from_lobby", false) if settings_menu else false if from_lobby: if lobby_panel: lobby_panel.show() else: if main_menu_panel: main_menu_panel.show() func _on_shop_pressed() -> void: if not NakamaManager.session: connection_status.text = "Must be logged in" return if not shop_panel_instance: var shop_scene = load("res://scenes/ui/shop_panel.tscn") if shop_scene: shop_panel_instance = shop_scene.instantiate() add_child(shop_panel_instance) shop_panel_instance.closed.connect(func(): if main_menu_panel: main_menu_panel.show()) if shop_panel_instance: if main_menu_panel: main_menu_panel.hide() shop_panel_instance.show_panel() func _on_banner1_pressed() -> void: var gacha_scene = load("res://scenes/ui/gacha_panel.tscn") if not gacha_scene: connection_status.text = "Gacha panel not found" return var gacha = gacha_scene.instantiate() gacha.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(gacha) if main_menu_panel: main_menu_panel.hide() # GachaPanel emits "closed" from its BackBtn handler if gacha.has_signal("closed"): gacha.closed.connect(func(): gacha.queue_free() if main_menu_panel: main_menu_panel.show() ) else: # Fallback: listen for BackBtn directly var back = gacha.get_node_or_null("%BackBtn") if back: back.pressed.connect(func(): gacha.queue_free() if main_menu_panel: main_menu_panel.show() ) func _on_leaderboard_pressed() -> void: if not leaderboard_panel_instance: var leaderboard_panel_scene := load("res://scenes/ui/leaderboard_panel.tscn") if leaderboard_panel_scene: leaderboard_panel_instance = leaderboard_panel_scene.instantiate() leaderboard_panel_instance.closed.connect(func(): leaderboard_panel_instance.hide() if main_menu_panel: main_menu_panel.show() ) # Full-screen overlay leaderboard_panel_instance.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(leaderboard_panel_instance) if leaderboard_panel_instance: if main_menu_panel: main_menu_panel.hide() leaderboard_panel_instance.show_panel() func _on_ticket_pressed() -> void: if not NakamaManager.session: connection_status.text = "Must be logged in" return if not daily_reward_panel_instance: var scene = load("res://scenes/ui/daily_reward_panel.tscn") if scene: daily_reward_panel_instance = scene.instantiate() daily_reward_panel_instance.closed.connect(func(): if main_menu_panel: main_menu_panel.show() ) add_child(daily_reward_panel_instance) if daily_reward_panel_instance: if main_menu_panel: main_menu_panel.hide() daily_reward_panel_instance.show_panel() func _go_to_login() -> void: if get_tree(): get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn") # ============================================================================= # LobbyManager Signal Handlers # ============================================================================= func _on_room_list_updated(rooms: Array) -> void: room_list.clear() # Remove previously cloned row nodes (children after Head and ItemTemplate) if item_template: var parent = item_template.get_parent() for child in parent.get_children(): if child.has_meta("room_row"): child.queue_free() var filtered := rooms.filter(func(r): return _room_mode_filter.is_empty() or r.get("game_mode", "") == _room_mode_filter ) if item_template: # Use visual row template var parent = item_template.get_parent() for i in range(filtered.size()): var room = filtered[i] var row = item_template.duplicate() row.visible = true row.set_meta("room_row", true) row.set_meta("room_index", i) var room_id_short = room.get("room_name", "???") var host_name = room.get("host_name", "Unknown") var player_count = room.get("player_count", 1) var max_players = room.get("max_players", 8) var mode = room.get("game_mode", "") var mode_short = "FMD" if mode == "Freemode" else ("SNG" if mode == "Stop n Go" else mode.left(3).to_upper()) # Populate labels inside the cloned template var hbox = row.get_node("PanelContainer/MarginContainer/HBoxContainer") if hbox: var lbl_id = hbox.get_child(0) # ListRoomID var lbl_host = hbox.get_child(1) # ListHost var lbl_mode = hbox.get_child(2) # ListMode var lbl_cap = hbox.get_child(3) # ListCap if lbl_id: lbl_id.text = room_id_short if lbl_host: lbl_host.text = host_name if lbl_mode: lbl_mode.text = mode_short if lbl_cap: lbl_cap.text = "%d/%d" % [player_count, max_players] parent.add_child(row) # Make row clickable — select in ItemList + fill MatchIdInput var panel_box = row.get_node_or_null("PanelContainer") if panel_box: var idx = i panel_box.gui_input.connect(func(event: InputEvent): if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: _on_room_selected(idx) if event.double_click: _on_room_activated(idx) ) panel_box.mouse_filter = Control.MOUSE_FILTER_STOP else: # Fallback: flat strings in ItemList for room in filtered: var room_name = room.get("room_name", "Unknown") var host_name = room.get("host_name", "Unknown") var player_count = room.get("player_count", 1) var max_players = room.get("max_players", 4) var mode = room.get("game_mode", "") var mode_tag = " [%s]" % mode if not mode.is_empty() else "" room_list.add_item("%s - %s (%d/%d)%s" % [room_name, host_name, player_count, max_players, mode_tag]) var filter_note = " [Filter: %s]" % _room_mode_filter if not _room_mode_filter.is_empty() else "" if filtered.size() == 0: connection_status.text = "No rooms available%s" % filter_note else: connection_status.text = "Found %d room(s)%s" % [filtered.size(), filter_note] func _on_room_joined(room_data: Dictionary) -> void: _show_panel("lobby") current_match_id = room_data.get("match_id", "") match_id_display.text = "ID: %s" % _truncate_id(current_match_id) # Configure host-specific UI var is_host = LobbyManager.is_host host_banner.visible = is_host start_game_btn.visible = is_host _update_settings_visibility() # Update values from LobbyManager _on_match_duration_changed(LobbyManager.match_duration) _on_randomize_spawn_changed(LobbyManager.randomize_spawn) _on_enable_cycle_timer_changed(LobbyManager.enable_cycle_timer) # Scarcity Update _on_scarcity_mode_changed(LobbyManager.scarcity_mode) # Initial UI sync for custom modes _on_sng_update() _on_doors_update() # Area selector: only host can interact area_left_btn.disabled = not is_host area_right_btn.disabled = not is_host area_name_label.text = LobbyManager.get_selected_area() # Game Mode Update if game_mode_option: game_mode_option.visible = is_host if game_mode_text_label: game_mode_text_label.visible = not is_host _on_game_mode_changed(LobbyManager.game_mode) _update_player_slots() connection_status.text = "Connected to room" func _on_room_left() -> void: _show_panel("main_menu") connection_status.text = "Left room" # Release bot names for reuse for key in _bot_names.keys(): NameGenerator.release_bot_name(_bot_names[key]) _bot_names.clear() func _on_host_disconnected() -> void: # Keep the connection status updated in the UI connection_status.text = "Host disconnected. Returning to menu..." _show_panel("main_menu") func _on_player_joined(player_data: Dictionary) -> void: _update_player_slots() status_label.text = "%s joined!" % player_data.get("name", "Player") func _on_player_left(_player_id: int) -> void: _update_player_slots() status_label.text = "A player left" func _on_ready_state_changed(_player_id: int, _is_ready: bool) -> void: _update_player_slots() _update_status() func _on_all_players_ready() -> void: if LobbyManager.is_host: start_game_btn.disabled = false status_label.text = "All ready! Start the match!" else: status_label.text = "All ready! Waiting for host..." func _on_game_starting() -> void: connection_status.text = "Starting game..." # Instantiate and use the loading screen var loading_screen_scene = load("res://scenes/loading_screen/loading_screen.tscn") if loading_screen_scene: var loading_screen = loading_screen_scene.instantiate() get_tree().root.add_child(loading_screen) loading_screen.load_level("res://scenes/main.tscn") else: # Fallback if loading screen fails to load get_tree().change_scene_to_file("res://scenes/main.tscn") func _on_match_duration_changed(duration_seconds: int) -> void: if not LobbyManager.is_host: _update_duration_text_label(duration_seconds) func _on_randomize_spawn_changed(enabled: bool) -> void: if random_spawn_check: random_spawn_check.set_pressed_no_signal(enabled) if random_spawn_label: random_spawn_label.text = "Random \u2713" if enabled else "Random \u2717" func _on_enable_cycle_timer_changed(enabled: bool) -> void: if enable_timer_check: enable_timer_check.set_pressed_no_signal(enabled) if enable_timer_label: enable_timer_label.text = "Timer \u2713" if enabled else "Timer \u2717" func _on_character_changed(_player_id: int, _character_name: String) -> void: _update_player_slots() func _on_area_changed(area_name: String) -> void: area_name_label.text = area_name func _on_connected_to_nakama() -> void: connection_status.text = "Connected to server" _join_global_chat() func _on_connection_failed(error_message: String) -> void: connection_status.text = "Connection failed: %s" % error_message _show_panel("main_menu") func _on_profile_updated() -> void: """Handle profile updates (name/avatar change).""" var display_name: String = "" if UserProfileManager.is_profile_loaded: display_name = UserProfileManager.get_display_name() elif not AuthManager.is_guest and AuthManager.is_authenticated: display_name = "LOADING..." else: # Is Guest or not logged in yet if LobbyManager.local_player_name.is_empty() or LobbyManager.local_player_name == "Guest": display_name = NameGenerator.generate_guest_name() else: display_name = LobbyManager.local_player_name if main_title: main_title.text = display_name if username_label: username_label.text = display_name if avatar_display: var avatar_url = UserProfileManager.get_avatar_url() if ResourceLoader.exists(avatar_url): avatar_display.texture = load(avatar_url) if main_menu_profile_btn: main_menu_profile_btn.text = "" if gold_label and UserProfileManager.is_profile_loaded: gold_label.text = str(UserProfileManager.wallet.get("gold", 0)) if star_label and UserProfileManager.is_profile_loaded: star_label.text = str(UserProfileManager.wallet.get("star", 0)) # Update Room tab stats if room_player_username: room_player_username.text = display_name if room_player_score and UserProfileManager.is_profile_loaded: var total = UserProfileManager.stats.get("total_score", 0) room_player_score.text = str(total).pad_zeros(6) if room_player_rank and UserProfileManager.is_profile_loaded: room_player_rank.text = str(_local_player_rank).pad_zeros(2) if room_avatar and avatar_display and avatar_display.texture: # Copy the style and override it to use the avatar texture var style = StyleBoxTexture.new() style.texture = avatar_display.texture room_avatar.add_theme_stylebox_override("panel", style) # Sync to LobbyManager LobbyManager.set_player_name(display_name) _setup_3d_preview() # ============================================================================= # Room Profile Card Sync # ============================================================================= func _sync_room_profile_card() -> void: """Sync username, total_score, rank, and avatar into the RoomTab profile card.""" if room_player_username: room_player_username.text = UserProfileManager.get_display_name() if UserProfileManager.is_profile_loaded else LobbyManager.local_player_name if room_player_score and UserProfileManager.is_profile_loaded: var total = UserProfileManager.stats.get("total_score", 0) room_player_score.text = str(total).pad_zeros(6) if room_player_rank: room_player_rank.text = str(_local_player_rank).pad_zeros(2) if room_avatar and avatar_display and avatar_display.texture: var style = StyleBoxTexture.new() style.texture = avatar_display.texture room_avatar.add_theme_stylebox_override("panel", style) # Fetch rank from leaderboard in background and update label when done _fetch_local_player_rank() # ============================================================================= # Leaderboard Rank Fetch # ============================================================================= func _fetch_local_player_rank() -> void: """Fetch local player's rank from Nakama leaderboard and update the Rank label.""" if not NakamaManager.session or AuthManager.is_guest: return var my_id := NakamaManager.session.user_id # List around the local player to get their rank var result = await NakamaManager.client.list_leaderboard_records_around_owner_async( NakamaManager.session, "global_high_score", my_id, null, 1 ) if result.is_exception(): return if result.owner_records and result.owner_records.size() > 0: var rec = result.owner_records[0] _local_player_rank = int(rec.rank) elif result.records and result.records.size() > 0: _local_player_rank = int(result.records[0].rank) else: return if room_player_rank and room_list_panel.visible: room_player_rank.text = str(_local_player_rank).pad_zeros(2) # ============================================================================= # Player Slot Updates # ============================================================================= func _update_player_slots() -> void: """Update all player slot visuals based on current player list.""" if not multiplayer.has_multiplayer_peer(): return var players = LobbyManager.get_players() var my_id = multiplayer.get_unique_id() for i in range(player_slots.size()): var slot = player_slots[i] var slot_num = i + 1 if i < players.size(): var player = players[i] slot.visible = true # Update player name var name_label = slot.get_node_or_null("PlayerName%d" % slot_num) if name_label: var display_name = player.get("name", "Player %d" % slot_num) if player.get("id") == 1: display_name += " (Host)" name_label.text = display_name # Update character preview var char_preview = slot.get_node_or_null("CharacterPreview%d" % slot_num) var char_name = player.get("character", "Bob") if char_preview and character_textures.has(char_name): char_preview.texture = character_textures[char_name] # Check if this is the local player var is_local_player = player.get("id") == my_id # Update character name in nav (inside CharacterNav) var char_name_in_nav = slot.get_node_or_null("CharacterNav%d/CharacterName%d" % [slot_num, slot_num]) if char_name_in_nav: char_name_in_nav.text = char_name # Update character name label (outside nav, for non-local players) var char_name_label = slot.get_node_or_null("CharacterNameLabel%d" % slot_num) if char_name_label: char_name_label.text = char_name char_name_label.visible = not is_local_player # Show/hide character navigation (only for local player) var char_nav = slot.get_node_or_null("CharacterNav%d" % slot_num) if char_nav: char_nav.visible = is_local_player # Update ready status var ready_label = slot.get_node_or_null("ReadyStatus%d" % slot_num) if ready_label: var is_ready = player.get("is_ready", false) ready_label.text = "READY ✓" if is_ready else "NOT READY" ready_label.add_theme_color_override("font_color", Color(0.4, 0.8, 0.4) if is_ready else Color(0.6, 0.6, 0.6)) # + Friend button — node must exist in lobby.tscn as "AddFriendBtn1", "AddFriendBtn2"... var player_nakama_id: String = player.get("nakama_id", "") var my_nakama_id: String = NakamaManager.session.user_id if NakamaManager.session else "" var add_friend_btn: Button = slot.get_node_or_null("AddFriendBtn%d" % slot_num) if add_friend_btn: if player_nakama_id.is_empty() or player_nakama_id == my_nakama_id: add_friend_btn.visible = false else: add_friend_btn.visible = true if FriendManager.is_friend(player_nakama_id): add_friend_btn.text = "Friend ✓" add_friend_btn.disabled = true else: add_friend_btn.text = "+ Friend" add_friend_btn.disabled = false if not add_friend_btn.pressed.is_connected(func(): _on_add_friend_pressed(player_nakama_id)): add_friend_btn.pressed.connect(func(): _on_add_friend_pressed(player_nakama_id)) else: # Empty slot - show as bot placeholder slot.visible = true # Assign stable bot name for this slot (generate once, reuse) if not _bot_names.has(i): _bot_names[i] = NameGenerator.generate_bot_name() var bot_display_name: String = _bot_names[i] # Update name to show bot with an icon prefix var name_label = slot.get_node_or_null("PlayerName%d" % slot_num) if name_label: name_label.text = "🤖 " + bot_display_name # Use a character for bot preview var char_preview = slot.get_node_or_null("CharacterPreview%d" % slot_num) var bot_characters = ["Copper", "Dabro", "Gatot", "Pip"] var bot_char = bot_characters[(i) % bot_characters.size()] if char_preview and character_textures.has(bot_char): char_preview.texture = character_textures[bot_char] # Hide character navigation for bots var char_nav = slot.get_node_or_null("CharacterNav%d" % slot_num) if char_nav: char_nav.visible = false # Update character name label var char_name_label = slot.get_node_or_null("CharacterNameLabel%d" % slot_num) if char_name_label: char_name_label.text = bot_char char_name_label.visible = true # Show ready status as waiting var ready_label = slot.get_node_or_null("ReadyStatus%d" % slot_num) if ready_label: ready_label.text = "WAITING..." ready_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.7)) # Hide friend button for bots/empty slots var add_friend_btn: Button = slot.get_node_or_null("AddFriendBtn%d" % slot_num) if add_friend_btn: add_friend_btn.visible = false _update_status() func _update_status() -> void: var players = LobbyManager.get_players() var ready_count = 0 for player in players: if player.get("is_ready", false): ready_count += 1 status_label.text = "Ready: %d/%d" % [ready_count, players.size()] if LobbyManager.is_host: start_game_btn.disabled = not LobbyManager.is_all_ready() # ============================================================================= # Helper Functions # ============================================================================= func _update_duration_text_label(duration_seconds: int) -> void: var duration_text: String match duration_seconds: 60: duration_text = "1 min" 120: duration_text = "2 min" 180: duration_text = "3 min" 300: duration_text = "5 min" 600: duration_text = "10 min" _: duration_text = "%d sec" % duration_seconds duration_text_label.text = duration_text func _truncate_id(id: String) -> String: if id.length() > 16: return id.substr(0, 8) + "..." + id.substr(-4) return id func _apply_loadout_character() -> void: """Apply the player's saved loadout default character to LobbyManager before entering a room.""" var saved_char: String = UserProfileManager.profile.get("loadout_character", "") if saved_char.is_empty(): return var idx := LobbyManager.available_characters.find(saved_char) if idx != -1: LobbyManager.local_character_index = idx print("[Lobby] Loadout character applied: ", saved_char) # ============================================================================= # Social / Friend Functions # ============================================================================= func _on_add_friend_pressed(nakama_id: String) -> void: var ok = await FriendManager.add_friend_by_id(nakama_id) if ok: _update_player_slots() func _on_invite_friends_pressed() -> void: """Open the invite friends dialog (scene-based).""" var match_id = current_match_id if match_id.is_empty(): return var friends = FriendManager.get_mutual_friends() var scene = load("res://scenes/ui/invite_friends_dialog.tscn") as PackedScene if not scene: return var dialog = scene.instantiate() add_child(dialog) dialog.open(friends, match_id) dialog.closed.connect(dialog.queue_free) func _on_lobby_invite_received(from_user_id: String, from_name: String, match_id: String) -> void: """Show invite notification popup. Only shown if not already in a game.""" if get_tree().current_scene.scene_file_path != "res://scenes/lobby.tscn": return if lobby_panel and lobby_panel.visible: return if _invite_popup: _invite_popup.queue_free() _pending_invite_match_id = match_id var scene = load("res://scenes/ui/lobby_invite_popup.tscn") as PackedScene if scene: _invite_popup = scene.instantiate() add_child(_invite_popup) _invite_popup.setup(from_name) _invite_popup.accepted.connect(_on_invite_accepted) _invite_popup.declined.connect(func(): _invite_popup.queue_free()) _invite_popup.popup_centered() else: # Fallback if scene not yet added to project var dlg := AcceptDialog.new() dlg.title = "Lobby Invitation" dlg.dialog_text = "%s invited you!\nJoin?" % from_name dlg.ok_button_text = "Join" dlg.add_cancel_button("Decline") add_child(dlg) dlg.confirmed.connect(_on_invite_accepted) dlg.canceled.connect(dlg.queue_free) dlg.popup_centered() _invite_popup = dlg func _on_invite_accepted() -> void: if not _pending_invite_match_id.is_empty(): LobbyManager.join_room(_pending_invite_match_id) if _invite_popup: _invite_popup.queue_free() _pending_invite_match_id = "" func _on_social_pressed() -> void: if not social_panel_instance: var scene = load("res://scenes/ui/social_panel.tscn") if scene: social_panel_instance = scene.instantiate() social_panel_instance.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) # Wrap in CanvasLayer so it draws above 3D viewport and all lobby UI var cl := CanvasLayer.new() cl.layer = 100 cl.name = "SocialCanvasLayer" add_child(cl) cl.add_child(social_panel_instance) if social_panel_instance.has_signal("dm_requested"): social_panel_instance.dm_requested.connect(func(user_id, username): social_panel_instance.hide() _open_dm_tab(user_id, username) # Restore Lobby UI if main_menu_panel: var main_hbox = main_menu_panel.get_node_or_null("MainMargin/MainHBox") if main_hbox: var left_col = main_hbox.get_node_or_null("LeftCol") if left_col: for child in left_col.get_children(): child.show() var right_col = main_hbox.get_node_or_null("RightCol") if right_col: right_col.show() ) if social_panel_instance.has_signal("closed"): social_panel_instance.closed.connect(func(): social_panel_instance.hide() # Restore Lobby UI if main_menu_panel: var main_hbox = main_menu_panel.get_node_or_null("MainMargin/MainHBox") if main_hbox: var left_col = main_hbox.get_node_or_null("LeftCol") if left_col: for child in left_col.get_children(): child.show() var right_col = main_hbox.get_node_or_null("RightCol") if right_col: right_col.show() ) if social_panel_instance: # Hide unnecessary UI to focus on Social/Chat if main_menu_panel: var main_hbox = main_menu_panel.get_node_or_null("MainMargin/MainHBox") if main_hbox: var left_col = main_hbox.get_node_or_null("LeftCol") if left_col: for child in left_col.get_children(): # Keep Chat, Input, and Spacer (to keep chat at bottom) if child.name not in ["ChatPanel", "HBoxContainer", "SpacerMiddle"]: child.hide() var right_col = main_hbox.get_node_or_null("RightCol") if right_col: right_col.hide() social_panel_instance.show() # ============================================================================= # Global Chat System # ============================================================================= var _active_chat_context: String = "global" var _dm_tabs: Dictionary = {} # user_id -> Control var _dm_messages: Dictionary = {} # user_id -> Array func _join_global_chat() -> void: """Join the persistent global lobby chat channel via Nakama socket.""" # Already in the channel — don't rejoin if _chat_channel != null: return var socket = NakamaManager.socket if not socket or not socket.is_connected_to_host(): push_warning("[Chat] Socket not connected, skipping chat join.") return # Join a persistent room channel named by our constant var result = await socket.join_chat_async(GLOBAL_CHAT_ROOM, NakamaSocket.ChannelType.Room, true, # persistence = true (server stores history) false) # hidden = false if result.is_exception(): push_warning("[Chat] Failed to join global chat: " + result.get_exception().message) return _chat_channel = result print("[Chat] Joined global channel: ", _chat_channel.id) # Connect incoming message signal if not socket.received_channel_message.is_connected(_on_chat_message_received): socket.received_channel_message.connect(_on_chat_message_received) # Load history _chat_messages.clear() var history_result = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, _chat_channel.id, 50, false) if not history_result.is_exception() and history_result.messages: var msgs = history_result.messages.duplicate() msgs.reverse() # Oldest to newest for msg in msgs: _add_chat_message(msg, false) _trim_old_messages() _refresh_chat_display() func _on_chat_message_received(message) -> void: """Nakama socket signal: a message arrived on any channel.""" if _chat_channel == null or message.channel_id != _chat_channel.id: return # Ignore messages from ourselves (we inject them locally for instant feedback) if NakamaManager.session and message.sender_id == NakamaManager.session.user_id: return _add_chat_message(message, true) func _add_chat_message(message, refresh_display: bool) -> void: # content is a String (JSON) — parse to extract our "msg" field var text: String = "" var parsed = JSON.parse_string(message.content) if typeof(parsed) == TYPE_DICTIONARY: text = parsed.get("msg", message.content) else: text = message.content # Sender: use username property directly (falls back to first 8 chars of sender_id) var sender: String = message.username if sender.is_empty() and message.sender_id: sender = message.sender_id.substr(0, 8) elif sender.is_empty(): sender = "Unknown" # Timestamp → HH:MM var ts_str: String = _format_nakama_time(message.create_time) _chat_messages.append({ "sender": sender, "content": text, "ts": ts_str, "date": message.create_time.substr(0, 10) if message.create_time else Time.get_date_string_from_system() }) if refresh_display: _trim_old_messages() _refresh_chat_display() func _on_chat_send_pressed() -> void: """Send a message to the global chat channel or DM.""" if chat_input == null or chat_input.text.strip_edges().is_empty(): return var text = chat_input.text.strip_edges() chat_input.text = "" chat_input.grab_focus() if _friend_suggest_panel and _friend_suggest_panel.visible: _friend_suggest_panel.hide() # Check if user used @ shortcut if text.begins_with("@"): var space_idx = text.find(" ") var target_username = "" var dm_message = "" if space_idx > 0: target_username = text.substr(1, space_idx - 1) dm_message = text.substr(space_idx + 1).strip_edges() else: target_username = text.substr(1).strip_edges() if not target_username.is_empty(): var target_friend = null for f in FriendManager.friends: if f.get("username", "") == target_username: target_friend = f break if target_friend: var user_id = target_friend.get("user_id", "") var username = target_friend.get("username", "") _open_dm_tab(user_id, username) if not dm_message.is_empty(): _send_dm_message(user_id, dm_message) return elif text.begins_with("@"): _inject_local_message("User %s not found in friends." % target_username) return if _active_chat_context != "global": if text == "/clear": _dm_messages[_active_chat_context] = [] _refresh_chat_display() return _send_dm_message(_active_chat_context, text) return # Handle /clear command on global tab if text == "/clear": var is_admin = await AdminManager._check_admin_status() if is_admin: # Clear local buffer _chat_messages.clear() _refresh_chat_display() # Also clear server-side persisted messages so they don't reload if _chat_channel and NakamaManager.session and NakamaManager.client: var payload = JSON.stringify({"channel_id": _chat_channel.id}) var rpc_result = await NakamaManager.client.rpc_async(NakamaManager.session, "admin_clear_global_chat", payload) if rpc_result.is_exception(): push_warning("[Chat] admin_clear_global_chat RPC failed: " + rpc_result.get_exception().message) else: _inject_local_message("[SYSTEM] : Global chat cleared by admin.") else: _inject_local_message("[SYSTEM] : Unknown cmd, /clear only usable on DM between user.") return # Instantly show locally for best UX _inject_local_message(text) var socket = NakamaManager.socket if not socket or _chat_channel == null: return # Nakama GDScript SDK: write_chat_message_async takes a Dictionary, not a JSON string var content := {"msg": text} var result = await socket.write_chat_message_async(_chat_channel.id, content) if result.is_exception(): push_warning("[Chat] Failed to send message: " + result.get_exception().message) func _send_dm_message(user_id: String, text: String) -> void: var sent = await FriendManager.send_dm(user_id, text) if sent: if not _dm_messages.has(user_id): _dm_messages[user_id] = [] _dm_messages[user_id].append({"sender": "You", "content": text, "ts": _get_local_time()}) if _active_chat_context == user_id: _refresh_chat_display() func _on_lobby_dm_received(from_user_id: String, from_name: String, message: String) -> void: if not _dm_messages.has(from_user_id): _dm_messages[from_user_id] = [] _dm_messages[from_user_id].append({"sender": from_name, "content": message, "ts": _get_local_time()}) if not _dm_tabs.has(from_user_id): _create_dm_tab(from_user_id, from_name) if _active_chat_context == from_user_id: _refresh_chat_display() func _open_dm_tab(user_id: String, username: String) -> void: if not _dm_tabs.has(user_id): _create_dm_tab(user_id, username) _dm_messages[user_id] = [] _switch_chat_tab(user_id) var history = await FriendManager.get_dm_history(user_id) var my_id = NakamaManager.session.user_id if NakamaManager.session else "" for entry in history: var is_self = entry.get("from") == my_id var sender_name = "You" if is_self else username var ts = _format_nakama_time(entry.get("create_time", "")) _dm_messages[user_id].append({"sender": sender_name, "content": entry.get("msg", ""), "ts": ts}) if _active_chat_context == user_id: _refresh_chat_display() else: _switch_chat_tab(user_id) func _create_dm_tab(user_id: String, username: String) -> void: var tabs_container = get_node_or_null("%ChatTabsContainer") var template = get_node_or_null("%DMTabTemplate") if not tabs_container or not template: return var hbox = template.duplicate() hbox.visible = true var btn = hbox.get_node("DMTabBtn") if btn: btn.text = username btn.pressed.connect(func(): _switch_chat_tab(user_id)) var close_btn = hbox.get_node("DMTabCloseBtn") if close_btn: close_btn.pressed.connect(func(): _close_dm_tab(user_id)) tabs_container.add_child(hbox) _dm_tabs[user_id] = hbox func _close_dm_tab(user_id: String) -> void: if _dm_tabs.has(user_id): var tab = _dm_tabs[user_id] tab.queue_free() _dm_tabs.erase(user_id) if _active_chat_context == user_id: _switch_chat_tab("global") func _switch_chat_tab(context_id: String) -> void: _active_chat_context = context_id _refresh_chat_display() var tabs_container = get_node_or_null("%ChatTabsContainer") if tabs_container: var global_btn = get_node_or_null("%GlobalChatTabBtn") if global_btn: global_btn.modulate = Color(1.0, 1.0, 1.0) if context_id == "global" else Color(0.6, 0.6, 0.6) for u_id in _dm_tabs: var tab_hbox = _dm_tabs[u_id] tab_hbox.modulate = Color(1.0, 1.0, 1.0) if context_id == u_id else Color(0.6, 0.6, 0.6) func _setup_friend_suggest_ui() -> void: _friend_suggest_panel = get_node_or_null("%FriendSuggestPanel") _friend_suggest_list = get_node_or_null("%FriendSuggestList") if not _friend_suggest_panel or not _friend_suggest_list: push_warning("[Lobby] FriendSuggestPanel or FriendSuggestList not found in scene") return _friend_suggest_panel.visible = false # item_activated fires on double-click / Enter (desktop) _friend_suggest_list.item_activated.connect(_on_friend_suggest_activated) # item_selected fires on single tap (touch / mobile) _friend_suggest_list.item_selected.connect(_on_friend_suggest_activated) func _on_chat_input_changed(new_text: String) -> void: if not _friend_suggest_panel or not _friend_suggest_list: return if new_text.begins_with("@") and not " " in new_text: var search = new_text.substr(1).to_lower() _friend_suggest_list.clear() for f in FriendManager.friends: if f.get("state", -1) == FriendManager.STATE_FRIEND: var uname = f.get("username", "") if uname.to_lower().begins_with(search) or search.is_empty(): _friend_suggest_list.add_item(uname) _friend_suggest_panel.visible = _friend_suggest_list.item_count > 0 else: _friend_suggest_panel.visible = false func _on_friend_suggest_activated(index: int) -> void: if not _friend_suggest_list: return var uname = _friend_suggest_list.get_item_text(index) chat_input.text = "@%s " % uname chat_input.caret_column = chat_input.text.length() chat_input.grab_focus() _friend_suggest_panel.visible = false func _inject_local_message(text: String) -> void: """Display a message as the local player when offline/fallback.""" var display_name = UserProfileManager.get_display_name("You") var ts_str = _get_local_time() _chat_messages.append({ "sender": display_name, "content": text, "ts": ts_str, "date": Time.get_date_string_from_system() }) _refresh_chat_display() func _trim_old_messages() -> void: """Keep only the most recent 100 messages to prevent memory/UI bloat.""" if _chat_messages.size() > 100: _chat_messages = _chat_messages.slice(-100) func _refresh_chat_display() -> void: """Re-render the RichTextLabel with all buffered messages.""" if not chat_display: return chat_display.clear() var messages_to_show = _chat_messages if _active_chat_context != "global": if _dm_messages.has(_active_chat_context): messages_to_show = _dm_messages[_active_chat_context] else: messages_to_show = [] for msg in messages_to_show: var ts: String = msg.get("ts", "") var sender: String = msg.get("sender", "?") var text: String = msg.get("content", "") # Format: [dim][HH:MM] [/dim][b]Name:[/b] message chat_display.append_text("[color=#888888][%s][/color] [b]%s:[/b] %s\n" % [ts, sender, text]) # Scroll to bottom await get_tree().process_frame if chat_display: var scrollbar = chat_display.get_v_scroll_bar() if scrollbar: chat_display.scroll_to_line(chat_display.get_line_count()) func _format_nakama_time(time_str: String) -> String: """Convert Nakama time to local 'DD-MM-YY - HH:MM'.""" if time_str.is_valid_int(): var unix_time = time_str.to_int() var d = Time.get_date_dict_from_unix_time(unix_time) var t = Time.get_time_dict_from_unix_time(unix_time) return "%02d-%02d-%02d - %02d:%02d" % [d.day, d.month, d.year % 100, t.hour, t.minute] # Fallback for ISO strings or empty if time_str.length() >= 19: var date_part = time_str.substr(0, 10).split("-") var time_part = time_str.substr(11, 5) # HH:MM if date_part.size() == 3: var year = date_part[0].substr(2, 2) var month = date_part[1] var day = date_part[2] return "%s-%s-%s - %s" % [day, month, year, time_part] return _get_local_time() func _get_local_time() -> String: var d = Time.get_date_dict_from_system() var t = Time.get_time_dict_from_system() return "%02d-%02d-%02d - %02d:%02d" % [d.day, d.month, d.year % 100, t.hour, t.minute] func _leave_global_chat() -> void: """Leave the chat channel cleanly.""" var socket = NakamaManager.socket if socket and _chat_channel: socket.received_channel_message.disconnect(_on_chat_message_received) await socket.leave_chat_async(_chat_channel.id) _chat_channel = null