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/Components.html#playerinputmanager-component")]
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
{
}
}
}