466 lines
18 KiB
C#
466 lines
18 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using FishNet;
|
||
using FishNet.Managing;
|
||
using Steamworks;
|
||
using UnityEngine;
|
||
using Ashwild.Player;
|
||
|
||
namespace Ashwild.Network
|
||
{
|
||
/// <summary>
|
||
/// The one place that bridges Steam friend invites into the game. It does three things:
|
||
/// • SEND — while in a session, keeps a private "carrier" Steam lobby (one per local player, all
|
||
/// advertising the same host connect code) so the pause-menu "Invite" button can open Steam's
|
||
/// lobby invite overlay. Any session member — host or client — can invite their own friends,
|
||
/// and every invite still points at the single host's room (see NetworkSessionManager.MaxPlayers).
|
||
/// • PRESENCE — publishes the session's connect code as Steam Rich Presence while online, so
|
||
/// friends see "Join Game" in their friends list and that path carries the code JoinSession reads.
|
||
/// • RECEIVE — routes Steam's join paths to match the player's intent:
|
||
/// – LobbyInvite_t (invitation just ARRIVED) → raises PlayerEvents.InviteReceived so the menu
|
||
/// pops the invitation card; the player joins with one in-game click, no Alt-Tab to Steam.
|
||
/// – GameLobbyJoinRequested_t / GameRichPresenceJoinRequested_t (player explicitly clicked
|
||
/// "Join" inside Steam) → joins the session directly, skipping the card.
|
||
/// Also resolves friend avatars (async via Steam) for the invitation card.
|
||
/// Place this on the persistent FishNet NetworkManager GameObject, beside NetworkSessionManager.
|
||
/// </summary>
|
||
[DisallowMultipleComponent]
|
||
public class SteamInviteService : MonoBehaviour
|
||
{
|
||
#region Constants
|
||
|
||
/// <summary>
|
||
/// Steam Rich Presence / lobby-data key that makes the "Join Game" entry appear for friends
|
||
/// and carries the connect code an incoming friend uses to reach our session.
|
||
/// </summary>
|
||
private const string ConnectKey = "connect";
|
||
|
||
/// <summary>
|
||
/// Fallback member cap for the carrier lobby when the session manager is unavailable.
|
||
/// </summary>
|
||
private const int FallbackMemberLimit = 6;
|
||
|
||
#endregion
|
||
|
||
#region State
|
||
|
||
/// <summary>
|
||
/// Global access point used by the invite button and the invite popup.
|
||
/// </summary>
|
||
public static SteamInviteService Instance { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Steam callback fired when a friend clicks "Join Game" via our Rich Presence connect string.
|
||
/// </summary>
|
||
private Callback<GameRichPresenceJoinRequested_t> joinRequestedCallback;
|
||
|
||
/// <summary>
|
||
/// Steam callback fired when the local player clicks "Join" on a lobby invite inside Steam.
|
||
/// </summary>
|
||
private Callback<GameLobbyJoinRequested_t> lobbyJoinRequestedCallback;
|
||
|
||
/// <summary>
|
||
/// Steam callback fired the moment a friend's lobby invitation ARRIVES (before any accept).
|
||
/// </summary>
|
||
private Callback<LobbyInvite_t> lobbyInviteCallback;
|
||
|
||
/// <summary>
|
||
/// Steam callback fired when an avatar image finishes downloading from Steam's servers.
|
||
/// </summary>
|
||
private Callback<AvatarImageLoaded_t> avatarLoadedCallback;
|
||
|
||
/// <summary>
|
||
/// Async result of CreateLobby — writes the connect code into the new lobby once it exists.
|
||
/// </summary>
|
||
private CallResult<LobbyCreated_t> lobbyCreatedResult;
|
||
|
||
/// <summary>
|
||
/// The local player's carrier lobby while in a session (Nil when none). Exists only so the
|
||
/// Steam overlay has a lobby to invite friends to; nobody actually stays in it.
|
||
/// </summary>
|
||
private CSteamID currentLobby;
|
||
|
||
/// <summary>
|
||
/// Connect code to stamp onto the carrier lobby once CreateLobby completes.
|
||
/// </summary>
|
||
private string pendingLobbyCode;
|
||
|
||
/// <summary>
|
||
/// Avatar requests still waiting on Steam to finish downloading the image, by SteamID64.
|
||
/// </summary>
|
||
private readonly Dictionary<ulong, Action<Sprite>> pendingAvatars = new Dictionary<ulong, Action<Sprite>>();
|
||
|
||
/// <summary>
|
||
/// True once the Steam callbacks have been registered (guards the deferred init).
|
||
/// </summary>
|
||
private bool callbacksReady;
|
||
|
||
#endregion
|
||
|
||
#region Unity Lifecycle
|
||
|
||
/// <summary>
|
||
/// Establishes the singleton; the GameObject persists via the NetworkManager itself.
|
||
/// </summary>
|
||
private void Awake()
|
||
{
|
||
if (Instance != null && Instance != this)
|
||
{
|
||
Destroy(this);
|
||
return;
|
||
}
|
||
Instance = this;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Subscribes to the session bus and registers the Steam callbacks (once Steam is up).
|
||
/// </summary>
|
||
private void OnEnable()
|
||
{
|
||
PlayerEvents.SessionStarted += HandleSessionOnline;
|
||
PlayerEvents.SessionJoined += HandleSessionJoined;
|
||
PlayerEvents.SessionStopped += HandleSessionStopped;
|
||
|
||
EnsureCallbacks();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Unsubscribes from the bus, tears down the carrier lobby, and disposes the Steam callbacks —
|
||
/// mirrors OnEnable.
|
||
/// </summary>
|
||
private void OnDisable()
|
||
{
|
||
PlayerEvents.SessionStarted -= HandleSessionOnline;
|
||
PlayerEvents.SessionJoined -= HandleSessionJoined;
|
||
PlayerEvents.SessionStopped -= HandleSessionStopped;
|
||
|
||
LeaveCarrierLobby();
|
||
|
||
joinRequestedCallback?.Dispose();
|
||
lobbyJoinRequestedCallback?.Dispose();
|
||
lobbyInviteCallback?.Dispose();
|
||
avatarLoadedCallback?.Dispose();
|
||
lobbyCreatedResult?.Dispose();
|
||
joinRequestedCallback = null;
|
||
lobbyJoinRequestedCallback = null;
|
||
lobbyInviteCallback = null;
|
||
avatarLoadedCallback = null;
|
||
lobbyCreatedResult = null;
|
||
callbacksReady = false;
|
||
|
||
pendingAvatars.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retries callback registration until Steam is initialized, then stops checking.
|
||
/// </summary>
|
||
private void Update()
|
||
{
|
||
if (!callbacksReady) EnsureCallbacks();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Clears the singleton reference and leaves any carrier lobby still open.
|
||
/// </summary>
|
||
private void OnDestroy()
|
||
{
|
||
LeaveCarrierLobby();
|
||
if (Instance == this) Instance = null;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Public API
|
||
|
||
/// <summary>
|
||
/// Opens the Steam overlay so the player can invite a friend to the current session. Prefers
|
||
/// the lobby invite dialog (so the friend gets an in-game invitation card via LobbyInvite_t);
|
||
/// falls back to the plain connect-string dialog if the carrier lobby isn't ready. Requires
|
||
/// being in a session (host or client) so there is a code to share.
|
||
/// </summary>
|
||
public void OpenInviteOverlay()
|
||
{
|
||
if (!SteamManager.Initialized)
|
||
{
|
||
Debug.LogError("[SteamInvite] Steam is not initialized — cannot open the invite overlay.", this);
|
||
return;
|
||
}
|
||
|
||
string code = PlayerEvents.SessionCode;
|
||
if (string.IsNullOrEmpty(code))
|
||
{
|
||
Debug.LogWarning("[SteamInvite] No active session — nothing to invite a friend to yet.", this);
|
||
return;
|
||
}
|
||
|
||
if (currentLobby.IsValid())
|
||
SteamFriends.ActivateGameOverlayInviteDialog(currentLobby);
|
||
else
|
||
SteamFriends.ActivateGameOverlayInviteDialogConnectString(code);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolves a friend's avatar as a Sprite. Returns it immediately via the callback when
|
||
/// Steam already has it cached, otherwise waits for Steam to download it (may never fire
|
||
/// if the friend has no avatar — callers should keep their fallback in that case).
|
||
/// </summary>
|
||
public void RequestAvatar(ulong steamId, Action<Sprite> onReady)
|
||
{
|
||
if (onReady == null) return;
|
||
if (!SteamManager.Initialized)
|
||
{
|
||
onReady(null);
|
||
return;
|
||
}
|
||
|
||
CSteamID id = new CSteamID(steamId);
|
||
int handle = SteamFriends.GetLargeFriendAvatar(id);
|
||
|
||
if (handle > 0)
|
||
{
|
||
onReady(SteamAvatarUtil.BuildSprite(handle));
|
||
return;
|
||
}
|
||
|
||
// -1 means Steam is fetching it from its servers → wait for AvatarImageLoaded_t.
|
||
// 0 means the friend simply has no avatar set.
|
||
if (handle == -1)
|
||
pendingAvatars[steamId] = onReady;
|
||
else
|
||
onReady(null);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Steam Callbacks
|
||
|
||
/// <summary>
|
||
/// A friend's lobby invitation just ARRIVED — surface it to the menu popup so the player can
|
||
/// join with one in-game click. The connect code is read from the inviter's Rich Presence,
|
||
/// which every session member publishes; an empty read means we can't build a joinable card.
|
||
/// </summary>
|
||
private void OnLobbyInvite(LobbyInvite_t cb)
|
||
{
|
||
CSteamID inviter = new CSteamID(cb.m_ulSteamIDUser);
|
||
string connect = ResolveConnectFromFriend(inviter);
|
||
if (string.IsNullOrEmpty(connect))
|
||
{
|
||
Debug.LogWarning($"[SteamInvite] Lobby invite from {cb.m_ulSteamIDUser} had no readable connect code — ignoring.", this);
|
||
return;
|
||
}
|
||
|
||
string inviterName = inviter.IsValid() ? SteamFriends.GetFriendPersonaName(inviter) : "Un ami";
|
||
Debug.Log($"[SteamInvite] Invitation reçue de {inviterName} (connect={connect}).", this);
|
||
PlayerEvents.RaiseInviteReceived(cb.m_ulSteamIDUser, inviterName, connect);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The player clicked "Join" on a lobby invite inside Steam — an explicit intent, so we join
|
||
/// the session directly and skip the invitation card.
|
||
/// </summary>
|
||
private void OnLobbyJoinRequested(GameLobbyJoinRequested_t cb)
|
||
{
|
||
string connect = ResolveConnectFromFriend(cb.m_steamIDFriend);
|
||
if (string.IsNullOrEmpty(connect))
|
||
{
|
||
Debug.LogWarning("[SteamInvite] Lobby join request had no readable connect code — ignoring.", this);
|
||
return;
|
||
}
|
||
|
||
JoinDirect(connect);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A friend clicked "Join Game" via our Rich Presence connect string — an explicit intent, so
|
||
/// we join directly rather than popping the invitation card.
|
||
/// </summary>
|
||
private void OnJoinRequested(GameRichPresenceJoinRequested_t cb)
|
||
{
|
||
string connect = cb.m_rgchConnect;
|
||
if (string.IsNullOrEmpty(connect))
|
||
{
|
||
Debug.LogWarning("[SteamInvite] Join request had an empty connect string — ignoring.", this);
|
||
return;
|
||
}
|
||
|
||
JoinDirect(connect);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The carrier lobby finished creating — record it and stamp the connect code onto it so a
|
||
/// joining friend could also recover the code from lobby data if needed.
|
||
/// </summary>
|
||
private void OnLobbyCreated(LobbyCreated_t cb, bool ioFailure)
|
||
{
|
||
if (ioFailure || cb.m_eResult != EResult.k_EResultOK)
|
||
{
|
||
Debug.LogWarning($"[SteamInvite] Failed to create the invite lobby (result={cb.m_eResult}, ioFailure={ioFailure}). Invites fall back to the connect string.", this);
|
||
return;
|
||
}
|
||
|
||
currentLobby = new CSteamID(cb.m_ulSteamIDLobby);
|
||
if (!string.IsNullOrEmpty(pendingLobbyCode))
|
||
SteamMatchmaking.SetLobbyData(currentLobby, ConnectKey, pendingLobbyCode);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A requested avatar finished downloading — complete the matching pending request.
|
||
/// </summary>
|
||
private void OnAvatarLoaded(AvatarImageLoaded_t cb)
|
||
{
|
||
ulong id = cb.m_steamID.m_SteamID;
|
||
if (!pendingAvatars.TryGetValue(id, out Action<Sprite> onReady)) return;
|
||
|
||
pendingAvatars.Remove(id);
|
||
onReady(SteamAvatarUtil.BuildSprite(cb.m_iImage));
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Event Handlers
|
||
|
||
/// <summary>
|
||
/// Host came online — advertise the session code and open a carrier lobby friends can be invited to.
|
||
/// </summary>
|
||
private void HandleSessionOnline(string code)
|
||
{
|
||
PublishConnect(code);
|
||
CreateCarrierLobby(code);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Joined a host — advertise the same code and open a carrier lobby so this client can also
|
||
/// invite its own friends into the host's room.
|
||
/// </summary>
|
||
private void HandleSessionJoined()
|
||
{
|
||
string code = PlayerEvents.SessionCode;
|
||
PublishConnect(code);
|
||
CreateCarrierLobby(code);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Session ended — stop advertising as joinable and tear down the carrier lobby.
|
||
/// </summary>
|
||
private void HandleSessionStopped()
|
||
{
|
||
if (SteamManager.Initialized)
|
||
SteamFriends.SetRichPresence(ConnectKey, null);
|
||
|
||
LeaveCarrierLobby();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Internal Helpers
|
||
|
||
/// <summary>
|
||
/// Registers the Steam callbacks once Steam is initialized; safe to call repeatedly.
|
||
/// </summary>
|
||
private void EnsureCallbacks()
|
||
{
|
||
if (callbacksReady || !SteamManager.Initialized) return;
|
||
|
||
joinRequestedCallback = Callback<GameRichPresenceJoinRequested_t>.Create(OnJoinRequested);
|
||
lobbyJoinRequestedCallback = Callback<GameLobbyJoinRequested_t>.Create(OnLobbyJoinRequested);
|
||
lobbyInviteCallback = Callback<LobbyInvite_t>.Create(OnLobbyInvite);
|
||
avatarLoadedCallback = Callback<AvatarImageLoaded_t>.Create(OnAvatarLoaded);
|
||
lobbyCreatedResult = CallResult<LobbyCreated_t>.Create(OnLobbyCreated);
|
||
callbacksReady = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Writes the connect code into Steam Rich Presence (no-op when Steam/code is missing).
|
||
/// </summary>
|
||
private void PublishConnect(string code)
|
||
{
|
||
if (!SteamManager.Initialized || string.IsNullOrEmpty(code)) return;
|
||
SteamFriends.SetRichPresence(ConnectKey, code);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Opens (or refreshes) the private carrier lobby that the invite overlay hangs its friend
|
||
/// invites off. Friends-only and capped at the room's player limit; the connect code is stamped
|
||
/// on once LobbyCreated returns. No-op without Steam or a code; refreshes data if one already exists.
|
||
/// </summary>
|
||
private void CreateCarrierLobby(string code)
|
||
{
|
||
if (!SteamManager.Initialized || string.IsNullOrEmpty(code)) return;
|
||
|
||
pendingLobbyCode = code;
|
||
|
||
if (currentLobby.IsValid())
|
||
{
|
||
SteamMatchmaking.SetLobbyData(currentLobby, ConnectKey, code);
|
||
return;
|
||
}
|
||
|
||
int memberLimit = NetworkSessionManager.Instance != null
|
||
? NetworkSessionManager.Instance.MaxPlayers
|
||
: FallbackMemberLimit;
|
||
|
||
SteamAPICall_t call = SteamMatchmaking.CreateLobby(ELobbyType.k_ELobbyTypeFriendsOnly, memberLimit);
|
||
lobbyCreatedResult?.Set(call);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Leaves and forgets the carrier lobby, if one is open.
|
||
/// </summary>
|
||
private void LeaveCarrierLobby()
|
||
{
|
||
pendingLobbyCode = null;
|
||
if (!currentLobby.IsValid()) return;
|
||
|
||
if (SteamManager.Initialized)
|
||
SteamMatchmaking.LeaveLobby(currentLobby);
|
||
currentLobby = CSteamID.Nil;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads the connect code a session member publishes via Rich Presence. Returns empty when the
|
||
/// friend is invalid, offline, or hasn't published one (they may not be in a session).
|
||
/// </summary>
|
||
private string ResolveConnectFromFriend(CSteamID friend)
|
||
{
|
||
if (!SteamManager.Initialized || !friend.IsValid()) return string.Empty;
|
||
return SteamFriends.GetFriendRichPresence(friend, ConnectKey);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Connects straight to a session by its code — used when the player expressed an explicit
|
||
/// "Join" intent inside Steam. Ignored (with a log) when already in a session so a stray Steam
|
||
/// click can't tear down a running game.
|
||
/// </summary>
|
||
private void JoinDirect(string code)
|
||
{
|
||
if (IsAlreadyInSession())
|
||
{
|
||
Debug.LogWarning("[SteamInvite] Ignoring a Steam join request — already in a session.", this);
|
||
return;
|
||
}
|
||
|
||
if (NetworkSessionManager.Instance == null)
|
||
{
|
||
Debug.LogError("[SteamInvite] Cannot join — NetworkSessionManager missing.", this);
|
||
return;
|
||
}
|
||
|
||
GameSession.Mode = GameSession.LaunchMode.Join;
|
||
GameSession.JoinCode = code;
|
||
NetworkSessionManager.Instance.JoinSession(code);
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when a client or server connection is already up, so we shouldn't start a fresh join.
|
||
/// </summary>
|
||
private bool IsAlreadyInSession()
|
||
{
|
||
NetworkManager nm = InstanceFinder.NetworkManager;
|
||
if (nm == null) return false;
|
||
return nm.ClientManager.Started || nm.ServerManager.Started;
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
}
|