Files
FFCardGame/scripts/ui/OnlineLobby.gd
2026-02-02 16:28:53 -05:00

662 lines
22 KiB
GDScript

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