Files
megakoop/Networking/Steam/SteamLobbyService.cs
Dominik G. 2c283c6623 Fix UI
2025-10-28 14:49:02 +01:00

497 lines
21 KiB
C#

#if STEAMWORKSNET
using System;
using System.Collections;
using System.Collections.Generic;
using Steamworks;
using UnityEngine;
namespace MegaKoop.Steam
{
/// <summary>
/// Steam Lobby Service using Steamworks.NET
/// Handles create/join/leave/invite and lobby member updates.
/// </summary>
public class SteamLobbyService : MonoBehaviour
{
public static SteamLobbyService Instance { get; private set; }
public bool IsInLobby => _lobbyId.m_SteamID != 0 && _inLobby;
public bool IsHost { get; private set; }
public string LobbyCode { get; private set; } = string.Empty;
public int MaxMembers { get; private set; } = 4;
public CSteamID LobbyId => _lobbyId;
public string LobbyIdString => _lobbyId.m_SteamID.ToString();
public string LocalSteamIdString => SteamManager.Initialized ? SteamUser.GetSteamID().m_SteamID.ToString() : "0";
public string HostSteamIdString => IsInLobby ? SteamMatchmaking.GetLobbyData(_lobbyId, "host") : string.Empty;
public event Action OnLobbyCreated;
public event Action OnLobbyEntered;
public event Action OnLobbyLeft;
public event Action OnMembersChanged;
public event Action OnLobbyDataUpdated;
public event Action<string> OnJoinFailed; // reason
public event Action<string> OnAvatarUpdated; // steamId string
private Callback<LobbyCreated_t> _cbLobbyCreated;
private Callback<LobbyEnter_t> _cbLobbyEnter;
private Callback<LobbyChatUpdate_t> _cbLobbyChatUpdate;
private Callback<LobbyDataUpdate_t> _cbLobbyDataUpdate;
private Callback<LobbyMatchList_t> _cbLobbyMatchList;
private Callback<GameLobbyJoinRequested_t> _cbLobbyJoinRequested;
private Callback<GameRichPresenceJoinRequested_t> _cbRichPresenceJoinRequested;
private Callback<GameOverlayActivated_t> _cbOverlayActivated;
private Callback<AvatarImageLoaded_t> _cbAvatarImageLoaded;
private CSteamID _lobbyId;
private bool _inLobby;
private readonly List<CSteamID> _members = new();
private readonly System.Collections.Generic.Dictionary<ulong, Sprite> _avatarCache = new();
private bool _overlayWarned = false;
private bool _lastOverlayActive = false;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
if (!SteamManager.Initialized)
{
Debug.LogWarning("[SteamLobbyService] Steam not initialized. Service is idle.");
return;
}
_cbLobbyCreated = Callback<LobbyCreated_t>.Create(OnLobbyCreatedCb);
_cbLobbyEnter = Callback<LobbyEnter_t>.Create(OnLobbyEnterCb);
_cbLobbyChatUpdate = Callback<LobbyChatUpdate_t>.Create(OnLobbyChatUpdateCb);
_cbLobbyDataUpdate = Callback<LobbyDataUpdate_t>.Create(OnLobbyDataUpdateCb);
_cbLobbyMatchList = Callback<LobbyMatchList_t>.Create(OnLobbyMatchListCb);
_cbLobbyJoinRequested = Callback<GameLobbyJoinRequested_t>.Create(OnLobbyJoinRequestedCb);
_cbRichPresenceJoinRequested = Callback<GameRichPresenceJoinRequested_t>.Create(OnRichPresenceJoinRequestedCb);
_cbOverlayActivated = Callback<GameOverlayActivated_t>.Create(OnOverlayActivatedCb);
_cbAvatarImageLoaded = Callback<AvatarImageLoaded_t>.Create(OnAvatarImageLoadedCb);
}
#region Public API
public void CreateLobby(int maxPlayers, bool isPublic)
{
if (!SteamManager.Initialized) { OnJoinFailed?.Invoke("Steam not initialized"); return; }
MaxMembers = maxPlayers;
ELobbyType type = isPublic ? ELobbyType.k_ELobbyTypePublic : ELobbyType.k_ELobbyTypeFriendsOnly;
SteamMatchmaking.CreateLobby(type, maxPlayers);
}
public void LeaveLobby()
{
if (!IsInLobby) return;
SteamMatchmaking.LeaveLobby(_lobbyId);
_inLobby = false;
_members.Clear();
_lobbyId = default;
IsHost = false;
LobbyCode = string.Empty;
OnLobbyLeft?.Invoke();
}
public void InviteFriends()
{
if (!IsInLobby) return;
_lastOverlayActive = false;
SteamFriends.ActivateGameOverlayInviteDialog(_lobbyId);
StartCoroutine(EnsureOverlayOpenedRoutine());
}
public void OpenFriendsOverlay()
{
SteamFriends.ActivateGameOverlay("Friends");
}
private void OnOverlayActivatedCb(GameOverlayActivated_t cb)
{
_lastOverlayActive = cb.m_bActive != 0;
}
private IEnumerator EnsureOverlayOpenedRoutine()
{
float t = 0f;
const float timeout = 0.5f;
while (t < timeout)
{
if (_lastOverlayActive) yield break;
t += Time.unscaledDeltaTime;
yield return null;
}
if (!_lastOverlayActive)
{
if (!_overlayWarned)
{
Debug.LogWarning("[SteamLobbyService] Overlay did not activate; opening Friends overlay as fallback.");
_overlayWarned = true;
}
SteamFriends.ActivateGameOverlay("Friends");
}
}
public void Kick(string steamId)
{
if (!IsInLobby || !IsHost) return;
if (ulong.TryParse(steamId, out ulong sid))
{
// KickLobbyMember is not exposed in some Steamworks.NET versions.
// Try via reflection, otherwise log a warning and rely on custom policy (client leaves on request).
try
{
var mm = typeof(SteamMatchmaking);
var mi = mm.GetMethod("KickLobbyMember", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (mi != null)
{
mi.Invoke(null, new object[] { _lobbyId, new CSteamID(sid) });
return;
}
}
catch { }
// Fallback: set a lobby data key to signal the target client to leave by itself
var key = $"kick_{sid}";
SteamMatchmaking.SetLobbyData(_lobbyId, key, "1");
Debug.LogWarning($"[SteamLobbyService] KickLobbyMember not available; set lobby key '{key}' to signal client to leave.");
}
}
public void SetReady(bool ready)
{
if (!IsInLobby) return;
SteamMatchmaking.SetLobbyMemberData(_lobbyId, "ready", ready ? "1" : "0");
}
public void StartGameSignal()
{
if (!IsInLobby || !IsHost) return;
SteamMatchmaking.SetLobbyData(_lobbyId, "start", "1");
}
public bool IsStartSignaled()
{
if (!IsInLobby) return false;
return SteamMatchmaking.GetLobbyData(_lobbyId, "start") == "1";
}
public bool IsOverlayEnabled() => SteamUtils.IsOverlayEnabled();
public bool TryGetAvatarSprite(string steamId, out Sprite sprite, bool large = true)
{
sprite = null;
if (!ulong.TryParse(steamId, out var sid)) return false;
if (_avatarCache.TryGetValue(sid, out var cached)) { sprite = cached; return true; }
var csid = new CSteamID(sid);
int imageId = large ? SteamFriends.GetLargeFriendAvatar(csid) : SteamFriends.GetSmallFriendAvatar(csid);
if (imageId <= 0)
{
// Ask Steam to fetch persona data (which includes avatar) and try later via callback
SteamFriends.RequestUserInformation(csid, true);
return false;
}
if (!SteamUtils.GetImageSize(imageId, out uint w, out uint h) || w == 0 || h == 0) return false;
byte[] data = new byte[w * h * 4];
if (!SteamUtils.GetImageRGBA(imageId, data, (int)data.Length)) return false;
// Flip vertically (Steam image origin differs from Unity)
var flipped = new byte[data.Length];
int rowSize = (int)w * 4;
for (int y = 0; y < h; y++)
{
int src = (int)((h - 1 - y) * rowSize);
int dst = (int)(y * rowSize);
System.Buffer.BlockCopy(data, src, flipped, dst, rowSize);
}
var tex = new Texture2D((int)w, (int)h, TextureFormat.RGBA32, false);
tex.LoadRawTextureData(flipped);
tex.Apply();
var spr = Sprite.Create(tex, new Rect(0,0, tex.width, tex.height), new Vector2(0.5f,0.5f), 100f);
_avatarCache[sid] = spr;
sprite = spr;
return true;
}
private void OnAvatarImageLoadedCb(AvatarImageLoaded_t cb)
{
// Build and cache sprite, then notify listeners
if (!SteamUtils.GetImageSize(cb.m_iImage, out uint w, out uint h) || w == 0 || h == 0) return;
byte[] data = new byte[w * h * 4];
if (!SteamUtils.GetImageRGBA(cb.m_iImage, data, (int)data.Length)) return;
// Flip vertically
var flipped = new byte[data.Length];
int rowSize = (int)w * 4;
for (int y = 0; y < h; y++)
{
int src = (int)((h - 1 - y) * rowSize);
int dst = (int)(y * rowSize);
System.Buffer.BlockCopy(data, src, flipped, dst, rowSize);
}
var tex = new Texture2D((int)w, (int)h, TextureFormat.RGBA32, false);
tex.LoadRawTextureData(flipped);
tex.Apply();
var spr = Sprite.Create(tex, new Rect(0,0, tex.width, tex.height), new Vector2(0.5f,0.5f), 100f);
var sid = cb.m_steamID.m_SteamID;
_avatarCache[sid] = spr;
OnAvatarUpdated?.Invoke(sid.ToString());
}
public IReadOnlyList<(string steamId, string name, bool online)> GetFriends()
{
var list = new List<(string, string, bool)>();
if (!SteamManager.Initialized) return list;
int count = SteamFriends.GetFriendCount(EFriendFlags.k_EFriendFlagImmediate);
for (int i = 0; i < count; i++)
{
var fid = SteamFriends.GetFriendByIndex(i, EFriendFlags.k_EFriendFlagImmediate);
var name = SteamFriends.GetFriendPersonaName(fid);
var state = SteamFriends.GetFriendPersonaState(fid);
bool online = state != EPersonaState.k_EPersonaStateOffline;
// Proactively request avatar if not ready yet
int imgId = SteamFriends.GetSmallFriendAvatar(fid);
if (imgId <= 0) SteamFriends.RequestUserInformation(fid, true);
list.Add((fid.m_SteamID.ToString(), name, online));
}
return list;
}
private string GetLobbyConnectString()
{
if (!IsInLobby) return string.Empty;
var appId = SteamUtils.GetAppID().m_AppId.ToString();
return $"steam://joinlobby/{appId}/{_lobbyId.m_SteamID}/{SteamUser.GetSteamID().m_SteamID}";
}
public void InviteFriendBySteamId(string steamId)
{
if (!IsInLobby) return;
if (!ulong.TryParse(steamId, out var fid)) return;
var conn = GetLobbyConnectString();
if (string.IsNullOrEmpty(conn)) return;
SteamFriends.SetRichPresence("connect", conn);
SteamFriends.InviteUserToGame(new CSteamID(fid), conn);
Debug.Log($"[SteamLobbyService] Sent game invite to {steamId} using connect string.");
}
public bool IsKickedLocal()
{
if (!IsInLobby) return false;
var key = $"kick_{LocalSteamIdString}";
return SteamMatchmaking.GetLobbyData(_lobbyId, key) == "1";
}
public IReadOnlyList<(string steamId, string name, bool isReady, bool isHost)> GetMembers()
{
var list = new List<(string, string, bool, bool)>();
if (!IsInLobby) return list;
int count = SteamMatchmaking.GetNumLobbyMembers(_lobbyId);
for (int i = 0; i < count; i++)
{
var member = SteamMatchmaking.GetLobbyMemberByIndex(_lobbyId, i);
bool ready = SteamMatchmaking.GetLobbyMemberData(_lobbyId, member, "ready") == "1";
bool isHost = SteamMatchmaking.GetLobbyOwner(_lobbyId) == member;
string name = SteamFriends.GetFriendPersonaName(member);
list.Add((member.m_SteamID.ToString(), name, ready, isHost));
}
return list;
}
public void JoinLobbyByCode(string code)
{
if (!SteamManager.Initialized) { OnJoinFailed?.Invoke("Steam not initialized"); return; }
SteamMatchmaking.AddRequestLobbyListStringFilter("code", code, ELobbyComparison.k_ELobbyComparisonEqual);
SteamMatchmaking.RequestLobbyList();
}
#endregion
#region Callbacks
private void OnLobbyCreatedCb(LobbyCreated_t cb)
{
if (cb.m_eResult != EResult.k_EResultOK)
{
OnJoinFailed?.Invoke($"Create failed: {cb.m_eResult}");
return;
}
_lobbyId = new CSteamID(cb.m_ulSteamIDLobby);
IsHost = true;
_inLobby = true;
LobbyCode = GenerateCode();
SteamMatchmaking.SetLobbyData(_lobbyId, "code", LobbyCode);
SteamMatchmaking.SetLobbyData(_lobbyId, "name", SteamFriends.GetPersonaName() + "'s Lobby");
SteamMatchmaking.SetLobbyData(_lobbyId, "host", SteamUser.GetSteamID().m_SteamID.ToString());
SteamMatchmaking.SetLobbyMemberLimit(_lobbyId, MaxMembers);
SteamMatchmaking.SetLobbyJoinable(_lobbyId, true);
SteamMatchmaking.SetLobbyMemberData(_lobbyId, "ready", "1");
RefreshMembers();
OnLobbyCreated?.Invoke();
OnLobbyEntered?.Invoke();
OnLobbyDataUpdated?.Invoke();
}
private void OnLobbyEnterCb(LobbyEnter_t cb)
{
_lobbyId = new CSteamID(cb.m_ulSteamIDLobby);
_inLobby = true;
IsHost = SteamMatchmaking.GetLobbyOwner(_lobbyId) == SteamUser.GetSteamID();
LobbyCode = SteamMatchmaking.GetLobbyData(_lobbyId, "code");
if (!IsHost)
SteamMatchmaking.SetLobbyMemberData(_lobbyId, "ready", "0");
RefreshMembers();
OnLobbyEntered?.Invoke();
}
private void OnLobbyChatUpdateCb(LobbyChatUpdate_t cb)
{
if (new CSteamID(cb.m_ulSteamIDLobby) != _lobbyId) return;
RefreshMembers();
OnMembersChanged?.Invoke();
}
private void OnLobbyDataUpdateCb(LobbyDataUpdate_t cb)
{
if (new CSteamID(cb.m_ulSteamIDLobby) != _lobbyId) return;
OnLobbyDataUpdated?.Invoke();
}
private void OnLobbyMatchListCb(LobbyMatchList_t cb)
{
int lobbies = (int)cb.m_nLobbiesMatching;
if (lobbies <= 0)
{
OnJoinFailed?.Invoke("No lobby with this code");
return;
}
var id = SteamMatchmaking.GetLobbyByIndex(0);
SteamMatchmaking.JoinLobby(id);
}
private void OnLobbyJoinRequestedCb(GameLobbyJoinRequested_t cb)
{
SteamMatchmaking.JoinLobby(cb.m_steamIDLobby);
}
private void OnRichPresenceJoinRequestedCb(GameRichPresenceJoinRequested_t cb)
{
// Parse steam://joinlobby/<appId>/<lobbyId>/<steamId> and join the lobby
string conn = cb.m_rgchConnect;
if (!string.IsNullOrEmpty(conn))
{
try
{
var s = conn.Trim();
const string prefix = "steam://joinlobby/";
if (s.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
var rest = s.Substring(prefix.Length);
var parts = rest.Split('/');
// Expected: [appId, lobbyId, steamId]
if (parts.Length >= 2)
{
if (ulong.TryParse(parts[1], out var lobbyId))
{
var lobby = new CSteamID(lobbyId);
SteamMatchmaking.JoinLobby(lobby);
return;
}
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"[SteamLobbyService] Failed to parse rich presence connect '{conn}': {ex.Message}");
}
}
Debug.LogWarning($"[SteamLobbyService] RichPresence join requested but could not parse connect '{conn}'.");
}
#endregion
private void RefreshMembers()
{
_members.Clear();
if (!IsInLobby) return;
int count = SteamMatchmaking.GetNumLobbyMembers(_lobbyId);
for (int i = 0; i < count; i++)
{
_members.Add(SteamMatchmaking.GetLobbyMemberByIndex(_lobbyId, i));
}
}
private static string GenerateCode()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
System.Text.StringBuilder sb = new System.Text.StringBuilder(6);
var rnd = new System.Random();
for (int i = 0; i < 6; i++) sb.Append(chars[rnd.Next(chars.Length)]);
return sb.ToString();
}
}
}
#else
using System;
using System.Collections.Generic;
using UnityEngine;
namespace MegaKoop.Steam
{
// Safe stub without Steamworks.NET
public class SteamLobbyService : MonoBehaviour
{
public static SteamLobbyService Instance { get; private set; }
public bool IsInLobby => _isInLobby;
public bool IsHost { get; private set; }
public string LobbyCode { get; private set; } = string.Empty;
public int MaxMembers { get; private set; } = 4;
public string LobbyIdString => "0";
public string LocalSteamIdString => "0";
public event Action OnLobbyCreated;
public event Action OnLobbyEntered;
public event Action OnLobbyLeft;
public event Action OnMembersChanged;
public event Action OnLobbyDataUpdated;
public event Action<string> OnJoinFailed;
public event Action<string> OnAvatarUpdated;
private bool _isInLobby;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
Debug.LogWarning("[SteamLobbyService] Stub active. Install Steamworks.NET and define STEAMWORKSNET.");
}
public void CreateLobby(int maxPlayers, bool isPublic)
{
MaxMembers = maxPlayers; IsHost = true; _isInLobby = true; LobbyCode = "AAAAAA";
OnLobbyCreated?.Invoke(); OnLobbyEntered?.Invoke(); OnLobbyDataUpdated?.Invoke();
}
public void LeaveLobby() { _isInLobby = false; IsHost = false; LobbyCode = string.Empty; OnLobbyLeft?.Invoke(); }
public void InviteFriends() { Debug.Log("[SteamLobbyService] InviteFriends stub"); }
public void Kick(string steamId) { Debug.Log($"[SteamLobbyService] Kick stub {steamId}"); }
public void SetReady(bool ready) { Debug.Log($"[SteamLobbyService] SetReady {ready}"); OnLobbyDataUpdated?.Invoke(); }
public void StartGameSignal() { Debug.Log("[SteamLobbyService] StartGame signal"); }
public bool IsStartSignaled() => false;
public bool IsOverlayEnabled() => false;
public bool TryGetAvatarSprite(string steamId, out Sprite sprite, bool large = true) { sprite = null; return false; }
public void OpenFriendsOverlay() { Debug.Log("[SteamLobbyService] OpenFriendsOverlay stub"); }
public IReadOnlyList<(string steamId, string name, bool online)> GetFriends() => new List<(string, string, bool)>();
public void InviteFriendBySteamId(string steamId) { Debug.Log("[SteamLobbyService] InviteFriendBySteamId stub"); }
public bool IsKickedLocal() => false;
public IReadOnlyList<(string steamId, string name, bool isReady, bool isHost)> GetMembers() => new List<(string,string,bool,bool)>();
public void JoinLobbyByCode(string code) { _isInLobby = true; IsHost = false; LobbyCode = code; OnLobbyEntered?.Invoke(); }
}
}
#endif