update
This commit is contained in:
@@ -28,6 +28,8 @@ GoalManager="*res://scripts/managers/goal_manager.gd"
|
|||||||
GameStateManager="*res://scripts/managers/game_state_manager.gd"
|
GameStateManager="*res://scripts/managers/game_state_manager.gd"
|
||||||
LobbyManager="*res://scripts/managers/lobby_manager.gd"
|
LobbyManager="*res://scripts/managers/lobby_manager.gd"
|
||||||
GameUpdateManager="*res://scripts/managers/game_update_manager.gd"
|
GameUpdateManager="*res://scripts/managers/game_update_manager.gd"
|
||||||
|
AuthManager="*res://scripts/managers/auth_manager.gd"
|
||||||
|
UserProfileManager="*res://scripts/managers/user_profile_manager.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
|
|||||||
+109
@@ -28,10 +28,27 @@ extends Control
|
|||||||
# UI References - Status
|
# UI References - Status
|
||||||
@onready var connection_status = $StatusBar/ConnectionStatus
|
@onready var connection_status = $StatusBar/ConnectionStatus
|
||||||
|
|
||||||
|
# UI References - User Profile Bar (will be added to scene)
|
||||||
|
var user_profile_bar: Control
|
||||||
|
var profile_panel_instance: Control
|
||||||
|
|
||||||
# Store current match ID for copy function
|
# Store current match ID for copy function
|
||||||
var current_match_id: String = ""
|
var current_match_id: String = ""
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
|
# Check if user is authenticated
|
||||||
|
if not AuthManager.is_logged_in():
|
||||||
|
# Redirect to login screen
|
||||||
|
get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Initialize user profile bar
|
||||||
|
_setup_user_profile_bar()
|
||||||
|
|
||||||
|
# Set player name from profile
|
||||||
|
if player_name_input:
|
||||||
|
player_name_input.text = UserProfileManager.get_display_name()
|
||||||
|
|
||||||
# Connect button signals
|
# Connect button signals
|
||||||
create_room_btn.pressed.connect(_on_create_room_pressed)
|
create_room_btn.pressed.connect(_on_create_room_pressed)
|
||||||
browse_rooms_btn.pressed.connect(_on_browse_rooms_pressed)
|
browse_rooms_btn.pressed.connect(_on_browse_rooms_pressed)
|
||||||
@@ -59,6 +76,98 @@ func _ready():
|
|||||||
|
|
||||||
# Show main menu initially
|
# Show main menu initially
|
||||||
_show_panel("main_menu")
|
_show_panel("main_menu")
|
||||||
|
_update_profile_bar()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# User Profile Bar
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func _setup_user_profile_bar() -> void:
|
||||||
|
# Create profile bar dynamically (or get reference if in scene)
|
||||||
|
user_profile_bar = _create_profile_bar()
|
||||||
|
add_child(user_profile_bar)
|
||||||
|
|
||||||
|
func _create_profile_bar() -> Control:
|
||||||
|
var bar := HBoxContainer.new()
|
||||||
|
bar.name = "UserProfileBar"
|
||||||
|
bar.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
||||||
|
bar.offset_top = 5
|
||||||
|
bar.offset_bottom = 45
|
||||||
|
bar.offset_left = 10
|
||||||
|
bar.offset_right = -10
|
||||||
|
|
||||||
|
# Avatar
|
||||||
|
var avatar := TextureRect.new()
|
||||||
|
avatar.name = "Avatar"
|
||||||
|
avatar.custom_minimum_size = Vector2(35, 35)
|
||||||
|
avatar.expand_mode = TextureRect.EXPAND_FIT_WIDTH
|
||||||
|
avatar.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
bar.add_child(avatar)
|
||||||
|
|
||||||
|
# Display name
|
||||||
|
var name_label := Label.new()
|
||||||
|
name_label.name = "DisplayName"
|
||||||
|
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
name_label.add_theme_color_override("font_color", Color(0, 0.831, 1))
|
||||||
|
bar.add_child(name_label)
|
||||||
|
|
||||||
|
# Account type badge
|
||||||
|
var badge := Label.new()
|
||||||
|
badge.name = "AccountBadge"
|
||||||
|
badge.add_theme_font_size_override("font_size", 10)
|
||||||
|
badge.add_theme_color_override("font_color", Color(0.5, 0.5, 0.6))
|
||||||
|
bar.add_child(badge)
|
||||||
|
|
||||||
|
# Profile button
|
||||||
|
var profile_btn := Button.new()
|
||||||
|
profile_btn.name = "ProfileBtn"
|
||||||
|
profile_btn.text = "Profile"
|
||||||
|
profile_btn.pressed.connect(_on_profile_btn_pressed)
|
||||||
|
bar.add_child(profile_btn)
|
||||||
|
|
||||||
|
# Logout button
|
||||||
|
var logout_btn := Button.new()
|
||||||
|
logout_btn.name = "LogoutBtn"
|
||||||
|
logout_btn.text = "Logout"
|
||||||
|
logout_btn.pressed.connect(_on_logout_pressed)
|
||||||
|
bar.add_child(logout_btn)
|
||||||
|
|
||||||
|
return bar
|
||||||
|
|
||||||
|
func _update_profile_bar() -> void:
|
||||||
|
if not user_profile_bar:
|
||||||
|
return
|
||||||
|
|
||||||
|
var name_label := user_profile_bar.get_node_or_null("DisplayName") as Label
|
||||||
|
if name_label:
|
||||||
|
name_label.text = UserProfileManager.get_display_name()
|
||||||
|
|
||||||
|
var badge := user_profile_bar.get_node_or_null("AccountBadge") as Label
|
||||||
|
if badge:
|
||||||
|
badge.text = "[Guest]" if AuthManager.is_guest else "[Registered]"
|
||||||
|
|
||||||
|
var avatar := user_profile_bar.get_node_or_null("Avatar") as TextureRect
|
||||||
|
if avatar:
|
||||||
|
var avatar_url := UserProfileManager.get_avatar_url()
|
||||||
|
if ResourceLoader.exists(avatar_url):
|
||||||
|
avatar.texture = load(avatar_url)
|
||||||
|
|
||||||
|
func _on_profile_btn_pressed() -> void:
|
||||||
|
# Show profile panel
|
||||||
|
if not profile_panel_instance:
|
||||||
|
var profile_panel_scene := load("res://scenes/ui/profile_panel.tscn")
|
||||||
|
profile_panel_instance = profile_panel_scene.instantiate()
|
||||||
|
profile_panel_instance.closed.connect(func(): profile_panel_instance.hide())
|
||||||
|
profile_panel_instance.profile_updated.connect(_update_profile_bar)
|
||||||
|
add_child(profile_panel_instance)
|
||||||
|
|
||||||
|
profile_panel_instance.show_panel()
|
||||||
|
# Center the panel
|
||||||
|
profile_panel_instance.position = (get_viewport_rect().size - profile_panel_instance.size) / 2
|
||||||
|
|
||||||
|
func _on_logout_pressed() -> void:
|
||||||
|
AuthManager.logout()
|
||||||
|
get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn")
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Panel Management
|
# Panel Management
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://admin_panel"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/admin_panel.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="AdminPanel" type="PanelContainer"]
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_right = 450.0
|
||||||
|
offset_bottom = 500.0
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="VBox" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Header" type="HBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="VBox/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_colors/font_color = Color(1, 0.4, 0.4, 1)
|
||||||
|
theme_override_font_sizes/font_size = 20
|
||||||
|
text = "⚙ Admin Panel"
|
||||||
|
|
||||||
|
[node name="CloseButton" type="Button" parent="VBox/Header"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(30, 30)
|
||||||
|
text = "✕"
|
||||||
|
|
||||||
|
[node name="TabContainer" type="TabContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="Players" type="VBoxContainer" parent="VBox/TabContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
metadata/_tab_index = 0
|
||||||
|
|
||||||
|
[node name="PlayerList" type="ItemList" parent="VBox/TabContainer/Players"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
custom_minimum_size = Vector2(0, 150)
|
||||||
|
|
||||||
|
[node name="PlayerActions" type="HBoxContainer" parent="VBox/TabContainer/Players"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="KickBtn" type="Button" parent="VBox/TabContainer/Players/PlayerActions"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "Kick"
|
||||||
|
|
||||||
|
[node name="BanBtn" type="Button" parent="VBox/TabContainer/Players/PlayerActions"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "Ban"
|
||||||
|
|
||||||
|
[node name="MuteBtn" type="Button" parent="VBox/TabContainer/Players/PlayerActions"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "Mute"
|
||||||
|
|
||||||
|
[node name="Server" type="VBoxContainer" parent="VBox/TabContainer"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
metadata/_tab_index = 1
|
||||||
|
|
||||||
|
[node name="StatsGrid" type="GridContainer" parent="VBox/TabContainer/Server"]
|
||||||
|
layout_mode = 2
|
||||||
|
columns = 2
|
||||||
|
|
||||||
|
[node name="Label1" type="Label" parent="VBox/TabContainer/Server/StatsGrid"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Connected Players:"
|
||||||
|
|
||||||
|
[node name="PlayerCount" type="Label" parent="VBox/TabContainer/Server/StatsGrid"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "0"
|
||||||
|
|
||||||
|
[node name="Label2" type="Label" parent="VBox/TabContainer/Server/StatsGrid"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Match ID:"
|
||||||
|
|
||||||
|
[node name="MatchIdLabel" type="Label" parent="VBox/TabContainer/Server/StatsGrid"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "N/A"
|
||||||
|
|
||||||
|
[node name="Label3" type="Label" parent="VBox/TabContainer/Server/StatsGrid"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Server Status:"
|
||||||
|
|
||||||
|
[node name="ServerStatus" type="Label" parent="VBox/TabContainer/Server/StatsGrid"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0, 1, 0.5, 1)
|
||||||
|
text = "Running"
|
||||||
|
|
||||||
|
[node name="ServerActions" type="VBoxContainer" parent="VBox/TabContainer/Server"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="EndMatchBtn" type="Button" parent="VBox/TabContainer/Server/ServerActions"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "End Match"
|
||||||
|
|
||||||
|
[node name="RestartMatchBtn" type="Button" parent="VBox/TabContainer/Server/ServerActions"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Restart Match"
|
||||||
|
|
||||||
|
[node name="Bans" type="VBoxContainer" parent="VBox/TabContainer"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
metadata/_tab_index = 2
|
||||||
|
|
||||||
|
[node name="BanList" type="ItemList" parent="VBox/TabContainer/Bans"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
custom_minimum_size = Vector2(0, 150)
|
||||||
|
|
||||||
|
[node name="UnbanBtn" type="Button" parent="VBox/TabContainer/Bans"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Unban Selected"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
horizontal_alignment = 1
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://login_screen"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/login_screen.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="LoginScreen" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="Background" type="ColorRect" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
color = Color(0.039, 0.039, 0.102, 1)
|
||||||
|
|
||||||
|
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
|
||||||
|
[node name="MainPanel" type="PanelContainer" parent="CenterContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(400, 0)
|
||||||
|
|
||||||
|
[node name="VBox" type="VBoxContainer" parent="CenterContainer/MainPanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
|
[node name="LogoLabel" type="Label" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0, 0.831, 1, 1)
|
||||||
|
theme_override_font_sizes/font_size = 48
|
||||||
|
text = "TEKTON"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="SubtitleLabel" type="Label" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1)
|
||||||
|
theme_override_font_sizes/font_size = 14
|
||||||
|
text = "Tactical Multiplayer"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="Spacer1" type="Control" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 20)
|
||||||
|
|
||||||
|
[node name="GuestButton" type="Button" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 50)
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
text = "▶ PLAY AS GUEST"
|
||||||
|
|
||||||
|
[node name="Spacer2" type="Control" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 10)
|
||||||
|
|
||||||
|
[node name="OrLabel" type="Label" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.4, 0.4, 0.5, 1)
|
||||||
|
text = "─────── or sign in ───────"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="EmailInput" type="LineEdit" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
placeholder_text = "Email"
|
||||||
|
|
||||||
|
[node name="PasswordInput" type="LineEdit" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
placeholder_text = "Password"
|
||||||
|
secret = true
|
||||||
|
|
||||||
|
[node name="RememberMe" type="CheckBox" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
button_pressed = true
|
||||||
|
text = "Remember me"
|
||||||
|
|
||||||
|
[node name="LoginButton" type="Button" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 45)
|
||||||
|
text = "Sign In"
|
||||||
|
|
||||||
|
[node name="RegisterLink" type="LinkButton" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Don't have an account? Register"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="Spacer3" type="Control" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 10)
|
||||||
|
|
||||||
|
[node name="SocialLabel" type="Label" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.4, 0.4, 0.5, 1)
|
||||||
|
text = "─────── or continue with ───────"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="SocialButtons" type="HBoxContainer" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="GoogleButton" type="Button" parent="CenterContainer/MainPanel/VBox/SocialButtons"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(100, 40)
|
||||||
|
text = "Google"
|
||||||
|
|
||||||
|
[node name="AppleButton" type="Button" parent="CenterContainer/MainPanel/VBox/SocialButtons"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(100, 40)
|
||||||
|
text = "Apple"
|
||||||
|
|
||||||
|
[node name="FacebookButton" type="Button" parent="CenterContainer/MainPanel/VBox/SocialButtons"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(100, 40)
|
||||||
|
text = "Facebook"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(1, 0.4, 0.4, 1)
|
||||||
|
horizontal_alignment = 1
|
||||||
|
autowrap_mode = 2
|
||||||
|
|
||||||
|
[node name="LoadingSpinner" type="TextureProgressBar" parent="CenterContainer/MainPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(40, 40)
|
||||||
|
size_flags_horizontal = 4
|
||||||
|
|
||||||
|
[node name="RegistrationPanel" type="PanelContainer" parent="CenterContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(400, 0)
|
||||||
|
|
||||||
|
[node name="VBox" type="VBoxContainer" parent="CenterContainer/RegistrationPanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0, 0.831, 1, 1)
|
||||||
|
theme_override_font_sizes/font_size = 24
|
||||||
|
text = "Create Account"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="RegEmailInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
placeholder_text = "Email"
|
||||||
|
|
||||||
|
[node name="RegUsernameInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
placeholder_text = "Username"
|
||||||
|
|
||||||
|
[node name="RegPasswordInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
placeholder_text = "Password"
|
||||||
|
secret = true
|
||||||
|
|
||||||
|
[node name="RegConfirmPasswordInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
placeholder_text = "Confirm Password"
|
||||||
|
secret = true
|
||||||
|
|
||||||
|
[node name="PasswordStrength" type="ProgressBar" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 8)
|
||||||
|
max_value = 4.0
|
||||||
|
show_percentage = false
|
||||||
|
|
||||||
|
[node name="PasswordHint" type="Label" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1)
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
text = "Min 8 characters, include number and symbol"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="RegisterButton" type="Button" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(0, 45)
|
||||||
|
text = "Create Account"
|
||||||
|
|
||||||
|
[node name="BackToLoginLink" type="LinkButton" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Already have an account? Sign In"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="RegStatusLabel" type="Label" parent="CenterContainer/RegistrationPanel/VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(1, 0.4, 0.4, 1)
|
||||||
|
horizontal_alignment = 1
|
||||||
|
autowrap_mode = 2
|
||||||
|
|
||||||
|
[node name="VersionLabel" type="Label" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 3
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_left = -100.0
|
||||||
|
offset_top = -25.0
|
||||||
|
offset_right = -10.0
|
||||||
|
offset_bottom = -10.0
|
||||||
|
theme_override_colors/font_color = Color(0.3, 0.3, 0.4, 1)
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
text = "v1.0.0"
|
||||||
|
horizontal_alignment = 2
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://profile_panel"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/profile_panel.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="ProfilePanel" type="PanelContainer"]
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_right = 350.0
|
||||||
|
offset_bottom = 400.0
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="VBox" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="Header" type="HBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="VBox/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_colors/font_color = Color(0, 0.831, 1, 1)
|
||||||
|
theme_override_font_sizes/font_size = 20
|
||||||
|
text = "Profile"
|
||||||
|
|
||||||
|
[node name="CloseButton" type="Button" parent="VBox/Header"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(30, 30)
|
||||||
|
text = "✕"
|
||||||
|
|
||||||
|
[node name="AvatarSection" type="HBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="AvatarDisplay" type="TextureRect" parent="VBox/AvatarSection"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(80, 80)
|
||||||
|
expand_mode = 1
|
||||||
|
stretch_mode = 5
|
||||||
|
|
||||||
|
[node name="AvatarButtons" type="VBoxContainer" parent="VBox/AvatarSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="ChangeAvatarBtn" type="Button" parent="VBox/AvatarSection/AvatarButtons"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Change Avatar"
|
||||||
|
|
||||||
|
[node name="DisplayNameSection" type="VBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="VBox/DisplayNameSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1)
|
||||||
|
text = "Display Name"
|
||||||
|
|
||||||
|
[node name="HBox" type="HBoxContainer" parent="VBox/DisplayNameSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="DisplayNameInput" type="LineEdit" parent="VBox/DisplayNameSection/HBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
custom_minimum_size = Vector2(0, 35)
|
||||||
|
max_length = 50
|
||||||
|
|
||||||
|
[node name="SaveNameBtn" type="Button" parent="VBox/DisplayNameSection/HBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Save"
|
||||||
|
|
||||||
|
[node name="Separator1" type="HSeparator" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="StatsSection" type="VBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="StatsTitle" type="Label" parent="VBox/StatsSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0, 0.831, 1, 1)
|
||||||
|
text = "Statistics"
|
||||||
|
|
||||||
|
[node name="GamesPlayed" type="Label" parent="VBox/StatsSection"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Games Played: 0"
|
||||||
|
|
||||||
|
[node name="WinRate" type="Label" parent="VBox/StatsSection"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Win Rate: 0%"
|
||||||
|
|
||||||
|
[node name="HighScore" type="Label" parent="VBox/StatsSection"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "High Score: 0"
|
||||||
|
|
||||||
|
[node name="Separator2" type="HSeparator" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="AccountSection" type="VBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="AccountType" type="Label" parent="VBox/AccountSection"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1)
|
||||||
|
text = "Account: Guest"
|
||||||
|
|
||||||
|
[node name="LinkAccountBtn" type="Button" parent="VBox/AccountSection"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Link Email (Keep Progress)"
|
||||||
|
|
||||||
|
[node name="LogoutBtn" type="Button" parent="VBox/AccountSection"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Logout"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="AvatarSelectionPopup" type="PopupPanel" parent="."]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
title = "Select Avatar"
|
||||||
|
size = Vector2i(300, 200)
|
||||||
|
|
||||||
|
[node name="GridContainer" type="GridContainer" parent="AvatarSelectionPopup"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
offset_left = 10.0
|
||||||
|
offset_top = 10.0
|
||||||
|
offset_right = 290.0
|
||||||
|
offset_bottom = 190.0
|
||||||
|
columns = 3
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Nakama Server Setup for Tekton
|
||||||
|
|
||||||
|
This guide explains how to deploy the admin module to your Nakama server.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `tekton_admin.ts` - TypeScript server runtime module
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Nakama server installed and running
|
||||||
|
2. Node.js and TypeScript for compilation (if using TypeScript)
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
### Option 1: TypeScript (Recommended)
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
npm install -g typescript
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Compile TypeScript to JavaScript:**
|
||||||
|
```bash
|
||||||
|
cd server/nakama
|
||||||
|
tsc tekton_admin.ts --outDir dist --lib ES2020 --types nakama-runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Copy to Nakama modules directory:**
|
||||||
|
```bash
|
||||||
|
cp dist/tekton_admin.js /path/to/nakama/data/modules/
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Restart Nakama server**
|
||||||
|
|
||||||
|
### Option 2: Convert to Lua
|
||||||
|
|
||||||
|
If you prefer Lua, the TypeScript can be converted. See Nakama docs.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
In your `nakama.yml` or `nakama-docker.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime:
|
||||||
|
js_entrypoint: "tekton_admin.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for multiple modules:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime:
|
||||||
|
js_entrypoint: "index.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Role System
|
||||||
|
|
||||||
|
User roles are stored in account metadata:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "player" | "moderator" | "admin" | "owner"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setting Initial Admin
|
||||||
|
|
||||||
|
Run this in Nakama console or via API:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE users
|
||||||
|
SET metadata = '{"role": "owner"}'
|
||||||
|
WHERE username = 'your_admin_username';
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via Nakama HTTP API:
|
||||||
|
```bash
|
||||||
|
curl -X PUT "http://localhost:7350/v2/console/account/{user_id}" \
|
||||||
|
-H "Authorization: Bearer {admin_token}" \
|
||||||
|
-d '{"metadata": "{\"role\": \"owner\"}"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## RPC Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Required Role | Description |
|
||||||
|
|----------|--------------|-------------|
|
||||||
|
| `admin_kick_player` | Host or Admin | Kick from match |
|
||||||
|
| `admin_ban_player` | Admin only | Ban user |
|
||||||
|
| `admin_unban_player` | Admin only | Unban user |
|
||||||
|
| `admin_get_ban_list` | Admin only | Get all bans |
|
||||||
|
| `admin_get_server_stats` | Host or Admin | Server statistics |
|
||||||
|
| `admin_end_match` | Host or Admin | End current match |
|
||||||
|
| `admin_set_user_role` | Owner only | Change user roles |
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
1. **Never trust the client** - All admin operations are validated server-side
|
||||||
|
2. **Rate limiting** - Consider adding rate limits for RPC calls
|
||||||
|
3. **Logging** - All admin actions are logged with user ID
|
||||||
|
4. **Ban storage** - Bans are stored in Nakama storage, not local files
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
1. Create a test user with admin role
|
||||||
|
2. Login with the admin user
|
||||||
|
3. Press F10 in-game to open admin panel
|
||||||
|
4. Verify kick/ban operations work
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### RPC Not Found
|
||||||
|
- Check module is loaded: `nakama --help` or check logs
|
||||||
|
- Verify js_entrypoint in config
|
||||||
|
|
||||||
|
### Permission Denied
|
||||||
|
- Check user has correct role in metadata
|
||||||
|
- Verify session is valid
|
||||||
|
|
||||||
|
### Ban Not Working
|
||||||
|
- Check Nakama storage permissions
|
||||||
|
- Verify user_id is correct format (UUID)
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
/**
|
||||||
|
* Tekton Nakama Server Runtime Module
|
||||||
|
*
|
||||||
|
* This module provides secure admin operations via RPC calls.
|
||||||
|
* Deploy this to your Nakama server's runtime directory.
|
||||||
|
*
|
||||||
|
* For TypeScript modules, compile to JavaScript and place in:
|
||||||
|
* - data/modules/ (for Nakama Docker)
|
||||||
|
* - Or configure in nakama config.yml
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initialize RPC endpoints
|
||||||
|
function InitModule(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
initializer: nkruntime.Initializer
|
||||||
|
) {
|
||||||
|
// Admin RPCs
|
||||||
|
initializer.registerRpc("admin_kick_player", rpcAdminKickPlayer);
|
||||||
|
initializer.registerRpc("admin_ban_player", rpcAdminBanPlayer);
|
||||||
|
initializer.registerRpc("admin_unban_player", rpcAdminUnbanPlayer);
|
||||||
|
initializer.registerRpc("admin_get_ban_list", rpcAdminGetBanList);
|
||||||
|
initializer.registerRpc("admin_get_server_stats", rpcAdminGetServerStats);
|
||||||
|
initializer.registerRpc("admin_get_player_list", rpcAdminGetPlayerList);
|
||||||
|
initializer.registerRpc("admin_end_match", rpcAdminEndMatch);
|
||||||
|
initializer.registerRpc("admin_set_user_role", rpcAdminSetUserRole);
|
||||||
|
|
||||||
|
// User management RPCs
|
||||||
|
initializer.registerRpc("get_user_profile", rpcGetUserProfile);
|
||||||
|
initializer.registerRpc("update_user_profile", rpcUpdateUserProfile);
|
||||||
|
|
||||||
|
logger.info("Tekton admin module loaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Authorization Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface UserMetadata {
|
||||||
|
role?: string;
|
||||||
|
banned?: boolean;
|
||||||
|
ban_reason?: string;
|
||||||
|
ban_expires?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ["admin", "moderator", "owner"];
|
||||||
|
|
||||||
|
function isAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): boolean {
|
||||||
|
if (!ctx.userId) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = nk.accountGetId(ctx.userId);
|
||||||
|
const metadata = JSON.parse(account.user.metadata || "{}") as UserMetadata;
|
||||||
|
return ADMIN_ROLES.includes(metadata.role || "");
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMatchHost(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
matchId: string
|
||||||
|
): boolean {
|
||||||
|
if (!ctx.userId || !matchId) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get match state to check host
|
||||||
|
const match = nk.matchGet(matchId);
|
||||||
|
if (!match) return false;
|
||||||
|
|
||||||
|
// The first user to join (presence) is typically the host
|
||||||
|
// This logic may need adjustment based on your match handler
|
||||||
|
const state = JSON.parse(match.state || "{}");
|
||||||
|
return state.hostUserId === ctx.userId;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): void {
|
||||||
|
if (!isAdmin(ctx, nk)) {
|
||||||
|
throw new Error("Admin privileges required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdminOrHost(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
matchId: string
|
||||||
|
): void {
|
||||||
|
if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) {
|
||||||
|
throw new Error("Admin or host privileges required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Admin RPCs
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface KickPlayerRequest {
|
||||||
|
match_id: string;
|
||||||
|
user_id: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcAdminKickPlayer(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
const request = JSON.parse(payload) as KickPlayerRequest;
|
||||||
|
|
||||||
|
requireAdminOrHost(ctx, nk, request.match_id);
|
||||||
|
|
||||||
|
// Can't kick yourself
|
||||||
|
if (request.user_id === ctx.userId) {
|
||||||
|
throw new Error("Cannot kick yourself");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Signal the match to kick the player
|
||||||
|
nk.matchSignal(request.match_id, JSON.stringify({
|
||||||
|
action: "kick",
|
||||||
|
user_id: request.user_id,
|
||||||
|
reason: request.reason || "Kicked by admin"
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(`Player ${request.user_id} kicked from match ${request.match_id} by ${ctx.userId}`);
|
||||||
|
|
||||||
|
return JSON.stringify({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to kick player: ${e}`);
|
||||||
|
throw new Error("Failed to kick player");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BanPlayerRequest {
|
||||||
|
user_id: string;
|
||||||
|
reason?: string;
|
||||||
|
duration_hours?: number; // 0 = permanent
|
||||||
|
match_id?: string; // Optional: also kick from current match
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcAdminBanPlayer(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
const request = JSON.parse(payload) as BanPlayerRequest;
|
||||||
|
|
||||||
|
// Only full admins can ban (not just match hosts)
|
||||||
|
requireAdmin(ctx, nk);
|
||||||
|
|
||||||
|
if (request.user_id === ctx.userId) {
|
||||||
|
throw new Error("Cannot ban yourself");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get target user's account
|
||||||
|
const targetAccount = nk.accountGetId(request.user_id);
|
||||||
|
const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata;
|
||||||
|
|
||||||
|
// Don't allow banning other admins
|
||||||
|
if (ADMIN_ROLES.includes(metadata.role || "")) {
|
||||||
|
throw new Error("Cannot ban an admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ban in metadata
|
||||||
|
const banExpires = request.duration_hours && request.duration_hours > 0
|
||||||
|
? new Date(Date.now() + request.duration_hours * 60 * 60 * 1000).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
metadata.banned = true;
|
||||||
|
metadata.ban_reason = request.reason || "Banned by admin";
|
||||||
|
if (banExpires) {
|
||||||
|
metadata.ban_expires = banExpires;
|
||||||
|
}
|
||||||
|
|
||||||
|
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
|
||||||
|
|
||||||
|
// Also kick from current match if specified
|
||||||
|
if (request.match_id) {
|
||||||
|
nk.matchSignal(request.match_id, JSON.stringify({
|
||||||
|
action: "kick",
|
||||||
|
user_id: request.user_id,
|
||||||
|
reason: "Banned: " + (request.reason || "")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in ban list (for quick lookup)
|
||||||
|
const banRecord = {
|
||||||
|
user_id: request.user_id,
|
||||||
|
username: targetAccount.user.username,
|
||||||
|
banned_by: ctx.userId,
|
||||||
|
banned_at: new Date().toISOString(),
|
||||||
|
reason: request.reason,
|
||||||
|
expires: banExpires
|
||||||
|
};
|
||||||
|
|
||||||
|
nk.storageWrite([{
|
||||||
|
collection: "bans",
|
||||||
|
key: request.user_id,
|
||||||
|
userId: "00000000-0000-0000-0000-000000000000", // System-owned
|
||||||
|
value: banRecord,
|
||||||
|
permissionRead: 2, // Public read
|
||||||
|
permissionWrite: 0 // No one can write (except server)
|
||||||
|
}]);
|
||||||
|
|
||||||
|
logger.warn(`Player ${request.user_id} banned by ${ctx.userId}. Reason: ${request.reason}`);
|
||||||
|
|
||||||
|
return JSON.stringify({ success: true, ban: banRecord });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to ban player: ${e}`);
|
||||||
|
throw new Error(`Failed to ban player: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnbanPlayerRequest {
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcAdminUnbanPlayer(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
const request = JSON.parse(payload) as UnbanPlayerRequest;
|
||||||
|
|
||||||
|
requireAdmin(ctx, nk);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get target user's account
|
||||||
|
const targetAccount = nk.accountGetId(request.user_id);
|
||||||
|
const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata;
|
||||||
|
|
||||||
|
// Remove ban
|
||||||
|
delete metadata.banned;
|
||||||
|
delete metadata.ban_reason;
|
||||||
|
delete metadata.ban_expires;
|
||||||
|
|
||||||
|
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
|
||||||
|
|
||||||
|
// Remove from ban list
|
||||||
|
nk.storageDelete([{
|
||||||
|
collection: "bans",
|
||||||
|
key: request.user_id,
|
||||||
|
userId: "00000000-0000-0000-0000-000000000000"
|
||||||
|
}]);
|
||||||
|
|
||||||
|
logger.info(`Player ${request.user_id} unbanned by ${ctx.userId}`);
|
||||||
|
|
||||||
|
return JSON.stringify({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to unban player: ${e}`);
|
||||||
|
throw new Error("Failed to unban player");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcAdminGetBanList(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
requireAdmin(ctx, nk);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = nk.storageList(
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
"bans",
|
||||||
|
100,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
const bans = result.objects?.map(obj => obj.value) || [];
|
||||||
|
|
||||||
|
return JSON.stringify({ bans });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to get ban list: ${e}`);
|
||||||
|
return JSON.stringify({ bans: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetServerStatsRequest {
|
||||||
|
match_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcAdminGetServerStats(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
const request = JSON.parse(payload || "{}") as GetServerStatsRequest;
|
||||||
|
|
||||||
|
if (request.match_id) {
|
||||||
|
requireAdminOrHost(ctx, nk, request.match_id);
|
||||||
|
} else {
|
||||||
|
requireAdmin(ctx, nk);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get server-wide stats
|
||||||
|
const matches = nk.matchList(100, true, null, null, null, null);
|
||||||
|
const activeMatchCount = matches?.length || 0;
|
||||||
|
|
||||||
|
let totalPlayers = 0;
|
||||||
|
if (matches) {
|
||||||
|
for (const match of matches) {
|
||||||
|
totalPlayers += match.size || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
active_matches: activeMatchCount,
|
||||||
|
total_players: totalPlayers,
|
||||||
|
server_time: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// If specific match requested, include match details
|
||||||
|
if (request.match_id) {
|
||||||
|
try {
|
||||||
|
const match = nk.matchGet(request.match_id);
|
||||||
|
if (match) {
|
||||||
|
(stats as any).match = {
|
||||||
|
id: match.matchId,
|
||||||
|
size: match.size,
|
||||||
|
tick_rate: match.tickRate,
|
||||||
|
authoritative: match.authoritative
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Match not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(stats);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to get server stats: ${e}`);
|
||||||
|
throw new Error("Failed to get server stats");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetPlayerListRequest {
|
||||||
|
match_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcAdminGetPlayerList(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
const request = JSON.parse(payload) as GetPlayerListRequest;
|
||||||
|
|
||||||
|
requireAdminOrHost(ctx, nk, request.match_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const match = nk.matchGet(request.match_id);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Match not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get player details
|
||||||
|
const players: any[] = [];
|
||||||
|
|
||||||
|
// Note: In actual implementation, you'd need to track presences
|
||||||
|
// This is a simplified version - adjust based on your match handler
|
||||||
|
|
||||||
|
return JSON.stringify({ players });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to get player list: ${e}`);
|
||||||
|
throw new Error("Failed to get player list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EndMatchRequest {
|
||||||
|
match_id: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcAdminEndMatch(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
const request = JSON.parse(payload) as EndMatchRequest;
|
||||||
|
|
||||||
|
requireAdminOrHost(ctx, nk, request.match_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Signal match to end
|
||||||
|
nk.matchSignal(request.match_id, JSON.stringify({
|
||||||
|
action: "end_match",
|
||||||
|
reason: request.reason || "Ended by admin"
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(`Match ${request.match_id} ended by ${ctx.userId}`);
|
||||||
|
|
||||||
|
return JSON.stringify({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to end match: ${e}`);
|
||||||
|
throw new Error("Failed to end match");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetUserRoleRequest {
|
||||||
|
user_id: string;
|
||||||
|
role: string; // "player", "moderator", "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcAdminSetUserRole(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
const request = JSON.parse(payload) as SetUserRoleRequest;
|
||||||
|
|
||||||
|
// Only owner/super-admin can set roles
|
||||||
|
const callerAccount = nk.accountGetId(ctx.userId!);
|
||||||
|
const callerMetadata = JSON.parse(callerAccount.user.metadata || "{}");
|
||||||
|
|
||||||
|
if (callerMetadata.role !== "owner") {
|
||||||
|
throw new Error("Only owners can modify user roles");
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRoles = ["player", "moderator", "admin"];
|
||||||
|
if (!validRoles.includes(request.role)) {
|
||||||
|
throw new Error("Invalid role");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetAccount = nk.accountGetId(request.user_id);
|
||||||
|
const metadata = JSON.parse(targetAccount.user.metadata || "{}");
|
||||||
|
|
||||||
|
metadata.role = request.role;
|
||||||
|
|
||||||
|
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
|
||||||
|
|
||||||
|
logger.info(`User ${request.user_id} role set to ${request.role} by ${ctx.userId}`);
|
||||||
|
|
||||||
|
return JSON.stringify({ success: true, role: request.role });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to set user role: ${e}`);
|
||||||
|
throw new Error("Failed to set user role");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// User Profile RPCs
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function rpcGetUserProfile(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
const request = JSON.parse(payload || "{}");
|
||||||
|
const targetUserId = request.user_id || ctx.userId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = nk.accountGetId(targetUserId!);
|
||||||
|
const metadata = JSON.parse(account.user.metadata || "{}");
|
||||||
|
|
||||||
|
// Check if banned
|
||||||
|
if (metadata.banned && targetUserId === ctx.userId) {
|
||||||
|
// Check if ban expired
|
||||||
|
if (metadata.ban_expires) {
|
||||||
|
const expiresAt = new Date(metadata.ban_expires);
|
||||||
|
if (expiresAt <= new Date()) {
|
||||||
|
// Ban expired, remove it
|
||||||
|
delete metadata.banned;
|
||||||
|
delete metadata.ban_reason;
|
||||||
|
delete metadata.ban_expires;
|
||||||
|
nk.accountUpdateId(targetUserId!, null, null, null, null, null, null, JSON.stringify(metadata));
|
||||||
|
} else {
|
||||||
|
throw new Error(`Account banned until ${metadata.ban_expires}. Reason: ${metadata.ban_reason}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Account permanently banned. Reason: ${metadata.ban_reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
user_id: account.user.id,
|
||||||
|
username: account.user.username,
|
||||||
|
display_name: account.user.displayName,
|
||||||
|
avatar_url: account.user.avatarUrl,
|
||||||
|
create_time: account.user.createTime,
|
||||||
|
role: metadata.role || "player"
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateProfileRequest {
|
||||||
|
display_name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcUpdateUserProfile(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
payload: string
|
||||||
|
): string {
|
||||||
|
if (!ctx.userId) {
|
||||||
|
throw new Error("Not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = JSON.parse(payload) as UpdateProfileRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
nk.accountUpdateId(
|
||||||
|
ctx.userId,
|
||||||
|
null, // username
|
||||||
|
request.display_name || null,
|
||||||
|
null, // timezone
|
||||||
|
null, // location
|
||||||
|
null, // lang
|
||||||
|
request.avatar_url || null,
|
||||||
|
null // metadata
|
||||||
|
);
|
||||||
|
|
||||||
|
return JSON.stringify({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to update profile: ${e}`);
|
||||||
|
throw new Error("Failed to update profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before login hook to check ban status
|
||||||
|
function beforeAuthenticateEmail(
|
||||||
|
ctx: nkruntime.Context,
|
||||||
|
logger: nkruntime.Logger,
|
||||||
|
nk: nkruntime.Nakama,
|
||||||
|
data: nkruntime.AuthenticateEmailRequest
|
||||||
|
): nkruntime.AuthenticateEmailRequest {
|
||||||
|
// Can't check ban before auth, so we check in afterAuthenticate
|
||||||
|
return data;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user