7380161743
Version bump to 2.3.6. New game mode features 20×20 arena with central cannon obstacle, three escalating phases (Open Arena, Route Pressure, Survival), and collectible tiles (Hearts, Diamonds, Stars, Coins) with pattern-matching missions. Players dodge candy volleys while completing collection goals. Updated export paths and version strings across all platforms (Windows, Android, Web, Linux).
946 lines
30 KiB
GDScript
946 lines
30 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)
|
|
|
|
# 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
|
|
|
|
# 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)
|
|
|
|
# =============================================================================
|
|
# 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 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_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
|