Hey,
I want to connect with two players to an online multiplayer session. Each player should be able to use keyboard or gamepad controls and be able to switch dynamically.
Setup
I am using the New Input System (1.11.1) and the First Person Controller from the Unity Starter Assets package [1] (1.3). Further, I use Netcode for GameObjects (2.1.1) and the Multiplayer Widgets (1.0.0) to create and join a lobby.
What works
I tested functionality using the multiplayer play mode in the editor as well as with stand-alone builds using two computers. Creating and joining a session works as expected and both players spawn a character respectively. Further, when only one player is in the session, then the input system works as expected and both, keyboard and gamepad controls work and I can dynamically switch between the input devices.
The Problem
However, when joining the session with the second player, then input devices are forced such that player 0 is assigned the keyboard and player 1 is assigned the gamepad control scheme. This is even the case when testing on two computers and even when the computer of player 1 has no gamepad.
More Details
I was already debugging a lot, so here is more info. The First Person Controller from the Unity Starter Assets package comes with two scripts as well as the player input component:
Player Input Component
I use the default action map (and only one exists). The âDefault Schemeâ is set to <Any>. Setting it to KeyboardMouse does not resolve the problem and player 1 is still mapped to gamepad according to the editorâs inspector during play mode.
StarterAssetsInputs
Implements listeners for input events. This is a MonoBehaviour script. Added some events but basically unchanged from my side. Based on my debug logs, this script seems to work as expected.
Namely, in multiplayer play mode, when two players are in the session and the window of player 0 is active: Pressing inputs on the keyboard or the gamepad leads to a debug log in the input listener only for player 0 and NOT for player 1. The same is true in the other direction.
using System;
using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
namespace StarterAssets
{
public class StarterAssetsInputs : MonoBehaviour
{
[Header("Character Input Values")]
public Vector2 move;
public Vector2 look;
public bool jump;
public bool sprint;
public bool tab_menu = false;
[Header("Movement Settings")]
public bool analogMovement;
[Header("Mouse Cursor Settings")]
public bool cursorLocked = true;
public bool cursorInputForLook = true;
#if ENABLE_INPUT_SYSTEM
public void OnMove(InputValue value)
{
Debug.Log("StarterAssetsInputs - move input received");
MoveInput(value.Get<Vector2>());
}
public void OnLook(InputValue value)
{
if(cursorInputForLook)
{
LookInput(value.Get<Vector2>());
}
}
public void OnJump(InputValue value)
{
JumpInput(value.isPressed);
}
public void OnSprint(InputValue value)
{
SprintInput(value.isPressed);
}
#endif
public void OnTabMenu(InputValue value)
{
TabMenuInput(value.isPressed);
}
public void MoveInput(Vector2 newMoveDirection)
{
move = newMoveDirection;
}
public void LookInput(Vector2 newLookDirection)
{
look = newLookDirection;
}
public void JumpInput(bool newJumpState)
{
jump = newJumpState;
}
public void SprintInput(bool newSprintState)
{
sprint = newSprintState;
}
public void TabMenuInput(bool newTabMenuState)
{
tab_menu = newTabMenuState;
}
private void OnApplicationFocus(bool hasFocus)
{
SetCursorState(cursorLocked);
}
private void SetCursorState(bool newState)
{
Cursor.lockState = newState ? CursorLockMode.Locked : CursorLockMode.None;
}
}
}
FirstPersonController
Changed to a NetworkBehaviour script (which I think it has to be). Added the following wherever applicable:
if (!IsOwner) return;
In the Update() method after the IsOwner check, only inputs from the âforcedâ input device are received. I.e., in Update(), player 0 only receives inputs from the âforcedâ keyboard input, while player 1 only receives inputs from the âforcedâ gamepad input.
This implies, that the listener events are somehow not received in the first person controller?
using MimicSpace;
using Unity.Netcode;
using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
using UnityEngine.Windows;
#endif
namespace StarterAssets
{
[RequireComponent(typeof(CharacterController))]
#if ENABLE_INPUT_SYSTEM
[RequireComponent(typeof(PlayerInput))]
#endif
public class FirstPersonController : NetworkBehaviour
{
[Header("Player")]
[Tooltip("Move speed of the character in m/s")]
public float MoveSpeed = 4.0f;
[Tooltip("Sprint speed of the character in m/s")]
public float SprintSpeed = 6.0f;
[Tooltip("Rotation speed of the character")]
public float RotationSpeed = 1.0f;
[Tooltip("Acceleration and deceleration")]
public float SpeedChangeRate = 10.0f;
[Space(10)]
[Tooltip("The height the player can jump")]
public float JumpHeight = 1.2f;
[Tooltip("The character uses its own gravity value. The engine default is -9.81f")]
public float Gravity = -15.0f;
[Space(10)]
[Tooltip("Time required to pass before being able to jump again. Set to 0f to instantly jump again")]
public float JumpTimeout = 0.1f;
[Tooltip("Time required to pass before entering the fall state. Useful for walking down stairs")]
public float FallTimeout = 0.15f;
[Header("Player Grounded")]
[Tooltip("If the character is grounded or not. Not part of the CharacterController built in grounded check")]
public bool Grounded = true;
[Tooltip("Useful for rough ground")]
public float GroundedOffset = -0.14f;
[Tooltip("The radius of the grounded check. Should match the radius of the CharacterController")]
public float GroundedRadius = 0.5f;
[Tooltip("What layers the character uses as ground")]
public LayerMask GroundLayers;
[Header("Cinemachine")]
[Tooltip("The follow target set in the Cinemachine Virtual Camera that the camera will follow")]
private GameObject _cinemachineCameraTarget;
[Tooltip("How far in degrees can you move the camera up")]
public float TopClamp = 90.0f;
[Tooltip("How far in degrees can you move the camera down")]
public float BottomClamp = -90.0f;
// cinemachine
private float _cinemachineTargetPitch;
// player
private float _speed;
private float _rotationVelocity;
private float _verticalVelocity;
private float _terminalVelocity = 53.0f;
private Vector2 _prevAnimMovement = new Vector2(0, 0);
private float _animLerpCoef = 10f;
// timeout deltatime
private float _jumpTimeoutDelta;
private float _fallTimeoutDelta;
// connection ui
private bool _tab_menu_shown;
#if ENABLE_INPUT_SYSTEM
private PlayerInput _playerInput;
#endif
private CharacterController _controller;
private StarterAssetsInputs _input;
private GameObject _mainCamera;
private GameObject _connection_ui;
private Animator _animator;
private const float _threshold = 0.01f;
private bool IsCurrentDeviceMouse
{
get
{
#if ENABLE_INPUT_SYSTEM
return _playerInput.currentControlScheme == "KeyboardMouse";
#else
return false;
#endif
}
}
/*
public override void OnNetworkSpawn()
{
if (!IsOwner)
{
this.enabled = false;
}
}
*/
private void Awake()
{
if (!IsOwner) return;
// get a reference to our main camera
if (_mainCamera == null)
{
_mainCamera = GameObject.FindGameObjectWithTag("MainCamera");
}
}
private void Start()
{
// NOTE IsOwner needs to be checked here and it needs to be a NetworkBehavior script, otherwise, the connection_ui is shared among players!
if (!IsOwner) return;
if (_connection_ui == null)
{
_connection_ui = GameObject.FindGameObjectWithTag("ConnectionUI");
_tab_menu_shown = false;
_connection_ui.SetActive(_tab_menu_shown);
}
_controller = GetComponent<CharacterController>();
_input = GetComponent<StarterAssetsInputs>();
#if ENABLE_INPUT_SYSTEM
_playerInput = GetComponent<PlayerInput>();
#else
Debug.LogError( "Starter Assets package is missing dependencies. Please use Tools/Starter Assets/Reinstall Dependencies to fix it");
#endif
// add cinemachine camera target
_cinemachineCameraTarget = GameObject.FindGameObjectWithTag("CinemachineTarget");
// add animator
_animator = GetComponent<Animator>();
// reset our timeouts on start
_jumpTimeoutDelta = JumpTimeout;
_fallTimeoutDelta = FallTimeout;
}
private void Update()
{
if (!IsOwner) return;
Debug.Log("ID " + OwnerClientId + " " + _playerInput.currentControlScheme);
JumpAndGravity();
GroundedCheck();
Move();
MoveAnimation();
ToggleTabMenu();
}
private void LateUpdate()
{
if (!IsOwner) return;
CameraRotation();
}
private void GroundedCheck()
{
// set sphere position, with offset
Vector3 spherePosition = new Vector3(transform.position.x, transform.position.y - GroundedOffset, transform.position.z);
Grounded = Physics.CheckSphere(spherePosition, GroundedRadius, GroundLayers, QueryTriggerInteraction.Ignore);
}
private void CameraRotation()
{
// if there is an input
if (_input.look.sqrMagnitude >= _threshold)
{
//Don't multiply mouse input by Time.deltaTime
float deltaTimeMultiplier = IsCurrentDeviceMouse ? 1.0f : Time.deltaTime;
_cinemachineTargetPitch += _input.look.y * RotationSpeed * deltaTimeMultiplier;
_rotationVelocity = _input.look.x * RotationSpeed * deltaTimeMultiplier;
// clamp our pitch rotation
_cinemachineTargetPitch = ClampAngle(_cinemachineTargetPitch, BottomClamp, TopClamp);
// Update Cinemachine camera target pitch
_cinemachineCameraTarget.transform.localRotation = Quaternion.Euler(_cinemachineTargetPitch, 0.0f, 0.0f);
// rotate the player left and right
transform.Rotate(Vector3.up * _rotationVelocity);
}
}
private void Move()
{
// set target speed based on move speed, sprint speed and if sprint is pressed
float targetSpeed = _input.sprint ? SprintSpeed : MoveSpeed;
// a simplistic acceleration and deceleration designed to be easy to remove, replace, or iterate upon
// note: Vector2's == operator uses approximation so is not floating point error prone, and is cheaper than magnitude
// if there is no input, set the target speed to 0
if (_input.move == Vector2.zero) targetSpeed = 0.0f;
// a reference to the players current horizontal velocity
float currentHorizontalSpeed = new Vector3(_controller.velocity.x, 0.0f, _controller.velocity.z).magnitude;
float speedOffset = 0.1f;
float inputMagnitude = _input.analogMovement ? _input.move.magnitude : 1f;
// accelerate or decelerate to target speed
if (currentHorizontalSpeed < targetSpeed - speedOffset || currentHorizontalSpeed > targetSpeed + speedOffset)
{
// creates curved result rather than a linear one giving a more organic speed change
// note T in Lerp is clamped, so we don't need to clamp our speed
_speed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed * inputMagnitude, Time.deltaTime * SpeedChangeRate);
// round speed to 3 decimal places
_speed = Mathf.Round(_speed * 1000f) / 1000f;
}
else
{
_speed = targetSpeed;
}
// normalise input direction
Vector3 inputDirection = new Vector3(_input.move.x, 0.0f, _input.move.y).normalized;
// note: Vector2's != operator uses approximation so is not floating point error prone, and is cheaper than magnitude
// if there is a move input rotate player when the player is moving
if (_input.move != Vector2.zero)
{
// move
inputDirection = transform.right * _input.move.x + transform.forward * _input.move.y;
}
Vector3 movement = inputDirection.normalized * Mathf.Min(inputDirection.magnitude, 1.0f);
// move the player
_controller.Move(movement * (_speed * Time.deltaTime) + new Vector3(0.0f, _verticalVelocity, 0.0f) * Time.deltaTime);
}
private void MoveAnimation()
{
// move animation
Vector2 curAnimMovement = new Vector2(_input.move.x, _input.move.y);
curAnimMovement = curAnimMovement.normalized * Mathf.Min(curAnimMovement.magnitude, 1.0f);
Vector2 lerpAnimMovement = Vector2.Lerp(_prevAnimMovement, curAnimMovement, _animLerpCoef * Time.deltaTime);
_animator.SetFloat("animMoveX", lerpAnimMovement.x);
_animator.SetFloat("animMoveY", lerpAnimMovement.y);
_prevAnimMovement = lerpAnimMovement;
}
private void JumpAndGravity()
{
if (Grounded)
{
// reset the fall timeout timer
_fallTimeoutDelta = FallTimeout;
// stop our velocity dropping infinitely when grounded
if (_verticalVelocity < 0.0f)
{
_verticalVelocity = -2f;
}
// Jump
if (_input.jump && _jumpTimeoutDelta <= 0.0f)
{
// the square root of H * -2 * G = how much velocity needed to reach desired height
_verticalVelocity = Mathf.Sqrt(JumpHeight * -2f * Gravity);
}
// jump timeout
if (_jumpTimeoutDelta >= 0.0f)
{
_jumpTimeoutDelta -= Time.deltaTime;
}
}
else
{
// reset the jump timeout timer
_jumpTimeoutDelta = JumpTimeout;
// fall timeout
if (_fallTimeoutDelta >= 0.0f)
{
_fallTimeoutDelta -= Time.deltaTime;
}
// if we are not grounded, do not jump
_input.jump = false;
}
// apply gravity over time if under terminal (multiply by delta time twice to linearly speed up over time)
if (_verticalVelocity < _terminalVelocity)
{
_verticalVelocity += Gravity * Time.deltaTime;
}
}
private void ToggleTabMenu()
{
if (_input.tab_menu)
{
_input.tab_menu = false; // only enter function once
_tab_menu_shown = !_tab_menu_shown;
_connection_ui.SetActive(_tab_menu_shown);
}
}
private static float ClampAngle(float lfAngle, float lfMin, float lfMax)
{
if (lfAngle < -360f) lfAngle += 360f;
if (lfAngle > 360f) lfAngle -= 360f;
return Mathf.Clamp(lfAngle, lfMin, lfMax);
}
private void OnDrawGizmosSelected()
{
Color transparentGreen = new Color(0.0f, 1.0f, 0.0f, 0.35f);
Color transparentRed = new Color(1.0f, 0.0f, 0.0f, 0.35f);
if (Grounded) Gizmos.color = transparentGreen;
else Gizmos.color = transparentRed;
// when selected, draw a gizmo in the position of, and matching radius of, the grounded collider
Gizmos.DrawSphere(new Vector3(transform.position.x, transform.position.y - GroundedOffset, transform.position.z), GroundedRadius);
}
}
}
Conclusion
I am probably making a stupid mistake, however, I could not find any information regarding this oddly specific problem. Is there some setting that I am overlooking? Am I wrong and the FirstPersonController should not be a NetworkBehaviour script? Is there any example/tutorial on first person controllers in a multiplayer setup that I overlooked?
I am happy to provide more information.
Thank you in advance.