using System; using UnityEngine.Events; using UnityEngine.InputSystem.Controls; using UnityEngine.InputSystem.LowLevel; using UnityEngine.InputSystem.Users; using UnityEngine.InputSystem.Utilities; #if UNITY_EDITOR using UnityEditor; #endif ////REVIEW: should we automatically pool/retain up to maxPlayerCount player instances? ////REVIEW: the join/leave messages should probably give a *GameObject* rather than the PlayerInput component (which can be gotten to via a simple GetComponent(InChildren) call) ////TODO: add support for reacting to players missing devices namespace UnityEngine.InputSystem { /// /// Manages joining and leaving of players. /// /// /// This is a singleton component. Only one instance is meant to be active in a game /// at any one time. To retrieve the current instance, use . /// /// Note that a PlayerInputManager is not strictly required to have multiple components. /// What PlayerInputManager provides is the implementation of specific player join mechanisms /// () as well as automatic assignment of split-screen areas (). /// However, you can always implement your own custom logic instead and simply instantiate multiple GameObjects with /// yourself. /// [AddComponentMenu("Input/Player Input Manager")] [HelpURL(InputSystem.kDocUrl + "/manual/PlayerInputManager.html")] public class PlayerInputManager : MonoBehaviour { /// /// Name of the message that is sent when a player joins the game. /// public const string PlayerJoinedMessage = "OnPlayerJoined"; public const string PlayerLeftMessage = "OnPlayerLeft"; /// /// If enabled, each player will automatically be assigned a portion of the available screen area. /// /// /// For this to work, each component must have an associated /// object through . /// /// Note that as player join, the screen may be increasingly subdivided and players may see their /// previous screen area getting resized. /// public bool splitScreen { get => m_SplitScreen; set { if (m_SplitScreen == value) return; m_SplitScreen = value; if (!m_SplitScreen) { // Reset rects on all player cameras. foreach (var player in PlayerInput.all) { var camera = player.camera; if (camera != null) camera.rect = new Rect(0, 0, 1, 1); } } else { UpdateSplitScreen(); } } } ////REVIEW: we probably need support for filling unused screen areas automatically /// /// If is enabled, this property determines whether subdividing the screen is allowed to /// produce screen areas that have an aspect ratio different from the screen resolution. /// /// /// By default, when is enabled, the manager will add or remove screen subdivisions in /// steps of two. This means that when, for example, the second player is added, the screen will be subdivided into /// a left and a right screen area; the left one allocated to the first player and the right one allocated to the /// second player. /// /// This behavior makes optimal use of screen real estate but will result in screen areas that have aspect ratios /// different from the screen resolution. If this is not acceptable, this property can be set to true to enforce /// split-screen to only create screen areas that have the same aspect ratio of the screen. /// /// This results in the screen being subdivided more aggressively. When, for example, a second player is added, /// the screen will immediately be divided into a four-way split-screen setup with the lower two screen areas /// not being used. /// /// This property is irrelevant if is used. /// public bool maintainAspectRatioInSplitScreen => m_MaintainAspectRatioInSplitScreen; /// /// If is enabled, this property determines how many screen divisions there will be. /// /// /// This is only used if is true. /// /// By default this is set to -1 which means the screen will automatically be divided to best fit the /// current number of players i.e. the highest player index in /// public int fixedNumberOfSplitScreens => m_FixedNumberOfSplitScreens; /// /// The normalized screen rectangle available for allocating player split-screens into. /// /// /// This is only used if is true. /// /// By default it is set to (0,0,1,1), i.e. the entire screen area will be used for player screens. /// If, for example, part of the screen should display a UI/information shared by all players, this /// property can be used to cut off the area and not have it used by PlayerInputManager. /// public Rect splitScreenArea => m_SplitScreenRect; /// /// The current number of active players. /// /// /// This count corresponds to all instances that are currently enabled. /// public int playerCount => PlayerInput.s_AllActivePlayersCount; ////FIXME: this needs to be settable /// /// Maximum number of players allowed concurrently in the game. /// /// /// If this limit is reached, joining is turned off automatically. /// /// By default this is set to -1. Any negative value deactivates the player limit and allows /// arbitrary many players to join. /// public int maxPlayerCount => m_MaxPlayerCount; /// /// Whether new players can currently join. /// /// /// While this is true, new players can join via the mechanism determined by . /// /// /// public bool joiningEnabled => m_AllowJoining; /// /// Determines the mechanism by which players can join when joining is enabled (). /// /// /// public PlayerJoinBehavior joinBehavior { get => m_JoinBehavior; set { if (m_JoinBehavior == value) return; var joiningEnabled = m_AllowJoining; if (joiningEnabled) DisableJoining(); m_JoinBehavior = value; if (joiningEnabled) EnableJoining(); } } /// /// The input action that a player must trigger to join the game. /// /// /// If the join action is a reference to an existing input action, it will be cloned when the PlayerInputManager /// is enabled. This avoids the situation where the join action can become disabled after the first user joins which /// can happen when the join action is the same as a player in-game action. When a player joins, input bindings from /// devices other than the device they joined with are disabled. If the join action had a binding for keyboard and one /// for gamepad for example, and the first player joined using the keyboard, the expectation is that the next player /// could still join by pressing the gamepad join button. Without the cloning behavior, the gamepad input would have /// been disabled. /// /// For more details about joining behavior, see . /// public InputActionProperty joinAction { get => m_JoinAction; set { if (m_JoinAction == value) return; ////REVIEW: should we suppress notifications for temporary disables? var joinEnabled = m_AllowJoining && m_JoinBehavior == PlayerJoinBehavior.JoinPlayersWhenJoinActionIsTriggered; if (joinEnabled) DisableJoining(); m_JoinAction = value; if (joinEnabled) EnableJoining(); } } public PlayerNotifications notificationBehavior { get => m_NotificationBehavior; set => m_NotificationBehavior = value; } public PlayerJoinedEvent playerJoinedEvent { get { if (m_PlayerJoinedEvent == null) m_PlayerJoinedEvent = new PlayerJoinedEvent(); return m_PlayerJoinedEvent; } } public PlayerLeftEvent playerLeftEvent { get { if (m_PlayerLeftEvent == null) m_PlayerLeftEvent = new PlayerLeftEvent(); return m_PlayerLeftEvent; } } public event Action onPlayerJoined { add { if (value == null) throw new ArgumentNullException(nameof(value)); m_PlayerJoinedCallbacks.AddCallback(value); } remove { if (value == null) throw new ArgumentNullException(nameof(value)); m_PlayerJoinedCallbacks.RemoveCallback(value); } } public event Action onPlayerLeft { add { if (value == null) throw new ArgumentNullException(nameof(value)); m_PlayerLeftCallbacks.AddCallback(value); } remove { if (value == null) throw new ArgumentNullException(nameof(value)); m_PlayerLeftCallbacks.RemoveCallback(value); } } /// /// Reference to the prefab that the manager will instantiate when players join. /// /// Prefab to instantiate for new players. public GameObject playerPrefab { get => m_PlayerPrefab; set => m_PlayerPrefab = value; } /// /// Singleton instance of the manager. /// /// Singleton instance or null. public static PlayerInputManager instance { get; private set; } /// /// Allow players to join the game based on . /// /// /// public void EnableJoining() { switch (m_JoinBehavior) { case PlayerJoinBehavior.JoinPlayersWhenButtonIsPressed: ValidateInputActionAsset(); if (!m_UnpairedDeviceUsedDelegateHooked) { if (m_UnpairedDeviceUsedDelegate == null) m_UnpairedDeviceUsedDelegate = OnUnpairedDeviceUsed; InputUser.onUnpairedDeviceUsed += m_UnpairedDeviceUsedDelegate; m_UnpairedDeviceUsedDelegateHooked = true; ++InputUser.listenForUnpairedDeviceActivity; } break; case PlayerJoinBehavior.JoinPlayersWhenJoinActionIsTriggered: // Hook into join action if we have one. if (m_JoinAction.action != null) { if (!m_JoinActionDelegateHooked) { if (m_JoinActionDelegate == null) m_JoinActionDelegate = JoinPlayerFromActionIfNotAlreadyJoined; m_JoinAction.action.performed += m_JoinActionDelegate; m_JoinActionDelegateHooked = true; } m_JoinAction.action.Enable(); } else { Debug.LogError( $"No join action configured on PlayerInputManager but join behavior is set to {nameof(PlayerJoinBehavior.JoinPlayersWhenJoinActionIsTriggered)}", this); } break; } m_AllowJoining = true; } /// /// Inhibit players from joining the game. /// /// /// public void DisableJoining() { switch (m_JoinBehavior) { case PlayerJoinBehavior.JoinPlayersWhenButtonIsPressed: if (m_UnpairedDeviceUsedDelegateHooked) { InputUser.onUnpairedDeviceUsed -= m_UnpairedDeviceUsedDelegate; m_UnpairedDeviceUsedDelegateHooked = false; --InputUser.listenForUnpairedDeviceActivity; } break; case PlayerJoinBehavior.JoinPlayersWhenJoinActionIsTriggered: if (m_JoinActionDelegateHooked) { var joinAction = m_JoinAction.action; if (joinAction != null) m_JoinAction.action.performed -= m_JoinActionDelegate; m_JoinActionDelegateHooked = false; } m_JoinAction.action?.Disable(); break; } m_AllowJoining = false; } ////TODO /// /// Join a new player based on input on a UI element. /// /// /// This should be called directly from a UI callback such as . The device /// that the player joins with is taken from the device that was used to interact with the UI element. /// internal void JoinPlayerFromUI() { if (!CheckIfPlayerCanJoin()) return; //find used device; InputSystemUIInputModule should probably make that available throw new NotImplementedException(); } /// /// Join a new player based on input received through an . /// /// /// /// public void JoinPlayerFromAction(InputAction.CallbackContext context) { if (!CheckIfPlayerCanJoin()) return; var device = context.control.device; JoinPlayer(pairWithDevice: device); } public void JoinPlayerFromActionIfNotAlreadyJoined(InputAction.CallbackContext context) { if (!CheckIfPlayerCanJoin()) return; var device = context.control.device; if (PlayerInput.FindFirstPairedToDevice(device) != null) return; JoinPlayer(pairWithDevice: device); } /// /// Spawn a new player from . /// /// Optional explicit to assign to the player. Must be unique within /// . If not supplied, a player index will be assigned automatically (smallest unused index will be used). /// Optional . If supplied, this assigns a split-screen area to the player. For example, /// a split-screen index of /// Control scheme to activate on the player (optional). If not supplied, a control scheme will /// be selected based on . If no device is given either, the first control scheme that matches /// the currently available unpaired devices (see ) is used. /// Device to pair to the player. Also determines which control scheme to use if /// is not given. /// The newly instantiated player or null if joining failed. /// /// Joining must be enabled (see ) or the method will fail. /// /// To pair multiple devices, use . /// public PlayerInput JoinPlayer(int playerIndex = -1, int splitScreenIndex = -1, string controlScheme = null, InputDevice pairWithDevice = null) { if (!CheckIfPlayerCanJoin(playerIndex)) return null; PlayerInput.s_DestroyIfDeviceSetupUnsuccessful = true; return PlayerInput.Instantiate(m_PlayerPrefab, playerIndex: playerIndex, splitScreenIndex: splitScreenIndex, controlScheme: controlScheme, pairWithDevice: pairWithDevice); } /// /// Spawn a new player from . /// /// Optional explicit to assign to the player. Must be unique within /// . If not supplied, a player index will be assigned automatically (smallest unused index will be used). /// Optional . If supplied, this assigns a split-screen area to the player. For example, /// a split-screen index of /// Control scheme to activate on the player (optional). If not supplied, a control scheme will /// be selected based on . If no device is given either, the first control scheme that matches /// the currently available unpaired devices (see ) is used. /// Devices to pair to the player. Also determines which control scheme to use if /// is not given. /// The newly instantiated player or null if joining failed. /// /// Joining must be enabled (see ) or the method will fail. /// public PlayerInput JoinPlayer(int playerIndex = -1, int splitScreenIndex = -1, string controlScheme = null, params InputDevice[] pairWithDevices) { if (!CheckIfPlayerCanJoin(playerIndex)) return null; PlayerInput.s_DestroyIfDeviceSetupUnsuccessful = true; return PlayerInput.Instantiate(m_PlayerPrefab, playerIndex: playerIndex, splitScreenIndex: splitScreenIndex, controlScheme: controlScheme, pairWithDevices: pairWithDevices); } [SerializeField] internal PlayerNotifications m_NotificationBehavior; [Tooltip("Set a limit for the maximum number of players who are able to join.")] [SerializeField] internal int m_MaxPlayerCount = -1; [SerializeField] internal bool m_AllowJoining = true; [SerializeField] internal PlayerJoinBehavior m_JoinBehavior; [SerializeField] internal PlayerJoinedEvent m_PlayerJoinedEvent; [SerializeField] internal PlayerLeftEvent m_PlayerLeftEvent; [SerializeField] internal InputActionProperty m_JoinAction; [SerializeField] internal GameObject m_PlayerPrefab; [SerializeField] internal bool m_SplitScreen; [SerializeField] internal bool m_MaintainAspectRatioInSplitScreen; [Tooltip("Explicitly set a fixed number of screens or otherwise allow the screen to be divided automatically to best fit the number of players.")] [SerializeField] internal int m_FixedNumberOfSplitScreens = -1; [SerializeField] internal Rect m_SplitScreenRect = new Rect(0, 0, 1, 1); [NonSerialized] private bool m_JoinActionDelegateHooked; [NonSerialized] private bool m_UnpairedDeviceUsedDelegateHooked; [NonSerialized] private Action m_JoinActionDelegate; [NonSerialized] private Action m_UnpairedDeviceUsedDelegate; [NonSerialized] private CallbackArray> m_PlayerJoinedCallbacks; [NonSerialized] private CallbackArray> m_PlayerLeftCallbacks; internal static string[] messages => new[] { PlayerJoinedMessage, PlayerLeftMessage, }; private bool CheckIfPlayerCanJoin(int playerIndex = -1) { if (m_PlayerPrefab == null) { Debug.LogError("playerPrefab must be set in order to be able to join new players", this); return false; } if (m_MaxPlayerCount >= 0 && playerCount >= m_MaxPlayerCount) { Debug.LogError("Have reached maximum player count of " + maxPlayerCount, this); return false; } // If we have a player index, make sure it's unique. if (playerIndex != -1) { for (var i = 0; i < PlayerInput.s_AllActivePlayersCount; ++i) if (PlayerInput.s_AllActivePlayers[i].playerIndex == playerIndex) { Debug.LogError( $"Player index #{playerIndex} is already taken by player {PlayerInput.s_AllActivePlayers[i]}", PlayerInput.s_AllActivePlayers[i]); return false; } } return true; } private void OnUnpairedDeviceUsed(InputControl control, InputEventPtr eventPtr) { if (!m_AllowJoining) return; if (m_JoinBehavior == PlayerJoinBehavior.JoinPlayersWhenButtonIsPressed) { // Make sure it's a button that was actuated. if (!(control is ButtonControl)) return; // Make sure it's a device that is usable by the player's actions. We don't want // to join a player who's then stranded and has no way to actually interact with the game. if (!IsDeviceUsableWithPlayerActions(control.device)) return; ////REVIEW: should we log a warning or error when the actions for the player do not have control schemes? JoinPlayer(pairWithDevice: control.device); } } private void OnEnable() { if (instance == null) { instance = this; } else { Debug.LogWarning("Multiple PlayerInputManagers in the game. There should only be one PlayerInputManager", this); return; } // if the join action is a reference, clone it so we don't run into problems with the action being disabled by // PlayerInput when devices are assigned to individual players if (joinAction.reference != null && joinAction.action?.actionMap?.asset != null) { var inputActionAsset = Instantiate(joinAction.action.actionMap.asset); var inputActionReference = InputActionReference.Create(inputActionAsset.FindAction(joinAction.action.name)); joinAction = new InputActionProperty(inputActionReference); } // Join all players already in the game. for (var i = 0; i < PlayerInput.s_AllActivePlayersCount; ++i) NotifyPlayerJoined(PlayerInput.s_AllActivePlayers[i]); if (m_AllowJoining) EnableJoining(); } private void OnDisable() { if (instance == this) instance = null; if (m_AllowJoining) DisableJoining(); } /// /// If split-screen is enabled, then for each player in the game, adjust the player's /// to fit the player's split screen area according to the number of players currently in the game and the /// current split-screen configuration. /// private void UpdateSplitScreen() { // Nothing to do if split-screen is not enabled. if (!m_SplitScreen) return; // Determine number of split-screens to create based on highest player index we have. var minSplitScreenCount = 0; foreach (var player in PlayerInput.all) { if (player.playerIndex >= minSplitScreenCount) minSplitScreenCount = player.playerIndex + 1; } // Adjust to fixed number if we have it. if (m_FixedNumberOfSplitScreens > 0) { if (m_FixedNumberOfSplitScreens < minSplitScreenCount) Debug.LogWarning( $"Highest playerIndex of {minSplitScreenCount} exceeds fixed number of split-screens of {m_FixedNumberOfSplitScreens}", this); minSplitScreenCount = m_FixedNumberOfSplitScreens; } // Determine divisions along X and Y. Usually, we have a square grid of split-screens so all we need to // do is make it large enough to fit all players. var numDivisionsX = Mathf.CeilToInt(Mathf.Sqrt(minSplitScreenCount)); var numDivisionsY = numDivisionsX; if (!m_MaintainAspectRatioInSplitScreen && numDivisionsX * (numDivisionsX - 1) >= minSplitScreenCount) { // We're allowed to produce split-screens with aspect ratios different from the screen meaning // that we always add one more column before finally adding an entirely new row. numDivisionsY -= 1; } // Assign split-screen area to each player. foreach (var player in PlayerInput.all) { // Make sure the player's splitScreenIndex isn't out of range. var splitScreenIndex = player.splitScreenIndex; if (splitScreenIndex >= numDivisionsX * numDivisionsY) { Debug.LogError( $"Split-screen index of {splitScreenIndex} on player is out of range (have {numDivisionsX * numDivisionsY} screens); resetting to playerIndex", player); player.m_SplitScreenIndex = player.playerIndex; } // Make sure we have a camera. var camera = player.camera; if (camera == null) { Debug.LogError( "Player has no camera associated with it. Cannot set up split-screen. Point PlayerInput.camera to camera for player.", player); continue; } // Assign split-screen area based on m_SplitScreenRect. var column = splitScreenIndex % numDivisionsX; var row = splitScreenIndex / numDivisionsX; var rect = new Rect { width = m_SplitScreenRect.width / numDivisionsX, height = m_SplitScreenRect.height / numDivisionsY }; rect.x = m_SplitScreenRect.x + column * rect.width; // Y is bottom-to-top but we fill from top down. rect.y = m_SplitScreenRect.y + m_SplitScreenRect.height - (row + 1) * rect.height; camera.rect = rect; } } private bool IsDeviceUsableWithPlayerActions(InputDevice device) { Debug.Assert(device != null); if (m_PlayerPrefab == null) return true; var playerInput = m_PlayerPrefab.GetComponentInChildren(); if (playerInput == null) return true; var actions = playerInput.actions; if (actions == null) return true; // If the asset has control schemes, see if there's one that works with the device plus // whatever unpaired devices we have left. if (actions.controlSchemes.Count > 0) { using (var unpairedDevices = InputUser.GetUnpairedInputDevices()) { if (InputControlScheme.FindControlSchemeForDevices(unpairedDevices, actions.controlSchemes, mustIncludeDevice: device) == null) return false; } return true; } // Otherwise just check whether any of the maps has bindings usable with the device. foreach (var actionMap in actions.actionMaps) if (actionMap.IsUsableWithDevice(device)) return true; return false; } private void ValidateInputActionAsset() { #if DEVELOPMENT_BUILD || UNITY_EDITOR if (m_PlayerPrefab == null || m_PlayerPrefab.GetComponentInChildren() == null) return; var actions = m_PlayerPrefab.GetComponentInChildren().actions; if (actions == null) return; var isValid = true; foreach (var controlScheme in actions.controlSchemes) { if (controlScheme.deviceRequirements.Count > 0) break; isValid = false; } if (isValid) return; var assetInfo = actions.name; #if UNITY_EDITOR assetInfo = AssetDatabase.GetAssetPath(actions); #endif Debug.LogWarning($"The input action asset '{assetInfo}' in the player prefab assigned to PlayerInputManager has " + "no control schemes with required devices. The JoinPlayersWhenButtonIsPressed join behavior " + "will not work unless the expected input devices are listed as requirements in the input " + "action asset.", m_PlayerPrefab); #endif } /// /// Called by when it is enabled. /// /// internal void NotifyPlayerJoined(PlayerInput player) { Debug.Assert(player != null); UpdateSplitScreen(); switch (m_NotificationBehavior) { case PlayerNotifications.SendMessages: SendMessage(PlayerJoinedMessage, player, SendMessageOptions.DontRequireReceiver); break; case PlayerNotifications.BroadcastMessages: BroadcastMessage(PlayerJoinedMessage, player, SendMessageOptions.DontRequireReceiver); break; case PlayerNotifications.InvokeUnityEvents: m_PlayerJoinedEvent?.Invoke(player); break; case PlayerNotifications.InvokeCSharpEvents: DelegateHelpers.InvokeCallbacksSafe(ref m_PlayerJoinedCallbacks, player, "onPlayerJoined"); break; } } /// /// Called by when it is disabled. /// /// internal void NotifyPlayerLeft(PlayerInput player) { Debug.Assert(player != null); UpdateSplitScreen(); switch (m_NotificationBehavior) { case PlayerNotifications.SendMessages: SendMessage(PlayerLeftMessage, player, SendMessageOptions.DontRequireReceiver); break; case PlayerNotifications.BroadcastMessages: BroadcastMessage(PlayerLeftMessage, player, SendMessageOptions.DontRequireReceiver); break; case PlayerNotifications.InvokeUnityEvents: m_PlayerLeftEvent?.Invoke(player); break; case PlayerNotifications.InvokeCSharpEvents: DelegateHelpers.InvokeCallbacksSafe(ref m_PlayerLeftCallbacks, player, "onPlayerLeft"); break; } } [Serializable] public class PlayerJoinedEvent : UnityEvent { } [Serializable] public class PlayerLeftEvent : UnityEvent { } } }