diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 400a5f2..73e9f62 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -186,6 +186,7 @@ func _ready(): 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) @@ -216,6 +217,11 @@ func _ready(): # 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 = "" # ============================================================================= # Setup @@ -672,6 +678,10 @@ func _on_room_left() -> void: _show_panel("main_menu") connection_status.text = "Left room" +func _on_host_disconnected() -> void: + _show_panel("main_menu") + connection_status.text = "Host disconnected. Match terminated." + func _on_player_joined(player_data: Dictionary) -> void: _update_player_slots() status_label.text = "%s joined!" % player_data.get("name", "Player") diff --git a/scenes/main.gd b/scenes/main.gd index c150eae..43a47a4 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -29,6 +29,8 @@ func _ready(): # Connect to multiplayer signals multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) + LobbyManager.host_disconnected.connect(_on_host_disconnected) + LobbyManager.game_starting.connect(_on_rematch_starting) # Connect to Nakama signals NakamaManager.match_joined.connect(_on_match_joined) @@ -1200,11 +1202,73 @@ func add_newly_connected_player_character(new_peer_id: int): func _on_peer_disconnected(peer_id: int): if multiplayer.is_server(): - GameStateManager.remove_player(peer_id) - if GameStateManager.enable_bots: - var next_id = PlayerManager.get_next_available_bot_id(GameStateManager.max_players, GameStateManager.players) - if next_id != -1: - _add_bot(next_id) + print("[Main] Peer %d disconnected. Checking for bot replacement..." % peer_id) + + var player_node = get_node_or_null(str(peer_id)) + if player_node and not player_node.is_bot: + # Cache state before removing + var pos = player_node.current_position + var p_score = player_node.score + var p_goals = player_node.goals.duplicate() + var p_char = player_node.selected_character + + # Remove human player + GameStateManager.remove_player(peer_id) + player_node.queue_free() + + # Add replacement bot + if GameStateManager.enable_bots: + var next_bot_id = PlayerManager.get_next_available_bot_id(GameStateManager.max_players, GameStateManager.players) + if next_bot_id != -1: + print("[Main] Replacing Player %d with Bot %d" % [peer_id, next_bot_id]) + _replace_player_with_bot(next_bot_id, pos, p_score, p_goals, p_char) + else: + GameStateManager.remove_player(peer_id) + +func _replace_player_with_bot(bot_id: int, pos: Vector2i, p_score: int, p_goals: Array, p_char: String): + """Creates a bot to replace a disconnected player and restores their state.""" + rpc("create_bot_with_state", bot_id, pos, p_score, p_goals, p_char) + +@rpc("call_local") +func create_bot_with_state(bot_id: int, pos: Vector2i, p_score: int, p_goals: Array, p_char: String): + if not GameStateManager.enable_bots: + return + + if has_node(str(bot_id)): + return + + var bot_character = PlayerManager.create_bot(bot_id) + call_deferred("add_child", bot_character) + bot_character.add_to_group("Players", true) + bot_character.add_to_group("Bots", true) + + # Apply transferred state + bot_character.current_position = pos + bot_character.score = p_score + bot_character.goals = p_goals + bot_character.selected_character = p_char + + if multiplayer.is_server(): + GameStateManager.add_bot(bot_id) + # Ensure position is synced + bot_character.update_player_position(pos) + +func _on_host_disconnected(): + """Called when the host leaves. Returns clients to the main menu.""" + print("[Main] Host disconnected. Match terminated. Returning to lobby...") + get_tree().change_scene_to_file("res://scenes/lobby.tscn") + +func _on_rematch_starting(): + """Called when a rematch is triggered. Reloads the game scene.""" + print("[Main] Rematch starting! Resetting state and reloading scene...") + + # Reset singletons/managers that persist across scene reloads + GameStateManager.reset() + GoalManager.reset() + TurnManager.reset() + + is_match_ended = false + get_tree().change_scene_to_file("res://scenes/main.tscn") # ============================================================================= # Turn Management (RPC Handlers) @@ -1716,6 +1780,10 @@ func sync_game_end_portal_mode(winner_id: int): func _on_match_ended(): """Called when the global match timer ends - show game over screen.""" + if is_match_ended: + return + + is_match_ended = true print("[Main] Match ended! Showing game over screen...") # Disable player controls @@ -1927,7 +1995,39 @@ func _show_game_over_panel(): # Add local player entry leaderboard_container.add_child(create_entry.call(local_player_rank)) - # Back to Menu button + # 3. Rematch Option + var rematch_container = HBoxContainer.new() + rematch_container.alignment = BoxContainer.ALIGNMENT_CENTER + rematch_container.add_theme_constant_override("separation", 20) + inner_vbox.add_child(rematch_container) + + var rematch_btn = Button.new() + rematch_btn.name = "RematchBtn" + rematch_btn.text = "REMATCH" + rematch_btn.custom_minimum_size = Vector2(200, 60) + rematch_btn.add_theme_font_size_override("font_size", 20) + rematch_btn.pressed.connect(func(): + rematch_btn.disabled = true + rematch_btn.text = "VOTED" + LobbyManager.request_rematch.rpc(multiplayer.get_unique_id()) + ) + rematch_container.add_child(rematch_btn) + + var rematch_label = Label.new() + rematch_label.name = "RematchVoteLabel" + rematch_label.text = "0/2" + rematch_label.add_theme_font_size_override("font_size", 24) + rematch_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + rematch_container.add_child(rematch_label) + + LobbyManager.rematch_votes_updated.connect(func(count, required): + if is_instance_valid(rematch_label): + rematch_label.text = "%d/%d" % [count, required] + if count >= required: + rematch_label.add_theme_color_override("font_color", Color.GREEN) + ) + + # 4. Back to Menu button var back_btn = Button.new() back_btn.name = "BackToMenuBtn" back_btn.text = "BACK TO MAIN MENU" diff --git a/scenes/player.gd b/scenes/player.gd index 36c23ac..2671ca0 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -151,7 +151,13 @@ var _is_highlighting: bool = false @onready var character_gatot: Node3D = $Gatot @onready var character_oldpop: Node3D = $Oldpop -var selected_character: String = "Masbro" # Default character (matches tscn default visibility) +var _selected_character: String = "Masbro" +var selected_character: String: + get: return _selected_character + set(value): + _selected_character = value + if is_inside_tree(): + set_character(value) const AVAILABLE_CHARACTERS: Array[String] = ["Bob", "Masbro", "Gatot", "Oldpop"] diff --git a/scripts/managers/goal_manager.gd b/scripts/managers/goal_manager.gd index 3d7e310..eba2d8a 100644 --- a/scripts/managers/goal_manager.gd +++ b/scripts/managers/goal_manager.gd @@ -101,3 +101,8 @@ func get_boost_multiplier(player_id: int) -> float: # Player is faster than average -> Boost fills slower # Scale down to 0.8x return 0.8 + +func reset(): + preset_goals.clear() + player_completion_times.clear() + player_start_times.clear() diff --git a/scripts/managers/lobby_manager.gd b/scripts/managers/lobby_manager.gd index 674aad3..c8d2b58 100644 --- a/scripts/managers/lobby_manager.gd +++ b/scripts/managers/lobby_manager.gd @@ -12,12 +12,14 @@ 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 host_disconnected() signal game_starting() signal match_duration_changed(duration_seconds: int) signal randomize_spawn_changed(enabled: bool) signal character_changed(player_id: int, character_name: String) signal area_changed(area_name: String) signal player_list_changed() +signal rematch_votes_updated(count: int, required: int) # Stop N Go settings signals signal sng_go_duration_changed(duration: int) @@ -50,6 +52,9 @@ signal enable_cycle_timer_changed(enabled: bool) var scarcity_mode: String = "Normal" # Normal, Aggressive, Chaos signal scarcity_mode_changed(mode: String) +# Disconnection reason for UI feedback +var disconnect_reason: String = "" + # Stop N Go settings var sng_go_duration: int = 15 var sng_stop_duration: int = 4 @@ -60,6 +65,9 @@ var doors_swap_time: int = 15 var doors_refresh_time: int = 25 var doors_required_goals: int = 8 +# Rematch tracking +var rematch_votes: Array = [] # [player_id, ...] + # Character and area selection var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"] var available_areas: Array[String] = [] @@ -87,6 +95,7 @@ func _ready(): NakamaManager.match_joined.connect(_on_match_joined) multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) + multiplayer.server_disconnected.connect(_on_server_disconnected) func _update_available_areas(mode: String) -> void: match mode: @@ -133,14 +142,13 @@ func join_room(match_id: String) -> void: func leave_room() -> void: """Leave the current room.""" - current_room = {} - players_in_room.clear() - is_host = false - _all_ready = false + print("[LobbyManager] Leaving room. Clearing all local state.") - # Disconnect from Nakama match - if NakamaManager.socket: - NakamaManager.socket.close() + # Important: Reset all lobby settings and player lists first + reset() + + # Disconnect from Nakama and reset multiplayer peer + NakamaManager.cleanup() # Important: Clean up game state as well to prevent ghost players GameStateManager.reset() @@ -496,13 +504,13 @@ func sync_game_mode(mode: String) -> void: _update_available_areas(mode) emit_signal("game_mode_changed", mode) -func start_game() -> void: +func start_game(force: bool = false) -> 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: + if not force and not _all_ready: push_error("Not all players are ready") return @@ -639,6 +647,54 @@ func _on_peer_disconnected(peer_id: int) -> void: emit_signal("player_left", peer_id) _check_all_ready() +func _on_server_disconnected() -> void: + """Called on all clients when the host (server) disconnects.""" + print("[LobbyManager] Server (Host) disconnected. Terminating room...") + disconnect_reason = "Host disconnected. Match terminated." + rematch_votes.clear() + emit_signal("host_disconnected") + leave_room() + +# ============================================================================= +# Rematch Logic +# ============================================================================= + +func reset_rematch_votes() -> void: + rematch_votes.clear() + emit_signal("rematch_votes_updated", 0, 2) + +@rpc("any_peer", "call_local", "reliable") +func request_rematch(player_id: int) -> void: + """Client requests a rematch. Only 2 votes needed to trigger.""" + if not multiplayer.is_server(): + return + + if player_id not in rematch_votes: + rematch_votes.append(player_id) + print("[LobbyManager] Rematch vote from %d. Total: %d/2" % [player_id, rematch_votes.size()]) + + # Sync vote count to all clients + rpc("sync_rematch_votes", rematch_votes.size(), 2) + + # Check if we have enough votes + if rematch_votes.size() >= 2: + print("[LobbyManager] Rematch threshold met! Starting game...") + start_rematch() + +@rpc("authority", "call_local", "reliable") +func sync_rematch_votes(count: int, required: int) -> void: + emit_signal("rematch_votes_updated", count, required) + +func start_rematch() -> void: + """Host starts the rematch.""" + if not is_host: + return + + reset_rematch_votes() + + # Start game using existing start_game logic, bypassing ready check + start_game(true) + @rpc("reliable") func sync_player_list(player_list: Array) -> void: """Sync player list from host to all clients.""" diff --git a/scripts/managers/turn_manager.gd b/scripts/managers/turn_manager.gd index dbce0db..a454df6 100644 --- a/scripts/managers/turn_manager.gd +++ b/scripts/managers/turn_manager.gd @@ -21,3 +21,7 @@ func end_current_turn(): func reset_turn(): current_turn_index = -1 + +func reset(): + current_turn_index = 0 + turn_based_mode = false diff --git a/scripts/nakama_manager.gd b/scripts/nakama_manager.gd index cd3660e..788f81f 100644 --- a/scripts/nakama_manager.gd +++ b/scripts/nakama_manager.gd @@ -51,8 +51,12 @@ func connect_to_nakama_async(email: String = "", password: String = "") -> bool: # 1. Authenticate if email == "": var device_id = OS.get_unique_id() - # Use a more stable ID for testing instead of randi() every call - # If you need multiple clients on one machine, consider a command line arg or config + + # If running in editor or debug, append a unique suffix to allow multiple + # instances on one machine to have separate sessions. + if OS.is_debug_build(): + device_id += "_" + str(Time.get_ticks_msec()) + "_" + str(randi() % 1000) + session = await client.authenticate_device_async(device_id) else: session = await client.authenticate_email_async(email, password)