feat: update 2.1.9

This commit is contained in:
2026-04-29 01:36:49 +08:00
parent 1585b91509
commit 8a2f865ad8
76 changed files with 2628 additions and 39 deletions
+69 -1
View File
@@ -10,7 +10,7 @@ signal session_restored
signal logged_out
# Auth modes
enum AuthMode {GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, CUSTOM}
enum AuthMode {GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, STEAM, CUSTOM}
# User data
var current_user: Dictionary = {}
@@ -325,6 +325,74 @@ func login_with_facebook(access_token: String) -> bool:
emit_signal("auth_completed", true, current_user)
return true
func login_with_steam() -> bool:
emit_signal("auth_started")
auth_mode = AuthMode.STEAM
is_guest = false
print("[AuthManager] Steam login...")
# Get Steamworks manager from BackendService
var backend = get_node_or_null("/root/BackendService")
if not backend:
emit_signal("auth_failed", "BackendService not available")
return false
var steamworks = backend.get_steamworks_manager()
if not steamworks or not steamworks.is_initialized():
emit_signal("auth_failed", "Steamworks not available. Please launch through Steam.")
return false
# Try full Steam ticket auth first, fall back to Steam ID custom auth for dev/testing
var session: NakamaSession = await _authenticate_steam_with_fallback(steamworks)
if session == null or session.is_exception():
var error: String = "Steam authentication failed"
if session != null:
error = session.get_exception().message
emit_signal("auth_failed", error)
return false
NakamaManager.session = session
_save_session(session, AuthMode.STEAM)
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 _authenticate_steam_with_fallback(steamworks: Node) -> NakamaSession:
# Try proper Steam ticket auth first
var auth_ticket = steamworks.get_auth_session_ticket()
if not auth_ticket.is_empty():
print("[AuthManager] Got Steam auth ticket, authenticating with Nakama...")
var session: NakamaSession = await NakamaManager.client.authenticate_steam_async(auth_ticket, null, true)
if not session.is_exception():
return session
print("[AuthManager] Steam ticket auth failed: %s" % session.get_exception().message)
print("[AuthManager] Falling back to Steam ID custom auth (dev mode)...")
# Fallback: use Steam ID + username to create an email-style account (works without publisher key)
var steam_id = str(steamworks.get_steam_user_id())
var steam_name = steamworks.get_steam_user_name()
if steam_id == "0" or steam_id.is_empty():
return null
# Derive email and password from Steam credentials
var email = steam_name.to_lower().replace(" ", "_") + "@steam.local"
var password = steam_name # Default password = Steam username
var username = steam_name
print("[AuthManager] Using Steam email auth: %s (%s)" % [email, username])
var fallback_session: NakamaSession = await NakamaManager.client.authenticate_email_async(email, password, username, true)
return fallback_session
# =============================================================================
# Account Linking (Convert Guest to Full Account)
# =============================================================================
+176
View File
@@ -0,0 +1,176 @@
extends Node
## FriendManager - Nakama friend list, DM channels, and lobby invites
## Autoload: FriendManager
signal friends_updated(friends: Array)
signal lobby_invite_received(from_user_id: String, from_name: String, match_id: String)
signal dm_message_received(from_user_id: String, from_name: String, message: String)
## Notification codes (must match server-side RPC)
const NOTIF_LOBBY_INVITE := 1001
## Friend state codes from Nakama
const STATE_FRIEND := 0
const STATE_INVITE_OUT := 1
const STATE_INVITE_IN := 2
const STATE_BLOCKED := 3
var friends: Array = [] # [{user_id, username, state}]
var _friend_ids: Dictionary = {} # user_id -> true (mutual friends only)
var _dm_channels: Dictionary = {} # user_id -> NakamaChannel
func _ready() -> void:
NakamaManager.connected_to_nakama.connect(_on_nakama_connected)
if NakamaManager.socket and NakamaManager.socket.is_connected_to_host():
_connect_socket_signals()
func _on_nakama_connected() -> void:
_connect_socket_signals()
load_friends()
func _connect_socket_signals() -> void:
var socket = NakamaManager.socket
if not socket:
return
if not socket.received_notification.is_connected(_on_notification_received):
socket.received_notification.connect(_on_notification_received)
if not socket.received_channel_message.is_connected(_on_channel_message):
socket.received_channel_message.connect(_on_channel_message)
# =============================================================================
# Friend List
# =============================================================================
func load_friends() -> void:
if not NakamaManager.session:
return
var result = await NakamaManager.client.list_friends_async(NakamaManager.session, 100, null, null)
if result.is_exception():
push_warning("[FriendManager] Failed to load friends: " + result.get_exception().message)
return
friends.clear()
_friend_ids.clear()
for f in result.friends:
var u = f.user
var entry := {
"user_id": u.id,
"username": u.display_name if u.display_name != "" else u.username,
"state": f.state,
}
friends.append(entry)
if f.state == STATE_FRIEND:
_friend_ids[u.id] = true
emit_signal("friends_updated", friends)
func get_mutual_friends() -> Array:
return friends.filter(func(f): return f.state == STATE_FRIEND)
func is_friend(user_id: String) -> bool:
return _friend_ids.has(user_id)
func add_friend_by_id(user_id: String) -> bool:
if not NakamaManager.session:
return false
var result = await NakamaManager.client.add_friends_async(
NakamaManager.session, PackedStringArray([user_id]), null)
if result.is_exception():
push_warning("[FriendManager] add_friend failed: " + result.get_exception().message)
return false
load_friends()
return true
func add_friend_by_username(username: String) -> bool:
if not NakamaManager.session:
return false
var result = await NakamaManager.client.add_friends_async(
NakamaManager.session, null, PackedStringArray([username]))
if result.is_exception():
push_warning("[FriendManager] add_friend_by_username failed: " + result.get_exception().message)
return false
load_friends()
return true
func remove_friend(user_id: String) -> bool:
if not NakamaManager.session:
return false
var result = await NakamaManager.client.delete_friends_async(
NakamaManager.session, PackedStringArray([user_id]), null)
if result.is_exception():
return false
load_friends()
return true
# =============================================================================
# Lobby Invites
# =============================================================================
func send_lobby_invite(to_user_id: String, match_id: String) -> void:
if not NakamaManager.session:
return
var payload = JSON.stringify({"to_user_id": to_user_id, "match_id": match_id})
var result = await NakamaManager.client.rpc_async(
NakamaManager.session, "send_lobby_invite", payload)
if result.is_exception():
push_warning("[FriendManager] send_lobby_invite failed: " + result.get_exception().message)
func _on_notification_received(notification) -> void:
if notification.code == NOTIF_LOBBY_INVITE:
var content = JSON.parse_string(notification.content)
if content:
var from_name: String = content.get("from_name", "Someone")
var match_id: String = content.get("match_id", "")
emit_signal("lobby_invite_received", notification.sender_id, from_name, match_id)
# =============================================================================
# Direct Messages
# =============================================================================
func open_dm(user_id: String) -> Object:
if _dm_channels.has(user_id):
return _dm_channels[user_id]
var socket = NakamaManager.socket
if not socket:
return null
var channel = await socket.join_chat_async(
user_id, NakamaSocket.ChannelType.DirectMessage, true, false)
if channel.is_exception():
push_warning("[FriendManager] Failed to open DM with " + user_id)
return null
_dm_channels[user_id] = channel
return channel
func send_dm(user_id: String, message: String) -> bool:
var channel = await open_dm(user_id)
if not channel:
return false
var socket = NakamaManager.socket
if not socket:
return false
var result = await socket.write_chat_message_async(channel.id, {"msg": message})
return not result.is_exception()
func get_dm_channel_id(user_id: String) -> String:
var ch = _dm_channels.get(user_id, null)
return ch.id if ch else ""
func _on_channel_message(message) -> void:
# Route to DM signal if this message is from a DM channel
for user_id in _dm_channels:
var ch = _dm_channels[user_id]
if ch.id == message.channel_id:
var text: String = ""
var parsed = JSON.parse_string(message.content)
if typeof(parsed) == TYPE_DICTIONARY:
text = parsed.get("msg", message.content)
else:
text = message.content
emit_signal("dm_message_received", message.sender_id, message.username, text)
return
func close_all_dm_channels() -> void:
var socket = NakamaManager.socket
for user_id in _dm_channels:
var ch = _dm_channels[user_id]
if socket:
socket.leave_chat_async(ch.id)
_dm_channels.clear()
+1
View File
@@ -0,0 +1 @@
uid://deq26r61rqq51
+11 -7
View File
@@ -745,7 +745,8 @@ func _on_match_joined(match_id: String) -> void:
"id": my_id,
"name": local_player_name,
"is_ready": false,
"character": available_characters[local_character_index]
"character": available_characters[local_character_index],
"nakama_id": NakamaManager.session.user_id if NakamaManager.session else ""
}
players_in_room.append(my_data)
@@ -755,16 +756,17 @@ func _on_match_joined(match_id: String) -> void:
# Client will request room info when peer connection is established
@rpc("any_peer", "reliable")
func request_room_info(requester_id: int, requester_name: String, requester_character: String) -> void:
"""Client requests room info from host, sending their name and character."""
func request_room_info(requester_id: int, requester_name: String, requester_character: String, requester_nakama_id: String = "") -> void:
"""Client requests room info from host, sending their name, character and nakama_id."""
if not multiplayer.is_server():
return
# Update the player's name and character in the list
# Update the player's name, character and nakama_id in the list
for player in players_in_room:
if player["id"] == requester_id:
player["name"] = requester_name
player["character"] = requester_character
player["nakama_id"] = requester_nakama_id
break
# Send room data to requester
@@ -805,7 +807,8 @@ func _on_peer_connected(peer_id: int) -> void:
"id": peer_id,
"name": "Player %d" % peer_id,
"is_ready": false,
"character": available_characters[0]
"character": available_characters[0],
"nakama_id": ""
}
players_in_room.append(new_player)
@@ -818,8 +821,9 @@ func _on_peer_connected(peer_id: int) -> void:
if peer_id == 1 and not is_host:
# Wait a frame to ensure connection is stable
await get_tree().process_frame
# Send our actual name and character to the host
rpc_id(1, "request_room_info", multiplayer.get_unique_id(), local_player_name, available_characters[local_character_index])
# Send our actual name, character, and nakama_id to the host
var my_nakama_id: String = NakamaManager.session.user_id if NakamaManager.session else ""
rpc_id(1, "request_room_info", multiplayer.get_unique_id(), local_player_name, available_characters[local_character_index], my_nakama_id)
func _on_peer_disconnected(peer_id: int) -> void:
"""Called when peer disconnects."""
+1 -1
View File
@@ -20,7 +20,7 @@ signal avatar_changed(url: String)
var profile: Dictionary = {}
var stats: Dictionary = {}
var wallet: Dictionary = {"gold": 0, "star": 0}
var fragments: Dictionary = {} # frag_common, frag_uncommon, frag_rare
var fragments: Dictionary = {} # frag_common, frag_uncommon, frag_rare
var inventory: Array = []
var loadout: Dictionary = {"head": "", "costume": "", "glove": "", "accessory": ""}
var shop_catalog: Dictionary = {}
+133
View File
@@ -0,0 +1,133 @@
extends Node
## Unified interface for backend services
## All platforms use Nakama for achievements, leaderboards, and shop
## Steam is only used for authentication (auth session ticket for Nakama login)
enum Platform {
DESKTOP_STEAM,
DESKTOP_NAKAMA,
MOBILE_NAKAMA
}
var current_platform: Platform = Platform.DESKTOP_STEAM
var steamworks_manager: Node # Only for auth ticket retrieval
var nakama_backend: Node
func _ready() -> void:
_detect_platform()
_initialize_backend()
func _detect_platform() -> void:
# Detect if running on mobile or desktop
if OS.has_feature("android") or OS.has_feature("ios"):
current_platform = Platform.MOBILE_NAKAMA
else:
# Desktop: detect Steam by checking if GodotSteam class exists
# OS.has_feature("steam") is only true when launched through Steam client,
# but ClassDB.class_exists("Steam") is true whenever the GDExtension is enabled
if ClassDB.class_exists("Steam"):
current_platform = Platform.DESKTOP_STEAM
else:
current_platform = Platform.DESKTOP_NAKAMA
func _initialize_backend() -> void:
# All platforms use Nakama for backend features
# Steamworks is only initialized for auth ticket retrieval when GodotSteam is available
if current_platform == Platform.DESKTOP_STEAM:
_initialize_steamworks_for_auth()
_initialize_nakama()
func _initialize_steamworks_for_auth() -> void:
var steamworks_script = load("res://scripts/services/steamworks_manager.gd")
if steamworks_script:
steamworks_manager = steamworks_script.new()
add_child(steamworks_manager)
print("BackendService: Initialized Steamworks for auth only")
else:
push_error("BackendService: Failed to load Steamworks manager")
func _initialize_nakama() -> void:
nakama_backend = NakamaManager
if nakama_backend:
_connect_nakama_signals()
print("BackendService: Initialized Nakama backend")
else:
push_error("BackendService: NakamaManager not found")
func _connect_nakama_signals() -> void:
# Nakama signals are handled directly by NakamaManager
# No need to connect through BackendService
pass
## Achievement Methods
# All platforms use Nakama for achievements
func unlock_achievement(achievement_id: String) -> void:
if nakama_backend:
# Nakama achievement implementation
pass
func set_achievement_progress(achievement_id: String, current: int, max: int) -> void:
if nakama_backend:
# Nakama progress implementation
pass
func get_achievement_progress(achievement_id: String) -> Dictionary:
if nakama_backend:
# Nakama get progress implementation
pass
return {}
func get_all_achievements() -> Array:
if nakama_backend:
# Nakama get all achievements implementation
pass
return []
## Leaderboard Methods
# All platforms use Nakama for leaderboards
func submit_leaderboard_score(leaderboard_id: String, score: int) -> void:
if nakama_backend:
# Nakama leaderboard submission - use UserProfileManager.submit_to_leaderboard()
await UserProfileManager.submit_to_leaderboard()
func get_leaderboard_entries(leaderboard_id: String, range_start: int = 1, range_end: int = 10) -> void:
if nakama_backend:
# Nakama get leaderboard entries - use LeaderboardPanel._fetch_leaderboard_data()
# This is handled by the UI panel directly
pass
## Shop Methods
# All platforms use Nakama for shop
func purchase_shop_item(item_id: String) -> void:
if nakama_backend:
# Nakama shop purchase - use UserProfileManager.purchase_item()
# This is handled by the UI panel directly via ShopPanel
pass
func get_shop_items() -> void:
if nakama_backend:
# Nakama get shop items - use UserProfileManager.fetch_shop_catalog()
# This is handled by the UI panel directly via ShopPanel
pass
## Utility Methods
func is_initialized() -> bool:
# Nakama is the primary backend for all features
if nakama_backend != null:
return true
# Steamworks is optional (only for auth)
return false
func get_platform_name() -> String:
return Platform.keys()[current_platform]
func get_steamworks_manager() -> Node:
# Returns SteamworksManager for auth ticket retrieval (Steam login)
return steamworks_manager
+1
View File
@@ -0,0 +1 @@
uid://dnpp80dw4ve8l
+72
View File
@@ -0,0 +1,72 @@
class_name SteamworksManager
extends Node
## Steamworks integration for Nakama authentication only
## Used to get Steam auth session tickets for Nakama login/registration
## Steam singleton is provided by GodotSteam GDExtension
var is_steam_initialized: bool = false
var steam_app_id: int = ProjectSettings.get_setting("steam/initialization/app_id", 480)
func _ready() -> void:
_initialize_steam()
func _initialize_steam() -> void:
# Check if GodotSteam GDExtension is loaded
if not ClassDB.class_exists("Steam"):
push_error("SteamworksManager: GodotSteam GDExtension not found. Enable it in Project Settings > Plugins.")
return
# Use steamInitEx for proper initialization with status reporting
var init_result: Dictionary = Steam.steamInitEx()
var status: int = init_result.get("status", -1)
var verbal: String = init_result.get("verbal", "Unknown error")
if status == 0:
is_steam_initialized = true
print("SteamworksManager: Steam initialized (App ID: %s)" % steam_app_id)
else:
push_warning("SteamworksManager: Steam init failed [%d] - %s" % [status, verbal])
print("SteamworksManager: Make sure Steam is running and App ID %s is valid" % steam_app_id)
func is_initialized() -> bool:
return is_steam_initialized
## Auth Methods
func get_auth_session_ticket() -> String:
if not is_steam_initialized:
push_warning("SteamworksManager: Steam not initialized, cannot get auth ticket")
return ""
# getAuthSessionTicket returns a Dictionary in GodotSteam {"id": int, "buffer": PackedByteArray}
var ticket_data = Steam.getAuthSessionTicket()
if typeof(ticket_data) == TYPE_DICTIONARY:
var buffer: PackedByteArray = ticket_data.get("buffer", PackedByteArray())
if buffer.size() > 0:
var ticket_hex = buffer.hex_encode()
print("SteamworksManager: Got Steam auth session ticket")
return ticket_hex
push_error("SteamworksManager: Auth ticket buffer is empty")
return ""
elif typeof(ticket_data) == TYPE_STRING and not ticket_data.is_empty():
print("SteamworksManager: Got Steam auth session ticket")
return ticket_data
else:
push_error("SteamworksManager: Failed to get auth session ticket")
return ""
func get_steam_user_name() -> String:
if not is_steam_initialized:
return ""
return Steam.getPersonaName()
func get_steam_user_id() -> int:
if not is_steam_initialized:
return 0
return Steam.getSteamID()
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
if is_steam_initialized:
Steam.steamShutdown()
@@ -0,0 +1 @@
uid://3k02bu0c2ast
+35
View File
@@ -0,0 +1,35 @@
extends HBoxContainer
## FriendRow — Single row in the friends list inside SocialPanel.
@onready var _name_label: Label = %NameLabel
@onready var _state_label: Label = %StateLabel
@onready var _dm_btn: Button = %DMBtn
@onready var _accept_btn: Button = %AcceptBtn
@onready var _decline_btn: Button = %DeclineBtn
@onready var _remove_btn: Button = %RemoveBtn
var _user_id: String
var _username: String
var _panel: Control # reference to SocialPanel for opening DM
func setup(uid: String, uname: String, state: int, panel: Control) -> void:
_user_id = uid
_username = uname
_panel = panel
_name_label.text = uname
match state:
FriendManager.STATE_FRIEND:
_dm_btn.visible = true
_remove_btn.visible = true
_dm_btn.pressed.connect(func(): panel.call("_open_dm", uid, uname))
_remove_btn.pressed.connect(func(): FriendManager.remove_friend(uid))
FriendManager.STATE_INVITE_OUT:
_state_label.text = "(invite sent)"
_state_label.visible = true
FriendManager.STATE_INVITE_IN:
_accept_btn.visible = true
_decline_btn.visible = true
_accept_btn.pressed.connect(func(): FriendManager.add_friend_by_id(uid))
_decline_btn.pressed.connect(func(): FriendManager.remove_friend(uid))
+1
View File
@@ -0,0 +1 @@
uid://blugche8rky44
+27
View File
@@ -0,0 +1,27 @@
extends Window
signal closed
@onready var _friend_rows: VBoxContainer = %FriendRows
@onready var _no_friends_label: Label = %NoFriendsLabel
@onready var _close_btn: Button = %CloseBtn
const INVITE_ROW_SCENE := preload("res://scenes/ui/invite_row.tscn")
func _ready() -> void:
_close_btn.pressed.connect(func(): emit_signal("closed"))
close_requested.connect(func(): emit_signal("closed"))
func open(friends: Array, match_id: String) -> void:
if friends.is_empty():
_no_friends_label.visible = true
popup_centered()
return
_no_friends_label.visible = false
for f in friends:
var row: Control = INVITE_ROW_SCENE.instantiate()
_friend_rows.add_child(row)
row.setup(f.get("username", "?"), f.get("user_id", ""), match_id)
popup_centered()
+1
View File
@@ -0,0 +1 @@
uid://c3aw5biordna
+12
View File
@@ -0,0 +1,12 @@
extends HBoxContainer
@onready var _name_label: Label = %NameLabel
@onready var _invite_btn: Button = %InviteBtn
func setup(username: String, user_id: String, match_id: String) -> void:
_name_label.text = username
_invite_btn.pressed.connect(func():
FriendManager.send_lobby_invite(user_id, match_id)
_invite_btn.text = "Sent!"
_invite_btn.disabled = true
)
+1
View File
@@ -0,0 +1 @@
uid://by70gfocmnnbj
+14
View File
@@ -0,0 +1,14 @@
extends ConfirmationDialog
signal accepted
signal declined
@onready var _message_label: Label = %MessageLabel
func _ready() -> void:
confirmed.connect(func(): emit_signal("accepted"))
canceled.connect(func(): emit_signal("declined"))
get_cancel_button().text = "Decline"
func setup(from_name: String) -> void:
_message_label.text = "%s invited you to join their lobby!\n\nJoin now?" % from_name
+1
View File
@@ -0,0 +1 @@
uid://okk0tebly5gr
+17 -4
View File
@@ -10,6 +10,7 @@ extends Control
@onready var google_button := %GoogleButton as Button
@onready var apple_button := %AppleButton as Button
@onready var facebook_button := %FacebookButton as Button
@onready var steam_button := %SteamButton as Button
@onready var status_label := %StatusLabel as Label
@onready var loading_spinner := %LoadingSpinner as TextureProgressBar
@@ -65,6 +66,7 @@ func _connect_signals() -> void:
google_button.pressed.connect(_on_google_pressed)
apple_button.pressed.connect(_on_apple_pressed)
facebook_button.pressed.connect(_on_facebook_pressed)
steam_button.pressed.connect(_on_steam_pressed)
# Registration buttons
register_button.pressed.connect(_on_register_pressed)
@@ -99,15 +101,18 @@ func _setup_ui() -> void:
_configure_social_buttons()
func _configure_social_buttons() -> void:
# Google - available on all platforms
google_button.visible = true
# Google - hidden until API is configured
google_button.visible = false
# 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
# Facebook - hidden until API is configured
facebook_button.visible = false
# Steam - show when GodotSteam GDExtension is available
steam_button.visible = ClassDB.class_exists("Steam")
# =============================================================================
# Panel Switching
@@ -190,6 +195,13 @@ func _on_facebook_pressed() -> void:
# When you have the access token from Facebook SDK:
# AuthManager.login_with_facebook(access_token)
func _on_steam_pressed() -> void:
if is_loading:
return
# Steam login uses Steamworks auth ticket
AuthManager.login_with_steam()
func _on_server_option_selected(index: int) -> void:
@@ -421,6 +433,7 @@ func _set_inputs_enabled(enabled: bool) -> void:
google_button.disabled = not enabled
apple_button.disabled = not enabled
facebook_button.disabled = not enabled
steam_button.disabled = not enabled
email_input.editable = enabled
password_input.editable = enabled
reg_email_input.editable = enabled
+1
View File
@@ -231,6 +231,7 @@ func _auth_mode_name(mode: int) -> String:
AuthManager.AuthMode.GOOGLE: return "Google"
AuthManager.AuthMode.APPLE: return "Apple"
AuthManager.AuthMode.FACEBOOK: return "Facebook"
AuthManager.AuthMode.STEAM: return "Steam"
_: return "Guest"
# ─────────────────────────────────────────────────────────────
+164
View File
@@ -0,0 +1,164 @@
extends Control
## SocialPanel — Friend list with DM and global chat tabs.
## Nodes defined in social_panel.tscn; this script handles all logic.
signal closed
# ─── Node references via %UniqueName ─────────────────────────────────────
@onready var _close_btn: Button = %CloseBtn
@onready var _friends_tab_btn: Button = %FriendsTabBtn
@onready var _global_tab_btn: Button = %GlobalTabBtn
@onready var _dm_tab_btn: Button = %DMTabBtn
@onready var _friends_view: VBoxContainer = %FriendsView
@onready var _global_view: VBoxContainer = %GlobalView
@onready var _dm_view: VBoxContainer = %DMView
@onready var _add_friend_input: LineEdit = %AddFriendInput
@onready var _add_friend_btn: Button = %AddFriendBtn
@onready var _friend_list: VBoxContainer = %FriendList
@onready var _global_log: RichTextLabel = %GlobalLog
@onready var _global_input: LineEdit = %GlobalInput
@onready var _global_send_btn: Button = %GlobalSendBtn
@onready var _dm_back_btn: Button = %DMBackBtn
@onready var _dm_username_label: Label = %DMUsernameLabel
@onready var _dm_log: RichTextLabel = %DMLog
@onready var _dm_input: LineEdit = %DMInput
@onready var _dm_send_btn: Button = %DMSendBtn
# ─── State ────────────────────────────────────────────────────────────────
var _active_dm_user_id: String = ""
var _active_dm_username: String = ""
var _dm_history: Dictionary = {}
var _global_chat_channel = null
var _current_tab: String = "friends"
# ─── Lifecycle ────────────────────────────────────────────────────────────
func _ready() -> void:
_close_btn.pressed.connect(func(): emit_signal("closed"); hide())
_friends_tab_btn.pressed.connect(func(): _show_tab("friends"))
_global_tab_btn.pressed.connect(func(): _show_tab("global"))
_dm_tab_btn.pressed.connect(func(): _show_tab("dm"))
_add_friend_btn.pressed.connect(_on_add_friend_pressed)
_add_friend_input.text_submitted.connect(func(_t): _on_add_friend_pressed())
_global_send_btn.pressed.connect(_send_global_message)
_global_input.text_submitted.connect(func(_t): _send_global_message())
_dm_send_btn.pressed.connect(_send_dm)
_dm_input.text_submitted.connect(func(_t): _send_dm())
_dm_back_btn.pressed.connect(func(): _show_tab("friends"))
FriendManager.friends_updated.connect(_refresh_friend_list)
FriendManager.dm_message_received.connect(_on_dm_received)
NakamaManager.connected_to_nakama.connect(_join_global_chat)
if NakamaManager.socket and NakamaManager.socket.is_connected_to_host():
_join_global_chat()
FriendManager.load_friends()
_show_tab("friends")
func _refresh_friend_list(friends: Array) -> void:
if not _friend_list:
return
for ch in _friend_list.get_children():
ch.queue_free()
if friends.is_empty():
var empty_lbl := Label.new()
empty_lbl.text = "No friends yet. Add someone above!"
_friend_list.add_child(empty_lbl)
return
var friend_row_scene := preload("res://scenes/ui/friend_row.tscn")
for f in friends:
var uid: String = f.get("user_id", "")
var uname: String = f.get("username", "?")
var state: int = f.get("state", 0)
var row: Control = friend_row_scene.instantiate()
_friend_list.add_child(row)
row.setup(uid, uname, state, self)
func _on_add_friend_pressed() -> void:
var val := _add_friend_input.text.strip_edges()
if val.is_empty():
return
_add_friend_input.text = ""
if val.length() == 36 and val.count("-") == 4:
FriendManager.add_friend_by_id(val)
else:
FriendManager.add_friend_by_username(val)
func _join_global_chat() -> void:
if _global_chat_channel:
return
var socket = NakamaManager.socket
if not socket:
return
var channel = await socket.join_chat_async(
"social_global", NakamaSocket.ChannelType.Room, true, false)
if channel.is_exception():
return
_global_chat_channel = channel
if not socket.received_channel_message.is_connected(_on_global_message):
socket.received_channel_message.connect(_on_global_message)
func _send_global_message() -> void:
var text = _global_input.text.strip_edges()
if text.is_empty() or not _global_chat_channel:
return
_global_input.text = ""
var socket = NakamaManager.socket
if socket:
socket.write_chat_message_async(_global_chat_channel.id, {"msg": text})
func _on_global_message(msg) -> void:
if not _global_chat_channel or msg.channel_id != _global_chat_channel.id:
return
var text: String = ""
var parsed = JSON.parse_string(msg.content)
if typeof(parsed) == TYPE_DICTIONARY:
text = parsed.get("msg", msg.content)
else:
text = msg.content
var sender_name: String = msg.username if msg.username else "?"
if _global_log:
_global_log.append_text("[b]%s:[/b] %s\n" % [sender_name, text])
func _open_dm(user_id: String, username: String) -> void:
_active_dm_user_id = user_id
_active_dm_username = username
_dm_username_label.text = "DM: %s" % username
_dm_tab_btn.visible = true
# Reload history
_dm_log.clear()
var history: Array = _dm_history.get(user_id, [])
for entry in history:
var is_self = entry.get("from") == "me"
var prefix = "[b]%s:[/b]" % ("You" if is_self else username)
_dm_log.append_text("%s %s\n" % [prefix, entry.get("msg", "")])
# Open channel
FriendManager.open_dm(user_id)
_show_tab("dm")
func _send_dm() -> void:
var text = _dm_input.text.strip_edges()
if text.is_empty() or _active_dm_user_id.is_empty():
return
_dm_input.text = ""
var sent = await FriendManager.send_dm(_active_dm_user_id, text)
if sent:
if not _dm_history.has(_active_dm_user_id):
_dm_history[_active_dm_user_id] = []
_dm_history[_active_dm_user_id].append({"from": "me", "msg": text})
_dm_log.append_text("[b]You:[/b] %s\n" % text)
func _on_dm_received(from_user_id: String, from_name: String, message: String) -> void:
if not _dm_history.has(from_user_id):
_dm_history[from_user_id] = []
_dm_history[from_user_id].append({"from": from_user_id, "msg": message})
if _active_dm_user_id == from_user_id and _current_tab == "dm":
_dm_log.append_text("[b]%s:[/b] %s\n" % [from_name, message])
# ─── Tab switching ─────────────────────────────────────────────────────────
func _show_tab(tab: String) -> void:
_current_tab = tab
_friends_view.visible = tab == "friends"
_global_view.visible = tab == "global"
_dm_view.visible = tab == "dm"
+1
View File
@@ -0,0 +1 @@
uid://dyr5tlvds11ib