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 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) signal sng_stop_duration_changed(duration: int) signal sng_required_goals_changed(goals: int) # Tekton Doors settings signals signal doors_swap_time_changed(time: int) signal doors_refresh_time_changed(time: int) signal doors_required_goals_changed(goals: int) # Gauntlet settings signals signal gauntlet_round_duration_changed(duration: int) signal gauntlet_growth_interval_changed(interval: float) signal gauntlet_cells_per_tick_changed(cells: Dictionary) # Mekton Bulls settings signals signal mekton_bulls_round_duration_changed(duration: int) signal mekton_bulls_phase_interval_changed(interval: int) signal mekton_bulls_points_changed(min_pts: int, max_pts: int) # 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 is_lan_mode: bool = false # True when using direct ENet (no Nakama) const LAN_PORT: int = 7777 # Port for LAN direct connections const LAN_DISCOVERY_PORT: int = 7778 # Port for LAN discovery var local_player_name: String = "Player" # Tutorial Mode Flag var is_tutorial_mode: bool = false var _udp_peer: PacketPeerUDP var _broadcast_timer: Timer # Match duration in seconds (configurable in lobby by host) var match_duration: int = 180 # Default 3 minutes # Randomize spawn locations (configurable in lobby by host) var randomize_spawn: bool = false # Default enabled # Timer setting var enable_cycle_timer: bool = false # Default disabled signal enable_cycle_timer_changed(enabled: bool) # Scarcity setting 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 = 20 var sng_stop_duration: int = 4 var sng_required_goals: int = 8 # Tekton Doors settings var doors_swap_time: int = 15 var doors_refresh_time: int = 25 var doors_required_goals: int = 8 # Gauntlet settings var gauntlet_round_duration: int = 180 var gauntlet_growth_interval: float = 3.0 # seconds between growth ticks var gauntlet_cells_per_tick: Dictionary = { "phase1": [4, 6], "phase2": [6, 8], "phase3": [8, 10], } # Mekton Bulls settings var mekton_bulls_round_duration: int = 120 var mekton_bulls_phase_interval: int = 30 var mekton_bulls_min_points: int = 100 var mekton_bulls_max_points: int = 1000 # 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] = [] var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival", "Mekton Bulls"] var selected_area: String = "Freemode Arena" # Host-controlled var game_mode: String = "Freemode" # Host-controlled var local_character_index: int = 0 # Local player's character index func get_game_mode() -> GameMode.Mode: return GameMode.from_string(game_mode) func is_game_mode(mode: GameMode.Mode) -> bool: return get_game_mode() == mode # Signals signal game_mode_changed(mode: String) # Ready to start game check var _all_ready: bool = false func _ready(): _update_available_areas(game_mode) # Setup UDP for LAN discovery _udp_peer = PacketPeerUDP.new() # 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) multiplayer.server_disconnected.connect(_on_server_disconnected) func _process(_delta): _listen_for_lan_discovery() func _listen_for_lan_discovery(): if not _udp_peer or not is_lan_mode or is_host: return if not _udp_peer.is_bound(): return while _udp_peer.get_available_packet_count() > 0: var packet = _udp_peer.get_packet() var ip = _udp_peer.get_packet_ip() var data_str = packet.get_string_from_utf8() if not data_str.begins_with("TEKTON_HOST:"): continue var room_info_json = data_str.trim_prefix("TEKTON_HOST:") var room_info = JSON.parse_string(room_info_json) if room_info is Dictionary: room_info["ip"] = ip _update_lan_room_list(room_info) func _update_available_areas(mode: String) -> void: match mode: "Freemode": available_areas = ["Freemode Arena", "Classic", "Colloseum"] "Stop n Go": available_areas = ["Stop N Go Arena"] "Tekton Doors": available_areas = ["Tekton Doors Area"] "Candy Pump Survival": available_areas = ["Gauntlet Arena"] "Mekton Bulls": available_areas = ["Mekton Bulls Arena"] _: available_areas = ["Classic"] # ============================================================================= # Room Creation / Joining # ============================================================================= func start_tutorial(mode: String = "Freemode") -> void: is_tutorial_mode = true game_mode = mode match_duration = 300 GameStateManager.enable_bots = true GameStateManager.max_players = 2 # 1 Player + 1 Bot create_room_lan("Tutorial") func create_room(room_name: String) -> void: """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, "max_players": GameStateManager.max_players, "game_mode": game_mode } # 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({ "host_name": local_player_name, "game_mode": game_mode, "max_players": GameStateManager.max_players }) func join_room(match_id: String) -> void: """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: push_error("Failed to connect to Nakama") return 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", "game_mode": game_mode } # 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) _start_lan_broadcast(room_name) emit_signal("room_joined", current_room) return true func _start_lan_broadcast(room_name: String): _stop_lan_broadcast() if _udp_peer.bind(0) != OK: push_error("[LAN] Failed to bind UDP for broadcasting") return _udp_peer.set_broadcast_enabled(true) _broadcast_timer = Timer.new() _broadcast_timer.name = "LANBroadcastTimer" _broadcast_timer.wait_time = 2.0 _broadcast_timer.autostart = true add_child(_broadcast_timer) _broadcast_timer.timeout.connect(_broadcast_lan_room.bind(room_name)) func _broadcast_lan_room(room_name: String): var room_data = { "room_name": room_name, "host_name": local_player_name, "player_count": players_in_room.size(), "max_players": GameStateManager.max_players, "match_id": "LAN", "game_mode": game_mode } var msg = "TEKTON_HOST:" + JSON.stringify(room_data) _udp_peer.set_dest_address("255.255.255.255", LAN_DISCOVERY_PORT) _udp_peer.put_packet(msg.to_utf8_buffer()) func _stop_lan_broadcast(): if _broadcast_timer: _broadcast_timer.stop() _broadcast_timer.queue_free() _broadcast_timer = null if _udp_peer and _udp_peer.is_bound(): _udp_peer.close() func _update_lan_room_list(room_info: Dictionary): var found = false for i in range(available_rooms.size()): if available_rooms[i].get("ip") == room_info["ip"]: available_rooms[i] = room_info found = true break if not found: available_rooms.append(room_info) emit_signal("room_list_updated", available_rooms) 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.") # 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...") kick_all_clients.rpc() # Important: Reset all lobby settings and player lists first reset() _stop_lan_broadcast() # Emit before nulling peer so UI can still access peer info if needed emit_signal("room_left") if is_lan_mode: # LAN mode: Host should keep peer alive long enough to reach lobby if not is_host or get_tree().current_scene.name == "Lobby": 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() func refresh_room_list() -> void: """Request updated room list from Nakama or scan for LAN rooms.""" if is_lan_mode: available_rooms.clear() if _udp_peer.is_bound(): _udp_peer.close() var err = _udp_peer.bind(LAN_DISCOVERY_PORT) if err != OK: push_error("[LAN] Failed to bind to discovery port %d" % LAN_DISCOVERY_PORT) emit_signal("room_list_updated", available_rooms) return 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.""" if not multiplayer.has_multiplayer_peer(): return 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.""" # Allow solo play in both LAN and Nakama modes var min_players = 1 if players_in_room.size() < min_players: _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 @rpc("authority", "call_local", "reliable") func kick_all_clients() -> void: """Called on all clients when the host leaves to ensure they are returned to the menu.""" if not is_host: print("[LobbyManager] Received kick from host. Returning to menu...") disconnect_reason = "Host left the lobby. Room closed." emit_signal("host_disconnected") # We use call_deferred to avoid potential issues during RPC stack processing call_deferred("leave_room") # ============================================================================= # Game Start # ============================================================================= func set_match_duration(duration_seconds: int) -> void: """Host sets match duration. Syncs to all clients.""" match_duration = duration_seconds if is_host: rpc("sync_match_duration", duration_seconds) @rpc("authority", "call_local", "reliable") func sync_match_duration(duration_seconds: int) -> void: """Sync match duration from host to clients.""" match_duration = duration_seconds emit_signal("match_duration_changed", duration_seconds) func get_match_duration() -> int: return match_duration func set_randomize_spawn(enabled: bool) -> void: """Host sets randomize spawn. Syncs to all clients.""" randomize_spawn = enabled if is_host: rpc("sync_randomize_spawn", enabled) @rpc("authority", "call_local", "reliable") func sync_randomize_spawn(enabled: bool) -> void: """Sync randomize spawn from host to clients.""" randomize_spawn = enabled emit_signal("randomize_spawn_changed", enabled) func get_randomize_spawn() -> bool: return randomize_spawn # ============================================================================= # Timer Setting # ============================================================================= func set_enable_cycle_timer(enabled: bool) -> void: """Host sets enable cycle timer. Syncs to all clients.""" enable_cycle_timer = enabled if is_host: rpc("sync_enable_cycle_timer", enabled) @rpc("authority", "call_local", "reliable") func sync_enable_cycle_timer(enabled: bool) -> void: """Sync enable cycle timer from host to clients.""" enable_cycle_timer = enabled emit_signal("enable_cycle_timer_changed", enabled) func get_enable_cycle_timer() -> bool: return enable_cycle_timer # ============================================================================= # Scarcity Mode # ============================================================================= func set_scarcity_mode(mode: String) -> void: """Host sets scarcity mode. Syncs to clients.""" scarcity_mode = mode if is_host: rpc("sync_scarcity_mode", mode) @rpc("authority", "call_local", "reliable") func sync_scarcity_mode(mode: String) -> void: scarcity_mode = mode # Update ScarcityModel immediately ScarcityModel.set_mode(mode) emit_signal("scarcity_mode_changed", mode) func get_scarcity_mode() -> String: return scarcity_mode # ============================================================================= # Stop N Go Settings # ============================================================================= func set_sng_go_duration(duration: int) -> void: sng_go_duration = duration if is_host: rpc("sync_sng_go_duration", duration) @rpc("authority", "call_local", "reliable") func sync_sng_go_duration(duration: int) -> void: sng_go_duration = duration emit_signal("sng_go_duration_changed", duration) func set_sng_stop_duration(duration: int) -> void: sng_stop_duration = duration if is_host: rpc("sync_sng_stop_duration", duration) @rpc("authority", "call_local", "reliable") func sync_sng_stop_duration(duration: int) -> void: sng_stop_duration = duration emit_signal("sng_stop_duration_changed", duration) func set_sng_required_goals(goals: int) -> void: sng_required_goals = goals if is_host: rpc("sync_sng_required_goals", goals) @rpc("authority", "call_local", "reliable") func sync_sng_required_goals(goals: int) -> void: sng_required_goals = goals emit_signal("sng_required_goals_changed", goals) # ============================================================================= # Tekton Doors Settings # ============================================================================= func set_doors_swap_time(time: int) -> void: doors_swap_time = time if is_host: rpc("sync_doors_swap_time", time) @rpc("authority", "call_local", "reliable") func sync_doors_swap_time(time: int) -> void: doors_swap_time = time emit_signal("doors_swap_time_changed", time) func set_doors_refresh_time(time: int) -> void: doors_refresh_time = time if is_host: rpc("sync_doors_refresh_time", time) @rpc("authority", "call_local", "reliable") func sync_doors_refresh_time(time: int) -> void: doors_refresh_time = time emit_signal("doors_refresh_time_changed", time) func set_doors_required_goals(goals: int) -> void: doors_required_goals = goals if is_host: rpc("sync_doors_required_goals", goals) @rpc("authority", "call_local", "reliable") func sync_doors_required_goals(goals: int) -> void: doors_required_goals = goals emit_signal("doors_required_goals_changed", goals) # ============================================================================= # Gauntlet Settings # ============================================================================= func set_gauntlet_round_duration(duration: int) -> void: gauntlet_round_duration = duration if is_host: rpc("sync_gauntlet_round_duration", duration) @rpc("authority", "call_local", "reliable") func sync_gauntlet_round_duration(duration: int) -> void: gauntlet_round_duration = duration emit_signal("gauntlet_round_duration_changed", duration) func set_gauntlet_growth_interval(interval: float) -> void: gauntlet_growth_interval = interval if is_host: rpc("sync_gauntlet_growth_interval", interval) @rpc("authority", "call_local", "reliable") func sync_gauntlet_growth_interval(interval: float) -> void: gauntlet_growth_interval = interval emit_signal("gauntlet_growth_interval_changed", interval) func set_gauntlet_cells_per_tick(cells: Dictionary) -> void: gauntlet_cells_per_tick = cells if is_host: rpc("sync_gauntlet_cells_per_tick", cells) @rpc("authority", "call_local", "reliable") func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void: gauntlet_cells_per_tick = cells emit_signal("gauntlet_cells_per_tick_changed", cells) # ============================================================================= # Mekton Bulls Settings # ============================================================================= func set_mekton_bulls_round_duration(duration: int) -> void: mekton_bulls_round_duration = duration if is_host: rpc("sync_mekton_bulls_round_duration", duration) @rpc("authority", "call_local", "reliable") func sync_mekton_bulls_round_duration(duration: int) -> void: mekton_bulls_round_duration = duration emit_signal("mekton_bulls_round_duration_changed", duration) func set_mekton_bulls_phase_interval(interval: int) -> void: mekton_bulls_phase_interval = interval if is_host: rpc("sync_mekton_bulls_phase_interval", interval) @rpc("authority", "call_local", "reliable") func sync_mekton_bulls_phase_interval(interval: int) -> void: mekton_bulls_phase_interval = interval emit_signal("mekton_bulls_phase_interval_changed", interval) func set_mekton_bulls_points(min_pts: int, max_pts: int) -> void: mekton_bulls_min_points = min_pts mekton_bulls_max_points = max_pts if is_host: rpc("sync_mekton_bulls_points", min_pts, max_pts) @rpc("authority", "call_local", "reliable") func sync_mekton_bulls_points(min_pts: int, max_pts: int) -> void: mekton_bulls_min_points = min_pts mekton_bulls_max_points = max_pts emit_signal("mekton_bulls_points_changed", min_pts, max_pts) # ============================================================================= # Character Selection # ============================================================================= func get_local_character() -> String: """Get the local player's current character name.""" return available_characters[local_character_index] func set_character(character_name: String) -> void: """Set local player's character. Syncs to all peers.""" var idx = available_characters.find(character_name) if idx == -1: push_error("Invalid character: " + character_name) return local_character_index = idx if not multiplayer.has_multiplayer_peer(): return var my_id = multiplayer.get_unique_id() # Update local player data for player in players_in_room: if player["id"] == my_id: player["character"] = character_name break # Sync to all peers rpc("sync_character", my_id, character_name) func cycle_character(direction: int) -> void: """Cycle through available characters. direction: -1 for left, +1 for right.""" local_character_index = wrapi(local_character_index + direction, 0, available_characters.size()) set_character(available_characters[local_character_index]) func select_random_character() -> void: """Select a random character (excluding 'Randomized' option itself).""" # Pick from indices 0-3 (Copper, Dabro, Gatot, Pip) var random_idx = randi() % 4 local_character_index = random_idx set_character(available_characters[local_character_index]) @rpc("any_peer", "call_local", "reliable") func sync_character(player_id: int, character_name: String) -> void: """Sync character selection across all clients.""" for player in players_in_room: if player["id"] == player_id: player["character"] = character_name break emit_signal("character_changed", player_id, character_name) emit_signal("player_list_changed") # ============================================================================= # Player Name Management # ============================================================================= func set_player_name(new_name: String) -> void: """Set local player's name. Syncs to all peers.""" local_player_name = new_name if not multiplayer.has_multiplayer_peer(): emit_signal("player_list_changed") return var my_id = multiplayer.get_unique_id() # Update local player data for player in players_in_room: if player["id"] == my_id: player["name"] = new_name break # Sync to all peers if connected if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED: rpc("sync_player_name", my_id, new_name) emit_signal("player_list_changed") @rpc("any_peer", "call_local", "reliable") func sync_player_name(player_id: int, new_name: String) -> void: """Sync player name across all clients.""" for player in players_in_room: if player["id"] == player_id: player["name"] = new_name break emit_signal("player_list_changed") # ============================================================================= # Area Selection (Host Only) # ============================================================================= func get_selected_area() -> String: return selected_area func get_area_index() -> int: return available_areas.find(selected_area) func set_area(area_name: String) -> void: """Host sets the game area. Syncs to all clients.""" if not is_host: push_warning("Only host can change area") return if area_name not in available_areas: push_error("Invalid area: " + area_name) return selected_area = area_name rpc("sync_area", area_name) func cycle_area(direction: int) -> void: """Host cycles through available areas. direction: -1 for left, +1 for right.""" if not is_host: return var current_idx = available_areas.find(selected_area) var new_idx = wrapi(current_idx + direction, 0, available_areas.size()) set_area(available_areas[new_idx]) @rpc("authority", "call_local", "reliable") func sync_area(area_name: String) -> void: """Sync area selection from host to clients.""" selected_area = area_name emit_signal("area_changed", area_name) # ============================================================================= # Game Mode Selection (Host Only) # ============================================================================= func set_game_mode(mode: String) -> void: """Host sets the game mode. Syncs to all clients.""" if not is_host: push_warning("Only host can change game mode") return if mode not in available_game_modes: push_error("Invalid game mode: " + mode) return game_mode = mode rpc("sync_game_mode", mode) _update_available_areas(mode) # Only force switch the area if the selected area is NOT valid for this mode if selected_area not in available_areas: set_area(available_areas[0]) else: # Important: even if the area is technically in the list, if they just clicked Free Mode # we should default them to Free Mode Area if they were on Stop n Go Area before. if mode == "Free Mode" and "Free Mode Area" in available_areas: set_area("Free Mode Area") elif mode == "Stop n Go" and "Stop n Go Area" in available_areas: set_area("Stop n Go Area") elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas: set_area("Tekton Doors Area") elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas: set_area("Gauntlet Arena") @rpc("authority", "call_local", "reliable") func sync_game_mode(mode: String) -> void: """Sync game mode selection from host to clients.""" game_mode = mode _update_available_areas(mode) # Try to smart-match the client's local area to the mode as well so their UI matches if mode == "Free Mode" and "Free Mode Area" in available_areas: selected_area = "Free Mode Area" elif mode == "Stop n Go" and "Stop n Go Area" in available_areas: selected_area = "Stop n Go Area" elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas: selected_area = "Tekton Doors Area" elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas: selected_area = "Gauntlet Arena" elif selected_area not in available_areas: selected_area = available_areas[0] emit_signal("game_mode_changed", mode) 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 force and not _all_ready: push_error("Not all players are ready") return # Sync match duration to all clients before starting rpc("sync_match_duration", match_duration) # Sync timer setting rpc("sync_enable_cycle_timer", enable_cycle_timer) # Sync scarcity mode rpc("sync_scarcity_mode", scarcity_mode) # Sync game mode features rpc("sync_sng_go_duration", sng_go_duration) rpc("sync_sng_stop_duration", sng_stop_duration) rpc("sync_sng_required_goals", sng_required_goals) rpc("sync_doors_swap_time", doors_swap_time) rpc("sync_doors_refresh_time", doors_refresh_time) rpc("sync_doors_required_goals", doors_required_goals) # Sync gauntlet settings rpc("sync_gauntlet_round_duration", gauntlet_round_duration) rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval) rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) # Sync mekton bulls rpc("sync_mekton_bulls_round_duration", mekton_bulls_round_duration) rpc("sync_mekton_bulls_phase_interval", mekton_bulls_phase_interval) rpc("sync_mekton_bulls_points", mekton_bulls_min_points, mekton_bulls_max_points) # Sync game mode rpc("sync_game_mode", game_mode) # 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.""" # 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 current_room["room_name"] = short_id if not multiplayer.has_multiplayer_peer(): return # 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, "character": available_characters[local_character_index], "nakama_id": NakamaManager.session.user_id if NakamaManager.session else "" } 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, requester_name: String, requester_character: String, requester_nakama_id: String = "") -> void: """Client requests room info from host, sending their name, character and nakama_id.""" if not multiplayer.is_server(): return # Update the player's name, character and nakama_id in the list for player in players_in_room: if player["id"] == requester_id: player["name"] = requester_name player["character"] = requester_character player["nakama_id"] = requester_nakama_id break # Send room data to requester rpc_id(requester_id, "receive_room_info", current_room, players_in_room) # Sync current lobby settings to the joining client rpc_id(requester_id, "sync_match_duration", match_duration) rpc_id(requester_id, "sync_randomize_spawn", randomize_spawn) rpc_id(requester_id, "sync_enable_cycle_timer", enable_cycle_timer) rpc_id(requester_id, "sync_scarcity_mode", scarcity_mode) rpc_id(requester_id, "sync_sng_go_duration", sng_go_duration) rpc_id(requester_id, "sync_sng_stop_duration", sng_stop_duration) rpc_id(requester_id, "sync_sng_required_goals", sng_required_goals) rpc_id(requester_id, "sync_doors_swap_time", doors_swap_time) rpc_id(requester_id, "sync_doors_refresh_time", doors_refresh_time) rpc_id(requester_id, "sync_doors_required_goals", doors_required_goals) rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration) rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval) rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) rpc_id(requester_id, "sync_mekton_bulls_round_duration", mekton_bulls_round_duration) rpc_id(requester_id, "sync_mekton_bulls_phase_interval", mekton_bulls_phase_interval) rpc_id(requester_id, "sync_mekton_bulls_points", mekton_bulls_min_points, mekton_bulls_max_points) rpc_id(requester_id, "sync_game_mode", game_mode) rpc_id(requester_id, "sync_area", selected_area) # Also sync updated player list to all other clients rpc("sync_player_list", players_in_room) emit_signal("player_list_changed") @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, "character": available_characters[0], "nakama_id": "" } 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) _check_all_ready() 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 # Send our actual name, character, and nakama_id to the host var my_nakama_id: String = NakamaManager.session.user_id if NakamaManager.session else "" rpc_id(1, "request_room_info", multiplayer.get_unique_id(), local_player_name, available_characters[local_character_index], my_nakama_id) func _on_peer_disconnected(peer_id: int) -> void: """Called when peer disconnects.""" print("Peer disconnected: ", peer_id) # If the host (peer 1) disconnected and we are not host, we should be kicked if peer_id == 1 and not is_host: print("[LobbyManager] Host peer disconnected. Kicking self...") _on_server_disconnected() return 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() 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() # Ensure full cleanup and state reset leave_room() emit_signal("host_disconnected") # ============================================================================= # Rematch Logic # ============================================================================= func _get_connected_human_count() -> int: """Returns the number of connected real human players (peers + host, excluding bots).""" if multiplayer.has_multiplayer_peer(): return multiplayer.get_peers().size() + 1 # peers + self (host) return max(1, players_in_room.size()) # Fallback to lobby list func reset_rematch_votes() -> void: rematch_votes.clear() var required = max(1, ceili(_get_connected_human_count() / 2.0)) emit_signal("rematch_votes_updated", 0, required) @rpc("any_peer", "call_local", "reliable") func request_rematch(player_id: int) -> void: """Client requests a rematch. Votes needed depend on connected real players (excluding bots).""" if not multiplayer.is_server(): return if player_id not in rematch_votes: rematch_votes.append(player_id) var required = max(1, ceili(_get_connected_human_count() / 2.0)) print("[LobbyManager] Rematch vote from %d. Total: %d/%d" % [player_id, rematch_votes.size(), required]) # Sync vote count to all clients rpc("sync_rematch_votes", rematch_votes.size(), required) # Check if we have enough votes if rematch_votes.size() >= required: 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.""" 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 is_tutorial_mode = false match_duration = 180 # Reset to default 3 minutes game_mode = "Freemode" _update_available_areas(game_mode) selected_area = available_areas[0] local_character_index = 0 # Default to "Copper" enable_cycle_timer = false sng_go_duration = 20 sng_stop_duration = 4 sng_required_goals = 8 doors_swap_time = 15 doors_refresh_time = 25 doors_required_goals = 8 mekton_bulls_round_duration = 120 mekton_bulls_phase_interval = 30 mekton_bulls_min_points = 100 mekton_bulls_max_points = 1000