feat: Implement core multiplayer features including user authentication, profile management, lobby, game mode managers, and leaderboard.

This commit is contained in:
2026-03-12 03:55:20 +08:00
parent 650d241a72
commit 4f6783b468
13 changed files with 1151 additions and 31 deletions
+183 -11
View File
@@ -12,6 +12,9 @@ extends Control
@onready var server_option = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerOption @onready var server_option = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerOption
@onready var server_ip_input = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerIPInput @onready var server_ip_input = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerIPInput
# Leaderboard Reference
@onready var leaderboard_btn = $MainMenuPanel/VBoxContainer/ButtonSection/LeaderboardBtn
# UI References - Room List # UI References - Room List
@onready var room_list_panel = $RoomListPanel @onready var room_list_panel = $RoomListPanel
@onready var room_list = $RoomListPanel/VBoxContainer/RoomList @onready var room_list = $RoomListPanel/VBoxContainer/RoomList
@@ -42,9 +45,23 @@ extends Control
@onready var enable_timer_label = $LobbyPanel/TopBar/SettingsSection/EnableTimerLabel @onready var enable_timer_label = $LobbyPanel/TopBar/SettingsSection/EnableTimerLabel
@onready var scarcity_option = $LobbyPanel/TopBar/SettingsSection/ScarcityOption @onready var scarcity_option = $LobbyPanel/TopBar/SettingsSection/ScarcityOption
@onready var scarcity_label = $LobbyPanel/TopBar/SettingsSection/ScarcityLabel @onready var scarcity_label = $LobbyPanel/TopBar/SettingsSection/ScarcityLabel
@onready var scarcity_spacer = $LobbyPanel/TopBar/SettingsSection/ScarcitySpacer
@onready var spawn_spacer = $LobbyPanel/TopBar/SettingsSection/SpawnSpacer
@onready var timer_spacer = $LobbyPanel/TopBar/SettingsSection/TimerSpacer
@onready var game_mode_option = $LobbyPanel/TopBar/SettingsSection/GameModeOption @onready var game_mode_option = $LobbyPanel/TopBar/SettingsSection/GameModeOption
@onready var game_mode_text_label = $LobbyPanel/TopBar/SettingsSection/GameModeTextLabel @onready var game_mode_text_label = $LobbyPanel/TopBar/SettingsSection/GameModeTextLabel
# Custom Settings Containers
var sng_settings_container: HBoxContainer
var sng_go_option: OptionButton
var sng_stop_option: OptionButton
var sng_goals_option: OptionButton
var doors_settings_container: HBoxContainer
var doors_swap_option: OptionButton
var doors_refresh_option: OptionButton
var doors_goals_option: OptionButton
# UI References - Player Slots # UI References - Player Slots
@onready var players_container = $LobbyPanel/PlayersContainer @onready var players_container = $LobbyPanel/PlayersContainer
@onready var players_container2 = $LobbyPanel/PlayersContainer2 @onready var players_container2 = $LobbyPanel/PlayersContainer2
@@ -75,6 +92,8 @@ var admin_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 = ""
var leaderboard_panel_instance: Control
# Server Selection Controls (Now in tscn) # Server Selection Controls (Now in tscn)
# var server_option: OptionButton # var server_option: OptionButton
# var server_ip_input: LineEdit # var server_ip_input: LineEdit
@@ -92,6 +111,9 @@ func _ready():
# Get player slot references # Get player slot references
_setup_player_slots() _setup_player_slots()
# Setup Game Mode specific UI dynamically
_create_custom_settings_ui()
# Set player name from profile and configure input visibility # Set player name from profile and configure input visibility
if player_name_input: if player_name_input:
# Get the parent container for the input to hide/show properly # Get the parent container for the input to hide/show properly
@@ -118,6 +140,8 @@ func _ready():
main_menu_profile_btn.pressed.connect(_on_profile_btn_pressed) main_menu_profile_btn.pressed.connect(_on_profile_btn_pressed)
if lobby_settings_btn: if lobby_settings_btn:
lobby_settings_btn.pressed.connect(_on_settings_pressed) lobby_settings_btn.pressed.connect(_on_settings_pressed)
if leaderboard_btn:
leaderboard_btn.pressed.connect(_on_leaderboard_pressed)
# Connect Server Selection signals # Connect Server Selection signals
if server_option: if server_option:
@@ -176,6 +200,13 @@ func _ready():
LobbyManager.game_mode_changed.connect(_on_game_mode_changed) LobbyManager.game_mode_changed.connect(_on_game_mode_changed)
LobbyManager.player_list_changed.connect(_update_player_slots) LobbyManager.player_list_changed.connect(_update_player_slots)
LobbyManager.sng_go_duration_changed.connect(_on_sng_update)
LobbyManager.sng_stop_duration_changed.connect(_on_sng_update)
LobbyManager.sng_required_goals_changed.connect(_on_sng_update)
LobbyManager.doors_swap_time_changed.connect(_on_doors_update)
LobbyManager.doors_refresh_time_changed.connect(_on_doors_update)
LobbyManager.doors_required_goals_changed.connect(_on_doors_update)
# Connect NakamaManager signals # Connect NakamaManager signals
NakamaManager.connected_to_nakama.connect(_on_connected_to_nakama) NakamaManager.connected_to_nakama.connect(_on_connected_to_nakama)
NakamaManager.connection_failed.connect(_on_connection_failed) NakamaManager.connection_failed.connect(_on_connection_failed)
@@ -331,6 +362,116 @@ func _on_back_pressed() -> void:
_show_panel("main_menu") _show_panel("main_menu")
connection_status.text = "" connection_status.text = ""
func _update_settings_visibility() -> void:
var is_host = LobbyManager.is_host
var is_freemode = LobbyManager.game_mode == "Freemode"
# Duration
var show_duration = is_freemode
duration_option.visible = is_host and show_duration
duration_text_label.visible = not is_host and show_duration
$LobbyPanel/TopBar/SettingsSection/DurationLabel.visible = show_duration
# Random Spawn
var show_spawn = is_freemode
random_spawn_check.visible = is_host and show_spawn
random_spawn_label.visible = not is_host and show_spawn
if spawn_spacer: spawn_spacer.visible = show_spawn
# Timer
var show_timer = is_freemode
enable_timer_check.visible = is_host and show_timer
enable_timer_label.visible = not is_host and show_timer
if timer_spacer: timer_spacer.visible = show_timer
# Scarcity
var show_scarcity = is_freemode
if scarcity_option: scarcity_option.visible = is_host and show_scarcity
if scarcity_label: scarcity_label.visible = not is_host and show_scarcity
if scarcity_spacer: scarcity_spacer.visible = show_scarcity
# Custom mode sets
var is_sng = LobbyManager.game_mode == "Stop n Go"
if sng_settings_container:
sng_settings_container.visible = is_sng
sng_go_option.disabled = not is_host
sng_stop_option.disabled = not is_host
sng_goals_option.disabled = not is_host
var is_doors = LobbyManager.game_mode == "Tekton Doors"
if doors_settings_container:
doors_settings_container.visible = is_doors
doors_swap_option.disabled = not is_host
doors_refresh_option.disabled = not is_host
doors_goals_option.disabled = not is_host
func _create_custom_settings_ui() -> void:
var settings_section = $LobbyPanel/TopBar/SettingsSection
if not settings_section: return
# Stop n Go
sng_settings_container = HBoxContainer.new()
sng_settings_container.visible = false
settings_section.add_child(sng_settings_container)
_add_label(sng_settings_container, "Go Time:")
sng_go_option = OptionButton.new()
sng_go_option.add_item("10s"); sng_go_option.add_item("15s"); sng_go_option.add_item("25s")
sng_go_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_sng_go_duration([10, 15, 25][idx]))
sng_settings_container.add_child(sng_go_option)
_add_label(sng_settings_container, "Stop Time:")
sng_stop_option = OptionButton.new()
sng_stop_option.add_item("3s"); sng_stop_option.add_item("4s"); sng_stop_option.add_item("5s")
sng_stop_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_sng_stop_duration([3, 4, 5][idx]))
sng_settings_container.add_child(sng_stop_option)
_add_label(sng_settings_container, "Req Goals:")
sng_goals_option = OptionButton.new()
sng_goals_option.add_item("5"); sng_goals_option.add_item("8"); sng_goals_option.add_item("12")
sng_goals_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_sng_required_goals([5, 8, 12][idx]))
sng_settings_container.add_child(sng_goals_option)
# Tekton Doors
doors_settings_container = HBoxContainer.new()
doors_settings_container.visible = false
settings_section.add_child(doors_settings_container)
_add_label(doors_settings_container, "Swap Wait:")
doors_swap_option = OptionButton.new()
doors_swap_option.add_item("10s"); doors_swap_option.add_item("15s"); doors_swap_option.add_item("30s")
doors_swap_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_doors_swap_time([10, 15, 30][idx]))
doors_settings_container.add_child(doors_swap_option)
_add_label(doors_settings_container, "Tile Refresh:")
doors_refresh_option = OptionButton.new()
doors_refresh_option.add_item("15s"); doors_refresh_option.add_item("25s"); doors_refresh_option.add_item("40s")
doors_refresh_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_doors_refresh_time([15, 25, 40][idx]))
doors_settings_container.add_child(doors_refresh_option)
_add_label(doors_settings_container, "Req Goals:")
doors_goals_option = OptionButton.new()
doors_goals_option.add_item("5"); doors_goals_option.add_item("8"); doors_goals_option.add_item("12")
doors_goals_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_doors_required_goals([5, 8, 12][idx]))
doors_settings_container.add_child(doors_goals_option)
# Move Game Mode selector to the far right
var gm_spacer = settings_section.get_node_or_null("GameModeSpacer")
var gm_option = settings_section.get_node_or_null("GameModeOption")
var gm_label = settings_section.get_node_or_null("GameModeTextLabel")
if gm_spacer: settings_section.move_child(gm_spacer, -1)
if gm_option: settings_section.move_child(gm_option, -1)
if gm_label: settings_section.move_child(gm_label, -1)
func _add_label(parent: Control, text: String):
var spacer = Control.new()
spacer.custom_minimum_size = Vector2(10, 0)
parent.add_child(spacer)
var lbl = Label.new()
lbl.text = text
parent.add_child(lbl)
# ============================================================================= # =============================================================================
# Lobby Button Handlers # Lobby Button Handlers
# ============================================================================= # =============================================================================
@@ -393,7 +534,32 @@ func _on_game_mode_changed(mode: String) -> void:
break break
if game_mode_text_label: if game_mode_text_label:
game_mode_text_label.text = mode game_mode_text_label.text = mode
_update_settings_visibility()
func _on_sng_update(_val: int = 0) -> void:
if not sng_go_option: return
var go_idx = [10, 15, 25].find(LobbyManager.sng_go_duration)
if go_idx != -1: sng_go_option.selected = go_idx
var stop_idx = [3, 4, 5].find(LobbyManager.sng_stop_duration)
if stop_idx != -1: sng_stop_option.selected = stop_idx
var goals_idx = [5, 8, 12].find(LobbyManager.sng_required_goals)
if goals_idx != -1: sng_goals_option.selected = goals_idx
func _on_doors_update(_val: int = 0) -> void:
if not doors_swap_option: return
var swap_idx = [10, 15, 30].find(LobbyManager.doors_swap_time)
if swap_idx != -1: doors_swap_option.selected = swap_idx
var refresh_idx = [15, 25, 40].find(LobbyManager.doors_refresh_time)
if refresh_idx != -1: doors_refresh_option.selected = refresh_idx
var goals_idx = [5, 8, 12].find(LobbyManager.doors_required_goals)
if goals_idx != -1: doors_goals_option.selected = goals_idx
func _update_random_spawn_label(enabled: bool) -> void: func _update_random_spawn_label(enabled: bool) -> void:
if random_spawn_label: if random_spawn_label:
@@ -431,6 +597,18 @@ func _on_settings_pressed():
if settings_menu: if settings_menu:
settings_menu.open() settings_menu.open()
func _on_leaderboard_pressed() -> void:
if not leaderboard_panel_instance:
var leaderboard_panel_scene := load("res://scenes/ui/leaderboard_panel.tscn")
if leaderboard_panel_scene:
leaderboard_panel_instance = leaderboard_panel_scene.instantiate()
leaderboard_panel_instance.closed.connect(func(): leaderboard_panel_instance.hide())
add_child(leaderboard_panel_instance)
if leaderboard_panel_instance:
leaderboard_panel_instance.show_panel()
# Center it and apply some offset if needed
func _go_to_login() -> void: func _go_to_login() -> void:
if get_tree(): if get_tree():
get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn") get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn")
@@ -463,15 +641,7 @@ func _on_room_joined(room_data: Dictionary) -> void:
host_banner.visible = is_host host_banner.visible = is_host
start_game_btn.visible = is_host start_game_btn.visible = is_host
# Duration: host sees dropdown, clients see text _update_settings_visibility()
duration_option.visible = is_host
duration_text_label.visible = not is_host
random_spawn_check.visible = is_host
random_spawn_label.visible = not is_host
enable_timer_check.visible = is_host
enable_timer_label.visible = not is_host
# Update values from LobbyManager # Update values from LobbyManager
_on_match_duration_changed(LobbyManager.match_duration) _on_match_duration_changed(LobbyManager.match_duration)
@@ -479,10 +649,12 @@ func _on_room_joined(room_data: Dictionary) -> void:
_on_enable_cycle_timer_changed(LobbyManager.enable_cycle_timer) _on_enable_cycle_timer_changed(LobbyManager.enable_cycle_timer)
# Scarcity Update # Scarcity Update
if scarcity_option: scarcity_option.visible = is_host
if scarcity_label: scarcity_label.visible = not is_host
_on_scarcity_mode_changed(LobbyManager.scarcity_mode) _on_scarcity_mode_changed(LobbyManager.scarcity_mode)
# Initial UI sync for custom modes
_on_sng_update()
_on_doors_update()
# Area selector: only host can interact # Area selector: only host can interact
area_left_btn.disabled = not is_host area_left_btn.disabled = not is_host
area_right_btn.disabled = not is_host area_right_btn.disabled = not is_host
+6
View File
@@ -138,6 +138,12 @@ layout_mode = 2
theme_override_font_sizes/font_size = 16 theme_override_font_sizes/font_size = 16
text = "BROWSE ROOMS" text = "BROWSE ROOMS"
[node name="LeaderboardBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ButtonSection"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
theme_override_font_sizes/font_size = 16
text = "LEADERBOARD"
[node name="SettingsBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ButtonSection" unique_id=123456789] [node name="SettingsBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ButtonSection" unique_id=123456789]
custom_minimum_size = Vector2(0, 48) custom_minimum_size = Vector2(0, 48)
layout_mode = 2 layout_mode = 2
+84
View File
@@ -0,0 +1,84 @@
[gd_scene format=3 uid="uid://c3q4w3j6e7y8u"]
[ext_resource type="Script" uid="uid://d1e2f3g4h5i6j" path="res://scripts/ui/leaderboard_panel.gd" id="1"]
[ext_resource type="Theme" uid="uid://da337sh5qxi0s" path="res://assets/themes/ui_theme.tres" id="2"]
[node name="LeaderboardPanel" type="PanelContainer"]
custom_minimum_size = Vector2(500, 400)
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -250.0
offset_top = -200.0
offset_right = 250.0
offset_bottom = 200.0
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("2")
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.647, 0.996, 0.224, 1)
theme_override_font_sizes/font_size = 24
text = "LEADERBOARD"
horizontal_alignment = 1
[node name="CloseBtn" type="Button" parent="VBox/Header"]
unique_name_in_owner = true
custom_minimum_size = Vector2(32, 32)
layout_mode = 2
text = "X"
[node name="SortTabs" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 8
alignment = 1
[node name="SortScoreBtn" type="Button" parent="VBox/SortTabs"]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 36)
layout_mode = 2
text = "High Score"
[node name="SortWinRateBtn" type="Button" parent="VBox/SortTabs"]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 36)
layout_mode = 2
text = "Win Rate"
[node name="SortGamesBtn" type="Button" parent="VBox/SortTabs"]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 36)
layout_mode = 2
text = "Games Played"
[node name="HSeparator" type="HSeparator" parent="VBox"]
layout_mode = 2
[node name="ScrollContainer" type="ScrollContainer" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="LeaderboardList" type="VBoxContainer" parent="VBox/ScrollContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="StatusLabel" type="Label" parent="VBox"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.69, 0.529, 0.357, 1)
text = "Loading data..."
horizontal_alignment = 1
+18
View File
@@ -208,6 +208,24 @@ theme_override_font_sizes/font_size = 11
text = "Min 8 characters, include number and symbol" text = "Min 8 characters, include number and symbol"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="RegCaptchaContainer" type="VBoxContainer" parent="CenterContainer/RegistrationPanel/VBox"]
layout_mode = 2
theme_override_constants/separation = 4
[node name="RegCaptchaQuestion" type="Label" parent="CenterContainer/RegistrationPanel/VBox/RegCaptchaContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.647, 0.996, 0.224, 1)
text = "Security Check: 5 + 7 = ?"
horizontal_alignment = 1
[node name="RegCaptchaInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox/RegCaptchaContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
placeholder_text = "Answer"
alignment = 1
[node name="RegisterButton" type="Button" parent="CenterContainer/RegistrationPanel/VBox"] [node name="RegisterButton" type="Button" parent="CenterContainer/RegistrationPanel/VBox"]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48) custom_minimum_size = Vector2(0, 48)
+101
View File
@@ -19,6 +19,16 @@ signal character_changed(player_id: int, character_name: String)
signal area_changed(area_name: String) signal area_changed(area_name: String)
signal player_list_changed() signal player_list_changed()
# Stop N Go settings signals
signal sng_go_duration_changed(duration: int)
signal sng_stop_duration_changed(duration: int)
signal sng_required_goals_changed(goals: int)
# Tekton Doors settings signals
signal doors_swap_time_changed(time: int)
signal doors_refresh_time_changed(time: int)
signal doors_required_goals_changed(goals: int)
# Room data structure # Room data structure
var current_room: Dictionary = {} var current_room: Dictionary = {}
var players_in_room: Array = [] # [{id, name, is_ready}] var players_in_room: Array = [] # [{id, name, is_ready}]
@@ -40,6 +50,16 @@ signal enable_cycle_timer_changed(enabled: bool)
var scarcity_mode: String = "Normal" # Normal, Aggressive, Chaos var scarcity_mode: String = "Normal" # Normal, Aggressive, Chaos
signal scarcity_mode_changed(mode: String) signal scarcity_mode_changed(mode: String)
# Stop N Go settings
var sng_go_duration: int = 15
var sng_stop_duration: int = 4
var sng_required_goals: int = 8
# Tekton Doors settings
var doors_swap_time: int = 15
var doors_refresh_time: int = 25
var doors_required_goals: int = 8
# Character and area selection # Character and area selection
var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"] var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"]
var available_areas: Array[String] = [] var available_areas: Array[String] = []
@@ -259,6 +279,68 @@ func sync_scarcity_mode(mode: String) -> void:
func get_scarcity_mode() -> String: func get_scarcity_mode() -> String:
return scarcity_mode return scarcity_mode
# =============================================================================
# Stop N Go Settings
# =============================================================================
func set_sng_go_duration(duration: int) -> void:
sng_go_duration = duration
if is_host: rpc("sync_sng_go_duration", duration)
@rpc("authority", "call_local", "reliable")
func sync_sng_go_duration(duration: int) -> void:
sng_go_duration = duration
emit_signal("sng_go_duration_changed", duration)
func set_sng_stop_duration(duration: int) -> void:
sng_stop_duration = duration
if is_host: rpc("sync_sng_stop_duration", duration)
@rpc("authority", "call_local", "reliable")
func sync_sng_stop_duration(duration: int) -> void:
sng_stop_duration = duration
emit_signal("sng_stop_duration_changed", duration)
func set_sng_required_goals(goals: int) -> void:
sng_required_goals = goals
if is_host: rpc("sync_sng_required_goals", goals)
@rpc("authority", "call_local", "reliable")
func sync_sng_required_goals(goals: int) -> void:
sng_required_goals = goals
emit_signal("sng_required_goals_changed", goals)
# =============================================================================
# Tekton Doors Settings
# =============================================================================
func set_doors_swap_time(time: int) -> void:
doors_swap_time = time
if is_host: rpc("sync_doors_swap_time", time)
@rpc("authority", "call_local", "reliable")
func sync_doors_swap_time(time: int) -> void:
doors_swap_time = time
emit_signal("doors_swap_time_changed", time)
func set_doors_refresh_time(time: int) -> void:
doors_refresh_time = time
if is_host: rpc("sync_doors_refresh_time", time)
@rpc("authority", "call_local", "reliable")
func sync_doors_refresh_time(time: int) -> void:
doors_refresh_time = time
emit_signal("doors_refresh_time_changed", time)
func set_doors_required_goals(goals: int) -> void:
doors_required_goals = goals
if is_host: rpc("sync_doors_required_goals", goals)
@rpc("authority", "call_local", "reliable")
func sync_doors_required_goals(goals: int) -> void:
doors_required_goals = goals
emit_signal("doors_required_goals_changed", goals)
# ============================================================================= # =============================================================================
# Character Selection # Character Selection
# ============================================================================= # =============================================================================
@@ -430,6 +512,13 @@ func start_game() -> void:
rpc("sync_enable_cycle_timer", enable_cycle_timer) rpc("sync_enable_cycle_timer", enable_cycle_timer)
# Sync scarcity mode # Sync scarcity mode
rpc("sync_scarcity_mode", scarcity_mode) rpc("sync_scarcity_mode", scarcity_mode)
# Sync game mode features
rpc("sync_sng_go_duration", sng_go_duration)
rpc("sync_sng_stop_duration", sng_stop_duration)
rpc("sync_sng_required_goals", sng_required_goals)
rpc("sync_doors_swap_time", doors_swap_time)
rpc("sync_doors_refresh_time", doors_refresh_time)
rpc("sync_doors_required_goals", doors_required_goals)
# Sync game mode # Sync game mode
rpc("sync_game_mode", game_mode) rpc("sync_game_mode", game_mode)
@@ -492,6 +581,12 @@ func request_room_info(requester_id: int, requester_name: String, requester_char
rpc_id(requester_id, "sync_randomize_spawn", randomize_spawn) rpc_id(requester_id, "sync_randomize_spawn", randomize_spawn)
rpc_id(requester_id, "sync_enable_cycle_timer", enable_cycle_timer) rpc_id(requester_id, "sync_enable_cycle_timer", enable_cycle_timer)
rpc_id(requester_id, "sync_scarcity_mode", scarcity_mode) rpc_id(requester_id, "sync_scarcity_mode", scarcity_mode)
rpc_id(requester_id, "sync_sng_go_duration", sng_go_duration)
rpc_id(requester_id, "sync_sng_stop_duration", sng_stop_duration)
rpc_id(requester_id, "sync_sng_required_goals", sng_required_goals)
rpc_id(requester_id, "sync_doors_swap_time", doors_swap_time)
rpc_id(requester_id, "sync_doors_refresh_time", doors_refresh_time)
rpc_id(requester_id, "sync_doors_required_goals", doors_required_goals)
rpc_id(requester_id, "sync_game_mode", game_mode) rpc_id(requester_id, "sync_game_mode", game_mode)
rpc_id(requester_id, "sync_area", selected_area) rpc_id(requester_id, "sync_area", selected_area)
@@ -568,3 +663,9 @@ func reset() -> void:
selected_area = available_areas[0] selected_area = available_areas[0]
local_character_index = 0 # Default to "Copper" local_character_index = 0 # Default to "Copper"
enable_cycle_timer = false enable_cycle_timer = false
sng_go_duration = 15
sng_stop_duration = 4
sng_required_goals = 8
doors_swap_time = 15
doors_refresh_time = 25
doors_required_goals = 8
+9 -4
View File
@@ -17,7 +17,6 @@ var doors = [] # List of PortalDoor nodes
var swap_timer: Timer var swap_timer: Timer
var tile_refresh_timer: Timer var tile_refresh_timer: Timer
var finish_spawned: bool = false var finish_spawned: bool = false
var missions_required: int = 8
var arena_setup_done: bool = false var arena_setup_done: bool = false
var player_portal_cooldowns: Dictionary = {} var player_portal_cooldowns: Dictionary = {}
@@ -41,6 +40,7 @@ func initialize(p_main: Node, p_gridmap: Node):
# Connection Swap Timer (15s) # Connection Swap Timer (15s)
swap_timer = Timer.new() swap_timer = Timer.new()
swap_timer.name = "PortalSwapTimer" swap_timer.name = "PortalSwapTimer"
# Initial wait time; gets reset when started based on game mode settings
swap_timer.wait_time = 15.0 swap_timer.wait_time = 15.0
swap_timer.timeout.connect(_on_swap_timer_timeout) swap_timer.timeout.connect(_on_swap_timer_timeout)
add_child(swap_timer) add_child(swap_timer)
@@ -48,6 +48,7 @@ func initialize(p_main: Node, p_gridmap: Node):
# Tile Refresh Timer (25s) # Tile Refresh Timer (25s)
tile_refresh_timer = Timer.new() tile_refresh_timer = Timer.new()
tile_refresh_timer.name = "TileRefreshTimer" tile_refresh_timer.name = "TileRefreshTimer"
# Initial wait time; gets reset when started based on game mode settings
tile_refresh_timer.wait_time = 25.0 tile_refresh_timer.wait_time = 25.0
tile_refresh_timer.timeout.connect(_on_tile_refresh_timer_timeout) tile_refresh_timer.timeout.connect(_on_tile_refresh_timer_timeout)
add_child(tile_refresh_timer) add_child(tile_refresh_timer)
@@ -77,6 +78,10 @@ func start_game_mode():
setup_arena_locally() setup_arena_locally()
_randomize_connections() _randomize_connections()
# Configure dynamic timings from LobbyManager before starting
swap_timer.wait_time = float(LobbyManager.doors_swap_time)
tile_refresh_timer.wait_time = float(LobbyManager.doors_refresh_time)
# Start Timers # Start Timers
if swap_timer.is_stopped(): if swap_timer.is_stopped():
swap_timer.start() swap_timer.start()
@@ -176,9 +181,9 @@ func _update_hud_visuals():
var gcm = main.get_node_or_null("GoalsCycleManager") var gcm = main.get_node_or_null("GoalsCycleManager")
var completed_count = gcm.player_goal_counts.get(my_id, 0) if gcm else 0 var completed_count = gcm.player_goal_counts.get(my_id, 0) if gcm else 0
mission_label.text = "GOALS (%d/%d)" % [completed_count, missions_required] mission_label.text = "GOALS (%d/%d)" % [completed_count, LobbyManager.doors_required_goals]
if completed_count >= missions_required: if completed_count >= LobbyManager.doors_required_goals:
mission_label.text = "ALL GOALS COMPLETE!\nFIND THE FINISH ROOM!" mission_label.text = "ALL GOALS COMPLETE!\nFIND THE FINISH ROOM!"
mission_label.add_theme_color_override("font_color", Color.GOLD) mission_label.add_theme_color_override("font_color", Color.GOLD)
@@ -194,7 +199,7 @@ func _update_hud_visuals():
func is_mission_complete(peer_id: int) -> bool: func is_mission_complete(peer_id: int) -> bool:
var gcm = main.get_node_or_null("GoalsCycleManager") var gcm = main.get_node_or_null("GoalsCycleManager")
if not gcm: return false if not gcm: return false
return gcm.player_goal_counts.get(peer_id, 0) >= missions_required return gcm.player_goal_counts.get(peer_id, 0) >= LobbyManager.doors_required_goals
func check_win_condition(player_id: int, pos: Vector2i) -> bool: func check_win_condition(player_id: int, pos: Vector2i) -> bool:
# 1. Check if on finish tile # 1. Check if on finish tile
+7 -10
View File
@@ -9,10 +9,6 @@ signal player_penalized(player_id: int)
enum Phase {GO, STOP} enum Phase {GO, STOP}
const GO_DURATION: float = 15.0
const STOP_DURATION: float = 4.0
const REQUIRED_GOALS: int = 8
# Dynamic Safe Zone # Dynamic Safe Zone
const SAFE_ZONE_PRE_TIME: float = 5.0 # Seconds before STOP to spawn safe zone const SAFE_ZONE_PRE_TIME: float = 5.0 # Seconds before STOP to spawn safe zone
const SAFE_ZONE_RADIUS: int = 2 # 5x5 area (radius 2 from center) const SAFE_ZONE_RADIUS: int = 2 # 5x5 area (radius 2 from center)
@@ -34,7 +30,7 @@ const PERMANENT_POWERUP_LOCATIONS: Array[Vector2i] = [
] ]
var current_phase: Phase = Phase.GO var current_phase: Phase = Phase.GO
var phase_timer: float = GO_DURATION var phase_timer: float = 15.0 # Initialized dynamically later
var is_active: bool = false var is_active: bool = false
var player_missions: Dictionary = {} # player_id -> {target_tile: int, required: int, current: int} var player_missions: Dictionary = {} # player_id -> {target_tile: int, required: int, current: int}
@@ -134,10 +130,11 @@ func _update_hud_visuals():
# Get count from GoalsCycleManager (Source of truth for PlayerBoardLabel) # Get count from GoalsCycleManager (Source of truth for PlayerBoardLabel)
var completed_count = goals_cycle_manager.player_goal_counts.get(my_id, 0) if goals_cycle_manager else 0 var completed_count = goals_cycle_manager.player_goal_counts.get(my_id, 0) if goals_cycle_manager else 0
var required_goals = LobbyManager.sng_required_goals
mission_label.text = "GOALS (%d/%d)" % [completed_count, REQUIRED_GOALS] mission_label.text = "GOALS (%d/%d)" % [completed_count, required_goals]
if completed_count >= REQUIRED_GOALS: if completed_count >= required_goals:
mission_label.text = "ALL GOALS COMPLETE!\nREACH THE FINISH!" mission_label.text = "ALL GOALS COMPLETE!\nREACH THE FINISH!"
mission_label.add_theme_color_override("font_color", Color.GOLD) mission_label.add_theme_color_override("font_color", Color.GOLD)
@@ -235,7 +232,7 @@ func start_game_mode():
func _start_phase(phase: Phase): func _start_phase(phase: Phase):
current_phase = phase current_phase = phase
phase_timer = GO_DURATION if phase == Phase.GO else STOP_DURATION phase_timer = float(LobbyManager.sng_go_duration) if phase == Phase.GO else float(LobbyManager.sng_stop_duration)
var phase_name = "GO" if phase == Phase.GO else "STOP" var phase_name = "GO" if phase == Phase.GO else "STOP"
if can_rpc(): if can_rpc():
@@ -436,7 +433,7 @@ func is_mission_complete(player_id: int) -> bool:
if not goals_cycle_manager: return false if not goals_cycle_manager: return false
var completed_count = goals_cycle_manager.player_goal_counts.get(player_id, 0) var completed_count = goals_cycle_manager.player_goal_counts.get(player_id, 0)
return completed_count >= REQUIRED_GOALS return completed_count >= LobbyManager.sng_required_goals
func check_win_condition(player_id: int, position: Vector2i) -> bool: func check_win_condition(player_id: int, position: Vector2i) -> bool:
# 1. Must reach the finish line (Column 21) # 1. Must reach the finish line (Column 21)
@@ -452,7 +449,7 @@ func check_win_condition(player_id: int, position: Vector2i) -> bool:
var main = get_node_or_null("/root/Main") var main = get_node_or_null("/root/Main")
var player_node = main.get_node_or_null(str(player_id)) if main else null var player_node = main.get_node_or_null(str(player_id)) if main else null
if player_node: if player_node:
NotificationManager.send_message(player_node, "Incomplete! Achieve %d goals to win!" % REQUIRED_GOALS, NotificationManager.MessageType.WARNING) NotificationManager.send_message(player_node, "Incomplete! Achieve %d goals to win!" % LobbyManager.sng_required_goals, NotificationManager.MessageType.WARNING)
print("[StopNGo] Player %d reached finish but goals incomplete." % player_id) print("[StopNGo] Player %d reached finish but goals incomplete." % player_id)
return false return false
+4 -6
View File
@@ -16,12 +16,10 @@ const STATS_COLLECTION := "stats"
# Available avatars (predefined) # Available avatars (predefined)
const AVATARS := [ const AVATARS := [
"res://assets/avatars/avatar_default.png", "res://assets/graphics/character_selection/sc_characters/sc_pip.png",
"res://assets/avatars/avatar_warrior.png", "res://assets/graphics/character_selection/sc_characters/sc_gatot.png",
"res://assets/avatars/avatar_mage.png", "res://assets/graphics/character_selection/sc_characters/sc_dabro.png",
"res://assets/avatars/avatar_rogue.png", "res://assets/graphics/character_selection/sc_characters/sc_copper.png"
"res://assets/avatars/avatar_tank.png",
"res://assets/avatars/avatar_healer.png",
] ]
func _ready() -> void: func _ready() -> void:
+170
View File
@@ -0,0 +1,170 @@
extends PanelContainer
signal closed
@onready var close_btn := %CloseBtn as Button
@onready var sort_score_btn := %SortScoreBtn as Button
@onready var sort_win_rate_btn := %SortWinRateBtn as Button
@onready var sort_games_btn := %SortGamesBtn as Button
@onready var leaderboard_list := %LeaderboardList as VBoxContainer
@onready var status_label := %StatusLabel as Label
var leaderboard_data: Array = []
var current_sort_key: String = "high_score"
func _ready() -> void:
close_btn.pressed.connect(_on_close_pressed)
sort_score_btn.pressed.connect(func(): _sort_by("high_score"))
sort_win_rate_btn.pressed.connect(func(): _sort_by("win_rate"))
sort_games_btn.pressed.connect(func(): _sort_by("games_played"))
_update_tab_visuals()
func show_panel() -> void:
show()
_fetch_leaderboard_data()
func _on_close_pressed() -> void:
hide()
emit_signal("closed")
func _fetch_leaderboard_data() -> void:
if not NakamaManager.session:
status_label.text = "Not connected to Nakama"
return
status_label.text = "Loading data..."
# Clear existing items
for child in leaderboard_list.get_children():
child.queue_free()
var payload = JSON.stringify({})
var result = await NakamaManager.client.rpc_async(NakamaManager.session, "get_leaderboard_stats", payload)
if result.is_exception():
status_label.text = "Failed to load leaderboard"
push_error("[Leaderboard] RPC failed: ", result.get_exception().message)
return
var response_text = result.payload
var json = JSON.new()
var error = json.parse(response_text)
if error == OK:
var data = json.get_data()
if data.has("leaderboard"):
leaderboard_data = data.leaderboard
_calculate_win_rates()
status_label.text = ""
_sort_by(current_sort_key)
else:
status_label.text = "No data found"
else:
status_label.text = "Error parsing data"
func _calculate_win_rates() -> void:
for entry in leaderboard_data:
var played = entry.get("games_played", 0)
var won = entry.get("games_won", 0)
if played > 0:
entry["win_rate"] = float(won) / float(played) * 100.0
else:
entry["win_rate"] = 0.0
func _sort_by(key: String) -> void:
current_sort_key = key
_update_tab_visuals()
leaderboard_data.sort_custom(func(a, b): return a.get(key, 0) > b.get(key, 0))
_populate_list()
func _update_tab_visuals() -> void:
var color_active = Color(0.647, 0.996, 0.224, 1)
var color_inactive = Color(0.69, 0.529, 0.357, 1)
sort_score_btn.add_theme_color_override("font_color", color_active if current_sort_key == "high_score" else color_inactive)
sort_win_rate_btn.add_theme_color_override("font_color", color_active if current_sort_key == "win_rate" else color_inactive)
sort_games_btn.add_theme_color_override("font_color", color_active if current_sort_key == "games_played" else color_inactive)
func _populate_list() -> void:
for child in leaderboard_list.get_children():
child.queue_free()
if leaderboard_data.size() == 0:
status_label.text = "No players found"
return
for i in range(leaderboard_data.size()):
var entry = leaderboard_data[i]
_create_leaderboard_item(i + 1, entry)
func _create_leaderboard_item(rank: int, entry: Dictionary) -> void:
var item = PanelContainer.new()
var style = StyleBoxFlat.new()
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
if rank <= 3:
style.bg_color = Color(0.2, 0.2, 0.15, 1.0) # Slightly highlight top 3
style.set_corner_radius_all(4)
style.content_margin_left = 10
style.content_margin_right = 10
style.content_margin_top = 8
style.content_margin_bottom = 8
item.add_theme_stylebox_override("panel", style)
var hbox = HBoxContainer.new()
hbox.theme_override_constants.separation = 16
item.add_child(hbox)
# Rank
var rank_label = Label.new()
rank_label.text = "#" + str(rank)
rank_label.custom_minimum_size = Vector2(40, 0)
if rank == 1:
rank_label.add_theme_color_override("font_color", Color.GOLD)
elif rank == 2:
rank_label.add_theme_color_override("font_color", Color.SILVER)
elif rank == 3:
rank_label.add_theme_color_override("font_color", Color.DARK_ORANGE)
else:
rank_label.add_theme_color_override("font_color", Color.LIGHT_GRAY)
hbox.add_child(rank_label)
# Avatar
var avatar_rect = TextureRect.new()
avatar_rect.custom_minimum_size = Vector2(32, 32)
avatar_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
var avatar_url = entry.get("avatar_url", UserProfileManager.AVATARS[0])
if avatar_url.is_empty() or not ResourceLoader.exists(avatar_url):
avatar_url = UserProfileManager.AVATARS[0]
avatar_rect.texture = load(avatar_url)
hbox.add_child(avatar_rect)
# Name
var name_label = Label.new()
name_label.text = entry.get("display_name", "Unknown")
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.add_theme_color_override("font_color", Color.WHITE)
hbox.add_child(name_label)
# Value based on current sort
var value_label = Label.new()
var color = Color(0.647, 0.996, 0.224, 1) # TEKTON green
match current_sort_key:
"high_score":
value_label.text = str(entry.get("high_score", 0))
"win_rate":
value_label.text = "%.1f%%" % entry.get("win_rate", 0.0)
"games_played":
value_label.text = str(entry.get("games_played", 0))
value_label.add_theme_color_override("font_color", color)
value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
value_label.custom_minimum_size = Vector2(80, 0)
hbox.add_child(value_label)
leaderboard_list.add_child(item)
+1
View File
@@ -0,0 +1 @@
uid://qe1nth1bnep8
+23
View File
@@ -22,10 +22,14 @@ extends Control
@onready var reg_confirm_password_input := %RegConfirmPasswordInput as LineEdit @onready var reg_confirm_password_input := %RegConfirmPasswordInput as LineEdit
@onready var password_strength := %PasswordStrength as ProgressBar @onready var password_strength := %PasswordStrength as ProgressBar
@onready var password_hint := %PasswordHint as Label @onready var password_hint := %PasswordHint as Label
@onready var reg_captcha_question := %RegCaptchaQuestion as Label
@onready var reg_captcha_input := %RegCaptchaInput as LineEdit
@onready var register_button := %RegisterButton as Button @onready var register_button := %RegisterButton as Button
@onready var back_to_login_link := %BackToLoginLink as LinkButton @onready var back_to_login_link := %BackToLoginLink as LinkButton
@onready var reg_status_label := %RegStatusLabel as Label @onready var reg_status_label := %RegStatusLabel as Label
var current_captcha_answer: int = 0
# Main panel reference # Main panel reference
@onready var main_panel := $CenterContainer/MainPanel as PanelContainer @onready var main_panel := $CenterContainer/MainPanel as PanelContainer
@@ -101,8 +105,16 @@ func _show_registration() -> void:
main_panel.visible = false main_panel.visible = false
registration_panel.visible = true registration_panel.visible = true
reg_status_label.text = "" reg_status_label.text = ""
_generate_captcha()
reg_email_input.grab_focus() reg_email_input.grab_focus()
func _generate_captcha() -> void:
var num1 := randi_range(1, 10)
var num2 := randi_range(1, 10)
current_captcha_answer = num1 + num2
reg_captcha_question.text = "Security Check: %d + %d = ?" % [num1, num2]
reg_captcha_input.text = ""
func _show_login() -> void: func _show_login() -> void:
registration_panel.visible = false registration_panel.visible = false
main_panel.visible = true main_panel.visible = true
@@ -254,6 +266,7 @@ func _on_register_pressed() -> void:
var username := reg_username_input.text.strip_edges() var username := reg_username_input.text.strip_edges()
var password := reg_password_input.text var password := reg_password_input.text
var confirm_password := reg_confirm_password_input.text var confirm_password := reg_confirm_password_input.text
var captcha_answer := reg_captcha_input.text.strip_edges()
# Validation # Validation
if email.is_empty(): if email.is_empty():
@@ -288,6 +301,15 @@ func _on_register_pressed() -> void:
_show_reg_error("Password is too weak. Add numbers or symbols.") _show_reg_error("Password is too weak. Add numbers or symbols.")
return return
if captcha_answer.is_empty():
_show_reg_error("Please solve the security check.")
return
if not captcha_answer.is_valid_int() or int(captcha_answer) != current_captcha_answer:
_show_reg_error("Incorrect security check answer.")
_generate_captcha()
return
AuthManager.register_with_email(email, password, username) AuthManager.register_with_email(email, password, username)
func _check_password_strength(password: String) -> void: func _check_password_strength(password: String) -> void:
@@ -411,6 +433,7 @@ func _set_inputs_enabled(enabled: bool) -> void:
reg_username_input.editable = enabled reg_username_input.editable = enabled
reg_password_input.editable = enabled reg_password_input.editable = enabled
reg_confirm_password_input.editable = enabled reg_confirm_password_input.editable = enabled
reg_captcha_input.editable = enabled
func _is_valid_email(email: String) -> bool: func _is_valid_email(email: String) -> bool:
# Simple email validation # Simple email validation
+486
View File
@@ -0,0 +1,486 @@
/**
* Tekton Nakama Server Runtime Module
*
* This module provides secure admin operations via RPC calls.
* Deploy this to your Nakama server's runtime directory.
*/
// Initialize RPC endpoints
function InitModule(ctx, logger, nk, 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);
// Leaderboard RPCs
initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats);
logger.info("Tekton admin module loaded");
}
// =============================================================================
// Authorization Helpers
// =============================================================================
var ADMIN_ROLES = ["admin", "moderator", "owner"];
function isAdmin(ctx, nk) {
if (!ctx.userId) return false;
try {
var account = nk.accountGetId(ctx.userId);
var metadata = JSON.parse(account.user.metadata || "{}");
return ADMIN_ROLES.indexOf(metadata.role || "") !== -1;
} catch (e) {
return false;
}
}
function isMatchHost(ctx, nk, matchId) {
if (!ctx.userId || !matchId) return false;
try {
// Get match state to check host
var 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
var state = JSON.parse(match.state || "{}");
return state.hostUserId === ctx.userId;
} catch (e) {
return false;
}
}
function requireAdmin(ctx, nk) {
if (!isAdmin(ctx, nk)) {
throw new Error("Admin privileges required");
}
}
function requireAdminOrHost(ctx, nk, matchId) {
if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) {
throw new Error("Admin or host privileges required");
}
}
// =============================================================================
// Admin RPCs
// =============================================================================
function rpcAdminKickPlayer(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
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");
}
}
function rpcAdminBanPlayer(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
// 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
var targetAccount = nk.accountGetId(request.user_id);
var metadata = JSON.parse(targetAccount.user.metadata || "{}");
// Don't allow banning other admins
if (ADMIN_ROLES.indexOf(metadata.role || "") !== -1) {
throw new Error("Cannot ban an admin");
}
// Set ban in metadata
var 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)
var 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);
}
}
function rpcAdminUnbanPlayer(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
requireAdmin(ctx, nk);
try {
// Get target user's account
var targetAccount = nk.accountGetId(request.user_id);
var metadata = JSON.parse(targetAccount.user.metadata || "{}");
// 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, logger, nk, payload) {
requireAdmin(ctx, nk);
try {
var result = nk.storageList(
"00000000-0000-0000-0000-000000000000",
"bans",
100,
""
);
var bans = result.objects ? result.objects.map(function(obj) { return obj.value; }) : [];
return JSON.stringify({ bans: bans });
} catch (e) {
logger.error("Failed to get ban list: " + e);
return JSON.stringify({ bans: [] });
}
}
function rpcAdminGetServerStats(ctx, logger, nk, payload) {
var request = JSON.parse(payload || "{}");
if (request.match_id) {
requireAdminOrHost(ctx, nk, request.match_id);
} else {
requireAdmin(ctx, nk);
}
try {
// Get server-wide stats
var matches = nk.matchList(100, true, null, null, null, null);
var activeMatchCount = matches ? matches.length : 0;
var totalPlayers = 0;
if (matches) {
for (var i = 0; i < matches.length; i++) {
totalPlayers += matches[i].size || 0;
}
}
var stats = {
active_matches: activeMatchCount,
total_players: totalPlayers,
server_time: new Date().toISOString()
};
// If specific match requested, include match details
if (request.match_id) {
try {
var match = nk.matchGet(request.match_id);
if (match) {
stats.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");
}
}
function rpcAdminGetPlayerList(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
requireAdminOrHost(ctx, nk, request.match_id);
try {
var match = nk.matchGet(request.match_id);
if (!match) {
throw new Error("Match not found");
}
// Get player details
var players = [];
// 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: players });
} catch (e) {
logger.error("Failed to get player list: " + e);
throw new Error("Failed to get player list");
}
}
function rpcAdminEndMatch(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
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");
}
}
function rpcAdminSetUserRole(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
// Only owner/super-admin can set roles
var callerAccount = nk.accountGetId(ctx.userId);
var callerMetadata = JSON.parse(callerAccount.user.metadata || "{}");
if (callerMetadata.role !== "owner") {
throw new Error("Only owners can modify user roles");
}
var validRoles = ["player", "moderator", "admin"];
if (validRoles.indexOf(request.role) === -1) {
throw new Error("Invalid role");
}
try {
var targetAccount = nk.accountGetId(request.user_id);
var 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, logger, nk, payload) {
var request = JSON.parse(payload || "{}");
var targetUserId = request.user_id || ctx.userId;
try {
var account = nk.accountGetId(targetUserId);
var metadata = JSON.parse(account.user.metadata || "{}");
// Check if banned
if (metadata.banned && targetUserId === ctx.userId) {
// Check if ban expired
if (metadata.ban_expires) {
var 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;
}
}
function rpcUpdateUserProfile(ctx, logger, nk, payload) {
if (!ctx.userId) {
throw new Error("Not authenticated");
}
var request = JSON.parse(payload);
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");
}
}
// =============================================================================
// Leaderboard RPCs
// =============================================================================
function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
try {
// Query the "stats" collection to get all user stats
// Warning: For a large production game this should be indexed using Nakama's actual Leaderboard system,
// but this works for listing all users' custom storage stats.
var limit = 100;
var result = nk.storageList(null, "stats", limit, "");
var statsObjects = result.objects || [];
var leaderboardData = [];
for (var i = 0; i < statsObjects.length; i++) {
var obj = statsObjects[i];
try {
var stats = JSON.parse(obj.value);
var userId = obj.userId;
// Get the user's profile to retrieve their display name
var displayName = "Unknown";
var avatarUrl = "";
try {
var account = nk.accountGetId(userId);
displayName = account.user.displayName || account.user.username;
avatarUrl = account.user.avatarUrl;
} catch (e) {
// Ignore if account fetch fails
}
leaderboardData.push({
user_id: userId,
display_name: displayName,
avatar_url: avatarUrl,
games_played: stats.games_played || 0,
games_won: stats.games_won || 0,
high_score: stats.high_score || 0
});
} catch (e) {
logger.error("Error parsing stats for object: " + obj.key);
}
}
return JSON.stringify({ leaderboard: leaderboardData });
} catch (e) {
logger.error("Failed to get leaderboard stats: " + e);
throw new Error("Failed to get leaderboard stats");
}
}
// Before login hook to check ban status
function beforeAuthenticateEmail(ctx, logger, nk, data) {
// Can't check ban before auth, so we check in afterAuthenticate
return data;
}
+59
View File
@@ -29,6 +29,9 @@ function InitModule(
// User management RPCs // User management RPCs
initializer.registerRpc("get_user_profile", rpcGetUserProfile); initializer.registerRpc("get_user_profile", rpcGetUserProfile);
initializer.registerRpc("update_user_profile", rpcUpdateUserProfile); initializer.registerRpc("update_user_profile", rpcUpdateUserProfile);
// Leaderboard RPCs
initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats);
logger.info("Tekton admin module loaded"); logger.info("Tekton admin module loaded");
} }
@@ -538,6 +541,62 @@ function rpcUpdateUserProfile(
} }
} }
// =============================================================================
// Leaderboard RPCs
// =============================================================================
function rpcGetLeaderboardStats(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
try {
// Query the "stats" collection to get all user stats
// Warning: For a large production game this should be indexed using Nakama's actual Leaderboard system,
// but this works for listing all users' custom storage stats.
const limit = 100;
const result = nk.storageList(null, "stats", limit, "");
const statsObjects = result.objects || [];
const leaderboardData: any[] = [];
for (const obj of statsObjects) {
try {
const stats = JSON.parse(obj.value);
const userId = obj.userId;
// Get the user's profile to retrieve their display name
let displayName = "Unknown";
let avatarUrl = "";
try {
const account = nk.accountGetId(userId);
displayName = account.user.displayName || account.user.username;
avatarUrl = account.user.avatarUrl;
} catch (e) {
// Ignore if account fetch fails
}
leaderboardData.push({
user_id: userId,
display_name: displayName,
avatar_url: avatarUrl,
games_played: stats.games_played || 0,
games_won: stats.games_won || 0,
high_score: stats.high_score || 0
});
} catch (e) {
logger.error(`Error parsing stats for object: ${obj.key}`);
}
}
return JSON.stringify({ leaderboard: leaderboardData });
} catch (e) {
logger.error(`Failed to get leaderboard stats: ${e}`);
throw new Error("Failed to get leaderboard stats");
}
}
// Before login hook to check ban status // Before login hook to check ban status
function beforeAuthenticateEmail( function beforeAuthenticateEmail(
ctx: nkruntime.Context, ctx: nkruntime.Context,