feat: Implement Nakama serialization, Realtime API, and a comprehensive lobby system with UI and player management.

This commit is contained in:
Yogi Wiguna
2026-02-09 16:46:42 +08:00
parent a68878483f
commit bffa9474e9
7 changed files with 221 additions and 45 deletions
@@ -644,9 +644,7 @@ class UserPresence extends NakamaAsyncResult:
return NakamaSerializer.serialize(self) return NakamaSerializer.serialize(self)
func _to_string(): func _to_string():
if is_exception(): return get_exception()._to_string() return "UserPresence"
return "UserPresence<persistence=%s, session_id=%s, status=%s, username=%s, user_id=%s>" % [
persistence, session_id, status, username, user_id]
static func create(p_ns : GDScript, p_dict : Dictionary) -> UserPresence: static func create(p_ns : GDScript, p_dict : Dictionary) -> UserPresence:
return _safe_ret(NakamaSerializer.deserialize(p_ns, "UserPresence", p_dict), UserPresence) as UserPresence return _safe_ret(NakamaSerializer.deserialize(p_ns, "UserPresence", p_dict), UserPresence) as UserPresence
@@ -1,7 +1,20 @@
extends RefCounted extends RefCounted
class_name NakamaSerializer 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 out = {}
var schema = p_obj.get("_SCHEMA") var schema = p_obj.get("_SCHEMA")
if schema == null: if schema == null:
@@ -18,13 +31,13 @@ static func serialize(p_obj : Object) -> Dictionary:
var val_type = typeof(val) var val_type = typeof(val)
match val_type: match val_type:
TYPE_OBJECT: # Simple objects TYPE_OBJECT: # Simple objects
out[k] = serialize(val) out[k] = serialize(val, p_depth + 1)
TYPE_ARRAY: # Array of objects TYPE_ARRAY: # Array of objects
var arr = [] var arr = []
for e in val: for e in val:
if typeof(e) != TYPE_OBJECT: if typeof(e) != TYPE_OBJECT:
continue continue
arr.append(serialize(e)) arr.append(serialize(e, p_depth + 1))
out[k] = arr out[k] = arr
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_STRING_ARRAY: # Array of ints, bools, or strings TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_STRING_ARRAY: # Array of ints, bools, or strings
var arr = [] var arr = []
@@ -41,7 +54,7 @@ static func serialize(p_obj : Object) -> Dictionary:
for l in val: for l in val:
if typeof(val[l]) != TYPE_OBJECT: if typeof(val[l]) != TYPE_OBJECT:
continue continue
dict[l] = serialize(val[l]) dict[l] = serialize(val[l], p_depth + 1)
else: # Map of simple types else: # Map of simple types
for l in val: for l in val:
var e = val[l] var e = val[l]
+14
View File
@@ -166,6 +166,9 @@ func _ready():
NakamaManager.connected_to_nakama.connect(_on_connected_to_nakama) NakamaManager.connected_to_nakama.connect(_on_connected_to_nakama)
NakamaManager.connection_failed.connect(_on_connection_failed) NakamaManager.connection_failed.connect(_on_connection_failed)
# Connect UserProfileManager signals
UserProfileManager.profile_updated.connect(_on_profile_updated)
# Show main menu initially # Show main menu initially
_show_panel("main_menu") _show_panel("main_menu")
@@ -487,6 +490,17 @@ func _on_connection_failed(error_message: String) -> void:
connection_status.text = "Connection failed: %s" % error_message connection_status.text = "Connection failed: %s" % error_message
_show_panel("main_menu") _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 # Player Slot Updates
# ============================================================================= # =============================================================================
+1 -1
View File
@@ -394,8 +394,8 @@ custom_minimum_size = Vector2(90, 28)
layout_mode = 2 layout_mode = 2
theme_override_fonts/font = ExtResource("5_pc087") theme_override_fonts/font = ExtResource("5_pc087")
theme_override_font_sizes/font_size = 11 theme_override_font_sizes/font_size = 11
item_count = 3
selected = 0 selected = 0
item_count = 3
popup/item_0/text = "Normal" popup/item_0/text = "Normal"
popup/item_0/id = 0 popup/item_0/id = 0
popup/item_1/text = "Scarce" popup/item_1/text = "Scarce"
+107 -31
View File
@@ -370,6 +370,9 @@ func _setup_host_game():
if touch_controls: if touch_controls:
touch_controls.set_player(player_character) 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) # Spawn client players that joined via lobby (need to add them first)
var lobby_players = LobbyManager.get_players() var lobby_players = LobbyManager.get_players()
for lobby_player in lobby_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) player_character.add_to_group("Players", true)
GameStateManager.add_player(peer_id) 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 # Tell all clients to create this player
rpc("add_newly_connected_player_character", peer_id) rpc("add_newly_connected_player_character", peer_id)
@@ -529,43 +539,93 @@ func _start_game():
ui_manager.initialize_leaderboard_with_players(all_players) ui_manager.initialize_leaderboard_with_players(all_players)
func _assign_random_spawn_positions(): func _assign_random_spawn_positions():
"""Assign random unique spawn positions to all players.""" """Assign spawn positions distributed to 4 corners (2 per corner for 8 players)."""
# Fetch all valid walkable positions from the generated map
var valid_spawns = []
var enhanced_gridmap = $EnhancedGridMap var enhanced_gridmap = $EnhancedGridMap
if not enhanced_gridmap:
if enhanced_gridmap: return
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))
# Shuffle spawn locations # Lists for each quadrant
valid_spawns.shuffle() 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 # Get all players
var all_players = get_tree().get_nodes_in_group("Players") var all_players = get_tree().get_nodes_in_group("Players")
# Assign positions
var spawn_index = 0 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: for player in all_players:
if spawn_index >= valid_spawns.size(): var assigned_pos = Vector2i(-1, -1)
print("Critical: Not enough spawn points for players!")
break # Try to get from the current quadrant
var spawn_pos = valid_spawns[spawn_index] var quadrant_idx = spawn_index % 4
# Set position and sync to all clients var quadrant = quadrants[quadrant_idx]
player.current_position = spawn_pos
player.position = player.grid_to_world(spawn_pos) if quadrant.size() > 0:
player.spawn_point_selected = true assigned_pos = quadrant.pop_back()
player.rpc("set_spawn_position", spawn_pos) 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 spawn_index += 1
# ============================================================================= # =============================================================================
@@ -910,9 +970,11 @@ func request_full_player_sync(requesting_peer_id: int):
var player_data = { var player_data = {
"peer_id": peer_id, "peer_id": peer_id,
"position": player.current_position, "position": player.current_position,
"name": player.display_name,
"goals": player.goals, "goals": player.goals,
"playerboard": player.playerboard, "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) rpc_id(requesting_peer_id, "create_specific_player", player_data)
await get_tree().create_timer(0.1).timeout await get_tree().create_timer(0.1).timeout
@@ -933,6 +995,20 @@ func create_specific_player(data: Dictionary):
add_child(player_character) add_child(player_character)
player_character.add_to_group("Players", true) 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"]: if data["is_bot"]:
player_character.add_to_group("Bots", true) player_character.add_to_group("Bots", true)
player_character.is_bot = true player_character.is_bot = true
+50 -6
View File
@@ -13,7 +13,20 @@ var powerup_manager
var score: int = 0 var score: int = 0
# Display name (synced across network) # 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 # Special effect states
var is_frozen: bool = false var is_frozen: bool = false
@@ -262,6 +275,11 @@ func _ready():
rpc("sync_position", current_position) rpc("sync_position", current_position)
else: else:
target_visual_position = global_position 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(): func _init_managers():
movement_manager = load("res://scripts/managers/player_movement_manager.gd").new() 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") @rpc("any_peer", "call_local", "reliable")
func sync_display_name(new_name: String) -> void: func sync_display_name(new_name: String) -> void:
"""Sync display name across network.""" """Sync display name across network."""
display_name = new_name _display_name = new_name
$Name.text = display_name if has_node("Name"):
$Name.text = _display_name
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
func sync_modulate(color: Color) -> void: func sync_modulate(color: Color) -> void:
@@ -893,6 +912,11 @@ func activate_powerup(effect_id: int):
func _process(delta): 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(): if is_multiplayer_authority():
# Visual debugging - show display name with connection status # Visual debugging - show display name with connection status
$Name.text = display_name if not display_name.is_empty() else str(name) $Name.text = display_name if not display_name.is_empty() else str(name)
@@ -906,8 +930,11 @@ func _process(delta):
# Client-side visual smoothing # Client-side visual smoothing
# Only interpolate if NOT running a movement tween, OR if the drift is large (teleport/snap) # Only interpolate if NOT running a movement tween, OR if the drift is large (teleport/snap)
if not is_player_moving: if not is_player_moving:
# Snap to target if very close (prevents micro-jitter) var dist_sq = global_position.distance_squared_to(target_visual_position)
if global_position.distance_squared_to(target_visual_position) < 0.001:
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 global_position = target_visual_position
else: else:
# Interpolate towards the target position received from authority # 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 is_player_moving = true
if path.size() > 0: if path.size() > 0:
target_position = Vector2i(path[-1].x, path[-1].y) 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() var tween = create_tween()
tween.set_trans(Tween.TRANS_LINEAR) 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 step_duration = step_duration / movement_manager.speed_multiplier
for point in path: 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(): tween.tween_callback(func():
current_position = Vector2i(path[-1].x, path[-1].y) current_position = Vector2i(path[-1].x, path[-1].y)
@@ -1739,6 +1773,13 @@ func update_visual_position():
@rpc("any_peer", "call_local") @rpc("any_peer", "call_local")
func sync_position(pos: Vector2i): func sync_position(pos: Vector2i):
current_position = pos 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 # Always update the visual position after position sync
var new_pos = Vector3( var new_pos = Vector3(
current_position.x * cell_size.x + cell_size.x * 0.5, 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 global_position = new_pos
target_visual_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") @rpc("any_peer", "call_local", "reliable")
+31
View File
@@ -277,6 +277,37 @@ func sync_character(player_id: int, character_name: String) -> void:
emit_signal("character_changed", player_id, character_name) emit_signal("character_changed", player_id, character_name)
emit_signal("player_list_changed") 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) # Area Selection (Host Only)
# ============================================================================= # =============================================================================