994 lines
32 KiB
GDScript
994 lines
32 KiB
GDScript
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_cannon_interval_changed(interval: int)
|
|
signal gauntlet_volley_size_changed(size: 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_cannon_interval: int = 5
|
|
var gauntlet_volley_size: int = 5
|
|
|
|
# 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", "Candy Cannon Survival"]
|
|
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"]
|
|
"Candy Cannon Survival":
|
|
available_areas = ["Gauntlet 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_cannon_interval(interval: int) -> void:
|
|
gauntlet_cannon_interval = interval
|
|
if is_host: rpc("sync_gauntlet_cannon_interval", interval)
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_gauntlet_cannon_interval(interval: int) -> void:
|
|
gauntlet_cannon_interval = interval
|
|
emit_signal("gauntlet_cannon_interval_changed", interval)
|
|
|
|
func set_gauntlet_volley_size(size: int) -> void:
|
|
gauntlet_volley_size = size
|
|
if is_host: rpc("sync_gauntlet_volley_size", size)
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_gauntlet_volley_size(size: int) -> void:
|
|
gauntlet_volley_size = size
|
|
emit_signal("gauntlet_volley_size_changed", size)
|
|
|
|
# =============================================================================
|
|
# 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)
|
|
if selected_area not in available_areas:
|
|
set_area(available_areas[0])
|
|
|
|
@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)
|
|
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_cannon_interval", gauntlet_cannon_interval)
|
|
rpc("sync_gauntlet_volley_size", gauntlet_volley_size)
|
|
# 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_cannon_interval", gauntlet_cannon_interval)
|
|
rpc_id(requester_id, "sync_gauntlet_volley_size", gauntlet_volley_size)
|
|
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
|