using System ;
using Unity.BossRoom.Gameplay.Actions ;
using Unity.BossRoom.Gameplay.Configuration ;
using Unity.BossRoom.Gameplay.GameplayObjects ;
using Unity.BossRoom.Gameplay.GameplayObjects.Character ;
using Unity.Netcode ;
using UnityEngine ;
using UnityEngine.AI ;
using UnityEngine.Assertions ;
using UnityEngine.EventSystems ;
namespace Unity.BossRoom.Gameplay.UserInput
{
/// <summary>
/// Captures inputs for a character on a client and sends them to the server.
/// </summary>
[RequireComponent(typeof(ServerCharacter))]
public class ClientInputSender : NetworkBehaviour
{
const float k_MouseInputRaycastDistance = 100f ;
//The movement input rate is capped at 40ms (or 25 fps). This provides a nice balance between responsiveness and
//upstream network conservation. This matters when holding down your mouse button to move.
const float k_MoveSendRateSeconds = 0.04f ; //25 fps.
const float k_TargetMoveTimeout = 0.45f ; //prevent moves for this long after targeting someone (helps prevent walking to the guy you clicked).
float m_LastSentMove ;
// Cache raycast hit array so that we can use non alloc raycasts
readonly RaycastHit [ ] k_CachedHit = new RaycastHit [ 4 ] ;
// This is basically a constant but layer masks cannot be created in the constructor, that's why it's assigned int Awake.
LayerMask m_GroundLayerMask ;
LayerMask m_ActionLayerMask ;
LayerMask m_PlatformLayerMask ;
const float k_MaxNavMeshDistance = 1f ;
RaycastHitComparer m_RaycastHitComparer ;
[SerializeField]
ServerCharacter m_ServerCharacter ;
/// <summary>
/// This event fires at the time when an action request is sent to the server.
/// </summary>
public event Action < ActionRequestData > ActionInputEvent ;
/// <summary>
/// This describes how a skill was requested. Skills requested via mouse click will do raycasts to determine their target; skills requested
/// in other matters will use the stateful target stored in NetworkCharacterState.
/// </summary>
public enum SkillTriggerStyle
{
None , //no skill was triggered.
MouseClick , //skill was triggered via mouse-click implying you should do a raycast from the mouse position to find a target.
Keyboard , //skill was triggered via a Keyboard press, implying target should be taken from the active target.
KeyboardRelease , //represents a released key.
UI , //skill was triggered from the UI, and similar to Keyboard, target should be inferred from the active target.
UIRelease , //represents letting go of the mouse-button on a UI button
}
bool IsReleaseStyle ( SkillTriggerStyle style )
{
return style = = SkillTriggerStyle . KeyboardRelease | | style = = SkillTriggerStyle . UIRelease ;
}
/// <summary>
/// This struct essentially relays the call params of RequestAction to FixedUpdate. Recall that we may need to do raycasts
/// as part of doing the action, and raycasts done outside of FixedUpdate can give inconsistent results (otherwise we would
/// just expose PerformAction as a public method, and let it be called in whatever scoped it liked.
/// </summary>
/// <remarks>
/// Reference: https://answers.unity.com/questions/1141633/why-does-fixedupdate-work-when-update-doesnt.html
/// </remarks>
struct ActionRequest
{
public SkillTriggerStyle TriggerStyle ;
public ActionID RequestedActionID ;
public ulong TargetId ;
}
/// <summary>
/// List of ActionRequests that have been received since the last FixedUpdate ran. This is a static array, to avoid allocs, and
/// because we don't really want to let this list grow indefinitely.
/// </summary>
readonly ActionRequest [ ] m_ActionRequests = new ActionRequest [ 5 ] ;
/// <summary>
/// Number of ActionRequests that have been queued since the last FixedUpdate.
/// </summary>
int m_ActionRequestCount ;
BaseActionInput m_CurrentSkillInput ;
bool m_MoveRequest ;
Camera m_MainCamera ;
public event Action < Vector3 > ClientMoveEvent ;
/// <summary>
/// Convenience getter that returns our CharacterData
/// </summary>
CharacterClass CharacterClass = > m_ServerCharacter . CharacterClass ;
[SerializeField]
PhysicsWrapper m_PhysicsWrapper ;
public ActionState actionState1 { get ; private set ; }
public ActionState actionState2 { get ; private set ; }
public ActionState actionState3 { get ; private set ; }
public System . Action action1ModifiedCallback ;
ServerCharacter m_TargetServerCharacter ;
void Awake ( )
{
m_MainCamera = Camera . main ;
}
public override void OnNetworkSpawn ( )
{
if ( ! IsClient | | ! IsOwner )
{
enabled = false ;
// dont need to do anything else if not the owner
return ;
}
m_ServerCharacter . TargetId . OnValueChanged + = OnTargetChanged ;
m_ServerCharacter . HeldNetworkObject . OnValueChanged + = OnHeldNetworkObjectChanged ;
if ( CharacterClass . Skill1 & &
GameDataSource . Instance . TryGetActionPrototypeByID ( CharacterClass . Skill1 . ActionID , out var action1 ) )
{
actionState1 = new ActionState ( ) { actionID = action1 . ActionID , selectable = true } ;
}
if ( CharacterClass . Skill2 & &
GameDataSource . Instance . TryGetActionPrototypeByID ( CharacterClass . Skill2 . ActionID , out var action2 ) )
{
actionState2 = new ActionState ( ) { actionID = action2 . ActionID , selectable = true } ;
}
if ( CharacterClass . Skill3 & &
GameDataSource . Instance . TryGetActionPrototypeByID ( CharacterClass . Skill3 . ActionID , out var action3 ) )
{
actionState3 = new ActionState ( ) { actionID = action3 . ActionID , selectable = true } ;
}
m_GroundLayerMask = LayerMask . GetMask ( new [ ] { "Ground" } ) ;
m_ActionLayerMask = LayerMask . GetMask ( new [ ] { "PCs" , "NPCs" , "Ground" } ) ;
m_PlatformLayerMask = LayerMask . GetMask ( new [ ] { "Platform" } ) ;
m_RaycastHitComparer = new RaycastHitComparer ( ) ;
}
public override void OnNetworkDespawn ( )
{
if ( m_ServerCharacter )
{
m_ServerCharacter . TargetId . OnValueChanged - = OnTargetChanged ;
m_ServerCharacter . HeldNetworkObject . OnValueChanged - = OnHeldNetworkObjectChanged ;
}
if ( m_TargetServerCharacter )
{
m_TargetServerCharacter . NetLifeState . LifeState . OnValueChanged - = OnTargetLifeStateChanged ;
}
}
void OnTargetChanged ( ulong previousValue , ulong newValue )
{
if ( m_TargetServerCharacter )
{
m_TargetServerCharacter . NetLifeState . LifeState . OnValueChanged - = OnTargetLifeStateChanged ;
}
m_TargetServerCharacter = null ;
if ( NetworkManager . Singleton . SpawnManager . SpawnedObjects . TryGetValue ( newValue , out var selection ) & &
selection . TryGetComponent ( out m_TargetServerCharacter ) )
{
m_TargetServerCharacter . NetLifeState . LifeState . OnValueChanged + = OnTargetLifeStateChanged ;
}
UpdateAction1 ( ) ;
}
void OnHeldNetworkObjectChanged ( ulong previousValue , ulong newValue )
{
UpdateAction1 ( ) ;
}
void OnTargetLifeStateChanged ( LifeState previousValue , LifeState newValue )
{
UpdateAction1 ( ) ;
}
void FinishSkill ( )
{
m_CurrentSkillInput = null ;
}
void SendInput ( ActionRequestData action )
{
ActionInputEvent ? . Invoke ( action ) ;
m_ServerCharacter . ServerPlayActionRpc ( action ) ;
}
void FixedUpdate ( )
{
//play all ActionRequests, in FIFO order.
for ( int i = 0 ; i < m_ActionRequestCount ; + + i )
{
if ( m_CurrentSkillInput ! = null )
{
//actions requested while input is active are discarded, except for "Release" requests, which go through.
if ( IsReleaseStyle ( m_ActionRequests [ i ] . TriggerStyle ) )
{
m_CurrentSkillInput . OnReleaseKey ( ) ;
}
}
else if ( ! IsReleaseStyle ( m_ActionRequests [ i ] . TriggerStyle ) )
{
var actionPrototype = GameDataSource . Instance . GetActionPrototypeByID ( m_ActionRequests [ i ] . RequestedActionID ) ;
if ( actionPrototype . Config . ActionInput ! = null )
{
var skillPlayer = Instantiate ( actionPrototype . Config . ActionInput ) ;
skillPlayer . Initiate ( m_ServerCharacter , m_PhysicsWrapper . Transform . position , actionPrototype . ActionID , SendInput , FinishSkill ) ;
m_CurrentSkillInput = skillPlayer ;
}
else
{
PerformSkill ( actionPrototype . ActionID , m_ActionRequests [ i ] . TriggerStyle , m_ActionRequests [ i ] . TargetId ) ;
}
}
}
m_ActionRequestCount = 0 ;
if ( EventSystem . current . currentSelectedGameObject ! = null )
{
return ;
}
if ( m_MoveRequest )
{
m_MoveRequest = false ;
if ( ( Time . time - m_LastSentMove ) > k_MoveSendRateSeconds )
{
m_LastSentMove = Time . time ;
var ray = m_MainCamera . ScreenPointToRay ( UnityEngine . Input . mousePosition ) ;
var groundHits = Physics . RaycastNonAlloc ( ray ,
k_CachedHit ,
k_MouseInputRaycastDistance ,
m_GroundLayerMask ) ;
if ( groundHits > 0 )
{
if ( groundHits > 1 )
{
// sort hits by distance
Array . Sort ( k_CachedHit , 0 , groundHits , m_RaycastHitComparer ) ;
}
// verify point is indeed on navmesh surface
if ( NavMesh . SamplePosition ( k_CachedHit [ 0 ] . point ,
out var hit ,
k_MaxNavMeshDistance ,
NavMesh . AllAreas ) )
{
m_ServerCharacter . ServerSendCharacterInputRpc ( hit . position ) ;
//Send our client only click request
ClientMoveEvent ? . Invoke ( hit . position ) ;
}
}
}
}
}
/// <summary>
/// Perform a skill in response to some input trigger. This is the common method to which all input-driven skill plays funnel.
/// </summary>
/// <param name="actionID">The action you want to play. Note that "Skill1" may be overriden contextually depending on the target.</param>
/// <param name="triggerStyle">What sort of input triggered this skill?</param>
/// <param name="targetId">(optional) Pass in a specific networkID to target for this action</param>
void PerformSkill ( ActionID actionID , SkillTriggerStyle triggerStyle , ulong targetId = 0 )
{
Transform hitTransform = null ;
if ( targetId ! = 0 )
{
// if a targetId is given, try to find the object
NetworkObject targetNetObj ;
if ( NetworkManager . Singleton . SpawnManager . SpawnedObjects . TryGetValue ( targetId , out targetNetObj ) )
{
hitTransform = targetNetObj . transform ;
}
}
else
{
// otherwise try to find an object under the input position
int numHits = 0 ;
if ( triggerStyle = = SkillTriggerStyle . MouseClick )
{
var ray = m_MainCamera . ScreenPointToRay ( UnityEngine . Input . mousePosition ) ;
numHits = Physics . RaycastNonAlloc ( ray , k_CachedHit , k_MouseInputRaycastDistance , m_ActionLayerMask ) ;
}
int networkedHitIndex = - 1 ;
for ( int i = 0 ; i < numHits ; i + + )
{
if ( k_CachedHit [ i ] . transform . GetComponentInParent < NetworkObject > ( ) )
{
networkedHitIndex = i ;
break ;
}
}
hitTransform = networkedHitIndex > = 0 ? k_CachedHit [ networkedHitIndex ] . transform : null ;
}
if ( GetActionRequestForTarget ( hitTransform , actionID , triggerStyle , out ActionRequestData playerAction ) )
{
//Don't trigger our move logic for a while. This protects us from moving just because we clicked on them to target them.
m_LastSentMove = Time . time + k_TargetMoveTimeout ;
SendInput ( playerAction ) ;
}
else if ( ! GameDataSource . Instance . GetActionPrototypeByID ( actionID ) . IsGeneralTargetAction )
{
// clicked on nothing... perform an "untargeted" attack on the spot they clicked on.
// (Different Actions will deal with this differently. For some, like archer arrows, this will fire an arrow
// in the desired direction. For others, like mage's bolts, this will fire a "miss" projectile at the spot clicked on.)
var data = new ActionRequestData ( ) ;
PopulateSkillRequest ( k_CachedHit [ 0 ] . point , actionID , ref data ) ;
SendInput ( data ) ;
}
}
/// <summary>
/// When you right-click on something you will want to do contextually different things. For example you might attack an enemy,
/// but revive a friend. You might also decide to do nothing (e.g. right-clicking on a friend who hasn't FAINTED).
/// </summary>
/// <param name="hit">The Transform of the entity we clicked on, or null if none.</param>
/// <param name="actionID">The Action to build for</param>
/// <param name="triggerStyle">How did this skill play get triggered? Mouse, Keyboard, UI etc.</param>
/// <param name="resultData">Out parameter that will be filled with the resulting action, if any.</param>
/// <returns>true if we should play an action, false otherwise. </returns>
bool GetActionRequestForTarget ( Transform hit , ActionID actionID , SkillTriggerStyle triggerStyle , out ActionRequestData resultData )
{
resultData = new ActionRequestData ( ) ;
var targetNetObj = hit ! = null ? hit . GetComponentInParent < NetworkObject > ( ) : null ;
//if we can't get our target from the submitted hit transform, get it from our stateful target in our ServerCharacter.
if ( ! targetNetObj & & ! GameDataSource . Instance . GetActionPrototypeByID ( actionID ) . IsGeneralTargetAction )
{
ulong targetId = m_ServerCharacter . TargetId . Value ;
NetworkManager . Singleton . SpawnManager . SpawnedObjects . TryGetValue ( targetId , out targetNetObj ) ;
}
//sanity check that this is indeed a valid target.
if ( targetNetObj = = null | | ! ActionUtils . IsValidTarget ( targetNetObj . NetworkObjectId ) )
{
return false ;
}
if ( targetNetObj . TryGetComponent < ServerCharacter > ( out var serverCharacter ) )
{
//Skill1 may be contextually overridden if it was generated from a mouse-click.
if ( actionID = = CharacterClass . Skill1 . ActionID & & triggerStyle = = SkillTriggerStyle . MouseClick )
{
if ( ! serverCharacter . IsNpc & & serverCharacter . LifeState = = LifeState . Fainted )
{
//right-clicked on a downed ally--change the skill play to Revive.
actionID = GameDataSource . Instance . ReviveActionPrototype . ActionID ;
}
}
}
Vector3 targetHitPoint ;
if ( PhysicsWrapper . TryGetPhysicsWrapper ( targetNetObj . NetworkObjectId , out var movementContainer ) )
{
targetHitPoint = movementContainer . Transform . position ;
}
else
{
targetHitPoint = targetNetObj . transform . position ;
}
// record our target in case this action uses that info (non-targeted attacks will ignore this)
resultData . ActionID = actionID ;
resultData . TargetIds = new ulong [ ] { targetNetObj . NetworkObjectId } ;
PopulateSkillRequest ( targetHitPoint , actionID , ref resultData ) ;
return true ;
}
/// <summary>
/// Populates the ActionRequestData with additional information. The TargetIds of the action should already be set before calling this.
/// </summary>
/// <param name="hitPoint">The point in world space where the click ray hit the target.</param>
/// <param name="actionID">The action to perform (will be stamped on the resultData)</param>
/// <param name="resultData">The ActionRequestData to be filled out with additional information.</param>
void PopulateSkillRequest ( Vector3 hitPoint , ActionID actionID , ref ActionRequestData resultData )
{
resultData . ActionID = actionID ;
var actionConfig = GameDataSource . Instance . GetActionPrototypeByID ( actionID ) . Config ;
//most skill types should implicitly close distance. The ones that don't are explicitly set to false in the following switch.
resultData . ShouldClose = true ;
// figure out the Direction in case we want to send it
Vector3 offset = hitPoint - m_PhysicsWrapper . Transform . position ;
offset . y = 0 ;
Vector3 direction = offset . normalized ;
switch ( actionConfig . Logic )
{
//for projectile logic, infer the direction from the click position.
case ActionLogic . LaunchProjectile :
resultData . Direction = direction ;
resultData . ShouldClose = false ; //why? Because you could be lining up a shot, hoping to hit other people between you and your target. Moving you would be quite invasive.
return ;
case ActionLogic . Melee :
resultData . Direction = direction ;
return ;
case ActionLogic . Target :
resultData . ShouldClose = false ;
return ;
case ActionLogic . Emote :
resultData . CancelMovement = true ;
return ;
case ActionLogic . RangedFXTargeted :
resultData . Position = hitPoint ;
return ;
case ActionLogic . DashAttack :
resultData . Position = hitPoint ;
return ;
case ActionLogic . PickUp :
resultData . CancelMovement = true ;
resultData . ShouldQueue = false ;
return ;
}
}
/// <summary>
/// Request an action be performed. This will occur on the next FixedUpdate.
/// </summary>
/// <param name="actionID"> The action you'd like to perform. </param>
/// <param name="triggerStyle"> What input style triggered this action. </param>
/// <param name="targetId"> NetworkObjectId of target. </param>
public void RequestAction ( ActionID actionID , SkillTriggerStyle triggerStyle , ulong targetId = 0 )
{
Assert . IsNotNull ( GameDataSource . Instance . GetActionPrototypeByID ( actionID ) ,
$"Action with actionID {actionID} must be contained in the Action prototypes of GameDataSource!" ) ;
if ( m_ActionRequestCount < m_ActionRequests . Length )
{
m_ActionRequests [ m_ActionRequestCount ] . RequestedActionID = actionID ;
m_ActionRequests [ m_ActionRequestCount ] . TriggerStyle = triggerStyle ;
m_ActionRequests [ m_ActionRequestCount ] . TargetId = targetId ;
m_ActionRequestCount + + ;
}
}
void Update ( )
{
if ( Input . GetKeyDown ( KeyCode . Alpha1 ) & & CharacterClass . Skill1 )
{
RequestAction ( actionState1 . actionID , SkillTriggerStyle . Keyboard ) ;
}
else if ( Input . GetKeyUp ( KeyCode . Alpha1 ) & & CharacterClass . Skill1 )
{
RequestAction ( actionState1 . actionID , SkillTriggerStyle . KeyboardRelease ) ;
}
if ( Input . GetKeyDown ( KeyCode . Alpha2 ) & & CharacterClass . Skill2 )
{
RequestAction ( actionState2 . actionID , SkillTriggerStyle . Keyboard ) ;
}
else if ( Input . GetKeyUp ( KeyCode . Alpha2 ) & & CharacterClass . Skill2 )
{
RequestAction ( actionState2 . actionID , SkillTriggerStyle . KeyboardRelease ) ;
}
if ( Input . GetKeyDown ( KeyCode . Alpha3 ) & & CharacterClass . Skill3 )
{
RequestAction ( actionState3 . actionID , SkillTriggerStyle . Keyboard ) ;
}
else if ( Input . GetKeyUp ( KeyCode . Alpha3 ) & & CharacterClass . Skill3 )
{
RequestAction ( actionState3 . actionID , SkillTriggerStyle . KeyboardRelease ) ;
}
if ( Input . GetKeyDown ( KeyCode . Alpha5 ) )
{
RequestAction ( GameDataSource . Instance . Emote1ActionPrototype . ActionID , SkillTriggerStyle . Keyboard ) ;
}
if ( Input . GetKeyDown ( KeyCode . Alpha6 ) )
{
RequestAction ( GameDataSource . Instance . Emote2ActionPrototype . ActionID , SkillTriggerStyle . Keyboard ) ;
}
if ( Input . GetKeyDown ( KeyCode . Alpha7 ) )
{
RequestAction ( GameDataSource . Instance . Emote3ActionPrototype . ActionID , SkillTriggerStyle . Keyboard ) ;
}
if ( Input . GetKeyDown ( KeyCode . Alpha8 ) )
{
RequestAction ( GameDataSource . Instance . Emote4ActionPrototype . ActionID , SkillTriggerStyle . Keyboard ) ;
}
if ( ! EventSystem . current . IsPointerOverGameObject ( ) & & m_CurrentSkillInput = = null )
{
//IsPointerOverGameObject() is a simple way to determine if the mouse is over a UI element. If it is, we don't perform mouse input logic,
//to model the button "blocking" mouse clicks from falling through and interacting with the world.
bool isCharacterClicked = false ;
if ( Input . GetMouseButtonDown ( 1 ) )
{
RequestAction ( CharacterClass . Skill1 . ActionID , SkillTriggerStyle . MouseClick ) ;
}
if ( Input . GetMouseButtonDown ( 0 ) ) // Left-click to request swap
{
var ray = m_MainCamera . ScreenPointToRay ( UnityEngine . Input . mousePosition ) ;
int hits = Physics . RaycastNonAlloc ( ray , k_CachedHit , k_MouseInputRaycastDistance , m_PlatformLayerMask ) ;
if ( hits > 0 )
{
for ( int i = 0 ; i < hits ; i + + )
{
if ( k_CachedHit [ i ] . transform . TryGetComponent ( out NetworkObject targetNetObj ) & &
targetNetObj ! = m_ServerCharacter . NetworkObject )
{
// Check if the target is a valid ServerCharacter
if ( targetNetObj . TryGetComponent ( out ServerCharacter targetCharacter ) )
{
// Initiate the swap
targetCharacter . NotifySwapRequestRpc ( m_ServerCharacter . NetworkObjectId ) ;
Debug . Log ( $"Swap request sent to {targetCharacter.name}." ) ;
isCharacterClicked = true ;
break ; // Exit the loop after initiating the swap
}
}
}
}
}
if ( ! isCharacterClicked & & Input . GetMouseButtonDown ( 0 ) )
{
m_MoveRequest = true ; // Set move request for holding left-click
}
}
}
void UpdateAction1 ( )
{
var isHoldingNetworkObject =
NetworkManager . Singleton . SpawnManager . SpawnedObjects . TryGetValue ( m_ServerCharacter . HeldNetworkObject . Value ,
out var heldNetworkObject ) ;
NetworkManager . Singleton . SpawnManager . SpawnedObjects . TryGetValue ( m_ServerCharacter . TargetId . Value ,
out var selection ) ;
var isSelectable = true ;
if ( isHoldingNetworkObject )
{
// show drop!
actionState1 . actionID = GameDataSource . Instance . DropActionPrototype . ActionID ;
}
else if ( ( m_ServerCharacter . TargetId . Value ! = 0
& & selection ! = null
& & selection . TryGetComponent ( out PickUpState pickUpState ) )
)
{
// special case: targeting a pickup-able item or holding a pickup object
actionState1 . actionID = GameDataSource . Instance . PickUpActionPrototype . ActionID ;
}
else if ( m_ServerCharacter . TargetId . Value ! = 0
& & selection ! = null
& & selection . NetworkObjectId ! = m_ServerCharacter . NetworkObjectId
& & selection . TryGetComponent ( out ServerCharacter charState )
& & ! charState . IsNpc )
{
// special case: when we have a player selected, we change the meaning of the basic action
// we have another player selected! In that case we want to reflect that our basic Action is a Revive, not an attack!
// But we need to know if the player is alive... if so, the button should be disabled (for better player communication)
actionState1 . actionID = GameDataSource . Instance . ReviveActionPrototype . ActionID ;
isSelectable = charState . NetLifeState . LifeState . Value ! = LifeState . Alive ;
}
else
{
actionState1 . SetActionState ( CharacterClass . Skill1 . ActionID ) ;
}
actionState1 . selectable = isSelectable ;
action1ModifiedCallback ? . Invoke ( ) ;
}
public class ActionState
{
public ActionID actionID { get ; internal set ; }
public bool selectable { get ; internal set ; }
internal void SetActionState ( ActionID newActionID , bool isSelectable = true )
{
actionID = newActionID ;
selectable = isSelectable ;
}
}
}
}