From eb018903aa6477312b4e9f52e8071a22545ed867 Mon Sep 17 00:00:00 2001 From: Yogi Wiguna Date: Mon, 16 Mar 2026 16:19:30 +0800 Subject: [PATCH] feat: implement initial lobby scene with main menu, server browser, and networking options. --- scenes/lobby.gd | 61 +++++++++++++++++- scenes/lobby.tscn | 49 ++++++++++++-- scenes/main.gd | 26 ++++++-- scenes/player.gd | 14 ++-- scripts/managers/lobby_manager.gd | 100 +++++++++++++++++++++++++++-- scripts/ui/login_screen.gd | 102 +++++++++++++++++++++++++++--- 6 files changed, 317 insertions(+), 35 deletions(-) diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 517edca..1179560 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -12,6 +12,10 @@ extends Control # UI References - Server Selection @onready var server_option = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerOption @onready var server_ip_input = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerIPInput +@onready var lan_section = $MainMenuPanel/VBoxContainer/ServerSelectionSection/LANSection +@onready var lan_ip_input = $MainMenuPanel/VBoxContainer/ServerSelectionSection/LANSection/LANIPInput +@onready var lan_host_btn = $MainMenuPanel/VBoxContainer/ServerSelectionSection/LANSection/LANHostBtn +@onready var lan_join_btn = $MainMenuPanel/VBoxContainer/ServerSelectionSection/LANSection/LANJoinBtn # Leaderboard Reference @onready var leaderboard_btn = $MainMenuPanel/VBoxContainer/ButtonSection/LeaderboardBtn @@ -154,6 +158,10 @@ func _ready(): 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)) + if lan_host_btn: + lan_host_btn.pressed.connect(_on_lan_host_pressed) + if lan_join_btn: + lan_join_btn.pressed.connect(func(): _on_lan_join_pressed(lan_ip_input.text if lan_ip_input else "127.0.0.1")) # Connect button signals - Room List refresh_btn.pressed.connect(_on_refresh_pressed) @@ -248,18 +256,58 @@ func _load_character_textures() -> void: func _on_server_option_selected(index: int) -> void: if index == 0: - # Localhost + # Nakama Localhost if server_ip_input: server_ip_input.visible = false + if lan_section: lan_section.visible = false NakamaManager.set_server("localhost") - else: - # Remote + elif index == 1: + # Nakama Remote if server_ip_input: server_ip_input.visible = true + if lan_section: lan_section.visible = false if server_ip_input: NakamaManager.set_server(server_ip_input.text) + else: + # LAN Direct + if server_ip_input: server_ip_input.visible = false + if lan_section: lan_section.visible = true func _on_server_ip_submitted(new_text: String) -> void: if server_option and server_option.selected == 1: NakamaManager.set_server(new_text.strip_edges()) +func _on_lan_host_pressed() -> void: + """Host a LAN game without Nakama.""" + var player_name = player_name_input.text.strip_edges() if player_name_input else "" + if player_name.is_empty(): + player_name = "Host" + LobbyManager.local_player_name = player_name + + if connection_status: + connection_status.text = "Starting LAN server..." + var ok = await LobbyManager.create_room_lan() + if not ok: + if connection_status: + connection_status.text = "Failed to start LAN server. Check port 7777." + +func _on_lan_join_pressed(host_ip: String) -> void: + """Join a LAN game by entering the host's IP.""" + var ip = host_ip.strip_edges() + if ip.is_empty(): + if connection_status: + connection_status.text = "Enter the host's IP address." + return + + var player_name = player_name_input.text.strip_edges() if player_name_input else "" + if player_name.is_empty(): + player_name = "Player" + LobbyManager.local_player_name = player_name + + if connection_status: + connection_status.text = "Connecting to %s..." % ip + var ok = LobbyManager.join_room_lan(ip) + if not ok: + if connection_status: + connection_status.text = "Failed to connect to %s. Is host running?" % ip + func _setup_game_modes() -> void: if not game_mode_option: return game_mode_option.clear() @@ -680,6 +728,13 @@ func _on_room_joined(room_data: Dictionary) -> void: _update_player_slots() connection_status.text = "Connected to room" + + # LAN solo mode: host is auto-ready, enable Start Game immediately + if LobbyManager.is_lan_mode and is_host: + ready_btn.button_pressed = true + ready_btn.text = "READY ✓" + LobbyManager.force_solo_ready() + status_label.text = "LAN Solo — press Start Game when ready!" func _on_room_left() -> void: _show_panel("main_menu") diff --git a/scenes/lobby.tscn b/scenes/lobby.tscn index 2296eb8..1c09d54 100644 --- a/scenes/lobby.tscn +++ b/scenes/lobby.tscn @@ -100,24 +100,63 @@ theme_override_constants/separation = 10 layout_mode = 2 theme_override_colors/font_color = Color(0.69, 0.529, 0.357, 1) theme_override_font_sizes/font_size = 13 -text = "NAKAMA SERVER" +text = "CONNECTION MODE" [node name="ServerOption" type="OptionButton" parent="MainMenuPanel/VBoxContainer/ServerSelectionSection" unique_id=748392103] custom_minimum_size = Vector2(0, 44) layout_mode = 2 selected = 0 -item_count = 2 -popup/item_0/text = "Localhost (Testing)" +item_count = 3 +popup/item_0/text = "Nakama - Localhost (Testing)" popup/item_0/id = 0 -popup/item_1/text = "Remote Server (Host IP)" +popup/item_1/text = "Nakama - Remote Server (Host IP)" popup/item_1/id = 1 +popup/item_2/text = "LAN Direct (No Server)" +popup/item_2/id = 2 [node name="ServerIPInput" type="LineEdit" parent="MainMenuPanel/VBoxContainer/ServerSelectionSection" unique_id=748392104] visible = false custom_minimum_size = Vector2(0, 44) layout_mode = 2 text = "127.0.0.1" -placeholder_text = "Enter Server IP Address..." +placeholder_text = "Enter Nakama Server IP..." + +[node name="LANSection" type="VBoxContainer" parent="MainMenuPanel/VBoxContainer/ServerSelectionSection" unique_id=748392110] +visible = false +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="LANInfo" type="Label" parent="MainMenuPanel/VBoxContainer/ServerSelectionSection/LANSection" unique_id=748392111] +layout_mode = 2 +theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1) +theme_override_font_sizes/font_size = 12 +text = "Play over LAN without any server.\nFirewall may need to allow port 7777." +autowrap_mode = 3 + +[node name="LANHostBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ServerSelectionSection/LANSection" unique_id=748392112] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +text = "HOST LAN GAME" + +[node name="LANOrLabel" type="Label" parent="MainMenuPanel/VBoxContainer/ServerSelectionSection/LANSection" unique_id=748392113] +layout_mode = 2 +theme_override_colors/font_color = Color(0.5, 0.5, 0.5, 1) +theme_override_font_sizes/font_size = 11 +text = "── or join a friend ──" +horizontal_alignment = 1 + +[node name="LANIPInput" type="LineEdit" parent="MainMenuPanel/VBoxContainer/ServerSelectionSection/LANSection" unique_id=748392114] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "127.0.0.1" +placeholder_text = "Host IP (e.g. 192.168.1.10)" + +[node name="LANJoinBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ServerSelectionSection/LANSection" unique_id=748392115] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +text = "JOIN LAN GAME" [node name="ServerSeparator" type="HSeparator" parent="MainMenuPanel/VBoxContainer" unique_id=748392105] layout_mode = 2 diff --git a/scenes/main.gd b/scenes/main.gd index 2bebdd5..db780cc 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -51,7 +51,9 @@ func _ready(): # NetworkPanel is visible during gameplay # Auto-start game if coming from lobby (already connected to match) - if NakamaManager.is_connected_to_nakama() and multiplayer.get_unique_id() != 0: + # Works for both Nakama mode and LAN direct mode (ENet). + var is_lan_connected = LobbyManager.is_lan_mode and multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED + if (NakamaManager.is_connected_to_nakama() or is_lan_connected) and multiplayer.get_unique_id() != 0: print("Coming from lobby - auto-starting game...") await get_tree().process_frame _auto_start_from_lobby() @@ -685,9 +687,11 @@ func _start_game(): wait_time += 0.2 # Allow socket/peer to stabilize before blasting RPCs - await get_tree().create_timer(2.0).timeout + # Snappier delay for LAN mode + var delay = 0.5 if LobbyManager.is_lan_mode else 2.0 + await get_tree().create_timer(delay).timeout - # NOW assign random spawn positions for EVERYONE (Host, Client, Bots) + # NOW assign spawn positions for EVERYONE (Host, Client, Bots) # This safely sends RPCs over the completed socket connection _assign_random_spawn_positions() @@ -972,7 +976,8 @@ func spawn_tekton_npc(): # Generate a consistent ID/Name for sync (add index to ensure uniqueness) var tekton_id = Time.get_ticks_msec() + spawned_count _create_tekton(valid_pos, tekton_id) - if can_rpc(): + # Only broadcast to clients if there are remote peers connected + if can_rpc() and multiplayer.get_peers().size() > 0: rpc("sync_spawn_tekton", valid_pos, tekton_id) spawned_count += 1 @@ -980,6 +985,8 @@ func spawn_tekton_npc(): @rpc("call_remote", "reliable") func sync_spawn_tekton(pos: Vector2i, tekton_id: int): + # Safety: only create if scene is fully ready + if not is_inside_tree(): return _create_tekton(pos, tekton_id) func _create_tekton(pos: Vector2i, tekton_id: int, is_static: bool = false): @@ -1566,6 +1573,12 @@ func request_randomize_item(grid_position: Vector2i): func sync_grid_item(x: int, y: int, z: int, item: int): var enhanced_gridmap = $EnhancedGridMap if enhanced_gridmap: + # FLOOR ENFORCEMENT: Visual tiles (IDs 7-20) must ALWAYS be on layer Y=1. + # If somehow sent to Y=0, redirect them to Y=1. + if item >= 7 and item <= 20 and y == 0: + push_warning("[Main] Tile %d was sent to floor Y=0 at (%d,0,%d). Redirecting to Y=1." % [item, x, z]) + y = 1 + # PROTECTED FLOOR CHECK: Block tiles (7-20) from being placed on walls (4) or void (-1) # Note: We allow spawning on Safe Zones, Start, and Finish as it's on Layer 1. if y == 1 and item >= 7 and item <= 20: @@ -1602,7 +1615,10 @@ func sync_grid_items_batch(data: Array): var y = entry.get("y", 0) var z = entry.get("z", 0) var item = entry.get("item", -1) - + # FLOOR ENFORCEMENT: Visual tiles (IDs 7-20) must ALWAYS be on layer Y=1. + if item >= 7 and item <= 20 and y == 0: + y = 1 + # PROTECTED FLOOR CHECK if y == 1 and item >= 7 and item <= 20: var f0 = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z)) diff --git a/scenes/player.gd b/scenes/player.gd index 103e601..f4e59ad 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -1186,13 +1186,15 @@ func find_valid_starting_position() -> Vector2i: if is_bot: return _find_random_spawn_position() else: - # Auto-assign the first available spawn point for fixed spawning - for spawn_pos in spawn_locations: - if not is_position_occupied(spawn_pos): - return spawn_pos + # For Stop n Go, use Column 0 + if LobbyManager.game_mode == "Stop n Go": + for spawn_pos in spawn_locations: + if not is_position_occupied(spawn_pos): + return spawn_pos - # Fallback (should typically not be reached if spawn_locations > max_players) - return Vector2i(0, 0) + # For Freemode, try corners if random spawn is OFF, or just find any walkable + # But wait, host will assign most of the time. This is just for initial _ready. + return Vector2i(1, 1) # Default away from the very edge if possible # highlight_available_spawn_points is no longer needed for manual selection in this mode func highlight_available_spawn_points(): diff --git a/scripts/managers/lobby_manager.gd b/scripts/managers/lobby_manager.gd index d424fb7..c4cb3b6 100644 --- a/scripts/managers/lobby_manager.gd +++ b/scripts/managers/lobby_manager.gd @@ -36,6 +36,8 @@ var current_room: Dictionary = {} var players_in_room: Array = [] # [{id, name, is_ready}] var available_rooms: Array = [] var is_host: bool = false +var is_lan_mode: bool = false # True when using direct ENet (no Nakama) +const LAN_PORT: int = 7777 # Port for LAN direct connections var local_player_name: String = "Player" # Match duration in seconds (configurable in lobby by host) @@ -113,8 +115,9 @@ func _update_available_areas(mode: String) -> void: # ============================================================================= func create_room(room_name: String) -> void: - """Host creates a new room with the given name.""" + """Host creates a new room with the given name (Nakama).""" is_host = true + is_lan_mode = false current_room = { "room_name": room_name, "host_name": local_player_name, @@ -130,8 +133,9 @@ func create_room(room_name: String) -> void: NakamaManager.host_game() func join_room(match_id: String) -> void: - """Client joins an existing room by match ID.""" + """Client joins an existing room by match ID (Nakama).""" is_host = false + is_lan_mode = false var success = await NakamaManager.connect_to_nakama_async() if not success: @@ -140,6 +144,66 @@ func join_room(match_id: String) -> void: NakamaManager.join_game(match_id) +# ============================================================================= +# LAN Mode (Direct ENet, no Nakama/Docker needed) +# ============================================================================= + +func create_room_lan(room_name: String = "LAN Game") -> bool: + """Host creates a LAN room via direct ENet. No Nakama/Docker required.""" + is_host = true + is_lan_mode = true + + var peer = ENetMultiplayerPeer.new() + var err = peer.create_server(LAN_PORT, GameStateManager.max_players) + if err != OK: + push_error("[LAN] Failed to create ENet server on port %d: %s" % [LAN_PORT, err]) + return false + + multiplayer.set_multiplayer_peer(peer) + + current_room = { + "room_name": room_name, + "host_name": local_player_name, + "max_players": GameStateManager.max_players, + "match_id": "LAN" + } + + # Add host to player list + var my_id = multiplayer.get_unique_id() # Will be 1 + players_in_room.clear() + players_in_room.append({ + "id": my_id, + "name": local_player_name, + "is_ready": false, + "character": available_characters[local_character_index] + }) + + print("[LAN] Server created on port %d. Waiting for players..." % LAN_PORT) + emit_signal("room_joined", current_room) + return true + +func join_room_lan(host_ip: String) -> bool: + """Client joins a LAN room by the host's IP address. No Nakama/Docker required.""" + is_host = false + is_lan_mode = true + + var peer = ENetMultiplayerPeer.new() + var err = peer.create_client(host_ip, LAN_PORT) + if err != OK: + push_error("[LAN] Failed to connect to %s:%d: %s" % [host_ip, LAN_PORT, err]) + return false + + multiplayer.set_multiplayer_peer(peer) + + current_room = { + "room_name": "LAN Game", + "match_id": "LAN" + } + + print("[LAN] Connecting to %s:%d..." % [host_ip, LAN_PORT]) + # _on_peer_connected will fire once connected and trigger request_room_info. + return true + func leave_room() -> void: """Leave the current room.""" print("[LobbyManager] Leaving room. Clearing all local state.") @@ -147,15 +211,19 @@ func leave_room() -> void: # If we are the host, notify all clients to kick them back to menu/lobby if is_host and multiplayer.has_multiplayer_peer() and multiplayer.is_server(): print("[LobbyManager] Host is leaving. Kicking all clients...") - # We use rpc() instead of .rpc() for compatibility with older Godot 4 versions if applicable, - # but .rpc() is standard in 4.x. Let's stick to standard. kick_all_clients.rpc() # Important: Reset all lobby settings and player lists first reset() - # Disconnect from Nakama and reset multiplayer peer - NakamaManager.cleanup() + if is_lan_mode: + # LAN mode: just close the ENet peer directly + if multiplayer.has_multiplayer_peer(): + multiplayer.set_multiplayer_peer(null) + is_lan_mode = false + else: + # Nakama mode: full Nakama cleanup + NakamaManager.cleanup() # Important: Clean up game state as well to prevent ghost players GameStateManager.reset() @@ -206,7 +274,9 @@ func sync_ready_state(player_id: int, is_ready: bool) -> void: func _check_all_ready() -> void: """Check if all players are ready.""" - if players_in_room.size() < 2: + # In LAN mode allow solo play - only 1 player needed + var min_players = 1 if is_lan_mode else 2 + if players_in_room.size() < min_players: _all_ready = false return @@ -218,6 +288,17 @@ func _check_all_ready() -> void: _all_ready = true emit_signal("all_players_ready") +func force_solo_ready() -> void: + """Mark the local player as ready immediately (for solo LAN play).""" + if not multiplayer.has_multiplayer_peer(): + return + var my_id = multiplayer.get_unique_id() + for player in players_in_room: + if player["id"] == my_id: + player["is_ready"] = true + break + _check_all_ready() + func is_all_ready() -> bool: return _all_ready @@ -562,6 +643,11 @@ func _on_game_starting() -> void: func _on_match_joined(match_id: String) -> void: """Called when successfully joined a Nakama match.""" + # LAN mode handles room setup entirely in create_room_lan() / join_room_lan(). + # Skip this Nakama-specific handler to avoid double-adding the player. + if is_lan_mode: + return + 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 diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index 2fd2e10..8d108a8 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -38,6 +38,7 @@ var is_loading: bool = false # Server Selection Controls var server_option: OptionButton var server_ip_input: LineEdit +var lan_section: VBoxContainer # LAN-specific controls func _ready() -> void: _connect_signals() @@ -203,7 +204,7 @@ func _setup_server_config_ui() -> void: # Server Label var label = Label.new() - label.text = "NAKAMA SERVER" + label.text = "CONNECTION MODE" label.add_theme_color_override("font_color", Color(0.69, 0.529, 0.357, 1)) label.add_theme_font_size_override("font_size", 13) server_section.add_child(label) @@ -212,8 +213,9 @@ func _setup_server_config_ui() -> void: server_option = OptionButton.new() server_option.name = "ServerOption" server_option.custom_minimum_size = Vector2(0, 44) - server_option.add_item("Localhost (Testing)") - server_option.add_item("Remote Server (Host IP)") + server_option.add_item("Nakama - Localhost (Testing)") + server_option.add_item("Nakama - Remote Server (Host IP)") + server_option.add_item("LAN Direct (No Server)") # Set initial state based on NakamaManager if NakamaManager.nakama_host == "localhost": @@ -224,17 +226,62 @@ func _setup_server_config_ui() -> void: server_option.item_selected.connect(_on_server_option_selected) server_section.add_child(server_option) - # Server IP Input + # Nakama Server IP Input server_ip_input = LineEdit.new() server_ip_input.name = "ServerIPInput" server_ip_input.custom_minimum_size = Vector2(0, 44) - server_ip_input.placeholder_text = "Enter Server IP Address..." + server_ip_input.placeholder_text = "Enter Nakama Server IP..." server_ip_input.text = NakamaManager.nakama_host if NakamaManager.nakama_host != "localhost" else "127.0.0.1" server_ip_input.visible = server_option.selected == 1 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)) server_section.add_child(server_ip_input) + # --- LAN Section --- + lan_section = VBoxContainer.new() + lan_section.name = "LANSection" + lan_section.add_theme_constant_override("separation", 8) + lan_section.visible = false + server_section.add_child(lan_section) + + var lan_info = Label.new() + lan_info.text = "Play over LAN without any server.\nFirewall may need to allow port 7777." + lan_info.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1)) + lan_info.add_theme_font_size_override("font_size", 12) + lan_info.autowrap_mode = TextServer.AUTOWRAP_WORD + lan_section.add_child(lan_info) + + # Host LAN button + var lan_host_btn = Button.new() + lan_host_btn.name = "LANHostBtn" + lan_host_btn.text = "HOST LAN GAME" + lan_host_btn.custom_minimum_size = Vector2(0, 44) + lan_host_btn.pressed.connect(_on_lan_host_pressed) + lan_section.add_child(lan_host_btn) + + var lan_sep = Label.new() + lan_sep.text = "── or join a friend ──" + lan_sep.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + lan_sep.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1)) + lan_sep.add_theme_font_size_override("font_size", 11) + lan_section.add_child(lan_sep) + + # LAN Host IP input + var lan_ip = LineEdit.new() + lan_ip.name = "LANIPInput" + lan_ip.custom_minimum_size = Vector2(0, 44) + lan_ip.placeholder_text = "Host IP (e.g. 192.168.1.10)" + lan_ip.text = "127.0.0.1" + lan_section.add_child(lan_ip) + + # Join LAN button + var lan_join_btn = Button.new() + lan_join_btn.name = "LANJoinBtn" + lan_join_btn.text = "JOIN LAN GAME" + lan_join_btn.custom_minimum_size = Vector2(0, 44) + lan_join_btn.pressed.connect(func(): _on_lan_join_pressed(lan_ip.text)) + lan_section.add_child(lan_join_btn) + # Add a separator after the section var separator = HSeparator.new() vbox.add_child(separator) @@ -242,18 +289,55 @@ func _setup_server_config_ui() -> void: func _on_server_option_selected(index: int) -> void: if index == 0: - # Localhost + # Nakama Localhost server_ip_input.visible = false + if lan_section: lan_section.visible = false NakamaManager.set_server("localhost") - else: - # Remote + elif index == 1: + # Nakama Remote server_ip_input.visible = true + if lan_section: lan_section.visible = false NakamaManager.set_server(server_ip_input.text) + else: + # LAN Direct + server_ip_input.visible = false + if lan_section: lan_section.visible = true func _on_server_ip_submitted(new_text: String) -> void: - if server_option.selected == 1: + if server_option and server_option.selected == 1: NakamaManager.set_server(new_text.strip_edges()) +func _on_lan_host_pressed() -> void: + """Host a LAN game without logging in to Nakama.""" + var player_name = email_input.text.strip_edges() + if player_name.is_empty(): + player_name = "Host" + LobbyManager.local_player_name = player_name + + var ok = await LobbyManager.create_room_lan() + if ok: + _go_to_lobby() + else: + _show_error("Failed to create LAN server. Check firewall for port 7777.") + +func _on_lan_join_pressed(host_ip: String) -> void: + """Join a LAN game without logging in to Nakama.""" + var ip = host_ip.strip_edges() + if ip.is_empty(): + _show_error("Please enter the host's IP address.") + return + + var player_name = email_input.text.strip_edges() + if player_name.is_empty(): + player_name = "Player" + LobbyManager.local_player_name = player_name + + var ok = LobbyManager.join_room_lan(ip) + if ok: + _go_to_lobby() + else: + _show_error("Failed to connect to %s. Is the host running?" % ip) + # ============================================================================= # Registration Handlers # =============================================================================