diff --git a/Assets/Scripts/EOSVoiceChat.cs b/Assets/Scripts/EOSVoiceChat.cs index 9a4bffc..a3c93f7 100644 --- a/Assets/Scripts/EOSVoiceChat.cs +++ b/Assets/Scripts/EOSVoiceChat.cs @@ -20,24 +20,29 @@ public class EOSVoiceChat private readonly RTCInterface rtcInterface; private readonly RTCAudioInterface audioInterface; private readonly Func productUserProvider; + private readonly IProductUserMapper productUserMapper; + + private readonly Dictionary chatUsers = new Dictionary(); private string connectedLobbyId; + private string rtcRoomName; public bool IsConnected => lobbyInterface != null && rtcInterface != null && audioInterface != null && !string.IsNullOrEmpty(connectedLobbyId); private ulong? onConnectionChangedCallbackId, onParticipantStatusChangedCallbackId, onParticipantUpdatedCallbackId; - + /// /// Provide the required interfaces for voice chat. Product User ID is provided through a callback, so that the /// same instance of this class can remain in use even if the logged in user changes. /// public EOSVoiceChat( LobbyInterface lobbyInterface, RTCInterface rtcInterface, RTCAudioInterface audioInterface, - Func productUserProvider) + Func productUserProvider, IProductUserMapper productUserMapper) { this.lobbyInterface = lobbyInterface; this.rtcInterface = rtcInterface; this.audioInterface = audioInterface; this.productUserProvider = productUserProvider; + this.productUserMapper = productUserMapper; } /// @@ -105,19 +110,24 @@ public class EOSVoiceChat { case Result.Success: connectedLobbyId = data.LobbyId; - Debug.Log($"Chat lobby creation successful, lobby ID = {connectedLobbyId}"); - connectArgs.onCompleted?.Invoke(true); + UpdateRTCRoomName(); + Debug.Log($"Chat lobby creation successful, lobby ID = {connectedLobbyId}, RTC room name = {rtcRoomName}"); + + chatUsers.Clear(); SubscribeToRoomNotifications(); + connectArgs.onCompleted?.Invoke(true); break; case Result.LobbyLobbyAlreadyExists: // This can happen if two clients try to create the same lobby at the same time, a classic race condition. // Try to join the other client's newly created chat lobby instead. connectedLobbyId = null; + rtcRoomName = null; Debug.Log("Chat lobby already exists, attempting to join it..."); ConnectToChat(connectArgs.chatLobbyName, connectArgs.onCompleted, connectArgs.maxChatPlayers); break; default: connectedLobbyId = null; + rtcRoomName = null; Debug.LogError($"Chat lobby creation failed, result code = {data.ResultCode}"); connectArgs.onCompleted?.Invoke(false); break; @@ -131,18 +141,39 @@ public class EOSVoiceChat { case Result.Success: connectedLobbyId = data.LobbyId; - Debug.Log($"Chat lobby joined successfully, lobby ID = {connectedLobbyId}"); - connectArgs.onCompleted?.Invoke(true); + UpdateRTCRoomName(); + Debug.Log($"Chat lobby joined successfully, lobby ID = {connectedLobbyId}, RTC room name = {rtcRoomName}"); + + chatUsers.Clear(); SubscribeToRoomNotifications(); + connectArgs.onCompleted?.Invoke(true); break; default: connectedLobbyId = null; + rtcRoomName = null; Debug.LogError($"Chat lobby join failed, result code = {data.ResultCode}"); connectArgs.onCompleted?.Invoke(false); break; } } + private void UpdateRTCRoomName() + { + rtcRoomName = null; + + var result = lobbyInterface.GetRTCRoomName( + new GetRTCRoomNameOptions { LocalUserId = productUserProvider.Invoke(), LobbyId = connectedLobbyId }, + out string roomName); + + if (result != Result.Success) + { + Debug.LogError($"Failed to obtain RTC room name for lobby with ID {connectedLobbyId}"); + return; + } + + rtcRoomName = roomName; + } + public void DisconnectChat() { if (!IsConnected) @@ -163,6 +194,8 @@ public class EOSVoiceChat ); connectedLobbyId = null; + rtcRoomName = null; + chatUsers.Clear(); } /// @@ -173,25 +206,33 @@ public class EOSVoiceChat if (!IsConnected) return; - // TODO: can also disable Sending of data entirely - audioInterface.SetAudioInputSettings(new SetAudioInputSettingsOptions + audioInterface.UpdateSending(new UpdateSendingOptions { LocalUserId = productUserProvider.Invoke(), - DeviceId = null, // Default input device - Volume = muted ? 0f : 100f, - }); + RoomName = rtcRoomName, + AudioStatus = muted ? RTCAudioStatus.Disabled : RTCAudioStatus.Enabled, + }, null, data => { }); } /// /// Mute or unmute a specific remove player. This can be used to filter out specific players in the chat lobby, /// or for manually muting toxic players. /// - public void SetRemoteMuted(string playerId, bool muted) + public void SetRemoteMuted(string remotePlayerId, bool muted) { if (!IsConnected) return; - + productUserMapper.MapPlatformIdToProductUser(remotePlayerId, remoteProductUser => + { + audioInterface.UpdateReceiving(new UpdateReceivingOptions + { + LocalUserId = productUserProvider.Invoke(), + ParticipantId = remoteProductUser, + RoomName = rtcRoomName, + AudioEnabled = !muted, + }, null, data => { }); + }); } /// @@ -202,7 +243,18 @@ public class EOSVoiceChat { if (!IsConnected) return; - + + var localUserId = productUserProvider.Invoke(); + foreach (var remoteProductUser in chatUsers.Keys) + { + audioInterface.UpdateReceiving(new UpdateReceivingOptions + { + LocalUserId = localUserId, + ParticipantId = remoteProductUser, + RoomName = rtcRoomName, + AudioEnabled = false, + }, null, data => { }); + } } /// @@ -218,23 +270,30 @@ public class EOSVoiceChat Volume = volume * 50f, }); } + + /// + /// Whether the requested player is currently talking or not. + /// This can be either the local player or a remote player. + /// + public bool IsUserTalking(string playerId) + { + foreach (var chatUser in chatUsers.Values) + { + if (chatUser.platformPlayerId != playerId) + continue; + + return chatUser.audioStatus == RTCAudioStatus.Enabled && chatUser.isSpeaking; + } + + return false; + } // TODO: handle input/output device changes (keep track of current devices) - // TODO: handle participant changes (add/remove users) => also map to platform-specific player IDs - // TODO: query local/remote status (is talking or not) private void SubscribeToRoomNotifications() { var localUserId = productUserProvider.Invoke(); - if (lobbyInterface.GetRTCRoomName( - new GetRTCRoomNameOptions { LocalUserId = localUserId, LobbyId = connectedLobbyId }, - out string roomName) != Result.Success) - { - Debug.LogError($"Failed to obtain RTC room name for lobby with ID {connectedLobbyId}"); - return; - } - if (!onConnectionChangedCallbackId.HasValue) { onConnectionChangedCallbackId = lobbyInterface.AddNotifyRTCRoomConnectionChanged @@ -256,7 +315,7 @@ public class EOSVoiceChat new AddNotifyParticipantStatusChangedOptions { LocalUserId = localUserId, - RoomName = roomName, + RoomName = rtcRoomName, }, null, HandleParticipantStatusChanged @@ -270,7 +329,7 @@ public class EOSVoiceChat new AddNotifyParticipantUpdatedOptions { LocalUserId = localUserId, - RoomName = roomName, + RoomName = rtcRoomName, }, null, HandleParticipantUpdated @@ -303,16 +362,44 @@ public class EOSVoiceChat { Debug.Log($"RTC Room connection changed, connected = {data.IsConnected}, disconnect reason = {data.DisconnectReason}"); // Note: reconnecting is handled automatically by the lobby system + + if (!data.IsConnected && + (data.DisconnectReason == Result.UserKicked || data.DisconnectReason == Result.UserBanned)) + { + // If we're either kicked or banned then we won't automatically reconnect, so leave the lobby properly + DisconnectChat(); + } } private void HandleParticipantStatusChanged(ParticipantStatusChangedCallbackInfo data) { Debug.Log($"Participant status changed, participant = {data.ParticipantId}, status = {data.ParticipantStatus}"); + + switch (data.ParticipantStatus) + { + case RTCParticipantStatus.Joined: + if (chatUsers.ContainsKey(data.ParticipantId)) + return; + + var chatUser = new ChatUser(data.ParticipantId); + productUserMapper.MapProductUserToPlatformId(data.ParticipantId, pid => chatUser.platformPlayerId = pid); + chatUsers.Add(data.ParticipantId, chatUser); + break; + case RTCParticipantStatus.Left: + chatUsers.Remove(data.ParticipantId); + break; + } } private void HandleParticipantUpdated(ParticipantUpdatedCallbackInfo data) { Debug.Log($"Participant status changed, participant = {data.ParticipantId}, speaking = {data.Speaking}, audio status = {data.AudioStatus}"); + + if (chatUsers.TryGetValue(data.ParticipantId, out var chatUser)) + { + chatUser.audioStatus = data.AudioStatus; + chatUser.isSpeaking = data.Speaking; + } } private class ChatConnectArgs @@ -332,8 +419,14 @@ public class EOSVoiceChat private class ChatUser { public readonly ProductUserId productUserId; - public string platformPlayerId; - + public string platformPlayerId; + public RTCAudioStatus audioStatus; + public bool isSpeaking; + + public ChatUser(ProductUserId productUserId) + { + this.productUserId = productUserId; + } } } diff --git a/Assets/Scripts/IProductUserMapper.cs b/Assets/Scripts/IProductUserMapper.cs new file mode 100644 index 0000000..36ed9ca --- /dev/null +++ b/Assets/Scripts/IProductUserMapper.cs @@ -0,0 +1,13 @@ +using System; +using Epic.OnlineServices; + +/// +/// Generic interface for converting between EOS Product Users and platform-specific Player IDs. +/// The exact rules for mapping from one to the other may differ per platform, e.g. Steam and Epic will use different logic than Xbox and PlayStation. +/// The methods in this interface are asynchronous, though implementations may choose to pre-cache mappings to speed up the process. +/// +public interface IProductUserMapper +{ + void MapProductUserToPlatformId(ProductUserId productUserId, Action platformIdCallback); + void MapPlatformIdToProductUser(string platformId, Action productUserCallback); +} diff --git a/Assets/Scripts/IProductUserMapper.cs.meta b/Assets/Scripts/IProductUserMapper.cs.meta new file mode 100644 index 0000000..dca9303 --- /dev/null +++ b/Assets/Scripts/IProductUserMapper.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d8a80c90c491474dbe97eba62578254f +timeCreated: 1625665094 \ No newline at end of file diff --git a/Assets/Scripts/MagnificentVoiceChat.cs b/Assets/Scripts/MagnificentVoiceChat.cs index 74d9ff8..6c08daa 100644 --- a/Assets/Scripts/MagnificentVoiceChat.cs +++ b/Assets/Scripts/MagnificentVoiceChat.cs @@ -126,7 +126,7 @@ public class MagnificentVoiceChat : MonoBehaviour ScopeFlags = AuthScopeFlags.BasicProfile, }, null, HandleLoginResult); - voiceChat = new EOSVoiceChat(lobbyInterface, rtcInterface, audioInterface, () => localProductUserId); + voiceChat = new EOSVoiceChat(lobbyInterface, rtcInterface, audioInterface, () => localProductUserId, new NilProductUserMapper()); } private Credentials GetEpicCredentials() // This is platform-specific @@ -305,4 +305,15 @@ public class MagnificentVoiceChat : MonoBehaviour GUILayout.Label(status.ToString()); } + + private class NilProductUserMapper : IProductUserMapper + { + public void MapProductUserToPlatformId(ProductUserId productUserId, Action platformIdCallback) + { + } + + public void MapPlatformIdToProductUser(string platformId, Action productUserCallback) + { + } + } }