Files
Emberwild/Assets/GAME/Script/Network/SteamInviteService.cs
2026-07-04 09:33:53 +02:00

466 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}