feat: implement core lobby management system with Nakama integration, player state, and game settings.
This commit is contained in:
@@ -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")
|
||||
|
||||
+106
-6
@@ -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"
|
||||
|
||||
+7
-1
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user