feat: Implement Nakama serialization, Realtime API, and a comprehensive lobby system with UI and player management.
This commit is contained in:
@@ -644,9 +644,7 @@ class UserPresence extends NakamaAsyncResult:
|
||||
return NakamaSerializer.serialize(self)
|
||||
|
||||
func _to_string():
|
||||
if is_exception(): return get_exception()._to_string()
|
||||
return "UserPresence<persistence=%s, session_id=%s, status=%s, username=%s, user_id=%s>" % [
|
||||
persistence, session_id, status, username, user_id]
|
||||
return "UserPresence"
|
||||
|
||||
static func create(p_ns : GDScript, p_dict : Dictionary) -> UserPresence:
|
||||
return _safe_ret(NakamaSerializer.deserialize(p_ns, "UserPresence", p_dict), UserPresence) as UserPresence
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
extends RefCounted
|
||||
class_name NakamaSerializer
|
||||
|
||||
static func serialize(p_obj : Object) -> Dictionary:
|
||||
static var _global_depth := 0
|
||||
|
||||
static func serialize(p_obj : Object, p_depth : int = 0) -> Dictionary:
|
||||
_global_depth += 1
|
||||
if _global_depth > 64:
|
||||
_global_depth -= 1
|
||||
printerr("NakamaSerializer: Global recursion limit reached!")
|
||||
return {}
|
||||
|
||||
var result = _serialize_impl(p_obj, p_depth)
|
||||
_global_depth -= 1
|
||||
return result
|
||||
|
||||
static func _serialize_impl(p_obj : Object, p_depth : int) -> Dictionary:
|
||||
var out = {}
|
||||
var schema = p_obj.get("_SCHEMA")
|
||||
if schema == null:
|
||||
@@ -18,13 +31,13 @@ static func serialize(p_obj : Object) -> Dictionary:
|
||||
var val_type = typeof(val)
|
||||
match val_type:
|
||||
TYPE_OBJECT: # Simple objects
|
||||
out[k] = serialize(val)
|
||||
out[k] = serialize(val, p_depth + 1)
|
||||
TYPE_ARRAY: # Array of objects
|
||||
var arr = []
|
||||
for e in val:
|
||||
if typeof(e) != TYPE_OBJECT:
|
||||
continue
|
||||
arr.append(serialize(e))
|
||||
arr.append(serialize(e, p_depth + 1))
|
||||
out[k] = arr
|
||||
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_STRING_ARRAY: # Array of ints, bools, or strings
|
||||
var arr = []
|
||||
@@ -41,7 +54,7 @@ static func serialize(p_obj : Object) -> Dictionary:
|
||||
for l in val:
|
||||
if typeof(val[l]) != TYPE_OBJECT:
|
||||
continue
|
||||
dict[l] = serialize(val[l])
|
||||
dict[l] = serialize(val[l], p_depth + 1)
|
||||
else: # Map of simple types
|
||||
for l in val:
|
||||
var e = val[l]
|
||||
|
||||
@@ -166,6 +166,9 @@ func _ready():
|
||||
NakamaManager.connected_to_nakama.connect(_on_connected_to_nakama)
|
||||
NakamaManager.connection_failed.connect(_on_connection_failed)
|
||||
|
||||
# Connect UserProfileManager signals
|
||||
UserProfileManager.profile_updated.connect(_on_profile_updated)
|
||||
|
||||
# Show main menu initially
|
||||
_show_panel("main_menu")
|
||||
|
||||
@@ -487,6 +490,17 @@ func _on_connection_failed(error_message: String) -> void:
|
||||
connection_status.text = "Connection failed: %s" % error_message
|
||||
_show_panel("main_menu")
|
||||
|
||||
func _on_profile_updated() -> void:
|
||||
"""Handle profile updates (name/avatar change)."""
|
||||
var new_name = UserProfileManager.get_display_name()
|
||||
|
||||
# Update input if visible
|
||||
if player_name_input:
|
||||
player_name_input.text = new_name
|
||||
|
||||
# Sync to LobbyManager if we are in a room or just locally
|
||||
LobbyManager.set_player_name(new_name)
|
||||
|
||||
# =============================================================================
|
||||
# Player Slot Updates
|
||||
# =============================================================================
|
||||
|
||||
+1
-1
@@ -394,8 +394,8 @@ custom_minimum_size = Vector2(90, 28)
|
||||
layout_mode = 2
|
||||
theme_override_fonts/font = ExtResource("5_pc087")
|
||||
theme_override_font_sizes/font_size = 11
|
||||
item_count = 3
|
||||
selected = 0
|
||||
item_count = 3
|
||||
popup/item_0/text = "Normal"
|
||||
popup/item_0/id = 0
|
||||
popup/item_1/text = "Scarce"
|
||||
|
||||
+107
-31
@@ -370,6 +370,9 @@ func _setup_host_game():
|
||||
if touch_controls:
|
||||
touch_controls.set_player(player_character)
|
||||
|
||||
# Set host name
|
||||
player_character.display_name = LobbyManager.local_player_name
|
||||
|
||||
# Spawn client players that joined via lobby (need to add them first)
|
||||
var lobby_players = LobbyManager.get_players()
|
||||
for lobby_player in lobby_players:
|
||||
@@ -429,6 +432,13 @@ func _spawn_lobby_client_sync(peer_id: int):
|
||||
player_character.add_to_group("Players", true)
|
||||
GameStateManager.add_player(peer_id)
|
||||
|
||||
# Set name from LobbyManager data if available
|
||||
var lobby_players = LobbyManager.get_players()
|
||||
for p_data in lobby_players:
|
||||
if p_data.get("id") == peer_id:
|
||||
player_character.display_name = p_data.get("name", "Player")
|
||||
break
|
||||
|
||||
# Tell all clients to create this player
|
||||
rpc("add_newly_connected_player_character", peer_id)
|
||||
|
||||
@@ -529,43 +539,93 @@ func _start_game():
|
||||
ui_manager.initialize_leaderboard_with_players(all_players)
|
||||
|
||||
func _assign_random_spawn_positions():
|
||||
"""Assign random unique spawn positions to all players."""
|
||||
# Fetch all valid walkable positions from the generated map
|
||||
var valid_spawns = []
|
||||
"""Assign spawn positions distributed to 4 corners (2 per corner for 8 players)."""
|
||||
var enhanced_gridmap = $EnhancedGridMap
|
||||
|
||||
if enhanced_gridmap:
|
||||
for x in range(enhanced_gridmap.columns):
|
||||
for z in range(enhanced_gridmap.rows):
|
||||
var ground = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z))
|
||||
if ground == 0: # Walkable
|
||||
valid_spawns.append(Vector2i(x, z))
|
||||
|
||||
# Fallback if map generation failed or is empty
|
||||
if valid_spawns.size() < 12:
|
||||
print("Warning: Low spawn count! Adding defaults.")
|
||||
for i in range(12):
|
||||
if not Vector2i(0, i) in valid_spawns:
|
||||
valid_spawns.append(Vector2i(0, i))
|
||||
if not enhanced_gridmap:
|
||||
return
|
||||
|
||||
# Shuffle spawn locations
|
||||
valid_spawns.shuffle()
|
||||
# Lists for each quadrant
|
||||
var spawns_TL = [] # Top-Left
|
||||
var spawns_TR = [] # Top-Right
|
||||
var spawns_BL = [] # Bottom-Left
|
||||
var spawns_BR = [] # Bottom-Right
|
||||
var all_spawns = [] # Fallback
|
||||
|
||||
var mid_x = enhanced_gridmap.columns / 2
|
||||
var mid_z = enhanced_gridmap.rows / 2
|
||||
|
||||
for x in range(enhanced_gridmap.columns):
|
||||
for z in range(enhanced_gridmap.rows):
|
||||
var ground = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z))
|
||||
if ground == 0: # Walkable
|
||||
var pos = Vector2i(x, z)
|
||||
all_spawns.append(pos)
|
||||
|
||||
if x < mid_x and z < mid_z:
|
||||
spawns_TL.append(pos)
|
||||
elif x >= mid_x and z < mid_z:
|
||||
spawns_TR.append(pos)
|
||||
elif x < mid_x and z >= mid_z:
|
||||
spawns_BL.append(pos)
|
||||
else:
|
||||
spawns_BR.append(pos)
|
||||
|
||||
# Sort lists by distance to corners (closest to corner should be last, to be popped first)
|
||||
# TL: Close to (0,0) -> Sort descending distance (so closest is at end)
|
||||
spawns_TL.sort_custom(func(a, b): return a.length_squared() > b.length_squared())
|
||||
|
||||
# TR: Close to (13, 0)
|
||||
var tr_corner = Vector2i(enhanced_gridmap.columns - 1, 0)
|
||||
spawns_TR.sort_custom(func(a, b): return a.distance_squared_to(tr_corner) > b.distance_squared_to(tr_corner))
|
||||
|
||||
# BL: Close to (0, 13)
|
||||
var bl_corner = Vector2i(0, enhanced_gridmap.rows - 1)
|
||||
spawns_BL.sort_custom(func(a, b): return a.distance_squared_to(bl_corner) > b.distance_squared_to(bl_corner))
|
||||
|
||||
# BR: Close to (13, 13)
|
||||
var br_corner = Vector2i(enhanced_gridmap.columns - 1, enhanced_gridmap.rows - 1)
|
||||
spawns_BR.sort_custom(func(a, b): return a.distance_squared_to(br_corner) > b.distance_squared_to(br_corner))
|
||||
|
||||
# Fallback shuffle
|
||||
all_spawns.shuffle()
|
||||
|
||||
# Get all players
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
|
||||
# Assign positions
|
||||
var spawn_index = 0
|
||||
|
||||
# Round-robin assignment to corners: TL, TR, BR, BL, TL, TR, BR, BL...
|
||||
# Order: TL -> TR -> BR -> BL (Clockwise-ish)
|
||||
var quadrants = [spawns_TL, spawns_TR, spawns_BR, spawns_BL]
|
||||
|
||||
for player in all_players:
|
||||
if spawn_index >= valid_spawns.size():
|
||||
print("Critical: Not enough spawn points for players!")
|
||||
break
|
||||
var spawn_pos = valid_spawns[spawn_index]
|
||||
# Set position and sync to all clients
|
||||
player.current_position = spawn_pos
|
||||
player.position = player.grid_to_world(spawn_pos)
|
||||
player.spawn_point_selected = true
|
||||
player.rpc("set_spawn_position", spawn_pos)
|
||||
var assigned_pos = Vector2i(-1, -1)
|
||||
|
||||
# Try to get from the current quadrant
|
||||
var quadrant_idx = spawn_index % 4
|
||||
var quadrant = quadrants[quadrant_idx]
|
||||
|
||||
if quadrant.size() > 0:
|
||||
assigned_pos = quadrant.pop_back()
|
||||
else:
|
||||
# Fallback: Try other quadrants if preferred one is empty
|
||||
for q in quadrants:
|
||||
if q.size() > 0:
|
||||
assigned_pos = q.pop_back()
|
||||
break
|
||||
|
||||
# Ultimate fallback: Random from anywhere
|
||||
if assigned_pos == Vector2i(-1, -1) and all_spawns.size() > 0:
|
||||
assigned_pos = all_spawns.pop_back()
|
||||
|
||||
if assigned_pos != Vector2i(-1, -1):
|
||||
# Set position and sync to all clients
|
||||
player.current_position = assigned_pos
|
||||
player.position = player.grid_to_world(assigned_pos)
|
||||
player.spawn_point_selected = true
|
||||
player.rpc("set_spawn_position", assigned_pos)
|
||||
else:
|
||||
print("Critical: No spawn point found for player ", player.name)
|
||||
|
||||
spawn_index += 1
|
||||
|
||||
# =============================================================================
|
||||
@@ -910,9 +970,11 @@ func request_full_player_sync(requesting_peer_id: int):
|
||||
var player_data = {
|
||||
"peer_id": peer_id,
|
||||
"position": player.current_position,
|
||||
"name": player.display_name,
|
||||
"goals": player.goals,
|
||||
"playerboard": player.playerboard,
|
||||
"is_bot": player.is_bot || player.is_in_group("Bots")
|
||||
"is_bot": player.is_bot || player.is_in_group("Bots"),
|
||||
"spawn_point_selected": player.spawn_point_selected
|
||||
}
|
||||
rpc_id(requesting_peer_id, "create_specific_player", player_data)
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
@@ -933,6 +995,20 @@ func create_specific_player(data: Dictionary):
|
||||
add_child(player_character)
|
||||
player_character.add_to_group("Players", true)
|
||||
|
||||
# Set spawn flag directly so it doesn't hide itself
|
||||
if data.has("spawn_point_selected") and data["spawn_point_selected"]:
|
||||
player_character.spawn_point_selected = true
|
||||
player_character.visible = true
|
||||
|
||||
# Ensure visual position matches logical
|
||||
var new_pos = player_character.grid_to_world(data["position"])
|
||||
player_character.global_position = new_pos
|
||||
player_character.target_visual_position = new_pos
|
||||
|
||||
# Set display name
|
||||
if data.has("name"):
|
||||
player_character.display_name = data["name"]
|
||||
|
||||
if data["is_bot"]:
|
||||
player_character.add_to_group("Bots", true)
|
||||
player_character.is_bot = true
|
||||
|
||||
+50
-6
@@ -13,7 +13,20 @@ var powerup_manager
|
||||
var score: int = 0
|
||||
|
||||
# Display name (synced across network)
|
||||
var display_name: String = ""
|
||||
var _display_name: String = ""
|
||||
var display_name: String:
|
||||
set(value):
|
||||
_display_name = value
|
||||
# Update label if it exists
|
||||
var name_label = get_node_or_null("Name")
|
||||
if name_label:
|
||||
name_label.text = _display_name
|
||||
|
||||
# Sync to other peers if we are authority
|
||||
if is_multiplayer_authority() and is_inside_tree():
|
||||
rpc("sync_display_name", _display_name)
|
||||
get:
|
||||
return _display_name
|
||||
|
||||
# Special effect states
|
||||
var is_frozen: bool = false
|
||||
@@ -262,6 +275,11 @@ func _ready():
|
||||
rpc("sync_position", current_position)
|
||||
else:
|
||||
target_visual_position = global_position
|
||||
# If random spawn is enabled, do NOT broadcast (0,0). Wait for set_spawn_position.
|
||||
|
||||
# Prevent visual "jump" by hiding until spawn position is confirmed
|
||||
if LobbyManager.get_randomize_spawn() and not spawn_point_selected:
|
||||
visible = false
|
||||
|
||||
func _init_managers():
|
||||
movement_manager = load("res://scripts/managers/player_movement_manager.gd").new()
|
||||
@@ -588,8 +606,9 @@ func sync_bot_status(is_bot_status: bool):
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func sync_display_name(new_name: String) -> void:
|
||||
"""Sync display name across network."""
|
||||
display_name = new_name
|
||||
$Name.text = display_name
|
||||
_display_name = new_name
|
||||
if has_node("Name"):
|
||||
$Name.text = _display_name
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func sync_modulate(color: Color) -> void:
|
||||
@@ -893,6 +912,11 @@ func activate_powerup(effect_id: int):
|
||||
|
||||
|
||||
func _process(delta):
|
||||
# Failsafe: Ensure player is visible if spawn point is selected and random spawn is active
|
||||
# This handles race conditions where set_spawn_position might be called before _ready finishes hiding
|
||||
if LobbyManager.get_randomize_spawn() and spawn_point_selected and not visible:
|
||||
visible = true
|
||||
|
||||
if is_multiplayer_authority():
|
||||
# Visual debugging - show display name with connection status
|
||||
$Name.text = display_name if not display_name.is_empty() else str(name)
|
||||
@@ -906,8 +930,11 @@ func _process(delta):
|
||||
# Client-side visual smoothing
|
||||
# Only interpolate if NOT running a movement tween, OR if the drift is large (teleport/snap)
|
||||
if not is_player_moving:
|
||||
# Snap to target if very close (prevents micro-jitter)
|
||||
if global_position.distance_squared_to(target_visual_position) < 0.001:
|
||||
var dist_sq = global_position.distance_squared_to(target_visual_position)
|
||||
|
||||
if dist_sq > 4.0: # If distance > 2.0 units (teleport/spawn), snap immediately
|
||||
global_position = target_visual_position
|
||||
elif dist_sq < 0.001: # Prevent micro-jitter
|
||||
global_position = target_visual_position
|
||||
else:
|
||||
# Interpolate towards the target position received from authority
|
||||
@@ -1157,6 +1184,12 @@ func start_movement_along_path(path: Array, clear_visual: bool = true):
|
||||
is_player_moving = true
|
||||
if path.size() > 0:
|
||||
target_position = Vector2i(path[-1].x, path[-1].y)
|
||||
|
||||
# FORCE SNAP START: Ensure we start animating from our actual grid position
|
||||
# This prevents "jumps" if the visual node drifted or was set via global_position vs position mismatch
|
||||
var start_world_pos = grid_to_world(current_position)
|
||||
if global_position.distance_squared_to(start_world_pos) > 0.001:
|
||||
global_position = start_world_pos
|
||||
|
||||
var tween = create_tween()
|
||||
tween.set_trans(Tween.TRANS_LINEAR)
|
||||
@@ -1167,7 +1200,8 @@ func start_movement_along_path(path: Array, clear_visual: bool = true):
|
||||
step_duration = step_duration / movement_manager.speed_multiplier
|
||||
|
||||
for point in path:
|
||||
tween.tween_property(self, "position", grid_to_world(Vector2i(point.x, point.y)), step_duration)
|
||||
# Use global_position for consistency
|
||||
tween.tween_property(self, "global_position", grid_to_world(Vector2i(point.x, point.y)), step_duration)
|
||||
|
||||
tween.tween_callback(func():
|
||||
current_position = Vector2i(path[-1].x, path[-1].y)
|
||||
@@ -1739,6 +1773,13 @@ func update_visual_position():
|
||||
@rpc("any_peer", "call_local")
|
||||
func sync_position(pos: Vector2i):
|
||||
current_position = pos
|
||||
|
||||
# If random spawn is active, receiving a position sync essentially confirms a valid spawn
|
||||
if LobbyManager.get_randomize_spawn():
|
||||
spawn_point_selected = true
|
||||
if not visible:
|
||||
visible = true
|
||||
|
||||
# Always update the visual position after position sync
|
||||
var new_pos = Vector3(
|
||||
current_position.x * cell_size.x + cell_size.x * 0.5,
|
||||
@@ -1767,6 +1808,9 @@ func set_spawn_position(pos: Vector2i):
|
||||
|
||||
global_position = new_pos
|
||||
target_visual_position = new_pos
|
||||
|
||||
# Reveal character now that it's in the correct position
|
||||
visible = true
|
||||
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
|
||||
@@ -277,6 +277,37 @@ func sync_character(player_id: int, character_name: String) -> void:
|
||||
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
|
||||
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():
|
||||
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)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user