(Feat) Add Network Body

This commit is contained in:
2026-07-04 09:33:53 +02:00
parent 7bd2aa3120
commit 031ac42e5d
32 changed files with 1385 additions and 49 deletions
+224 -25
View File
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using FishNet;
using FishNet.Managing;
using Steamworks;
using UnityEngine;
using Ashwild.Player;
@@ -8,13 +10,18 @@ namespace Ashwild.Network
{
/// <summary>
/// The one place that bridges Steam friend invites into the game. It does three things:
/// • SEND — opens the Steam overlay friends list so the local player can invite a friend to
/// their current session (the in-game pause menu "Invite" button calls OpenInviteOverlay).
/// • 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" and invites carry the code that JoinSession understands.
/// • RECEIVE — listens for Steam's GameRichPresenceJoinRequested callback (fired when a friend
/// accepts our invite or clicks "Join Game") and raises PlayerEvents.InviteReceived so the
/// menu can pop the invitation card. Also resolves friend avatars (async via Steam).
/// 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]
@@ -23,10 +30,16 @@ namespace Ashwild.Network
#region Constants
/// <summary>
/// Steam Rich Presence key that makes the "Join Game" entry appear for friends.
/// 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
@@ -37,15 +50,41 @@ namespace Ashwild.Network
public static SteamInviteService Instance { get; private set; }
/// <summary>
/// Steam callback fired when a friend accepts our invite or clicks "Join Game".
/// 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>
@@ -86,7 +125,8 @@ namespace Ashwild.Network
}
/// <summary>
/// Unsubscribes from the bus and disposes the Steam callbacks — mirrors OnEnable.
/// Unsubscribes from the bus, tears down the carrier lobby, and disposes the Steam callbacks —
/// mirrors OnEnable.
/// </summary>
private void OnDisable()
{
@@ -94,10 +134,18 @@ namespace Ashwild.Network
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();
@@ -112,10 +160,11 @@ namespace Ashwild.Network
}
/// <summary>
/// Clears the singleton reference.
/// Clears the singleton reference and leaves any carrier lobby still open.
/// </summary>
private void OnDestroy()
{
LeaveCarrierLobby();
if (Instance == this) Instance = null;
}
@@ -124,8 +173,10 @@ namespace Ashwild.Network
#region Public API
/// <summary>
/// Opens the Steam overlay friends list so the player can invite a friend to the current
/// session. Requires being in a session (host or client) so there is a code to share.
/// 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()
{
@@ -142,7 +193,10 @@ namespace Ashwild.Network
return;
}
SteamFriends.ActivateGameOverlayInviteDialogConnectString(code);
if (currentLobby.IsValid())
SteamFriends.ActivateGameOverlayInviteDialog(currentLobby);
else
SteamFriends.ActivateGameOverlayInviteDialogConnectString(code);
}
/// <summary>
@@ -181,7 +235,44 @@ namespace Ashwild.Network
#region Steam Callbacks
/// <summary>
/// A friend accepted our invite / clicked "Join Game" — surface it to the menu popup.
/// 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)
{
@@ -192,13 +283,24 @@ namespace Ashwild.Network
return;
}
ulong inviterId = cb.m_steamIDFriend.m_SteamID;
string inviterName = cb.m_steamIDFriend.IsValid()
? SteamFriends.GetFriendPersonaName(cb.m_steamIDFriend)
: "Un ami";
JoinDirect(connect);
}
Debug.Log($"[SteamInvite] Invitation reçue de {inviterName} (connect={connect}).", this);
PlayerEvents.RaiseInviteReceived(inviterId, inviterName, 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>
@@ -218,22 +320,34 @@ namespace Ashwild.Network
#region Event Handlers
/// <summary>
/// Host came online — advertise the session code so friends can join/invite.
/// 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);
private void HandleSessionOnline(string code)
{
PublishConnect(code);
CreateCarrierLobby(code);
}
/// <summary>
/// Joined a host — advertise the same code so this client can also invite friends.
/// 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() => PublishConnect(PlayerEvents.SessionCode);
private void HandleSessionJoined()
{
string code = PlayerEvents.SessionCode;
PublishConnect(code);
CreateCarrierLobby(code);
}
/// <summary>
/// Session ended — remove the Rich Presence so we stop advertising as joinable.
/// 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
@@ -248,7 +362,10 @@ namespace Ashwild.Network
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;
}
@@ -261,6 +378,88 @@ namespace Ashwild.Network
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
}
}