Files
tekton/scenes/lobby.gd
T
2026-05-12 17:55:53 +08:00

1980 lines
70 KiB
GDScript

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