feat: bug fix social system

This commit is contained in:
2026-04-30 04:18:46 +08:00
parent 2a1a76e682
commit 54be7bbb25
9 changed files with 607 additions and 235 deletions
+114 -23
View File
@@ -7,7 +7,8 @@ signal lobby_invite_received(from_user_id: String, from_name: String, match_id:
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
const NOTIF_LOBBY_INVITE := 1001
const NOTIF_FRIEND_REQUEST := 1002
## Friend state codes from Nakama
const STATE_FRIEND := 0
@@ -15,18 +16,59 @@ 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 friends: Array = [] # [{user_id, username, state}]
var _friend_ids: Dictionary = {} # user_id -> true (mutual friends only)
var _dm_channels: Dictionary = {} # user_id -> NakamaChannel
var _has_pending_requests: bool = false # set before UI is ready
func _ready() -> void:
NakamaManager.connected_to_nakama.connect(_on_nakama_connected)
AuthManager.logged_out.connect(_on_logged_out)
if NakamaManager.socket and NakamaManager.socket.is_connected_to_host():
_connect_socket_signals()
func _on_logged_out() -> void:
print("[FriendManager] Clearing state on logout")
friends.clear()
_friend_ids.clear()
close_all_dm_channels()
_has_pending_requests = false
emit_signal("friends_updated", [])
func _on_nakama_connected() -> void:
_connect_socket_signals()
load_friends()
await load_friends()
_fetch_pending_notifications()
func _fetch_pending_notifications() -> void:
if not NakamaManager.session or not NakamaManager.client:
return
print("[FriendManager] _fetch_pending_notifications: fetching...")
# Retrieve all persistent notifications queued while offline (up to 100)
var result = await NakamaManager.client.list_notifications_async(
NakamaManager.session, 100, null)
if result.is_exception():
push_warning("[FriendManager] Failed to fetch notifications: " + result.get_exception().message)
return
print("[FriendManager] _fetch_pending_notifications: count=%d" % result.notifications.size())
var has_friend_request := false
var ids_to_delete: PackedStringArray = PackedStringArray()
for notif in result.notifications:
print("[FriendManager] notification: code=%d sender=%s" % [notif.code, notif.sender_id])
ids_to_delete.append(notif.id)
if notif.code == NOTIF_FRIEND_REQUEST:
has_friend_request = true
# Acknowledge all so they are not delivered again next login
if ids_to_delete.size() > 0:
NakamaManager.client.delete_notifications_async(NakamaManager.session, ids_to_delete)
# If any were friend requests, reload the friends list to show them
if has_friend_request:
print("[FriendManager] _fetch_pending_notifications: reloading friends for pending request")
load_friends()
func _connect_socket_signals() -> void:
var socket = NakamaManager.socket
@@ -43,13 +85,16 @@ func _connect_socket_signals() -> void:
func load_friends() -> void:
if not NakamaManager.session:
print("[FriendManager] load_friends: no session")
return
var result = await NakamaManager.client.list_friends_async(NakamaManager.session, 100, null, null)
print("[FriendManager] load_friends: fetching...")
var result = await NakamaManager.client.list_friends_async(NakamaManager.session, null, 100, null)
if result.is_exception():
push_warning("[FriendManager] Failed to load friends: " + result.get_exception().message)
return
friends.clear()
_friend_ids.clear()
_has_pending_requests = false
for f in result.friends:
var u = f.user
var entry := {
@@ -58,8 +103,12 @@ func load_friends() -> void:
"state": f.state,
}
friends.append(entry)
print("[FriendManager] friend: %s state=%d" % [u.username, f.state])
if f.state == STATE_FRIEND:
_friend_ids[u.id] = true
if f.state == STATE_INVITE_IN:
_has_pending_requests = true
print("[FriendManager] load_friends done: %d entries" % friends.size())
emit_signal("friends_updated", friends)
func get_mutual_friends() -> Array:
@@ -71,17 +120,30 @@ func is_friend(user_id: String) -> bool:
func add_friend_by_id(user_id: String) -> bool:
if not NakamaManager.session:
return false
print("[FriendManager] add_friend_by_id: adding %s" % user_id)
# Step 1: Add the friend relationship via native Nakama client API
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
print("[FriendManager] add_friend_by_id: add_friends_async OK, sending notification RPC...")
# Step 2: Notify the target via RPC
var payload = JSON.stringify({"user_id": user_id})
var rpc_result = await NakamaManager.client.rpc_async(NakamaManager.session, "send_friend_request", payload)
if rpc_result.is_exception():
push_error("[FriendManager] rpcSendFriendRequest failed: " + rpc_result.get_exception().message)
else:
print("[FriendManager] rpcSendFriendRequest OK: " + str(rpc_result.payload))
load_friends()
return true
func add_friend_by_username(username: String) -> bool:
if not NakamaManager.session:
return false
# Resolve username → user_id first via search, then use the RPC
# Fall back to direct Nakama API (no notification sent to target)
var result = await NakamaManager.client.add_friends_async(
NakamaManager.session, null, PackedStringArray([username]))
if result.is_exception():
@@ -114,12 +176,18 @@ func send_lobby_invite(to_user_id: String, match_id: String) -> void:
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)
print("[FriendManager] _on_notification_received: code=%d sender=%s" % [notification.code, notification.sender_id])
match 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)
NOTIF_FRIEND_REQUEST:
# Refresh friends list so the incoming request appears immediately
print("[FriendManager] friend request notification received, reloading friends...")
load_friends()
# =============================================================================
# Direct Messages
@@ -139,6 +207,30 @@ func open_dm(user_id: String) -> Object:
_dm_channels[user_id] = channel
return channel
func get_dm_history(user_id: String, limit: int = 50) -> Array:
if not NakamaManager.session:
return []
var channel = await open_dm(user_id)
if not channel:
return []
var result = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, channel.id, limit, true)
if result.is_exception():
push_warning("[FriendManager] Failed to fetch DM history: " + result.get_exception().message)
return []
var history: Array = []
for msg in result.messages:
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
history.append({"from": msg.sender_id, "msg": text, "username": msg.username})
history.reverse() # Oldest to newest
return history
func send_dm(user_id: String, message: String) -> bool:
var channel = await open_dm(user_id)
if not channel:
@@ -154,18 +246,17 @@ func get_dm_channel_id(user_id: String) -> String:
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
# Ignore global chat
if "social_global" in message.channel_id:
return
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)
func close_all_dm_channels() -> void:
var socket = NakamaManager.socket
+1 -1
View File
@@ -23,7 +23,7 @@ func setup(uid: String, uname: String, state: int, panel: Control) -> void:
FriendManager.STATE_FRIEND:
_dm_btn.visible = true
_remove_btn.visible = true
_dm_btn.pressed.connect(func(): panel.call("_open_dm", uid, uname))
_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)"
+190 -122
View File
@@ -1,140 +1,216 @@
extends Control
## SocialPanel — Friend list with DM and global chat tabs.
## Nodes defined in social_panel.tscn; this script handles all logic.
## SocialPanel — Search, Requests, Friends, DM tabs.
## All UI nodes are defined in social_panel.tscn.
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
# ─── Node references ─────────────────────────────────────────────────────────
@onready var _close_btn: Button = %CloseBtn
# ─── 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"
# Tab buttons
@onready var _search_tab_btn: Button = %SearchTabBtn
@onready var _requests_tab_btn: Button = %RequestsTabBtn
@onready var _friends_tab_btn: Button = %FriendsTabBtn
@onready var _dm_tab_btn: Button = %DMTabBtn
# ─── Lifecycle ────────────────────────────────────────────────────────────
# Views
@onready var _search_view: VBoxContainer = %SearchView
@onready var _requests_view: VBoxContainer = %RequestsView
@onready var _friends_view: VBoxContainer = %FriendsView
@onready var _dm_view: VBoxContainer = %DMView
# Search tab nodes
@onready var _search_input: LineEdit = %SearchInput
@onready var _search_btn: Button = %SearchBtn
@onready var _no_search_results: Label = %NoSearchResultsLabel
@onready var _search_results_list: VBoxContainer = %SearchResultsList
@onready var _search_result_tmpl: HBoxContainer = %SearchResultTemplate
# Requests tab nodes
@onready var _no_requests_label: Label = %NoRequestsLabel
@onready var _requests_list: VBoxContainer = %RequestsList
@onready var _request_row_tmpl: HBoxContainer = %RequestRowTemplate
# Friends tab nodes
@onready var _no_friends_label: Label = %NoFriendsLabel
@onready var _friend_list: VBoxContainer = %FriendList
# DM tab nodes
@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 _current_tab: String = "search"
var _active_dm_user_id: String = ""
var _dm_history: Dictionary = {}
# ─── Lifecycle ───────────────────────────────────────────────────────────────
func _ready() -> void:
_close_btn.pressed.connect(func(): emit_signal("closed"); hide())
# Tab buttons
_search_tab_btn.pressed.connect(func(): _show_tab("search"))
_requests_tab_btn.pressed.connect(func(): _show_tab("requests"))
_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_back_btn.pressed.connect(func(): _show_tab("friends"))
# Search
_search_btn.pressed.connect(_on_search_pressed)
_search_input.text_submitted.connect(func(_t): _on_search_pressed())
# DM
_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:
# FriendManager signals
FriendManager.friends_updated.connect(_on_friends_updated)
FriendManager.dm_message_received.connect(_on_dm_received)
# Replay already-loaded friends in case FriendManager loaded before this panel was ready
if FriendManager.friends.size() > 0:
_on_friends_updated(FriendManager.friends)
else:
FriendManager.load_friends()
_show_tab("search")
# ─── Tab Switching ───────────────────────────────────────────────────────────
func _show_tab(tab: String) -> void:
_current_tab = tab
_search_view.visible = tab == "search"
_requests_view.visible = tab == "requests"
_friends_view.visible = tab == "friends"
_dm_view.visible = tab == "dm"
# Auto-load search results on first open
if tab == "search" and _search_results_list.get_child_count() == 0:
_on_search_pressed()
# ─── Search Tab ──────────────────────────────────────────────────────────────
func _on_search_pressed() -> void:
var query := _search_input.text.strip_edges()
# UUID → add directly
if query.length() == 36 and query.count("-") == 4:
FriendManager.add_friend_by_id(query)
_search_input.text = ""
return
_search_btn.disabled = true
var payload = JSON.stringify({"query": query})
var result = await NakamaManager.client.rpc_async(NakamaManager.session, "search_users", payload)
_search_btn.disabled = false
if result.is_exception():
push_warning("[Social] Search failed: " + result.get_exception().message)
return
var response = JSON.parse_string(result.payload)
if not response or not response.has("users"):
return
_populate_search_results(response.users)
func _populate_search_results(users: Array) -> void:
for ch in _search_results_list.get_children():
ch.queue_free()
_no_search_results.visible = users.is_empty()
var my_id = NakamaManager.session.user_id if NakamaManager.session else ""
for u in users:
if u.user_id == my_id:
continue # skip self
var row: HBoxContainer = _search_result_tmpl.duplicate()
row.show()
row.get_node("SRNameLabel").text = u.display_name + " (@" + u.username + ")"
var add_btn: Button = row.get_node("SRAddBtn")
add_btn.pressed.connect(func():
FriendManager.add_friend_by_id(u.user_id)
add_btn.text = "Sent ✓"
add_btn.disabled = true
)
_search_results_list.add_child(row)
# ─── Requests Tab ────────────────────────────────────────────────────────────
func _populate_requests(incoming: Array) -> void:
for ch in _requests_list.get_children():
ch.queue_free()
_no_requests_label.visible = incoming.is_empty()
for f in incoming:
var row: HBoxContainer = _request_row_tmpl.duplicate()
row.show()
row.get_node("RRNameLabel").text = f.username
var accept_btn: Button = row.get_node("RRAcceptBtn")
var decline_btn: Button = row.get_node("RRDeclineBtn")
var uid: String = f.user_id
accept_btn.pressed.connect(func():
FriendManager.add_friend_by_id(uid)
row.queue_free()
)
decline_btn.pressed.connect(func():
FriendManager.remove_friend(uid)
row.queue_free()
)
_requests_list.add_child(row)
# Badge on tab button
if incoming.is_empty():
_requests_tab_btn.text = "Requests"
else:
_requests_tab_btn.text = "Requests (%d)" % incoming.size()
# ─── Friends Tab ─────────────────────────────────────────────────────────────
func _populate_friends(mutual: Array) -> void:
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
_no_friends_label.visible = mutual.is_empty()
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()
for f in mutual:
var row: Control = friend_row_scene.instantiate()
_friend_list.add_child(row)
row.setup(uid, uname, state, self)
row.setup(f.user_id, f.username, f.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)
# ─── FriendManager Callbacks ─────────────────────────────────────────────────
func _on_friends_updated(friends: Array) -> void:
print("[SocialPanel] _on_friends_updated: total=%d" % friends.size())
var incoming := friends.filter(func(f): return f.state == FriendManager.STATE_INVITE_IN)
var mutual := friends.filter(func(f): return f.state == FriendManager.STATE_FRIEND)
print("[SocialPanel] incoming=%d mutual=%d" % [incoming.size(), mutual.size()])
_populate_requests(incoming)
_populate_friends(mutual)
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 ──────────────────────────────────────────────────────────────────────
func open_dm(user_id: String, username: String) -> void:
_active_dm_user_id = user_id
_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")
_dm_log.append_text("[i]Loading history...[/i]\n")
var history = await FriendManager.get_dm_history(user_id)
_dm_log.clear()
_dm_history[user_id] = []
var my_id = NakamaManager.session.user_id if NakamaManager.session else ""
for entry in history:
var is_self = entry.get("from") == my_id
var sender_name = "You" if is_self else username
_dm_history[user_id].append({"from": "me" if is_self else entry.get("from"), "msg": entry.get("msg")})
_dm_log.append_text("[b]%s:[/b] %s\n" % [sender_name, entry.get("msg", "")])
FriendManager.open_dm(user_id)
func _send_dm() -> void:
var text = _dm_input.text.strip_edges()
@@ -152,13 +228,5 @@ func _on_dm_received(from_user_id: String, from_name: String, message: String) -
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"