From 438c0c0d6e1a3eeac7c70301690637cd2fcd70fa Mon Sep 17 00:00:00 2001 From: adtpdn Date: Sat, 6 Dec 2025 02:27:08 +0800 Subject: [PATCH] feat: Add initial lobby and main scenes with Nakama and lobby management scripts. --- project.godot | 3 +- scenes/lobby.gd | 246 ++++++++++++++ scenes/lobby.gd.uid | 1 + scenes/lobby.tscn | 448 ++++++++++++++++++++++++++ scenes/main.gd | 111 ++++--- scenes/main.tscn | 1 + scripts/managers/lobby_manager.gd | 249 ++++++++++++++ scripts/managers/lobby_manager.gd.uid | 1 + scripts/nakama_manager.gd | 40 +++ 9 files changed, 1061 insertions(+), 39 deletions(-) create mode 100644 scenes/lobby.gd create mode 100644 scenes/lobby.gd.uid create mode 100644 scenes/lobby.tscn create mode 100644 scripts/managers/lobby_manager.gd create mode 100644 scripts/managers/lobby_manager.gd.uid diff --git a/project.godot b/project.godot index 2c10470..b85fb93 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="tekton-local" -run/main_scene="uid://dxn87yj8qnfpp" +run/main_scene="res://scenes/lobby.tscn" config/features=PackedStringArray("4.4", "Forward Plus") config/icon="res://icon.svg" @@ -26,6 +26,7 @@ PlayerManager="*res://scripts/managers/player_manager.gd" TurnManager="*res://scripts/managers/turn_manager.gd" GoalManager="*res://scripts/managers/goal_manager.gd" GameStateManager="*res://scripts/managers/game_state_manager.gd" +LobbyManager="*res://scripts/managers/lobby_manager.gd" [display] diff --git a/scenes/lobby.gd b/scenes/lobby.gd new file mode 100644 index 0000000..9679917 --- /dev/null +++ b/scenes/lobby.gd @@ -0,0 +1,246 @@ +extends Control + +# UI References - Main Menu +@onready var main_menu_panel = $MainMenuPanel +@onready var player_name_input = $MainMenuPanel/VBoxContainer/InputSection/PlayerNameInput +@onready var create_room_btn = $MainMenuPanel/VBoxContainer/ButtonSection/CreateRoomBtn +@onready var browse_rooms_btn = $MainMenuPanel/VBoxContainer/ButtonSection/BrowseRoomsBtn + +# UI References - Room List +@onready var room_list_panel = $RoomListPanel +@onready var room_list = $RoomListPanel/VBoxContainer/RoomList +@onready var match_id_input = $RoomListPanel/VBoxContainer/MatchIdInput +@onready var refresh_btn = $RoomListPanel/VBoxContainer/ButtonContainer/RefreshBtn +@onready var join_btn = $RoomListPanel/VBoxContainer/ButtonContainer/JoinBtn +@onready var back_btn = $RoomListPanel/VBoxContainer/ButtonContainer/BackBtn + +# UI References - Lobby +@onready var lobby_panel = $LobbyPanel +@onready var room_name_header = $LobbyPanel/VBoxContainer/RoomNameHeader +@onready var match_id_display = $LobbyPanel/VBoxContainer/MatchIdContainer/MatchIdDisplay +@onready var copy_id_btn = $LobbyPanel/VBoxContainer/MatchIdContainer/CopyIdBtn +@onready var player_list = $LobbyPanel/VBoxContainer/PlayerList +@onready var status_label = $LobbyPanel/VBoxContainer/StatusLabel +@onready var ready_btn = $LobbyPanel/VBoxContainer/ButtonContainer/ReadyBtn +@onready var start_game_btn = $LobbyPanel/VBoxContainer/ButtonContainer/StartGameBtn +@onready var leave_btn = $LobbyPanel/VBoxContainer/ButtonContainer/LeaveBtn + +# UI References - Status +@onready var connection_status = $StatusBar/ConnectionStatus + +# Store current match ID for copy function +var current_match_id: String = "" + +func _ready(): + # Connect button signals + create_room_btn.pressed.connect(_on_create_room_pressed) + browse_rooms_btn.pressed.connect(_on_browse_rooms_pressed) + refresh_btn.pressed.connect(_on_refresh_pressed) + join_btn.pressed.connect(_on_join_pressed) + back_btn.pressed.connect(_on_back_pressed) + ready_btn.toggled.connect(_on_ready_toggled) + start_game_btn.pressed.connect(_on_start_game_pressed) + leave_btn.pressed.connect(_on_leave_pressed) + copy_id_btn.pressed.connect(_on_copy_id_pressed) + + # 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.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) + + # Connect NakamaManager signals + NakamaManager.connected_to_nakama.connect(_on_connected_to_nakama) + NakamaManager.connection_failed.connect(_on_connection_failed) + + # Show main menu initially + _show_panel("main_menu") + +# ============================================================================= +# 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_create_room_pressed() -> void: + # Set player name + LobbyManager.local_player_name = player_name_input.text.strip_edges() + if LobbyManager.local_player_name.is_empty(): + LobbyManager.local_player_name = "Host" + + connection_status.text = "Creating room..." + # Room name auto-generated since Nakama doesn't support custom names + LobbyManager.create_room("Room %d" % randi_range(1000, 9999)) + +func _on_browse_rooms_pressed() -> void: + _show_panel("room_list") + connection_status.text = "Loading rooms..." + 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_join_pressed() -> void: + # First check if there's a match ID entered + var match_id = match_id_input.text.strip_edges() + + if match_id.is_empty(): + # Try to use selected room from list + 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", "") + + if match_id.is_empty(): + connection_status.text = "No room selected" + return + + # Set player name + LobbyManager.local_player_name = player_name_input.text.strip_edges() + if LobbyManager.local_player_name.is_empty(): + LobbyManager.local_player_name = "Player" + + connection_status.text = "Joining room..." + LobbyManager.join_room(match_id) + +func _on_back_pressed() -> void: + _show_panel("main_menu") + connection_status.text = "" + +# ============================================================================= +# 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" + +func _on_copy_id_pressed() -> void: + DisplayServer.clipboard_set(current_match_id) + connection_status.text = "Match ID copied to clipboard!" + +# ============================================================================= +# LobbyManager Signal Handlers +# ============================================================================= + +func _on_room_list_updated(rooms: Array) -> void: + room_list.clear() + for room in rooms: + 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) + room_list.add_item("%s - %s (%d/%d)" % [room_name, host_name, player_count, max_players]) + + if rooms.size() == 0: + connection_status.text = "No rooms available" + else: + connection_status.text = "Found %d room(s)" % rooms.size() + +func _on_room_joined(room_data: Dictionary) -> void: + _show_panel("lobby") + current_match_id = room_data.get("match_id", "") + room_name_header.text = "ROOM: %s" % room_data.get("room_name", "Unknown") + match_id_display.text = "Match ID: %s " % current_match_id + + # Update start button visibility (host only) + start_game_btn.visible = LobbyManager.is_host + + _update_player_list() + connection_status.text = "Connected to room" + +func _on_room_left() -> void: + _show_panel("main_menu") + connection_status.text = "Left room" + +func _on_player_joined(player_data: Dictionary) -> void: + _update_player_list() + status_label.text = "%s joined!" % player_data.get("name", "Player") + +func _on_player_left(_player_id: int) -> void: + _update_player_list() + status_label.text = "A player left" + +func _on_ready_state_changed(_player_id: int, _is_ready: bool) -> void: + _update_player_list() + _update_status() + +func _on_all_players_ready() -> void: + if LobbyManager.is_host: + start_game_btn.disabled = false + status_label.text = "All players ready! Host can start." + else: + status_label.text = "All players ready! Waiting for host..." + +func _on_game_starting() -> void: + connection_status.text = "Starting game..." + # Small delay for visual feedback + await get_tree().create_timer(0.5).timeout + get_tree().change_scene_to_file("res://scenes/main.tscn") + +# ============================================================================= +# NakamaManager Signal Handlers +# ============================================================================= + +func _on_connected_to_nakama() -> void: + connection_status.text = "Connected to server" + +func _on_connection_failed(error_message: String) -> void: + connection_status.text = "Connection failed: %s" % error_message + _show_panel("main_menu") + +# ============================================================================= +# Helper Functions +# ============================================================================= + +func _update_player_list() -> void: + player_list.clear() + var players = LobbyManager.get_players() + for player in players: + var player_name = player.get("name", "Unknown") + var is_ready = player.get("is_ready", false) + var ready_icon = " ✓" if is_ready else " ✗" + var host_tag = " (Host)" if player.get("id") == 1 else "" + player_list.add_item("%s%s%s" % [player_name, host_tag, ready_icon]) + +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() diff --git a/scenes/lobby.gd.uid b/scenes/lobby.gd.uid new file mode 100644 index 0000000..ca03a30 --- /dev/null +++ b/scenes/lobby.gd.uid @@ -0,0 +1 @@ +uid://b5q6yekyk0tld diff --git a/scenes/lobby.tscn b/scenes/lobby.tscn new file mode 100644 index 0000000..ad2976f --- /dev/null +++ b/scenes/lobby.tscn @@ -0,0 +1,448 @@ +[gd_scene load_steps=7 format=3 uid="uid://b7nxt2hc4kqp8"] + +[ext_resource type="Script" uid="uid://b5q6yekyk0tld" path="res://scenes/lobby.gd" id="1_lobby"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"] +content_margin_left = 24.0 +content_margin_top = 20.0 +content_margin_right = 24.0 +content_margin_bottom = 20.0 +bg_color = Color(0.12, 0.12, 0.14, 0.95) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.9, 0.45, 0.1, 0.8) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_input"] +content_margin_left = 12.0 +content_margin_top = 8.0 +content_margin_right = 12.0 +content_margin_bottom = 8.0 +bg_color = Color(0.08, 0.08, 0.1, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.3, 0.3, 0.35, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"] +content_margin_left = 16.0 +content_margin_top = 8.0 +content_margin_right = 16.0 +content_margin_bottom = 8.0 +bg_color = Color(0.9, 0.45, 0.1, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(1, 0.6, 0.2, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_pressed"] +content_margin_left = 16.0 +content_margin_top = 8.0 +content_margin_right = 16.0 +content_margin_bottom = 8.0 +bg_color = Color(0.7, 0.35, 0.05, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.9, 0.5, 0.15, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_normal"] +content_margin_left = 16.0 +content_margin_top = 8.0 +content_margin_right = 16.0 +content_margin_bottom = 8.0 +bg_color = Color(0.18, 0.18, 0.2, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.4, 0.4, 0.45, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 + +[node name="Lobby" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_lobby") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.06, 0.06, 0.08, 1) + +[node name="BackgroundPattern" type="ColorRect" parent="."] +modulate = Color(1, 1, 1, 0.03) +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.9, 0.45, 0.1, 1) + +[node name="MainMenuPanel" type="PanelContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -220.0 +offset_top = -220.0 +offset_right = 220.0 +offset_bottom = 220.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_panel") + +[node name="VBoxContainer" type="VBoxContainer" parent="MainMenuPanel"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="TitleContainer" type="VBoxContainer" parent="MainMenuPanel/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="Title" type="Label" parent="MainMenuPanel/VBoxContainer/TitleContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.95, 0.5, 0.15, 1) +theme_override_font_sizes/font_size = 42 +text = "TEKTON DASH" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Subtitle" type="Label" parent="MainMenuPanel/VBoxContainer/TitleContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.5, 0.5, 0.55, 1) +theme_override_font_sizes/font_size = 12 +text = "ARMAGEDDON VERSION" +horizontal_alignment = 1 + +[node name="Separator" type="HSeparator" parent="MainMenuPanel/VBoxContainer"] +modulate = Color(0.9, 0.45, 0.1, 0.5) +layout_mode = 2 + +[node name="InputSection" type="VBoxContainer" parent="MainMenuPanel/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="PlayerNameLabel" type="Label" parent="MainMenuPanel/VBoxContainer/InputSection"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.7, 0.7, 0.75, 1) +theme_override_font_sizes/font_size = 13 +text = "YOUR NAME" + +[node name="PlayerNameInput" type="LineEdit" parent="MainMenuPanel/VBoxContainer/InputSection"] +custom_minimum_size = Vector2(0, 36) +layout_mode = 2 +theme_override_colors/font_placeholder_color = Color(0.4, 0.4, 0.45, 1) +theme_override_colors/font_color = Color(0.9, 0.9, 0.95, 1) +theme_override_font_sizes/font_size = 14 +theme_override_styles/normal = SubResource("StyleBoxFlat_input") +text = "Player" +placeholder_text = "Enter your name..." + +[node name="ButtonSection" type="VBoxContainer" parent="MainMenuPanel/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="CreateRoomBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ButtonSection"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_override_colors/font_hover_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.95, 0.95, 1, 1) +theme_override_colors/font_pressed_color = Color(1, 1, 1, 1) +theme_override_font_sizes/font_size = 15 +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +text = "CREATE ROOM" + +[node name="BrowseRoomsBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ButtonSection"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_override_colors/font_hover_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.95, 0.95, 1, 1) +theme_override_colors/font_pressed_color = Color(1, 1, 1, 1) +theme_override_font_sizes/font_size = 15 +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +text = "BROWSE ROOMS" + +[node name="RoomListPanel" type="PanelContainer" parent="."] +visible = false +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -280.0 +offset_top = -240.0 +offset_right = 280.0 +offset_bottom = 240.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_panel") + +[node name="VBoxContainer" type="VBoxContainer" parent="RoomListPanel"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Header" type="Label" parent="RoomListPanel/VBoxContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.95, 0.5, 0.15, 1) +theme_override_font_sizes/font_size = 28 +text = "SERVER BROWSER" +horizontal_alignment = 1 + +[node name="HSeparator" type="HSeparator" parent="RoomListPanel/VBoxContainer"] +modulate = Color(0.9, 0.45, 0.1, 0.5) +layout_mode = 2 + +[node name="MatchIdLabel" type="Label" parent="RoomListPanel/VBoxContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.6, 0.6, 0.65, 1) +theme_override_font_sizes/font_size = 12 +text = "DIRECT CONNECT (MATCH ID)" + +[node name="MatchIdInput" type="LineEdit" parent="RoomListPanel/VBoxContainer"] +custom_minimum_size = Vector2(0, 36) +layout_mode = 2 +theme_override_colors/font_placeholder_color = Color(0.4, 0.4, 0.45, 1) +theme_override_colors/font_color = Color(0.9, 0.9, 0.95, 1) +theme_override_font_sizes/font_size = 14 +theme_override_styles/normal = SubResource("StyleBoxFlat_input") +placeholder_text = "Paste match ID here..." + +[node name="RoomListLabel" type="Label" parent="RoomListPanel/VBoxContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.6, 0.6, 0.65, 1) +theme_override_font_sizes/font_size = 12 +text = "AVAILABLE SERVERS" + +[node name="RoomList" type="ItemList" parent="RoomListPanel/VBoxContainer"] +custom_minimum_size = Vector2(0, 180) +layout_mode = 2 +theme_override_colors/font_selected_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.85, 0.85, 0.9, 1) +theme_override_font_sizes/font_size = 14 +allow_reselect = true + +[node name="ButtonContainer" type="HBoxContainer" parent="RoomListPanel/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="RefreshBtn" type="Button" parent="RoomListPanel/VBoxContainer/ButtonContainer"] +custom_minimum_size = Vector2(100, 40) +layout_mode = 2 +theme_override_colors/font_hover_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.95, 0.95, 1, 1) +theme_override_font_sizes/font_size = 13 +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +text = "REFRESH" + +[node name="JoinBtn" type="Button" parent="RoomListPanel/VBoxContainer/ButtonContainer"] +custom_minimum_size = Vector2(120, 40) +layout_mode = 2 +theme_override_colors/font_hover_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.95, 0.95, 1, 1) +theme_override_font_sizes/font_size = 13 +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +text = "JOIN SERVER" + +[node name="BackBtn" type="Button" parent="RoomListPanel/VBoxContainer/ButtonContainer"] +custom_minimum_size = Vector2(100, 40) +layout_mode = 2 +theme_override_colors/font_hover_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.95, 0.95, 1, 1) +theme_override_font_sizes/font_size = 13 +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +text = "BACK" + +[node name="LobbyPanel" type="PanelContainer" parent="."] +visible = false +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -280.0 +offset_top = -240.0 +offset_right = 280.0 +offset_bottom = 240.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_panel") + +[node name="VBoxContainer" type="VBoxContainer" parent="LobbyPanel"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="RoomNameHeader" type="Label" parent="LobbyPanel/VBoxContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.95, 0.5, 0.15, 1) +theme_override_font_sizes/font_size = 26 +text = "ROOM: " +horizontal_alignment = 1 + +[node name="MatchIdContainer" type="HBoxContainer" parent="LobbyPanel/VBoxContainer"] +layout_mode = 2 +alignment = 1 + +[node name="MatchIdDisplay" type="Label" parent="LobbyPanel/VBoxContainer/MatchIdContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.45, 0.45, 0.5, 1) +theme_override_font_sizes/font_size = 10 +text = "Match ID: " + +[node name="CopyIdBtn" type="Button" parent="LobbyPanel/VBoxContainer/MatchIdContainer"] +custom_minimum_size = Vector2(70, 24) +layout_mode = 2 +theme_override_colors/font_hover_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.7, 0.7, 0.75, 1) +theme_override_font_sizes/font_size = 10 +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +text = "COPY" + +[node name="HSeparator" type="HSeparator" parent="LobbyPanel/VBoxContainer"] +modulate = Color(0.9, 0.45, 0.1, 0.5) +layout_mode = 2 + +[node name="PlayersLabel" type="Label" parent="LobbyPanel/VBoxContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.6, 0.6, 0.65, 1) +theme_override_font_sizes/font_size = 13 +text = "PLAYERS" + +[node name="PlayerList" type="ItemList" parent="LobbyPanel/VBoxContainer"] +custom_minimum_size = Vector2(0, 140) +layout_mode = 2 +theme_override_colors/font_selected_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.85, 0.85, 0.9, 1) +theme_override_font_sizes/font_size = 14 +allow_reselect = true + +[node name="StatusLabel" type="Label" parent="LobbyPanel/VBoxContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.95, 0.6, 0.2, 1) +theme_override_font_sizes/font_size = 14 +text = "Waiting for players..." +horizontal_alignment = 1 + +[node name="ButtonContainer" type="HBoxContainer" parent="LobbyPanel/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="ReadyBtn" type="Button" parent="LobbyPanel/VBoxContainer/ButtonContainer"] +custom_minimum_size = Vector2(100, 44) +layout_mode = 2 +theme_override_colors/font_hover_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.95, 0.95, 1, 1) +theme_override_font_sizes/font_size = 14 +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +toggle_mode = true +text = "READY" + +[node name="StartGameBtn" type="Button" parent="LobbyPanel/VBoxContainer/ButtonContainer"] +custom_minimum_size = Vector2(130, 44) +layout_mode = 2 +theme_override_colors/font_hover_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.95, 0.95, 1, 1) +theme_override_font_sizes/font_size = 14 +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +disabled = true +text = "START GAME" + +[node name="LeaveBtn" type="Button" parent="LobbyPanel/VBoxContainer/ButtonContainer"] +custom_minimum_size = Vector2(100, 44) +layout_mode = 2 +theme_override_colors/font_hover_color = Color(0.1, 0.1, 0.12, 1) +theme_override_colors/font_color = Color(0.95, 0.95, 1, 1) +theme_override_font_sizes/font_size = 14 +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +text = "LEAVE" + +[node name="StatusBar" type="PanelContainer" parent="."] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 39.0 +offset_top = -83.0 +offset_right = -39.0 +offset_bottom = -26.0 +grow_horizontal = 2 +grow_vertical = 0 +theme_override_styles/panel = SubResource("StyleBoxFlat_panel") + +[node name="ConnectionStatus" type="Label" parent="StatusBar"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.5, 0.5, 0.55, 1) +theme_override_font_sizes/font_size = 12 +text = "NOT CONNECTED" +horizontal_alignment = 1 + +[node name="VersionLabel" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -126.0 +offset_top = 19.0 +offset_right = -18.0 +offset_bottom = 37.0 +grow_horizontal = 0 +theme_override_colors/font_color = Color(0.35, 0.35, 0.4, 1) +theme_override_font_sizes/font_size = 11 +text = "v0.1.0 ALPHA" +horizontal_alignment = 2 diff --git a/scenes/main.gd b/scenes/main.gd index 39c6ffd..2dc59a6 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -9,7 +9,6 @@ var ui_manager var obstacle_manager # Minimal local state -var match_id_input: LineEdit var _connection_check_timer: float = 0.0 func _ready(): @@ -27,7 +26,12 @@ func _ready(): ui_manager.setup_action_buttons(_set_action_state_callback) ui_manager.setup_playerboard_ui() _setup_obstacle_ui() - _setup_match_input() + + # Auto-start game if coming from lobby (already connected to match) + if NakamaManager.is_connected_to_nakama() and multiplayer.get_unique_id() != 0: + print("Coming from lobby - auto-starting game...") + await get_tree().process_frame + _auto_start_from_lobby() func _init_managers(): # Create and attach scene managers @@ -41,13 +45,6 @@ func _init_managers(): add_child(obstacle_manager) obstacle_manager.initialize($EnhancedGridMap) -func _setup_match_input(): - match_id_input = LineEdit.new() - match_id_input.placeholder_text = "Enter Match ID to Join" - match_id_input.custom_minimum_size = Vector2(200, 30) - match_id_input.position = Vector2(10, 50) - $Menu.add_child(match_id_input) - func _setup_obstacle_ui(): var obstacle_button = Button.new() obstacle_button.text = "Place Obstacle" @@ -80,34 +77,9 @@ func _process(delta): verify_all_connections() # ============================================================================= -# Network Button Handlers +# Network Callbacks # ============================================================================= -func _on_host_pressed(): - $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Server (Creating Match...)" - $Menu.visible = false - var success = await NakamaManager.connect_to_nakama_async() - if not success: - $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Connection Failed" - $Menu.visible = true - return - NakamaManager.host_game() - -func _on_join_pressed(): - var match_id = match_id_input.text.strip_edges() - if match_id.is_empty(): - print("Please enter a Match ID") - return - - $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Client (Joining...)" - $Menu.visible = false - var success = await NakamaManager.connect_to_nakama_async() - if not success: - $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Connection Failed" - $Menu.visible = true - return - NakamaManager.join_game(match_id) - func _on_match_joined(match_id: String): $NetworkPanel/NetworkInfo/UniquePeerID.text = str(multiplayer.get_unique_id()) @@ -151,16 +123,79 @@ func _setup_host_game(): _update_player_goals_ui(0, host_goals) ui_manager.update_playerboard_ui() - # Add bots - if GameStateManager.enable_bots: + # Spawn client players that joined via lobby + var lobby_players = LobbyManager.get_players() + for lobby_player in lobby_players: + var peer_id = lobby_player.get("id", 0) + if peer_id != 1 and peer_id != 0: # Skip host (1) and invalid (0) + print("Spawning lobby player: ", peer_id) + await get_tree().create_timer(0.3).timeout + _spawn_lobby_client(peer_id) + + # Add bots (only if no lobby players connected) + if GameStateManager.enable_bots and lobby_players.size() <= 1: for i in range(2, GameStateManager.max_players + 1): _add_bot(i) _start_game() +func _spawn_lobby_client(peer_id: int): + """Spawn a client player that was in the lobby.""" + if has_node(str(peer_id)): + return + + var player_character = PlayerManager.add_player_character(peer_id) + add_child(player_character) + player_character.add_to_group("Players", true) + GameStateManager.add_player(peer_id) + + # Tell all clients to create this player + rpc("add_newly_connected_player_character", peer_id) + + # Wait for player to be ready then assign goals + await get_tree().create_timer(0.3).timeout + var player_index = GameStateManager.players.find(peer_id) + if player_index >= 0 and player_index < GoalManager.preset_goals.size(): + var player_goals = GoalManager.preset_goals[player_index].duplicate() + player_character.goals = player_goals + call_deferred("_deferred_set_player_goals", peer_id, player_goals) + func _setup_client_game(): + """Setup client when transitioning from lobby.""" + var my_id = multiplayer.get_unique_id() + print("Client setup - my peer ID: ", my_id) + + # Create local player immediately + if not has_node(str(my_id)): + var player_character = PlayerManager.add_player_character(my_id) + add_child(player_character) + player_character.add_to_group("Players", true) + GameStateManager.add_player(my_id) + GameStateManager.local_player_character = player_character + ui_manager.set_local_player(player_character) + ui_manager.update_button_states() + print("Created local player for client: ", my_id) + + # Wait for host to be ready, then request full sync await get_tree().create_timer(2.0).timeout - rpc_id(1, "request_full_player_sync", multiplayer.get_unique_id()) + rpc_id(1, "request_full_player_sync", my_id) + +func _auto_start_from_lobby(): + """Called when main.tscn is loaded from lobby - game is already connected.""" + $NetworkPanel/NetworkInfo/UniquePeerID.text = str(multiplayer.get_unique_id()) + + # Get match ID from LobbyManager + var match_id = LobbyManager.current_room.get("match_id", "") + var short_id = match_id.substr(0, 8) if match_id.length() > 8 else match_id + + if multiplayer.is_server(): + print("Auto-starting as HOST - Match: ", short_id) + $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Host (Match: %s)" % short_id + _setup_host_game() + else: + print("Auto-starting as CLIENT - Match: ", short_id) + $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Client (Match: %s)" % short_id + _setup_client_game() func _start_game(): if multiplayer.is_server(): diff --git a/scenes/main.tscn b/scenes/main.tscn index 388d562..6f32bf9 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -994,6 +994,7 @@ layout_mode = 2 text = "Arrange" [node name="Menu" type="VBoxContainer" parent="."] +visible = false anchors_preset = 8 anchor_left = 0.5 anchor_top = 0.5 diff --git a/scripts/managers/lobby_manager.gd b/scripts/managers/lobby_manager.gd new file mode 100644 index 0000000..d99a94d --- /dev/null +++ b/scripts/managers/lobby_manager.gd @@ -0,0 +1,249 @@ +extends Node + +# LobbyManager - Manages room/lobby state across scenes + +# Signals +signal room_list_updated(rooms: Array) +signal room_joined(room_data: Dictionary) +signal room_left() +signal player_joined(player_data: Dictionary) +signal player_left(player_id: int) +signal ready_state_changed(player_id: int, is_ready: bool) +signal all_players_ready() +signal game_starting() + +# Room data structure +var current_room: Dictionary = {} +var players_in_room: Array = [] # [{id, name, is_ready}] +var available_rooms: Array = [] +var is_host: bool = false +var local_player_name: String = "Player" + +# Ready to start game check +var _all_ready: bool = false + +func _ready(): + # Connect to Nakama signals + NakamaManager.match_joined.connect(_on_match_joined) + multiplayer.peer_connected.connect(_on_peer_connected) + multiplayer.peer_disconnected.connect(_on_peer_disconnected) + +# ============================================================================= +# Room Creation / Joining +# ============================================================================= + +func create_room(room_name: String) -> void: + """Host creates a new room with the given name.""" + is_host = true + current_room = { + "room_name": room_name, + "host_name": local_player_name, + "max_players": GameStateManager.max_players + } + + # Connect to Nakama and create match + var success = await NakamaManager.connect_to_nakama_async() + if not success: + push_error("Failed to connect to Nakama") + return + + NakamaManager.host_game() + +func join_room(match_id: String) -> void: + """Client joins an existing room by match ID.""" + is_host = false + + var success = await NakamaManager.connect_to_nakama_async() + if not success: + push_error("Failed to connect to Nakama") + return + + NakamaManager.join_game(match_id) + +func leave_room() -> void: + """Leave the current room.""" + current_room = {} + players_in_room.clear() + is_host = false + _all_ready = false + + # Disconnect from Nakama match + if NakamaManager.socket: + NakamaManager.socket.close() + + emit_signal("room_left") + +func refresh_room_list() -> void: + """Request updated room list from Nakama.""" + if not NakamaManager.is_connected_to_nakama(): + var success = await NakamaManager.connect_to_nakama_async() + if not success: + return + + var rooms = await NakamaManager.list_matches_async() + available_rooms = rooms + emit_signal("room_list_updated", rooms) + +# ============================================================================= +# Ready State Management +# ============================================================================= + +func set_ready(is_ready: bool) -> void: + """Set local player's ready state.""" + var my_id = multiplayer.get_unique_id() + + # Update local state + for player in players_in_room: + if player["id"] == my_id: + player["is_ready"] = is_ready + break + + # Sync to all peers + rpc("sync_ready_state", my_id, is_ready) + +@rpc("any_peer", "call_local", "reliable") +func sync_ready_state(player_id: int, is_ready: bool) -> void: + """Sync ready state across all clients.""" + for player in players_in_room: + if player["id"] == player_id: + player["is_ready"] = is_ready + break + + emit_signal("ready_state_changed", player_id, is_ready) + _check_all_ready() + +func _check_all_ready() -> void: + """Check if all players are ready.""" + if players_in_room.size() < 2: + _all_ready = false + return + + for player in players_in_room: + if not player["is_ready"]: + _all_ready = false + return + + _all_ready = true + emit_signal("all_players_ready") + +func is_all_ready() -> bool: + return _all_ready + +# ============================================================================= +# Game Start +# ============================================================================= + +func start_game() -> void: + """Host triggers game start (transitions all players to main.tscn).""" + if not is_host: + push_error("Only host can start the game") + return + + if not _all_ready: + push_error("Not all players are ready") + return + + # Notify all clients to start + rpc("_on_game_starting") + +@rpc("call_local", "reliable") +func _on_game_starting() -> void: + """Called on all clients when game is starting.""" + emit_signal("game_starting") + # Scene change will be handled by lobby.gd after receiving this signal + +# ============================================================================= +# Player Management +# ============================================================================= + +func _on_match_joined(match_id: String) -> void: + """Called when successfully joined a Nakama match.""" + current_room["match_id"] = match_id + # Use first 8 chars of match ID as room name (matches server browser) + var short_id = match_id.substr(0, 8) if match_id.length() > 8 else match_id + current_room["room_name"] = short_id + + # Add self to player list + var my_id = multiplayer.get_unique_id() + var my_data = { + "id": my_id, + "name": local_player_name, + "is_ready": false + } + players_in_room.append(my_data) + + if is_host: + # Host is automatically in the room + emit_signal("room_joined", current_room) + # Client will request room info when peer connection is established + +@rpc("any_peer", "reliable") +func request_room_info(requester_id: int) -> void: + """Client requests room info from host.""" + if not multiplayer.is_server(): + return + + # Send room data to requester + rpc_id(requester_id, "receive_room_info", current_room, players_in_room) + +@rpc("reliable") +func receive_room_info(room_data: Dictionary, player_list: Array) -> void: + """Client receives room info from host.""" + current_room = room_data + players_in_room = player_list + emit_signal("room_joined", current_room) + +func _on_peer_connected(peer_id: int) -> void: + """Called when new peer connects.""" + print("Peer connected: ", peer_id) + + if multiplayer.is_server(): + # Host: add new player and sync list + var new_player = { + "id": peer_id, + "name": "Player %d" % peer_id, + "is_ready": false + } + players_in_room.append(new_player) + + # Sync player list to all clients + rpc("sync_player_list", players_in_room) + emit_signal("player_joined", new_player) + else: + # Client: if we connected to the host (peer_id 1), request room info + if peer_id == 1 and not is_host: + # Wait a frame to ensure connection is stable + await get_tree().process_frame + rpc_id(1, "request_room_info", multiplayer.get_unique_id()) + +func _on_peer_disconnected(peer_id: int) -> void: + """Called when peer disconnects.""" + for i in range(players_in_room.size()): + if players_in_room[i]["id"] == peer_id: + players_in_room.remove_at(i) + break + + if multiplayer.is_server(): + rpc("sync_player_list", players_in_room) + + emit_signal("player_left", peer_id) + _check_all_ready() + +@rpc("reliable") +func sync_player_list(player_list: Array) -> void: + """Sync player list from host to all clients.""" + players_in_room = player_list + +func get_players() -> Array: + return players_in_room + +func get_room_name() -> String: + return current_room.get("room_name", "Unknown Room") + +func reset() -> void: + """Reset lobby state.""" + current_room = {} + players_in_room.clear() + available_rooms.clear() + is_host = false + _all_ready = false diff --git a/scripts/managers/lobby_manager.gd.uid b/scripts/managers/lobby_manager.gd.uid new file mode 100644 index 0000000..8a944b1 --- /dev/null +++ b/scripts/managers/lobby_manager.gd.uid @@ -0,0 +1 @@ +uid://d23uvudhylph diff --git a/scripts/nakama_manager.gd b/scripts/nakama_manager.gd index 2c70612..09fda89 100644 --- a/scripts/nakama_manager.gd +++ b/scripts/nakama_manager.gd @@ -112,6 +112,46 @@ func _on_bridge_match_join_error(error) -> void: func is_connected_to_nakama() -> bool: return socket != null and socket.is_connected_to_host() +# --- Match Listing --- + +func list_matches_async() -> Array: + """Query available matches from Nakama server.""" + if not client: + push_error("Cannot list matches: Client not initialized") + return [] + + if not session or session.is_expired(): + push_error("Cannot list matches: No valid session") + return [] + + print("Querying matches from Nakama server...") + + # Query matches - min 0, max 8 players, limit 20, authoritative=false for relayed matches + var result = await client.list_matches_async(session, 0, 8, 20, false, "", "") + + if result.is_exception(): + printerr("Failed to list matches: ", result.get_exception().message) + return [] + + var rooms: Array = [] + if result.matches: + print("Found %d matches" % result.matches.size()) + for match_data in result.matches: + print(" Match: ", match_data.match_id, " - Size: ", match_data.size) + # Use first 8 chars of match ID as room identifier since Nakama doesn't store custom names + var short_id = match_data.match_id.substr(0, 8) if match_data.match_id.length() > 8 else match_data.match_id + rooms.append({ + "match_id": match_data.match_id, + "room_name": short_id, + "host_name": "Host", + "player_count": match_data.size if match_data.size else 1, + "max_players": 4 + }) + else: + print("No matches found") + + return rooms + func _exit_tree(): if socket: socket.close()