This commit is contained in:
2025-12-10 02:55:07 +08:00
parent 58d28366bb
commit 7ad20497d8
12 changed files with 2925 additions and 0 deletions
+426
View File
@@ -0,0 +1,426 @@
extends Node
## AuthManager - Centralized authentication handling for Nakama
## Supports: Guest (device ID), Email/Password, Social (Google, Apple, Facebook)
# Signals
signal auth_started
signal auth_completed(success: bool, user_data: Dictionary)
signal auth_failed(error: String)
signal session_restored
signal logged_out
# Auth modes
enum AuthMode { GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, CUSTOM }
# User data
var current_user: Dictionary = {}
var is_authenticated: bool = false
var is_guest: bool = false
var auth_mode: AuthMode = AuthMode.GUEST
# Session persistence
const SESSION_FILE := "user://auth_session.dat"
const CREDENTIALS_FILE := "user://auth_credentials.dat"
# Encryption key for session storage (replace with your own!)
const ENCRYPTION_KEY := "tekton_secret_key_change_me_123"
func _ready() -> void:
# Try to restore session on startup
call_deferred("_try_restore_session")
# =============================================================================
# Session Persistence
# =============================================================================
func _try_restore_session() -> void:
if not FileAccess.file_exists(SESSION_FILE):
print("[AuthManager] No saved session found")
return
var file := FileAccess.open_encrypted_with_pass(SESSION_FILE, FileAccess.READ, ENCRYPTION_KEY)
if not file:
print("[AuthManager] Could not open session file")
return
var session_data := file.get_var()
file.close()
if not session_data or not session_data is Dictionary:
return
var token: String = session_data.get("token", "")
var refresh_token: String = session_data.get("refresh_token", "")
var saved_auth_mode: int = session_data.get("auth_mode", AuthMode.GUEST)
if token.is_empty():
return
print("[AuthManager] Attempting to restore session...")
# Try to restore the session
var session := NakamaClient.restore_session(token)
if session.is_expired():
# Try to refresh
if refresh_token:
var refreshed := await NakamaManager.client.session_refresh_async(session)
if not refreshed.is_exception():
session = refreshed
_save_session(session, saved_auth_mode)
else:
print("[AuthManager] Session refresh failed, need to re-login")
return
else:
print("[AuthManager] Session expired, need to re-login")
return
# Session valid, connect
NakamaManager.session = session
auth_mode = saved_auth_mode as AuthMode
is_guest = auth_mode == AuthMode.GUEST
var socket_success := await _connect_socket()
if socket_success:
await _load_user_profile()
is_authenticated = true
emit_signal("session_restored")
emit_signal("auth_completed", true, current_user)
print("[AuthManager] Session restored successfully")
func _save_session(session: NakamaSession, mode: AuthMode) -> void:
var file := FileAccess.open_encrypted_with_pass(SESSION_FILE, FileAccess.WRITE, ENCRYPTION_KEY)
if file:
file.store_var({
"token": session.token,
"refresh_token": session.refresh_token,
"auth_mode": mode,
"user_id": session.user_id
})
file.close()
func clear_session() -> void:
if FileAccess.file_exists(SESSION_FILE):
DirAccess.remove_absolute(ProjectSettings.globalize_path(SESSION_FILE))
if FileAccess.file_exists(CREDENTIALS_FILE):
DirAccess.remove_absolute(ProjectSettings.globalize_path(CREDENTIALS_FILE))
# =============================================================================
# Guest Authentication
# =============================================================================
func login_as_guest() -> bool:
emit_signal("auth_started")
auth_mode = AuthMode.GUEST
is_guest = true
# Use device ID for guest auth
var device_id := _get_device_id()
print("[AuthManager] Guest login with device: ", device_id.substr(0, 8) + "...")
var session := await NakamaManager.client.authenticate_device_async(device_id, null, true)
if session.is_exception():
var error: String = session.get_exception().message
emit_signal("auth_failed", error)
return false
NakamaManager.session = session
_save_session(session, AuthMode.GUEST)
var socket_success := await _connect_socket()
if not socket_success:
emit_signal("auth_failed", "Failed to connect to game server")
return false
await _load_user_profile()
is_authenticated = true
emit_signal("auth_completed", true, current_user)
return true
func _get_device_id() -> String:
# Try to load saved device ID for consistent guest identity
var id_file := "user://device_id.txt"
if FileAccess.file_exists(id_file):
var file := FileAccess.open(id_file, FileAccess.READ)
if file:
var saved_id := file.get_as_text().strip_edges()
file.close()
if not saved_id.is_empty():
return saved_id
# Generate new device ID
var device_id := OS.get_unique_id()
if device_id.is_empty():
device_id = str(randi()) + str(Time.get_ticks_msec())
# Save for future use
var file := FileAccess.open(id_file, FileAccess.WRITE)
if file:
file.store_string(device_id)
file.close()
return device_id
# =============================================================================
# Email/Password Authentication
# =============================================================================
func login_with_email(email: String, password: String, remember: bool = true) -> bool:
emit_signal("auth_started")
auth_mode = AuthMode.EMAIL
is_guest = false
print("[AuthManager] Email login: ", email)
var session := await NakamaManager.client.authenticate_email_async(email, password, null, false)
if session.is_exception():
var error: String = session.get_exception().message
emit_signal("auth_failed", error)
return false
NakamaManager.session = session
if remember:
_save_session(session, AuthMode.EMAIL)
var socket_success := await _connect_socket()
if not socket_success:
emit_signal("auth_failed", "Failed to connect to game server")
return false
await _load_user_profile()
is_authenticated = true
emit_signal("auth_completed", true, current_user)
return true
func register_with_email(email: String, password: String, username: String = "") -> bool:
emit_signal("auth_started")
auth_mode = AuthMode.EMAIL
is_guest = false
print("[AuthManager] Registering: ", email)
# Create account (true = create if not exists)
var session := await NakamaManager.client.authenticate_email_async(email, password, username, true)
if session.is_exception():
var error: String = session.get_exception().message
emit_signal("auth_failed", error)
return false
NakamaManager.session = session
_save_session(session, AuthMode.EMAIL)
var socket_success := await _connect_socket()
if not socket_success:
emit_signal("auth_failed", "Failed to connect to game server")
return false
await _load_user_profile()
is_authenticated = true
emit_signal("auth_completed", true, current_user)
return true
# =============================================================================
# Social Authentication
# =============================================================================
func login_with_google(id_token: String) -> bool:
emit_signal("auth_started")
auth_mode = AuthMode.GOOGLE
is_guest = false
print("[AuthManager] Google login...")
var session := await NakamaManager.client.authenticate_google_async(id_token, null, true)
if session.is_exception():
var error: String = session.get_exception().message
emit_signal("auth_failed", error)
return false
NakamaManager.session = session
_save_session(session, AuthMode.GOOGLE)
var socket_success := await _connect_socket()
if not socket_success:
emit_signal("auth_failed", "Failed to connect to game server")
return false
await _load_user_profile()
is_authenticated = true
emit_signal("auth_completed", true, current_user)
return true
func login_with_apple(id_token: String) -> bool:
emit_signal("auth_started")
auth_mode = AuthMode.APPLE
is_guest = false
print("[AuthManager] Apple login...")
var session := await NakamaManager.client.authenticate_apple_async(id_token, null, true)
if session.is_exception():
var error: String = session.get_exception().message
emit_signal("auth_failed", error)
return false
NakamaManager.session = session
_save_session(session, AuthMode.APPLE)
var socket_success := await _connect_socket()
if not socket_success:
emit_signal("auth_failed", "Failed to connect to game server")
return false
await _load_user_profile()
is_authenticated = true
emit_signal("auth_completed", true, current_user)
return true
func login_with_facebook(access_token: String) -> bool:
emit_signal("auth_started")
auth_mode = AuthMode.FACEBOOK
is_guest = false
print("[AuthManager] Facebook login...")
var session := await NakamaManager.client.authenticate_facebook_async(access_token, null, true)
if session.is_exception():
var error: String = session.get_exception().message
emit_signal("auth_failed", error)
return false
NakamaManager.session = session
_save_session(session, AuthMode.FACEBOOK)
var socket_success := await _connect_socket()
if not socket_success:
emit_signal("auth_failed", "Failed to connect to game server")
return false
await _load_user_profile()
is_authenticated = true
emit_signal("auth_completed", true, current_user)
return true
# =============================================================================
# Account Linking (Convert Guest to Full Account)
# =============================================================================
func link_email(email: String, password: String) -> bool:
if not is_authenticated or not NakamaManager.session:
return false
print("[AuthManager] Linking email to guest account...")
var result := await NakamaManager.client.link_email_async(NakamaManager.session, email, password)
if result.is_exception():
push_error("[AuthManager] Link failed: " + result.get_exception().message)
return false
is_guest = false
auth_mode = AuthMode.EMAIL
_save_session(NakamaManager.session, AuthMode.EMAIL)
print("[AuthManager] Email linked successfully!")
return true
func link_google(id_token: String) -> bool:
if not is_authenticated or not NakamaManager.session:
return false
var result := await NakamaManager.client.link_google_async(NakamaManager.session, id_token)
if result.is_exception():
return false
is_guest = false
auth_mode = AuthMode.GOOGLE
_save_session(NakamaManager.session, AuthMode.GOOGLE)
return true
# =============================================================================
# Logout
# =============================================================================
func logout() -> void:
print("[AuthManager] Logging out...")
if NakamaManager.socket:
NakamaManager.socket.close()
clear_session()
current_user = {}
is_authenticated = false
is_guest = false
NakamaManager.session = null
emit_signal("logged_out")
# =============================================================================
# Helper Functions
# =============================================================================
func _connect_socket() -> bool:
if NakamaManager.socket and NakamaManager.socket.is_connected_to_host():
return true
NakamaManager.socket = Nakama.create_socket_from(NakamaManager.client)
var result := await NakamaManager.socket.connect_async(NakamaManager.session)
if result.is_exception():
push_error("[AuthManager] Socket connection failed: " + result.get_exception().message)
return false
# Initialize multiplayer bridge
NakamaManager.bridge = NakamaMultiplayerBridge.new(NakamaManager.socket)
NakamaManager.bridge.match_joined.connect(NakamaManager._on_bridge_match_joined)
NakamaManager.bridge.match_join_error.connect(NakamaManager._on_bridge_match_join_error)
multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer)
return true
func _load_user_profile() -> void:
if not NakamaManager.session:
return
var account := await NakamaManager.client.get_account_async(NakamaManager.session)
if account.is_exception():
push_error("[AuthManager] Failed to load account")
return
current_user = {
"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,
"email": account.email,
"is_guest": is_guest,
"auth_mode": auth_mode,
"created_at": account.user.create_time
}
print("[AuthManager] User profile loaded: ", current_user.display_name)
func get_display_name() -> String:
return current_user.get("display_name", "Guest")
func get_user_id() -> String:
return current_user.get("user_id", "")
func is_logged_in() -> bool:
return is_authenticated
+270
View File
@@ -0,0 +1,270 @@
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 = {}
# Nakama storage collection names
const PROFILE_COLLECTION := "profiles"
const STATS_COLLECTION := "stats"
# Available avatars (predefined)
const AVATARS := [
"res://assets/avatars/avatar_default.png",
"res://assets/avatars/avatar_warrior.png",
"res://assets/avatars/avatar_mage.png",
"res://assets/avatars/avatar_rogue.png",
"res://assets/avatars/avatar_tank.png",
"res://assets/avatars/avatar_healer.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 = {}
# =============================================================================
# Profile Loading
# =============================================================================
func load_profile() -> Dictionary:
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)
# Load stats
await load_stats()
emit_signal("profile_loaded", profile)
print("[UserProfileManager] Profile loaded: ", profile.display_name)
return profile
func load_stats() -> Dictionary:
if not NakamaManager.session:
return {}
var user_id := NakamaManager.session.user_id
var storage_result := await NakamaManager.client.read_storage_objects_async(
NakamaManager.session,
[NakamaStorageObjectId.new(STATS_COLLECTION, "game_stats", 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
else:
# Initialize default stats
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 not NakamaManager.session:
emit_signal("profile_update_failed", "Not authenticated")
return false
if new_name.strip_edges().is_empty():
emit_signal("profile_update_failed", "Display name cannot be empty")
return false
if new_name.length() > 50:
emit_signal("profile_update_failed", "Display name too long (max 50 characters)")
return false
var result := await NakamaManager.client.update_account_async(
NakamaManager.session,
null, # username (don't change)
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]
var success := await _save_profile_data()
if success:
emit_signal("avatar_changed", AVATARS[avatar_index])
emit_signal("profile_updated")
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")
}
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
# =============================================================================
# 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)
# =============================================================================
# Getters
# =============================================================================
func get_display_name() -> String:
return profile.get("display_name", "Guest")
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
+403
View File
@@ -0,0 +1,403 @@
extends PanelContainer
## Admin panel for in-game server management using Nakama RPC
## Requires server-side tekton_admin.ts module for security
signal closed
signal player_kicked(player_id: String)
signal player_banned(player_id: String)
@onready var close_button := %CloseButton as Button
@onready var player_list := %PlayerList as ItemList
@onready var kick_btn := %KickBtn as Button
@onready var ban_btn := %BanBtn as Button
@onready var mute_btn := %MuteBtn as Button
@onready var player_count_label := %PlayerCount as Label
@onready var match_id_label := %MatchIdLabel as Label
@onready var server_status_label := %ServerStatus as Label
@onready var end_match_btn := %EndMatchBtn as Button
@onready var restart_match_btn := %RestartMatchBtn as Button
@onready var ban_list := %BanList as ItemList
@onready var unban_btn := %UnbanBtn as Button
@onready var status_label := %StatusLabel as Label
# Player data cache
var players: Array = []
var banned_players: Array = [] # [{user_id, username, banned_at, reason, expires}]
var is_admin: bool = false
var is_host: bool = false
func _ready() -> void:
_connect_signals()
visible = false
func _connect_signals() -> void:
close_button.pressed.connect(_on_close_pressed)
kick_btn.pressed.connect(_on_kick_pressed)
ban_btn.pressed.connect(_on_ban_pressed)
mute_btn.pressed.connect(_on_mute_pressed)
end_match_btn.pressed.connect(_on_end_match_pressed)
restart_match_btn.pressed.connect(_on_restart_match_pressed)
unban_btn.pressed.connect(_on_unban_pressed)
player_list.item_selected.connect(_on_player_selected)
func show_panel() -> void:
# Check permissions
is_host = LobbyManager.is_host
is_admin = await _check_admin_status()
if not is_host and not is_admin:
status_label.text = "Admin access required"
status_label.add_theme_color_override("font_color", Color.RED)
return
# Show/hide admin-only features
ban_btn.visible = is_admin
unban_btn.visible = is_admin
await _refresh_all_data()
visible = true
func _check_admin_status() -> bool:
# Check with server if user has admin role
var result := await _rpc_call("get_user_profile", {})
if result.has("role"):
return result.role in ["admin", "moderator", "owner"]
return false
func _on_close_pressed() -> void:
visible = false
emit_signal("closed")
# =============================================================================
# RPC Helpers
# =============================================================================
func _rpc_call(rpc_name: String, payload: Dictionary) -> Dictionary:
if not NakamaManager.client or not NakamaManager.session:
push_error("[AdminPanel] Not connected to Nakama")
return {"error": "Not connected"}
var result := await NakamaManager.client.rpc_async(
NakamaManager.session,
rpc_name,
JSON.stringify(payload)
)
if result.is_exception():
var error: String = result.get_exception().message
push_error("[AdminPanel] RPC Error: " + error)
status_label.text = "Error: " + error
status_label.add_theme_color_override("font_color", Color.RED)
return {"error": error}
if result.payload:
return JSON.parse_string(result.payload)
return {}
# =============================================================================
# Data Refresh
# =============================================================================
func _refresh_all_data() -> void:
await _refresh_player_list()
await _refresh_server_stats()
if is_admin:
await _refresh_ban_list()
status_label.text = ""
func _refresh_player_list() -> void:
player_list.clear()
players = LobbyManager.get_players()
for player in players:
var name: String = player.get("name", "Unknown")
var id: int = player.get("id", 0)
var user_id: String = player.get("user_id", "")
var is_player_host := id == 1
var display := name
if is_player_host:
display += " (Host)"
player_list.add_item(display)
# Store user_id as metadata
player_list.set_item_metadata(player_list.item_count - 1, {
"user_id": user_id,
"peer_id": id,
"name": name
})
_update_action_buttons()
func _refresh_server_stats() -> void:
var stats := await _rpc_call("admin_get_server_stats", {
"match_id": NakamaManager.current_match_id
})
if stats.has("error"):
return
player_count_label.text = str(stats.get("total_players", LobbyManager.get_players().size()))
var match_id := NakamaManager.current_match_id
if match_id.length() > 16:
match_id = match_id.substr(0, 16) + "..."
match_id_label.text = match_id if match_id else "N/A"
server_status_label.text = "Running"
server_status_label.add_theme_color_override("font_color", Color.GREEN)
func _refresh_ban_list() -> void:
ban_list.clear()
var result := await _rpc_call("admin_get_ban_list", {})
if result.has("error"):
return
banned_players = result.get("bans", [])
for ban in banned_players:
var display := "%s - %s" % [
ban.get("username", "Unknown"),
ban.get("reason", "No reason")
]
if ban.get("expires"):
display += " (until " + ban.expires + ")"
ban_list.add_item(display)
ban_list.set_item_metadata(ban_list.item_count - 1, ban)
# =============================================================================
# Player Actions
# =============================================================================
func _on_player_selected(_index: int) -> void:
_update_action_buttons()
func _update_action_buttons() -> void:
var selected := player_list.get_selected_items()
if selected.is_empty():
kick_btn.disabled = true
ban_btn.disabled = true
mute_btn.disabled = true
return
var idx: int = selected[0]
var meta: Dictionary = player_list.get_item_metadata(idx)
var is_player_host := meta.get("peer_id", 0) == 1
# Can't kick/ban the host or yourself
var is_self := meta.get("user_id", "") == AuthManager.current_user.get("user_id", "")
kick_btn.disabled = is_player_host or is_self
ban_btn.disabled = is_player_host or is_self or not is_admin
mute_btn.disabled = is_self
func _on_kick_pressed() -> void:
var selected := player_list.get_selected_items()
if selected.is_empty():
return
var meta: Dictionary = player_list.get_item_metadata(selected[0])
var user_id: String = meta.get("user_id", "")
var player_name: String = meta.get("name", "Unknown")
if user_id.is_empty():
status_label.text = "Cannot kick: Invalid player"
return
status_label.text = "Kicking player..."
var result := await _rpc_call("admin_kick_player", {
"match_id": NakamaManager.current_match_id,
"user_id": user_id,
"reason": "Kicked by " + ("admin" if is_admin else "host")
})
if result.get("success", false):
status_label.text = "Kicked: " + player_name
status_label.add_theme_color_override("font_color", Color.GREEN)
emit_signal("player_kicked", user_id)
await get_tree().create_timer(0.5).timeout
_refresh_player_list()
else:
status_label.text = "Failed to kick player"
status_label.add_theme_color_override("font_color", Color.RED)
func _on_ban_pressed() -> void:
var selected := player_list.get_selected_items()
if selected.is_empty():
return
var meta: Dictionary = player_list.get_item_metadata(selected[0])
var user_id: String = meta.get("user_id", "")
var player_name: String = meta.get("name", "Unknown")
if user_id.is_empty():
status_label.text = "Cannot ban: Invalid player"
return
# Show ban dialog
var dialog := _create_ban_dialog(user_id, player_name)
add_child(dialog)
dialog.popup_centered()
func _create_ban_dialog(user_id: String, player_name: String) -> ConfirmationDialog:
var dialog := ConfirmationDialog.new()
dialog.title = "Ban Player"
var vbox := VBoxContainer.new()
var info_label := Label.new()
info_label.text = "Ban player: " + player_name
vbox.add_child(info_label)
var reason_label := Label.new()
reason_label.text = "Reason:"
vbox.add_child(reason_label)
var reason_input := LineEdit.new()
reason_input.placeholder_text = "Enter ban reason"
vbox.add_child(reason_input)
var duration_label := Label.new()
duration_label.text = "Duration (hours, 0 = permanent):"
vbox.add_child(duration_label)
var duration_input := SpinBox.new()
duration_input.min_value = 0
duration_input.max_value = 8760 # 1 year
duration_input.value = 24 # Default 24 hours
vbox.add_child(duration_input)
dialog.add_child(vbox)
dialog.confirmed.connect(func():
_execute_ban(user_id, player_name, reason_input.text, int(duration_input.value))
dialog.queue_free()
)
dialog.canceled.connect(func():
dialog.queue_free()
)
return dialog
func _execute_ban(user_id: String, player_name: String, reason: String, duration_hours: int) -> void:
status_label.text = "Banning player..."
var result := await _rpc_call("admin_ban_player", {
"user_id": user_id,
"reason": reason if reason else "No reason provided",
"duration_hours": duration_hours,
"match_id": NakamaManager.current_match_id
})
if result.get("success", false):
status_label.text = "Banned: " + player_name
status_label.add_theme_color_override("font_color", Color.GREEN)
emit_signal("player_banned", user_id)
await get_tree().create_timer(0.5).timeout
await _refresh_all_data()
else:
status_label.text = "Failed to ban: " + result.get("error", "Unknown error")
status_label.add_theme_color_override("font_color", Color.RED)
func _on_mute_pressed() -> void:
var selected := player_list.get_selected_items()
if selected.is_empty():
return
var meta: Dictionary = player_list.get_item_metadata(selected[0])
var player_name: String = meta.get("name", "Unknown")
# TODO: Implement mute via chat system RPC
status_label.text = "Muted: " + player_name
# =============================================================================
# Server Controls
# =============================================================================
func _on_end_match_pressed() -> void:
var dialog := ConfirmationDialog.new()
dialog.dialog_text = "Are you sure you want to end this match?\nAll players will be returned to the lobby."
add_child(dialog)
dialog.popup_centered()
dialog.confirmed.connect(func():
status_label.text = "Ending match..."
var result := await _rpc_call("admin_end_match", {
"match_id": NakamaManager.current_match_id,
"reason": "Ended by " + ("admin" if is_admin else "host")
})
if result.get("success", false):
status_label.text = "Match ended"
else:
status_label.text = "Failed to end match"
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
func _on_restart_match_pressed() -> void:
var dialog := ConfirmationDialog.new()
dialog.dialog_text = "Restart the current match?"
add_child(dialog)
dialog.popup_centered()
dialog.confirmed.connect(func():
# For restart, we'll use a different approach - reload scene locally
# The actual match restart logic would depend on your game
get_tree().reload_current_scene()
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
# =============================================================================
# Ban List Management
# =============================================================================
func _on_unban_pressed() -> void:
var selected := ban_list.get_selected_items()
if selected.is_empty():
status_label.text = "Select a player to unban"
return
var meta: Dictionary = ban_list.get_item_metadata(selected[0])
var user_id: String = meta.get("user_id", "")
var username: String = meta.get("username", "Unknown")
if user_id.is_empty():
status_label.text = "Invalid ban entry"
return
status_label.text = "Unbanning..."
var result := await _rpc_call("admin_unban_player", {
"user_id": user_id
})
if result.get("success", false):
status_label.text = "Unbanned: " + username
status_label.add_theme_color_override("font_color", Color.GREEN)
await _refresh_ban_list()
else:
status_label.text = "Failed to unban"
status_label.add_theme_color_override("font_color", Color.RED)
# =============================================================================
# Input Handling
# =============================================================================
func _input(event: InputEvent) -> void:
# Toggle admin panel with F10
if event is InputEventKey and event.pressed and event.keycode == KEY_F10:
if visible:
_on_close_pressed()
else:
show_panel()
+340
View File
@@ -0,0 +1,340 @@
extends Control
## Login screen controller - handles authentication UI
# Login panel elements
@onready var guest_button := %GuestButton as Button
@onready var email_input := %EmailInput as LineEdit
@onready var password_input := %PasswordInput as LineEdit
@onready var remember_me := %RememberMe as CheckBox
@onready var login_button := %LoginButton as Button
@onready var register_link := %RegisterLink as LinkButton
@onready var google_button := %GoogleButton as Button
@onready var apple_button := %AppleButton as Button
@onready var facebook_button := %FacebookButton as Button
@onready var status_label := %StatusLabel as Label
@onready var loading_spinner := %LoadingSpinner as TextureProgressBar
# Registration panel elements
@onready var registration_panel := %RegistrationPanel as PanelContainer
@onready var reg_email_input := %RegEmailInput as LineEdit
@onready var reg_username_input := %RegUsernameInput as LineEdit
@onready var reg_password_input := %RegPasswordInput as LineEdit
@onready var reg_confirm_password_input := %RegConfirmPasswordInput as LineEdit
@onready var password_strength := %PasswordStrength as ProgressBar
@onready var password_hint := %PasswordHint as Label
@onready var register_button := %RegisterButton as Button
@onready var back_to_login_link := %BackToLoginLink as LinkButton
@onready var reg_status_label := %RegStatusLabel as Label
# Main panel reference
@onready var main_panel := $CenterContainer/MainPanel as PanelContainer
var is_loading: bool = false
func _ready() -> void:
_connect_signals()
_setup_ui()
# Check if already authenticated
if AuthManager.is_logged_in():
_go_to_lobby()
func _connect_signals() -> void:
# Login buttons
guest_button.pressed.connect(_on_guest_pressed)
login_button.pressed.connect(_on_login_pressed)
register_link.pressed.connect(_show_registration)
# Social buttons
google_button.pressed.connect(_on_google_pressed)
apple_button.pressed.connect(_on_apple_pressed)
facebook_button.pressed.connect(_on_facebook_pressed)
# Registration buttons
register_button.pressed.connect(_on_register_pressed)
back_to_login_link.pressed.connect(_show_login)
# Password strength checker
reg_password_input.text_changed.connect(_check_password_strength)
# Auth manager signals
AuthManager.auth_started.connect(_on_auth_started)
AuthManager.auth_completed.connect(_on_auth_completed)
AuthManager.auth_failed.connect(_on_auth_failed)
AuthManager.session_restored.connect(_on_session_restored)
# Enter key to submit
password_input.text_submitted.connect(func(_t): _on_login_pressed())
reg_confirm_password_input.text_submitted.connect(func(_t): _on_register_pressed())
func _setup_ui() -> void:
status_label.text = ""
reg_status_label.text = ""
loading_spinner.visible = false
registration_panel.visible = false
main_panel.visible = true
# Hide social buttons on platforms where they're not supported
_configure_social_buttons()
func _configure_social_buttons() -> void:
# Google - available on all platforms
google_button.visible = true
# Apple - iOS and macOS only (or hide if not configured)
var os := OS.get_name()
apple_button.visible = os in ["iOS", "macOS"]
# Facebook - available on all platforms
facebook_button.visible = true
# =============================================================================
# Panel Switching
# =============================================================================
func _show_registration() -> void:
main_panel.visible = false
registration_panel.visible = true
reg_status_label.text = ""
reg_email_input.grab_focus()
func _show_login() -> void:
registration_panel.visible = false
main_panel.visible = true
status_label.text = ""
email_input.grab_focus()
# =============================================================================
# Login Handlers
# =============================================================================
func _on_guest_pressed() -> void:
if is_loading:
return
AuthManager.login_as_guest()
func _on_login_pressed() -> void:
if is_loading:
return
var email := email_input.text.strip_edges()
var password := password_input.text
if email.is_empty():
_show_error("Please enter your email")
return
if password.is_empty():
_show_error("Please enter your password")
return
if not _is_valid_email(email):
_show_error("Please enter a valid email address")
return
AuthManager.login_with_email(email, password, remember_me.button_pressed)
func _on_google_pressed() -> void:
if is_loading:
return
# Note: Actual Google Sign-In requires platform-specific implementation
# This is a placeholder - you need to integrate Google Sign-In SDK
_show_error("Google Sign-In requires SDK integration")
# When you have the ID token from Google SDK:
# AuthManager.login_with_google(id_token)
func _on_apple_pressed() -> void:
if is_loading:
return
# Note: Apple Sign-In requires platform-specific implementation
_show_error("Apple Sign-In requires SDK integration")
# When you have the ID token from Apple:
# AuthManager.login_with_apple(id_token)
func _on_facebook_pressed() -> void:
if is_loading:
return
# Note: Facebook Login requires platform-specific implementation
_show_error("Facebook Login requires SDK integration")
# When you have the access token from Facebook SDK:
# AuthManager.login_with_facebook(access_token)
# =============================================================================
# Registration Handlers
# =============================================================================
func _on_register_pressed() -> void:
if is_loading:
return
var email := reg_email_input.text.strip_edges()
var username := reg_username_input.text.strip_edges()
var password := reg_password_input.text
var confirm_password := reg_confirm_password_input.text
# Validation
if email.is_empty():
_show_reg_error("Please enter your email")
return
if not _is_valid_email(email):
_show_reg_error("Please enter a valid email address")
return
if username.is_empty():
_show_reg_error("Please enter a username")
return
if username.length() < 3:
_show_reg_error("Username must be at least 3 characters")
return
if password.is_empty():
_show_reg_error("Please enter a password")
return
if password.length() < 8:
_show_reg_error("Password must be at least 8 characters")
return
if password != confirm_password:
_show_reg_error("Passwords do not match")
return
if _calculate_password_strength(password) < 2:
_show_reg_error("Password is too weak. Add numbers or symbols.")
return
AuthManager.register_with_email(email, password, username)
func _check_password_strength(password: String) -> void:
var strength := _calculate_password_strength(password)
password_strength.value = strength
# Color based on strength
var color: Color
match strength:
0, 1:
color = Color.RED
password_hint.text = "Weak - add more characters"
2:
color = Color.ORANGE
password_hint.text = "Fair - add numbers or symbols"
3:
color = Color.YELLOW_GREEN
password_hint.text = "Good"
4:
color = Color.GREEN
password_hint.text = "Strong!"
# Apply color to progress bar
var style := StyleBoxFlat.new()
style.bg_color = color
password_strength.add_theme_stylebox_override("fill", style)
func _calculate_password_strength(password: String) -> int:
var strength := 0
if password.length() >= 8:
strength += 1
if password.length() >= 12:
strength += 1
var has_upper := false
var has_lower := false
var has_digit := false
var has_special := false
for c in password:
if c.to_upper() != c.to_lower():
if c == c.to_upper():
has_upper = true
else:
has_lower = true
elif c.is_valid_int():
has_digit = true
else:
has_special = true
if has_upper and has_lower:
strength += 1
if has_digit:
strength += 0.5
if has_special:
strength += 0.5
return mini(int(strength), 4)
# =============================================================================
# Auth Manager Callbacks
# =============================================================================
func _on_auth_started() -> void:
is_loading = true
loading_spinner.visible = true
_set_inputs_enabled(false)
status_label.text = ""
reg_status_label.text = ""
func _on_auth_completed(success: bool, _user_data: Dictionary) -> void:
is_loading = false
loading_spinner.visible = false
_set_inputs_enabled(true)
if success:
_go_to_lobby()
func _on_auth_failed(error: String) -> void:
is_loading = false
loading_spinner.visible = false
_set_inputs_enabled(true)
if registration_panel.visible:
_show_reg_error(error)
else:
_show_error(error)
func _on_session_restored() -> void:
_go_to_lobby()
# =============================================================================
# Helper Functions
# =============================================================================
func _show_error(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4))
func _show_reg_error(message: String) -> void:
reg_status_label.text = message
reg_status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4))
func _set_inputs_enabled(enabled: bool) -> void:
guest_button.disabled = not enabled
login_button.disabled = not enabled
register_button.disabled = not enabled
google_button.disabled = not enabled
apple_button.disabled = not enabled
facebook_button.disabled = not enabled
email_input.editable = enabled
password_input.editable = enabled
reg_email_input.editable = enabled
reg_username_input.editable = enabled
reg_password_input.editable = enabled
reg_confirm_password_input.editable = enabled
func _is_valid_email(email: String) -> bool:
# Simple email validation
var regex := RegEx.new()
regex.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
return regex.search(email) != null
func _go_to_lobby() -> void:
# Navigate to lobby scene
get_tree().change_scene_to_file("res://assets/models/meshes/lobby.tscn")
+194
View File
@@ -0,0 +1,194 @@
extends PanelContainer
## Profile panel controller - displays and edits user profile
signal closed
signal profile_updated
@onready var close_button := %CloseButton as Button
@onready var avatar_display := %AvatarDisplay as TextureRect
@onready var change_avatar_btn := %ChangeAvatarBtn as Button
@onready var display_name_input := %DisplayNameInput as LineEdit
@onready var save_name_btn := %SaveNameBtn as Button
@onready var games_played_label := %GamesPlayed as Label
@onready var win_rate_label := %WinRate as Label
@onready var high_score_label := %HighScore as Label
@onready var account_type_label := %AccountType as Label
@onready var link_account_btn := %LinkAccountBtn as Button
@onready var logout_btn := %LogoutBtn as Button
@onready var status_label := %StatusLabel as Label
@onready var avatar_popup := %AvatarSelectionPopup as PopupPanel
@onready var avatar_grid := %GridContainer as GridContainer
func _ready() -> void:
_connect_signals()
_load_profile_data()
_setup_avatar_grid()
func _connect_signals() -> void:
close_button.pressed.connect(_on_close_pressed)
change_avatar_btn.pressed.connect(_on_change_avatar_pressed)
save_name_btn.pressed.connect(_on_save_name_pressed)
link_account_btn.pressed.connect(_on_link_account_pressed)
logout_btn.pressed.connect(_on_logout_pressed)
UserProfileManager.profile_updated.connect(_on_profile_updated)
UserProfileManager.profile_update_failed.connect(_on_profile_update_failed)
func _load_profile_data() -> void:
var profile := UserProfileManager.profile
var stats := UserProfileManager.stats
# Display name
display_name_input.text = profile.get("display_name", "Guest")
# Avatar
var avatar_url: String = UserProfileManager.get_avatar_url()
if ResourceLoader.exists(avatar_url):
avatar_display.texture = load(avatar_url)
# Stats
games_played_label.text = "Games Played: %d" % stats.get("games_played", 0)
win_rate_label.text = "Win Rate: %.1f%%" % UserProfileManager.get_win_rate()
high_score_label.text = "High Score: %d" % stats.get("high_score", 0)
# Account type
if AuthManager.is_guest:
account_type_label.text = "Account: Guest"
link_account_btn.visible = true
link_account_btn.text = "Link Email (Keep Progress)"
else:
var mode_name := _get_auth_mode_name(AuthManager.auth_mode)
account_type_label.text = "Account: %s" % mode_name
link_account_btn.visible = false
status_label.text = ""
func _get_auth_mode_name(mode: int) -> String:
match mode:
AuthManager.AuthMode.EMAIL:
return "Email"
AuthManager.AuthMode.GOOGLE:
return "Google"
AuthManager.AuthMode.APPLE:
return "Apple"
AuthManager.AuthMode.FACEBOOK:
return "Facebook"
_:
return "Guest"
func _setup_avatar_grid() -> void:
# Clear existing
for child in avatar_grid.get_children():
child.queue_free()
# Add avatar buttons
for i in range(UserProfileManager.AVATARS.size()):
var avatar_path: String = UserProfileManager.AVATARS[i]
var btn := Button.new()
btn.custom_minimum_size = Vector2(64, 64)
if ResourceLoader.exists(avatar_path):
var tex := load(avatar_path) as Texture2D
btn.icon = tex
btn.expand_icon = true
else:
btn.text = str(i + 1)
btn.pressed.connect(_on_avatar_selected.bind(i))
avatar_grid.add_child(btn)
func _on_close_pressed() -> void:
hide()
emit_signal("closed")
func _on_change_avatar_pressed() -> void:
avatar_popup.popup_centered()
func _on_avatar_selected(index: int) -> void:
avatar_popup.hide()
status_label.text = "Saving avatar..."
var success := await UserProfileManager.update_avatar(index)
if success:
var avatar_url: String = UserProfileManager.get_avatar_url()
if ResourceLoader.exists(avatar_url):
avatar_display.texture = load(avatar_url)
status_label.text = "Avatar updated!"
else:
status_label.text = "Failed to update avatar"
func _on_save_name_pressed() -> void:
var new_name := display_name_input.text.strip_edges()
if new_name.is_empty():
status_label.text = "Name cannot be empty"
return
status_label.text = "Saving..."
save_name_btn.disabled = true
var success := await UserProfileManager.update_display_name(new_name)
save_name_btn.disabled = false
if success:
status_label.text = "Name updated!"
emit_signal("profile_updated")
else:
status_label.text = "Failed to update name"
func _on_link_account_pressed() -> void:
# Show link account dialog
# For now, just show a simple popup
var dialog := AcceptDialog.new()
dialog.title = "Link Email"
dialog.dialog_text = "Enter your email and password to link this guest account.\nYour progress will be preserved!"
var vbox := VBoxContainer.new()
var email_input := LineEdit.new()
email_input.placeholder_text = "Email"
var password_input := LineEdit.new()
password_input.placeholder_text = "Password"
password_input.secret = true
vbox.add_child(email_input)
vbox.add_child(password_input)
dialog.add_child(vbox)
add_child(dialog)
dialog.popup_centered()
dialog.confirmed.connect(func():
var email := email_input.text.strip_edges()
var password := password_input.text
if email.is_empty() or password.is_empty():
status_label.text = "Please fill in all fields"
return
status_label.text = "Linking account..."
var success := await AuthManager.link_email(email, password)
if success:
status_label.text = "Account linked successfully!"
link_account_btn.visible = false
account_type_label.text = "Account: Email"
else:
status_label.text = "Failed to link account"
dialog.queue_free()
)
func _on_logout_pressed() -> void:
AuthManager.logout()
get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn")
func _on_profile_updated() -> void:
_load_profile_data()
func _on_profile_update_failed(error: String) -> void:
status_label.text = error
func show_panel() -> void:
_load_profile_data()
show()