Join and leave improvements
This commit is contained in:
@@ -18,15 +18,18 @@ namespace MegaKoop.Game.Networking
|
|||||||
public class LobbyGameSceneCoordinator : MonoBehaviour
|
public class LobbyGameSceneCoordinator : MonoBehaviour
|
||||||
{
|
{
|
||||||
private static LobbyGameSceneCoordinator Instance;
|
private static LobbyGameSceneCoordinator Instance;
|
||||||
|
public static LobbyGameSceneCoordinator Current => Instance;
|
||||||
|
|
||||||
[SerializeField] private string characterSceneName = "CharacterScene";
|
[SerializeField] private string characterSceneName = "CharacterScene";
|
||||||
[SerializeField] private float spawnRadius = 3f;
|
[SerializeField] private float spawnRadius = 3f;
|
||||||
[SerializeField] private float minimumSpawnSpacing = 2.5f;
|
[SerializeField] private float minimumSpawnSpacing = 2.5f;
|
||||||
|
|
||||||
private readonly List<LobbyPlayerInfo> pendingPlayers = new();
|
private readonly List<LobbyPlayerInfo> pendingPlayers = new();
|
||||||
|
private readonly Dictionary<ulong, GameObject> spawnedPlayerObjects = new();
|
||||||
private string localSteamId = string.Empty;
|
private string localSteamId = string.Empty;
|
||||||
private bool loadPending;
|
private bool loadPending;
|
||||||
private bool hasSpawned;
|
private bool hasSpawned;
|
||||||
|
private SteamCoopNetworkManager coopNetworkManager;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
@@ -40,11 +43,13 @@ namespace MegaKoop.Game.Networking
|
|||||||
Instance = this;
|
Instance = this;
|
||||||
DontDestroyOnLoad(gameObject);
|
DontDestroyOnLoad(gameObject);
|
||||||
SceneManager.sceneLoaded += HandleSceneLoaded;
|
SceneManager.sceneLoaded += HandleSceneLoaded;
|
||||||
|
EnsureNetworkSubscription();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
SceneManager.sceneLoaded -= HandleSceneLoaded;
|
SceneManager.sceneLoaded -= HandleSceneLoaded;
|
||||||
|
UnsubscribeFromNetwork();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -113,6 +118,14 @@ namespace MegaKoop.Game.Networking
|
|||||||
pendingPlayers.Sort((a, b) => string.CompareOrdinal(a.SteamId ?? string.Empty, b.SteamId ?? string.Empty));
|
pendingPlayers.Sort((a, b) => string.CompareOrdinal(a.SteamId ?? string.Empty, b.SteamId ?? string.Empty));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LateUpdate()
|
||||||
|
{
|
||||||
|
if (coopNetworkManager == null)
|
||||||
|
{
|
||||||
|
EnsureNetworkSubscription();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleSceneLoaded(Scene scene, LoadSceneMode mode)
|
private void HandleSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||||
{
|
{
|
||||||
if (!string.Equals(scene.name, characterSceneName, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(scene.name, characterSceneName, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -125,6 +138,7 @@ namespace MegaKoop.Game.Networking
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnsureNetworkSubscription();
|
||||||
loadPending = false;
|
loadPending = false;
|
||||||
hasSpawned = false;
|
hasSpawned = false;
|
||||||
SpawnPlayersInScene(scene);
|
SpawnPlayersInScene(scene);
|
||||||
@@ -137,6 +151,7 @@ namespace MegaKoop.Game.Networking
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnsureNetworkSubscription();
|
||||||
var template = FindWizardTemplate(scene);
|
var template = FindWizardTemplate(scene);
|
||||||
if (template == null)
|
if (template == null)
|
||||||
{
|
{
|
||||||
@@ -148,6 +163,7 @@ namespace MegaKoop.Game.Networking
|
|||||||
|
|
||||||
// Proactively remove any previously spawned character clones (e.g., from a duplicate coordinator)
|
// Proactively remove any previously spawned character clones (e.g., from a duplicate coordinator)
|
||||||
DespawnExistingClones(scene, template);
|
DespawnExistingClones(scene, template);
|
||||||
|
spawnedPlayerObjects.Clear();
|
||||||
|
|
||||||
Vector3 basePosition = template.transform.position;
|
Vector3 basePosition = template.transform.position;
|
||||||
Quaternion baseRotation = template.transform.rotation;
|
Quaternion baseRotation = template.transform.rotation;
|
||||||
@@ -168,6 +184,17 @@ namespace MegaKoop.Game.Networking
|
|||||||
|
|
||||||
ConfigureCloneForPlayer(clone, info, i);
|
ConfigureCloneForPlayer(clone, info, i);
|
||||||
|
|
||||||
|
ulong ownerSteamId = ParseSteamId(info.SteamId);
|
||||||
|
if (ownerSteamId == 0UL && info.IsLocal)
|
||||||
|
{
|
||||||
|
ownerSteamId = ParseSteamId(localSteamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownerSteamId != 0UL)
|
||||||
|
{
|
||||||
|
spawnedPlayerObjects[ownerSteamId] = clone;
|
||||||
|
}
|
||||||
|
|
||||||
clone.SetActive(true);
|
clone.SetActive(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,5 +386,106 @@ namespace MegaKoop.Game.Networking
|
|||||||
public string DisplayName;
|
public string DisplayName;
|
||||||
public bool IsLocal;
|
public bool IsLocal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureNetworkSubscription()
|
||||||
|
{
|
||||||
|
var manager = SteamCoopNetworkManager.Instance;
|
||||||
|
if (manager == null || manager == coopNetworkManager)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UnsubscribeFromNetwork();
|
||||||
|
|
||||||
|
coopNetworkManager = manager;
|
||||||
|
coopNetworkManager.HostClosed += HandleHostClosed;
|
||||||
|
coopNetworkManager.PlayerDisconnected += HandlePlayerDisconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeFromNetwork()
|
||||||
|
{
|
||||||
|
if (coopNetworkManager == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
coopNetworkManager.HostClosed -= HandleHostClosed;
|
||||||
|
coopNetworkManager.PlayerDisconnected -= HandlePlayerDisconnected;
|
||||||
|
coopNetworkManager = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleHostClosed()
|
||||||
|
{
|
||||||
|
Debug.Log("[LobbyGameSceneCoordinator] Host closed session; despawning all players.");
|
||||||
|
RemoveAllSpawnedPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandlePlayerDisconnected(ulong steamId)
|
||||||
|
{
|
||||||
|
RemoveSpawnedPlayer(steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveAllSpawnedPlayers()
|
||||||
|
{
|
||||||
|
if (spawnedPlayerObjects.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var avatar in spawnedPlayerObjects.Values)
|
||||||
|
{
|
||||||
|
if (avatar != null)
|
||||||
|
{
|
||||||
|
Destroy(avatar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnedPlayerObjects.Clear();
|
||||||
|
hasSpawned = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSpawnedPlayer(ulong steamId)
|
||||||
|
{
|
||||||
|
if (steamId == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnedPlayerObjects.TryGetValue(steamId, out GameObject avatar))
|
||||||
|
{
|
||||||
|
if (avatar != null)
|
||||||
|
{
|
||||||
|
Destroy(avatar);
|
||||||
|
}
|
||||||
|
spawnedPlayerObjects.Remove(steamId);
|
||||||
|
Debug.Log($"[LobbyGameSceneCoordinator] Removed avatar for {steamId}.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ulong? keyToRemove = null;
|
||||||
|
foreach (var kvp in spawnedPlayerObjects)
|
||||||
|
{
|
||||||
|
var target = kvp.Value;
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
keyToRemove = kvp.Key;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bridge = target.GetComponent<SteamCharacterNetworkBridge>();
|
||||||
|
if (bridge != null && bridge.OwnerSteamId == steamId)
|
||||||
|
{
|
||||||
|
Destroy(target);
|
||||||
|
keyToRemove = kvp.Key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyToRemove.HasValue)
|
||||||
|
{
|
||||||
|
spawnedPlayerObjects.Remove(keyToRemove.Value);
|
||||||
|
Debug.Log($"[LobbyGameSceneCoordinator] Removed avatar for {steamId} (fallback search).");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ namespace MegaKoop.Game.Networking
|
|||||||
|
|
||||||
public bool IsLocalPlayer => isLocalPlayer;
|
public bool IsLocalPlayer => isLocalPlayer;
|
||||||
public bool IsAuthority => isAuthority;
|
public bool IsAuthority => isAuthority;
|
||||||
|
public ulong OwnerSteamId => ownerSteamId;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,13 +17,20 @@ namespace MegaKoop.Game.Networking
|
|||||||
[SerializeField] private SteamP2PTransport p2pTransport;
|
[SerializeField] private SteamP2PTransport p2pTransport;
|
||||||
|
|
||||||
private readonly Dictionary<NetworkMessageType, Action<NetworkMessage>> handlers = new();
|
private readonly Dictionary<NetworkMessageType, Action<NetworkMessage>> handlers = new();
|
||||||
|
private readonly HashSet<ulong> lobbyMembers = new();
|
||||||
|
private readonly Dictionary<ulong, string> memberNames = new();
|
||||||
|
private readonly List<OnScreenMessage> onScreenMessages = new();
|
||||||
private bool isHost;
|
private bool isHost;
|
||||||
private bool isConnected;
|
private bool isConnected;
|
||||||
|
private ulong lobbyOwnerSteamId;
|
||||||
|
|
||||||
public bool IsHost => isHost;
|
public bool IsHost => isHost;
|
||||||
public bool IsConnected => isConnected;
|
public bool IsConnected => isConnected;
|
||||||
public CSteamID ActiveLobby => lobbyManager != null ? lobbyManager.GetActiveLobby() : CSteamID.Nil;
|
public CSteamID ActiveLobby => lobbyManager != null ? lobbyManager.GetActiveLobby() : CSteamID.Nil;
|
||||||
|
|
||||||
|
public event Action HostClosed;
|
||||||
|
public event Action<ulong> PlayerDisconnected;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
if (Instance != null)
|
if (Instance != null)
|
||||||
@@ -131,6 +138,44 @@ namespace MegaKoop.Game.Networking
|
|||||||
isConnected = steamService.IsInLobby;
|
isConnected = steamService.IsInLobby;
|
||||||
isHost = steamService.IsHost;
|
isHost = steamService.IsHost;
|
||||||
|
|
||||||
|
if (steamService.IsInLobby)
|
||||||
|
{
|
||||||
|
lobbyMembers.Clear();
|
||||||
|
memberNames.Clear();
|
||||||
|
lobbyOwnerSteamId = 0;
|
||||||
|
|
||||||
|
var members = steamService.GetMembers();
|
||||||
|
if (members != null)
|
||||||
|
{
|
||||||
|
foreach (var member in members)
|
||||||
|
{
|
||||||
|
if (!ulong.TryParse(member.steamId, out ulong steamId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lobbyMembers.Add(steamId);
|
||||||
|
memberNames[steamId] = string.IsNullOrWhiteSpace(member.name) ? steamId.ToString() : member.name;
|
||||||
|
|
||||||
|
if (member.isHost)
|
||||||
|
{
|
||||||
|
lobbyOwnerSteamId = steamId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lobbyOwnerSteamId == 0 && steamService.IsHost && SteamBootstrap.IsInitialized)
|
||||||
|
{
|
||||||
|
lobbyOwnerSteamId = SteamUser.GetSteamID().m_SteamID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lobbyMembers.Clear();
|
||||||
|
memberNames.Clear();
|
||||||
|
lobbyOwnerSteamId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
#if STEAMWORKSNET
|
#if STEAMWORKSNET
|
||||||
if (p2pTransport != null)
|
if (p2pTransport != null)
|
||||||
{
|
{
|
||||||
@@ -167,6 +212,10 @@ namespace MegaKoop.Game.Networking
|
|||||||
isHost = true;
|
isHost = true;
|
||||||
isConnected = true;
|
isConnected = true;
|
||||||
p2pTransport?.SetActiveLobby(lobbyId);
|
p2pTransport?.SetActiveLobby(lobbyId);
|
||||||
|
lobbyOwnerSteamId = SteamUser.GetSteamID().m_SteamID;
|
||||||
|
lobbyMembers.Clear();
|
||||||
|
memberNames.Clear();
|
||||||
|
RegisterMember(lobbyOwnerSteamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleLobbyJoined(CSteamID lobbyId)
|
private void HandleLobbyJoined(CSteamID lobbyId)
|
||||||
@@ -177,22 +226,189 @@ namespace MegaKoop.Game.Networking
|
|||||||
string ownerId = SteamMatchmaking.GetLobbyData(lobbyId, "owner");
|
string ownerId = SteamMatchmaking.GetLobbyData(lobbyId, "owner");
|
||||||
if (!string.IsNullOrEmpty(ownerId) && ulong.TryParse(ownerId, out ulong ownerSteamId))
|
if (!string.IsNullOrEmpty(ownerId) && ulong.TryParse(ownerId, out ulong ownerSteamId))
|
||||||
{
|
{
|
||||||
|
lobbyOwnerSteamId = ownerSteamId;
|
||||||
isHost = ownerSteamId == SteamUser.GetSteamID().m_SteamID;
|
isHost = ownerSteamId == SteamUser.GetSteamID().m_SteamID;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
isHost = SteamMatchmaking.GetLobbyOwner(lobbyId) == SteamUser.GetSteamID();
|
CSteamID owner = SteamMatchmaking.GetLobbyOwner(lobbyId);
|
||||||
|
lobbyOwnerSteamId = owner.m_SteamID;
|
||||||
|
isHost = owner == SteamUser.GetSteamID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RegisterExistingMembers(lobbyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleLobbyMemberJoined(CSteamID member)
|
private void HandleLobbyMemberJoined(CSteamID member)
|
||||||
{
|
{
|
||||||
|
RegisterMember(member.m_SteamID);
|
||||||
|
|
||||||
|
if (ActiveLobby != CSteamID.Nil)
|
||||||
|
{
|
||||||
|
CSteamID owner = SteamMatchmaking.GetLobbyOwner(ActiveLobby);
|
||||||
|
if (owner != CSteamID.Nil)
|
||||||
|
{
|
||||||
|
lobbyOwnerSteamId = owner.m_SteamID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Debug.Log("[SteamCoopNetworkManager] Member joined: " + member);
|
Debug.Log("[SteamCoopNetworkManager] Member joined: " + member);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleLobbyMemberLeft(CSteamID member)
|
private void HandleLobbyMemberLeft(CSteamID member)
|
||||||
|
{
|
||||||
|
ulong steamId = member.m_SteamID;
|
||||||
|
string displayName = ResolveMemberName(steamId);
|
||||||
|
|
||||||
|
bool hostLeft = lobbyOwnerSteamId != 0 && steamId == lobbyOwnerSteamId;
|
||||||
|
lobbyMembers.Remove(steamId);
|
||||||
|
memberNames.Remove(steamId);
|
||||||
|
|
||||||
|
if (hostLeft)
|
||||||
|
{
|
||||||
|
Debug.Log("[SteamCoopNetworkManager] Host left lobby: " + member);
|
||||||
|
ShowOnScreenMessage("Host closed the game.", 6f);
|
||||||
|
HostClosed?.Invoke();
|
||||||
|
|
||||||
|
if (!isHost)
|
||||||
|
{
|
||||||
|
ForceDisconnectFromLobby();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
Debug.Log("[SteamCoopNetworkManager] Member left: " + member);
|
Debug.Log("[SteamCoopNetworkManager] Member left: " + member);
|
||||||
|
string message = string.IsNullOrEmpty(displayName) ? "A player disconnected." : $"{displayName} disconnected.";
|
||||||
|
ShowOnScreenMessage(message, 4f);
|
||||||
|
PlayerDisconnected?.Invoke(steamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
if (onScreenMessages.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float now = Time.unscaledTime;
|
||||||
|
for (int i = onScreenMessages.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (onScreenMessages[i].ExpiresAt <= now)
|
||||||
|
{
|
||||||
|
onScreenMessages.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGUI()
|
||||||
|
{
|
||||||
|
if (onScreenMessages.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float y = 20f;
|
||||||
|
foreach (var entry in onScreenMessages)
|
||||||
|
{
|
||||||
|
if (entry.ExpiresAt <= Time.unscaledTime)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
GUIContent content = new(entry.Text);
|
||||||
|
Vector2 size = GUI.skin.box.CalcSize(content);
|
||||||
|
Rect rect = new(20f, y, Mathf.Max(220f, size.x + 20f), size.y + 10f);
|
||||||
|
GUI.Box(rect, content);
|
||||||
|
y += rect.height + 6f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterExistingMembers(CSteamID lobbyId)
|
||||||
|
{
|
||||||
|
lobbyMembers.Clear();
|
||||||
|
memberNames.Clear();
|
||||||
|
|
||||||
|
int count = SteamMatchmaking.GetNumLobbyMembers(lobbyId);
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
CSteamID member = SteamMatchmaking.GetLobbyMemberByIndex(lobbyId, i);
|
||||||
|
RegisterMember(member.m_SteamID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterMember(ulong steamId)
|
||||||
|
{
|
||||||
|
if (steamId == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lobbyMembers.Add(steamId);
|
||||||
|
memberNames[steamId] = ResolveMemberNameInternal(steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveMemberName(ulong steamId)
|
||||||
|
{
|
||||||
|
if (memberNames.TryGetValue(steamId, out string cached) && !string.IsNullOrEmpty(cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolveMemberNameInternal(steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveMemberNameInternal(ulong steamId)
|
||||||
|
{
|
||||||
|
#if STEAMWORKSNET
|
||||||
|
if (steamId != 0 && SteamBootstrap.IsInitialized)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return SteamFriends.GetFriendPersonaName(new CSteamID(steamId));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore Steam API hiccups and fall back to id string.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return steamId != 0 ? steamId.ToString() : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowOnScreenMessage(string message, float lifetimeSeconds)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float expiry = Time.unscaledTime + Mathf.Max(0.5f, lifetimeSeconds);
|
||||||
|
onScreenMessages.Add(new OnScreenMessage
|
||||||
|
{
|
||||||
|
Text = message,
|
||||||
|
ExpiresAt = expiry
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ForceDisconnectFromLobby()
|
||||||
|
{
|
||||||
|
if (ActiveLobby != CSteamID.Nil && SteamBootstrap.IsInitialized)
|
||||||
|
{
|
||||||
|
SteamMatchmaking.LeaveLobby(ActiveLobby);
|
||||||
|
}
|
||||||
|
|
||||||
|
lobbyMembers.Clear();
|
||||||
|
memberNames.Clear();
|
||||||
|
lobbyOwnerSteamId = 0;
|
||||||
|
isConnected = false;
|
||||||
|
p2pTransport?.SetActiveLobby(CSteamID.Nil);
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OnScreenMessage
|
||||||
|
{
|
||||||
|
public string Text;
|
||||||
|
public float ExpiresAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user