feat: update player auth, fix bugs

This commit is contained in:
2026-04-09 00:36:23 +08:00
parent 7ffe7680ad
commit 222621139e
9 changed files with 118 additions and 36 deletions
@@ -14,7 +14,7 @@
[ext_resource type="Texture2D" uid="uid://dpkx1a780pvwv" path="res://assets/textures/tile_diamond.png" id="10_sx8rm"]
[ext_resource type="BoxMesh" uid="uid://fy4bhoeii40c" path="res://addons/enhanced_gridmap/meshlibrary/tile_safe_zone.tres" id="10_uwjsj"]
[ext_resource type="BoxMesh" uid="uid://b5cc3prem52r6" path="res://addons/enhanced_gridmap/meshlibrary/tile_freeze.tres" id="11_pgnbl"]
[ext_resource type="BoxMesh" path="res://addons/enhanced_gridmap/meshlibrary/tile_non_walkable.tres" id="11_uwjsj"]
[ext_resource type="BoxMesh" uid="uid://dcjdwbffgtutt" path="res://addons/enhanced_gridmap/meshlibrary/tile_non_walkable.tres" id="11_uwjsj"]
[sub_resource type="CompressedTexture2D" id="CompressedTexture2D_5d0gc"]
load_path = "res://.godot/imported/tile_heart.png-deeef50755ca225f028608dfd16900e6.s3tc.ctex"
@@ -1,6 +1,6 @@
[gd_resource type="PanoramaSkyMaterial" format=3 uid="uid://bdwxlbar41mxw"]
[ext_resource type="Texture2D" uid="uid://didk5l287jms7" path="res://assets/levels/level_water/sky_sea_01.png" id="1_f0k4a"]
[ext_resource type="Texture2D" uid="uid://dep1ng3aqb2jw" path="res://assets/models/arena/free_mode/sky_sea_01.png" id="1_f0k4a"]
[resource]
panorama = ExtResource("1_f0k4a")
@@ -1,4 +1,4 @@
[gd_resource type="CompressedTexture2D" format=3 uid="uid://b41afev4wfdrf"]
[resource]
load_path = "res://.godot/imported/tiles_slot.png-6771785cbdb9e12b6d9f880d4743aecf.ctex"
load_path = "res://.godot/imported/tiles_slot.png-e539385cf7c9354d657e81e23ace6c2b.s3tc.ctex"
+4 -2
View File
@@ -65,6 +65,7 @@ text = "← BACK"
[node name="RefreshBtn" type="Button" parent="MainLayout/LeftPanel/LeftVBox/Header" unique_id=993543919]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(44, 44)
layout_mode = 2
tooltip_text = "Refresh Data"
@@ -72,10 +73,11 @@ text = "⟳"
[node name="SyncBtn" type="Button" parent="MainLayout/LeftPanel/LeftVBox/Header" unique_id=1452457095]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 44)
custom_minimum_size = Vector2(160, 44)
layout_mode = 2
tooltip_text = "Sync your score to the global leaderboard"
text = "↑ Sync Score"
text = "⟳ Refresh
"
[node name="Title" type="Label" parent="MainLayout/LeftPanel/LeftVBox/Header" unique_id=1037998429]
layout_mode = 2
+2 -2
View File
@@ -105,9 +105,9 @@ func _save_session(session: NakamaSession, mode: AuthMode) -> void:
func clear_session() -> void:
if FileAccess.file_exists(SESSION_FILE):
DirAccess.remove_absolute(ProjectSettings.globalize_path(SESSION_FILE))
DirAccess.remove_absolute(SESSION_FILE)
if FileAccess.file_exists(CREDENTIALS_FILE):
DirAccess.remove_absolute(ProjectSettings.globalize_path(CREDENTIALS_FILE))
DirAccess.remove_absolute(CREDENTIALS_FILE)
# =============================================================================
# Guest Authentication
+19 -7
View File
@@ -44,6 +44,11 @@ func _on_logged_out() -> void:
# =============================================================================
func load_profile() -> Dictionary:
# Reset state first to ensure no old account data carries over
profile = {}
stats = {}
is_profile_loaded = false
if not NakamaManager.session:
push_error("[UserProfileManager] No session available")
return {}
@@ -89,11 +94,14 @@ func load_profile() -> Dictionary:
# Auto-sync existing score to native Nakama leaderboard in background
if stats.get("high_score", 0) > 0 and NakamaManager.session and not AuthManager.is_guest:
_submit_to_leaderboard.call_deferred()
submit_to_leaderboard.call_deferred()
return profile
func load_stats() -> Dictionary:
# Reset stats first to ensure fresh data for new logins
stats = {}
if not NakamaManager.session:
return {}
@@ -104,12 +112,16 @@ func load_stats() -> Dictionary:
[NakamaStorageObjectId.new(STATS_COLLECTION, "game_stats", user_id)]
)
print("[UserProfileManager] Loading stats for user_id: ", user_id)
if not storage_result.is_exception() and storage_result.objects.size() > 0:
var stored_data = JSON.parse_string(storage_result.objects[0].value)
if stored_data:
stats = stored_data
print("[UserProfileManager] Stats loaded from Nakama: ", stats)
else:
# Initialize default stats
print("[UserProfileManager] No stats found in Nakama, creating defaults")
stats = {
"games_played": 0,
"games_won": 0,
@@ -191,7 +203,7 @@ func update_avatar(avatar_index: int) -> bool:
# Immediately update leaderboard with new avatar
if stats.get("high_score", 0) > 0:
_submit_to_leaderboard.call_deferred()
submit_to_leaderboard.call_deferred()
return success
@@ -277,20 +289,20 @@ func record_game_result(won: bool, score: int) -> void:
await update_stats(stats)
# Also submit to Nakama native leaderboard so global_high_score is populated
await _submit_to_leaderboard()
await submit_to_leaderboard()
func _submit_to_leaderboard() -> void:
func submit_to_leaderboard() -> void:
"""Submits the current high_score via server RPC (required for authoritative leaderboards)."""
if not NakamaManager.session:
return
if stats.get("high_score", 0) <= 0:
return
# We allow syncing even with 0 score, so the player appears on the leaderboard with their avatar/loadout
var payload = JSON.stringify({
"score": int(stats.get("high_score", 0)),
"games_played": int(stats.get("games_played", 0)),
"games_won": int(stats.get("games_won", 0)),
"avatar_url": profile.get("avatar_url", "")
"avatar_url": profile.get("avatar_url", ""),
"loadout_character": profile.get("loadout_character", "Copper")
})
var result = await NakamaManager.client.rpc_async(
+37 -11
View File
@@ -27,6 +27,7 @@ signal closed
# -------------------------------------------------------------------------
var leaderboard_data: Array = []
var current_sort_key: String = "high_score"
var current_selected_index: int = 0
var _anim_player: AnimationPlayer
# Maps game character name -> GLB node name in the SubViewport
@@ -43,6 +44,7 @@ func _ready() -> void:
back_btn.pressed.connect(_on_close_pressed)
refresh_btn.pressed.connect(_fetch_leaderboard_data)
sync_btn.pressed.connect(_on_sync_pressed)
sort_score_btn.pressed.connect(func(): _sort_by("high_score"))
sort_win_rate_btn.pressed.connect(func(): _sort_by("win_rate"))
sort_games_btn.pressed.connect(func(): _sort_by("games_played"))
@@ -74,7 +76,7 @@ func _on_sync_pressed() -> void:
status_label.text = "Must be logged in to sync"
return
status_label.text = "Syncing your score..."
await UserProfileManager._submit_to_leaderboard()
await UserProfileManager.submit_to_leaderboard()
status_label.text = "Synced! Refreshing..."
await get_tree().create_timer(0.5).timeout
_fetch_leaderboard_data()
@@ -126,11 +128,14 @@ func _fetch_native_leaderboard() -> Array:
var parsed = JSON.parse_string(record.metadata)
if parsed is Dictionary:
meta = parsed
if record.owner_id == NakamaManager.session.user_id:
print("[Leaderboard] Local player meta: ", meta)
data.append({
"user_id": record.owner_id,
"display_name": record.username if (record.username and not record.username.is_empty()) else "Unknown",
"avatar_url": meta.get("avatar_url", ""),
"loadout_character": meta.get("loadout_character", "Copper"),
"high_score": int(record.score),
"games_played": int(meta.get("games_played", 0)),
"games_won": int(meta.get("games_won", 0)),
@@ -174,6 +179,7 @@ func _calculate_win_rates() -> void:
# -------------------------------------------------------------------------
func _sort_by(key: String) -> void:
current_sort_key = key
current_selected_index = 0
_update_tab_visuals()
leaderboard_data.sort_custom(func(a, b): return a.get(key, 0) > b.get(key, 0))
_populate_list()
@@ -184,6 +190,11 @@ func _update_tab_visuals() -> void:
sort_score_btn.add_theme_color_override("font_color", color_active if current_sort_key == "high_score" else color_inactive)
sort_win_rate_btn.add_theme_color_override("font_color", color_active if current_sort_key == "win_rate" else color_inactive)
sort_games_btn.add_theme_color_override("font_color", color_active if current_sort_key == "games_played" else color_inactive)
# Make the background of unselected tabs visibly darker via modulate so they contrast against the active yellow background
sort_score_btn.self_modulate = Color(1, 1, 1) if current_sort_key == "high_score" else Color(0.5, 0.5, 0.5)
sort_win_rate_btn.self_modulate = Color(1, 1, 1) if current_sort_key == "win_rate" else Color(0.5, 0.5, 0.5)
sort_games_btn.self_modulate = Color(1, 1, 1) if current_sort_key == "games_played" else Color(0.5, 0.5, 0.5)
func _populate_list() -> void:
for child in leaderboard_list.get_children():
@@ -200,9 +211,15 @@ func _populate_list() -> void:
func _create_leaderboard_item(rank: int, entry: Dictionary, index: int) -> void:
var item = PanelContainer.new()
var style = StyleBoxFlat.new()
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
if rank <= 3:
if index == current_selected_index:
# Highlight color for the currently selected player row
style.bg_color = Color(0.25, 0.35, 0.20, 1.0)
elif rank <= 3:
style.bg_color = Color(0.2, 0.2, 0.15, 1.0)
else:
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
style.set_corner_radius_all(4)
style.content_margin_left = 10
style.content_margin_right = 10
@@ -229,9 +246,13 @@ func _create_leaderboard_item(rank: int, entry: Dictionary, index: int) -> void:
var avatar_rect = TextureRect.new()
avatar_rect.custom_minimum_size = Vector2(32, 32)
avatar_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
var avatar_url = entry.get("avatar_url", UserProfileManager.AVATARS[0])
var avatar_url = entry.get("avatar_url", "")
if avatar_url.is_empty() or not ResourceLoader.exists(avatar_url):
if not avatar_url.is_empty():
print("[Leaderboard] Avatar URL not found or invalid: ", avatar_url)
avatar_url = UserProfileManager.AVATARS[0]
avatar_rect.texture = load(avatar_url)
hbox.add_child(avatar_rect)
@@ -259,6 +280,8 @@ func _create_leaderboard_item(rank: int, entry: Dictionary, index: int) -> void:
# Make row clickable to update 3D preview
item.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
current_selected_index = index
_populate_list() # Re-draw list to apply the new active highlight colors visually
_show_entry_preview(index)
)
item.mouse_filter = Control.MOUSE_FILTER_STOP
@@ -276,13 +299,16 @@ func _show_entry_preview(index: int) -> void:
return
var entry = leaderboard_data[index]
# Determine character from avatar_url index
var avatar_url: String = entry.get("avatar_url", "")
var char_name := "Copper"
for i in range(UserProfileManager.AVATARS.size()):
if UserProfileManager.AVATARS[i] == avatar_url:
char_name = AVATAR_TO_CHAR[i] if i < AVATAR_TO_CHAR.size() else "Copper"
break
# Determine character from metadata or avatar_url index
var char_name: String = entry.get("loadout_character", "")
if char_name.is_empty():
var avatar_url: String = entry.get("avatar_url", "")
char_name = "Copper"
for i in range(UserProfileManager.AVATARS.size()):
if UserProfileManager.AVATARS[i] == avatar_url:
char_name = AVATAR_TO_CHAR[i] if i < AVATAR_TO_CHAR.size() else "Copper"
break
_update_3d_preview(char_name)
+8 -1
View File
@@ -80,6 +80,7 @@ func _connect_signals() -> void:
char_right_btn.pressed.connect(func(): _cycle_loadout_char(1))
set_default_btn.pressed.connect(_on_set_default_pressed)
UserProfileManager.profile_loaded.connect(func(p): _on_profile_updated())
UserProfileManager.profile_updated.connect(_on_profile_updated)
UserProfileManager.profile_update_failed.connect(_on_profile_update_failed)
@@ -172,6 +173,8 @@ func _on_set_default_pressed() -> void:
_refresh_loadout_ui()
# Persist to storage
_save_loadout_to_profile()
# Sync to leaderboard immediately
UserProfileManager.submit_to_leaderboard()
func _save_loadout_to_profile() -> void:
"""Save loadout_character field to Nakama profile storage."""
@@ -336,7 +339,7 @@ func _setup_account_settings_ui() -> void:
reset_stats_btn.pressed.connect(func():
var conf = ConfirmationDialog.new()
conf.dialog_text = "Are you SURE you want to irreversibly wipe all your stats to 0?"
add_child(conf)
acc_settings_dialog.add_child(conf)
conf.popup_centered()
conf.confirmed.connect(func():
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "reset_stats", "{}")
@@ -376,6 +379,10 @@ func _on_close_pressed() -> void:
emit_signal("closed")
func show_panel() -> void:
if not UserProfileManager.is_profile_loaded:
status_label.text = "Loading profile from server..."
status_label.add_theme_color_override("font_color", Color.YELLOW)
_load_profile_data()
_load_loadout()
_check_admin_visibility()
+45 -10
View File
@@ -468,6 +468,7 @@ function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
username: record.username,
display_name: record.displayName || record.username,
avatar_url: metadata.avatar_url || "",
loadout_character: metadata.loadout_character || "Copper",
high_score: record.score || 0,
games_played: metadata.games_played || 0,
games_won: metadata.games_won || 0
@@ -492,7 +493,8 @@ function rpcSubmitScore(ctx, logger, nk, payload) {
var metadata = {
games_played: request.games_played || 0,
games_won: request.games_won || 0,
avatar_url: account.user.avatarUrl || request.avatar_url || ""
avatar_url: account.user.avatarUrl || request.avatar_url || "",
loadout_character: request.loadout_character || "Copper"
};
nk.leaderboardRecordWrite(
"global_high_score",
@@ -523,7 +525,6 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
var userId = obj.userId;
var value;
try {
// obj.value may already be an object or a string depending on Nakama version
value = (typeof obj.value === "string") ? JSON.parse(obj.value) : obj.value;
} catch (e) { continue; }
if (!value) continue;
@@ -533,13 +534,43 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
high_score: value.high_score || 0,
games_played: value.games_played || 0,
games_won: value.games_won || 0,
avatar_url: value.avatar_url || ""
avatar_url: value.avatar_url || "",
loadout_character: value.loadout_character || ""
};
} else {
userGroup[userId].high_score = Math.max(userGroup[userId].high_score, value.high_score || 0);
userGroup[userId].games_played = Math.max(userGroup[userId].games_played, value.games_played || 0);
userGroup[userId].games_won = Math.max(userGroup[userId].games_won, value.games_won || 0);
}
// Prioritize avatar and character from game_stats or if current is empty
if (obj.key === "game_stats" || !userGroup[userId].avatar_url) {
if (value.avatar_url) userGroup[userId].avatar_url = value.avatar_url;
if (value.loadout_character) userGroup[userId].loadout_character = value.loadout_character;
}
}
// Phase 2: Read profiles collection to get loadout and avatars!
var profileResult = nk.storageList(null, "profiles", 100, "");
var profileObjects = profileResult.objects || [];
for (var i = 0; i < profileObjects.length; i++) {
var obj = profileObjects[i];
var userId = obj.userId;
if (obj.key !== "profile") continue;
var value;
try {
value = (typeof obj.value === "string") ? JSON.parse(obj.value) : obj.value;
} catch (e) { continue; }
if (!userGroup[userId]) {
userGroup[userId] = {
high_score: 0, games_played: 0, games_won: 0,
avatar_url: "", loadout_character: ""
};
}
// If the profile has avatar or loadout, merge them. Natively preferred over empty
if (value.avatar_url && !userGroup[userId].avatar_url) userGroup[userId].avatar_url = value.avatar_url;
if (value.loadout_character && !userGroup[userId].loadout_character) userGroup[userId].loadout_character = value.loadout_character;
}
var count = 0;
@@ -551,7 +582,8 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
var meta = {
games_played: stats.games_played || 0,
games_won: stats.games_won || 0,
avatar_url: stats.avatar_url || account.user.avatarUrl || ""
avatar_url: stats.avatar_url || account.user.avatarUrl || "res://assets/graphics/character_selection/sc_characters/sc_copper.png",
loadout_character: stats.loadout_character || "Copper"
};
nk.leaderboardRecordWrite("global_high_score", uid, account.user.username, stats.high_score, 0, meta);
count++;
@@ -724,10 +756,11 @@ function rpcAdminUpdateStats(ctx, logger, nk, payload) {
var metadata = {
games_played: stats.games_played || 0,
games_won: stats.games_won || 0,
avatar_url: account.user.avatarUrl || ""
avatar_url: account.user.avatarUrl || "",
loadout_character: stats.loadout_character || "Copper"
};
nk.leaderboardRecordWrite("global_high_score", targetUserId, account.user.username, score, subscore, JSON.stringify(metadata));
nk.leaderboardRecordWrite("global_high_score", targetUserId, account.user.username, score, subscore, metadata);
logger.info("Stats updated for user " + targetUserId + " by admin " + ctx.userId + " (game_stats + Native)");
return JSON.stringify({ success: true });
@@ -792,7 +825,8 @@ function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) {
high_score: value.high_score || 0,
games_played: value.games_played || 0,
games_won: value.games_won || 0,
avatar_url: ""
avatar_url: "",
loadout_character: value.loadout_character || "Copper"
};
} else {
// Merge logic: sum counts, max high score
@@ -801,11 +835,11 @@ function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) {
userGroup[userId].games_won += (value.games_won || 0);
}
// Prioritize avatar from game_stats
// Prioritize avatar and character from game_stats
if (key === "game_stats" || !userGroup[userId].avatar_url) {
try {
// Try to get avatar from storage value if present, else fallback to account later
if (value.avatar_url) userGroup[userId].avatar_url = value.avatar_url;
if (value.loadout_character) userGroup[userId].loadout_character = value.loadout_character;
} catch (e) {}
}
}
@@ -820,7 +854,8 @@ function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) {
var metadata = {
games_played: stats.games_played,
games_won: stats.games_won,
avatar_url: stats.avatar_url || account.user.avatarUrl || ""
avatar_url: stats.avatar_url || account.user.avatarUrl || "",
loadout_character: stats.loadout_character || "Copper"
};
nk.leaderboardRecordWrite("global_high_score", userId, account.user.username, stats.high_score, 0, metadata);