410 lines
12 KiB
GDScript
410 lines
12 KiB
GDScript
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 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.display_name)
|
|
|
|
# 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
|
|
|
|
# =============================================================================
|
|
# 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
|