extends Node ## UserProfileManager - Manages user profile data with Nakama storage signal profile_loaded(profile: Dictionary) signal profile_updated signal profile_update_failed(error: String) signal avatar_changed(url: String) # Profile data var profile: Dictionary = {} var stats: Dictionary = {} var wallet: Dictionary = {"gold": 0, "star": 0} var inventory: Array = [] var loadout: Dictionary = {"head": "", "costume": "", "glove": "", "accessory": ""} var shop_catalog: Dictionary = {} var is_profile_loaded: bool = false # Nakama storage collection names const PROFILE_COLLECTION := "profiles" const STATS_COLLECTION := "stats" # Available avatars (predefined) const AVATARS := [ "res://assets/graphics/character_selection/sc_characters/sc_pip.png", "res://assets/graphics/character_selection/sc_characters/sc_gatot.png", "res://assets/graphics/character_selection/sc_characters/sc_dabro.png", "res://assets/graphics/character_selection/sc_characters/sc_copper.png" ] func _ready() -> void: # Connect to auth signals if has_node("/root/AuthManager"): var auth := get_node("/root/AuthManager") auth.auth_completed.connect(_on_auth_completed) auth.logged_out.connect(_on_logged_out) func _on_auth_completed(success: bool, _user_data: Dictionary) -> void: if success: await load_profile() func _on_logged_out() -> void: profile = {} stats = {} is_profile_loaded = false # ============================================================================= # Profile Loading # ============================================================================= func load_profile() -> Dictionary: # Reset state first to ensure no old account data carries over profile = {} stats = {} wallet = {"gold": 0, "star": 0} inventory = [] is_profile_loaded = false if not NakamaManager.session: push_error("[UserProfileManager] No session available") return {} # First get basic account info var account = await NakamaManager.client.get_account_async(NakamaManager.session) if account.is_exception(): push_error("[UserProfileManager] Failed to load account") return {} profile = { "user_id": account.user.id, "username": account.user.username, "display_name": account.user.display_name if account.user.display_name else account.user.username, "avatar_url": account.user.avatar_url, "avatar_index": 0, "email": account.email, "created_at": account.user.create_time, "online": account.user.online, "bio": "", "country": "", "language": "en" } # Load custom profile data from storage var storage_result = await NakamaManager.client.read_storage_objects_async( NakamaManager.session, [NakamaStorageObjectId.new(PROFILE_COLLECTION, "profile", account.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: profile.merge(stored_data, true) if stored_data.has("loadout"): loadout = stored_data["loadout"] # Parse Wallet if account.wallet: var w_data = JSON.parse_string(account.wallet) if w_data: wallet["gold"] = w_data.get("gold", 0) wallet["star"] = w_data.get("star", 0) # Load Inventory await load_inventory() # Load stats await load_stats() is_profile_loaded = true emit_signal("profile_loaded", profile) print("[UserProfileManager] Profile loaded: ", profile.get("display_name", "Unknown")) # 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() return profile func load_inventory() -> void: if not NakamaManager.session: return inventory.clear() var result = await NakamaManager.client.list_storage_objects_async( NakamaManager.session, "inventory", NakamaManager.session.user_id, 100 ) if not result.is_exception() and result.objects: for obj in result.objects: inventory.append(obj.key) func load_stats() -> Dictionary: # Reset stats first to ensure fresh data for new logins stats = {} if not NakamaManager.session: return {} var user_id: String = NakamaManager.session.user_id var storage_result = await NakamaManager.client.read_storage_objects_async( NakamaManager.session, [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, "games_lost": 0, "total_score": 0, "high_score": 0, "play_time_minutes": 0 } return stats # ============================================================================= # Profile Updates # ============================================================================= func update_display_name(new_name: String) -> bool: if new_name.strip_edges().is_empty(): emit_signal("profile_update_failed", "Display name cannot be empty") return false if new_name.length() > 6: emit_signal("profile_update_failed", "Display name too long (max 6 characters)") return false var regex = RegEx.new() regex.compile("^[A-Za-z0-9 ]+$") if not regex.search(new_name): emit_signal("profile_update_failed", "Name can only contain letters, numbers, and spaces") return false # Offline fallback (no session) if not NakamaManager.session: profile["display_name"] = new_name emit_signal("profile_updated") return true var formatted_username = new_name.replace(" ", "_").to_lower() var result: NakamaAsyncResult = await NakamaManager.client.update_account_async( NakamaManager.session, formatted_username, # username (sync to display name) new_name # display_name ) if result.is_exception(): emit_signal("profile_update_failed", result.get_exception().message) return false profile["display_name"] = new_name emit_signal("profile_updated") return true func update_avatar(avatar_index: int) -> bool: if avatar_index < 0 or avatar_index >= AVATARS.size(): emit_signal("profile_update_failed", "Invalid avatar index") return false if not NakamaManager.session: emit_signal("profile_update_failed", "Not authenticated") return false # Store avatar in custom profile data profile["avatar_index"] = avatar_index profile["avatar_url"] = AVATARS[avatar_index] # Update native avatar URL for the leaderboard var avatar_url = AVATARS[avatar_index] var result = await NakamaManager.client.update_account_async( NakamaManager.session, null, null, avatar_url ) if result.is_exception(): emit_signal("profile_update_failed", "Failed to update native avatar") return false var success := await _save_profile_data() if success: emit_signal("avatar_changed", avatar_url) emit_signal("profile_updated") # Immediately update leaderboard with new avatar if stats.get("high_score", 0) > 0: submit_to_leaderboard.call_deferred() return success func update_bio(new_bio: String) -> bool: if new_bio.length() > 200: emit_signal("profile_update_failed", "Bio too long (max 200 characters)") return false profile["bio"] = new_bio return await _save_profile_data() func _save_profile_data() -> bool: if not NakamaManager.session: return false var custom_data := { "avatar_index": profile.get("avatar_index", 0), "bio": profile.get("bio", ""), "country": profile.get("country", ""), "language": profile.get("language", "en"), "loadout": loadout } var write_obj := NakamaWriteStorageObject.new( PROFILE_COLLECTION, "profile", 2, # Public read 1, # Owner write JSON.stringify(custom_data), "" # Version (empty = overwrite) ) var result = await NakamaManager.client.write_storage_objects_async( NakamaManager.session, [write_obj] ) if result.is_exception(): emit_signal("profile_update_failed", result.get_exception().message) return false emit_signal("profile_updated") return true func update_loadout(category: String, item_id: String) -> bool: if not loadout.has(category): return false loadout[category] = item_id return await _save_profile_data() func purchase_item(item_id: String, price_gold: int, price_star: int, category: String) -> bool: if not NakamaManager.session: return false var payload = JSON.stringify({ "item_id": item_id, "price_gold": price_gold, "price_star": price_star, "category": category }) var result = await NakamaManager.client.rpc_async( NakamaManager.session, "purchase_item", payload ) if result.is_exception(): push_error("[UserProfileManager] Purchase failed: ", result.get_exception().message) return false # Update local cache if price_gold > 0: wallet["gold"] -= price_gold if price_star > 0: wallet["star"] -= price_star if not inventory.has(item_id): inventory.append(item_id) emit_signal("profile_updated") return true func fetch_shop_catalog() -> void: if not NakamaManager.session: return var result = await NakamaManager.client.rpc_async( NakamaManager.session, "get_shop_catalog", "{}" ) if result.is_exception(): push_error("[UserProfileManager] Failed to fetch shop catalog: ", result.get_exception().message) return var payload: Dictionary = JSON.parse_string(result.payload) if payload and payload.has("catalog"): shop_catalog = payload.catalog emit_signal("profile_updated") func buy_currency(package_id: String) -> bool: if not NakamaManager.session: return false var payload = JSON.stringify({ "package_id": package_id }) var result = await NakamaManager.client.rpc_async( NakamaManager.session, "buy_currency", payload ) if result.is_exception(): push_error("[UserProfileManager] Failed to buy currency: ", result.get_exception().message) return false await _reload_wallet() return true func _reload_wallet() -> void: if not NakamaManager.session: return var account = await NakamaManager.client.get_account_async(NakamaManager.session) if account.is_exception(): return if account.wallet: var w_data = JSON.parse_string(account.wallet) if w_data: wallet["gold"] = w_data.get("gold", 0) wallet["star"] = w_data.get("star", 0) emit_signal("profile_updated") # ============================================================================= # Stats Management # ============================================================================= func update_stats(new_stats: Dictionary) -> bool: stats.merge(new_stats, true) if not NakamaManager.session: return false var write_obj := NakamaWriteStorageObject.new( STATS_COLLECTION, "game_stats", 2, # Public read 1, # Owner write JSON.stringify(stats), "" ) var result = await NakamaManager.client.write_storage_objects_async( NakamaManager.session, [write_obj] ) return not result.is_exception() func record_game_result(won: bool, score: int) -> void: stats["games_played"] = stats.get("games_played", 0) + 1 if won: stats["games_won"] = stats.get("games_won", 0) + 1 else: stats["games_lost"] = stats.get("games_lost", 0) + 1 stats["total_score"] = stats.get("total_score", 0) + score if score > stats.get("high_score", 0): stats["high_score"] = score await update_stats(stats) # Also submit to Nakama native leaderboard so global_high_score is populated await submit_to_leaderboard() func submit_to_leaderboard() -> void: """Submits the current high_score via server RPC (required for authoritative leaderboards).""" if not NakamaManager.session: 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", ""), "loadout_character": profile.get("loadout_character", "Copper") }) var result = await NakamaManager.client.rpc_async( NakamaManager.session, "submit_score", payload ) if result.is_exception(): push_warning("[UserProfileManager] Leaderboard RPC failed: ", result.get_exception().message) else: print("[UserProfileManager] Leaderboard score submitted: ", stats.get("high_score", 0)) # ============================================================================= # Getters # ============================================================================= func get_display_name(fallback: String = "Guest") -> String: if not is_profile_loaded: return fallback return profile.get("display_name", fallback) func get_avatar_url() -> String: var index: int = profile.get("avatar_index", 0) if index >= 0 and index < AVATARS.size(): return AVATARS[index] return AVATARS[0] func get_stats() -> Dictionary: return stats func get_win_rate() -> float: var played: int = stats.get("games_played", 0) if played == 0: return 0.0 return float(stats.get("games_won", 0)) / float(played) * 100.0