feat: update 2.1.9
This commit is contained in:
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://deq26r61rqq51
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dnpp80dw4ve8l
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://blugche8rky44
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3aw5biordna
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://by70gfocmnnbj
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://okk0tebly5gr
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyr5tlvds11ib
|
||||
Reference in New Issue
Block a user