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 { /// /// Captures inputs for a character on a client and sends them to the server. /// [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; const float k_MaxNavMeshDistance = 1f; RaycastHitComparer m_RaycastHitComparer; [SerializeField] ServerCharacter m_ServerCharacter; /// /// This event fires at the time when an action request is sent to the server. /// public event Action ActionInputEvent; /// /// 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. /// 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; } /// /// 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. /// /// /// Reference: https://answers.unity.com/questions/1141633/why-does-fixedupdate-work-when-update-doesnt.html /// struct ActionRequest { public SkillTriggerStyle TriggerStyle; public ActionID RequestedActionID; public ulong TargetId; } /// /// 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. /// readonly ActionRequest[] m_ActionRequests = new ActionRequest[5]; /// /// Number of ActionRequests that have been queued since the last FixedUpdate. /// int m_ActionRequestCount; BaseActionInput m_CurrentSkillInput; bool m_MoveRequest; Camera m_MainCamera; public event Action ClientMoveEvent; /// /// Convenience getter that returns our CharacterData /// 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_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); } } } } } /// /// Perform a skill in response to some input trigger. This is the common method to which all input-driven skill plays funnel. /// /// The action you want to play. Note that "Skill1" may be overriden contextually depending on the target. /// What sort of input triggered this skill? /// (optional) Pass in a specific networkID to target for this action 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()) { 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); } } /// /// 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). /// /// The Transform of the entity we clicked on, or null if none. /// The Action to build for /// How did this skill play get triggered? Mouse, Keyboard, UI etc. /// Out parameter that will be filled with the resulting action, if any. /// true if we should play an action, false otherwise. bool GetActionRequestForTarget(Transform hit, ActionID actionID, SkillTriggerStyle triggerStyle, out ActionRequestData resultData) { resultData = new ActionRequestData(); var targetNetObj = hit != null ? hit.GetComponentInParent() : 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(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; } /// /// Populates the ActionRequestData with additional information. The TargetIds of the action should already be set before calling this. /// /// The point in world space where the click ray hit the target. /// The action to perform (will be stamped on the resultData) /// The ActionRequestData to be filled out with additional information. 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; } } /// /// Request an action be performed. This will occur on the next FixedUpdate. /// /// The action you'd like to perform. /// What input style triggered this action. /// NetworkObjectId of target. 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. if (Input.GetMouseButtonDown(1)) { RequestAction(CharacterClass.Skill1.ActionID, SkillTriggerStyle.MouseClick); } if (Input.GetMouseButtonDown(0)) { RequestAction(GameDataSource.Instance.GeneralTargetActionPrototype.ActionID, SkillTriggerStyle.MouseClick); } else if (Input.GetMouseButton(0)) { m_MoveRequest = true; } } } 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; } } } }