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
+101
View File
@@ -19,6 +19,16 @@ signal character_changed(player_id: int, character_name: String)
signal area_changed(area_name: String)
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
var current_room: Dictionary = {}
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
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
var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"]
var available_areas: Array[String] = []
@@ -259,6 +279,68 @@ func sync_scarcity_mode(mode: String) -> void:
func get_scarcity_mode() -> String:
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
# =============================================================================
@@ -430,6 +512,13 @@ func start_game() -> void:
rpc("sync_enable_cycle_timer", enable_cycle_timer)
# Sync 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
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_enable_cycle_timer", enable_cycle_timer)
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_area", selected_area)
@@ -568,3 +663,9 @@ func reset() -> void:
selected_area = available_areas[0]
local_character_index = 0 # Default to "Copper"
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 tile_refresh_timer: Timer
var finish_spawned: bool = false
var missions_required: int = 8
var arena_setup_done: bool = false
var player_portal_cooldowns: Dictionary = {}
@@ -41,6 +40,7 @@ func initialize(p_main: Node, p_gridmap: Node):
# Connection Swap Timer (15s)
swap_timer = Timer.new()
swap_timer.name = "PortalSwapTimer"
# Initial wait time; gets reset when started based on game mode settings
swap_timer.wait_time = 15.0
swap_timer.timeout.connect(_on_swap_timer_timeout)
add_child(swap_timer)
@@ -48,6 +48,7 @@ func initialize(p_main: Node, p_gridmap: Node):
# Tile Refresh Timer (25s)
tile_refresh_timer = Timer.new()
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.timeout.connect(_on_tile_refresh_timer_timeout)
add_child(tile_refresh_timer)
@@ -77,6 +78,10 @@ func start_game_mode():
setup_arena_locally()
_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
if swap_timer.is_stopped():
swap_timer.start()
@@ -176,9 +181,9 @@ func _update_hud_visuals():
var gcm = main.get_node_or_null("GoalsCycleManager")
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.add_theme_color_override("font_color", Color.GOLD)
@@ -194,7 +199,7 @@ func _update_hud_visuals():
func is_mission_complete(peer_id: int) -> bool:
var gcm = main.get_node_or_null("GoalsCycleManager")
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:
# 1. Check if on finish tile
+7 -10
View File
@@ -9,10 +9,6 @@ signal player_penalized(player_id: int)
enum Phase {GO, STOP}
const GO_DURATION: float = 15.0
const STOP_DURATION: float = 4.0
const REQUIRED_GOALS: int = 8
# Dynamic 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)
@@ -34,7 +30,7 @@ const PERMANENT_POWERUP_LOCATIONS: Array[Vector2i] = [
]
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 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)
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.add_theme_color_override("font_color", Color.GOLD)
@@ -235,7 +232,7 @@ func start_game_mode():
func _start_phase(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"
if can_rpc():
@@ -436,7 +433,7 @@ func is_mission_complete(player_id: int) -> bool:
if not goals_cycle_manager: return false
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:
# 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 player_node = main.get_node_or_null(str(player_id)) if main else null
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)
return false
+4 -6
View File
@@ -16,12 +16,10 @@ 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",
"res://assets/graphics/character_selection/sc_characters/sc_pip.png",
"res://assets/graphics/character_selection/sc_characters/sc_gatot.png",
"res://assets/graphics/character_selection/sc_characters/sc_dabro.png",
"res://assets/graphics/character_selection/sc_characters/sc_copper.png"
]
func _ready() -> void:
+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 password_strength := %PasswordStrength as ProgressBar
@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 back_to_login_link := %BackToLoginLink as LinkButton
@onready var reg_status_label := %RegStatusLabel as Label
var current_captcha_answer: int = 0
# Main panel reference
@onready var main_panel := $CenterContainer/MainPanel as PanelContainer
@@ -101,8 +105,16 @@ func _show_registration() -> void:
main_panel.visible = false
registration_panel.visible = true
reg_status_label.text = ""
_generate_captcha()
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:
registration_panel.visible = false
main_panel.visible = true
@@ -254,6 +266,7 @@ func _on_register_pressed() -> void:
var username := reg_username_input.text.strip_edges()
var password := reg_password_input.text
var confirm_password := reg_confirm_password_input.text
var captcha_answer := reg_captcha_input.text.strip_edges()
# Validation
if email.is_empty():
@@ -288,6 +301,15 @@ func _on_register_pressed() -> void:
_show_reg_error("Password is too weak. Add numbers or symbols.")
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)
func _check_password_strength(password: String) -> void:
@@ -411,6 +433,7 @@ func _set_inputs_enabled(enabled: bool) -> void:
reg_username_input.editable = enabled
reg_password_input.editable = enabled
reg_confirm_password_input.editable = enabled
reg_captcha_input.editable = enabled
func _is_valid_email(email: String) -> bool:
# Simple email validation